WebSocket 通信を使って、クライアントとサーバーで「どやさどやさ」してみる
WebSocket 通信をパッケージを使わずに
最近 「Real World HTTP 第2版」を読みました。この本は、知識だけではなく、実際に実装してみて学ぶというスタイルになっており、とても素晴らしい本でした。ただ、WebSocket に関しては、「既にあるパッケージを使っての説明」となっておりました。
そこで、WebSocket の仕様が記載されている RFC 6455 を参考に、パッケージを使わずにブラウザとサーバー間での WebSocket 通信を実装しようと思ったのが始まりです。
今回の実装例は、「Real World HTTP」に倣ってGo言語で記載致しますが、実装に関する必要な仕様は随時、説明していくので、他の言語でも大丈夫です。
是非、一緒に実装してみましょう。
完成した実装は GitHub にあげています
まず初めに
まずは、シンプルなHTMLを返すサーバーを作成してみます。
package main
import (
"log"
"net/http"
)
func main() {
var httpServer http.Server
http.Handle("/", http.FileServer(http.Dir("public")))
log.Println("start http listening :3000")
httpServer.Addr = ":3000"
log.Println(httpServer.ListenAndServe())
}
<html>
<head>
<title>Web Sokect DOYASA</title>
</head>
<body>
<h1>WebSocket Test Client</h1>
</body>
</html>
サーバーを起動して http://localhost:3000 にアクセスし、HTMLが正常に表示されればOKです。
ブラウザからハンドシェイク
それでは、実際にブラウザとサーバー間で WebSocket 通信を確立させましょう。
まず、ブラウザからサーバーにハンドシェイクします。
RFCによると、クライアントからのハンドシェイク形式は以下の通りです。
The handshake from the client looks as follows:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
抜粋: https://datatracker.ietf.org/doc/html/rfc6455#section-1.2
形式はHTTPと同じですね。見慣れない項目を補足すると
-
Upgrade: websocket
Connection: Upgrade
WebSoket 通信しようと伝えるためのものです。 -
Sec-WebSocket-Key
後で認証のために使います。 -
Sec-WebSocket-Protocol
WebSoket 通信上で送受信されるデータの取り扱いプロトコルを決めるものです。JSON-RPC とかも指定できるみたいですが、今回は使いません。省略可能な項目です。 -
Sec-WebSocket-Version
WebSocket のバージョンです。2022/02時点では13
が最新です。
それでは、実際にブラウザからサーバーに対して、ハンドシェイクをしてみましょう。ブラウザからはWebSocket オブジェクトを使うことによって、簡単にハンドシェイクリクエストを出すことができます。
// ブラウザからハンドシェイクを送る
new WebSocket('ws://localhost:3000/websoket');
上記の例だと、/websocket
にアクセスされるため、サーバー側ではそのリクエストを受け取り、ハンドシェイクの中身を見てみます。
func handlerWebSocket(w http.ResponseWriter, r *http.Request) {
dump, err := httputil.DumpRequest(r, true)
if err != nil {
http.Error(w, fmt.Sprint(err), http.StatusInternalServerError)
return
}
fmt.Println(string(dump))
}
詳細な変更点は GitHub でご確認下さい。
では、サーバーを立ち上げて、アクセスしてみましょう。
サーバー側には以下のようなリクエストが来ていることが分かります。
GET /websocket HTTP/1.1
Host: localhost:3000
Connection: Upgrade
Origin: http://localhost:3000
Sec-Websocket-Extensions: permessage-deflate; client_max_window_bits
Sec-Websocket-Key: dGdc5t123DuO4PkkB/eSUA==
Sec-Websocket-Version: 13
Upgrade: websocket
(User-Agent などの不要なヘッダーは削除しています)
概ね、想定通りのリクエストです。
見慣れない Sec-Websocket-Extensions
は拡張を表します。 permessage-deflate
は Per-Message Compression Extensions
のことで、メッセージ圧縮の拡張らしいです。ブラウザはそれが使えるよとサーバーに伝えています。今回は使わないので、無視します。
リクエストは送れましたが、ブラウザのコンソールには
WebSocket connection to 'ws://localhost:3000/websocket' failed:
が表示されます。ブラウザから握手をしようとしたが、サーバーが無視した状態ですね。
それでは、サーバーからも手を差し伸べましょう。
サーバーからハンドシェイク
サーバーからのハンドシェイクは以下の形式となっています。
The handshake from the server looks as follows:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
抜粋: https://datatracker.ietf.org/doc/html/rfc6455#section-1.2
上述した通り Sec-WebSocket-Protocol
は使わないので、必要なのは Sec-WebSocket-Accept
だけです。
その Sec-WebSocket-Accept
は、RFC 6455 には以下のように書いてあります。
concatenate this with the Globally Unique Identifier (GUID, [RFC4122]) "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" in string form, which is unlikely to be used by network endpoints that do not understand the WebSocket Protocol. A SHA-1 hash (160 bits) [FIPS.180-3], base64-encoded (see Section 4 of [RFC4648]), of this concatenation is then returned in the server's handshake.
抜粋: https://datatracker.ietf.org/doc/html/rfc6455#section-1.3
要約すると
- ブラウザからきた
Sec-Websocket-Key
の末尾に258EAFA5-E914-47DA-95CA-C5AB0DC85B11
を付与する - その文字列を元に
SHA-1
でハッシュ値を作成する - ハッシュ値を
base64
でエンコーディングする - これを
Sec-WebSocket-Accept
として返す
ということです。言われた通りに実装してみましょう。
func buildAcceptKey(key string) string {
h := sha1.New()
h.Write([]byte(key))
h.Write([]byte("258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
それでは、Sec-WebSocket-Accept
を含めて、ブラウザにレスポンスを返して見ます。
func handlerUpdrade(w http.ResponseWriter, r *http.Request) {
// ここからのやりとりは、HTTP protocol ではなくなるので、 Hijacker を使う
hijacker := w.(http.Hijacker)
conn, readWriter, err := hijacker.Hijack()
if err != nil {
panic(err)
}
defer conn.Close()
// ブラウザからのキーを元にして、レスポンス用のキーを作成
key := r.Header.Get("Sec-Websocket-Key")
acceptKey := buildAcceptKey(key)
readWriter.WriteString("HTTP/1.1 101 Switching Protocols\r\n")
readWriter.WriteString("Upgrade: websocket\r\n")
readWriter.WriteString("Connection: Upgrade\r\n")
readWriter.WriteString("Sec-WebSocket-Accept: " + acceptKey + "\r\n")
readWriter.WriteString("\r\n") // 空白行でステータスラインの終わりを示す
readWriter.Flush()
}
できました!
接続できているかの確認のために、JSを少し変更します。
const socket = new WebSocket('ws://localhost:3000/websocket');
// WebSocket 通信が確立した時に発火するイベント
socket.addEventListener('open', event => {
console.log("upgraded!!");
});
GitHub での diff です。
それでは、サーバーを立ち上げて、実際に見てみましょう。
コンソール上に upgraded!!
と表示されれば、成功です!
ブラウザから「どやさ」を送る
WebSocket 通信が確立しましたので、実際にメッセージのやり取りをしてみることにします。
メッセージはバイナリ形式のデータフレームでやり取りされます。もちろん、このフレーム形式も RFC 6455 に記載されています。
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 ... |
+---------------------------------------------------------------+
抜粋: https://datatracker.ietf.org/doc/html/rfc6455#section-5.2
....順に説明していきます。
まず、各項目の説明の前に、これらの値を保持する箱を用意します。
type Frame struct {
fin int
rsv1 int
rsv2 int
rsv3 int
opcode int
mask int
payloadLength int
maskingKey []byte
payloadData []byte
}
先に Payload Data
ですが、ここには、やり取りする実際のデータが入ります。今回で言えば「どやさ」です。詳細は後述致します。
まず、データの取得は、オクテットストリーム( byte の配列)となりますので、その byte 配列から最初の 1byte を取得してみましょう。1byte = 8bit なので、フレーム形式でいうところの、左から 0 ~ 7
のデータが取得できます。つまり、FIN
から opcode
です。
0 1 2 3 4 5 6 7
+-+-+-+-+-------+
|F|R|R|R| opcode|
|I|S|S|S| (4) |
|N|V|V|V| |
| |1|2|3| |
// 最初の byte を読み込む
index := 0
firstByte := int(buffer[index])
それでは、各項目説明していきます。
- FIN
これは final fragment のフラグで、1 なら終わり、0 なら後続があること示します。FIN は一番左のbitのため、 1000 0000 (0x80)
との論理積をとり、右に7シフトして取得しています。
(16進数、2進数の変換表は https://qiita.com/inabe49/items/805c2d2bcd9e70c37ef6 にて、まとめてくれています)
f.fin = (firstByte & 0x80) >> 7
あとは、同じ要領です。
- RSV1-3
Sec-Websocket-Extensions
での拡張を使っている場合、ここにそのフラグが入ります。今回は使っていないですが、一応、取得だけはしておきます。
f.rsv1 = (firstByte & 0x40) >> 6
f.rsv2 = (firstByte & 0x20) >> 5
f.rsv3 = (firstByte & 0x10) >> 4
- opcode
残りの 4bit で実際にやりとりするデータの種別を表します。今回は「どやさ」なので、テキスト(0001
)が入ります。他にもバイナリとかコネクションクローズとかがあります。気になる方は RFC 6455 をご確認下さい。
// 残りの 4bit が opcode を表すため、シフトは不要
f.opcode = firstByte & 0x0F
では、次の項目です。フレームの図を見ると、次のbyteで含まれるのは、MASK
と Payload length
のようです。
//次の byte を読み込む
index += 1
secondByte := int(buffer[index])
- MASK
一番左の bit はマスクのフラグです。1 なら Payload Data
がマスクされている。0 ならされていないとなります。そして、クライアントからサーバーに送るフレームは必ずマスクする ことが決められています。
// 取得は FIN と同じ
f.mask = (secondByte & 0x80) >> 7
- Payload length
残り 7bit が、Payload Data の長さを表します。
// 残り 7bit が長さを表すため、シフトは不要
f.payloadLength = secondByte & 0x7F
ただ、この Payload length は少し曲者です。7bit で指定できる最大値は 127 となります。これだと、127 より長いデータは送れなくなるので、以下の条件が加えられています。
先ほど取得した Payload length が
126 の場合は、「次の 2byte が UInt16 として 本当の Payload length となる」
127 の場合は、「次の 8byte が UInt64 として 本当の Payload length となる」
です。
つまり 126 と 127 は予約番号として、その数値の場合は、次の byte 以降で Payload の長さを表しています。
if f.payloadLength == 126 {
// 長さが126の場合は、次の 2byte が UInt16 として 本当の Payload length となる
length := binary.BigEndian.Uint16(buffer[index:(index + 2)])
f.payloadLength = int(length)
index += 2
} else if f.payloadLength == 127 {
// 長さが 127 の場合は、次の 8byte が UInt64 として 本当の Payload length となる
length := binary.BigEndian.Uint64(buffer[index:(index + 8)])
f.payloadLength = int(length)
index += 8
}
- Masking key
次の 4byte はマスクのキーとなります。これは、マスクされている場合のみ付与されています。
if f.mask > 0 {
f.maskingKey = buffer[index:(index + 4)]
index += 4
}
- Payload Data
やっと、きました!ここに「どやさ」が入っています。取り出してあげましょう。
ちなみに、Payload Data はExtention Data
+Application Data
で構成されており、Extention Data
は拡張を使っていた場合にデータが入ってきます。今回は使っていないので、Application Data
だけです。さぁ取り出してあげましょう。
// データの長さは payloadLength に入っているので、その分を取得する
payload := buffer[index:(index + f.payloadLength)]
ここで忘れてはいけないのが、ブラウザからのデータはマスクされているということです。マスクされている場合はマスクキーとの排他的論理和 (XOR)をする必要があります。
if f.mask > 0 {
for i := 0; i < f.payloadLength; i++ {
payload[i] ^= f.maskingKey[i%4]
}
}
f.payloadData = payload
お疲れ様でした。これで、ブラウザからのデータフレームをうまく処理できたはずです。サーバー側で payloadData を表示するように実装を変更します。
data := make([]byte, bufferSize)
for {
// ブラウザからデータフレームを受け取る
n, err := readWriter.Read(data)
if err != nil {
panic(err)
}
frame := Frame{}
frame.parse(data[:n])
fmt.Println(string(frame.payloadData))
}
また、ブラウザ側では WebSoket 通信が開始した時に、「どやさ」を送るようにします。
socket.addEventListener('open', function (event) {
socket.send('どやさ');
});
diff です。
サーバーを立ち上げて、「どやさ」が受け取れるか確認してみてください。
サーバーから「どやさ」を送る
せっかくの双方向です。サーバーからも「どやさ」を送ることにしてみましょう。
やり方は簡単で、受け取った時とは逆のことをすればいいだけです。今回は簡略のために、payloadLength
は 126 未満決め打ちとします。
func (f *Frame) toBytes() (data []byte) {
bits := 0
bits |= (f.fin << 7)
bits |= (f.rsv1 << 6)
bits |= (f.rsv2 << 5)
bits |= (f.rsv3 << 4)
bits |= f.opcode
// first byte を追加
data = append(data, byte(bits))
bits = 0
bits |= (f.mask << 7)
bits |= f.payloadLength // 長さは 126 未満と仮定
// second byte を追加
data = append(data, byte(bits))
// 実際のデータを追加
data = append(data, f.payloadData...)
return data
}
これをブラウザに送ります。
sendFrame := buildFrame("どやさ")
readWriter.Write(sendFrame.toBytes())
readWriter.Flush()
ブラウザでは受け取ると、messageイベントが発火されます。内容をコンソールに表示してみましょう。
// サーバーからメッセージを受信した時に発火するイベント
socket.addEventListener('message', event => {
console.log(event.data);
});
diff です
これで、ブラウザのコンソールに「どやさ」が表示されるはずです。
どやさ
いかがでしたでしょうか。
今回は単純な文字列を送るだけで、validation や close 処理などは省いていますが、個人的には、シンプルなプロトコルだなと思いました。今後も RFC で興味がある仕様は実装してみようかと思います。皆様も是非挑戦してみて下さい。
ただ、その前に、「どやさ」ボタンを作って、サーバーに「どやさ」を送りつけましょう。
お疲れ様でした。
Discussion