ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

dom4j实战教程:基于Java的XML增删改查与XPath操作指南

dom4j实战教程:基于Java的XML增删改查与XPath操作指南

先纠正一个拼写:标题里的 domj4,大概率是 dom4j 的手误。原因很简单,Java 生态里没有叫“domj4”的知名库,但 dom4j 是处理 XML 的明星级开源库,你随便打开几个老项目的 pom.xml 就能看到它的身影。我最早接触 XML 解析时用原生 DOM,代码又长又绕,改一个嵌套节点要写一堆 getChildNodes 循环,后来换成 dom4j,整个体验像从手动挡换成了自动挡。这篇文章就把我用 dom4j 做查询、新增、修改、删除、保存的完整过程捋一遍,适合刚接触 XML 处理,或者想从原生 DOM 切换方案的开发者。

1. 为什么是dom4j:先搞清楚它解决什么问题

1.1 XML解析的主流方案,dom4j处在什么位置

XML 解析本质上是把一段带尖括号的文本变成程序能理解、能操作的内存结构。常见的方案有四种:DOM、SAX/StAX、JDOM、dom4j。很多人一上来就用 DOM,因为它能整体读取、任意修改,但一旦 XML 结构复杂,DOM 的 API 会让你写出大量重复代码。

表格直接看差别:

方案解析方式支持修改代码友好度内存占用典型场景
DOM树结构一次性加载支持一般高小文档、频繁修改
SAX流式解析不支持一般低大文件只读
StAX拉式流解析不支持较好低大文件只读、流式写
JDOM树结构支持较好中中小文档,贴近 Java 集合
dom4j树结构支持好中中小文档、XPath、框架内嵌

dom4j 的好处在于它把 DOM 的“全量可操作”和 SAX 的“底层性能”结合了起来。它内部用 SAXReader 去解析,对外给你一个完整的 Document 树,既能读又能写,还提供了顺手得多的 API。Spring 等主流框架早期也大量用它做 XML 配置解析,这从侧面说明它在工程界的地位。

1.2 dom4j眼中的XML:节点树模型

dom4j 把 XML 文件看作一棵树。Document 是整棵树,对应文件本身;Element 是树干和树枝,对应一对标签中间的内容;Attribute 是挂在树干上的标签贴纸,对应标签里的键值对;Text 是树叶,对应被标签包裹的可见文字。

核心接口就这么几个,不需要记太多,先认识它们:

  • Document:代表整个 XML 文档,有且只有一个根节点
  • Element:代表一个元素,可以包含子元素、属性、文本
  • Attribute:代表元素的一个属性,比如<book id="1">里的 id
  • Text:代表元素内的文本内容,日常开发中一般用 setText/addText 间接操作

用这个模型去理解,增删改查就变得很直观:增,就是在树上加一个节点或属性;删,就是剪掉一个节点;改,就是替换某个节点的内容;查,就是沿着树的枝干找到目标节点。

2. 先把环境跑通:依赖、读取、创建一份XML

2.1 引入依赖,注意版本和XPath传递依赖

如果你用 Maven 管理项目,pom.xml 里加这两条就行:

<dependency> <groupId>org.dom4j</groupId> <artifactId>dom4j</artifactId> <version>2.1.3</version> </dependency> <dependency> <groupId>jaxen</groupId> <artifactId>jaxen</artifactId> <version>1.2.0</version> </dependency>

第一条是 dom4j 本体,第二条是 XPath 引擎。很多人只加 dom4j,运行到 selectSingleNode 时突然报NoClassDefFoundError: org/jaxen/JaxenException,一脸懵。原因就是 dom4j 的 XPath 能力依赖 jaxen,而有些版本不会自动带上它,所以建议显式声明,省得后面踩坑。

非 Maven 项目也别慌,直接下载 dom4j 和 jaxen 两个 jar 包丢进 classpath 即可。

2.2 从文件或字符串读取XML

读取一个 XML 文件最基本的方式是使用 SAXReader:

import org.dom4j.Document; import org.dom4j.DocumentException; import org.dom4j.Element; import org.dom4j.io.SAXReader; import java.io.File; public class ReadXmlDemo { public static void main(String[] args) { SAXReader reader = new SAXReader(); try { Document document = reader.read(new File("books.xml")); Element root = document.getRootElement(); System.out.println("根节点: " + root.getName()); for (Element book : root.elements("book")) { String id = book.attributeValue("id"); String title = book.elementText("title"); System.out.println(id + " - " + title); } } catch (DocumentException e) { e.printStackTrace(); } } }

如果 XML 不是存在文件里,而是来自某个接口返回的字符串,直接用DocumentHelper.parseText()更快:

