php xml 入门学习资料

前言

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);

}

三个必须记住的细节:

  1. $book->title 返回的是 SimpleXMLElement 对象,不是字符串 。把它当字符串用(拼接、== 比较、传进需要 string 的函数)之前,先 (string) 强制转换。不转换的话,两个「内容相同」的节点比较是不相等的------比的是对象。
  2. 同名子元素会构成一个集合 。$library->book 是一个可遍历的 SimpleXMLElement,count($library->book) 能得到数量(它实现了 Countable)。
  3. 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。

防御侧的做法,逐条记住:

  1. 不要给 simplexml_load_string() / loadXML() 传 LIBXML_NOENT 或 LIBXML_DTDLOAD。 这两个常量会打开实体替换与 DTD 加载,是把风险变成现实的直接开关。需要处理 CDATA 时用 LIBXML_NOCDATA,不要用 LIBXML_NOENT。
  2. 显式传 LIBXML_NONET,禁止解析器在解析过程中访问网络。
  3. 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+ 不需要也不应再调用

}
  1. 只接受 UTF-8 或明确允许的编码 ,拒绝 DOCTYPE 声明也是常见做法:解析前先检查原始字符串里是否包含 <!DOCTYPE,包含就直接拒绝。这属于业务层的白名单策略,简单有效。
  2. 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 把默认值调到了安全的一侧,需要警惕的反而是那些照抄老教程、主动加上了危险常量的代码。

相关推荐
熊猫钓鱼>_>1 小时前
Kotlin Multiplatform for OpenHarmony 实战:为 Landscapist 实现图片加载适配
开发语言·kotlin·华为云·ai编程·harmonyos·鸿蒙·openharmony
Nebula_g2 小时前
JavaSE加强:Commons-io框架
java·开发语言·算法·javase
Do_It_Today2 小时前
php url路由入门实例
开发语言·php
小羊没烦恼!2 小时前
关于大型asp.net应用系统的架构-架构的选择
java·服务器·开发语言·前端·c#
夏幻灵2 小时前
前端八股:JavaScript 深拷贝详解:实现方式、递归原理与循环引用
开发语言·前端·javascript
傲世仙尊2 小时前
C++起步-命名空间解决命名冲突与cin-cout缺省参数
开发语言·c++
fundoit2 小时前
资源服务器如何对 JWT 进行验签
java·运维·服务器·spring boot·php·oauth2
hljqwb2 小时前
java服务异常日志只打印异常类型,没有堆栈定位分析(十)
java·开发语言