5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Json.NETで、継承関係のあるクラスを可読性を重視しながらシリアライズ/デシリアライズする方法

Last updated at Posted at 2020-02-12

目的

自作のデータ保持用クラスが複数あり、これらの間に継承関係があるとする。これらのクラスインスタンスをJSON形式にシリアライズ/デシリアライズするため、Json.NETを使用する。

前提

Json.NETは、シンプルなPOCOクラスであれば特別な設定なしにJSON文字列とのシリアライズ/デシリアライズ処理を行ってくれる。また複雑な構造のクラスに対しては、カスタムコンバータに独自のシリアライズ/デシリアライズ処理を定義して利用することもできる。

今回問題となったのは、下記の例のように

  • 自作クラス間に継承の親子関係があり
  • かつ、親クラスが抽象クラスであるなどの理由で、子クラスのインスタンスへデシリアライズしたい
  • しかし(例えば、IList<親クラス>型で子クラスのインスタンスを保持しているため)、Json.NETは親クラスとしてデシリアライズを試みてしまう

場合である。

JsonNetTest0.cs
// 親の抽象クラス
public abstract class Parent
{
    public string Prop0;
}

// 子クラス(具象クラス)
public class Child1 : Parent
{
    public int Prop1;
}

public class Child2 : Parent
{
    public double Prop2;
}

※あくまで例のため簡素なクラスとしていますが、実際にはもう少し複雑なものを想定して書いています。

Program.cs
using Newtonsoft.Json;
using System.Diagnostics;

namespace JsonNetTest
{
    class Program
    {
        static void Main(string[] args)
        {
            var child1 = new Child1() { Prop0 = "Child1", Prop1 = 1234 };
            var child2 = new Child2() { Prop0 = "Child2", Prop2 = 1.234 };

            string child1Json = JsonConvert.SerializeObject(child1);    //"{\"Prop0\":\"Child1\",\"Prop1\":1234}"

            var child1FromJson = JsonConvert.DeserializeObject<Child1>(child1Json);
            Debug.WriteLine($"Child1.Prop0: {child1.Prop0 == child1FromJson.Prop0}");   //Child1.Prop0: True
            Debug.WriteLine($"Child1.Prop1: {child1.Prop1 == child1FromJson.Prop1}");   //Child1.Prop1: True

            string child2Json = JsonConvert.SerializeObject(child2);    //"{\"Prop0\":\"Child2\",\"Prop2\":1.234}"
            var child2FromJson = JsonConvert.DeserializeObject<Child2>(child2Json);
            Debug.WriteLine($"Child2.Prop0: {child2.Prop0 == child2FromJson.Prop0}");   //Child2.Prop0: True
            Debug.WriteLine($"Child2.Prop2: {child2.Prop2 == child2FromJson.Prop2}");   //Child2.Prop2: True
        }
    }
}

ここまでは問題ない。

上記の例ではChild1Child2は共通の親クラスParentを持つ。そこでこれらのインスタンスを、例えばParent[]型の配列でまとめて扱いたくなる場合がある。しかし、

Parent[] parents = new Parent[] { child1, child2 } ;
string parentsJson = JsonConvert.SerializeObject(parents);
Parent[] parentsFromJson = JsonConvert.DeserializeObject<Parent[]>(parentsJson);
// Newtonsoft.Json.JsonSerializationException:
// 'Could not create an instance of type JsonNetTest1.Parent.
// Type is an interface or abstract class and cannot be instantiated.
// Path '[0].Prop1', line 3, position 12.'

このような配列型オブジェクトのデシリアライズはそのままでは行えない。Parentという抽象クラス型の配列の要素を、どの子クラスへデシリアライズすればよいのか判らないからである。

解決法1: TypeNameHandlingを使う

Json.NETでオブジェクトをシリアライズ/デシリアライズする際、オプションにTypeNameHandlingを設定することで、インスタンスの型の情報をJSON文字列に付加することができる(公式ドキュメント)。"$type""$values"というキーが追加され、.NETにおける型の情報が記録される。

適用前

parents(TypeNameHandling.None).json
[
  {
    "Prop1": 1234,
    "Prop0": "Child1"
  },
  {
    "Prop2": 1.234,
    "Prop0": "Child2"
  }
]

適用後