String xml = "<books><book id=\"1\"><title>Java编程思想</title></book></books>"; Document document = DocumentHelper.parseText(xml);

SAXReader 本质上是对底层 SAX 解析器的封装,它可以一边解析一边构建内存树,对外看起来是一次性读入,实际底层已经做了流式处理。

2.3 从零创建XML并保存

有读取自然要有创建。创建一个全新 XML 文档的代码并不复杂:

import org.dom4j.Document; import org.dom4j.DocumentHelper; import org.dom4j.Element; Document document = DocumentHelper.createDocument(); Element root = document.addElement("books"); Element book1 = root.addElement("book"); book1.addAttribute("id", "1"); book1.addElement("title").addText("Java编程思想"); book1.addElement("author").addText("某个作者"); Element book2 = root.addElement("book"); book2.addAttribute("id", "2"); book2.addElement("title").addText("重构"); book2.addElement("author").addText("另一个作者");

这里有个新手容易疑惑的点:addElement到底返回什么?它返回的是新建出来的 Element,所以你可以连着写.addElement("title").addText("..."),每一步都是在对刚创建的子元素操作,逻辑非常顺。

保存部分后面专门讲,先在脑海里记住这个闭环:Document 是根,Element 是节点,addElement 和 addText 负责填充,最后通过 XMLWriter 写出去。

3. 查询操作:节点遍历、属性读取和XPath定位

3.1 基础遍历:拿根节点、子节点、属性

查询是增删改的前置操作,先找到目标才能动手。最简单的方式是层级遍历:

Element root = document.getRootElement(); // 获取 root 下所有名为 book 的子元素 List<Element> books = root.elements("book"); for (Element book : books) { String id = book.attributeValue("id"); String title = book.elementText("title"); String price = book.elementText("price"); System.out.println(id + " / " + title + " / " + price); } // 获取 root 下第一个 book 子元素 Element firstBook = root.element("book"); // 获取某个属性的值,属性不存在时返回 null String category = firstBook.attributeValue("category");

dom4j 对“取子元素文本”提供了很友好的快捷方式。比如book.elementText("title"),如果<book>下有名为title的子元素,就直接返回它的文本;没有就返回 null,省去了先 element 再 getText 的两步操作。

3.2 用XPath精准定位,少写循环

遇到深层嵌套的 XML,一层层 getElement 会写到怀疑人生。这时候就该上 XPath 了:

import org.dom4j.Node; // 查找 id 属性为 2 的 book 节点 Node node = document.selectSingleNode("//book[@id='2']"); if (node instanceof Element) { Element book = (Element) node; System.out.println(book.elementText("title")); } // 查找所有 book 下的 title,返回列表 List<Node> titles = document.selectNodes("//book/title"); for (Node titleNode : titles) { System.out.println(titleNode.getText()); }

XPath 的写法我很建议单独积累,它语法不多,但信息密度很高。//book表示在任意层级找 book;[@id='2']表示过滤属性;/title表示取子元素。组合在一起,一条语句就能替代几十行循环。

不过要注意,selectSingleNode查不到结果时会返回 null,别直接强转,否则 NPE 等着你。

3.3 查询时要避开的三个坑

第一个坑是空指针。很多查询结果可能为 null,代码里要习惯判空,不要一上来就((Element) node).elementText(...)。

第二个坑是命名空间。如果 XML 里带了命名空间,比如<ns:book xmlns:ns="http://example.com/books">,你直接用//book是查不出来的。正确做法是注册前缀:

Map<String, String> nsMap = new HashMap<>(); nsMap.put("ns", "http://example.com/books"); XPath xpath = DocumentHelper.createXPath("//ns:book[@ns:id='1']"); xpath.setNamespaceURIs(nsMap); Node node = xpath.selectSingleNode(document);

第三个坑是文本节点和 CDATA 的取值。CDATA 里面的内容在 dom4j 里可能被解析成 CDATA 节点,直接用 getText 取不到,需要判断节点类型。遇到这类数据,我一般先把节点 toString 打出来看一眼,再决定处理方式,不建议盲目依赖 getText。

4. 添加与修改:动态调整XML结构

4.1 新增节点、属性和文本

新增操作在 dom4j 里非常顺手,基本就是链式调用:

Element root = document.getRootElement(); Element newBook = root.addElement("book"); newBook.addAttribute("id", "4"); newBook.addElement("title").addText("深入理解Java虚拟机"); newBook.addElement("author").addText("某作者"); newBook.addElement("price").addText("129.00");

注意,addElement默认把新节点追加到父节点的最后一个子元素后面。如果你的业务对节点顺序没有强要求,这样最省事。但有顺序要求时,就需要下面这招了。

