Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 17 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save yfakariya/11e40a5ebf537ee545cfdd3b6e8c5fbe to your computer and use it in GitHub Desktop.
Save yfakariya/11e40a5ebf537ee545cfdd3b6e8c5fbe to your computer and use it in GitHub Desktop.
Deep dive of Microsoft.Extensions.DependencyInjection in Japanese

Microsoft.Extensions.DependencyInjection Deep Dive

そんなに深くない気がしますが。Microsoft.Extensions.DependencyInjection の DI についてざっくりまとめた記事です。なお、長いので、Microsoft.Extensions.DependencyInjectionM.E.DI と略します。

例によって、公式ドキュメント にすべて書いてある、はずですが、ここでは少し別の観点でまとめてみます。また、ドキュメントに書いてない(と思う)内部実装についてもいくつか書いてあります。

使い方

  • IServiceCollection の拡張メソッドを使用して、フレームワークやランタイムが提供する IServiceCollection の実装に対して登録していきます。
  • IServiceCollectionServiceDescriptor というサービスについてのスペックを表すオブジェクトのコレクションである。ServiceDescriptor はサービス型をキーにして、ライフタイムと実装を持ちます。
  • 実装は、実装の型か、カスタムファクトリとなるデリゲートか、実装のインスタンスの形で与えられる。実装の型を与えられた場合、既定のファクトリによってインスタンス化されます。
    • カスタムファクトリは、IServiceProvider を引数として受け取るので、必要な依存先サービスを取得できます。なお、この IServiceProvider は後述のスコープの概念を実装しているので、現在のスコープのインスタンスが取得できます。そのため、カスタムファクトリでは必ずこの IServiceProvider 経由でインスタンスを取得しなければなりません。
  • IServiceCollection は、BuildServiceProvider() 拡張メソッドで IServiceProvider を構築します。この IServiceProvider を使用して、サービス型のインスタンスを取得します。

パッケージ構造

