XPath 테스터

XPath를 평가해 모든 일치를 보여 주고, 함정도 설명합니다.

입력
대기 중문서를 붙여넣으면 검사합니다. 입력하는 동안 검증이 실행됩니다.

모든 처리는 이 탭 안에서 이루어집니다. 붙여넣은 내용은 업로드되거나 기록되거나 전송되지 않습니다. 네트워크 패널을 열어 확인하세요.

문서를 붙여넣고 식을 입력하면, 일치하는 모든 노드가 종류와, 거기까지 도달하는 경로(/catalog/book[2]/title)와 값과 함께 나열됩니다. 목록 위에는 일치 개수와 평가 시간이 표시됩니다. 평가는 document.evaluate를 통해 브라우저 자체 엔진에서 실행되므로, 이 페이지는 XPath 라이브러리를 전혀 내려보내지 않고 붙여넣은 것도 탭을 벗어나지 않습니다.

이 도구가 필요해지는 때는, 식이 곧 디버깅하기 곤란한 곳으로 들어가려 할 때입니다. Schematron 규칙, XSLT의 match 패턴, Camel 스플리터, 스크래핑 셀렉터 같은 것들이죠. //book이 아무것도 고르지 못한다는 사실을 여기서 아는 편이, 운영에서 조용한 빈 결과로 알게 되는 것보다 쌉니다.

다른 점은 네임스페이스 처리입니다. 무료 테스터 대부분은 document.evaluate에 널 리졸버를 넘기므로, 접두사가 붙은 식은 모두 예외를 던지고 기본 네임스페이스가 있는 문서는 설명 없이 0건을 돌려줍니다. 이 도구는 문서 안의 모든 xmlns 선언을 모아 전부 바인딩하고, 빈 결과를 돌려주기 전에 기본 네임스페이스 함정을 먼저 지목합니다.

아무것도 찾지 못하는 식

이것이 가장 흔한 XPath 실패이며, 증상은 "정말로 그 요소가 없는 경우"와 구별되지 않습니다. 루트에 xmlns="urn:books"가 붙은 문서를 떠올려 보세요. 그 안의 요소 이름은 book이 아닙니다. 확장된 이름은 {urn:books}book입니다. XPath 1.0에는 기본 네임스페이스라는 개념이 없어서 접두사 없는 이름은 "네임스페이스 없음"을 뜻하고, //book은 {}book을 요구합니다. 그런 노드는 없습니다. 0건, 오류 없음.

MDN은 딱 잘라 말합니다. XPath에는 일반 요소 참조에 적용된 기본 네임스페이스를 집어낼 방법이 없다. 대신 그 URI에 직접 접두사를 바인딩하세요. 접두사는 식에 한정되므로, 문서가 xmlns:b="urn:books"라고 적어 두었더라도 x를 바인딩하기만 하면 //x:book이라고 써도 됩니다.

이 페이지는 기본 네임스페이스를 발견하면 거기에 접두사 ns를 바인딩합니다. 그래서 //ns:book이 아무 준비 없이 동작하고, 실제로 찾아낸 URI를 넣어 결과 위에 이 함정을 알려 줍니다. 네임스페이스 입력란은 prefix=uri 쌍으로 직접 바인딩을 받고, 그것이 문서에서 거둔 것보다 우선합니다.

  • 접두사를 바인딩: //ns:book/ns:title. 가장 짧고, 코드에서도 이렇게 쓰고 싶은 형태입니다.
  • 네임스페이스 무시: //*[local-name()="book"]. 어느 네임스페이스의 book과도 일치하며, 리졸버를 직접 제어할 수 없을 때 알아 둘 만합니다.
  • 접두사 없이 정확하게: //*[namespace-uri()="urn:books" and local-name()="book"].
  • 속성은 다릅니다. 기본 네임스페이스는 속성 이름에 절대 적용되지 않으므로, <book xmlns="urn:books" id="7"/>에서 요소는 {urn:books}book이지만 속성은 그냥 {}id입니다. @ns:id가 아니라 @id로 고르세요.

슬래시, 술어, 위치

