[.NET 6] System.Text.Json.Nodes.JsonNode の一致を判定する
環境
- .NET 6 (LangVersion: C# 11 preview)
- Raw string literals を使い、JSON文字列リテラルを簡単にしています。
.csprojの状態を示します。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
</PropertyGroup>
</Project>
本記事の内容は試作品で、間違いがあるかもしれません。また、後述しますがじきに標準サポートされる可能性があります(参考)。
目的
2つのJsonNode
オブジェクトの一致性を確認したい
今回は JsonNode
を使った場合について考えます。特定のモデルクラスにバインドせず、型が不定の状態で扱います。
何が難しいかといえば、以下のようなケースです。この2つのJSONが同じだと判定してほしいのです。オブジェクト内での順番が違っても問題なく一致と判断することが求められます。
{
"A": 1,
"B": 2
}
{
"B": 2,
"A": 1
}
JsonNode
はParse時の入力JSON文字列での順番をしっかり覚えてくれちゃっており、この点だけ見てもToJsonString
やToString
で得た文字列を比較する手は使えません。
using System.Text.Json.Nodes;
var json1 = """
{
"A": 1,
"B": 2
}
""";
var json2 = """
{
"B": 2,
"A": 1
}
""";
Console.WriteLine(JsonNode.Parse(json1).ToJsonString());
Console.WriteLine(JsonNode.Parse(json2).ToJsonString());
{"A":1,"B":2}
{"B":2,"A":1}
出来合いの比較メソッドが無いか調べましたが、.NET 6時点では見つけられませんでした。あまり自信がありませんが、自作を試みました。
参考: Json.NET (Newtonsoft.Json)での方法
参考までに、みんな大好きJson.NETでの方法を示します。JToken.DeepEquals
で一発です。
using Newtonsoft.Json.Linq;
var json1 = """
{
"A": 1,
"B": 2
}
""";
var json2 = """
{
"B": 2,
"A": 1
}
""";
var jo1 = JObject.Parse(json1);
var jo2 = JObject.Parse(json2);
Console.WriteLine(JToken.DeepEquals(jo1, jo2)); // True
これを System.Text.Json
の JsonNode
ではどうしたらよいのかという話です。テストケースで期待値JSONとの比較を書きたいシーンがしばしばあり、モデルバインドをしたくないときに大変困っています。
dotnet開発側の動き
issueに挙げられていました。
そしてこれを解決するpull requestが出ており、JsonNode.DeepEquals
の姿が!
JsonNode node = JsonNode.Parse("{\"Prop\":{\"NestedProp\":42}}");
JsonNode other = node.DeepClone();
bool same = JsonNode.DeepEquals(node, other); // true
ということで、じきに改善しそうな空気を感じます。本記事は.NET 6のいまいま何とかしのぐ方法の1つを考えるものです。[1]
戦略
JsonNode
はabstractな型で、JSONの仕様に即して具象型が3つあります。
-
JsonNode
-
JsonObject
オブジェクトに相当{}
-
JsonArray
配列に相当[]
-
JsonValue
文字列・数値等の単一の値に相当"hoge"
3.14
false
...
-
つまり、JsonNode
として参照が渡ってきても、実体はこの具象型3種のうちのどれかです。それぞれについて型で分岐を書き、JsonObject
やJsonArray
であれば子の要素をたどって再帰的に一致を確かめることにしました。たどり続けるといつかはJsonValue
に行き着くはずです。
ちなみに、JSONでのnullは、C#でもnullで扱われるようです。JsonValue(null)
のようなもので表されるわけではありません。よって正確には4つの分岐になります。
コード
ここではJsonNode
に生やす拡張メソッドにしています。DeepEquals
が生えます。いろいろとコーナーケースが抜けてそうですが・・・。
public static class JsonNodeExtensions
{
public static bool DeepEquals(this JsonNode? self, JsonNode? other)
{
if (self is JsonArray selfArray && other is JsonArray otherArray)
{
return (selfArray.Count == otherArray.Count) &&
selfArray.Zip(otherArray).All(p => DeepEquals(p.First, p.Second));
}
if (self is JsonObject selfObject && other is JsonObject otherObject)
{
if (selfObject.Count != otherObject.Count)
return false;
foreach (var (key, selfChild) in selfObject)
{
if (!otherObject.TryGetPropertyValue(key, out var otherChild))
return false;
if (!DeepEquals(selfChild, otherChild))
return false;
}
return true;
}
if (self is JsonValue selfValue && other is JsonValue otherValue)
{
var selfJson = self.ToJsonString();
var otherJson = other.ToJsonString();
return selfJson == otherJson;
}
if (self is null && other is null)
{
return true;
}
return false;
}
}
使用例
簡単なサンプル
var json1 = """
{
"A": 1,
"B": 2
}
""";
var json2 = """
{
"B": 2,
"A": 1
}
""";
var node1 = JsonNode.Parse(json1);
var node2 = JsonNode.Parse(json2);
Console.WriteLine(node1.DeepEquals(node2)); // True
少し複雑になっても通ります。
var json1 = """
{
"string": "Hello world!",
"double": 1.2345,
"null": null,
"boolean": false,
"array": [1, 2, 3],
"object": {"a":1, "b":2}
}
""";
var json2 = """
{
"array": [1, 2, 3],
"object": {"a":1, "b":2},
"string": "Hello world!",
"double": 1.2345,
"null": null,
"boolean": false
}
""";
var node1 = JsonNode.Parse(json1);
var node2 = JsonNode.Parse(json2);
Console.WriteLine(node1.DeepEquals(node2)); // True
オブジェクトと異なり、配列要素の順番は一致することが求められます。意図通りです。なお各要素のnameとageを入れ替えるのは問題なくTrueとなります。
var json1 = """
[
{"name": "Alice", "age": 10},
{"name": "Bob", "age": 20},
{"name": "Charlie", "age": 30}
]
""";
var json2 = """
[
{"name": "Charlie", "age": 30},
{"name": "Alice", "age": 10},
{"name": "Bob", "age": 20}
]
""";
var node1 = JsonNode.Parse(json1);
var node2 = JsonNode.Parse(json2);
Console.WriteLine(node1.DeepEquals(node2)); // False
問題点・コメント
-
JsonValue
同士の一致判定が手抜きです。上記実装では、JsonValue.ToJsonString()
の文字列比較としていますが、少なくとも1つ問題がわかっています。JsonValueが数値(Number, C#でいうdouble)の場合です。例えば1E4
と10000
は同じ値を示します[2]が、先に述べたように元のJSON文字列での書き方を大事に覚えてくれてしまうので、ToJsonStringで比較すると異なるという判定になってしまいます。- JsonValueだと、JsonValue.TryGetValue<double>()を行っておいて成功した場合はdouble値としての比較を行う、くらいしか思いつきません。[3]
var node = JsonNode.Parse("{ \"value1\":1E4, \"value2\":10000 }");
// 値を取り出すとC#としては同じ値に見える
Console.WriteLine((double)node["value1"]); // 10000
Console.WriteLine((double)node["value2"]); // 10000
// ToJsonStringの結果は元のJSON文字列を踏襲する
Console.WriteLine(node["value1"].ToJsonString()); // 1E4
Console.WriteLine(node["value2"].ToJsonString()); // 10000
- ちなみに Json.NET (
JToken.DeepEquals
) でもこの例は不一致になります。ちょっと事情は異なりまして、Parse時にプリミティブ型の選択がC#の仕様らしい感じで行われているようで、10000
とあればint、1E+4
や10000.0
とあればdoubleとされている模様です。したがって1E4
と10000.0
なら一致します。これはこれでややこしいですね。何が正解なのかよくわからなくなりました。
var jo1 = JObject.Parse("{ \"value\":1E4 }");
var jo2 = JObject.Parse("{ \"value\":10000 }");
var jo3 = JObject.Parse("{ \"value\":10000.0 }");
Console.WriteLine(jo1.ToString(Formatting.None)); // {"value":10000.0}
Console.WriteLine(jo2.ToString(Formatting.None)); // {"value":10000}
Console.WriteLine(jo3.ToString(Formatting.None)); // {"value":10000.0}
Console.WriteLine(JToken.DeepEquals(jo1, jo2)); // False
Console.WriteLine(JToken.DeepEquals(jo1, jo3)); // True
- 私のJSON比較の動機は前述の通り主にテスト用途です。結局ここで示した自作の比較処理だとまだ足りないのでさらに詰めなければいけませんし、実戦投入ならばテストのテストを書く羽目になってきます。まだJson.NETが手放せない状態です。
Discussion