Come creare un semplice server WebSocket Python utilizzando Tornado

Pubblicato: 2022-03-11

Con l'aumento della popolarità delle applicazioni Web in tempo reale, i WebSocket sono diventati una tecnologia chiave nella loro implementazione. I giorni in cui dovevi premere costantemente il pulsante di ricarica per ricevere gli aggiornamenti dal server sono ormai lontani. Le applicazioni Web che desiderano fornire aggiornamenti in tempo reale non devono più eseguire il polling del server per le modifiche, ma i server inviano le modifiche al flusso non appena si verificano. Robusti framework Web hanno iniziato a supportare WebSocket immediatamente. Ruby on Rails 5, ad esempio, è andato ancora oltre e ha aggiunto il supporto per i cavi di azione.

Nel mondo di Python esistono molti framework web popolari. Framework come Django forniscono quasi tutto il necessario per creare applicazioni web e tutto ciò che manca può essere recuperato con uno dei migliaia di plugin disponibili per Django. Tuttavia, a causa del modo in cui funzionano Python o la maggior parte dei suoi framework Web, la gestione di connessioni di lunga durata può diventare rapidamente un incubo. Il modello threaded e il blocco globale dell'interprete sono spesso considerati il ​​tallone d'Achille di Python.

Ma tutto questo ha iniziato a cambiare. Con alcune nuove funzionalità di Python 3 e framework già esistenti per Python, come Tornado, gestire connessioni di lunga durata non è più una sfida. Tornado fornisce funzionalità di server Web in Python che sono particolarmente utili nella gestione di connessioni di lunga durata.

In questo articolo, daremo un'occhiata a come un semplice server WebSocket può essere costruito in Python usando Tornado. L'applicazione demo ci consentirà di caricare un file di valori separati da tabulazioni (TSV), analizzarlo e renderne disponibile il contenuto a un URL univoco.

Tornado e WebSocket

Tornado è una libreria di rete asincrona ed è specializzata nella gestione delle reti basate su eventi. Dal momento che può naturalmente contenere decine di migliaia di connessioni aperte contemporaneamente, un server può trarne vantaggio e gestire molte connessioni WebSocket all'interno di un singolo nodo. WebSocket è un protocollo che fornisce canali di comunicazione full-duplex su una singola connessione TCP. Poiché si tratta di un socket aperto, questa tecnica rende stateful una connessione Web e facilita il trasferimento di dati in tempo reale da e verso il server. Il server, mantenendo gli stati dei client, semplifica l'implementazione di applicazioni di chat in tempo reale o giochi web basati su WebSocket.

I WebSocket sono progettati per essere implementati in browser Web e server e sono attualmente supportati in tutti i principali browser Web. Una connessione viene aperta una volta e i messaggi possono viaggiare avanti e indietro più volte prima che la connessione venga chiusa.

L'installazione di Tornado è piuttosto semplice. È elencato in PyPI e può essere installato utilizzando pip o easy_install:

 pip install tornado

Tornado viene fornito con la propria implementazione di WebSocket. Ai fini di questo articolo, questo è praticamente tutto ciò di cui avremo bisogno.

WebSocket in azione

Uno dei vantaggi dell'utilizzo di WebSocket è la sua proprietà stateful. Questo cambia il modo in cui di solito pensiamo alla comunicazione client-server. Un caso d'uso particolare di questo è dove al server è richiesto di eseguire processi lunghi e lenti e di trasmettere gradualmente i risultati al client.

Nella nostra applicazione di esempio, l'utente potrà caricare un file tramite WebSocket. Per l'intera durata della connessione, il server conserverà in memoria il file analizzato. Su richiesta, il server può quindi inviare parti del file al front-end. Inoltre, il file sarà reso disponibile a un URL che potrà essere visualizzato da più utenti. Se un altro file viene caricato allo stesso URL, chiunque lo guardi sarà in grado di vedere immediatamente il nuovo file.

Per il front-end, utilizzeremo AngularJS. Questo framework e le librerie ci permetteranno di gestire facilmente i caricamenti di file e l'impaginazione. Per tutto ciò che riguarda i WebSocket, invece, utilizzeremo le funzioni JavaScript standard.

Questa semplice applicazione sarà suddivisa in tre file separati:

  • parser.py: dove è implementato il nostro server Tornado con i gestori delle richieste
  • templates/index.html: modello HTML front-end
  • static/parser.js: per il nostro JavaScript front-end

Apertura di un WebSocket

Dal front-end, è possibile stabilire una connessione WebSocket istanziando un oggetto WebSocket:

 new WebSocket(WEBSOCKET_URL);

Questo è qualcosa che dovremo fare al caricamento della pagina. Una volta creata un'istanza di un oggetto WebSocket, i gestori devono essere collegati per gestire tre eventi importanti:

  • aperto: attivato quando viene stabilita una connessione
  • messaggio: attivato quando viene ricevuto un messaggio dal server
  • close: attivato quando una connessione viene chiusa
 $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();

Poiché questi gestori di eventi non attiveranno automaticamente il ciclo di vita $scope di AngularJS, il contenuto della funzione del gestore deve essere racchiuso in $apply. Nel caso tu sia interessato, esistono pacchetti specifici di AngularJS che semplificano l'integrazione di WebSocket nelle applicazioni AngularJS.

