21
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

分散合意形成アルゴリズム『Raft』でゲームサーバーを冗長化してみた

Last updated at Posted at 2025-05-21

こんにちは!
株式会社OGIXのエンジニアH.Nです。

今日は、分散合意形成アルゴリズム『Raft』を使って、ゲームサーバーを冗長化してみたいと思います。

Raftとは?

複数のサーバーから構成される分散システム上で、サーバーどうしが『合意形成』をするために設計されたアルゴリズム。
リーダー1台と、フォロワーN台によって構成される。
リーダーは選挙によって選ばれる。
リーダー -> フォロワーでログを同期し、同じ状態に保つことができる。
Raftを構成するサーバーのうち、どれか1つが落ちたとしても、残ったサーバーで運用を継続することができる。リーダーが落ちた場合は新しいリーダーが選挙で選ばれる。また、落ちたサーバーが復帰した際、最新のログが同期され、データの整合性が保たれる。

Raftの仕組み

言葉で見てもチンプンカンプンな感じなので、学級委員の選挙に例えてみましょう。
Aクラスには太郎さん、剛さん、花子さんの3人の生徒がいて、学級委員を決めなければなりません。
学級委員の権力は絶大で、一般生徒は学級委員の指示にはすべて従います。

スクリーンショット 2025-05-21 172430.png

太郎さんが学級委員に立候補しました。
選挙のフェーズに入り、剛さんと花子さんは投票を行います。

投票の結果、無事に太郎さんが学級委員に当選しました。

スクリーンショット 2025-05-21 173846.png

太郎さんが学級委員となったので、剛さんと花子さんに指示を出します。
一定時間おきに連絡(ハートビート)し、2人が元気に登校しているかを確認。
サーバーの状態が変わったら、変わったことを2人に通知。
3人が一貫して同じ状態になるように、太郎さんは最善を尽くします。

しかし、ある日、太郎さんが風邪にかかってしまい、休んでしまいました。

スクリーンショット 2025-05-21 172702.png

太郎さんからハートビートが来ないので、剛さんと花子さんは困ります。
そこで、花子さんが新しい学級委員に立候補しました。
剛さんが投票を行い、無事に花子さんの当選が決定。

スクリーンショット 2025-05-21 173942.png

花子さんから剛さんにログが同期され、サーバーは変わらず運用されていきます。

そして、太郎さんが風邪から復帰しました。
太郎さんは教室に戻ると花子さんが学級委員になったことを知り、花子さんからログをもらって同期をとり、一般生徒として活動していきます。

スクリーンショット 2025-05-21 174026.png

Raftではこのように、選挙を行ってリーダーを決め、リーダー不在になったら新たなリーダーを選挙で選ぶことで、堅牢で一貫性のあるクラスターを構成することができます。

三目並べのゲームサーバーを作ってみる

では、Raftを使ってゲームサーバーを実装してみたいと思います!

今回作成するのは、簡単な三目並べのゲームサーバーです。

スクリーンショット 2025-05-21 174217.png

リーダー1台とフォロワー2台の構成。
プレーヤーはHttpでリーダーにアクセスし、ゲームの開始、マスの移動、ゲームの状態取得、などをリクエストします。
リーダーはリクエストを受信し、状態が更新されたことをフォロワーに通知。3台で状態を同期し続けます。
※この時、フォロワーは状態を変更する権限が無いので、フォロワーに来たリクエストはリーダーにリダイレクトするのがよいでしょう。

今回はRaftのためのサンプルですので、プレーヤー同士のマッチングやユーザー認証、リアルタイム通信などは省略します。

構成

Go言語 1.23
github.com/hashicorp/raft

Go言語でサーバー実装し、Raftの部分はhashicorpさんのライブラリを使用します。

ディレクトリ構成

├── server
│   └── server.go
├── tictactoe
│   ├── fsm.go
│   └── fsm_test.go
├── docker-compose.yml
├── Dockerfile
├── main.go
├── go.mod
└── README.md

serverディレクトリにサーバーコード、tictactoeディレクトリにゲームロジックを格納します。
docker-composeでRaftクラスタをまとめて起動できるようにします。

API設計

サーバーのHTTP APIを以下のように設計します。

ノード参加 API

POST /join

新しいノードをRaftクラスターに追加します。

リクエスト

{
  "node_id": "node2",
  "raft_addr": "127.0.0.1:50001"
}
フィールド 説明
node_id string 新しいノードの一意の識別子
raft_addr string 新しいノードのRaft通信アドレス

レスポンス

成功した場合、ステータスコード200を返します。

ゲーム管理 API

POST /game/start

新しい三目並べゲームを開始します。

リクエスト

{
  "game_id": "game1",
  "player1": "Alice",
  "player2": "Bob"
}
フィールド 説明
game_id string 新しいゲームの一意の識別子
player1 string 最初のプレイヤーの名前(先手)
player2 string 2番目のプレイヤーの名前(後手)

レスポンス

成功した場合、ステータスコード200と新しいゲームの状態を含むJSONオブジェクトを返します:

{
  "game_id": "game1",
  "player1": "Alice",
  "player2": "Bob",
  "board": [[0,0,0],[0,0,0],[0,0,0]],
  "next_turn": "Alice",
  "winner": "",
  "is_over": false,
  "created_at": 1634567890123
}
POST /game/move

ゲームで移動を行います。

リクエスト

{
  "game_id": "game1",
  "player": "Alice",
  "row": 0,
  "col": 0
}
フィールド 説明
game_id string ゲームの一意の識別子
player string 移動を行うプレイヤーの名前
row int 移動の行インデックス(0-2)
col int 移動の列インデックス(0-2)

レスポンス

成功した場合、ステータスコード200と更新されたゲームの状態を含むJSONオブジェクトを返します:

{
  "game_id": "game1",
  "player1": "Alice",
  "player2": "Bob",
  "board": [[1,0,0],[0,0,0],[0,0,0]],
  "next_turn": "Bob",
  "winner": "",
  "is_over": false,
  "created_at": 1634567890123
}
GET /game

ゲームの現在の状態を取得します。

リクエストパラメータ

パラメータ 説明
game_id string 取得するゲームの一意の識別子

レスポンス

成功した場合、ステータスコード200とゲームの状態を含むJSONオブジェクトを返します:

{
  "game_id": "game1",
  "player1": "Alice",
  "player2": "Bob",
  "board": [[1,0,0],[0,2,0],[0,0,1]],
  "next_turn": "Bob",
  "winner": "",
  "is_over": false,
  "created_at": 1634567890123
}
フィールド 説明
game_id string ゲームの一意の識別子
player1 string 最初のプレイヤーの名前
player2 string 2番目のプレイヤーの名前
board [3][3]int ゲームボード(0: 空, 1: Player1, 2: Player2)
next_turn string 次の手番のプレイヤー名
winner string 勝者のプレイヤー名(まだ勝者がいない場合は空)
is_over bool ゲームが終了したかどうか
created_at int64 ゲームが作成されたタイムスタンプ(ミリ秒)

コマンドライン引数

サーバー起動時に、Raftのノード間通信用のアドレス等を設定できるようにします。

引数 説明 デフォルト値
-id ノードの一意の識別子 node1
-raft Raftプロトコルのバインドアドレス(ノード間通信用) 127.0.0.1:50000
-http HTTP APIのバインドアドレス 127.0.0.1:8000
-join 参加する既存クラスターのノードアドレス(空の場合は新しいクラスターを作成) "" (空)

FSM(有限ステートマシン)の設計