/는 자식으로 가는 한 걸음입니다. //는 /descendant-or-self::node()/의 축약형이고, 그래서 /catalog/book은 루트 바로 아래의 book 요소만 찾고 //book은 어느 깊이에 있든 찾습니다. 뒤쪽이 더 너그럽지만 큰 문서에서는 상당히 느립니다. 한 노드의 자식이 아니라 모든 노드를 방문하기 때문입니다.

술어의 색인은 0이 아니라 1부터 시작하므로, [0]으로 끝나는 식은 조용히 아무것도 돌려주지 않습니다. 더 미묘한 함정은 술어가 식 전체가 아니라 자기 단계에 붙는다는 점입니다. //book[1]은 "자기 부모 기준으로 첫 번째 book 자식인 모든 book"을 뜻하므로, 카탈로그가 셋인 문서에서는 노드 셋이 돌아옵니다. 전체에서 첫 번째 하나가 필요하면 괄호가 필요합니다. (//book)[1]입니다.

position()과 last()는 문맥, 즉 현재 단계가 만들어 낸 노드 목록에 대한 함수입니다. book[last()]는 각 부모 아래의 마지막 book입니다. 맨 숫자는 [position() = 2]의 축약형이고, 그래서 //book[@lang="en"][1]과 //book[1][@lang="en"]은 서로 다른 집합입니다. 앞의 것은 거른 뒤 하나를 집고, 뒤의 것은 하나를 집은 뒤 거릅니다.

  • child::가 기본 축이므로 book과 child::book은 같은 식입니다.
  • descendant::는 아래쪽을, parent::(..)와 ancestor::는 위쪽을 찾습니다.
  • following-sibling::과 preceding-sibling::은 같은 층에 머무릅니다. "이 title 다음에 오는 price"를 말하는 방법입니다.
  • attribute::는 @로, self::는 축약형에서 .으로 씁니다.
  • namespace::는 명세에 있지만 파이어폭스는 구현하지 않았습니다. 여기에 기대지 마세요.

XPath 1.0에 없는 것

브라우저가 구현한 것은 XPath 1.0뿐입니다. 이 API는 DOM Level 3 XPath에서 왔고, 지금은 폐기된 W3C Note가 되었으며, WHATWG DOM 표준 8절에 남아 있습니다. 2.0을 가진 브라우저는 없고 앞으로도 없습니다. 그래서 이 페이지는 내놓을 수 없는 버전을 광고하는 대신 천장이 어디인지 밝힙니다.

XPath 1.0의 타입은 넷입니다. 노드 집합, 문자열, 숫자, 불리언. 2.0은 그 모델을 시퀀스와 스키마 인식 타이핑으로 바꾸고 사람들이 가장 아쉬워하는 것들을 가져왔습니다. matches(), replace(), tokenize(), 진짜 날짜 타입, for와 if 식. 3.1은 맵과 배열, 그리고 => 화살표를 더했습니다. 여기서는 그 전부가 동작하지 않고, PHP의 DOMXPath, .NET의 XPathNavigator, Java의 javax.xml.xpath도 1.0이라 사정은 같습니다. 가장 자주 쓰게 될 우회책은 tokenize 대신 substring-before와 substring-after, 문자 클래스 대신 translate()입니다.

결과 읽기

