/note/tech

大規模Unityゲーム開発の設計事例 〜ドメイン駆動設計とDIコンテナを導入した一年を振り返る〜

要約:

■ 1. ドメイン駆動設計を導入した経緯

  • 開発スピードとメンテナンス性の両立:
    • スマートフォン向けソーシャルゲーム開発では、素早く開発してリリースする事が大事
    • リリース後の運用・新機能追加をスムーズに行う事も同様に大事
  • 設計の綺麗さは金銭的な価値も生む:
    • イテレーションが素早く回せる事で試行錯誤の回数が増え、品質が上がる
    • リリース後、運用に入っても機能追加がすぐ実装でき、バグが出にくく、出ても即座に対応できる
    • 設計を疎かにすると機能実装に時間がかかりバグが多くなり、ユーザー離れ・機会損失に繋がる
  • 多人数開発で起きる問題:
    • 自己流の設計が至るところに散らばってしまう
    • 自己流がぶつかり合うとコードレビューが難しくなる
    • わからない・面倒だからという諦めや、自己流同士の対立が生まれる
  • チーム独自手法の限界:
    • 本を参照したりネットで検索する事ができない
    • チームの外に出た時に通用せず、覚え直しになる
    • チーム内で思想を統一でき、実績のある体系化された手法が必要
  • EasyではなくSimpleを選択:
    • Easyはモック開発や小規模な開発に向いている
    • 大規模な開発にはSimpleが向いていると判断
    • 手数とコード量は増えるが、分かりやすい構造を目指す
    • Simpleの実現にDDDが向いていると判断

■ 2. ドメイン駆動設計とは

  • DDDの位置づけ:
    • 2003年にエリック・エヴァンスの書籍が発売され、Domain Driven Designの頭文字からDDDと呼ばれる
    • 上手くシステム開発を進めるためのパターン・ランゲージ
    • 良くある問題と、それに対するベストプラクティスを集めたパターン集
  • ドメインの定義:
    • ソフトウェアが解決しようとしている問題の対象領域
    • 会計システムであれば会計に必要な金銭・帳票といった概念
    • 物流システムなら物流の倉庫・貨物・輸送手段といった概念
    • 一意に決まるものではなく、ソフトウェアによって違う
  • ゲーム開発への適用可否:
    • ゲーム開発ではあまり聞かないが適用可能であり、ジャンルはあまり関係ない
    • ドメインに焦点をあてて開発する、というだけ
    • ゲーム制作のドメインは、そのゲーム独自の概念・ルール・仕様
  • DDDが説くのはゲーム開発では当たり前の事:
    • ディレクター・プランナーとよく対話し、ゲームが必要としている独自の機能を理解する
    • 対話して明らかになった知識を元に、共通のモデルを作りあげる
    • モデルをプログラムに純度高く反映させる
    • これらをどう実現するかのベストプラクティスが説明されている
  • 向かないジャンル:
    • 完全にデータ中心である場合
    • 小規模・シンプルな場合
    • 高速化が最重要である場合
  • 向いているジャンル:
    • 継続的に機能が変更され、複雑性をもったアプリケーション
    • ゲーム開発はこれに当てはまる
  • ベースとなる技術はオブジェクト指向:
    • データと関数を個別に扱わず、双方を一体化したオブジェクトを基礎要素にする
    • オブジェクト間の相互作用を重視してプログラムを構築する
    • 現実世界の物や概念をオブジェクトに模して表現する
    • 手続き型はデータと振る舞いの記述が分かれ、オブジェクトを単なるデータ構造としてみる点で異なる
  • ECSとの住み分け:
    • Experimental版のECSはデータ指向でありオブジェクト指向で書けないため、DDDを適用しづらい
    • ゲームのドメイン部分はDDDで開発し、高速化が必要になる部分はECSという住み分けが必要
    • ECS化される部分はグラフィック描画、物理演算、サウンド処理、AIなどと想定

