Blazor WebAssembly(.NET 8)公開後に発生する「KeyValuePair2」例外の原因と解決策|ILトリミング・AOT・System.Text.Jsonの落とし穴と対処法

Blazor WebAssembly(.NET 8)アプリを発行した直後、JavaScript コンソールに意味不明な KeyValuePair2 を含む例外が出てアプリが即時停止――ローカル開発では再現せず、原因箇所も特定できない。そんな「公開後だけ壊れる」現象を、再現→切り分け→根治まで一気通貫で解説します。

目次

現象の再確認:公開後にだけ出る KeyValuePair2 例外

ホスティング環境へ発行した .NET 8 Blazor WebAssembly アプリで、起動直後にコンソールへ以下が出力され、アプリが停止します。

AggregateException_ctor_DefaultMessage
(ConstructorContainsNullParameterNames,
 System.Collections.Generic.KeyValuePair2[System.String,System.String])

ソース上で KeyValuePair<,> を明示的に使っていないにもかかわらず、例外メッセージには KeyValuePair2 が登場します。これは「アプリのどこかが暗黙に KeyValuePair<string,string> を扱い、かつ コンストラクターのパラメーター名情報が欠落 したために .NET ランタイム(正確には JSON 系やバインド系のリフレクション)がエラーになった」ことを示します。公開後だけ再現するのは、多くの場合 IL トリミング(Linker)や AOT 最適化 の影響です。

なぜ KeyValuePair2 が出るのか:暗黙利用の代表ルート

アプリが暗黙に KeyValuePair<string,string> を使う場面は意外に多く、発行(Release+Trim/AOT)時に初めて破綻します。代表的な経路を整理します。

  • 構成/オプションのバインド:Options<T> の T に IEnumerable<KeyValuePair<string,string>> などが含まれる、あるいは Dictionary<string,string> を内部で KeyValuePair に投影。
  • HTTP/JSON のやり取り:HttpClient.GetFromJsonAsync<List<KeyValuePair<string,string>>> のようなデシリアライズ、あるいは中間 DTO が KeyValuePair をメンバーに持つ。
  • JS 相互運用:JavaScript 側の Map<string,string> / オブジェクトを C# 側で IEnumerable<KeyValuePair<string,string>> として受けている。
  • ログ/タグ:一部ライブラリが内部でタグ/メタデータを KeyValuePair<string,string> として保持し、起動時に構築。
  • 古いパッケージ:.NET 8 対応が甘いバージョンが、トリミング後のメタデータ欠落に耐性がない。

根本原因の正体:トリミングと AOT で失われる「コンストラクターのパラメーター名」

.NET 8(特に WebAssembly)で Release 発行すると、既定で IL トリミングが有効になり、未参照と推定された型やメタデータが削減されます。System.Text.Json や構成バインダーは、パラメーター付きコンストラクターの「パラメーター名」と JSON プロパティ名の対応付けに反射情報を使うため、メタデータが削られると名前照合ができず、メッセージキー ConstructorContainsNullParameterNames を含む例外が発生します。

開発時(Debug)はトリミングが無効または緩い設定のため再現しませんが、公開時(Release)だけ壊れる典型パターンです。

まずやる一次切り分け(5分)

例外が アプリの組み立て段階で出ているのか、ページ/コンポーネントの描画段階で出ているのかを素早く判定します。

// Program.cs
using Microsoft.JSInterop;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.Logging;

var builder = WebAssemblyHostBuilder.CreateDefault(args);
// 省略: 各種サービス登録

builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.SetMinimumLevel(LogLevel.Trace);

var host = builder.Build();

// ここまで到達するか確認(JS コンソールに出る)
var js = host.Services.GetRequiredService();
await js.InvokeVoidAsync("console.log", "Blazor WebAssembly app started successfully!");

await host.RunAsync(); 

上記の ログが出れば「ページ/コンポーネント側」、出なければ 「builder.Build() 以前(DI/構成バインド/ライブラリ初期化)」 で落ちています。

判定次に見る場所典型原因
ログが出ないDI 登録、Configure<TOptions>、AddXxx() の内部構成バインドで KeyValuePair を含む型を生成
ログが出る最初に表示されるレイアウト/ページの OnInitialized[Async]/OnParametersSet[Async]HttpClient の JSON 受信や JS 相互運用で KeyValuePair をデシリアライズ

詳細診断:ログ強化と「どの型で落ちているか」を掴む

トリミング後はスタックトレースが短くなることがあります。以下を追加し、可能な限り .NET 側の詳細例外をコンソールへ出します。

// Program.cs(既存の Logging 設定に追記)
builder.Logging.AddFilter("Microsoft", LogLevel.Trace);
builder.Logging.AddFilter("System", LogLevel.Trace);

