Go 言語で Websocket を使ったアプリケーションをつくる

# はじめに
* Go 言語の Websocket フレームワーク `github.com/trevex/golem` を使って Websocket 通信をするアプリケーションをつくってみる

# Websocket と golem について
* Go 言語の Websocket ライブラリには `golang.org/x/net/websocket` や `github.com/gorilla/websocket` などがある
* `github.com/trevex/golem` は軽量な Websocket フレームワーク
* `github.com/gorilla/websocket` とかをそのまま使ってもいいけど `golem` には以下のような機能があって手軽に Websocket 通信するアプリケーションが作れる
  * イベントと関数のルーティング
  * JSON エンコード・デコード
  * ルーム機能
  * 接続型の拡張

# エコーサーバーをつくる
* `イベントと関数のルーティング` と `JSON エンコード・デコード` の実例を見るため、送信した内容がそのまま返信されるエコーサーバーを作ってみる

## メインファイルの作成
* 以下の内容で `echo.go` を作成
* ルータの使い方ルーティングの設定をして `http.HadleFunc` 登録するだけ
* `golem.Router` の `On` メソッドでイベント名と関数のルーティングが定義される
* `On("echo", echo)` と定義されているとき `echo {"msg":"hello world"}` というメッセージを受け取ると、メッセージを `{イベント名} {JSON文字列}` と判定してイベント名に対応する関数 `echo(conn *golem.Connection, data *echoMessage)` の引数から JSON エンコードする型は `*echoMessage` だと判断しエンコードした値を引数として関数を実行する

```go
package main

import (
	"github.com/trevex/golem"
	"net/http"
	"log"
)

// HTTP サーバー起動
func main() {
	http.HandleFunc("/ws", createRouter().Handler())
	log.Fatal(http.ListenAndServe(":8080", nil))
}

// ルーターの作成とルーティング設定
func createRouter() *golem.Router {
	router := golem.NewRouter()
	router.On("echo", echo)
	return router
}

// echo イベントでハンドリングされる関数
// 受け取ったメッセージをそのまま返す
func echo(conn *golem.Connection, data *echoMessage) {
	conn.Emit("echo", data)
}

// メッセージクラス
type echoMessage struct {
	Msg string `json:"msg"`
}
```

## テストファイルの作成
* 以下の内容で `echo_test.go` を作成

```go
package main

import (
	"errors"
	"github.com/gorilla/websocket"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"
)

func TestValidCase(t *testing.T) {
	// サーバーの作成
	ts := httptest.NewServer(http.HandlerFunc(createRouter().Handler()))
	defer ts.Close()

	// クライアントの作成
	client1, err := createClient(ts)
	if err != nil {
		t.Fatal(err)
	}
	defer client1.Close()

	// メッセージの送信
	err = writeMessage(client1, `echo {"msg":"hello world"}`)
	if err != nil {
		t.Fatal(err)
	}

	// メッセージの受信
	res, err := readMessage(client1)
	if err != nil {
		t.Error(err)
	}
	// 送信したメッセージが返ってくる
	if res != `echo {"msg":"hello world"}` {
		t.Error("response is not valid: " + res)
	}
}

func createClient(ts *httptest.Server) (*websocket.Conn, error) {
	dialer := websocket.Dialer{
		Subprotocols:    []string{},
		ReadBufferSize:  1024,
		WriteBufferSize: 1024,
	}

	url := strings.Replace(ts.URL, "http://", "ws://", 1)
	header := http.Header{"Accept-Encoding": []string{"gzip"}}

	conn, _, err := dialer.Dial(url, header)
	if err != nil {
		return nil, err
	}

	return conn, nil
}

func writeMessage(conn *websocket.Conn, message string) error {
	return conn.WriteMessage(websocket.TextMessage, []byte(message))
}

func readMessage(conn *websocket.Conn) (string, error) {
	conn.SetReadDeadline(time.Now().Add(1 * time.Second))
	messageType, p, err := conn.ReadMessage()
	if err != nil {
		return "", err
	}
	if messageType != websocket.TextMessage {
		return "", errors.New("invalid message type")
	}
	return string(p), nil
}
```

# チャットサーバーをつくる
* `ルーム機能` を実例で確認するためチャットサーバーを作ってみる

