JAXBのXmlAdapterを自作する

JAX-WSやJAX-RSの登場でよく見かけるようになったJAXB。XMLスキーマで定義されたデータ型とJavaのデータ型の相互変換を行ってくれる、とても便利な仕様です。もちろん単に利用するだけでもその恩恵にあずかることはできるのですが、相互変換の対応をカスタマイズすることでより有効活用することができるのです。

筆者は過去にJAXBのカスタマイズを経験したことがあり、そのときの経験をもとに少しお話ししたいと思います。キーワードは「XmlAdapter」です。

XmlAdapterは、javax.xml.bind.annotation.adapters.XmlAdapterを継承したクラスとして実装します。クラスを宣言するに当たって型パラメータ <XMLと自動的に対応づけられている型, 実際に対応づけたい型> を宣言する必要があります。ここでは例として、XML側の型をxs:date、Java側の型をjavax.time.calendar.OffsetDateの場合を見てゆきます。

javax.time.calendar.OffsetDateは、Java SE 8で導入が予定されている新しい日付・時刻フレームワークの一部です。その仕様はJSR-310で規定されており、参照実装がThreeTenプロジェクトとして開発が進められています。

今回の要件では、XmlAdapterのクラス定義(XmlDateAdapterと命名します)は以下のようになります。

public class XmlDateAdapter 
  extends XmlAdapter<XMLGregorianCalendar, OffsetDate> {
  // snip
}

ここで、XMLスキーマのxs:dateと対応づけられた型がXMLGregorianCalendarという、java.util.Calendarのサブクラスです。標準ではjava.util.Dateやjava.util.Calendarとのマッピングが実装されているのですが、JSR-310はまだドラフト版であることもあってマッピングが提供されていません。そこで独自にマッピングを行うロジックを実装する必要が出てくるわけです。

XmlAdapterにはオーバーライドしなければならない2つの抽象メソッド、marshalとunmarshalがあります。それぞれ次のような働きをするメソッドです。

  • marshalは、XMLへ出力する(=JavaBeansから入力する)操作
  • unmarshalは、XMLから入力する(=JavaBeansへ出力する)操作

marshal/unmarshalは日本ではあまり馴染みのない単語なので、取り違えることのないよう注意してください。

marshal/unmarshalのシグネチャを追加したXmlDateAdapterの定義は以下のようになります。

public class XmlDateAdapter 
  extends XmlAdapter<XMLGregorianCalendar, OffsetDate> {
  @Override
  public XMLGregorianCalendar marshal(OffsetDate value) throws Exception {
    // snip
    return null;
  }
  @Override
  public OffsetDate unmarshal(XMLGregorianCalendar value) throws Exception {
    // snip
    return null;
  }
}

これらのメソッドをうまい具合に実装して、実際のアノテーションで次のように指定すればよいことになります。

<!-- XMLスキーマ 側の記述-->
<xs:element name="systemDate" type="xs:date"></xs:element>
@XmlElement(name = "systemDate", required = true)
@XmlJavaTypeAdapter(XmlDateAdapter.class)
public OffsetDate getSystemDate() {
  return systemDate;  // OffsetDate クラス
}

ここまでの流れはOffsetDateでなく古いDate/Calendarを使用する場合でも同様です。重要なことはmarshalとunmarshalの引数と戻り値の型を取り違えないようにすることです。筆者は慣れるまで次のように覚えました。

  • marshal = JavaBeansから入力(XMLへ出力)
  • unmarshal = JavaBeansへ出力(XMLから入力)
  • XML側の型は変えられない。JavaBeans側の型は変えられる。

では、XmlDateAdapterクラスの実装に入りたいと思います。

import javax.time.calendar.OffsetDate;
import javax.time.calendar.ZoneOffset;
import javax.xml.bind.annotation.adapters.XmlAdapter;
import javax.xml.datatype.DatatypeFactory;
import javax.xml.datatype.XMLGregorianCalendar;

/**
 * XML の日付型と Java の OffsetDate クラスのマッピング情報を定義します。
 */
public class XmlDateAdapter extends XmlAdapter<XmlGregorianCalendar,  OffsetDate> {
  /**
   * JavaBeans のプロパティ値をマッピング規則に基づき XML へ出力します。
   * @param 入力となる OffsetDate オブジェクト
   * @return XML への出力値を表す XMLGregorianCalendar オブジェクト
   * @throws 処理中に例外が発生した場合
   */
  @Override
  public XMLGregorianCalendar marshal(OffsetDate value) throws Exception {
    // javax.time.* は ZoneOffset が時・分・秒の各フィールドを持っているが、
    // XMLGregorianCalendar は分単位のオフセット値で表すため。
    ZoneOffset offset = OffsetDate.now().getOffset();
    int timezone = offset.getHoursField() * 60 + offset.getMinutesField();
    
    // XMLGregorianCalendar のインスタンスを作成する。
    // java.util.Calendar の月は 0-11 だが、XMLGregorianCalendar はなぜか 1-12
    return DatatypeFactory.newInstance().newXMLGregorianCalendarDate(
                value.getYear(),                    // 年
                value.getMonthOfYear().getValue(),  // 月(1-12)
                value.getDayOfMonth(),              // 日
                timezone);                          // タイムゾーン(分)
  }

  /**
   * XML のデータ型をマッピング規則に基づき JavaBeans のプロパティ値を設定します。
   * @param XML からの入力値に対応した XMLGregorianCalendar オブジェクト
   * @return JavaBeans プロパティへの出力値を表す OffsetDate オブジェクト
   * @throws 処理中に例外が発生した場合
   */
  @Override
  public OffsetDate unmarshal(XMLGregorianCalendar value) throws Exception {
    // XMLGregorianCalendar の分単位オフセットを javax.time.* 向けに変換する。
    int timezone = value.getTimezone();
    ZoneOffset offset = ZoneOffset.ofHoursMinutes(timezone / 60, timezone % 60);
    
    // OffsetDate のインスタンスを作成する。
    // java.util.Calendar の月は 0-11 だが、XMLGregorianCalendar はなぜか 1-12
    return OffsetDate.of(
                value.getYear(),   // 年
                value.getMonth(),  // 月
                value.getDay(),    // 日
                offset);           // タイムゾーン(時・分・秒)
  }
}