システム構成を整理する(2)(Unityメモ)

システム設計は責任分担の調整、つまり政治


前回のまとめ

・ユースケースの記述方法やクラス関係を再検討したい
・モジュールを整理するため、ヤードという概念を導入。機能の保守責任の分掌、つまり所属に近い概念。名前空間による実装が意味的に近い
・モジュール間の依存性の整理をする方法が必要だと気づいた

今回も車輪の再発明を進めてゆきます。

依存性の整理

インタフェースの所属を下位側に割り当てる

システム設計において、モジュールの依存性を整理することが大事です。特に相互依存があると、システムの理解や保守作業に手間がかかります。
さてレイヤ同士を結合する際に、両者の中間となる存在をはさむことがあります。この存在をインタフェースと呼びます。ここでのインタフェースは言語仕様ではなく、「界面」の概念の方を指します。
一般的には依存性逆転の原則に従い、インタフェースを抽象メソッド(つまり関数型の規約)として定義し、下位レイヤ側で抽象メソッドを実装する、という方法が採られることが多いです。

しかし単にインタフェースを用いるだけでは依存性は整理されません。インタフェースの保守責任、つまり所属も含めて考えることが必要です。
まず上位側はインタフェースを介して下位側の処理を呼び出すのですが、このとき上位側から下位側へ委譲関係が出来ます。
そしてインタフェースは上位側と下位側の両方から依存されています。このときにインタフェースの所属を上位側に割り当てると、見かけ上は下位側から上位側へ依存関係が作られます。すると委譲関係と依存関係の方向が逆向きになり、事実上の相互依存となります。
依存性逆転の原則を保ちつつ、事実上の相互依存を避けるためには、インタフェースの所属を下位側に割り当てます。すると見かけ上の依存関係が上位側から下位側への方向となり、委譲関係の方向とそろいます。

インタフェースの所属とレイヤ間の関係性の方向

異なるレイヤ間の情報伝達

システム設計においては、モジュールの依存関係の整理に加えて、異なるレイヤ間の情報伝達を設計する必要があります。
情報伝達においては、情報を持っているレイヤ、情報を最終的に受け取るレイヤ、情報伝達をする処理を呼び出すレイヤが異なる場合があります。これらの呼び出しや情報伝達の関係性にも方向性があり、その向きの組み合わせによって情報伝達を実現するための処理が変わってきます。

情報伝達の種類は、GetとSetに大別できます。
  Get:他者から基準オブジェクトへ情報が流れる(基準側が情報を得る)
  Set:基準オブジェクトから他者へ情報が流れる(基準側が情報を与える)
情報伝達と呼び出しの方向には、次の関係があります。
  Get:情報伝達の方向と呼び出しの方向が逆向き
  Set:情報伝達の方向と呼び出しの方向が同じ
 この方向の組み合わせに従うと、ある情報伝達の流れを実現するために、アダプタ側で呼び出す処理とオブジェクト側で実装すべき処理が定まります。

アダプタを使用するインタフェース

インタフェースを実現する方法として、抽象メソッドの記述と実装のほか、アダプタオブジェクトを用いる方法があります。アダプタから上位レイヤと下位レイヤを呼び出し、情報伝達を行います。

上位側が下位側からGetする場合、アダプタ内部では次の動作を行います。
  1. 下位レイヤのGetを呼び出し、アダプタが値を得る
  2. 上位レイヤのSetを呼び出し、アダプタが値を上位レイヤに与える
逆に上位側が下位側にSetする場合は以下のようになります。
  1. 上位レイヤのGetを呼び出し、アダプタが値を得る
  2. 下位レイヤのSetを呼び出し、アダプタが値を下位レイヤに与える

アダプタを使用するインタフェースにおける情報伝達の図式。
アダプタ側で呼び出す処理とオブジェクト側で実装すべき処理を記述

抽象メソッドの実装ではレイヤが直接情報伝達をするので、ここまで考える必要はありません。しかし別のオブジェクトを介してレイヤが情報伝達する場合は、情報伝達と呼び出しのペアを把握することで実装の内容を決めやすくなります。特に次で述べるコールバックが絡んでくる場合に考えが整理しやすくなります。

