土屋つかさの技術ブログは今か無しか

土屋つかさが主にプログラミングについて語るブログです。

#unity VContainerを使ってDI(Dependency Injection)コンテナについて最低限理解する

 「DIコンテナ」という言葉を聞いたことがあっても、それがなにをする物で、どういう風に使うのかがピンと来ない人は多いかと思います(土屋もそうでした)。この記事では、Unity用DIコンテナフレームワーク「VContainer」を最小限のサンプルコードで動作させ、DIパターン/DIコンテナについて解説します。

注意

・サンプルコードでは、わかりやすさを優先して、VContainerでは積極的に推奨していない手法*1を使用しています。
・土屋の理解の範囲でのDIパターン/DIコンテナの解説なので、偏りや間違いがあるかと思います。なにかありましたらコメントでどうぞ。
・サンプルコードはVContainer1.5.8以降でないと意図した挙動になりません*2。これだけはマジ注意

最小限のサンプルコードでVContainerを動かしてみる

 まずサンプルコードを実行してみます。DIコンテナ初体験の人は、実行結果を見て戸惑うかもしれませんが、後程ちゃんと説明します。

STEP1/4:空プロジェクトにVContainerをインポートする

 まずはVContainerのセットアップからです。ここまで書いててまだVContainerについて説明していないことに気づきましたが後でやります。
 VContainerのセットアップにはいくつか方法がありますが、ここでは事前準備がいらない方法を紹介します。
1・空のプロジェクトを作成してから、テキストエディタなどで"Packages/manifest.json"ファイルを開き、"dependencies"のブロックに下記行を追加する。

"nuget.mono-cecil": "0.1.6-preview",

 これは、VContainerが内部で使用するmono-cecilというライブラリを読み込む指定になります*3
f:id:t_tutiya:20210228201233p:plain
 上画像みたいな感じです。これでエラーが出た時は、配列の一番下要素の行末に","を書いてたりしないか確認してください(やりがち)。
2・Githubのリリースページから最新版の.UnityPacageをダウンロードする
https://github.com/hadashiA/VContainer/releases
 執筆時点ではv1.5.10が最新版です。Assetsのリンクにある"VContainer.1.5.10.unitypackage"をクリックしてダウンロードします。
f:id:t_tutiya:20210228201710p:plain
3・ファイルをprojectウィンドウにドラッグアンドドロップするとImportウィンドウが開くので、Importボタンを押す。

STEP2/4:csファイル作成

以下の3つのcsファイルを作成してProjectフォルダ内に配置します。ファイル名を大文字小文字含めて同一にしないと、意図する挙動にならないので注意してください。

Logger.cs
//Logger.cs
using UnityEngine;

namespace Tsukasa.Sample
{
    public interface ILogger
    {
        void Output(string text);
    }

    public class Consolelogger : ILogger
    {
        public void Output(string text)
        {
            Debug.Log($"コンソールにログを出力中:{text}");
        }
    }

    public class FileLogger : ILogger
    {
        public void Output(string text)
        {
            Debug.Log($"ファイルにログを出力中:{text}");
        }
    }
}
LogButton.cs
//LogButton.cs
using UnityEngine.UI;
using UnityEngine;

using VContainer;

namespace Tsukasa.Sample
{
    public class LogButton : MonoBehaviour
    {
        public ILogger outputService;

        [Inject]
        public void Construct(ILogger outputService)
        {
            this.outputService = outputService;
        }

        public void Start()
        {
            transform.GetComponent<Button>().onClick.AddListener(() => ButtonClick());
        }

        public void ButtonClick()
        {
            outputService.Output($"{transform.GetComponent<Button>().name}がクリックされました");
        }
    }
}
GameLifetimeScope.cs
//GameLifetimeScope.cs
using VContainer;
using VContainer.Unity;

