System.Text.JsonのJsonNodeを使ってみる

# 概要
最近の.NET界隈ではJSONをいじくりまわしたいときはSystem.Text.Jsonを使うのがおすすめのようだ。
たまたま使う機会があったので調べてみたところ、主に以下のことができるみたい。

1. JSON文字列のシリアライズ/デシリアライズ
2. JsonDocumentによる読み取り専用JSON DOMを使った高速なデータアクセス
3. JsonNodeによる読み書き可能なJSON DOMを使ったデータ編集
今回は「3. JsonNodeによる読み書き可能なJSON DOMを使ったデータ編集」についてまとめる。

# JsonNodeとJsonDocument
Microsoft DocsのJsonNodeに関する説明は以下のとおりである。
> `JsonNode`およびその派生クラスを使用すると、変更可能なDOMを作成することができます。
> `JsonNode` DOMは作成後に変更できます。`JsonDocument` DOMは変更できません。

一方JsonDocumentは以下のように説明されている。
> `JsonDocument`を使用すると、`Utf8JsonReader`を使用して読み取り専用DOMを構築することができます。
> `JsonDocument` DOMでは、そのデータにより高速にアクセスできます。

https://docs.microsoft.com/ja-jp/dotnet/standard/serialization/system-text-json-use-dom-utf8jsonreader-utf8jsonwriter?pivots=dotnet-6-0

JSONファイルやJSON文字列をプログラム中に取り込んで解釈したいだけの場合は`JsonDocument`を使うとよいが、
JSON内のデータを編集する場合は`JsonNode`を使うようだ。

`JsonNode`や`JsonDocument`はデータ型を動的に決定した場合、すなわち**事前に型がわかっていない/型を意識したくない場合**に使用するべきだ。
もしJSONデータ内部の型や構造が事前に判明しているのであれば、シリアライズ/デシリアライズを使用したほうが賢明である。
書かないといけないコード量がかなり違うし、たぶん実行速度もシリアライズ/デシリアライズのほうが有利な気がする。

下の姉妹記事も参照してください。

https://qiita.com/TheParkSider/items/7d85f730314dfe642c91

# テスト用データ
会社情報(`companies`)や社員情報(`employees`)からなる謎のデータ。

``` JSON
{
    "version": "1.0.0",
    "description": "企業情報",
    "companies": [
        {
            "name": "A株式会社",
            "address": "東京都○○市",
            "employees": [
                {
                    "name": "鈴木",
                    "age": 21,
                    "president": false
                },
                {
                    "name": "田中",
                    "age": 30,
                    "president": false
                },
                {
                    "name": "高橋",
                    "age": 54,
                    "president": true
                }
            ]
        },
        {
            "name": "株式会社B",
            "address": "大阪府××市",
            "employees": [
                {
                    "name": "上田",
                    "age": 43,
                    "president": true
                },
                {
                    "name": "小沢",
                    "age": 27,
                    "president": false
                }
            ]
        },
        {
            "name": "C Co., Ltd.",
            "address": "愛知県××市",
            "employees": [
                {
                    "name": "平井",
                    "age": 36,
                    "president": true
                }
            ]
        }
    ]
}
```

# Json文字列を読み込んで分析する
`JsonNode`はJSONデータを編集したい場合に使用するべきだが、当然データを読み出すだけの場合も使用できる。
`DisplayJsonNodeRecursively()`を再帰的に呼び出してすべての要素の型名と値を表示してみる。

