銀の光と碧い空

クラウドなインフラとC#なアプリ開発の狭間にいるエンジニアの日々

System.Text.JsonやJson.NET で一部だけキーが可変なJSONを処理する

とあるREST APIのエンドポイントが /values/xxx みたいなエンドポイントに対して次のような形式のJSONを返します(実際にはこの3倍くらい属性があり、listの中の配列も多数あります)。XXXで指定した名前の値のリストを取得するというイメージで、この名前のパターンは無数にあります。

{
  "Value": {
    "Key1": "value1",
    "Data": [
      {
        "Timestamp": "2020-10-29T13:02:00.000Z",
        "XXX": 123.45
      },
      {
        "Timestamp": "2020-10-29T13:03:00.000Z",
        "XXX": 456.78
      }
    ]
  }
}

つまり、XXXの部分が可変であるため、単純にC#のクラスにマッピングさせることができないのです。かといって、属性1つのためにJSON全体の構造をdynamicなりJsonDocument(JObject)として扱うのもつらいものがあります。 そこで使える機能が、クラスのメンバーに存在しないJSONのキーをC#側でDictionary型のオブジェクトとして保持することができます。この機能は、System.Text.JsonでもJson.NetでもJsonExtensionDataという同じ名前の属性で使えるのですが、名前空間と指定できる対象が異なります。

Json.NET では名前空間がNewtonsoft.Jsonであり、プロパティもしくはフィールドに指定できます。

www.newtonsoft.com

つぎのようなクラスを用意しておくと、JSONからC#に変換するときはTimestamp以外のJSONのキーとその値は_additionalDataに格納され、C#からJSONに変換するときはこの辞書のキーと値がJSONのキーと値として追加sれます。

public class Data
{
    public DateTime Timestamp{ get; set; }

    [JsonExtensionData]
    private Dictionary<string, JToken> _additionalData;
}

System.Text.Jsonでは名前空間がSystem.Text.Json.Serializationであり、プロパティにしか指定できません。

docs.microsoft.com

public class Data
{
    public DateTime Timestamp{ get; set; }

    [JsonExtensionData]
    public IDictionary<string, object> AdditionalData;
}

このクラスに最初の例のJSONを変換すると、DictionaryにはキーがXXXで値が数値のエントリーが一つ入った状態になります。さて、まずはこれで目的を達したのですが、便利さのためにこのXXXというキーの名前とその数値をKeyNameValueというプロパティに格納することを考えましょう。 Json.NETではドキュメントに書いてある通り、OnDeserialized属性を使って、C#からJSONへの変換が完了したときに実行できるコードをそのクラスの中に定義することができます。

public class Data
{
    public DateTime Timestamp{ get; set; }

    public string KeyName { get; set; }
    public double Value { get; set; }
    
    [JsonExtensionData]
    private Dictionary<string, JToken> _additionalData;

    [OnDeserialized]
    private void OnDeserialized(StreamingContext context)
    {
        var first = segment.ExtensionData.FirstOrDefault();
        if (first.Key != null)
        {
            KeyName = first.Key;
            Value = (double)first.Value;
        }
    }

    public DirectoryAccount()
    {
        _additionalData = new Dictionary<string, JToken>();
    }
}

これに対し、System.Text.Jsonではこのドキュメントにあるとおり、カスタムコンバーターを作成することで対応できます。

docs.microsoft.com

今回のケースだとこのようになるでしょう。

public class GetMetricSegmentCallbacksConverter : JsonConverter<GetMetricSegment>
{
    public override GetMetricSegment Read(
        ref Utf8JsonReader reader,
        Type type,
        JsonSerializerOptions options)
    {
        GetMetricSegment result = JsonSerializer.Deserialize<GetMetricSegment>(ref reader);
            
        var first = result.ExtensionData.FirstOrDefault();
        if (first.Key != null)
        {
            result.Key = first.Key;
            result.Value = (double)first.Value;
        }

        return result;
    }

    public override void Write(
        Utf8JsonWriter writer,
        GetMetricSegment result, JsonSerializerOptions options)
    {
        result.ExtensionData = new Dictionary<string, object>{{result.Key, result.Value}};
        JsonSerializer.Serialize(writer, result);
    }
}

いずれのケースも2つ以上オーバーフローしたキーが格納されているケースなどには対応できないので、適宜APIの仕様に合わせて対応しましょう。