## メインファイルの作成
* `chat.go` として作成
* `golem.RoomManager` の `Join` メソッドでチャットルームに入り `Emit` メソッドでチャットルームに参加している全員にメッセージを送信している

```go
package main

import (
	"github.com/trevex/golem"
	"net/http"
	"log"
)

var room_manager = golem.NewRoomManager()

// HTTP サーバー起動
func main() {
	http.HandleFunc("/ws", createRouter().Handler())
	log.Fatal(http.ListenAndServe(":8080", nil))
}

// ルーターの作成とルーティング設定
func createRouter() *golem.Router {
	router := golem.NewRouter()
	router.On("join", join)
	router.On("say", say)
	return router
}

func join(conn *golem.Connection, data *joinRequest) {
	room_manager.Join(data.Name, conn)
}

type joinRequest struct {
	Name string `json:"name"`
}

func say(conn *golem.Connection, data *sayRequest) {
	room_manager.Emit(data.Name, "say", &sayResponse{Msg: data.Msg})
}

type sayRequest struct {
	Name string `json:"name"`
	Msg  string `json:"msg"`
}

type sayResponse struct {
	Msg string `json:"msg"`
}
```

## テストファイルの作成
* `chat_test.go` として作成

```go
package main

import (
	"errors"
	"github.com/gorilla/websocket"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"
)

func TestValidCase(t *testing.T) {
	// サーバーの作成
	ts := httptest.NewServer(http.HandlerFunc(createRouter().Handler()))
	defer ts.Close()

	// クライアント1の作成
	client1, err := createClient(ts)
	if err != nil {
		t.Fatal(err)
	}
	defer client1.Close()

	// クライアント2の作成
	client2, err := createClient(ts)
	if err != nil {
		t.Fatal(err)
	}
	defer client2.Close()

	// クライアント1がルームに参加
	err = writeMessage(client1, `join {"name":"room1"}`)
	if err != nil {
		t.Fatal(err)
	}

	// クライアント2がルームに参加
	err = writeMessage(client2, `join {"name":"room1"}`)
	if err != nil {
		t.Fatal(err)
	}

	// クライアント1が発言
	err = writeMessage(client1, `say {"name":"room1","msg":"hello room1"}`)
	if err != nil {
		t.Fatal(err)
	}

	// クライアント1が発言を受け取る
	res, err := readMessage(client1)
	if err != nil {
		t.Error(err)
	}
	if res != `say {"msg":"hello room1"}` {
		t.Error("response is not valid: " + res)
	}

	// 同じルームのクライアント2も発言を受け取る
	res, err = readMessage(client2)
	if err != nil {
		t.Error(err)
	}
	if res != `say {"msg":"hello room1"}` {
		t.Error("response is not valid: " + res)
	}
}

func createClient(ts *httptest.Server) (*websocket.Conn, error) {
	dialer := websocket.Dialer{
		Subprotocols:    []string{},
		ReadBufferSize:  1024,
		WriteBufferSize: 1024,
	}

	url := strings.Replace(ts.URL, "http://", "ws://", 1)
	header := http.Header{"Accept-Encoding": []string{"gzip"}}

	conn, _, err := dialer.Dial(url, header)
	if err != nil {
		return nil, err
	}

	return conn, nil
}

func writeMessage(conn *websocket.Conn, message string) error {
	return conn.WriteMessage(websocket.TextMessage, []byte(message))
}

func readMessage(conn *websocket.Conn) (string, error) {
	conn.SetReadDeadline(time.Now().Add(1 * time.Second))
	messageType, p, err := conn.ReadMessage()
	if err != nil {
		return "", err
	}
	if messageType != websocket.TextMessage {
		return "", errors.New("invalid message type")
	}
	return string(p), nil
}
```

# チャットサーバーを拡張する
* チャットサーバーにおいてクライアントから `say` を送信する際に毎回チャットルーム名を指定しなければいけないのが冗長だと感じたためデフォルトのルーム名を指定できるように変更する
* この場合 `golem.Connection` をメンバーとする新しい型を作成して `golem.Connection` の代わりに利用することができる

## メインファイルの作成
* `chat_extension.go` の作成
* 以下のように `golem.Connection` をメンバーに持つ型とコンストラクタ関数を作成し `golem.Router.SetConnectionExtension` で指定すると `conn` として `say` などのイベントハンドラー関数から利用することができる
* その際イベントハンドラー関数側の型も変更する必要がある
* 今回は `join` で `conn.Name` にルーム名をセットするようにしたことで `say` でルーム名を指定せずにメッセージを送信することができている

