Generic Host の DI たちが依存性を救うようです

今回も Generic Host 関連のエントリです。そして Generic Host の中でも パッケージに標準で含まれている Microsoft.Extensions.DependencyInjection(以下、MS.E.DI)の使い方についてまとめました。

尚、このエントリは Visual Studio 2022 Community Edition で .NET 6 以降 と C# を使用するエントリなので、C# での基本的なコーディング知識を持っている人が対象です。

Generic Host に標準で含まれている DI コンテナの MS.E.DI は【.NET6 で Generic Host を使った常駐アプリ】でも紹介しましたが、使っていく内に、前のエントリに書いた内容では足りないと感じるようになりました。

上のエントリを書いた以降で、ネタもそれなりに貯まったので、前回のおさらいも含めた新規エントリとしてまとめました!

又、DI の基本的な考え方については【Prism の DI コンテナらは Ioc 上に歌う【step: 4 .NET Core WPF Prism MVVM 入門 2020】】で詳しく紹介しているのでそちらを読んでください。

Prism の DI コンテナらは Ioc 上に歌う】は WPF の Prism の DI 機能について紹介しているエントリですが、DI の基本的な考え方も含めた内容にしているので『DI ってよく分かっていない…』と言う人にはぜひ読んで欲しいです!

Generic Host の DI についてのおさらい

Generic Host の DI コンテナは別の DI コンテナに差し替えることもできるようですが、デフォルトで MS.E.DI を使用する形になっています。MS.E.DI には多くの拡張メソッドが定義されていて、自分で追加する事も出来ますが、最終的に以下の 3 種類に集約されます。

メソッド内容
AddSingletonシングルトンの名前通り、どこに DI しても最初に作成したインスタンスが DI される
生存期間:インスタンスを最初に生成した時 ~ アプリの終了
AddTransientコンストラクタに DI される度に新たなインスタンスが生成される
生存期間:コンストラクタに DI された時 ~ アプリの終了
AddScoped上の 2 つとは異なり、コンストラクタに DI できない
メソッド内で生成され、そのメソッド内で破棄されるクラスを登録する。
生存期間:呼び出されたメソッド内で破棄される

上の表の通り、生存期間とインスタンスの生成タイミングで集約できます。

DI されたオブジェクトの生存期間

上で紹介した 3 種類の生存期間をサンプルアプリで確認します。サンプルアプリは【ワーカーサービステンプレート】から作成して、作成直後は fig. 1 のようなファイルが生成されます。

fig.1 ワーカーサービステンプレートから作成したプロジェクトのソリューションエクスプローラー

最近のエントリで【ワーカーサービステンプレート】から作成したサンプルを紹介することが多いのは楽だからです。通常のコンソールアプリテンプレートから作成すると、Microsoft.Extensions.Hosting パッケージをインストールしたりエントリポイントを 0 から書く必要がありますが、ワーカーサービステンプレートなら最低限のスケルトンが自動で作成されます。

src. 1 はワーカーサービステンプレートから自動生成されたエントリポイントです。

.NET6 で Generic Host を使った常駐アプリ】でも紹介していますが、MS.E.DI への登録は 4 ~ 7 行目のラムダ式内に Add ~ を追加する事で登録されます。

実際の登録方法は後述します。

シングルトンなクラスの生存期間

6 行目の『AddHostedService』も拡張メソッドで、内部的に AddSingleton を呼び出しています。シングルトンなので、上に書いた一覧の通り、生存期間はアプリ実行直後 ~ 終了になります。src. 2 のようにコンストラクタ、アプリの開始・終了時に Console.WriteLine を置いて生成と破棄のタイミングを確認します。

実行結果が fig. 2 です。

fig.2 アプリの開始・終了ログの実行結果

上にも書いた通り、AddHostedService で登録されるWorker クラスはシングルトンなので、ApplicationStarted で出力しているログの前にインスタンスが生成されている事が確認できます。ちなみに Worker クラスの Dispose は継承元の BackgroundService で処理されているため、ログは仕込めませんでした。(2023/1/17 juner さんの指摘により削除)

↑すみません。juner さんの指摘で確認したら、完全に管理人の勘違いでした。Worker クラスの Dispose メソッドをオーバライドすればログを仕込むことができます。GitHub リポジトリ側のソースは Dispose にもログを入れています。

ちなみに、アプリの開始・終了時のログを出力している IHostApplicationLifetime は src. 1 の CreateDefaultBuilder 内で MS.E.DI に登録済みなので、src. 2 のようにを DI できます。IHostApplicationLifetime は src. 2 のように開始・終了時に処理を差し込んだり、IHostApplicationLifetime.StopApplication() でアプリの終了を要求する事もできるので、覚えておいて損はないと思います。

