前几天一个同事拿着 XML 配置文件来找我说后端解析同一份配置测试环境没问题生产环境偶发报错。我打开文件扫了一眼就发现根因了——根节点下该有的字段少了多出来的节点却被代码当成可选分支在兼容。他这里缺的正是 XML 文档结构验证解析器只知道这份 XML“格式正确”并不知道它的“结构合法”。这种场景从 XML 诞生起就有标准解法DTD。DTD 全称 Document Type Definition负责约定 XML 里有哪些元素、元素怎么嵌套、属性类型是什么它存在的价值就是把“格式对”和“结构对”这两件事彻底分开。这篇文章我会拿一个完整的实例从头到尾拆一遍 DTD 的语法、组织方式、验证工具和绕过不了的坑适合刚接触 XML 校验的人也适合写了多年配置文件但从不做结构约束的开发者。1. 没有结构约束的 XML为什么总是出问题1.1 没有 DTD 的 XML 长什么样很多人一开始接触 XML写出来是这样的bookstore book idbk001 title深入理解XML解析/title author张三/author price89.9/price /book /bookstore这份 XML 自己看没问题但如果交给程序去解析问题就来了price到底是字符串还是数字author会不会在某条数据里缺失book下能不能出现第二个title能不能把title和author顺序换一下这些事 XML 本体不回答。假设下游代码这样读String priceText bookElement.getElementsByTagName(price).item(0).getTextContent(); double price Double.parseDouble(priceText);某天新增了一条数据price被写成了待定程序直接抛NumberFormatException。再比如另一个同事认为book下面应该先写author再写title如果所有数据都按真实姓名排序标题反而乱套。这些都不是 XML 格式的问题但都因为结构没有约束而变成线上事故。1.2 XML 校验的两个层次well-formed 和 validXML 的合法性分成两个层次很多人一直没分清。第一层叫良构well-formed只要求语法正确有且只有一个根节点、标签闭合、属性值有引号、命名规范等。任何解析器连这一层都过不了就会直接拒收。第二层叫有效valid要求在良构的基础上内容符合一套事先定义好的结构规则。这就是 DTD 和 XSD 这类验证语言发挥作用的地方。DTD 管的就是第二层元素顺序、出现次数、属性取值、实体定义。打个比方良构相当于“你写了一句语法通顺的中文”有效相当于“这句话必须按合同条款的固定句式来写”。对程序来说两者缺一不可。很多线上 XML 解析问题其实都出在第二层没人管而不是第一层解析器不够好。另一个常见误解是DTD 已经过时了。实际上它从 XML 1.0 规范里出生到今天依然被大量基础设施使用。你打开 MyBatis 的 Mapper 文件、macOS 的 plist 文件甚至老一代 Spring 配置都可能碰到 DTD。它语法比 XSD 简单很多学习成本低对文档结构约束这种需求来说往往够用。2. 手写一份可运行的 DTD图书目录实例全解2.1 先看目标 XML这节我们用一份图书目录作为实例。目标 XML 长这样?xml version1.0 encodingUTF-8? !DOCTYPE bookstore SYSTEM bookstore.dtd bookstore book idbk001 isbn978-7-111-12345-6 categoryprogramming title深入理解XML解析/title author张三/author price currencyCNY89.9/price /book book idbk002 isbn978-7-121-54321-0 categorydatabase title数据库原理/title author李四/author price currencyCNY76.00/price /book /bookstore先不急着写 DTD我们把规则理出来根节点是bookstore里面可以有多本book每本book必须有id、isbn和category三个属性book的子元素顺序必须是title、author、priceprice可以有currency属性缺省默认CNY。这些规则不是拍脑袋定的它们应该来源于业务需求。写 DTD 的第一步不是写语法而是把业务需要保证的约束列出来。2.2 元素声明内容模型的语法拆解DTD 中元素声明用!ELEMENT!ELEMENT bookstore (book) !ELEMENT book (title, author, price) !ELEMENT title (#PCDATA) !ELEMENT author (#PCDATA) !ELEMENT price (#PCDATA)针对这段声明逐个拆开看bookstore (book)表示根节点bookstore的内容是一个子元素book表示出现一次或多次。写零本也不行至少得有一本。book (title, author, price)表示book里有两个关键点逗号代表顺序必须严格按title、author、price来顺序调换即使 XML 格式正确验证时也会报错。title (#PCDATA)表示title元素只能包含文本不能再出现子元素。除了DTD 还有两个频率修饰符修饰符含义例子无恰好一次(title)?零次或一次即可选(subtitle?)*零次或多次(tag*)一次或多次(book)如果把规则改成“一本book必须有title可以没有subtitle可以有很多个review”元素声明就是!ELEMENT book (title, subtitle?, author, price, review*)内容模型里还可以用竖线|表示选择关系。比如“left和right二选一”写作(!ELEMENT page (left | right))。混合内容则写成(#PCDATA | emphasis)*代表元素内可以混着文本和emphasis子元素但出现频率必须是*这是 DTD 规定死的。很多人写(#PCDATA | emphasis)不带星号验证直接失败这是个高频报错点。这里要特别说明#PCDATA和CDATA的区别。#PCDATA是元素内容全称 parsed character data里面的、这些字符需要被解析因此必须转义。CDATA通常用在属性值类型里表示“任意字符串都可以”。在 DTD 中给元素声明内容时可以直接写(#PCDATA)但不能写(CDATA)。这个两个词长得像含义差得远新手最容易混淆。2.3 属性声明类型、默认值和 ID属性用!ATTLIST声明一个声明可以同时约束多个属性!ATTLIST book id ID #REQUIRED isbn CDATA #REQUIRED category (programming | database | network) programming逐项看id ID #REQUIREDid的类型是 ID且必填。ID 类型的值在整份文档里必须唯一而且不能以数字开头必须以字母或下划线开头。这就是为什么上面的实例里我写bk001而不是978-7...——很多初学者会把 ISBN 这类数字串直接声明成 ID结果验证怎么都不过还以为是编码问题。isbn CDATA #REQUIREDisbn是字符串必填。它不需要唯一性用 CDATA 就够了。category (programming | database | network) programming这是枚举类型取值只能在这三个里选一个默认值是programming。如果 XML 里category写了web验证会报错如果完全不写则默认补上programming。DTD 属性默认值的写法有四种写法含义#REQUIRED必须出现#IMPLIED可选可有可无#FIXED 值固定值如果出现则必须等于该值默认值不写时自动补上该值#FIXED是个容易被忽略但很有用的类型。比如所有bookstore都得标记版本!ATTLIST bookstore version CDATA #FIXED 1.0XML 里写bookstore version1.0合法写bookstore version2.0就报错不写version也算合法因为解析器会自动补成1.0。除了 ID 和 CDATA属性类型还有 IDREF引用文档内某个 ID、IDREFS多个 ID 引用、NMTOKEN受限的名字标记、ENTITY 等。IDREF 在“文章引用作者、订单引用用户”这类关系场景很常用不过它只能保证存在一个同名的 ID不能保证类型匹配更深入的关系校验还是得靠代码。2.4 把完整实例串起来验证把前面这些声明放进一个bookstore.dtd文件!ELEMENT bookstore (book) !ELEMENT book (title, author, price) !ELEMENT title (#PCDATA) !ELEMENT author (#PCDATA) !ELEMENT price (#PCDATA) !ATTLIST book id ID #REQUIRED isbn CDATA #REQUIRED category (programming | database | network) programming !ATTLIST price currency (CNY | USD | EUR) CNY然后用命令行验证后面第 5 节会详细讲工具正常会输出类似下面这样的结果book.xml validates如果你把title和author的顺序换一下或者把category写成webxmllint会立刻给出错误行号。这就是文档结构验证的实际价值把错误挡在解析和部署之前而不是等到程序跑起来才炸。3. DOCTYPE 的组织方式内部、外部、PUBLIC 和 SYSTEM 到底怎么选3.1 内部 DTD一个文件全搞定如果不是很复杂的约束可以把 DTD 直接嵌在 XML 内部写法是 DOCTYPE 后跟中括号?xml version1.0 encodingUTF-8? !DOCTYPE note [ !ELEMENT note (to, from, body) !ELEMENT to (#PCDATA) !ELEMENT from (#PCDATA) !ELEMENT body (#PCDATA) ] note to接收方/to from发送方/from body正文内容/body /note内部 DTD 的优点是文件自包含拷走一份 XML 就带走所有结构规则不需要额外分发.dtd文件。缺点是如果多个 XML 共用同一套规则就得在每一份里复制粘贴一遍改规则时容易漏改。所以内部 DTD 适合那种临时共享、体量很小的配置文件。3.2 SYSTEM 外部 DTD引入本地或远程规则外部 DTD 把结构规则单独放到.dtd文件里XML 里用 SYSTEM 指向它!DOCTYPE bookstore SYSTEM bookstore.dtd这里的bookstore.dtd是系统标识符URI可以是相对路径、绝对路径也可以是 URL。实例里的bookstore.dtd和book.xml在同一个目录下所以直接写文件名。用外部 DTD 的好处是多个 XML 可以共用同一份规则。比如一个电商系统有订单、商品、用户三种 XML各自对应一个 DTD哪天需要添加“商品必须属于某个类目”只改一个goods.dtd就行。代价是文件分发时要记得把 DTD 一起带过去否则某些严格验证的解析器会报“无法加载 DTD”。3.3 PUBLIC 公共 DTD框架和系统都在用的形式PUBLIC 和 SYSTEM 不同它额外提供一个公共标识符。常见格式!DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd前半段-//mybatis.org//DTD Mapper 3.0//EN是公共标识符后半段是系统标识符。解析器通常不用公共标识符去网络上找而是由具体的解析环境把公共标识符映射到本地文件。这样即使系统标识符指向的 URL 访问不了验证依然能完成速度和安全性都会好很多。三者的对比可以总结成一张表方式定义位置适用场景风险点内部 DTDXML 内部单文件、结构简单多文件维护困难外部 SYSTEM独立.dtd文件多文件共用一组规则文件缺失或路径不对外部 PUBLIC独立.dtd文件 公共标识符框架级配置、行业标准解析器不支持映射时会回退到网络公共标识符还有一个规则如果标识符以-开头表示未经标准化组织批准比如厂商自定义的标识符以开头表示已通过标准化组织注册。MyBatis 和 Apple 的 DTD 都用了-开头因为它们属于厂商私有约定。实际项目中我倾向这样选单纯给内部系统验 XML用 SYSTEM 外部 DTD 就够了要给行业交换数据优先用公开的 DTD 或 XSD如果只是临时写个测试脚本内部 DTD 最快。4. 现实世界里的 DTDMyBatis Mapper 与 Apple plist 的验证细节4.1 MyBatis 的 Mapper XML 初始化链路很多 Java 后端同学天天写 MyBatis 的 Mapper 文件但从来没注意过文件头的 DOCTYPE!DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd这个 DOCTYPE 不是摆设。MyBatis 启动时解析 Mapper XML 的完整链路大概是SqlSessionFactoryBuilder读入全局配置mybatis-config.xml创建XMLConfigBuilder。XMLConfigBuilder解析configuration根节点挨个处理mappers、typeAliases等子节点。遇到 Mapper 文件时创建XMLMapperBuilder去解析 Mapper XML。解析器底层是XPathParser它在读取 XML 时发现 DOCTYPE 声明会把解析工作交给XMLMapperEntityResolver。XMLMapperEntityResolver实现了EntityResolver接口根据 PUBLIC 的 DTD 标识符从 mybatis 的 jar 包org/apache/ibatis/builder/xml下加载本地 DTD 文件而不是真的去访问http://mybatis.org/...那个 URL。DTD 校验通过后XML 里的select、insert、update、delete、resultMap等节点才会被解析成对应的 MappedStatement 和 ResultMap 对象。我在面试里经常问一句话如果 MyBatis 的 Mapper XML 里突然多写了一个xxx标签启动会发生什么答案不是“忽略它”而是 DTD 校验直接失败MyBatisException抛出应用启动中断。比如mapper下要求select、insert这些标签按特定内容模型出现如果你把select放到了resultMap里面DTD 对resultMap的内容模型立刻不认。这也解释了为什么 MyBatis 使用 DTD 而不是 XSD它只需要最基础的结构约束DTD 体积小、语法简单、解析开销低而且定义在 jar 包内部天然离线可用。全局配置mybatis-config.xml的 DTD 是-//mybatis.org//DTD Config 3.0//EN和 Mapper 的 DTD 不是同一个别搞混。4.2 Apple plist 的 DTD 实例macOS 和 iOS 开发中常见的 plistProperty List文件也在用 DTD。一个典型的 plist 大概是?xml version1.0 encodingUTF-8? !DOCTYPE plist PUBLIC -//Apple//DTD PLIST 1.0//EN http://www.apple.com/DTDs/PropertyList-1.0.dtd plist version1.0 dict keyCFBundleName/key stringDemo/string keyCFBundleVersion/key string1.0/string /dict /plistApple 官方定义的 PropertyList-1.0.dtd 约束了plist根元素下面可以出现什么对象。整个 plist 最终只会有一个核心对象可以是dict、array、string、integer、real、true、false、date、data等。比如dict的内容模型要求的是“key和其他值节点交替出现”这正好是 plist 的格式特征。不过 DTD 对 plist 的约束粒度有限。它只能约束“dict里先出现key再出现值”但没法约束“某个 key 对应的值必须是 string 还是 integer”这种业务语义层面的检查最终还得靠代码。如果你在 Xcode 或 plist 编辑工具里保存了错误的 plist工具本身会先做 DTD 校验格式不合法直接拒绝。4.3 为什么这些项目不换成 XSD既然 XSDXML Schema Definition能力更强为什么 MyBatis 和 Apple 还在用 DTD原因有三点一是兼容性。DTD 是 XML 1.0 的一部分所有 XML 解析器都支持而 XSD 是 W3C XML Schema 规范解析器必须额外加载 Schema 解析器环境适配成本高。二是体积和速度。DTD 语法紧凑几行就能描述清楚解析开销也小。MyBatis 这种轻量级框架不愿意为了配置文件引入复杂的 Schema 处理链路。三是历史惯性。MyBatis 和 plist 的 DTD 已经存在十几年所有工具链都围绕它调试好了贸然切换到 XSD 对上游工具和下游用户都有破坏性。但这不意味着 DTD 永远够用。如果你需要精确的日期格式、数值范围、正则表达式校验或者需要处理带命名空间的复杂 XMLXSD 才是正确选择。第 6 节我会专门讲 DTD 的边界。5. 让机器帮你验证xmllint、IDEA 与解析器实操链路5.1 命令行验证xmllint 的完整操作Linux 和 macOS 上一般自带libxml2里面的xmllint是最方便的 DTD 验证工具。用起来很简单xmllint --noout --valid book.xml--noout表示不输出解析后的 XML 内容因为我们只想看验证结果。--valid表示执行带 DTD 校验的解析。如果验证通过输出只有一行book.xml validates。如果有错会像这样book.xml:7: element title: validity error : Element book content does not follow the DTD, expecting (author , price), got (author )这种错误信息虽然长但很直观指定了行号、出错的元素以及 DTD 期望的内容模型。如果 XML 文件里没有写DOCTYPE但你仍然想拿外部 DTD 去验证可以这样xmllint --noout --dtdvalid bookstore.dtd book.xml这在写新 XML、还不想改文件头时非常有用。验证 CDATA 和编码问题时xmllint也有一个好用的参数xmllint --noout --valid --encoding UTF-8 book.xml如果 XML 文件声明的编码和实际内容不一致这个命令会直接报错。文件收集自不同来源时这一步能提前暴露乱码隐患。5.2 IDE 验证与“保存时不要重新格式化 XML”IDEA 社区版对 XML 的 DTD 验证支持一直不错。你把 MyBatis Mapper 文件或带 DTD 的 XML 拖进 IDEA它会自动解析 DOCTYPE左边会出现一个.dtd文件如果你写错元素名或属性编辑器会直接标红。这个能力社区版就有不需要买旗舰版原因是 IDEA 内置的 XML 插件对 DTD 解析做了完整支持。很多人问“IDEA 社区版怎么让 XML 里的文件不格式化”我猜他们遇到了两种情况第一种是保存文件时 IDEA 自动重排 XML把原本整齐的属性、缩进改掉。解决办法是打开 Settings - Tools - Actions on Save找到Reformat code这项把它取消勾选。这样保存时 IDEA 不再重新格式化。第二种是只想对某个 XML 片段禁用格式化其他区域保持自动格式化。IDEA 支持 Formatter Control操作路径是 Settings - Editor - Code Style - Formatter Control把它启用。然后在你不想被重排的内容前后加入 XML 注释标记!-- formatter:off -- book idbk001 isbn978-7-111-12345-6 categoryprogramming ... /book !-- formatter:on --标记内的代码会被 IDEA 当作保护区格式化时原样保留。我自己的经验是IDEA 2023.2 的社区版对 XML 注释里的formatter:off是认的但为了保险如果你发现标记不生效优先用第一种方法关掉保存时自动格式化。其实格式化和 DTD 校验是两个概念。格式化解决的是“看起来统一”DTD 解决的是“结构合法”。先让结构验证通过再去考虑格式排查问题才不会混在一起。5.3 解析器级验证Java 和 Python 的做法如果你在写代码需要在运行时做文档结构验证Java 的 DOM 解析器本身支持 DTD 校验。经典写法DocumentBuilderFactory factory DocumentBuilderFactory.newInstance(); factory.setValidating(true); DocumentBuilder builder factory.newDocumentBuilder(); Document doc builder.parse(new File(book.xml));setValidating(true)表示解析器在解析时执行 DTD 校验出错的节点会抛SAXParseException。需要提醒的是setValidating(true)会让解析器要求 DTD如果你的 XML 没有 DOCTYPE它不会报“缺少 DTD”而是直接跳过校验。所以想强制校验代码里最好检查一下doc.getDoctype()是否为 null。还有一个安全点生产环境解析 XML 前要评估外部实体风险DTD 支持引入外部实体如果解析器配置不当可能被恶意 XML 利用第 6 节详细说。不要为了贪图方便在业务系统里裸奔setValidating(true)至少要设置安全特性或限制外部实体访问。Python 里用lxml做 DTD 验证也很顺手from lxml import etree parser etree.XMLParser(dtd_validationTrue, no_networkTrue) tree etree.parse(book.xml, parser)dtd_validationTrue表示执行 DTD 校验no_networkTrue表示禁止解析器访问网络防止 DTD 里的外部实体把数据拉到本地来。解析出错时会抛出 XMLSyntaxError里面含有行号和期望内容。两种解析器级验证的对比语言库参数适用场景JavaJDK 内置 DOMfactory.setValidating(true)后端服务解析上传 XMLPythonlxmlXMLParser(dtd_validationTrue, no_networkTrue)脚本、测试、CI 校验5.4 从错误信息反推问题多年实践下来DTD 验证报错最常见的就是下面几类我列成一个速查表报错关键内容实际原因解决办法element not declaredXML 里出现了 DTD 没声明的元素在 DTD 中补!ELEMENT声明does not carry attribute属性未声明在!ATTLIST里补属性content does not follow the DTD子元素顺序或次数不对对照 DTD 内容模型调整 XMLAttribute ... must be uniqueID 类型属性值重复改成文档内唯一的值不能以数字开头Entity ... not defined使用了未声明的实体补充!ENTITY声明或改用字符引用EOF ... no DTDDOCTYPE 缺失或 DTD 文件没加载到检查 DOCTYPE 路径和文件是否存在排查顺序我一般是先看根元素是否声明再看子元素顺序和次数最后看属性。因为根元素错了后面的错误信息经常是连环的只看第一行错误往往被带偏。6. DTD 的边界与我最常踩的坑6.1 EMPTY 元素与空文本几乎每个人都踩过DTD 里!ELEMENT br EMPTY表示这个元素不能有任何内容也不能有文本子节点。问题是XML 编辑器一旦把br /自动展开成br /br某些解析器会认为这在结构上违反了EMPTY声明因为元素节点里出现了换行和空格。你可能会看到类似“The content of the element is not allowed”的错误。解决办法很简单EMPTY元素一律写成自带闭合的br /不要在标签之间留任何空格和换行。这个坑在生成 XML 的模板代码里尤其常见。Java 字符串拼接br text /br看着正常但text如果为空或者包含多余的空白就会撞上 DTD 校验。6.2 DTD 不能做的校验与硬伤DTD 的硬伤很明显它只能做结构约束做不了类型约束。price声明成(#PCDATA)之后你填89.9合法填abc同样合法。想限制price必须是 0 到 10000 之间的数字DTD 完全无能为力。另外 DTD 有一个很尴尬的地方就是它和命名空间配合得不好。XML 里用了xs:element这种带前缀的名字DTD 中声明时只能用整个带前缀的字符串作为元素名等于把前缀写死而 XSD 本身就是为了支持命名空间设计的所以在 SOAP、WSDL 这些需要大量使用命名空间的场景你会很少看到 DTD。DTD 也无法表达“如果 A 元素存在那么 B 元素必须存在”这种跨元素约束。这类业务规则到最后都得拿到代码层做二次校验所以纯 DTD 并不能替代业务校验逻辑。6.3 实体扩展攻击与安全防护DTD 的实体机制是一把双刃剑。声明实体能减少重复内容比如!ENTITY copyright 2024, example.com但外部实体可以把 XML 里的值指向本地文件或网络地址。如果解析器配置不当攻击者可以构造一个包含外部实体的 XML让程序把服务器上的/etc/passwd文件内容读出来并回传到响应里这就是经典的 XXEXML External Entity攻击。另一种攻击叫“十亿笑”Billion Laughs用嵌套实体把很小的 XML 展开成几个 GB 的数据直接耗尽服务器内存。比如!ENTITY a aaaa... !ENTITY b a;a;a;...这行只是说明原理实际环境里一定不要在自己的代码里组装这种结构。防护原则很简单不需要 DTD 时尽量在解析器中禁用 DOCTYPE 声明。需要 DTD 时禁用外部实体和参数实体。禁止解析器访问网络资源no_networkTrue这类配置能挡掉很大一部分风险。在没有明确需求时不要从不可信来源解析 XML 文件。这也是为什么我在第 5 节 Java 例子里强调别单纯为了“跑通”就把setValidating(true)和外部实体访问一起打开。6.4 什么时候该考虑 XSD如果你的 XML 开始出现以下需求我建议尽早切换到 XSD需要限制数值范围、日期格式、正则表达式。需要在不同命名空间下复用同一个元素定义。需要区分元素内容是空、文本、还是复杂子元素。需要更精确地控制元素和属性是否唯一、是否可空。XSD 的代价是语法复杂、学习成本高完整写一份 XSD 比写 DTD 累得多。常见的折中方案是简单配置文件继续用 DTD数据交换和需要类型约束的接口文档用 XSD。比如企业内部系统之间传递的订单 XML我一般直接配 XSD因为字段类型、枚举、必填项这些业务规则都在 Schema 里解析方和生成方拿着同一份 XSD 对齐沟通成本低很多。DTD 不是银弹但也不是古董。对一批结构固定的配置文件来说几行 DTD 就能建立一道基础防线成本低、见效快而且所有语言的解析器都认。最后说一个我自己的习惯不管用 DTD 还是 XSD我几乎都会在 CI 里加一步xmllint --valid确保所有配置文件在发布前被机器验证过。有人觉得多此一举但现实是XML 这类格式最大的问题不是解析器跑不通而是它太容易“看起来没问题”了。文档结构验证这件事花的成本很小省下的排障时间却很可观。