コールバック渡し

基準オブジェクトから他者へ情報伝達したいが、他者が処理の起点となる場合があります。この場合、実装を決めるのがややこしくなります。具体的にはGUIのイベント処理です。特に下位レイヤのイベント発生時に上位レイヤから下位レイヤに値をSetしたい場合がややこしいです。
この処理の実装を考えるのが難しいのは、呼び出しと参照関係の方向性が逆向きになるためです。これを解決するために、コールバックという概念を用います。コールバックを用いることで、呼び出しの方向を逆転できます。

コールバックの注意点は、もともと実現したい情報の流れに対し、コールバック内で実行すべき情報伝達処理が逆転することです。これは呼び出しの方向が逆転するためです。
  元々実現したい情報の流れがGet ⇔ コールバックで与える処理はSet
  元々実現したい情報の流れがSet ⇔ コールバックで与える処理はGet

上記の考えを踏まえて、アダプタの依存先が処理の起点となる場合は、アダプタの依存先からコールバックを呼び出す方法をとります。

アダプタの依存先が処理の起点(呼び出し元)となる場合の図式。イベントハンドラとラムダ式に似せた疑似コードで記述

上記の考え方はレイヤ全般の話です。これらを我らがヤードに適用すると、以下のようになります。

ヤードにおけるアダプタの動作の概要

HubヤードまたはContentヤードがアダプタの役割を持ちます。アダプタの役割を持たないヤードが処理の起点となる場合は、アダプタが該当のヤードにコールバックを渡します。

アダプタの基本設計

Hubヤードの中で実装するアダプタの中身を展開しました。

アダプタの中身。ここでの下位レイヤ=コールバックの呼び出し元となるレイヤ。
アダプタの構造は抽象層と実装層のリング状になる。

依存性の整理に加えて、コールバックもまた抽象メソッド(インタフェース)を用いて実現できます。
レイヤ間で扱う値の型が異なる場合は型変換が必要です。そのため型変換も含めてアダプタの設計を考えました。
また型変換に限らない一般の関数をアダプタで実行させたい場合も、同様のインタフェースを定義・実装することで、汎用的なアダプタを設計できます。

依存性逆転の原理を用いると、異なる概念を抽象メソッド(インタフェース)という一つの仕組みにより実現できます。
・多態性
・依存性の整理
・コールバック
ただしこれらの概念は別のものです。インタフェースが実現できる概念は一つではないので、あるインタフェースを見た時に、それが実現したい概念は何であるかを認識しておくことが大事です。

サブシステムとサブサービス

システムとサービス

複数の関連する機能をまとめた概念がモジュールで、モジュールを協調動作させるための実装がシステムです。アーキテクチャにおいて各レイヤがモジュールにあたり、アプリケーションがシステムにあたります。レイヤを協調動作させてはじめて自立したアプリケーションとして成立します。

モジュールとシステムの関係の模式図。システムは複数のモジュールを組み合わせている

制作中のゲームシステムの場合はHub以外の各ヤードがモジュールに相当します。ヤードをまたぐ処理はHubやシーンオブジェクトが担うので、Hubライブラリやシーンを実装したGameObjectが1個のシステムに対応します。
システムのうち、特定の要求に対して設計された応答を返すものがサービスです。システムは設計の構造に注目していて、サービスは動作の内容に注目しています。

サブシステムとサブサービスの自立性

大規模なシステムをいくつかの小規模なシステム、つまりサブシステムに分割したくなることがよくあります。ここで注意したいのは、サブシステムは分割前のシステムに対する上下関係を示すだけの言葉で、サブシステムもシステムとして完結しています。
つまりサブシステムは単なるモジュールの実装ではなく、モジュールの実装より上位の存在です。サブシステムもまたモジュールを協調動作させる機能を持ち、かつある程度は自立して動作することが期待されます。
サービスについてもシステムと同様です。大規模なサービスをサブサービスに分割する場合も、サブサービスはある程度自立して応答することが期待されます。