さらに、JSON デシリアライズの箇所に捕捉ログを仕込みます(疑わしい場所を網羅)。

try
{
    // 疑わしい処理例:JSON 受信
    var pairs = await http.GetFromJsonAsync&lt;List&lt;KeyValuePair&lt;string,string&gt;&gt;&gt;("api/pairs");
    Console.WriteLine($"pairs.Count = {pairs?.Count}");
}
catch (Exception ex)
{
    Console.Error.WriteLine("[JSON Deserialize] " + ex);
    throw;
}

「早期に塞ぐ」3 つのアプローチ(推奨順)

1) 設計を置き換える:KeyValuePair を DTO/Dictionary に置換

最も安全で副作用が少ないのは、公開境界(HTTP/JS/Options)で KeyValuePair を使わないことです。以下のように DTO または Dictionary<string,string> に置き換えます。

// NG: 公開境界に IEnumerable<KeyValuePair<string,string>> を使う
public record BadConfig
{
    public IEnumerable<KeyValuePair<string,string>> Items { get; init; } = [];
}

// OK: DTO または Dictionary に変更
public record PairDto(string Key, string Value);

public record GoodConfig
{
public List Items { get; init; } = [];
// もしくは public Dictionary Items { get; init; } = new();
} 

2) System.Text.Json のソースジェネレーターを導入(反射排除)

既存の型を大きく変えられない場合は、JSON の型メタデータをコンパイル時に生成し、トリミングに耐性を持たせます。

// JsonContext.cs
using System.Text.Json.Serialization;

[JsonSourceGenerationOptions(WriteIndented = false)]
[JsonSerializable(typeof(KeyValuePair))]
[JsonSerializable(typeof(List>))]
[JsonSerializable(typeof(Dictionary))]
internal partial class AppJsonContext : JsonSerializerContext
{
} 
// 使い方例(HttpClient 拡張)
var data = await http.GetFromJsonAsync(
    "api/pairs",
    AppJsonContext.Default.ListKeyValuePairStringString);

.NET 8 では JsonTypeInfoResolver 連結も可能です。既存の JsonSerializerOptions に差し込む場合は以下。

using System.Text.Json.Serialization.Metadata;

var options = new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)
{
TypeInfoResolver = JsonTypeInfoResolver.Combine(
AppJsonContext.Default
)
};

// 普通のジェネリック API でも options を渡せば生成済みメタデータが使われる
var x = System.Text.Json.JsonSerializer.Deserialize>>(json, options); 

3) 一時回避:トリミング/ AOT を無効化(根治ではない)

原因箇所が特定できるまでの一時回避として、トリミングや AOT を止める方法もあります。サイズ/起動時間は悪化しますが、公開停止の回避には有効です。

&lt;!-- .csproj --&gt;
&lt;PropertyGroup&gt;
  &lt;PublishTrimmed&gt;false&lt;/PublishTrimmed&gt;
  &lt;RunAOTCompilation&gt;false&lt;/RunAOTCompilation&gt;
&lt;/PropertyGroup&gt;

再発行後に例外が消えるなら、やはりトリミング/反射が原因です。恒久対応(1 or 2)へ移行してください。

あなたのケースを特定する再現用ミニテスト

アプリとは独立に、以下の ミニ再現 を Release+Trim で実行すると、同じ種の例外が出ることを確認できます。

using System.Text.Json;

// Debug では通り、Publish(Release+Trim)で落ちることがある
var json = "[{"Key":"A","Value":"1"}]";
var list = JsonSerializer.Deserialize>>(json);
Console.WriteLine(list?.Count); 

ページ/コンポーネント側で落ちる場合の定番修正

  • HTTP 受信 DTO を変更:List<PairDto> または Dictionary<string,string> に差し替え。API 側も同様に変更。
  • JS 相互運用:JS 側の Map を Object(プレーン)へ変換して送る。C# 側は Dictionary<string,string> で受ける。
  • ログ/タグ:カスタムロガーで KeyValuePair 配列を構築している場合は DTO に置換。

DI/構成バインドで落ちる場合の定番修正

起動時(builder.Build() 直前/直後)に落ちるときは、以下を重点確認します。

  • Options パターン:Configure<TOptions>/Bind で KeyValuePair を含む型を使っていないか。
    → DTO/Dictionary に置換、または バインド対象型から KeyValuePair を排除。
  • 古いミドルウェア/認証:旧版が内部で KeyValuePair の反射に依存。
    → .NET 8 対応版へ更新。更新できない場合は トリミング除外 をピンポイント適用。

トリミング除外の最小化:rd.xml(Linker 設定)例

やむを得ずトリミングを部分的に止める場合は、対象アセンブリ/型をできるだけ狭く指定します。以下は「自分の DTO に含まれるコンストラクター情報」を守る例です(BCL の KeyValuePair ではなく、あなたの型を指定するのがコツ)。

