Skip to content

Instantly share code, notes, and snippets.

@yfakariya
Created November 4, 2019 11:44
Show Gist options
  • Save yfakariya/ff6cd9653509181c8191f875e45be80f to your computer and use it in GitHub Desktop.
Save yfakariya/ff6cd9653509181c8191f875e45be80f to your computer and use it in GitHub Desktop.
Deep dive of Microsoft.Extensions.Options in Japanese

Microsoft.Extensions.Options Deep Dive

最近は .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 ならリクエストごとにスコープが作成されます)で登録されます。

Scopedでほしいパターン(リクエスト開始時に最新の値に更新してほしいパターン)

IOptionSnapshot<T> を使います。そうすると、DI のスコープごとに最新の値が来ますし、逆に言えばスコープ(ASP.NET Core ならリクエスト)内で来る値が変わることもありません。

ただし、繰り返しになりますが、そもそも動的な構成情報の変更のサポート自体が限定的であることは忘れないでください(そのうち対応するかもしれませんが)。

任意のタイミングで最新の値が欲しいパターン

すべてが HTTP リクエストを受け取る AP サーバー上で動くと思うなよ。ということで、長時間実行されるプロセスで、かつ動的に構成情報が変更されうるという、やや特殊(?)なケースを考えてみましょう。 この場合、コンストラクターで IOptionsMonitor<TOptions> を受け取り、ここから最新の値をどうにか取得することになります。

具体的には、名前付きでない構成から取得する場合は CurrentValue、名前付き構成(ここでは踏み込みません)では Get(String name) メソッドで取得します。 基本的には整合性が取れている(はず)の単位でまとめて取得したいでしょうから、これらの呼び出しの結果をローカル変数に保存して、その値を使って処理をするはずです(都度 CurrentValue を呼び出すと、毎回違う値が返ってくるかもしれませんので避けましょう)。

なお、TOptions の値が変わった場合にまとめて動作を変更したい場合もあると思います。その場合は、IOtionsMonitor<TOptions>.OnChange(Action<TOptions, String>) メソッド経由でイベントハンドラーを登録しておき、そのイベントハンドラーで処理を行います。

くどいようですが、そもそも動的な構成情報の変更のサポート自体が限定的であることは忘れないでください(そのうち対応するかもしれませんが)。

構成オプションの作り方

次に、この IOptions<TOptions> のオブジェクトの作り方を説明します。

パターン1:構成情報としてオプションを読み込む

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 時点では、BindNonPublicPropertiestrue にして、public でない書き込み可能プロパティに対するバインドを行うように変更できます。

さらに、多くの場合はオプションのバリデーションを行いたいでしょうから、OptionsBuilder<TOptions>.ValidateDataAnnotations() 拡張メソッドを呼び出して、データアノテーション属性ベースのバリデーションを有効にします。また、属性ベースでは足りない場合には、OptionsBuilder<TOptions>.Validate(Func<TOptions, bool> validation, string failureMessage)を呼び出して、カスタムのバリデーションを実装できます(たとえば、複数のプロパティの整合性を取りたい場合とか)。

オプションの初期化を詳細に制御したい場合には、OptionsBuilder<TOptions>.ConfigureOptionsBuilder<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 について補足しておくと、IConfiguration は構成情報の塊を表すインターフェイスで、IConfigurationBuilder を使用して、様々なソース(インメモリコレクション、環境変数、コマンドライン、JSON、Azure Key Vault など)から取得、マージした構成情報(: 区切りのキーを持つ、文字列のディクショナリ)です。 この構成情報は、キーの階層構造を持っており(ファイルパスが /\ でディレクトリ構造に区切られるのと同じ)、IConfiguration.GetSection(String) でサブセクションとして構成情報の一部を表す IConfiguration を取得できます。たとえば、log:levellog:outputconnectionString:data の 3 つを持つ IConfiguration から、log: で始まるもののみを持つ構成情報(log:levellog:output のみを持つ)を IConfiguration として取得できます。実用上は、この単位で TOptions にバインドすることが多いはずです。その方が責務が分割されて楽になるので。

