Newtonsoft.Json から System.Text.Json へ移行したら JSON のデシリアライズが失敗した

2022/08/01に公開約2,800字

開発チームの川江です。Cosmos DB を多用する関係で、JSON のシリアライズ・デシリアライズを行うコードを近頃よく書いています。

C# で JSON を扱う場合のライブラリは、以前は Newtonsoft.Json (Json.NET) が定番でしたが、今では .NET 標準の System.Text.Json 名前空間を使えるようになりました。しかし既存のコードを移行する場合、両者の振る舞いの違いに注意が必要です。実際の移行時に、Newtonsoft.Json だと問題なくデシリアライズできていたクラスが、System.Text.Json にしたら例外が出るようになった、という問題がありました。

System.Text.Json ではデシリアライズに失敗するケース

分かりやすくするため、以下のシンプルな JSON を例にします。

{
    id: 1,
    name: "ジェイテックジャパン"
}

この JSON から以下のような、引数ありのコンストラクタしか持たないクラスにデシリアライズする、つまりクラスのインスタンス化をするとします。

// このクラスにはデシリアライズ可能
public class Company
{
    public int Id { get; }
    public string Name { get; }

    public Company(int id, string name)
    {
        Id = id;
        Name = name;
    }
}

こうしたクラスであっても、コンストラクタの引数とプロパティの名前および型が一致していれば(名前の大文字小文字の違いは無視されます)、Newtonsoft.Json でも System.Text.Json でもデシリアライズが可能です。

しかし、クラスのコンストラクタやプロパティの定義によっては、Newtonsoft.Json では問題ないものの、System.Text.Json では例外となる場合があります。

ケース1: コンストラクタにプロパティと一致しない引数が含まれる

これが実際に社内で起きたケースです。以下のクラスのように、コンストラクタの引数にプロパティと一致しないものが含まれている場合、System.Text.Json では一致しない引数を無視してくれず、例外が発生してしまいます。

// デシリアライズに失敗する
public class Company
{
    public int Id { get; }
    public string Name { get; }

    public Company(int id, string name, IAnyService? anyService = null)
    {
        Id = id;
        Name = name;

        // IAnyServiceを使った何かの処理
        // ...
    }
}

ケース2: コンストラクタの引数名とプロパティ名が異なる

プロジェクトで出たのは上記の問題だけだったのですが、検証した結果、System.Text.Json だと失敗するケースは他にもありました。以下のように、コンストラクタの引数名とクラスのプロパティ名が異なるクラスです。

// これもデシリアライズに失敗する
public class Company
{
    public int CompanyId { get; }
    public string CompanyName { get; }

    public Company(int id, string name)
    {
        CompanyId = id;
        CompanyName = name;
    }
}

ケース3: コンストラクタの引数とプロパティの型が異なる

名前ではなく型が一致しない場合も、System.Text.Json はデシリアライズできません。以下のようなクラスです。

// これもデシリアライズに失敗する
public class Company
{
    public int Id { get; }
    public string Name { get; }

    public Company(byte id, string name)
    {
        Id = id;        // 引数はbyte型だがプロパティはint型
        Name = name;
    }
}

対応策

問題となるケースはいずれも、そういうクラス定義をすることがどうなのか、という議論はありますが、現実には十分あり得るコードです。問題が起きた場合の対応策としては、

  1. コンストラクタの引数とプロパティの名前・型が一致するようにする。コンストラクタの引数にはそれ以外のものを渡さない。
  2. クラスに引数なしコンストラクタを追加し、プロパティにinit専用セッターをつける。

仕様上、引数なしコンストラクタは追加できるが、プロパティにpublic なinit専用セッターをつけられない、というケースもありえます。その場合は、init を private にし、プロパティに[JsonInclude]属性をつけるという方法で解決できます。

using System.Text.Json.Serialization;

// これでもデシリアライズ可能
public class Company
{
    [JsonInclude]
    public int Id { get; private init; }

    [JsonInclude]
    public string Name { get; private init; } = default!;

    public Company()
    { }
}

少し不思議な感じがしますが、このクラスも問題なくデシリアライズできます。

結論

ここに挙げたケースはコンパイルエラーにはならず、プログラムを実行して始めて分かります。加えて、Newtonsoft.Json と System.Text.Json には、ここに挙げた以外の相違点も色々あります。移行時には十分な検証が必要であると改めて認識しました。

Discussion

ログインするとコメントできます