
【cocone TECH TALK VOL6・前編】 リアルタイム対戦xバックエンドアーキテクチャ
-
2023年5月26日
こんにちは、新卒のCです。
2023年4月25日に開催された「cocone Tech Talk vol.6」では、弊社の3人の開発者がバックエンドに関するテーマについて発表しましたが、その内容を前・中・後編に分けてまとめていきたいと思います。
今回は前編として、戸丸さんの「リアルタイム対戦xバックエンドアーキテクチャ」について紹介させていただきます。
概要
まずは、発表のあらましについてお話したいと思います。
戸丸さんは、「リアルタイム対戦」を開発する間に経た試行錯誤についてご紹介してくださいました。特に、採用した通信方式の変化に注目していただければ幸いです!
リアルタイム通信をどの技術で実現するか
リアルタイム通信は、双方向通信を行うためのSocketやUnityなどのゲームエンジンが支援するマルチプレイヤーゲーム対応のパッケージ・gRPCのBidirectional Streaming RPCなどで実現することができます。選択肢が様々ですので、「サービス・ゲームデザインとしてはどうか」・「運用保守の面でどうか」・「UXとしてどうか」などの要素を考慮して通信方式を選ぶと良いですね。
採用した技術
今回の通信方式としてはgRPCを採用しました。
他のAPIが既にgRPCで実装されているので、リアルタイム通信も同じ技術で実装すると運用が容易になりますね。
ここで、gRPCは4種の通信方式に大別されるため、どの方式を採用するかをまた考える必要があります。
gRPCの4種の通信方式
-
Unary RPC
最も単純なサービスとして、クライアントが1つのリクエストを送ると、サーバーが1つのレスポンスを返す通信方式です。REST APIのステートレス(stateless)と似てますね。 -
Client Streaming RPC
クライアントがリクエストを複数回に分けてストリーム(stream:データを連続の流れとして送信すること)として送ります。サーバーは全てのリクエストを受け取ってから1回のレスポンスを返します。 -
Server Streaming RPC
1回のリクエストをクライアントが送信し、サーバーはレスポンスを複数回に分けて返す通信方式です。 -
Bidirectional Streaming RPC
双方向に対してストリームでデータを送信する方式のことです。ここで、リクエストのストリームとサーバーのストリームは独立しているため、クライアントとサーバーはお互い任意のタイミングでデータを送信することができます。
初期に採用したのは…
4種の通信方式の中で最初に採用したのはBidirectional Streaming RPCです。リアル対戦が求めている双方向通信をそのまま選択するとなんとなく上手くいきそうですね。
以下の画像は、Bidirectional Streaming RPCの特徴についてまとめているスライドです。
しかし、実装の過程で次のような問題が発生しました。
- テストの複雑化
- Client/Server間のやりとりの増加
- send/recvそれぞれのgoroutineを管理する必要
- ある操作に対する検証結果が必要な場合が多い
- このメソッドってクライアントに何か投げるんだっけ?
- 増え続けるoneof
例えば、「増え続けるoneof」の場合はClientとServerのmessageにRequest(Say, Foo, Bar, Echo)とResponse(SayResult, FooResult, BarResult, EchoResult)を追加していく必要があるため、コードが長くて見辛くなる問題がありました。
最終的な着地点
上記のような問題を解決するため、コードを複雑化するBidirectional Streaming RPCの代わりに、UnaryとServer Streaming RPC通信方式を目的に合わせて適宜使うように修正しました。
相手の操作情報を含むサーバーからの通知はServer Streaming RPCを、それ以外は1リクエスト/1レスポンスのUnary RPCを採用しました。
以下は、修正後の通信の関係図です。
例えば、ClientAがエモートを出す場合はUnary RPCでサーバーとの通信を行います。この際、サーバーではClientAがエモートを持っているかどうか等の検証および検証結果に応じた処理が行われます。ClientAがエモートを出したことをClientBに通知をするときはServer Streaming RPCが利用されます。
また、Redis PubSubを使い、サーバー間でのイベントの共有が可能となるようにしました。Redis PubSubとは、リアルタイムメッセージングが可能な機能で、受信する側が特定のチャンネルを購読(サブスクライブ)すると、発信する側はそのチャンネルにメッセージを送ることで、購読した全員にパブリッシュすることができる仕組みです。
上図の例では、ApServerAとApServerBが同じチャンネルをサブスクライブしているため、ApServerAでパブリッシュしたイベントの情報はRedis PubSubよりApServerBにも反映され、サーバー間でのイベント共有が可能となります。
通信方式の変更の結果
通信方式をBidirectional Streaming RPCからUnary RPC + Server Streaming RPCへ変更した結果、よりわかりやすいコードになりました。
- 馴染んでいたUnary RPCを利用するため、実装やテストがスムーズにできた。
- Request/ResponseがIDLにて明示的に定義されるため、関係性の把握が容易になり、「この操作が来たら何を返すんだっけ?」のような混同が無くなった。
- Unaryは操作(CRUD)、Streamingはサーバーからの通知と役割を切り分けられたため、コードの可読性が上がった。
苦労したポイント
実装の過程で以下のようなことに悩まされました。
- 再接続時の復帰処理
復帰時の進行状況に応じて状態を再現する必要があった。
また切断中は自動行動を行うため、タイミングによっては進行不能になるなど、
リリース直前まで頭を悩ませられた。 - 細かいエッジケース対応
〜の時に〜をするとバグる、という様な話が初期は特に多かった。
最終的にはBOTを作成し、自動的にバトルを繰り返す仕組みでカバーした。
ローンチ後、大きな問題は起きていないので、良い結果が出せた。
参考: https://qiita.com/maruc/items/2393647be5caa32623e3 - 負荷対策
システム上、最新のデータを取得するケースが多く、Primaryアクセスが頻発。
キャッシュアサイドパターンを用いることで、負荷軽減を行った。
(整合性を担保する必要があるので、可能であれば避けたい所ではあった) - PubSubに悩まされた
go-redisを使用していたが、コネクション数が多大になったり、
ConnectionPoolとPubSubの接続状態連携が行われていないなど…。
最終的にはある程度自前でコントロールを行った。
ここで、「PubSubに悩まされた」について詳述します。
go-redisのPubSubでは基本1回のサブスクライブで1つのAPとRedisとのコネクションが発生しますが、もしサブスクライブしているユーザが1万人接続していたら、1万個のコネクションが発生してAPとRedis両方に多大な負荷を与えることになります。このような問題を解決するために、コネクションを一定数維持するようにして、1つのコネクションで複数のサブスクライブができるように仕組みを変えました。また、goのchannelを利用してAP間で共有されているイベントを各ユーザに送信するようにしました。
まとめ
- ゲームデザインに合った技術の選定・設計が重要
- 表面上動くものはすぐに作れる時代ではあるが、その先が深い。
- どのようにテストを行うかも初期段階で考えておくと良い。
- 開発中はクライアント担当とずっと「うーん…」と唸ることになる。
- …とはいえ、開発自体は楽しい!
ex) このタイミングで切断されたら…、地下鉄で瞬断したら…、バイナリが改造されたら…など
スライド
新卒の所感
何もかもが新鮮で未知の世界に飛び込んだ感覚ですね!gRPCの通信方式やエッジケース、PubSub等わからない概念が出てきたらまず調べてみて、自分の理解が間違ってるかどうかを戸丸さんに確認していただきました(大変お世話になりました)。個人的には、試行錯誤を経て最善に辿り着くストーリーから感銘を受けました。また、今回コードの複雑化のため除外となったBidirectional Streaming RPCはどの場面で活用できるのかも気になりました。
中・後編の記事は下記リンクからお読みいただけます!
【cocone TECH TALK VOL6・中編】 ココネグループのブロックチェーン MOOI Network とのバックエンド連携
【cocone TECH TALK VOL.6・後編】Kotlin バックエンドアーキテクチャ of アバターサービス