もう昔の話になってしまいますが、2010年末頃にJerseyを使ったWeb-APIの実装をしていました。当時はjersey1.0.3だったかと思いますが、オブジェクトのJSONバインディングで困ったことがあります。具体的にはBeanのList型フィールドが要素を持たない、あるいは1件だけ持つというケースで生成されるJSONが受け取り側の期待と異なるという問題です。割とFAQだと思われるのですが、当時はクライアントとの結合時に発覚したので、あわてて色々調べたり試したり、ずいぶん苦労した記憶があります。
本当は何が正解だったのだろうというのが気になったので改めて調べてみることにしました。
問題
やりたいこと
- Jersey1.0.3(2010年当時の最新)を利用してWeb-APIサーバを立てる
- APIの出力はXMLとJSONをサポートしたい⇒JAXB Based JSON supportを採用
- 以下のようにあるBeanのリストを取得するAPIを、複数それぞれ異なる型のBeanに対して作りたい。
123456789101112// 以下 getter/setter/Constructorは省略@XmlRootElementpublic class MyListHolder {@XmlElementprivate List list;@XmlRootElementpublic static class MyBean {@XmlElementprivate String name;}}
期待したこと
- listの要素が0件の時、JSONの出力:
{"list":[]}
- listの要素が1件の時、JSONの出力:
{"list":[{"name":"aaa"}]}
- listの要素が2件の時、JSONの出力:
{"list":[{"name":"aaa"},{"name":"bbb"}]}
実際の挙動
- listの要素が0件の時、JSONの出力:
{
"list":[]} - listの要素が1件の時、JSONの出力:
{"list":
[{"name":"aaa"}]} - listの要素が2件の時、JSONの出力:
{"list":[{"name":"aaa"},{"name":"bbb"}]}
当時の解決方法
- 要素数0の時
-
リソースのメソッド内で場合分けする。返却するJSONは固定なので、事前に定義しておいて文字型で返却する。
12345678@GETpublic Object list(...) {ListHolder response = ...;if (response.getList().size() == 0) {return "{list:[]}";}return response;} - 要素数1の時
-
ContextResolverを追加してJSON形式の出力方法を設定する。⇒ContextResolverの書き方
※なお、当時はJSON NotationsのNATURALを知らず、Mapped#arraysにList型の要素名を指定して回るという方法を採っていた。 - POJO based JSON binding support
⇒JSONと任意のJavaオブジェクトを相互変換する外部ライブラリに任せる - JAXB based JSON binding support
⇒JAXBの実装がオブジェクトを解析し、出力時にXMLではなくJSON形式に変換する戦略 - Low-level JSON parsing & processing support
⇒JSONに対応するデータ型が定義されており、専用のMessageBodyWriterが出力を行う。本記事では深堀しない
もっと良い解決方法
一番簡単な方法としては以下が良さそうだという結論になりました。ついでに最新のJersey2.16についても確認しました。
Jersey1系
JSONバインディングをJacksonに任せれば良い(設定方法)
Jacksonのデフォルトの挙動は、本記事で注目している問題については期待通りで、JAXB用のアノテーションを残しておけばXMLの出力もサポートされることが確認できました。
Jersey2系
推奨されている、MOXyのJSONサポートを有効にする(設定方法)
MOXyのデフォルトの挙動は、本記事で注目している問題については期待通りでした。MOXyはJAXBの実装の一つなので、JAXB用のアノテーションを残しておけばXMLの出力もサポートされます。
解説
やり方については分かりましたが、どうしてこうするのかを確認します。
JAX-RSにおける、メディアタイプとオブジェクトの対応付けについて
JerseyはJAX-RSのリファレンス実装ということで、そもそも仕様上はどうなっているのかを確認してみました。JAX-RS 2.0によると
The MessageBodyWriter interface defines the contract between the JAX-RS runtime and components that provide mapping services from a Java type to a representation
とあり、javax.ws.rs.ext.MessageBodyWriter
を介して変換が行われるという決まりになっています。従って、JSON形式の出力をどのMessageBodyWriter実装が担っているかを理解する必要があります。
JerseyのJSONサポート方針(1系、2系共通)
ドキュメントによると、以下の3つのアプローチがあるとされています。これらはMessageBodyWriterが何で実装されているかの違いを表すようです。
次に、Jersey1系とJersey2系の実装の違いを確認します。バージョンの違いはこちらにまとまっています。
Jersey1系
昔取った方法はJAXB based JSON binding supportでした。
MessageBodyWriterはcom.sun.jersey.json.impl.provider.entity.JSONRootElementProvider
です。JAXBの実装は通常reference implementationが使われているようです。⇒Jersey1.19が利用するJAXB実装
JSONRootElementProviderはcom.sun.jersey.api.json.JSONConfiguration
に基づいて、javax.xml.bind.Marshaller
(XMLをどのようにシリアライズするかを決める)を提供するという仕組みになっています。つまりJAXB実装の都合で空のListに対するJSON出力が空文字列になってしまう、ということだと思われます。(XMLの出力にlist要素が現れなくなることと対応しているのでしょう)
これに対しJacksonを使うというのはPOJO based JSON binding supportに相当します。
MessageBodyWriterはorg.codehaus.jackson.jaxrs.JacksonJsonProvider
に実装されています。JacksonはJSONとの変換を行うためのライブラリなので期待通りの挙動です。何となく、JAXBのアノテーションがしてあるとJAXB側で処理されるように思い込んでましたが、JSONを出力する時はJacksonが呼び出されるようです。
Jersey2系
MOXyはJAXBの別実装ですが、驚いたことにXMLとJSONを両方期待通りに出力してくれます。
Binding to JSON & XML - Handling Collectionsという記事を参考にサンプルコードを書いてListの要素数を変えながら動作確認してみましたが、Marshallerの切り替えだけで制御できていることが分かります。なお、空のListを出力するかはorg.eclipse.persistence.jaxb.MarshallerProperties.JSON_MARSHAL_EMPTY_COLLECTIONS
というプロパティで設定可能です。デフォルトはTRUE(有効)になっています。
Jersey2系でもJacksonは使えるようですが、MOXyが推奨とされていますし、他の理由がなければこれでOKですね。
以上です。これから最新版を使う人にとっては、そもそも問題に気づかないかも知れませんね。そういう意味では残念ですが、あやふやだったところを確かめられて個人的には満足です。