はじめに
今回はGolangとWebSocketを用いて、簡単な送受信サービスを作ってみようと思い立ち、
どうせならとメモ兼情報共有として初記事にしてみたいと思います。
あまり深い話はないので、まだGolanger歴浅いけど、とりあえずGolangでWebSocketサーバーを書きたくて書きたくて毎日寝不足でうなされている方が対象になると思います。
サンプルコード
メッセージを送信すると、サーバーから何らかのメッセージが返ってくるだけのサービスです。
https://github.com/nekozuki-dev/go-websocket-sample
通信データ形式
まずはクライアント/サーバー間のやり取り形式を決めておきます。
今回は扱いやすくコードもシンプルでいけるJSON
を採用してみます。
idにはメッセージの識別IDを、bodyにはデータ本体を入れ、受信時にどの処理を呼ぶべきかわかるようにしておきます。
{ "id":"", "body":"" }
本格的に開発を行う場合、Stream化やFragment化を考慮し、
Byte配列を用いて先頭4bytesに判別用IDとデータサイズを入れてあげると良いです。
MSGID | BODY SIZE | BODY BYTES |
---|---|---|
2 bytes | 2 bytes | n bytes |
識別ID | データ本体の長さ | データ本体 |
また、最初はJSONで...後々こだわろう!という場合には、Encodeを担う部分を抽象化してあげると切替が効きやすいので良いです。
package構成
今回開発するサービスのpackage構成は下記のようにしてみました。
極力平たくしてありますが、中~大規模で何かを開発する場合、何らかのアーキテクチャを用いると良いです。
Golangは循環参照に厳しいので、依存関係は一方向external → app
となるように気を付けます。
読むときもmain → external → app
と読みやすくなるので、嬉しい事尽くしです。
.
├── app
│ ├── conn.go
│ ├── handlers.go
│ ├── packet.go
│ └── user.go
├── external
│ ├── wsservice
│ │ ├── conn.go
│ │ └── listener.go
│ └── router.go
└── main.go
解説
それぞれのソースコードから抜粋しつつ、解説をしていきます。
wsservice.Listenner
クライアントからの接続/切断を管理し、コールバック関数を呼び出す役割を持ちます。
個人的なルールとして、排他処理が必要な変数は確認しやすいようHogeAcync
に含め、Hoge
に埋め込んでいます。
type AcceptHandler func(Conn)
type CloseHandler func(Conn)
type Listener interface {
Run()
RegisterAcceptHandler(AcceptHandler)
RegisterCloseHandler(CloseHandler)
}
Run
関数ではクライアントからの接続受付を開始しています。
接続が来た際はhttp.HandleFunc("/ws", lis.handleConnection)
にて、handleConnection
関数を呼び出しています。
func (lis *listener) Run() {
http.HandleFunc("/ws", lis.handleConnection)
servAddr := fmt.Sprintf(":%d", lis.port)
fmt.Println("BeginListener", servAddr)
if err := http.ListenAndServe(servAddr, nil); err != nil {
panic(err)
}
}
func (lis *listener) handleConnection(w http.ResponseWriter, r *http.Request) {
ws, err := lis.upgrader.Upgrade(w, r, nil)
if err != nil {
fmt.Println("Error", err.Error())
return
}
defer lis.closeConnection(ws)
addr := ws.RemoteAddr().String()
fmt.Println("NewConnection", addr)
c := NewConn(ws)
lis.m.Lock()
lis.conns[ws] = c
lis.m.Unlock()
if lis.acceptHandler != nil {
lis.acceptHandler(c)
}
}
wsservice.Conn
websocket.Conn
のWrapperであり、メッセージの送受信及びWebSocketの状態管理を担当します。
type Conn interface {
Run(readCh chan []byte, closeCh chan bool)
Write([]byte)
Close()
}
送受信用のGoroutineを立ち上げ、切断されるまで待機します。
sync.WaitGroup
を用いることで、全てのGoroutineが終了してから関数を抜けるようにしてあります。
func (c *conn) Run(readCh chan []byte, closeCh chan bool) {
c.wg = &sync.WaitGroup{}
c.writeCh = make(chan []byte)
// Wait Read Goroutine
errCh := make(chan error)
c.wg.Add(1)
go c.waitRead(readCh, errCh)
// Wait Write Groutine
c.wg.Add(1)
go c.waitWrite()
// Process
for {
select {
case <-errCh:
close(c.writeCh)
c.wg.Wait()
close(closeCh)
return
}
}
}
Read/Writeでは、エラー発生時にSocketを閉じるようにしています。
WriteはWriteChanelに値が送られた場合に走るようにすることで、処理がブロックされるのを防ぐことができます。
func (c *conn) waitWrite() {
defer c.wg.Done()
fmt.Println("Begin WaitWrite Goroutine.")
for bytes := range c.writeCh {
if err := c.ws.WriteMessage(websocket.TextMessage, bytes); err != nil {
fmt.Println("Error", err)
break
}
}
c.Close()
fmt.Println("End WaitWrite Goroutine.")
}
func (c *conn) waitRead(readCh chan []byte, errCh chan error) {
defer c.wg.Done()
fmt.Println("Begin WaitRead Goroutine.")
for {
_, readBytes, err := c.ws.ReadMessage()
if err != nil {
errCh <- err
break
}
readCh <- readBytes
}
c.Close()
fmt.Println("End WaitRead Goroutine.")
}
external.Router
アプリケーションの入口となり、今回は接続時にUserを作成する役割も持ちます。
type Router interface {
Run(port int)
}
func (r *router) Run(port int) {
wsListener := wsservice.NewListener(port)
wsListener.RegisterAcceptHandler(r.OnAccept)
wsListener.RegisterCloseHandler(r.OnClose)
wsListener.Run()
}
func (r *router) OnAccept(c wsservice.Conn) {
fmt.Println("OnAccept")
u := app.NewUser(c)
u.Run()
}
func (r *router) OnClose(c wsservice.Conn) {
fmt.Println("OnClose")
}
app.User
クライアントとの通信及び、受信時の処理を記載します。
内部にwsservice.Conn
をinterface化したapp.Conn
を持っており、実際の通信時にはそれを用います。
wsservice.Conn
を直接利用しないことで、依存関係を一方向external -> app
にしています。
type User interface {
Run()
Write(msgid uint16, body interface{})
}
wsservice.Connに渡した受信用Chanelに値が送られてきた場合に、doHandler
を呼びだしています。
注意点として、select
内にdefault
を記載しない場合、ここで処理がブロックされるので、for
内で様々な処理をしたい場合などは必ず入れるようにすると良いです。
func (u *user) Run() {
u.msgHandlers.Register(1, u.handleMessage)
readCh := make(chan []byte)
closeCh := make(chan bool)
go u.conn.Run(readCh, closeCh)
for {
select {
case bytes := <-readCh:
u.doHandler(bytes)
case <-closeCh:
fmt.Println("CloseUser")
return
default:
}
}
}
データ送信時には、structをjson.Marshalを利用してbyte配列へ変換し、wsservice.Conn
のWrite
関数へ渡しています。
func (u *user) Write(msgid uint16, body interface{}) {
packet := &Packet{
ID: msgid,
Body: body,
}
bytes, err := json.Marshal(packet)
if err != nil {
u.conn.Close()
return
}
u.conn.Write(bytes)
}
データを受信した場合、byte配列をPacketへ変換し、識別IDとデータ本体を取り出しています。
その後、MessageHandlersからコールバックを取得し、呼び出します。
handleMessage関数では、与えられたデータ本体を利用するPacketへ変換しています。
今回は下記packageを使用させていただきました。
mapstructure : github.com/mitchellh/mapstructure
func (u *user) doHandler(bytes []byte) error {
packet := &Packet{}
if err := json.Unmarshal(bytes, packet); err != nil {
return err
}
handler := u.msgHandlers.Get(packet.ID)
if handler != nil {
handler(packet.Body)
}
return nil
}
func (u *user) handleMessage(body interface{}) {
req := &MessagePacket{}
if err := mapstructure.Decode(body, req); err != nil {
fmt.Println(err.Error())
return
}
fmt.Println(req.Msg)
res := &MessagePacket{
Msg: "ばななをあげる",
}
u.Write(1, res)
}
app.Handlers
受信データの識別用IDとコールバックを管理します。
識別IDをKeyとしたmapを持ち、コールバックを登録しています。
type MessageHandleFunc func(interface{})
type MessageHandlers interface {
Get(msgid uint16) MessageHandleFunc
Register(msgid uint16, handler MessageHandleFunc)
Unregister(msgid uint16)
}
func NewMessageHandlers() MessageHandlers {
return &messageHandlers{
handlers: make(map[uint16]MessageHandleFunc),
}
}
type messageHandlers struct {
handlers map[uint16]MessageHandleFunc
}
func (m *messageHandlers) Get(msgid uint16) MessageHandleFunc {
return m.handlers[msgid]
}
func (m *messageHandlers) Register(msgid uint16, handler MessageHandleFunc) {
m.handlers[msgid] = handler
}
func (m *messageHandlers) Unregister(msgid uint16) {
delete(m.handlers, msgid)
}
app.Packet
送受信データの構造定義を行っています。
今回はJSONでやり取りを行うため、jsonのkeyを指定した属性を合わせて埋め込みます。
type (
Packet struct {
ID uint16 `json:"id"`
Body interface{} `json:"body"`
}
MessagePacket struct {
Msg string `json:"msg"`
}
)
main.go
Routerを作成し、指定のポートで接続を受け付けるのみです。
func main() {
router := external.NewRouter()
router.Run(9080)
}
クライアント側
ばななをあげるとばななをもらえる
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>WebSocketSample</title>
</head>
<script>
var sock = new WebSocket('ws://127.0.0.1:9080/ws');
var send = function(msgid, body) {
var packet = {
'id': msgid,
'body': body
};
var json = JSON.stringify(packet)
sock.send(json)
};
sock.addEventListener('open', function(e) {
console.log('Connect success.')
document.getElementById('banana').addEventListener('click',function(e) {
var msg = {
'msg': 'ばななをあげる'
};
send(1, msg)
});
});
sock.addEventListener('close', function(e) {
console.log('Connect close.')
});
sock.addEventListener('message', function(e) {
var json = JSON.parse(e.data)
var msgid = json.id;
var body = json.body;
if (msgid == 1) {
console.log(body.msg);
}
});
</script>
<body>
<input type="button" id="banana" value="バナナを送る" />
</body>
</html>
おしまい
Golangerが世界を埋め尽くすことを祈っています。
それでは。