JavaのXPath実装の比較

いままで日本語のAPIドキュメントがあるという理由でjavax.xml.xpath.XPathを使っていたのだけれども、隣の芝生であるところのorg.apache.xpath.XPathAPIが異様に青く見えて仕方ないので違いを調べてみた。

一番の違いは、前者はファクトリクラスで生成したオブジェクトを使ってXPath式を評価するのに対し、後者はスタティックメソッドを使ってXPath式を評価する点だと思った。あとはメソッドに渡す引数の型が java.lang.Objectorg.w3c.dom.Node かの違いとか、返ってくるのが java.lang.Objectorg.w3c.dom.NodeList かの違いとかそんなところ。ちょっと細かく見ていってみよう。

こういうXMLを処理させたい。

<?xml version='1.0' encoding='UTF-8' ?>
<diary>
  <title>へんな日記</title>
  <entry date="2007-05-04T20:46:02+09:00">
    <title>朝起きて夜寝た</title>
    <p>朝起きたけど、ただの人間には興味がないので夜に寝た。</p>
  </entry>
  <entry date="2007-05-04T20:47:39+09:00">
    <title>朝食べて夜食べた</title>
    <p>朝食べたけど、まだその域に達していないので夜にも食べた。</p>
  </entry>
</diary>

どちらのライブラリを使うにせよ、まずXMLをパースしてDOMにする。

DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
Document document = builder.parse(xml);
Element root = doc.getDocumentElement();

javax.xml.xpath.XPath の場合はファクトリクラスを使ってXPathオブジェクトを作る。

XPathFactory factory = XPathFactory.newInstance();
XPath xpath = factory.newXPath();

org.apache.xpath.XPathAPI は静的メソッドを呼ぶので事前準備はない。

単一ノードの値の取得

diary要素直下のtitle要素の値を取得してみる。

javax.xml.xpath.XPathの場合はevaluateメソッドを使う。

System.out.println(xpath.evaluate("/diary/title", root));

org.apache.xpath.XPathAPIの場合はevalメソッドを使う。

System.out.println(XPathAPI.eval(root, "/diary/title"));

複数ノードの繰り返し処理

entry要素について、その子要素のp要素の値を取得してみる。

javax.xml.xpath.XPathの場合はevaluateメソッドを使う。

NodeList entries = (NodeList) xpath.evaluate("/diary/entry", root, XPathConstants.NODESET);
for(int i = 0; i < entries.getLength(); i++) System.out.println(xpath.evaluate("p", entries.item(i)));

キャストをしないといけないのが面倒。あと、evaluateの第3引数で返却値の値を指定するくらいなら、最初から違う名前のメソッドにしておけば良いのではないか。それと、Xalanが吐き出すorg.w3c.dom.NodeListオブジェクトはIterableの実装ではないので、拡張Forループを使えない。IterableNodeListの両方を実装したオブジェクトを吐き出してくれるXMLパーサがあればとってもTiger。

entry要素ごとに他にも処理をするならともかく、単にp要素だけを抜き出したいなら、以下のようにすればXPathの評価を1回だけにできる。

NodeList entries = (NodeList) xpath.evaluate("/diary/entry/p", root, XPathConstants.NODESET);
for(int i = 0; i < entries.getLength(); i++) System.out.println(entries.item(i).getFirstChild().getNodeValue());

org.apache.xpath.XPathAPIの場合はselectNodeIteratorメソッドを使う。

NodeIterator it = XPathAPI.selectNodeIterator(root, "/diary/entry");
Node node;
while((node = it.nextNode())!=null) System.out.println(XPathAPI.eval(node, "p"));

selectNodeListメソッドを使っても良い。その場合はjavax.xml.xpath.XPathと大体同じ。

名前空間つきのXMLの場合

と、ここまでだと、org.apache.xpath.XPathAPIの方が使いやすい感じがする。XPathのために新たにオブジェクトを生成する必要がないし、ほしいデータ型によって使うメソッドが違っている方がコードを読みやすい。しかし、例のXMLに名前空間を宣言すると、どうもうまくいかなくなるのだ。

以下のような名前空間付きのXMLを処理させることを考える。

<?xml version='1.0' encoding='UTF-8' ?>
<diary xmlns="http://txqz.net/ns/diary#" xmlns:xh="http://www.w3.org/1999/xhtml">
  <title>へんな日記</title>
  <entry date="2007-05-04T20:46:02+09:00">
    <title>朝起きて夜寝た</title>
    <xh:p>朝起きたけど、ただの人間には興味がないので夜に寝た。</xh:p>
  </entry>
  <entry date="2007-05-04T20:47:39+09:00">
    <title>朝食べて夜食べた</title>
    <xh:p>朝食べたけど、まだその域に達していないので夜にも食べた。</xh:p>
  </entry>
</diary>

javax.xml.xpath.XPathを使う場合は、setNamespaceContextメソッドで名前空間を指定する。IBMの解説を参考にして、javax.xml.namespace.NamespaceContextを実装する。

import java.util.Iterator;
import java.util.Map;

import javax.xml.XMLConstants;
import javax.xml.namespace.NamespaceContext;