AddTransient するクラスの生存期間

続いて、Worker クラスに DI するためのクラスが src. 3 です。

TransientSample クラスは、コンストラクタと Dispose でログを吐き出すだけのクラスです。これを src. 4 のように MS.E.DI に登録します。

MS.E.DI に登録した TransientSample を src. 5 のように Worker クラスに DI します。

実行すると、fig. 3 のようにログが出力されます。

fig.3 TransientSample のログ

ここで注目するのは下側の赤枠で囲んだ部分に出力されている『TransientSample Dispose...』です。src. 5 では TransientSampleDispose していませんが、TransientSample.Dispose に仕込んだログが出力されています。これは、原則として DI コンテナは登録したクラスの生成と破棄を受け持つように Generic Host で構成されているからで、TransientSample.Dispose () は MS.E.DI が呼び出しています。ここでは、DI コンテナに登録したクラスは、自分で Dispose しなくても DI コンテナ(ここでは MS.E.DI)が Dispose してくれると言う事が確認できます。

逆に言うと、DI コンテナに登録したクラスは DI コンテナ(ここでは MS.E.DI)が破棄されるまで Dispose されないので、ステートレスな Web アプリとは違い、ステートフルな PC 用のクライアントアプリ等では、より生存期間に注意した設計が必要だと言えます。

fig. 3 の通り、AddTransient で登録したクラスでも、シングルトンに DI した場合はシングルトンの生存期間とイコールになる事は覚えておいた方が良いと思います。

AddScoped するクラスの生存期間

.NET6 で Generic Host を使った常駐アプリ】でも紹介していますが、ここまで紹介してきたクラスとは違い、AddScoped で登録したクラスはコンストラクタに DI できません

AddScoped で登録したクラスをコンストラクタに DI すると実行時に即、例外が Throw されますAddScoped で登録したクラスのインスタンスを取得する方法は、コンストラクタインジェクションとは全く違うので、改めてこのエントリでも紹介しておきます。AddScoped 用に src. 6 のようなクラス(クラス自体に特殊な記述は不要です)を追加します。

このクラスも上で紹介した TransientSample と同様にコンストラクタと Dispose でログを出力するだけのクラスですが、publicFoo メソッドも定義しています。このクラスを src. 7 のように MS.E.DI に登録します。

AddScoped で登録したクラスのインスタンスは src. 8 のように取得します。

AddScoped で登録したクラスを MS.E.DI から取得する場合は、コンストラクタのパラメータに、登録したクラスではなく src. 8 のように常にIServiceScopeFactory】を DI する必要があります。そして ScopeSample のインスタンスは 35 ~ 39 行目のように Create(Async)Scope() で取得した IServiceScope.ServiceProvider.GetRequiredService<登録した型>() から取得します。

実行結果は fig. 4 のようにログが出力されるので、src. 8 の通り、3 秒ごとに ScopeSample のインスタンスが生成され、Foo() の呼び出し後に Dispose される事が確認できます。

fig.4 ScopeSample の生成と破棄

IServiceScope.ServiceProvider.GetRequiredService() を実行する度に ScopeSample のコンストラクタが呼ばれ、Dispose していないのに Dispose のログも出力されています。AddScoped で登録したクラスは IServiceScope.ServiceProvider が DI コンテナになり、IServiceScope の破棄(using を抜ける)と同時に IServiceScope(src. 8 では scope)から生成したクラスも破棄されます。

ここまでが【.NET6 で Generic Host を使った常駐アプリ】で紹介した内容のおさらいです。

前のエントリでは生存期間は検証していなかった(そこまで気にしていなかったw)ので、改めてこのエントリで紹介しました。

MS.E.DI にクラスを登録するパターン

前章で、MS.E.DI に登録したクラスの生存期間を紹介したので、本章では Add ~ メソッドを呼び出す際の実装クラスの指定方法を紹介します。本章は『おさらい』の中に置いていますが、実は【.NET6 で Generic Host を使った常駐アプリ】でもほとんど紹介していなかったので、ついでに紹介しますw

型パラメータを 2 つ指定する

凡例:Add[Singleton | Transient | Scoped]<IFoo, Foo>();

使用頻度の高いパターンだと思います。【IFoo】をキーとして登録し、DI 時に【IFoo】を指定すると【Foo のインスタンス】が DI されます。

型パラメータを 1 つ指定する

凡例: Add[Singleton | Transient | Scoped]<Foo>();

