🙆‍♀️

VContainerがどのように動いているのか読む

2022/12/12に公開

この記事はUnityアドベントカレンダー1 12日目のエントリです。

https://qiita.com/advent-calendar/2022/unity

VContainer便利ですよね。僕も良く使っており、たまにPull Requestなどを送ったりしています。
しかもOSSなのでコードも無料で読めてしまうんです。これはお得ですね。

あなたがVContainerを利用してコードを書いてる以上、不具合を見つけたときに「ライブラリ側かも?」と調査するときがいつか訪れるでしょう。その際に全く実装がわからないのと、ある程度の流れを知っているのとでは大きく変わってきます。

そんな来るべき時のために、今回はVContainerの内部実装をざっくりと見ていきましょう。

目的

VContainerのコードを追わなければならない!となったときの障壁を減らす。
あわよくばコントリビューターを増やす。

対象者

  • VContainerを使っている人

注意

今回は Pre IL Code Generation を行わないケースでの解説です。また、最低限のフローの解説を行うのでいくつか省略されている実装があります。

また、バージョンは v1.12.0です。
https://github.com/hadashiA/VContainer/releases/tag/1.12.0

もくじ

  • サンプルコード
  • LifetimeScopeがシーンにいると何が始まるのか
  • 解決って具体的に何が起きているんですか
  • 誰が最初にResolveされるのか

サンプルコード

以下の TestLifetimeScope Foo Bar を定義したときの挙動をベースに考えてみましょう。

public class Foo
{
}

public class Bar : IInitializable
{
    private readonly Foo _foo;

    public Bar(Foo foo) => _foo = foo;
    public void Initialize() => Debug.Log("bar");
}

public class TestLifetimescope : LifetimeScope
{
    protected override void Configure(IContainerBuilder builder)
    {
        builder.Register<Foo>(Lifetime.Singleton);
        builder.Register<Bar>(Lifetime.Singleton)
            .AsImplementedInterfaces();
    }
}

TestLifetimeScope をシーンに配置して Auto Runにチェックを入れておくと、 Bar.Initialize() が呼び出されログが吐かれます。

LifetimeScopeがシーンにいると何が始まるのか

おもむろにシーンにLifetimeScopeを置くと、Auto RunならAwakeで自身のBuildメソッドを呼び出してContainerインスタンスの生成を始めます。

https://github.com/hadashiA/VContainer/blob/1.12.0/VContainer/Assets/VContainer/Runtime/Unity/LifetimeScope.cs#L157-L193

Auto Run を外しているなら、外部から Build() を呼び出すことになります。

Containerはインスタンスの生成やインジェクションなどを行う、いわゆる一般的にDI Containerと呼ばれるものの受け口になります。厳密にはIObjectResolverインタフェースを実装したクラスですが、LifetimeScopeのレイヤー上はContainerと名付けられています。

Containerを作成するための処理を分けると、ざっくりと以下の3つです。

  • 親のContainerの参照
  • オブジェクトの登録
  • Registryの作成

親のContainerの参照

Containerは親子関係を持つことができます。Lifetime.SingletonもしくはLifetime.Scopedの場合は対象のContainerになかったら親のContainerを遡って解決する仕組みです。

https://vcontainer.hadashikick.jp/ja/scoping/lifetime-overview

ということで、ビルドする前にContainerの親子関係を明らかにする必要があります。GetRuntimeParentは親のContainerを取得するメソッドです。

https://github.com/hadashiA/VContainer/blob/1.12.0/VContainer/Assets/VContainer/Runtime/Unity/LifetimeScope.cs#L267-L296

一口に親を探すと言っても複数のパターンが存在します。

  1. 親がいない(設定されていない)
  2. 親が設定されているがインスタンスが存在しない、もしくはContainerがビルドされていない
  3. 自身がRootLifetimeScopeである(RootLifetimeScope)
  4. 親はいないがRootLifetimeScopeが設定されている
  5. 親が上書きされている(Enqueue)

これらをチェックし適切な親のLifetimeScopeを返しています。2の状況だった場合はVContainerParentTypeReferenceNotFoundという例外を吐きます。この場合は自身をビルドする前に親のContainerがビルドされる必要があるので、一旦ビルド待ちリストに突っ込まれ他のLifetimeScopeのビルドが行われます。

オブジェクトの登録

登録は、VContainerでいうところのRegister処理を行うことです。今回だとTestLifetimescope.Configureに書いたFoo Bar のRegisterを実際に呼ぶところが以下になります。

https://github.com/hadashiA/VContainer/blob/1.12.0/VContainer/Assets/VContainer/Runtime/Unity/LifetimeScope.cs#L247-L265

