VContainerがどのように動いているのか読む
この記事はUnityアドベントカレンダー1 12日目のエントリです。
VContainer便利ですよね。僕も良く使っており、たまにPull Requestなどを送ったりしています。
しかもOSSなのでコードも無料で読めてしまうんです。これはお得ですね。
あなたがVContainerを利用してコードを書いてる以上、不具合を見つけたときに「ライブラリ側かも?」と調査するときがいつか訪れるでしょう。その際に全く実装がわからないのと、ある程度の流れを知っているのとでは大きく変わってきます。
そんな来るべき時のために、今回はVContainerの内部実装をざっくりと見ていきましょう。
目的
VContainerのコードを追わなければならない!となったときの障壁を減らす。
あわよくばコントリビューターを増やす。
対象者
- VContainerを使っている人
注意
今回は Pre IL Code Generation を行わないケースでの解説です。また、最低限のフローの解説を行うのでいくつか省略されている実装があります。
また、バージョンは v1.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インスタンスの生成を始めます。
Auto Run
を外しているなら、外部から Build()
を呼び出すことになります。
Containerはインスタンスの生成やインジェクションなどを行う、いわゆる一般的にDI Containerと呼ばれるものの受け口になります。厳密にはIObjectResolver
インタフェースを実装したクラスですが、LifetimeScopeのレイヤー上はContainerと名付けられています。
Containerを作成するための処理を分けると、ざっくりと以下の3つです。
- 親のContainerの参照
- オブジェクトの登録
- Registryの作成
親のContainerの参照
Containerは親子関係を持つことができます。Lifetime.Singleton
もしくはLifetime.Scoped
の場合は対象のContainerになかったら親のContainerを遡って解決する仕組みです。
ということで、ビルドする前にContainerの親子関係を明らかにする必要があります。GetRuntimeParent
は親のContainerを取得するメソッドです。
一口に親を探すと言っても複数のパターンが存在します。
- 親がいない(設定されていない)
- 親が設定されているがインスタンスが存在しない、もしくはContainerがビルドされていない
- 自身がRootLifetimeScopeである(RootLifetimeScope)
- 親はいないがRootLifetimeScopeが設定されている
- 親が上書きされている(Enqueue)
これらをチェックし適切な親のLifetimeScopeを返しています。2の状況だった場合はVContainerParentTypeReferenceNotFound
という例外を吐きます。この場合は自身をビルドする前に親のContainerがビルドされる必要があるので、一旦ビルド待ちリストに突っ込まれ他のLifetimeScopeのビルドが行われます。
オブジェクトの登録
登録は、VContainerでいうところのRegister処理を行うことです。今回だとTestLifetimescope.Configure
に書いたFoo
Bar
のRegisterを実際に呼ぶところが以下になります。
ユーザーがoverrideしたConfigureの他にもIInstaller
でのオブジェクトの登録もここでまとめて行われます。IInstaller
はCreateScopeでのコールバックだったり、LifetimeScopeを用いずに自前でRegister処理を書きたいときに使ったりします。
その他にも自身を登録したりEntryPointを登録したりと共通で必要な要素はここで登録されます。
ちなみにRegister
系のメソッドはContainerBuilderには直接定義されておらず、拡張メソッドになってます。
Registryの作成
Register<Foo>(Lifetime.Singleton)
と書くと、Foo
を解決するために必要な情報が構築されていきます。実際の型だったり実装されたインタフェースだったり、Lifetimeの設定などがそれにあたります。
それらの必要な情報を元に以下のクラスが作られていきます。
- 実際にインスタンスを生成する
Registration
- Registrationを作成する
RegistrationBuilder
- Registrationをまとめる
Registry
最終的にはRegistryインスタンスがContainerのコンストラクタに渡されてBuild完了となります。
Registration
Registration
は実際にインスタンスを生成するときに呼ばれるクラスです。
型情報を渡してSpawnするのがお仕事で、更に詳細の生成ロジックは別のクラスに移譲してます。
RegistrationBuilder
名前の通り、Registrationを作るためのクラスです。
RegistrationBuilder
はオブジェクトの型やインタフェース情報などを保持しています。
As
で型を指定したりWithParameter
でパラメータを外部から渡したりなど、通常のRegisterの返り値として使われているので見覚えあるメソッドも多いでしょう。
RegistrationBuilderはRegisterの方法によって派生クラスがいくつかあります。
- 最初からインスタンスを保持する
InstanceRegistrationBuilder
- 最初からComponentを保持する
ComponentRegistrationBuilder
- コールバックを受け取る
FuncRegistrationBuilder
RegisterInstance(instance)
と言った既に存在するインスタンスをRegisterするときはInstanceRegistrationBuilder
が生成されます。存在するインスタンスがUnityのComponent継承(いわゆるMonobehaviourなど)であれば ComponentRegistrationBuilder
が生成されます。
FuncRegistrationBuilder
はRegisterFactory
など生成をコールバックで渡すときに使われています。
Registry
生成されたRegistrationリストを保持するのがRegistryクラスです。
保持しているRegistationを型情報から取得できるようにしています。
この3つのステップを経て、Containerが作成されています。
このフローの目的はあくまでContainerの生成であり、中に登録されているオブジェクトが実際にインスタンスとして生成されるかどうかというのはあまり重要ではありません。Containerのインスタンスはすべて必要なときに生成されるよう遅延評価されてるからです。
とはいえ、実際にはこのタイミングで概ね生成されていますが、それはEntryPointsBuilder
を起動させているからです。これに関しては後述。
解決って具体的に何が起きているんですか
VContainerでの解決というのは、ざっくりと以下のことを行っています。
- 任意の型を渡すと、そのインスタンスが取得できる
- インスタンスは必要に応じて生成もしくはキャッシュされる
コードで書くと以下のとおりです。
objectResolver.Resolve<Foo>();
Resolveメソッドを呼ばれた段階で、Foo
インスタンスが生成orキャッシュを取得できます。
また、Foo
が依存しているオブジェクトがある場合は再帰的にそちらもResolveされます。
このメソッドが呼ばれたタイミングで、RegistrationのSpawnInstance
が呼び出されインスタンスが生成されます。
Registrationクラス自体は詳細を知らず、以下のインタフェースのインスタンスに移譲しています。
- インスタンスの提供方法を知ってる
IInstanceProvider
- Injectionの方法を知ってる
IInjector
ざっくりとシーケンスは次のとおりです。
IInstanceProvider
これは、インスタンスをどのように提供するか知っているインタフェースです。どのようにとは、例えば型Tを提供する場合に以下のようなことです。
- 新規にTのインスタンスを作る(
Register<T>
) - 生成済みのTインスタンスを渡す(
RegisterInstance(instance)
) - IReadonlyList<T>型として返す(Register Collection)
- ComponentなのでシーンからFindObjectOfType<T>で探して渡す(
RegisterComponentInHierarchy<T>()
) - etc...
これらの状況によっては内部実装が大きく異なるので、IInstanceProvider
として抽象化されています。
特定のRegisterだけ上手くいかない!という場合は個別のProviderクラスの実装を追うのがよいでしょう。
IInjector
IInjector
はインスタンスに対して実際にインジェクションを行う役割を持ったインタフェースです。
現状のインジェクション方法は、リフレクションを用いた方法とPre IL Code Generationで生成したコードによるインジェクションの2種類です。
今回はコード生成を行っていないのでReflectionInjector
が使われます。かなり根本のコードなので、あまり触ることはないかもしれません。
ちなみに、どちらの方法を使うのかはInjectorCache
に問い合わせて判断しています。
また、WithParameterなどで外からパラメーターが渡された場合、IInjectParameter
のインスタンスに保持され、インジェクションの際に参照されます。
誰が最初にResolveされるのか
インスタンスは遅延評価なので、Resolveされない限りはインスタンスはつくられません。なので誰もResolveしないなら一切先に進まないはずです。しかしVContaienrはユーザーがResolveしなくてもなんだか動くようになっています。何故なら、前述したEntryPointBuilder
が発火しているからです。
EntryPointsBuilder
LifetimeScopeのオブジェクト登録の最後で、EntryPointsBuilder.EnsureDispatcherRegistered
が呼ばれています。
RegisterBuildCallback
はビルド完了時のコールバックを登録するメソッドです。
これは、EntryPointDispatcher
をRegisterして、且つビルド完了時EntryPointDispatcher
をResolveしています。
つまり、通常LifetimeScopeではEntryPointDispatcher
のインスタンスが必ず存在することになります。
次にDispatch()
メソッドを見ていきましょう。
ここでやっているのは、IInitializable
ITickable
といったVContainerの各種エントリポイントに紐づいたオブジェクトの解決と実行です。
例えばサンプルのBar
はIInitializable
を実装しているので、Dispatch()
内部での以下の部分でインスタンスが生成されます。
そして、Bar
はFoo
に依存しているのでFoo
のインスタンスも生成されます。
このように、エントリポイントの各種インタフェースを実装したクラスから芋づる式にインスタンスが生成されています。
逆に言えばどれだけオブジェクトを登録してもエントリポイントのインタフェースをRegisterしていない場合はインスタンスは生成されません。
まとめ
DIはなんでも自動でやってくれるので黒魔術と思われがちなところはありますが、リフレクションだったりといわゆるメタプログラミングになりそうな部分は全体の一部であり、それ以外は普通のよく構造化された設計だと思うのでみんな気軽に触ってみるのがいいと思います。
テストもいっぱいあるのでそこから読み解いていくのもオススメです。
Discussion