Jackson:継承クラスを扱う方法

2020年2月7日

JSONを使ってクライアント・サーバ間の通信を行ったり、データ永続化したりする場合に必ず困るはめになることが「JSONは継承を扱えない」点である。

例えば、以下のようなクラス構造があり、FooもBarもTopとして扱いたい。

interface Top {}
class Foo implements Top {}
class Bar implements Bar {}
Top top = new Foo(); 
String json = topをシリアライズしてJSONとする
Foo foo = (Foo)jsonからデシリアライズしてFooに戻す。

このような操作はそのままではできない。JSON化した時に「どのクラスをシリアライズしたものなのか」という情報が入らないからである。

JSON化文字列に元クラスの目印を入れる

ここではJacksonのアノテーションによる例を見てみる。Jacksonには対象クラスにアノテーションせずとも、これを行う機能があるのだが、ここでは都合上アノテーションによるもののみとする。


import static org.junit.Assert.*; import org.junit.*; import com.fasterxml.jackson.annotation.*; import com.fasterxml.jackson.annotation.JsonAutoDetect.*; import com.fasterxml.jackson.annotation.JsonInclude.*; import com.fasterxml.jackson.annotation.JsonSubTypes.*; import com.fasterxml.jackson.annotation.JsonTypeInfo.*; import com.fasterxml.jackson.databind.*; public class SubTypeTest { @JsonTypeInfo(use=Id.NAME) @JsonSubTypes({ @Type(name="F", value=Foo.class), @Type(name="B", value=Bar.class) }) static class Top { } static class Foo extends Top { int a; int b; Foo() {} Foo(int a, int b) { this.a = a; this.b = b; } @Override public String toString() { return "Foo:" + a + "," + b; } } static class Bar extends Top { String x; String y; Bar() {} Bar(String x, String y) { this.x = x; this.y = y; } @Override public String toString() { return "Bar:" + x + "," + y; } } static ObjectMapper mapper = new ObjectMapper() .setVisibility(PropertyAccessor.ALL, Visibility.NONE) .setVisibility(PropertyAccessor.FIELD, Visibility.ANY) .setSerializationInclusion(Include.NON_NULL) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); ; @Test public void test() throws Exception { { Top top = new Foo(123, 456); String json = mapper.writeValueAsString(top); assertEquals("{\"@type\":\"F\",\"a\":123,\"b\":456}", json); assertEquals("Foo:123,456", mapper.readValue(json, Top.class).toString()); } { Top top = new Bar("abc", "xyz"); String json = mapper.writeValueAsString(top); assertEquals("{\"@type\":\"B\",\"x\":\"abc\",\"y\":\"xyz\"}", json); assertEquals("Bar:abc,xyz", mapper.readValue(json, Top.class).toString()); } } }

見ての通り、Fooをシリアライズした後、デシリアライズすればFooに戻り、Barについても同じである。

ここでのミソとしては、シリアライズ後のJSON文字列に付加されている「”@type”:”F”」あるいは「”@type”:”B”」という部分である。この部分で、実際にシリアライズされたのがFooなのかBarなのかを区別している。

そして、これを付加する指示を行っているのが、Topにつけられたアノテーションだ。

  @JsonTypeInfo(use=Id.NAME)
  @JsonSubTypes({ 
    @Type(name="F", value=Foo.class), 
    @Type(name="B", value=Bar.class) 
  })  
  static class Top {
  }

見ての通り、Fooの場合は”F”、Barの場合は”B”としている。このやり方の他に、実際のFull-Qualifiedクラス名称をJSON中に入れ込んでしまう方法等もあるが、やめた方が無難だろう。クラス名称やパッケージ名称を変更できなくなるからだ。

他のオプションとして、JsonTypeInfoに指定できるものとしてはpropertyがある。指定がないと、

{"@type":"F","a":123,"b":456}

などとなるが、これを「@JsonTypeInfo(use=Id.NAME, property=”@”)」などと指定すると、

{"@":"F","a":123,"b":456}

