Comment créer un serveur Python WebSocket simple à l'aide de Tornado
Publié: 2022-03-11Avec la popularité croissante des applications Web en temps réel, les WebSockets sont devenus une technologie clé dans leur mise en œuvre. L'époque où vous deviez constamment appuyer sur le bouton de rechargement pour recevoir les mises à jour du serveur est révolue depuis longtemps. Les applications Web qui souhaitent fournir des mises à jour en temps réel n'ont plus à interroger le serveur pour les modifications. Au lieu de cela, les serveurs transmettent les modifications au fur et à mesure qu'elles se produisent. Des frameworks Web robustes ont commencé à prendre en charge WebSockets prêts à l'emploi. Ruby on Rails 5, par exemple, est allé encore plus loin et a ajouté la prise en charge des câbles d'action.
Dans le monde de Python, de nombreux frameworks Web populaires existent. Des frameworks tels que Django fournissent presque tout le nécessaire pour créer des applications Web, et tout ce qui lui manque peut être compensé avec l'un des milliers de plugins disponibles pour Django. Cependant, en raison du fonctionnement de Python ou de la plupart de ses frameworks Web, la gestion de connexions de longue durée peut rapidement devenir un cauchemar. Le modèle fileté et le verrou global de l'interpréteur sont souvent considérés comme le talon d'Achille de Python.
Mais tout cela a commencé à changer. Avec certaines nouvelles fonctionnalités de Python 3 et les frameworks qui existent déjà pour Python, tels que Tornado, la gestion des connexions de longue durée n'est plus un défi. Tornado fournit des fonctionnalités de serveur Web en Python qui sont particulièrement utiles pour gérer les connexions de longue durée.
Dans cet article, nous verrons comment un simple serveur WebSocket peut être construit en Python à l'aide de Tornado. L'application de démonstration nous permettra de télécharger un fichier de valeurs séparées par des tabulations (TSV), de l'analyser et de rendre son contenu disponible sur une URL unique.
Tornade et WebSockets
Tornado est une bibliothèque réseau asynchrone et se spécialise dans le traitement des réseaux événementiels. Puisqu'il peut naturellement contenir des dizaines de milliers de connexions ouvertes simultanément, un serveur peut en tirer parti et gérer de nombreuses connexions WebSocket au sein d'un seul nœud. WebSocket est un protocole qui fournit des canaux de communication en duplex intégral sur une seule connexion TCP. Comme il s'agit d'un socket ouvert, cette technique rend une connexion Web avec état et facilite le transfert de données en temps réel vers et depuis le serveur. Le serveur, qui conserve les états des clients, facilite la mise en œuvre d'applications de chat en temps réel ou de jeux Web basés sur WebSockets.
Les WebSockets sont conçus pour être implémentés dans les navigateurs Web et les serveurs, et sont actuellement pris en charge dans tous les principaux navigateurs Web. Une connexion est ouverte une fois et les messages peuvent aller et venir plusieurs fois avant que la connexion ne soit fermée.
L'installation de Tornado est assez simple. Il est répertorié dans PyPI et peut être installé à l'aide de pip ou easy_install :
pip install tornado
Tornado est livré avec sa propre implémentation de WebSockets. Pour les besoins de cet article, c'est à peu près tout ce dont nous aurons besoin.
WebSocket en action
L'un des avantages de l'utilisation de WebSocket est sa propriété avec état. Cela change la façon dont nous pensons généralement à la communication client-serveur. Un cas d'utilisation particulier est celui où le serveur doit exécuter de longs processus lents et renvoyer progressivement les résultats au client.
Dans notre exemple d'application, l'utilisateur pourra télécharger un fichier via WebSocket. Pendant toute la durée de vie de la connexion, le serveur conservera le fichier analysé en mémoire. Sur demande, le serveur peut alors renvoyer des parties du fichier au frontal. De plus, le fichier sera mis à disposition sur une URL qui pourra ensuite être consultée par plusieurs utilisateurs. Si un autre fichier est téléchargé à la même URL, tous ceux qui le consultent pourront voir le nouveau fichier immédiatement.
Pour le front-end, nous utiliserons AngularJS. Ce framework et ces bibliothèques nous permettront de gérer facilement les téléchargements de fichiers et la pagination. Pour tout ce qui concerne WebSockets, cependant, nous utiliserons des fonctions JavaScript standard.
Cette application simple sera décomposée en trois fichiers distincts :
- parser.py : où notre serveur Tornado avec les gestionnaires de requêtes est implémenté
- templates/index.html : modèle HTML frontal
- static/parser.js : pour notre JavaScript frontal
Ouvrir un WebSocket
Depuis le front-end, une connexion WebSocket peut être établie en instanciant un objet WebSocket :
new WebSocket(WEBSOCKET_URL);
C'est quelque chose que nous devrons faire au chargement de la page. Une fois qu'un objet WebSocket est instancié, des gestionnaires doivent être attachés pour gérer trois événements importants :
- ouvert : déclenché lorsqu'une connexion est établie
- message : déclenché lorsqu'un message est reçu du serveur
- close : déclenché lorsqu'une connexion est fermée
$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();
Étant donné que ces gestionnaires d'événements ne déclencheront pas automatiquement le cycle de vie $scope d'AngularJS, le contenu de la fonction de gestionnaire doit être encapsulé dans $apply. Au cas où vous seriez intéressé, il existe des packages spécifiques à AngularJS qui facilitent l'intégration de WebSocket dans les applications AngularJS.
Il convient de mentionner que les connexions WebSocket abandonnées ne sont pas automatiquement rétablies et obligeront l'application à tenter de se reconnecter lorsque le gestionnaire d'événements de fermeture est déclenché. Cela dépasse un peu le cadre de cet article.
Sélection d'un fichier à télécharger
Étant donné que nous construisons une application d'une seule page à l'aide d'AngularJS, tenter de soumettre des formulaires avec des fichiers à l'ancienne ne fonctionnera pas. Pour faciliter les choses, nous utiliserons la bibliothèque ng-file-upload de Danial Farid. À l'aide de quoi, tout ce que nous devons faire pour permettre à un utilisateur de télécharger un fichier est d'ajouter un bouton à notre modèle frontal avec des directives AngularJS spécifiques :
<button class="btn btn-default" type="file" ngf-select="uploadFile($file, $invalidFiles)" accept=".tsv" ngf-max-size="10MB">Select File</button>
La bibliothèque, entre autres choses, nous permet de définir une extension et une taille de fichier acceptables. Cliquer sur ce bouton, comme n'importe quel élément <input type=”file”>
, ouvrira le sélecteur de fichier standard.

