はじめに
- 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
だと判断しエンコードした値を引数として関数を実行する
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.RoomManager
のJoin
メソッドでチャットルームに入り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
などのイベントハンドラー関数から利用することができる - その際イベントハンドラー関数側の型も変更する必要がある
- 今回は
join
でconn.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 をフォークして自分の好みに改変しようとしていたところ、最後にあげた拡張機能について知り、改変なんかしなくても拡張機能で十分だったと気がついたのでこの記事を書きました。