Como criar um servidor WebSocket Python simples usando o Tornado

Publicados: 2022-03-11

Com o aumento da popularidade das aplicações web em tempo real, os WebSockets tornaram-se uma tecnologia chave na sua implementação. Os dias em que você precisava pressionar constantemente o botão de recarregar para receber atualizações do servidor já se foram. Os aplicativos da Web que desejam fornecer atualizações em tempo real não precisam mais consultar o servidor em busca de alterações - em vez disso, os servidores enviam as alterações pelo fluxo conforme elas acontecem. Estruturas da Web robustas começaram a oferecer suporte a WebSockets fora da caixa. Ruby on Rails 5, por exemplo, foi ainda mais longe e adicionou suporte para cabos de ação.

No mundo do Python, existem muitos frameworks web populares. Frameworks como o Django fornecem quase tudo o que é necessário para construir aplicações web, e qualquer coisa que falte pode ser feita com um dos milhares de plugins disponíveis para o Django. No entanto, devido à maneira como o Python ou a maioria de seus frameworks da Web funcionam, lidar com conexões de longa duração pode rapidamente se tornar um pesadelo. O modelo encadeado e o bloqueio do interpretador global são frequentemente considerados o calcanhar de Aquiles do Python.

Mas tudo isso começou a mudar. Com alguns novos recursos do Python 3 e estruturas que já existem para Python, como Tornado, lidar com conexões de longa duração não é mais um desafio. Tornado fornece recursos de servidor web em Python que são especificamente úteis para lidar com conexões de longa duração.

Neste artigo, veremos como um servidor WebSocket simples pode ser construído em Python usando o Tornado. O aplicativo de demonstração nos permitirá fazer upload de um arquivo de valores separados por tabulação (TSV), analisá-lo e disponibilizar seu conteúdo em um URL exclusivo.

Tornado e WebSockets

Tornado é uma biblioteca de rede assíncrona e especializada em lidar com redes orientadas a eventos. Como ele pode naturalmente manter dezenas de milhares de conexões abertas simultaneamente, um servidor pode tirar proveito disso e lidar com muitas conexões WebSocket em um único nó. WebSocket é um protocolo que fornece canais de comunicação full-duplex em uma única conexão TCP. Por ser um soquete aberto, essa técnica torna uma conexão web stateful e facilita a transferência de dados em tempo real de e para o servidor. O servidor, mantendo os estados dos clientes, facilita a implementação de aplicativos de bate-papo em tempo real ou jogos da web baseados em WebSockets.

Os WebSockets são projetados para serem implementados em navegadores e servidores da Web e atualmente são suportados em todos os principais navegadores da Web. Uma conexão é aberta uma vez e as mensagens podem ir e vir várias vezes antes que a conexão seja fechada.

Instalar o Tornado é bastante simples. Ele está listado no PyPI e pode ser instalado usando pip ou easy_install:

 pip install tornado

Tornado vem com sua própria implementação de WebSockets. Para os propósitos deste artigo, isso é praticamente tudo o que precisamos.

WebSockets em ação

Uma das vantagens de usar o WebSocket é sua propriedade stateful. Isso muda a maneira como normalmente pensamos na comunicação cliente-servidor. Um caso de uso específico disso é quando o servidor precisa executar processos longos e lentos e transmitir gradualmente os resultados de volta ao cliente.

Em nossa aplicação de exemplo, o usuário poderá fazer upload de um arquivo através do WebSocket. Durante todo o tempo de vida da conexão, o servidor manterá o arquivo analisado na memória. Mediante solicitações, o servidor pode enviar de volta partes do arquivo para o front-end. Além disso, o arquivo será disponibilizado em uma URL que poderá ser visualizada por vários usuários. Se outro arquivo for carregado no mesmo URL, todos que o visualizarem poderão ver o novo arquivo imediatamente.

Para front-end, usaremos AngularJS. Essa estrutura e bibliotecas nos permitirão lidar facilmente com uploads de arquivos e paginação. Para tudo relacionado a WebSockets, no entanto, usaremos funções JavaScript padrão.

Este aplicativo simples será dividido em três arquivos separados:

  • parser.py: onde nosso servidor Tornado com os manipuladores de solicitações é implementado
  • templates/index.html: modelo HTML de front-end
  • static/parser.js: para nosso JavaScript de front-end

Abrindo um WebSocket

A partir do front-end, uma conexão WebSocket pode ser estabelecida instanciando um objeto WebSocket:

 new WebSocket(WEBSOCKET_URL);

Isso é algo que teremos que fazer no carregamento da página. Depois que um objeto WebSocket é instanciado, os manipuladores devem ser anexados para manipular três eventos importantes:

  • open: disparado quando uma conexão é estabelecida
  • mensagem: disparado quando uma mensagem é recebida do servidor
  • close: disparado quando uma conexão é fechada
 $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();

Como esses manipuladores de eventos não acionarão automaticamente o ciclo de vida $scope do AngularJS, o conteúdo da função do manipulador precisa ser encapsulado em $apply. Caso você esteja interessado, existem pacotes específicos do AngularJS que facilitam a integração do WebSocket em aplicativos AngularJS.

Vale a pena mencionar que as conexões WebSocket descartadas não são restabelecidas automaticamente e exigirão que o aplicativo tente reconectar quando o manipulador de eventos close for acionado. Isso está um pouco além do escopo deste artigo.