모든 식이 노드를 돌려주는 것은 아닙니다. count(//book)은 숫자를, string(/catalog/@id)는 문자열을 돌려주므로, 패널은 네 가지 타입 중 무엇이 돌아왔는지 알려 주고 스칼라는 빈 목록이 아니라 값으로 출력합니다. 스칼라 0과 빈 노드 집합은 대부분의 도구에서 비슷해 보이지만 뜻이 다릅니다.

각 일치 항목은 노드 종류(요소, 속성, 텍스트, CDATA, 주석, 처리 명령), 거기까지의 경로, 그리고 값을 보여 줍니다. 요소라면 직렬화한 XML이고, 속성이라면 속성 값입니다. 일치한 내용은 마크업이 아니라 언제나 텍스트로 페이지에 기록되므로, script 요소가 든 문서라도 무언가를 실행할 수 없습니다. 식을 실행하기 전에 문서는 적격 형식이어야 하고, 바인딩되지 않은 접두사는 사용할 수 있는 접두사와 함께 이름으로 보고되며, 화면에는 처음 1,000건이 그려지지만 목록 위의 개수는 실제 총계입니다.

코드로 같은 일 하기

여섯 가지 모두 XPath 1.0을 구현하며, 여섯 가지 모두 네임스페이스 접두사를 직접 등록하게 합니다. 문서 자체의 접두사가 자동으로 반영되는 일은 결코 없으며, 그래서 모든 예제에 네임스페이스 인자가 등장합니다.

const source = `<?xml version="1.0"?>
<library xmlns="urn:books">
  <book id="b1"><title>XML in a Nutshell</title></book>
</library>`;

const doc = new DOMParser().parseFromString(source, 'application/xml');
if (doc.querySelector('parsererror')) throw new Error('not well-formed');

// document.createNSResolver is deprecated: it now returns its input
// unchanged and is kept only for compatibility. Write the resolver
// yourself. These prefixes are local to the expression and need not
// match the ones the document uses.
const NS = { bk: 'urn:books' };
const resolve = (prefix) => NS[prefix] ?? null;

// Snapshot, not iterator: an iterator result is invalidated by any
// mutation of the document while you are still walking it.
const snap = doc.evaluate(
  '//bk:book[@id="b1"]/bk:title',   // //title would match nothing
  doc,
  resolve,
  XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
  null,
);

for (let i = 0; i < snap.snapshotLength; i++) {
  console.log(snap.snapshotItem(i).textContent);
}
from lxml import etree

# lxml's defaults resolve entities and fetch external DTDs. All three
# flags below are needed to close that off.
parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False)
tree = etree.fromstring(source.encode('utf-8'), parser)

# lxml refuses a None key in the namespaces map, for the same reason the
# spec does: XPath 1.0 cannot address a default namespace. Give it a prefix.
ns = {'bk': 'urn:books'}

for title in tree.xpath('//bk:book/bk:title', namespaces=ns):
    print(title.text)

# An invalid expression raises rather than returning empty.
try:
    tree.xpath('//bk:book[')
except etree.XPathEvalError as e:
    print(f'bad expression: {e}')

# The escape hatch when you cannot register prefixes:
tree.xpath("//*[local-name()='book']")
import javax.xml.XMLConstants;
import javax.xml.namespace.NamespaceContext;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.xpath.*;
import org.w3c.dom.NodeList;
import java.util.Iterator;
import java.util.Map;

DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();

// setNamespaceAware is FALSE by default. Leave it off and every prefixed
// element is treated as one long local name, so bk:book never matches.
// This is the usual cause of "it works in xmllint but not in Java".
dbf.setNamespaceAware(true);
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);

var doc = dbf.newDocumentBuilder()
    .parse(new java.io.ByteArrayInputStream(bytes));

XPathFactory xpf = XPathFactory.newInstance();
xpf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
XPath xpath = xpf.newXPath();

Map<String, String> ns = Map.of("bk", "urn:books");
xpath.setNamespaceContext(new NamespaceContext() {
    public String getNamespaceURI(String prefix) {
        return ns.getOrDefault(prefix, XMLConstants.NULL_NS_URI);
    }
    public String getPrefix(String uri) { return null; }
    public Iterator<String> getPrefixes(String uri) { return null; }
});

NodeList nodes = (NodeList) xpath.evaluate(
    "//bk:book/bk:title", doc, XPathConstants.NODESET);

for (int i = 0; i < nodes.getLength(); i++) {
    System.out.println(nodes.item(i).getTextContent());
}
using System.Xml;
using System.Xml.XPath;

var settings = new XmlReaderSettings
{
    DtdProcessing = DtdProcessing.Prohibit,
    XmlResolver = null,
};

using var reader = XmlReader.Create(new StringReader(source), settings);
var nav = new XPathDocument(reader).CreateNavigator();

