目的
自作のデータ保持用クラスが複数あり、これらの間に継承関係があるとする。これらのクラスインスタンスをJSON形式にシリアライズ/デシリアライズするため、Json.NETを使用する。
前提
Json.NETは、シンプルなPOCOクラスであれば特別な設定なしにJSON文字列とのシリアライズ/デシリアライズ処理を行ってくれる。また複雑な構造のクラスに対しては、カスタムコンバータに独自のシリアライズ/デシリアライズ処理を定義して利用することもできる。
今回問題となったのは、下記の例のように
- 自作クラス間に継承の親子関係があり
- かつ、親クラスが抽象クラスであるなどの理由で、子クラスのインスタンスへデシリアライズしたい
- しかし(例えば、
IList<親クラス>
型で子クラスのインスタンスを保持しているため)、Json.NETは親クラスとしてデシリアライズを試みてしまう
場合である。
// 親の抽象クラスpublicabstractclassParent{publicstringProp0;}// 子クラス(具象クラス)publicclassChild1:Parent{publicintProp1;}publicclassChild2:Parent{publicdoubleProp2;}
※あくまで例のため簡素なクラスとしていますが、実際にはもう少し複雑なものを想定して書いています。
usingNewtonsoft.Json;usingSystem.Diagnostics;namespaceJsonNetTest{classProgram{staticvoidMain(string[]args){varchild1=newChild1(){Prop0="Child1",Prop1=1234};varchild2=newChild2(){Prop0="Child2",Prop2=1.234};stringchild1Json=JsonConvert.SerializeObject(child1);//"{\"Prop0\":\"Child1\",\"Prop1\":1234}"varchild1FromJson=JsonConvert.DeserializeObject<Child1>(child1Json);Debug.WriteLine($"Child1.Prop0: {child1.Prop0==child1FromJson.Prop0}");//Child1.Prop0: TrueDebug.WriteLine($"Child1.Prop1: {child1.Prop1==child1FromJson.Prop1}");//Child1.Prop1: Truestringchild2Json=JsonConvert.SerializeObject(child2);//"{\"Prop0\":\"Child2\",\"Prop2\":1.234}"varchild2FromJson=JsonConvert.DeserializeObject<Child2>(child2Json);Debug.WriteLine($"Child2.Prop0: {child2.Prop0==child2FromJson.Prop0}");//Child2.Prop0: TrueDebug.WriteLine($"Child2.Prop2: {child2.Prop2==child2FromJson.Prop2}");//Child2.Prop2: True}}}
ここまでは問題ない。
上記の例ではChild1
とChild2
は共通の親クラスParentを持つ。そこでこれらのインスタンスを、例えばParent[]
型の配列でまとめて扱いたくなる場合がある。しかし、
Parent[]parents=newParent[]{child1,child2};stringparentsJson=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における型の情報が記録される。
適用前
[{"Prop1":1234,"Prop0":"Child1"},{"Prop2":1.234,"Prop0":"Child2"}]
適用後
{"$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
キーに保存することにする。
[{"ChildType":1,"Prop0":"Child1","Prop1":1234},{"ChildType":2,"Prop0":"Child2","Prop2":1.234}]
// 親の抽象クラスの属性に、使用するカスタムコンバータを指定する[JsonConverter(typeof(ParentConverter))]publicabstractclassParent{publicstringProp0;}
// カスタムコンバータの定義classParentConverter:JsonConverter{publicoverrideboolCanConvert(TypeobjectType){returnobjectType==typeof(Parent);}//シリアライズ処理publicoverridevoidWriteJson(JsonWriterwriter,objectvalue,JsonSerializerserializer){writer.WriteStartObject();writer.WritePropertyName("Prop0");writer.WriteValue(((Parent)value).Prop0);if(valueisChild1child1)//型を調べて、適切な処理に分岐する{writer.WritePropertyName("ChildType");writer.WriteValue(1);writer.WritePropertyName("Prop1");writer.WriteValue(child1.Prop1);}elseif(valueisChild2child2){writer.WritePropertyName("ChildType");writer.WriteValue(2);writer.WritePropertyName("Prop2");writer.WriteValue(child2.Prop2);}else{thrownewJsonWriterException();}writer.WriteEndObject();}//デシリアライズ処理publicoverrideobjectReadJson(JsonReaderreader,TypeobjectType,objectexistingValue,JsonSerializerserializer){JObjectjobject=JObject.Load(reader);//JObject型でJSONの中身を読めるようにするswitch((int)jobject["ChildType"])//ChildTypeの値によって、適切な処理に分岐する{case1:returnnewChild1(){Prop0=(string)jobject["Prop0"],Prop1=(int)jobject["Prop1"]};case2:returnnewChild2(){Prop0=(string)jobject["Prop0"],Prop2=(double)jobject["Prop2"]};default:thrownewJsonReaderException();}}}
これで、Child1
およびChild2
を適切に使い分けてシリアライズ/デシリアライズされるようになった。
問題点
この方法では親クラスのカスタムコンバータに全ての子クラスのシリアライズ/デシリアライズ処理が集約される。子クラスやプロパティが少ないうちは問題にならないが、増えるにつれて1つのカスタムコンバータクラスが肥大化し、可読性・メンテナンス性が低下する。端的に言えば、SRPの原則上良くないように思われる。
解決法2.1: 各子クラス専用のカスタムコンバータを定義し、親クラスのカスタムコンバータから呼び出す
これを解決するために、まず子クラス毎に専用のカスタムコンバータを定義する。上記で親クラス用のコンバータに記述したシリアライズ/デシリアライズ処理を取り出し、子クラス用の方で定義する。
// 子クラス専用のコンバータ Child2に対しても同様に定義するclassChild1Converter:JsonConverter{publicoverrideboolCanConvert(TypeobjectType){returnobjectType==typeof(Child1);}publicoverrideobjectReadJson(JsonReaderreader,TypeobjectType,objectexistingValue,JsonSerializerserializer){JObjectjobject=JObject.Load(reader);returnnewChild1(){Prop0=(string)jobject["Prop0"],Prop1=(int)jobject["Prop1"]};}publicoverridevoidWriteJson(JsonWriterwriter,objectvalue,JsonSerializerserializer){varchild1Value=(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();}}
そして親クラス用のカスタムコンバータには、型を判別して子クラスに分岐する処理だけを残す。
// 親クラスのコンバータ Child1Converter、Child2Converterの処理を呼び出すclassParentConverter:JsonConverter{publicoverrideboolCanConvert(TypeobjectType){returnobjectType==typeof(Parent);}publicoverridevoidWriteJson(JsonWriterwriter,objectvalue,JsonSerializerserializer){JsonConverterconverter;if(valueisChild1){converter=newChild1Converter();}elseif(valueisChild2){converter=newChild2Converter();}else{thrownewJsonReaderException();}converter.WriteJson(writer,value,serializer);}publicoverrideobjectReadJson(JsonReaderreader,TypeobjectType,objectexistingValue,JsonSerializerserializer){JObjectjobject=JObject.Load(reader);JsonConverterconverter;switch((int)jobject["ChildType"]){case1:converter=newChild1Converter();break;case2:converter=newChild2Converter();break;default:thrownewJsonReaderException();}varnewReader=jobject.CreateReader();returnconverter.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
属性を明示的に指定しておくと、子クラスを直接シリアライズ/デシリアライズする時、親クラスの方を経由せずに直接呼び出してくれる……はず。
[JsonConverter(typeof(Child1Converter))]publicclassChild1:Parent{publicintProp1;}
(ただ指定しなくても、Json.NETは親クラスのJsonConverter
属性まで遡って調べるらしく、最終的には正しいカスタムコンバータを呼んできてくれる。)
解決法2.2: 親クラスのカスタムコンバータのWriteJsonを省略する
更に試行錯誤を重ねたところ、今回のユースケースではどうやらParentConverter
のWriteJsonメソッドは不要らしいことが判った。Json.Netの方でParent[]の各要素の型を自動的に判別して、適切な子クラス用のコンバータを呼び出してくれている……らしい。というわけで、
// 親クラスのコンバータ Child1Converter、Child2Converterの処理を呼び出すclassParentConverter:JsonConverter{publicoverrideboolCanWrite=>false;publicoverridevoidWriteJson(JsonWriterwriter,objectvalue,JsonSerializerserializer){thrownewNotImplementedException();}(以下略)
こうなった。
元々はこういう感じに、子クラスのJsonConverter
属性を読みに行き、対応するカスタムコンバータを探してWriteJson
を呼び出す方法を試していた。一応、動作することは確認できている。
publicoverridevoidWriteJson(JsonWriterwriter,objectvalue,JsonSerializerserializer){JsonConverterAttributejca=(JsonConverterAttribute)Attribute.GetCustomAttribute(value.GetType(),typeof(JsonConverterAttribute));objectinstance=Activator.CreateInstance(jca.ConverterType,false);jca.ConverterType.GetMethod("WriteJson").Invoke(instance,newobject[]{writer,value,serializer});}
まとめ
継承関係のある親・子クラスに対してJson.NETのカスタムコンバータを定義し、1つの子クラスのシリアライズ/デシリアライズ処理を対応する1つのクラス内で完結させ、かつ親クラスをデシリアライズできるようにした。実装に当たっては出力されるJSON、および入出力を行うカスタムコンバータクラスの可読性に配慮した形とした。
これに限らず、もっと良さげな方法があればぜひご教示下さい。
最終的なソースコード
表示する
usingSystem;usingSystem.Diagnostics;usingNewtonsoft.Json;usingNewtonsoft.Json.Linq;namespaceJsonNetTest2_2{// 親の抽象クラス[JsonConverter(typeof(ParentConverter))]publicabstractclassParent{publicstringProp0;}// 子クラス(具象クラス) 対応するカスタムコンバータを指定している[JsonConverter(typeof(Child1Converter))]publicclassChild1:Parent{publicintProp1;}[JsonConverter(typeof(Child2Converter))]publicclassChild2:Parent{publicdoubleProp2;}classChild1Converter:JsonConverter{publicoverrideboolCanConvert(TypeobjectType){returnobjectType==typeof(Child1);}publicoverrideobjectReadJson(JsonReaderreader,TypeobjectType,objectexistingValue,JsonSerializerserializer){JObjectjobject=JObject.Load(reader);returnnewChild1(){Prop0=(string)jobject["Prop0"],Prop1=(int)jobject["Prop1"]};}publicoverridevoidWriteJson(JsonWriterwriter,objectvalue,JsonSerializerserializer){varchild1Value=(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();}}classChild2Converter:JsonConverter{publicoverrideboolCanConvert(TypeobjectType){returnobjectType==typeof(Child2);}publicoverrideobjectReadJson(JsonReaderreader,TypeobjectType,objectexistingValue,JsonSerializerserializer){JObjectjobject=JObject.Load(reader);returnnewChild2(){Prop0=(string)jobject["Prop0"],Prop2=(double)jobject["Prop2"]};}publicoverridevoidWriteJson(JsonWriterwriter,objectvalue,JsonSerializerserializer){varchild1Value=(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();}}classParentConverter:JsonConverter{publicoverrideboolCanConvert(TypeobjectType){returnobjectType==typeof(Parent);}publicoverrideboolCanWrite=>false;publicoverridevoidWriteJson(JsonWriterwriter,objectvalue,JsonSerializerserializer){thrownewNotImplementedException();}publicoverrideobjectReadJson(JsonReaderreader,TypeobjectType,objectexistingValue,JsonSerializerserializer){JObjectjobject=JObject.Load(reader);JsonConverterconverter;switch((int)jobject["ChildType"]){case1:converter=newChild1Converter();break;case2:converter=newChild2Converter();break;default:thrownewJsonReaderException();}varnewReader=jobject.CreateReader();//As JsonReader cannot be used twice, create new one and pass it.returnconverter.ReadJson(newReader,objectType,existingValue,serializer);}}}
usingNewtonsoft.Json;usingSystem.Diagnostics;usingJsonNetTest2_1;namespaceJsonNetTest{classProgram{staticvoidMain(string[]args){varchild1=newChild1(){Prop0="Child1",Prop1=1234};varchild2=newChild2(){Prop0="Child2",Prop2=1.234};stringchild1Json=JsonConvert.SerializeObject(child1);varchild1FromJson=JsonConvert.DeserializeObject<Child1>(child1Json);Debug.WriteLine($"Child1.Prop0: {child1.Prop0==child1FromJson.Prop0}");Debug.WriteLine($"Child1.Prop1: {child1.Prop1==child1FromJson.Prop1}");stringchild2Json=JsonConvert.SerializeObject((Parent)child2);varchild2FromJson=JsonConvert.DeserializeObject<Child2>(child2Json);Debug.WriteLine($"Child2.Prop0: {child2.Prop0==child2FromJson.Prop0}");Debug.WriteLine($"Child2.Prop2: {child2.Prop2==child2FromJson.Prop2}");Parent[]parents=newParent[]{child1,child2};varjss=newJsonSerializerSettings(){Formatting=Formatting.Indented};stringparentsJson=JsonConvert.SerializeObject(parents,jss);Parent[]parentsFromJson=JsonConvert.DeserializeObject<Parent[]>(parentsJson,jss);foreach(varchildinparentsFromJson){Debug.WriteLine(JsonConvert.SerializeObject(child));}}}}