Téléchargement du fichier
Lorsque vous souhaitez transférer des données binaires, vous pouvez choisir entre tableau tampon et blob. S'il ne s'agit que de données brutes comme un fichier image, choisissez blob et gérez-le correctement dans le serveur. Le tampon de tableau est destiné au tampon binaire de longueur fixe et un fichier texte tel que TSV peut être transféré au format de chaîne d'octets. Cet extrait de code montre comment télécharger un fichier au format tableau tampon.
$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 directive ng-file-upload fournit une fonction uploadFile. Ici, vous pouvez transformer le fichier en un tampon de tableau à l'aide d'un FileReader et l'envoyer via le WebSocket.
Notez que l'envoi de fichiers volumineux via WebSocket en les lisant dans des tampons de tableau n'est peut-être pas le moyen le plus optimal de les télécharger car il peut rapidement occuper trop de mémoire, ce qui entraîne une mauvaise expérience.
Recevoir le fichier sur le serveur
Tornado détermine le type de message à l'aide de l'opcode 4 bits et renvoie str pour les données binaires et unicode pour le texte.
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)
Dans le serveur Web Tornado, le tampon de tableau est reçu dans le type de str.
Dans cet exemple, le type de contenu que nous attendons est TSV, donc le fichier est analysé et transformé en dictionnaire. Bien sûr, dans les applications réelles, il existe des moyens plus sains de gérer les téléchargements arbitraires.
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())
Demander une page
Étant donné que notre objectif est d'afficher les données TSV téléchargées en blocs de petites pages, nous avons besoin d'un moyen de demander une page particulière. Pour simplifier les choses, nous utiliserons simplement la même connexion WebSocket pour envoyer le numéro de page à notre serveur.
$scope.pageChanged = function() { ws = $scope.ws; ws.send($scope.currentPage); }
Le serveur recevra ce message en unicode :
def on_message(self, message): if isinstance(message, unicode): page_no = int(message) self.write_message(self.make_message(page_no))
Tenter de répondre avec un dict d'un serveur Tornado WebSocket l'encodera automatiquement au format JSON. Il est donc tout à fait acceptable d'envoyer simplement un dict contenant 100 lignes de contenu.
Partage d'accès avec d'autres
Pour pouvoir partager l'accès au même téléchargement avec plusieurs utilisateurs, nous devons être en mesure d'identifier de manière unique les téléchargements. Chaque fois qu'un utilisateur se connecte au serveur via WebSocket, un UUID aléatoire sera généré et attribué à sa connexion.
def open(self, doc_uuid=None): if doc_uuid is None: self.uuid = str(uuid.uuid4())
uuid.uuid4()
génère un UUID aléatoire et str() convertit un UUID en une chaîne de chiffres hexadécimaux sous forme standard.
Si un autre utilisateur avec un UUID se connecte au serveur, l'instance correspondante de FileHandler est ajoutée à un dictionnaire avec l'UUID comme clé et est supprimée lorsque la connexion est fermée.
@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]
Le dictionnaire des clients peut générer une KeyError lors de l'ajout ou de la suppression simultanée de clients. Comme Tornado est une bibliothèque réseau asynchrone, elle fournit des mécanismes de verrouillage pour la synchronisation. Un simple verrou avec coroutine correspond à ce cas de gestion du dictionnaire des clients.
Si un utilisateur télécharge un fichier ou se déplace entre les pages, tous les utilisateurs avec le même UUID affichent la même page.
@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)
Courir derrière Nginx
L'implémentation de WebSockets est très simple, mais il y a des choses délicates à considérer lors de son utilisation dans des environnements de production. Tornado est un serveur Web, il peut donc recevoir directement les demandes des utilisateurs, mais le déployer derrière Nginx peut être un meilleur choix pour de nombreuses raisons. Cependant, il faut un peu plus d'efforts pour pouvoir utiliser WebSockets via 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"; } } }
Les deux directives proxy_set_header
Nginx à transmettre les en-têtes nécessaires aux serveurs principaux nécessaires à la mise à niveau de la connexion à WebSocket.
Et après?
Dans cet article, nous avons implémenté une application Web Python simple qui utilise WebSockets pour maintenir des connexions persistantes entre le serveur et chacun des clients. Avec les frameworks de mise en réseau asynchrone modernes comme Tornado, il est tout à fait possible de maintenir simultanément des dizaines de milliers de connexions ouvertes en Python.
Bien que certains aspects de la mise en œuvre de cette application de démonstration auraient pu être réalisés différemment, j'espère qu'elle a tout de même aidé à démontrer l'utilisation de WebSockets dans le framework https://www.toptal.com/tornado. Le code source de l'application de démonstration est disponible sur GitHub.