Public/tip & tech

dom4j -> xpath 예제

quantapia 2013. 2. 18. 11:41

자바 프로그램에서 XML 쿼리하기

Elliotte Rusty Harold, Adjunct Professor, Polytechnic University

2006년 9월 4일

XPath 식은 상세한 Document Object Model(DOM) 네비게이션 코드보다 작성하기가 훨씬 더 쉽습니다. XML 문서에서 정보를 추출하는 가장 빠르고 간단한 방법은 Java™ 프로그램 안에 XPath 식을 삽입하는 것입니다. Java 5에는 XPath로 문서를 쿼리하는 XML 객체-모델 독립형 라이브러리인 javax.xml.xpath 패키지가 포함되었습니다.

누군가에게 우유를 사오라고 시켜야 한다면, 그 사람에게 어떻게 말할 것인가? "우유를 좀 사다주겠니?" 라고 할 것인가? 아니면 "저기 현관문을 통해 이 집을 나가라. 저 길에서 좌회전을 하고 세 블록을 더 걸어라. 오른쪽으로 돌아서 반 블록만 걸어라. 오른쪽으로 돌아서 가게로 들어가라. 4번 통로로 가라. 그 통로에서 5미터를 걸어라. 좌회전 한 다음 우유 병을 집어서 계산대로 가져가라. 계산을 하고 다시 집으로 오라." 정말 웃기지 않은가? 웬만한 성인들이라면 "우유를 사다 주십시오."라는 간단한 부탁에 우유를 사다 줄 정도의 지능은 있다.

쿼리 언어와 컴퓨터 검색은 비슷하다. 데이터베이스 검색을 위해 상세한 로직을 작성하는 것 보다, "Find a copy of Cryptonomicon" 이라고 하는 것이 더 쉽다. 검색 연산은 매우 비슷한 로직을 갖고 있기 때문에 "Find all the books by Neal Stephenson" 같은 문장을 만들 수 있는 일반 언어를 고안하고 특정 데이터 스토어에 대해 쿼리를 처리하는 엔진을 작성할 수 있다.

XPath

많은 쿼리 언어들 중에, Structured Query Language(SQL)는 특정 유형의 관계형 데이터베이스에 최적화 된 언어이다. 기타 쿼리 언어로는 Object Query Language(OQL)와 XQuery가 있다. 하지만 이 글의 주제는 XML 문서를 쿼리하도록 설계된 XPath이다. 예를 들어, 한 문서에서 저자가 Neal Stephenson인 모든 책을 찾으라는 간단한 XPath 쿼리는 다음과 같다.

//book[author="Neal Stephenson"]/title

반대로 같은 정보에 대한 순수 DOM 검색은 <리스트 1>과 같다:

<리스트 1> 저자가 Neal Stephenson인 모든 책 제목을 찾는 DOM 코드

ArrayList result = new ArrayList();

NodeList books = doc.getElementsByTagName("book");

for (int i = 0; i < books.getLength(); i++) {

Element book = (Element)books.item(i);

NodeList authors = book.getElementsByTagName("author");

boolean stephenson = false;

for (int j = 0; j < authors.getLength(); j++) {

Element author = (Element)authors.item(j);

NodeList children = author.getChildNodes();

StringBuffer sb = new StringBuffer();

for (int k = 0; k < children.getLength(); k++) {

Node child = children.item(k);

// really should to do this recursively

if (child.getNodeType() == Node.TEXT_NODE) {

sb.append(child.getNodeValue());

}

}

if (sb.toString().equals("Neal Stephenson")) {

stephenson = true;

break;

}

}

if (stephenson) {

NodeList titles = book.getElementsByTagName("title");

for (int j = 0; j < titles.getLength(); j++) {

result.add(titles.item(j));

}

}

}

<리스트 1>의 DOM 코드는 간단한 XPath 식 만큼 포괄적이거나 강력하지 않다. 어떤 것을 작성, 디버깅, 관리하겠는가? 해답은 명확하다.

