Cómo crear un servidor WebSocket de Python simple usando Tornado
Publicado: 2022-03-11Con el aumento de la popularidad de las aplicaciones web en tiempo real, los WebSockets se han convertido en una tecnología clave en su implementación. Los días en los que tenía que presionar constantemente el botón de recarga para recibir actualizaciones del servidor quedaron atrás. Las aplicaciones web que desean proporcionar actualizaciones en tiempo real ya no tienen que sondear el servidor en busca de cambios; en su lugar, los servidores envían los cambios a medida que ocurren. Los marcos web robustos han comenzado a admitir WebSockets listos para usar. Ruby on Rails 5, por ejemplo, lo llevó aún más lejos y agregó soporte para cables de acción.
En el mundo de Python, existen muchos marcos web populares. Los marcos como Django proporcionan casi todo lo necesario para crear aplicaciones web, y cualquier cosa que falte se puede compensar con uno de los miles de complementos disponibles para Django. Sin embargo, debido a la forma en que Python o la mayoría de sus marcos web funcionan, el manejo de conexiones de larga duración puede convertirse rápidamente en una pesadilla. El modelo de subprocesos y el bloqueo del intérprete global a menudo se consideran el talón de Aquiles de Python.
Pero todo eso ha comenzado a cambiar. Con ciertas características nuevas de Python 3 y marcos que ya existen para Python, como Tornado, manejar conexiones de larga duración ya no es un desafío. Tornado proporciona capacidades de servidor web en Python que son específicamente útiles para manejar conexiones de larga duración.
En este artículo, veremos cómo se puede construir un servidor WebSocket simple en Python usando Tornado. La aplicación de demostración nos permitirá cargar un archivo de valores separados por tabuladores (TSV), analizarlo y hacer que su contenido esté disponible en una URL única.
Tornado y WebSockets
Tornado es una biblioteca de red asincrónica y se especializa en el manejo de redes impulsadas por eventos. Dado que naturalmente puede contener decenas de miles de conexiones abiertas al mismo tiempo, un servidor puede aprovechar esto y manejar muchas conexiones WebSocket dentro de un solo nodo. WebSocket es un protocolo que proporciona canales de comunicación full-duplex a través de una única conexión TCP. Como es un socket abierto, esta técnica hace que una conexión web tenga estado y facilita la transferencia de datos en tiempo real hacia y desde el servidor. El servidor, al mantener los estados de los clientes, facilita la implementación de aplicaciones de chat en tiempo real o juegos web basados en WebSockets.
WebSockets está diseñado para implementarse en servidores y navegadores web, y actualmente es compatible con todos los principales navegadores web. Una conexión se abre una vez y los mensajes pueden viajar de un lado a otro varias veces antes de que se cierre la conexión.
Instalar Tornado es bastante simple. Está listado en PyPI y se puede instalar usando pip o easy_install:
pip install tornado
Tornado viene con su propia implementación de WebSockets. A los efectos de este artículo, esto es prácticamente todo lo que necesitaremos.
WebSockets en acción
Una de las ventajas de usar WebSocket es su propiedad con estado. Esto cambia la forma en que normalmente pensamos en la comunicación cliente-servidor. Un caso de uso particular de esto es cuando se requiere que el servidor realice procesos largos y lentos y transmita gradualmente los resultados al cliente.
En nuestra aplicación de ejemplo, el usuario podrá cargar un archivo a través de WebSocket. Durante toda la vida útil de la conexión, el servidor conservará el archivo analizado en la memoria. A pedido, el servidor puede enviar partes del archivo al front-end. Además, el archivo estará disponible en una URL que luego podrán ver varios usuarios. Si se carga otro archivo en la misma URL, todos los que lo miren podrán ver el nuevo archivo inmediatamente.
Para el front-end, usaremos AngularJS. Este marco y bibliotecas nos permitirán manejar fácilmente la carga y paginación de archivos. Sin embargo, para todo lo relacionado con WebSockets, utilizaremos funciones estándar de JavaScript.
Esta sencilla aplicación se dividirá en tres archivos independientes:
- parser.py: donde se implementa nuestro servidor Tornado con los controladores de solicitudes
- templates/index.html: plantilla HTML de front-end
- static/parser.js: para nuestro JavaScript front-end
Abriendo un WebSocket
Desde el front-end, se puede establecer una conexión WebSocket instanciando un objeto WebSocket:
new WebSocket(WEBSOCKET_URL);
Esto es algo que tendremos que hacer al cargar la página. Una vez que se crea una instancia de un objeto WebSocket, se deben adjuntar controladores para manejar tres eventos importantes:
- abierto: disparado cuando se establece una conexión
- mensaje: se activa cuando se recibe un mensaje del servidor
- close: se activa cuando se cierra una conexión
$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();
Dado que estos controladores de eventos no activarán automáticamente el ciclo de vida de $scope de AngularJS, el contenido de la función del controlador debe incluirse en $apply. En caso de que esté interesado, existen paquetes específicos de AngularJS que facilitan la integración de WebSocket en aplicaciones AngularJS.
Vale la pena mencionar que las conexiones de WebSocket interrumpidas no se restablecen automáticamente y requerirán que la aplicación intente volver a conectarse cuando se active el controlador de eventos de cierre. Esto está un poco más allá del alcance de este artículo.
Selección de un archivo para cargar
Dado que estamos creando una aplicación de una sola página usando AngularJS, intentar enviar formularios con archivos de la manera antigua no funcionará. Para facilitar las cosas, utilizaremos la biblioteca ng-file-upload de Danial Farid. Con lo cual, todo lo que debemos hacer para permitir que un usuario cargue un archivo es agregar un botón a nuestra plantilla de front-end con directivas específicas de AngularJS:
<button class="btn btn-default" type="file" ngf-select="uploadFile($file, $invalidFiles)" accept=".tsv" ngf-max-size="10MB">Select File</button>
La biblioteca, entre muchas cosas, nos permite establecer una extensión y un tamaño de archivo aceptables. Al hacer clic en este botón, al igual que cualquier elemento <input type=”file”>
, se abrirá el selector de archivos estándar.
Subiendo el archivo
Cuando desee transferir datos binarios, puede elegir entre búfer de matriz y blob. Si se trata solo de datos sin procesar como un archivo de imagen, elija blob y manéjelo correctamente en el servidor. El búfer de matriz es para búfer binario de longitud fija y un archivo de texto como TSV se puede transferir en formato de cadena de bytes. Este fragmento de código muestra cómo cargar un archivo en formato de búfer 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); } }
La directiva ng-file-upload proporciona una función uploadFile. Aquí puede transformar el archivo en un búfer de matriz utilizando un FileReader y enviarlo a través de WebSocket.
Tenga en cuenta que enviar archivos grandes a través de WebSocket leyéndolos en búferes de matriz puede no ser la forma más óptima de cargarlos, ya que puede ocupar rápidamente mucha memoria, lo que resulta en una mala experiencia.
Recibir el archivo en el servidor
Tornado determina el tipo de mensaje utilizando el código de operación de 4 bits y devuelve str para datos binarios y 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)
En el servidor web Tornado, el búfer de matriz se recibe en el tipo de str.
En este ejemplo, el tipo de contenido que esperamos es TSV, por lo que el archivo se analiza y transforma en un diccionario. Por supuesto, en aplicaciones reales, existen formas más sensatas de lidiar con cargas arbitrarias.
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 una página
Dado que nuestro objetivo es mostrar los datos TSV cargados en fragmentos de páginas pequeñas, necesitamos un medio para solicitar una página en particular. Para simplificar las cosas, simplemente usaremos la misma conexión WebSocket para enviar el número de página a nuestro servidor.
$scope.pageChanged = function() { ws = $scope.ws; ws.send($scope.currentPage); }
El servidor recibirá este mensaje como unicode:
def on_message(self, message): if isinstance(message, unicode): page_no = int(message) self.write_message(self.make_message(page_no))
Intentar responder con un dict de un servidor Tornado WebSocket lo codificará automáticamente en formato JSON. Por lo tanto, está completamente bien enviar un dictado que contenga 100 filas de contenido.
Compartir acceso con otros
Para poder compartir el acceso a la misma carga con varios usuarios, debemos poder identificar de forma única las cargas. Cada vez que un usuario se conecta al servidor a través de WebSocket, se generará y asignará un UUID aleatorio a su conexión.
def open(self, doc_uuid=None): if doc_uuid is None: self.uuid = str(uuid.uuid4())
uuid.uuid4()
genera un UUID aleatorio y str() convierte un UUID en una cadena de dígitos hexadecimales en forma estándar.
Si otro usuario con un UUID se conecta al servidor, la instancia correspondiente de FileHandler se agrega a un diccionario con el UUID como clave y se elimina cuando se cierra la conexión.
@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]
El diccionario de clientes puede arrojar un KeyError al agregar o eliminar clientes simultáneamente. Como Tornado es una biblioteca de redes asíncronas, proporciona mecanismos de bloqueo para la sincronización. Un simple candado con rutina encaja en este caso de manejo de diccionario de clientes.
Si algún usuario carga un archivo o se mueve entre páginas, todos los usuarios con el mismo UUID ven la misma 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)
Corriendo detrás de Nginx
La implementación de WebSockets es muy simple, pero hay algunas cosas difíciles que se deben tener en cuenta cuando se usa en entornos de producción. Tornado es un servidor web, por lo que puede recibir las solicitudes de los usuarios directamente, pero implementarlo detrás de Nginx puede ser una mejor opción por muchas razones. Sin embargo, se necesita un poco más de esfuerzo para poder usar WebSockets a través de 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"; } } }
Las dos directivas proxy_set_header
hacen que Nginx pase los encabezados necesarios a los servidores back-end que son necesarios para actualizar la conexión a WebSocket.
¿Que sigue?
En este artículo, implementamos una aplicación web de Python simple que usa WebSockets para mantener conexiones persistentes entre el servidor y cada uno de los clientes. Con los marcos de trabajo de redes asincrónicas modernas como Tornado, mantener decenas de miles de conexiones abiertas al mismo tiempo en Python es completamente factible.
Aunque ciertos aspectos de implementación de esta aplicación de demostración se podrían haber hecho de manera diferente, espero que todavía haya ayudado a demostrar el uso de WebSockets en el marco https://www.toptal.com/tornado. El código fuente de la aplicación de demostración está disponible en GitHub.