Day 8: 最終評価 - 解答例

総合課題: WebSocketチャットサーバー

Day 8の最終評価では、これまで学んだ全ての概念を統合した本格的なWebSocketチャットサーバーを構築します。この最終プロジェクトは、以下の全てのトピックを実践的に統合します:

  • Day 1-2: 型システム、エラー処理、defer
  • Day 3: インターフェース設計
  • Day 4: ゴルーチン、Mutex、WaitGroup
  • Day 5: チャネル、select、パイプライン
  • Day 6: context、設計パターン
  • Day 7: HTTPサーバー、テスト戦略

---

アーキテクチャ選択のトレードオフ分析

アプローチ1: シンプルなHub-Client パターン(推奨)

長所:

  • シンプルで理解しやすい
  • Go の並行処理パターンに最適
  • チャネルベースの通信でデータ競合を自然に回避
  • スケーラビリティが高い(数千のクライアントまで)

短所:

  • 永続化層がない(メモリのみ)
  • 単一サーバーインスタンスに限定
  • 高度な認証・認可機構なし

適用場面: 学習目的、プロトタイプ、小〜中規模のリアルタイムアプリケーション

アプローチ2: レイヤードアーキテクチャ

Presentation Layer (WebSocket Handlers)
         ↓
Service Layer (Business Logic)
         ↓
Repository Layer (Data Access)
         ↓
Database (PostgreSQL/Redis)

長所:

  • 責務の明確な分離
  • テストが容易
  • データの永続化
  • 複数サーバーへのスケール可能

短所:

  • 複雑性が増す
  • パフォーマンスオーバーヘッド
  • 初期セットアップコストが高い

適用場面: 本番環境、大規模アプリケーション、長期メンテナンスが必要な場合

アプローチ3: クリーンアーキテクチャ

Entities (Domain Models)
    ↓
Use Cases (Application Logic)
    ↓
Interface Adapters (Controllers, Presenters)
    ↓
Frameworks & Drivers (WebSocket, DB)

長所:

  • フレームワーク非依存
  • テスト容易性が最大
  • ビジネスロジックの独立性
  • 長期的な保守性

短所:

  • 初期実装コストが非常に高い
  • 過剰設計になりがち
  • 小規模プロジェクトには不向き

適用場面: エンタープライズアプリケーション、複雑なビジネスロジック

---

MVPアプローチ: 段階的な実装パス

Phase 1: 最小限の動作実装(MVP)

最初は最もシンプルな実装から始めます。

// 最小限のHub(コメント付き)
package main

import (
    "sync"
)

// Hub はクライアントの集合を管理し、メッセージをブロードキャストする中央ハブ
type Hub struct {
    // clients: 接続中の全クライアントを保持するマップ
    // map[*Client]bool の bool は存在確認用(セット的な使い方)
    clients map[*Client]bool

    // broadcast: クライアントから受信したメッセージを全体にブロードキャストするチャネル
    // バッファサイズ256は、短時間に大量のメッセージが来ても詰まらないようにするため
    broadcast chan []byte

    // register: 新しいクライアントの登録リクエストを受け取るチャネル
    register chan *Client

    // unregister: クライアントの登録解除リクエストを受け取るチャネル
    unregister chan *Client

    // mu: clients マップへの並行アクセスを保護するMutex
    mu sync.RWMutex
}

// NewHub は新しいHubインスタンスを生成
func NewHub() *Hub {
    return &Hub{
        clients:    make(map[*Client]bool),
        broadcast:  make(chan []byte, 256), // バッファ付きチャネル
        register:   make(chan *Client),
        unregister: make(chan *Client),
    }
}

// Run はHubのメインループを開始(ゴルーチンで実行される想定)
func (h *Hub) Run() {
    // 無限ループでチャネルからのイベントを待機
    for {
        select {
        case client := <-h.register:
            // 新しいクライアントを登録
            h.mu.Lock() // 書き込みロック取得
            h.clients[client] = true
            h.mu.Unlock() // ロック解放

        case client := <-h.unregister:
            // クライアントの登録を解除
            h.mu.Lock()
            if _, ok := h.clients[client]; ok {
                delete(h.clients, client)
                close(client.send) // クライアントのチャネルをクローズ
            }
            h.mu.Unlock()

        case message := <-h.broadcast:
            // 全クライアントにメッセージをブロードキャスト
            h.mu.RLock() // 読み取りロック(複数ゴルーチンが同時読み取り可能)
            for client := range h.clients {
                select {
                case client.send <- message:
                    // メッセージ送信成功
                default:
                    // クライアントのバッファが満杯の場合は切断
                    close(client.send)
                    delete(h.clients, client)
                }
            }
            h.mu.RUnlock()
        }
    }
}