하지만 표현적인 특성 그 자체로 보자면 XPath는 자바 언어가 아니다. 사실 XPath는 완전한 프로그래밍 언어가 아니다. XPath로 많은 것을 표현할 수 없다. 쿼리도 예외는 아니다. 예를 들어, XPath는 International Standard Book Number(ISBN) 식별 번호가 맞지 않거나 로열티를 지불해야 하는 모든 저자들을 찾을 수 없다. 다행히도, XPath와 자바 프로그램을 통합할 수 있다. 자바의 장점과 XPath의 장점만을 취할 수 있는 것이다.

최근까지, 자바 프로그램이 XPath 쿼리를 만들 때 사용했던 정확한 애플리케이션 프로그램 인터페이스(API)는 XPath 엔진에 따라 변했다. Xalan은 하나의 API를, Saxon은 또 다른 API를, 또 다른 엔진은 다른 API를 갖고 있었다. 이상적으로는 다양한 퍼포먼스 특성을 갖춘 다양한 엔진들을 사용하고 싶을 것이다.

이러한 이유 때문에 Java 5에서는 javax.xml.xpath 패키지를 도입하여 엔진과 객체 모델에 독립적인 XPath 라이브러리를 선보였다. 이 패키지는 XML Processing(JAXP) 1.3용 자바 API를 설치한다면 Java 1.3과 이후 버전에서도 사용할 수 있다. 다른 제품들 중에서도, Xalan 2.7과 Saxon 8에는 이 라이브러리가 포함되어 있다.


간단한 예제

이것이 실제로 어떻게 작동하는지를 먼저 설명한 다음 세부적으로 들어가 보도록 하겠다. Neal Stephenson이 집필한 도서 리스트를 쿼리한다고 가정해 보자. 특별히, 그 리스트는 <리스트 2>의 형식으로 되어 있다:

<리스트 2> 도서 정보를 포함하고 있는 XML 문서

<inventory>

<book year="2000">

<title>Snow Crash</title>

<author>Neal Stephenson</author>

<publisher>Spectra</publisher>

<isbn>0553380958</isbn>

<price>14.95</price>

</book>

<book year="2005">

<title>Burning Tower</title>

<author>Larry Niven</author>

<author>Jerry Pournelle</author>

<publisher>Pocket</publisher>

<isbn>0743416910</isbn>

<price>5.99</price>

</book>

<book year="1995">

<title>Zodiac</title>

<author>Neal Stephenson</author>

<publisher>Spectra</publisher>

<isbn>0553573862</isbn>

<price>7.50</price>

</book>

<!-- more books... -->

</inventory>

모든 책을 찾는 XPath 쿼리는 간단하다.

//book[author="Neal Stephenson"]

책들의 제목을 찾으려면 한 단계만 더 추가하면 되고, 식은 다음과 같다.

//book[author="Neal Stephenson"]/title

마지막으로 여러분이 진정으로 원하는 것은 title 엘리먼트의 텍스트 노드 자식들이다. 여기에는 한 단계가 더 필요하다. 그래서 전체 식은 다음과 같이 된다.

//book[author="Neal Stephenson"]/title/text()

추상 팩토리

XPathFactory는 추상 팩토리이다. 추상 팩토리 디자인 패턴에서는 하나의 API가 DOM, JDOM, XOM 같은 다양한 객체 모델을 지원한다. 다양한 모델을 선택하려면 객체 모델을 구분하는 Uniform Resource Identifier(URI)를 XPathFactory.newInstance() 메서드로 전달한다. 예를 들어, http://xom.nu/는 XOM을 채택한다. 하지만 실제로, 현재까지 이 API가 지원하는 객체 모델은 DOM이 유일하다.

이제, 자바 언어에서 이 검색을 실행하고, 검색된 모든 책들의 제목을 프린트하는 간단한 프로그램을 만들 것이다. 우선, 문서를 DOM Document 객체로 문서를 로딩해야 한다. 이 문서가 현재 실행 디렉터리의 books.xml 파일에 있는 것으로 간주한다. 다음은 문서를 파싱하고 상응하는 Document 객체를 만드는 코드이다.

