rksoftware

Visual Studio とか C# とかが好きです

C# で定義が未知の Json を扱う (.NET Framework / .NET Core) まとめ

以前に書いた一連の記事をまとめた記事です。

C# で定義が未知の Json を扱う (.NET Framework / JObject) - rksoftware
C# で定義が未知の Json を扱う (.NET Framework / ExpandoObject) - rksoftware
C# で定義が未知の Json を扱う (.NET Core / System.Text.Json) - rksoftware
C# で定義が未知の Json を扱う (.NET Core / System.Text.Json / System.Dynamic.ExpandoObject) - rksoftware

■ 【過去】 .NET Framework 時代

Json を扱う際には .NET Framework 時代には Json.NET というライブラリがよく使われていました。このライブラリの Newtonsoft.Json.JsonConvert.DeserializeObject メソッドで未知の Json を扱うことができました。
メソッドの定義は次のようになっています。

public static object? DeserializeObject(string value);

使う際には次のように dynamic 型変数に代入して使っていた方も多いのではないでしょうか?

dynamic document = Newtonsoft.Json.JsonConvert.DeserializeObject(
    @"{ ""prop1"": ""value1"", ""prop2"": ""value2""}");

Console.WriteLine(document.prop1);  // value1 と出力される
Console.WriteLine(document.prop2);  // value2 と出力される

実際にはこの際、DeserializeObject からの戻りは何だろうと次のコードで検証。

dynamic document = Newtonsoft.Json.JsonConvert.DeserializeObject(
    @"{ ""prop1"": ""value1"", ""prop2"": ""value2""}");

Console.WriteLine(((object)document).GetType().FullName);  // Newtonsoft.Json.Linq.JObject

Newtonsoft.Json.Linq.JObject になっていました。

プロパティを表示する

Newtonsoft.Json.Linq.JObjectIDictionary<string, JToken?> を実装しているので、Key と Value を foreach で回せます。

Newtonsoft.Json.Linq.JObject document = 
    (Newtonsoft.Json.Linq.JObject)Newtonsoft.Json.JsonConvert.DeserializeObject(
        @"{ ""prop1"": ""value1"", ""prop2"": ""value2""}");

foreach (var m in document)
    Console.WriteLine($"key:{m.Key}, Value:{m.Value}");

// key:prop1, Value:value1
// key:prop2, Value:value2
// と出力される

プロパティを追加する

IDictionaryAdd メソッドでプロパティを追加できます。

Newtonsoft.Json.Linq.JObject document = 
    (Newtonsoft.Json.Linq.JObject)Newtonsoft.Json.JsonConvert.DeserializeObject(
        @"{ ""prop1"": ""value1"", ""prop2"": ""value2""}");

((IDictionary<string, Newtonsoft.Json.Linq.JToken?>)document).Add("prop3", "value3");

foreach (var m in document)
    Console.WriteLine($"key:{m.Key}, Value:{m.Value}");

Console.WriteLine($"key:prop3, Value:{((dynamic)document).prop3}");

// key:prop1, Value:value1
// key:prop2, Value:value2
// key:prop3, Value:value3
// key:prop3, Value:value3
// と出力される

dynamic 型にして次のようにしてもできます。

((dynamic)document).prop3 = "value3";

プロパティを削除する

IDictionaryRemove メソッドでプロパティを削除できます。

Newtonsoft.Json.Linq.JObject document = 
    (Newtonsoft.Json.Linq.JObject)Newtonsoft.Json.JsonConvert.DeserializeObject(
        @"{ ""prop1"": ""value1"", ""prop2"": ""value2""}");

((IDictionary<string, Newtonsoft.Json.Linq.JToken?>)document).Remove("prop1");

foreach (var m in document)
    Console.WriteLine($"key:{m.Key}, Value:{m.Value}");

// key:prop2, Value:value2
// と出力される

IDictionary<string, Newtonsoft.Json.Linq.JToken?> を実装しているので特にキャストしなくても OK です。

document.Remove("prop1");

■ System.Dynamic.ExpandoObject を使ってみる

C# には実は dynamic の中身として使う System.Dynamic.ExpandoObject クラスがいます。ここからははこの型を使ってみましょう。

型の指定、型の確認

dynamic document = Newtonsoft.Json.JsonConvert.DeserializeObject<System.Dynamic.ExpandoObject>(
    @"{ ""prop1"": ""value1"", ""prop2"": ""value2""}");
