System.Text.Json で型が不定のJSONを扱う(.NET)

.NET Framework では JSON を扱うとき、Newtonsoft.Json を使用してきました。.NET になってパフォーマンスやセキュリティ、標準への準拠などの観点から System.Text.Json の使用が推奨されています。
※.NET Standard 2.0 以降, .NET Framework 4.7.2以降, .NET Core 2.0以降でサポート。

System.Text.Json は、Microsoft のドキュメントを見てもわかるように、Newtonsoft.Json の完全互換ではありません。このため、Newtonsoft.Json から System.Text.Json への移行で機械的な入れ替えでは動作しなくなるものがいくつかあります(コンパイルすら通らない)。今回はその中で、型が不定の JSON の扱い方について記載します。

型が決まっている場合

まずはおさらいとして、型が決まっている場合の扱い方です。以下のようにデータモデルになるクラスを作成します。

public class WeatherForecast
{
    public DateTimeOffset Date { get; set; }
    public int TemperatureCelsius { get; set; }
    public string? Summary { get; set; }
    public string? SummaryField;
    public IList<DateTimeOffset>? DatesAvailable { get; set; }
    public Dictionary<string, HighLowTemps>? TemperatureRanges { get; set; }
    public string[]? SummaryWords { get; set; }
}

あとはこれを型引数に与えてシリアライズ/デシリアライズするだけです。

// デシリアライズ
WeatherForecast? weatherForecast = JsonSerializer.Deserialize<WeatherForecast>(jsonString);

// シリアライズ
var options = new JsonSerializerOptions { WriteIndented = true };
string serialized = JsonSerializer.Serialize(weatherForecast, options);

型が決まっていない場合

Newtonsoft.Json では、以下のように扱っていました。型を与えなくてもいい感じに変換してくれます。

// デシリアライズ
var jsonObject = Newtonsoft.Json.JsonConvert.DeserializeObject(jsonString);

// シリアライズ
var serialized = Newtonsoft.Json.JsonConvert.SerializeObject(jsonObject);

System.Text.Json のJsonSerializerには、上記のうち型引数がないDeserializeがありません。

// デシリアライズ(コンパイルエラー)
var jsonObject = System.Text.Json.JsonSerializer.Deserialize(jsonString);

ではどうするか、というとSystem.Text.Json.Nodes.JsonNodeを使います。これで型を与えなくてもいい感じに変換してくれます。

// デシリアライズ
var jsonNode = System.Text.Json.Nodes.JsonNode.Parse(jsonString);

// シリアライズ
var serialized = jsonNode?.ToJsonString();

直感的な操作ができるうえに(AsArray()などを挟めば)Linq でも操作できるためなかなか使い勝手がいいです。

// lang=json
var jsonString = """
{
	"date": "2019-08-01T00:00:00-07:00",
	"temperature_celsius": 25,
	"summary": "hot",
	"dates_available": [
		"2019-08-01T00:00:00-07:00",
		"2019-08-02T00:00:00-07:00"
	],
	"temperature_ranges": {
		"cold": {
			"high": 20,
			"low": -10
		},
		"hot": {
			"high": 60,
			"low": 20
		}
	},
	"summary_words": [
		"cool",
		"windy",
		"humid"
	]
}
""";

var jsonNode = System.Text.Json.Nodes.JsonNode.Parse(jsonString);

var temperatureCelsius = jsonNode?["temperature_celsius"];
// 25

var datesAvailable = jsonNode?["dates_available"]?.FirstOrDefault();
// "2019-08-01T00:00:00-07:00"

var summaryWords = jsonNode?["summary_words"]?.AsArray().Select(x => x?.ToString()?.ToUpper());
// "COOL"
// "WINDY"
// "HUMID"

実は最初System.Text.Json.Nodes.JsonNodeに気が付かずにSystem.Text.Json.JsonDocumentParseメソッドを使ってしまい、あまりの操作性の悪さに System.Text.Json の使用を躊躇していました。よく調べましょう。

