GoでWebSocketサーバーを実装する—gorilla/websocketで双方向通信を実現

GoでWebSocketサーバーを実装する—gorilla/websocketで双方向通信を実現 | mohablog
目次

リアルタイム通信の必要性とWebSocketの役割

ここ数年、Web アプリケーションではリアルタイムなデータ更新が必須になってきました。チャットアプリ、通知システム、ライブダッシュボードなど、サーバーからクライアントへの積極的な情報送信が求められるシーンが増えています。

HTTP ポーリングやロングポーリングは確かに機能しますが、その方式では効率が悪く、レイテンシーも高くなりがち。WebSocket はこうした課題を解決するプロトコルで、一度コネクションを確立すれば、サーバーとクライアント間で低遅延かつ効率的に双方向通信ができます。

Go 言語でこれを実装しようとしたとき、公式ライブラリだけでは面倒な部分が多いことに気づきました。そこで登場するのが gorilla/websocket です。この記事では、gorilla/websocket を使ってシンプルな WebSocket サーバーを作り、実際のプロジェクトで使える実装パターンを紹介します。

WebSocket の基礎知識

HTTP と WebSocket の違い

WebSocket は HTTP から始まりますが、アップグレードハンドシェイクを通じて別のプロトコルに遷移します。確立されたコネクションはフレーム形式でデータを交換し、どちらからでも通信を開始できるのが特徴です。

特性 HTTP WebSocket
通信方向 クライアント → サーバーのみ 双方向
レイテンシー 新しい接続が必要 コネクション再利用で低遅延
プロトコルオーバーヘッド リクエスト/レスポンスヘッダ毎回 初回ハンドシェイクのみ
サーバー主導の通信 ポーリング必要 ネイティブ対応

WebSocket フレームの構造

WebSocket ではデータをフレーム単位で送受信します。テキストフレーム、バイナリフレーム、制御フレーム(クローズ、ピングなど)があり、gorilla/websocket はこの低レベルの複雑さを隠蔽してくれます。

gorilla/websocket のセットアップ

環境とバージョン

この記事の実装は以下の環境を想定しています。

  • Go 1.22 以上
  • gorilla/websocket v1.5.1

インストール

プロジェクトディレクトリで以下を実行します。

go get -u github.com/gorilla/websocket
go: added github.com/gorilla/websocket v1.5.1

基本的な WebSocket サーバーの実装

まずはシンプルなエコーサーバー

WebSocket サーバーの最小限の実装から始めましょう。クライアントから受け取ったメッセージをそのまま返す「エコーサーバー」です。

package main

import (
	"fmt"
	"log"
	"net/http"

	"github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
}

func handleWebSocket(w http.ResponseWriter, r *http.Request) {
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Println("Upgrade error:", err)
		return
	}
	defer conn.Close()

	for {
		messageType, data, err := conn.ReadMessage()
		if err != nil {
			log.Println("Read error:", err)
			break
		}

		fmt.Printf("Received: %s\n", string(data))

		err = conn.WriteMessage(messageType, data)
		if err != nil {
			log.Println("Write error:", err)
			break
		}
	}
}

func main() {
	http.HandleFunc("/ws", handleWebSocket)
	log.Println("Server listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}
Server listening on :8080

重要なポイントの解説

Upgrader 構造体は HTTP コネクションを WebSocket にアップグレードするための設定を持ちます。ReadBufferSizeWriteBufferSize はバッファサイズで、通常は 1024 から 4096 程度で問題ありません。

conn.ReadMessage() はメッセージを読み込みます。ここでブロッキングし、クライアントからのデータを待ちます。WriteMessage() でクライアントに返信します。接続が閉じられるか読み込みエラーが発生したら、ループを抜けて接続を終了します。

CORS とセキュリティの考慮

CORS チェックの無視は危険

デフォルトの Upgrader は CORS チェックをスキップします。これは開発時には便利ですが、本番環境では許可されたオリジンからのアップグレードのみを受け付けるべき。実装してみたら、予期しないオリジンからの接続が大量に来た、というケースに遭遇したことがあります。

以下のように設定を厳密にしましょう。

var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
	CheckOrigin: func(r *http.Request) bool {
		origin := r.Header.Get("Origin")
		// 許可するオリジンのホワイトリスト
		allowedOrigins := map[string]bool{
			"https://example.com":     true,
			"https://app.example.com": true,
		}
		return allowedOrigins[origin]
	},
}
// 許可されたオリジンのみアップグレード成功

接続のタイムアウト設定

WebSocket コネクションが無限に開き続けるのを防ぐため、リードタイムアウトを設定します。

func handleWebSocket(w http.ResponseWriter, r *http.Request) {
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Println("Upgrade error:", err)
		return
	}
	defer conn.Close()

	// 60秒のタイムアウト設定
	conn.SetReadDeadline(time.Now().Add(60 * time.Second))
	conn.SetPongHandler(func(string) error {
		// ポングを受け取ったらタイムアウトをリセット
		conn.SetReadDeadline(time.Now().Add(60 * time.Second))
		return nil
	})

	// メッセージ読み込みループ
	for {
		messageType, data, err := conn.ReadMessage()
		if err != nil {
			break
		}
		// 処理...
	}
}
// タイムアウト発生時に自動的に接続を切断