三目並べを有限ステートマシンとして表現します。

                                  +----------------+
                                  |                |
                                  |  ゲーム未作成  |
                                  |                |
                                  +--------+-------+
                                           |
                                           | StartGame
                                           | コマンド
                                           v
+----------------+  Player1の移動  +----------------+
|                |  <------------- |                |
| Player2の番    |                 |  Player1の番   |
|                |  -------------> |                |
+----------------+  Player2の移動  +----------------+
        |                                  |
        |                                  |
        +------------+                     | 勝利条件
        |            |                     | 達成
        | 勝利条件   | マスが全て埋まる      v
        | 達成       | (勝者なし)         +----------------+
        v            v                     |                |
+----------------+  +----------------+     | Player1の勝利  |
|                |  |                |     | ゲーム終了     |
| Player2の勝利  |  | 引き分け      |       |                |
| ゲーム終了     |  | ゲーム終了     |       +----------------+
|                |  |                |
+----------------+  +----------------+

状態の説明

  • ゲーム未作成: 初期状態。ゲームはまだ作成されていません
  • Player1の番: Player1が次の手を打つ番です
  • Player2の番: Player2が次の手を打つ番です
  • Player1の勝利: Player1が勝利条件(縦、横、または対角線に3つ揃える)を達成し、ゲームが終了しました
  • Player2の勝利: Player2が勝利条件を達成し、ゲームが終了しました
  • 引き分け: ボードが埋まり、勝者がいない状態でゲームが終了しました

遷移の説明

  • StartGameコマンド: 新しいゲームを作成し、Player1が最初の手を打つ状態に移行します
  • Player1の移動: Player1が手を打ち、勝利条件を達成していなければPlayer2の番に移行します。勝利条件を達成した場合はPlayer1の勝利状態に移行します。ボードが埋まった場合は引き分け状態に移行します
  • Player2の移動: Player2が手を打ち、勝利条件を達成していなければPlayer1の番に移行します。勝利条件を達成した場合はPlayer2の勝利状態に移行します。ボードが埋まった場合は引き分け状態に移行します

ストレージ

Raftのログ永続化のため、以下のストレージを用意します。
BoltDBに格納します。

  • ログストレージ:Raftログエントリを保存
  • 安定ストレージ:現在の任期と投票などのRaftメタデータを保存
  • スナップショットストレージ:FSM状態の定期的なスナップショットを保存

クラスター形成

Raftクラスターは以下の順序で形成されます。

  1. ブートストラップ:最初のノードがクラスターをブートストラップ
  2. 参加:追加ノードが既存のクラスターに参加

ゲームロジックの実装

それでは早速、ゲームロジックの実装に取り掛かりましょう!
まずは必要な構造体を定義します。

構造体定義の作成
tictactoe/fsm.go
// コマンドタイプ
const (
	CommandTypeStartGame = "start_game" // ゲーム開始
	CommandTypeMove      = "move"       // マスの移動
)

// Command FSMに適用されるコマンド
type Command struct {
	Type string          `json:"type"`
	Data json.RawMessage `json:"data"`
}

// StartGameData ゲーム開始のデータ
// note 今回は最初から2人のプレーヤーがマッチングしているものとして実装します
type StartGameData struct {
	GameID  string `json:"game_id"`
	Player1 string `json:"player1"`
	Player2 string `json:"player2"`
}

// MoveData プレイヤー移動のデータ
type MoveData struct {
	GameID string `json:"game_id"`
	Player string `json:"player"`
	Row    int    `json:"row"`     // 配置したいマスのX座標
	Col    int    `json:"col"`     // 配置したいマスのy座標
}

// GameState ゲームの状態
type GameState struct {
	GameID    string    `json:"game_id"`
	Player1   string    `json:"player1"`
	Player2   string    `json:"player2"`
	Board     [3][3]int `json:"board"` // 盤面の状態を表す二重配列 0: 空, 1: Player1, 2: Player2
	NextTurn  string    `json:"next_turn"`
	Winner    string    `json:"winner"`
	IsOver    bool      `json:"is_over"`
	CreatedAt int64     `json:"created_at"`
}

// CommandResult コマンド実行の結果
type CommandResult struct {
	Game  *GameState
	Error error
}

必要そうな構造体を定義してみました。
次に、FSM(有限ステートマシン)として、ゲームロジックを実装していきましょう。

ゲームロジックの実装
tictactoe/fsm.go
package tictactoe

import (
	"encoding/json"
	"fmt"
	"io"
	"sync"

	"github.com/hashicorp/raft"
)

// FSM Tic-tac-toeゲームのFSM実装
type FSM struct {
	games map[string]*GameState // ゲームIDからゲーム状態へのマップ
	mu    sync.RWMutex          // gamesマップを保護するためのミューテックス
}

// NewFSM 新しいFSMを作成
func NewFSM() *FSM {
	return &FSM{
		games: make(map[string]*GameState),
	}
}

// Apply RaftログエントリをFSMに適用
func (f *FSM) Apply(log *raft.Log) interface{} {
	var cmd Command
	if err := json.Unmarshal(log.Data, &cmd); err != nil {
		fmt.Printf("コマンドのアンマーシャルエラー: %v\n", err)
		return nil
	}

	var result *CommandResult
	switch cmd.Type {
	case CommandTypeStartGame:
		result = f.applyStartGame(cmd.Data, log.Index)
	case CommandTypeMove:
		result = f.applyMove(cmd.Data)
	default:
		err := fmt.Errorf("不明なコマンドタイプ: %s", cmd.Type)
		fmt.Println(err)
		return nil
	}

	// エラーがあればnilを返す、なければゲーム状態を返す
	if result.Error != nil {
		return nil
	}
	return result.Game
}

// applyStartGame ゲーム開始コマンドを適用
func (f *FSM) applyStartGame(cmdData json.RawMessage, logIndex uint64) *CommandResult {
	var data StartGameData
	if err := json.Unmarshal(cmdData, &data); err != nil {
		err := fmt.Errorf("エラー: データのアンマーシャルに失敗しました: %v", err)
		fmt.Println(err)
		return &CommandResult{Game: nil, Error: err}
	}

	gameID := data.GameID
	if gameID == "" {
		err := fmt.Errorf("エラー: game_idが空です")
		fmt.Println(err)
		return &CommandResult{Game: nil, Error: err}
	}

	player1 := data.Player1
	if player1 == "" {
		err := fmt.Errorf("エラー: player1が空です")
		fmt.Println(err)
		return &CommandResult{Game: nil, Error: err}
	}

	player2 := data.Player2
	if player2 == "" {
		err := fmt.Errorf("エラー: player2が空です")
		fmt.Println(err)
		return &CommandResult{Game: nil, Error: err}
	}

	f.mu.Lock()
	defer f.mu.Unlock()

	// 既存のゲームをチェック
	if _, exists := f.games[gameID]; exists {
		err := fmt.Errorf("ゲームID %s は既に存在します", gameID)
		fmt.Println(err)
		return &CommandResult{Game: nil, Error: err}
	}

	// 新しいゲームを作成
	game := &GameState{
		GameID:    gameID,
		Player1:   player1,
		Player2:   player2,
		Board:     [3][3]int{},
		NextTurn:  player1,
		Winner:    "",
		IsOver:    false,
		CreatedAt: time.Now().UnixMilli(),
	}

	f.games[gameID] = game
	fmt.Printf("新しいゲームを作成しました: %s\n", gameID)

	// 初期盤面の状態をアスキーアートで表示
	fmt.Printf("ゲームID %s の初期盤面状態:\n", gameID)
	printBoardAsASCII(game.Board)

	return &CommandResult{Game: game, Error: nil}
}

