【Go】Websocketの概要とgorilla/websocketによる実装例
WebSocket とは
WebSocket は、クライアント(通常はブラウザ)とサーバーの間で双方向かつ常時接続を可能にする通信 HTTP プロトコルです。HTTP のようにリクエストとレスポンスを繰り返すのではなく、一度確立した接続を維持し続け、両者が自由にデータを送受信できます。これにより、リアルタイム性が求められるアプリケーションで広く活用されています。
WebSocket の通信フロー
- 接続要求
まずクライアントがサーバーに対して WebSocket 接続を要求します。
これは通常の HTTP リクエストを使い、Upgrade: websocket ヘッダを付与します。
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <ランダムなキー>
Sec-WebSocket-Version: 13
- サーバーの応答
サーバーが WebSocket をサポートしていれば、以下のような HTTP 101 Switching Protocols を返却します。
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: <サーバー側で生成した応答キー>
ここで HTTP から WebSocket に切り替わり、HTTP 通信の時に使用していた、すでに確立済みの TCP ソケットがそのまま利用されます。
- フレームによるデータ通信
接続が確立すると、HTTP ではなく WebSocket 専用の「フレーム」形式でデータをやり取りします。
フレームには以下の種類があります。
- テキストフレーム:UTF-8 文字列
- バイナリフレーム:画像、音声などバイナリデータ
- 制御フレーム:Ping/Pong/Close など
クライアント・サーバー双方が自由なタイミングでフレームを送信でき、双方向通信が可能になります。
-
接続維持(Ping/Pong)
WebSocket は長時間接続を前提としているため、定期的に Ping フレームを送信し、相手から Pong フレームが返ってくることで通信が生きているかを確認し、ネットワーク切断や以上終了を検知できます。 -
接続終了(Close フレーム)
通信が不要になった場合は、Close フレームを送信して接続を終了します。双方が Close を確認すると、TCP 接続が閉じられます。異常終了の時もタイムアウトやエラーによって接続は解放されます。

通信フロー
(引用:https://shukapin.com/infographicIT/websocket)
また、WebSocket サーバーの設計にはいくつかのアークテクチャパターンが存在し、用途やスケール要件によって選択肢が変わります。
- ハブ(集中管理型)
- Pub/Sub(メッセージブロッカー型)
- Actor モデル
- イベント駆動(Event Bus 型)
Go によるチャット機能の実装
私が個人開発した Web アプリケーションにチャット機能を実装した時の実装例を元に説明します。
このアプリケーションは Echo で API サーバーをクリーンアーキテクチャに基づいて構築しています。本 PJ は小規模なアプリケーションであるため、元々構築していた RESTfulAPI の API 内に、Hub パターンを使用した WebSocket チャットを同居させて実装しています。ライブラリとしてgorilla/websocketを使用しています。
Hub パターンについて
Hub パターンは、複数の WebSocket クライアントを中央の「ハブ」オブジェクトで一元管理する方式です。
クライアントがサーバーに接続すると、ハブに登録され、クライアント同士のメッセージ交換はすべてハブを介して行われます。
イメージは「空港のハブ」のようなもので、利用者(クライアント)は直接全員と通信するのではなく、一度ハブに集まって、そこから必要な相手に転送される構造です。

(引用: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
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)
}
}
}
}
}
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
}
}
}
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
}
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
}
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"))
}
参考文献
Discussion