複数クライアントとのブロードキャスト実装

ハブパターンの採用

実務では、複数のクライアントに同じメッセージを配信する「ブロードキャスト」が必要になります。例えば、チャットアプリなら全ユーザーに新しいメッセージを通知する必要があります。

このパターンは gorilla/websocket の公式ドキュメントでも推奨されている「ハブ」パターンです。

package main

import (
	"log"
	"net/http"
	"time"

	"github.com/gorilla/websocket"
)

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

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, 256),
		register:   make(chan *Client),
		unregister: make(chan *Client),
	}
}

func (h *Hub) Run() {
	for {
		select {
		case client := <-h.register:
			h.clients[client] = true
			log.Println("Client registered. Total:", len(h.clients))

		case client := <-h.unregister:
			if _, ok := h.clients[client]; ok {
				delete(h.clients, client)
				close(client.send)
				log.Println("Client unregistered. Total:", len(h.clients))
			}

		case message := <-h.broadcast:
			for client := range h.clients {
				select {
				case client.send <- message:
				default:
					// 送信キューが満杯の場合は切断
					close(client.send)
					delete(h.clients, client)
				}
			}
		}
	}
}

func (c *Client) ReadFromClient() {
	defer func() {
		c.hub.unregister <- c
		c.conn.Close()
	}()

	c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
	c.conn.SetPongHandler(func(string) error {
		c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
		return nil
	})

	for {
		_, message, err := c.conn.ReadMessage()
		if err != nil {
			break
		}
		c.hub.broadcast <- message
	}
}

func (c *Client) WriteToClient() {
	defer c.conn.Close()

	for message := range c.send {
		err := c.conn.WriteMessage(websocket.TextMessage, message)
		if err != nil {
			return
		}
	}
}

var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
	CheckOrigin: func(r *http.Request) bool {
		return true // 開発用、本番は厳密に
	},
}

func handleWebSocket(hub *Hub, w http.ResponseWriter, r *http.Request) {
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Println("Upgrade error:", err)
		return
	}

	client := &Client{
		hub:  hub,
		conn: conn,
		send: make(chan []byte, 256),
	}
	client.hub.register <- client

	go client.WriteToClient()
	go client.ReadFromClient()
}