props(TypeNameHandling.All).json
{
  "$type": "JsonNetTest1.Parent[], JsonNetTest",
  "$values": [
    {
      "$type": "JsonNetTest1.Child1, JsonNetTest",
      "Prop1": 1234,
      "Prop0": "Child1"
    },
    {
      "$type": "JsonNetTest1.Child2, JsonNetTest",
      "Prop2": 1.234,
      "Prop0": "Child2"
    }
  ]
}

最初はこの方法を採ったが、例えば上の例だと配列のメンバ1つ1つに$typeキーと型の名前が記述されるため、JSON文字列の情報の密度が薄まるのが気になった。配列の要素数が多いと、全体を確認するのも一苦労である。

とは言えビルトインで使える方法なので、システム外部からJSONを読み込んだりせず1、JSON文字列の可読性や互換性などを気にしないなら、こちらでも何ら問題ない……はず。

解決法2: (本題)可読性を考慮したカスタムコンバータを実装する

こちらが当記事の本題。

まず子クラスへデシリアライズする際の都合上、シリアライズ時に型を判別できる情報はどうしてもJSON文字列に含める必要がある。そこで親クラス専用のカスタムコンバータを定義し、これを使ってコンパクト、かつ型を識別できる書式へシリアライズ/デシリアライズを行ってもらう。

実装例

型の情報を、以下のようにChildTypeキーに保存することにする。

parents.json
[
  {
    "ChildType": 1,
    "Prop0": "Child1",
    "Prop1": 1234
  },
  {
    "ChildType": 2,
    "Prop0": "Child2",
    "Prop2": 1.234
  }
]
Parent.cs
    // 親の抽象クラスの属性に、使用するカスタムコンバータを指定する
    [JsonConverter(typeof(ParentConverter))]
    public abstract class Parent
    {
        public string Prop0;
    }
ParentConverter.cs
// カスタムコンバータの定義
class ParentConverter : JsonConverter
    {
    public override bool CanConvert(Type objectType)
    {
        return objectType == typeof(Parent);
    }

    //シリアライズ処理
    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        writer.WriteStartObject();
        writer.WritePropertyName("Prop0");
        writer.WriteValue(((Parent)value).Prop0);

        if (value is Child1 child1) //型を調べて、適切な処理に分岐する
        {
            writer.WritePropertyName("ChildType");
            writer.WriteValue(1);
            writer.WritePropertyName("Prop1");
            writer.WriteValue(child1.Prop1);
        }
        else if (value is Child2 child2)
        {
            writer.WritePropertyName("ChildType");
            writer.WriteValue(2);
            writer.WritePropertyName("Prop2");
            writer.WriteValue(child2.Prop2);
        }
        else
        {
            throw new JsonWriterException();
        }
        writer.WriteEndObject();
    }

    //デシリアライズ処理
    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        JObject jobject = JObject.Load(reader); //JObject型でJSONの中身を読めるようにする
        switch ((int)jobject["ChildType"])      //ChildTypeの値によって、適切な処理に分岐する
        {
            case 1:
                return new Child1() { Prop0 = (string)jobject["Prop0"], Prop1 = (int)jobject["Prop1"] };
            case 2:
                return new Child2() { Prop0 = (string)jobject["Prop0"], Prop2 = (double)jobject["Prop2"] };
            default:
                throw new JsonReaderException();
        }
    }
}

これで、Child1およびChild2を適切に使い分けてシリアライズ/デシリアライズされるようになった。

問題点

この方法では親クラスのカスタムコンバータに全ての子クラスのシリアライズ/デシリアライズ処理が集約される。子クラスやプロパティが少ないうちは問題にならないが、増えるにつれて1つのカスタムコンバータクラスが肥大化し、可読性・メンテナンス性が低下する。端的に言えば、SRPの原則上良くないように思われる。

解決法2.1: 各子クラス専用のカスタムコンバータを定義し、親クラスのカスタムコンバータから呼び出す

これを解決するために、まず子クラス毎に専用のカスタムコンバータを定義する。上記で親クラス用のコンバータに記述したシリアライズ/デシリアライズ処理を取り出し、子クラス用の方で定義する。

Child1Converter.cs
// 子クラス専用のコンバータ Child2に対しても同様に定義する
class Child1Converter : JsonConverter
{
    public override bool CanConvert(Type objectType)
    {
        return objectType == typeof(Child1);
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        JObject jobject = JObject.Load(reader);
        return new Child1()
        {
            Prop0 = (string)jobject["Prop0"],
            Prop1 = (int)jobject["Prop1"]
        };
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        var child1Value = (Child1)value;

        writer.WriteStartObject();
        writer.WritePropertyName("ChildType");
        writer.WriteValue(1);
        writer.WritePropertyName("Prop0");
        writer.WriteValue(child1Value.Prop0);
        writer.WritePropertyName("Prop1");
        writer.WriteValue(child1Value.Prop1);
        writer.WriteEndObject();
    }
}

