夏のブログリレー 29 日目の記事です!
たけのひとです。今日は去年1年間制作してたポーカーのバックエンド制作の話をします。ほぼ備忘録です。ゲーム自体は完成しなかったけど……。
色々話したいことはあるのですが、今回は"バックエンド"(技術的な話)のみに焦点を当てて色々話します!
先にお断りするのですが、プロジェクトの方針でソースコードは公開しないことにしました。なのでコードは一切出てこないです。若干わかりづらいかもですがご了承ください…!
何作ってたの?
ポーカー(テキサスホールデム)の 1vs1 の対戦アプリです。レート戦とフレンドバトルがあります。
テキサスホールデムについては、↓の動画とか見ると大体わかるはず!これの 1 対 1 版です。
ただこの記事を読む上では、"ターン制のオンライン対戦ゲーム" ということさえ頭の中に入っていれば問題ないと思います!
技術スタック
- フロントエンド: Unity (C#)
- バックエンド:Go / Echo, sqlc
- DB: MariaDB
- そのほか: HTTP / Websocket で通信, ConoHa の VPS 上で起動
通信について
まず、フロント・バックの通信方法についてですが、ゲーム外では HTTP / websocket, ゲーム中は Websocket を使って行いました。
ゲーム外に関しては、以下のような機能を実装していました:
- マッチング
- ランキング
- フレンドの追加・やりとり フレンドバトル
- 対戦履歴 / リプレイ確認
マッチングはおいておいて、他は大体ふつーの Web バックエンドと同じことやればよさそうなので、使い慣れている技術を持ってきました(それで Go を持ってきた)。
問題はリアルタイム通信の方で、Websocket と gRPC の 2 択で検討していたのですが、gRPC が C# でうまく使えないという問題にあたってしまって、結局 Websocket を使うことにしました。
Websocket、 openAPI みたいないい感じに通信仕様を書ける場所がなくてちょっと困ってたり…。これを解決せずに開発終盤まで持っていった結果どうにもならなくなってしまったので、最後は git 上で md 形式で仕様書をまとめることにしました(この点をどうにかできそうだというところで、 gRPC は気になっていました)。
ちなみに今回 Pub/Sub の仕組みは実装してません。だって対戦相手1人しかいないんだもん。不特定多数にイベントを送信する、ということがないので、Client struct にメッセージを送信するメソッドが生えていて、それを直接叩く処理になっています。Player A にも B にも送信する、という仕組みがいろんなところで生えていて少し面倒だった。
バックエンドの構成
バックエンドはだいたいこんな感じのアーキテクチャ・実装になりました:
handler
だいたいハンドラー。ロジックも全部持ってます。レスポンスの整形とかも一緒にやっちゃいます。ws-handler
Websocket のイベントを分解して、handler っぽく扱って処理します。ws
github.com/gorilla/websocket
のラッパーになっています。クライアント情報にuserId
とかを入れ込んだり、ping
pong
をさせたりします。処理するイベントは全部ws-handler
に飛びます。database
sqlc が生成してくれてるコードたちが全部ここに。transaction の管理もここでやります。application
マッチングとか、ws のクライアントを管理とか、進行中のゲームを管理したりします。poker
ゲーム内の処理を全部動かします。poker.Master
という構造体が擬似的なゲームサーバーとなっていて、ゲーム開始時にStartGame
関数を呼び出すことで、Master
が作成されるとともに goroutine が起動し、色々よしなにします。その後はMaster
のメソッドを叩いて操作し、Master
に登録されているClient
interface にメッセージ送信を命令する、という形で構成されています。
traP の人(別に traP じゃなくてもそうなんですけど)みんなクリーンアーキテクチャを採用しがち。ただ自分は昔 Laravel で書いてたというのもあって、この書き方にまだ慣れないんですよね…(最近になって慣れてきました)。なのでこういう感じになっています。
パッケージ間の依存関係を図にすると、大体こういう感じ (破線になってるのは interface 使って DI してるよ〜、という意味。使い方あってる?)
ちなみに、これパッケージ構成を整理するリファクタリング途中のときの図なので、もしかしたら破綻があるかもしれないです。あったらごめん。
大変だったことピックアップ
大変だったこと、学んだことを3つだけピックアップしておきます。
poker Master とタイマーについて
前述の通り、 poker.Master
は仮想のゲームサーバーを模しています。 Master
には主に以下のような機能があります:
- アクション(コマンド)を実行する
- Master からクライアントにメッセージを発出する
- ゲームの盤面を動かす(手番など)
- 排他ロック処理
- 各種タイマーの管理
(他にもゲーム終了時のクリーンアップとか、再接続処理とか色々…)
ここではゲームタイマーの管理についてピックアップしていきます。これは明らかに普通の Web サーバーにはない機能です。
ターン制のゲームなので "いつアクションが届いたか" を細かく管理する必要はないのですが、持ち時間が存在するのでこれを管理する必要があります。これが結構面倒でした。
Go 標準の time.Timer
は一定時間後に channel に通知が来るだけなので、消費時間を管理したりできません。ということで、自分で消費時間を管理できて、残り時間を確認できる timer を自作して管理することにしました。(ついでに context
を渡して外から強制終了できるようにしたり…。)
これを使って、時間切れになったときは新しく時間切れのメソッドが叩かれる、みたいな実装にしました。排他ロックを実装してたので、デッドロックとかが怖かった。
あとは "timer の goroutine がずっと残る(消えない)" とか "ゲーム終了後に timer の時間切れイベントが発火してゲームを進行しようとし、色々あって最後 panic する" とか timer 周りのバグが沢山あって大変でした。考慮しきれんて。
あとは、ラグを計算してそこから逆算して消費時間を計算〜、とかできると格好良かったなとか思ってます。
エラーの取り扱い
インゲームでエラーが発生したときのエラーの取り扱いに結構悩みました。外に開示するべきエラーなのか、ゲームを中断するべきエラーなのか、、などなど…。
あとは結構複雑な構造になっていたのにもかかわらずメソッドで出たエラーはラップせずにそのまま return していたので、どこで出たエラーなのか分からないということもしばしば起きてしまいました。
この辺を解決しようとして、pokererrors pkg を自分で作ることにしました。こういう感じになったらしい。なんかもうちょっと上手く作れた気がする…。
type Error interface {
error
Unwrap() error
Type() ErrorType
IsInternalError() bool
}
type ErrorType struct {
IsInternalErr bool // whether to show the error message to the user
Message string // message to be displayed to log / should be in English
Code string // code to be used in client
ExternalMessage string // message to be displayed to users / message in Japanese
}
大量に発生する panic
しばしば panic が起きてアプリケーションが落ちる、ということがありました。原因は色々あったのですが
- nil チェックしてなくて落ちてる(なんで…)
- 排他制御を忘れていて挙動がずれる
- 実装上のバグ
バグは発生し次第都度対処はしてたのですが、本番で落ちてしまうと問題なので結局自分で recover できる機構を組むことになりました。panic が発生したら panic が発生した当該ゲームを強制終了してログを残し、各 Client に強制終了を通知して close する、というロジックです。普通の Web サーバーだったら別に panic が起きても最悪 recover するだけで良い(なんなら echo の middleware 使えば基本問題にならない)ので、それと比べるとちょっと面倒だなぁ、と思った瞬間でした。
感想
goroutine を使いだす(非同期処理を使いだす)と、一気に考慮事項が増えて難しかったです。あと想像できないバグも沢山出てきて特定がすごく大変でした。このあたりを気にせずに使えるようになっている Echo って実はすごく優秀なのでは…?
他にも語れることは沢山あるのですが、これ以上記事が長くなったら誰も読まなさそうなのでこの辺にします!めっちゃ雑な記事になっちゃってごめんなさい。読んで頂きありがとうございました〜!