Console.WriteLine(((object)document).GetType().FullName);  // System.Dynamic.ExpandoObject

System.Dynamic.ExpandoObject になりました。

プロパティを表示する

普通に dynamic っぽく。

dynamic document = Newtonsoft.Json.JsonConvert.DeserializeObject<System.Dynamic.ExpandoObject>(
    @"{ ""prop1"": ""value1"", ""prop2"": ""value2""}");

Console.WriteLine(document.prop1);  // value1 と出力される
Console.WriteLine(document.prop2);  // value2 と出力される

System.Dynamic.ExpandoObjectIDictionary<string, object> を実装しているので、Key と Value を foreach で回せます。

System.Dynamic.ExpandoObject document = Newtonsoft.Json.JsonConvert.DeserializeObject<System.Dynamic.ExpandoObject>(
    @"{ ""prop1"": ""value1"", ""prop2"": ""value2""}");

foreach (var m in document)
    Console.WriteLine($"key:{m.Key}, Value:{m.Value}");

// key:prop1, Value:value1
// key:prop2, Value:value2
// と出力される

プロパティを追加する

IDictionaryAdd メソッドでプロパティを追加できます。

System.Dynamic.ExpandoObject document = Newtonsoft.Json.JsonConvert.DeserializeObject<System.Dynamic.ExpandoObject>(
    @"{ ""prop1"": ""value1"", ""prop2"": ""value2""}");

((IDictionary<string, object>)document).Add("prop3", "value3");

foreach (var m in document)
    Console.WriteLine($"key:{m.Key}, Value:{m.Value}");

Console.WriteLine($"key:prop3, Value:{((dynamic)document).prop3}");
// key:prop1, Value:value1
// key:prop2, Value:value2
// key:prop3, Value:value3
// key:prop3, Value:value3
// と出力される

dynamic 型にして次のようにしてもできます。

((dynamic)document).prop3 = "value3";

プロパティを削除する

IDictionaryRemove メソッドでプロパティを削除できます。

System.Dynamic.ExpandoObject document = Newtonsoft.Json.JsonConvert.DeserializeObject<System.Dynamic.ExpandoObject>(
    @"{ ""prop1"": ""value1"", ""prop2"": ""value2""}");

((IDictionary<string, object>)document).Remove("prop1");

foreach (var m in document)
    Console.WriteLine($"key:{m.Key}, Value:{m.Value}");

// key:prop2, Value:value2
// と出力される

IDictionary<string, object> を実装しているので特にキャストしなくても OK です。

document.Remove("prop1", out var _);

■ 現行世代の Json API / System.Text.Json

さて、これまでは .NET Framework 時代の話をしてきました。今の時代 (.NET Core 3 以降) は System.Text.Json を使用します。
補足 Json.NET は .NET Core でも使えます。パフォーマンスは System.Text.Json に劣るとのことですが使い勝手としてまだまだ Json.NET にメリットがある場面はありそうです

ここからは System.Text.Json

  • System.Text.Json.JsonDocument.Parse メソッドによる System.Text.Json.JsonDocument で扱う
  • TValue System.Text.Json.JsonSerializer.Deserialize<TValue> メソッドで System.Dynamic.ExpandoObject を扱う

ことで未知の Json を扱ってみましょう。
これまでと同じようなことを System.Text.Json で試していきます。

■ System.Text.Json.JsonDocument

メソッドの定義は次のようになっています。

public static JsonDocument Parse(string json, JsonDocumentOptions options = default)

とりあえず以前の記事のように、dynamic 型変数に代入して使ってみましょう。

dynamic の中身

using dynamic document = System.Text.Json.JsonDocument.Parse(
    @"{ ""prop1"": ""value1"", ""prop2"": ""value2""}");

Console.WriteLine(((object)document).GetType().FullName);  // System.Text.Json.JsonDocument

中身の型は当たり前ですが、System.Text.Json.JsonDocument です。

プロパティ

プロパティを取り出そうとしてみましょう

using dynamic document = System.Text.Json.JsonDocument.Parse(
    @"{ ""prop1"": ""value1"", ""prop2"": ""value2""}");

// 実行時エラー
// Microsoft.CSharp.RuntimeBinder.RuntimeBinderException: ''System.Text.Json.JsonDocument' does not contain a definition for 'prop1''
Console.WriteLine(document.prop1);
Console.WriteLine(document.prop2);

残念ながら、実行時に例外が発生します。

Microsoft.CSharp.RuntimeBinder.RuntimeBinderException: ''System.Text.Json.JsonDocument' does not contain a definition for 'prop1''

