はじめに
よく実装は「疎結合にすべき」といわれます。
そのためにレイヤードアーキテクチャに腐敗防止層を設けたり、依存性逆転の原則を適用したりして、3rdパーティのライブラリの変更から自身のコードを守ることがよく行われています。
Assembly definition
Unityでは Assembly definitionによりアセンブリ空間を定義でき、ディレクトリ構成を基にアセンブリ空間を分離することができます。
レイヤ定義を namespace
任せにするだけでなく、アセンブリ空間さえ分離してやることでより他の開発者にレイヤ構成を遵守させることができそうです。
アセンブリ空間分離のお試しプロジェクトを作成しました。
以下で、レイヤ分離の意図を解説します。
詳細は知らない
Main
はインタフェースの IDependencee
を所持しており処理を行います。また、MonoBehaviour
でもあるのでUnityから Start()
が実行され、 .DoSomething()
が実行されます。
public class Main : MonoBehaviour
{
[Inject] IDependencee dependencee;
void Start()
{
dependencee.DoSomething();
Debug.Log(dependencee.GetCommonObject().value);
}
}
Main
が知っているのは interface の IDependencee
のみであり、実装の詳細である Dependencee
に関する知識はありません。これは Dependencee
の実装が今後変更された場合も Main
には影響が及ばないことを意味しています。
さらに言えば、 Dependencer.asmdef は DependenceeImplement.asmdef を参照しないので、Main.cs の実装内では Dependencee
クラスのインスタンスをnewすることすらできません。
Dependencer.asmdef が参照するのはいわゆる腐敗防止層である DependenceeAbstruct.asmdef になります。
ここには振る舞いを表すinterfaceのみが定義されています。
namespace AsmdefDependencyPattern.DependenceeAbstruct
{
public interface IDependencee
{
void DoSomething();
Common GetCommonObject();
}
}
もちろん DependenceeImplement.asmdef は interface を実装するために、DependenceeAbstruct.asmdef を参照します。
(上の図にはasmdef参照の矢印は含まれていません)
namespace AsmdefDependencyPattern.DependenceeImplement
{
public class Dependencee : DependenceeAbstruct.IDependencee
{
public void DoSomething()
{
UnityEngine.Debug.Log("Dependencee.DoSomething()");
}
public Common GetCommonObject()
{
return new Common {value = 999};
}
}
}
Dependency Injection
さて、では Main
に Dependencee
クラスのインスタンスを渡しているのは何者でしょうか?
Dependency Injection(DI)によってこの問題を解決しています。
Unityではおなじみの Zenject というDIフレームワークを使いました。
IDependencee
が要求されたときに、Dependencee
のインスタンスが渡されるように設定を行います。
public class DependenceIntermediary : Zenject.MonoInstaller
{
public override void InstallBindings()
{
Container.Bind<IDependencee>().To<Dependencee>().AsTransient();
}
}
DependenceIntermediary.asmdef は依存性注入のため DependenceeAbstruct.asmdef と DependenceeImplement.asmdef の両方を参照します。依存性注入を行う特性上、多くの asmdef を参照するようになると思います。
そしてシーン上に Main
, DependenceIntermediary
, Zenject.SceneContext
を配置することで、SceneContext
により IDependencee
と Dependencee
の間の依存関係が解決され、Main.Awake()
やMain.Start()
が実行される時には Dependencee
のインスタンスがフィールドインジェクション経由で取得できる訳です。
共通のクラス定義
場合によっては各レイヤを横断するクラスの定義や定数定義が必要になると思われます。
共通の定義は すべての asmdef から参照される必要があるので、これまでの .asmdef に追加するのでなく、新たに共通定義用の asmdef を作成する必要があります。
仮に、 Dependencer.asmdef に共通定義のクラスを作成した場合、DependenceeAbstruct.asmdef から Dependencer.asmdef を参照する必要が出てきます。Dependencer.asmdef は既に DependenceeAbstruct.asmdef を参照しているので、この場合循環参照が発生してしまうので破綻してしまいます。(Assembly definitionでは循環参照を設定することはできません)
新たに作成した Common.asmdef は他の asmdef を参照せず、逆に他3つの asmdef から参照される形になります。Common.asmdef 内のクラスが変更された場合、広範囲の asmdef に影響がでる形になります。
おわり
UnityのAssembly definitionは簡単に定義出来て強力な効果を発揮するのでUnityを使うなら勉強しておいて損は無いはずです。実際私は asmdef のレイヤ定義の過程で依存性逆転の理解がかなりできました。
ちなみに、Dependencer/Dependenceeは造語です。reviewer/revieweeのように、
- Dependencer: 依存する人(利用側)
- Dependencee: 依存される側(利用される側)
を意図しています。
asmdefによるレイヤ分けに興味が出てきた方は是非こちらもご覧ください