鷲ノ巣

C# とか PowerShell とか。当ブログの記事は(特に公開直後は)頻繁に改定される場合があることをご了承ください。

.NET のオプション機能を完全に理解した。

この記事は以下の記事に対するリベンジです。
tech.blog.aerie.jp

反省点

前回の記事で何がダメだったか。
これです。

最初のリクエスト時に一度だけ JSON をパースするだけだと、アプリケーションのスタートアップ時に一度だけ読み込まれるだけです。その後、アプリケーションが再起動されない限り、IP アドレスの情報が更新されません。
もしアプリケーションが長期間動き続けてしまうと、プロキシの IP アドレス情報が増減したことを検知できないかもしれません。
そのため、1日1回くらいは、何らかの方法で JSON を再読み込みすべきでしょう。

手作業で更新するんだと更新漏れが怖いから自動化したのに、自動化しても更新に気づかないんじゃ本末転倒です。
というわけで、どうにかしましょう。

案その1

このアプリは AWS の ECS 上で動いているので、定期的にコンテナを再起動するとかでもよいのでは……とか思いました。が、AWS はそういう機能を提供してなさそうでしたし*1、ダウンタイムなしでローリング再起動とかをちゃんと制御するのは面倒くさそうなので、この路線を検討するのはやめました。

案その2

というわけで、アプリケーションの中でキャッシュをリフレッシュする方法を考えることにします。
前回利用した AsyncLazy<T> には、保持している値を無効化して再初期化するような機能はなさそうでした。そういう機能を持つライブラリも面白そうではありましたが、よくわからんので見送りました。

AsyncLazy<T> が同一インスタンス上でのリセット機能を持たないのであれば、AsyncLazy<T>インスタンス自体を作り直してしまえばいいですね。
というわけで最初に実装したのはこんな感じ(やっていることをざっくり掴むための疑似コードなので、いろいろ足りていませんがご容赦を)。
キャッシュの管理とミドルウェアの構成を別サービスに分離して、ちょっとわかりやすくなったかもしれません。

public class AwsForwardedHeadersService
{
    private AsyncLazy<ForwardedHeadersMiddleware> _middleware;

    public Task<ForwardedHeadersMiddleware> GetConfiguredMiddleware()
    {
        if (RefreshRequired())
        {
            this._middleware = new(this.CreateMiddleware);
        }

        return this._middleware.GetValueAsync();
    }
}
public class AwsForwardedHeadersMiddleware
{
    private readonly RequestDelegate _next;
    private readonly AwsForwardedHeadersService _service;

    public AwsForwardedHeadersMiddleware(
        RequestDelegate next,
        AwsForwardedHeadersService service)
    {
        this._next = next;
        this._service = service;
    }

    public async Task Invoke(
        HttpContext context)
    {
        var middleware = await this._service.GetConfiguredMiddleware().ConfigureAwait(false);

        middleware.ApplyForwarders(context);

        await this._next(context).ConfigureAwait(false);
    }
}

ForwardedHeadersMiddleware実装を見て、ステートレスなクラスなので問題ないのはわかっていましたが、なんかミドルウェア自体をキャッシュするのって気持ち悪いなって思ったので、ForwardedHeadersOptions をキャッシュするように変えてみたりもしました。本質的にはあんまり変わらないので、コードは省略します。

オプション パターン

そもそもの問題は、ForwardedHeadersMiddleware の動作を制御する ForwardedHeadersOptions の内容が、スタートアップ時に一度しか読み取られないという点にあります。
これは何故かというと、ForwardedHeadersMiddleware がそのコンストラクタにおいて、オプションを IOptions<ForwardedHeadersOptions> という型で受け取っており、かつ、その値をフィールドに保持してしまっているからです。

.NET の DI で構成オプションを受け取る際には IOptions<T> 型で受け取るのはよくあるパターンなのですが、このインターフェイスには他にも似たようなファミリーが存在します。
それが以下の 3 つです。

docs.microsoft.com

これらの違いは、オプションの値がいつ更新されるかです。こうしたバリエーションを使うことで、オプションの値を、アプリケーションの実行中に変更することができます。
それぞれの違いを簡単に言うと、以下のようになります。

  • IOptions<T>:シングルトン サービスとして DI に登録され、最初に Value が要求された際に値を構成して、以降はアプリケーションが終わるまで、その値を保持し続ける。
  • IOptionsSnapshot<T>:スコープド サービスとして DI に登録され、最初に Value が要求された際に値を構成する。スコープド サービスなので、スコープ(ASP.NET Core においてはリクエスト)の都度、インスタンスが作り直され、その際に最新の値が取得される。
  • IOptionsMonitor<T>:シングルトン サービスとして DI に登録され、複数の機構によって値の更新タイミングをコントロールすることができる。

