🥋

[.NET 6] System.Text.Json.Nodes.JsonNode の一致を判定する

2022/07/13に公開

環境

  • .NET 6 (LangVersion: C# 11 preview)

.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文字列での順番をしっかり覚えてくれちゃっており、この点だけ見てもToJsonStringToStringで得た文字列を比較する手は使えません。

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.JsonJsonNode ではどうしたらよいのかという話です。テストケースで期待値JSONとの比較を書きたいシーンがしばしばあり、モデルバインドをしたくないときに大変困っています。

dotnet開発側の動き

issueに挙げられていました。
https://github.com/dotnet/runtime/issues/33388

そしてこれを解決するpull requestが出ており、JsonNode.DeepEquals の姿が!
https://github.com/dotnet/runtime/issues/56592

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として参照が渡ってきても、実体はこの具象型3種のうちのどれかです。それぞれについて型で分岐を書き、JsonObjectJsonArrayであれば子の要素をたどって再帰的に一致を確かめることにしました。たどり続けるといつかは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)の場合です。例えば1E410000は同じ値を示します[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+410000.0とあればdoubleとされている模様です。したがって1E410000.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が手放せない状態です。
脚注
  1. がんばって自作し本記事を書き上げたあとにこれらの動きを知ったのが正直なところです。 ↩︎

  2. JSONでも1E+4や3.14e-20といった指数表記は仕様上妥当です。 ↩︎

  3. やや話がそれますが、もしSystem.Text.Json.JsonElementのほうだとValueKindを見て分岐でき、多少ましな実装にできそうです。 ↩︎

Discussion