class NamespaceContextImpl implements NamespaceContext{
  private Map<String,String> nsMap;
  public NamespaceContextImpl(Map<String,String> nsMap){
    this.nsMap = nsMap;
  }
  public String getNamespaceURI(String prefix) {
    if(prefix == null) throw new NullPointerException("Null prefix");
    if(nsMap.containsKey(prefix)) return nsMap.get(prefix);
    return XMLConstants.NULL_NS_URI;
  }
  public String getPrefix(String uri) {
    throw new UnsupportedOperationException();
  }
  public Iterator getPrefixes(String namespaceURI) {
    throw new UnsupportedOperationException();
  }
}

で、XMLを読むときに;

DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(new InputSource(new StringReader(xml)));
Element root = doc.getDocumentElement();
XPathFactory xFactory = XPathFactory.newInstance();
XPath xpath = xFactory.newXPath();
xpath.setNamespaceContext(getNamespaceContext(doc, "txqz"));

getNamespaceContextメソッドの中身はこんな感じ。

private NamespaceContext getNamespaceContext(Document doc, String defaultPrefix){
  Map<String,String> nsMap = new HashMap<String,String>();
  Element root = doc.getDocumentElement();
  NamedNodeMap attrs = root.getAttributes();
  String xmlns = "xmlns";
  for(int i = 0; i < attrs.getLength(); i++){
    Node attr = attrs.item(i);
    String[] name = attr.getNodeName().split(":");
    if(xmlns.equals(name[0]))
      nsMap.put(name.length == 1 ? defaultPrefix : name[1], attr.getNodeValue());
  }
  return new NamespaceContextImpl(nsMap);
}

さっきと同じことをするには下のように名前空間接頭辞をつければよい。

NodeList entries = (NodeList) xpath.evaluate("/txqz:diary/txqz:entry", root, XPathConstants.NODESET);
for(int i = 0; i < entries.getLength(); i++) System.out.println(xpath.evaluate("xh:p", entries.item(i)));

org.apache.xpath.XPathAPIの名前空間付きXMLの処理には問題がある。ドキュメントを見ると、namespaceNodeをあらわすorg.w3c.dom.Nodeオブジェクトを第3引数に取るメソッドは確かに用意されている。そこで以下のように指定してみたがうまくいかない。

Node namespace = XPathAPI.selectSingleNode(root, "//namespace::xh");
System.out.println(XPathAPI.eval(root, "//xh:p[1]", namespace));

xh接頭辞が名前空間に解決されていないよんと言われてしまう。

Exception in thread "main" javax.xml.transform.TransformerException: Prefix must resolve to a namespace: xh
   at org.apache.xpath.compiler.XPathParser.error(XPathParser.java:602)
   at org.apache.xpath.compiler.Lexer.mapNSTokens(Lexer.java:674)
   at org.apache.xpath.compiler.Lexer.tokenize(Lexer.java:397)
   at org.apache.xpath.compiler.Lexer.tokenize(Lexer.java:139)
   at org.apache.xpath.compiler.XPathParser.initXPath(XPathParser.java:143)
   at org.apache.xpath.XPath.<init>(XPath.java:217)
   at org.apache.xpath.XPathAPI.eval(XPathAPI.java:279)
   at net.txqz.xpathapi.XPathAPITest.main(XPathAPITest.java:56)

一応、述語を使えばやりたいことはできる。

System.out.println(XPathAPI.eval(root, "//*[local-name()='p' and namespace-uri()='http://www.w3.org/1999/xhtml'][1]"));

でも、いかにも長ったらしくてあんまり使いたくない。定数にしてしまう手もあるが、どうもねぇ。

困って検索してみたらXML & SOA にそれっぽい書き込みがあった

namespaceとそのprefixを設定するには、PrefixResolverインターフェイスを継承して、getNamespaceForPrefix メソッドを実装します。そして、それをXPathAPIに渡します。

ということでorg.xml.utils.PrefixResolverを実装して以下のようなクラスを作った。

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

import javax.xml.XMLConstants;

import org.apache.xml.utils.PrefixResolver;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;

public class PrefixResolverImpl implements PrefixResolver {
  private Map<String,String> nsMap;
  public PrefixResolverImpl(Element root, String defaultPrefix){
    nsMap = new HashMap<String,String>();
    NamedNodeMap attrs = root.getAttributes();
    String xmlns = "xmlns";
    for(int i = 0; i < attrs.getLength(); i++){
      Node attr = attrs.item(i);
      String[] name = attr.getNodeName().split(":");
      if(xmlns.equals(name[0]))
        nsMap.put(name.length == 1 ? defaultPrefix : name[1], attr.getNodeValue());
    }
  }
  public String getNamespaceForPrefix(String prefix) {
    if(prefix == null) throw new NullPointerException("Null prefix");
    if(nsMap.containsKey(prefix)) return nsMap.get(prefix);
    return XMLConstants.NULL_NS_URI;
  }
  public String getNamespaceForPrefix(String arg0, Node arg1) {
    throw new UnsupportedOperationException();
  }
  public String getBaseIdentifier() {
    throw new UnsupportedOperationException();
  }
}

元ソースはこんな感じ。

DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(new InputSource(new StringReader(xml)));
Element root = doc.getDocumentElement();
PrefixResolver resolver = new PrefixResolverImpl(root, "txqz");
System.out.println(XPathAPI.eval(root, "/txqz:diary/txqz:entry/xh:p[1]",resolver));

一応これで良いみたいだけど、面倒くさすぎだろう常識的に考えて……。ていうか何でPrefixResolverをインプリメントしたオブジェクトをevalとかの第3引数に入れれるの? ここに入るのはNodeじゃないの? 割り切れないものを感じつつ、とりあえずこれくらいで終わり。