Selecionando um arquivo para upload

Como estamos construindo um aplicativo de página única usando AngularJS, tentar enviar formulários com arquivos da maneira antiga não funcionará. Para facilitar as coisas, usaremos a biblioteca ng-file-upload de Danial Farid. Usando which, tudo o que precisamos fazer para permitir que um usuário faça upload de um arquivo é adicionar um botão ao nosso modelo de front-end com diretivas específicas do AngularJS:

 <button class="btn btn-default" type="file" ngf-select="uploadFile($file, $invalidFiles)" accept=".tsv" ngf-max-size="10MB">Select File</button>

A biblioteca, entre muitas coisas, nos permite definir extensão e tamanho de arquivo aceitáveis. Clicar neste botão, assim como qualquer elemento <input type=”file”> , abrirá o seletor de arquivos padrão.

Carregando o arquivo

Quando você deseja transferir dados binários, pode escolher entre buffer de matriz e blob. Se forem apenas dados brutos como um arquivo de imagem, escolha blob e trate-o corretamente no servidor. O buffer de matriz é para buffer binário de comprimento fixo e um arquivo de texto como TSV pode ser transferido no formato de string de bytes. Este snippet de código mostra como fazer upload de um arquivo no formato de buffer de matriz.

 $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); } }

A diretiva ng-file-upload fornece uma função uploadFile. Aqui você pode transformar o arquivo em um buffer de array usando um FileReader e enviá-lo pelo WebSocket.

Observe que enviar arquivos grandes pelo WebSocket lendo-os em buffers de matriz pode não ser a melhor maneira de carregá-los, pois pode ocupar rapidamente muita memória, resultando em uma experiência ruim.

Receba o arquivo no servidor

Mesa

Tornado determina o tipo de mensagem usando o opcode de 4 bits e retorna str para dados binários e unicode para texto.

 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)

No servidor web Tornado, o buffer do array é recebido no tipo str.

Neste exemplo, o tipo de conteúdo que esperamos é TSV, então o arquivo é analisado e transformado em um dicionário. Claro, em aplicações reais, existem maneiras mais sensatas de lidar com uploads arbitrários.

 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())

Solicitar uma página

Como nosso objetivo é mostrar dados de TSV carregados em partes de pequenas páginas, precisamos de um meio de solicitar uma página específica. Para simplificar, simplesmente usaremos a mesma conexão WebSocket para enviar o número da página ao nosso servidor.

 $scope.pageChanged = function() { ws = $scope.ws; ws.send($scope.currentPage); }

O servidor receberá esta mensagem como unicode:

 def on_message(self, message): if isinstance(message, unicode): page_no = int(message) self.write_message(self.make_message(page_no))

A tentativa de responder com um dict de um servidor Tornado WebSocket irá codificá-lo automaticamente no formato JSON. Portanto, não há problema em enviar um dict que contenha 100 linhas de conteúdo.

Compartilhamento de acesso com outras pessoas

Para poder compartilhar o acesso ao mesmo upload com vários usuários, precisamos identificar os uploads de forma exclusiva. Sempre que um usuário se conecta ao servidor via WebSocket, um UUID aleatório será gerado e atribuído à sua conexão.

 def open(self, doc_uuid=None): if doc_uuid is None: self.uuid = str(uuid.uuid4())

uuid.uuid4() gera um UUID aleatório e str() converte um UUID em uma string de dígitos hexadecimais no formato padrão.

Se outro usuário com um UUID se conectar ao servidor, a instância correspondente do FileHandler será adicionada a um dicionário com o UUID como a chave e será removida quando a conexão for fechada.

 @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]

O dicionário de clientes pode lançar um KeyError ao adicionar ou remover clientes simultaneamente. Como o Tornado é uma biblioteca de rede assíncrona, ele fornece mecanismos de bloqueio para sincronização. Um simples bloqueio com corrotina se encaixa neste caso de manipulação de dicionário de clientes.

Se algum usuário carregar um arquivo ou mover-se entre as páginas, todos os usuários com o mesmo UUID visualizarão a mesma página.

 @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)

Correndo atrás do Nginx

A implementação de WebSockets é muito simples, mas há algumas coisas complicadas a serem consideradas ao usá-lo em ambientes de produção. O Tornado é um servidor web, portanto, pode receber as solicitações dos usuários diretamente, mas implantá-lo atrás do Nginx pode ser uma escolha melhor por vários motivos. No entanto, é preciso um pouco mais de esforço para poder usar WebSockets por meio do 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"; } } }

As duas diretivas proxy_set_header fazem com que o Nginx passe os cabeçalhos necessários para os servidores back-end que são necessários para atualizar a conexão com o WebSocket.

Qual é o próximo?

Neste artigo, implementamos um aplicativo web simples em Python que usa WebSockets para manter conexões persistentes entre o servidor e cada um dos clientes. Com estruturas de rede assíncronas modernas como o Tornado, manter dezenas de milhares de conexões abertas simultaneamente em Python é totalmente viável.

Embora certos aspectos de implementação deste aplicativo de demonstração possam ter sido feitos de maneira diferente, espero que ainda tenha ajudado a demonstrar o uso de WebSockets na estrutura https://www.toptal.com/tornado. O código-fonte do aplicativo de demonstração está disponível no GitHub.

Relacionado: Tutorial multithreading do Python: simultaneidade e paralelismo