■ 3. 参考書籍

  • エヴァンス本:
    • ドメイン駆動設計の原典
    • かなり難しく、意気込んで読み始めるとまず挫折しがち
  • DDD Quickly:
    • PDFで配布され、日本語版もある
    • ドメインモデルを作っていく過程などの例がとても分かりやすい
  • ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本:
    • エヴァンス本にあるプログラミングに適用するパターンをメインに紹介している
    • ドメインについても多く言及されており、入門に最適
    • チーム内で読書会を実施し、その後も継続的に勉強会を実施
  • ドメイン駆動設計 モデリング/実装ガイド:
    • ドメインモデリングをはじめとしてDDD全般が解説されている
    • モデリングについて書かれている書籍は少ないため大変参考になる
  • DDD Reference:
    • PDFで配布され、全容を把握するのに非常に役に立つ
    • 日本語に訳している人もいる

■ 4. ゲーム制作におけるドメイン

  • ゲームのドメインの捉え方:
    • ゲームが表現しようとしている対象領域
    • 自分たちが作っているゲームで表現しようとしているもの、ゲームが提供する遊び
    • システムを構成する要素から汎用的な部分を抜いて残る部分
  • 汎用的な部分の例:
    • ファイルI/O、データベース、通信、サウンド処理
    • UI、描画エンジン、物理エンジン、ユーザー入力
  • 自キャラの成長ロジック:
    • どうやってレベルアップするか
    • 武器・防具を装備するか
    • スキルの存在
  • インゲームのドメイン:
    • ゲームのルール
    • いつスキルが発動するのか
    • ダメージの計算方法
  • アクションゲームのドメイン例:
    • ステージ情報として何がどこに配置されるか、壊せる・壊せない・移動するか
    • 自キャラ・敵キャラの配置
    • HP、MP、攻撃方法などの自分・敵キャラのステータス
    • ジャンプする、敵を踏みつけて撃退するといったゲーム固有のルール
  • 将棋ゲームのドメイン知識:
    • 40枚の駒が登場する
    • 駒の性能は8種類あり、それぞれ動ける範囲が違う
    • 成駒により動ける範囲が増えるという成長ロジックがある
    • 9x9の81マスで戦う
    • 相手の駒を取り、保持して使う
  • 野球ゲームのドメイン知識:
    • 9人対9人で戦う
    • 選手には打率・防御率・スタミナなどの能力パラメーターがある
    • ストライク3つでアウトとなり、バッターはボールを打ち返す
    • 攻撃側・防御側に分かれ、交互に入れ替わる
    • 選手の成長要素、必殺技、アイテムなどゲーム特有のルールも加わる
  • モデルの作りあげ方:
    • ドメイン知識をゲーム中で扱えるようにモデルを作りあげる
    • オリジナルゲームの場合、最初から明確な事は多くない
    • 対話・モデリングを繰り返し、ぼんやりしたモデルの輪郭をハッキリさせていく
  • ドメインの分離:
    • ドメインとそれ以外で分離させる事が大事
    • ドメインロジックをUIコンポーネントに書いたりしない
    • Viewとドメインを分離する発想はMVC的な発想と同じ
  • 瀕死のキャラクターが点滅する例:
    • どういう時に、どういう条件で点滅するのかをViewには記述しない
    • 瀕死状態の定義や、瀕死状態かどうかの判断を書くのはドメインロジック

■ 5. ドメインエキスパートとユビキタス言語

  • ドメインエキスパート:
    • アプリが対象とする領域の専門家
    • 銀行の業務支援アプリを作るなら、業務を熟知する銀行員が該当する
    • 現プロジェクトではディレクター・プランナーをドメインエキスパートと捉えた
  • ドメインエキスパートとの対話:
    • 絶えず対話し、ドメインをうまくモデリングするのが大切
    • 仕様書をそのまま実装するのはNG
    • 仕様書に記載された機能の裏にある目的などを理解する必要がある
  • ユビキタス言語:
    • プロジェクト内で使う共通の言葉を定義する
    • 開発者とドメインエキスパートが同じ単語を別の意味で用いないようにする
    • キャラクターの特殊能力をアビリティと呼ぶかスキルと呼ぶか、といった揺れを統一する
    • 用語がバラバラだと変換コストが発生し、勘違いも生まれる
  • ユビキタス言語のコードへの適用:
    • プログラム中に使われる関数・変数にもユビキタス言語を採用する
    • 日本語だった場合は難しいため、英語との対応表を作って管理している
    • サーバー・クライアントでプログラム内の単語を合わせるのにも役立つ

