Start Debugging

.NET 11 における IOptions<T> と IOptionsSnapshot<T> と IOptionsMonitor<T> の違い

既定では IOptions<T> を使ってください。シングルトンが設定のリロードを見る必要がある場合は IOptionsMonitor<T> を、スコープ付きの利用側が 1 リクエストの間だけ安定した値を必要とする場合にのみ IOptionsSnapshot<T> を選びます。判断軸は設定の形ではなく、利用側のライフタイムです。

明確な理由がない限り IOptions<T> を注入してください。これはシングルトンであり、設定クラスをプロセスの生存期間中にちょうど 1 回だけバインドし、3 つの中で解決コストが最も小さいものです。長時間動作するサービスが再起動なしに設定変更を観測する必要がある場合は IOptionsMonitor<T> を、そして IOptionsSnapshot<T> は 1 つの限られたケース、つまり単一のリクエストの間は安定していてリクエスト間では異なってよい値を必要とするスコープ付きまたは一時的な利用側の場合にのみ選びます。これを決める軸は、注入される設定の形ではなく、注入する側のクラスのライフタイムです。以下の内容はすべて .NET 11 (Preview 6、SDK 11.0.100-preview.6.26359.118 で検証) と C# 14、Microsoft.Extensions.Options 11.0.0 を対象としています。これら 3 つのインターフェースは .NET Core 2.0 以来この動作なので、すべて .NET 10 GA でもそのまま動きます。本当に新しいのは最後に触れる .NET 11 の検証まわりだけです。

機能マトリクス

機能IOptions<T>IOptionsSnapshot<T>IOptionsMonitor<T>
具体的な実装UnnamedOptionsManager<T>OptionsManager<T>OptionsMonitor<T>
DI でのライフタイムSingletonScopedSingleton
シングルトンへ注入できるかできるできない、キャプティブ依存になるできる
設定のリロードを見るか一切見ない見る、次のスコープから見る、即座に
名前付きオプション非対応対応、Get(name)対応、Get(name)
変更コールバックなしなしあり、OnChange
値へのアクセス.Value.Value.Get(name).CurrentValue.Get(name)
バインダーが動く頻度プロセスごとに 1 回スコープごと、名前ごとに 1 回変更ごと、名前ごとに 1 回
インスタンスのキャッシュ先シングルトンのフィールドスコープ付きマネージャー内の OptionsCache<T>シングルトンの IOptionsMonitorCache<T>

重要なのは主に 2 行です。ライフタイムの行は起動時の例外を生み、「バインダーが動く頻度」の行はホットパスでの予想外の CPU 使用を生みます。それ以外はこの 2 行から導かれます。

3 つとも AddOptions() が登録し、これはホストが呼び出してくれます。OptionsServiceCollectionExtensions より:

// Microsoft.Extensions.Options 11.0.0 -- what AddOptions() actually registers
services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptions<>), typeof(UnnamedOptionsManager<>)));
services.TryAdd(ServiceDescriptor.Scoped(typeof(IOptionsSnapshot<>), typeof(OptionsManager<>)));
services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptionsMonitor<>), typeof(OptionsMonitor<>)));
services.TryAdd(ServiceDescriptor.Transient(typeof(IOptionsFactory<>), typeof(OptionsFactory<>)));
services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptionsMonitorCache<>), typeof(OptionsCache<>)));

IOptionsFactory<T> が transient で、実際の作業を担っている点に注目してください。登録されたすべての IConfigureOptions<T> を順に実行し、続いてすべての IPostConfigureOptions<T> を実行し、その後に検証を行います。3 つのアクセサー用インターフェースは、このファクトリーの出力をどれだけ積極的にキャッシュするかだけが異なります。話はそれだけであり、だからこそ選択はライフタイムの問題になるのです。

設定クラスと登録コードは 3 つとも同一です:

// .NET 11, C# 14
public sealed class PaymentOptions
{
    public string ApiKey { get; set; } = "";
    public int TimeoutSeconds { get; set; } = 30;
}

// Program.cs
builder.Services.Configure<PaymentOptions>(
    builder.Configuration.GetSection("Payment"));

IOptions を選ぶべき場面

これを既定にしてください。リロード対応を諦めることになりますが、ほとんどのサービスではそれは実質的な損失ではありません。

// .NET 11, C# 14
public sealed class PaymentClient(IOptions<PaymentOptions> options)
{
    // Safe: the value is fixed for the life of the process.
    private readonly PaymentOptions _settings = options.Value;

    public TimeSpan Timeout => TimeSpan.FromSeconds(_settings.TimeoutSeconds);
}

IOptions<T> の代償はちょうど 1 つ、名前付きオプションに対応していないことです。そのため Configure<Features>("Personalize", ...) は見えません。同じクラスの構成が 2 つ必要なら、その時点で IOptions<T> は選択肢から外れています。それはまた、実際にモデル化したいものに対して名前付きオプションより .NET 11 の依存性注入におけるキー付きサービスのほうが適しているかを確認すべきタイミングでもあります。

