goでWebサービス No.9(ウェブソケット)

8 min読了の目安(約7700字TECH技術記事

今回はウェブソケットについてまとめます。ウェブソケットはチャットなどの複数のユーザがインタラクティブにやりとりをするアプリケーションには必要な技術です。クライアントサーバシステムではサーバはクライアントのリクエストに対してレスポンスを返すだけなので他のクライアントが加えた変更に関してはこちらからリクエストを送らない限り知る術はありません。それを解決するのがウェブソケットです。

今回のデータはgithubにあげています。必要なファイルはクローンしてお使いください。

注意

コマンドラインを使った操作が出て来ることがあります。cd, ls, mkdir, touchといった基本的なコマンドを知っていることを前提に進めさせていただきます。
環境の違いで操作や実行結果に差異が出てくる可能性があります。私の実行環境は以下になります。

MacBook Pro (Early 2015)
macOS Catalina ver.10.15.6
go version go1.14.7 darwin/amd64
エディタはVScode

これまでの双方向通信

ウェブソケット(web socket)が登場するまでにも双方向通信を行う技術はありました。ここでは2つ紹介します。

Polling(ポーリング)

これは一方向のウェブで双方向を実現しようと思ったらまず思いつく手段だと思いますが一定間隔で常にリクエストを送り続ける方法です。もちろんこれはオーバーヘッドが大き過ぎてウェブソケットが登場した今良い方法とは言えません。

Comet(コメット)

CometはJavaScriptなどを使いリクエストを送ります。サーバ側はリクエストに対してすぐにレスポンスを返さず何かしらの変更があってからレスポンスを返します。これによって無駄なリクエストを減らすことが出来ます。しかしHTTP通信が持っている無駄は解消されませんでした。

上であげた2つはHTTP通信上で双方向通信を実現する言わばハック的なものであり、双方向通信を行うためには新しい仕組みが必要でした。

Web Socket(ウェブソケット)

効率よく双方向通信を行うために設けられた専用の規格がウェブソケットです。ウェブソケットは双方向通信を行う技術ではなくプロトコルです。
ウェブソケットはまずHTTP通信を使用しハンドシェイクにより通信を確立します。通信を確立した後はプロトコルをウェブソケットに移しヘッダの小さい通信を行います。このプロトコル上ではクライアントからでもサーバからでもデータ送信が出来ます。
元々はHTML5の仕様の一部でしたが後から個別の仕様として切り分けられました。

Goで実装してみる

ここからはGoで実装してもう少し詳しい仕組みをみていきましょう。まずは以下のようなディレクトリ構成でファイルを準備します。

$ tree
.
├── client
│   ├── main.go
│   └── public
│       └── index.html
└── server
    └── main.go

コード

それぞれのファイルに以下のように記述していきましょう。

client/main.go
// code:web9-1
package main

import (
	"log"
	"net/http"
)

func main() {
	port := "3000"
	http.Handle("/", http.StripPrefix("/", http.FileServer(http.Dir("public"))))
	log.Printf("Server listening on http://localhost:%s/", port)
	log.Print(http.ListenAndServe(":"+port, nil))
}

web9-1は単純にnet/httpパッケージを使ってweb9-2のページをホスティングしているだけです。

client/public/index.html
<!-- code:web9-2 -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <script type="text/javascript">
        var sock = null;
        var wsuri = "ws://127.0.0.1:1234";

        window.onload = function() {

            console.log("onload");

            sock = new WebSocket(wsuri);

            sock.onopen = function() {
                console.log("connected to " + wsuri);
            }

            sock.onclose = function(e) {
                console.log("connection closed (" + e.code + ")");
            }

            sock.onmessage = function(e) {
                console.log("message received: " + e.data);
            }
        };

        function send() {
            var msg = document.getElementById('message').value;
            sock.send(msg);
        };
    </script>
    <h1>WebSocket Echo Test</h1>
    <form>
        <p>
            Message: <input id="message" type="text" value="Hello, world!">
        </p>
    </form>
    <button onclick="send();">Send Message</button>
</body>
</html>

web9−2ではGoではなくJavaScriptでクライアント側のウェブソケットを実装しています。sock.onopenで指定されたURIのサーバとソケットを繋ぎます。接続が切断されるとsock.oncloseでウェブソケットが閉じられます。sock.onmessageでサーバからの送信を受け取ります。こちらから送信する場合はsock.send()で送ることが出来ます。
ここではフォームに入力した文字列をサーバに送信しています。

server/main.go
// code:web9-3
package main

import (
	"fmt"
	"log"
	"net/http"
	"time"

	"golang.org/x/net/websocket"
)

func echo(ws *websocket.Conn) {
	var err error

	for {
		var reply string // 受信メッセージ保持

		if err = websocket.Message.Receive(ws, &reply); err != nil {
			fmt.Println("cant recieve")
			break
		}

		fmt.Println("Recieve back from client:", reply)

		msg := "Recieved:" + reply + "at:" + string(time.Now().Format("2006/1/2 15:04:05"))
		fmt.Println("Sending to client:", msg)

		if err = websocket.Message.Send(ws, msg); err != nil {
			fmt.Println("cant send.")
			break
		}

	}
}

func main() {
	http.Handle("/", websocket.Handler(echo))

	if err := http.ListenAndServe(":1234", nil); err != nil {
		log.Fatal("Listen and Serve:", err)
	}
}

サーバ側はGoで実装しています。Goでは標準パッケージがウェブソケットをサポートしていないのでサードパーティのものを使います。今回はオーソドックスなgolang.org/x/net/websocketを使います。ネット上ではgorillaというGo言語用のウェブツールキットを使った例が多いような印象を受けたのでそちらを使用してもいいと思います。
main関数の実装はシンプルでhttp.ListenAndServeで指定ポートでリクエストを受け付けhttp.Handleでリクエストをwebsocket.Handlerに渡します。
リクエストを受け取ったwebsocket.Handlerは処理をecho関数に渡します。
echo関数に具体的な処理が書かれています。echo関数はwebsocket.Handlerからコネクションを受け取ります。コネクションを受け取った時点で接続は確立しているので、echo関数ではどのようなやりとりをするかを記述するだけです。
今回はecho関数の名の通りリクエストで送られてきたメッセージに受信した時間をつけて送り返しています。

実行してみた結果

まずはブラウザ上で最初にどのようなやりとりがあるかを確認しましょう。これはchromeのデベロッパーツールのNetworkで確認することが出来ます。先ほど説明したようにウェブソケットは最初HTTPで通信を確立します。
まずクライアントのリクエストです。ここで重要なのはSec-WebSocket-Keyです。

GET ws://127.0.0.1:1234/ HTTP/1.1
Host: 127.0.0.1:1234
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36
Upgrade: websocket
Origin: http://localhost:3000
Sec-WebSocket-Version: 13
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Sec-WebSocket-Key: L7XSkAaV9mTTJbtCOhsZkg==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

次にサーバからのレスポンスです。ここではクライアントから受け取ったSec-WebSocket-Keyを使ってSec-WebSocket-Acceptを生成します。これをクライアントに送り返すことで通信が確立します。

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: nk/xVRZ0rn2kjrM6nr8O/elGToQ=

ちなみにSec-WebSocket-Acceptの生成方法は公開されていてGoで実装すると以下のようになります。

// code:web9-4
package main

import (
	"crypto/sha1"
	"encoding/base64"
	"fmt"
)

func main() {
	key := "L7XSkAaV9mTTJbtCOhsZkg=="
	magicStr := "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
	key = key + magicStr
	
	sha := sha1.New()
	sha.Write([]byte(key))
	bs := sha.Sum(nil)
	accept := base64.StdEncoding.EncodeToString(bs)
	
	fmt.Println(accept)
}
/* 実行結果
//Sec-WebSocket-Acceptと同じもの
nk/xVRZ0rn2kjrM6nr8O/elGToQ=
*/

通信が確立したらプロトコルがhttpからwsに移ります。これはクライアント側のJavaScriptの実装からも確認できると思います。

var wsuri = "ws://127.0.0.1:1234";

ちなみにhttpsのように暗号化通信にも対応していてその場合はwssになります。

var wsuri = "wss://127.0.0.1:1234";

あとは実装通りメッセージを送るとブラウザコンソールにそのまま返ってきます。

chrome console
onload
(index):20 connected to ws://127.0.0.1:1234
(index):28 Hello, world!
(index):28 

ちなみにalert("hello")と送ってみましたが文字列として判断されアラートウィンドウは表示されませんでした。

実装2

上の実装だとすぐにレスポンスを返しているので双方向通信という感じがしません。そこでクライアントを2つ作ってどちらか片方がメッセージを送ると接続しているもう一方のクライアントにも送られるように実装しなおしてみます。

server/main.go
// code:web9-5
// 接続しているクライアントを保持
var conns list.List

func echo(ws *websocket.Conn) {
    var err error
    // 新規のクライアントならconnに追加
	var conn *list.Element
	contain := false
	for c := conns.Front(); c != nil; c = c.Next() {
		if c.Value == ws {
			contain = true
		}
	}
	if !contain {
		conn = conns.PushBack(ws)
	}
	for {
		var reply string
		clientHost := ws.Config().Origin
        // 接続が途切れたらconnから削除
		if err = websocket.Message.Receive(ws, &reply); err != nil {
			conns.Remove(conn)
			ws.Close()
			fmt.Println("cant recieve", clientHost)
			break
		}
        // 送信するメッセージの生成
		fmt.Println("Recieve back from client:", reply)
		msg := "Recieved:" + reply + "from:" + clientHost.Host + "at:" + string(time.Now().Format("2006/1/2 15:04:05"))
		fmt.Println("Sending to client:", msg)
        // 保持している全てのクライアントに送信
		for c := conns.Front(); c != nil; c = c.Next() {
			if err = websocket.Message.Send(c.Value.(*websocket.Conn), msg); err != nil {
				fmt.Println("cant send.")
				break
			}
			reply = ""
		}
	}
}

ここでは変更した部分のみを載せています。実装は単純で接続したクライアントのコネクションをvar conns list.Listで保持して、送信するときはforループで保持されている全てのコネクションに送信しています。
クライアント側はそれぞれ異なるポートで開いてください。
実行結果はさっきとあまり代わり映えしないので見せるより自分で実行してみた方が感動があると思います。なのでここは割愛させてもらいます。

あとはチャットなど具体的な目的に合わせて実装を変えていけばいいのかなと思います。