Vale la pena ricordare che le connessioni WebSocket interrotte non vengono ristabilite automaticamente e richiederà che l'applicazione tenti di riconnettersi quando viene attivato il gestore di eventi di chiusura. Questo è un po' oltre lo scopo di questo articolo.

Selezione di un file da caricare

Poiché stiamo creando un'applicazione a pagina singola utilizzando AngularJS, tentare di inviare moduli con file nel modo antico non funzionerà. Per semplificare le cose, useremo la libreria ng-file-upload di Danial Farid. Usando il quale, tutto ciò che dobbiamo fare per consentire a un utente di caricare un file è aggiungere un pulsante al nostro modello front-end con specifiche direttive AngularJS:

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

La libreria, tra le tante cose, ci consente di impostare l'estensione e la dimensione del file accettabili. Facendo clic su questo pulsante, proprio come qualsiasi elemento <input type=”file”> , si aprirà il selettore di file standard.

Caricamento del file

Quando si desidera trasferire dati binari, è possibile scegliere tra array buffer e BLOB. Se sono solo dati grezzi come un file immagine, scegli blob e gestiscilo correttamente nel server. Il buffer dell'array è per il buffer binario di lunghezza fissa e un file di testo come TSV può essere trasferito nel formato di stringa di byte. Questo frammento di codice mostra come caricare un file in formato buffer di matrice.

 $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 direttiva ng-file-upload fornisce una funzione uploadFile. Qui puoi trasformare il file in un buffer di array usando un FileReader e inviarlo tramite WebSocket.

Si noti che l'invio di file di grandi dimensioni su WebSocket leggendoli nei buffer dell'array potrebbe non essere il modo migliore per caricarli poiché può occupare rapidamente molta memoria con conseguente scarsa esperienza.

Ricevi il file sul server

Tavolo

Tornado determina il tipo di messaggio utilizzando l'opcode a 4 bit e restituisce str per i dati binari e unicode per il testo.

 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)

Nel server Web Tornado, il buffer dell'array viene ricevuto nel tipo di str.

In questo esempio il tipo di contenuto che ci aspettiamo è TSV, quindi il file viene analizzato e trasformato in un dizionario. Naturalmente, nelle applicazioni reali, ci sono modi più sani di gestire i caricamenti arbitrari.

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

Richiedi una Pagina

Poiché il nostro obiettivo è mostrare i dati TSV caricati in blocchi di piccole pagine, abbiamo bisogno di un mezzo per richiedere una pagina particolare. Per semplificare le cose, utilizzeremo semplicemente la stessa connessione WebSocket per inviare il numero di pagina al nostro server.

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

Il server riceverà questo messaggio come unicode:

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

Il tentativo di rispondere con un dict da un server Tornado WebSocket lo codificherà automaticamente in formato JSON. Quindi è del tutto normale inviare un dict che contiene 100 righe di contenuto.

Condivisione dell'accesso con altri

Per poter condividere l'accesso allo stesso caricamento con più utenti, dobbiamo essere in grado di identificare in modo univoco i caricamenti. Ogni volta che un utente si connette al server tramite WebSocket, verrà generato un UUID casuale e assegnato alla sua connessione.

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

uuid.uuid4() genera un UUID casuale e str() converte un UUID in una stringa di cifre esadecimali in formato standard.

Se un altro utente con un UUID si connette al server, l'istanza corrispondente di FileHandler viene aggiunta a un dizionario con l'UUID come chiave e viene rimossa quando la connessione viene chiusa.

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

Il dizionario dei client può generare un KeyError quando si aggiungono o rimuovono i client contemporaneamente. Poiché Tornado è una libreria di rete asincrona, fornisce meccanismi di blocco per la sincronizzazione. Un semplice lucchetto con coroutine si adatta a questo caso di gestione del dizionario dei clienti.

Se un utente carica un file o si sposta tra le pagine, tutti gli utenti con lo stesso UUID visualizzano la stessa pagina.

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

Correre dietro Nginx

L'implementazione di WebSocket è molto semplice, ma ci sono alcune cose complicate da considerare quando lo si utilizza in ambienti di produzione. Tornado è un server Web, quindi può ricevere direttamente le richieste degli utenti, ma implementarlo dietro Nginx potrebbe essere una scelta migliore per molte ragioni. Tuttavia, è necessario uno sforzo leggermente maggiore per poter utilizzare WebSocket tramite 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"; } } }

Le due direttive proxy_set_header fanno sì che Nginx passi le intestazioni necessarie ai server back-end necessari per aggiornare la connessione a WebSocket.

Qual è il prossimo?

In questo articolo, abbiamo implementato una semplice applicazione Web Python che utilizza WebSocket per mantenere connessioni persistenti tra il server e ciascuno dei client. Con i moderni framework di rete asincroni come Tornado, tenere contemporaneamente decine di migliaia di connessioni aperte in Python è del tutto fattibile.

Sebbene alcuni aspetti di implementazione di questa applicazione demo avrebbero potuto essere eseguiti in modo diverso, spero che abbia comunque aiutato a dimostrare l'utilizzo di WebSocket in https://www.toptal.com/tornado framework. Il codice sorgente dell'applicazione demo è disponibile su GitHub.

Correlati: Tutorial sul multithreading Python: concorrenza e parallelismo