Как создать простой сервер Python WebSocket с помощью Tornado
Опубликовано: 2022-03-11С ростом популярности веб-приложений реального времени WebSockets стали ключевой технологией их реализации. Дни, когда вам приходилось постоянно нажимать кнопку перезагрузки, чтобы получать обновления с сервера, давно прошли. Веб-приложениям, которые хотят предоставлять обновления в режиме реального времени, больше не нужно опрашивать сервер на наличие изменений — вместо этого серверы передают изменения по мере их появления. Надежные веб-фреймворки начали поддерживать WebSockets из коробки. Ruby on Rails 5, например, пошел еще дальше и добавил поддержку кабелей действий.
В мире Python существует множество популярных веб-фреймворков. Фреймворки, такие как Django, предоставляют почти все необходимое для создания веб-приложений, а все, чего ему не хватает, можно восполнить с помощью одного из тысяч подключаемых модулей, доступных для Django. Однако из-за того, как работает Python или большинство его веб-фреймворков, обработка долгоживущих соединений может быстро превратиться в кошмар. Многопоточная модель и глобальная блокировка интерпретатора часто считаются ахиллесовой пятой Python.
Но все это начало меняться. С некоторыми новыми функциями Python 3 и платформами, которые уже существуют для Python, такими как Tornado, обработка долгоживущих соединений больше не является проблемой. Tornado предоставляет возможности веб-сервера на Python, которые особенно полезны при работе с долгоживущими соединениями.
В этой статье мы рассмотрим, как можно создать простой сервер WebSocket на Python с использованием Tornado. Демонстрационное приложение позволит нам загрузить файл значений, разделенных табуляцией (TSV), проанализировать его и сделать его содержимое доступным по уникальному URL-адресу.
Торнадо и веб-сокеты
Tornado — это асинхронная сетевая библиотека, специализирующаяся на работе с сетями, управляемыми событиями. Поскольку он может, естественно, одновременно поддерживать десятки тысяч открытых соединений, сервер может воспользоваться этим и обрабатывать множество соединений WebSocket в пределах одного узла. WebSocket — это протокол, который обеспечивает полнодуплексные каналы связи по одному TCP-соединению. Поскольку это открытый сокет, этот метод обеспечивает сохранение состояния веб-соединения и облегчает передачу данных в режиме реального времени на сервер и с сервера. Сервер, хранящий состояния клиентов, упрощает реализацию чат-приложений в реальном времени или веб-игр на основе WebSockets.
WebSockets предназначены для реализации в веб-браузерах и серверах и в настоящее время поддерживаются во всех основных веб-браузерах. Соединение открывается один раз, и сообщения могут передаваться туда и обратно несколько раз, прежде чем соединение будет закрыто.
Установить Tornado довольно просто. Он указан в PyPI и может быть установлен с помощью pip или easy_install:
pip install tornado
Tornado поставляется с собственной реализацией WebSockets. Для целей этой статьи это почти все, что нам нужно.
Веб-сокеты в действии
Одним из преимуществ использования WebSocket является его свойство сохранения состояния. Это меняет наше обычное представление о взаимодействии клиент-сервер. Одним из конкретных случаев использования этого является то, что сервер должен выполнять длительные медленные процессы и постепенно передавать результаты обратно клиенту.
В нашем примере приложения пользователь сможет загрузить файл через WebSocket. В течение всего времени существования соединения сервер будет хранить проанализированный файл в памяти. По запросу сервер может затем отправить части файла во внешний интерфейс. Кроме того, файл будет доступен по URL-адресу, который затем смогут просматривать несколько пользователей. Если другой файл загружен по тому же URL-адресу, все, кто просматривает его, смогут сразу же увидеть новый файл.
Для интерфейса мы будем использовать AngularJS. Этот фреймворк и библиотеки позволят нам легко обрабатывать загрузку файлов и разбиение на страницы. Однако для всего, что связано с WebSockets, мы будем использовать стандартные функции JavaScript.
Это простое приложение будет разбито на три отдельных файла:
- parser.py: где реализован наш сервер Tornado с обработчиками запросов
- templates/index.html: внешний HTML-шаблон
- static/parser.js: для нашего интерфейса JavaScript.
Открытие веб-сокета
С внешнего интерфейса соединение WebSocket может быть установлено путем создания экземпляра объекта WebSocket:
new WebSocket(WEBSOCKET_URL);
Это то, что нам нужно будет сделать при загрузке страницы. После создания экземпляра объекта WebSocket необходимо подключить обработчики для обработки трех важных событий:
- open: срабатывает при установлении соединения
- message: срабатывает при получении сообщения от сервера
- close: срабатывает при закрытии соединения
$scope.init = function() { $scope.ws = new WebSocket('ws://' + location.host + '/parser/ws'); $scope.ws.binaryType = 'arraybuffer'; $scope.ws.onopen = function() { console.log('Connected.') }; $scope.ws.onmessage = function(evt) { $scope.$apply(function () { message = JSON.parse(evt.data); $scope.currentPage = parseInt(message['page_no']); $scope.totalRows = parseInt(message['total_number']); $scope.rows = message['data']; }); }; $scope.ws.onclose = function() { console.log('Connection is closed...'); }; } $scope.init();
Поскольку эти обработчики событий не будут автоматически запускать жизненный цикл $scope AngularJS, содержимое функции обработчика необходимо обернуть в $apply. Если вам интересно, существуют специальные пакеты AngularJS, которые упрощают интеграцию WebSocket в приложения AngularJS.
Стоит отметить, что разорванные соединения WebSocket не восстанавливаются автоматически и требуют от приложения попытки повторного подключения при срабатывании обработчика события закрытия. Это немного выходит за рамки данной статьи.
Выбор файла для загрузки
Поскольку мы создаем одностраничное приложение с использованием AngularJS, попытка отправки форм с файлами старым способом не сработает. Чтобы упростить задачу, мы будем использовать библиотеку ng-file-upload от Даниала Фарида. Используя это, все, что нам нужно сделать, чтобы позволить пользователю загрузить файл, — это добавить кнопку в наш интерфейсный шаблон с определенными директивами AngularJS:
<button class="btn btn-default" type="file" ngf-select="uploadFile($file, $invalidFiles)" accept=".tsv" ngf-max-size="10MB">Select File</button>
Библиотека, среди прочего, позволяет нам установить приемлемое расширение и размер файла. Нажатие на эту кнопку, как и на любой элемент <input type=”file”>
, откроет стандартное средство выбора файлов.
Загрузка файла
Если вы хотите передать двоичные данные, вы можете выбрать между буфером массива и большим двоичным объектом. Если это просто необработанные данные, такие как файл изображения, выберите blob и правильно обработайте его на сервере. Буфер массива предназначен для двоичного буфера фиксированной длины, а текстовый файл, такой как TSV, может быть передан в формате строки байтов. Этот фрагмент кода показывает, как загрузить файл в формате буфера массива.

