Cum se creează un server WebSocket Python simplu folosind Tornado
Publicat: 2022-03-11Odată cu creșterea popularității aplicațiilor web în timp real, WebSockets au devenit o tehnologie cheie în implementarea lor. Zilele în care trebuia să apeși constant butonul de reîncărcare pentru a primi actualizări de la server au trecut de mult. Aplicațiile web care doresc să ofere actualizări în timp real nu mai trebuie să interogheze serverul pentru modificări - în schimb, serverele împing modificările în flux pe măsură ce se întâmplă. Cadrele web robuste au început să accepte WebSockets din cutie. Ruby on Rails 5, de exemplu, a dus-o și mai departe și a adăugat suport pentru cablurile de acțiune.
În lumea Python, există multe cadre web populare. Framework-uri precum Django oferă aproape tot ceea ce este necesar pentru a construi aplicații web, iar tot ceea ce îi lipsește poate fi compensat cu unul dintre miile de pluginuri disponibile pentru Django. Cu toate acestea, datorită modului în care funcționează Python sau majoritatea cadrelor sale web, gestionarea conexiunilor cu viață lungă poate deveni rapid un coșmar. Modelul filetat și blocarea interpretului global sunt adesea considerate călcâiul lui Ahile al lui Python.
Dar toate acestea au început să se schimbe. Cu anumite caracteristici noi ale Python 3 și cadre care există deja pentru Python, cum ar fi Tornado, gestionarea conexiunilor de lungă durată nu mai este o provocare. Tornado oferă capabilități de server web în Python, care sunt utile în mod special în gestionarea conexiunilor de lungă durată.
În acest articol, vom arunca o privire asupra modului în care un server WebSocket simplu poate fi construit în Python folosind Tornado. Aplicația demo ne va permite să încărcăm un fișier cu valori separate prin file (TSV), să-l analizăm și să facem conținutul său disponibil la o adresă URL unică.
Tornado și WebSockets
Tornado este o bibliotecă de rețea asincronă și este specializată în gestionarea rețelelor bazate pe evenimente. Deoarece poate deține în mod natural zeci de mii de conexiuni deschise simultan, un server poate profita de acest lucru și poate gestiona o mulțime de conexiuni WebSocket într-un singur nod. WebSocket este un protocol care oferă canale de comunicație full-duplex printr-o singură conexiune TCP. Deoarece este o priză deschisă, această tehnică face o conexiune web cu stare și facilitează transferul de date în timp real către și de la server. Serverul, păstrând stările clienților, facilitează implementarea aplicațiilor de chat în timp real sau a jocurilor web bazate pe WebSockets.
WebSockets sunt concepute pentru a fi implementate în browsere web și servere și sunt acceptate în prezent în toate browserele web majore. O conexiune este deschisă o dată, iar mesajele pot călători înainte și înapoi de mai multe ori înainte ca conexiunea să fie închisă.
Instalarea Tornado este destul de simplă. Este listat în PyPI și poate fi instalat folosind pip sau easy_install:
pip install tornado
Tornado vine cu propria sa implementare a WebSockets. În sensul acestui articol, acesta este aproape tot ce vom avea nevoie.
WebSockets în acțiune
Unul dintre avantajele utilizării WebSocket este proprietatea sa de stat. Acest lucru schimbă modul în care ne gândim de obicei la comunicarea client-server. Un caz de utilizare particular al acestui lucru este cazul în care serverul trebuie să efectueze procese lungi și lente și să transmită treptat rezultatele înapoi către client.
În aplicația noastră exemplu, utilizatorul va putea încărca un fișier prin WebSocket. Pe toată durata de viață a conexiunii, serverul va păstra fișierul analizat în memorie. La cerere, serverul poate trimite înapoi părți ale fișierului către front-end. În plus, fișierul va fi disponibil la o adresă URL care poate fi apoi vizualizată de mai mulți utilizatori. Dacă un alt fișier este încărcat la aceeași adresă URL, toți cei care se uită la el vor putea vedea noul fișier imediat.
Pentru front-end, vom folosi AngularJS. Acest cadru și bibliotecile ne vor permite să gestionăm cu ușurință încărcările de fișiere și paginarea. Cu toate acestea, pentru tot ce are legătură cu WebSockets, vom folosi funcții JavaScript standard.
Această aplicație simplă va fi împărțită în trei fișiere separate:
- parser.py: unde este implementat serverul nostru Tornado cu manipulatorii de cereri
- templates/index.html: șablon HTML front-end
- static/parser.js: pentru JavaScript-ul nostru front-end
Deschiderea unui WebSocket
Din front-end, o conexiune WebSocket poate fi stabilită prin instanțierea unui obiect WebSocket:
new WebSocket(WEBSOCKET_URL);
Acesta este ceva ce va trebui să facem la încărcarea paginii. Odată ce un obiect WebSocket este instanțiat, handlerele trebuie atașate pentru a gestiona trei evenimente importante:
- deschis: declanșat când se stabilește o conexiune
- mesaj: declanșat când se primește un mesaj de la server
- close: declanșat când o conexiune este închisă
$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();
Deoarece acești handlere de evenimente nu vor declanșa automat ciclul de viață $scope al AngularJS, conținutul funcției de gestionare trebuie să fie împachetat în $apply. În cazul în care sunteți interesat, există pachete specifice AngularJS care facilitează integrarea WebSocket în aplicațiile AngularJS.
Merită menționat că conexiunile WebSocket abandonate nu sunt restabilite automat și va solicita aplicației să încerce reconectari atunci când este declanșat handlerul de evenimente de închidere. Acest lucru este puțin dincolo de scopul acestui articol.
Selectarea unui fișier de încărcat
Deoarece construim o aplicație cu o singură pagină folosind AngularJS, încercarea de a trimite formulare cu fișiere în modul vechi nu va funcționa. Pentru a ușura lucrurile, vom folosi biblioteca ng-file-upload a lui Danial Farid. Folosind care, tot ce trebuie să facem pentru a permite unui utilizator să încarce un fișier este să adăugăm un buton la șablonul nostru front-end cu directive specifice AngularJS:
<button class="btn btn-default" type="file" ngf-select="uploadFile($file, $invalidFiles)" accept=".tsv" ngf-max-size="10MB">Select File</button>
Biblioteca, printre multe lucruri, ne permite să setăm extensia și dimensiunea de fișier acceptabile. Făcând clic pe acest buton, la fel ca orice element <input type=”file”>
, se va deschide selectorul de fișiere standard.
Încărcarea fișierului
Când doriți să transferați date binare, puteți alege dintre bufferul de matrice și blob. Dacă sunt doar date brute precum un fișier imagine, alegeți blob și gestionați-l corect pe server. Buffer-ul de matrice este pentru buffer-ul binar cu lungime fixă și un fișier text precum TSV poate fi transferat în formatul unui șir de octeți. Acest fragment de cod arată cum să încărcați un fișier în format tampon de 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); } }
Directiva ng-file-upload oferă o funcție uploadFile. Aici puteți transforma fișierul într-un buffer de matrice folosind un FileReader și îl puteți trimite prin WebSocket.
Rețineți că trimiterea de fișiere mari prin WebSocket prin citirea lor în tampoanele matrice poate să nu fie cea mai optimă modalitate de a le încărca, deoarece poate ocupa rapid multă memorie, rezultând o experiență slabă.
Primiți fișierul pe server
Tornado determină tipul mesajului folosind codul operațional pe 4 biți și returnează str pentru date binare și unicode pentru text.
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)
În serverul web Tornado, tamponul de matrice este primit în tipul str.
În acest exemplu, tipul de conținut pe care îl așteptăm este TSV, deci fișierul este analizat și transformat într-un dicționar. Desigur, în aplicațiile reale, există modalități mai sănătoase de a face față încărcărilor arbitrare.
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())
Solicitați o pagină
Deoarece scopul nostru este să afișăm datele TSV încărcate în bucăți de pagini mici, avem nevoie de un mijloc de a solicita o anumită pagină. Pentru a menține lucrurile simple, vom folosi pur și simplu aceeași conexiune WebSocket pentru a trimite numărul paginii către serverul nostru.
$scope.pageChanged = function() { ws = $scope.ws; ws.send($scope.currentPage); }
Serverul va primi acest mesaj ca unicode:
def on_message(self, message): if isinstance(message, unicode): page_no = int(message) self.write_message(self.make_message(page_no))
Încercarea de a răspunde cu un dict de la un server Tornado WebSocket îl va codifica automat în format JSON. Deci, este complet în regulă să trimiți un dict care conține 100 de rânduri de conținut.
Partajarea accesului cu alții
Pentru a putea partaja accesul la aceeași încărcare cu mai mulți utilizatori, trebuie să fim capabili să identificăm în mod unic încărcările. Ori de câte ori un utilizator se conectează la server prin WebSocket, un UUID aleator va fi generat și atribuit conexiunii sale.
def open(self, doc_uuid=None): if doc_uuid is None: self.uuid = str(uuid.uuid4())
uuid.uuid4()
generează un UUID aleator și str() convertește un UUID într-un șir de cifre hexadecimale în formă standard.
Dacă un alt utilizator cu un UUID se conectează la server, instanța corespunzătoare a FileHandler este adăugată la un dicționar cu UUID-ul ca cheie și este eliminată când conexiunea este închisă.
@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]
Dicționarul clienților poate arunca o KeyError atunci când adăugați sau eliminați clienți simultan. Deoarece Tornado este o bibliotecă de rețea asincronă, oferă mecanisme de blocare pentru sincronizare. O blocare simplă cu corutine se potrivește acestui caz de manipulare a dicționarului clienților.
Dacă orice utilizator încarcă un fișier sau se deplasează între pagini, toți utilizatorii cu același UUID văd aceeași pagină.
@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)
Alergând în spatele lui Nginx
Implementarea WebSockets este foarte simplă, dar există câteva lucruri dificile de luat în considerare atunci când îl utilizați în medii de producție. Tornado este un server web, deci poate primi cererile utilizatorilor direct, dar implementarea lui în spatele Nginx poate fi o alegere mai bună din multe motive. Cu toate acestea, este nevoie de puțin mai mult efort pentru a putea folosi WebSockets prin 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"; } } }
Cele două directive proxy_set_header
fac pe Nginx să treacă anteturile necesare către serverele back-end, care sunt necesare pentru actualizarea conexiunii la WebSocket.
Ce urmeaza?
În acest articol, am implementat o aplicație web Python simplă care utilizează WebSockets pentru a menține conexiuni persistente între server și fiecare dintre clienți. Cu cadre moderne de rețea asincrone, cum ar fi Tornado, deținerea simultană a zeci de mii de conexiuni deschise în Python este pe deplin fezabilă.
Deși anumite aspecte de implementare ale acestei aplicații demo ar fi putut fi făcute diferit, sper că a ajutat în continuare la demonstrarea utilizării WebSockets în https://www.toptal.com/tornado framework. Codul sursă al aplicației demo este disponibil pe GitHub.