Java: Jackson で子から親へたどれるようにするには

JSON などのデータを読み書きするためのライブラリとして、Java でよく使われるライブラリのひとつに Jackson がある。

Jackson Project Home @github
https://github.com/FasterXML/jackson

単純な内容であれば、POJO さえ用意すれば簡単に読み書きが行えて使いやすいのだが、もう少し複雑な要件もアノテーションを使って表現できるようになっている。そのうちの 1 つに、親子間の双方向参照がある。

単純な POJO によるツリー

ここで言う親子関係というのは、親クラスと子クラスというような継承関係ではなく、ディレクトリとサブディレクトリ、あるいはディレクトリとファイルといった、包含関係のことである。
まずは、ID と名前を持った親・子それぞれの POJO を定義し、親には子のリストを持つ場合を考えてみよう。
以下に、POJO に値を入れて親子関係を作り、Jackson でコンソールに出力する例を示す。なお、この例では JUnit の @Test アノテーションLombok の @Data アノテーションを使っているが、手軽に試せるようにするためだけであって、普通に setter/getter を手書きしたり、main メソッドから実行しても構わない。あくまでポイントは、親が子のリストを持っているということだ。

	@Data
	static class Parent1 {
		private int id;
		private String name;
		private List<Child1> children;
	}

	@Data
	static class Child1 {
		private int id;
		private String name;
	}

	@Test
	public void simple() throws IOException {
		Child1 c11 = new Child1();
		c11.setId(11);
		c11.setName("シンエイブ");

		Child1 c12 = new Child1();
		c12.setId(12);
		c12.setName("センエイブ");

		Parent1 p1 = new Parent1();
		p1.setId(1);
		p1.setName("クンエイブ");
		p1.setChildren(Arrays.asList(c11, c12));

		new ObjectMapper().writer().with(SerializationFeature.INDENT_OUTPUT)
				.writeValue(System.out, p1);
	}

実行すると、以下のような結果がコンソールに出力される。子のリストが配列としてシリアライズされたことがわかる。本題でないので割愛するが、この JSON を Jackson でシリアライズすると、きちんと元のオブジェクトツリーが復元される。

{
  "id" : 1,
  "name" : "クンエイブ",
  "children" : [ {
    "id" : 11,
    "name" : "シンエイブ"
  }, {
    "id" : 12,
    "name" : "センエイブ"
  } ]
}

子から親を参照するには

前項の出力をデシリアライズすると、親が子のリストを持っている状態が復元される。つまり、親オブジェクトから子に参照をたどることはできる。一方で、子から親に参照をたどることができない。
Jackson では、この問題に対処するため、@JsonManagedReference および @JsonBackReference というアノテーションが用意されている。使い方は簡単で、親側のメンバに @JsonManagedReference、子側のメンバに @JsonBackReference を付与するだけでいい。

	@Data
	static class Parent {
		// 略

		@JsonManagedReference
		private List<Child> children;
	}

	@Data
	static class Child {
		// 略

		@JsonBackReference
		private Parent parent;
	}

デシリアライザは、@JsonBackReference が付与されたプロパティを見つけると、自動的に親をセットしてくれる。シリアライザはこのアノテーションが付いているプロパティを無視するので、出力された JSON に余計なデータが出力されることはない。
これによって何が嬉しいかと言うと、子を起点にして親や兄弟といった他のノードをたどり、情報を集めることができるようになる。
前項の例を少しいじって、次のようなコードを考えてみる。

	@Data
	static class Parent2 {
		private int id;
		private String name;
		@JsonManagedReference
		private List<Child2> children;
	}

	@Data
	static class Child2 {
		private int id;
		private String name;
		@JsonBackReference
		private Parent2 parent;

		@JsonIgnore
		public Stream<Child2> brothers() {
			return this.getParent().getChildren().stream()
					.filter(c -> c != this);
		}
	}

	@Test
	public void bidi() throws IOException {
		ObjectMapper mapper = new ObjectMapper();
		String json;

		{
			Child2 c21 = new Child2();
			c21.setId(21);
			c21.setName("Flopsy");

			Child2 c22 = new Child2();
			c22.setId(22);
			c22.setName("Mopsy");

			Child2 c23 = new Child2();
			c23.setId(23);
			c23.setName("Cotton-tail");

			Child2 c24 = new Child2();
			c24.setId(24);
			c24.setName("Peter");

			Parent2 p2 = new Parent2();
			p2.setId(2);
			p2.setName("Mrs. Rabbit");
			p2.setChildren(Arrays.asList(c21, c22, c23, c24));

			json = mapper.writer().with(SerializationFeature.INDENT_OUTPUT)
					.writeValueAsString(p2);
			System.out.println(json);
			System.out.println();
		}

		{
			Parent2 p2 = mapper.reader(Parent2.class).readValue(json);
			System.out.printf("親: %s%n", p2.getName());

			Child2 c24 = p2.getChildren().get(3);
			System.out.printf("4人目の子: %s%n", c24.getName());
			System.out.printf("4人目の子の親: %s%n", c24.getParent().getName());
			System.out.printf(
					"4人目の子の兄弟: %s%n",
					c24.brothers().map(Child2::getName)
							.collect(Collectors.joining(", ")));
		}
	}

子側のクラスに追加した brothers() メソッドは、自分の兄弟を探して Stream として返す。
上記のコードを実行すると、以下の出力が得られる。

{
  "id" : 2,
  "name" : "Mrs. Rabbit",
  "children" : [ {
    "id" : 21,
    "name" : "Flopsy"
  }, {
    "id" : 22,
    "name" : "Mopsy"
  }, {
    "id" : 23,
    "name" : "Cotton-tail"
  }, {
    "id" : 24,
    "name" : "Peter"
  } ]
}

親: Mrs. Rabbit
4人目の子: Peter
4人目の子の親: Mrs. Rabbit
4人目の子の兄弟: Flopsy, Mopsy, Cotton-tail

まず、最初に出力されている JSON が「不自然」でないことがわかる。JSON の生成時、使うライブラリやその設定によっては、独自形式のメタ情報が埋め込まれた「不自然」な JSON が生成されることがあり、実際のところ Jackson でもそのようなケースがあるのだが、ここではそのような様子はない。そして、「4人目の子の親」「4人目の子の兄弟」が正しく表示されていることから、子から親への参照が利用できていることがわかる。素晴らしい。
Java のコードを再度見てもらいたいのだが、最初にオブジェクトツリーを作成した際には、子の setParent() メソッドを呼んでいない。だからシリアライズの時点では null になっているはずである。さらに、JSON には "parent" という項目がないことと、にも関わらずデシリアライズして Java オブジェクトを復元した時点では getParent() で参照をたどれることから、シリアライザには無視されていること、子に対して親への参照をセットしているのはデシリアライザであることが確認できる。
細かいことを言うと、シリアライズ前とデシリアライズ後で、オブジェクトの内容が少しだけ異なることになる。そのことが問題になる場合には、シリアライズ前のオブジェクトに対して自分で setParent() を呼んで明示的に親への参照をセットしておいてもいいが、いずれにしてもシリアライズの際には無視される。
なお、上記の brothers() のように、ツリー内の他のオブジェクトを返すメソッドを追加する場合、何もしないとこのメソッドもシリアライズ対象として認識されてしまう。結果、循環参照が発生し、シリアライズに失敗する。ここでは @JsonIgnore アノテーションを付与して単純に処理対象から除外した。Jackson には循環参照を防ぐための仕組みも用意されているので、そちらを使って対処することも可能だろう。

参考ページ

Feature: Handle bi-directional references using declarative method(s)
http://wiki.fasterxml.com/JacksonFeatureBiDirReferences
ビアトリクスの描いたキャラクターたち
http://www.peterrabbit-japan.com/characters/index.html