🎲

【Go】Websocketの概要とgorilla/websocketによる実装例

に公開

WebSocket とは

WebSocket は、クライアント(通常はブラウザ)とサーバーの間で双方向かつ常時接続を可能にする通信 HTTP プロトコルです。HTTP のようにリクエストとレスポンスを繰り返すのではなく、一度確立した接続を維持し続け、両者が自由にデータを送受信できます。これにより、リアルタイム性が求められるアプリケーションで広く活用されています。

WebSocket の通信フロー

  1. 接続要求
    まずクライアントがサーバーに対して WebSocket 接続を要求します。
    これは通常の HTTP リクエストを使い、Upgrade: websocket ヘッダを付与します。
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <ランダムなキー>
Sec-WebSocket-Version: 13
  1. サーバーの応答
    サーバーが WebSocket をサポートしていれば、以下のような HTTP 101 Switching Protocols を返却します。
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: <サーバー側で生成した応答キー>

ここで HTTP から WebSocket に切り替わり、HTTP 通信の時に使用していた、すでに確立済みの TCP ソケットがそのまま利用されます。

  1. フレームによるデータ通信
    接続が確立すると、HTTP ではなく WebSocket 専用の「フレーム」形式でデータをやり取りします。
    フレームには以下の種類があります。
  • テキストフレーム:UTF-8 文字列
  • バイナリフレーム:画像、音声などバイナリデータ
  • 制御フレーム:Ping/Pong/Close など
    クライアント・サーバー双方が自由なタイミングでフレームを送信でき、双方向通信が可能になります。
  1. 接続維持(Ping/Pong)
    WebSocket は長時間接続を前提としているため、定期的に Ping フレームを送信し、相手から Pong フレームが返ってくることで通信が生きているかを確認し、ネットワーク切断や以上終了を検知できます。

  2. 接続終了(Close フレーム)
    通信が不要になった場合は、Close フレームを送信して接続を終了します。双方が Close を確認すると、TCP 接続が閉じられます。異常終了の時もタイムアウトやエラーによって接続は解放されます。