ここまでのサンプルでずっと書いていたパターンです。『DI だからインタフェースを定義しなければならない』訳ではありません【実装型】を登録して【実装型】を DI するのも DI としては当然アリです。但し、ユニットテスト等で実装を差し替える事は出来なくなるので注意は必要です。
小規模なアプリで、ポリモーフィズムが必要ない場合に選択するパターンです。
※ 当然ですが、このパターンで型パラメータにインタフェースや抽象クラスなどを指定すると、実行時に例外が Throw されます

DI するインスタンスを自分で New する

凡例: AddSingleton<Foo>(new Foo());又は、AddSingleton<Foo>(new Foo("bar"));

AddSingleton 限定のパターンです。インスタンスの生成を MS.E.DI に任せず、プログラマ自身が生成したインスタンスを渡すパターンです。但し、DI コンテナで生成していないクラスは Dispose されないので、このパターンを使用した場合は、自身で Dispose する必要があります。このパターンを使用したいと思った場合は、下の【ファクトリメソッドでインスタンスの生成をカスタマイズする】で紹介する方法で代替えできないか検討した方が良いと思います。

Singleton 以外の Add 系メソッドで登録したクラスは、DI される度に新しいインスタンスが生成されるので、他のパターンでは存在しないオーバーロードで、AddSingleton 限定です。

ファクトリメソッドでインスタンスの生成をカスタマイズする

凡例: Add[Singleton | Transient | Scoped]<IFoo>(_ => new Foo("bar"));

パッと見、上と同じパターンに見えるかもしれませんが、ラムダ式で生成したインスタンスを返すようになっている点が違いです。DI するクラスのコンストラクタにパラメータを指定したいような場合や、生成したクラスのプロパティ等に値を設定したい場合に選択するパターンです。このラムダ式はDI されるタイミングで呼び出されます登録時に呼び出される訳では無い事は意識しておく必要があります。

src. 9 のようにエントリポイントとファクトリメソッドにログを仕込んで、呼び出される順番を確認します。

ついでに RunAsync の後にもログを仕込んでみましたが、特に意味はありません。15 行目で DI するクラスに SQLiteConnection を指定している事も、特に意味はないので気にしないでください。

src. 9 で登録したクラスを src. 10 のように TransientSample に DI します(DI するだけ)。

DI した DbConnectionprivate な変数にセットする処理のみを追加しました。実行すると fig. 5 のようにコンソールログが出力されます。

fig.5 ファクトリメソッドのログ出力順を確認した結果

DbConnection Factory!】が『RunAsync 直前!』の後(TransientSample のコンストラクタの直前)に呼び出されている事が確認できます。

念のため補足しておきますが、管理人がこの項で言いたいのは、“コンストラクタにパラメータを設定したいクラスはファクトリメソッドで指定する”ではありません。コンストラクタのパラメータは MS.E.DI に全て丸投げして解決してもらうのが基本です。ファクトリメソッドはあくまでも、MS.E.DI では解決できない値(リテラルなスカラー値や、設定情報から読み込んだ情報等)を手動で設定する場合や生成したインスタンスにカスタマイズした処理を実行したい場合等に選択するパターンです。

『おさらい』はここまでで、以降から少し応用的な MS.E.DI の使用方法を紹介していきます。

同一のインタフェースから派生する複数の型を登録する

ここからは DB に接続するための IDbConnection を例に紹介します。『DB は自分には必要ねーけど!』と言う方も当然居ると思いますが、管理人がこの後で書く予定にしているエントリの都合もあって、IDbConnection を例にします。IDbConnection は、あくまでも例で、DB に寄せた内容にはしていないので、章タイトルの通り、同一のインタフェースから複数のクラスが派生したパターンを想像してください。

DB に詳しくない方のために補足すると、DB の接続系のクラスは fig. 6 のような継承関係で作成されている ADO.NET プロパイダがほとんどだと思います。

fig. 6 DB 接続(ADO.NET)の継承関係

fig. 5 の赤点線枠で囲んだクラスはこれまでと同様、src. 11 のように MS.E.DI に登録できます。

12 行目と 13 行目で違う書き方をしていますが、特に意味はありません。src. 11 のように、同一の型に複数の実装クラスを登録すると、何が DI されるのかを確認するためのサンプルです。

IDbConnection ではなく、DbConnection を指定しているのも特に意味は無く、IDbConnection には非同期メソッドが定義されていないから…と言うだけの理由です。

src. 11 で登録した型を src. 12 のように DI します。

前章の src. 10 からの変更した箇所は DI された型をログに出力する 13 行目のみです。

fig. 7 が実行結果です。

fig.7 複数の実装型を登録した型を DI

赤枠で囲んだ所のように、SqlConnection が DI されている事が確認できます。これは【依存関係の挿入 – .NET | Microsoft Learn – サービス登録メソッド】に書かれている通り、同一の型に複数の実装を登録した場合、1 番最後に登録した実装が DI されると言う MS.E.DI の仕様です。

そうなると、src. 11 の 12 行目で登録した SQLiteConnection は登録しただけで DI できない事になります。

複数の実装を DI で一気に取得する

上で紹介した【依存関係の挿入 – .NET | Microsoft Learn – サービス登録メソッド】にも書かれていますが、src. 11 のように、1 つの型に対して複数の実装を登録した場合、DI の型に IEnumerable<コンテナに登録した型> を指定すると登録した全インスタンスが取得できます

src. 13 のようにコンストラクタのパラメータを変更して確認します。

実行すると、fig. 8 の赤枠で囲んだ箇所のように DbConnection として登録した全ての実装型が取得できています。

fig.8 IEnumerable<登録した型> を DI

IEnumerable が DI されるので実際に使用したいクラスを取り出す必要はありますが、LINQ 等で Where(con => con is ~) とか Where(con => con.GetType().Name == ~) とすれば取り出せるので問題は無いと思います。

src. 13 ではコンストラクタに DI しています(AddTransient した型なので当然)が、AddScoped で登録した場合でも src. 8 で紹介したように IServiceScope.ServiceProvider.GetRequiredService<IEnumerable<登録した型>>(); で、同じように取得できます。

但し、IEnumerable<登録した型> で DI すると、登録した実装型が全て生成される事は覚えておく必要があります。つまり、MS.E.DI に登録した全ての実装型が必要な場合は問題ありませんが、登録した実装方の内(ここでは 2 つ)、1 つしか使用しない場合は残りのインスタンスは生成されただけで無駄になる(自動で破棄されるとは言え)と言う問題はあります。

抽象型と実装型の両方を同時に登録する

前章では、1 つの型に対して複数の実装を登録して、DI で全ての実装を取得できる事、DI されたタイミングで登録した全インスタンスが生成されてしまう事、無駄なインスタンスまで生成される可能性がある事を紹介しました。

このような状況を解消するにはベストではないかもしれませんが、src. 14 のように実装型をキーとして登録するしかありません。

ちなみに、src. 14 の通り、DbConnection と実装型に継承関係がある場合でも、並列で指定する事が出来ます。src. 14 のように登録しても、src. 15 のように受け取ることができます。

実行結果の画面は貼りませんが、private の変数に全て値が設定され、別々のインスタンスが設定されます。但し、前章にも書いた通り、実装型を DI すると、ユニットテスト等で実装を差し替える事が出来ないデメリットがあるので、パフォーマンスとのトレードオフになります。

ここで紹介した方法はベストプラクティスではなく、あくまでも『こんな事もできる』と言う方法を紹介しているので、メリット・デメリットを考慮して適用してください。

まとめ的な

このエントリでは【.NET6 で Generic Host を使った常駐アプリ】で紹介できなかった MS.E.DI の挙動を紹介しました。実は、MS.E.DI(Generic Host)では、ここに書いた内容以外に、構成情報と呼ばれる設定ファイルやコマンドライン引数をコンストラクタのパラメータに設定するようなこともできますが、別のエントリで紹介する予定です。

今回のサンプルも GitHub リポジトリ に上げています!

 

2022/11/16 追記
このエントリを公開した後しばらく経ってから気づきましたが、このエントリで 100 本目になるみたいです!まあ、アニメ系のエントリも含めての本数なので、若干の悔しさはありますが、何とか 100 本達成できたのは単純に嬉しいです!

以前ほどの頻度では更新できていませんが、とりあえず今は手持ちのネタ自体はあるので、月一ペースでは公開したいなと思っています!今後ともよろしくお願いいたします。

 

 

 

 

おすすめ

2件のフィードバック

  1. juner より:

    > ちなみに Worker クラスの Dispose は継承元の BackgroundService で処理されているため、ログは仕込めませんでした。
    とありますが、
    BackgroundService のソース見る限り Dispose() は 継承可能なのでログは仕込めるのでは……?
    https://source.dot.net/#Microsoft.Extensions.Hosting.Abstractions/BackgroundService.cs,89

    • 沖田玲朗 より:

      juner さん
      ご指摘ありがとうございました。お恥ずかしい…virtual なので当然 override できますね。エントリの本文と GitHub リポジトリ側のソースは修正しました。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください