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で学んだ全ての概念を統合し、プロダクショングレードのアプリケーション開発へのステップを示しています。