``` C#
using System;
using System.IO;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Unicode;

namespace JsonNodeTest
{
    internal class Program
    {
        internal static string jsonStr =
@"
{
    ""version"": ""1.0.0"",
    ""description"": ""企業情報"",
    ""companies"": [
        {
            ""name"": ""A株式会社"",
            ""address"": ""東京都○○市"",
            ""employees"": [
                {
                    ""name"": ""鈴木"",
                    ""age"": 21,
                    ""president"": false
                },
                {
                    ""name"": ""田中"",
                    ""age"": 30,
                    ""president"": false
                },
                {
                    ""name"": ""高橋"",
                    ""age"": 54,
                    ""president"": true
                }
            ]
        },
        {
            ""name"": ""株式会社B"",
            ""address"": ""大阪府××市"",
            ""employees"": [
                {
                    ""name"": ""上田"",
                    ""age"": 43,
                    ""president"": true
                },
                {
                    ""name"": ""小沢"",
                    ""age"": 27,
                    ""president"": false
                }
            ]
        },
        {
            ""name"": ""C Co., Ltd."",
            ""address"": ""愛知県××市"",
            ""employees"": [
                {
                    ""name"": ""平井"",
                    ""age"": 36,
                    ""president"": true
                }
            ]
        }
    ]
}
";
        static void DisplayJsonNodeRecursively(JsonNode node, int level = 0)
        {
            // オブジェクトのとき
            if (node is JsonObject)
            {
                string indent = new string('\t', ++level);
                foreach (var child in node.AsObject())
                {
                    // オブジェクトの中身(KeyValuePair<string, JsonNode?>型)を取り出す
                    Console.WriteLine($"{indent}Name: {child.Key}");
                    Console.WriteLine($"{indent}Path: {child.Value.GetPath()}");
                    Console.WriteLine($"{indent}Type: {child.Value.GetType().Name}");

                    // 子のJsonNodeも同様に探索する
                    DisplayJsonNodeRecursively(child.Value, level);
                }
            }

            // 配列のとき
            if (node is JsonArray)
            {
                string indent = new string('\t', ++level);
                foreach (var child in node.AsArray())
                {
                    // オブジェクトの中身(JsonNode?型)を取り出す
                    Console.WriteLine($"{indent}Path: {child.GetPath()}");
                    Console.WriteLine($"{indent}Type: {child.GetType().Name}");

                    // 子のJsonNodeも同様に探索する
                    DisplayJsonNodeRecursively(child, level);
                }
            }

            // それ以外の型
            if (node is JsonValue)
            {
                string indent = new string('\t', level);
                Console.WriteLine($"{indent}Value: {node.AsValue().ToString()}");
            }
        }

        static void Main(string[] args)
        {
            JsonNode node = JsonNode.Parse(jsonStr);

            Console.WriteLine($"Name: root");
            Console.WriteLine($"Path: {node.GetPath()}");
            Console.WriteLine($"Type: {node.GetType().Name}");

            // すべての要素の型名と値を再帰的に表示する
            DisplayJsonNodeRecursively(node);
        }
    }
}

```

