ほげほげー

C#メインにプログラミング周りから日常のあれこれとかを不定期に書いていきます。

JsonConverter<T> を使って多態化されたオブジェクトを JsonSerializer でシリアライズ・デシリアライズする

前の記事に記載した通り、.NET Core 3.1 から追加された JsonSerializer はポリモーフィズムには対応していません。
そのため、JsonConverter<T> を使って自前で対応する必要があります。

この記事では前提として以下の名前空間の宣言とクラスが存在している事とします。

using System.Text.Json;
using System.Text.Json.Serialization;

public abstract class GameMachine
{
    public GameMachine(int price)
    {
        Price = price;
    }
    public int Price { get; init; }
}

public class NintendoSwitch : GameMachine
{
    public NintendoSwitch(bool isLite, int price)
        : base(price)
    {
        IsLite = isLite;
    }
    public bool IsLite { get; init; }
}

public class XboxSeries : GameMachine
{
    public XboxSeries(EditionType edition, int price)
        : base(price)
    {
        Edition = edition;
    }
    public EditionType Edition { get; init; }
    public enum EditionType
    {
        X,
        S
    }
}

public class PlayStation5 : GameMachine
{
    public PlayStation5(bool isDigitalOnly, int price)
        : base(price)
    {
        IsDigitalOnly = isDigitalOnly;
    }
    public bool IsDigitalOnly { get; init; }
}

JsonConvert<T> 無しでの挙動を確認する

基底クラスに触れない形で実装。

public class GameCatalog
{
    public NintendoSwitch NintendoSwitch { get; }
    public XboxSeries Xbox { get; }
    public PlayStation5 PlayStation { get; }
    public GameCatalog(NintendoSwitch nintendoSwitch, XboxSeries xbox, PlayStation5 playStation)
    {
        NintendoSwitch = nintendoSwitch;
        Xbox = xbox;
        PlayStation = playStation;
    }
}

void Main()
{
    var catalog = new GameCatalog(
        new NintendoSwitch(true, 20000),
        new XboxSeries(XboxSeries.EditionType.X, 50000),
        new PlayStation5(false, 60000)
    );
    var json = JsonSerializer.Serialize(catalog);
    var catalog2 = JsonSerializer.Deserialize<GameCatalog>(json);
}

すると、正常にシリアライズ・デシリアライズが出来ている事が分かります。

f:id:tyhe:20210413182303p:plain

続いてこれを基底クラスを使って実装。

public class GameCatalog
{
    public GameMachine[] GameMachines { get; }
    public GameCatalog(params GameMachine[] gameMachines)
    {
        GameMachines = gameMachines;
    }
}

void Main()
{
    var catalog = new GameCatalog(
        new NintendoSwitch(true, 20000),
        new XboxSeries(XboxSeries.EditionType.X, 50000),
        new PlayStation5(false, 60000)
    );
    var json = JsonSerializer.Serialize(catalog);
    var catalog2 = JsonSerializer.Deserialize<GameCatalog>(json);
}

これを実行すると Deserialize 時に以下のエラーが発生します。

Deserialization of types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported. Type 'GameMachine'

ポリモーフィズムに対応していないのでこうなるのは正しい挙動です。 一方で、Serialize 時にはエラーが出ておらず、正常に実行されたように見えますが、実際は以下のJSON が出力されており、基底クラスで定義されている物しか出力されていないことが分かります。

{"GameMachines":[{"Price":20000},{"Price":50000},{"Price":60000}]}

それでは JsonConverer<T> を実装してポリモーフィズムに対応してみます。

JsonConverter<T> の実装

JsonConverter<GameMachine> を実装して GameMachine のポリモーフィズムに対応します。

JsonConverter の実装はこちら。コメントで色々と

public class GameMachineConverter : JsonConverter<GameMachine>
{
    /// <summary>
    /// 識別子
    /// </summary>
    private enum Discriminator
    {
        NintendoSwitch,
        XboxSeries,
        PlayStation5
    }
    
    /// <summary>
    /// GameMachine を継承していたら変換可能とする
    /// </summary>
    public override bool CanConvert(Type typeToConvert)
        => typeof(GameMachine).IsAssignableTo(typeToConvert);

    /// <summary>
    /// 抽象メソッドの JsonConverter<T>.Read を実装。
    /// Deserialize 時に呼ばれる。
    /// 識別子を元にインスタンス化するクラスを選択する。
    /// </summary>
    /// <remarks>
    /// コメント上では前提として value が NintendoSwitch であることとする
    /// </remarks>
    public override GameMachine Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        // {"Discriminator":0,"Value":{"Price":21978,"IsLite":true}} を順に読んでいく
        