<리스트 3> JAXP로 문서 파싱하기

DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();

factory.setNamespaceAware(true); // never forget this!

DocumentBuilder builder = factory.newDocumentBuilder();

Document doc = builder.parse("books.xml");

지금까지는, 단순한 표준 JAXP와 DOM에 불과하다. 새로운 것은 아무것도 없다.

다음에는 XPathFactory를 만든다:

XPathFactory factory = XPathFactory.newInstance();

이 팩토리를 사용하여 XPath 객체를 만든다:

XPath xpath = factory.newXPath();

XPath 객체가 XPath 식을 컴파일 한다:

PathExpression expr = xpath.compile("//book[author='Neal Stephenson']/title/text()");

마지막으로 XPath 식을 계산하여 결과를 얻는다. 이 식은 특정 컨텍스트 노드에 따라 계산된다. 이 경우에는 전체 문서이다. 리턴 유형을 지정하는 것 역시 필수이다. 여기에서 node-set을 요청한다:

Object result = expr.evaluate(doc, XPathConstants.NODESET);

Immediate evaluation

XPath 식을 단 한번만 사용하려고 한다면 컴파일 단계를 건너 뛰고 대신 XPath 객체에 evaluate() 메서드를 호출한다. 하지만 같은 식을 여러 번 재사용 하면 컴파일이 더 빠르다.

결과를 DOM NodeList에 던지고 이것을 반복하여 모든 제목을 찾는다:

NodeList nodes = (NodeList)result;

for (int i = 0; i < nodes.getLength(); i++) {

System.out.println(nodes.item(i).getNodeValue());

}

<리스트 4>는 이 모든 것을 하나의 프로그램으로 모은 것이다. 이러한 방식으로 throws 문에서 선언해야 하는 여러 예외들을 던질 수 있다.

<리스트 4> 고정된 XPath 식으로 XML 문서를 쿼리하는 전체 프로그램

import java.io.IOException;

import org.w3c.dom.*;

import org.xml.sax.SAXException;

import javax.xml.parsers.*;

import javax.xml.xpath.*;

public class XPathExample {

public static void main(String[] args) throws ParserConfigurationException,

SAXException, IOException, XPathExpressionException {

DocumentBuilderFactory domFactory = DocumentBuilderFactory.newInstance();

domFactory.setNamespaceAware(true); // never forget this!

DocumentBuilder builder = domFactory.newDocumentBuilder();

Document doc = builder.parse("books.xml");

XPathFactory factory = XPathFactory.newInstance();

XPath xpath = factory.newXPath();

XPathExpression expr =

xpath.compile("//book[author='Neal Stephenson']/title/text()");

Object result = expr.evaluate(doc, XPathConstants.NODESET);

NodeList nodes = (NodeList)result;

for (int i = 0; i < nodes.getLength(); i++) {

System.out.println(nodes.item(i).getNodeValue());

}

}

}

XPath 데이터 모델

XPath와 자바 처럼, 다른 언어들을 혼합할 때 마다 두 개를 하나로 붙인 곳에 뚜렷한 흠집이 생기기 마련이다. 모든 것이 딱 맞지는 않는다. XPath와 자바 언어는 동일한 유형 시스템을 갖고 있지 않다. XPath 1.0은 단지 네 개의 기본 데이터 유형들만 갖고 있다:

  • node-set
  • number
  • boolean
  • string

물론, 자바는 사용자 정의 객체 유형을 비롯하여 훨씬 더 많이 갖고 있다.

대부분의 XPath 식, 특히 위치 경로는 node-set을 리턴한다. 하지만 다른 가능성도 있다. 예를 들어,

