Jak stworzyć prosty serwer WebSocket w Pythonie za pomocą Tornado?
Opublikowany: 2022-03-11Wraz ze wzrostem popularności aplikacji internetowych czasu rzeczywistego, WebSockety stały się kluczową technologią w ich wdrażaniu. Dni, w których trzeba było ciągle naciskać przycisk przeładowania, aby otrzymywać aktualizacje z serwera, już dawno minęły. Aplikacje internetowe, które chcą udostępniać aktualizacje w czasie rzeczywistym, nie muszą już odpytywać serwera o zmiany — zamiast tego serwery przesyłają zmiany w miarę ich pojawiania się. Solidne frameworki internetowe zaczęły obsługiwać WebSockets po wyjęciu z pudełka. Na przykład Ruby on Rails 5 poszedł jeszcze dalej i dodał obsługę kabli akcji.
W świecie Pythona istnieje wiele popularnych frameworków internetowych. Frameworki takie jak Django zapewniają prawie wszystko, co jest niezbędne do tworzenia aplikacji internetowych, a wszystko, czego im brakuje, można uzupełnić jedną z tysięcy wtyczek dostępnych dla Django. Jednak ze względu na sposób działania Pythona lub większości jego frameworków internetowych, obsługa długotrwałych połączeń może szybko stać się koszmarem. Model wątkowy i globalna blokada interpretera są często uważane za piętę achillesową Pythona.
Ale wszystko to zaczęło się zmieniać. Dzięki pewnym nowym funkcjom Pythona 3 i frameworkom, które już istnieją dla Pythona, takim jak Tornado, obsługa długotrwałych połączeń nie jest już wyzwaniem. Tornado zapewnia możliwości serwera WWW w Pythonie, które są szczególnie przydatne w obsłudze długotrwałych połączeń.
W tym artykule przyjrzymy się, jak prosty serwer WebSocket można zbudować w Pythonie przy użyciu Tornado. Aplikacja demonstracyjna pozwoli nam wgrać plik z wartościami rozdzielanymi tabulatorami (TSV), przeanalizować go i udostępnić jego zawartość pod unikalnym adresem URL.
Tornado i gniazda sieciowe
Tornado to asynchroniczna biblioteka sieciowa, która specjalizuje się w obsłudze sieci opartej na zdarzeniach. Ponieważ może naturalnie obsługiwać jednocześnie dziesiątki tysięcy otwartych połączeń, serwer może to wykorzystać i obsłużyć wiele połączeń WebSocket w ramach jednego węzła. WebSocket to protokół, który zapewnia pełnodupleksowe kanały komunikacyjne za pośrednictwem pojedynczego połączenia TCP. Ponieważ jest to otwarte gniazdo, technika ta sprawia, że połączenie internetowe jest stanowe i ułatwia przesyłanie danych w czasie rzeczywistym do iz serwera. Serwer, zachowując stany klientów, ułatwia implementację aplikacji chatowych w czasie rzeczywistym lub gier internetowych opartych na WebSockets.
WebSockets są przeznaczone do implementacji w przeglądarkach internetowych i serwerach i są obecnie obsługiwane we wszystkich głównych przeglądarkach internetowych. Połączenie jest otwierane raz, a wiadomości mogą podróżować tam iz powrotem wiele razy, zanim połączenie zostanie zamknięte.
Instalacja Tornado jest dość prosta. Jest wymieniony w PyPI i można go zainstalować za pomocą pip lub easy_install:
pip install tornadoTornado ma własną implementację WebSockets. Na potrzeby tego artykułu to prawie wszystko, czego potrzebujemy.
WebSockets w akcji
Jedną z zalet korzystania z WebSocket jest jego właściwość stanowa. Zmienia to sposób, w jaki zwykle myślimy o komunikacji klient-serwer. Jednym szczególnym przypadkiem użycia tego jest sytuacja, w której serwer jest zobowiązany do wykonywania długich, powolnych procesów i stopniowego przesyłania wyników z powrotem do klienta.
W naszej przykładowej aplikacji użytkownik będzie mógł przesłać plik przez WebSocket. Przez cały okres istnienia połączenia serwer będzie przechowywać przeanalizowany plik w pamięci. Na żądanie serwer może następnie odesłać części pliku do interfejsu użytkownika. Ponadto plik zostanie udostępniony pod adresem URL, który będzie mógł być następnie przeglądany przez wielu użytkowników. Jeśli inny plik zostanie przesłany pod tym samym adresem URL, każdy, kto go przegląda, będzie mógł natychmiast zobaczyć nowy plik.
Dla front-endu użyjemy AngularJS. Ten framework i biblioteki pozwolą nam łatwo obsługiwać przesyłanie plików i paginację. Jednak do wszystkiego, co dotyczy WebSockets, użyjemy standardowych funkcji JavaScript.
Ta prosta aplikacja zostanie podzielona na trzy oddzielne pliki:
- parser.py: gdzie zaimplementowany jest nasz serwer Tornado z obsługą żądań
- templates/index.html: front-endowy szablon HTML
- static/parser.js: dla naszego interfejsu JavaScript
Otwieranie WebSocketa
Z poziomu frontonu połączenie WebSocket można nawiązać, tworząc wystąpienie obiektu WebSocket:
new WebSocket(WEBSOCKET_URL);To jest coś, co będziemy musieli zrobić podczas ładowania strony. Po utworzeniu instancji obiektu WebSocket należy dołączyć programy obsługi, aby obsłużyć trzy ważne zdarzenia:
- otwarte: uruchamiane po nawiązaniu połączenia
- wiadomość: wywoływana po otrzymaniu wiadomości z serwera
- close: uruchamiany, gdy połączenie jest zamknięte
$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();Ponieważ te programy obsługi zdarzeń nie będą automatycznie wyzwalać cyklu życia $scope AngularJS, zawartość funkcji obsługi musi być opakowana w $apply. Jeśli jesteś zainteresowany, istnieją specyficzne pakiety AngularJS, które ułatwiają integrację WebSocket w aplikacjach AngularJS.
Warto wspomnieć, że porzucone połączenia WebSocket nie są automatycznie ponownie ustanawiane i będą wymagały od aplikacji próby ponownego nawiązania połączenia, gdy zostanie wyzwolona procedura obsługi zdarzenia zamknięcia. To trochę wykracza poza zakres tego artykułu.
Wybór pliku do przesłania
Ponieważ budujemy jednostronicową aplikację przy użyciu AngularJS, próby przesyłania formularzy z plikami w stary sposób nie zadziałają. Aby to ułatwić, użyjemy biblioteki ng-file-upload Daniala Farida. Korzystając z tego, wszystko, co musimy zrobić, aby umożliwić użytkownikowi przesłanie pliku, to dodać przycisk do naszego szablonu front-endu z określonymi dyrektywami AngularJS:
<button class="btn btn-default" type="file" ngf-select="uploadFile($file, $invalidFiles)" accept=".tsv" ngf-max-size="10MB">Select File</button> Biblioteka, między innymi, pozwala nam ustawić akceptowalne rozszerzenie i rozmiar pliku. Kliknięcie tego przycisku, podobnie jak każdego elementu <input type=”file”> , otworzy standardowy selektor plików.
Przesyłanie pliku
Jeśli chcesz przesłać dane binarne, możesz wybrać między buforem tablicy i obiektem BLOB. Jeśli są to tylko surowe dane, takie jak plik obrazu, wybierz BLOB i obsługuj go poprawnie na serwerze. Bufor tablicy służy do buforowania binarnego o stałej długości, a plik tekstowy, taki jak TSV, może być przesyłany w formacie ciągu bajtów. Ten fragment kodu pokazuje, jak przesłać plik w formacie buforu tablicy.

