Start Debugging

修正: IConfiguration.Bind が appsettings.json から配列や List<T> プロパティに値を設定しない

バインダーは、public setter のない配列プロパティ、get のみの IReadOnlyList<T>、フィールド、そしてソースジェネレーター使用時の init 専用メンバーを黙ってスキップします。さらに既定値を置き換えずに追記します。.NET 10.0.12 と 11 RC 1 で計測しました。

結論: ConfigurationBinder はコレクションをバインドできなくても例外をスローしません。プロパティをそのまま放置するだけです。よくある原因は次のとおりです。プロパティが public setter のない配列 (または IReadOnlyList<T>、IEnumerable<T>) である、プロパティではなく public フィールドである、GetSection に渡したセクション名が JSON と一致していない、あるいは Native AOT やトリミングを有効にしたことでバインダーがソースジェネレーターに切り替わり、そのジェネレーターが init アクセサーを無視している、というものです。プロパティに public な get; set; を付け、正しいセクションをバインドし、ErrorOnUnknownConfiguration を有効にして次の不一致では明確に失敗するようにしてください。リストはバインドされるのに 余分な 要素が含まれている場合、それはこのバグのもう半分です。バインダーはプロパティがすでに持っている内容に追記し、決して置き換えません。

以下の内容はすべて、SDK 10.0.302 と Microsoft.Extensions.Configuration.Binder 10.0.12 の組み合わせでファイルベースのプローブを使って計測し、その後 .NET 11 RC 1 SDK 上で 11.0.0-rc.1.26425.128 を使って再実行したものです。どの行も 2 つのバージョンで同一でした。重要な違いはリフレクションバインダーとソース生成バインダーの間にあり、.NET 10 と 11 の間にはありません。

バインダーがコレクションを黙ってスキップする理由

ConfigurationBinder.cs のリフレクションバインダーは、プロパティごとに書き込めるかどうかを判定します。判定は単純です。public getter が必要で、変更 ではなく 置き換え が必要なものについては public setter (または BinderOptions.BindNonPublicProperties = true) も必要です。判定に失敗すると、BindProperty は何も言わずに戻ります。

この “置き換え” と “変更” の区別が、紛らわしいケースのほとんどを説明します。

要素の変換エラーも握りつぶされます。BindArray と BindCollection では、各要素が try/catch の中でバインドされ、ErrorOnUnknownConfiguration が設定されている場合にだけ再スローされます。int[] の中の "abc" のような値は、結果から単に消えます。

計測結果の一覧

プローブは { "App": { "Hosts": [ "a.example", "b.example" ] } } をさまざまな形のオプションクラスにバインドします。既定のリフレクションバインダーで 1 回、EnableConfigurationBindingGenerator=true で 1 回です。

// .NET 10.0.12 / .NET 11 RC 1, Microsoft.Extensions.Configuration.Binder
class GetOnlyArray { public string[] Hosts { get; } = []; }
class GetOnlyList { public List<string> Hosts { get; } = new(); }
class GetOnlyRoList { public IReadOnlyList<string> Hosts { get; } = []; }
class FieldArray { public string[] Hosts = []; }
class PrivateSet { public string[] Hosts { get; private set; } = []; }
class InitOnly { public string[] Hosts { get; init; } = []; }
class Settable { public string[] Hosts { get; set; } = []; }
プロパティの形リフレクションバインダーソースジェネレーター
string[] { get; }[][]
List<string> { get; } = new()[a, b][a, b]
IReadOnlyList<string> { get; } = [][][]
IList<string> { get; } = new List<string>()[a, b][a, b]
string[] の public フィールド[][]
string[] { get; private set; }[][]
同上、BindNonPublicProperties = true[a, b]NotSupportedException
string[] { get; init; }[a, b][]
string[] { get; set; }[a, b][a, b]
Get<T>() 経由の record Opts(string[] Hosts)[a, b][a, b]
ImmutableArray<string> { get; set; }[]NullReferenceException

3 つの行はもう一度見ておく価値があります。init アクセサーはリフレクションでは動作しますが、ジェネレーターでは黙ってスキップされます。ImmutableArray<T> は一度も値が設定されません。そして、このプローブをジェネレーターでビルドしたときの警告は ゼロ だったので、どちらについてもコンパイル時には何も教えてくれません。