このMVPの問題点:

  • ルーム機能なし(全員が同じチャットルーム)
  • メッセージ履歴なし
  • ユーザー識別なし
  • エラーログなし

Phase 2: ルーム機能の追加

// 改良版Hub: ルーム機能追加
type Hub struct {
    // rooms: ルーム名をキーに、そのルーム内のクライアント集合を値に持つマップ
    // map[string]map[*Client]bool は「ルーム → クライアントセット」の2段階マップ
    rooms map[string]map[*Client]bool

    broadcast  chan Message // メッセージ構造体に変更
    register   chan *Client
    unregister chan *Client
    mu         sync.RWMutex
}

// Message 構造体: ルーム情報を含む
type Message struct {
    Room    string `json:"room"`    // 対象ルーム
    Content string `json:"content"` // メッセージ内容
    // 将来的に Username, Timestamp なども追加可能
}

// registerClient: ルーム別にクライアントを登録
func (h *Hub) registerClient(client *Client) {
    h.mu.Lock()
    defer h.mu.Unlock() // 関数終了時に必ずアンロック

    // ルームが存在しない場合は新規作成
    if h.rooms[client.room] == nil {
        h.rooms[client.room] = make(map[*Client]bool)
    }
    // クライアントをルームに追加
    h.rooms[client.room][client] = true
}

// broadcastToRoom: 特定のルームにのみブロードキャスト
func (h *Hub) broadcastToRoom(room string, message []byte) {
    h.mu.RLock()
    clients := h.rooms[room] // ルーム内のクライアント取得
    h.mu.RUnlock()

    if clients == nil {
        return // ルームが存在しない場合は何もしない
    }

    // そのルーム内の全クライアントにメッセージ送信
    for client := range clients {
        select {
        case client.send <- message:
        default:
            close(client.send)
            h.mu.Lock()
            delete(clients, client)
            h.mu.Unlock()
        }
    }
}

Phase 3: メッセージ履歴とメタデータ

// さらに改良されたHub: 履歴管理機能追加
type Hub struct {
    rooms      map[string]map[*Client]bool
    broadcast  chan Message
    register   chan *Client
    unregister chan *Client
    mu         sync.RWMutex

    // history: ルームごとのメッセージ履歴(最新100件を保持)
    history   map[string][]Message
    historyMu sync.RWMutex // 履歴専用のMutex(ロック粒度を細かく)
}

// Message 構造体: 完全版
type Message struct {
    Type      string    `json:"type"`      // "message", "system", "join", "leave" など
    Username  string    `json:"username"`  // 送信者名
    Content   string    `json:"content"`   // メッセージ本文
    Timestamp time.Time `json:"timestamp"` // タイムスタンプ
    Room      string    `json:"room"`      // 対象ルーム
}

// addToHistory: メッセージを履歴に追加(最新100件のみ保持)
func (h *Hub) addToHistory(message Message) {
    h.historyMu.Lock() // 履歴専用のロック
    defer h.historyMu.Unlock()

    // ルームの履歴を取得(なければ空スライス)
    history := h.history[message.Room]
    history = append(history, message)

    // 履歴が100件を超えたら古いものを削除
    // スライスの後ろ100件のみを保持
    if len(history) > 100 {
        history = history[len(history)-100:]
    }

    h.history[message.Room] = history
}

// sendHistory: 新規参加クライアントに履歴を送信
func (h *Hub) sendHistory(client *Client) {
    h.historyMu.RLock() // 読み取り専用ロック
    history := h.history[client.room]
    h.historyMu.RUnlock()

    // 履歴の各メッセージをクライアントに送信
    for _, msg := range history {
        messageBytes, err := json.Marshal(msg)
        if err != nil {
            continue // エラーは無視して次のメッセージへ
        }

        select {
        case client.send <- messageBytes:
            // 送信成功
        default:
            // バッファ満杯の場合は送信中断
            return
        }
    }
}

