41
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

はじめてのGolang+WebSocket

Posted at

はじめに

今回は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に埋め込んでいます。

wsservice/listener.go
type AcceptHandler func(Conn)
type CloseHandler func(Conn)

type Listener interface {
	Run()
	RegisterAcceptHandler(AcceptHandler)
	RegisterCloseHandler(CloseHandler)
}

Run関数ではクライアントからの接続受付を開始しています。
接続が来た際はhttp.HandleFunc("/ws", lis.handleConnection)にて、handleConnection関数を呼び出しています。

wsservice/listener.go
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)
	}
}
wsservice/listener.go
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の状態管理を担当します。

wsservice/conn.go
type Conn interface {
	Run(readCh chan []byte, closeCh chan bool)
	Write([]byte)
	Close()
}

送受信用のGoroutineを立ち上げ、切断されるまで待機します。
sync.WaitGroupを用いることで、全てのGoroutineが終了してから関数を抜けるようにしてあります。

wsservice/conn.go
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に値が送られた場合に走るようにすることで、処理がブロックされるのを防ぐことができます。

wsservice/conn.go
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を作成する役割も持ちます。

external/router.go
type Router interface {
	Run(port int)
}
external/router.go
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にしています。

application/user.go
type User interface {
	Run()
	Write(msgid uint16, body interface{})
}

wsservice.Connに渡した受信用Chanelに値が送られてきた場合に、doHandlerを呼びだしています。
注意点として、select内にdefaultを記載しない場合、ここで処理がブロックされるので、for内で様々な処理をしたい場合などは必ず入れるようにすると良いです。

application/user.go
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.ConnWrite関数へ渡しています。

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を持ち、コールバックを登録しています。

application/handlers.go
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を指定した属性を合わせて埋め込みます。

application/packet.go
type (
	Packet struct {
		ID   uint16      `json:"id"`
		Body interface{} `json:"body"`
	}

	MessagePacket struct {
		Msg string `json:"msg"`
	}
)

main.go

Routerを作成し、指定のポートで接続を受け付けるのみです。

main.go
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が世界を埋め尽くすことを祈っています。
それでは。

41
36
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
41
36

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?