IOptionsSnapshot を選ぶべき場面

スコープ付きの利用側が、1 つの作業単位の中では一貫していて、作業単位をまたぐと変わってよい値を必要とする場合に使ってください。

// .NET 11, C# 14 -- scoped service, values stable for this request
public sealed class CheckoutService(IOptionsSnapshot<PaymentOptions> snapshot)
{
    private readonly PaymentOptions _settings = snapshot.Value;

    public string Key => _settings.ApiKey;
}

厳しい制限が 2 つあります。1 つ目に、IOptionsSnapshot<T>Scoped として登録されているため、シングルトンへの注入は失敗します。シングルトンである IHostedServiceBackgroundService への注入も同様です。ホストは Development 環境で ValidateScopesValidateOnBuild を有効にするため、そこでは起動時に明確な Cannot consume scoped service が出ます。Development 以外ではこれらのチェックは既定で無効であり、同じコードが、黙って一度も更新されないキャプティブ依存を解決してしまいます。失敗を大きな音で知りたいなら、すべての環境でスコープ検証を有効にしてください。回避策は BackgroundService の内側でスコープを作ることですが、欲しかったのが新しい値だけなら IOptionsMonitor<T> のほうが単純な答えです。2 つ目に、コンソールアプリや素の IHost では自分で作らない限り周囲にスコープが存在しないため、Web ホスト以外での IOptionsSnapshot<T> は、ほぼ確実に本当は IOptionsMonitor<T> が欲しかったということを意味します。

IOptionsMonitor を選ぶべき場面

シングルトンが変更を見る必要がある場合、あるいはコールバックが必要な場合に使ってください。

OptionsMonitor<T> は登録されたすべての IOptionsChangeTokenSource<T> を購読します。どれかが発火すると InvokeChanged_cache.TryRemove(name) を実行し、すぐに TOptions options = Get(name) で再構築し、その新しいインスタンスでリスナーを呼び出します。CurrentValueGet(Options.DefaultName) の薄いラッパーであり、その中身は _cache.GetOrAdd(localName, () => localFactory.Create(localName)) です。

// .NET 11, C# 14 -- singleton, always current
public sealed class RateLimiter : IDisposable
{
    private readonly IDisposable? _subscription;
    private volatile PaymentOptions _current;

    public RateLimiter(IOptionsMonitor<PaymentOptions> monitor)
    {
        _current = monitor.CurrentValue;
        _subscription = monitor.OnChange(updated => _current = updated);
    }

    public int TimeoutSeconds => _current.TimeoutSeconds;

    public void Dispose() => _subscription?.Dispose();
}

この IDisposable は重要です。OnChangeChangeTrackerDisposable を返し、その Dispose_monitor._onChange -= OnChange を実行します。スコープ付きまたは一時的なサービスからコールバックを登録して戻り値を捨てると、リクエストごとにシングルトンのマルチキャストデリゲートへリスナーが追加され、二度と外れません。結果としてゆっくりとしたメモリリークとコールバックの嵐が起き、これは IOptionsMonitor<T> の使い方が壊れる最も一般的なパターンの 1 つです。

変更通知が存在するのは Microsoft.Extensions.Configuration.Json.Ini.Xml.KeyPerFile.UserSecrets のようなファイルシステムベースの構成プロバイダーだけであり、しかもプロバイダーが reloadOnChange: true で追加された場合に限られます。環境変数プロバイダーやコマンドラインプロバイダーは決して発火しないため、それらのソースの上では IOptionsMonitor<T> は、少し高価な IOptions<T> へと静かに退化します。

意味のある計測はナノ秒の数値ではなく回数

ここでは意図的に ns/op の数値を出しません。3 つの解決コストは、あなた自身の IConfigureOptions<T> デリゲートと検証処理が何をするかに支配されるため、私のマシンの数値はあなたのマシンについて何も語らないからです。移植可能な数値はバインダーが何回動くかであり、これは 15 行ほどで計測できます。

// .NET 11 Preview 6, C# 14 -- counts how often the options are actually built
public sealed class CountingConfigure : IConfigureOptions<PaymentOptions>
{
    public static int Count;
    public void Configure(PaymentOptions options) => Interlocked.Increment(ref Count);
}

builder.Services.AddSingleton<IConfigureOptions<PaymentOptions>, CountingConfigure>();

app.MapGet("/probe", (
    IOptions<PaymentOptions> o,
    IOptionsSnapshot<PaymentOptions> s,
    IOptionsMonitor<PaymentOptions> m) =>
{
    _ = o.Value; _ = s.Value; _ = m.CurrentValue;
    return CountingConfigure.Count;
});