---

完全実装: プロダクションレディコード

main.go - エントリーポイント(詳細コメント版)

package main

import (
    "context"
    "flag"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    // === 1. コマンドラインフラグの解析 ===
    // flag パッケージで起動オプションを定義
    addr := flag.String("addr", ":8080", "http service address")
    // 使い方: go run main.go -addr=:9000
    flag.Parse() // フラグを解析

    // === 2. ロガーの設定 ===
    // 標準出力にタイムスタンプ付きでログを出力
    log.SetFlags(log.LstdFlags | log.Lshortfile)

    // === 3. Hubの初期化と起動 ===
    hub := NewHub()
    go hub.Run() // Hubのメインループを別ゴルーチンで実行

    // === 4. HTTPルーティング設定 ===
    // http.HandleFunc でパスとハンドラを紐付け
    http.HandleFunc("/", serveHome) // ホームページ
    http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
        // WebSocketエンドポイント
        // クロージャーで hub を渡す
        serveWs(hub, w, r)
    })
    // API エンドポイント(統計情報)
    http.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) {
        serveStats(hub, w, r)
    })

    // === 5. HTTPサーバーの設定 ===
    server := &http.Server{
        Addr: *addr,
        // タイムアウト設定でリソース枯渇を防ぐ
        ReadTimeout:  10 * time.Second,  // リクエスト読み取りタイムアウト
        WriteTimeout: 10 * time.Second,  // レスポンス書き込みタイムアウト
        IdleTimeout:  120 * time.Second, // Keep-Alive接続のアイドルタイムアウト
    }

    // === 6. サーバーを別ゴルーチンで起動 ===
    go func() {
        log.Printf("Server starting on %s", *addr)
        // ListenAndServe はブロッキング関数
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            // ErrServerClosed は正常なシャットダウン時に返されるので無視
            log.Fatalf("Server error: %v", err)
        }
    }()

    // === 7. グレースフルシャットダウンの実装 ===
    // シグナルを受け取るチャネル(バッファサイズ1)
    quit := make(chan os.Signal, 1)
    // SIGINT (Ctrl+C) と SIGTERM を受け取る
    signal.Notify(quit, os.Interrupt, syscall.SIGTERM)

    // シグナルが来るまでブロック
    <-quit
    log.Println("Shutting down server...")

    // シャットダウン用のコンテキスト(10秒でタイムアウト)
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    // 既存の接続を待ってから終了(最大10秒)
    if err := server.Shutdown(ctx); err != nil {
        log.Fatalf("Server forced to shutdown: %v", err)
    }

    log.Println("Server exited gracefully")
}

// serveHome はホームページ(HTML)を提供
func serveHome(w http.ResponseWriter, r *http.Request) {
    // パスが "/" 以外は404を返す
    if r.URL.Path != "/" {
        http.Error(w, "Not found", http.StatusNotFound)
        return
    }
    // GET以外のメソッドは405を返す
    if r.Method != http.MethodGet {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }
    // HTMLファイルを直接提供
    http.ServeFile(w, r, "home.html")
}

// serveStats はルームの統計情報をJSON形式で返す
func serveStats(hub *Hub, w http.ResponseWriter, r *http.Request) {
    // CORS ヘッダーを設定(フロントエンドが別オリジンの場合に必要)
    w.Header().Set("Access-Control-Allow-Origin", "*")
    w.Header().Set("Content-Type", "application/json")

    // Hub から統計情報を取得
    stats := hub.GetRoomStats()

    // JSON にエンコードしてレスポンス
    if err := json.NewEncoder(w).Encode(stats); err != nil {
        log.Printf("Error encoding stats: %v", err)
        http.Error(w, "Internal server error", http.StatusInternalServerError)
    }
}

hub.go - メッセージハブ(完全版)

package main

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

// Message はチャットメッセージを表す構造体
type Message struct {
    Type      string    `json:"type"`               // メッセージタイプ(message, system, join, leave)
    Username  string    `json:"username"`           // 送信者のユーザー名
    Content   string    `json:"content"`            // メッセージ本文
    Timestamp time.Time `json:"timestamp"`          // メッセージのタイムスタンプ
    Room      string    `json:"room,omitempty"`     // 対象ルーム(omitempty で空の場合はJSON出力しない)
}

