🙌

WebSocket 通信を使って、クライアントとサーバーで「どやさどやさ」してみる

2022/02/09に公開

WebSocket 通信をパッケージを使わずに

最近 「Real World HTTP 第2版」を読みました。この本は、知識だけではなく、実際に実装してみて学ぶというスタイルになっており、とても素晴らしい本でした。ただ、WebSocket に関しては、「既にあるパッケージを使っての説明」となっておりました。

そこで、WebSocket の仕様が記載されている RFC 6455 を参考に、パッケージを使わずにブラウザとサーバー間での WebSocket 通信を実装しようと思ったのが始まりです。

今回の実装例は、「Real World HTTP」に倣ってGo言語で記載致しますが、実装に関する必要な仕様は随時、説明していくので、他の言語でも大丈夫です。
是非、一緒に実装してみましょう。

完成した実装は GitHub にあげています
https://github.com/fukurose/go-websocket-doyasa

まず初めに

まずは、シンプルなHTMLを返すサーバーを作成してみます。

main.go
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())
}
public/index.html
<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 オブジェクトを使うことによって、簡単にハンドシェイクリクエストを出すことができます。

public/index.html
// ブラウザからハンドシェイクを送る
new WebSocket('ws://localhost:3000/websoket');

上記の例だと、/websocket にアクセスされるため、サーバー側ではそのリクエストを受け取り、ハンドシェイクの中身を見てみます。

main.go
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 でご確認下さい。
https://github.com/fukurose/go-websocket-doyasa/commit/ba45436db2e8b8e545be81c2270da312637d61c7

では、サーバーを立ち上げて、アクセスしてみましょう。
サーバー側には以下のようなリクエストが来ていることが分かります。

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-deflatePer-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

要約すると

  1. ブラウザからきた Sec-Websocket-Key の末尾に258EAFA5-E914-47DA-95CA-C5AB0DC85B11 を付与する
  2. その文字列を元に SHA-1 でハッシュ値を作成する
  3. ハッシュ値を base64 でエンコーディングする
  4. これを Sec-WebSocket-Accept として返す

ということです。言われた通りに実装してみましょう。

main.go
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 を含めて、ブラウザにレスポンスを返して見ます。

main.go
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を少し変更します。

public/index.html
const socket = new WebSocket('ws://localhost:3000/websocket');

// WebSocket 通信が確立した時に発火するイベント
socket.addEventListener('open', event => {
  console.log("upgraded!!");
});

GitHub での diff です。
https://github.com/fukurose/go-websocket-doyasa/commit/a7ce876cb6e4d5af2b48018a93afe5b6c71bace1

それでは、サーバーを立ち上げて、実際に見てみましょう。
コンソール上に 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

....順に説明していきます。

まず、各項目の説明の前に、これらの値を保持する箱を用意します。

frame.go
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|       |

frame.go
// 最初の 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 にて、まとめてくれています)

frame.go
f.fin = (firstByte & 0x80) >> 7

あとは、同じ要領です。

  • RSV1-3

Sec-Websocket-Extensions での拡張を使っている場合、ここにそのフラグが入ります。今回は使っていないですが、一応、取得だけはしておきます。

frame.go
f.rsv1 = (firstByte & 0x40) >> 6
f.rsv2 = (firstByte & 0x20) >> 5
f.rsv3 = (firstByte & 0x10) >> 4
  • opcode

残りの 4bit で実際にやりとりするデータの種別を表します。今回は「どやさ」なので、テキスト(0001)が入ります。他にもバイナリとかコネクションクローズとかがあります。気になる方は RFC 6455 をご確認下さい。

frame.go
// 残りの 4bit が opcode を表すため、シフトは不要
f.opcode = firstByte & 0x0F

では、次の項目です。フレームの図を見ると、次のbyteで含まれるのは、MASKPayload length のようです。

frame.go
//次の byte を読み込む
index += 1
secondByte := int(buffer[index])
  • MASK

一番左の bit はマスクのフラグです。1 なら Payload Data がマスクされている。0 ならされていないとなります。そして、クライアントからサーバーに送るフレームは必ずマスクする ことが決められています。

frame.go
// 取得は FIN と同じ
f.mask = (secondByte & 0x80) >> 7
  • Payload length

残り 7bit が、Payload Data の長さを表します。

frame.go
// 残り 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 の長さを表しています。

frame.go
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 はマスクのキーとなります。これは、マスクされている場合のみ付与されています。

frame.go
if f.mask > 0 {
  f.maskingKey = buffer[index:(index + 4)]
  index += 4
}
  • Payload Data
    やっと、きました!ここに「どやさ」が入っています。取り出してあげましょう。
    ちなみに、Payload Data は Extention Data + Application Data で構成されており、Extention Data は拡張を使っていた場合にデータが入ってきます。今回は使っていないので、 Application Data だけです。さぁ取り出してあげましょう。
frame.go
// データの長さは payloadLength に入っているので、その分を取得する
payload := buffer[index:(index + f.payloadLength)]

ここで忘れてはいけないのが、ブラウザからのデータはマスクされているということです。マスクされている場合はマスクキーとの排他的論理和 (XOR)をする必要があります。

frame.go
if f.mask > 0 {
  for i := 0; i < f.payloadLength; i++ {
    payload[i] ^= f.maskingKey[i%4]
  }
}

f.payloadData = payload

お疲れ様でした。これで、ブラウザからのデータフレームをうまく処理できたはずです。サーバー側で payloadData を表示するように実装を変更します。

main.go
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 です。
https://github.com/fukurose/go-websocket-doyasa/commit/dc66813e5d82adb673ad8163b435b190e2dc1a04#diff-33903057844a2a36f8ef0ff84defe5a68b17aa8e6ad54b956fca91870da404d1

サーバーを立ち上げて、「どやさ」が受け取れるか確認してみてください。

サーバーから「どやさ」を送る

せっかくの双方向です。サーバーからも「どやさ」を送ることにしてみましょう。
やり方は簡単で、受け取った時とは逆のことをすればいいだけです。今回は簡略のために、payloadLength は 126 未満決め打ちとします。

frame.go
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 です
https://github.com/fukurose/go-websocket-doyasa/commit/7941e1f2078e54b3d0377580a04c3ce55ec41452

これで、ブラウザのコンソールに「どやさ」が表示されるはずです。

どやさ

いかがでしたでしょうか。
今回は単純な文字列を送るだけで、validation や close 処理などは省いていますが、個人的には、シンプルなプロトコルだなと思いました。今後も RFC で興味がある仕様は実装してみようかと思います。皆様も是非挑戦してみて下さい。

ただ、その前に、「どやさ」ボタンを作って、サーバーに「どやさ」を送りつけましょう。

お疲れ様でした。

Discussion