■ 6. プログラムに適用するパターン

  • 値オブジェクト:
    • C#の値型とは無関係
    • 一度生成されたら中身が変化しない不変のオブジェクト
    • 中身が同じなら等価とみなす
    • 実装に面倒な箇所があるが、不変性によって実装は大幅に単純化される
    • 安全に共有や参照渡しができる大きなメリットがあり、DDDに関係無く有用
  • 値オブジェクトの実装:
    • 全てのフィールドをreadonlyにし、コンストラクタで値を設定する
    • 全てのフィールド変数が同一であるか比較するEquals()を用意する
    • 全てのフィールドを考慮したGetHashCode()関数を用意する
    • これらがあると中身で比較され、Dictionaryのkeyとして中身ベースで使用できる
  • エンティティ:
    • データベースのエンティティとは無関係
    • 生成された後、中身がどんどん変化していくもの
    • 中身が変化しても同一のものとして扱い、同一性によって区別される
    • 目印となるidを持ち、idが同じであれば中身が違っても等価とみなす
    • 現プロジェクトでは変化していくユーザー情報、成長していくキャラクター情報が該当する
  • サービス:
    • 物としてモデリングできないものを、値オブジェクトやエンティティを取り扱うサービスとして実装する
    • 気を抜くと手続き型となるため、多用しないよう注意する
  • 集約:
    • 関連するオブジェクトの集まりであり、データを変更するための単位として扱われる
    • ひとつの塊として扱い、窓口を通さないアクセスは認めない
    • 窓口を飛び越して中身にアクセスできない点はFacadeパターンによく似ている
    • 窓口を設ける事で複雑な構造をカプセル化する
  • リポジトリ:
    • データである集約の置き場所
    • データストアを操作する処理をカプセル化し、集約の保存・取得はここを通す
    • 他のクラスはデータストアを直接操作しない
    • DBがある事を意識せず、メモリ内コレクションのように振る舞う
  • 境界づけられたコンテキスト:
    • 複数チームで同時開発していると、ある概念に対して複数のモデルが出来る事がある
    • これに対し、無理に統一された大きなモデルを作る必要はない
    • それぞれのモデルが適用される境界を明確にし、境界内だけで適用されるモデルを作る
    • 境界を明示的にする事で境界内を独立して開発可能になり、他の箇所との結合度が減る

■ 7. レイヤードアーキテクチャ

  • 4層構成:
    • ユーザーインターフェース(プレゼンテーション)、アプリケーション、ドメイン、インフラストラクチャの4層に分ける
    • 実践ドメイン駆動設計で紹介されている、インフラストラクチャの位置を変更したバージョンを採用
    • ドメインオブジェクトを管理するインフラストラクチャ層がドメイン層を参照できた方が都合が良かったため
  • インフラストラクチャ層:
    • 基盤的な機能の実装を担う
    • 通信機能、DBアクセス、ローカルセーブデータなどの実装
    • リポジトリの実装
  • ユーザーインターフェース層:
    • UI表示を担う
    • アウトゲームのMVC/MVPで構成された各画面が該当する
    • MonoBehaviour継承クラスだからUI層とはせず、そのクラスが提供している機能で判断している
  • アプリケーション層:
    • ドメインオブジェクト・基盤機能を使い、アプリケーション全体の調整・進行役を担う
    • ドメインとUI層の架け橋であり、ゲームが持つ機能の実装にあたる
    • 現プロジェクトではユーザー情報・キャラクター情報の管理、取得、変更を担う
    • 機能毎にユースケース・クラスを作成し、そこに処理を記述している
  • ドメイン層:
    • 値オブジェクト、エンティティ、ドメインサービスによるドメインロジックの実装
    • システムの心臓部であり、他のレイヤーと隔離されている事が重要
  • 他のアーキテクチャパターン:
    • オニオンアーキテクチャ、ヘキサゴナルアーキテクチャなどがある
  • Assembly Definitionsによる実装:
    • レイヤードアーキテクチャをAssembly Definitionsで定義する
    • 相互参照を無くして依存関係が単純になるメリットがある
    • 厳密に依存関係をコントロールする運用は結構難しい
  • 依存関係逆転の原則:
    • Assembly Definitionsを設定すると上から下への単一方向にしかアクセスできなくなる
    • とはいえ逆方向にあるレイヤーの機能を使いたい事はある
    • 具象ではなく抽象だけを参照するようにする
    • 具象は実装クラス、抽象はinterfaceやabstractなどの抽象宣言を指す
    • アクセス可能な階層のinterfaceに依存するようにして解決する
    • 実際の処理の流れと、ソースコードの参照方向が逆になる
  • インスタンス取得の課題:
    • 結局は上層のインスタンスを取得する必要がある
    • Abstract Factory、Service Locatorなどの解決方法がある
    • 現プロジェクトではDIコンテナを使用した