$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); } }Dyrektywa ng-file-upload udostępnia funkcję uploadFile. Tutaj możesz przekształcić plik w bufor tablicy za pomocą FileReader i wysłać go przez WebSocket.
Należy pamiętać, że wysyłanie dużych plików przez WebSocket poprzez odczytywanie ich do buforów tablicy może nie być optymalnym sposobem ich przesyłania, ponieważ może szybko zająć zbyt dużo pamięci, co skutkuje słabą obsługą.
Odbierz plik na serwerze
Tornado określa typ wiadomości za pomocą 4-bitowego opcode i zwraca str dla danych binarnych i unicode dla tekstu.
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)W serwerze sieciowym Tornado odbierany jest bufor tablicy typu str.
W tym przykładzie spodziewany typ treści to TSV, więc plik jest analizowany i przekształcany w słownik. Oczywiście w rzeczywistych aplikacjach istnieją rozsądniejsze sposoby radzenia sobie z przypadkowym przesyłaniem.
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())Poproś o stronę
Ponieważ naszym celem jest pokazanie przesłanych danych TSV w kawałkach małych stron, potrzebujemy sposobu na zażądanie konkretnej strony. Aby uprościć sprawę, po prostu użyjemy tego samego połączenia WebSocket do wysłania numeru strony do naszego serwera.
$scope.pageChanged = function() { ws = $scope.ws; ws.send($scope.currentPage); }Serwer otrzyma tę wiadomość jako Unicode:
def on_message(self, message): if isinstance(message, unicode): page_no = int(message) self.write_message(self.make_message(page_no))Próba odpowiedzi za pomocą dyktatu z serwera Tornado WebSocket spowoduje automatyczne zakodowanie go w formacie JSON. Więc jest całkowicie w porządku, aby po prostu wysłać dykt, który zawiera 100 wierszy treści.
Udostępnianie dostępu innym
Aby móc współdzielić dostęp do tego samego pliku z wieloma użytkownikami, musimy być w stanie jednoznacznie zidentyfikować przesłane pliki. Za każdym razem, gdy użytkownik łączy się z serwerem przez WebSocket, losowy identyfikator UUID zostanie wygenerowany i przypisany do jego połączenia.
def open(self, doc_uuid=None): if doc_uuid is None: self.uuid = str(uuid.uuid4()) uuid.uuid4() generuje losowy UUID, a str() konwertuje UUID na ciąg cyfr szesnastkowych w standardowej formie.
Jeśli inny użytkownik z identyfikatorem UUID łączy się z serwerem, odpowiednia instancja FileHandler jest dodawana do słownika z identyfikatorem UUID jako kluczem i jest usuwana po zamknięciu połączenia.
@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]Słownik klientów może zgłosić KeyError podczas jednoczesnego dodawania lub usuwania klientów. Ponieważ Tornado jest asynchroniczną biblioteką sieciową, zapewnia mechanizmy blokujące do synchronizacji. Prosty zamek ze współprogramem pasuje do tego przypadku obsługi słownika klientów.
Jeśli jakikolwiek użytkownik prześle plik lub przeniesie się między stronami, wszyscy użytkownicy z tym samym UUID zobaczą tę samą stronę.
@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)Bieg za Nginx
Implementacja WebSockets jest bardzo prosta, ale podczas używania jej w środowiskach produkcyjnych należy wziąć pod uwagę kilka trudnych rzeczy. Tornado jest serwerem sieciowym, więc może bezpośrednio otrzymywać żądania użytkowników, ale wdrożenie go za Nginx może być lepszym wyborem z wielu powodów. Jednak potrzeba nieco więcej wysiłku, aby móc korzystać z WebSockets za pośrednictwem 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"; } } } Dwie dyrektywy proxy_set_header sprawiają, że Nginx przekazuje niezbędne nagłówki do serwerów zaplecza, które są niezbędne do uaktualnienia połączenia do WebSocket.
Co dalej?
W tym artykule zaimplementowaliśmy prostą aplikację internetową w języku Python, która wykorzystuje WebSockets do utrzymywania trwałych połączeń między serwerem a każdym z klientów. Dzięki nowoczesnym asynchronicznym frameworkom sieciowym, takim jak Tornado, utrzymywanie dziesiątek tysięcy otwartych połączeń jednocześnie w Pythonie jest całkowicie wykonalne.
Chociaż niektóre aspekty implementacji tej aplikacji demonstracyjnej mogły zostać wykonane inaczej, mam nadzieję, że nadal pomogło to zademonstrować użycie WebSockets we frameworku https://www.toptal.com/tornado. Kod źródłowy aplikacji demonstracyjnej jest dostępny na GitHub.
