Как создать простой сервер 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.

Связанный: Учебное пособие по многопоточности Python: параллелизм и параллелизм