完全に理解した記念にブログを書きます。
なんかちょっと前*1にも似たようなものを書いた気がしますけれども、まぁ、完全に理解するのは何度してもいいですからね。軽率に理解していきましょう。
tech.blog.aerie.jp
もう少しちゃんと違いを言うと、前回の記事は ASP.NET Core のミドルウェアの話が半分くらいありました。今回の記事はオプション一色です。
前回と重複する部分もありますが、前回書いていない機能も含みます。
なお、本記事は .NET 8 を前提としています。
- 簡単な使い方
- IOptions<TOptions> ファミリー
- IOptionsFactory<TOptions> インターフェイス
- IConfigureOptions<TOptions> インターフェイス
- IPostConfigureOptions<TOptions> インターフェイス
- IValidateOptions<TOptions> インターフェイス
- OptionsBuilder<TOptions> クラス
- 名前付きオプション
- Data Annotations によるオプションの検証
- スタートアップ時検証
- 検証ソース ジェネレーター
- 構成バインド ソース ジェネレーター
- 単体テストに便利なメソッド
- コンテキスト付きオプション
- おわりに
簡単な使い方
オプションというのは、.NET アプリケーションを構成する様々な要素の動作をカスタマイズするのに使うことができる、強く型付けされた .NET オブジェクトです。
8割くらい*2は、こんなふうに使われると思います。
なお services
は、DI コンテナの構成に使う IServiceCollection インターフェイス、configuration
は IConfiguration インターフェイスです。
使う側では IOptions<TOptions> インターフェイスでラップして受け取り、Value プロパティで中身を取り出します。
// 定義 public sealed class FooOptions { public int Count { get; set; } } // 設定 services.Configure<FooOptions>(configuration); // 使う側 public sealed class FooService( IOptions<FooOptions> options) { public void Bar() { // options.Value が FooOptions 型 var count = options.Value.Count; } }
こういった使われ方が多いことから、「IConfiguration
インターフェイスのタイプセーフなラッパーでしょ?」と認識されることも多いかもしれません。が、.NET オブジェクトなら何でもいいので、IConfiguration
で表現しきれない、単なるデータの塊ではないオブジェクトや、デリゲート(ラムダ式)等を持つこともできます。
そういう場合は以下のようにコードで設定します。
Configure<TOptions> メソッドに指定したデリゲートは、FooOptions
クラスのインスタンスが作られるときに呼ばれます。
services.Configure<FooOptions>(static options => { options.GetValue = () => 3; });
このあたりまでわかっていれば、大体の場合は困らないと思います。
なのでここからは完全解説編です。
IOptions<TOptions> ファミリー
IOptions<TOptions>
インターフェイスには兄弟のようなインターフェイスがいます。
- IOptions<TOptions> インターフェイス
- IOptionsSnapshot<TOptions> インターフェイス
- IOptionsMonitor<TOptions> インターフェイス
これらの違いは、値がいつ更新されるのかです。
まず、それぞれの実装クラスの DI ライフタイムを見ておきましょう。
DI ライフタイムとは、IServiceCollection
に実装を登録する際に使用するインスタンスの寿命のタイプであり、ServiceLifetime 列挙型で定義されています。
ライフタイム | 意味 |
---|---|
Singleton |
DI コンテナ(IServiceProvider)ごとに1つのインスタンスが作られ保持される。 |
Scoped |
スコープごとに1つのインスタンスが作られ保持される。 |
Transient |
IServiceProvider.GetService メソッドが呼ばれる都度、新しいインスタンスが作られる。 |
Singleton
は DI コンテナごとにインスタンスが作られると書きましたが、大抵のアプリケーションでは、プロセス全体でただ1つの IServiceProvider
インスタンスを使うでしょうから、実質的にプロセス全体で1つのインスタンスを使い回すと考えて構いません。
Scoped
はどういう風にスコープが作られるかによりますが、ASP.NET Core で使う場合、典型的には1回のリクエストに対して1つのスコープが作られます。そのため、同一リクエスト内では同じインスタンスが使われ、リクエストをまたいでインスタンスが共有されることはありません。
Transient
では、インスタンスの共有は一切行われません。
さて、というわけで本題です。
インターフェイス | 実装クラス | ライフタイム |
---|---|---|
IOptions<TOptions> |
UnnamedOptionsManager<TOptions> *3 |
Singleton |
IOptionsSnapshot<TOptions> |
OptionsManager<TOptions> | Scoped |
IOptionsMonitor<TOptions> |
OptionsMonitor<TOptions> | Singleton |
IOptions<TOptions>
インターフェイスでオプションを受け取る場合、最初に Value
プロパティにアクセスしたときにインスタンスが作られ、プロセスの寿命に渡ってそのインスタンスが使い回されます。つまり、オプションの値は、一度初期化された後は一切更新されません。
IOptionsSnapshot<TOptions>
インターフェイスで受け取ると、新しいスコープ(ASP.NET Core の場合は新しいリクエスト)で Value
プロパティにアクセスする都度、新しいインスタンスが作られます。同じスコープ内では同じインスタンスを共有します。
リクエストの度にオブジェクトが作り直されることになるので、ものすごいリクエスト数の Web アプリでは、パフォーマンスが問題になることがあるかもしれません。そういう意味では IOptionsMonitor<TOptions>
よりも使いどころがシビアかもしれないですね。
IOptionsMonitor<TOptions>
インターフェイスの場合は複雑です。実装である OptionsMonitor<TOptions>
クラス自身は Singleton
のためプロセス全体で同じインスタンスが使い回されますが、その内部で寿命管理が行われます。
OptionsMonitor<TOptions>
クラスのコンストラクタを見てみましょう。以下の 3 つのオブジェクトが注入されることが分かります。
- IOptionsFactory<TOptions> インターフェイス
- IOptionsChangeTokenSource<TOptions> インターフェイスのコレクション
- IOptionsMonitorCache<TOptions> インターフェイス
IOptionsFactory<TOptions>
オブジェクトは、UnnamedOptionsManager<TOptions>
クラスや OptionsManager<TOptions>
クラスにも注入され、TOptions
型の作成と初期化を行います。
IOptionsChangeTokenSource<TOptions>
インターフェイスは IChangeToken インターフェイスと連携して、データソース側から値を更新することを指示します。例えば、appsettings.json
ファイルが更新された時に値を更新するといったことが可能になります。
まぁ、Web アプリケーションの場合、設定ファイルを更新するときはデプロイし直すことが多いと思いますが。GUI アプリケーションだったら、設定が変わった際にアプリケーションを再起動せずとも設定が反映されると、ちょっと嬉しいですね。
IOptionsMonitorCache<TOptions>
インターフェイスは文字通り、値をキャッシュする役割です。こちら側で寿命管理をすることでも、インスタンスを再作成するタイミングを制御することができます。
IOptionsFactory<TOptions> インターフェイス
IOptions<TOptions>
ファミリーが主に「オプションの値を保持する」役割だったのに対し、こちらは「オプションの値を生成する」ことを責務とするものです。
IOptions<TOptions>
ファミリーが値の生成・更新を必要としたときに、IOptionsFactory<TOptions>.Create メソッドを呼んで値を用意します。
既定の実装である OptionsFactory<TOptions> クラスのコンストラクタの引数を見てみましょう。以下の型の引数を取ることがわかります。
- IConfigureOptions<TOptions> インターフェイスのコレクション
- IPostConfigureOptions<TOptions> インターフェイスのコレクション
- IValidateOptions<TOptions> インターフェイスのコレクション
オプションの新しいインスタンスが必要になった時、OptionsFactory<TOptions>.Create メソッドは、以下の順で処理を行います。
- OptionsFactory<TOptions>.CreateInstance メソッドで、新しい空の
TOptions
インスタンスを作成する。 - IConfigureOptions<TOptions>.Configure メソッドを順に作用させて、オプション値をセットする。
- IPostConfigureOptions<TOptions>.PostConfigure メソッドを順に作用させて、さらにオプション値をセットする。
- IValidateOptions<TOptions>.Validate メソッドを順に作用させて、オプション値を検証する。
日頃「どうして IOptions<TOptions>
なんていうインターフェイスを挟まなきゃいけないんだろう。直接 TOptions
を突っ込んでくれればいいじゃん」と思っていた方、いるんじゃないでしょうか。
そこには、他の IOptionsSnapshot<TOptions>
インターフェイス等との一貫性という理由もあるかもしれませんが、これらの処理をオンデマンドで呼び出したいからという理由もあるのだろうと思います。
また、普通の .NET の DI だと、利用側で要求するインターフェイスに対して、あらかじめ登録されているその実装を返すというのが基本です。
しかし、利用側で IOptionsSnapshot<TOptions>
を要求しているのに対して、登録側で登録しているのは IConfigureOptions<TOptions>
等なんだけどどうなってんの? というのも、この OptionsFactory<TOptions>
が間をつなぐことによって実現されているわけですね。
なお、このように、空のインスタンスの生成と段階的な内容のセットという処理を行う都合上、TOptions
は読み取り専用の record
型ではダメです。
さて、関連するインターフェイスを一つ一つ見ていきましょう。
IConfigureOptions<TOptions> インターフェイス
IConfigureOptions<TOptions>
インターフェイスは、TOptions
オブジェクトにオプション値をセットするものです。
簡単な使い方の節で見た、IConfiguration
オブジェクトから値を埋める Configure<TOptions>(IConfiguration)
メソッドや、デリゲートで値をセットする Configure<TOptions>(Action<TOptions>)
メソッドを呼んだ時には、それらによってデータを埋める IConfigureOptions<TOptions>
インターフェイスの実装クラスが DI コンテナに登録されています。
また、このインターフェイスを自分で実装することで、値の設定方法をカスタマイズすることもできます。
// 定義 public sealed class MyConfigureOptions : IConfigureOptions<FooOptions> { public void Configure(FooOptions options) { options.Count = 3; } } // 登録 // Singleton が適切かどうかは MyConfigureOptions の実装によります services.AddSingleton<IConfigureOptions<FooOptions>, MyConfigureOptions>();
IPostConfigureOptions<TOptions> インターフェイス
IConfigureOptions<TOptions>
インターフェイスと役割としては似通っています。
すべての IConfigureOptions<TOptions>.Configure
メソッドが呼ばれた後に呼ばれるため、たとえば、アプリケーションで値が構成されなかった項目について、ライブラリやフレームワークの側でデフォルト値を埋めるといった用途が考えられます。
// 定義 public sealed class MyPostConfigureOptions : IPostConfigureOptions<FooOptions> { public void PostConfigure(string? name, FooOptions options) { options.Message ??= "Hello, World."; } } // 登録 // Singleton が適切かどうかは MyPostConfigureOptions の実装によります services.AddSingleton<IPostConfigureOptions<FooOptions>, MyPostConfigureOptions>();
IValidateOptions<TOptions> インターフェイス
これまでにセットされた値が正しいか検証するのに使用します。
public sealed class MyValidateOptions : IValidateOptions<FooOptions> { public ValidateOptionsResult Validate(string? name, FooOptions options) { return (options.Count < 0) ? ValidateOptionsResult.Fail($"{nameof(FooOptions.Count)} は 0 以上") : ValidateOptionsResult.Success; } } // 登録 // Singleton が適切かどうかは MyValidateOptions の実装によります services.AddSingleton<IValidateOptions<FooOptions>, MyValidateOptions>();
OptionsBuilder<TOptions> クラス
OptionsBuilder<TOptions> クラスは、これまでに説明してきた IConfigureOptions<TOptions>
、IPostConfigureOptions<TOptions>
、IValidateOptions<TOptions>
を簡単に構成するためのクラスです。
以下のコード中で AddOptions<TOptions> メソッドが返すのが OptionsBuilder<TOptions>
オブジェクトで、そこからメソッド チェーンを使ってオプションを構成していきます。
Configure、PostConfigure、Validate といったメソッドを呼び出す度に、内部で上記の IConfigureOptions<TOptions>
、IPostConfigureOptions<TOptions>
、IValidateOptions<TOptions>
といったインターフェイスを実装する型が Services プロパティに登録されていきます。
AddOptions<TOptions>
メソッドを呼んだだけでは何も登録されませんので注意してください。
また、型引数を取らない AddOptions メソッドは全く違う用途なので、こちらにも注意してください。
Configure
メソッドの代わりに Bind メソッドを使うことで、IConfiguration
から値を注入することもできます。
既に DI コンテナに IConfiguration
が登録されていることが前提なら、BindConfiguration メソッドを使うのもお手軽でいいでしょう。
services .AddOptions<FooOptions>() .Bind(configuration) .PostConfigure<IOptions<OtherOptions>>( static (fooOptions, otherOptions) => { fooOptions.Message ??= otherOptions.Value.Message; }) .Validate( static fooOptions => { return (fooOptions.Count < 0) ? ValidateOptionsResult.Fail($"{nameof(FooOptions.Count)} は 0 以上") : ValidateOptionsResult.Success; });
Configure
、PostConfigure
、Validate
のいずれのメソッドでも、上記の PostConfigure
メソッドの例にあるように、既に登録済みの依存関係を注入して使うことができます。
たとえば、あるオプションから別のオプションに値を転記したり、複雑な検証ロジックを別クラスに独立させて DI コンテナに登録しておいて呼び出したりといったことができます。
services.Configure
メソッドによる構成は簡単ですが、こういった柔軟性はないので、こちらの方法も知っておいて損はありません。
もちろん、各種インターフェイスを自分で実装することでも DI による依存性注入は使えますが、こちらの方が簡便ですね。そのため、先の各インターフェイスの解説では、あまり実装例を載せていません。
余談ですが、ConfigureOptions<TOptions> メソッドでも、これら各種インターフェイスの実装を登録することができます。
ただ、これは内部でリフレクションを多用するため、AOT コンパイル等を気にする現代的には、あまり推奨されません。
OptionsBuilder<TOptions>
クラスの各種メソッドを使うか、IServiceCollection
に直接登録する方がいいでしょう。
名前付きオプション
.NET の DI では、基本的には型をキーとしてサービスを解決します。
TOptions
を構成するための IConfigureOptions<TOptions>
は複数登録でき、既に見たように、IEnumerable<IConfigureOptions<TOptions>>
という形で OptionsFactory<TOptions>
オブジェクトに注入されます。
ただ、例えばアプリケーション中で FooOptions
を2つの異なる用途で使いたい、それぞれで別の値を構成したいということがあるとすると、型でしか識別できないのは不便です。
そこで、それぞれの用途ごとにオプションに名前を付けて識別できるようになっています。
services.Configure<FooOptions>("foo1", configuration.GetSection("Foo1")); services.Configure<FooOptions>("foo2", configuration.GetSection("Foo2"));
このようにして登録したそれぞれのオプションは、IOptionsSnapshot<TOptions>.Get メソッドや IOptionsMonitor<TOptions>.Get メソッドに名前を指定して取得します。
public sealed class FooService1 { public FooService1( IOptionsSnapshot<FooOptions> optionsSnapshot) { var options = optionsSnapshot.Get("foo1"); } }
既に出てきたコード中にある string? name
という引数は、このオプション名です。
なので真面目に実装するなら、引数 name
を見て、「このオプションは自分が構成・検証すべきものか?」を判定するべきです。
OptionsFactory<TOptions>
には名前に関係なくすべての実装が注入されてしまうので、実装の側で判断するということです。
なお、IOptions<TOptions>
インターフェイスは名前を持つことができません*4。
名前を指定せずに登録されたオプションは Options.DefaultName という既定の名前で登録されますが、これは要するに string.Empty です。
IOptions<TOptions>.Value
、IOptionsSnapshot<TOptions>.Value
、IOptionsMonitor<TOptions>.CurrentValue
といったプロパティで取得されるのは、名前なしで(言い換えれば Options.DefaultName
を指定して)構成されたオプションのみです。
名前付きの構成をサポートするには、IConfigureNamedOptions<TOptions> インターフェイスを実装します。これは IConfigureOptions<TOptions>
を拡張したものです。
IConfigureNamedOptions<TOptions>
インターフェイスは IConfigureOptions<TOptions>
から派生しています。
そして、OptionsFactory<TOptions>
のところで見たように、IConfigureOptions<TOptions>
として注入されます。
ですから、何かしらの理由で IConfigureNamedOptions<TOptions>
インターフェイスを実装したクラスを自前で作る時は、IConfigureOptions<TOptions>
として(も)登録しなければなりません。
まぁ、OptionsBuilder<TOptions>
を使う方が楽だと思いますが。
// 定義 public sealed class ConfigureNamedFooOptions(string? name) : IConfigureNamedOptions<FooOptions> { public string? Name { get; set; } = name; public void Configure(XOptions options) { // 普通にやるとこれが呼び出されることはないのだけど // 意味的整合性を取るとしたらこうなる this.Configure(Options.DefaultName, options); } public void Configure(string? name, XOptions options) { // 実際にはここに null が来ることはなさそうなのだけど // null はワイルドカードを意味しているように思われる if (name == null || name == this.Name) { // オプション値を設定する } } } // 登録 var configureOptions = new ConfigureNamedFooOptions(name); services .AddSingleton<IConfigureOptions<FooOptions>>(configureOptions) .AddSingleton<IConfigureNamedOptions<FooOptions>>(configureOptions);
名前に関係なく、すべての TOptions
型に対して構成を行いたいときは、ConfigureAll<TOptions> メソッドや PostConfigureAll<TOptions> メソッドを使います。
ValidateAll
っていうメソッドはないですね。一応、こういう登録をすれば実現できそうではありますが。
services.AddSingleton<IValidateOptions<FooOptions>>( new ValidateOptions<FooOptions>( null, static options => { /* 何かしらの検証ロジック */ }, "検証失敗時のメッセージ"));
OptionsBuilder<TOptions>
を使って名前付きオプションを登録するには、AddOptions<TOptions>(string? name) メソッドを使います。
OptionsBuilder<TOptions>
では、名前に関係なく、すべての TOptions
型に対して行う構成というのは登録できなさそうです。AddOptions<FooOptions>(null)
と書いても、名前なし(Options.DefaultName
)で登録されてしまいます。
余談ですが、.NET 8 以降、DI の際に型だけでなくキーによってもサービスを識別する機能が追加されたので、オプションでもこれが使えると便利かなぁと思います。
// このコードは妄想です。 public sealed class FooService1( [FromKeyedService("foo1")] IOptions<FooOptions> options) { }
もっと言えばこんなんでもいいですね。
// このコードは妄想です。 public sealed class FooService1( [FromOptions] FooOptions options, [FromOptions("foo1")] FooOptions options) { }
Data Annotations によるオプションの検証
オプション値が正しいかを検証するには複数の方法があります。
既に解説したように、IValidateOptions<TOptions>
インターフェイスを実装して DI コンテナに登録してもいいですし、OptionsBuilder<TOptions>.Validate
メソッドを使ってデリゲートで検証してもいいです。
が、もっと簡単な方法もあります。それは Data Annotations を使うというものです。
たとえば、以下のように RangeAttribute をつけることで、FooOptions.Count
が1以上3以下であることを検証できます。
ValidateDataAnnotations メソッドの呼び出しを忘れずにしてください。
// 定義 public sealed class FooOptions { [Range(1, 3)] public int Count { get; set; } } // 登録 services .AddOptions<FooOptions>() .Bind(configuration) .ValidateDataAnnotations();
もちろん、ValidationAttribute クラスから派生したカスタム検証属性もサポートしています。
また、オプション型に IValidatableObject インターフェイスを実装することでも検証が行えます。
さらに、Data Annotations の一部ではありませんが、追加で使える属性が 2 つあります。ValidateEnumeratedItemsAttribute と ValidateObjectMembersAttribute です。
以下のように、コレクションの個々の要素をチェックしたり、ネストされたオブジェクトの検証を有効にしたりするのに使います。
これらを使うにも ValidateDataAnnotations
メソッドの呼び出しが必要です。
public sealed class FooOptions { [ValidateEnumeratedItems] [Range(1, 3)] public int[] Array { get; set; } = []; [ValidateObjectMembers] public NestedOptions { get; set; } = new(); } public sealed class NestedOptions { [StringLength(100)] public string Name { get; set; } = string.Empty; }
残念なことに、検証時に ValidationContext.GetService メソッドが使えません。
検証するために IServiceProvider
によって解決される他のサービスが必要なのであれば、OptionsBuilder<TOptions>.Validate
メソッドを使った方がいいかもしれませんね。
スタートアップ時検証
OptionsBuilder<TOptions>.Validate
メソッドによる検証も、ValidateDataAnnotations
メソッドによる検証も、結局はIValidateOptions<TOptions>
インターフェイスの実装を DI コンテナに登録しているだけです。
その注入先は OptionsFactory<TOptions>
クラスで、これはオプションの使用時に IOptions<TOptions>
なり何なりによって値が必要とされるタイミングまで呼ばれません。
つまり、検証のタイミングは、実際にオプション値が使われるタイミングということです。
ただ、appsettings.json
の記述の誤りなどには、できればもっと早く気づきたいですよね。
そのためにスタートアップ時検証という仕組みが用意されています。
以下のように、ValidateOnStart メソッドを呼ぶだけです。
services .AddOptions<FooOptions>() .Bind(configuration) .Validate(static options => { /* 何かしらの検証ロジック */ }) .ValidateOnStart();
ひとつ注意点があります。
これは、内部的に IStartupValidator インターフェイスの実装を DI コンテナに登録することで実現されていて、IStartupValidator.Validate メソッドが呼ばれたタイミングで検証が実行されます。
では、このメソッドは誰がどこで呼ぶのでしょうか。
これが呼ばれるのは .NET 汎用ホストの開始時です。
ですから、汎用ホスト上に構築されている ASP.NET Core アプリケーションなどでは、ValidateOnStart
を呼ぶだけで有効になります。
汎用ホストを利用せず、自力で ServiceCollection クラスから IServiceProvider
を組み立てているような場合には、以下の一行を仕込んでやる必要があります。
serviceProvider.GetRequiredService<IStartupValidator>().Validate();
検証ソース ジェネレーター
近年、AOT コンパイル等の需要を見込んで、実行時のリフレクションやコード生成を避け、事前にコードを生成しておくソース ジェネレーター の活用が広がっています。
Data Annotations による検証も、内部ではリフレクションを多用していますので、その代わりに、検証コードを事前に生成する機能が用意されています。
バリデーターの検証コードを事前生成するには、以下のような IValidateOptions<TOptions>
インターフェイスの実装クラスを用意します。
クラスを partial
指定し、OptionsValidatorAttribute を付けるだけです。
FooOptions
の方には従来通りの Data Annotations 属性をつけておきましょう。
なお、ソースの事前生成をする場合、ValidateDataAnnotations
メソッドの呼び出しは必要ありません。
// 定義 [OptionsValidator] public sealed partial class GeneratedOptionsValidator : IValidateOptions<FooOptions> { } // 登録 // ValidateDataAnnotations() は不要 services .AddOptions<FooOptions>() .Bind(configuration); services .AddSingleton<IValidateOptions<FooOptions>, GeneratedOptionsValidator>();
この場合、OptionsBuilder<TOptions>
による登録はできませんので、DI コンテナに直接登録する必要があります。
ただし、スタートアップ時検証を同時に行う場合には、IServiceColection
インターフェイスの拡張メソッドとして AddOptionsWithValidateOnStart<TOptions, TValidateOptions> という長ったらしい名前のメソッドが用意されていますので、これを通常の AddOptions<TOptions>
メソッドの代わりに使うことができます。
services .AddOptionsWithValidateOnStart<FooOptions, GeneratedOptionsValidator>() .Bind(configuration);
前述の [ValidateEnumeratedItems]
や [ValidateObjectMembers]
もサポートされています。
この場合、これらの属性にバリデーター クラスを指定する必要があります。まぁ、事前生成の有無にかかわらず、常につけておけばいいでしょう。
public sealed class FooOptions { [ValidateEnumeratedItems] [Range(1, 3)] public int[] Array { get; set; } = []; [ValidateObjectMembers(typeof(GeneratedNestedOptionsValidator))] public NestedOptions { get; set; } = new(); } public sealed class NestedOptions { [StringLength(100)] public string Name { get; set; } = string.Empty; } [OptionsValidator] public sealed partial class GeneratedNestedOptionsValidator : IValidateOptions<NestedOptions> { }
余談ですが、RangeAttribute
等の一部の属性は内部的にリフレクションを使用するため、生成されたコードでは、RangeAttribute
の実装をそのまま呼び出すのではなく、その代わりになる、リフレクションを使用しない実装クラスが生成されています。
ただし、あるソース ジェネレーターが生成したコードを元に別のソース ジェネレーターを動かすことができないという制約のためか、RegularExpressionAttribute が GeneratedRegexAttribute で生成されるようなソースジェネレーターによる実装に挿し替わることはありません。将来に期待ですね。
自前で ValidationAttribute
から派生したカスタム検証属性を使う場合でも、リフレクションを使用しないことが推奨されます。
構成バインド ソース ジェネレーター
オプション機能と関わりが深いものとして IConfiguration
インターフェイスがあります。
これはイメージ的には IDictionary<string, string> インターフェイスのようなもので、フラットな文字列 - 文字列の Key-Value Pair です。
これを型付きの .NET オブジェクトに変換するには、Bind メソッドや Get<T> メソッドが使えます。
FooOptions options; configuration.Bind(options); // あるいは var options = configuration.Get<FooOptions>();
TOptions
を IConfiguration
から構成する機能も、これらを使って実現されています。
しかし、これらの内部でも、リフレクションで FooOptions
のプロパティを再帰的に列挙して云々という処理が行われているわけで、AOT 時代にはそぐわないものになっています。
そこで .NET 8 から、これもソース ジェネレーターでできるようになっています。
これを有効にするには、プロジェクト ファイルに <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
を書きます。
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net8.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <!-- この行を追加 --> <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator> </PropertyGroup> </Project>
AOT コンパイル関連のオプションである PublishTrimmed を true
に設定すると、上記のオプションは自動的に有効になります。
これを有効にすると、IConfiguration
から値を取得する様々なところで、事前生成されたソースが使われるようになります。
これは、これまでに見てきた Bind
、Get<T>
、Configure<TOptions>
、BindConfiguration<TOptions>
といったメソッドの呼び出しをインターセプターで乗っ取ることで実現されています。
単体テストに便利なメソッド
単体テスト時に IOptions<TOptions>
オブジェクトを簡単に作るのに便利なのが、Options.Create<TOptions> メソッドです。
これは OptionsWrapper<TOptions> オブジェクトを返します。
あくまで既に存在する TOptions
オブジェクトから IOptions<TOptions>
オブジェクトを作るだけの便利メソッドですので、DI コンテナから値を注入したり、検証したりする機能は持ちません。
また、IOptionsSnapshot<TOptions>
や IOptionsMonitor<TOptions>
といった高度なインターフェイスに対応するものも用意されていません。
そのあたりは Moq や NSubstitute 等のモッキング ライブラリを活用するといいでしょう。
コンテキスト付きオプション
Microsoft.Extensions.Options.Contextual パッケージをインストールすることで、コンテキスト付きオプションという機能が使えるようになります。
通常のオプションは、アプリケーション全体に渡ってグローバルに構成された値を保持しますが、コンテキスト付きオプションでは、例えばメソッド引数の値に応じて異なるオプションを取得するといったことができそうです。
ただ、まだプレビュー段階で正式リリースされていませんので、詳細な解説は省きます。