/probe を繰り返し叩くとカウンターはリクエストごとにちょうど 1 ずつ増え、その 1 は IOptionsSnapshot<T> によるものです。IOptions<T> は最初のリクエストでのみ寄与し、IOptionsMonitor<T> は最初のリクエストとその後はリロードごとに 1 回寄与し、IOptionsSnapshot<T> はすべてのリクエストで寄与します。新しいスコープは空の OptionsCache<T> を持つ新しい OptionsManager<T> を意味するからです。この登録に .ValidateDataAnnotations() を加えると、検証処理も毎リクエスト再実行されます。毎秒 5,000 リクエストを処理するエンドポイントなら、ほぼ変わらない値のために毎秒 5,000 回の再バインドと 5,000 回の検証が走ることになります。これが IOptionsSnapshot<T> を既定にすべきでない具体的な理由であり、グラフを鵜呑みにするのではなく自分のアプリで検証できる主張です。

判断を決めてしまう落とし穴

OnChange は気にしていない設定でも発火します。 コールバックはあなたのセクションではなく、構成ルートの変更トークンに結び付いています。IConfiguration のどこかへの書き込みが 1 つあるだけで、アプリ内のすべての IOptionsMonitor<T> リスナーが呼ばれます。.NET チームはこれを dotnet/runtime#109445 として記録し、対応予定なしとしてクローズしたため、この挙動は恒久的です。構成のどこかが変わる限り、すべての IOptionsMonitor インスタンスが自身のコールバックを発火させ得ます。コールバックが高価なリソースを再構築するなら、直前の値をキャッシュして比較してから動いてください。

OnChange は 1 回の保存でも複数回発火します。 エディターはファイルを複数の操作に分けて書き込み、その下にある IFileProvider.Watch はそのそれぞれを報告します。そのため 1 回の Ctrl+S で 2 回、ときにはそれ以上のコールバックが起きるのが普通です。これは dotnet/aspnetcore#2542 であり、オプションのスタックのバグではなくファイル監視の副作用です。コールバックを冪等にするか、デバウンスしてください。

ファイル監視は Docker のボリュームやネットワーク共有では信頼できません。 代わりにポーリングさせるには DOTNET_USE_POLLING_FILE_WATCHER=1 を設定します。ポーリング間隔は 4 秒で、設定変更はできません。反映の速さを当てにしていた場合、これは現実的な制約になります。

IOptions<T> の「ずっと」は本当にずっとです。 値は .Value が最初に読まれたときにバインドされ、プロセスの生存期間中キャッシュされます。チームの理解が「設定オブジェクトは更新される」であれば、障害対応中に設定を反映させても何も起きず、IOptions<T> が壊れているように見えます。これは設定クラスごとに決めて、文書に残してください。

スコープ付きサービスでオプションを構成するのは、どのアクセサーを使っていても罠です。 IOptions<T> の場合、IConfigureOptions<T> はルートプロバイダー経由で解決されるため、構成デリゲートに注入したスコープ付き依存はキャプティブ依存になります。代わりに IServiceProvider を解決して Configure の内側でスコープを作り、そのスコープはリクエストのスコープではないことを覚えておいてください。

.NET 11 で加わったもの

知っておく価値があるのは 2 つで、いずれもアクセサー層ではなく検証層の話です。

OptionsBuilder<TOptions> に、デリゲートではなく型パラメーターを受け取るジェネリックな Validate オーバーロードが加わりました。その型は IValidateOptions<TOptions> を実装し、DI コンテナーに登録されている必要があります。これによりオプション検証が通常の DI パターンに揃います:

// .NET 11, C# 14
services.AddSingleton<IValidateOptions<MyOptions>, MyOptionsValidator>();
services.AddOptions<MyOptions>()
    .Bind(configuration.GetSection("MyOptions"))
    .Validate<MyOptionsValidator>();

System.ComponentModel.DataAnnotations も .NET 11 で非同期検証に対応し、AsyncValidationAttributeIAsyncValidatableObjectValidator.ValidateObjectAsync が使えるようになりました。Microsoft.Extensions.Options は新しい IAsyncStartupValidator を通じてこれを取り込むため、妥当性がネットワーク呼び出しに依存するオプションでも、初回利用時ではなく起動時にアプリを失敗させられます。どちらの変更も、どのアクセサーを注入すべきかには影響しません。ただし両方とも、ValidateOnStart を .NET 10 のときより強い既定にします。

改めて推奨

すべての設定クラスは IOptions<T> から始めてください。特定のシングルトンに変更を観測する明文化された必要がある場合に IOptionsMonitor<T> へ移り、OnChange の購読は破棄してください。IOptionsSnapshot<T> は、実際に変化する値についてスコープ付きの利用側がリクエスト単位の安定性を必要とする場合にだけ使い、その代償として毎リクエストで完全な再バインドと再検証を支払っていることを受け入れてください。コンパイルエラーが消えたからという理由で IOptionsSnapshot<T> に手を伸ばしているなら、それはライフタイムの問題をパフォーマンスの問題で解決したということです。

関連記事

参考資料

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る