// applyMove プレイヤーの移動コマンドを適用
func (f *FSM) applyMove(cmdData json.RawMessage) *CommandResult {
	var data MoveData
	if err := json.Unmarshal(cmdData, &data); err != nil {
		err := fmt.Errorf("エラー: データのアンマーシャルに失敗しました: %v", err)
		fmt.Println(err)
		return &CommandResult{Game: nil, Error: err}
	}

	gameID := data.GameID
	if gameID == "" {
		err := fmt.Errorf("エラー: game_idが空です")
		fmt.Println(err)
		return &CommandResult{Game: nil, Error: err}
	}

	player := data.Player
	if player == "" {
		err := fmt.Errorf("エラー: playerが空です")
		fmt.Println(err)
		return &CommandResult{Game: nil, Error: err}
	}

	row := data.Row
	col := data.Col

	f.mu.Lock()
	defer f.mu.Unlock()

	// ゲームの存在をチェック
	game, exists := f.games[gameID]
	if !exists {
		err := fmt.Errorf("ゲームID %s が見つかりません", gameID)
		fmt.Println(err)
		return &CommandResult{Game: nil, Error: err}
	}

	// ゲームが終了していないことを確認
	if game.IsOver {
		err := fmt.Errorf("ゲームID %s は既に終了しています", gameID)
		fmt.Println(err)
		return &CommandResult{Game: nil, Error: err}
	}

	// プレイヤーのターンであることを確認
	if game.NextTurn != player {
		err := fmt.Errorf("プレイヤー %s のターンではありません", player)
		fmt.Println(err)
		return &CommandResult{Game: nil, Error: err}
	}

	// 移動が有効であることを確認
	if row < 0 || row > 2 || col < 0 || col > 2 {
		err := fmt.Errorf("無効な移動: (%d, %d)", row, col)
		fmt.Println(err)
		return &CommandResult{Game: nil, Error: err}
	}

	// セルが空であることを確認
	if game.Board[row][col] != 0 {
		err := fmt.Errorf("セル (%d, %d) は既に占有されています", row, col)
		fmt.Println(err)
		return &CommandResult{Game: nil, Error: err}
	}

	// 移動を適用
	playerValue := 1
	if player == game.Player2 {
		playerValue = 2
	}
	game.Board[row][col] = playerValue

	// 盤面の状態をアスキーアートで表示
	fmt.Printf("ゲームID %s の盤面状態:\n", gameID)
	printBoardAsASCII(game.Board)

	// 勝者をチェック
	winner := f.checkWinner(game)
	if winner != "" {
		game.Winner = winner
		game.IsOver = true
		fmt.Printf("ゲームID %s の勝者: %s\n", gameID, winner)
	} else if f.isBoardFull(game) {
		// 引き分けをチェック
		game.IsOver = true
		fmt.Printf("ゲームID %s は引き分けです\n", gameID)
	} else {
		// 次のプレイヤーのターン
		if player == game.Player1 {
			game.NextTurn = game.Player2
		} else {
			game.NextTurn = game.Player1
		}
	}

	return &CommandResult{Game: game, Error: nil}
}

// checkWinner ゲームの勝者をチェック
func (f *FSM) checkWinner(game *GameState) string {
	// 行をチェック
	for i := 0; i < 3; i++ {
		if game.Board[i][0] != 0 && game.Board[i][0] == game.Board[i][1] && game.Board[i][1] == game.Board[i][2] {
			if game.Board[i][0] == 1 {
				return game.Player1
			} else {
				return game.Player2
			}
		}
	}

	// 列をチェック
	for i := 0; i < 3; i++ {
		if game.Board[0][i] != 0 && game.Board[0][i] == game.Board[1][i] && game.Board[1][i] == game.Board[2][i] {
			if game.Board[0][i] == 1 {
				return game.Player1
			} else {
				return game.Player2
			}
		}
	}

	// 対角線をチェック
	if game.Board[0][0] != 0 && game.Board[0][0] == game.Board[1][1] && game.Board[1][1] == game.Board[2][2] {
		if game.Board[0][0] == 1 {
			return game.Player1
		} else {
			return game.Player2
		}
	}

	if game.Board[0][2] != 0 && game.Board[0][2] == game.Board[1][1] && game.Board[1][1] == game.Board[2][0] {
		if game.Board[0][2] == 1 {
			return game.Player1
		} else {
			return game.Player2
		}
	}

	return ""
}

// isBoardFull ボードが埋まっているかどうかをチェック
func (f *FSM) isBoardFull(game *GameState) bool {
	for i := 0; i < 3; i++ {
		for j := 0; j < 3; j++ {
			if game.Board[i][j] == 0 {
				return false
			}
		}
	}
	return true
}

// GetGame ゲームIDに基づいてゲーム状態を返します
// ディープコピーを返すことで、外部からの変更が内部状態に影響しないようにします
func (f *FSM) GetGame(gameID string) *GameState {
	f.mu.RLock()
	defer f.mu.RUnlock()

	game, exists := f.games[gameID]
	if !exists {
		return nil
	}

	// ディープコピーを作成
	gameCopy := &GameState{
		GameID:    game.GameID,
		Player1:   game.Player1,
		Player2:   game.Player2,
		NextTurn:  game.NextTurn,
		Winner:    game.Winner,
		IsOver:    game.IsOver,
		CreatedAt: game.CreatedAt,
	}

	// ボードの2次元配列をコピー
	for i := 0; i < 3; i++ {
		for j := 0; j < 3; j++ {
			gameCopy.Board[i][j] = game.Board[i][j]
		}
	}

	return gameCopy
}

// Snapshot FSMの状態のスナップショットを返す
func (f *FSM) Snapshot() (raft.FSMSnapshot, error) {
	f.mu.RLock()
	defer f.mu.RUnlock()

	// gamesマップのコピーを作成
	games := make(map[string]*GameState)
	for k, v := range f.games {
		games[k] = v
	}

	return &FSMSnapshot{
		games: games,
	}, nil
}

// Restore スナップショットからFSMの状態を復元
func (f *FSM) Restore(rc io.ReadCloser) error {
	defer rc.Close()

	data, err := io.ReadAll(rc)
	if err != nil {
		return err
	}

	var games map[string]*GameState
	if err := json.Unmarshal(data, &games); err != nil {
		return err
	}

	f.mu.Lock()
	defer f.mu.Unlock()

	f.games = games
	return nil
}

// printBoardAsASCII 盤面の状態をアスキーアートとして標準出力に表示
func printBoardAsASCII(board [3][3]int) {
	fmt.Println("\n┌───┬───┬───┐")
	for i := 0; i < 3; i++ {
		fmt.Print("│")
		for j := 0; j < 3; j++ {
			symbol := " "
			if board[i][j] == 1 {
				symbol = "X"
			} else if board[i][j] == 2 {
				symbol = "O"
			}
			fmt.Printf(" %s │", symbol)
		}
		if i < 2 {
			fmt.Println("\n├───┼───┼───┤")
		} else {
			fmt.Println("\n└───┴───┴───┘")
		}
	}
	fmt.Println()
}

// FSMSnapshot FSMの状態のスナップショット
type FSMSnapshot struct {
	games map[string]*GameState
}

// Persist スナップショットを指定されたシンクに書き込み
func (f *FSMSnapshot) Persist(sink raft.SnapshotSink) error {
	data, err := json.Marshal(f.games)
	if err != nil {
		sink.Cancel()
		return err
	}

	if _, err := sink.Write(data); err != nil {
		sink.Cancel()
		return err
	}

	return sink.Close()
}

// Release スナップショットに関連するリソースを解放
func (f *FSMSnapshot) Release() {}

