0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【go/goa】goaで最小構成でwebsocketを実装するサンプルコード

0
Posted at

実装パターンについて

  1. Echo:双方向通信の基礎(1対1)
    送信したメッセージをそのまま送信元のクライアントに返します。
  2. Chat:複数接続への配信(多対多)
    多対多の通信モデルで複数のクライアント間でメッセージを共有します。

まずはEchoで双方向ストリームの型と挙動を掴みます。
次にChatに進み複数接続へブロードキャストする実装をしてみます。

0. 事前準備

  • Goプロジェクトを初期化しておきます。
    go mod init goa-test
  • goa v3系を前提としています。

1. Echoの実装

  1. DSLでWebSocket前提の双方向ストリームを定義します。

    design.go
    package design
    
    var _ = API("goa-test", func() {
        Title("Goa Test API")
    })
    
    var _ = Service("ws", func() {
    	Description("WebSocketサービス")
    	HTTP(func() { 
            Path("/ws") 
        })
    
    	Method("echo", func() {
    		Description("クライアントから受信したメッセージをそのまま返す")
            // 受信
    		StreamingPayload(func() {
    			Attribute("message", String)
    			Required("message")
    		})
            // 送信
    		StreamingResult(func() {
    			Attribute("message", String)
    			Required("message")
    		})
    		HTTP(func() { 
                // WebSocketは必ずGETでUpgrade
                GET("/echo") 
            })
    	})
    })
    
  2. go mod tidyで 依存関係を解決してから、 goa gen goa-test/design / goa example goa-test/design を実行して、gen/ws/...cmd/goa_test/... を生成します。

  3. 生成された cmd/goa_test/http.go 内のupgraderを編集します。

    cmd/.../http.go
    - upgrader := &websocket.Upgrader{}
    + upgrader := &websocket.Upgrader{
    + 	CheckOrigin: func(r *http.Request) bool {
    + 		if dbg { // --debug=true なら許可
    + 			return true
    + 		}
    + 		switch r.Header.Get("Origin") {
    + 		// 本番では許可するオリジンを定義します
    + 		case "https://your-frontend.example.com":
    + 			return true
    + 		default:
    + 			return false
    + 		}
    + 	},
    + }
    
  4. Echoロジックの実装
    ポイント

    • Recv() で1件受信 → そのまま Send() で返す。
    • 受信側が切断すれば Recv() はエラーで落ちるのでループを抜けて終了。
    ws.go
    func (s *wssrvc) Echo(ctx context.Context, stream ws.EchoServerStream) error {
    	log.Printf(ctx, "ws.echo")
    	for {
    		in, err := stream.Recv()    // client -> server
    		if err != nil {
    			return err
    		}
    		out := &ws.EchoResult{Message: fmt.Sprintf("echo: %s", in.Message)}
    		if err := stream.Send(out); err != nil { // server -> client
    			return err
    		}
    	}
    }
    
  5. APIを起動
    go run goa-test/cmd/goa_test --http-port=8080 --debug=true

  6. 動作検証用のHTMLを作成し、Live Serverなどで起動

    <!DOCTYPE html>
    <html>
    
    <head>
      <title>Chat</title>
    </head>
    
    <body>
      <h1>WebSocket Chat</h1>
      <form action="" onsubmit="sendMessage(event)">
        <input type="text" id="senderName" autocomplete="off" placeholder="Your Name" value="USER1" />
        <textarea id="messageText" autocomplete="off"></textarea>
        <button>Send</button>
      </form>
      <ul id='messages'>
      </ul>
      <script>
        const ws = new WebSocket("ws://localhost:8080/ws/echo");
        ws.onmessage = (event) => {
          const messages = document.getElementById('messages')
          const message = document.createElement('li')
          const content = document.createTextNode(event.data)
          message.appendChild(content)
          messages.appendChild(message)
        };
        const sendMessage = (event) => {
          const input = document.getElementById("messageText")
          const sender = document.getElementById("senderName")
          ws.send(JSON.stringify({ message: input.value, sender: sender.value }))
          input.value = ''
          event.preventDefault()
        }
      </script>
    </body>
    
    </html>
    

    今回はAPIの実装に焦点をあてるためシンプルなHTMLを使用します。
    https://fastapi.tiangolo.com/ja/advanced/websockets/#_1 から拝借しました。

  7. ブラウザを開き動作確認
    3.gif
    送信と同時にAPIが応答します。
    しかしこのままでは各クライアントは独立したままなので、他クライアントとのメッセージの共有はできません。
    次に、Chatメソッドを新たに定義し、多対多でのメッセージの共有ができるようにしたいと思います。

