JAXBニッチ技特集: XMLを属性に基づいて特定のサブクラスに非整列化(unmarshal)する

元ネタはStackOverflowのこちらの質問。

inheritance - Java/JAXB: Unmarshall Xml to specific subclass based on an attribute - Stack Overflow

上記の例ではクラス名そのものを属性値として設定しているが、そうではなく「コード値」的な属性でサブクラスが指定される場合にはどうすれば良いか?

どういう場合かというと、例えば以下のようなXMLを非整列化したいとする。

<commandList>
  <command code="C" data="新規作成データ" />
  <command code="D" id="1" />
</commandList>

これはデータ操作を 要素で指定するもので、操作の種類は code 属性で決まる。

  • C(CREATE)なら data 属性の値を使って新規レコードを作成する。
  • D(DELETE)なら id 属性に指定したIDのレコードを削除する。

なので、command 要素は、全ての考えられる属性を持った単一クラスに非整列化するのではなく、

  • code="C" なら CreateCommand クラス
  • code="D" なら DeleteCommand クラス

に非整列化できれば便利だ。

なお、以下の方法で使用するJAXBの実装には、JREに付属のJAXB RIではなくEclipseLink MOXyを使う必要があるので注意*1

方法1: ClassExtractor を使う

以下のようなJavaクラスを作成していく。

commandList/command に対応するクラス

commandList 要素に対応する CommandList クラス。

package example.jaxb.inherit.model;

import java.util.ArrayList;
import java.util.List;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlRootElement;

@XmlRootElement(name = "commandList")
public class CommandList {

    @XmlElement(required = true)
    protected List<Command> command;

    public List<Command> getCommand() {
        if (command == null) {
            command = new ArrayList<Command>();
        }
        return this.command;
    }
}

command 要素の基底クラスである Command クラス。StackOverflow の回答にあるように XmlCustomizer を設定している。CommandCustomizer クラスについては後述。

package example.jaxb.inherit.model;

import javax.xml.bind.annotation.XmlAttribute;
import org.eclipse.persistence.oxm.annotations.XmlCustomizer;
import example.jaxb.inherit.CommandCustomizer;

@XmlCustomizer(CommandCustomizer.class)
public abstract class Command {
// 中身は空
}
command の具象クラス

code="C" の command 要素に対応する CreateCommand クラス。

package example.jaxb.inherit.model;

import javax.xml.bind.annotation.XmlAttribute;

public class CreateCommand extends Command
{
    @XmlAttribute
    protected String data;

    public String getData() {
        return data;
    }

    public void setData(String value) {
        this.data = value;
    }

    public String toString() {
    	return "Create: data=[" + data + "]";
    }
}

code="D" の command 要素に対応する DeleteCommand クラス。

package example.jaxb.inherit.model;

import javax.xml.bind.annotation.XmlAttribute;

public class DeleteCommand extends Command
{
    @XmlAttribute
    protected Integer id;

    public Integer getId() {
        return id;
    }

    public void setId(Integer value) {
        this.id = value;
    }

    public String toString() {
    	return "Delete: id=[" + id + "]";
    }
}
CommandCustomizer

ここがStackOverflowの回答と異なる点。DescriptorCustomizer から属性→クラスのマッピングを設定する方法にはいくつかあるようだが、とりあえず EclipseLink 2.2.0 の時点では以下のように ClassExtractor なるものを使えばうまくいく模様。

package example.jaxb.inherit;

import java.util.HashMap;
import java.util.Map;

import org.eclipse.persistence.config.DescriptorCustomizer;
import org.eclipse.persistence.descriptors.ClassDescriptor;
import org.eclipse.persistence.descriptors.ClassExtractor;
import org.eclipse.persistence.oxm.XMLField;
import org.eclipse.persistence.sessions.Record;
import org.eclipse.persistence.sessions.Session;

import example.jaxb.inherit.model.CreateCommand;
import example.jaxb.inherit.model.DeleteCommand;

public class CommandCustomizer implements DescriptorCustomizer {

    @Override
    public void customize(ClassDescriptor descriptor) throws Exception {
        final Map<String, Class<?>> classMapping = new HashMap<String, Class<?>>();
        classMapping.put("C", CreateCommand.class);
        classMapping.put("D", DeleteCommand.class);

        descriptor.getInheritancePolicy().setClassExtractor(new ClassExtractor() {
            // 対象XML要素を表す Record から、非整列化に使用するクラスを返すメソッドを実装
            @Override
            public Class<?> extractClassFromRow(Record databaseRow, Session session) {
                Object indicator = databaseRow.get(new XMLField("@code"));
                return classMapping.get(indicator);
            }
        });
    }
}
メインクラスその他

Eclipse MOXy を使う必要があるので、上記のJavaクラス群と同じパッケージに jaxb.properties というファイルを作成し、以下の内容を記述する。

javax.xml.bind.context.factory=org.eclipse.persistence.jaxb.JAXBContextFactory

メインクラスは以下のような感じ。特に何の変哲も無い。

package example.jaxb.inherit;

import java.io.InputStream;
import java.net.URL;

import javax.xml.bind.JAXBContext;
import javax.xml.bind.Unmarshaller;
import javax.xml.transform.Source;
import javax.xml.transform.stream.StreamSource;

import example.jaxb.inherit.model.Command;
import example.jaxb.inherit.model.CommandList;