■ 8. DIコンテナ

  • singletonを使いたくない理由:
    • global変数である
    • 依存関係が見えづらい
    • 具象クラスに依存するため依存関係逆転の原則が使えない
    • interfaceを使った実装の入れ替えが難しい
  • DIとDIコンテナ:
    • 外部から必要とするインスタンスを渡すのがDI
    • 上から渡すには渡すものを作る必要があり、その繰り返しがずっと上の階層まで続いてしまう
    • 最終的に依存関係の解決だけを担うものが必要になり、それを自動で行うのがDIコンテナ
    • DIコンテナは誰が何を必要としているのか、生成の順序などを管理する黒子のような存在
  • プロジェクトでの使い方:
    • 各種サービスの連携に依存関係逆転の原則を活用する
    • レイヤーを跨いだ参照の受け渡しに使う
    • 逆方向のレイヤーにある実装を使用する場合は、interfaceを置く場所で制御する
  • 実装の差し替え:
    • interfaceに依存する事で実装の差し替えを行う事ができる
    • デバッグ用にUI非表示でlog出力だけ行う高速周回モードを実装した例がある
  • 導入による変化:
    • クラス間の依存関係把握が簡潔になり、アクセスコントロールがやりやすい
    • 機能を使うためにコンストラクタで宣言する必要があるため、依存関係が見えやすい
    • 不要なクラスの利用がなくなり、役割分担も明確になる
    • singletonはどこからアクセスされるのか把握が難しい
  • 網の目依存という落とし穴:
    • DIコンテナ上のオブジェクト同士を思うままに関連付けると、依存性が網の目のようになる
    • あらゆるクラスを登録して自由に参照を取得するのはやりがち
    • それは全てがsingleton、全てstaticクラスである事と同じ
  • 登録対象の絞り込み:
    • 現プロジェクトではサービスやリポジトリのRootインスタンスのみがDIコンテナに登録可能
    • Root要素から下は参照関係の解決にDIコンテナを使わない
    • Root要素から下は参照の受け渡しであるただのDIで解決している
    • Easyのためではなく、simpleにするために使う
  • ライブラリ選定:
    • Zenject(extenject)を採用
    • Zenjectの固有機能はあまり使わず、他のDIコンテナライブラリに簡単に乗り換えられるようにしている
  • マイルドな使い方:
    • プロジェクト起動時とシーン読み込み直後の初期化でDIコンテナに詰める・取得するだけ
    • シーン初期化以降に動的生成されるinstance/prefabにはinjectionしていない
    • シーンはタイトル画面・アウトゲーム・インゲームのような粒度で分けている
    • 基本はコンストラクタインジェクションを使う
    • MonoBehaviourに対してはメソッドインジェクションを使う