パターン2:コードでオプションを設定する

単体テストをするときにはどうしましょうか。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 の挿入とか嬉しくない気が。

組み込みの変更通知

さて、Microsoft.Extensions.Configuration には変更通知が考慮されています。具体的には、構成ソースを表す IConfigurationSource が変更を通知し、IConfigurationProvider がその通知を受け取って、構成情報のディクショナリを更新し、それを上位に通知するという仕組みが、ConfigurationProvider 抽象クラスにて実装されています。

  1. IConfigurationProvider の実装は、ペアになる IConfigurationSource から通知を受け取ります。
    • なお、現在、この処理は FileConfigurationProvider しか実装していません。
  2. IConfigurationProvider は、 IConfigurationSource からの通知を受け取り、内部のディクショナリを更新し、再読み込みトークンに通知を行います。
  3. ConfigurationRoot などの IConfigurationProvider のコンシューマーは、GetReloadToken() から返される IChangeToken を購読します。
    • ConfigurationRoot などの IConfigurationRoot 実装は、このプロバイダーからの変更通知に対する応答として、自身の GetReloadToken() が返す IChangeToken で変更を通知します。
    • ConfigurationRootConfigurationProvider による IChangeToken の実装は ConfigurationReloadToken であり、これは CancellationTokenSource を使って実装されています。
  4. IOptionsMonitor<TOptions> のような IConfigurationRoot のコンシューマーが、変更通知を使用します(そして、IOptionsMonitor<TOptions>.OnChange で登録されたイベントハンドラーが実行されます)。

なので、この仕組みを活用するには、そういう IConfigurationProvider を実装する必要があります。実装については、FileConfigurationProvider 周りの実装を github で見るのが手っ取り早いでしょう。

手動再読み込みと変更通知

なお、IConfigurationRoot.Reload() を呼び出すことで、各構成プロバイダーから最新の値を読み直すことができます(もちろん、IConfigurationProvider.Load() が、改めて値を読んでくる実装になっていることと、IConfigurationRoot.Reload() がそのように実装されていることが前提です)。 そして、その後で基底のインターフェイスである IConfiguration で宣言された IConfiguration.GetReloadToken() で返される IChangeToken に通知されます。そのため、テストなどでは、インメモリ構成情報を編集し、IConfigurationRoot.Realod() を呼び出すことで、構成情報の再読み込みをテストできます。

IOptionsMonitor<TOptions> の作り方再び

さて、ここで、先ほど説明省略した 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 のソースコードを見るのが手っ取り早いと思いますが、これまで説明してきた内容の全体像を説明することで、ある程度その助けになるかと思いますので、これから説明します。

ConfigurationとBinder

前述のように、構成情報(IConfiguration)の実体はコロン区切りの文字列のキーと、文字列値の組み合わせのリストにすぎません。 これをオブジェクトの形で取得できるようにするには、バインディング(binding)が必要です。 構成情報のバインディングは、Microsoft.Extensions.Configuration.ConfigurationBinder に定義された拡張メソッドによって行われます。 実装自体はシンプルにリフレクションを使用した値の設定で、設定先のプロパティの型がコレクションでもちゃんとバインドされます。 たとえば、IDictionary<string, string> にバインドすることもできるので、特定の構成セクションに単純なキーと文字列を定義するなんてこともできます。

さて、このバインディングは誰によって呼び出されているのでしょうか。

services.Configure(config)の裏側

先ほど見たように、最終的な 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 のメソッドを呼び出して、IConfigurationTOptions にバインドします。

IOptions五人集

さて、OptionsServiceCollectionExtensions.AddOptions(IServiceCollection) が登録する型についても触れておきましょう。 これらが Microsoft.Extensions.Options の実体であるといって差し支えないでしょう。

