これはBBSakura アドカレ12日目です。今更ながら、websocketについて気になってたので調べて、なおかつ実装を書く機会があったのでその実装コードを 踏まえて説明したいと思います。 結局めんどくさくなったので実装コードは気になったら見てください()
これが今回書いた実装です
websocketとは?
一言で言うと双方向通信を低コストで行うための仕組みです。RFC6445で標準化されています。 背景としては近年のインタラクションが激しいソフトウェアでは様々な方法でUXを邪魔せず、なおかつそれでソフトウェアの書き手が疲弊しないような仕組みが求められています。 そのために現在はHTTP/2やHTTP/3など様々な通信プロトコルが用意されています。その中でもwebsocketは過去のHTTP1.1のロングポーリングを使った力技な双方向通信実現をやめるための一手となる技術でした。 今回はそんなwebsocketについて説明してきます。
コネクションの成立方法
クライアントからサーバーにリクエストを投げつけてコネクション確立をするところがスタートだったりします。
具体的にはHTTPの通信のinterfaceからupgradeという形を用いてwebsocketプロトコルへの移行をしています。イメージとしてはhttpをやめてL7をwebsocketのプロトコルに切り替える感じです。 以下の画像のようになりますが、まずはそこまでを説明します。
まず、クライアントからリクエストを投げつけます。今回はecho.websocket.orgにリクエストを投げる例を以下に示します。
もし自分で試してみたい場合は https://github.com/takehaya/internet_exercises/blob/master/exec_4/websock/example.py の ws = create_connection("ws://localhost:5001/")
を ws = create_connection("ws://echo.websocket.org")
に書き換えていただいてwiresharkを叩くとパケットが覗けます👀
GET / HTTP/1.1 Upgrade: websocket Connection: Upgrade Host: echo.websocket.org:80 Sec-WebSocket-Version: 13 Sec-WebSocket-Key: nRHLKGL+8fuaCJmJTh+JTA== Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
これは普通にGETメソッドを叩いてるだけなんですが、特徴的な部分としては Upgrade
ヘッダーとConnection
ヘッダー、Sec-WebSocket-*
ヘッダーでしょうか。
何してるか説明していくと、まずこの Upgrade
ヘッダーとConnection
ヘッダーを利用して HTTPから WebSocketへのプロトコルのアップグレードを表現しています。
Sec-WebSocket-Versionは、切り替えるプロトコルのバージョンの指定になります。 もしサーバーが送られてきたバージョンに対応出来なければコネクションを切断すると言う形になります。現在のWebSocketの最新バージョンは13なのでこれを指定します。
ちなみに14とかのinvalidな値にしてみるとBad Requestと怒られが発生します。
GET / HTTP/1.1 Upgrade: websocket Connection: Upgrade Host: echo.websocket.org:80 Sec-WebSocket-Version: 14 Sec-WebSocket-Key: 3qsHtEfHaDKaCDkpiMzoLQ== Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits HTTP/1.1 400 Bad Request Content-Type: text/html Date: Thu, 12 Dec 2019 23:03:59 GMT Server: Kaazing Gateway Content-Length: 63
次に、Sec-Websocket-Keyヘッダーは、任意のクライアントとのコネクション確立の独立性を示すために作ります。値は自分は雑に os.urandom(16)
とかで作りました。
サーバはSec-Websocket-Keyヘッダーに指定された値を大元として新しく値を生成を行いSec-WebSocket-Acceptヘッダにその値を指定してレスポンスを返します。
クライアントとしては自分のSec-Websocket-Keyの値が使われているかどうかが確認出来る様になってます。その為、自分のリクエストに対するレスポンスである事が保証出来ます。
なお自分は実装をサボったので、一応実装方法はわかるぜってことで書いておくと、
Sec-WebSocket-Acceptは
で作られるので、 クライアントで検証するときは 自分で投げつけた鍵をsha1とってそれがマッチするかをcompare_digestとかで見てあげると実装がかけると思います
と、まぁ詰まるところこれらはプロトコルの切り替え要求です。簡単ですね。
レスポンスは以下のようになります。
HTTP/1.1 101 Web Socket Protocol Handshake Connection: Upgrade Date: Thu, 12 Dec 2019 22:41:45 GMT Sec-WebSocket-Accept: 5IuPVeGrbXxYZ6sAQ5Z/YjBVl20= Server: Kaazing Gateway Upgrade: websocket
これは 101 Switching Protocolsが返ってきてるので切り替え準備ができたと言うことがわかりました。
これらのことを WebSocket opening handshake
と呼びます。
どのように双方向通信をしてるか
ハンドシェイクが成功すると次はwebsocketの通信がいよいよできるようになります。 これらはTCP上で動いていて、ハンドシェイクで確立したTCPコネクションを引き続き使いまわして双方向にデータのやり取りをすることになります。
WebSocketは、フレームと呼ばれる単位系でデータの受送信を行います。フレームはあるデータの構造をとっていて具体的には以下のような構造になります。
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-------+-+-------------+-------------------------------+ |F|R|R|R| opcode|M| Payload len | Extended payload length | |I|S|S|S| (4) |A| (7) | (16/64) | |N|V|V|V| |S| | (if payload len==126/127) | | |1|2|3| |K| | | +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + | Extended payload length continued, if payload len == 127 | + - - - - - - - - - - - - - - - +-------------------------------+ | |Masking-key, if MASK set to 1 | +-------------------------------+-------------------------------+ | Masking-key (continued) | Payload Data | +-------------------------------- - - - - - - - - - - - - - - - + : Payload Data continued ... : + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + | Payload Data continued ... | +---------------------------------------------------------------+
これをいくつか説明すると以下のような感じになる。
- Fin:メッセージが終了するか(これが1ではない場合はフラグメントされている場合などがあげれる)
- RSV1~3:予約済み。非定義が飛んできたらWebSocketを失敗させる
opcode
- 0x0:継続フレーム
- 0x1:テキストフレーム
- 0x2:バイナリフレーム
- 0x3〜0x7:追加の データ フレーム用に予約済み
- 0x8:接続close を表す
- 0x9:ping
- 0xA:pong
- 0xB〜0xF:追加の 制御 フレーム用に予約済み
mask: マスクが有効になっているか
- Payload len: ペイロードの長さ
- Masking-key
まぁよくわからないと思うので Hello, World
と言うデータを送った時の一例を見てみると以下のようになります。
このパケットのみで成立するので、Fin:1
、RSVはセマンティクスが何もないので指定されません、テキストデータなので 0x1
でテキストデータのtypeで送っています。
>>> len("Hello, World") 12
確かにペイロードの長さが12となっていますね。 と言うことで非常に簡単な作りで通信を達成しています。
これらはmaskすることができてその場合は以下のようになります。
maskしているときは mask:True
で Masking-key
が追加されています。
他にもping pongと言ったopcodeがありますが、これを利用してサーバー側がビジー状態になってないかなどをみることができます(ただ、個人的にはTCPのコネクションで制御できてしまう範囲で大体事足りるのではと思うんですが)
HTTP/2との考察
詳しい読者はもう理解してると思いますが自分はHTTP/2の違いってなんだろうと思いましたので書きます。
HTTP/2を雑に説明するとHTTP/1.1で動く追加プロトコルで、特出すべきはHTTP Streamという概念が導入されてRFC7540として標準化されています。 これができたモチベーションとしてはAJAXなどの準リアルタイム双方向型の通信への利用などを考えるときにリクエストからレスポンスでひとまとめになるシェイクハンドのオーバーヘッドが無視できない大きさになってきたというものでした。 例えば我々がストリーミングでビデオ再生を行うときに知らず知らず使っていてyoutubeなどをみてるときによく目を凝らしてデベロッパーツールとかを使ってみると利用してるというわけです。
これだけ聞いてるとwebsocketじゃなくてHTTP2でも良いのでは😇と思ってしまいますね。なんならそもそも言い出したのどっちもGoogleですし😇僕もそれでいいと思います。(他に明確な違いを思いつけば教えて欲しいです!)
しかしこれの制御方法等が異なったり、websocketはhttp2よりも簡単な実装になっています。例えばよく知られているのが1つのhttpセッションで複数のストリームを論理的に成立させれますが、websocketの回線はhttpセッションと一対一の構成になります。これをみてhttp2の方がうまくやりくりしてると思いますが、やりたいことはほとんど同じように思います。というわけで自分が思うに同じような問題を同じような方法論でしかし異なるアプローチをした結果のように思います。
しかしながら、RFC8441、Bootstrapping WebSockets with HTTP/2が発行されて、http2でwebsocketを使うと言うものです。 まだまだ進化していて素晴らしいですね。これの個人的に面白いなと思う部分は HTTP GETメソッド でHTTP1.1/Websocketは 101スイッチングプロトコルでupgradeをして切り替わりますが、http2ではクライアントサイドでHTTP CONNECTを要求し、サーバーサイドからバージョンやプロトコル、拡張機能を返してそれに同意する場合status200を返すというフローになっています。これを使うとwebsocketのセッションはhttp2の単一のストリームのみが利用される。つまりHTTP2の利用をしながらWebsocketを利用できて効率的になって嬉しいというのがあります。
まとめ
Websocketはいいぞ!簡単な作りで便利!!
後書き
実はこれは大学で TCPで動くチャットアプリを作れって言われたんですが、正直何も面白い部分がなかったので高校の時にSocket.ioでなんかそういうの作ったなーと思い出したところから雑にWebsocket勉強するついでに実装するか〜ってなったものに説明をつけたものへの技術ネタ供養でした。
どうでもいいんですがSocket.ioはあれただのHTTP GETメソッドを投げてやりとりしてたりでいい感じに内部のライブラリが通信方法を選択してるんですよね。便利。
というわけで既存の実装をかなり参考にしたりしたのですが非常に概念が面白く学ぶところが多かったなと思いました。 残念ながらTLS対応部分は時間の関係で挫折してしまったのですが3日ぐらいでsend receiveはサクッと作れたので非常に簡単な実装ということがわかりました。 次機会があればQUITのクライアントを書きたいなと思います。