// Hub はアクティブなクライアントの集合を管理し、メッセージをブロードキャストする
type Hub struct {
    // rooms: ルーム名 → クライアント集合のマッピング
    rooms map[string]map[*Client]bool

    // broadcast: クライアントからの受信メッセージを処理するチャネル
    broadcast chan Message

    // register: クライアント登録リクエストを処理するチャネル
    register chan *Client

    // unregister: クライアント登録解除リクエストを処理するチャネル
    unregister chan *Client

    // mu: rooms への並行アクセスを保護する読み書きMutex
    mu sync.RWMutex

    // history: ルームごとのメッセージ履歴(最新100件)
    history map[string][]Message

    // historyMu: history への並行アクセスを保護する専用Mutex
    historyMu sync.RWMutex
}

// NewHub は新しいHubインスタンスを生成して返す
func NewHub() *Hub {
    return &Hub{
        rooms:      make(map[string]map[*Client]bool),
        broadcast:  make(chan Message, 256), // バッファサイズ256(高負荷時の詰まり防止)
        register:   make(chan *Client),
        unregister: make(chan *Client),
        history:    make(map[string][]Message),
    }
}

// Run はHubのメインループを開始する(ブロッキング関数)
func (h *Hub) Run() {
    // 無限ループでチャネルイベントを処理
    for {
        select {
        case client := <-h.register:
            // クライアント登録
            h.registerClient(client)

        case client := <-h.unregister:
            // クライアント登録解除
            h.unregisterClient(client)

        case message := <-h.broadcast:
            // メッセージブロードキャスト
            h.broadcastMessage(message)
        }
    }
}

// registerClient はクライアントをルームに追加する
func (h *Hub) registerClient(client *Client) {
    h.mu.Lock() // 書き込みロック取得
    defer h.mu.Unlock()

    // ルームが存在しない場合は作成
    if h.rooms[client.room] == nil {
        h.rooms[client.room] = make(map[*Client]bool)
        log.Printf("Room created: %s", client.room)
    }

    // クライアントをルームに追加
    h.rooms[client.room][client] = true
    log.Printf("Client registered: %s in room %s (total: %d)",
        client.username, client.room, len(h.rooms[client.room]))

    // ロック解放後に履歴送信と参加通知を行う
    // (デッドロックを避けるため、Unlock後に実行)
    h.mu.Unlock()

    // 新規クライアントにルーム履歴を送信
    h.sendHistory(client)

    // ルーム内の他のユーザーに参加を通知
    joinMsg := Message{
        Type:      "system",
        Content:   client.username + " がルームに参加しました",
        Timestamp: time.Now(),
        Room:      client.room,
    }
    h.broadcastToRoom(client.room, joinMsg)

    h.mu.Lock() // 再度ロック(defer のため)
}

// unregisterClient はクライアントをルームから削除する
func (h *Hub) unregisterClient(client *Client) {
    h.mu.Lock()
    defer h.mu.Unlock()

    // ルームが存在し、クライアントがそのルームに登録されているか確認
    if clients, ok := h.rooms[client.room]; ok {
        if _, exists := clients[client]; exists {
            // クライアントを削除
            delete(clients, client)
            close(client.send) // クライアントの送信チャネルをクローズ

            log.Printf("Client unregistered: %s from room %s (remaining: %d)",
                client.username, client.room, len(clients))

            // ルームが空になった場合はルーム自体を削除
            if len(clients) == 0 {
                delete(h.rooms, client.room)
                log.Printf("Room deleted: %s", client.room)
            } else {
                // ルームに他のユーザーがいる場合は退出を通知
                h.mu.Unlock() // デッドロック回避
                leaveMsg := Message{
                    Type:      "system",
                    Content:   client.username + " がルームを退出しました",
                    Timestamp: time.Now(),
                    Room:      client.room,
                }
                h.broadcastToRoom(client.room, leaveMsg)
                h.mu.Lock() // defer のため再ロック
            }
        }
    }
}

