こんにちは!
株式会社OGIXのエンジニアH.Nです。
今日は、分散合意形成アルゴリズム『Raft』を使って、ゲームサーバーを冗長化してみたいと思います。
Raftとは?
複数のサーバーから構成される分散システム上で、サーバーどうしが『合意形成』をするために設計されたアルゴリズム。
リーダー1台と、フォロワーN台によって構成される。
リーダーは選挙によって選ばれる。
リーダー -> フォロワーでログを同期し、同じ状態に保つことができる。
Raftを構成するサーバーのうち、どれか1つが落ちたとしても、残ったサーバーで運用を継続することができる。リーダーが落ちた場合は新しいリーダーが選挙で選ばれる。また、落ちたサーバーが復帰した際、最新のログが同期され、データの整合性が保たれる。
Raftの仕組み
言葉で見てもチンプンカンプンな感じなので、学級委員の選挙に例えてみましょう。
Aクラスには太郎さん、剛さん、花子さんの3人の生徒がいて、学級委員を決めなければなりません。
学級委員の権力は絶大で、一般生徒は学級委員の指示にはすべて従います。
太郎さんが学級委員に立候補しました。
選挙のフェーズに入り、剛さんと花子さんは投票を行います。
投票の結果、無事に太郎さんが学級委員に当選しました。
太郎さんが学級委員となったので、剛さんと花子さんに指示を出します。
一定時間おきに連絡(ハートビート)し、2人が元気に登校しているかを確認。
サーバーの状態が変わったら、変わったことを2人に通知。
3人が一貫して同じ状態になるように、太郎さんは最善を尽くします。
しかし、ある日、太郎さんが風邪にかかってしまい、休んでしまいました。
太郎さんからハートビートが来ないので、剛さんと花子さんは困ります。
そこで、花子さんが新しい学級委員に立候補しました。
剛さんが投票を行い、無事に花子さんの当選が決定。
花子さんから剛さんにログが同期され、サーバーは変わらず運用されていきます。
そして、太郎さんが風邪から復帰しました。
太郎さんは教室に戻ると花子さんが学級委員になったことを知り、花子さんからログをもらって同期をとり、一般生徒として活動していきます。
Raftではこのように、選挙を行ってリーダーを決め、リーダー不在になったら新たなリーダーを選挙で選ぶことで、堅牢で一貫性のあるクラスターを構成することができます。
三目並べのゲームサーバーを作ってみる
では、Raftを使ってゲームサーバーを実装してみたいと思います!
今回作成するのは、簡単な三目並べのゲームサーバーです。
リーダー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クラスターは以下の順序で形成されます。
- ブートストラップ:最初のノードがクラスターをブートストラップ
- 参加:追加ノードが既存のクラスターに参加
ゲームロジックの実装
それでは早速、ゲームロジックの実装に取り掛かりましょう!
まずは必要な構造体を定義します。
構造体定義の作成
// コマンドタイプ
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(有限ステートマシン)として、ゲームロジックを実装していきましょう。
ゲームロジックの実装
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では、このような有限ステートマシンが非常に重要になります。
ゲームロジックを有限ステートマシンとして定義し、ログから状態を更新していく仕組みにすることで、同じログなら同じ状態が担保できるようになり、ログの共有によって複数のサーバーの状態を同期することが可能になります。
一応、ゲームロジックのテストを書いておきましょう。
テストコード
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で簡易的に実装します。
サーバーコードの実装
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起動用の関数
// 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サーバー起動用の関数
// 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クラスターのブートストラップ、または参加のための関数。
ブートストラップ、または参加用の関数
// クラスターのブートストラップまたは参加を行う関数
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関数です。
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