
先纠正一个拼写标题里的 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 id1里的 idText代表元素内的文本内容日常开发中一般用 setText/addText 间接操作用这个模型去理解增删改查就变得很直观增就是在树上加一个节点或属性删就是剪掉一个节点改就是替换某个节点的内容查就是沿着树的枝干找到目标节点。2. 先把环境跑通依赖、读取、创建一份XML2.1 引入依赖注意版本和XPath传递依赖如果你用 Maven 管理项目pom.xml 里加这两条就行dependency groupIdorg.dom4j/groupId artifactIddom4j/artifactId version2.1.3/version /dependency dependency groupIdjaxen/groupId artifactIdjaxen/artifactId version1.2.0/version /dependency第一条是 dom4j 本体第二条是 XPath 引擎。很多人只加 dom4j运行到 selectSingleNode 时突然报NoClassDefFoundError: org/jaxen/JaxenException一脸懵。原因就是 dom4j 的 XPath 能力依赖 jaxen而有些版本不会自动带上它所以建议显式声明省得后面踩坑。非 Maven 项目也别慌直接下载 dom4j 和 jaxen 两个 jar 包丢进 classpath 即可。2.2 从文件或字符串读取XML读取一个 XML 文件最基本的方式是使用 SAXReaderimport 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 booksbook id\1\titleJava编程思想/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 的子元素 ListElement 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[id2]); if (node instanceof Element) { Element book (Element) node; System.out.println(book.elementText(title)); } // 查找所有 book 下的 title返回列表 ListNode titles document.selectNodes(//book/title); for (Node titleNode : titles) { System.out.println(titleNode.getText()); }XPath 的写法我很建议单独积累它语法不多但信息密度很高。//book表示在任意层级找 book[id2]表示过滤属性/title表示取子元素。组合在一起一条语句就能替代几十行循环。不过要注意selectSingleNode查不到结果时会返回 null别直接强转否则 NPE 等着你。3.3 查询时要避开的三个坑第一个坑是空指针。很多查询结果可能为 null代码里要习惯判空不要一上来就((Element) node).elementText(...)。第二个坑是命名空间。如果 XML 里带了命名空间比如ns:book xmlns:nshttp://example.com/books你直接用//book是查不出来的。正确做法是注册前缀MapString, String nsMap new HashMap(); nsMap.put(ns, http://example.com/books); XPath xpath DocumentHelper.createXPath(//ns:book[ns:id1]); 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 接口继承自 BranchBranch 提供了一个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[id4]); 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[id4]); if (book ! null) { book.detach(); // 从父节点上分离自己就成了一棵孤树 }另一种等价写法是Element parent book.getParent(); if (parent ! null) { parent.remove(book); }我推荐优先用 detach因为它自动处理了父子关系的解除不需要你先判断 parent 是不是 null。删除属性更简单Element book (Element) document.selectSingleNode(//book[id1]); Attribute attr book.attribute(price); if (attr ! null) { book.remove(attr); }如果想清空一个元素的所有子节点和文本又不想删掉这个元素本身用 clearContentroot.clearContent(); // 清空 root 下所有子节点5.2 循环删除最容易翻车别边遍历边删这是我见过新手踩得最多的坑想删除满足条件的多个节点于是写了类似下面的代码for (Element book : root.elements(book)) { if (delete.equals(book.attributeValue(status))) { root.remove(book); } }这段代码在运行到第二个删除操作时大概率抛ConcurrentModificationException因为你在遍历一个集合的同时又修改了它。树结构里的列表底层实现各不相同有的版本可能不抛异常但结果依然是不可预期的。安全做法是先收集、后删除ListElement 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[id1]); if (node ! null) { System.out.println(自检通过book id1 存在); } else { System.out.println(自检失败目标节点不存在); }如果是在生成配置文件这种场景我还会额外对比保存前后的节点数量确保没有在底层细节里丢数据。自检虽然多写几行但比上线后发现配置缺失要省心得多。7. 实际项目里的选型补充与避坑清单7.1 什么时候不该用dom4jdom4j 虽好但并不是所有场景都该用它。如果 XML 文件到了几百兆甚至几个 Gdom4j 的内存树模型会把整个文档塞进堆内存很容易 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 片段单独验证一下打印中间结果通常几行输出就能把问题定位清楚。