namespace Tsukasa.Sample
{
    public class GameLifetimeScope : LifetimeScope
    {
        protected override void Configure(IContainerBuilder builder)
        {
            builder.Register<ILogger, Consolelogger>(Lifetime.Singleton);
            builder.RegisterComponentInHierarchy<LogButton>();
        }
    }
}

STEP3/4:GameObjectにコンポーネントをアタッチする。

 2個のクラスをコンポーネントとしてオブジェクトにアタッチします。
1・シーン上にButtonを作成し、LogButtonクラスをアタッチします*4
f:id:t_tutiya:20210228201323p:plain

2・空のGameObjectを作成し、GameLifetimeScopeクラスをアタッチします。
f:id:t_tutiya:20210228201448p:plain

STEP4/4:動作確認

 再生ボタンをクリックしてplaymodeに移行した後、ボタンをクリックするたびにコンソールにログが出力されます。
f:id:t_tutiya:20210228201529p:plain

解説の流れ

 DIコンテナを普段使っていない場合、サンプルコードの挙動を見て、以下の疑問を持つかもしれません。

・ConsoleLoggerはいつ生成されたのか?
・Constract()メソッドはいつ実行されたのか?
・生成されたConsoleLoggerインスタンスのライフサイクルはどこが管理しているのか?
・なぜこんな手法が必要なのか?

それでは、改めてコードを解説しながら見ていきましょう

コード解説1(Logger.cs/LogButton.cs)

 ボタンを押すとログを出力するコードを考えます。ログはコンソールかファイルのどちらかに出力できるようにします。

Logger.cs

 Logger.csではログの出力を担当するクラスを実装しています。

public interface ILogger
{
    void Output(string text);
}

 ILoggerインターフェイスはOutput()メソッドを定義しています。引数のtextが出力対象の文字列になります。ログ出力オブジェクトは、ILoggerを継承してOutput()メソッドをオーバーライドし、個々のメディアへのログ出力を行います。

public class Consolelogger : ILogger
{
    public void Output(string text)
    {
        Debug.Log($"コンソールにログを出力中:{text}");
    }
}

public class FileLogger : ILogger
{
    public void Output(string text)
    {
        Debug.Log($"ファイルにログを出力中:{text}");
    }
}

 ConsoleLoggerクラスはコンソールへの、FileLoggerクラスはファイルへのログ出力を行います。ただし、ファイルの操作が面倒なので、両方ともコンソールに出力しています。

LogButton.cs

 LogButton.csファイルでは、このILogger継承オブジェクトを内部で使うコンポーネント(つまりMonoBehavior継承クラス)を実装します。

public class LogButton : MonoBehaviour
{
    public ILogger outputService;

    [Inject]
    public void Construct(ILogger outputService)
    {
        this.outputService = outputService;
    }

 LogButtonクラスは、privateメンバでILogger継承クラスのインスタンスを保持します。この値はConsuract()メソッドの引数を通じて設定されます。
 このクラスにはILogger継承クラスインスタンスは必須なので、本来ならコンストラクタで取得するようにしたい所ですが、MonoBehaviour継承クラスはコンストラクタを持てないので、このようにしています(Unityあるあるですね)。
 "[Inject]"はアトリビュートなのですが、これについては後述します。

public void Start()
{
    transform.GetComponent<Button>().onClick.AddListener(() => OnClick());
}

 Start()メソッドでは、自身が所属するGameObjectからButtonコンポーネントを取得し、そのButtonのクリックイベントハンドラにlogger.Output()を登録します。

public void ButtonClick()
{
    outputService.Output($"{transform.GetComponent<Button>().name}がクリックされました");
}

 ボタンがクリックされるとButtonのOnClickイベントが発火し、イベントハンドラに登録されたButtonClick()メソッドが実行されます。ここでoutpuServiceに格納されたILogger継承クラスインスタンス(ConsoleLoggerあるいはFileLogger)のOutput()メソッドが呼ばれ、インスタンスに応じたログ出力処理が行われます。

Dependency Injection(DI)パターン