ただこのSystem.Text.Json.Nodes.JsonNode 、read only の場合はいいのですが、値を編集しようとするとなかなかに苦戦します。ただし、基本的には型を定義するので型が不定のまま編集するというシーンはあまりありません。実際にあった例としては、任意のペイロードを送れる WebHook を作ろうとしたときにこの問題にぶち当たりました。以下のように自由にペイロードを定義して WebHook に登録し、「$xxx」の部分を実際に発生したイベントものに置き換えて送信するという感じです。

// peyload-1
{
    "id": "$id",
    "name": "$name"
}

// peyload-2
{
    "event": {
        "id": "$id",
        "name": "$name"
    },
    "created_at": "$created_at"
}

さて編集方法になりますが、System.Text.Json.Nodes.JsonNode は直接編集できないため、親ノードを辿ってその子(親の子なので自分)を差し替えるという方法をとりました。以下のような拡張メソッドを作成し….
※拡張メソッドで作る是非は要検討。

public static class JsonNodeExtension
{
    public static void Replace<T>(this JsonNode? node, T value)
    {
        if (node == null) throw new ArgumentNullException(nameof(node));

        // $.parent.child のようなフォーマットでパスが入っている
        // 親から見たこのノードのキーを取り出す
        var path = node.GetPath();
        var key = path[(path.LastIndexOf(".") + 1)..];

        var parent = node.Parent;
        if (parent is JsonArray array)
        {
            parent[key] = JsonValue.Create(value);
        }
        else if (parent is JsonObject obj)
        {
            parent[key] = JsonValue.Create(value);
        }
    }

    public static void ReplaceAll<T>(this JsonNode? node, string oldValue, T newValue)
    {
        if (node == null) throw new ArgumentNullException(nameof(node));

        foreach (var target in node.Search(oldValue).ToList())
        {
            target.Replace(newValue);
        }
    }


    public static IEnumerable<JsonNode> Search(this JsonNode? node, string value)
    {
        if (node is JsonArray array)
        {
            foreach (var child in array.AsArray())
            {
                foreach (var result in Search(child, value))
                {
                    yield return result;
                }
            }
        }
        else if (node is JsonObject obj)
        {
            foreach (var child in obj)
            {
                foreach (var result in Search(child.Value, value))
                {
                    yield return result;
                }
            }
        }
        // 今回は検索対象の値が「$name」のような文字列であることがわかっているためこの比較で良い
        // 数値やtrue/falseなども検索したい場合は JsonElement.ValueKind の値を見て判定する必要がある
        // (比較用の JsonElement を作るのも手かもしれない)
        else if (node != null && node.AsValue().ToString() == value)
        {
            yield return node;
        }
    }
}

以下のように編集できました。なかなかにめんどくさいのでもっといい方法があれば知りたいです。

// lang=json
var jsonString = """
{
    "event": {
        "id": "$id",
        "name": "$name"
    },
    "created_at": "$created_at"
}
""";

var jsonNode = System.Text.Json.Nodes.JsonNode.Parse(jsonString);
jsonNode.ReplaceAll("$id", 1);
jsonNode.ReplaceAll("$name", "watanabe");
jsonNode.ReplaceAll("$created_at", DateTime.UtcNow);

// {"event":{"id":1,"name":"watanabe"},"created_at":"2022-02-21T08:09:15.2226466Z"}

まとめ

  • .NET では System.Text.Json を使いましょう。
  • 型が不定の場合は System.Text.Json.Nodes.JsonNodeSystem.Text.Json.JsonDocumentには気をつけて!
  • System.Text.Json.Nodes.JsonNodeは編集が大変。

余談ですが、サンプルコード中に出てくる JSON の書き方は 「raw string literal」(日本語だと「生文字列リテラル」)という機能で C# 11 で実装予定です。JSONをコード中に書くことはそこまで多くありませんが、このようなサンプルを書くときや UnitTest を作るときなどは、エスケープを気にする必要がなく重宝すると思います。
※Visual Studio 2022 バージョン 17.2 Preview 1 以降でないとコンパイルが通らないため気を付けてください。

コメントを残す

メールアドレスが公開されることはありません。

CAPTCHA