今回は、1日1回の再読み込みというのが要件なので、リクエストの都度では頻度が高すぎます。そのため、IOptionsMonitor<T> を使う必要があります。

余談ですが、appsettings.json から読み込んだ IConfiguration をオプションにバインドする場合も、appsettings.json ファイルの更新を監視して最新の値を読み込みなおすことが可能です。まぁ、GUI アプリならともかく、Web アプリではあまりやらないでしょうが。

IOptionsMonitor<T> の更新機構

IOptionsMonitor<T> の既定の実装クラスは OptionsMonitor<T> です。このコンストラクタの引数を見ると、以下のものがあります。

それぞれの役割は以下のようになります。

  • IOptionsFactory<T>:オプション値を生成する。値を更新する必要があるたびに呼ばれる。
  • IOptionsChangeTokenSource<T>:値を更新する必要があることを OptionsMonitor<T> に通知する。
  • IOptionsMonitorCache<T>:値をキャッシュする。

実際に値を保持しているのは IOptionsMonitorCache<T> です。

さて、ここから、オプション値を更新するには、2 つの方法があることがわかります。

  • IOptionsChangeTokenSource<T> を通じて値の更新を要求する。
  • IOptionsMonitorCache<T> からキャッシュされている値を削除することで、次回の取得時に値を更新させる。

前者がプッシュ型、後者がプル型とでも言えるかもしれません。
今回は、1日1回の更新を要件としたので、プル型が適切でしょう。キャッシュの有効期限を1日にすればよいですね。

なお、AWS の IP アドレス帯の情報が更新された際は、SNS によるプッシュ型の通知を受け取ることもできます。今回の実装はこれには対応していません。
docs.aws.amazon.com

IMemoryCache との組み合わせ

さて、しかし、IOptionsMonitorCache<T> に保持されているキャッシュに、一定の保持期限を設けるにはどうしたらいいのでしょうか? どうも、標準の機能としては用意されていないように思われます。
そこで、そうした高度な機能を持つキャッシュ機構である IMemoryCache と組み合わせることにしましょう。

というわけで、こんな感じのものを、どん。

internal class OptionsMonitorCache<T> :
    IOptionsMonitorCache<T>,
    IDisposable
    where T : class
{
    private readonly IMemoryCache _cache;

    private readonly IOptions<OptionsMonitorCacheOptions> _options;

    private CancellationTokenSource _cts = new();

    public OptionsMonitorCache(
        IMemoryCache cache,
        IOptions<OptionsMonitorCacheOptions> options)
    {
        ArgumentNullException.ThrowIfNull(cache);
        ArgumentNullException.ThrowIfNull(options);

        this._cache = cache;
        this._options = options;
    }

    public T GetOrAdd(
        string name,
        Func<T> createOptions)
    {
        return this._cache.GetOrCreate(name, entry => {
            var value = createOptions();
            entry.SetValue(value);

            var entryOptions = this.CreateCacheEntryOptions();
            entry.SetOptions(entryOptions);

            return value;
        });
    }

    public bool TryAdd(
        string name,
        T options)
    {
        if (this._cache.TryGetValue(name, out _))
        {
            return false;
        }

        var entryOptions = this.CreateCacheEntryOptions();
        this._cache.Set(name, options, entryOptions);

        return true;
    }

    public bool TryRemove(string name)
    {
        if (!this._cache.TryGetValue(name, out _))
        {
            return false;
        }

        this._cache.Remove(name);
        return true;
    }

    public void Clear()
    {
        var cts = this._cts;
        this._cts = new();

        cts.Cancel();
    }

    public void Dispose()
    {
        this._cache.Dispose();
        this._cts.Dispose();
    }

    private MemoryCacheEntryOptions CreateCacheEntryOptions()
    {
        var cacheOptions = new MemoryCacheEntryOptions();
        cacheOptions.AddExpirationToken(new CancellationChangeToken(this._cts.Token));
        cacheOptions.AbsoluteExpirationRelativeToNow = this._options.Value?.Expiration;

        return cacheOptions;
    }
}

OptionsMonitorCacheOptions .NET の型ではなく、アプリケーションで用意した型です。

TryAddTryRemove の実装はあまり厳密とは言えませんが、まぁ大丈夫でしょう。たぶん。