Microsoft.Extensions.DependencyInjection は以下のような構造を持っています。

  • Microsoft.Extensions.DependencyInjection.Abstraction。DI を設定するコード(いわゆる「貧者の DI」とか「合成ルート(composition root)」とか言われるコード)で使うためのインターフェイスと拡張メソッドが定義されています。M.E.DI 対応のクラスライブラリを作りたい(ロガーとか)場合や、がっつりカスタムの DI コンテナーを作りたい場合にはこれを参照すれば OK。このパッケージには以下のような型が含まれています。
    • メインとなる API 群。
      • ServiceDescriptor。DI で注入するサービスとその仕様を表します(具体的には、サービス型、ライフタイム、そして、実装型、実装オブジェクトのインスタンス、または実装オブジェクトのファクトリデリゲートのいずれかの組み合わせ)。
      • IServiceCollection。DI で注入するサービスのリスト。IList<ServiceDescritor> 型にすぎないのですが、この IServiceCollection に対して、様々な拡張メソッドが定義されます。
    • DI コンテナーの実装に必要な SPI(Service Provider Interface)群。
      • IServiceProviderFactoryIServiceCollection から IServiceProvider を生成します。
      • IServiceScope。スコープを表します。現在のスコープに対応する IServiceProvider へのアクセス手段を提供します。
      • IServiceScopeFactory。新しい IServiceScope を生成します。
      • ISupportRequiredService。必須のサービスを要求されたときに通常とは別の振る舞いをする IServiceProvider を表現します。
        • 実装しない場合、GetRequiredService 拡張メソッドは、必須のサービスが見つからない場合(IServiceProvider.GetService(Type)null を返した場合)、InvalidOperationException をスローします。
    • IServiceProvovider の拡張メソッド。IServiceProvider をサービスロケーターとして使用するうえで便利なメソッド群を追加しています。
    • ActivatorUtilities。サービスロケーターとしての IServiceProvider を使用して、コンストラクターインジェクションを使用したインスタンス化を実装します。
    • ActivatorUtilitiesConstructorAttribute。コンストラクターのオーバーロードが複数ある場合に、ActivatorUtilities が使用するコンストラクターであることを宣言します。
  • Microsoft.Extensions.DependencyInjectionM.E.DI の既定の実装で、以下のような API が用意されています。
    • ServiceCollectionIServiceCollection の実体です。単なるリストです。
    • ServiceProviderIServiceProvider の実装です。
    • DefaultServiceProviderFactory。`

登録

DI コンテナーへの登録は、コンポジションルート(ASP.NET Core や GenericHost を使用しているコードであれば StartUp、そうでなければ Main から呼び出す適当な初期化処理)で、IServiceCollectionStartUp がないのであれば、誰も IServiceCollection をインスタンス化してくれないので、ServiceCollectionnew して作成します)に対して追加していきます。

追加時には、Add{ライフタイム} メソッドで追加します。ライフタイムについては後述しますが、わからなければこれを使え、というものはありません。シングルトン、スコープ付き、一時的の 3 種類を適切に使い分ける必要があります。いずれにせよ、サービス型(DI される側で受け取る型)と、それに対応する実装(サービス型と互換性のある、サービスを実装するオブジェクト)のペアを登録します。なお、実装は、型(ジェネリック引数で指定)、ファクトリ(Func<IServiceProvider, T> 型のデリゲートを、ラムダ式やメソッド参照で指定)、オブジェクト(初期化済みのシングルトンオブジェクトを定数的に渡す)のいずれかで指定できます。なお、Add{ライフタイム} は、内部的には ServiceDescriptor を作成して、IServiceCollection.Add() を呼び出しているだけです。

ライブラリやフレームワークを使用する場合、ライブラリやフレームワークの作成者が DI 登録用の IServiceCollection の拡張メソッド(だいたい Configure{機能名}() とか Add{機能名}() になっています)を呼び出せばよいでしょう。アプリケーションのドメインオブジェクト(ドメインモデルとか、トランザクションスクリプトとかテーブルモジュールとかいうあれ)やら、その先で呼び出すやつ(リポジトリとかデータアクセスオブジェクトとかいうあれ)は、Add{ライフタイム}() で追加するか、似たような共通の仕組みを用意するといいでしょう。

なお、実装の追加方法として、Add{ライフタイム}TryAdd{ライフタイム} があります。これは、指定したサービス型が、追加先の IServiceCollection に既に登録済みである場合に、気にせずに追加するのか(Add{ライフタイム})、追加しないのか(TryAdd{ライフタイム})の違いがあります。複数の実装があっても構わない場合は Add{ライフタイム}、二重登録してはまずいものは TryAdd{ライフタイム} を使用します。通常、二重登録はコンポジションルートのバグだと思いますが、クラスライブラリなどをの作成するときに、DI の登録処理(つまりは IServiceCollection の拡張メソッド)がどのように呼び出されるかわからないので、TryAdd{ライフタイム} を呼ぶといいでしょう。迷ったら TryAdd{ライフタイム} でいいとは思います。また、複数登録したいが、二重登録はしたくないというケースもあるでしょう。言い換えると、あるサービス型に対し、複数の実装を登録したいが、サービス型と実装のペアは二重登録したくない場合です。この場合、TryAddEnumerable() 拡張メソッドを使うことができます。残念なことに、TryAddEnumerable()ServiceDescriptor を受け取るオーバーロードしか用意されていませんが、ServiceDescriptor には、ライフタイムの名前が付いた静的なファクトリメソッドが用意されているので、それを活用できます。

さらに、既存の実装を差し替えたい場合もあるでしょう。たとえば、デバッグモードのときだけ実装を差し替えるなどです。この場合、IServiceCollection.Replace() 拡張メソッドを使用できます。これは、指定した ServiceDescriptor のサービス型と一致するサービス型を置き換える処理……に見えますが、実際には先頭のものを削除して、末尾に追加するという挙動になるので注意してください。なお、Remove()RemoveAll() といった拡張メソッドもあります。

TryAdd{ライフタイム}()TryAddEnumerable()Replace()Remove()RemoveAll() を使うには、Microsoft.Extensions.DependencyInjection.Extensions 名前空間を using ディレクティブで宣言する必要があります。なお、パッケージは Microsoft.Extensions.DependencyInjection.Abstraction になります。

余談ですが、サービス型と実装の型は同一でもまったく問題ありません。実装を DI で入れ替えたい型というのは、多くの場合外部に依存した型(ファイルアクセスとか DB アクセスとか)のみで、それを使うだけの型は実装が 1 つだけというのはよくある話です。レイヤー構造であれば一番下のレイヤー、ヘキサゴナルやオニオン型なら一番外側のみが切り替えたい対象でしょう。逆に言えば、レイヤー構造の一番下以外や、ヘキサゴナルやオニオンの内側は、依存先が(連鎖的に)注入されてほしいだけで、サービス型と実装を区別する必要はないはずです。メソッドの追加や変更のたびに、複数の箇所に分散したシグネチャを直さないで済むならその方が良いですよね。

また、サービス型と実装の型は同一でも良いということは、サービス型がインターフェイスである必要はないということです。抽象クラスである必要すらありません。たとえば、外部アクセスの処理で共通の部分があり、サービス型をインターフェイスではなく基底クラスにしたい場合はそれでもかまいません。

生成と破棄

  • ライフタイムに応じて、既定またはカスタムファクトリが呼び出されます。
    • ライフタイムは以下の 3 種類です。
      • シングルトンは、最初に呼び出されたときに生成され、以降はキャッシュされたシングルトンオブジェクトが返ります。
      • スコープ付き(scoped)は、スコープごとに作成されます。スコープはフレームワークやランタイムが IServicProvider.CreateScope() 拡張メソッド(またはその実装である IServiceScopeFactory.CreateScope() メソッド)を呼び出すことで作成され、スコープにキャッシュされます。
      • 一時的(transient)の場合は、IServiceProvider から要求されるたびにインスタンス化されます。ただし、破棄はスコープと一緒に行われます。
    • このとき、スコープ付きまたは一時的の場合はスコープ(スコープが開始されていない場合はルートスコープ)に、インスタンスが登録される。このインスタンスは、スコープが破棄されるときに一緒に破棄されます。
      • IAsyncDisposable を実装されている場合は、DisposeAsync が呼び出される。IDisposableIAsyncDisposable が両方実装されている場合は、IAsyncDisposable が優先されます。

シングルトンライフタイムのオブジェクトと、ルートスコープに生成されたオブジェクトは、ServiceProviderDispose されたタイミングで破棄されます。それ以外のオブジェクトは、スコープが Dispose されたタイミングで破棄されます。言い換えると、ルートスコープの中で生成されたスコープ付きオブジェクトはシングルトンとして動作します。また、ルートスコープの中で生成された一時的オブジェクトは、ルートスコープが破棄されるまで破棄されません。さらに、ルートスコープがそれらの一時的オブジェクトへの参照を保持するので、スコープを実装していない実行環境(たとえば単純なコンソールアプリケーションや WebJob)で一時的なオブジェクトを取得すると、メモリリークを引き起こします。

コンストラクターインジェクションの挙動

M.E.DI はコンストラクターインジェクションのみ実装しています。つまり、依存先のサービスはコンストラクターのパラメーターとして受け取ります。コンストラクターパラメーターの型と一致するサービス型の実装が注入されます。

コンストラクターの選択

ver 3.0 時点では、以下のようになっています。

  • public なコンストラクターが対象となります。
  • 候補が 1 つの場合、そのコンストラクターが得らればれます。
  • 候補が複数ある場合、DI なのか(IServiceProvider から取得するのか)、ActivatorUtilities を使用するのかで異なります。
  • IServiceProvider から取得する場合:
    • 最も多くのパラメーターのある、正確に言えば最上位のスーパーセットを持つコンストラクターが選ばれます。
    • コンストラクターが選べない場合はエラーです。
  • ActivatorUtilities の場合:
    • [ActivatorUtilitiesConstructor] が付与されたコンストラクターがある場合、そのコンストラクターが対象となります。
    • そうではない場合、ActivatorUtilities に渡したカスタム引数の一致度が最も高いものが対象となります。
    • そうではない場合、メタデータ上、最も後に宣言されたものが使用されます。これは明らかに実装詳細なので、この動作には依存しない方が良いでしょう。

つまり、IServiceProviderActivatorUtilities の両方でうまくいくコンストラクターの解決方法がありません。そのため、ベストプラクティスをまとめると、以下のようになります。

  • public なコンストラクターを 1 つ用意する。
  • テストを楽にするなどの目的でコンストラクターのオーバーロードを加える場合、[InternalsVisibleTo] などを用意して非 public なコンストラクターにする。

実装の選択

前述のように、IServiceCollectionAdd{ライフタイム} を呼び出した場合、1 つのサービス型に複数の実装が登録されている場合があります。その場合の挙動は以下のようになります。

  • コンストラクターパラメーターの型がサービス型の場合、最後に IServiceCollection に登録した実装が渡されます。
  • コンストラクターパラメーターの型が IEnumerable<サービス型> の場合、登録した順に実装のリストが渡されます。

DI の実装

ServiceProvider の実装

ServiceProvider は、その実装としていくつかの「エンジン」を持ちます。

  • ServiceProviderEngine。基底クラス。
    • RuntimeServiceProviderEngineRuntimeResolver による実装型の生成を行います。
      • これは、Array.CreateInstanceConstructorInfo.Invoke によるサービス生成になります。
    • CompiledServiceProviderEngineM.E.DI アセンブリのビルドオプションに応じて、Emit API または式木による実行時 IL 生成によって実装型の生成を行います。
      • DynamicServiceProviderEngine。1 回目は RuntimeResolver による実装型の生成を行うが、2 回目は CompiledServiceProviderEngine による生成を行います。
    • ExpressionsServiceProviderEngine。ベンチマーク用。式木による実行時 IL 生成によって実装型の生成を行います。
    • ILEmitServiceProviderEngine。ベンチマーク用。Emit API による実行時 IL 生成によって実装型の生成を行います。

どのエンジンが使われるのかは以下のようになります。

  • .NET Standard または .NET Framework 向けの場合、DynamicServiceProviderEngine
  • .NET Core 向けの場合、IsDynamicCodeCompiledRuntimeFeature としてサポートされていれば、DynamicServiceProviderEngine。そうでなければ RuntimeServiceProviderEngine

エンジンの動作

実装型の生成は、ファクトリによって実行されます。

  1. サービススコープが渡されていなければ、ルートスコープを使用します。
  2. ファクトリがない場合、実体化(realize)を行います。
    1. サービス型から、コールサイト(callsite)を作成します。これは、CallSiteFactory で実装されます。
    2. コールサイトが作成できたなら、それを使用してサービスの実体化を行います。これは RealizeService 仮想メソッドで行い、ここがエンジンごとの実装差異になります。
  3. スコープを引数にして、ファクトリを呼び出します。

コールサイトは、サービスディスクリプターの種類ごとに異なります。なお、IServiceProviderIServiceScopeFactory については、固定の実装になります。

  • IServiceProvider の場合は ServiceProvider を返す ServiceProviderCallSite
  • IServiceScopeFactory の場合はエンジンそのものを返す ServiceScopeFactoryCallSite
  • インスタンスを持つ場合は ConstantCallSite
  • カスタムファクトリを持つ場合は FactoryCallSite
  • そうではなく、サービス型が登録されている場合は ConstructorCallSite
    • 引数については ServiceCallSite
      • 引数がサービスとして解決できれば、そのコールサイト。
      • そうではないが、既定値が指定されているか、または値型であれば、その既定値を使用した ConstantCallSite
  • そうではなく、サービス型のジェネリック型定義が登録されている場合は、サービス型のジェネリック型引数を使用して実装型のクローズドジェネリック型を構築し、その構築したクローズドジェネリック型のインスタンスを生成する ConstructorCallSite
  • そうではなく、IEnumerable<T> が要求されている場合は、IEnumerableCallSite

実体化処理

なお、実体化される処理は以下の疑似コードで表現できます。

基本
// resolve<T> がコールサイトごとの処理。
scope =>
{
    IDictionary<ServiceCacheKey, object> resolvedServices = scope.ResolvedServices;
    ServiceCacheKey key = new ServiceCacheKey(typeof(T), {slot#});
    bool lockWasTaken;
    try
    {
        Monitor.Enter(resolvedServices, ref lockWasTaken);
        object resolved;
        if (!resolvedServices.TryGetValue(key, out resolved))
        {
            resolved = CaptureDisposable!!

            resolved =
                typeof(IDisposable).IsAssignableFrom(typeof(T))
                || typeof(IAsyncDisposable).IsAssignableFrom(typeof(T))
                ? scope.CaptureDisposable(
                    resolve<T>(scope);
                )
                : resolve<T>(scope)
            reslovedServices.Add(key, resolved);
        }

        return resolved;
    }
    finally
    {
        if (lockWasTaken)
        {
            Monitor.Exit();
        }
    }
}
ConstantCallSite の resolve
// サービスの場合
_ => serviceDescriptor.Instance;
// パラメーターの既定値の場合
_ => defaultValue;
FactoryCallSite の resolve
scope =>
    {
        Func<IServiceProvider, object> factory = serviceDescriptor.Factory;
        return factory((IServiceProvider)scope);
    }

ConstructorCallSite の resolve

// resolve<T>() は基本呼び出し(コールサイトごとに異なる)
scope =>
    new T((T)resolve<T1>(scope), (T)resolve<T2>(scope))
IEnumerableCallSite の resolve
// resolve<T>() は基本呼び出し(コールサイトごとに異なる)
scope => new T[] {(T)resolve<T1>(scope), (T)resolve<T2>(scope), ...}
ServiceProviderCallSite の resolve
scope => scope;
ServiceScopeFactoryCallSite の resolve
_ => _engine; // 最終的には new ServiceProviderEngineScope(_engine)

補足

ちなみに、コールサイトの構築時には、以下のようなこともやってます。

  • 型の循環参照による無限再帰の検出(循環参照があれば InvalidOperationException
  • スレッドのスタックが不足しているかチェックし、不足している場合には別スレッドで処理を実行。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment