最近は .NET Core の仕事をしています。
さて、ASP.NET Core を使っていると、とりあえず構成情報みたいなものは IOptions<TOptions> で受け取っておけみたいな雑な話を目にします。
一応 公式のドキュメント はあるのですが、正直読んでもよくわからない。柔軟なんだねー、なるほどねーみたいな感じになりました。なので、ここではできる限り(?)網羅的に解説してみようと思います。
IOptions<TOptions>って何?(TOptionsを直接注入すればいいじゃん)IOptionsSnapshot<TOptions>とかたくさんあってよくわかんない- オプションの動的更新に必要なものは?
IConfigurationとの関係は?
などを説明していきます。
なお、この文書では、DI の仕組みとして Microsoft.Extensions.DependencyInjection、言い換えると ServiceCollection を使用した DI(もちろん、それを使用する ASP.NET Core や Azure WebJob SDK や Azure Function)に関してのみ考えます。それ以外の DI の仕組みを使用する場合には動作が異なる場合があります。
最初に、IOptions<TOptions> とは何で、どういうときに使うのかを振り返ろうと思います。
これ自体は、プログラムの動作を決定する設定情報を、プログラムの外から指定できるようにするためのフレームワークです。
IOptions<TOptions> 系列を使うことで、DI(依存先注入)の仕組みに乗っかりつつ、外部設定をうまく扱えるようになります。
逆に言えば、(Microsoft.Extensions.DependencyInjection 互換の)DI に乗っかる必要がない、型の動作を変える情報を構成情報(IConfiguration)から受け取る必要がない場合は不要なものです。
まずは、IOptions<TOptions> を受け取る側、使う側のおさらいをしましょう。と言っても、このあたりの情報はサンプルコードも色々ありますし、docs.microsoft.com を読めばいいのではと思うので、簡単に。
コンストラクター引数として、IOptions<TOptions>、IOptionsSnapshot<TOptions>、IOptionsMonitor<TOptions> のいずれかを受け取ります(DI コンテナーから受け取ります)。
それぞれ、実際のオプション設定(TOptions 型のオブジェクトで表す設定情報のグループ)は、Value プロパティで受け取ります。
オプション設定は、その場で取得してフィールドやプロパティに保存しておくか、あるいは後で取得できるようにこれら IOptions[Snapshot|Monitor] をフィールドに保存しておき、必要な時にその Value または CurrentValue を呼ぶことで参照します。
TOptions は、ある一定のまとまりを持った構成のグループを表しており、IOtions[Snapshot|Monitor] からはこの単位で取得できます。なので、ある TOptions に定義された各プロパティには、ある時点で指定された値がまとめて(同時期のものが)来るはずです(ただし、それらの値が正しいか、整合性を持っているか、正しくない場合にどう振る舞うのかは、TOptions またはその呼び出し元の実装にゆだねられています)。
さて、これらの使い分けについて考えていきましょう。
特に要件がなければ IOptions<TOptions> で受け取る、でも構わないのですが、上位互換である IOptionsSnapshot<TOptions> で受け取りましょう。
リクエストスコープでの最新の構成情報の反映(できる場合もある、くらい。詳細は後述)と、名前付き構成機能が使えます。
どうしても変更を無視したい場合にのみ、名前付き構成を捨てて IOptions<TOptions> を使うのでもいいとは思います。
とはいえ、既存の IOptions<TOptions> を使ったコードを直すほどの価値はないかなと思います。
余談ですが、ServiceCollection に DI されるオブジェクトの実体はどちらも OptionsManager<TOptions> になります。ただし、インターフェイスを登録するときのライフサイクル指定が異なっていて、IOptions<TOptions> はシングルトン、IOptionsSnapshot<TOptions> はスコープ付き(ASP.NET Core ならリクエストごとにスコープが作成されます)で登録されます。
IOptionSnapshot<T> を使います。そうすると、DI のスコープごとに最新の値が来ますし、逆に言えばスコープ(ASP.NET Core ならリクエスト)内で来る値が変わることもありません。
ただし、繰り返しになりますが、そもそも動的な構成情報の変更のサポート自体が限定的であることは忘れないでください(そのうち対応するかもしれませんが)。
すべてが HTTP リクエストを受け取る AP サーバー上で動くと思うなよ。ということで、長時間実行されるプロセスで、かつ動的に構成情報が変更されうるという、やや特殊(?)なケースを考えてみましょう。
この場合、コンストラクターで IOptionsMonitor<TOptions> を受け取り、ここから最新の値をどうにか取得することになります。
具体的には、名前付きでない構成から取得する場合は CurrentValue、名前付き構成(ここでは踏み込みません)では Get(String name) メソッドで取得します。
基本的には整合性が取れている(はず)の単位でまとめて取得したいでしょうから、これらの呼び出しの結果をローカル変数に保存して、その値を使って処理をするはずです(都度 CurrentValue を呼び出すと、毎回違う値が返ってくるかもしれませんので避けましょう)。
なお、TOptions の値が変わった場合にまとめて動作を変更したい場合もあると思います。その場合は、IOtionsMonitor<TOptions>.OnChange(Action<TOptions, String>) メソッド経由でイベントハンドラーを登録しておき、そのイベントハンドラーで処理を行います。
くどいようですが、そもそも動的な構成情報の変更のサポート自体が限定的であることは忘れないでください(そのうち対応するかもしれませんが)。
次に、この IOptions<TOptions> のオブジェクトの作り方を説明します。
ASP.NET Core のドキュメントにもあるやつです。構成情報を何らかの方法で読み込み(ようは何とかして IConfiguration オブジェクトを取得し)、その内容を TOptions にマップするように DI の設定を行います。
基本的には、IServiceCollection.AddOptions<TOptions>([string name]) 拡張メソッドを呼び出せばいいです。
そうすると、TOptions で指定した型に対する IOptions<TOptions>、IOptionsSnapshot<TOptions>、IOptionsMonitor<TOptions> やそれに必要な各種ジェネリック型が DI に登録されます。そして、この戻り値である OptionsBuilder<TOptions> に対して、追加のオプションを設定します。
string name引数は、オプションに対して付ける名前です。指定しないオーバーロードでは、Options.DefaultNameの値(空文字列)が指定され、既定のオプションの設定になります。この名前は、IOptionsSnapshot<TOptions>やIOptionsMonitor<TOptions>のGet(string)で渡す名前になります。
まず、たいていの場合は構成情報からオプションを読み込みたいでしょうから、OptionsBuilder<TOptions>.Bind(IConfiguration config [, Action<BinderOptions> configureBinder]) を呼び出して、構成情報にバインドします。
IConfiguration configurationはバインド先の構成情報です。この後詳しく説明します。Action<BinderOptions> configureBinderは、BinderOptionsのカスタマイズを行うデリゲートを渡します。- 3.0 時点では、
BindNonPublicPropertiesをtrueにして、publicでない書き込み可能プロパティに対するバインドを行うように変更できます。
- 3.0 時点では、
さらに、多くの場合はオプションのバリデーションを行いたいでしょうから、OptionsBuilder<TOptions>.ValidateDataAnnotations() 拡張メソッドを呼び出して、データアノテーション属性ベースのバリデーションを有効にします。また、属性ベースでは足りない場合には、OptionsBuilder<TOptions>.Validate(Func<TOptions, bool> validation, string failureMessage)を呼び出して、カスタムのバリデーションを実装できます(たとえば、複数のプロパティの整合性を取りたい場合とか)。
オプションの初期化を詳細に制御したい場合には、OptionsBuilder<TOptions>.Configure や OptionsBuilder<TOptions>.PostConfigure を使用できます。これらのメソッドでは、依存先のサービスをラムダ式の引数として受け取るようにもできます。なお、実行順としては、(構成情報のバインドを含む)Configure処理、PostConfigure処理、Validate処理の順です。
なお、IServiceCollection.AddOptions<TOptions>() 拡張メソッドでは、実際には以下を行います。
IServiceCollection.AddOptions()を呼び出し、以下を登録します。いずれもオープンジェネリック型として登録します。IOptions<>のシングルトンな実装型としてのOptionsManager<>。IOptionsSnapshot<>のスコープ付きの実装型としてのOptionsManager<>。IOptionsMonitor<>のシングルトンな実装型としてのOptionsMonitor<>。IOptionsFactory<>の都度生成な実装型としてのOptionsFactory<>。IOptionsMonitorCache<>のシングルトンな実装型としてのOptionsCache<>。
OptionsBuilder<TOptions>.Bind() 拡張メソッドでは、実際には以下を行います。
IServiceCollection.Configure<TOptions>()を呼び出します。これは、実際には以下を行います。IServiceCollection.AddOptions()を呼び出します。しかし、これは通常AddOptions<TOptions>()ですでに行われているため、何も起こりません。IOptionsChangeTokenSource<TOptions>としてConfigurationChangeTokenSource<TOptions>をシングルトンで登録し、変更通知を行えるようにします。IConfigureOptions<TOptions>としてNamedConfigureFromConfigurationOptions<TOptions>をシングルトンで登録し、構成情報のバインドを行えるようにします。
ここで、IConfiguration について補足しておくと、IConfiguration は構成情報の塊を表すインターフェイスで、IConfigurationBuilder を使用して、様々なソース(インメモリコレクション、環境変数、コマンドライン、JSON、Azure Key Vault など)から取得、マージした構成情報(: 区切りのキーを持つ、文字列のディクショナリ)です。
この構成情報は、キーの階層構造を持っており(ファイルパスが / や \ でディレクトリ構造に区切られるのと同じ)、IConfiguration.GetSection(String) でサブセクションとして構成情報の一部を表す IConfiguration を取得できます。たとえば、log:level、log:output、connectionString:data の 3 つを持つ IConfiguration から、log: で始まるもののみを持つ構成情報(log:level と log:output のみを持つ)を IConfiguration として取得できます。実用上は、この単位で TOptions にバインドすることが多いはずです。その方が責務が分割されて楽になるので。
単体テストをするときにはどうしましょうか。DI しましょうか。単体テストなのに DI コンテナーと結合し、その初期化に長い時間をかけ、単体テストコードを書いているはずなのに、やっているのはコードのテストではなくて DI コンテナと単体テストフレームワークの連携機能になる現実と戦いましょうか。そうならないようにちゃんと仕組みを考えるのも悪くはないかもしれませんが、もっと簡単に生きましょう。
まず思いつくのは、Moq なりのモックフレームワークで IOptions<TOptions> なりのモックを作るというのがあります。悪くないですが、そもそも Microsoft.Extensions.Options の既定の実装は public に公開されているので、素直にそれを使うのも手です。以下に説明するように、IOptions<TOptions> は責務が十分に分割されている(裏を返せば型同士の関係が結構複雑)ので、普通のオブジェクトとして IOptions<TOptions> のモックを作成できます。
IOptions<TOptions> や IOptionsSnapshot<TOptions> の場合、その既定の実装である OptionsManager<TOptions> オブジェクトを作成します。具体的には、以下のようにします(ただ、この程度であれば、モックフレームワークを使っても変わらない気もしますが)
var options =
new OptionsManager<TOptions>(
new OptionsFactory<TOptions>(
new IConfigureOptions<TOptions>[]
{
new ConfigureNamedOptions(
String.Empty, // 構成の名前。CurrentValue で取得する名前のない構成に対しては空文字列を指定。
options =>
{
// ここで、TOptions の各プロパティを好きなように設定する。
}
)
},
Enumerable.Empty<IPostConfigureOptions<TOptions>>()
)
);少々煩雑なので、下記のようなヘルパーを用意しておくと幸せになれるでしょう(というか、探せばどこかにありそうですが)。
public static class OptionsSnapshot
{
public static IOptionsSnapshot<TOptions> Create<TOptions>(Action<TOptions> initializer)
where TOptions : class, new()
=> new OptionsManager<TOptions>(
new OptionsFactory<TOptions>(
new IConfigureOptions<TOptions>[]
{
new ConfigureNamedOptions(
String.Empty,
initializer
)
},
Enumerable.Empty<IPostConfigureOptions<TOptions>>()
)
);
}呼び出し側はこんな感じです。
var options =
OptionsSnapshot.Create(
c =>
{
c.Foo = ...;
c.Bar = ...;
:
}
)さて、IOptionsMonitor<TOptions> の方は少し面倒です。何しろ、変更通知をサポートする IOptionsMonitor<TOptions> 自体が複雑なので。順を追って説明しましょう。
ここでは、この後説明する構成情報の動的変更を含めてテストすることを前提とします(そうでなければそもそも IOptionsMonitor<TOptions> を必要としないはずです)。また、そのためには、構成情報の動的変更を通知する IOptionsChangeTokenSource<TOptions> というオブジェクトが必要となりますが、それは何かしら用意できるものとします(たとえば、この後説明する ConfigurationChangeTokenSource<TOptions> を使うとか)。
さて、IOptionsChangeTokenSource<TOptions> 型の changeTokenSource オブジェクトが用意できたとして、以下のように OptionsMonitor<TOptions> を作れば OK です。OptionsFactory<TOptions> オブジェクトを作成する部分は先ほどと同じで、IOptionsChangeTokenSource<TOptions> と OptionsChache<TOptions> が必要な部分が異なります。
var optionsMonitor =
new OptionsMonitor<TOptions>(
new OptionsFactory<TOptions>(
new IConfigureOptions<TOptions>[]
{
new ConfigureNamedOptions(
String.Empty, // 構成の名前
options =>
{
// ここで、TOptions の各プロパティを好きなように設定
}
)
},
Enumerable.Empty<IPostConfigureOptions<TOptions>>()
),
changeTokenSource, // ここが面倒なところ
new OptionsCache<TOptions>()
);ヘルパーを用意するとしたらこんな感じでしょうか。
public static class OptionsMonitor
{
public static IOptionsMonitor<TOptions> Create<TOptions>(Action<TOptions> initializer, IOptionsChangeTokenSource<TOptions> changeTokenSource)
where TOptions : class, new()
=> new OptionsManager<TOptions>(
new OptionsFactory<TOptions>(
new IConfigureOptions<TOptions>[]
{
new ConfigureNamedOptions(
String.Empty,
initializer
)
},
Enumerable.Empty<IPostConfigureOptions<TOptions>>()
),
changeTokenSource,
new OptionsCache<TOptions>()
);
}さて、IOptionsSnapshot<TOptions> と IOptionsMonitor<TOptions> は動的な構成変更の反映をサポートしますが、どのようになっているのでしょうか。
まずは、前提となる Microsoft.Extensions.Configuration を見ていきましょう。
正直、Microsoft.Extensions.Configuration については Deep Dive into Microsoft Configuration が詳しいのでお勧めです。
ポイントは、
- 構成情報は、
:区切りのキーを持つ、文字列のディクショナリである。 - キーの大文字と小文字は区別されない。
- 構成ソース(
IConfigurationSource)は、元のソースの形式(リスト、ツリー構造など)から文字列のディクショナリを読み取る役割を持つ。- 構成ソースは、
IConfigurationBuilderの拡張メソッドを使って構成する。この場合、後から追加される構成ソースの値が、先に追加した構成ソースの値を上書きする形でマージされる。 - Microsoft が出している構成ソースとして、インメモリコレクション(
Memory)、環境変数(Environment)、コマンドライン(CommandLine)、XML(Xml)、JSON(Json)、ini(Ini)、1 ファイル 1 データ(KeyPerFile)、Azure Key Vault(AzureKeyVault)、ユーザーシークレット(UserSecret)がある。 - 動的変更をサポートしているのはファイル系(XML、JSON、ini)のみ。
- ファイル系については、
IFileProvider実装を交換することで、インメモリからの読み込みなどもサポートできる。 - 環境変数の場合、*nix 系で苦労しないように、
__で区切られたキーをサポートする。また、全ての環境変数を読み込むことがないよう、対象のプレフィックスを指定できる。 - コマンドラインの場合、
--section:subsection:keyみたいにできる。普通、コマンドラインに:は含めないので、構成情報と他が混ざることはないはず。なお、--key=valueでも--key valueでもよい。また、重複した場合は後勝ち。 - 配列のインデックスは数値のキー扱い。たとえば、
{"foo":[{"bar":"boo"}]}はfoo:0:barというキーとbooという値になる。
- 構成ソースは、
TOptionsにバインドするときや、GetValue<T>()のときに、構成の値をTypeConverterによって変換する。- 接続文字列は、
ConnectionString:{名前}がデファクト。なお、ConnectionString:{名前}_ProviderNameにプロバイダー名(System.Data.SqlClientなどのDbProviderFactoryに渡す名前)を入れるというデファクトもある。 - 環境変数用のソース(とプロバイダー)には、Azure App Service を意識した設定が組み込まれているので、Azure Web App のアプリケーション設定とシームレスに連携できる(この辺が
Microsoft.Extensionsなのでしょう……個人的に、Azure 向けの機能がプラガブルでない形で組み込まれているのは気持ち悪いと思いますが)。- にしても PostgreSQL がなかったりするので、
Customにして、自分で{名前}_ProviderNameをアプリケーション設定に入れたが良い気はします。プロバイダー名が必要ってことは、様々な RDBMS をサポートしたいのでしょうし、その労力をかけられる状況で、{名前}_ProviderNameの挿入とか嬉しくない気が。
- にしても PostgreSQL がなかったりするので、
さて、Microsoft.Extensions.Configuration には変更通知が考慮されています。具体的には、構成ソースを表す IConfigurationSource が変更を通知し、IConfigurationProvider がその通知を受け取って、構成情報のディクショナリを更新し、それを上位に通知するという仕組みが、ConfigurationProvider 抽象クラスにて実装されています。
IConfigurationProviderの実装は、ペアになるIConfigurationSourceから通知を受け取ります。- なお、現在、この処理は
FileConfigurationProviderしか実装していません。
- なお、現在、この処理は
IConfigurationProviderは、IConfigurationSourceからの通知を受け取り、内部のディクショナリを更新し、再読み込みトークンに通知を行います。ConfigurationRootなどのIConfigurationProviderのコンシューマーは、GetReloadToken()から返されるIChangeTokenを購読します。ConfigurationRootなどのIConfigurationRoot実装は、このプロバイダーからの変更通知に対する応答として、自身のGetReloadToken()が返すIChangeTokenで変更を通知します。ConfigurationRootとConfigurationProviderによるIChangeTokenの実装はConfigurationReloadTokenであり、これはCancellationTokenSourceを使って実装されています。
IOptionsMonitor<TOptions>のようなIConfigurationRootのコンシューマーが、変更通知を使用します(そして、IOptionsMonitor<TOptions>.OnChangeで登録されたイベントハンドラーが実行されます)。
なので、この仕組みを活用するには、そういう IConfigurationProvider を実装する必要があります。実装については、FileConfigurationProvider 周りの実装を github で見るのが手っ取り早いでしょう。
なお、IConfigurationRoot.Reload() を呼び出すことで、各構成プロバイダーから最新の値を読み直すことができます(もちろん、IConfigurationProvider.Load() が、改めて値を読んでくる実装になっていることと、IConfigurationRoot.Reload() がそのように実装されていることが前提です)。
そして、その後で基底のインターフェイスである IConfiguration で宣言された IConfiguration.GetReloadToken() で返される IChangeToken に通知されます。そのため、テストなどでは、インメモリ構成情報を編集し、IConfigurationRoot.Realod() を呼び出すことで、構成情報の再読み込みをテストできます。
さて、ここで、先ほど説明省略した IOptionsChangeTokenSource<TOptions> に戻りましょう。このオブジェクトは、ある名前付き構成オプションの変更を、IOptionsMonitor<TOptions> に渡す役目を持ちます。このインターフェイスの Microsoft.Extensions.Configuration 連携用の実装として、ConfigurationChangeTokenSource<TOptions> があり、このオブジェクトは IConfiguration.GetReloadToken() で返される変更通知を転送します。
なので、構成情報を使う場合、以下のようにします。
var configData = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var config =
new ConfigurationBuilder()
.AddMemory(configData)
.Build();
var changeTokenSource =
new ConfigurationChangeTokenSource<TOptions>(String.Empty, config);
var monitor = OptionsMonitor.Create(onChange, changeTokenSource);さて、ここまで使い方を見てきましたが、いくつかの疑問が生じた方もいるのではないでしょうか。
私たちは、IServiceCollection の拡張メソッド ConfigureOptions<TOptions>() を呼んだだけです。
DI の仕組みを使用してオブジェクトが組みあがっているのは想像できますが、具体的にどのように構成情報から IOptions<TOptions>(やその進化系)が構築され、渡されるのでしょうか。
疑問を解決するには github のソースコードを見るのが手っ取り早いと思いますが、これまで説明してきた内容の全体像を説明することで、ある程度その助けになるかと思いますので、これから説明します。
前述のように、構成情報(IConfiguration)の実体はコロン区切りの文字列のキーと、文字列値の組み合わせのリストにすぎません。
これをオブジェクトの形で取得できるようにするには、バインディング(binding)が必要です。
構成情報のバインディングは、Microsoft.Extensions.Configuration.ConfigurationBinder に定義された拡張メソッドによって行われます。
実装自体はシンプルにリフレクションを使用した値の設定で、設定先のプロパティの型がコレクションでもちゃんとバインドされます。
たとえば、IDictionary<string, string> にバインドすることもできるので、特定の構成セクションに単純なキーと文字列を定義するなんてこともできます。
さて、このバインディングは誰によって呼び出されているのでしょうか。
先ほど見たように、最終的な IOptions<TOptions>(やその進化系)は、OptionsManager<TOptions> と、それが保持する OptionsFactory<TOptions>、そしてそれが保持する 1 つ以上の IConfigureOptions<TOptions> によって構築できます。
私たちが IServiceCollection.Configure<TOptions>(IConfiguration) 拡張メソッドを実行する、正確には Microsoft.Extensions.DependencyInjection.OptionsConfigurationServiceCollectionExtensions.Configure<TOptions>(IServiceCollection, IConfiguration) 静的メソッドを実行すると、内部的には以下のように DI コンテナーに型が登録されます。
IOtionsの構築に必要な型の登録(Microsoft.Extensions.DependencyInjection.OptionsServiceCollectionExtensions.AddOptions(IServiceCollection)による)。詳しくは後述しますが、ここでOptionsFactory<TOptions>とOptionsManager<TOptions>が登録されます。- 構成変更を購読するための
IConfigurationChangeTokenSource<TOptions>の実装として、シングルトンなConfigurationChangeTokenSource<TOptions>オブジェクトを登録する。 IConfigureOptions<TOptions>の実装として、シングルトンなNamedConfigureFromConfigurationOptions<TOptions>を登録する。
お察しのように、この NamedConfigureFromConfigurationOptions<TOptions> が ConfigurationBinder のメソッドを呼び出して、IConfiguration を TOptions にバインドします。
さて、OptionsServiceCollectionExtensions.AddOptions(IServiceCollection) が登録する型についても触れておきましょう。
これらが Microsoft.Extensions.Options の実体であるといって差し支えないでしょう。
まず、サービス型について見て行きます。AddOptions() で登録される型は次の 5 つです。
IOptions<TOptions>。古参。.NET Core 1.0 からあり、構成情報がバインドされたオブジェクトを保持する単純な型です。実装型が(ジェネリック型として)シングルトンとして登録されるため、一度初期化されると、常に同じ値が返ってくるはずです。IOptionsSnapshot<TOptions>。.NET Core 1.1 から追加されました。こちらはシングルトンではなくスコープ付き(scoped)なので、リクエストごとにオブジェクトが初期化されます。そのため、構成情報の再読み込みに対応していれば、リクエストの受付時の最新の情報が反映されているはずです。さらに、名前付き構成もサポートしています(名前付き構成については今回は詳細に触れませんが、Environmentの値がDevelopment、Staging、Productionいずれの状態なのかに応じて構成情報のセットを変える機能です)。IOptionsMonitor<TOptions>。IOptionsSnapshot<TOptions>を拡張したもので、変更通知をサポートします。バッチやデーモンなどの実装、ASP.NET Core であればIHostedServiceで構成情報を受け取るのに向いているでしょう。IOptionsFactory<TOptions>。実際にTOptionsをインスタンス化して初期化する役割を持ちます。IOptionsMonitorCache<TOptions>。IOptionsMonitor<TOptions>用の、バインド済みTOptionsのキャッシュ。名前付き構成をサポートします。
また、それぞれの実装型についても見て行きましょう。
IOptions<TOptions>とIOptionsSnapshot<TOptions>の実体は、どちらもOptionsManager<TOptions>オブジェクトです。OptionsManager<TOptions>は、IOptionsFactory<TOptions>を DI で受け取り、そこから取得した結果をキャッシュし、ValueプロパティやGet()メソッド呼び出しに対してはそのキャッシュを返します。つまり、あるOptionsManager<TOptions>オブジェクトは、常に同一のTOptionsを返します。OptionsManager<TOptions>が静的なIOptions<TOptions>としてふるまうか、リクエスト開始時のスナップショットであるIOptionsSnapshot<TOptions>としてふるまうのかは、DI コンテナーに対してシングルトンとして登録されるか、スコープ付きとして登録されるかの違いだけです。なお、キャッシュ機構をカスタマイズすることはできません。IOptionsMonitor<TOptions>の実装はシングルトンなOptionsMonitor<TOptions>です。このクラスは、DI されたIOptionsChangeSource<TOptions>からの通知を使用して、構成情報の変更通知を実装します。なお、TOptionsの作成とキャッシュは、OptionsManager<TOptions>と同様です。ただし、TOptionsの作成と初期化を DI されたIOptionsFactory<TOptions>に委譲するのは同じですが、キャッシュについては DI されたIOptionsMonitorCache<TOptions>に処理を委譲します。つまり、IOptionsMonitorCache<TOptions>の実装を入れ替えることで、キャッシュの動作を変更できます(その使い道はちょっと思いつきませんが)。IOptionsFactory<TOptions>の実装は遷移的な(都度インスタンス化される)OptionsFactory<TOptions>です。このオブジェクトはTOptionsをインスタンス化し、IConfigureOptions<TOptions>とIPostConfigureOptions<T>を使用してオプションを初期化する役割を持ちます。もっとも、TOptionsにはnew()制約があるので、この実装は単にnew TOptions()するだけです。また、このファクトリは作成したTOptionsをIConfigureOptions<TOptions>とIPostConfigureOptions<TOption>に渡して初期化を委譲する関係上、TOptionsにはclass制約も要求します(参照型に制限されます)。IOptionsMonitorCache<TOptions>の実装は、シングルトンなOptionsMonitorCache<TOptions>です。これはConcurrentDictionary<string, TOptions>を使用した、名前付き構成の、スレッドセーフなキャッシュの単純な実装です。
ところで、DI の構成で services.Configure<TOptions>() で登録され、コンストラクターに渡されるのは IOptions<TOptions> であって、TOptions 型そのものではないのでしょうか?
IOptions<TOptions> には TOptions 型を返す Value プロパティしかなく、一見無駄な中間層に見えます。
理由は、TOptions を生成する一連のロジックを DI 可能にするため、となります。実は、IOptions<TOptions>.Value を初期化する実装は、次のようになっています。
IOtions<TOptions>の既定の実装であるOptionsManager<TOptions>は、コンストラクターインジェクションによってIOtionsFactory<TOptions>を受け取ります。OptionsManager<TOptions>は、値(Valueプロパティ)の生成をIOptionsFactory<TOptions>に委譲します。IOpotionsFactory<TOptions>の既定の実装であるOptionsFactory<TOptions>は、コンストラクターインジェクションで受け取ったIConfigureOptions<TOption>のコレクション、IPostConfigureOptions<TOptions>のコレクション、そしてIOptionsVaildator<TOptions>のコレクションを使用して、以下のようにTOptionsを初期化します。- まず、
IOptionsFactory<TOptions>の宣言においてTOptionsにはnew()制約がついているため、既定のコンストラクターを呼び出してTOptions型のインスタンスを生成します。 IConfigureOptions<TOptions>とIPostConfigureOptions<TOptions>のコレクションをそれぞれ順番に呼び出します(このとき、IConfigureOptions<TOptions>の実体がIConfigureNamedOptions<TOptions>の場合、そちらの実装を呼び出して名前付き構成をサポートします)。このとき、IOptionFactory<TOptions>のTOptionsにはclass制約もついているため、IConfigureOptions<TOptions>やIPostConfigureOptions<TOptions>にはTOptionsの参照が渡るため、その内容を変更できます。- (2.2 以降)
IValidateOptions<TOptions>のコレクションを順番に呼び出します。
- まず、
このような DI の連鎖により、TOptions の初期化処理を注入できるようになります。そして、この方法を使用して、構成情報をオプション値にバインドしています。
前述のように、OptionsBuilder<TOptions>.Validate() 拡張メソッドで Func<TOptions, bool> 型のデリゲートを渡して、カスタムのバリデーションロジックを登録できます。具体的に言うと、戻り値が false のとき、バリデーションは失敗します。このバリデーション処理は、IValidateOptions<TOptions> の実装である ValidateOptions<TOptions> クラスで実装されており、OptionsBuilder<TOptions> はこのオブジェクトを IValidateOptions<TOptions> のシングルトンまたは都度生成のサービスとして DI コンテナーに登録します(依存先がある場合は都度生成、そうでない場合はシングルトン)。なお、バリデーション失敗時のメッセージは、OptionsBuilder<TOptions>.Validate() 拡張メソッドに引数として渡します。
同様に、ValidateDataAnnotations() 拡張メソッドで、データアノテーション属性ベースのバリデーションも有効になります。実装は DataAnnotationValidateOptions クラスで、System.ComponentModel.DataAnnotation.Validator.TryValidateObject() メソッドに処理を委譲します。シングルトンなサービスとして登録されます。なお、エラーメッセージは、ValidatetionResult のリストごとに、DataAnnotation validation failed for members: '{カンマ区切りの ValidationResult.MemberNames}' with the error: '{ ValidationResult.ErrorMessage}'." で固定です。
なお、バリデーションに失敗すると、バリデーションを呼び出す OptionsFactory<TOptions> の実装は ValidationException をスローします。さらに、OptionsMonitor<TOptions> は構成ソースの変更を検知した際に、内部で保持するキャッシュを削除してから OptionsFactory<TOptions> に新しいオプションを要求する(そしてキャッシュは更新されない)ことと、キャッシュに値がない場合は常に OptionsFactory<TOptions> に新しいオプションを要求するので、変更後の値がバリデーションエラーになった場合、CurrentValue は例外をスローします。大抵の場合は、構成情報が変更になった場合は古い値で動き続けてほしいでしょうから、以下のようにすると良いと思います(が、ここはいろいろなご意見を伺いたいところですが)。
IOptionsMonitor<TOptions>を受け取ったクラスは、CurrentValueの値をフィールドに保持する。この時点でエラーの場合は起動時のエラーということで、あきらめる。IOptionsMonitor<TOptions>を受け取ったクラスは、OnChange()でイベントハンドラーを登録する。そのイベントハンドラーでは、CurrentValueの値を使用して、フィールドの値を更新する。このとき、CurrentValueがOptionValidationExceptionをスローした場合、ログに出力したり何らかのアラートを出したりし、フィールドの値を更新せずにしておく。
構成情報のコードを追っていくと、しばしば Microsoft.Extensions.Primitives パッケージの型に行きつきます。これは Microsoft.Extensions に所属するフレームワークの共通ライブラリ群で、次のような機能が定義されています。
IChangeToken。変更通知のためのフレームワークです。IFileProvider。ファイル処理を抽象化しています(微妙に使いづらい気がしますが)。