Raftでは、このような有限ステートマシンが非常に重要になります。
ゲームロジックを有限ステートマシンとして定義し、ログから状態を更新していく仕組みにすることで、同じログなら同じ状態が担保できるようになり、ログの共有によって複数のサーバーの状態を同期することが可能になります。

一応、ゲームロジックのテストを書いておきましょう。

テストコード
tictactoe/fsm_test.go
package tictactoe

import (
	"encoding/json"
	"io"
	"testing"

	"github.com/hashicorp/raft"
)

// TestGetGameDeepCopy GetGameのディープコピーのテスト
func TestGetGameDeepCopy(t *testing.T) {
	// FSMの新しいインスタンスを作成
	fsm := NewFSM()

	// ゲーム状態を作成
	gameID := "test-game"
	player1 := "player1"
	player2 := "player2"

	// FSMにゲームを追加
	game := &GameState{
		GameID:    gameID,
		Player1:   player1,
		Player2:   player2,
		Board:     [3][3]int{},
		NextTurn:  player1,
		Winner:    "",
		IsOver:    false,
		CreatedAt: 1,
	}

	// ボードに値を設定
	game.Board[0][0] = 1

	// FSMの内部状態にゲームを追加
	fsm.games[gameID] = game

	// ゲームのコピーを取得
	gameCopy := fsm.GetGame(gameID)

	// コピーが同じ値を持っていることを確認
	if gameCopy.GameID != gameID {
		t.Errorf("GameIDが期待値と異なります。期待: %s, 実際: %s", gameID, gameCopy.GameID)
	}
	if gameCopy.Board[0][0] != 1 {
		t.Errorf("Board[0][0]が期待値と異なります。期待: 1, 実際: %d", gameCopy.Board[0][0])
	}

	// コピーを変更
	gameCopy.Board[0][0] = 2
	gameCopy.NextTurn = player2

	// 元のゲームを再度取得
	originalGame := fsm.games[gameID]

	// 元のゲームが変更されていないことを確認
	if originalGame.Board[0][0] != 1 {
		t.Errorf("元のゲームが変更されています!期待: 1, 実際: %d", originalGame.Board[0][0])
	}
	if originalGame.NextTurn != player1 {
		t.Errorf("元のゲームが変更されています!期待: %s, 実際: %s", player1, originalGame.NextTurn)
	}

	// コピーが変更されていることを確認
	if gameCopy.Board[0][0] != 2 {
		t.Errorf("コピーが変更されていません!期待: 2, 実際: %d", gameCopy.Board[0][0])
	}
	if gameCopy.NextTurn != player2 {
		t.Errorf("コピーが変更されていません!期待: %s, 実際: %s", player2, gameCopy.NextTurn)
	}
}

// TestCheckWinner 勝者判定ロジックをテストします
func TestCheckWinner(t *testing.T) {
	// FSMの新しいインスタンスを作成
	fsm := NewFSM()

	// テストケース
	testCases := []struct {
		name     string
		board    [3][3]int
		player1  string
		player2  string
		expected string
	}{
		{
			name: "横一列でPlayer1が勝利",
			board: [3][3]int{
				{1, 1, 1},
				{0, 2, 0},
				{2, 0, 2},
			},
			player1:  "player1",
			player2:  "player2",
			expected: "player1",
		},
		{
			name: "縦一列でPlayer2が勝利",
			board: [3][3]int{
				{1, 2, 0},
				{0, 2, 1},
				{1, 2, 0},
			},
			player1:  "player1",
			player2:  "player2",
			expected: "player2",
		},
		{
			name: "対角線でPlayer1が勝利",
			board: [3][3]int{
				{1, 0, 2},
				{0, 1, 2},
				{2, 0, 1},
			},
			player1:  "player1",
			player2:  "player2",
			expected: "player1",
		},
		{
			name: "勝者なし",
			board: [3][3]int{
				{1, 2, 1},
				{2, 1, 2},
				{2, 1, 0},
			},
			player1:  "player1",
			player2:  "player2",
			expected: "",
		},
	}

	// 各テストケースを実行
	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			game := &GameState{
				Player1: tc.player1,
				Player2: tc.player2,
				Board:   tc.board,
			}

			winner := fsm.checkWinner(game)
			if winner != tc.expected {
				t.Errorf("勝者が期待値と異なります。期待: %s, 実際: %s", tc.expected, winner)
			}
		})
	}
}

// TestIsBoardFull ボードが埋まっているかどうかのチェックをテストします
func TestIsBoardFull(t *testing.T) {
	// FSMの新しいインスタンスを作成
	fsm := NewFSM()

	// テストケース
	testCases := []struct {
		name     string
		board    [3][3]int
		expected bool
	}{
		{
			name: "空のボード",
			board: [3][3]int{
				{0, 0, 0},
				{0, 0, 0},
				{0, 0, 0},
			},
			expected: false,
		},
		{
			name: "部分的に埋まったボード",
			board: [3][3]int{
				{1, 2, 1},
				{2, 1, 0},
				{0, 1, 2},
			},
			expected: false,
		},
		{
			name: "完全に埋まったボード",
			board: [3][3]int{
				{1, 2, 1},
				{2, 1, 2},
				{1, 1, 2},
			},
			expected: true,
		},
	}

	// 各テストケースを実行
	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			game := &GameState{
				Board: tc.board,
			}

			isFull := fsm.isBoardFull(game)
			if isFull != tc.expected {
				t.Errorf("ボードの状態が期待値と異なります。期待: %v, 実際: %v", tc.expected, isFull)
			}
		})
	}
}

// TestApplyStartGame ゲーム開始コマンドの適用をテストします
func TestApplyStartGame(t *testing.T) {
	// FSMの新しいインスタンスを作成
	fsm := NewFSM()

	// 有効なゲーム開始コマンドを作成
	gameID := "test-game"
	player1 := "player1"
	player2 := "player2"

	startGameData := StartGameData{
		GameID:  gameID,
		Player1: player1,
		Player2: player2,
	}

	// データをJSON形式にシリアライズ
	cmdDataBytes, err := json.Marshal(startGameData)
	if err != nil {
		t.Fatalf("データのシリアライズに失敗しました: %v", err)
	}
	cmdData := json.RawMessage(cmdDataBytes)

	// コマンドを適用
	result := fsm.applyStartGame(cmdData, 1)

	// 結果を検証
	if result.Error != nil {
		t.Errorf("ゲーム開始コマンドの適用に失敗しました: %v", result.Error)
	}

	if result.Game.GameID != gameID {
		t.Errorf("GameIDが期待値と異なります。期待: %s, 実際: %s", gameID, result.Game.GameID)
	}

	if result.Game.Player1 != player1 {
		t.Errorf("Player1が期待値と異なります。期待: %s, 実際: %s", player1, result.Game.Player1)
	}

	if result.Game.Player2 != player2 {
		t.Errorf("Player2が期待値と異なります。期待: %s, 実際: %s", player2, result.Game.Player2)
	}

	if result.Game.NextTurn != player1 {
		t.Errorf("NextTurnが期待値と異なります。期待: %s, 実際: %s", player1, result.Game.NextTurn)
	}

	// 同じゲームIDで再度適用(エラーになるはず)
	result = fsm.applyStartGame(cmdData, 2)
	if result.Error == nil {
		t.Errorf("既存のゲームIDでエラーが発生しませんでした")
	}
}

// TestApplyMove プレイヤーの移動コマンドの適用をテストします
func TestApplyMove(t *testing.T) {
	// FSMの新しいインスタンスを作成
	fsm := NewFSM()

	// ゲームを作成
	gameID := "test-game"
	player1 := "player1"
	player2 := "player2"

	game := &GameState{
		GameID:    gameID,
		Player1:   player1,
		Player2:   player2,
		Board:     [3][3]int{},
		NextTurn:  player1,
		Winner:    "",
		IsOver:    false,
		CreatedAt: 1,
	}

	fsm.games[gameID] = game

	// 有効な移動コマンドを作成
	moveData := MoveData{
		GameID: gameID,
		Player: player1,
		Row:    0,
		Col:    0,
	}

	// データをJSON形式にシリアライズ
	cmdDataBytes, err := json.Marshal(moveData)
	if err != nil {
		t.Fatalf("データのシリアライズに失敗しました: %v", err)
	}
	cmdData := json.RawMessage(cmdDataBytes)

	// コマンドを適用
	result := fsm.applyMove(cmdData)

	// 結果を検証
	if result.Error != nil {
		t.Errorf("移動コマンドの適用に失敗しました: %v", result.Error)
	}

	if result.Game.Board[0][0] != 1 {
		t.Errorf("ボードの状態が期待値と異なります。期待: 1, 実際: %d", result.Game.Board[0][0])
	}

	if result.Game.NextTurn != player2 {
		t.Errorf("NextTurnが期待値と異なります。期待: %s, 実際: %s", player2, result.Game.NextTurn)
	}

	// 不正なプレイヤーの移動(順番外)
	invalidPlayerMove := MoveData{
		GameID: gameID,
		Player: player1, // player2の番
		Row:    0,
		Col:    1,
	}

	// データをJSON形式にシリアライズ
	invalidPlayerMoveBytes, err := json.Marshal(invalidPlayerMove)
	if err != nil {
		t.Fatalf("データのシリアライズに失敗しました: %v", err)
	}
	cmdData = json.RawMessage(invalidPlayerMoveBytes)

	result = fsm.applyMove(cmdData)
	if result.Error == nil {
		t.Errorf("不正なプレイヤーの移動でエラーが発生しませんでした")
	}

	// 既に埋まっているセルへの移動
	occupiedCellMove := MoveData{
		GameID: gameID,
		Player: player2,
		Row:    0,
		Col:    0, // 既に埋まっている
	}

	// データをJSON形式にシリアライズ
	occupiedCellMoveBytes, err := json.Marshal(occupiedCellMove)
	if err != nil {
		t.Fatalf("データのシリアライズに失敗しました: %v", err)
	}
	cmdData = json.RawMessage(occupiedCellMoveBytes)

	result = fsm.applyMove(cmdData)
	if result.Error == nil {
		t.Errorf("既に埋まっているセルへの移動でエラーが発生しませんでした")
	}
}

// TestApply Raftログエントリの適用をテストします
func TestApply(t *testing.T) {
	// FSMの新しいインスタンスを作成
	fsm := NewFSM()

	// ゲーム開始コマンドを作成
	gameID := "test-game"
	player1 := "player1"
	player2 := "player2"

	startGameData := StartGameData{
		GameID:  gameID,
		Player1: player1,
		Player2: player2,
	}

	// データをJSON形式にシリアライズ
	startGameDataBytes, err := json.Marshal(startGameData)
	if err != nil {
		t.Fatalf("データのシリアライズに失敗しました: %v", err)
	}

	startGameCmd := Command{
		Type: CommandTypeStartGame,
		Data: json.RawMessage(startGameDataBytes),
	}

	// コマンドをJSONにシリアライズ
	startGameCmdBytes, err := json.Marshal(startGameCmd)
	if err != nil {
		t.Fatalf("コマンドのシリアライズに失敗しました: %v", err)
	}

	// Raftログエントリを作成
	log := &raft.Log{
		Index: 1,
		Data:  startGameCmdBytes,
	}

	// コマンドを適用
	result := fsm.Apply(log)

	// 結果を検証
	game, ok := result.(*GameState)
	if !ok {
		t.Fatalf("結果がGameStateではありません")
	}

	if game.GameID != gameID {
		t.Errorf("GameIDが期待値と異なります。期待: %s, 実際: %s", gameID, game.GameID)
	}

	// 移動コマンドを作成
	moveData := MoveData{
		GameID: gameID,
		Player: player1,
		Row:    0,
		Col:    0,
	}

	// データをJSON形式にシリアライズ
	moveDataBytes, err := json.Marshal(moveData)
	if err != nil {
		t.Fatalf("データのシリアライズに失敗しました: %v", err)
	}

	moveCmd := Command{
		Type: CommandTypeMove,
		Data: json.RawMessage(moveDataBytes),
	}

	// コマンドをJSONにシリアライズ
	moveCmdBytes, err := json.Marshal(moveCmd)
	if err != nil {
		t.Fatalf("コマンドのシリアライズに失敗しました: %v", err)
	}

	// Raftログエントリを作成
	log = &raft.Log{
		Index: 2,
		Data:  moveCmdBytes,
	}

	// コマンドを適用
	result = fsm.Apply(log)

	// 結果を検証
	game, ok = result.(*GameState)
	if !ok {
		t.Fatalf("結果がGameStateではありません")
	}

	if game.Board[0][0] != 1 {
		t.Errorf("ボードの状態が期待値と異なります。期待: 1, 実際: %d", game.Board[0][0])
	}

	// 不明なコマンドタイプを作成
	// 空のJSONオブジェクトを作成
	emptyJSON := []byte("{}")

	unknownCmd := Command{
		Type: "unknown_command",
		Data: json.RawMessage(emptyJSON),
	}

	// コマンドをJSONにシリアライズ
	unknownData, err := json.Marshal(unknownCmd)
	if err != nil {
		t.Fatalf("コマンドのシリアライズに失敗しました: %v", err)
	}

	// Raftログエントリを作成
	log = &raft.Log{
		Index: 3,
		Data:  unknownData,
	}

	// コマンドを適用
	result = fsm.Apply(log)

	// 結果を検証(不明なコマンドタイプではnilが返される)
	if result != nil {
		t.Errorf("不明なコマンドタイプでnilが返されませんでした")
	}
}

// TestSnapshotAndRestore スナップショットの作成と復元をテストします
func TestSnapshotAndRestore(t *testing.T) {
	// FSMの新しいインスタンスを作成
	fsm := NewFSM()

	// ゲームを作成して追加
	gameID := "test-game"
	player1 := "player1"
	player2 := "player2"

	game := &GameState{
		GameID:    gameID,
		Player1:   player1,
		Player2:   player2,
		Board:     [3][3]int{{1, 0, 0}, {0, 2, 0}, {0, 0, 1}},
		NextTurn:  player2,
		Winner:    "",
		IsOver:    false,
		CreatedAt: 1,
	}

	fsm.games[gameID] = game

	// スナップショットを作成
	snapshot, err := fsm.Snapshot()
	if err != nil {
		t.Fatalf("スナップショットの作成に失敗しました: %v", err)
	}

	// 新しいFSMを作成(復元先)
	newFSM := NewFSM()

	// スナップショットをJSONにシリアライズ
	fsmSnapshot, ok := snapshot.(*FSMSnapshot)
	if !ok {
		t.Fatalf("スナップショットの型が正しくありません")
	}

	data, err := json.Marshal(fsmSnapshot.games)
	if err != nil {
		t.Fatalf("スナップショットのシリアライズに失敗しました: %v", err)
	}

	// io.ReadCloserを作成
	rc := &testReadCloser{data: data}

	// スナップショットを復元
	err = newFSM.Restore(rc)
	if err != nil {
		t.Fatalf("スナップショットの復元に失敗しました: %v", err)
	}

	// 復元されたゲームを検証
	restoredGame := newFSM.GetGame(gameID)
	if restoredGame == nil {
		t.Fatalf("ゲームが復元されませんでした")
	}

	if restoredGame.GameID != gameID {
		t.Errorf("GameIDが期待値と異なります。期待: %s, 実際: %s", gameID, restoredGame.GameID)
	}

	if restoredGame.Board[0][0] != 1 || restoredGame.Board[1][1] != 2 || restoredGame.Board[2][2] != 1 {
		t.Errorf("ボードの状態が期待値と異なります")
	}

	if restoredGame.NextTurn != player2 {
		t.Errorf("NextTurnが期待値と異なります。期待: %s, 実際: %s", player2, restoredGame.NextTurn)
	}
}

// テスト用のReadCloser
type testReadCloser struct {
	data []byte
	pos  int
}

func (r *testReadCloser) Read(p []byte) (n int, err error) {
	if r.pos >= len(r.data) {
		return 0, io.EOF
	}
	n = copy(p, r.data[r.pos:])
	r.pos += n
	return n, nil
}

func (r *testReadCloser) Close() error {
	return nil
}

考えられそうなパターンについてテストができたのではないでしょうか!

サーバーコードの実装

サーバーコードを書いていきます。
今回は、標準のnet/httpで簡易的に実装します。

サーバーコードの実装
server/server.go
package server

import (
	"encoding/json"
	"fmt"
	"net/http"
	"time"

	"Raft/tictactoe"

	"github.com/hashicorp/raft"
)

// Server RaftノードのHTTPサーバー
type Server struct {
	Raft *raft.Raft
	FSM  *tictactoe.FSM
}

// JoinRequest クラスターへの参加リクエスト
type JoinRequest struct {
	NodeID   string `json:"node_id"`
	RaftAddr string `json:"raft_addr"`
}

// StartGameRequest ゲーム開始リクエスト
type StartGameRequest struct {
	GameID  string `json:"game_id"`
	Player1 string `json:"player1"`
	Player2 string `json:"player2"`
}

// MoveRequest プレイヤーの移動リクエスト
type MoveRequest struct {
	GameID string `json:"game_id"`
	Player string `json:"player"`
	Row    int    `json:"row"`
	Col    int    `json:"col"`
}

// NewServer 新しいサーバーを作成
func NewServer(raft *raft.Raft, fsm *tictactoe.FSM) *Server {
	return &Server{
		Raft: raft,
		FSM:  fsm,
	}
}

// HandleJoin クラスターへの参加リクエストを処理
func (s *Server) HandleJoin(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}

	// このノードがリーダーかどうかを確認
	if s.Raft.State() != raft.Leader {
		// リーダーでない場合はリーダーに転送
		leader := s.Raft.Leader()
		if leader == "" {
			w.WriteHeader(http.StatusServiceUnavailable)
			fmt.Fprint(w, "利用可能なリーダーがありません")
			return
		}

		// リーダーにリダイレクト
		w.Header().Set("Location", fmt.Sprintf("http://%s/join", leader))
		w.WriteHeader(http.StatusTemporaryRedirect)
		return
	}

	var req JoinRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		fmt.Fprintf(w, "リクエストのデコードエラー: %v", err)
		return
	}

	// クラスターにノードを追加
	f := s.Raft.AddVoter(raft.ServerID(req.NodeID), raft.ServerAddress(req.RaftAddr), 0, 0)
	if err := f.Error(); err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		fmt.Fprintf(w, "投票者追加エラー: %v", err)
		return
	}

	w.WriteHeader(http.StatusOK)
}

// HandleStartGame ゲーム開始リクエストを処理
func (s *Server) HandleStartGame(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}

	// このノードがリーダーかどうかを確認
	if s.Raft.State() != raft.Leader {
		// リーダーでない場合はリーダーに転送
		leader := s.Raft.Leader()
		if leader == "" {
			w.WriteHeader(http.StatusServiceUnavailable)
			fmt.Fprint(w, "利用可能なリーダーがありません")
			return
		}

		// リーダーにリダイレクト
		w.Header().Set("Location", fmt.Sprintf("http://%s/game/start", leader))
		w.WriteHeader(http.StatusTemporaryRedirect)
		return
	}

	var req StartGameRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		fmt.Fprintf(w, "リクエストのデコードエラー: %v", err)
		return
	}

	// コマンドを作成
	startDataBytes, err := json.Marshal(tictactoe.StartGameData{
		GameID:  req.GameID,
		Player1: req.Player1,
		Player2: req.Player2,
	})
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		fmt.Fprintf(w, "startGameData marshal error: %v", err)
		return
	}
	cmd := tictactoe.Command{
		Type: tictactoe.CommandTypeStartGame,
		Data: json.RawMessage(startDataBytes),
	}

	// JSONに変換
	jsonData, err := json.Marshal(cmd)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		fmt.Fprintf(w, "コマンドのマーシャルエラー: %v", err)
		return
	}

	// Raftに適用
	f := s.Raft.Apply(jsonData, 5*time.Second)
	if err := f.Error(); err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		fmt.Fprintf(w, "コマンド適用エラー: %v", err)
		return
	}

	// 新しいゲームの状態を取得
	game := s.FSM.GetGame(req.GameID)
	if game == nil {
		w.WriteHeader(http.StatusInternalServerError)
		fmt.Fprintf(w, "ゲームの作成に失敗しました")
		return
	}

	// JSONに変換
	jsonData, err = json.Marshal(game)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		fmt.Fprintf(w, "ゲーム状態のマーシャルエラー: %v", err)
		return
	}

	// コンテンツタイプを設定してレスポンスを書き込む
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	w.Write(jsonData)
}

// HandleMove プレイヤーの移動リクエストを処理
func (s *Server) HandleMove(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}

	// このノードがリーダーかどうかを確認
	if s.Raft.State() != raft.Leader {
		// リーダーでない場合はリーダーに転送
		leader := s.Raft.Leader()
		if leader == "" {
			w.WriteHeader(http.StatusServiceUnavailable)
			fmt.Fprint(w, "利用可能なリーダーがありません")
			return
		}

		// リーダーにリダイレクト
		w.Header().Set("Location", fmt.Sprintf("http://%s/game/move", leader))
		w.WriteHeader(http.StatusTemporaryRedirect)
		return
	}

	var req MoveRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		fmt.Fprintf(w, "リクエストのデコードエラー: %v", err)
		return
	}

	// コマンドを作成
	moveDataBytes, err := json.Marshal(tictactoe.MoveData{
		GameID: req.GameID,
		Player: req.Player,
		Row:    req.Row,
		Col:    req.Col,
	})
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		fmt.Fprintf(w, "移動データのマーシャルエラー: %v", err)
		return
	}
	cmd := tictactoe.Command{
		Type: tictactoe.CommandTypeMove,
		Data: json.RawMessage(moveDataBytes),
	}

	// JSONに変換
	jsonData, err := json.Marshal(cmd)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		fmt.Fprintf(w, "コマンドのマーシャルエラー: %v", err)
		return
	}

	// Raftに適用
	f := s.Raft.Apply(jsonData, 5*time.Second)
	if err := f.Error(); err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		fmt.Fprintf(w, "コマンド適用エラー: %v", err)
		return
	}

	// 更新されたゲームの状態を取得
	game := s.FSM.GetGame(req.GameID)
	if game == nil {
		w.WriteHeader(http.StatusNotFound)
		fmt.Fprintf(w, "ゲームが見つかりません")
		return
	}

	// JSONに変換
	jsonData, err = json.Marshal(game)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		fmt.Fprintf(w, "ゲーム状態のマーシャルエラー: %v", err)
		return
	}

	// コンテンツタイプを設定してレスポンスを書き込む
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	w.Write(jsonData)
}

