この記事は以下の記事に対するリベンジです。
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 つです。
IOptions<T>
- IOptionsSnapshot<T>
- IOptionsMonitor<T>
これらの違いは、オプションの値がいつ更新されるかです。こうしたバリエーションを使うことで、オプションの値を、アプリケーションの実行中に変更することができます。
それぞれの違いを簡単に言うと、以下のようになります。
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 の型ではなく、アプリケーションで用意した型です。
TryAdd
や TryRemove
の実装はあまり厳密とは言えませんが、まぁ大丈夫でしょう。たぶん。
Clear
については、IMemoryCache
には相当の機能がなさそうなので、ちょっと工夫しています。
具体的に言いますと、(最初から、あるいは、前回 Clear
が呼ばれてから)今までに追加したキャッシュ エントリを一斉に無効化することで、削除に代えています。
CancellationChangeToken は、前の節でちらっと言及した IOptionsChangeTokenSource<T>
に関連するクラスです。外部からプッシュ型で変更通知を送るのに使います。
変更通知は、IOptionsMonitor<T>
の場合はオプション値を再読み込みするという意味でしたが、IMemoryCache
の場合は、キャッシュを期限切れにさせる効果を持ちます。
内部的には CancellationToken を使用していますので、CancellationTokenSource.Cancel を呼ぶことで、期限切れ状態にすることができます。
CancellationTokenSource
は一度キャンセルしたら復活させることはできませんので、キャンセルする度に新しいインスタンスに入れ替える必要があります。
CancellationTokenSource
(this._cts
)を一旦ローカル変数に移し、新しいインスタンスをセットしてからキャンセルしているのは、念のためです。
今回は使用していないのですが、IOptionsChangeTokenSource<T>
の実装を試みた際に、そうしないと無限ループになってしまったので。
ちなみに CancellationTokenSource
は IDisposable を実装しますが、入れ替えの際に古いインスタンスの Dispose を呼んでいません。
どうも、実装を見た感じ、一定時間後にキャンセルさせるためのタイマーや WaitHandle を使っていなければ Dispose
する必要がなさそうでしたので。
CancellationTokenSource
を Dispose
すべきなのか、どのようにするのかというのは、いつも悩む問題です。
まぁ、こんな風に実装してみたところで、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.GetFromJsonAsync で JSON をデシリアライズした後、ちょこちょこ変形しているだけです。
サービスはこんな風にして登録して使います。
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:もしあったら教えてください