 サンプルコードでは、ILoggerインターフェイスを用意して、LogButtonからログ出力機能を分離し、コードが疎結合になるようにしています。ログ出力のようにI/O処理がからむ箇所では処理系依存が多いため、このようなコードをよく書きます(書きますよね?)。アクセス先がDBだったりクラウドだったりした場合もLogButtonクラスはそのままに、ILoggerインターフェイスを継承してクラスを一個作れば対応できるわけです。

 大規模開発において、このような構造のコードを書くことはよくあります。そこで、この頻出パターンには「Dependency Injection」という名前が付けられています*5

 オブジェクトAが動作する為にオブジェクトBを必要とする場合、BをAの「依存(Dependence)オブジェクト」であるとし、またそのBは、Aの外部から送り込まれてくるので、「依存オブジェクトを注入する」という意味で、「Dependency Injection(依存性の注入)」と名付けられているわけです。

 ちなみに、日本語圏では「Dependency Injection」を「依存性の注入」と訳されて紹介されることがあるのですが、これはパターンの名称なので、「ディペンデンシーインジェクション」とそのまま読む方が適切だと思います。長いので一般には「DI(ディーアイ)」と略されます*6

DI実装で考えること:生存期間(ライフサイクル)とオブジェクトグラフの管理

 DIで実装する時、今回のLogButtonくらいの小さなコードならそこまで気にする必要ないんですが、開発規模が大きくなると、コード設計時に「依存オブジェクトの生存期間管理」「オブジェクトグラフ管理」について考える必要が出てきます。それぞれ見ていきましょう。

依存オブジェクトの生存期間(ライフサイクル)の管理

 多くの場合、DIでは依存オブジェクトの設定をコンストラクタで行います(サンプルコードではConstructメソッドを使っているのはMonoBehavior継承クラスがコンストラクトを持てないからで、これについては後述します)。
 例えばこんな感じでしょうか。

var fuga = new LogButton(new FileLogger);

 この時、生成されたLogButtonインスタンスと、注入されたFileLoggerインスタンスの生存期間(ライフサイクルと言います)は同じで、fugaがdisposeされた時に同時に消滅します。

 しかし、FileLoggerは内部メンバを持たないので、生成は1回だけにして、すべてのLogButtonで使い回したい所です*7。しかしその場合、そのFileLoggerのインスタンスを保持するメンバ変数は、どこに記述するのが適切でしょうか?

 どこでもいいじゃんという気もしますが、DIされたオブジェクトを使おうとした時に、既に依存オブジェクトが削除されていたということがあれば簡単に実行時エラーを起こしてしまいます。後述しますが、依存オブジェクト自体に別の依存オブエジェクトがDIされている事もあり、各オブジェクトの生存期間、すなわちライフサイクルを想定して設計するのはとても重要で、かつ面倒なのです。

 先述した「1回だけ生成して使い回すオブジェクト」はシングルトンパターンと呼ばれるスタイルで書くことが多いです。シングルトンは通常、コード全体で同じインスタンスを使い回し、かつ、その生存期間はプログラムの開始から終了までになります。依存オブジェクトをシングルトンで実装するのも一つの手でしょう。

 ただ、このスコープやライフサイクルをもっと小さくしたい場合があります。Unityで言うと「現在のシーンが続いている間だけシングルトンとして機能する依存オブジェクト」を用意したい時は工夫が必要になります。更に言えば依存オブジェクトが常にシングルトンなわけでもありません。

オブジェクトグラフの管理

 DIする依存オブジェクトが複数になる場合もあります*8

var fuga = new LogButton(new DependencyA, new DependencyB, new DependencyC);

 このオブジェクトがコード上の複数の箇所で生成する必要がある場合、当然この1行がコード上に何度も書かれることになります。ここまではいいとしましょう。