```go
package main

import (
	"github.com/trevex/golem"
	"net/http"
	"log"
)

var room_manager = golem.NewRoomManager()

// HTTP サーバー起動
func main() {
	http.HandleFunc("/ws", createRouter().Handler())
	log.Fatal(http.ListenAndServe(":8080", nil))
}

// ルーターの作成とルーティング設定
func createRouter() *golem.Router {
	router := golem.NewRouter()
	router.SetConnectionExtension(NewConnection)
	router.On("join", join)
	router.On("say", say)
	return router
}

type Connection struct {
	*golem.Connection
	Name string
}

func NewConnection(conn *golem.Connection) *Connection {
	return &Connection{Connection: conn}
}

func join(conn *Connection, data *joinRequest) {
	conn.Name = data.Name
	room_manager.Join(data.Name, conn.Connection)
}

type joinRequest struct {
	Name string `json:"name"`
}

func say(conn *Connection, data *sayRequest) {
	room_manager.Emit(conn.Name, "say", &sayResponse{Msg: data.Msg})
}

type sayRequest struct {
	Msg  string `json:"msg"`
}

type sayResponse struct {
	Msg string `json:"msg"`
}
```

## テストファイルの作成
* `chat_extension_test.go` の作成

```go
package main

import (
	"errors"
	"github.com/gorilla/websocket"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"
)

func TestValidCase(t *testing.T) {
	// サーバーの作成
	ts := httptest.NewServer(http.HandlerFunc(createRouter().Handler()))
	defer ts.Close()

	// クライアント1の作成
	client1, err := createClient(ts)
	if err != nil {
		t.Fatal(err)
	}
	defer client1.Close()

	// クライアント2の作成
	client2, err := createClient(ts)
	if err != nil {
		t.Fatal(err)
	}
	defer client2.Close()

	// クライアント1がルームに参加
	err = writeMessage(client1, `join {"name":"room1"}`)
	if err != nil {
		t.Fatal(err)
	}

	// クライアント2がルームに参加
	err = writeMessage(client2, `join {"name":"room1"}`)
	if err != nil {
		t.Fatal(err)
	}

	// クライアント1が発言
	err = writeMessage(client1, `say {"msg":"hello room1"}`)
	if err != nil {
		t.Fatal(err)
	}

	// クライアント1が発言を受け取る
	res, err := readMessage(client1)
	if err != nil {
		t.Error(err)
	}
	if res != `say {"msg":"hello room1"}` {
		t.Error("response is not valid: " + res)
	}

	// 同じルームのクライアント2も発言を受け取る
	res, err = readMessage(client2)
	if err != nil {
		t.Error(err)
	}
	if res != `say {"msg":"hello room1"}` {
		t.Error("response is not valid: " + res)
	}
}

func createClient(ts *httptest.Server) (*websocket.Conn, error) {
	dialer := websocket.Dialer{
		Subprotocols:    []string{},
		ReadBufferSize:  1024,
		WriteBufferSize: 1024,
	}

	url := strings.Replace(ts.URL, "http://", "ws://", 1)
	header := http.Header{"Accept-Encoding": []string{"gzip"}}

	conn, _, err := dialer.Dial(url, header)
	if err != nil {
		return nil, err
	}

	return conn, nil
}

func writeMessage(conn *websocket.Conn, message string) error {
	return conn.WriteMessage(websocket.TextMessage, []byte(message))
}

func readMessage(conn *websocket.Conn) (string, error) {
	conn.SetReadDeadline(time.Now().Add(1 * time.Second))
	messageType, p, err := conn.ReadMessage()
	if err != nil {
		return "", err
	}
	if messageType != websocket.TextMessage {
		return "", errors.New("invalid message type")
	}
	return string(p), nil
}
```

# さいごに
* golem 使って簡単なゲームを1本作ってみて RoomManager の扱い方やルーム毎のデータの持ち方が微妙だと感じたので golem をフォークして自分の好みに改変しようとしていたところ、最後にあげた拡張機能について知り、改変なんかしなくても拡張機能で十分だったと気がついたのでこの記事を書きました。