■ 9. その他の設計手法

  • テスト:
    • ロジックが書かれた集約・エンティティ・値オブジェクトなどのユニットテストを実施
    • ドメインモデルとして纏める事でテストしやすくなる
    • ライブラリ的な機能のテストも行う
    • UnityのTest Runnerを使い、Jenkinsで定期実行している
  • 契約による設計:
    • 関数の呼び出し元を顧客、呼ばれる関数を供給者と見立てるメタファー
    • 顧客が契約を守るなら、供給者は仕事を保証する
    • 事前条件は関数の開始時に、関数を呼ぶ側で保証すべき条件
    • 事後条件は関数の終了時に保証すべき条件
    • 不変条件はそのオブジェクトが常に満たすべき条件
    • DDDでも表明(Assertion)としてパターンが紹介されている
    • 現プロジェクトではこれから充実させる段階であり、主にドメインロジックをチェックする
  • 単体で動くように作る:
    • とても重要であり、最初期からこれを念頭に作る
    • ゲームを最初から立ち上げずに、最小ではprefab単位で挙動のチェックが出来るようにする
    • 小さい単位で独立してテスト可能に作る事で、結合度が低く使い回しが効くパーツが作られる
    • アウトゲームの各画面・ポップアップはMVPのView部分だけ切り出してテスト可能にしている
    • インゲームにはviewテスト用シーンを用意し、テスト項目をinspectorに表示してボタン押下で挙動を確認する
    • inspectorにボタンを表示するのはアセットOdinを使用
  • prefabの構造規約:
    • ドメイン駆動設計における集約の考え方を適用する
    • Prefabパーツへのアクセスはroot要素のコンポーネントを経由する
    • inspector、Find()、GetComponentInChildren()等でroot要素を飛び越えて中身の参照を取得しない
    • UnityにはEasyにするための仕組みが沢山あるが、敢えて使わない
    • デバッグ機能やモックシーンはEasyで良い

■ 10. 実装においての例外

  • DIコンテナで困るもの:
    • UIのボタンクラスなど、Hierarchyツリー上で下層に置かれるUIパーツ
    • 末端オブジェクトからDIコンテナに入っているオブジェクトにアクセスしたい場合
    • ボタンを押したら音が鳴る機能などは割り切ってsingletonにしている
  • イベント発行の例外:
    • ゲーム固有のイベントを発行・管理するものもsingletonにしている
    • PS4のトロフィー獲得表示のように、何かを達成したタイミングで表示されるものが該当する
    • サウンド再生もイベントで表現できるが、単独で使用される事が多いため敢えて分けている

■ 11. つまづいた点

  • とりあえず色々Entityにされがち:
    • 生成後に変化しない物までEntityとして作ってしまった
    • 値オブジェクト等のImmutableなクラスに変更して対処した
    • 情報を他のレイヤーに運ぶだけのオブジェクトはDTOとして表現する
    • 現プロジェクトではDTOもImmutableとして作っている
  • ドメインモデル貧血症:
    • エンティティ・値オブジェクトにドメインロジックが書かれず、サービスに手続き型で書かれてしまう
    • OOPを意識して書くよう対処する
    • チームでモデリングの技量を上げる必要があり、継続的に勉強会を実施している
  • 集約の境界が曖昧:
    • 複数のエンティティや値オブジェクトから出来る集約は、直接中身を触りたくなる
    • 必要経費と捉え、Rootを通したアクセスのみ認めるように変更した
    • 情報を伝えるためのバケツリレーのようなメソッドがたくさん出来てしまうが、我慢する
    • これもeasy vs simpleでsimpleを採用する話
  • 集約アクセスの許容範囲:
    • 理想的には全てのアクセスで集約Rootを通したいが、実装が煩雑になるため部分的に許可している
    • 内部の値を一時的に外部から参照するだけならOK
    • 境界内のオブジェクトが互いに参照を保持し合うのもOK
    • 集約内部に持つインスタンスのメソッドを外部から実行するのはNG
  • デメテルの法則:
    • クラスCのメソッドfが呼び出してよいのは、Cそのものと、fで生成されたオブジェクトのメソッド
    • 加えて、fの引数で渡されたオブジェクトと、Cのインスタンス変数に保持されたオブジェクトのメソッド
    • 許されたメソッドから返されたオブジェクトのメソッドを呼び出してはいけない
    • 友達とのみ会話し、知らない人とは会話してはいけないという原則
    • 適用するのは振る舞いを持つオブジェクトに対してのみ
    • 振る舞いを持たないデータ構造は内部を公開しても構わない
  • 集約ルール徹底の効果:
    • 集約のルールを徹底するだけでスパゲティコードになる可能性はグッと下がる
  • リポジトリの粒度が集約単位ではない:
    • 本来、リポジトリは集約の単位で作成される
    • 集約内部にあるEntity単位でもリポジトリがある状態だった
    • 集約の内側パーツをリポジトリが持つ事になってしまう
    • モデリングが上手くいかず集約の範囲が曖昧だったのが原因
    • 集約の境界をしっかり定義し、その単位で整理しなおす