2. chat(複数接続に配信)の実装

  1. DSLでChatメソッドを定義します。

    design.go
    Method("chat", func() {
        Description("チャットサービス。受信したメッセージをすべてのクライアントに配信する。")
        StreamingPayload(func() {
            Attribute("message", String, "client→server")
            Attribute("sender", String, "送信者名(クライアントが指定)")
            Required("message", "sender")
        })
        StreamingResult(func() {
            Attribute("message", String, "server→client")
            Attribute("sender", String, "送信者名")
            Attribute("timestamp", String, "送信時刻", func() {
                Format(FormatDateTime)
            })
            Required("message", "sender", "timestamp")
        })
        HTTP(func() {
            GET("/chat")
        })
    })
    
  2. goa gen goa-test/design を実行して、コードを生成します。
    cmd/goa_test/... は Echo で生成済み。

  3. cmd/.../http.go の upgrader は Echo でやった通り。

  4. Chatロジックの実装

    ws.go
    type wssrvc struct {
    	hub *hub
    }
    
    // NewWs returns the ws service implementation.
    func NewWs() ws.Service {
    	return &wssrvc{hub: newHub()}
    }
    
    // 接続している一人のクライアントを表す
    type client struct {
    	stream ws.ChatServerStream // サーバ→クライアント送信用ストリーム
    	send   chan *ws.ChatResult // サーバ→クライアント送信用チャネル
    }
    
    // 全クライアントを管理する
    type hub struct {
    	mu      sync.RWMutex         // clients への同時アクセスを防止するためのロック
    	clients map[*client]struct{} // 今サーバーに接続している全員
    }
    
    func newHub() *hub {
    	return &hub{clients: make(map[*client]struct{})}
    }
    
    // クライアント追加
    func (h *hub) add(c *client) {
    	h.mu.Lock()
    	h.clients[c] = struct{}{} // マップにクライアントを追加
    	h.mu.Unlock()
    }
    
    // クライアント削除
    func (h *hub) remove(c *client) {
    	h.mu.Lock()
    	delete(h.clients, c) // マップからクライアントを削除
    	h.mu.Unlock()
    	close(c.send)
    }
    
    // 全クライアントにメッセージ配信
    func (h *hub) broadcast(msg *ws.ChatResult) {
    	h.mu.RLock()
    	defer h.mu.RUnlock()
    	for c := range h.clients {
    		select {
    		case c.send <- msg: // メッセージを送信チャネルに送る
    		default:
    			// 詰まり対策: 送信バッファが詰まっていたら切断
    			go h.remove(c)
    		}
    	}
    }
    
    // チャットサービス。受信したメッセージをすべてのクライアントに配信する。
    func (s *wssrvc) Chat(ctx context.Context, stream ws.ChatServerStream) (err error) {
    	log.Printf(ctx, "ws.chat")
    	// クライアントを生成してHubに登録
    	c := &client{
    		stream: stream,
    		send:   make(chan *ws.ChatResult, 16),
    	}
    	s.hub.add(c)
    	defer s.hub.remove(c)
    
    	// 送信用ゴルーチン
    	go func() {
    		for msg := range c.send { // ここでチャネル(chan)からのメッセージを待つ チャネルは他のゴルーチンから送られてくるデータを順に受け取る通路
    			// メッセージ送信
    			if err := c.stream.Send(msg); err != nil {
    				return
    			}
    		}
    	}()
    
    	// 受信→全員へ配信
    	for {
    		// Recv はクライアントからの1メッセージを受信する
    		in, err := stream.Recv()
    		if err != nil {
    			// クライアント切断やctx.Done()でここに来る
    			return err
    		}
    		msg := &ws.ChatResult{
    			Message:   in.Message,
    			Sender:    in.Sender,
    			Timestamp: time.Now().Format(time.RFC3339),
    		}
    		s.hub.broadcast(msg)
    	}
    }
    
  5. HTMLのwebsocketのURLをchatへ向けます

    - const ws = new WebSocket("ws://localhost:8080/ws/echo");
    + const ws = new WebSocket("ws://localhost:8080/ws/chat");
    
  6. 複数のタブを立ち上げてメッセージを送るとそれぞれのクライアントに同期されているのがわかります。
    2.gif


参考にしたサイト
https://zenn.dev/takehiro1111/articles/go_web_socket
https://fastapi.tiangolo.com/ja/advanced/websockets/

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?