Tornadoを使用して単純なPythonWebSocketサーバーを作成する方法
公開: 2022-03-11リアルタイムWebアプリケーションの人気が高まるにつれ、WebSocketはその実装における重要なテクノロジーになりました。 サーバーから更新を受信するために常にリロードボタンを押さなければならなかった時代は過ぎ去りました。 リアルタイムの更新を提供したいWebアプリケーションは、変更についてサーバーをポーリングする必要がなくなりました。代わりに、サーバーは変更が発生したときに変更をストリームにプッシュします。 堅牢なWebフレームワークは、そのままWebSocketのサポートを開始しました。 たとえば、Ruby on Rails 5はそれをさらに発展させ、アクションケーブルのサポートを追加しました。
Pythonの世界には、多くの人気のあるWebフレームワークが存在します。 Djangoなどのフレームワークは、Webアプリケーションの構築に必要なほぼすべてのものを提供し、不足しているものはすべて、Djangoで利用可能な数千のプラグインの1つで構成できます。 ただし、PythonまたはそのほとんどのWebフレームワークの動作方法により、長期間有効な接続の処理はすぐに悪夢になる可能性があります。 スレッドモデルとグローバルインタープリターロックは、Pythonのアキレス腱と見なされることがよくあります。
しかし、そのすべてが変化し始めています。 Python 3の特定の新機能と、TornadoなどのPythonにすでに存在するフレームワークにより、長寿命の接続を処理することはもはや課題ではありません。 Tornadoは、PythonでWebサーバー機能を提供します。これは、長期間の接続の処理に特に役立ちます。
この記事では、Tornadoを使用してPythonで単純なWebSocketサーバーを構築する方法を見ていきます。 デモアプリケーションでは、タブ区切り値(TSV)ファイルをアップロードして解析し、そのコンテンツを一意のURLで利用できるようにします。
竜巻とWebSocket
Tornadoは非同期ネットワークライブラリであり、イベント駆動型ネットワークの処理を専門としています。 当然、数万のオープン接続を同時に保持できるため、サーバーはこれを利用して、単一ノード内で多くのWebSocket接続を処理できます。 WebSocketは、単一のTCP接続を介して全二重通信チャネルを提供するプロトコルです。 オープンソケットであるため、この手法はWeb接続をステートフルにし、サーバーとの間のリアルタイムデータ転送を容易にします。 サーバーは、クライアントの状態を保持し、WebSocketに基づくリアルタイムチャットアプリケーションまたはWebゲームの実装を容易にします。
WebSocketは、Webブラウザーとサーバーに実装されるように設計されており、現在、すべての主要なWebブラウザーでサポートされています。 接続が1回開かれると、接続が閉じられる前にメッセージが複数回前後に移動する可能性があります。
Tornadoのインストールはかなり簡単です。 これはPyPIにリストされており、pipまたはeasy_installを使用してインストールできます。
pip install tornado
Tornadoには、独自のWebSocketの実装が付属しています。 この記事の目的のために、これは私たちが必要とするほとんどすべてです。
動作中のWebSocket
WebSocketを使用する利点の1つは、ステートフルプロパティです。 これにより、クライアント/サーバー通信の通常の考え方が変わります。 この特定のユースケースの1つは、サーバーが長く遅いプロセスを実行し、結果を徐々にクライアントにストリーミングする必要がある場合です。
このサンプルアプリケーションでは、ユーザーはWebSocketを介してファイルをアップロードできます。 接続の存続期間全体にわたって、サーバーは解析されたファイルをメモリ内に保持します。 要求に応じて、サーバーはファイルの一部をフロントエンドに送り返すことができます。 さらに、ファイルは複数のユーザーが表示できるURLで利用できるようになります。 同じURLに別のファイルがアップロードされている場合、そのファイルを見ている人は誰でもすぐに新しいファイルを見ることができます。
フロントエンドには、AngularJSを使用します。 このフレームワークとライブラリにより、ファイルのアップロードとページ付けを簡単に処理できるようになります。 ただし、WebSocketに関連するすべてのものについては、標準のJavaScript関数を使用します。
この単純なアプリケーションは、3つの別々のファイルに分割されます。
- parser.py:リクエストハンドラーを備えたTornadoサーバーが実装されている場所
- templates / index.html:フロントエンドHTMLテンプレート
- static / parser.js:フロントエンドJavaScript用
WebSocketを開く
フロントエンドから、WebSocketオブジェクトをインスタンス化することでWebSocket接続を確立できます。
new WebSocket(WEBSOCKET_URL);
これは、ページの読み込み時に行う必要があることです。 WebSocketオブジェクトがインスタンス化されたら、次の3つの重要なイベントを処理するためにハンドラーをアタッチする必要があります。
- open:接続が確立されたときに発生します
- メッセージ:サーバーからメッセージを受信したときに発生します
- close:接続が閉じられたときに発生します
$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();
これらのイベントハンドラーはAngularJSの$scopeライフサイクルを自動的にトリガーしないため、ハンドラー関数のコンテンツは$applyでラップする必要があります。 興味がある場合は、AngularJSアプリケーションにWebSocketを簡単に統合できるAngularJS固有のパッケージがあります。
ドロップされたWebSocket接続は自動的に再確立されず、closeイベントハンドラーがトリガーされたときにアプリケーションが再接続を試行する必要があることに注意してください。 これは、この記事の範囲を少し超えています。
アップロードするファイルの選択
AngularJSを使用してシングルページアプリケーションを構築しているため、ファイルを含むフォームを送信しようとしても、昔ながらの方法では機能しません。 簡単にするために、DanialFaridのng-file-uploadライブラリを使用します。 これを使用して、ユーザーがファイルをアップロードできるようにするために必要なのは、特定のAngularJSディレクティブを使用してフロントエンドテンプレートにボタンを追加することだけです。
<button class="btn btn-default" type="file" ngf-select="uploadFile($file, $invalidFiles)" accept=".tsv" ngf-max-size="10MB">Select File</button>
ライブラリは、とりわけ、許容可能なファイル拡張子とサイズを設定することを可能にします。 <input type=”file”>
要素と同様に、このボタンをクリックすると、標準のファイルピッカーが開きます。
ファイルのアップロード
バイナリデータを転送する場合は、配列バッファとblobから選択できます。 画像ファイルのような生データの場合は、blobを選択し、サーバーで適切に処理します。 配列バッファは固定長のバイナリバッファ用であり、TSVのようなテキストファイルはバイト文字列の形式で転送できます。 このコードスニペットは、ファイルを配列バッファー形式でアップロードする方法を示しています。
$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); } }
ng-file-uploadディレクティブは、uploadFile関数を提供します。 ここでは、FileReaderを使用してファイルを配列バッファーに変換し、WebSocketを介して送信できます。