■ 12. 悩んでいる点

  • Equals()、GetHashCode()の実装負荷:
    • メンバ変数が少ない場合は大丈夫だが、多い場合は実装が大変
    • 値オブジェクト基底クラスを作る方法がMSドキュメントで紹介されているが、パフォーマンス面の不安が残る
    • 現状は数が少なければ手動で記述している
    • 数が多い場合はIDEの自動生成が楽だが、メンバ変数が増えた際に忘れず生成しなおす必要がある
    • タプルを使った実装も検討中
    • C#9のinitキーワード、record型が来れば楽になる見込み
  • structを採用しない理由:
    • Equals()が自動的に実装されるが、継承が出来ない
    • パフォーマンスの懸念もあるため、現プロジェクトではクラスを使っている
  • 大きな集約をどこまで許容するか:
    • ある概念が情報を大量に保持し、どれも密接に関係していて切り離すのが難しいケースがよくある
    • 分割した場合、整合性・不変条件をどう保っていくかが課題
    • トランザクション単位で分割する手法がある
    • UnityのクライアントコードではDBが絡むトランザクションが関係ない事も多く、大きいままでも良いのか判断がつかない
    • モデリングが上手く行っていない可能性もあり、明確な答えは出ておらず、いったん大きいまま扱っている
  • Unityでの実装がどのレイヤーになるか:
    • 導入直後、ゲームに必要な汎用機能をどのレイヤーに置くのか悩んだ
    • 特にUnityの機能を使った、Viewと関連する部分で悩んだ
    • 対象はアセットバンドル読み込み、ポップアップ表示、シーン読み込みと画面遷移、音を出す機能など
    • 現状はユースケースの実現と捉え、アプリケーション層で実装されている
    • ドメインと関係の無い基盤機能と捉え、実装はインフラストラクチャ層に置く方が適切だった
    • その機能を使う処理がアプリケーション層にある、という形が適切だった
  • singletonのレイヤー変更困難:
    • 一部singletonになっているものは、気軽にレイヤーを変更できない
    • 実装クラス指定アクセスになる
    • interfaceを使った依存関係逆転の原則も使えない
    • ServiceLocatorを使ってsingletonを纏める手法を検討中
  • 適切なレイヤーの判断:
    • どこが適切なレイヤーか未だに悩む
    • とはいえドメインとそれ以外を分離する事が最も大切
    • そこ以外は厳密に考えなくても良いのかもしれない

■ 13. まとめ

  • ドメイン駆動設計の良いところ:
    • 導入する事でチーム内で統一された設計思想を共有できるようになった
    • 悩んだ時や説明する際に参照できるリソースが豊富
    • データと振る舞いがセットで記述される事でコードの保守性・可読性が向上する
    • ドメインへの理解が推奨される
    • 一度習得すれば所属プロジェクトが変わっても様々な面で力になる
    • Unityでなくても良く、特定の技術にあまり左右されない
  • ドメイン駆動設計の大変なところ:
    • 導入のハードルはなかなか高く、継続的な学習が必要
    • チーム内に浸透するまで時間がかかる
    • まずはプログラムの実装パターン適用からでも効果はある
    • それでも各実装パターンへの正確な理解は必要
    • 本質はドメインへの理解を深める事だが、形から入る事で分かる事も多い
  • DIコンテナの良いところ:
    • singletonの代わりとなる
    • 多態性を使ったモック作成がやりやすい
    • 依存関係を管理しやすい
  • DIコンテナの大変なところ:
    • 最初の学習コストがかかる
    • 結局やっている事は単純ながら、概念を掴むのが難しい
  • 総括:
    • ドメイン駆動設計とDIコンテナは非常に相性が良い
    • レイヤードアーキテクチャを実践するうえで役に立つ
    • 双方ともアプリの種類や使用している技術に左右されづらく、汎用的な知識・手法として役に立つ