So erstellen Sie einen einfachen Python-WebSocket-Server mit Tornado
Veröffentlicht: 2022-03-11Mit der zunehmenden Popularität von Echtzeit-Webanwendungen sind WebSockets zu einer Schlüsseltechnologie bei deren Implementierung geworden. Die Zeiten, in denen Sie ständig die Reload-Taste drücken mussten, um Updates vom Server zu erhalten, sind lange vorbei. Webanwendungen, die Echtzeit-Updates bereitstellen möchten, müssen den Server nicht mehr nach Änderungen abfragen – stattdessen schieben Server Änderungen in den Stream, sobald sie auftreten. Robuste Web-Frameworks haben damit begonnen, WebSockets standardmäßig zu unterstützen. Ruby on Rails 5 zum Beispiel ging noch weiter und fügte Unterstützung für Action-Kabel hinzu.
In der Welt von Python gibt es viele beliebte Web-Frameworks. Frameworks wie Django bieten fast alles, was zum Erstellen von Webanwendungen erforderlich ist, und alles, was fehlt, kann mit einem der Tausenden von Plugins nachgeholt werden, die für Django verfügbar sind. Aufgrund der Funktionsweise von Python oder den meisten seiner Web-Frameworks kann der Umgang mit langlebigen Verbindungen jedoch schnell zu einem Alptraum werden. Das Threaded-Modell und die globale Interpreter-Sperre werden oft als die Achillesferse von Python angesehen.
Aber all das hat begonnen, sich zu ändern. Mit bestimmten neuen Funktionen von Python 3 und Frameworks, die bereits für Python existieren, wie z. B. Tornado, ist der Umgang mit langlebigen Verbindungen keine Herausforderung mehr. Tornado bietet Webserver-Fähigkeiten in Python, die besonders beim Umgang mit langlebigen Verbindungen nützlich sind.
In diesem Artikel werfen wir einen Blick darauf, wie ein einfacher WebSocket-Server mit Tornado in Python gebaut werden kann. Die Demoanwendung ermöglicht es uns, eine Datei mit tabulatorgetrennten Werten (TSV) hochzuladen, sie zu parsen und ihren Inhalt unter einer eindeutigen URL verfügbar zu machen.
Tornado und WebSockets
Tornado ist eine asynchrone Netzwerkbibliothek und spezialisiert auf den Umgang mit ereignisgesteuerten Netzwerken. Da er natürlich Zehntausende offener Verbindungen gleichzeitig halten kann, kann ein Server dies ausnutzen und viele WebSocket-Verbindungen innerhalb eines einzigen Knotens verarbeiten. WebSocket ist ein Protokoll, das Vollduplex-Kommunikationskanäle über eine einzelne TCP-Verbindung bereitstellt. Da es sich um einen offenen Socket handelt, macht diese Technik eine Webverbindung zustandsbehaftet und erleichtert die Datenübertragung in Echtzeit zum und vom Server. Der Server, der die Zustände der Clients speichert, erleichtert die Implementierung von Echtzeit-Chat-Anwendungen oder Webspielen auf Basis von WebSockets.
WebSockets sind für die Implementierung in Webbrowsern und Servern konzipiert und werden derzeit von allen gängigen Webbrowsern unterstützt. Eine Verbindung wird einmal geöffnet und Nachrichten können mehrmals hin- und hergeschickt werden, bevor die Verbindung geschlossen wird.
Die Installation von Tornado ist ziemlich einfach. Es ist in PyPI aufgeführt und kann mit pip oder easy_install installiert werden:
pip install tornado
Tornado wird mit einer eigenen Implementierung von WebSockets geliefert. Für die Zwecke dieses Artikels ist dies so ziemlich alles, was wir brauchen.
WebSockets in Aktion
Einer der Vorteile der Verwendung von WebSocket ist seine zustandsbehaftete Eigenschaft. Dies verändert die Art und Weise, wie wir normalerweise über Client-Server-Kommunikation denken. Ein besonderer Anwendungsfall hierfür ist, wenn der Server lange, langsame Prozesse ausführen und die Ergebnisse schrittweise zurück an den Client streamen muss.
In unserer Beispielanwendung kann der Benutzer eine Datei über WebSocket hochladen. Für die gesamte Lebensdauer der Verbindung behält der Server die analysierte Datei im Arbeitsspeicher. Auf Anfrage kann der Server dann Teile der Datei an das Frontend zurücksenden. Außerdem wird die Datei unter einer URL bereitgestellt, die dann von mehreren Benutzern eingesehen werden kann. Wenn eine andere Datei unter derselben URL hochgeladen wird, kann jeder Betrachter die neue Datei sofort sehen.
Für das Frontend verwenden wir AngularJS. Dieses Framework und die Bibliotheken ermöglichen es uns, Datei-Uploads und Paginierung einfach zu handhaben. Für alles, was mit WebSockets zu tun hat, verwenden wir jedoch Standard-JavaScript-Funktionen.
Diese einfache Anwendung wird in drei separate Dateien unterteilt:
- parser.py: wo unser Tornado-Server mit den Request-Handlern implementiert ist
- templates/index.html: Frontend-HTML-Vorlage
- static/parser.js: Für unser Frontend-JavaScript
Öffnen eines WebSockets
Vom Frontend aus kann eine WebSocket-Verbindung hergestellt werden, indem ein WebSocket-Objekt instanziiert wird:
new WebSocket(WEBSOCKET_URL);
Dies müssen wir beim Laden der Seite tun. Sobald ein WebSocket-Objekt instanziiert ist, müssen Handler angehängt werden, um drei wichtige Ereignisse zu verarbeiten:
- open: Wird ausgelöst, wenn eine Verbindung hergestellt wird
- Nachricht: Wird ausgelöst, wenn eine Nachricht vom Server empfangen wird
- close: Wird ausgelöst, wenn eine Verbindung geschlossen wird
$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();
Da diese Event-Handler den $scope-Lebenszyklus von AngularJS nicht automatisch auslösen, muss der Inhalt der Handler-Funktion in $apply eingeschlossen werden. Falls Sie interessiert sind, gibt es AngularJS-spezifische Pakete, die es einfacher machen, WebSocket in AngularJS-Anwendungen zu integrieren.
Es ist erwähnenswert, dass unterbrochene WebSocket-Verbindungen nicht automatisch wiederhergestellt werden und die Anwendung einen erneuten Verbindungsversuch unternehmen muss, wenn der Ereignishandler für das Schließen ausgelöst wird. Dies geht etwas über den Rahmen dieses Artikels hinaus.
Auswählen einer hochzuladenden Datei
Da wir eine Single-Page-Anwendung mit AngularJS erstellen, wird der Versuch, Formulare mit Dateien auf die uralte Art und Weise zu übermitteln, nicht funktionieren. Zur Vereinfachung verwenden wir die ng-file-upload-Bibliothek von Danial Farid. Damit ein Benutzer eine Datei hochladen kann, müssen wir lediglich eine Schaltfläche zu unserer Frontend-Vorlage mit bestimmten AngularJS-Anweisungen hinzufügen:
<button class="btn btn-default" type="file" ngf-select="uploadFile($file, $invalidFiles)" accept=".tsv" ngf-max-size="10MB">Select File</button>
Die Bibliothek ermöglicht es uns unter anderem, eine akzeptable Dateierweiterung und -größe festzulegen. Wenn Sie auf diese Schaltfläche klicken, öffnet sich wie bei jedem <input type=”file”>
-Element die standardmäßige Dateiauswahl.
Hochladen der Datei
Wenn Sie binäre Daten übertragen möchten, können Sie zwischen Array Buffer und Blob wählen. Wenn es sich nur um Rohdaten wie eine Bilddatei handelt, wählen Sie Blob und behandeln Sie sie ordnungsgemäß auf dem Server. Der Array-Puffer ist für einen binären Puffer mit fester Länge und eine Textdatei wie TSV kann im Format einer Byte-Kette übertragen werden. Dieses Code-Snippet zeigt, wie eine Datei im Array-Pufferformat hochgeladen wird.