手順を追って修正する

  1. セクションのパスを確認します。 builder.Configuration.GetSection("App") は、プロパティ名に至るまで JSON と正確に一致している必要があります (照合は大文字と小文字を区別しないので、大文字小文字は問題ではありません)。セクションではなくルートをバインドするという最もよくある打ち間違いは、プローブでは [] になりました。バインダーを疑う前に、構成が実際に何を保持しているかを出力してください。

    // .NET 10 / 11
    foreach (var kv in builder.Configuration.GetSection("App").AsEnumerable())
        Console.WriteLine($"{kv.Key} = {kv.Value}");
    // Among the output you should see:
    // App:Hosts:0 = a.example
    // App:Hosts:1 = b.example

    配列はインデックス付きのキー (App:Hosts:0、App:Hosts:1) に平坦化されます。これらの行がない場合、問題はクラスではなくファイル (出力にコピーされていない、環境名が違う、ネストが違う) にあります。

  2. コレクションに public setter を付けます。 ほとんどの報告はこれで解決します。

    // .NET 10 / 11
    public sealed class AppOptions
    {
        public string[] Hosts { get; set; } = [];
        public List<EndpointOptions> Endpoints { get; set; } = [];
    }
    
    public sealed class EndpointOptions
    {
        public string Url { get; set; } = "";
    }

    プロジェクトが PublishAot や PublishTrimmed で発行される可能性が少しでもあるなら、init ではなく get; set; を使ってください (後述)。オプションクラスでは ImmutableArray<T> を避けてください。利用側に読み取り専用のセマンティクスを見せたい場合は、IReadOnlyList<T> { get; set; } を公開します。リフレクションバインダーはそこに string[] を、ジェネレーターは List<T> を代入し、プローブではどちらも正しく値が設定されました。

  3. 不一致で明確に失敗させます。 ErrorOnUnknownConfiguration は、構成に対応するプロパティのないキーがあると例外をスローし、さらにバインダーが要素の変換エラーを握りつぶすのも止めます。

    // .NET 10 / 11
    builder.Services.AddOptions<AppOptions>()
        .Bind(builder.Configuration.GetSection("App"),
              o => o.ErrorOnUnknownConfiguration = true)
        .ValidateOnStart();

    JSON に (単数形の) "Host": ["a"] がある場合、プローブは InvalidOperationException: 'ErrorOnUnknownConfiguration' was set on the provided BinderOptions, but the following properties were not found on the instance of Settable: 'Host' をスローしました。"Ports": [1, "abc", 3] の場合は 'ErrorOnUnknownConfiguration' was set and binding has failed をスローし、内部例外は Failed to convert configuration value 'abc' at 'App:Ports:1' to type 'System.Int32' でした。このオプションなしでは、同じバインドは [1, 3] を返しました。

    検証と組み合わせて、空のリストを本番環境での謎ではなく起動時の失敗にしましょう。IValidateOptions<T> による起動時のオプション検証 で ValidateOnStart 側を詳しく扱っています。

  4. コレクションを既定値で初期化するのをやめます。 次のセクションを参照してください。既定値は置き換えられず、追記されます。

バインダーは既定値を置き換えずに追記する

これは空のリストを修正した直後に遭遇するバグです。プロパティに既定値を与え、値を持つセクションをバインドしてみます。

// .NET 10 / 11
public sealed class AppOptions
{
    public List<string> Hosts { get; set; } = ["localhost"];
}
// appsettings.json: "App": { "Hosts": [ "a.example", "b.example" ] }
// Result: [ "localhost", "a.example", "b.example" ]

プローブは List<T>、string[]、IEnumerable<T>、IReadOnlyList<T>、HashSet<T> のいずれでもこの結果を返し、Bind と Get<T>() のどちらでも同じでした。BindArray は文字どおり、構成された要素を追加する前に既存の要素を新しいリストにコピーするところから始めます。同じインスタンスに対して Bind を 2 回呼び出すと (たとえば変更トークンのコールバックから)、[a, b, a, b] になりました。

これは昔からある意図的な動作です。既存のコレクションを上書きするオプションは 2021 年に dotnet/runtime#62112 で提案されましたが、Future マイルストーンのまま今も open で、dotnet/runtime#118204 も同様なので、フラグを待たないでください。既定値はバインドの 後 に、構成が何も提供しなかった場合にだけ適用します。

// .NET 10 / 11
public sealed class AppOptions
{
    public string[]? Hosts { get; set; }
}

builder.Services.Configure<AppOptions>(builder.Configuration.GetSection("App"));
builder.Services.PostConfigure<AppOptions>(o => o.Hosts ??= ["localhost"]);