4.2 往指定位置插入新元素

dom4j 的 Element 接口继承自 Branch,Branch 提供了一个add(int index, Node node)方法,可以指定插入位置。先把新节点创建出来,再插入到目标位置:

Element newBook = DocumentHelper.createElement("book"); newBook.addAttribute("id", "0"); newBook.addElement("title").addText("代码整洁之道"); root.add(0, newBook); // 插到第一个子元素的位置

这段代码在内存里先把“游离节点”newBook 建好,然后通过root.add(0, newBook)把它挂到树的指定位置。位置下标从 0 开始,和 List 的规则一致。

4.3 修改属性与文本内容

修改比新增更简单,定位到节点后直接改:

Element book = (Element) document.selectSingleNode("//book[@id='4']"); book.addAttribute("price", "139.00"); // 属性存在则覆盖,不存在则新增 book.element("title").setText("深入理解Java虚拟机(第2版)"); book.element("author").setText("更新后的作者");

很多人会纠结addAttribute和setAttributeValue的区别,我的经验是:日常场景用addAttribute就够了,它天然具备“不存在就新建、存在就更新”的语义,代码意图也更明确。setAttributeValue在某些特殊实现里对 null 的处理容易让人误解,不如 addAttribute 直观。

4.4 改完之后的几个注意事项

新增和修改虽然简单,但有几个细节容易出问题。

一个是最容易忽视的“游离节点”问题:用DocumentHelper.createElement创建的节点,在没有被 add 到某个 Document 之前,它不属于任何树。如果你把这个节点保存到另一个 List 里,改了半天才发现没挂到树上,白忙一场。

另一个是空文本问题。setText(null)和setText("")在生成 XML 时表现不一样:前者可能生成<title/>这样的空标签,后者可能生成<title></title>,虽然语义近似,但视觉和部分解析器的行为有差异。最好统一约定,避免不同节点风格混杂。

5. 删除操作:干净利落还是暗藏风险

5.1 删除节点、属性和清空内容

删除节点最简单的方式是找到它,然后调用 detach 或者让父节点移除它:

Element book = (Element) document.selectSingleNode("//book[@id='4']"); if (book != null) { book.detach(); // 从父节点上分离,自己就成了一棵孤树 }

另一种等价写法是:

Element parent = book.getParent(); if (parent != null) { parent.remove(book); }

我推荐优先用 detach,因为它自动处理了父子关系的解除,不需要你先判断 parent 是不是 null。删除属性更简单:

Element book = (Element) document.selectSingleNode("//book[@id='1']"); Attribute attr = book.attribute("price"); if (attr != null) { book.remove(attr); }

如果想清空一个元素的所有子节点和文本,又不想删掉这个元素本身,用 clearContent:

root.clearContent(); // 清空 root 下所有子节点

5.2 循环删除最容易翻车,别边遍历边删

这是我见过新手踩得最多的坑:想删除满足条件的多个节点,于是写了类似下面的代码:

for (Element book : root.elements("book")) { if ("delete".equals(book.attributeValue("status"))) { root.remove(book); } }

这段代码在运行到第二个删除操作时大概率抛ConcurrentModificationException,因为你在遍历一个集合的同时又修改了它。树结构里的列表底层实现各不相同,有的版本可能不抛异常,但结果依然是不可预期的。

安全做法是先收集、后删除:

List<Element> toDelete = new ArrayList<>(); for (Element book : root.elements("book")) { if ("delete".equals(book.attributeValue("status"))) { toDelete.add(book); } } for (Element book : toDelete) { root.remove(book); }

这样遍历过程中只做收集,删除发生在遍历结束之后,不会动到底层列表结构。

5.3 删除节点之后,别继续拿旧引用当文档节点

detach 之后,旧引用仍然指向那个节点,但它已经从文档树里掉下来了。如果你继续给它 addElement 或者 setText,改的是一棵“孤树”,序列化整个 Document 时它不会出现。

我在实际工作中就被这个问题坑过一次:删掉一批旧配置后没有及时清理缓存里的引用,程序后续又在缓存里查这些节点,结果读到的内容还是旧值,排查半天才发现缓存没同步。删除节点时要养成习惯,删完就从自己的业务数据结构里同步移除相关引用。

6. 保存XML:编码、缩进和流关闭都要管

6.1 OutputFormat控制输出格式

保存 XML 时,输出格式直接决定文件的可读性。dom4j 提供了 OutputFormat 来管理缩进、换行和编码:

import org.dom4j.io.OutputFormat; // 美观格式,带缩进和换行 OutputFormat prettyFormat = OutputFormat.createPrettyPrint(); prettyFormat.setEncoding("UTF-8"); // 紧凑格式,适合文件体积敏感的场景 OutputFormat compactFormat = OutputFormat.createCompactFormat(); compactFormat.setEncoding("UTF-8");

createPrettyPrint默认缩进两个空格,你也可以用prettyFormat.setIndent(" ")改成四个空格。对于给新人看的配置文件,美观格式更友好;对于线上传输的 XML,紧凑格式更省带宽。

6.2 保存文件时的正确姿势

保存时,我强烈建议不要用 FileWriter,而是用 OutputStream 配合 OutputFormat 的编码设置:

import org.dom4j.io.XMLWriter; try (OutputStream out = new FileOutputStream("books-output.xml"); XMLWriter writer = new XMLWriter(out, prettyFormat)) { writer.write(document); } catch (IOException e) { e.printStackTrace(); }

为什么要避开 FileWriter?因为 FileWriter 默认使用操作系统平台的编码,在中文 Windows 上常常是 GBK,而你的 XML 声明可能是 UTF-8,两边一冲突,保存完一看,全是乱码。

用FileOutputStream就不会有这个问题,因为 XMLWriter 会严格按照OutputFormat里 setEncoding 的编码去写字节。这是很多人都会踩的经典坑,我建议直接把这个写法固化成模板。

6.3 写完之后如何自检

保存不是结束,至少应该确认文件确实被你期望的内容覆盖了。我常用的自检方法是把保存路径重新读一遍:

SAXReader reader = new SAXReader(); Document checkDoc = reader.read(new File("books-output.xml")); Node node = checkDoc.selectSingleNode("//book[@id='1']"); if (node != null) { System.out.println("自检通过,book id=1 存在"); } else { System.out.println("自检失败,目标节点不存在"); }

如果是在生成配置文件这种场景,我还会额外对比保存前后的节点数量,确保没有在底层细节里丢数据。自检虽然多写几行,但比上线后发现配置缺失要省心得多。

7. 实际项目里的选型补充与避坑清单

7.1 什么时候不该用dom4j

dom4j 虽好,但并不是所有场景都该用它。如果 XML 文件到了几百兆甚至几个 G,dom4j 的内存树模型会把整个文档塞进堆内存,很容易 OOM,这时候应该考虑 StAX 或 SAX 的流式处理,读一点处理一点。

如果你的目标是把 Java 对象直接变成 XML 或者从 XML 还原 Java 对象,优先考虑 JAXB,它基于注解,属性和节点结构能自动映射,比手写 dom4j 节点拼接省太多事。

还有一点,如果项目里不允许引入第三方依赖,原生 DOM 虽然啰嗦,但它是 JDK 自带的,能用就先用。引入任何开源库之前都要权衡维护成本,dom4j 已经很久没有大版本更新了,项目还依赖它的主要原因就是稳定和生态历史,这一点要心里有数。

7.2 高频问题清单一览表

异常或现象可能原因解决办法
DocumentExceptionXML 格式非法或文件不存在先校验 XML 内容,用浏览器打开原文件检查结构
NoClassDefFoundError: org/jaxen/JaxenException缺少 jaxen 依赖在依赖里显示声明 jaxen
保存后中文乱码用了 FileWriter 或编码设置不一致改用 FileOutputStream + format.setEncoding("UTF-8")
selectSingleNode 查不到数据被命名空间干扰,或 XPath 写错注册 namespace,或打印节点结构确认路径
ConcurrentModificationException边遍历边删除收集到临时 List 后再统一删除
保存后节点顺序不对addElement 默认追加到末尾使用 add(index, node) 插入指定位置

7.3 几个提升效率的小经验

把读写封装成一个工具类。我一般会写一个 XmlUtil,里面就两个静态方法:load(File)和save(Document, File, OutputFormat),项目各处调用,不用每次重复写 try-catch 和关闭逻辑。

操作前备份原文件。对线上配置或数据文件做增删改时,我会先复制一份.bak,确认新文件没问题再替换。这件事看起来土,但真能救命。

频繁保存时先写到字节数组,再一次性写入文件。比如循环处理 1000 个节点,每改一个就写一次文件,磁盘 IO 和文件损坏风险都会上升。更好的做法是在内存里把整个 Document 改完,最后只写一次。

最后想说,dom4j 的 API 真正的学习成本很低,难点全在实战中的各种隐蔽细节。我把这些能想到的都列出来,就是希望你能绕开那些我当年踩过的坑。实际使用时,遇到某个方法行为不符合预期,第一反应不要怀疑库有问题,先拿一个小 XML 片段单独验证一下,打印中间结果,通常几行输出就能把问题定位清楚。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进