如果只看标题,你可能会觉得今天的内容过于基础 —— XML,这不是比 JSON 还“老”的东西吗?但真正进了项目就会发现,老技术从来不等于没问题。Spring Boot 里 MyBatis 映射文件该放哪儿、怎么配才能用;一个看起来正常的 XML 解析接口,为什么被别人一个请求就读走了服务器文件;甚至工业软件里西门子 TIA Openness 的项目结构,也在用 XML 做数据交换。这些坑我都踩过,也见过不少新同事在同样地方反复打转。这篇 day36-xml 的学习笔记,就把我从基础解析、编辑器选择,到 MyBatis-Plus 配置、XXE 安全防御的一整套经验和踩坑记录整理出来,希望能帮你少走几步弯路。
1. XML 到底解决了什么问题,为什么还在学
1.1 XML 的核心形态:一个配置狂魔的自白
XML(Extensible Markup Language)是一种可扩展标记语言,它解决的核心问题只有一个:用一种自描述的、严格的文本格式来表达结构化的数据。这里的“严格”是双刃剑,严格意味着机器可以校验和解析,也意味着你写错一个尖括号、一个大写字母,整个程序都可能崩掉。
一个最小的 XML 长这样:
<?xml version="1.0" encoding="UTF-8"?> <order id="1001"> <customer> <name>张三</name> <phone>13800000000</phone> </customer> <items> <item sku="A001" quantity="2">机械键盘</item> <item sku="A002" quantity="1">显示器支架</item> </items> </order>结构上一眼就能看明白:根元素order包裹所有内容,customer和items是子元素,id、sku是属性。和 HTML 不同,XML 的标签不是预定义的,你可以根据数据模型随便造,所以它天生适合做“配置文件”和“消息交换格式”。再加上 DTD(文档类型定义)或 XSD(XML Schema),你还能强制规定每个元素出现几次、类型是什么,把数据准确性卡得死死的。
顺带一提,很多年后端老系统非常依赖这套“严格校验”。比如你调用某些老牌 ERP 的 OpenAPI,对方接口要求的就是 SOAP 协议里的 XML 消息,客户名称、物料编码、单据类型,哪个字段缺失或类型错误,立刻报 Fault。这类场景里 JSON 反而没 XML 灵活,因为 SOAP 的规范里带了完整的错误码和扩展机制,没有 XML 这套标签和命名空间,很难做到这种程度。我最初也觉得“都什么年代了还用 SOAP”,但真对接过一两个传统制造企业的 U9C 开放接口后,就明白了:在老工业软件和大型企业集成里,XML 至今仍是事实标准。
1.2 为什么 JSON 这么火,XML 还是没被淘汰
JSON 得益于体积小、和 JavaScript 天然契合、心智负担低,已经成为 Web API 的默认选择。但 XML 在某些领域依然活得很好,甚至无可替代。
| 特性 | XML | JSON |
|---|---|---|
| 数据类型表达 | 纯文本元素/属性,需配合 XSD 约束 | 原生支持字符串、数字、布尔、数组 |
| 可扩展性 | 支持命名空间、DTD/XSD、XPath/XSLT | 较弱,没有标准查询语言 |
| 元数据表达 | 属性比子元素更适合描述“数据的描述” | 用嵌套对象实现,结构容易臃肿 |
| 工具链生态 | 极度成熟,从解析到校验一应俱全 | 轻量,但复杂查询需要额外方案 |
| 典型场景 | 配置文件、SOAP 协议、Office 文档底稿、工业软件数据交换 | REST API、前端状态、移动端通信 |
最典型的例子是 Office 的 docx/xlsx 文件,本质就是一个 zip 压缩包,里面塞了几十个 XML 文件。你要用代码改一个 Word 文档的页眉页脚、Excel 的单元格样式,绕不开 OpenXML 里的那些 XML 节点。还有 Java 的 Maven 配置用的是 XML,Spring 早期的 Bean 定义也是 XML,工业自动化里的西门子 TIA Openness 也可以通过 XML 描述项目结构——下载学习它的 Openness 示例工程,你会发现大量 XML 文件。
所以我的观点很明确:你可以不喜欢 XML,但不能不会读、不会配、不会防它出问题。越是基础且普及的技术,一旦出错影响面越广。Day 36 这个节点学 XML,不是翻旧账,而是在给后续的项目实战补地基。
2. 打开、编辑和解析 XML:从入门到顺手
2.1 用什么工具读写 XML 才不痛苦
很多人被 XML 劝退,是因为最初用记事本打开一个几百行的 XML,全挤在一行,眼睛直接瞎了。其实工具选对,体验完全不差。
- VS Code:安装扩展 “XML Tools” 或 “XML Language Support”,格式化(Shift+Alt+F)、校验、XPath 查询都有。也有项目会配 XML 目录结构,这边不细说。
- IDEA / JetBrains 全家桶:自带 XML 高亮和格式整理。如果你在写 MyBatis 的 mapper XML,强烈建议装一个MyBatisX插件。装完后 mapper XML 的方法名、参数引用都是高亮绑定,点一下还能跳转到对应的 Mapper 接口方法。早期没有这个插件的时候,我每次改 SQL 只敢全局搜索方法名,效率极低,后来有插件了简直救命。
- Notepad++:轻量查看和快速替换,支持语法高亮,就是插件生态老一点。适合马上改一个临时文件,不用打开重型 IDE。
- XMLSpy / Oxygen XML Editor:专业 XML 编辑工具,对 XSD 校验、XSLT 调试支持极好,适合出版社、金融报文、政企对接这类需要严格校验的领域。日常写代码不需要上这么重。
编辑器里还有个容易被忽略的高亮点:MyBatis XML 的高亮,不只是颜色好看,更是查错的工具。比如#{}和${}如果用错了,插件往往能直接标出提示;XML 标签闭合有问题,也能第一时间看到红色波浪线。我见过一个同事,XML 里少写了一个</if>,自己盯了半小时没发现,IDEA 一眼就标出来了。所以从第一天做项目开始,就不要用记事本硬刚 XML。
2.2 DOM、SAX、StAX,三种解析模型怎么选
聊到解析,就绕不开三个经典模型。很多人刚学时容易混淆,我换个方式说。
| 解析模型 | 工作方式 | 优势 | 劣势 | 推荐场景 |
|---|---|---|---|---|
| DOM | 一次读入并构建完整树对象 | 使用简单,可增删改查 | 大文件吃内存明显 | 配置文件、中小型文档 |
| SAX | 事件驱动,边读边触发回调 | 速度快,内存占用小 | 不能倒退,代码逻辑复杂 | 超大 XML、流式处理 |
| StAX | 拉式解析,由代码主动取节点 | 灵活、可暂停 | 同样需要自己维护状态 | 高性能解析、增量处理 |
典型 Java 里用DocumentBuilderFactory来做 DOM 解析,把整个 XML 变成Document对象,然后用getElementsByTagName去查节点。这种方式对小配置非常友好。
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); DocumentBuilder builder = factory.newDocumentBuilder(); Document doc = builder.parse(new File("order.xml")); NodeList customers = doc.getElementsByTagName("name"); for (int i = 0; i < customers.getLength(); i++) { System.out.println(customers.item(i).getTextContent()); }Python 更简单,标准库的xml.etree.ElementTree可以一行读取:
import xml.etree.ElementTree as ET root = ET.parse("order.xml").getroot() for name in root.iter("name"): print(name.text)如果不求性能,开发阶段用这些完全够了。但如果你要解析的是 GB 级别的业务报文,DOM 直接会让内存爆掉,必须换 SAX 或 StAX。我记得有一次处理银行流水对账文件,几个 GB 的 XML,一开始用 DOM 只跑了几分钟就 OOM,后来切了 StAX 流式解析,内存占用从 2G 降到 300M,这就是模型选型的重要。
2.3 实战解析:Python 和 Java 各来一发
上面代码算是最小示例,这里补一个真实感强一点的。假设我们要从商品 XML 里读出所有 SKU 为A001的库存数量:
import xml.etree.ElementTree as ET xml_data = """ <inventory> <product sku="A001"> <name>机械键盘</name> <stock>15</stock> </product> <product sku="A002"> <name>显示器支架</name> <stock>7</stock> </product> </inventory> """ root = ET.fromstring(xml_data) for product in root.findall("product"): if product.get("sku") == "A001": stock = product.find("stock").text print(f"机械键盘库存: {stock}")如果 XML 里还有命名空间,findall时就要注意前缀。很多新手在这个位置被坑过:XML 开头明明写了xmlns:ns="http://xxx",Python 里却直接用ns前缀查找节点,结果 parse 返回空。正确做法是把命名空间单独映射一下:
ns = {"ns": "http://xxx"} root.findall(".//ns:product", ns)Java 这边我建议别直接用底层 DOM 写太多样板代码,Java 8+ 配合 XPath 更省心:
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); DocumentBuilder builder = factory.newDocumentBuilder(); Document doc = builder.parse(new FileInputStream("inventory.xml")); XPath xPath = XPathFactory.newInstance().newXPath(); String stock = xPath.evaluate("/inventory/product[@sku='A001']/stock/text()", doc); System.out.println(stock);这一小段看起来简单,但请注意DocumentBuilderFactory.newInstance()这一步,稍后讲到安全时你还会见到它。不同的newInstance()默认配置行为可能不一样,尤其在 JDK 高版本和低版本之间,配置外部实体的策略也不完全相同。所以解析器代码宁可写显式 setFeature,也不要偷懒用默认。
3. Spring Boot + MyBatis-Plus XML 映射文件:从配置到上线
3.1 XML 和 Mapper 接口要不要放同一个目录
这是最近后台提问里出现频率超高的问题:“Spring Boot 项目里,用了 MyBatis-Plus,XML 和 Mapper 接口放同一个文件夹下应该怎么配置?”我先给结论:
技术上完全可以,但没特殊理由,不建议非要把 XML 放 src/main/java 下面。
原因是 Maven 默认打包时,src/main/java目录只打包.java源文件,不会把.xml文件复制到target/classes。你把 XML 放再对的位置,不配置额外资源路径,运行时就是找不到映射文件,Mapper 方法直接报Invalid bound statement (not found)。
如果你的项目里所有 Mapper 接口都放在com.example.demo.mapper包下,为了查看方便硬要把 XML 也放在这个目录里,那么必须做两件事:
- 在
pom.xml里告诉 Maven,打包时把src/main/java下面的.xml文件也当作资源。 - 在
application.yml里把mapper-locations指到对应路径。
这样做确实让 Mapper 接口和 XML 在目录结构上处于同一包,开发者不用切资源目录就能看 SQL。但代价是每次新增 Mapper 模块都要注意配置对不对,一旦忽略,要么本地能跑但打 jar 后找不到 XML,要么 CI 里莫名其妙报错。我个人实验下来的最终结论是:放 resources 目录才是标准姿势。同目录方案适合学习实验、单模块小项目,生产环境还是按社区规范来,效率最高。
3.2 亲手把同目录配置跑通
如果你现在确实需要把 XML 和 Mapper 接口放在同一个包下,可以按下面的步骤走一遍。
第一步:项目里创建 Mapper 接口
package com.example.demo.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.demo.entity.User; import org.apache.ibatis.annotations.Mapper; @Mapper public interface UserMapper extends BaseMapper<User> { User selectByName(String name); }第二步:在同包目录下建同名 XML 文件
目录结构如下:
src/main/java/com/example/demo/mapper/ ├── UserMapper.java └── UserMapper.xmlUserMapper.xml的namespace必须是接口全限定名:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.demo.mapper.UserMapper"> <select id="selectByName" resultType="com.example.demo.entity.User"> SELECT * FROM user WHERE name = #{name} </select> </mapper>第三步:pom.xml 添加资源打包配置
<build> <resources> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> </resource> <resource> <directory>src/main/resources</directory> </resource> </resources> </build>注意第一个resource会把src/main/java下的 XML 全部拉进打包产物,同时保留原有的src/main/resources资源目录。否则你只写第一个<resource>,会覆盖掉默认资源目录,导致application.yml等文件也丢了。
第四步:application.yml 里指定 mapper-locations
mybatis-plus: mapper-locations: classpath*:com/example/demo/mapper/*.xmlclasspath*:前缀的意思是扫描所有 jar 包资源路径,如果有多模块项目,这个写法更稳妥。单模块直接用classpath:也行,但最好保持一致。
这里有一个易错点:如果你在pom.xml已经配置了把src/main/java内的 XML 打包到target/classes,那么最终产物里com/example/demo/mapper/UserMapper.xml与com/example/demo/mapper/UserMapper.class会出现在同一目录下,mapper-locations用classpath*:com/example/demo/mapper/*.xml才能正确匹配。
过一遍这些配置后,启动项目,看到日志里有Loaded mapper ...或 SQL 正常执行,就说明整个链路通了。用 MyBatisX 插件的话,Mapper 接口方法左侧会有一个小图标,点击能直接跳到对应 XML 语句,这就是高亮和导航的价值。
3.3 映射 XML 里的隐藏扣分点
配置只是第一步,真正在 XML 里写 SQL 才是天天踩雷的地方。给你分享几个我印象最深的问题。
第一,XML 特殊字符必须转义。SQL 里常见的><是 XML 的标签标志,直接写在 SQL 里会破坏 XML 结构。比如select * from user where age > 18,在 XML 里必须写成age > 18,或者用<![CDATA[包起来。
<select id="selectAdult" resultType="User"> <![CDATA[ select * from user where age > 18 and create_time < now() ]]> </select>CDATA 块里的内容会当成纯文本处理,这是最直观的解决方案。但注意 CDATA 不能直接套在动态 SQL 标签外面,比如<if>内部用<![CDATA[是可以的,可如果 CDATA 把<if>也包进去,动态标签就失效了。
第二,#{}和${}差别要牢记。#{}是预编译参数占位符,最终生成?占位符,可以防 SQL 注入;${}是字符串拼接,直接把内容塞进 SQL。MyBatis-Plus 的分页插件在某些场景下要拼接 order by 字段,可能不得不用${},但只要能传参数,一律优先用#{}。我之前审计代码时见过有人在 XML 里写order by ${sortField},这个sortField还是前端传的,非常危险。
第三,resultType 和 resultMap 不是一回事。如果查询结果要关联嵌套对象,或者数据库字段下划线与 Java 属性驼峰不一致,需要配置map-underscore-to-camel-case: true或显式使用resultMap。很多人只配了map-underscore-to-camel-case就以为所有字段都自动映射了,遇到复杂 DTO 照样报“绑定异常”。正确做法是:查简单实体类用 resultType + 下划线映射配置,查连表、聚合、一对一、一对多,直接写 resultMap,一清二楚。
4. 被忽略的 XML 安全问题:从 XCTF 题目看 XXE
4.1 Fake XML Cookbook 是如何被打穿的
[XCTF/NCTF2019] Fake XML Cookbook 是一道很经典的题目,它模拟了一个“XML 食谱”应用,用户提交一段 XML,应用会解析并把菜名等信息返回。表面上看,这是一个学习 XML 解析功能的练习环境,但实际上它暴露了 XML 领域最著名的安全漏洞之一 ——XXE(XML External Entity,XML 外部实体注入)。
我强调一下,以下内容仅用于安全学习和授权测试。如果拿到自己或已授权的实验环境,你可以尝试在提交的 XML 里插入一段外部实体定义:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE foo [ <!ENTITY xxe SYSTEM "file:///etc/passwd"> ]> <cookbook> <recipe> <name>&xxe;</name> </recipe> </cookbook>如果后端解析器没有禁用外部实体,&xxe;会被替换成服务器/etc/passwd的内容,然后应用把<name>节点的值返回给前端,你就相当于拿到了一台服务器的读取权限。这就是题目里 “Fake XML Cookbook” 的考点:表面在做饭,实际上在漏文件。
除了file://协议,常见的还有:
php://filter/read=convert.base64-encode/resource=index.php:PHP 场景下读源码。http://内网地址/:做 SSRF,探测内网端口。expect://id:如果目标环境 PHP 装了 expect 扩展,可能甚至执行命令。
问题根因很简单:XML 规范允许通过 DTD 声明外部实体,很多解析器为了兼容旧应用,默认开启了外部实体加载。攻击者只要找到一个能提交 XML 数据的入口,就能利用这一点。
4.2 给 XML 解析器上三道锁
我在平时开发里,凡是接收外部 XML 的接口,都会严格处理解析器配置。因为不同框架默认策略不一样,所以最稳妥的做法是显式禁用外部实体。
Java 里最常用的DocumentBuilderFactory需要设置多项 feature:
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); // 禁用 DTD dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); // 禁用外部普通实体和外部 DTD dbf.setFeature("http://xml.org/sax/features/external-general-entities", false); dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false); // 关闭外部 DTD 加载 dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); dbf.setXIncludeAware(false); dbf.setExpandEntityReferences(false);disallow-doctype-decl是最狠的一道锁,直接拒绝包含 DOCTYPE 声明的文档。大多数业务场景根本不需要用户传 DTD,遇到直接抛出异常即可。如果你基于 SAX 解析,同样有对应的XMLReader配置。
Python 里用lxml时,如果版本足够新,默认不会加载外部实体,但安全起见可以显式禁用:
from lxml import etree parser = etree.XMLParser(resolve_entities=False, no_network=True, dtd_validation=False) root = etree.fromstring(xml_data, parser=parser)不要用from lxml import etree时的默认解析器直接解析不可信 XML,默认行为在不同版本间可能变化。核心心法就一句:谁传 XML 给你,你就假设这个人想搞你,解析前先堵死所有外部资源加载路径。
4.3 MyBatis XML 也怕 XXE 吗
这个问题经常被搞混。有人看到安全通告说“XML 解析漏洞”,立刻怀疑自己项目里的 MyBatis mapper XML 是不是也有风险。
答案很清晰:MyBatis 的 mapper XML 是开发者自己写在代码库里的静态资源,不是用户输入,因此不可能被外部用户利用来注入 DOCTYPE。XXE 攻击成立的前提是解析“不可信的 XML 内容”,比如从 HTTP 请求体、上传文件、接口报文中直接读取的 XML。而你项目里的 mapper XML 是自己人写的,打包后放在 classpath 下,解析它时不会有用户的输入混进去。
但这不代表你能彻底松懈。如果项目里某个接口接收客户端上传的 XML,再用jackson-dataformat-xml或 JAXB 反序列化成 Java 对象,那就要检查是否安全配置了。还有一些企业集成场景,比如对接 SOAP 接口时,客户端发来的 SOAP Envelope 本身也带 DOCTYPE 的隐患。所以我的习惯是:凡是“入站 XML”,统一走同一个安全解析工具类,不搞特例。
这也是我在做完 Fake XML Cookbook 题目后最大的感触:很多安全问题不是新知识,而是旧技术里被忽略的默认行为。你多花十分钟配置一套安全解析器,可能就堵住了一个能泄露服务器文件的大口子。
5. XML 实操踩坑记录与排查速查
5.1 记忆犹新的四个坑
坑一:Windows 记事本编辑 XML,莫名多了两个字符。说起来很气,用记事本保存 UTF-8 编码的 XML 时,Windows 会自动在文件开头加上 BOM(Byte Order Mark)。Java 的某些 XML 解析器不认 BOM,会报类似Content is not allowed in prolog的错误。排查半天才发现是文件编码问题。解决办法是用 VS Code、Notepad++ 重新编码为 UTF-8 无 BOM。后来我所有 XML 文件都强制默认无 BOM,这条规则一直沿用至今。
坑二:多模块项目里 mapper-locations 通配符写错。假设子模块 A 的 mapper 在classpath:mapper/*.xml,子模块 B 的 mapper 也在自己模块的resources/mapper下,Spring Boot 启动时可能只扫描到其中一个模块。把配置改成classpath*:mapper/*.xml后,问题才消失。这个*的区别很微妙,classpath:只扫描当前项目 classpath 下的第一个匹配路径,classpath*:会扫描所有 jar 和依赖里的匹配路径。多模块项目一定要用后者,否则你会陷入“本地单模块跑得好好的,拆成微服务就找不到 XML”的尴尬。
坑三:XML 里写了中文注释,保存时编码不对。如果你的 XML 头声明<?xml version="1.0" encoding="UTF-8"?>,但文件实际用 GBK 保存,解析时会报编码错误或中文乱码。编辑器右下角改下编码,重新保存就可以。这个坑虽低级,但在 Windows 下团队协作时特别容易出现,也要在代码评审时注意,养成“统一 UTF-8”的规范。
坑四:MyBatis 的 namespace 和接口全限定名对不上。这是BindingException最常见的根源。如果你复制粘贴一份 XML,忘记改namespace,那不管怎么配置 mapper-locations,接口方法都找不到对应 SQL。排查时可以看启动日志中 MapperFactoryBean 的扫描记录,或直接用 MyBatisX 跳转检查。高亮插件在这里的真正意义,就是让你肉眼看到接口方法没有绿色导航线时,第一时间反应“命名空间是不是写错了”。
5.2 一套快速定位问题的姿势
遇到 XML 相关异常,我一般按以下顺序排查:
- 确认能不能解析:把 XML 内容单独复制到一个临时文件,用浏览器打开或 IDEA 里格式化,快速定位结构错误。浏览器直接打开 XML 报错行数通常很明确。
- 确认编码与 BOM:检查文件编码是否与 XML 头一致,有无 BOM。
- 确认路径扫描:Spring Boot 项目启动时如果提示
Invalid bound statement,先用jar tf看一下打包产物里到底有没有 XML 文件。 - 确认 SQL 语法:MyBatis 只负责把 XML 里的 SQL 语句发到数据库,如果 SQL 语法错误,数据库会报错,检查日志里
Preparing:后拼接出来的 SQL。 - 确认参数绑定:多个参数时,XML 里写
#{0}、#{1}在老版本有效,新版本推荐用@Param明确命名,否则容易绑定到错误的参数。
我把排查要点整理成了表格,你可以直接存下来对照。
| 异常现象 | 常见原因 | 排查方向 |
|---|---|---|
Content is not allowed in prolog | 文件头 BOM 或不可见字符 | 用编辑器另存为 UTF-8 无 BOM |
Invalid bound statement | XML 未打包或 namespace 不匹配 | 看 target/classes 有没有 xml;检查 namespace |
No such property: xxx | 参数名不对或缺少 @Param | XML 参数名与接口一致 |
| XML 解析器报 Entity 禁用 | 代码里显式禁止 DTD 导致与旧配置冲突 | 确认业务是否必须传 DTD,非必要保持禁用 |
| 中文乱码 | 文件编码与解析器不一致 | 统一 UTF-8 编码 |
附送一个小技巧:在application.yml里配置 MyBatis 的日志输出,能极大提升排查效率。
mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这样每次执行 SQL,控制台会打印完整的预处理语句和参数列表,你就能一眼看出#{}有没有被正确填充,${}有没有拼接异常。
最后再分享一个我自己养成的小习惯:项目里加一个简单的 XML 工具集合,统一封装“解析不可信 XML”的安全配置。团队其他人要解析 XML 时,不允许直接在业务代码里DocumentBuilderFactory.newInstance(),而是调用这个工具方法。这样即使有人不熟悉 XXE,也不会写出默认配置的解析器。像 XML 这种越基础的技术,越要有一套默认的安全实践和标准配置,才能真正少踩坑。