public class Main
{
    public static void main(String[] args) throws Exception
    {
        JAXBContext jaxbContext = JAXBContext.newInstance(
            CommandList.class, CreateCommand.class, DeleteCommand.class);

        URL xmlUrl = Main.class.getClassLoader().getResource("test.xml");
        Source xmlSource = new StreamSource(xmlUrl.toURI().toString());
        
        Unmarshaller unmarshaller = jaxbContext.createUnmarshaller();
        CommandList commands = (CommandList)unmarshaller.unmarshal(xmlSource);

        for (Command command : commands.getCommand()) {
        	System.out.println(command);
        }
    }
}
実行

上記の各クラス(+jaxb.properties)とMOXyに必要なライブラリ、test.xml(最初のXML)をクラスパスに配置し、実行すると

Create: data=[新規作成データ]
Delete: id=[1]

のように表示され、codeの値によって正しいクラスに非整列化されていることが確認できる。

方法2: discriminator を使う

この方法は、方法1よりも簡単で、非整列化だけでなく整列化も可能なので、こちらの方がおすすめ。

commandList/command に対応するクラス

ほぼ方法1の定義と同じだが、Command クラスには XmlCustomizer ではなく、XmlDiscriminatorNode を設定する。

import org.eclipse.persistence.oxm.annotations.XmlDiscriminatorNode;

(中略)

@XmlDiscriminatorNode("@code")
public abstract class Command {
// 中身は空
}

これはORMで継承を「単一テーブル継承 (single table inheritance)」や「クラステーブル継承 (class table inheritance)」で扱う場合に、レコードの具象クラスがどれなのかを示すためのカラム*2XML版になっている。

command の具象クラス

code="C" の command 要素に対応する CreateCommand クラス。親クラス Command に指定した XmlDiscriminatorNode の値が "C" であればこのクラスを使う、ということを XmlDiscriminatorValue で設定する。

import org.eclipse.persistence.oxm.annotations.XmlDiscriminatorValue;

(中略)

@XmlDiscriminatorValue("C")
public class CreateCommand extends Command
{
...
}

DeleteCommand クラスにも同様に @XmlDiscriminatorValue("D") を指定しておく。

メインクラスその他

この方法を使った場合、CommandCustomizer クラスは不要になる。

その他のメインクラス・実行手順は方法1と全く同じ。

この方法では、以下のように整列化も可能。

// 前述のMainクラスの続き
Marshaller marshaller = jaxbContext.createMarshaller();
StringWriter writer = new StringWriter();
marshaller.marshal(commands, writer);
System.out.println(writer.toString());

出力結果:

<?xml version="1.0" encoding="UTF-8"?>
<commandList><command data="hoge" code="C"/><command id="1" code="D"/></commandList>

おまけ: MOXyのアノテーションを付けられない/付けたくない場合

Command/CreateCommand/DeleteCommand クラスに MOXy 固有のアノテーションを付けたくない場合は、以下のようにする。

OXM設定ファイルの作成

以下のような MOXy の OXM(Object-XML-Mapping) 設定ファイルを作成する(これは方法2の場合)。

<?xml version="1.0"?>
<xml-bindings xmlns="http://www.eclipse.org/eclipselink/xsds/persistence/oxm"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://www.eclipse.org/eclipselink/xsds/persistence/oxm http://www.eclipse.org/eclipselink/xsds/eclipselink_oxm_2_3.xsd"
  version="2.3">

  <java-types>
    <java-type name="example.jaxb.inherit.model.Command" xml-discriminator-node="@code" />
    <java-type name="example.jaxb.inherit.model.CreateCommand" xml-discriminator-value="C" />
    <java-type name="example.jaxb.inherit.model.DeleteCommand" xml-discriminator-value="D" />
  </java-types>

</xml-bindings>

上記の内容を command-metadata.xml ファイルとしてクラスパス上に配置適当しておく。

Command/CreateCommand/DeleteCommand の修正

各クラスから、MOXy関連のアノテーション

  • @XmlDiscriminatorNode
  • @XmlDiscriminatorValue

を削除する。

@XmlAttribute 等はそのままでよい。

メインクラスの修正

メインクラスの最初で、以下のように設定を行う。

import org.eclipse.persistence.jaxb.JAXBContextFactory;

(略)

    public static void main(String[] args) throws Exception
    {
        Map<String, Source> metadata = new HashMap<String, Source>();
        // クラスパス上に配置した場合。
        // カレントディレクトリに配置した場合は単に new StreamSource("command-metadata.xml") でよい
        URL metadataUrl = Main.class.getClassLoader().getResource("command-metadata.xml");
        metadata.put(CommandList.class.getPackage().getName(), new StreamSource(metadataUrl.toURI().toString()));
        
        Map<String, Object> properties = new HashMap<String, Object>();
        properties.put(JAXBContextFactory.ECLIPSELINK_OXM_XML_KEY, metadata);
        
        JAXBContext jaxbContext = JAXBContext.newInstance(
                new Class[]{CommandList.class},
                properties);

        // あとは同じ

この辺の詳細については EclipseLink/Examples/MOXy/EclipseLink-OXM.XML - Eclipsepedia を参照。

*1:Maven2なら EclipseLink/Maven - Eclipsepedia を参照

*2:ActiveRecordならtypeカラム、JPAなら@DiscriminatorColumnとか