前言
XML(Extensible Markup Language,可扩展标记语言)是一种用标签描述结构化数据的文本格式。它在 PHP 的黄金年代承担了大量数据交换工作:RSS 订阅、站点地图(sitemap.xml)、SOAP 接口、配置文件,几乎到处都能见到它。今天新项目的数据交换基本被 JSON 取代了,但 XML 并没有消失------别人给你的接口返回 XML、历史遗留的配置文件是 XML、搜索引擎要的 sitemap 必须是 XML,这些活你还是得干。
PHP 处理 XML 的方式有个容易把人绕晕的地方:官方内置了不止一套 XML 扩展 ,simplexml_load_string()、DOMDocument、XMLReader、xml_parser_create() 各管一摊,新手常常随手挑一个就用,结果要么在几十兆的文档上把内存撑爆,要么在需要修改节点时发现手上这套 API 根本改不了。
这篇入门材料的目标是把这件事讲清楚:先建立「树模型」和「流模型」两条主线,再说清四个扩展各自属于哪条线、适合什么场景,然后给出可运行的读写示例,最后单独讲一节 XXE(XML External Entity,XML 外部实体)攻击的防御------这是 XML 解析里唯一必须提前知道的安全问题,且 PHP 8.0 在这里有一处行为变化。示例以 PHP 8.0+ 为基准。
一、两条主线:树模型与流模型
所有的 XML 处理方式,本质上分成两类:
- 树模型 :把整个文档一次性读进内存,构建成节点树。你可以随意在树里来回移动、修改节点、按条件查找。代价是内存占用与文档大小成正比 。
SimpleXML和DOMDocument属于这一类。 - 流模型 :不建整棵树,边读边处理,内存占用基本恒定。代价是只能顺序访问 ,处理逻辑必须写成「遇到某类节点就做什么」。
XMLReader(拉模式,pull)和xml_parser_*系列(推模式,SAX 回调)属于这一类;写文档则有XMLWriter。
选择标准很简单:文档能有多大?你需要读还是要改?
| 扩展 | 模型 | 读取 | 修改 | 内存特性 | 适用场景 |
|---|
|-------------|---|---|---------------|---------|-------------|
| SimpleXML | 树 | 易 | 勉强能改(受限于 API) | 随文档大小增长 | 中小型文档、以读取为主 |
|---------------|---|-----|--------------|--------------|----------------------|
| DOMDocument | 树 | 较繁琐 | 完整支持(增删改、重排) | 随文档大小增长,开销最大 | 需要修改、需要复杂 XPath、命名空间 |
|-------------|------|-------|-----|------|---------|
| XMLReader | 流(拉) | 需手写循环 | 不支持 | 基本恒定 | 大文档只读遍历 |
|-------------|------|-----|----|------|------------|
| XMLWriter | 流(推) | --- | 生成 | 基本恒定 | 大文档生成、边查边写 |
|----------------|--------|-----|-----|------|--------------|
| xml_parser_* | 流(SAX) | 回调式 | 不支持 | 基本恒定 | 仅历史代码;新代码不建议 |
一条实用经验:先用 SimpleXML 把逻辑写出来,等到发现内存不够用,再把那一处换成 XMLReader。反过来一上来就写流式解析,代码会难读很多。
二、用 SimpleXML 读文档
SimpleXML 的 API 几乎不需要学------元素就是属性,属性就是数组下标 。假设有一份 library.xml:
xml
<?xml version="1.0" encoding="UTF-8"?>
<library>
<book id="b1" lang="zh">
<title>PHP 入门</title>
<price currency="CNY">59.00</price>
</book>
<book id="b2" lang="en">
<title>Advanced PHP</title>
<price currency="USD">39.00</price>
</book>
</library>
读取它的完整代码:
php
<?php // 适用于 PHP 8.0+
declare(strict_types=1);
libxml_use_internal_errors(true); // 解析出错时不直接抛警告,改为收集起来
$xmlString = (string) file_get_contents(__DIR__ . '/library.xml');
$library = simplexml_load_string($xmlString);
if ($library === false) {
foreach (libxml_get_errors() as $err) {
printf("[级别 %d] 第 %d 行:%s", $err->level, $err->line, trim($err->message));
}
libxml_clear_errors();
exit(1);
}
libxml_clear_errors();
foreach ($library->book as $book) {
$id = (string) $book['id']; // 属性:用数组下标
$title = (string) $book->title; // 子元素:用属性名
$price = (string) $book->price;
$currency = (string) $book->price['currency']; // 子元素上的属性
printf("%s | %s | %s %s\n", $id, $title, $price, $currency);
}
// XPath:按条件筛选
foreach ($library->xpath('//book[@lang="zh"]/title') as $node) {
printf("中文书:%s\n", (string) $node);
}
三个必须记住的细节:
$book->title返回的是SimpleXMLElement对象,不是字符串 。把它当字符串用(拼接、==比较、传进需要string的函数)之前,先(string)强制转换。不转换的话,两个「内容相同」的节点比较是不相等的------比的是对象。- 同名子元素会构成一个集合 。
$library->book是一个可遍历的SimpleXMLElement,count($library->book)能得到数量(它实现了Countable)。 simplexml_load_string()解析失败时只返回false,原因得从libxml的错误缓冲区里取,所以要先libxml_use_internal_errors(true)。取完记得libxml_clear_errors(),否则错误会累积到下一次解析里。
命名空间下的节点要用 children() 指定命名空间,属性则用 attributes():
php
<?php // 适用于 PHP 8.0+
// 取默认命名空间之外、绑定了 http://example.com/ns 的 child 元素
foreach ($xml->children('http://example.com/ns') as $child) {
echo (string) $child, PHP_EOL;
}
三、用 DOMDocument 读与改
DOMDocument 遵循 W3C DOM 标准,API 比 SimpleXML 啰嗦得多,但它能做的事情也多得多------改节点、删节点、按顺序插入、输出格式化的 XML ,这些 SimpleXML 都不好办。
php
<?php // 适用于 PHP 8.0+
declare(strict_types=1);
$dom = new DOMDocument();
$dom->preserveWhiteSpace = false; // 忽略纯空白文本节点
$dom->formatOutput = true; // 输出时自动缩进
libxml_use_internal_errors(true);
// 只传 LIBXML_NONET:禁止解析器访问网络
$ok = $dom->loadXML((string) file_get_contents(__DIR__ . '/library.xml'), LIBXML_NONET);
if ($ok === false) {
foreach (libxml_get_errors() as $err) {
printf("[级别 %d] 第 %d 行:%s", $err->level, $err->line, trim($err->message));
}
libxml_clear_errors();
exit(1);
}
libxml_clear_errors();
// 用 XPath 精确选中节点
$xpath = new DOMXPath($dom);
$nodes = $xpath->query('//book[@lang="zh"]');
foreach ($nodes as $book) {
// $book 是 DOMElement
$title = $book->getElementsByTagName('title')->item(0);
printf("%s -> %s\n", $book->getAttribute('id'), $title?->textContent ?? '');
}
// 改:新增一个节点
$new = $dom->createElement('book');
$new->setAttribute('id', 'b3');
$new->setAttribute('lang', 'zh');
$title = $dom->createElement('title');
$title->appendChild($dom->createTextNode('PHP 进阶'));
$new->appendChild($title);
$dom->documentElement?->appendChild($new); // documentElement 是根元素 library
echo $dom->saveXML(), PHP_EOL;
要点:
preserveWhiteSpace = false与formatOutput = true要成对使用 ,才能得到缩进整齐的输出。但如果文档是混合内容 (标签之间夹着有意义的空白或文本,比如 HTML 风格的段落),去掉空白节点会改变语义,这种文档就别设preserveWhiteSpace = false。loadXML()的第二个参数是 libxml 选项的按位或 组合,可选常量包括LIBXML_NONET(禁止网络访问)、LIBXML_NOCDATA(把 CDATA 合并进文本节点)、LIBXML_NOBLANKS(丢弃纯空白节点)、LIBXML_NSCLEAN等。createTextNode()生成的是文本节点,textContent是读取元素内全部文本的快捷方式。写用户数据时用createTextNode()或textContent,它们会自动转义&、尖括号等字符;手工拼字符串再塞进 XML 则要自己保证转义,否则会生成非法 XML(甚至被用来注入节点)。
大文档怎么办? 用 XMLReader,它是「拉」模型------你主动调 read(),读到什么处理什么:
php
<?php // 适用于 PHP 8.0+
declare(strict_types=1);
$reader = new XMLReader();
if (!$reader->open(__DIR__ . '/library.xml', null, LIBXML_NONET)) {
exit("无法打开文档\n");
}
while ($reader->read()) {
// 只关心名字叫 title 的元素节点
if ($reader->nodeType === XMLReader::ELEMENT && $reader->name === 'title') {
echo $reader->readString(), PHP_EOL; // readString 读取当前节点的文本内容
}
}
$reader->close();
无论文档多大,XMLReader 占用的内存都基本恒定。生成大文档则用 XMLWriter:
php
<?php // 适用于 PHP 8.0+
declare(strict_types=1);
$writer = new XMLWriter();
$writer->openURI('php://output');
$writer->startDocument('1.0', 'UTF-8');
$writer->startElement('library');
$writer->startElement('book');
$writer->writeAttribute('id', 'b1');
$writer->writeElement('title', 'PHP 入门'); // 一次写出元素和文本(文本会被转义)
$writer->endElement();
$writer->endElement();
$writer->endDocument();
$writer->flush();
四、安全:XXE 防御(必读)
XML 标准允许在文档开头定义「文档类型定义」(DTD),DTD 里可以声明外部实体------一个指向文件或 URL 的引用。解析器如果去加载并替换这些实体,就能被利用来读取服务器上的本地文件、发起内网请求。这就是 XXE。
防御侧的做法,逐条记住:
- 不要给
simplexml_load_string()/loadXML()传LIBXML_NOENT或LIBXML_DTDLOAD。 这两个常量会打开实体替换与 DTD 加载,是把风险变成现实的直接开关。需要处理 CDATA 时用LIBXML_NOCDATA,不要用LIBXML_NOENT。 - 显式传
LIBXML_NONET,禁止解析器在解析过程中访问网络。 - PHP 8.0 及以后,外部实体加载默认就是关闭的 。原因是从 PHP 8.0 起绑定的 libxml 版本不低于 2.9.0,而 libxml 2.9.0 起默认禁用外部实体加载。因此
libxml_disable_entity_loader()在 PHP 8.0 已被废弃,PHP 8 上不需要再调用它。如果你的代码还要兼容 PHP 7,官方给出的兼容写法是:
php
<?php // 兼容 PHP 7 与 PHP 8 的写法
if (PHP_VERSION_ID < 80000) {
libxml_disable_entity_loader(true); // PHP 8.0 起已废弃,8.0+ 不需要也不应再调用
}
- 只接受 UTF-8 或明确允许的编码 ,拒绝
DOCTYPE声明也是常见做法:解析前先检查原始字符串里是否包含<!DOCTYPE,包含就直接拒绝。这属于业务层的白名单策略,简单有效。 - PHP 8.4 起新增了
LIBXML_NO_XXE常量(依赖 libxml 2.13.0 及以上),可以在解析时显式禁止 XXE。使用前请确认运行环境的 PHP 与 libxml 版本,低版本上该常量不存在。
一句话总结:在 PHP 8 上,只要你没有主动传入 LIBXML_NOENT / LIBXML_DTDLOAD,默认配置就是安全的;真正危险的是那些从网上抄来的、随手加了这两个常量的老代码。
常见坑点
- ❌ 直接
if ($book->title == 'PHP 入门')比较节点内容。✅$book->title是对象,要先(string) $book->title再比较,否则永远不相等。 - ❌
simplexml_load_string()返回false后一头雾水,不知道哪里错了。✅ 解析前libxml_use_internal_errors(true),失败后用libxml_get_errors()逐个读message/line/level,读完libxml_clear_errors()。 - ❌ 用
json_decode(json_encode($xml), true)把 XML「一键转数组」,然后当成通用方案。✅ 这个技巧会丢掉属性、命名空间、CDATA 的区分,空元素会变成空对象;只在结构简单且不需要属性时临时用,正式代码应显式遍历。 - ❌ 为了处理 CDATA 而传
LIBXML_NOENT。✅LIBXML_NOENT是实体替换开关,属于 XXE 的入口;处理 CDATA 应该用LIBXML_NOCDATA。 - ❌ 以为「PHP 8 了所以什么都不用管」,却忽略了老代码里遗留的
libxml_disable_entity_loader(true)与LIBXML_DTDLOAD。✅ 前者在 8.0 已废弃、调用会报废弃提示,可以直接删;后者必须删掉。 - ❌ 用正则表达式从 XML 里抠数据。✅ 属性顺序、单双引号、自闭合标签、CDATA、实体转义都会让正则翻车;用解析器。
- ❌ 访问一个不存在的子节点时以为会报错或返回
null。✅SimpleXML会返回一个「空」的SimpleXMLElement,(string)转换后是空字符串,isset()判断才可靠------这类问题不报错,只会静默拿到空值。 - ❌ 输出 XML 前已经有任何输出(哪怕是 BOM 或空行),然后再调
header()。✅ 设置响应头必须在输出之前;同时把php.ini的输出缓冲或模板留白都清干净,否则报「headers already sent」。
总结
| 需求 | 推荐扩展 | 关键 API |
|---|
|-----------|-------------|-------------------------------------------------------------|
| 快速读取中小型文档 | SimpleXML | simplexml_load_string()、simplexml_load_file()、xpath() |
|--------------|----------------------------|------------------------------------------------------------------------|
| 需要修改节点或格式化输出 | DOMDocument / DOMXPath | loadXML()、createElement()、createTextNode()、saveXML()、query() |
|-------|-------------|----------------------------------------------------|
| 遍历大文档 | XMLReader | open()、read()、nodeType、name、readString() |
|-------|-------------|---------------------------------------------------------------------|
| 生成大文档 | XMLWriter | startDocument()、startElement()、writeElement()、endDocument() |
|--------|--------|----------------------------------------------------------------------------------|
| 查看解析错误 | libxml | libxml_use_internal_errors(true)、libxml_get_errors()、libxml_clear_errors() |
|--------|-----------|-------------------------------------------------------------------|
| XXE 防御 | libxml 选项 | 不传 LIBXML_NOENT / LIBXML_DTDLOAD;传 LIBXML_NONET;PHP 8 默认已安全 |
入门 XML 的顺序建议是:先用 SimpleXML 把一个真实文档读出来(体会「元素即属性、属性即下标」),再用 DOMDocument 做一次增删改(体会节点树 API),最后遇到大文件时补上 XMLReader。至于安全,把「不主动打开实体替换」这条纪律记牢就够了------PHP 8 把默认值调到了安全的一侧,需要警惕的反而是那些照抄老教程、主动加上了危险常量的代码。