プローブでは、セクションが存在するときは [a, b]、存在しないときは [localhost] になりました。IOptionsMonitor<T> のファクトリはリロードのたびに新しいインスタンスを作るので、PostConfigure の処理は毎回クリーンな状態に対して実行されます。IOptions vs IOptionsSnapshot vs IOptionsMonitor で、それぞれのインスタンスがいつ作成されるかを説明しています。

重ねたファイルは配列をインデックス単位でマージする

appsettings.Development.json は appsettings.json の配列を置き換えません。構成プロバイダーはキーを提供するだけで、あるキーを最後に設定したプロバイダーが勝ちます。配列は単にキー 0、1、2 です。プローブでは次の 2 つのファイルを重ねました。

// appsettings.json
{ "App": { "Hosts": [ "a", "b", "c" ] } }
// appsettings.Development.json
{ "App": { "Hosts": [ "dev1", "dev2" ] } }

バインド結果は [dev1, dev2, c] でした。インデックス 2 は依然として基本ファイルから来ています。環境変数 (App__Hosts__0=env.example は最初の要素だけを置き換えました) やコマンドライン引数 (--App:Hosts:2=cli.example は 3 番目を追加しました) でも同じことが起きます。ASP.NET Core の構成ドキュメントはこの点を明記しており、ソース間でインデックスをそろえておくよう勧めています。

基本の配列をクリアしようとして試しがちな方法のうち、次の 2 つはうまくいきません。

うまくいくのは、基本ファイルにその配列をまったく定義しない、すべての環境ファイルで完全な配列を定義する、あるいは値を区切り文字付きの 1 つの文字列として保存し PostConfigure で分割する、のいずれかです。単純な "Hosts": "a.example,b.example" を string[] に直接バインドすると [] になります。バインダーは文字列を分割してくれません。

Native AOT とトリミングで知らないうちにバインダーが変わる

.NET SDK は、トリミングされたアプリでは構成バインディングのソースジェネレーターを自動的に有効にします。SDK 10.0.302 の Microsoft.NET.Sdk.FrameworkReferenceResolution.targets から抜粋します。

<PropertyGroup Condition="'$(PublishTrimmed)' == 'true' Or '$(PublishAot)' == 'true'">
  <EnableRequestDelegateGenerator Condition="'$(EnableRequestDelegateGenerator)' == ''">true</EnableRequestDelegateGenerator>
  <EnableConfigurationBindingGenerator Condition="'$(EnableConfigurationBindingGenerator)' == ''">true</EnableConfigurationBindingGenerator>
</PropertyGroup>

ジェネレーターは Bind、Get<T>、Configure<T> の呼び出しをコンパイル時にインターセプトします。これによって Native AOT はリフレクションなしでバインドできますが、別の実装であり、プローブでは動作の違いが 4 つ見つかりました。

ケースリフレクションソースジェネレーター
string[] { get; init; }バインドされる黙ってスキップされる
BindNonPublicProperties = trueprivate setter にバインドされるNotSupportedException
int[] への "Ports": [1, "abc", 3][1, 3]InvalidOperationException: Failed to convert configuration value 'abc'
int[] への "Ports": [1, null, 3]InvalidCastException[1, 3]
ImmutableArray<string>[]NullReferenceException

つまり、dotnet run では問題なくバインドされるアプリが、誰かがプロジェクトに <PublishAot>true</PublishAot> を追加した後には違うバインドになることがあります。AOT に移行するなら、Debug でも <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator> を明示的に設定し、テストが本番と同じバインダーを使うようにしてください。ASP.NET Core minimal API で Native AOT を使う では、同時に有効になる他のジェネレーターを扱っています。ファイルベースのアプリ (dotnet run app.cs) は既定で PublishAot=true なので、その方法で書いた簡単なプローブは、#:property PublishAot=false を追加しない限り、すでにジェネレーターで動いています。

知っておく価値のあるその他のケース

自分のオプションクラスをテストする方法

本番と同じジェネレーター設定で、実際の appsettings.json を実際のオプション型にバインドする単体テストを用意しておきましょう。

// .NET 10 / 11, xUnit
[Fact]
public void AppOptions_binds_hosts()
{
    var config = new ConfigurationBuilder()
        .AddJsonFile("appsettings.json")
        .Build();

    var options = config.GetSection("App")
        .Get<AppOptions>(o => o.ErrorOnUnknownConfiguration = true);

    Assert.NotNull(options);
    Assert.Equal(new[] { "a.example", "b.example" }, options.Hosts);
}

環境固有のファイルや環境変数を含むエンドツーエンドのカバレッジには、WebApplicationFactory による統合テスト を使うと、実際のホストから IOptions<AppOptions> を解決できます。

関連記事

出典

Comments

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

< 戻る