// broadcastMessage はメッセージをルーム内の全クライアントに送信する
func (h *Hub) broadcastMessage(message Message) {
    // タイムスタンプを設定(サーバー側で統一)
    message.Timestamp = time.Now()

    // メッセージを履歴に保存
    h.addToHistory(message)

    // ルームにブロードキャスト
    h.broadcastToRoom(message.Room, message)
}

// broadcastToRoom は特定のルーム内の全クライアントにメッセージを送信
func (h *Hub) broadcastToRoom(room string, message Message) {
    // 読み取りロックでルームのクライアント一覧を取得
    h.mu.RLock()
    clients := h.rooms[room]
    h.mu.RUnlock()

    if clients == nil {
        // ルームが存在しない場合は何もしない
        return
    }

    // メッセージをJSONにエンコード
    messageBytes, err := json.Marshal(message)
    if err != nil {
        log.Printf("Error marshaling message: %v", err)
        return
    }

    // ルーム内の各クライアントにメッセージを送信
    for client := range clients {
        select {
        case client.send <- messageBytes:
            // 送信成功
        default:
            // クライアントの送信バッファが満杯の場合は切断
            // (応答が遅いクライアントを自動的に切断)
            close(client.send)
            h.mu.Lock()
            delete(clients, client)
            h.mu.Unlock()
            log.Printf("Client disconnected due to full buffer: %s", client.username)
        }
    }
}

// addToHistory はメッセージをルーム履歴に追加(最新100件のみ保持)
func (h *Hub) addToHistory(message Message) {
    h.historyMu.Lock()
    defer h.historyMu.Unlock()

    // ルームの履歴を取得(存在しない場合は空スライス)
    history := h.history[message.Room]
    history = append(history, message)

    // 履歴が100件を超えた場合は古いものを削除
    if len(history) > 100 {
        history = history[len(history)-100:]
    }

    h.history[message.Room] = history
}

// sendHistory は新規クライアントにルームの履歴を送信
func (h *Hub) sendHistory(client *Client) {
    // 読み取りロックでルーム履歴を取得
    h.historyMu.RLock()
    history := h.history[client.room]
    h.historyMu.RUnlock()

    // 履歴の各メッセージを順番に送信
    for _, msg := range history {
        messageBytes, err := json.Marshal(msg)
        if err != nil {
            log.Printf("Error marshaling history message: %v", err)
            continue
        }

        select {
        case client.send <- messageBytes:
            // 送信成功
        default:
            // バッファ満杯の場合は履歴送信を中断
            log.Printf("Unable to send full history to %s", client.username)
            return
        }
    }

    log.Printf("History sent to %s: %d messages", client.username, len(history))
}

// GetRoomStats は全ルームの統計情報を返す(API用)
func (h *Hub) GetRoomStats() map[string]int {
    h.mu.RLock()
    defer h.mu.RUnlock()

    stats := make(map[string]int)
    for room, clients := range h.rooms {
        stats[room] = len(clients)
    }
    return stats
}

client.go - WebSocket接続管理(完全版)

package main

import (
    "bytes"
    "encoding/json"
    "log"
    "net/http"
    "time"

    "github.com/gorilla/websocket"
)

// WebSocket接続の各種タイムアウト設定
const (
    // writeWait: ピアへのメッセージ書き込みに許容される時間
    writeWait = 10 * time.Second

    // pongWait: ピアからの次のpongメッセージを待つ時間
    // これを過ぎると接続が切れたと判断
    pongWait = 60 * time.Second

    // pingPeriod: ピアにpingを送る間隔(pongWaitより短くする必要がある)
    // 54秒ごとにpingを送り、60秒以内にpongが返ってこないと切断
    pingPeriod = (pongWait * 9) / 10

    // maxMessageSize: ピアから受信するメッセージの最大サイズ(バイト)
    // 大きすぎるメッセージを受け入れないことでDoS攻撃を防ぐ
    maxMessageSize = 512
)

var (
    // 改行とスペースのバイト表現(メッセージ整形用)
    newline = []byte{'\n'}
    space   = []byte{' '}
)