ユーザーがoverrideしたConfigureの他にもIInstallerでのオブジェクトの登録もここでまとめて行われます。IInstallerはCreateScopeでのコールバックだったり、LifetimeScopeを用いずに自前でRegister処理を書きたいときに使ったりします。

https://vcontainer.hadashikick.jp/ja/scoping/generate-child-with-code-first

その他にも自身を登録したりEntryPointを登録したりと共通で必要な要素はここで登録されます。

ちなみにRegister系のメソッドはContainerBuilderには直接定義されておらず、拡張メソッドになってます。

https://github.com/hadashiA/VContainer/blob/1.12.0/VContainer/Assets/VContainer/Runtime/ContainerBuilderExtensions.cs
https://github.com/hadashiA/VContainer/blob/1.12.0/VContainer/Assets/VContainer/Runtime/Unity/ContainerBuilderUnityExtensions.cs

Registryの作成

Register<Foo>(Lifetime.Singleton) と書くと、Fooを解決するために必要な情報が構築されていきます。実際の型だったり実装されたインタフェースだったり、Lifetimeの設定などがそれにあたります。
それらの必要な情報を元に以下のクラスが作られていきます。

  • 実際にインスタンスを生成するRegistration
  • Registrationを作成するRegistrationBuilder
  • RegistrationをまとめるRegistry

最終的にはRegistryインスタンスがContainerのコンストラクタに渡されてBuild完了となります。

Registration

Registrationは実際にインスタンスを生成するときに呼ばれるクラスです。

https://github.com/hadashiA/VContainer/blob/1.12.0/VContainer/Assets/VContainer/Runtime/Registration.cs

型情報を渡してSpawnするのがお仕事で、更に詳細の生成ロジックは別のクラスに移譲してます。

RegistrationBuilder

名前の通り、Registrationを作るためのクラスです。

https://github.com/hadashiA/VContainer/blob/1.12.0/VContainer/Assets/VContainer/Runtime/RegistrationBuilder.cs

RegistrationBuilderはオブジェクトの型やインタフェース情報などを保持しています。
Asで型を指定したりWithParameterでパラメータを外部から渡したりなど、通常のRegisterの返り値として使われているので見覚えあるメソッドも多いでしょう。

RegistrationBuilderはRegisterの方法によって派生クラスがいくつかあります。

  • 最初からインスタンスを保持する InstanceRegistrationBuilder
  • 最初からComponentを保持する ComponentRegistrationBuilder
  • コールバックを受け取る FuncRegistrationBuilder

RegisterInstance(instance) と言った既に存在するインスタンスをRegisterするときはInstanceRegistrationBuilderが生成されます。存在するインスタンスがUnityのComponent継承(いわゆるMonobehaviourなど)であれば ComponentRegistrationBuilder が生成されます。
FuncRegistrationBuilderRegisterFactoryなど生成をコールバックで渡すときに使われています。

Registry

生成されたRegistrationリストを保持するのがRegistryクラスです。

https://github.com/hadashiA/VContainer/blob/1.12.0/VContainer/Assets/VContainer/Runtime/Registry.cs

保持しているRegistationを型情報から取得できるようにしています。

この3つのステップを経て、Containerが作成されています。

このフローの目的はあくまでContainerの生成であり、中に登録されているオブジェクトが実際にインスタンスとして生成されるかどうかというのはあまり重要ではありません。Containerのインスタンスはすべて必要なときに生成されるよう遅延評価されてるからです。

とはいえ、実際にはこのタイミングで概ね生成されていますが、それはEntryPointsBuilderを起動させているからです。これに関しては後述。

解決って具体的に何が起きているんですか

VContainerでの解決というのは、ざっくりと以下のことを行っています。

  • 任意の型を渡すと、そのインスタンスが取得できる
  • インスタンスは必要に応じて生成もしくはキャッシュされる

コードで書くと以下のとおりです。

objectResolver.Resolve<Foo>();

Resolveメソッドを呼ばれた段階で、Fooインスタンスが生成orキャッシュを取得できます。
また、Fooが依存しているオブジェクトがある場合は再帰的にそちらもResolveされます。
https://vcontainer.hadashikick.jp/ja/resolving/container-api

このメソッドが呼ばれたタイミングで、RegistrationのSpawnInstanceが呼び出されインスタンスが生成されます。

https://github.com/hadashiA/VContainer/blob/1.12.0/VContainer/Assets/VContainer/Runtime/Registration.cs#L33

Registrationクラス自体は詳細を知らず、以下のインタフェースのインスタンスに移譲しています。

  • インスタンスの提供方法を知ってる IInstanceProvider
  • Injectionの方法を知ってる IInjector