Clear については、IMemoryCache には相当の機能がなさそうなので、ちょっと工夫しています。
具体的に言いますと、(最初から、あるいは、前回 Clear が呼ばれてから)今までに追加したキャッシュ エントリを一斉に無効化することで、削除に代えています。
CancellationChangeToken は、前の節でちらっと言及した IOptionsChangeTokenSource<T> に関連するクラスです。外部からプッシュ型で変更通知を送るのに使います。
変更通知は、IOptionsMonitor<T> の場合はオプション値を再読み込みするという意味でしたが、IMemoryCache の場合は、キャッシュを期限切れにさせる効果を持ちます。
内部的には CancellationToken を使用していますので、CancellationTokenSource.Cancel を呼ぶことで、期限切れ状態にすることができます。
CancellationTokenSource は一度キャンセルしたら復活させることはできませんので、キャンセルする度に新しいインスタンスに入れ替える必要があります。
CancellationTokenSourcethis._cts)を一旦ローカル変数に移し、新しいインスタンスをセットしてからキャンセルしているのは、念のためです。
今回は使用していないのですが、IOptionsChangeTokenSource<T> の実装を試みた際に、そうしないと無限ループになってしまったので。

ちなみに CancellationTokenSourceIDisposable を実装しますが、入れ替えの際に古いインスタンスDispose を呼んでいません。
どうも、実装を見た感じ、一定時間後にキャンセルさせるためのタイマーや WaitHandle を使っていなければ Dispose する必要がなさそうでしたので。
CancellationTokenSourceDispose すべきなのか、どのようにするのかというのは、いつも悩む問題です。

まぁ、こんな風に実装してみたところで、Clear とか、呼ばれるかどうか知らないんですけどね。

個人的には、CancellationToken を、処理のキャンセル用途ではなく汎用の通知機構として使うのは気に入らないのですが、まぁ便利なので良しとしましょうか。
.NET チームはそんなことお構いなしみたいです。

IConfigureOptions<T>

もうひとつ関連するインターフェイスとして、IConfigureOptions<T> を紹介します。
前述のオプション三兄弟に対して値を構成する際、よく Configure を使います。こうすることで、ライブラリからアプリケーションに対して、オプションをカスタマイズする機会を与えているわけですね。

こんな感じのオプション設定のときに使います。見たことありますよね。
オプション値が要求されたときに、このラムダ式が呼ばれるように登録するのが Configure です。

services.AddFooBar(options => {
    options.BazBaz = ...;
});

AddFooBar の実装はこんな風になっています。

public static IServiceCollection AddFooBar(
    IServiceCollection services,
    Action<FooBarOptions> configure)
{
    services.Configure(configure);
    return services;
}

ただ、これだと、ラムダ式のパラメータに DI で注入される値を使うことができません。
そこで IConfigureOptions<T> の出番です。

たとえば、上記の OptionsMonitorCacheOptions に対しては、こんな実装がされています。

internal class OptionsMonitorCacheSetup :
    IConfigureOptions<OptionsMonitorCacheOptions>
{
    private readonly IOptions<AwsForwardedHeadersOptions> _options;

    public OptionsMonitorCacheSetup(
        IOptions<AwsForwardedHeadersOptions> options)
    {
        ArgumentNullException.ThrowIfNull(options);

        this._options = options;
    }

    public void Configure(
        OptionsMonitorCacheOptions options)
    {
        options.Expiration = this._options.Value?.RefreshInterval;
    }
}

これによって、別途構成しておいた AwsForwardedHeadersOptions から OptionsMonitorCacheOptions に値を移し替えることができます。

これを使うには、Configure の代わりに、こんな風に書きます。

services.AddSingleton<IConfigureOptions<OptionsMonitorCacheOptions>, OptionsMonitorCacheSetup>();

Configure を使う場合でも、内部的に IConfigureOptions<T> の実装が登録されています。

というわけで、案その3

結局、やりたかったことを振り返ると、以下のようになりました。

  • ForwardedHeadersOptions を定期的に更新したい → IOptionsMonitor<T> で受け取る。
  • キャッシュを自前で管理したくない、期限切れにさせたい → IMemoryCache を使う。

キャッシュ管理を IMemoryCache に任せたことで、AsyncLazy<T> は不要になりました。

ミドルウェアはシンプルかつきれいな実装になりました。

public class AwsForwardedHeadersMiddleware
{
    private readonly RequestDelegate _next;

    public AwsForwardedHeadersMiddleware(
        RequestDelegate next)
    {
        ArgumentNullException.ThrowIfNull(next);

        this._next = next;
    }

    public Task Invoke(
        HttpContext context,
        ILoggerFactory loggerFactory,
        IOptionsMonitor<ForwardedHeadersOptions> options)
    {
        var middleware = new ForwardedHeadersMiddleware(
            this._next,
            loggerFactory,
            Options.Create(options.CurrentValue));

        middleware.ApplyForwarders(context);

        return this._next(context);
    }
}

肝心の ForwardedHeadersOptions の組み立てはこう。
もともと、初期化時に非同期処理を使いたい! っていう動機で始めたことでしたが、前回反省したとおり、同期的に取得しています。
このコードが走るのは1日1回なので、性能的にもさほど問題にはならないでしょう。ユーザーからのリクエストが無くても、内部的にはヘルス チェック用のリクエストがしょっちゅう発生していますし。