// HandleGetGame ゲーム状態取得リクエストを処理
func (s *Server) HandleGetGame(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}

	// URLからゲームIDを取得
	gameID := r.URL.Query().Get("game_id")
	if gameID == "" {
		w.WriteHeader(http.StatusBadRequest)
		fmt.Fprintf(w, "ゲームIDが指定されていません")
		return
	}

	// ゲームの状態を取得
	game := s.FSM.GetGame(gameID)
	if game == nil {
		w.WriteHeader(http.StatusNotFound)
		fmt.Fprintf(w, "ゲームが見つかりません")
		return
	}

	// JSONに変換
	jsonData, err := json.Marshal(game)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		fmt.Fprintf(w, "ゲーム状態のマーシャルエラー: %v", err)
		return
	}

	// コンテンツタイプを設定してレスポンスを書き込む
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	w.Write(jsonData)
}

// SetupRoutes HTTPルートを設定します
func (s *Server) SetupRoutes() {
	http.HandleFunc("/join", s.HandleJoin)
	http.HandleFunc("/game/start", s.HandleStartGame)
	http.HandleFunc("/game/move", s.HandleMove)
	http.HandleFunc("/game", s.HandleGetGame)
}

クライアントからのHttpリクエストを受け付け、FSMの状態を更新するコードができました。

エントリーポイントを実装

それでは、このサーバープログラムのエントリーポイントを実装していきましょう。
まずはRaft起動用の関数を用意します。

Raft起動用の関数
main.go
// RaftComponents Raftの初期化で生成される全てのコンポーネントを保持する構造体
type RaftComponents struct {
	Raft        *raft.Raft
	FSM         *tictactoe.FSM
	LogStore    *raftboltdb.BoltStore
	StableStore *raftboltdb.BoltStore
	Config      *raft.Config
	Transport   raft.Transport
}

// Raftの初期化を行う関数
func initRaft(nodeID, raftAddr string) (*RaftComponents, error) {
	// Raft設定の作成
	config := raft.DefaultConfig()
	config.LocalID = raft.ServerID(nodeID)

	// Raftデータ用のディレクトリを作成
	raftDir := filepath.Join("raft-data", nodeID)
	os.MkdirAll(raftDir, 0755)

	// Raftログストアの作成
	logStore, err := raftboltdb.NewBoltStore(filepath.Join(raftDir, "logs.dat"))
	if err != nil {
		return nil, fmt.Errorf("ログストア作成エラー: %v", err)
	}

	// Raft安定ストアの作成
	stableStore, err := raftboltdb.NewBoltStore(filepath.Join(raftDir, "stable.dat"))
	if err != nil {
		return nil, fmt.Errorf("安定ストア作成エラー: %v", err)
	}

	// Raftスナップショットストアの作成
	snapshotStore, err := raft.NewFileSnapshotStore(raftDir, 3, os.Stderr)
	if err != nil {
		return nil, fmt.Errorf("スナップショットストア作成エラー: %v", err)
	}

	// Raftトランスポートの作成
	addr, err := net.ResolveTCPAddr("tcp", raftAddr)
	if err != nil {
		return nil, fmt.Errorf("TCPアドレス解決エラー: %v", err)
	}
	transport, err := raft.NewTCPTransport(raftAddr, addr, 3, 10*time.Second, os.Stderr)
	if err != nil {
		return nil, fmt.Errorf("TCPトランスポート作成エラー: %v", err)
	}

	// FSMの作成
	fsm := tictactoe.NewFSM()

	// Raftインスタンスの作成
	r, err := raft.NewRaft(config, fsm, logStore, stableStore, snapshotStore, transport)
	if err != nil {
		return nil, fmt.Errorf("Raft作成エラー: %v", err)
	}

	// RaftComponentsを作成して返す
	components := &RaftComponents{
		Raft:        r,
		FSM:         fsm,
		LogStore:    logStore,
		StableStore: stableStore,
		Config:      config,
		Transport:   transport,
	}

	return components, nil
}

次にHttpサーバー起動用の関数。

Httpサーバー起動用の関数
main.go
// HTTPサーバーの初期化と起動を行う関数
func initHttpServer(components *RaftComponents, httpAddr string) (*http.Server, error) {
	// HTTPサーバーの作成
	srv := server.NewServer(components.Raft, components.FSM)

	// HTTPルートの設定
	srv.SetupRoutes()

	// カスタムHTTPサーバーの作成
	httpServer := &http.Server{
		Addr:    httpAddr,
		Handler: nil, // DefaultServeMuxを使用
	}

	// エラーチャネルの作成
	errCh := make(chan error, 1)

	// 短い時間待機して初期化エラーをチェック(タイムアウトつきコンテキストを使用)
	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()

	// HTTPサーバーをゴルーチンで起動(コンテキストを渡す)
	go func(ctx context.Context) {
		fmt.Printf("HTTPサーバーを起動しています: %s\n", httpAddr)
		if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			errCh <- fmt.Errorf("HTTPサーバー起動エラー: %v", err)
			return
		}
		errCh <- nil
	}(ctx)

	select {
	case err := <-errCh:
		if err != nil {
			return nil, err
		}
	case <-ctx.Done():
		// タイムアウト - サーバーは正常に起動したと判断
	}

	return httpServer, nil
}

Raftクラスターのブートストラップ、または参加のための関数。

ブートストラップ、または参加用の関数
main.go
// クラスターのブートストラップまたは参加を行う関数
func bootstrapOrJoinCluster(components *RaftComponents, nodeID, raftAddr, joinAddr string) error {
	if joinAddr == "" {
		// 単一ノードクラスターとしてブートストラップ
		configuration := raft.Configuration{
			Servers: []raft.Server{
				{
					ID:      components.Config.LocalID,
					Address: components.Transport.LocalAddr(),
				},
			},
		}
		components.Raft.BootstrapCluster(configuration)
		fmt.Printf("ノード %s を %s でクラスターにブートストラップしました\n", nodeID, raftAddr)
		return nil
	}

	// 既存のクラスターに参加
	fmt.Printf("%s のノード %s を %s のクラスターに参加させています\n", joinAddr, nodeID, raftAddr)

	// 参加リクエストの作成
	joinReq := &server.JoinRequest{
		NodeID:   nodeID,
		RaftAddr: raftAddr,
	}

	// JSONに変換
	jsonData, err := json.Marshal(joinReq)
	if err != nil {
		return fmt.Errorf("参加リクエストのマーシャルエラー: %v", err)
	}

	// リトライ設定
	maxRetries := 10
	retryInterval := 2 * time.Second
	var lastErr error
	success := false
	currentJoinAddr := joinAddr

	// リトライループ
	for i := 0; i < maxRetries; i++ {
		// 参加するためのHTTPリクエストを送信
		resp, err := http.Post(fmt.Sprintf("http://%s/join", currentJoinAddr), "application/json", bytes.NewBuffer(jsonData))
		if err != nil {
			lastErr = err
			fmt.Printf("参加リクエスト送信エラー (リトライ %d/%d): %v\n", i+1, maxRetries, err)
			time.Sleep(retryInterval)
			continue
		}

		// リダイレクトの場合は新しいリーダーに従う
		if resp.StatusCode == http.StatusTemporaryRedirect {
			newLeaderURL := resp.Header.Get("Location")
			if newLeaderURL != "" {
				fmt.Printf("リーダーにリダイレクトされました: %s\n", newLeaderURL)
				// 単純にURLからホスト部分を抽出
				if parsedURL, err := url.Parse(newLeaderURL); err == nil {
					newLeader := parsedURL.Hostname()
					if parsedURL.Port() != "" {
						newLeader = newLeader + ":" + parsedURL.Port()
					}
					fmt.Printf("新しいリーダーアドレス: %s\n", newLeader)
					currentJoinAddr = newLeader // 新しいリーダーアドレスを設定
				} else {
					fmt.Printf("リダイレクトURLの解析エラー: %v\n", err)
				}
				resp.Body.Close()
				time.Sleep(retryInterval)
				continue
			}
		}

		defer resp.Body.Close()

		// 成功した場合
		if resp.StatusCode == http.StatusOK {
			fmt.Println("クラスターに正常に参加しました")
			success = true
			break
		}

		// エラーの場合
		body, _ := io.ReadAll(resp.Body)
		lastErr = fmt.Errorf("ステータスコード %d: %s", resp.StatusCode, body)
		fmt.Printf("クラスター参加エラー (リトライ %d/%d): %s\n", i+1, maxRetries, body)
		time.Sleep(retryInterval)
	}

	if !success {
		// すべてのリトライが失敗した場合
		return fmt.Errorf("クラスター参加エラー (最大リトライ回数に達しました): %v", lastErr)
	}

	return nil
}