// upgrader はHTTP接続をWebSocket接続にアップグレードする設定
var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024, // 読み取りバッファサイズ
    WriteBufferSize: 1024, // 書き込みバッファサイズ

    // CheckOrigin はCORS(オリジン間リソース共有)のチェック
    // 本番環境では適切なオリジンチェックを実装すべき
    CheckOrigin: func(r *http.Request) bool {
        return true // 開発環境では全オリジンを許可
        // 本番: origin := r.Header.Get("Origin")
        //       return origin == "https://yourdomain.com"
    },
}

// Client は単一のWebSocket接続を表す
type Client struct {
    hub      *Hub            // 所属するHub
    conn     *websocket.Conn // WebSocket接続
    send     chan []byte     // 送信メッセージのバッファチャネル
    username string          // ユーザー名
    room     string          // 参加しているルーム名
}

// readPump はWebSocket接続からメッセージを読み取り、Hubに転送する
// このメソッドは1つのゴルーチンで実行される
func (c *Client) readPump() {
    // 関数終了時にクリーンアップ
    defer func() {
        c.hub.unregister <- c // Hubに登録解除を通知
        c.conn.Close()         // WebSocket接続をクローズ
    }()

    // 受信メッセージサイズの上限を設定
    c.conn.SetReadLimit(maxMessageSize)

    // 読み取りデッドラインを設定(pongWait後にタイムアウト)
    c.conn.SetReadDeadline(time.Now().Add(pongWait))

    // pongメッセージを受信したときのハンドラを設定
    c.conn.SetPongHandler(func(string) error {
        // pongを受信したらデッドラインを延長
        c.conn.SetReadDeadline(time.Now().Add(pongWait))
        return nil
    })

    // メッセージ読み取りループ
    for {
        // WebSocketからメッセージを読み取る(ブロッキング)
        _, messageBytes, err := c.conn.ReadMessage()
        if err != nil {
            // 予期しない切断の場合のみログ出力
            if websocket.IsUnexpectedCloseError(err,
                websocket.CloseGoingAway,        // ページ遷移などの正常な切断
                websocket.CloseAbnormalClosure) { // 異常な切断
                log.Printf("WebSocket error: %v", err)
            }
            break // ループを抜けて接続を閉じる
        }

        // メッセージの正規化(前後の空白削除、改行をスペースに変換)
        messageBytes = bytes.TrimSpace(bytes.Replace(messageBytes, newline, space, -1))

        // JSONメッセージをパース
        var msg Message
        if err := json.Unmarshal(messageBytes, &msg); err != nil {
            log.Printf("Error parsing message from %s: %v", c.username, err)
            continue // パースエラーは無視して次のメッセージへ
        }

        // メッセージにメタデータを設定
        msg.Username = c.username // 送信者名をサーバー側で設定(改ざん防止)
        msg.Room = c.room         // ルーム名もサーバー側で設定
        msg.Type = "message"      // タイプを通常メッセージに設定

        // Hubにメッセージを転送(ブロードキャスト用)
        c.hub.broadcast <- msg
    }
}

// writePump はHubからメッセージを受け取り、WebSocket接続に書き込む
// このメソッドは1つのゴルーチンで実行される
func (c *Client) writePump() {
    // ping送信用のタイマー
    ticker := time.NewTicker(pingPeriod)

    // 関数終了時にクリーンアップ
    defer func() {
        ticker.Stop()   // タイマー停止
        c.conn.Close()  // WebSocket接続をクローズ
    }()

    // メッセージ送信ループ
    for {
        select {
        case message, ok := <-c.send:
            // 書き込みデッドラインを設定
            c.conn.SetWriteDeadline(time.Now().Add(writeWait))

            if !ok {
                // sendチャネルがクローズされた(Hubから切断指示)
                c.conn.WriteMessage(websocket.CloseMessage, []byte{})
                return
            }

            // テキストメッセージの書き込みを開始
            w, err := c.conn.NextWriter(websocket.TextMessage)
            if err != nil {
                return // 書き込みエラーで終了
            }
            w.Write(message) // メッセージを書き込み

            // キューに溜まっているメッセージを一括送信(効率化)
            n := len(c.send)
            for i := 0; i < n; i++ {
                w.Write(newline)      // 改行で区切り
                w.Write(<-c.send)     // 次のメッセージを追加
            }

            // 書き込みを完了
            if err := w.Close(); err != nil {
                return
            }

        case <-ticker.C:
            // pingPeriod ごとにpingメッセージを送信
            c.conn.SetWriteDeadline(time.Now().Add(writeWait))
            if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
                return // ping送信失敗で終了
            }
        }
    }
}

