.NET 8 世代で追加された、という言い回しが微妙なところです。.NET 8 で追加された、というと語弊があるので仕方ないのですが。
ともあれ、.NET 8 のリリースと同時に Microsoft.Extensions
系パッケージも v8 系がリリースされています。
この記事では Microsoft.Extensions.Http
v8 で追加された ConfigureHttpClientDefaults
について取り扱います。
使い方
使い方は非常に簡単。以下のように叩くだけです。
IServiceCollection services = ...; services.ConfigureHttpClientDefaults(httpClientBuilder => { httpClientBuilder.AddHttpMessageHandler(sp => { // 独自の DelegatingHandler を差し込んだり return new MyDelegatingHandler(); }); httpClientBuilder.ConfigurePrimaryHttpMessageHandler(sp => { return new SocketsHttpHandler() { // あれこれ設定した SocketsHttpHandler を // 既定の PrimaryHttpMessageHandler として用いるようにしたり }; }); });
ConfigureHttpClientDefaults
の引数の型は Action<IHttpClientBuilder>
なので、IHttpClientBuilder
に対していつも通りにいろいろ拡張メソッド叩いてあげれば OK。
これにより IHttpClientFactory
/ IHttpMessageHandlerFactory
を用いて HttpClient
/ HttpMessageHandler
を作成する際の既定のオプションを構成する事ができます。
また、named client や typed client それぞれに対する構成処理よりも先に既定の構成処理が走るので、簡単に既定のオプションに対する上書き等が可能です。 便利。
内部のお話
ConfigureHttpClientDefaults
は使うだけなら上記を知っていれば大概問題ありません。
しかしながら、素朴に使うだけでも幾つか気になる事が出てきます。
まずは IHttpClientBuilder
について。
IHttpClientBuilder
は以下のような interface です。
public interface IHttpClientBuilder { string Name { get; } IServiceCollection Services { get; } }
この IHttpClientBuilder
は主に AddHttpClient
で named client や typed client を構成する際に返ってくる型です。
そして IHttpClientBuilder.Name
には AddHttpClient
のパラメータとして渡した文字列や型引数で渡した型の名前が設定されています。
// named client services.AddHttpClient("MyHttpClient") // IHttpClientBuilder .AddHttpMessageHandler(sp => new MyDelegatingHandler(null!, null!)); // typed client services.AddHttpClient<MyHttpClient>() // IHttpClientBuilder .ConfigurePrimaryHttpMessageHandler(sp => new SocketsHttpHandler());
さてここで ConfigureHttpClientDefaults
に渡す構成デリゲート (configuration delegate) の引数がなんだったか思い出しましょう。
それは IHttpClientBuilder
だったのでした。ここで「既定のオプションを構成する際の IHttpClientBuilder.Name
とは一体なに...?」と当然疑問が沸いて出てきます。
その答えは null
です。interface 的には not null ですが、null
です。
実際のコードでは以下のように null!
で警告を潰しています。わお。
public static IServiceCollection ConfigureHttpClientDefaults(this IServiceCollection services, Action<IHttpClientBuilder> configure) { ThrowHelper.ThrowIfNull(services); ThrowHelper.ThrowIfNull(configure); AddHttpClient(services); configure(new DefaultHttpClientBuilder(services, name: null!)); return services; }
ConfigureHttpClientDefaults
に渡された構成デリゲートの実引数として new DefaultHttpClientBuilder(services, name: null!)
によって作成されたインスタンスが渡され、これが IHttpClientBuilder
に対する拡張メソッド経由で叩かれる事になります。
ConfigurePrimaryHttpMessageHandler
, ConfigureAdditionalHttpMessageHandlers
, AddHttpMessageHandler
等の IHttpClientBuilder
に対する拡張メソッドは基本的に HttpClientFactoryOptions
というオプションを構成しているにすぎません。どれも Services.Configure<HttpClientFactoryOptions>(builder.Name, ...)
している事からも分かります。
public static IHttpClientBuilder ConfigurePrimaryHttpMessageHandler(this IHttpClientBuilder builder, Func<IServiceProvider, HttpMessageHandler> configureHandler) { ThrowHelper.ThrowIfNull(builder); ThrowHelper.ThrowIfNull(configureHandler); builder.Services.Configure<HttpClientFactoryOptions>(builder.Name, options => { options.HttpMessageHandlerBuilderActions.Add(b => b.PrimaryHandler = configureHandler(b.Services)); }); return builder; } public static IHttpClientBuilder ConfigureAdditionalHttpMessageHandlers(this IHttpClientBuilder builder, Action<IList<DelegatingHandler>, IServiceProvider> configureAdditionalHandlers) { ThrowHelper.ThrowIfNull(builder); ThrowHelper.ThrowIfNull(configureAdditionalHandlers); builder.Services.Configure<HttpClientFactoryOptions>(builder.Name, options => { options.HttpMessageHandlerBuilderActions.Add(b => configureAdditionalHandlers(b.AdditionalHandlers, b.Services)); }); return builder; } public static IHttpClientBuilder AddHttpMessageHandler(this IHttpClientBuilder builder, Func<IServiceProvider, DelegatingHandler> configureHandler) { ThrowHelper.ThrowIfNull(builder); ThrowHelper.ThrowIfNull(configureHandler); builder.Services.Configure<HttpClientFactoryOptions>(builder.Name, options => { options.HttpMessageHandlerBuilderActions.Add(b => b.AdditionalHandlers.Add(configureHandler(b.Services))); }); return builder; }
ConfigureHttpClientDefaults
に渡した構成デリゲート内で IHttpClientBuilder
の拡張メソッドを叩いている場合、builder.Name
は常に null
です。
つまり name が null
の Named option に対して構成している事になります。
Name が null
の named option に対する構成処理
実は Configure
の name として null
を渡せます。
これは API ドキュメント(その1,その2)) からも読み取れます。
そして name が null
の場合の構成処理は特別扱いされています。
どのように特別扱いされているかというと、全ての named option に対して構成処理が適用されるようになります。
これはコードを見た方が理解が早いでしょう。
// (1) // name を指定しない overload services.Configure<MyOptions>(options => { options.Value += "Implicit.DefaultName__"; }); // (2) // (1) は内部的に Options.DefaultName を使用しているため (2)と等価 // Options.DefaultName は string.Empty と等価 services.Configure<MyOptions>(Options.DefaultName, options => { options.Value += "Explicit.DefaultName__"; }); // (3) // (1) や (2) で Options.DefaultName が渡されているからとって、 // すべてのオプションの既定として用いられるわけではない // つまり Value が Implicit.DefaultName__hoge__ とか Explicit.DefaultName__hoge__ になったりしない // 要するに (3) は (1) や (2) と干渉しない services.Configure<MyOptions>("hoge", options => { options.Value += "hoge__"; }); // (4) // (1), (2), (3) とは完全に別モノ // name が null の場合は特別扱いされており、全ての named option に対して影響を及ぼす services.Configure<MyOptions>(null, options => { options.Value = options.Value += "null__"; }); class MyOptions { public string? Value { get; set; } }
IOptionsMonitor<MyOptions>
を DI で渡してもらって出力してみると、以下のようになります。
class C(IOptionsMonitor<MyOptions> optionsMonitor) { public void M() { Console.WriteLine(optionsMonitor.CurrentValue.Value); Console.WriteLine(optionsMonitor.Get(Options.DefaultName).Value); Console.WriteLine(optionsMonitor.Get(null).Value); // 内部的に null ではなく Options.DefaultName が用いられる Console.WriteLine(optionsMonitor.Get("hoge").Value); // 出力 // Implicit.DefaultName__Explicit.DefaultName__null__ // Implicit.DefaultName__Explicit.DefaultName__null__ // Implicit.DefaultName__Explicit.DefaultName__null__ // hoge__null__ } }
このように Configure
に渡した name が null
な場合、すべての named option に対して構成処理が適用されます。
また、name が null
の named option が存在するわけではありません。
IOptionsMonitor<T>.Get
に null
を渡したところで、内部的に Options.DefaultName
が用いられるだけです。
コード上は ConfigureNamedOptions.Configure
等の実装を読むと null
が特別扱いを受けている事が分かります。
DefaultHttpClientBuilder に対する細工
Microsoft.Extensions.Dependencyinjection
の service collection は順序付きです。
そのため当然ながら、Configure
を叩く順序も重要です。
たとえば上の例でいうと (4)
の services.Configure<MyOptions>(null, ...);
を一番先頭に持ってくると、
MyOptions.Value
は null__Implicit.DefaultName__Explicit.DefaultName__
や null__hoge__
といった値になります。
このように、正しい順序で Configure
に渡した構成デリゲートが実行されないと、意図しない結果になってしまします。
ConfigureHttpClientDefaults
においてはどうでしょうか?
ConfigureHttpClientDefaults
で渡された構成デリゲートは、すべての named client に対する構成処理の「前」に実行される必要があります。
でないとそれぞれの named client 用に構成したオプションが、既定の構成処理により上書きされてしまいます。
それでは使い物になりません。
そのため DefaultHttpClientBuilder
の内部で使われている DefaultHttpClientBuilderServiceCollection
にはちょっとした小細工がなされています。
どのような小細工かというと、既定の構成処理を先に実行するため、ConfigureHttpClientDefaults
で渡された構成デリゲート内での ServiceDescriptor
の追加は、既に name が null
でない named option に対する構成処理として追加されている ServiceDescriptor
が service collection に存在する場合、その ServiceDescriptor
より前に insert するようになっています。
これにより望んだ挙動を得られるようになっています。
PostConfigure
はあっても、その逆はないので、このような処理をする必要があるのです。
internal sealed class DefaultHttpClientBuilderServiceCollection : IServiceCollection { // ~~ Add 意外は省略 ~~ public void Add(ServiceDescriptor item) { if (item.ServiceType != typeof(IConfigureOptions<HttpClientFactoryOptions>)) { _services.Add(item); return; } if (_isDefault) { // Insert IConfigureOptions<HttpClientFactoryOptions> services into the collection before named config descriptors. // This ensures they run and apply configuration first. Configuration for named clients run afterwards. if (_tracker.InsertDefaultsAfterDescriptor != null && _services.IndexOf(_tracker.InsertDefaultsAfterDescriptor) is var index && index != -1) { index++; _services.Insert(index, item); } else { _services.Add(item); } _tracker.InsertDefaultsAfterDescriptor = item; } else { // Track the location of where the first named config descriptor was added. _tracker.InsertDefaultsAfterDescriptor ??= _services.Last(); _services.Add(item); } } }
まとめ
ConfigureHttpClientDefaults
は便利。
そして実はそれは Microsoft.Extensions.Options
の named option の name が null のものに対する挙動に支えられているということでした。
ただしそれだけでは ConfigureHttpClientDefaults
としては満足のいく挙動にならないため、DefaultHttpClientBuilder
で小細工をしているのでした。
おまけ
それはそうと言葉使いが難しいところ。
- Configuration
- 構成 (名詞)
- .NET 世界においては IConfiguration の系譜を想像する
- Configure
- 構成する (動詞)
- NET 世界においては主にオプションを構成する際に用いるメソッド名として使われている
- Configuration delegate
- 公式ドキュメントでも使われている語彙
- Configure メソッド等にパラメータとして渡すデリゲートの事を指す
- パラメータ名としては殆どの場合
configure
では、「Configure する事(名詞)」それ自体を指すのはなんて言葉使えばいいのだろうか。
一般には「構成」でいいと思うけど、上記の通り .NET 世界だと紛らわしい。
ライブラリ上ではどういう言葉が使われているのよ、というと「Configure する事」を担う責務の型 (殆どの場合 configuration delegate の参照を握って適宜それを呼び出すための型) の interface には IConfigureNamedOptions
という名前が与えられており、そこに動詞持ってくるんだ!?という感じの命名となっている。
ボキャが足りない説ありません?
なお本記事においてはそれらを「構成処理」と記述する事とした。
十中八九「構成デリゲートを適用する」でも良いのだけれど、厳密には正しくはないので「構成処理を適用する」とか。
なんとも悩ましい。
References
- https://github.com/dotnet/runtime/issues/87914
- https://github.com/dotnet/runtime/pull/87953
- https://github.com/dotnet/runtime/blob/v8.0.7/src/libraries/Microsoft.Extensions.Http/src/DependencyInjection/HttpClientFactoryServiceCollectionExtensions.cs#L80-L90
- https://github.com/dotnet/runtime/blob/v8.0.7/src/libraries/Microsoft.Extensions.Http/src/DependencyInjection/HttpClientBuilderExtensions.cs
- https://github.com/dotnet/runtime/blob/v8.0.7/src/libraries/Microsoft.Extensions.Options/src/ConfigureNamedOptions.cs
- https://github.com/dotnet/runtime/blob/v8.0.7/src/libraries/Microsoft.Extensions.Http/src/DependencyInjection/DefaultHttpClientBuilder.cs
- https://github.com/dotnet/runtime/blob/v8.0.7/src/libraries/Microsoft.Extensions.Http/src/DependencyInjection/DefaultHttpClientBuilderServiceCollection.cs
- https://www.nuget.org/packages/Microsoft.Extensions.Http/8.0.0
- https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.httpclientfactoryservicecollectionextensions.configurehttpclientdefaults?view=net-8.0
- https://learn.microsoft.com/en-us/dotnet/core/extensions/httpclient-factory
- https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.optionsconfigurationservicecollectionextensions.configure?view=net-8.0
- https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options?view=aspnetcore-8.0