プロパティは次のようにして取り出せます。

System.Text.Json.JsonDocument document = System.Text.Json.JsonDocument.Parse(
    @"{ ""prop1"": ""value1"", ""prop2"": ""value2""}");

Console.WriteLine(document.RootElement.GetProperty("prop1").GetRawText());  // "value1" と出力される
Console.WriteLine(document.RootElement.GetProperty("prop2").GetRawText());  // "value2" と出力される
Console.WriteLine(document.RootElement.GetProperty("prop1").GetString());   // value1 と出力される
Console.WriteLine(document.RootElement.GetProperty("prop2").GetString());   // value2 と出力される

■ System.Text.Json.JsonSerializer.Deserialize

最初に結論を出しておくと、Json に階層化された構造があると、嬉しい結果になりません。その詳細は後述します。

メソッドの定義は次のようになっています。

public static TValue Deserialize<TValue>(string json, JsonSerializerOptions options = null);

とりあえず以前の記事のように、dynamic 型変数に代入して使ってみましょう。

dynamic の中身

もう敢えて確認する必要もないですが、System.Dynamic.ExpandoObject です。

dynamic document = System.Text.Json.JsonSerializer.Deserialize<System.Dynamic.ExpandoObject>(
    @"{ ""prop1"": ""value1"", ""prop2"": ""value2""}");

Console.WriteLine(document.GetType().FullName);  // System.Dynamic.ExpandoObject

プロパティを表示する

普通に dynamic っぽく。

dynamic document = System.Text.Json.JsonSerializer.Deserialize<System.Dynamic.ExpandoObject>(
    @"{ ""prop1"": ""value1"", ""prop2"": ""value2""}");

Console.WriteLine(document.prop1);  // value1 と出力される
Console.WriteLine(document.prop2);  // value2 と出力される

System.Dynamic.ExpandoObjectIDictionary<string, object> を実装しているので、Key と Value を foreach で回せます。

dynamic document = System.Text.Json.JsonSerializer.Deserialize<System.Dynamic.ExpandoObject>(
    @"{ ""prop1"": ""value1"", ""prop2"": ""value2""}");

foreach (var m in document)
    Console.WriteLine($"key:{m.Key}, Value:{m.Value}");

// key:prop1, Value:value1
// key:prop2, Value:value2
// と出力される

プロパティを追加する

IDictionaryAdd メソッドでプロパティを追加できます。

dynamic document = System.Text.Json.JsonSerializer.Deserialize<System.Dynamic.ExpandoObject>(
    @"{ ""prop1"": ""value1"", ""prop2"": ""value2""}");

((IDictionary<string, object>)document).Add("prop3", "value3");

foreach (var m in document)
    Console.WriteLine($"key:{m.Key}, Value:{m.Value}");

Console.WriteLine($"key:prop3, Value:{((dynamic)document).prop3}");
// key:prop1, Value:value1
// key:prop2, Value:value2
// key:prop3, Value:value3
// key:prop3, Value:value3
// と出力される

dynamic 型にして次のようにしてもできます。

((dynamic)document).prop3 = "value3";

プロパティを削除する

IDictionaryRemove メソッドでプロパティを削除できます。

System.Dynamic.ExpandoObject document = System.Text.Json.JsonSerializer.Deserialize<System.Dynamic.ExpandoObject>(
    @"{ ""prop1"": ""value1"", ""prop2"": ""value2""}");

((IDictionary<string, object>)document).Remove("prop1");

foreach (var m in document)
    Console.WriteLine($"key:{m.Key}, Value:{m.Value}");

// key:prop2, Value:value2
// と出力される

IDictionary<string, object> を実装しているので特にキャストしなくても OK です。

document.Remove("prop1", out var _);

■ System.Text.Json.JsonSerializer.Deserialize<System.Dynamic.ExpandoObject> の嬉しくないところ

Json に階層化された構造があると、嬉しい結果になりません。

dynamic document = System.Text.Json.JsonSerializer.Deserialize<System.Dynamic.ExpandoObject>(
    @"{ ""prop1"": ""a"", ""prop2"": 1, ""prop3"": { ""p31"":31 , ""p32"":32 }}");

// 実行時エラー
// Microsoft.CSharp.RuntimeBinder.RuntimeBinderException: ''System.Text.Json.JsonElement' does not contain a definition for 'p31''
Console.WriteLine(document.prop3.p31);