// serveWs はHTTPリクエストをWebSocket接続にアップグレードする
func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
    // クエリパラメータからユーザー名とルーム名を取得
    username := r.URL.Query().Get("username")
    room := r.URL.Query().Get("room")

    // デフォルト値を設定
    if username == "" {
        username = "匿名ユーザー"
    }
    if room == "" {
        room = "general"
    }

    // HTTP接続をWebSocketにアップグレード
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Printf("WebSocket upgrade error: %v", err)
        return
    }

    // 新しいClientインスタンスを作成
    client := &Client{
        hub:      hub,
        conn:     conn,
        send:     make(chan []byte, 256), // バッファサイズ256
        username: username,
        room:     room,
    }

    // Hubにクライアント登録を通知
    client.hub.register <- client

    // 読み取りと書き込みを別々のゴルーチンで実行
    // 呼び出し元のメモリ解放を許可するため、新しいゴルーチンで実行
    go client.writePump()
    go client.readPump()
}

---

Common Mistakes(よくある間違い)

1. データ競合

間違い:

// NG: Mutexなしでの並行アクセス
type Hub struct {
    clients map[*Client]bool // 複数ゴルーチンからアクセス
}

func (h *Hub) addClient(c *Client) {
    h.clients[c] = true // データ競合発生!
}

正解:

// OK: Mutexで保護
type Hub struct {
    clients map[*Client]bool
    mu      sync.RWMutex
}

func (h *Hub) addClient(c *Client) {
    h.mu.Lock()         // 書き込みロック
    h.clients[c] = true
    h.mu.Unlock()       // アンロック
}

2. チャネルのクローズ後の送信

間違い:

// NG: クローズ済みチャネルへの送信はパニック
close(client.send)
client.send <- message // panic!

正解:

// OK: クローズ前に削除
h.mu.Lock()
if _, ok := h.clients[client]; ok {
    delete(h.clients, client)
    close(client.send) // 削除後にクローズ
}
h.mu.Unlock()

3. Goroutine リーク

間違い:

// NG: 終了しないゴルーチン
func leakyFunction() {
    ch := make(chan int)
    go func() {
        for val := range ch {
            process(val)
        }
    }()
    // ch を閉じないとゴルーチンが永久に残る
}

正解:

// OK: contextでキャンセル可能に
func properFunction(ctx context.Context) {
    ch := make(chan int)
    go func() {
        for {
            select {
            case val := <-ch:
                process(val)
            case <-ctx.Done():
                return // contextキャンセル時に終了
            }
        }
    }()
}

4. エラーハンドリングの不足

間違い:

// NG: エラーを無視
json.Marshal(message) // エラーチェックなし

正解:

// OK: 適切なエラーハンドリング
messageBytes, err := json.Marshal(message)
if err != nil {
    log.Printf("Marshal error: %v", err)
    return
}

---

ベンチマークとパフォーマンス分析

ベンチマークテスト

// hub_bench_test.go
package main

import (
    "testing"
    "time"
)

// ベンチマーク: メッセージブロードキャストのスループット
func BenchmarkBroadcast(b *testing.B) {
    hub := NewHub()
    go hub.Run()

    // 100クライアントを登録
    for i := 0; i < 100; i++ {
        client := &Client{
            hub:      hub,
            send:     make(chan []byte, 256),
            username: fmt.Sprintf("user%d", i),
            room:     "benchmark",
        }
        hub.register <- client
        go func() {
            for range client.send {
                // メッセージを消費
            }
        }()
    }

    time.Sleep(100 * time.Millisecond) // 登録完了を待つ

    b.ResetTimer() // タイマーリセット

    // ベンチマーク実行
    for i := 0; i < b.N; i++ {
        msg := Message{
            Content: "benchmark message",
            Room:    "benchmark",
        }
        hub.broadcast <- msg
    }
}

// 結果例:
// BenchmarkBroadcast-8   50000   25000 ns/op   1200 B/op   15 allocs/op
// → 1回のブロードキャストに25マイクロ秒、1200バイトのメモリアロケーション