internal class ForwardedHeadersSetup :
    IConfigureOptions<ForwardedHeadersOptions>
{
    private readonly IIPRangesClient _client;

    private readonly IOptions<AwsForwardedHeadersOptions> _options;

    public ForwardedHeadersSetup(
        IIPRangesClient client,
        IOptions<AwsForwardedHeadersOptions> options)
    {
        ArgumentNullException.ThrowIfNull(client);
        ArgumentNullException.ThrowIfNull(options);

        this._client = client;
        this._options = options;
    }

    public void Configure(
        ForwardedHeadersOptions options)
    {
        var optionsValue = this._options.Value;

        var ipRangesClientOptions = new IPRangesClientOptions
        {
            IPRangesUri = optionsValue.IPRangesUri
        };

        var ipRanges = this._client
            .GetIPRangesAsync(ipRangesClientOptions, CancellationToken.None)
            .GetAwaiter()
            .GetResult();

        optionsValue.ConfigureOptions?.Invoke(options);

        foreach (var prefix in ipRanges.Prefixes)
        {
            if (!optionsValue.PrefixFilter(prefix))
            {
                continue;
            }

            var network = new IPNetwork(prefix.Prefix, prefix.PrefixLength);
            options.KnownNetworks.Add(network);
        }
    }
}

IPRangesClient の実装は載せませんが、まぁ HttpClient.GetFromJsonAsyncJSON をデシリアライズした後、ちょこちょこ変形しているだけです。

サービスはこんな風にして登録して使います。

public static class AwsForwardedHeadersServiceCollectionExtensions
{
    public static IServiceCollection AddAwsForwardedHeaders(
        this IServiceCollection services,
        Action<AwsForwardedHeadersOptions> configure)
    {
        services.AddHttpClient<IIPRangesClient, IPRangesClient>();
        services.AddMemoryCache();
        services.AddSingleton<IOptionsMonitorCache<ForwardedHeadersOptions>, OptionsMonitorCache<ForwardedHeadersOptions>>();
        services.AddSingleton<IConfigureOptions<OptionsMonitorCacheOptions>, OptionsMonitorCacheSetup>();
        services.AddSingleton<IConfigureOptions<ForwardedHeadersOptions>, ForwardedHeadersSetup>();
        services.Configure(configure);

        return services;
    }
}
services.AddAwsForwardedHeaders(options => {

    options.PrefixFilter = static prefix =>
        string.Equals(prefix.Service, "CLOUDFRONT", StringComparison.OrdinalIgnoreCase);

    options.ConfigureOptions = fho => {
        fho.ForwardedHeaders = ForwardedHeaders.All;
        fho.ForwardedProtoHeaderName = "CloudFront-Forwarded-Proto";
        fho.ForwardLimit = null;

        foreach (var network in this._appSettings.KnownNetworks)
        {
            if (TryParseNetwork(network, out var parsedNetwork))
            {
                fho.KnownNetworks.Add(parsedNetwork);
            }
        }
    };

    options.RefreshInterval = TimeSpan.FromDays(1);
});

おまけ:ISystemClock

キャッシュの期限切れのテストをするにあたっては、現在時刻をコントロールする必要があります。DateTimeOffset.Now のような非決定的な要素をハード コーディングしてしまうと、テストの再現性を確保するのに苦労するのは有名な話です。
そこで .NET には ISystemClock というインターフェイスが用意されており、これを実装したクラスを MemoryCacheOptions.Clock にセットしてやることで、「現在時刻」を制御することができます。

こんなものを作ると便利です。

internal class TestClock :
    ISystemClock
{
    public TestClock(
        DateTimeOffset initialValue)
    {
        this.UtcNow = initialValue;
    }

    public TestClock()
        : this(DateTimeOffset.UtcNow)
    {
    }

    public void Set(
        DateTimeOffset value)
    {
        this.UtcNow = value;
    }

    public void Advance(
        TimeSpan span)
    {
        this._now += span;
    }

    private DateTimeOffset _now;

    public DateTimeOffset UtcNow
    {
        get
        {
            return this._now;
        }

        private set
        {
            this._now = value.ToUniversalTime();
        }
    }
}

いやしかし、Microsoft.Extensions.Internal って、public に使えるのに、なかなか味のある名前空間ですよね。テストのためなんですよ!っていう感じが伝わってきます。
ところで、Microsoft.AspNetCore.Authentication 名前空間にも、まったく同じインターフェイスがあります。
微妙に違うものや internal なものも含めれば、.NET や ASP.NET Core 全体では、6 つもの ISystemClock インターフェイスが存在しています。
通化してしまえばいいのにと思うんですが、やはり、テストのためのものということで忌避されているんでしょうかね。

おわりに

よいお年を!

*1:もしあったら教えてください