 ある時、DependencyAを改修して、コンストラクタに引数を持たせることにしました。この時、コード上に散らばっている"new DependencyA"を修正する必要がありますね。今時のIDEなら一括で直せるでしょうが、このような作業が頻発した場合、修正作業はコストになります。

 また、先程も書いた通り、ある依存オブジェクトが別の依存オブジェクトからDIされることもあります。コードが大規模になった時、このようなオブジェクト間の相関関係(オブジェクトグラフ)を適切に把握、制御できる必要があります。

DIコンテナ

 ここまで書いてきた事は、「DIならではの検討課題」というわけでは無く、開発が大規模になれば形式はどうあれ起こりうる物です。エンタープライズ開発では、如何にコードを疎結合化し、その上で如何にオブジェクトグラフを安全に構築するかの研究が進めらてきました。メンテナンスがしやすく、バグを作り込みにくいコードを書ければ、開発作業が低コスト化できるからです。

 近年*9におけるその成果の一つが「DIコンテナ」と言えるでしょう。DIコンテナは、DIパターンの実装における上記の課題を、リフレクションという言語機能を使って支援するフレームワークです。

 というわけでようやくDIコンテナの解説なのですが、例によってここまでで書きたいことは大体書いたのであとは駆け足です。

 DIコンテナはフレームワークなので、使うDIコンテナによって使い方や流儀が異なります。UnityではZenjectとVContainerというDIコンテナフレームワークが広く知られています。本記事ではVContainerを使用しました。Zenjectに比べて知名度が低いかもしれないけどシンプルで使いやすいのでもっと広がれ!(私情)

コード解説2:GameLifetimeScope.cs

public class GameLifetimeScope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        builder.Register<ILogger, Consolelogger>(Lifetime.Singleton);//①
        builder.RegisterComponentInHierarchy<LogButton>();//②
    }
}

 VContainerでは、オブジェクトのライフサイクルとオブジェクトグラフの管理を、LifetimeScopeを継承したクラスで行います。LifetimeScopeはMonoBehavior継承クラスなので、それを継承したGameLifetimeScopeもコンポーネントになります。

 Configureメソッドの中で、管理したクラス(インスタンスの場合もある)を登録していきます。登録されたクラスは「コンテナ」に格納されます*10

①Consoleloggerクラスをコンテナに登録します。登録する際に、ConsoleloggerクラスをILoggerにキャストしても良いと指定しています。
 クラスが登録される時、そのクラスに"[Inject]"アトリビュートが付与されているかがチェックされます。もし付与されている場合、そのクラスが生成される際にVContainerが自動的にインジェクション処理を行う事になります。

②LogButtonクラスをコンテナにに登録します。MonoBehavior継承クラスの場合は、登録に使用するメソッドが変わります。

 RegisterComponentInHierarchyが実行されたタイミングで、シーン上にある全オブジェクト(つまりHierarchyに登録された全オブジェクト)のLogButtonコンポーネントがピックアップされ、"[Inject]"アトリビュートが付与されたConstruct()メソッドを自動的に実行します。

[Inject]
public void Construct(ILogger outputService)
{
    this.outputService = outputService;
}

 さて、実行するのは良いのですが、引数のILoggerオブジェクトはどこから持ってくればいいのでしょうか? ここで①の登録処理が効いてくるわけです。①でILoggerオブジェクトが必要な時にConsoleloggerを使用して良いと指定しているので、VContainerは内部でConsoleloggerインスタンスを生成し、Construct()メソッドに渡したのです。

 インジェクションはVContainerが行うので、プログラマは直接Contract()メソッドを記述する必要がありません。生成されたConsoleloggerインスタンスは、GameLifetimeScopeがライフサイクルを管理します。通常は、シーンが終了した時にまとめて削除されるでしょう。

 また、コンテナへの登録は実行時に行われるので、以下のように、DIコンテナに登録するクラスを判定で分岐させ、インジェクションされる依存オブジェクトを選択することもできます。

