こんにちは。松本です。
ポリモーフィズムはオブジェクト指向言語の魅力のひとつですが、これがオブジェクトと JSON のマッピングを複雑にするなあと度々感じています。
汎化された型として定義されたプロパティ(フィールド)を持つオブジェクトを JSON からデシリアライズするには、型解決に関する定義やロジックをコードや設定として組み込むことになります。ここに、モデルクラスの設計と、JSON のエンティティ設計の間でトレードオフが発生することがあり、ケースに応じた最適な設計/実装が必要になるわけです。
私が扱うソフトウェアのライフサイクルの特性上、繰り返される追加開発に対し、保守性/拡張性を維持することは大きな関心ごとです。クラス関係の妥当性や理解しやすさ、変更時の影響範囲の抽出しやすさ、クラス数、コード量、実装難易度など、様々なトレードオフスライダーを頭に描きながら、最適だと考える選択を行います。
この「JSON デシリアライズ時の型解決」にどのような方式を採用するかという設計、実装も、そんな選択に頭を悩ますひとつなのです。
と、背景を説明すると少々大げさな書き出しになりましたが、JSON ライブラリの Jackson を使った型解決方法の選択肢をいくつかご紹介しようという記事です。
型解決に失敗するってどういうこと?
次のコードは Java + Jackson を使ったサンプルです。
shape フィールドの型が Shape インターフェースであり、実行時にバインディングされるのは、その実装クラスである Rectangle や Triangle のインスタンスです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
package com.techscore.blog.example; import com.fasterxml.jackson.databind.ObjectMapper; public class Example0 { public Shape shape; static interface Shape {} static class Rectangle implements Shape { public int width; public int height; } static class Triangle implements Shape { public int base; public int height; } public static void main(String[] args) throws Exception { Rectangle rectangle = new Rectangle(); rectangle.width = 100; rectangle.height = 200; Example0 example = new Example0(); example.shape = rectangle; ObjectMapper mapper = new ObjectMapper(); System.out.println("シリアライズ結果:"); String json = mapper.writeValueAsString(example); System.out.println(json); System.out.println("デシリアライズは失敗する:"); Example0 result = mapper.readValue(json, Example0.class); } } |
実行すると、シリアライズには成功しますが、デシリアライズは型解決ができずに失敗します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
シリアライズ結果: {"shape":{"width":100,"height":200}} デシリアライズは失敗する: Exception in thread "main" com.fasterxml.jackson.databind.JsonMappingException: Can not construct instance of com.techscore.blog.example.Example0$Shape, problem: abstract types either need to be mapped to concrete types, have custom deserializer, or be instantiated with additional type information at [Source: {"shape":{"width":100,"height":200}}; line: 1, column: 2] (through reference chain: com.techscore.blog.example.Example0["shape"]) at com.fasterxml.jackson.databind.JsonMappingException.from(JsonMappingException.java:148) at com.fasterxml.jackson.databind.DeserializationContext.instantiationException(DeserializationContext.java:889) at com.fasterxml.jackson.databind.deser.AbstractDeserializer.deserialize(AbstractDeserializer.java:139) at com.fasterxml.jackson.databind.deser.SettableBeanProperty.deserialize(SettableBeanProperty.java:520) at com.fasterxml.jackson.databind.deser.impl.FieldProperty.deserializeAndSet(FieldProperty.java:101) at com.fasterxml.jackson.databind.deser.BeanDeserializer.vanillaDeserialize(BeanDeserializer.java:256) at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:125) at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:3702) at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:2714) at com.techscore.blog.example.Example0.main(Example0.java:36) |
例外メッセージにあるように、shape フィールドをデシリアライズしようとしたけど Shape は抽象型(インターフェース)だからインスタンス化できないよ!と怒っています。
このサンプルコードの場合、shape フィールドは Rectangle クラスのインスタンスとしてデシリアライズされるのが、期待する動作です。
【型解決方法1】 @JsonTypeInfo(use = Id.CLASS)
もっとも簡単お手軽な方法です。
先ほど型解決に失敗したサンプルコードを改造し、@JsonTypeInfo アノテーションを使って Shape インターフェースに型解決方法を定義しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
package com.techscore.blog.example; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; import com.fasterxml.jackson.databind.ObjectMapper; public class Example1 { public Shape shape; @JsonTypeInfo(use = Id.CLASS) static interface Shape {} static class Rectangle implements Shape { public int width; public int height; } static class Triangle implements Shape { public int base; public int height; } public static void main(String[] args) throws Exception { Rectangle rectangle = new Rectangle(); rectangle.width = 100; rectangle.height = 200; Example1 example = new Example1(); example.shape = rectangle; ObjectMapper mapper = new ObjectMapper(); System.out.println("シリアライズ結果:"); String json = mapper.writeValueAsString(example); System.out.println(json); System.out.println("デシリアライズ結果に含まれる shape フィールドの型:"); Example1 result = mapper.readValue(json, Example1.class); System.out.println(result.shape.getClass().getName()); } } |
@JsonTypeInfo アノテーションの use 属性に Id.CLASS を指定することで、Shape オブジェクトをシリアライズした JSON 内に "@class" というキーが追加され、その値として実装クラス名が書き出されます。デシリアライズ処理ではこの値に基づいて型解決が行われます。
実行結果はこれ。
1 2 3 4 |
シリアライズ結果: {"shape":{"@class":"com.techscore.blog.example.Example1$Rectangle","width":100,"height":200}} デシリアライズ結果に含まれる shape フィールドの型: com.techscore.blog.example.Example1$Rectangle |
クラス名が出力されてしまうことがセキュリティ面で良くないケースもありそうです。そこが問題にならないなら、実装クラスを追加してもコード変更の必要がなく、拡張性は良さそうです。
保守面を考えると、クラス設計変更を阻害する要因にもなるので注意が必要です。例えば実装クラスのクラス名が変更された場合、古いクラス名が使われた JSON をデシリアライズしようとすると、型解決に失敗してしまいます。
【型解決方法2】 @JsonTypeInfo(use = Id.NAME) + @JsonSubTypes
これも簡単な方法です。
型解決方法1との違いは、Shape インターフェースに付けられた @JsonTypeInfo アノテーションの use 属性値が Id.NAME に変わり、さらに @JsonSubTypes アノテーションが追加されたことです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
package com.techscore.blog.example; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; import com.fasterxml.jackson.databind.ObjectMapper; public class Example2 { public Shape shape; @JsonTypeInfo(use = Id.NAME) @JsonSubTypes({ @Type(name = "R", value = Rectangle.class), @Type(name = "T", value = Triangle.class) }) static interface Shape {} static class Rectangle implements Shape { public int width; public int height; } static class Triangle implements Shape { public int base; public int height; } public static void main(String[] args) throws Exception { Rectangle rectangle = new Rectangle(); rectangle.width = 100; rectangle.height = 200; Example2 example = new Example2(); example.shape = rectangle; ObjectMapper mapper = new ObjectMapper(); System.out.println("シリアライズ結果:"); String json = mapper.writeValueAsString(example); System.out.println(json); System.out.println("デシリアライズ結果に含まれる shape フィールドの型:"); Example2 result = mapper.readValue(json, Example2.class); System.out.println(result.shape.getClass().getName()); } } |
シリアライズ後の JSON 内に "@type" というキーが追加され、その値に識別名が書き出されます。デシリアライズ時の型解決は、@JsonSubTypes アノテーションに定義された識別名と実装クラス名のマッピングが使われます。
実行結果です。
1 2 3 4 |
シリアライズ結果: {"shape":{"@type":"R","width":100,"height":200}} デシリアライズ結果に含まれる shape フィールドの型: com.techscore.blog.example.Example2$Rectangle |
型解決方法1と比べ、識別名を使うことによって保守性が高まり、セキュリティへの懸念も無くなっています。その引き換えとして、実装クラスが追加される度に @JsonSubTypes アノテーションにマッピングを追加しなければならなくなった点が面倒なところです。
この方法では @JsonSubTypes アノテーションの定義により、親クラス(ここではインターフェースである Shape)の定義内に、子クラス(実装クラス)への参照が出来てしまいます。この相互リンクが気になるなら、この方法はお勧めできません。子クラスが親クラスの内部クラスとして定義されているようなケースなら最適でしょう。
【型解決方法3】 @JsonTypeInfo(use = Id.NAME) + SubtypeResolver
型解決方法2と似ていますが、マッピングの定義に @JsonSubTypes を使わず、ObjectMapper オブジェクトに NamedType オブジェクトとして登録することで代替しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
package com.techscore.blog.example; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.jsontype.NamedType; public class Example3 { public Shape shape; @JsonTypeInfo(use = Id.NAME) static interface Shape {} static class Rectangle implements Shape { public int width; public int height; } static class Triangle implements Shape { public int base; public int height; } public static void main(String[] args) throws Exception { Rectangle rectangle = new Rectangle(); rectangle.width = 100; rectangle.height = 200; Example3 example = new Example3(); example.shape = rectangle; ObjectMapper mapper = new ObjectMapper(); mapper.registerSubtypes( new NamedType(Rectangle.class, "R"), new NamedType(Triangle.class, "T")); System.out.println("シリアライズ結果:"); String json = mapper.writeValueAsString(example); System.out.println(json); System.out.println("デシリアライズ結果に含まれる shape フィールドの型:"); Example3 result = mapper.readValue(json, Example3.class); System.out.println(result.shape.getClass().getName()); } } |
JSON へのシリアライズ結果は型解決方法2と同じです。
サンプルコードでは簡素化するため、NamedType オブジェクトを ObjectMapper オブジェクトに直接登録していますが、この NamedType オブジェクトは registerSubtypes() メソッド内部で SubtypeResolver オブジェクトに追加されます。デシリアライズ時の型解決は、この SubtypeResolver オブジェクトに設定された識別名と実装クラス名のマッピングが使われます。
実行結果です。
1 2 3 4 |
シリアライズ結果: {"shape":{"@type":"R","width":100,"height":200}} デシリアライズ結果に含まれる shape フィールドの型: com.techscore.blog.example.Example3$Rectangle |
この方法では、型解決方法2で気になった相互リンク問題が解消します。また、識別名/実装クラス名のマッピング定義にアノテーションを使わないので、マッピングの設定を柔軟にできるというメリットがあります。
これを採用したクラスのシリアライズ/デシリアライズには、マッピングが定義された ObjectMapper オブジェクトを必ず使う、という実装ルールをどうやって徹底するかがポイントになります。
【型解決方法4】 @JsonTypeInfo + @JsonTypeIdResolver
多少、面倒ですが非常に柔軟な型解決方法です。
Shape インターフェースに @JsonTypeIdResolver アノテーションをつけ、型解決を担うクラスがどれであるかを定義しています(サンプルでは ShapeTypeIdResolver クラス)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 |
package com.techscore.blog.example; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; import com.fasterxml.jackson.databind.DatabindContext; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.annotation.JsonTypeIdResolver; import com.fasterxml.jackson.databind.jsontype.TypeIdResolver; import com.fasterxml.jackson.databind.type.TypeFactory; public class Example4 { public Shape shape; @JsonTypeInfo(use = Id.NAME) @JsonTypeIdResolver(ShapeTypeIdResolver.class) static interface Shape {} static class Rectangle implements Shape { public int width; public int height; } static class Triangle implements Shape { public int base; public int height; } static class ShapeTypeIdResolver implements TypeIdResolver { @Override public void init(JavaType baseType) {} @Override public String idFromValue(Object value) { return idFromValueAndType(value, value.getClass()); } @Override public String idFromValueAndType(Object value, Class<?> suggestedType) { if (Rectangle.class.isAssignableFrom(suggestedType)) { return "R"; } else if (Triangle.class.isAssignableFrom(suggestedType)) { return "T"; } throw new IllegalArgumentException(); } @Override public String idFromBaseType() { throw new UnsupportedOperationException(); } @Override public JavaType typeFromId(String id) { return typeFromId(null, id); } @Override public JavaType typeFromId(DatabindContext context, String id) { TypeFactory typeFactory = (context != null) ? context.getTypeFactory() : TypeFactory.defaultInstance(); if ("R".equals(id)) { return typeFactory.constructType(Rectangle.class); } else if ("T".equals(id)) { return typeFactory.constructType(Triangle.class); } throw new IllegalArgumentException(); } @Override public Id getMechanism() { return Id.NAME; } } public static void main(String[] args) throws Exception { Rectangle rectangle = new Rectangle(); rectangle.width = 100; rectangle.height = 200; Example4 example = new Example4(); example.shape = rectangle; ObjectMapper mapper = new ObjectMapper(); System.out.println("シリアライズ結果:"); String json = mapper.writeValueAsString(example); System.out.println(json); System.out.println("デシリアライズ結果に含まれる shape フィールドの型:"); Example4 result = mapper.readValue(json, Example4.class); System.out.println(result.shape.getClass().getName()); } } |
これも JSON へのシリアライズ結果は型解決方法2と同じです。
@JsonTypeIdResolver アノテーションで指定した TypeIdResolver の実装クラスの idFromValue() メソッドや idFromValueAndType() メソッドで実装クラスから ID への変換を行い、typeFromId() メソッドで ID から実装クラスの変換を行います。
ここで言う ID とは、@JsonTypeInfo アノテーションの use 属性の定義によって決定する情報で、サンプルでは "@type" キーに対する値として書き出された識別名のことです。
実行結果はこれです。
1 2 3 4 |
シリアライズ結果: {"shape":{"@type":"R","width":100,"height":200}} デシリアライズ結果に含まれる shape フィールドの型: com.techscore.blog.example.Example4$Rectangle |
TypeIdResolver の実装クラスを作ることで型解決に関するロジックが書けるので、複雑な型解決にも最適です。その引き換えとしてコード量が多くなり、テストコードの作成量が増えてしまいます。
【型解決方法5】 ミックスイン
いわゆるミックスインです。
サンプルコードは MixIn クラスを使って型解決方法2と同じことを実現しています(サンプルでは ShapeMixIn クラス)。
型解決には、モデルクラス自身ではなく MixIn クラスに付けられたアノテーションが使われます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
package com.techscore.blog.example; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; import com.fasterxml.jackson.databind.ObjectMapper; public class Example5 { public Shape shape; static interface Shape {} static class Rectangle implements Shape { public int width; public int height; } static class Triangle implements Shape { public int base; public int height; } @JsonTypeInfo(use = Id.NAME) @JsonSubTypes({ @Type(name = "R", value = Rectangle.class), @Type(name = "T", value = Triangle.class) }) static abstract class ShapeMixIn {} public static void main(String[] args) throws Exception { Rectangle rectangle = new Rectangle(); rectangle.width = 100; rectangle.height = 200; Example5 example = new Example5(); example.shape = rectangle; ObjectMapper mapper = new ObjectMapper(); mapper.addMixIn(Shape.class, ShapeMixIn.class); System.out.println("シリアライズ結果:"); String json = mapper.writeValueAsString(example); System.out.println(json); System.out.println("デシリアライズ結果に含まれる shape フィールドの型:"); Example5 result = mapper.readValue(json, Example5.class); System.out.println(result.shape.getClass().getName()); } } |
ObjectMapper オブジェクトに MixIn クラスを登録するのを忘れないようにしましょう。これによって、ShapeMixIn クラスに付けられた @JsonTypeInfo アノテーションと @JsonSubTypes アノテーションが、まるで Shape インターフェースに付いているかのように動作します。
実行結果はこれ。
1 2 3 4 |
シリアライズ結果: {"shape":{"@type":"R","width":100,"height":200}} デシリアライズ結果に含まれる shape フィールドの型: com.techscore.blog.example.Example5$Rectangle |
MixIn は、モデルクラスの設計と、JSON のエンティティ設計を分離でき、クラス設計がすっきりします。
サンプルには含めていませんが、モデルクラスのコンストラクタやメソッドにアノテーションを付けるようなケースも、MixIn クラスのコンストラクタやメソッドにアノテーションを付けることで代替できます(スタティックメソッドもミックスイン可能です)。
あえて難点を言えば、モデルクラスの拡張や変更に対し、MixIn クラスの変更を追随させなければならないケースがあることです。ここが、改修時の影響範囲として漏れないよう何らかの工夫が必要です。
モデルクラスにアノテーションがつけられない場合はどうすればいいの?
Java の基本 API や、外部ライブラリのクラスを汎化した型としてモデルクラスのプロパティ(フィールド)に定義するケースでは、少々工夫が必要です。
ここまでの型解決方法のほとんどが、対象となるモデルクラスに @JsonTypeInfo アノテーションを付けていました。しかし Java の基本 API や外部ライブラリに含まれるクラスにはアノテーションをつけることができません。ミックスインならこの点は解決しますが、ミックスインが使えないケースもありえます。
実は、@JsonTypeInfo アノテーションはプロパティ(フィールド)にも付けることが出来ます。つまり、対象プロパティ(フィールド)に定義を書けばいいわけです。
サンプルコードでは、field1 フィールドに対し @JsonTypeInfo アノテーションを追加しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
package com.techscore.blog.example; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo.As; import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; import com.fasterxml.jackson.databind.ObjectMapper; public class Example6 { @JsonTypeInfo(use = Id.CLASS, include=As.EXTERNAL_PROPERTY) public Object field1; public Object field2; public static void main(String[] args) throws Exception { Example6 example = new Example6(); example.field1 = 100L; example.field2 = 100L; ObjectMapper mapper = new ObjectMapper(); System.out.println("シリアライズ結果:"); String json = mapper.writeValueAsString(example); System.out.println(json); System.out.println("デシリアライズ結果:"); Example6 result = mapper.readValue(json, Example6.class); System.out.println("field1: " + result.field1.getClass().getName()); System.out.println("field2: " + result.field2.getClass().getName()); } } |
同じ Object 型のフィールドである field1 と field2 に対し、main() メソッド内でともに Long 型の 100 を代入し、それをシリアライズしてからデシリアライズしています。
結果は次のようになります。
1 2 3 4 5 |
シリアライズ結果: {"field1":100,"@class":"java.lang.Long","field2":100} デシリアライズ結果: field1: java.lang.Long field2: java.lang.Integer |
field1 は Long 型としてデシリアライズされ、field2 は Integer 型としてデシリアライズされています。これは、field1 に対して @JsonTypeInfo アノテーションで "@class" キーで実装クラス名を書き出すようにしたからです。
ここで肝となるのは、@JsonTypeInfo の include 属性に JsonTypeInfo.As.EXTERNAL_PROPERTY を指定することです。これによってキー ”@type” や "@class" が、対象プロパティ(フィールド)と同列のメンバーとして挿入されるようになります。
最後に
本文では触れませんでしたが、JSON に自動挿入される "@type" や “@class” といったキーは、@JsonTypeInfo アノテーションの property 属性で変更できます。セキュリティ面からも、この属性を使ってキーを変更しておいた方が良いでしょう。
Jackson には、ここで紹介しなかった型解決方法もいくつかあります。執筆当初は Serializer や Deserializer を使った型解決についても扱うつもりでしたが、記事が長くなりすぎたので諦めました。
それにしても、Java オブジェクトと JSON のマッピングもO/R マッピング同様で一筋縄ではいかないものです。