WebSocket 少し深掘り
以前websocketを使ったプロダクトと関ったことがキッカケで少し気になったので調べてみた。
websocketの用途等はいろんな記事でわかりやすい説明されているので省略。
大まかな流れは以下の図に示す通りだと思われる。
- クライアントからprotocol切り替え依頼(http GET upgrade)
- サーバ承認(protocol切り替え)
- websocket protocolを使った通信開始
パケットの調査
以下のような単純なサーバとクライアントを実行とキャプチャして内容を確認した。
クライアントはHi!を送り、受信後にサーバはYo!を返す。 これを三回繰り返す。
ほぼこの例文の通り。
双方向のメリットを引き出せてないが一旦無視。
-
server
package main import ( "context" "fmt" "log" "net/http" "time" "nhooyr.io/websocket" ) func main() { wsh := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { c, err := websocket.Accept(w, r, nil) if err != nil { // ... } defer c.Close(websocket.StatusInternalError, "the sky is falling") for { ctx, cancel := context.WithTimeout(context.Background(), time.Minute*10) defer cancel() _, message, err := c.Read(ctx) if err != nil { break } log.Printf("Received %s", message) err = c.Write(ctx, websocket.MessageText, []byte("Yo!")) if err != nil { break } } }) http.Handle("/", wsh) fmt.Println("start ws") log.Fatal(http.ListenAndServe(":8080", nil)) }
-
client
package main import ( "context" "fmt" "time" "nhooyr.io/websocket" "nhooyr.io/websocket/wsjson" ) func main() { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() c, _, err := websocket.Dial(ctx, "ws://localhost:8080", nil) if err != nil { // ... } defer c.Close(websocket.StatusInternalError, "the sky is falling") for i := 0; i < 3; i++ { wsjson.Write(ctx, c, "Hi!") _, b, _ := c.Read(ctx) fmt.Printf("respons is %s \n", string(b)) time.Sleep(time.Second * 2) } c.Close(websocket.StatusNormalClosure, "") }
調査結果
-
キャプチャの結果(wireshark)
-
puml(通信フロー)
-
フレームのフォーマットはこれ参照
https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers#format -
clientから送信するときだけはmaskを使っている。
maskとはpayloadを適当な乱数(key)でxor演算すること言うらしい。
https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers#exchanging_data_frames
clientから送る時はなぜmaskするのか気になった。(何のためにやっているのかは不明)
図にすると以下のようなことをしていると思われる。
-
messageを送るときはopcode 1 (text)を使っている。(textとはUTF-8でエンコードされたもの)
-
payload(送りたいデータの実体)の長さは126以上あると違う法則が適応される。
https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers#decoding_payload_length -
FINのbitを立てるとメッセージのラストであるとこを意味し、opcode=Continuationで今まで送ったメッセージを組み立てるように連絡する。
https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers#message_fragmentation
上記のgoの実装だと以下のようにmessageを送った後に空メッセージのFIN,Continuationを送っている。
Client: FIN=0, opcode=0x1, msg="Hi!"
Client: FIN=1, opcode=0x0, msg=""
-
websocketハンドシェイク時のclient:
Sec-WebSocket-Key
とserver:Sec-WebSocket-Accept
の関係
以下に詳しく書いてあるがクライアントが送ったキーSec-WebSocket-Key
を取得したサーバは
sha-1-hash(Sec-WebSocket-Key,"258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
してbase64でエンコードした値をSec-WebSocket-Accept
に入れて返す。
そしてクライアントは自分が送ったキーと一致するかどうかを検証する。(セキュリティ?)
という構図らしい。ここにわかりやすく書いてあった。
https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers#server_handshake_response
サーバ側の実装。
https://github.com/nhooyr/websocket/blob/v1.8.7/accept.go#L364
クライアント側の実装
https://github.com/nhooyr/websocket/blob/v1.8.7/dial.go#L205
若干なんで"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
なのか気になった。 -
データ量
サーバーがYo!を送信することに焦点を当てて比較。
以下のように、httpの方はYo!をGetのレスポンスとして返すのに185byte使っているが、websocketは73byteで送れている。(ただwebsocketコネクション時に200byteぐらい使うがそれは無視)- http response
- websocket
このことからwebsoketは細かいデータを頻繁に送受信するような構成に向いていることが予想できる。
- http response
興味がある部分だけ実装してみる
Websocket クライアント側のみ実装
- Yo! を送信するだけ
https://github.com/ryutaro-asada/go-net-test/blob/main/cmd/ws_test/net-websocket/main.go
その他
-
golangのwebsocketパッケージで最近は
nhooyr.io
が使われているらしい。
https://pkg.go.dev/nhooyr.io/websocket#section-readme
https://pkg.go.dev/golang.org/x/net/websocket#pkg-overview -
知名度の高いGorillaは去年末からメンテされなくなった?
https://thenewstack.io/gorilla-toolkit-open-source-project-becomes-abandonware/ -
kubernetesでも
nhooyr.io
になっている?
https://github.com/kubernetes/kubernetes/issues/114836
Discussion