実行時に例外が発生します。

Microsoft.CSharp.RuntimeBinder.RuntimeBinderException: ''System.Text.Json.JsonElement' does not contain a definition for 'p31''

階層化構造の子の要素も System.Dynamic.ExpandoObject になってくれると嬉しいのですが、System.Text.Json.JsonElement になっています。もじ p31 プロパティの値が欲しければ次のような感じです。

dynamic document = System.Text.Json.JsonSerializer.Deserialize<System.Dynamic.ExpandoObject>(
    @"{ ""prop1"": ""a"", ""prop2"": 1, ""prop3"": { ""p31"":31 , ""p32"":32 }}");

Console.WriteLine(document.prop3.GetProperty("p31").GetRawText());  // 31 と出力される);
Console.WriteLine(document.prop3.GetProperty("p31").GetInt32());    // 31 と出力される);

もう少し丁寧に書くと

dynamic document = System.Text.Json.JsonSerializer.Deserialize<System.Dynamic.ExpandoObject>(
    @"{ ""prop1"": ""a"", ""prop2"": 1, ""prop3"": { ""p31"":31 , ""p32"":32 }}");

Console.WriteLine(document.prop3.GetProperty("p31").TryGetInt32(out int value) ? value : default); // 31 と出力される);

こんな感じです。

■ やっぱり System.Text.Json でも System.Dynamic.ExpandoObject を使いたい

System.Text.Json.JsonSerializer.Deserialize<System.Dynamic.ExpandoObject> は多段に構造化された Json の場合、あまり嬉しくなかったので、雑に JsonDucument で デシリアライズした後に ExpandoObject に変えるコードを書いてみました。

### コンバートするコード

static System.Dynamic.ExpandoObject Parse(string json)
{
    using var document = System.Text.Json.JsonDocument.Parse(json);
    return toExpandoObject(document.RootElement);

    static object propertyValue(System.Text.Json.JsonElement elm) =>
        elm.ValueKind switch
        {
            System.Text.Json.JsonValueKind.Null => null,
            System.Text.Json.JsonValueKind.Number => elm.GetDecimal(),
            System.Text.Json.JsonValueKind.String => elm.GetString(),
            System.Text.Json.JsonValueKind.False => false,
            System.Text.Json.JsonValueKind.True => true,
            System.Text.Json.JsonValueKind.Array => elm.EnumerateArray().Select(m => propertyValue(m)).ToArray(),
            _ => toExpandoObject(elm),
        };

    static System.Dynamic.ExpandoObject toExpandoObject(System.Text.Json.JsonElement elm) =>
        elm.EnumerateObject()
        .Aggregate(
            new System.Dynamic.ExpandoObject(),
            (exo, prop) => { ((IDictionary<string, object>)exo).Add(prop.Name, propertyValue(prop.Value)); return exo; });
}

理屈は、まあ普通の C# コードなので読んでもらえば分かると思います。

使い方

メソッドなので普通に使えます。

dynamic expandoObject = Parse(
    @"{ ""prop1"": ""value1"", ""prop2"": 2, ""prop3"": { ""p31"": 31 , ""p32"": [1, ""2"", { ""p321"": ""value321"", ""p322"": ""value322"" } ] }}");

試しに、プロパティを追加したり削除したりなどして、シリアライズして結果を見てみましょう。

dynamic expandoObject = Parse(
    @"{ ""prop1"": ""value1"", ""prop2"": 2, ""prop3"": { ""p31"": 31 , ""p32"": [1, ""2"", { ""p321"": ""value321"", ""p322"": ""value322"" } ] }}");

// プロパティを追加してみる
expandoObject.prop3.p33 = 33;

// プロパティを削除してみる
((IDictionary<string, object>)expandoObject.prop3).Remove("p31");

// シリアライズしてみる
Console.WriteLine(
    System.Text.Json.JsonSerializer.Serialize(expandoObject)
);
// {"prop1":"value1","prop2":2,"prop3":{"p32":[1,"2",{"p321":"value321","p322":"value322"}],"p33":33}} と出力される

出力結果

{"prop1":"value1","prop2":2,"prop3":{"p32":[1,"2",{"p321":"value321","p322":"value322"}],"p33":33}}

それっぽく動いているようです。

このコンバートするコードは GitHub にもアップしています。 何か面白い活用のアイデアがあったら教えてください。

でもやっぱり

未知の構造の Json を扱う場合は、Json.NET を使ってしまうのが手っ取り早いかもしれません。