func main() {
	hub := NewHub()
	go hub.Run()

	http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
		handleWebSocket(hub, w, r)
	})

	log.Println("Server listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}
Server listening on :8080
Client registered. Total: 1
Client registered. Total: 2
Client unregistered. Total: 1

ハブパターンの仕組み

Hub は全クライアントを管理し、チャネル経由で登録・登録解除・ブロードキャストを処理します。各 Client は 2 つの goroutine で並行実行されます。

  • ReadFromClient: クライアントからメッセージを読み込み、Hub のブロードキャストチャネルに流す
  • WriteToClient: Hub からのブロードキャストメッセージをクライアントに送信

この設計により、複数クライアント間の同期を安全に保ちながら、効率的にメッセージをルーティングできます。

JSON メッセージの扱い

構造化メッセージの実装

実際のアプリケーションでは、テキストをそのまま送るのではなく JSON で構造化したメッセージを使用します。

package main

import (
	"encoding/json"
	"log"
	"time"
)

type Message struct {
	Type      string    `json:"type"`
	UserID    string    `json:"user_id"`
	Content   string    `json:"content"`
	Timestamp time.Time `json:"timestamp"`
}

func (c *Client) ReadFromClient() {
	defer func() {
		c.hub.unregister <- c
		c.conn.Close()
	}()

	for {
		var msg Message
		err := c.conn.ReadJSON(&msg)
		if err != nil {
			log.Println("ReadJSON error:", err)
			break
		}

		msg.Timestamp = time.Now()
		data, _ := json.Marshal(msg)
		c.hub.broadcast <- data
	}
}

func (c *Client) WriteToClient() {
	defer c.conn.Close()

	for data := range c.send {
		var msg Message
		json.Unmarshal(data, &msg)
		// または直接WriteJSON
		err := c.conn.WriteMessage(websocket.TextMessage, data)
		if err != nil {
			return
		}
	}
}
{"type":"message","user_id":"user123","content":"Hello!","timestamp":"2024-01-15T12:30:45Z"}

ReadJSON と WriteJSON の活用

gorilla/websocket には ReadJSONWriteJSON メソッドがあり、手動で json.Marshal/json.Unmarshal を呼ぶ手間が省けます。ただし、ブロードキャスト時は複数のクライアントに同じデータを送るため、一度シリアライズしたバイト列を再利用する方が効率的です。

本番環境での運用ポイント

メモリ管理とリソースリーク

長時間実行されるサーバーでは、接続が適切にクローズされているか監視が重要です。ログに「Unregistered」が出力されていないクライアントがあれば、リークの可能性があります。

以下の対策が有効です。

  • 読み込みタイムアウト、書き込みタイムアウトの設定
  • 接続数の監視と制限(同時接続数が増えすぎない)
  • goroutine リークの検出(pprof を使用)
  • 定期的なヘルスチェック(ping/pong フレーム)

デプロイ時の考慮事項

負荷分散環境では、複数のサーバーインスタンス間でメッセージを同期する必要があります。Redis の pub/sub や NATS などのメッセージブローカーを使い、サーバー間通信を実装することが一般的です。

また、ロードバランサーは WebSocket の長期接続に対応する必要があります。sticky session を有効にするか、コネクションドレイニング機能を活用しましょう。

よくあるトラブルシューティング

接続直後に切断される

CheckOrigin が CORS エラーで切断している可能性があります。ブラウザの開発者ツールでネットワークを確認し、WebSocket ハンドシェイクが失敗していないか確認してください。

メッセージが届かない

キューが満杯で送信ルーチンがブロックしている場合があります。チャネルのバッファサイズを大きくするか、メッセージを優先度で処理する工夫が必要です。

メモリ使用量が増え続ける

goroutine リークの兆候です。接続クローズ時に必ず goroutine を終了しているか、go tool pprof で確認しましょう。

まとめ

  • WebSocket は低遅延の双方向通信を実現し、チャットやリアルタイムダッシュボードに最適
  • gorilla/websocket は Upgrader で HTTP → WebSocket アップグレードを簡潔に実装できる
  • 複数クライアント間でのメッセージ配信には、ハブパターンで安全かつ効率的に対応できる
  • 本番環境では CORS チェック、タイムアウト設定、メモリ管理が重要
  • JSON メッセージの構造化により、複雑な通信ロジックを保守しやすく設計できる
  • 負荷分散環境では Redis などのブローカーと組み合わせ、サーバー間同期を実装する

よくある質問(FAQ)

gorilla/websocket と標準ライブラリの net/websocket の違いは?

Go の golang.org/x/net/websocket は一部動作が標準から外れており、RFC 6455 への準拠度が低い傾向があります。gorilla/websocket は RFC に厳密に従い、より実用的な API を提供しています。新規開発では gorilla/websocket の採用が推奨されています。

ping/pong フレームは自動で送信されるのか?

いいえ、gorilla/websocket は ping を自動送信しません。接続を維持したい場合は、サーバー側で定期的に ping を送信するか、クライアント側でタイムアウト検出時に再接続するロジックを実装する必要があります。

バイナリデータを送信したい場合は?

WriteMessage(websocket.BinaryMessage, data) でバイナリフレームを送信できます。ReadMessage はメッセージタイプを返すため、テキストかバイナリかを区別して処理できます。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次