PHP xpath()函数讲解

前言

先纠正一个前提:PHP 里没有名为 xpath() 的全局函数 。你看到的 xpath(),实际是两个类身上的方法:

  • SimpleXMLElement::xpath()------SimpleXML 扩展,返回 SimpleXMLElement 对象的数组;
  • DOMXPath::query() 与 DOMXPath::evaluate()------DOM 扩展,需要通过 new DOMXPath($dom) 创建对象后调用。

顺带说清楚另一个常见混淆:还有一类叫做 xpath 的第三方库方法(例如某模板引擎里的选择器),那是它们自己实现的,和 PHP 内置的 SimpleXML、DOM 无关,语法也不保证符合 XPath 规范。本文只讲内置的两套 API。

掌握 XPath 的价值在于:当你需要「选出第二层里带某个属性的所有节点」这类结构化查询时,用 XPath 一行就能表达,而手写嵌套的 foreach 又长又容易错。但 XPath 有几个非常反直觉的规则(尤其是命名空间),不知道就会卡住半天。本文把这些都摊开讲。

一、用 SimpleXML 起步:最省事的路径

php 复制代码
<?php // 适用于 PHP 8.0+,需要 ext-simplexml(PHP 默认启用)

$xml = <<<'XML'

<?xml version="1.0" encoding="UTF-8"?>

<catalog>

  <book id="b1" lang="zh"><title>PHP 入门</title><price>59</price></book>

  <book id="b2" lang="en"><title>PHP Advanced</title><price>89</price></book>

  <book id="b3" lang="zh"><title>PHP 进阶</title><price>79</price></book>

</catalog>

XML;



$sxe = simplexml_load_string($xml, SimpleXMLElement::class, LIBXML_NONET);

if ($sxe === false) {

    exit("XML 解析失败\n");

}



// 1) 选所有 title 元素

foreach ($sxe->xpath('//book/title') as $title) {

    echo (string) $title, "\n";

}



// 2) 用谓词按属性过滤

foreach ($sxe->xpath('//book[@lang="zh"]') as $book) {

    printf("%s => %s / %s\n", (string) $book['id'], (string) $book->title, (string) $book->price);

}



// 3) 只取文档里第一个 book(注意括号的位置)

$first = $sxe->xpath('(//book)[1]');

var_dump(is_array($first));            // bool(true)

var_dump((string) $first[0]['id']);    // string(2) "b1"

几个要点:

  • simplexml_load_string() 的第三个参数是 libxml 选项位。加 LIBXML_NONET 是有意的:它禁止解析过程中发起网络请求,能挡掉一类外部实体攻击(见下文安全部分)。
  • 解析失败时它返回 false 并触发 E_WARNING,所以要显式判断,不要直接链式取值。
  • xpath() 成功时返回数组(元素可能是空的数组),失败时返回值不是数组------历史上是 false,PHP 8 的方法签名里还包含 null。所以判断写法应该是 is_array($result),而不是 === false,前者在任何版本都可靠。

SimpleXMLElement::xpath() 的签名是:

php 复制代码
public SimpleXMLElement::xpath(string $expression): array|false|null

二、XPath 表达式里必须记住的几条规则

XPath 1.0 是 libxml 实现的版本,没有 XPath 2.0/3.0 的那些新特性。日常会用到的是这些:

表达式 含义

|-----------------|-----------|
| /catalog/book | 从根开始的绝对路径 |

|----------|--------------------|
| //book | 文档中任意位置的 book 元素 |

|------------|------------|
| . / .. | 当前节点 / 父节点 |

|-------|----|
| @id | 属性 |

|----------|------|
| text() | 文本节点 |

|-------|-----------|
| [1] | 谓词,取第一个匹配 |

|------------|------|
| [last()] | 最后一个 |

|----------------|--------|
| [@lang="zh"] | 属性等于某值 |

|----------------|---------|
| [price > 60] | 子元素数值比较 |