XPath 식 count(//book)

이 문서에 있는 책의 수를 리턴한다.

XPath 식 count(//book[@author="Neal Stephenson"]) > 10

부울(Boolean)을 리턴한다. 문서에 Neal Stephenson이 지은 책이 10권 이상이면 true이고 10권보다 적으면 false이다.

evaluate() 메서드는 Object를 리턴하도록 선언된다. 이것이 실제로 수행하는 것은 XPath 식의 결과에 따라, 그리고 요청한 유형에 따라 리턴하는 것이다. 일반적으로 XPath는,

  • 숫자를 java.lang.Double로 매핑한다.
  • 스트링을 java.lang.String으로 매핑한다.
  • 부울을 java.lang.Boolean으로 매핑한다.
  • node-set을 org.w3c.dom.NodeList로 매핑한다.

자바로 XPath 식을 계산할 때 두 번째 인자가 원하는 리턴 유형을 지정한다. 다섯 개의 가능성이 있는데, 모두 javax.xml.xpath.XPathConstants 클래스에 있는 네임드 상수이다:

  • XPathConstants.NODESET
  • XPathConstants.BOOLEAN
  • XPathConstants.NUMBER
  • XPathConstants.STRING
  • XPathConstants.NODE

마지막에 XPathConstants.NODE는 XPath 유형과 맞지 않는다. XPath 식이 하나의 노드만을 리턴한다는 것을 알고 있거나 단 한 개의 노드만 사용할 때 사용한다. XPath 식이 한 개 이상의 노드를 리턴하고 XPathConstants.NODE를 지정했다면 evaluate() 메서드는 이 문서 순서에서 첫 번째 노드를 리턴한다. XPath 식이 비어있는 세트를 선택하고 XPathConstants.NODE를 지정했다면 evaluate() 메서드는 null을 리턴한다.

변환이 되지 않으면 evaluate() 메서드는 XPathException을 발생시킨다.

XPath 2

지금까지, 여러분은 XPath 1.0을 사용했다. XPath 2는 유형 시스템을 많이 개정하고 확장했다. XPath 2를 지원하기 위해 자바 XPath API에 생긴 주요 변화라고 하면 새로운 XPath 2 유형들을 리턴하는 추가 상수들이다.


네임스페이스 컨텍스트

XML 문서의 엘리먼트들이 네임스페이스에 있다면 그 문서를 쿼리하는 XPath 식은 같은 네임스페이스를 사용해야 한다. XPath 식은 같은 접두사를 사용할 필요가 없고 같은 네임스페이스 URI만 사용하면 된다. 정말로, XML 문서가 디폴트 네임스페이스를 사용할 때 XPath 식은 접두사를 사용해야 한다. 심지어, 목표 문서가 그렇지 않더라도 말이다.

하지만, 자바 프로그램들은 XML 문서가 아니기 때문에 정상적인 네임스페이스 방식이 적용되지 않는다. 대신 접두사를 네임스페이스 URI로 매핑하는 객체가 사용된다. 이 객체는 javax.xml.namespace.NamespaceContext 인터페이스의 인스턴스이다. 예를 들어 책 문서가 http://www.example.com/books 네임스페이스에 있으면 <리스트 5> 처럼 된다:

<리스트 5> 기본 네임스페이스를 사용하는 XML 문서

<inventory xmlns="http://www.example.com/books">

<book year="2000">

<title>Snow Crash</title>

<author>Neal Stephenson</author>

<publisher>Spectra</publisher>

<isbn>0553380958</isbn>

<price>14.95</price>

</book>

<!-- more books... -->

</inventory>

Neal Stephenson의 모든 책 제목을 찾는 XPath 식은 이제 //pre:book[pre:author="Neal Stephenson"]/pre:title/text() 처럼 되었다. 하지만 접두사 pre를 http://www.example.com/books URI에 매핑해야 한다. NamespaceContext 인터페이스가 자바 소프트웨어 개발키트(JDK)나 JAXP에 디폴트 구현을 갖고 있지 않다는 것이 약간 의아하지만 어쨌든 없다. 하지만 구현하기도 어렵지 않다. <리스트 6>은 하나의 네임스페이스 구현 방법을 설명한다. xml 접두사를 매핑해야 한다.

<리스트 6> 하나의 네임스페이스를 바인딩 하는 간단한 컨텍스트

import java.util.Iterator;

import javax.xml.*;

import javax.xml.namespace.NamespaceContext;

public class PersonalNamespaceContext implements NamespaceContext {

public String getNamespaceURI(String prefix) {

if (prefix == null) throw new NullPointerException("Null prefix");

else if ("pre".equals(prefix)) return "http://www.example.com/books";

else if ("xml".equals(prefix)) return XMLConstants.XML_NS_URI;

return XMLConstants.NULL_NS_URI;

}

// This method isn't necessary for XPath processing.

public String getPrefix(String uri) {

throw new UnsupportedOperationException();

}

// This method isn't necessary for XPath processing either.

public Iterator getPrefixes(String uri) {

throw new UnsupportedOperationException();

}

}

맵을 사용하여 바인딩을 저장하고 재사용 가능한 네임스페이스 컨텍스트에 사용되는 Setter 메서드를 쉽게 추가할 수 있다.

NamespaceContext 객체를 만든 다음, 식을 컴파일 하기 전에 XPath 객체에 이것을 설치한다. 이제부터는 전과 같이 접두사를 사용하여 쿼리 할 수 있다.

<리스트 7> 네임스페이스를 사용하는 XPath 쿼리

XPathFactory factory = XPathFactory.newInstance();

XPath xpath = factory.newXPath();

xpath.setNamespaceContext(new PersonalNamespaceContext());

XPathExpression expr =

xpath.compile("//pre:book[pre:author='Neal Stephenson']/pre:title/text()");

Object result = expr.evaluate(doc, XPathConstants.NODESET);

NodeList nodes = (NodeList)result;

for (int i = 0; i < nodes.getLength(); i++) {

System.out.println(nodes.item(i).getNodeValue());

}


함수 사용

어떤 경우에는, XPath 식에서 사용할 수 있도록 자바에서 확장 기능을 정의하면 유용하다. 이러한 기능은 순수 XPath 만으로 수행하기 불가능한 태스크를 수행한다. 하지만 이들은 모호한 메서드가 아닌 진정한 함수여야 한다. 다시 말해서, 이들은 부작용이 없어야 한다(XPath 함수는 어떤 순서든 여러 번 계산될 수 있다.).

자바 XPath API를 통해 액세스 된 확장 함수들은 javax.xml.xpath.XPathFunction 인터페이스를 구현해야 한다. 이 인터페이스는 하나의 메서드, evaluate를 선언한다:

public Object evaluate(List args) throws XPathFunctionException

이 메서드는 자바 언어가 XPath로 변환될 수 있는 다섯 가지 유형들 중 하나를 리턴한다.

  • String
  • Double
  • Boolean
  • NodeList
  • Node

예를 들어, <리스트 8>은 ISBN에서 체크섬을 확인하고 Boolean을 리턴하는 확장 함수이다. 이 체크섬의 기본 규칙은 첫 번째 9개의 숫자가 각자의 자리수로 곱해진다는 것이다. (다시 말해서 첫 번째 숫자에는 1을 곱하고 두 번째 숫자에는 2를 곱한다.) 이렇게 값들이 추가되고 11로 나눈 후에 나머지 값이 나온다. 나머지가 10이면 마지막 숫자는 X이다.

<리스트 8> ISBN 체크를 위한 XPath 확장 함수

import java.util.List;

import javax.xml.xpath.*;

import org.w3c.dom.*;

public class ISBNValidator implements XPathFunction {

// This class could easily be implemented as a Singleton.

public Object evaluate(List args) throws XPathFunctionException {

if (args.size() != 1) {

throw new XPathFunctionException("Wrong number of arguments to valid-isbn()");

}

String isbn;

Object o = args.get(0);

// perform conversions

if (o instanceof String) isbn = (String)args.get(0);

else if (o instanceof Boolean) isbn = o.toString();

else if (o instanceof Double) isbn = o.toString();

else if (o instanceof NodeList) {

NodeList list = (NodeList)o;

Node node = list.item(0);

// getTextContent is available in Java 5 and DOM 3.

// In Java 1.4 and DOM 2, you'd need to recursively

// accumulate the content.

isbn = node.getTextContent();

}

else {

throw new XPathFunctionException("Could not convert argument type");

}

char[] data = isbn.toCharArray();

if (data.length != 10) return Boolean.FALSE;

int checksum = 0;

for (int i = 0; i < 9; i++) {

checksum += (i + 1) * (data[i] - '0');

}

int checkdigit = checksum % 11;

if (checkdigit + '0' == data[9] || (data[9] == 'X' && checkdigit == 10)) {

return Boolean.TRUE;

}

return Boolean.FALSE;

}

}

다음 단계는 자바 프로그램에 사용할 수 있는 확장 함수를 만드는 것이다. 이를 위해 식을 컴파일 하기 전에 XPath 객체에 javax.xml.xpath.XPathFunctionResolver를 설치한다. Resolver 함수는 XPath 이름과 이 함수에 대한 네임스페이스 URI를 이 함수를 구현하는 자바 클래스로 매핑한다. <리스트 9>는 http://www.example.com/books 라는 네임스페이스를 가진 확장 함수 valid-isbn을 <리스트 8>로 매핑하는 모습이다. 예를 들어, XPath 식 //book[not(pre:valid-isbn(isbn))]이 ISBN 체크섬이 맞지 않는 모든 책을 찾는다.

<리스트 9> 유효 isbn 확장 함수를 인식하는 함수 컨텍스트

import javax.xml.namespace.QName;

import javax.xml.xpath.*;

public class ISBNFunctionContext implements XPathFunctionResolver {

private static final QName name =

new QName("http://www.example.com/books", "valid-isbn");

public XPathFunction resolveFunction(QName name, int arity) {

if (name.equals(ISBNFunctionContext.name) && arity == 1) {

return new ISBNValidator();

}

return null;

}

}

확장 함수들은 네임스페이스에 있어야 하므로 확장 함수를 포함하고 있는 식을 계산할 때 NamespaceResolver를 사용해야 한다. 쿼리되는 문서가 네임스페이스를 전혀 사용하지 않더라도 말이다. XPathFunctionResolver, XPathFunction, NamespaceResolver는 인터페이스이기 때문에 이들을 같은 클래스에 둘 수 있다.


맺음말

SQL과 XPath 같은 선언적 언어에서 쿼리를 작성하는 것이 자바와 C 같이 명령형 언어를 사용하는 것 보다 더 쉽다. 자바와 C 같은 완벽한 언어로 복잡한 로직을 작성하는 것이 SQL과 XPath 같은 선언적 언어로 하는 것 보다 훨씬 쉽다. 다행히도 XPathFunctionResolver, XPathFunction, NamespaceResolver 같은 API를 사용하여 두 개를 혼합하는 것도 가능하다. 더 많은 데이터들이 XML로 옮겨지면서 javax.xml.xpath가 java.sql 만큼 중요해 질 것이다.


필자소개

Elliotte Rusty Harold는 New Orleans 태생이다. 그의 아내 Beth와 고양이 Charm 그리고 Marjorie와 함께 Brooklyn 근처 Prospect Heights에 살고 있다. Polytechnic University의 조교수로서 자바와 객체 지향 프로그래밍을 강의하고 있다. 그의 카페인 Cafe au Lait 웹 사이트는 가장 인기 있는 자바 사이트가 되었고, Cafe con Leche는 가장 대중적인 XML 사이트가 되었다. Effective XML, Processing XML with Java, Java Network Programming, Java I/O, 2nd edition을 집필했다. 그는 현재 XML 프로세스용 XOM API, Jaxen XPath 엔진 Jester 테스트 툴 작업을 하고 있다. 그는 9월에 보스턴에서 Java at Software Development Best Practices에 대한 연설도 할 예정이다.

[출처] Java - XPath API|작성자 아르미즈