$scope.uploadFile = function(file, errFiles) { ws = $scope.ws; $scope.f = file; $scope.errFile = errFiles && errFiles[0]; if (file) { reader = new FileReader(); rawData = new ArrayBuffer(); reader.onload = function(evt) { rawData = evt.target.result; ws.send(rawData); } reader.readAsArrayBuffer(file); } }
Директива ng-file-upload предоставляет функцию uploadFile. Здесь вы можете преобразовать файл в буфер массива с помощью FileReader и отправить его через WebSocket.
Обратите внимание, что отправка больших файлов через WebSocket путем считывания их в буферы массива может быть не самым оптимальным способом их загрузки, так как это может быстро занять много памяти, что приведет к ухудшению работы.
Получить файл на сервере
Tornado определяет тип сообщения, используя 4-битный код операции, и возвращает str для двоичных данных и unicode для текста.
if opcode == 0x1: # UTF-8 data self._message_bytes_in += len(data) try: decoded = data.decode("utf-8") except UnicodeDecodeError: self._abort() return self._run_callback(self.handler.on_message, decoded) elif opcode == 0x2: # Binary data self._message_bytes_in += len(data) self._run_callback(self.handler.on_message, data)
В веб-сервере Tornado буфер массива принимается в виде str.
В этом примере ожидаемый тип контента — TSV, поэтому файл анализируется и преобразуется в словарь. Конечно, в реальных приложениях есть более разумные способы борьбы с произвольными загрузками.
def make_message(self, page_no=1): page_size = 100 return { "page_no": page_no, "total_number": len(self.rows), "data": self.rows[page_size * (page_no - 1):page_size * page_no] } def on_message(self, message): if isinstance(message, str): self.rows = [csv.reader([line], delimiter="\t").next() for line in (x.strip() for x in message.splitlines()) if line] self.write_message(self.make_message())
Запрос страницы
Поскольку наша цель — показать загруженные TSV-данные в виде фрагментов небольших страниц, нам нужны средства для запроса конкретной страницы. Для простоты мы просто будем использовать одно и то же соединение WebSocket для отправки номера страницы на наш сервер.
$scope.pageChanged = function() { ws = $scope.ws; ws.send($scope.currentPage); }
Сервер получит это сообщение в формате unicode:
def on_message(self, message): if isinstance(message, unicode): page_no = int(message) self.write_message(self.make_message(page_no))
Попытка ответить dict с сервера Tornado WebSocket автоматически закодирует его в формате JSON. Так что вполне нормально просто отправить dict, который содержит 100 строк контента.
Совместное использование доступа с другими
Чтобы иметь возможность предоставлять доступ к одной и той же загрузке нескольким пользователям, нам необходимо иметь возможность однозначно идентифицировать загрузки. Всякий раз, когда пользователь подключается к серверу через WebSocket, будет сгенерирован случайный UUID и назначен его соединению.
def open(self, doc_uuid=None): if doc_uuid is None: self.uuid = str(uuid.uuid4())
uuid.uuid4()
генерирует случайный UUID, а str() преобразует UUID в строку шестнадцатеричных цифр в стандартной форме.
Если к серверу подключается другой пользователь с UUID, соответствующий экземпляр FileHandler добавляется в словарь с UUID в качестве ключа и удаляется при закрытии соединения.
@classmethod @tornado.gen.coroutine def add_clients(cls, doc_uuid, client): with (yield lock.acquire()): if doc_uuid in cls.clients: clients_with_uuid = FileHandler.clients[doc_uuid] clients_with_uuid.append(client) else: FileHandler.clients[doc_uuid] = [client] @classmethod @tornado.gen.coroutine def remove_clients(cls, doc_uuid, client): with (yield lock.acquire()): if doc_uuid in cls.clients: clients_with_uuid = FileHandler.clients[doc_uuid] clients_with_uuid.remove(client) if len(clients_with_uuid) == 0: del cls.clients[doc_uuid]
Словарь клиентов может выдать ошибку KeyError при одновременном добавлении или удалении клиентов. Поскольку Tornado — это асинхронная сетевая библиотека, она предоставляет механизмы блокировки для синхронизации. Простая блокировка с сопрограммой подходит для этого случая работы со словарем клиентов.
Если какой-либо пользователь загружает файл или перемещается между страницами, все пользователи с одинаковым UUID просматривают одну и ту же страницу.
@classmethod def send_messages(cls, doc_uuid): clients_with_uuid = cls.clients[doc_uuid] message = cls.make_message(doc_uuid) for client in clients_with_uuid: try: client.write_message(message) except: logging.error("Error sending message", exc_info=True)
Запуск Nginx
Внедрить WebSockets очень просто, но есть некоторые хитрости, которые следует учитывать при использовании их в производственных средах. Tornado — это веб-сервер, поэтому он может получать запросы пользователей напрямую, но его развертывание за Nginx может быть лучшим выбором по многим причинам. Однако для использования WebSockets через Nginx требуется немного больше усилий:
http { upstream parser { server 127.0.0.1:8080; } server { location ^~ /parser/ws { proxy_pass http://parser; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } } }
Две директивы proxy_set_header
заставляют Nginx передавать необходимые заголовки на внутренние серверы, необходимые для обновления соединения с WebSocket.
Что дальше?
В этой статье мы реализовали простое веб-приложение Python, которое использует WebSockets для поддержания постоянных соединений между сервером и каждым из клиентов. С современными асинхронными сетевыми платформами, такими как Tornado, одновременное поддержание десятков тысяч открытых соединений в Python вполне возможно.
Хотя некоторые аспекты реализации этого демонстрационного приложения можно было бы реализовать по-другому, я надеюсь, что оно все же помогло продемонстрировать использование веб-сокетов в https://www.toptal.com/tornado framework. Исходный код демонстрационного приложения доступен на GitHub.