API 通信の監視やキャッシュの検証、E2E テストなどで「受け取った JSON が本当に同じか」を厳密に判定したいことは多いものです。ここでは .NET(ASP.NET/.NET Framework 含む)で、文字列の見た目(空白・順序・フォーマット)に左右されない構造ベースの JSON 等値判定を、Newtonsoft.Json と System.Text.Json の両アプローチで実装し、現場で役立つ設計指針・落とし穴・テストのコツまで具体的に解説します。
JSON の構造比較(文字列比較を使わずに真偽を判定)
課題の整理
次の要件を満たしつつ、2 つの JSON が完全一致かどうかを true/false で返します。
- まず全プロパティ名の集合が一致するかを確認する。
- 一致したら各値(ネスト・配列・型を含む)を比較する。
- いずれかが不一致なら
false、すべて一致ならtrue。 - 単純な文字列比較(== 等)ではなく、JSON をパースして構造的に判定する。
文字列比較が危険な理由
- 空白・改行・インデントの違いで不一致になる(例:minify/pretty-print)。
- プロパティ順序は JSON 仕様上保証されないため、順序差で誤検知する。
- 数値表現(
1vs1.0)や Unicode エスケープ(\u3042vsあ)など、表記は違っても意味が同じケースがある。 - null と欠落を要件次第で同一視したいことがある。
最短で正確:ツリーを再帰比較する
JSON を DOM(ツリー)として読み取り、ノードの型・値・配列要素・オブジェクトのプロパティ集合を辿って再帰的に比較します。以下の 3 つが代表的手段です。
| 手段 | 実装ポイント | 向き・不向き |
|---|---|---|
Newtonsoft.JsonJToken.Parse→JToken.DeepEquals | 最短。2 つの JSON を JToken に読み込み JToken.DeepEquals(t1, t2) を呼ぶだけ。型・値・配列・ネストまで網羅。オブジェクトのプロパティ順序は無視される。 | 導入が容易。 true/false 判定のみならベスト。 |
System.Text.JsonJsonDocument/JsonElement | 自作の再帰比較で柔軟に制御(大小文字・配列順序・数値の許容差など)。 .NET ランタイム組込みで依存軽減。 | オプションが多い現場やパフォーマンス要件がある場合に最適。 |
| 差分ライブラリ(例:JsonDiffPatch 系) | 差分レポートが欲しいときに便利。 等値判定だけならオーバーキル。 | 不一致の詳細を可視化・監査したい要件向け。 |
Newtonsoft.Json で一発比較(推奨の最短ルート)
基本実装
using Newtonsoft.Json.Linq;
public static class JsonEquality
{
public static bool EqualByStructure(string json1, string json2)
{
if (ReferenceEquals(json1, json2)) return true;
if (json1 is null || json2 is null) return false;
// コメントや末尾カンマが来る可能性に備えるなら JsonLoadSettings を用意
var settings = new JsonLoadSettings
{
CommentHandling = CommentHandling.Ignore, // // や /* */ を無視
LineInfoHandling = LineInfoHandling.Ignore
};
var t1 = JToken.Parse(json1, settings);
var t2 = JToken.Parse(json2, settings);
return JToken.DeepEquals(t1, t2);
}
}
JToken.DeepEqualsは、オブジェクトのプロパティ順序を無視して比較します(集合として一致すれば OK)。- 配列は順序を考慮します([1,2] と [2,1] は不一致)。順序不要なら後述の「配列順序を無視する拡張」を参照。
- 値の比較は文字列化に頼らず、型も考慮して行われます。要件により数値の扱いを調整したい場合は「前処理」や「カスタム比較」を足します。
現場で効く前処理(正規化)
「等値の定義」を要件に合わせて、比較前に JSON を正規化(Normalization)するだけで、多くの局面をシンプルに解決できます。
| 要件 | 前処理の例 | ポイント |
|---|---|---|
| null と欠落を同一視 | 各オブジェクトで "key": null を除去してから比較 | 除去後のプロパティ集合が一致すれば OK |
| 数値の表現差(1 vs 1.0)を許容 | 数値ノードを decimal に寄せ、小数点末尾 0 をトリム | 丸めと桁落ちの方針をチームで固定化 |
| 配列の順序を無視 | 配列を集合として比較(マルチセット一致) | O(n²) でも多くの実務で十分。巨大配列は構造ハッシュで最適化 |
| プロパティ名の大小文字を無視 | プロパティ名を小文字化してから比較 | API 契約上の大小文字ポリシーを確認 |
Newtonsoft.Json:配列順序を無視する拡張
前処理で「配列を順序なし集合として比較」する例です。JToken を辿るだけで文字列比較や文字列キー生成に依存しません。
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
public static class JsonSetComparer
{
public static bool EqualIgnoringArrayOrder(string json1, string json2)
{
var t1 = JToken.Parse(json1);
var t2 = JToken.Parse(json2);
return Equal(t1, t2);
}
private static bool Equal(JToken a, JToken b)
{
if (a.Type != b.Type) return false;
switch (a.Type)
{
case JTokenType.Object:
{
var o1 = (JObject)a;
var o2 = (JObject)b;
if (o1.Count != o2.Count) return false;
foreach (var p1 in o1.Properties())
{
var p2 = o2.Property(p1.Name);
if (p2 is null) return false;
if (!Equal(p1.Value, p2.Value)) return false;
}
return true;
}
case JTokenType.Array:
{
var arr1 = (JArray)a;
var arr2 = (JArray)b;
if (arr1.Count != arr2.Count) return false;
var used = new bool[arr2.Count];
foreach (var e1 in arr1)
{
var found = false;
for (int i = 0; i < arr2.Count; i++)
{
if (used[i]) continue;
if (Equal(e1, arr2[i]))
{
used[i] = true;
found = true;
break;
}
}
if (!found) return false;
}
return true;
}
default:
// JValue 間の比較(型を維持したまま比較)
return JToken.DeepEquals(a, b);
}
}
}
数値の許容誤差(1 と 1.0 を同一視したい)
浮動小数点の丸めやフォーマット差をまたいで一致させたい場合は、数値ノードだけを decimal に寄せて比較します。
using System;
using Newtonsoft.Json.Linq;
public static class JsonNumberRelaxed
{
public static bool EqualWithNumberTolerance(string json1, string json2, decimal epsilon = 0m)
{
var t1 = JToken.Parse(json1);
var t2 = JToken.Parse(json2);
return Equal(t1, t2, epsilon);
}
private static bool Equal(JToken a, JToken b, decimal eps)
{
if (a.Type != b.Type)
{
// 片方が整数, 片方が浮動小数でも数値なら許容
if (IsNumber(a) && IsNumber(b))
return DecimalEqual(a, b, eps);
return false;
}
switch (a.Type)
{
case JTokenType.Object:
foreach (var p in ((JObject)a).Properties())
{
var q = ((JObject)b).Property(p.Name);
if (q is null || !Equal(p.Value, q.Value, eps)) return false;
}
return ((JObject)a).Count == ((JObject)b).Count;
case JTokenType.Array:
{
var aa = (JArray)a; var bb = (JArray)b;
if (aa.Count != bb.Count) return false;
for (int i = 0; i < aa.Count; i++)
if (!Equal(aa[i], bb[i], eps)) return false;
return true;
}
case JTokenType.Integer:
case JTokenType.Float:
return DecimalEqual(a, b, eps);
default:
return JToken.DeepEquals(a, b);
}
}
private static bool IsNumber(JToken t) => t.Type == JTokenType.Integer || t.Type == JTokenType.Float;
private static bool DecimalEqual(JToken a, JToken b, decimal eps)
{
decimal ToDec(JToken x)
{
if (x.Type == JTokenType.Integer) return x.Value<decimal>();
if (x.Type == JTokenType.Float) return x.Value<decimal>();
throw new InvalidOperationException();
}
var da = ToDec(a);
var db = ToDec(b);
return Math.Abs(da - db) <= eps;
}
}
System.Text.Json で柔軟に実装する
.NET Framework 4.6.1 以降(.NET Standard 2.0)でも System.Text.Json パッケージを導入すれば利用できます。配列順序や大小文字、数値誤差などの「等値の定義」をパラメータ化して、再利用しやすい比較器を用意します。
オプション付きの汎用比較器
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text.Json;
public sealed class JsonEqualityOptions
{
public bool PropertyNameCaseSensitive { get; init; } = true;
public bool ArrayOrderSensitive { get; init; } = true;
public bool TreatNullAsMissing { get; init; } = false;
public bool RelaxNumberEquality { get; init; } = false;
public double NumberTolerance { get; init; } = 0.0; // 例: 1e-12
public bool TryParseIso8601DateTime { get; init; } = false; // 文字列日時を時刻として比較
}
public static class JsonElementEquality
{
public static bool Equal(string json1, string json2, JsonEqualityOptions? options = null)
{
options ??= new JsonEqualityOptions();
using var d1 = JsonDocument.Parse(json1, new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip });
using var d2 = JsonDocument.Parse(json2, new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip });
return Equal(d1.RootElement, d2.RootElement, options);
}
public static bool Equal(JsonElement x, JsonElement y, JsonEqualityOptions opt)
{
if (x.ValueKind != y.ValueKind)
{
// 数値の型差を許容するモード
if (opt.RelaxNumberEquality && x.ValueKind == JsonValueKind.Number && y.ValueKind == JsonValueKind.Number)
return NumberEqual(x, y, opt);
return false;
}
switch (x.ValueKind)
{
case JsonValueKind.Object:
return ObjectEqual(x, y, opt);
case JsonValueKind.Array:
return ArrayEqual(x, y, opt);
case JsonValueKind.String:
if (opt.TryParseIso8601DateTime &&
DateTimeOffset.TryParse(x.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dx) &&
DateTimeOffset.TryParse(y.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dy))
{
return dx.UtcDateTime == dy.UtcDateTime;
}
return x.GetString() == y.GetString();
case JsonValueKind.Number:
return NumberEqual(x, y, opt);
case JsonValueKind.True:
case JsonValueKind.False:
return x.ValueKind == y.ValueKind;
case JsonValueKind.Null:
return true;
default:
return false;
}
}
private static bool ObjectEqual(JsonElement x, JsonElement y, JsonEqualityOptions opt)
{
var cmp = opt.PropertyNameCaseSensitive ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase;
var dx = new Dictionary<string, JsonElement>(cmp);
var dy = new Dictionary<string, JsonElement>(cmp);
foreach (var p in x.EnumerateObject())
{
if (opt.TreatNullAsMissing && p.Value.ValueKind == JsonValueKind.Null) continue;
dx[p.Name] = p.Value;
}
foreach (var p in y.EnumerateObject())
{
if (opt.TreatNullAsMissing && p.Value.ValueKind == JsonValueKind.Null) continue;
dy[p.Name] = p.Value;
}
if (dx.Count != dy.Count) return false;
foreach (var kv in dx)
{
if (!dy.TryGetValue(kv.Key, out var v2)) return false;
if (!Equal(kv.Value, v2, opt)) return false;
}
return true;
}
private static bool ArrayEqual(JsonElement x, JsonElement y, JsonEqualityOptions opt)
{
var ax = x.EnumerateArray();
var ay = y.EnumerateArray();
int cx = x.GetArrayLength();
int cy = y.GetArrayLength();
if (cx != cy) return false;
if (opt.ArrayOrderSensitive)
{
var ex = x.EnumerateArray();
var ey = y.EnumerateArray();
using var enumeratorX = ex.GetEnumerator();
using var enumeratorY = ey.GetEnumerator();
while (enumeratorX.MoveNext() && enumeratorY.MoveNext())
if (!Equal(enumeratorX.Current, enumeratorY.Current, opt)) return false;
return true;
}
else
{
// 多重集合として比較(O(n²)、実装はシンプル)
var listY = new List<JsonElement>();
foreach (var e in y.EnumerateArray()) listY.Add(e);
foreach (var e in x.EnumerateArray())
{
var idx = listY.FindIndex(t => Equal(e, t, opt));
if (idx < 0) return false;
listY.RemoveAt(idx);
}
return true;
}
}
private static bool NumberEqual(JsonElement x, JsonElement y, JsonEqualityOptions opt)
{
if (!opt.RelaxNumberEquality)
{
// 型差込みで厳密比較(文字列にせず、数値として比較)
// JsonElement は数値のラウンドトリップ保証があるため decimal 優先
if (x.TryGetDecimal(out var dx) && y.TryGetDecimal(out var dy))
return dx == dy;
return x.GetDouble().Equals(y.GetDouble());
}
else
{
// 許容誤差つき
double dx = x.TryGetDouble(out var ddx) ? ddx : (double)x.GetDecimal();
double dy = y.TryGetDouble(out var ddy) ? ddy : (double)y.GetDecimal();
return Math.Abs(dx - dy) <= opt.NumberTolerance;
}
}
}
不一致の「どこ」を知る:最初に食い違う JSONPath を返す
デバッグやログ用に、最初の不一致個所を JSONPath で返すとトラブルシュートが段違いに速くなります。
public static class JsonElementDiff
{
public static bool TryEqual(JsonElement x, JsonElement y, JsonEqualityOptions opt, out string path)
{
path = "$";
return EqualCore(x, y, opt, ref path);
}
private static bool EqualCore(JsonElement x, JsonElement y, JsonEqualityOptions opt, ref string path)
{
if (x.ValueKind != y.ValueKind && !(opt.RelaxNumberEquality && x.ValueKind == JsonValueKind.Number && y.ValueKind == JsonValueKind.Number))
{
return false;
}
switch (x.ValueKind)
{
case JsonValueKind.Object:
{
var cmp = opt.PropertyNameCaseSensitive ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase;
var dx = new Dictionary<string, JsonElement>(cmp);
foreach (var p in x.EnumerateObject())
{
if (opt.TreatNullAsMissing && p.Value.ValueKind == JsonValueKind.Null) continue;
dx[p.Name] = p.Value;
}
var dy = new Dictionary<string, JsonElement>(cmp);
foreach (var p in y.EnumerateObject())
{
if (opt.TreatNullAsMissing && p.Value.ValueKind == JsonValueKind.Null) continue;
dy[p.Name] = p.Value;
}
if (dx.Count != dy.Count) return false;
foreach (var kv in dx)
{
if (!dy.TryGetValue(kv.Key, out var v2))
{
path += $".{kv.Key}";
return false;
}
var subPath = path + "." + kv.Key;
if (!EqualCore(kv.Value, v2, opt, ref subPath))
{
path = subPath;
return false;
}
}
return true;
}
case JsonValueKind.Array:
{
int n = x.GetArrayLength();
if (n != y.GetArrayLength())
{
path += ".length";
return false;
}
if (opt.ArrayOrderSensitive)
{
int i = 0;
var ex = x.EnumerateArray();
var ey = y.EnumerateArray();
using var ix = ex.GetEnumerator();
using var iy = ey.GetEnumerator();
while (ix.MoveNext() && iy.MoveNext())
{
var subPath = path + $"[{i}]";
if (!EqualCore(ix.Current, iy.Current, opt, ref subPath))
{
path = subPath;
return false;
}
i++;
}
return true;
}
else
{
// 順序無視マッチング
var right = new List<JsonElement>();
foreach (var e in y.EnumerateArray()) right.Add(e);
int i = 0;
foreach (var left in x.EnumerateArray())
{
int j = right.FindIndex(r =>
{
var sub = path + $"[{i}]~?"; // 候補
return EqualCore(left, r, opt, ref sub);
});
if (j < 0)
{
path += $"[{i}]";
return false;
}
right.RemoveAt(j);
i++;
}
return true;
}
}
case JsonValueKind.String:
{
var s1 = x.GetString();
var s2 = y.GetString();
if (opt.TryParseIso8601DateTime &&
DateTimeOffset.TryParse(s1, out var d1) &&
DateTimeOffset.TryParse(s2, out var d2))
{
return d1.UtcDateTime == d2.UtcDateTime;
}
return s1 == s2;
}
case JsonValueKind.Number:
return NumberEqual(x, y, opt);
case JsonValueKind.True:
case JsonValueKind.False:
return x.ValueKind == y.ValueKind;
case JsonValueKind.Null:
return true;
default:
return false;
}
}
private static bool NumberEqual(JsonElement x, JsonElement y, JsonEqualityOptions opt)
{
if (!opt.RelaxNumberEquality)
{
if (x.TryGetDecimal(out var dx) && y.TryGetDecimal(out var dy)) return dx == dy;
return x.GetDouble().Equals(y.GetDouble());
}
else
{
double dx = x.TryGetDouble(out var ddx) ? ddx : (double)x.GetDecimal();
double dy = y.TryGetDouble(out var ddy) ? ddy : (double)y.GetDecimal();
return Math.Abs(dx - dy) <= opt.NumberTolerance;
}
}
}
等値の定義を決めるチェックリスト
| 項目 | 選択肢 | 推奨(一般論) |
|---|---|---|
| プロパティ順序 | 無視 / 厳密 | 無視(仕様上順序は意味を持たない) |
| 配列の順序 | 考慮 / 無視(集合) | 要件依存。ID 群なら無視、時間列なら考慮 |
| 数値 | 厳密(型含む) / 許容誤差 | 金融は decimal 厳密、センサー値は誤差許容 |
| プロパティ名の大小文字 | 区別 / 非区別 | 契約に従う。API が一貫しない場合は非区別化も検討 |
| null と欠落 | 区別 / 同一視 | クライアント互換性のため同一視を選ぶ現場多し |
| 日時 | 文字列として比較 / UTC に正規化して比較 | ログ比較は文字列、ビジネスロジックは時刻で比較 |
パフォーマンスとスケーリング
- 時間計算量:木のノード数に線形(O(n))。配列順序無視の O(n²) マッチングは、n が数千でも現場で許容されることが多いですが、巨大配列では構造ハッシュ(各要素をツリー走査して 64bit ハッシュ化→多重集合比較)で O(n) に近づけられます。ハッシュ衝突は理論上あり得るため、最終確認で実比較を入れる二段構えが安全です。
- メモリ:
JToken/JsonDocumentは DOM なので入力サイズに比例。巨大 JSON(数十 MB〜)ではUtf8JsonReaderによるストリーミング比較も検討(ただしオブジェクトの順序無視を実現するには一時的な辞書保持が必要)。 - 深さ制限:悪意あるネストでスタックが溢れないよう、最大深さをオプション化(
JsonDocumentOptions.MaxDepth、JsonLoadSettings)。 - スレッドセーフ:
JsonDocumentはusing範囲内で有効。長期キャッシュは避け、必要に応じてJsonNodeを使います。
ASP.NET(.NET Framework)への組み込み例
using System.Web.Mvc;
public class CompareController : Controller
{
[HttpPost]
public ActionResult EqualJsons()
{
string a = Request.Form["a"];
string b = Request.Form["b"];
bool equals = JsonEquality.EqualByStructure(a, b); // Newtonsoft.Json 版(最短)
return Json(new { equals });
}
}
System.Text.Json 版を WebAPI に
using System.Web.Http;
using System.Text.Json;
public class CompareRequest { public string A { get; set; } public string B { get; set; } }
public class CompareApiController : ApiController
{
[HttpPost]
public IHttpActionResult Post(CompareRequest req)
{
var options = new JsonEqualityOptions
{
ArrayOrderSensitive = false,
PropertyNameCaseSensitive = false,
RelaxNumberEquality = true,
NumberTolerance = 1e-9
};
bool equals = JsonElementEquality.Equal(req.A, req.B, options);
return Ok(new { equals });
}
}
単体テスト例(xUnit)
using Xunit;
public class JsonEqualityTests
{
[Fact]
public void ObjectOrderIsIgnored()
{
string a = "{ \n "x":1, "y":2 }";
string b = "{ "y":2, "x":1 }";
Assert.True(JsonEquality.EqualByStructure(a, b));
}
[Fact]
public void ArrayOrderMattersByDefault()
{
string a = "{ \"arr\": [1,2,3] }";
string b = "{ \"arr\": [3,2,1] }";
Assert.False(JsonEquality.EqualByStructure(a, b));
}
[Fact]
public void ArrayOrderIgnoredWithOption()
{
string a = "[{\"id\":2},{\"id\":1}]";
string b = "[{\"id\":1},{\"id\":2}]";
var options = new JsonEqualityOptions { ArrayOrderSensitive = false };
Assert.True(JsonElementEquality.Equal(a, b, options));
}
[Fact]
public void NumberToleranceWorks()
{
string a = "{ \"v\": 1.000000001 }";
string b = "{ \"v\": 1 }";
var options = new JsonEqualityOptions { RelaxNumberEquality = true, NumberTolerance = 1e-8 };
Assert.True(JsonElementEquality.Equal(a, b, options));
}
[Fact]
public void NullAndMissingAsSame()
{
string a = "{ \"name\": null, \"age\": 20 }";
string b = "{ \"age\": 20 }";
var options = new JsonEqualityOptions { TreatNullAsMissing = true };
Assert.True(JsonElementEquality.Equal(a, b, options));
}
}
よくある落とし穴と対策
- 重複プロパティ:JSON としては非推奨だが、実務で混入することも。Newtonsoft.Json は後勝ちで読み取られます。等値の定義上、前処理で重複検知・警告を入れるのが安全。
- 日時のタイムゾーン差:
Z(UTC)と+09:00の表記差は、文字列比較だと不一致。TryParseIso8601DateTimeオプションで UTC に正規化して比較すると安定します。 - NaN/Infinity:JSON 仕様上の数値ではないため、送受に混ざると比較不能。発生源を抑止するか、文字列として扱う方針を決める。
- 極端に大きな整数:
doubleでは丸めが起きます。金融・ID ではdecimalか文字列で送る契約を徹底。 - Unicode 正規化:全角・合成文字(例:濁点結合)の差異は、アプリ要件により
string.Normalize()で NFC/NFKC に寄せてから比較すると良い場合があります。
差分レポートが欲しいとき
監査ログやデバッグで「何が違うか」を出したい場合、上記の TryEqual(..., out path) で最初の不一致だけを記録するのが軽量で実用的です。網羅的な差分パッチ(Add/Remove/Replace)まで必要なら、JsonDiffPatch 系のライブラリを併用し、判定は構造比較、レポートはパッチで役割分担するのが安定します。
導入手順のメモ
- Newtonsoft.Json:既存の .NET Framework プロジェクトでも NuGet で即導入可能。
JToken.DeepEqualsを呼ぶ最短実装から始める。 - System.Text.Json:.NET Standard 2.0 を満たす .NET Framework(4.6.1+)なら NuGet パッケージで利用可。将来的な .NET 移行を見据えた基盤として有力。
サンプル:実務シナリオ
キャッシュのヒット判定
API レスポンス JSON が前回の快照と構造的に一致すれば、再描画や無駄な通知を抑制。配列順序がビジネスに無関係なら、順序無視の比較でヒット率を上げられます。
受入テスト(期待値 JSON との照合)
期待値ファイルは整形・コメントを含んでも OK。比較は CommentHandling.Skip を指定し、構造比較で安定化。日時や ID の一部を無視したい場合は、比較前に特定キーを削除するフィルタを挟みます。
メッセージの互換性監査
「互換更新」と称してプロパティを追加していないか、削除していないかを機械的にチェック。プロパティ集合の差分を取り、未知プロパティの検知で警告を上げると破壊的変更を早期に防げます。
まとめ
JSON の等値判定は、文字列ではなく構造を見るのが王道です。もっとも手軽なのは Newtonsoft.Json の JToken.DeepEquals。要件に応じた柔軟性が必要なら System.Text.Json でオプション化した再帰比較を実装すれば、プロパティ順序・配列順序・数値誤差・null/欠落・日時など、現場の多様な「等値の定義」をブレなく扱えます。判定だけでなく「どこが違うか」の把握やパフォーマンス最適化も含め、ここで紹介した設計・コードをベースに、自プロジェクトのポリシーとして標準化しておくと保守性が大きく向上します。

コメント