出力
```
Name: root
Path: $
Type: JsonObject
        Name: version
        Path: $.version
        Type: JsonValueTrimmable`1
        Value: 1.0.0
        Name: description
        Path: $.description
        Type: JsonValueTrimmable`1
        Value: 企業情報
        Name: companies
        Path: $.companies
        Type: JsonArray
                Path: $.companies[0]
                Type: JsonObject
                        Name: name
                        Path: $.companies[0].name
                        Type: JsonValueTrimmable`1
                        Value: A株式会社
                        Name: address
                        Path: $.companies[0].address
                        Type: JsonValueTrimmable`1
                        Value: 東京都○○市
                        Name: employees
                        Path: $.companies[0].employees
                        Type: JsonArray
                                Path: $.companies[0].employees[0]
                                Type: JsonObject
                                        Name: name
                                        Path: $.companies[0].employees[0].name
                                        Type: JsonValueTrimmable`1
                                        Value: 鈴木
                                        Name: age
                                        Path: $.companies[0].employees[0].age
                                        Type: JsonValueTrimmable`1
                                        Value: 21
                                        Name: president
                                        Path: $.companies[0].employees[0].president
                                        Type: JsonValueTrimmable`1
                                        Value: false
                                Path: $.companies[0].employees[1]
                                Type: JsonObject
                                        Name: name
                                        Path: $.companies[0].employees[1].name
                                        Type: JsonValueTrimmable`1
                                        Value: 田中
                                        Name: age
                                        Path: $.companies[0].employees[1].age
                                        Type: JsonValueTrimmable`1
                                        Value: 30
                                        Name: president
                                        Path: $.companies[0].employees[1].president
                                        Type: JsonValueTrimmable`1
                                        Value: false
                                Path: $.companies[0].employees[2]
                                Type: JsonObject
                                        Name: name
                                        Path: $.companies[0].employees[2].name
                                        Type: JsonValueTrimmable`1
                                        Value: 高橋
                                        Name: age
                                        Path: $.companies[0].employees[2].age
                                        Type: JsonValueTrimmable`1
                                        Value: 54
                                        Name: president
                                        Path: $.companies[0].employees[2].president
                                        Type: JsonValueTrimmable`1
                                        Value: true
                Path: $.companies[1]
                Type: JsonObject
                        Name: name
                        Path: $.companies[1].name
                        Type: JsonValueTrimmable`1
                        Value: 株式会社B
                        Name: address
                        Path: $.companies[1].address
                        Type: JsonValueTrimmable`1
                        Value: 大阪府××市
                        Name: employees
                        Path: $.companies[1].employees
                        Type: JsonArray
                                Path: $.companies[1].employees[0]
                                Type: JsonObject
                                        Name: name
                                        Path: $.companies[1].employees[0].name
                                        Type: JsonValueTrimmable`1
                                        Value: 上田
                                        Name: age
                                        Path: $.companies[1].employees[0].age
                                        Type: JsonValueTrimmable`1
                                        Value: 43
                                        Name: president
                                        Path: $.companies[1].employees[0].president
                                        Type: JsonValueTrimmable`1
                                        Value: true
                                Path: $.companies[1].employees[1]
                                Type: JsonObject
                                        Name: name
                                        Path: $.companies[1].employees[1].name
                                        Type: JsonValueTrimmable`1
                                        Value: 小沢
                                        Name: age
                                        Path: $.companies[1].employees[1].age
                                        Type: JsonValueTrimmable`1
                                        Value: 27
                                        Name: president
                                        Path: $.companies[1].employees[1].president
                                        Type: JsonValueTrimmable`1
                                        Value: false
                Path: $.companies[2]
                Type: JsonObject
                        Name: name
                        Path: $.companies[2].name
                        Type: JsonValueTrimmable`1
                        Value: C Co., Ltd.
                        Name: address
                        Path: $.companies[2].address
                        Type: JsonValueTrimmable`1
                        Value: 愛知県××市
                        Name: employees
                        Path: $.companies[2].employees
                        Type: JsonArray
                                Path: $.companies[2].employees[0]
                                Type: JsonObject
                                        Name: name
                                        Path: $.companies[2].employees[0].name
                                        Type: JsonValueTrimmable`1
                                        Value: 平井
                                        Name: age
                                        Path: $.companies[2].employees[0].age
                                        Type: JsonValueTrimmable`1
                                        Value: 36
                                        Name: president
                                        Path: $.companies[2].employees[0].president
                                        Type: JsonValueTrimmable`1
                                        Value: true
```
[別記事](https://qiita.com/TheParkSider/items/7d85f730314dfe642c91)で説明した`JsonDocument`を使った方法に比べてJson Pathによるパス情報(`GetPath()`)や親情報(`Parent`)を容易に取得できるようになっている。
`JsonDocument`の`ValueKind`のような中身のデータを判別する手段が用意されていないのが気になる。

# JsonNodeにデータを追加・編集・削除する
社員データに以下の編集を加えてみる。
- 社員ID風の一意の値(GUID)を追加する
- 社員の名前の後に"様"をつける
- 社員の年齢情報を削除する

``` C#
(省略)
static void Main(string[] args)
{
    JsonNode node = JsonNode.Parse(jsonStr);

    foreach(JsonObject company in node.AsObject()["companies"].AsArray())
    {
        foreach(JsonObject employee in company["employees"].AsArray())
        {
            employee["id"] = Guid.NewGuid().ToString();
            employee["name"] = employee["name"] + "様";
            employee.Remove("age");
        }
    }

    JsonSerializerOptions options = new JsonSerializerOptions
    {
        // オプションは他にもいろいろ指定できる
        WriteIndented = true,
        Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
    };

    string jsonResult = JsonSerializer.Serialize(node, options);
    Console.WriteLine(jsonResult);
}
(省略)
```

出力
```
{
  "version": "1.0.0",
  "description": "企業情報",
  "companies": [
    {
      "name": "A株式会社",
      "address": "東京都○○市",
      "employees": [
        {
          "name": "鈴木様",
          "president": false,
          "id": "e32a8e2c-37a0-4bbb-a66e-13ee8571bb26"
        },
        {
          "name": "田中様",
          "president": false,
          "id": "44d0f273-40ac-44f9-9117-4b4f442136af"
        },
        {
          "name": "高橋様",
          "president": true,
          "id": "e0a7b7ad-2258-4305-8234-98e737390643"
        }
      ]
    },
    {
      "name": "株式会社B",
      "address": "大阪府××市",
      "employees": [
        {
          "name": "上田様",
          "president": true,
          "id": "edf6e92a-7c3a-46bf-8ba9-9b542e86db90"
        },
        {
          "name": "小沢様",
          "president": false,
          "id": "79b1d80e-42a2-433f-816d-8b64bd8007a7"
        }
      ]
    },
    {
      "name": "C Co., Ltd.",
      "address": "愛知県××市",
      "employees": [
        {
          "name": "平井様",
          "president": true,
          "id": "f59b6f1a-196a-4b91-a778-2316e9f6de98"
        }
      ]
    }
  ]
}
```

# JsonNodeのデータをコピーする
ちょっとハマりポイント。どうやら各`JsonNode`には親情報`Parent`が埋め込まれているため、
異なる親にノードをコピーしたい場合にエラーが発生することがある。

例として「A株式会社」の「鈴木」を「株式会社B」に転職させてみよう。これは問題なくうまくいく。

``` C#
(省略)
static void Main(string[] args)
{
    JsonNode node = JsonNode.Parse(jsonStr);

    var suzuki = node.AsObject()["companies"].AsArray()[0]["employees"][0];
    var employeesOfA = node.AsObject()["companies"].AsArray()[0]["employees"].AsArray();
    var employeesOfB = node.AsObject()["companies"].AsArray()[1]["employees"].AsArray();

    // A株式会社から退職して
    employeesOfA.Remove(suzuki);

    // 株式会社Bに就職する
    employeesOfB.Add(suzuki);

    JsonSerializerOptions options = new JsonSerializerOptions
    {
        // オプションは他にもいろいろ指定できる
        WriteIndented = true,
        Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
    };

    string jsonResult = JsonSerializer.Serialize(node, options);
    Console.WriteLine(jsonResult);
}
```

ところが、株式会社Bに副業として所属する(株式会社Bに社員情報をコピーする)場合はエラーが発生する。
``` C#
(省略)
static void Main(string[] args)
{
    JsonNode node = JsonNode.Parse(jsonStr);

    var suzuki = node.AsObject()["companies"].AsArray()[0]["employees"][0];
    var employeesOfB = node.AsObject()["companies"].AsArray()[1]["employees"].AsArray();

    // ここでエラー！
    // System.InvalidOperationException: 'The node already has a parent.'
    employeesOfB.Add(suzuki);

    JsonSerializerOptions options = new JsonSerializerOptions
    {
        // オプションは他にもいろいろ指定できる
        WriteIndented = true,
        Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
    };

    string jsonResult = JsonSerializer.Serialize(node, options);
    Console.WriteLine(jsonResult);
}
```

回避策は一旦「鈴木」の情報をシリアライズして新たなオブジェクトとして作り直せばよい。
そのために`JsonNode`に新しく`Copy()`の拡張メソッドを追加するとよい。

``` C#
public static class JsonNodeExtensions
{
    // 拡張メソッド
    public static T Copy<T>(this T node) where T : JsonNode => node.Deserialize<T>();
}
(中略)
static void Main(string[] args)
{
    JsonNode node = JsonNode.Parse(jsonStr);

    var suzuki = node.AsObject()["companies"].AsArray()[0]["employees"][0];
    var employeesOfB = node.AsObject()["companies"].AsArray()[1]["employees"].AsArray();

    // 鈴木をいったんコピーする
    employeesOfB.Add(suzuki.Copy());

    JsonSerializerOptions options = new JsonSerializerOptions
    {
        // オプションは他にもいろいろ指定できる
        WriteIndented = true,
        Encoder = JavaScriptEncoder.Create(UnicodeRanges.All)
    };

    string jsonResult = JsonSerializer.Serialize(node, options);
    Console.WriteLine(jsonResult);
}
```

もっといいやり方があれば教えてください！