Scala.jsを使ってこんなことが出来ます、という具体例のひとつとして、オンライン対戦をサポートしたオセロを実装してみました。
サーバからクライアントまで含めてScalaで固めるとこんな感じ、という参考にしていただければ幸いです。
コードはこちらです。
https://github.com/yuiwai/othello4s
実装の細かい部分はさておき、全体の構想と構成をざっくりと解説してみたいと思います。
特に、モデルを共有したことによるメリットや、モデルと配線部分との境界、といった部分を主題としています。
また、本記事では特定のアーキテクチャであるとか、具体的な実装パターンであるとかというよりも、考え方にフォーカスしています。どう実装するのがいいかの詳細は、問題領域の性質とか、好みとか、取り組む状況によって変化すると思うので。
テーマ
- サーバ/クライアント間で、データ構造/ロジックの共通化を行うことにより、必要なコーディング量を削減し、本当に注力したい部分に注力できる構成を考える
- Scala.js+Reactによるフロント実装に加え、SSEを活用したリアルタイム性の高いサーバとの通信処理の組み込み方について模索する
アプリ概要
- オセロで遊べます。
- ブラウザ上で動くWebアプリです。
- ユーザは他のユーザとオンライン上で対戦できます。
- 他のユーザの対戦を観戦することができます。
- (おまけ)1人でも遊ぶことができます。
プロジェクト構成
プロジェクト名 | プラットフォーム | 説明 |
---|---|---|
core | JVM/JS | ゲームのコアとなる「オセロ」の知識を実装します |
service | JVM/JS | サーバが提供する機能のインターフェースを定義します |
server/akka | JVM | akka-httpをベースとしたサーバサイド実装です |
client/react | JS | scalajs-reactベースのブラウザ向けアプリケーション実装 |
使用ライブラリなど
- Scala.js
- scalajs-react
- akka-http
- circe
モデリング
ゲームのコアにあたる部分の実装指針です。
- オセロのルールを閉じ込めた
Othello
クラスを作成し、以下の2つの状態を保持- ボード ... 盤面。どこに何色の石が置かれているかを保持。
- 手番 ... 次は黒/白どちらの手番か(これは、通常プレイであれば、ボード上の石数から判断することも出来るものの、通常プレイ以外の拡張モードの構想もあり、切り出すことにしました)
- ゲームの情報を保持する
Game
クラスを作成し、勝敗や参加者のIDを保持- このクラスが
Othello
インスタンスを内包しています。
- このクラスが
- 参加者は
Participant
クラスで定義- 名前と状態(フリー、参加待ち、観戦中、対戦中)を持っています。
- 認証認可周りは今回の本題ではないので、ふわっと作ってます。
実装戦略
以下、どういった方針で実装に臨んだかをざざざっと書いていきたいと思います。
Serviceの抽象化
Service
のインターフェース定義を独立したプロジェクトとして切り出し、サーバ/クライアントの双方で実装します。
サーバ側は提供する機能の実体を、クライアント側は機能の利用者としてサーバを利用するためのつなぎこみをそれぞれ実装することになります。
これにより、両者の間にある通信プロトコルやシリアライズといった実装の詳細から、Service
に依存するコードを分離できます。
手っ取り早く言えばRPC的なものですが、今回はサーバ/クライアント双方がScalaですので、ダイレクトに一つのtraitを共有することができます。
今回は、こんな感じのtraitを定義しています。
trait Service[F[_]] {
def participate(name: ParticipantName): F[ParticipantId]
def allGames(participantId: ParticipantId): F[Seq[GameSummary]]
def game(gameId: GameId): F[Option[GameDetail]]
def createGame(participantId: ParticipantId): F[Either[ServiceError, GameSummary]]
def cancel(gameId: GameId, participantId: ParticipantId): F[Option[ServiceError]]
def entry(gameId: GameId, participantId: ParticipantId): F[Either[ServiceError, Game]]
def start(gameId: GameId, ownerId: ParticipantId): F[Either[ServiceError, Game]]
def putStone(gameId: GameId, participantId: ParticipantId, pos: Pos): F[Either[ServiceError, Game]]
def pass(gameId: GameId, participantId: ParticipantId): F[Either[ServiceError, Game]]
def giveUp(gameId: GameId, participantId: ParticipantId): F[Either[ServiceError, Game]]
}
WebAPIの定義と違い、HTTPやらの余分な雑音がなく、型情報のロストもありません。変更時もこのtraitを書き換えるだけで、サーバ/クライアントの双方で適切な実装を網羅することが強制されます。
定義の中身を軽く見ていきます。
結果型としてEither
を使っているので、エラーの伝搬が非常に楽になっています。失敗する可能性のあるサービスコールに対してのハンドリングは一つの課題かと思いますが、こうすることで網羅的なエラーハンドリングができます。
シリアライザにはcirceを使っていますが、Eitherのエンコードには以下のissueにあった実装を拝借しました。
メソッドもいくつかピックアップしてみます。
まずは、参加処理。
def participate(name: ParticipantName): F[ParticipantId]
オンラインモードでは、参加者は名前を入力してサービスに参加する必要があります。結果として参加IDというの返るので、これをこの後使ってゲームに参加するんだな、というのが読み取れるかと思います。
allGames
とgame
はそれぞれゲームの一覧と詳細の参照用です。参照系を別系統に分離することも考えましたが、大した規模でもないのでやめました。
putStone
は石を置く、pass
は手がない時のパス、giveUp
は文字通りのギブアップで、このあたりのメソッドはみなGame
を返します。操作に対して新しい状態を返し、丸っと置き換えるイミュータブルなアプローチです。
(いちおう、高カインド型になっていますが、実装は単にFuture
にしているだけで深い意図はないです。もしかしたら、Future
以外を使うかもなー、くらいな気持ちでした)
Read/Writeの分離と非同期通信
オンライン対戦中は、ゲームに関するイベントが非同期に発生します。
そのため、ReadとWriteを分離し、別々な経路で扱っています。
(原則として、Write側と対になるようにイベントが発生します)
- 手番のプレイヤーは自分の行動をWeb API経由で送信(Write)
- 対戦相手及び観戦者はSSE経由で非同期に受信(Read)
Read側はイベントを受け取り手元の状態に対して差分更新を試みます。
その際、イベントに付加されたバージョン番号と、手元の状態に付加されているバージョン番号を比較し、不一致していたら状態をリロードしています。これにより、コネクションが切断されてメッセージをロストしたとしてもそれを検知して状態を復元できます。
発生する通知はEvent
として列挙し、service
プロジェクトで定義してクライアント/サーバで共有しています。
sealed trait GameEvent {
val gameId: GameId
}
final case class StonePut(gameId: GameId, participantId: ParticipantId, pos: Pos, version: GameVersion) extends GameEvent
final case class GivenUp(gameId: GameId, version: GameVersion) extends GameEvent
final case class Terminated(gameId: GameId, version: GameVersion) extends GameEvent
final case class GamePrepared(gameId: GameId, challengerId: ParticipantId) extends GameEvent
final case class GameStarted(gameId: GameId) extends GameEvent
final case class GameCanceled(gameId: GameId) extends GameEvent
これらは全て、ゲーム内で発生するイベントですので、必ず一意なゲーム識別子(=GameId
)が紐ついています。よって、基底のGameEvent
トレイトでgameId
を宣言しています。
対戦者であろうと、観戦者であろうと、扱うモデルもイベントも一緒(観戦者は単にWriteの権限を持たずReadだけする参加者)なので、上記は両者に共通で使用しています。
実質、オセロのゲーム本体で使われるイベントは、StonePut
のみで、これは石を置いたことを表すイベントです。version
値により、「ある特定の時点のゲームに対する差分更新」であることが保証されるため、実装はシンプルになります。モデルはサーバ側でも同じものが保持されており、不正な状態はあらかじめ弾いているので、サーバ/クライアント間で不整合が生じる心配も少ないです(バグがあれば、それも含めて共有されますね!)
残りのイベントはゲームのライフサイクル(開始や終了など)に関するものです。
前節で特に触れなかったのですが、ゲーム開始時のフローは、ゲーム作成者(オーナー)が参加者を待ち、参加者が来た時点で開始待ち、オーナーが開始の手続きをすることでゲームが実際に始まる、という感じになっています。
GamePrepared
は参加者が来た状態(開始可能)、GameStarted
はそこからオーナーがゲームを開始した状態をそれぞれ通知しています。
参加待ち状態にしろ、開始待ち状態にしろ、手待ちの状態にしろ、相手がいるものは自分側の操作だけで完結しません。この辺は、作るものの求める要件、目指すユーザ体験によって、工夫が必要な部分かと思います(例えばタイムアウトであるとか)
オセロ自体は単純なモデルなので、まだそこまで旨味を感じないかもしれませんが、もっと複雑でかつ高負荷なモデルになった場合、いかに効率的に変更を伝搬するかが重要になってくるので、差分更新の有用性は増して来ます。
差分データを工夫してなるべく小さなバイナリに詰め込んだり、データの送信をある程度バッファリングして頻度を調整したり...要件に応じて色々な工夫が出来る部分ですね。
UI上のデータの流れを単方向化
(クライアント側のお話ですが、相変わらず、あまり具体的なコードは出てこないです。
Reactベースですので、状態(State)に対する変更の反映、という文脈で読んでいただけると幸いです。)
状態に変化をもたらすような入力(ユーザ操作等)と、状態の変化に伴って発生する変更を単方向で管理しています。発生しうる状態と操作の組み合わせを限定して網羅的に扱うことで、問題をシンプルにできます。
処理の配線があちこち行ったり来たりすると追うのも大変ですよね...。
具体的には、すべての状態変化を以下のようにAction
という列挙型で表現し、網羅的に扱っています。
sealed trait Action {
def >>(action: Action): CompositeAction = CompositeAction(this, action)
}
case object Initialize extends Action
final case class Participate(name: ParticipantName) extends Action
final case class LoadGames(participantId: ParticipantId) extends Action
final case class LoadGame(gameId: GameId, participantId: ParticipantId) extends Action
final case class CreateGame(participantId: ParticipantId) extends Action
final case class CreateCustomGame(participantId: ParticipantId, board: Board) extends Action
final case class EntryGame(gameId: GameId, participantId: ParticipantId) extends Action
final case class BeginEditMode(participantId: ParticipantId) extends Action
case object StartOnlineMode extends Action
case object StartOfflineMode extends Action
final case class CompositeAction(first: Action, second: Action) extends Action
sealed trait GameAction extends Action
final case class StartGame(gameId: GameId, participantId: ParticipantId) extends GameAction
final case class CancelGame(gameId: GameId, participantId: ParticipantId) extends GameAction
final case class PutStone(gameId: GameId, participantId: ParticipantId, pos: Pos) extends GameAction
final case class Pass(gameId: GameId, participantId: ParticipantId) extends GameAction
final case class GiveUp(gameId: GameId, participantId: ParticipantId) extends GameAction
final case class ReceiveEvent(participantId: ParticipantId, event: GameEvent) extends GameAction
final case class BackToEntrance(participantId: ParticipantId) extends GameAction
final case class UpdateGameSettings(settings: GameSettings) extends GameAction
状態変化は、現在の状態に対して、Action
が渡されて発生します。
上半分は主に画面の遷移であるとか、参加処理であるとか、いわゆる「アウトゲーム」と呼ばれるゲーム本体の外にある処理に関するAction
です。
下半分のGameAction
から派生したものが、「インゲーム」と呼ばれる対戦中のゲーム本体の中で使われるAction
です。また、一部直接オセロのモデルに関係しない操作として、BackToEntrance
のような、画面遷移のAction
も含んでいます。
(繰り返しになりますが)重要なことは、ユーザの操作が全て、Action
として網羅されている、ということ。ここから外れたイレギュラな処理を作り込んでしまうと、意図しない経路での処理が発生し、アプリケーションの配線が複雑化します。
Action
を受け取るための配線ですが、コールバック関数をコンポーネントのProps
で引き渡す、という、至ってシンプルな方式で十分かと思います。
入力の型はAction
に限定されているので、scalajs-react上では
handler: Action => Callback
のような表現となります。
コンテキストに応じてもっと型に制約を加えることもでき、今回のゲーム内部のAction
であれば、GameAction
型のため
handler: GameAction => Callback
になりますね。
このhandler
は以下のような感じで使えます(p
はProps
)
<.button(
^.onClick --> p.handler(BackToEntrance(p.participantId)),
"ゲーム一覧に戻る"
)
非常に直感的ではないでしょうか。
境界とデータ型
ここまで見てきてお気づきかと思いますが、戦略として、境界とデータ型による網羅的な表現に注力しています。
- サーバとクライアントの境界
- 書き込みと読み込みの境界
- 状態と状態の境界
泥臭いコードはどう頑張っても発生します。特に、インフラなどの実装の詳細部分では、通信や永続化、外部ライブラリやOSなど、個々の詳細の知識が露出し、コードは否応無く複雑になります。しかし、その泥臭いコードが境界を跨いで漏洩しないように制御することで、問題を切り分けて明確化することはできます。
処理の入り口と出口が明確になり、方向が定まって、入ってくるものと出ていくものがデータ型として明確になっていれば、実装の詳細が複雑になっても、複雑さを分離することで、問題の肥大化や、外部への漏洩/拡散を防ぐことができます。
大きく複雑な問題を解くための良い方法は、問題を小さく分割することです。つまり、分割の仕方が重要になります。どこに境界があり、どの方向への依存があるのか。そういったものを見極めていくのが大事でしょう。
答えは一つではないでしょうし、様々な問題領域に様々な解決手法があると思います。
(おまけ)オフラインモード
- 1人で先手/後手両方を操作することができます。
- 主に、作者のデバッグ用です。
- ごく簡単な自動プレイモードで対戦することができます。
- 地味に遊べます。
- 真面目にロジックを改善すれば、ちゃんと強くなると思います。
ロジックの考え方はシンプルで、単にある局面で有効な次の手を網羅して、その中から最も有効なものを選ぶ評価関数を実装するだけです。
まとめ
- Scala.js楽しいです
- scalajs-react使いやすいです
- (今回全く触れてないけど)Akka便利です