&lt;!-- LinkerDescriptor.xml --&gt;
&lt;linker&gt;
  &lt;assembly fullname="YourApp"&gt;
    &lt;type fullname="YourApp.Models.PairDto" preserve="all" /&gt;
    &lt;type fullname="YourApp.Models.GoodConfig" preserve="all" /&gt;
  &lt;/assembly&gt;
&lt;/linker&gt;
&lt;!-- .csproj --&gt;
&lt;ItemGroup&gt;
  &lt;TrimmerRootDescriptorFiles Include="LinkerDescriptor.xml" /&gt;
&lt;/ItemGroup&gt;

ポイントは「フレームワーク型を丸ごと守ろうとしない」こと。副作用が大きく、サイズ/最適化に悪影響が出ます。

キャッシュ/配信の罠:Service Worker と CDN を同時に疑う

公開直後の「環境 A では落ちるが B では落ちない」は、古いアセットがキャッシュから供給されている可能性が高いです。

  • Service Worker:service-worker.published.js を強制更新。キャッシュ名にバージョンを含める、旧キャッシュを activate で削除。
  • CDN:_framework/*.dll と blazor.boot.json の TTL を短くする/パージ。ファイル名ハッシュ(fingerprint)が変わるように発行。
  • 検証:ブラウザの「更新を常にネットワークから」に切り替え、DevTools で Service Worker を一時停止して動作確認。

ゼロダウンタイムのための「公開前セルフチェック」

チェック方法合格基準
Release+Trim での実行dotnet publish -c Release でローカル配信(静的サーバ)し実機検証コンソールに例外が出ない
Service Worker 無効時の確認DevTools > Application > Service Workers: 「Bypass for network」ネットワークから最新アセットが供給されて動く
依存パッケージの互換性.NET 8 対応版へ更新、古い記述の除去既知のトリミング非対応を排除
JSON ソース生成の採用主要 DTO/集合型を [JsonSerializable] に登録反射未使用でデシリアライズ可能

実戦サンプル:失敗コードと修正版

失敗しやすいコード(公開後)

// API 側
[HttpGet("pairs")]
public IEnumerable<KeyValuePair<string,string>> Get() => new[]
{
    new KeyValuePair<string,string>("A","1"),
    new KeyValuePair<string,string>("B","2")
};

// WASM 側
var pairs = await http.GetFromJsonAsync>>("api/pairs");
// Release+Trim にすると ConstructorContainsNullParameterNames で落ちることがある 

修正版(DTO 置換)

// 共有 DTO
public record PairDto(string Key, string Value);

// API 側
[HttpGet("pairs")]
public IEnumerable Get() => new[] { new PairDto("A","1"), new PairDto("B","2") };

// WASM 側
var pairs = await http.GetFromJsonAsync>("api/pairs"); 

修正版(JSON ソース生成)

// API 側はそのまま(KeyValuePair のままでもよい)
var pairs = await http.GetFromJsonAsync(
    "api/pairs",
    AppJsonContext.Default.ListKeyValuePairStringString);

「自分は KeyValuePair を書いていない」の正体

アプリのソースを全文検索しても KeyValuePair<,> が出ないのに例外が出る――よくあるのは以下です。

  • 拡張メソッドの内部で使用:GetFromJsonAsync などの内部実装で KeyValuePair をターゲットにしている。
  • ジェネリック型引数の展開先:T が Dictionary<string,string> のとき、内部で KeyValuePair<string,string> の列挙として処理される。
  • 外部ライブラリ:起動時の初期化で KeyValuePair ベースの設定テーブルを構築。

このため、現象発生地点のログを見つけ、その直前/直後のデータ型を観察するのが近道です。

公開後トラブルでも「即復旧」する運用 Tips

  • フェイルセーフな Service Worker:致命例外検知時に clients.claim() とキャッシュクリアを促す仕組みを用意。
  • エラーバナー:Program.cs のトップで try-catch し、例外時は軽量な HTML を document.body に直書きしてユーザーへ告知。
  • フィーチャートグル:トリミング影響が濃い箇所(JSON/Options)をトグルで切替できるように設計しておく。

提供チェックリスト(実装/運用の両輪)

項目具体策狙い
例外発生位置の切り分けbuilder.Build() 後の JS ログ/最初のページの疑わしい処理へ try-catch ログ前半(DI)/後半(UI)の大枠特定
暗黙の KeyValuePair 検索Options/JSON/JS 相互運用/ライブラリ初期化の型を点検問題の型を候補として列挙
詳細ログ出力builder.Logging.SetMinimumLevel(LogLevel.Trace)スタック/データの観測可能化
IL トリミング検証PublishTrimmed=false または ソース生成導入トリミング起因の切り分け
キャッシュ/配信確認Service Worker/ CDN のパージ、DevTools でバイパス古いバイナリ混在の排除

よくある質問(FAQ)

Q. Debug でも再現させたい

A. .csproj で PublishTrimmed=true にし、ローカルで dotnet publish -c Release や、静的サーバから配信して検証します。Service Worker を停止し、常にネットワークから読み込む設定で確認してください。

Q. どうして KeyValuePair だけ特別に壊れやすいの?

A. 基本的には 「パラメーター付きコンストラクターへの名前マッピング」 を使う型すべてが影響を受け得ます。KeyValuePair は BCL 由来でコード生成による最適化/縮約の対象になりやすく、トリミング後に名前情報が欠落するパスへ入りやすいのが理由です。

Q. rd.xml で KeyValuePair のメタデータを守れば直る?

A. フレームワーク型を大きく守ると副作用が大きく、ビルドサイズ/起動時間を悪化させます。根治としては DTO/Dictionary へ置換 or JSON ソース生成 を推奨します。

Q. どの箇所が KeyValuePair を使っているか見つけにくい

A. 「疑いの強い箇所だけ」周辺に try-catch と型名/JSON をログ出力するのが現実的です。HTTP/JS/Options 初期化の 3 箇所を重点的に監視しましょう。

最終チェック:公開前にここだけは確認

  • Release+Trim でアプリを実行し、起動直後のコンソールがクリーンである。
  • 主要 DTO を [JsonSerializable] に登録済み。KeyValuePair を公開境界で使用していない。
  • 依存ライブラリは .NET 8 対応版へ更新済み。
  • Service Worker/ CDN のキャッシュ戦略を見直し、ロールバック/ホットフィックス手段を準備。

まとめ

この例外は「KeyValuePair<,> を受け取るコンストラクターに、必要な『パラメーター名』メタデータが見つからない」ことが本質です。公開時にだけ再現するのは、IL トリミングや AOT の最適化で反射用メタデータが削られるからです。対処としては、

  1. 設計修正(DTO/Dictionary へ置換)で KeyValuePair を公開境界から排除する。
  2. JSON ソースジェネレーターで型メタデータを事前生成し、反射依存を断つ。
  3. 一時回避としてトリミング/AOT を無効化し、復旧を優先してから恒久対応へ。

加えて、「どこで落ちているか」 を builder.Build() 前後で切り分け、ログで「どの型がデシリアライズされているか」を観測できれば、原因特定は一気に進みます。キャッシュ/配信の罠も同時に疑い、最新版のアセットが確実に届いている状態で評価しましょう。これらを実施すれば、KeyValuePair2 例外で公開後にアプリが停止する問題は再現・診断・根治まで確実に到達できます。


付録:記事内の「すぐ使える」コード断片まとめ

  • 起動位置の切り分け(Program.cs に JS ログ)
var host = builder.Build();
var js = host.Services.GetRequiredService&lt;IJSRuntime&gt;();
await js.InvokeVoidAsync("console.log", "Blazor WebAssembly app started successfully!");
await host.RunAsync();
  • ログ強化
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.SetMinimumLevel(LogLevel.Trace);
  • DTO 置換の基本形
public record PairDto(string Key, string Value);
public record GoodConfig { public List&lt;PairDto&gt; Items { get; init; } = []; }
  • JSON ソース生成
[JsonSerializable(typeof(List&lt;KeyValuePair&lt;string,string&gt;&gt;))]
internal partial class AppJsonContext : JsonSerializerContext {}
  • 一時回避(トリミング/AOT OFF)
&lt;PublishTrimmed&gt;false&lt;/PublishTrimmed&gt;
&lt;RunAOTCompilation&gt;false&lt;/RunAOTCompilation&gt;

チェックリスト・簡易版

ステップ具体的な対応目的
例外発生位置の切り分けbuilder.Build() 直後に IJSRuntime でコンソール出力発生箇所を前半/後半で特定
暗黙の KeyValuePair 使用箇所の確認構成バインド/依存性注入/HTTP JSON/JS 相互運用/古いライブラリを点検null を渡した経路の洗い出し
詳細ログを出力builder.Logging.SetMinimumLevel(LogLevel.Trace) と周辺 try-catchスタックトレースとデータ観測
IL トリミング影響の検証PublishTrimmed=false にして再発行、または JSON ソース生成生成コードの欠落有無を確認
キャッシュ/CDN クリアService Worker/ CDN をパージし最新アセット配信古いバイナリの混在排除

以上の流れで、公開後にだけ現れる難解な KeyValuePair2 例外の正体を明らかにし、堅牢な Blazor WebAssembly 運用へつなげてください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次