そしてmain関数です。

main.go
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"net/url"
	"os"
	"os/signal"
	"path/filepath"
	"syscall"
	"time"

	"Raft/server"
	"Raft/tictactoe"

	"github.com/hashicorp/raft"
	raftboltdb "github.com/hashicorp/raft-boltdb/v2"
)

func main() {
	// コマンドライン引数の解析
	nodeID := flag.String("id", "node1", "ノードID")
	raftAddr := flag.String("raft", "127.0.0.1:50000", "Raftバインドアドレス")
	joinAddr := flag.String("join", "", "参加するノードのアドレス")
	httpAddr := flag.String("http", "127.0.0.1:8000", "HTTP APIバインドアドレス")
	flag.Parse()

	// Raftの初期化
	components, err := initRaft(*nodeID, *raftAddr)
	if err != nil {
		log.Fatalf("Raft初期化エラー: %v", err)
	}

	// HTTPサーバーの初期化と起動
	httpServer, err := initHttpServer(components, *httpAddr)
	if err != nil {
		log.Fatalf("HTTPサーバー初期化エラー: %v", err)
	}

	// クラスターのブートストラップまたは参加
	err = bootstrapOrJoinCluster(components, *nodeID, *raftAddr, *joinAddr)
	if err != nil {
		log.Fatalf("クラスターブートストラップ/参加エラー: %v", err)
	}

	// シグナルチャネルの設定
	signalCh := make(chan os.Signal, 1)
	signal.Notify(signalCh, os.Interrupt, syscall.SIGTERM)

	// シグナルを待機
	sig := <-signalCh
	fmt.Printf("\nシグナル %s を受信しました。グレースフルシャットダウンを開始します...\n", sig)

	// シャットダウンのタイムアウト設定
	shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer shutdownCancel()

	// HTTPサーバーのグレースフルシャットダウン
	fmt.Println("HTTPサーバーをシャットダウンしています...")
	if err := httpServer.Shutdown(shutdownCtx); err != nil {
		log.Printf("HTTPサーバーシャットダウンエラー: %v", err)
	}

	// Raftのシャットダウン
	fmt.Println("Raftをシャットダウンしています...")
	if future := components.Raft.Shutdown(); future.Error() != nil {
		log.Printf("Raftシャットダウンエラー: %v", future.Error())
	}

	// ストアのクローズ
	fmt.Println("ストアをクローズしています...")
	if err := components.LogStore.Close(); err != nil {
		log.Printf("ログストアクローズエラー: %v", err)
	}
	if err := components.StableStore.Close(); err != nil {
		log.Printf("安定ストアクローズエラー: %v", err)
	}

	fmt.Println("グレースフルシャットダウンが完了しました。")
	os.Exit(0)
}

サーバーの停止時にグレースフルシャットダウンがなされるように工夫しています。

Docker

起動用にDockerファイルを用意しましょう。

FROM golang:1.23-alpine AS builder

WORKDIR /app

# go.modとgo.sumファイルをコピー
COPY go.mod go.sum ./

# 依存関係をダウンロード
RUN go mod download

# ソースコードをコピー
COPY . .

# アプリケーションをビルド
RUN go build -o raft-app .

# 最小限のイメージを作成
FROM alpine:latest

WORKDIR /app

# ビルダーステージからバイナリをコピー
COPY --from=builder /app/raft-app .

# Raftデータ用のディレクトリを作成
RUN mkdir -p /app/raft-data

# HTTPとRaftポートを公開
EXPOSE 8000 50000

# エントリーポイントを設定
ENTRYPOINT ["./raft-app"]

マルチステージビルドにしてみました。
次にdocker composeです。

version: '3'

services:
  node1:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8000:8000"
    volumes:
      - ./data/node1:/app/raft-data
    command: ["-id", "node1", "-raft", "node1:50000", "-http", "0.0.0.0:8000"]
    networks:
      - raft-network

  node2:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8001:8000"
    volumes:
      - ./data/node2:/app/raft-data
    command: ["-id", "node2", "-raft", "node2:50000", "-http", "0.0.0.0:8000", "-join", "node1:8000"]
    depends_on:
      - node1
    networks:
      - raft-network

  node3:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "8002:8000"
    volumes:
      - ./data/node3:/app/raft-data
    command: ["-id", "node3", "-raft", "node3:50000", "-http", "0.0.0.0:8000", "-join", "node1:8000"]
    depends_on:
      - node1
    networks:
      - raft-network

networks:
  raft-network:
    driver: bridge

これでよし!

試してみよう

起動

docker-compose up

ゲームの開始

curl -X POST http://127.0.0.1:8000/game/start -H "Content-Type: application/json" -d '{"game_id":"game1","player1":"Alice","player2":"Bob"}'

ゲームの状態の取得

curl http://127.0.0.1:8000/game?game_id=game1

マスに書き込む

curl -X POST http://127.0.0.1:8000/game/move -H "Content-Type: application/json" -d '{"game_id":"game1","player":"Alice","row":0,"col":0}'

起動に成功したら、Httpリクエストで三目並べが遊べるはずです!

クラスターの3台のうち1台を停止してみましょう。
新たに選挙が行われ、新しいリーダーが選出され、変わらずサーバーが動作できるはずです。
※リーダーが変わるのでポート番号は変わります。

まとめ

いかがだったでしょうか。
三目並べを有限ステートマシンとして表現し、Raftでゲームサーバーを冗長化することができました。
1台が落ちても、他の2台が生きていれば変わらず運用できるので、ステートフルなサーバーを冗長化したい時に活用できるのではないでしょうか。
今回のようなゲームサーバーや、分散型データベース、キーバリューストア、ワーカーなど、様々なところでRaftは活用されています。

一緒に働く仲間を募集しています!

株式会社OGIXでは一緒に働いてくれる仲間を募集しています!
エンタメ制作集団としてゲームのみならず、未来を見据えたエンタメコンテンツの開発を行っています。

事業拡大に伴い、エンジニアさんを大募集しています。
興味のある方は下記リンクから弊社のことをぜひ知っていただき応募してもらえると嬉しいです。
▼会社について
https://www.wantedly.com/companies/company_6473754/about
▼代表インタビュー
https://www.wantedly.com/companies/company_6473754/post_articles/443064
▼東京オフィスの応募はこちら
https://www.wantedly.com/projects/1468324
▼新潟オフィスの応募はこちら
https://www.wantedly.com/projects/1468155

21
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?