ざっくりとシーケンスは次のとおりです。

IInstanceProvider

これは、インスタンスをどのように提供するか知っているインタフェースです。どのようにとは、例えば型Tを提供する場合に以下のようなことです。

  • 新規にTのインスタンスを作る(Register<T>)
  • 生成済みのTインスタンスを渡す(RegisterInstance(instance))
  • IReadonlyList<T>型として返す(Register Collection)
  • ComponentなのでシーンからFindObjectOfType<T>で探して渡す(RegisterComponentInHierarchy<T>())
  • etc...

これらの状況によっては内部実装が大きく異なるので、IInstanceProviderとして抽象化されています。

https://github.com/hadashiA/VContainer/tree/1.12.0/VContainer/Assets/VContainer/Runtime/Internal/InstanceProviders

特定のRegisterだけ上手くいかない!という場合は個別のProviderクラスの実装を追うのがよいでしょう。

IInjector

IInjectorはインスタンスに対して実際にインジェクションを行う役割を持ったインタフェースです。

現状のインジェクション方法は、リフレクションを用いた方法とPre IL Code Generationで生成したコードによるインジェクションの2種類です。

今回はコード生成を行っていないのでReflectionInjectorが使われます。かなり根本のコードなので、あまり触ることはないかもしれません。
https://github.com/hadashiA/VContainer/blob/1.12.0/VContainer/Assets/VContainer/Runtime/Internal/ReflectionInjector.cs

ちなみに、どちらの方法を使うのかはInjectorCacheに問い合わせて判断しています。

https://github.com/hadashiA/VContainer/blob/1.12.0/VContainer/Assets/VContainer/Runtime/Internal/InjectorCache.cs#L11-L22

また、WithParameterなどで外からパラメーターが渡された場合、IInjectParameter のインスタンスに保持され、インジェクションの際に参照されます。

誰が最初にResolveされるのか

インスタンスは遅延評価なので、Resolveされない限りはインスタンスはつくられません。なので誰もResolveしないなら一切先に進まないはずです。しかしVContaienrはユーザーがResolveしなくてもなんだか動くようになっています。何故なら、前述したEntryPointBuilderが発火しているからです。

EntryPointsBuilder

LifetimeScopeのオブジェクト登録の最後で、EntryPointsBuilder.EnsureDispatcherRegistered が呼ばれています。

https://github.com/hadashiA/VContainer/blob/1.12.0/VContainer/Assets/VContainer/Runtime/Unity/LifetimeScope.cs#L263-L264

https://github.com/hadashiA/VContainer/blob/1.12.0/VContainer/Assets/VContainer/Runtime/Unity/ContainerBuilderUnityExtensions.cs#L13-L23

RegisterBuildCallbackはビルド完了時のコールバックを登録するメソッドです。
これは、EntryPointDispatcherをRegisterして、且つビルド完了時EntryPointDispatcherをResolveしています。
つまり、通常LifetimeScopeではEntryPointDispatcherのインスタンスが必ず存在することになります。

次にDispatch()メソッドを見ていきましょう。

https://github.com/hadashiA/VContainer/blob/1.12.0/VContainer/Assets/VContainer/Runtime/Unity/EntryPointDispatcher.cs#L21-L149

ここでやっているのは、IInitializable ITickableといったVContainerの各種エントリポイントに紐づいたオブジェクトの解決と実行です。

https://vcontainer.hadashikick.jp/ja/integrations/entrypoint

例えばサンプルのBarIInitializableを実装しているので、Dispatch()内部での以下の部分でインスタンスが生成されます。

https://github.com/hadashiA/VContainer/blob/1.12.0/VContainer/Assets/VContainer/Runtime/Unity/EntryPointDispatcher.cs#L34

そして、BarFooに依存しているのでFooのインスタンスも生成されます。
このように、エントリポイントの各種インタフェースを実装したクラスから芋づる式にインスタンスが生成されています。
逆に言えばどれだけオブジェクトを登録してもエントリポイントのインタフェースをRegisterしていない場合はインスタンスは生成されません。

まとめ

DIはなんでも自動でやってくれるので黒魔術と思われがちなところはありますが、リフレクションだったりといわゆるメタプログラミングになりそうな部分は全体の一部であり、それ以外は普通のよく構造化された設計だと思うのでみんな気軽に触ってみるのがいいと思います。

テストもいっぱいあるのでそこから読み解いていくのもオススメです。
https://github.com/hadashiA/VContainer/tree/1.12.0/VContainer/Assets/VContainer/Tests

Discussion