となる。このように、クラス区別項目(?)の名称を指定できる。

他にincludeというプロパティがあり、これは以下のように指定する。

@JsonTypeInfo(use=Id.NAME, include=As.PROPERTY)

デフォルトは、As.PROPETYだが、これをAs.EXTERNAL_PROPERTYとしても、この例では変化が見られなかった。

As.WRAPPER_OBJECTにすると、property値は無視され、以下になる。

{"F":{"a":123,"b":456}}

As.WRAPPER_ARRAYにすると、これもproperty値は無視され、以下になる。

["F",{"a":123,"b":456}]

As.EXISTING_PROPERTYにすると、なぜかクラス選択情報が消えてしまう。何のためにあるのか不明。

{"a":123,"b":456}

これらの動作の違いというのは、おそらくJacksonを使用していない・できないシステムとのデータ交換のためだろう。個人的に最も無難と思われるのは、WRAPPER_OBJECTのような気がする。Jackson側はWRAPPER_OBJECTにしておき、Jacksonのような「継承」を使えないシステム側では、以下のような定義にしておけば良いかと思われる。

class Foo {}
class Bar {}
class Top {
  Foo F;
  Bar B;
}

さらに下位クラスのある場合

さらに下位クラスのある場合でもトップにそれを記述してやれば良いようだ。

import static org.junit.Assert.*;

import org.junit.*;

import com.fasterxml.jackson.annotation.*;
import com.fasterxml.jackson.annotation.JsonAutoDetect.*;
import com.fasterxml.jackson.annotation.JsonInclude.*;
import com.fasterxml.jackson.annotation.JsonSubTypes.*;
import com.fasterxml.jackson.annotation.JsonTypeInfo.*;
import com.fasterxml.jackson.databind.*;

public class SubTypeTest2 {

  @JsonTypeInfo(use = Id.NAME, property="@")
  @JsonSubTypes({ 
    @Type(name = "F", value = Foo.class), 
    @Type(name = "B", value = Bar.class) ,
    @Type(name = "F1", value = Foo1.class), 
    @Type(name = "F2", value = Foo2.class) 
  })  
  static class Top {
  }

  static class Foo extends Top {
    int a;
    int b;
  }

  static class Foo1 extends Foo {
    int c;
  }

  static class Foo2 extends Foo {
    int d;
  }

  static class Bar extends Top {
    String x;
    String y;
  }

  static ObjectMapper mapper = new ObjectMapper()
      .setVisibility(PropertyAccessor.ALL, Visibility.NONE)
      .setVisibility(PropertyAccessor.FIELD, Visibility.ANY)
      .setSerializationInclusion(Include.NON_NULL)
      .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
      .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS);
      ;

  @Test
  public void test()  throws Exception {
    String json;    
    json = mapper.writeValueAsString(new Foo1());
    assertTrue(mapper.readValue(json, Foo.class) instanceof Foo1);

    json = mapper.writeValueAsString(new Bar());
    assertTrue(mapper.readValue(json, Top.class) instanceof Bar);
  }
}

JAX-RSで使用する場合

なぜかJAX-RSでこれを使用した場合に正しくシリアライズ・デシリアライズされないことがあるようだ。原因は全くわかっていない。例えば、以下のようにした場合、

  @Path("/getTops")
  @GET
  public List<Top> getTops() {
    return ....
  }

シリアライズ後のJSONには正しく「クラス種類マーカー」がつかないケースがある(ついてるケースもある)。逆も同じで、

  @Path("/setTops")
  @POST
  public Response setTops(List<Top>list) {
    return ...
  }

とすると、送信側からは正しく「クラス種類マーカー」がついているのに、正しくデシリアライズできないケースがあった。

この場合には、Listをラップするクラスを作る解決策しか現在のところ思いつかない。

class TopList {
  List<Top>list;
}
....
  @Path("/getTops")
  @GET
  public TopList getTops() {
    return ....
  }
  @Path("/setTops")
  @POST
  public Response setTops(TopList topList) {
    return ...
  }