プロファイリング

# CPU プロファイル取得
go test -bench=. -cpuprofile=cpu.prof

# プロファイル解析
go tool pprof cpu.prof

# インタラクティブモード
(pprof) top10        # 上位10件のCPU使用関数
(pprof) list Hub.Run # 特定関数の詳細
(pprof) web          # グラフィカル表示(Graphviz必要)

# メモリプロファイル
go test -bench=. -memprofile=mem.prof
go tool pprof -alloc_space mem.prof

---

プロダクションレディへの拡張

1. 設定管理

// config.go
package main

import (
    "encoding/json"
    "os"
)

type Config struct {
    ServerAddr      string        `json:"server_addr"`
    ReadTimeout     time.Duration `json:"read_timeout"`
    WriteTimeout    time.Duration `json:"write_timeout"`
    MaxMessageSize  int64         `json:"max_message_size"`
    HistorySize     int           `json:"history_size"`
    LogLevel        string        `json:"log_level"`
}

// LoadConfig は設定ファイルを読み込む
func LoadConfig(path string) (*Config, error) {
    file, err := os.Open(path)
    if err != nil {
        return nil, fmt.Errorf("failed to open config: %w", err)
    }
    defer file.Close()

    var config Config
    if err := json.NewDecoder(file).Decode(&config); err != nil {
        return nil, fmt.Errorf("failed to decode config: %w", err)
    }

    return &config, nil
}

2. 構造化ロギング

// logger.go
package main

import (
    "go.uber.org/zap"
)

var logger *zap.Logger

func InitLogger() error {
    var err error
    logger, err = zap.NewProduction()
    if err != nil {
        return err
    }
    return nil
}

// 使用例
logger.Info("Client registered",
    zap.String("username", client.username),
    zap.String("room", client.room),
    zap.Int("total_clients", len(h.rooms[client.room])),
)

3. メトリクス収集(Prometheus)

// metrics.go
package main

import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
)

var (
    // メッセージ総数カウンター
    messagesTotal = promauto.NewCounter(prometheus.CounterOpts{
        Name: "chat_messages_total",
        Help: "Total number of chat messages",
    })

    // 接続クライアント数ゲージ
    connectedClients = promauto.NewGauge(prometheus.GaugeOpts{
        Name: "chat_connected_clients",
        Help: "Number of connected clients",
    })

    // メッセージ処理時間ヒストグラム
    messageDuration = promauto.NewHistogram(prometheus.HistogramOpts{
        Name:    "chat_message_duration_seconds",
        Help:    "Message processing duration",
        Buckets: prometheus.DefBuckets,
    })
)

// 使用例
func (h *Hub) broadcastMessage(message Message) {
    timer := prometheus.NewTimer(messageDuration)
    defer timer.ObserveDuration()

    messagesTotal.Inc()
    // ... メッセージ処理 ...
}

4. ヘルスチェックエンドポイント

// health.go
func serveHealth(w http.ResponseWriter, r *http.Request) {
    health := map[string]interface{}{
        "status": "healthy",
        "uptime": time.Since(startTime).Seconds(),
        "version": "1.0.0",
    }
    json.NewEncoder(w).Encode(health)
}

// main.go に追加
http.HandleFunc("/health", serveHealth)
http.HandleFunc("/metrics", promhttp.Handler()) // Prometheusメトリクス

---

実行とテスト

依存パッケージのインストール

go mod init chat-server
go get github.com/gorilla/websocket
go get go.uber.org/zap  # 構造化ログ(オプション)
go get github.com/prometheus/client_golang/prometheus  # メトリクス(オプション)

サーバー起動

# 標準起動
go run *.go

# カスタムポート指定
go run *.go -addr=:9000

# バックグラウンド実行
nohup go run *.go &

# バイナリビルド
go build -o chat-server
./chat-server -addr=:8080

負荷テスト

# Apache Bench
ab -n 1000 -c 100 http://localhost:8080/

# WebSocket負荷テスト
# tools/loadtest.go を作成
go run tools/loadtest.go -clients=1000 -messages=10000

---

この解答例は、Go Piscine Experiencedで学んだ全ての概念を統合し、プロダクショングレードのアプリケーション開発へのステップを示しています。