サービスの粒度:要求・応答のやり取り1回分が最小サービス

サブシステムやサブサービスはある程度自立して動作する存在です。この「ある程度」を決めるのが設計だと言えます。
システムについては、「依存性と機能の総量のトレードオフ」がサブシステムの粒度を決める基準だといえます。入力情報(引数)への依存性を減らすと個々の機能(関数)は簡素になりますが、機能の種類や、上位機能の実装は増えていきます。機能の種類を減らそうとすると、入力情報や依存するライブラリが増えていきます。
一方、サービスについては、要求・応答のやり取り1回分を実現する分量が最小サービス1個分の粒度といえます。
GUIの場合も同様で、応答一組ぶんの情報量を最小サービスとしてViewの実装を設計します。ユースケースのうちおおむね1スロット分の情報に対応付けられますが、ウィンドウは複数枚を同時に扱う場合がありえます。

サービス間構造とサービス内構造の直交化

DDDをはじめとするアーキテクチャは、一つのサービスについての設計方針です。サービスを分割して協調動作させたい場合は、サービスの組み合わせ方を別のアーキテクチャとして考えます。つまりサービス間の構造とサービス内の構造を直交化させます。
特にGUIのメニュー構造のようにサービスが木構造をとる場合、サービスの木構造とサービスの実装の内部構造を分けて考えます。

サブシステム・サブサービス間の情報伝達

システムが自身で完結できている場合、他のシステムとやり取りをする必要はありません。したがって、システムの情報伝達を考えるときはサブシステム間の情報伝達を扱うことになります。
DDD系のアーキテクチャは内部の層を外部から隔離する設計思想です。そのためDDD系でサブシステムを考えるときは、サブシステム間の情報伝達は再外層が行います。
一方、ヤードのアーキテクチャにおいては、Hubがサブシステム内の情報伝達と共にサブシステム間の情報伝達を行うことになります。Hubが内部ヤード間の情報伝達を定義・実行するので、サブシステム間の情報伝達も併せて行うのが効率的だといえます。

サービスについても同様です。
・完結したサービスよりもサブサービス間の情報伝達を考えることに意味がある
・Hubヤードがサブサービス間の情報伝達を行う
・DDD系のアーキテクチャでは再外層がマイクロサービス間の情報伝達を行う

Hub間の情報伝達

ユースケースのうちおおむね1スロット分の情報伝達が最小サービスとなります。これをHubヤード間の情報伝達により実現します。
特に木構造を持つメニュー構造について、ノードが1スロットに相当し、これが最小サービスに相当します。
Hubヤード間の情報伝達も、基本的にはアダプタを用いたヤード間の情報伝達と同じ仕組みで実現できます。ただし以下の点に注意します。
・具体的なアプリケーションの機能は、サブサービス内のModel, View, Storageヤードを呼び出すことで実行する
・サブサービス内のヤードからコールバックによりサービスが実行されることが多い。大抵はView(ユーザ入力)が起点となる

Model-Viewヤード間の情報伝達をHubが仲介するように、サブサービスのHubヤードの情報伝達を別のHubが仲介するような仕組みを作る方針もありえます。しかしHubが起点となるケースが少ないため、仲介Hubからのサービス実行はあまり行われないように感じます。そのためHubヤード間の情報伝達は直接の伝達のみを考えます。
このとき、下位側(コールバックを呼び出す側)のアダプタを省略できます。上位側と下位側の型変換については、型のインタフェースとして中間型を用意すると依存性を整理できます。

上図のアダプタをそのまま実装するとHub間のサービスのコールバックがネストします。しかし次で再考するContentヤードの概念を使うと、Hub間のサービスをコールバックをネストせずに書けます。つまりHub内のヤードを連携するイベントチャートと同様の枠組みで、Hub間をまたぐサービスの連携を書けるようになります。疑似コードを書くと次のような感じになります。