そして親クラス用のカスタムコンバータには、型を判別して子クラスに分岐する処理だけを残す。

ParentConverter.cs
// 親クラスのコンバータ Child1Converter、Child2Converterの処理を呼び出す
class ParentConverter : JsonConverter
{
    public override bool CanConvert(Type objectType)
    {
        return objectType == typeof(Parent);
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        JsonConverter converter;
        if (value is Child1)
        {
            converter = new Child1Converter();
        }
        else if (value is Child2)
        {
            converter = new Child2Converter();
        }
        else
        {
            throw new JsonReaderException();
        }
        converter.WriteJson(writer, value, serializer);
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        JObject jobject = JObject.Load(reader);
        JsonConverter converter;
        switch ((int)jobject["ChildType"])
        {
            case 1:
                converter = new Child1Converter();
                break;
            case 2:
                converter = new Child2Converter();
                break;
            default:
                throw new JsonReaderException();
        }
        var newReader = jobject.CreateReader();
        return converter.ReadJson(newReader, objectType, existingValue, serializer);
    }

ここで重要なのはReadJsonメソッド、上記の最後から3行目のvar newReader = jobject.CreateReader();ReadJsonメソッドの引数をそのまま子クラスのReadJsonに渡したくなるが、引数のうちJsonReader readerだけはそのまま渡すことができない。
ドキュメントによればJsonReaderは"forward-only access"なものであり、おそらく一度しか使えない仕様なのだろう。ReadJsonメソッドの冒頭でJObject jobject = JObject.Load(reader);とした時点で"使われて"いるため、子クラスのReadJsonメソッドに渡して再び使おうとするとJsonReaderExceptionが発生する。
そこで、jobjectから未使用のJsonReaderを作り直して渡している。

またついでに、以下のように子クラスのJsonConverter属性を明示的に指定しておくと、子クラスを直接シリアライズ/デシリアライズする時、親クラスの方を経由せずに直接呼び出してくれる……はず。

Child1.cs
[JsonConverter(typeof(Child1Converter))]
public class Child1 : Parent
{
    public int Prop1;
}

(ただ指定しなくても、Json.NETは親クラスのJsonConverter属性まで遡って調べるらしく、最終的には正しいカスタムコンバータを呼んできてくれる。)

解決法2.2: 親クラスのカスタムコンバータのWriteJsonを省略する

更に試行錯誤を重ねたところ、今回のユースケースではどうやらParentConverterのWriteJsonメソッドは不要らしいことが判った。Json.Netの方でParent[]の各要素の型を自動的に判別して、適切な子クラス用のコンバータを呼び出してくれている……らしい。というわけで、

ParentConverter.cs
// 親クラスのコンバータ Child1Converter、Child2Converterの処理を呼び出す
class ParentConverter : JsonConverter
{
        public override bool CanWrite => false;
        public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
        {
            throw new NotImplementedException();
        }
        (以下略)

こうなった。

元々はこういう感じに、子クラスのJsonConverter属性を読みに行き、対応するカスタムコンバータを探してWriteJsonを呼び出す方法を試していた。一応、動作することは確認できている。

public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
    JsonConverterAttribute jca = (JsonConverterAttribute)Attribute.GetCustomAttribute(value.GetType(), typeof(JsonConverterAttribute));
    object instance = Activator.CreateInstance(jca.ConverterType, false);
    jca.ConverterType.GetMethod("WriteJson").Invoke(instance, new object[] { writer, value, serializer });
}

まとめ

継承関係のある親・子クラスに対してJson.NETのカスタムコンバータを定義し、1つの子クラスのシリアライズ/デシリアライズ処理を対応する1つのクラス内で完結させ、かつ親クラスをデシリアライズできるようにした。実装に当たっては出力されるJSON、および入出力を行うカスタムコンバータクラスの可読性に配慮した形とした。

これに限らず、もっと良さげな方法があればぜひご教示下さい。

最終的なソースコード

表示する
JsonNetTest2_2.cs
using System;
using System.Diagnostics;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace JsonNetTest2_2
{
    // 親の抽象クラス
    [JsonConverter(typeof(ParentConverter))]
    public abstract class Parent
    {
        public string Prop0;
    }

    // 子クラス(具象クラス) 対応するカスタムコンバータを指定している
    [JsonConverter(typeof(Child1Converter))]
    public class Child1 : Parent
    {
        public int Prop1;
    }

    [JsonConverter(typeof(Child2Converter))]
    public class Child2 : Parent
    {
        public double Prop2;
    }


    class Child1Converter : JsonConverter
    {
        public override bool CanConvert(Type objectType)
        {
            return objectType == typeof(Child1);
        }

        public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
        {
            JObject jobject = JObject.Load(reader);
            return new Child1() { Prop0 = (string)jobject["Prop0"], Prop1 = (int)jobject["Prop1"] };
        }

        public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
        {
            var child1Value = (Child1)value;

            writer.WriteStartObject();
            writer.WritePropertyName("ChildType");
            writer.WriteValue(1);
            writer.WritePropertyName("Prop0");
            writer.WriteValue(child1Value.Prop0);
            writer.WritePropertyName("Prop1");
            writer.WriteValue(child1Value.Prop1);
            writer.WriteEndObject();
        }
    }

    class Child2Converter : JsonConverter
    {
        public override bool CanConvert(Type objectType)
        {
            return objectType == typeof(Child2);
        }

        public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
        {
            JObject jobject = JObject.Load(reader);
            return new Child2() { Prop0 = (string)jobject["Prop0"], Prop2 = (double)jobject["Prop2"] };
        }

        public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
        {
            var child1Value = (Child2)value;

            writer.WriteStartObject();
            writer.WritePropertyName("ChildType");
            writer.WriteValue(2);
            writer.WritePropertyName("Prop0");
            writer.WriteValue(child1Value.Prop0);
            writer.WritePropertyName("Prop2");
            writer.WriteValue(child1Value.Prop2);
            writer.WriteEndObject();
        }
    }

    class ParentConverter : JsonConverter
    {
        public override bool CanConvert(Type objectType)
        {
            return objectType == typeof(Parent);
        }
        public override bool CanWrite => false;
        public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
        {
            throw new NotImplementedException();
        }

        public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
        {
            JObject jobject = JObject.Load(reader);
            JsonConverter converter;
            switch ((int)jobject["ChildType"])
            {
                case 1:
                    converter = new Child1Converter();
                    break;
                case 2:
                    converter = new Child2Converter();
                    break;
                default:
                    throw new JsonReaderException();
            }
            var newReader = jobject.CreateReader(); //As JsonReader cannot be used twice, create new one and pass it.
            return converter.ReadJson(newReader, objectType, existingValue, serializer);
        }
    }
}
Program.cs
using Newtonsoft.Json;
using System.Diagnostics;

using JsonNetTest2_1;

namespace JsonNetTest
{
    class Program
    {
        static void Main(string[] args)
        {
            var child1 = new Child1() { Prop0 = "Child1", Prop1 = 1234 };
            var child2 = new Child2() { Prop0 = "Child2", Prop2 = 1.234 };
            string child1Json = JsonConvert.SerializeObject(child1);
            var child1FromJson = JsonConvert.DeserializeObject<Child1>(child1Json);
            Debug.WriteLine($"Child1.Prop0: {child1.Prop0 == child1FromJson.Prop0}");
            Debug.WriteLine($"Child1.Prop1: {child1.Prop1 == child1FromJson.Prop1}");

            string child2Json = JsonConvert.SerializeObject((Parent)child2);
            var child2FromJson = JsonConvert.DeserializeObject<Child2>(child2Json);
            Debug.WriteLine($"Child2.Prop0: {child2.Prop0 == child2FromJson.Prop0}");
            Debug.WriteLine($"Child2.Prop2: {child2.Prop2 == child2FromJson.Prop2}");

            Parent[] parents = new Parent[] { child1, child2 } ;
            var jss = new JsonSerializerSettings() { Formatting = Formatting.Indented};
            string parentsJson = JsonConvert.SerializeObject(parents, jss);
            Parent[] parentsFromJson = JsonConvert.DeserializeObject<Parent[]>(parentsJson, jss);
            foreach (var child in parentsFromJson)
            {
                Debug.WriteLine(JsonConvert.SerializeObject(child));
            }
        }
    }
}
  1. セキュリティ上の懸念が生じるらしい

5
0
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?