alt text
通信フロー
(引用:https://shukapin.com/infographicIT/websocket)

また、WebSocket サーバーの設計にはいくつかのアークテクチャパターンが存在し、用途やスケール要件によって選択肢が変わります。

  • ハブ(集中管理型)
  • Pub/Sub(メッセージブロッカー型)
  • Actor モデル
  • イベント駆動(Event Bus 型)

Go によるチャット機能の実装

私が個人開発した Web アプリケーションにチャット機能を実装した時の実装例を元に説明します。
https://github.com/kankankanp/Muslog
このアプリケーションは Echo で API サーバーをクリーンアーキテクチャに基づいて構築しています。本 PJ は小規模なアプリケーションであるため、元々構築していた RESTfulAPI の API 内に、Hub パターンを使用した WebSocket チャットを同居させて実装しています。ライブラリとしてgorilla/websocketを使用しています。

Hub パターンについて

Hub パターンは、複数の WebSocket クライアントを中央の「ハブ」オブジェクトで一元管理する方式です。
クライアントがサーバーに接続すると、ハブに登録され、クライアント同士のメッセージ交換はすべてハブを介して行われます。

イメージは「空港のハブ」のようなもので、利用者(クライアント)は直接全員と通信するのではなく、一度ハブに集まって、そこから必要な相手に転送される構造です。
alt text
(引用:https://note.com/twsnmp/n/ne64357e08038)
Hub パターンの構成要素は以下です。

  • Hub

    • クライアントの集合を管理する
    • イベントをチャネル経由で受け取り、ループで処理する
    • register(新規接続登録)
    • unregister(切断処理)
    • broadcast(全クライアントへのメッセージ配信)
  • Client

    • 個々の WebSocket 接続を表す
    • readPump(受信ループ)と writePump(送信ループ)の 2 つの goroutine で動作
    • メッセージを受け取ったら Hub に送信し、Hub が他のクライアントに配信
  • Handler

    • HTTP の Upgrade リクエストを受け、WebSocket 接続に変換
    • Client を生成し、Hub に登録

ディレクトリ構成と実装例

backendディレクトリ内の構成は以下です。

backend/
├── cmd/
│   └── backend/
│       └── main.go
└── internal/
    ├── adapter/
    │   └── handler/
    │       ├── chat_handler.go
    │       ├── hub.go
    │       └── client.go
    └── usecase/
        └── chat_usecase.go
hub.go
package handler

type Hub struct {
	clients    map[*Client]bool
	broadcast  chan []byte
	register   chan *Client
	unregister chan *Client
}

func NewHub() *Hub {
	return &Hub{
		clients:    make(map[*Client]bool),
		broadcast:  make(chan []byte),
		register:   make(chan *Client),
		unregister: make(chan *Client),
	}
}

// Hub のメインループで、クライアントの登録・解除・ブロードキャストを処理する。
func (h *Hub) Run() {
	for {
		select {
		case client := <-h.register:
			h.clients[client] = true
		case client := <-h.unregister:
			if _, ok := h.clients[client]; ok {
				delete(h.clients, client)
				close(client.send)
			}
		case message := <-h.broadcast:
			for client := range h.clients {
				select {
				case client.send <- message:
				default:
					delete(h.clients, client)
					close(client.send)
				}
			}
		}
	}
}
client.go
package handler

import (
	"encoding/json"
	"log"

	"github.com/gorilla/websocket"
)

type ChatMessage struct {
	UserID   string `json:"userId"`
	UserName string `json:"userName"`
	Content  string `json:"content"`
}

type Client struct {
	hub      *Hub
	conn     *websocket.Conn
	send     chan []byte
	UserID   string
	UserName string
}

// readPump はクライアントからの受信ループで、WebSocket からの入力を待ち続け、メッセージを受信したら Hub に渡す。
// エラーや切断が発生した場合はクライアントを Hub から解除し、接続を閉じる。
func (c *Client) readPump() {
	defer func() {
		c.hub.unregister <- c
		c.conn.Close()
	}()
	for {
		_, msg, err := c.conn.ReadMessage()
		if err != nil {
			log.Println("read error:", err)
			break
		}

		// メッセージをJSON化
		chatMsg := ChatMessage{
			UserID:   c.UserID,
			UserName: c.UserName,
			Content:  string(msg),
		}
		jsonMsg, _ := json.Marshal(chatMsg)

		// Hubへ送信
		c.hub.broadcast <- jsonMsg
	}
}

// writePump はクライアントへの送信ループで、Hub から渡されたメッセージをチャネル経由で受け取り、WebSocket 経由で送信する。
// 書き込みエラーや接続切断時には処理を終了する。
func (c *Client) writePump() {
	defer c.conn.Close()
	for msg := range c.send {
		if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
			log.Println("write error:", err)
			break
		}
	}
}
chat_handler.go
package handler

import (
	"net/http"
	"yourapp/internal/usecase"

	"github.com/gorilla/websocket"
	"github.com/labstack/echo/v4"
)

type ChatHandler struct {
	hub     *Hub
	usecase usecase.ChatUsecase
}

func NewChatHandler(hub *Hub, uc usecase.ChatUsecase) *ChatHandler {
	return &ChatHandler{hub: hub, usecase: uc}
}

var upgrader = websocket.Upgrader{
	CheckOrigin: func(r *http.Request) bool {
		// 正規Originのみ許可
		origin := r.Header.Get("Origin")
		return origin == "{フロントのOrigin}"
	},
}

// 新しい WebSocket 接続を処理するハンドラ。HTTP リクエストを WebSocket にアップグレードし、クライアント構造体を生成して Hub に登録する
// その後、クライアントごとに readPump(受信ループ)と writePump(送信ループ)を goroutine で並行実行する。この関数が呼ばれるたびに新しいクライアント接続が確立される。
func (h *ChatHandler) ServeWs(c echo.Context) error {
	conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
	if err != nil {
		return err
	}

	// 認証済みユーザー情報をContextから取得(例: middlewareで設定済み)
	userID := c.Get("user_id").(string)
	userName := c.Get("user_name").(string)

	client := &Client{
		hub:      h.hub,
		conn:     conn,
		send:     make(chan []byte, 256),
		UserID:   userID,
		UserName: userName,
	}

	h.hub.register <- client

	go client.writePump()
	go client.readPump()

	return nil
}
chat_usecase.go
package usecase

// 今は単純だが、将来的に「フィルタリング」や「通知ロジック」を追加できる
type ChatUsecase interface {
	ProcessMessage(input string) string
}

type chatUsecaseImpl struct{}

func NewChatUsecase() ChatUsecase {
	return &chatUsecaseImpl{}
}

func (u *chatUsecaseImpl) ProcessMessage(input string) string {
	// 今は何もせずそのまま返す
	return input
}
main.go
package main

import (
	"fmt"
	"yourapp/internal/adapter/handler"
	"yourapp/internal/usecase"

	"github.com/labstack/echo/v4"
)

func main() {
	e := echo.New()

	// Hubの初期化
	hub := handler.NewHub()
	go hub.Run()

	// ユースケース
	chatUC := usecase.NewChatUsecase()

	// WebSocketエンドポイント
	chatHandler := handler.NewChatHandler(hub, chatUC)
	e.GET("/ws/chat", chatHandler.ServeWs)

	fmt.Println("Server started on :8080")
	e.Logger.Fatal(e.Start(":8080"))
}

参考文献

https://shukapin.com/infographicIT/websocket
https://blog.p1ass.com/posts/websocket-with-layerd-architecture/
https://note.com/twsnmp/n/ne64357e08038

Discussion