まず、サービス型について見て行きます。AddOptions() で登録される型は次の 5 つです。

  • IOptions<TOptions>。古参。.NET Core 1.0 からあり、構成情報がバインドされたオブジェクトを保持する単純な型です。実装型が(ジェネリック型として)シングルトンとして登録されるため、一度初期化されると、常に同じ値が返ってくるはずです。
  • IOptionsSnapshot<TOptions>。.NET Core 1.1 から追加されました。こちらはシングルトンではなくスコープ付き(scoped)なので、リクエストごとにオブジェクトが初期化されます。そのため、構成情報の再読み込みに対応していれば、リクエストの受付時の最新の情報が反映されているはずです。さらに、名前付き構成もサポートしています(名前付き構成については今回は詳細に触れませんが、Environment の値が DevelopmentStagingProduction いずれの状態なのかに応じて構成情報のセットを変える機能です)。
  • 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() するだけです。また、このファクトリは作成した TOptionsIConfigureOptions<TOptions>IPostConfigureOptions<TOption> に渡して初期化を委譲する関係上、TOptions には class 制約も要求します(参照型に制限されます)。
  • IOptionsMonitorCache<TOptions> の実装は、シングルトンな OptionsMonitorCache<TOptions> です。これは ConcurrentDictionary<string, TOptions> を使用した、名前付き構成の、スレッドセーフなキャッシュの単純な実装です。

IOptions<TOptions> の役割

ところで、DI の構成で services.Configure<TOptions>() で登録され、コンストラクターに渡されるのは IOptions<TOptions> であって、TOptions 型そのものではないのでしょうか? IOptions<TOptions> には TOptions 型を返す Value プロパティしかなく、一見無駄な中間層に見えます。

理由は、TOptions を生成する一連のロジックを DI 可能にするため、となります。実は、IOptions<TOptions>.Value を初期化する実装は、次のようになっています。

  1. IOtions<TOptions> の既定の実装である OptionsManager<TOptions> は、コンストラクターインジェクションによって IOtionsFactory<TOptions> を受け取ります。
  2. OptionsManager<TOptions> は、値(Value プロパティ)の生成を IOptionsFactory<TOptions> に委譲します。
  3. IOpotionsFactory<TOptions> の既定の実装である OptionsFactory<TOptions> は、コンストラクターインジェクションで受け取った IConfigureOptions<TOption> のコレクション、 IPostConfigureOptions<TOptions> のコレクション、そして IOptionsVaildator<TOptions> のコレクションを使用して、以下のように TOptions を初期化します。
    1. まず、IOptionsFactory<TOptions> の宣言において TOptions には new() 制約がついているため、既定のコンストラクターを呼び出して TOptions 型のインスタンスを生成します。
    2. IConfigureOptions<TOptions>IPostConfigureOptions<TOptions> のコレクションをそれぞれ順番に呼び出します(このとき、IConfigureOptions<TOptions> の実体が IConfigureNamedOptions<TOptions> の場合、そちらの実装を呼び出して名前付き構成をサポートします)。このとき、IOptionFactory<TOptions>TOptions には class 制約もついているため、IConfigureOptions<TOptions>IPostConfigureOptions<TOptions> には TOptions の参照が渡るため、その内容を変更できます。
    3. (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 の値を使用して、フィールドの値を更新する。このとき、CurrentValueOptionValidationException をスローした場合、ログに出力したり何らかのアラートを出したりし、フィールドの値を更新せずにしておく。

余談:Primitives

構成情報のコードを追っていくと、しばしば Microsoft.Extensions.Primitives パッケージの型に行きつきます。これは Microsoft.Extensions に所属するフレームワークの共通ライブラリ群で、次のような機能が定義されています。

  • IChangeToken。変更通知のためのフレームワークです。
  • IFileProvider。ファイル処理を抽象化しています(微妙に使いづらい気がしますが)。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment