Posted at

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

More than 1 year has passed since last update.


はじめに


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


Websocket と golem について


  • Go 言語の Websocket ライブラリには golang.org/x/net/websocketgithub.com/gorilla/websocket などがある


  • github.com/trevex/golem は軽量な Websocket フレームワーク


  • github.com/gorilla/websocket とかをそのまま使ってもいいけど golem には以下のような機能があって手軽に Websocket 通信するアプリケーションが作れる


    • イベントと関数のルーティング

    • JSON エンコード・デコード

    • ルーム機能

    • 接続型の拡張




エコーサーバーをつくる



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


メインファイルの作成


  • 以下の内容で echo.go を作成

  • ルータの使い方ルーティングの設定をして http.HadleFunc 登録するだけ


  • golem.RouterOn メソッドでイベント名と関数のルーティングが定義される


  • On("echo", echo) と定義されているとき echo {"msg":"hello world"} というメッセージを受け取ると、メッセージを {イベント名} {JSON文字列} と判定してイベント名に対応する関数 echo(conn *golem.Connection, data *echoMessage) の引数から JSON エンコードする型は *echoMessage だと判断しエンコードした値を引数として関数を実行する

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 を作成

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.RoomManagerJoin メソッドでチャットルームに入り Emit メソッドでチャットルームに参加している全員にメッセージを送信している

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 として作成

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 などのイベントハンドラー関数から利用することができる

  • その際イベントハンドラー関数側の型も変更する必要がある

  • 今回は joinconn.Name にルーム名をセットするようにしたことで say でルーム名を指定せずにメッセージを送信することができている

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 の作成

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 をフォークして自分の好みに改変しようとしていたところ、最後にあげた拡張機能について知り、改変なんかしなくても拡張機能で十分だったと気がついたのでこの記事を書きました。