|-----------------|----------|
| count(//book) | 计数(返回数字) |

|----------------------|-------|
| contains(@id, "b") | 字符串包含 |

|-------------------------|-------|
| starts-with(@id, "b") | 字符串前缀 |

最容易错的一条是 [1] 的作用范围:

text 复制代码
//book[1]     ------ 选取「每个父节点的第一个 book 子元素」

(//book)[1]  ------ 选取整个文档中的第一个 book

因为 [1] 是紧跟在节点测试后面的谓词,它作用于每一步的中间结果集 ,而不是整条路径的最终结果。当所有 book 恰好是同一个父节点的子节点时两者结果一样,一旦它们分散在不同父节点下,结果就完全不同。

三、命名空间:XPath 的头号陷阱

XPath 1.0 的世界里,不带前缀的名字永远只匹配「无命名空间」的节点 。所以对一个声明了默认命名空间的文档,//entry 选不到任何东西------这不是 PHP 的怪癖,是 XPath 规范的规定。

解决办法是给命名空间 URI 起一个前缀,再在表达式里用这个前缀:

php 复制代码
<?php // 适用于 PHP 8.0+

$atom = <<<'XML'

<?xml version="1.0" encoding="UTF-8"?>

<feed xmlns="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">

  <entry><title>第一篇</title><dc:creator>alice</dc:creator></entry>

  <entry><title>第二篇</title><dc:creator>bob</dc:creator></entry>

</feed>

XML;



$sxe = new SimpleXMLElement($atom, LIBXML_NONET);



// 关键一步:把 URI 绑定到一个前缀上

$sxe->registerXPathNamespace('atom', 'http://www.w3.org/2005/Atom');

$sxe->registerXPathNamespace('dc', 'http://purl.org/dc/elements/1.1/');



// 前缀名由我们自己起,和文档里的前缀不必相同

$titles   = $sxe->xpath('//atom:entry/atom:title');

$creators = $sxe->xpath('//atom:entry/dc:creator');



foreach ($titles as $i => $t) {

    printf("%s by %s\n", (string) $t, (string) $creators[$i]);

}

SimpleXMLElement::registerXPathNamespace(string $prefix, string $namespace): bool 的注册是发生在这个对象上 的,所以要先注册、后查询。前缀用 atom 还是 a 完全无所谓,XPath 只认 URI。

用 DOM 做同样的事,签名略有不同:

php 复制代码
<?php // 适用于 PHP 8.0+,需要 ext-dom

$atom = <<<'XML'

<?xml version="1.0" encoding="UTF-8"?>

<feed xmlns="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">

  <entry><title>第一篇</title><dc:creator>alice</dc:creator></entry>

  <entry><title>第二篇</title><dc:creator>bob</dc:creator></entry>

</feed>

XML;



$doc = new DOMDocument();

$doc->loadXML($atom, LIBXML_NONET);



$xp = new DOMXPath($doc);

$xp->registerNamespace('atom', 'http://www.w3.org/2005/Atom');

$xp->registerNamespace('dc', 'http://purl.org/dc/elements/1.1/');



foreach ($xp->query('//atom:entry') as $entry) {

    // 第二个参数是上下文节点,配合相对路径使用

    $title = $xp->query('atom:title', $entry)->item(0)->textContent;

    printf("%s\n", $title);

}

对应的方法签名是:

php 复制代码
public DOMXPath::__construct(DOMDocument $document, bool $registerNodeNS = true)

public DOMXPath::query(string $expression, ?DOMNode $contextNode = null, bool $registerNodeNS = true): DOMNodeList|false

public DOMXPath::evaluate(string $expression, ?DOMNode $contextNode = null, bool $registerNodeNS = true): mixed

public DOMXPath::registerNamespace(string $prefix, string $namespace): bool

query() 只能返回节点集合,失败时返回 false;evaluate() 能返回 XPath 规定的任意类型(节点集、字符串、数字、布尔),但需要你自己判断类型。两者都会在表达式非法时给出警告,所以对动态拼接的表达式一定要检查返回值。

四、该选 SimpleXML 还是 DOM

对比项 SimpleXML DOM

|------|-----------------------|-------------------------------|
| 上手成本 | 低,属性式访问 $sxe->title | 高,需要 query() + item() 取节点 |

|-----|-----------------------|---------------|
| 返回值 | SimpleXMLElement 数组 | DOMNodeList |

|-----|------------------|----------------------|
| 取文本 | (string) $node | $node->textContent |

|-----|---------------|-----------------------------|
| 取属性 | $node['id'] | $node->getAttribute('id') |

|----|-----------------|------------------|
| 计数 | count($nodes) | $nodes->length |

|------|-----------|-------------------|
| 修改文档 | 支持,但写法较隐晦 | 完整支持,增删改查都有明确 API |

|------|----|----------------------------|
| 错误信息 | 较少 | 更完整(libxml_get_errors()) |

|----|--------|---------------|
| 内存 | 对象包装较省 | 完整 DOM 树,开销更大 |

经验法则:只读、结构简单,用 SimpleXML;需要增删改、需要精确的错误信息、或者文档很大要配合 XMLReader,用 DOM。

安全提醒(防御侧) :解析不可信的 XML 时要防外部实体注入(XXE)。做法是:加载字符串而不是不可控的路径、加 LIBXML_NONET、不要使用会把实体替换展开的 LIBXML_NOENT,并在解析前用 libxml_use_internal_errors(true) 收集错误而不是让警告直接暴露路径信息。现代的 libxml 2.9 及以上默认不加载外部实体,PHP 8.0 起 libxml_disable_entity_loader() 已被标记为废弃(它在新版 libxml 下本就不再有作用),所以真正的防线是「不解析不可信 XML」加上这些选项。

常见坑点

  1. ❌ $sxe->xpath('//entry') 对带默认命名空间的文档返回空数组,就断言「PHP 的 xpath 有 bug」。

✅ 这是 XPath 1.0 的规定:无前缀名只匹配无命名空间节点。先 registerXPathNamespace() 再用前缀查询。

  1. ❌ 用 if ($res === false) 判断 xpath() 是否失败。

✅ 用 is_array($res);失败时的返回值在不同版本里可能是 false 也可能是 null。

  1. ❌ 在 xpath() 返回结果上直接取属性,不检查数组是否为空:$res[0]['id']。

✅ 先判空:if ($res !== [] && isset($res[0])) { ... }。空数组的 [0] 取值会触发未定义索引警告。

  1. ❌ 写 //book[1] 却期望得到「文档里第一个 book」。

✅ 要的是 (//book)[1];//book[1] 是「每个父节点下的第一个 book」。

  1. ❌ 忘记 (string) 转换,直接把 SimpleXMLElement 对象当字符串用(比如存进数组或做 === 比较)。

✅ SimpleXML 对象比较的是「对象引用」,不是文本内容。取值时统一 (string) $node,属性同理 (string) $node['attr']。

  1. ❌ 用 $xp->evaluate('count(//book)') === 3 做判断。

✅ XPath 的数字是双精度浮点,evaluate() 返回的是 float,=== 3 恒为假。用 == 3,或者显式 (int) 转换后再比较。

  1. ❌ 对超大 XML 用 simplexml_load_file() / DOMDocument::load() 一次性读进内存。

✅ 流式方案用 XMLReader 逐个节点推进;只需要部分数据时也可以先用 XPath 缩小范围,但前提是文档已经在内存里了。

  1. ❌ 解析用户上传的 XML 时不加任何限制,也不检查返回值。

✅ 加 LIBXML_NONET、保持外部实体加载关闭、用 libxml_use_internal_errors(true) 收集错误,并对解析失败的输入走正常错误分支而不是继续处理 false。

总结

问题 结论

|------------------|----------------------------------------------------|
| 是否存在全局 xpath() | 不存在,是 SimpleXMLElement::xpath() 与 DOMXPath 的方法 |

|----------------|------------------------------------|
| SimpleXML 返回类型 | 成功为对象数组,失败值不是数组(用 is_array() 判断) |

|----------|--------------------------------------------------------|
| DOM 对应方法 | DOMXPath::query()(节点集)/ DOMXPath::evaluate()(任意类型) |

|------|---------------------------------------------------------------------|
| 命名空间 | 必须 registerXPathNamespace() / registerNamespace(),无前缀名不匹配默认命名空间 |

|-------|----------------------------|
| 谓词作用域 | //x[1] 与 (//x)[1] 语义不同 |

|------|---------------------------------|
| 数字结果 | XPath 数字是 float,别用 === 与整数比 |

|----|------------------------------------------------|
| 安全 | 不解析不可信 XML;用 LIBXML_NONET,不要开 LIBXML_NOENT |

把「无前缀不匹配命名空间」和「谓词作用域」这两条记牢,XPath 里剩下的问题基本都是语法细节。真正要选择 API 时,只读用 SimpleXML、要改文档用 DOM,两者的 XPath 表达式语法是完全一样的。

相关推荐
YYYing.1 小时前
【Python系列 (一) 】Python基础疑难杂症
开发语言·python
rannn_1111 小时前
JVM 垃圾回收面试题全解析:从基础到进阶
java·开发语言·jvm·后端·面试
知野小兔1 小时前
JavaScript 数组方法大全(超详细整理)
开发语言·前端·javascript
哈__1 小时前
飞牛OS还在手动维护?我用Ansible把重复操作写成了Playbook
开发语言·ansible·php
道尔柯南1 小时前
【QT】从零认识 Qt:跨平台 C++ GUI 开发入门
开发语言·c++·qt
二喵❥(^_-)1 小时前
接口测试流程的三个核心环节
开发语言·lua
唐青枫1 小时前
PHP Monolog 日志实战详解:级别、Handler、Processor 与 Symfony 配置
php
喜欢coding的谢同学1 小时前
Go语言常用设计模式基础和实践案例介绍
开发语言·设计模式·golang
MayZork2 小时前
std::filesystem 文件操作详解
开发语言·c++·qt