// Viewのコールバックで、View→ModelへのGetを実現
// 見かけ上はSetを行う
Content{
  callback = (foo) => {
    bar = ViewHub.View2IM(foo);

    qux = ModelHub.IM2Model(bar);
    ModelHub.Set(qux);
  };
  ViewHub.SetViewSetterCB(callback);
}

ViewHub{
  SetViewSetterCB(callback) {
    View.SetterCallback = callback;
  }
}

View{
  OnClick(){
    View.SetterCallback.Exec(foo);
  }
}

メニュー構造の定義とその所属

サービスの木構造とその所属

サブサービス間の情報伝達は設計のイメージを作れるようになりました。あとはサービスの構造の扱いを整理します。特に木構造のメニュー構造の扱いを再考しました。

スロット(黄)と操作(緑)の構造の一部。スロットが枝、操作が葉。
このコンテント構造を実現できるようにビューを実装する(再掲)

本ゲームシステムにおいて、メニュー構造はスロットをノードとする木構造で、スロットが最小サービスの応答となります。つまりメニュー構造はサービスが木構造をとったものとなります。

Contentヤード再考

サービスの構造については、以下のことを記述する必要があります。
・サービスインスタンスの木構造の定義
・サービス実行処理やコールバックの記述
・サービスインスタンスの生成処理
加えて、これらをどのレイヤに所属させるのかを考える必要があります。

これらの記述は様々な方法が考えられますが、抽象化した「サービスの構造をまとめて記述する」という概念が、Contentヤードに相当します。Contentヤードはモジュールやレイヤそのものではなく、設計上の規約にあたります。

サービス構造の要素の定義はHubヤードに所属します。その上で、規約としてのContentヤードの実現手段はいくつか考えられます。
手段A:抽象Hubを実装した具象クラスに、サービス構造の定義をstaticメンバとして直書きする(シングルトン)
手段B:ゲームオブジェクトのような外部クラスにサービス構造の定義を記述(Unityの場合はコンポーネントで実装)

注意点として、Contentヤードは概念として存在しますが、一対一に対応するコード実体を常に持つわけではありません。
手段Aの場合はHubヤードの中でContentヤードを実現します。手段Bの場合はViewヤードの中でContentヤードを実現します。

またContentヤードの概念とは別に、Hubのサービス構造に対応したメニュー構造をViewでも持たせる場合は、Hub内でサービス構造をメニュー構造に、つまりスロット構造に対応付けることになります。これはサービス構造とヤード内構造の変換であり、広義の型変換にあたります。実装は明示的な型変換でも、インタフェースだけ合わせて内部的にはインスタンスを生成するような処理でもかまいません。

Hubのサービス構造とViewのスロット構造の対応付けは型変換に相当

まとめ

制作中のゲームシステムについて、依存性の整理・メニュー構造の再設計を行うための枠組みを考えました。

・前回、ヤードという概念を導入。機能の保守責任の分掌、つまり所属をあらわす
・依存性、情報伝達、呼び出しの方向を整理し、ヤード間の見かけ上の相互依存をなくす
    ・インタフェースを所属させるヤードを決める
    ・情報伝達、呼び出しの方向性を元に、Get/Setを明確にする
    ・コールバックを用いて呼び出し方向を逆転させる
・応答一組ぶんの情報量を最小サービスとする。ユースケースのうちおおむね1スロット分に相当する。
・最小サービスを単位としてしてViewの実装を設計する
・サブサービス間の情報伝達としてゲームシステムを設計する
・Hubヤードがサービスの情報伝達を司る
    ・サブサービス内部のヤード間の情報伝達、型変換
    ・サブサービス間(Hub間)の情報伝達
    ・サービス構造の要素の定義
        ・サービス構造とヤード内構造(メニュー構造など)の変換も型変換
・「サービスの構造をまとめて記述する」という概念が、Contentヤードにあたる。Contentヤードは規約であり、実装方法は一つに限定しない

今後はこの枠組みに沿って現状の最小構成のゲームシステムを整理し、完成品に向けて拡張していくつもりです。


この記事が気に入ったらサポートをしてみませんか?