txqz memo

javax.xml.xpath.XPath vs org.apache.xpath.XPathAPI

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

一番の違いは、前者はファクトリクラスで生成したオブジェクトを使ってXPath式を評価するのに対し、後者はスタティックメソッドを使ってXPath式を評価する点だと思った。あとはメソッドに渡す引数の型がjava.lang.Objectかorg.w3c.dom.Nodeかの違いとか、返ってくるのがjava.lang.Objectかorg.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ループを使えない。IterableとNodeListの両方を実装したオブジェクトを吐き出してくれる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に名前空間を宣言すると、どうもうまくいかなくなるのだ。

javax.xml.xpath.XPath

<?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

問題はこっちを使った場合。ドキュメントを見ると、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じゃないの? 割り切れないものを感じつつ、とりあえずこれくらいで終わり。