ねののお庭。

かりかりもふもふ。

【C#】.NET 8 世代で追加された ConfigureHttpClientDefaults について。

.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 clienttyped 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>.Getnull を渡したところで、内部的に Options.DefaultName が用いられるだけです。

コード上は ConfigureNamedOptions.Configure 等の実装を読むと null が特別扱いを受けている事が分かります。

DefaultHttpClientBuilder に対する細工

Microsoft.Extensions.Dependencyinjection の service collection は順序付きです。 そのため当然ながら、Configure を叩く順序も重要です。

たとえば上の例でいうと (4)services.Configure<MyOptions>(null, ...); を一番先頭に持ってくると、 MyOptions.Valuenull__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