$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); } }
Die Direktive ng-file-upload stellt eine uploadFile-Funktion bereit. Hier können Sie die Datei mit einem FileReader in einen Array-Puffer umwandeln und über den WebSocket senden.
Beachten Sie, dass das Senden großer Dateien über WebSocket durch Einlesen in Array-Puffer möglicherweise nicht die optimale Methode zum Hochladen ist, da dies schnell zu viel Speicher belegen kann, was zu einer schlechten Erfahrung führt.
Empfangen Sie die Datei auf dem Server
Tornado bestimmt den Nachrichtentyp mithilfe des 4-Bit-Opcodes und gibt str für Binärdaten und Unicode für Text zurück.
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)
Im Tornado-Webserver wird der Array-Puffer im Typ str empfangen.
In diesem Beispiel ist der erwartete Inhaltstyp TSV, also wird die Datei geparst und in ein Wörterbuch umgewandelt. Natürlich gibt es in realen Anwendungen vernünftigere Möglichkeiten, mit willkürlichen Uploads umzugehen.
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())
Fordern Sie eine Seite an
Da unser Ziel darin besteht, hochgeladene TSV-Daten in Blöcken kleiner Seiten anzuzeigen, benötigen wir eine Möglichkeit, eine bestimmte Seite anzufordern. Der Einfachheit halber verwenden wir einfach dieselbe WebSocket-Verbindung, um die Seitenzahl an unseren Server zu senden.
$scope.pageChanged = function() { ws = $scope.ws; ws.send($scope.currentPage); }
Der Server erhält diese Nachricht als Unicode:
def on_message(self, message): if isinstance(message, unicode): page_no = int(message) self.write_message(self.make_message(page_no))
Wenn Sie versuchen, mit einem Diktat von einem Tornado-WebSocket-Server zu antworten, wird es automatisch im JSON-Format codiert. Es ist also völlig in Ordnung, nur ein Diktat zu senden, das 100 Zeilen Inhalt enthält.
Zugriff mit anderen teilen
Um den Zugriff auf denselben Upload mit mehreren Benutzern teilen zu können, müssen wir in der Lage sein, die Uploads eindeutig zu identifizieren. Immer wenn sich ein Benutzer über WebSocket mit dem Server verbindet, wird eine zufällige UUID generiert und seiner Verbindung zugewiesen.
def open(self, doc_uuid=None): if doc_uuid is None: self.uuid = str(uuid.uuid4())
uuid.uuid4()
generiert eine zufällige UUID und str() wandelt eine UUID in eine Zeichenfolge aus Hexadezimalziffern in Standardform um.
Wenn sich ein anderer Benutzer mit einer UUID mit dem Server verbindet, wird die entsprechende Instanz von FileHandler einem Wörterbuch mit der UUID als Schlüssel hinzugefügt und entfernt, wenn die Verbindung geschlossen wird.
@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]
Das Client-Wörterbuch kann einen KeyError auslösen, wenn Clients gleichzeitig hinzugefügt oder entfernt werden. Da Tornado eine asynchrone Netzwerkbibliothek ist, bietet sie Sperrmechanismen für die Synchronisierung. Eine einfache Sperre mit Coroutine passt zu diesem Fall der Handhabung des Client-Wörterbuchs.
Wenn ein Benutzer eine Datei hochlädt oder zwischen Seiten wechselt, sehen alle Benutzer mit derselben UUID dieselbe Seite.
@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)
Läuft hinter Nginx
Die Implementierung von WebSockets ist sehr einfach, aber bei der Verwendung in Produktionsumgebungen sind einige knifflige Dinge zu beachten. Tornado ist ein Webserver, daher kann er Benutzeranfragen direkt erhalten, aber die Bereitstellung hinter Nginx kann aus vielen Gründen die bessere Wahl sein. Es erfordert jedoch etwas mehr Aufwand, um WebSockets über Nginx verwenden zu können:
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"; } } }
Die beiden proxy_set_header
Direktiven veranlassen Nginx, die erforderlichen Header an die Back-End-Server zu übergeben, die für das Upgrade der Verbindung zu WebSocket erforderlich sind.
Was kommt als nächstes?
In diesem Artikel haben wir eine einfache Python-Webanwendung implementiert, die WebSockets verwendet, um dauerhafte Verbindungen zwischen dem Server und jedem der Clients aufrechtzuerhalten. Mit modernen asynchronen Netzwerk-Frameworks wie Tornado ist es durchaus möglich, Zehntausende offener Verbindungen gleichzeitig in Python zu halten.
Obwohl bestimmte Implementierungsaspekte dieser Demoanwendung anders hätten ausgeführt werden können, hoffe ich, dass sie dennoch dazu beigetragen hat, die Verwendung von WebSockets im https://www.toptal.com/tornado-Framework zu demonstrieren. Der Quellcode der Demoanwendung ist auf GitHub verfügbar.