そろそろWebSocketやってみようと思いました。かたりぃなです。
まずWebSocketを概念レベルで整理してから、技術的な実装をやっていきます。サンプルコードコピペしたい人は後半まで飛んでください。(未来の自分へのメッセージ)
WebSocketの概要
WebSocketはウェブアプリケーションでの双方向通信を実現するためのプロトコルです。
と一言で表現しても抽象的すぎます。このままでは何が美味しいのかわからないので幾つかの方向から分析してみます。
HTTPとの比較
HTTPは基本的にRequest-Responseという1組のメッセージのやり取りで構成されるシンプルなものです。
クライアントからのRequestがシーケンスの起点となっているため、クライアント→サーバのメッセージは任意のタイミングで実行できますが、サーバ→クライアントのメッセージは色々と難しいという問題があります。
クライアントがポーリングするとか、ロングポーリングという解決策もありますが、HTTPのヘッダを毎回送信するのとか、ロングポーリングの時間の問題とかいろいろあったりします。
んで、この問題を解決する方法の1つがWebSocketです。
WebSocket使わずにTCPレベルでやりとりすればいいのでは?
WebSocket以外の解決策を考えた時、実装レイヤを下げてトランスポート層(TCP,UDP)でやるのもテかと思います。
しかしそうしてしまうと面倒な問題が増えてしまいます。すぐ思いつくのは疎通できるかどうかの問題です。
疎通できるかどうかで一番大きなものはルータ越えの問題(NATトラバーサル)とかでしょうか。 昔のPCでやるMMORPGなんかで見かけた「ポートxxを通過できるようルータやファイアウォールを設定してください」とかいうやつですね。
最近だとルータのUPnPを叩いてポートマッピングを自動でやったりとかが多いようですが、Webアプリで双方向通信をするためにそういうのを使うのは大げさすぎます。
なので、そんな大げさなことはせず、せっかくWeb標準としての通信規格として存在しているWebSocketを利用するのが現時点では最良と考えます。
実際のプロトコル
誤解を恐れずにいうなら「HTTP上に構成されるソケット通信プロトコル」といったところでしょうか。
実際のシーケンスとしては
- クライアントはHTTPリクエストに「WebSocket使いたいよ!」というUpgrade要求を含めて、サーバへ要求
- サーバはアップグレード要求に対する応答を返す
- WebSocket通信開始
- クライアント/サーバとも任意のトリガでソケットをread/writeすればいい
これでNATトラバーサルの話はこの時点で既に半分はクリアしていることになります。 HTTP上に構成されるプロトコルなのでWebページを見る場合と何ら変わりません。
少々気を付けるポイントとしてKeepAlive的なところで、WebSocket接続を維持するために一定時間ごとにメッセージを送受信する必要があります。
ただし、いくつかのWebSocket対応ライブラリでは自動で面倒見てくれるようになっています。
今回利用するuwsgiも自動でやりますよと明記されています。
WebSocketを試してみる
実験環境はいつものです。
- ホストOS : Windows10 pro
- 仮想化 : Docker desktop for windows
- Webサーバ : nginx
- プログラミング言語 : python
- Webアプリフレームワーク : flask
- アプリケーションサーバ : uwsgi
システム構成
nginxをリバースプロキシとして、HTTPページとWebSocketページに振り分けます。
HTTPとWebSocketのどちらもWebアプリケーションのフレームワークとしてflaskを使用しますが、実際にはルーティング程度しかやっていません。
flaskをむき出しで運用するのはよくないので、アプリケーションサーバとしてuwsgiを使用することにします。
uwsgiとアプリケーション間のプロトコルは色々選択できるようになっています。今回はHTTPでやっちゃいます。
nginxの設定
ホスト名を見て振り分けたいところですが、ホストOSのhosts設定とか面倒なのでポート番号で振り分けることにしました。
HTTPの場合は何もせずHTTPサーバへ転送します。
user nginx; worker_processes 1; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; access_log /var/log/nginx/access.log main; sendfile on; #tcp_nopush on; keepalive_timeout 65; #gzip on; upstream uwsgi { server uwsgi:3031; } # FlaskによるHTTPサーバ server { listen 8080; charset utf-8; location / { include uwsgi_params; uwsgi_pass uwsgi; } } upstream websocket { server uwsgi-ws:3032; } map $http_upgrade $connection_upgrade { default upgrade; '' close; } # WebSocket server { listen 8081; charset utf-8; location / { proxy_pass http://websocket; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } } }
一個だけポイントがあって、WebSocketホスト側の転送設定でproxy_set_header
しています。
これはnginxをWebSocketのリバースプロキシとして使うとき、upgradeメッセージを適切にバックエンドに渡してあげる必要があるためです。
詳細はnginxのマニュアルを参照。
https://www.nginx.com/blog/websocket-nginx/
UWSGI
uwsgiはアプリケーションサーバです。cgi関連の実装にWSGIというのがありますが、それとは別物です。
初めてuwsgiの名前を知った時、u=μ(マイクロ)だと思って「軽量なwsgiプロトコルかな?」とか思ってしまいましたが、そうではなかったようです。
今回のuwsgiの役割はpythonアプリケーションのホスティングです。
今までflaskむき出しでテストしていましたが、これからはflaskむき出しではなく、サービス化も目指してこいつを使っていこうと思います。(実際、flaskのビルトインサーバは実運用では非推奨なので。)
uwsgi環境を用意するdockerfileはこうなりました。(後述の参考URLの内容ほぼそのままです)
# ベースイメージ FROM python:3.6 RUN mkdir /var/www # workdirの指定 WORKDIR /var/www # 依存Pythonライブラリ一覧コピー COPY requirements.txt ./ # 依存Pythonライブラリインストール RUN pip install --no-cache-dir -r requirements.txt # ログ出力用ディレクトリ RUN mkdir /var/log/uwsgi/ CMD ["uwsgi","--ini","/var/www/uwsgi.ini"]
依存ライブラリを指定するrequirements.txtには
Flask uwsgi
を書いておけばOKです。
UWSGIを構成する
今回は1つのuwsgiで1つのアプリケーションをホスティングします。実質、2つのuwsgiを立てます(http用,websoket用)。
1つのuwsgiで複数のWebアプリを管理する方法がまだわからない&dockerコンテナなら1コンテナ1役割のほうがいいだろうという考えです。
コンテナならこういうのを軽く実現できるから良いですね。
コンテナ起動時にuwsgi.iniを与えて、それに従って動作します。
たとえばwebsocket側のuwsgiの設定はこうなりました
[uwsgi] wsgi-file = /var/www/src/uwsgi_test.py callable = app master = true processes = 1 http=0.0.0.0:3032 vacuum = true die-on-term = true py-autoreload = 1 http-websockets = true log-5xx = true async = 100 ugreen = true
HTTP側はこんな感じです。
[uwsgi] wsgi-file = /var/www/src/run.py callable = app master = true processes = 1 http=0.0.0.0:3031 chmod-socket = 666 vacuum = true die-on-term = true py-autoreload = 1
注意点として、このdockerfileでインストールされるuwsgiはasyncとupgreenパラメータの設定が必要でした。 バージョンごとにasync対応かどうかみたいなのがあるみたいですね。
flask(HTTP)
単純な仕組みなのでコードだけ記載して説明は省略します。 hello worldを返すだけのアプリケーションです。
from flask import Flask app = Flask(__name__) @app.route("/") def hello(): return "Hello World!"
flask(WebSocket)
こちらも単純な仕組みなのでコードだけ記載して説明は省略します。 これで一旦動くとこまで来ましたが、なんか違う気がしていてちょっと自信ないです。
import uwsgi import time import logging from flask import Flask app = Flask(__name__) @app.route("/") def hello(): app.logger.setLevel(logging.INFO) app.logger.info('flask document root accessed') uwsgi.websocket_handshake() while True: msg = uwsgi.websocket_recv() app.logger.info('uwsgi.websocket_recv result = {}'.format(msg) ) uwsgi.websocket_send(msg) app.logger.info('uwsgi.websocket_send finish.') if __name__ == '__main__': app.run(debug=True)
docker-compose
ここまでに作成した3つのコンテナを立てたり落としたりするdocker-composeです。
今回は実験用なので、nginx, uwsgi(websocket), uwsgi(http)の3つのホストすべてを同じネットワークに接続しています。
各サービスでnetworksを記述することで、そこに接続されたホストと通信できるようなネットワーク構成ができます。
version: "2" services: # HTTPのWebアプリケーション uwsgi: build: ./app volumes: - ./app:/var/www/ networks: - python-website ports: - "3031:3031" environment: TZ: "Asia/Tokyo" # WebSocketのWebアプリケーション uwsgi-ws: build: ./appws volumes: - ./appws:/var/www/ networks: - python-website ports: - "3032:3032" environment: TZ: "Asia/Tokyo" # WebFront nginx: build: ./nginx volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf networks: - python-website ports: - "8080:8080" - "8081:8081" environment: TZ: "Asia/Tokyo" networks: python-website:
client
WebSocketのサーバを叩くのはjsからやることにしました。ほとんど書いたことありませんが。
本当は使い慣れた言語(C++とか)から叩きたかったのですが、仕様とか調べるのが面倒だったので。
jsのコードはこちらのページを参考にさせていただきました。
https://qiita.com/hiroykam/items/c3e3d20c223b01d9f0e8
<!DOCTYPE HTML> <html> <head> <script type="text/javascript"> function WebSocketTest() { if ("WebSocket" in window) { alert("WebSocket is supported by your Browser!"); var ws = new WebSocket("ws://localhost:8081/"); ws.onopen = function() { ws.send("Hello from client"); alert("Message is sent..."); }; ws.onmessage = function (evt) { var received_msg = evt.data; alert("Message is received..."); alert(received_msg); }; ws.onclose = function() { alert("Connection is closed..."); }; } else { alert("WebSocket NOT supported by your Browser!"); } } </script> </head> <body> <input type="button" onClick="WebSocketTest();" value="WebSocket Test"> </body> </html>
動作確認
上記htmlを開いてボタンを押せば順にアラートが表示されます。
サーバ側のログを見たい場合はdocker-compose logs
とかdocker log コンテナ名
で確認できます。
websocket側のログにwebsocketで接続受けたとかメッセージ受けたとか残っています。
感想と今後の展望
ひとまずWebSocketの最低限の基盤が整いました。
今後試していきたい内容は
- 具体的なデータ送受信(たとえばバイナリデータや、圧縮された映像、音声データのようなフレーム単位での時系列データなど)
- ポートでHTTP/WebSocket分岐ではなく、flaskのルーティングで分岐したい
- もう一歩進んでWebRTC
などでしょうか。
特にARでの利用を考えたときに物体検出などは時系列データである動画のほうが有利です。(空間方向だけでなく時間方向も推定する)
ただし、動画にしてしまうとエンコード時に多少なりとも情報が落ちる&処理の負荷があがるので、どうするのが最適かとか考えていきたいですね。
(よくある動画フォーマットならデコードせずに周波数空間で物体検出できるのでは?とか思っていますが、コード書くの面倒すぎるのでたぶんやらないと思います。)
サーバースペックとの兼ね合いもあると思うので、
それでは今回はこれくらいで。