// XmlNamespaceManager is not optional. .NET has no default-namespace
// concept in XPath either, so bind a prefix and use it.
var ns = new XmlNamespaceManager(nav.NameTable);
ns.AddNamespace("bk", "urn:books");

foreach (XPathNavigator node in nav.Select("//bk:book/bk:title", ns))
{
    Console.WriteLine(node.Value);
}

// Compile once if the expression is reused: Select() reparses every call.
XPathExpression expr = nav.Compile("count(//bk:book)");
expr.SetContext(ns);
Console.WriteLine((double)nav.Evaluate(expr));
<?php
$doc = new DOMDocument();
// LIBXML_NONET stops libxml2 fetching anything the document references.
if (!$doc->loadXML($source, LIBXML_NONET)) {
    fwrite(STDERR, "not well-formed\n");
    exit(1);
}

$xpath = new DOMXPath($doc);

// Prefixes must be registered even when the document declares them.
// registerNamespace('', ...) is accepted but useless: XPath 1.0 still
// cannot address the empty prefix.
$xpath->registerNamespace('bk', 'urn:books');

foreach ($xpath->query('//bk:book/bk:title') as $node) {
    echo $node->textContent, "\n";
}

// query() returns false on an invalid expression, not an empty list.
// A loose == comparison would read that false as "no results".
$result = $xpath->query('//bk:book[');
if ($result === false) {
    fwrite(STDERR, "invalid XPath expression\n");
}
# xmllint ships with libxml2 and is almost certainly already installed.
# --xpath has no way to register a namespace prefix, so against a
# namespaced document it prints "XPath set is empty" and exits 10.
xmllint --nonet --xpath '//book/title' doc.xml

# The interactive shell does have setns, and reads fine from a heredoc:
xmllint --nonet --shell doc.xml <<'EOF'
setns bk=urn:books
xpath //bk:book/bk:title
EOF

# Or sidestep prefixes entirely:
xmllint --nonet --xpath "//*[local-name()='title']/text()" doc.xml

# xmlstarlet is the friendlier option if you can install it:
xmlstarlet sel -N bk=urn:books -t -v '//bk:book/bk:title' -n doc.xml

여섯 가지에 공통된 점이 둘 있습니다. 네임스페이스 접두사는 문서가 제공하는 것이 아니라 여러분이 선언하는 것이라는 점, 그리고 잘못된 식을 알리는 방식이 제각각이라는 점(DOMException을 던지거나, XPathEvalError를 일으키거나, false를 돌려주거나, 종료 상태 10을 내거나)입니다. 그래서 이 예제들 중 어느 것도 질의 호출과 결과 사용을 한 줄에 몰아넣지 않았습니다.

자주 묻는 질문

제 XPath가 왜 아무 결과도 돌려주지 않나요?

대개는 문서에 기본 네임스페이스가 있는데 식에 접두사가 없기 때문입니다. 루트에 xmlns="urn:something"이 붙어 있으면 그 안의 book은 사실 {urn:something}book이고, //book은 네임스페이스 없는 book을 요구합니다. 0건에 오류도 없습니다. XPath 입장에서는 잘못된 일이 하나도 없었기 때문입니다.

이 페이지는 그것을 감지해 찾아낸 URI를 알려 주고 거기에 접두사 ns를 바인딩하므로 //ns:book이 바로 동작합니다. 접두사를 피하고 싶다면 //*[local-name()="book"]이 네임스페이스와 무관하게 지역 이름으로 일치합니다. 그 밖에 배제해야 할 원인은 대소문자(XPath는 구분합니다)와, 색인이 1부터인데 [0]이라고 쓴 술어입니다.

식을 테스트할 때 XML이 업로드되나요?

아닙니다. 적격 형식 검사는 이 탭의 Web Worker에서 돌고, 식은 브라우저의 로컬 API인 document.evaluate가 평가합니다. 여기에는 무언가를 보낼 서버 구성요소가 없습니다.