        // 初期状態では "{" が読み込まれているはず
        if (reader.TokenType != JsonTokenType.StartObject) throw new JsonException("オブジェクト開始文字から始まっていない");

        // 1つ読み進める
        // プロパティ名が読み込まれる: Discriminator
        if (!reader.Read() || reader.TokenType != JsonTokenType.PropertyName || reader.GetString() != "Discriminator") throw new JsonException("Discriminator が見つからない");

        // 1つ読み進める
        // Discriminator プロパティにセットされている値が読み込まれる: 0
        if (!reader.Read() || reader.TokenType != JsonTokenType.Number) throw new JsonException("Discriminator の値が数値ではない");

        // Discriminator プロパティの値を取得する: 0
        var discriminator = (Discriminator)reader.GetInt32();

        // 1つ読み進める
        // プロパティ名が読み込まれる(Value)
        if (!reader.Read() || reader.GetString() != "Value") throw new JsonException("Value が見つからない");

        // 1つ読み進める
        // "Value": の次は { なのでオブジェクト開始文字 "{" が読み込まれる
        if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject) throw new JsonException("オブジェクトが見つからない");

        // Discriminator を元に型を指定してデシリアライズする
        // ここで early return 出来そうだけどしちゃだめで、reader が最後の "}" まで読み込んでから return する必要がある
        var result = (GameMachine)JsonSerializer.Deserialize(ref reader, GetTypeByDiscriminator(discriminator));

        // 1つ読み進めてオブジェクト終了文字まで読み込む
        if (!reader.Read() || reader.TokenType != JsonTokenType.EndObject) throw new JsonException("オブジェクト終了文字が見つからない");

        return result;
    }
    
    /// <summary>
    /// Discriminator から型情報を取得する
    /// </summary>
    private Type GetTypeByDiscriminator(Discriminator discriminator)
    {
        switch (discriminator)
        {
            case Discriminator.NintendoSwitch:
                return typeof(NintendoSwitch);
                
            case Discriminator.XboxSeries:
                return typeof(XboxSeries);
                
            case Discriminator.PlayStation5:
                return typeof(PlayStation5);
                
            default:
                throw new IndexOutOfRangeException("未定義の Discriminator が指定された");
        }
    }

    /// <summary>
    /// 抽象メソッドの JsonConverter<T>.Write を実装。
    /// Serialize 時に呼ばれる。
    /// 派生クラスを識別可能とするために識別子的なものを付与した JSON を出力させる。
    /// JsonSerializerOptions は今回は使わない
    /// </summary>
    /// <remarks>
    /// コメント上では前提として value が NintendoSwitch であることとする
    /// </remarks>
    public override void Write(Utf8JsonWriter writer, GameMachine value, JsonSerializerOptions options)
    {
        // "{" を書き込む
        // {
        writer.WriteStartObject();
        
        // 識別子を書き込む
        // {"Discriminator":0
        writer.WriteNumber("Discriminator", (int)GetDiscriminator(value));
        
        // value を格納するプロパティを書き込む
        // {"Discriminator":0,"Value":
        writer.WritePropertyName("Value");
        
        // value をシリアライズする
        // {"Discriminator":0,"Value":{"Price":21978,"IsLite":true}
        JsonSerializer.Serialize(writer, value, value.GetType());
        
        // "}" を書込
        // {"Discriminator":0,"Value":{"Price":21978,"IsLite":true}}
        writer.WriteEndObject();
    }
    
    /// <summary>
    /// 識別子を取得する
    /// </summary>
    private Discriminator GetDiscriminator(GameMachine value)
    {
        switch (value)
        {
            case NintendoSwitch:
                return Discriminator.NintendoSwitch;
                
            case XboxSeries:
                return Discriminator.XboxSeries;

            case PlayStation5:
                return Discriminator.PlayStation5;
                
            default:
                throw new IndexOutOfRangeException("未定義の型が指定された");
        }
    }
}

これを実装した上で先ほど失敗したコードを実行してみます。

    var catalog = new GameCatalog(
        new NintendoSwitch(true, 20000),
        new XboxSeries(XboxSeries.EditionType.X, 50000),
        new PlayStation5(false, 60000)
    );
    var json = JsonSerializer.Serialize(catalog);
    var catalog2 = JsonSerializer.Deserialize<GameCatalog>(json);

f:id:tyhe:20210415190013p:plain

正常に動作するようになりました。

これくらいのコードなら自動生成もできると思うので必要になったら自動生成を実装すると良さそうです。