大きなファイルを配列バッファーに読み込んでWebSocket経由で送信することは、大量のメモリをすぐに占有してエクスペリエンスを低下させる可能性があるため、アップロードするのに最適な方法ではない可能性があることに注意してください。
サーバーでファイルを受信する
Tornadoは、4ビットのオペコードを使用してメッセージタイプを判別し、バイナリデータの場合はstrを返し、テキストの場合はunicodeを返します。
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)
Tornado Webサーバーでは、配列バッファーはstrのタイプで受信されます。
この例では、予想されるコンテンツのタイプはTSVであるため、ファイルが解析され、辞書に変換されます。 もちろん、実際のアプリケーションでは、任意のアップロードを処理するための賢明な方法があります。
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())
ページをリクエストする
アップロードされたTSVデータを小さなページのチャンクで表示することが目標であるため、特定のページをリクエストする手段が必要です。 簡単にするために、同じWebSocket接続を使用してページ番号をサーバーに送信します。
$scope.pageChanged = function() { ws = $scope.ws; ws.send($scope.currentPage); }
サーバーはこのメッセージをUnicodeとして受信します。
def on_message(self, message): if isinstance(message, unicode): page_no = int(message) self.write_message(self.make_message(page_no))
Tornado WebSocketサーバーからのdictで応答しようとすると、自動的にJSON形式でエンコードされます。 したがって、100行のコンテンツを含むdictを送信するだけで完全に問題ありません。
他の人とアクセスを共有する
同じアップロードへのアクセスを複数のユーザーと共有できるようにするには、アップロードを一意に識別できる必要があります。 ユーザーがWebSocketを介してサーバーに接続するたびに、ランダムなUUIDが生成され、接続に割り当てられます。
def open(self, doc_uuid=None): if doc_uuid is None: self.uuid = str(uuid.uuid4())
uuid.uuid4()
はランダムなUUIDを生成し、str()はUUIDを標準形式の16進数の文字列に変換します。
UUIDを持つ別のユーザーがサーバーに接続すると、FileHandlerの対応するインスタンスが、UUIDをキーとしてディクショナリに追加され、接続が閉じられると削除されます。
@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]
クライアントを同時に追加または削除すると、クライアントディクショナリがKeyErrorをスローする場合があります。 Tornadoは非同期ネットワークライブラリであるため、同期のためのロックメカニズムを提供します。 コルーチンを使用した単純なロックは、クライアント辞書を処理するこのケースに適合します。
いずれかのユーザーがファイルをアップロードしたり、ページ間を移動したりすると、同じUUIDを持つすべてのユーザーが同じページを表示します。
@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)
Nginxの背後で実行
WebSocketの実装は非常に簡単ですが、実稼働環境で使用する場合は注意が必要なことがいくつかあります。 TornadoはWebサーバーであるため、ユーザーの要求を直接取得できますが、多くの理由から、Nginxの背後にデプロイする方が適切な場合があります。 ただし、Nginxを介してWebSocketを使用できるようになるには、これまでになく少し手間がかかります。
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"; } } }
2つのproxy_set_header
ディレクティブにより、NginxはWebSocketへの接続をアップグレードするために必要なヘッダーをバックエンドサーバーに渡します。
次は何ですか?
この記事では、WebSocketを使用してサーバーと各クライアント間の永続的な接続を維持する単純なPythonWebアプリケーションを実装しました。 Tornadoのような最新の非同期ネットワークフレームワークを使用すると、Pythonで数万のオープン接続を同時に保持することが完全に実現可能です。
このデモアプリケーションの特定の実装の側面は別の方法で実行できたかもしれませんが、それでもhttps://www.toptal.com/tornadoフレームワークでのWebSocketの使用法を示すのに役立つことを願っています。 デモアプリケーションのソースコードはGitHubで入手できます。