XPath에서는 그 점이 대부분의 도구보다 더 중요합니다. 식을 작성하는 대상 문서가 샘플이 아니라 실제 페이로드이기 때문입니다. 파트너 API에서 캡처한 응답, 큐에서 꺼낸 메시지, SAML 어서션 같은 것들이죠. 네트워크 탭을 열고 문서를 붙여넣은 뒤 식을 실행해 보면, 계속 비어 있는 것을 볼 수 있습니다.

어떤 버전의 XPath를 지원하나요?

XPath 1.0입니다. 브라우저가 구현한 것이 그것이고 대안이 없기 때문입니다. 평가는 WHATWG DOM 표준 8절에 정의된 document.evaluate로 이루어집니다. 크롬, 파이어폭스, 사파리 모두 1.0이며 그 이상으로 갈 뜻을 밝힌 곳은 없습니다.

그래서 matches(), replace(), tokenize(), for와 if 식, 날짜 타입, 시퀀스, 맵, 배열은 여기서 모두 실패합니다. PHP의 DOMXPath, .NET의 XPathNavigator, Java의 javax.xml.xpath에서도 마찬가지입니다. 2.0이나 3.1이 필요하다면 Saxon이 답입니다. 브라우저에서는 Saxon-JS, JVM이나 .NET에서는 Saxon-HE입니다.

XPath에서 /와 //의 차이는 무엇인가요?

/는 직계 자식을 고르고, //는 /descendant-or-self::node()/의 축약형으로 어느 깊이에서든 고릅니다. 그래서 /catalog/book은 루트 catalog 바로 안의 book 요소와 일치하고, //book은 어디에 있는 book과도 일치합니다. 맨 앞의 /는 문서 루트에 고정하므로, 루트가 catalog인 문서에서 /book은 실패합니다.

함정은 //와 술어를 섞는 것입니다. //book[1]은 "문서에서 첫 번째 book"이 아닙니다. 술어는 단계에 적용되므로 "자기 부모 기준 첫 번째 book 자식인 모든 book"을 뜻하고, 카탈로그가 셋이면 노드 셋이 나옵니다. 의도한 결과를 얻으려면 감싸세요. (//book)[1]입니다.

요소 대신 속성을 고르려면?

이름 앞에 @를 붙이세요. //book/@id는 id 속성 노드를 고르고, 패널은 그 값과 속한 경로, 종류를 보여 줍니다. 속성을 고르는 것이 아니라 속성으로 거르고 싶다면 술어에 넣으세요. //book[@id="b1"]이 고르는 것은 book 요소이지 속성이 아닙니다.

속성에는 고유한 네임스페이스 규칙이 있고, 사람들이 가장 자주 틀리는 부분입니다. 기본 네임스페이스 선언은 속성 이름에 절대 적용되지 않으므로, <book xmlns="urn:books" id="7"/>에서 속성은 그냥 {}id입니다. @ns:id는 아무것과도 일치하지 않으니 @id로 고르세요. 속성이 네임스페이스에 속하는 것은 xlink:href나 xsi:schemaLocation처럼 접두사를 직접 쓴 경우뿐입니다.

여기서 HTML에 대해 XPath를 테스트할 수 있나요?

그 HTML이 적격 형식의 XML일 때만 가능하며, 대부분은 그렇지 않습니다. 입력은 application/xml로 파싱되므로 닫히지 않은 br 태그, 따옴표 없는 속성 값, URL 안의 그대로 쓰인 앰퍼샌드는 어떤 식이 실행되기도 전에 거부됩니다. 이는 의도한 것입니다. XPath는 HTML DOM에 대해 다르게 동작하는데, 거기서는 요소 이름이 소문자로 바뀌고 모든 것이 XHTML 네임스페이스에 들어갑니다.

여기처럼 엄격한 XMLDocument에 대해서는 노드 테스트가 대소문자를 구분하고 네임스페이스도 명세대로 동작합니다. 실제 세상의 HTML에는 너그러운 파서를 쓰세요. 파이썬의 lxml.html, 자바의 jsoup, 또는 CSS 셀렉터로 충분하다면 querySelector가 있습니다. XHTML, SVG, 그리고 이미 정리해 둔 조각은 여기서도 파싱됩니다.

관련 도구

참고 자료

이 도구로 해결되는 오류