protected override void Configure(IContainerBuilder builder)
{
    if(なんらかの判定){
        builder.Register<ILogger, Consolelogger>(Lifetime.Singleton);
    }else{
        builder.Register<ILogger, Filelogger>(Lifetime.Singleton);
    }
    builder.RegisterComponentInHierarchy<LogButton>();
}

 「なんらかの判定」は例えばインスペクタ上のトグルボタンや、外部のXMLファイルのパース結果などを使って、プログラムの外から与えることもできます。

 このように、DIコンテナは登録されたオブジェクトグラフに従って、必要なDIを自動的に行ってオブジェクトを生成し、かつライフサイクルを管理してくれるフレームワークなのです。

次回予定

 当初はここで補講として下記項目について軽くまとめるつもりだったんですが、すでに相当な分量になっている上に、これらを含めるとさらに膨れ上がるので、改めて記事を作ることにしました。というわけで以下については次回です。
・DIコンテナの解説が難しい理由
・DIってStrategyパターンじゃないの?
・DIってカプセル化原則に反してるんじゃないの?(IoC(制御の反転)の話)
・DIなのにファクトリの話しなくていいと思ってんの?
・DIコンテナは本当に開発効率に寄与するの?
・結局UnityではどこにDIコンテナを使うのが良いの?

参考リンク

記事を書くにあたり多くのサイトを参考にしました。ありがとうございます。

VContainer関連(実践)

vcontainer.hadashikick.jp
VContainerドキュメント。"VContainer Document"でググッてもここが最初に出て来ないのはなんでなんだ。わかりやすいので英語でも読める(それはそれとして日本語ドキュメント希望)

light11.hatenadiary.com
@harumak_11さんのVContainer解説。非常に分かりやすい。

qiita.com
貴重なVContainerの実践記事。ただし、Zenjectコードが混在してて混乱するかも。

VContainer関連(設計)

hadashia.hatenablog.com
VContainerの作者hadashiAさんによるVContainerの設計思想についての解説。

scrapbox.io
同じくhadashiAさんによるDIの使いどころについての解説

DIコンテナ全般

qiita.com
この記事を読んで、ようやくDIコンテナの有用性を理解できました。コードはPHPですが、そんなに難しくはないと思います。今回の記事で触れていないアンチパターンとしてのサービスロケーターについても触れています。

woshidan.hatenablog.com
DIパターンとストラテジーパターンはなにが違う(or同じ)なのかがよくまとまっている。ここで言及されている下2個の記事も有用です。

blog.a-way-out.net

www.infoq.com
2007年当時に行われた「DIコンテナは有用なのか」という海外の議論の解説。

読み物

kakutani.com
すべてはここから始まった、マーティンファウラーのDI解説記事。「DI」という名称を提案した張本人で、それまでいろんな呼称で呼ばれていた物が、恐らくこの記事が起点でDIに収斂しました。さすがに古い文章なので、読み物としてどうぞ。

soysoftware.sakura.ne.jp
「Unityのインスペクタを介した初期化処理ってつまりDIだよね?」という指摘。卓見です。これについてはいずれ改めて記事を起こしたい。

*1:コンポーネントへのインジェクション

*2:v1.5.7以前ではRegisterComponentInHierarchy()メソッド実行時にresloveが起きないため

*3:この記事のサンプルコードでは使用しないので、書かなくてもエラーにはならないかもだけど未確認

*4:説明のためにウィンドウを並べていますが、普段はこんなレイアウトにはしていません

*5:このような頻出パターンのことを「デザインパターン」と呼んでいます。DIもデザインパターンの一種です

*6:多分海外でも長いと思われてて略されてる

*7:staticクラスにするべきではという意見もあるでしょうし、今回の例ではそうすべきかもしれませんが、ここでは置きます

*8:というか、その方が多いと思います

*9:と言っても15年以上前に最初のブームが起きた手法です

*10:「DIコンテナ」の「コンテナ」はこれを指している……のかなあ? これはちょっと曖昧