Java开发必会:Dom4j解析XML,从读写到XPath实战避坑
说实话Java后端做到一定阶段几乎绕不开XML解析这件事。哪怕现在JSON已经是数据交换的主流XML依然稳如老狗地待在配置系统、报文协议、旧版框架适配这些场景里。而这门课选的Dom4j又是Java生态里开发效率最高的那一档解析库——我第一次用的时候觉得它比JDK自带的DOM那堆API优雅太多了。这篇内容不是教科书式地罗列API而是按我在项目里实际使用Dom4j的路径来拆先搞懂为什么需要它再动手读取、创建、修改XML最后配合XPath定位节点顺带把那些不踩一遍根本不知道的坑全部挑明。1. XML解析这件事为什么值得专门讲一课很多新人对XML有误解觉得它是一种过时的数据格式。但你去看现在的Java技术栈pom.xml是XMLweb.xml是XMLmybatis的全局配置是XMLSpring的早期配置也是XML。更不用说在银行、电信这些行业里系统间接口报文几乎全是XML。所以“会写Java”和“能处理XML”是两码事后者更像一种基础设施能力。XML本身是一种“自描述”的层级结构。一个标签可以带属性也可以嵌套子标签数据天然形成一棵树。比如book idB001 categoryJava titleJava编程基础/title author张三/author price59.9/price /book这里book是父节点title、author、price是子节点id和category是属性。程序要使用这些数据就必须把这种纯文本结构转换成内存里的对象——这就是“解析”的本质把XML字符流变成Java对象树或者变成一个个可处理的事件。我习惯用一个类比XML文件就像一份带目录的纸质档案解析器做的就是把档案的内容逐项录入电子表格。DOM方式是一键全量导入SAX方式是逐行读完马上登记而Dom4j是导入的同时还给你一套特别好用的检索和编辑工具。你不需要一次性把所有API背下来只需要抓住三条主线创建、读取、修改。这三条主线解决90%以上的业务需求。2. 解析方式横向对比Dom4j赢在哪里Java生态里XML解析方案不少JDK自带的最典型有DOM、SAX后来JDK 6加入StAX再加上第三方库Dom4j、JDOM等。很多人第一次接触会被绕晕我用表格直接对比常用方案每行都是我实际用过的体感方案模型优点缺点典型场景DOM树模型符合思维习惯可随意读写一次性加载全部内容内存吃紧小文件解析、需要频繁修改SAX事件模型流式处理内存占用极低只能顺序读不能随意跳转和修改超大文件解析上百MBStAX拉式流模型性能好读写双向API较底层编码繁琐高性能中间件Dom4j树模型增强接口友好链式调用支持XPath需要引入第三方依赖日常业务开发大部分场景从这个表能看出来DOM是全量树SAX是推送事件StAX是主动拉取流。Dom4j本质上吸收了DOM的易用性又在接口设计上做了大量优化把“读写XML”这件事的代码量压缩到最低。举个极端的例子用原生DOM创建一个带属性的节点要写四五行的模板代码Dom4j一行链式调用就搞定了。选Dom4j还有一个很现实的原因——它在国内的生态惯性太大了。早期的很多Java框架和项目底层XML处理用的就是Dom4j。你看一些讲Web框架和中间件源码的文章经常能看到它的影子。如果你只打算学一种XML解析方式Dom4j的性价比是最高的。另外Dom4j从2.x版本开始解决了JDK 9模块系统的兼容问题所以我的建议很明确不要再用网上老教程里翻出来的1.6.1直接上2.1.4省得后面遇到一堆ClassNotFound异常。3. 动手前的准备依赖引入和核心API图谱用Maven的话一个依赖就够了。这里我强烈建议把XPath的依赖也一并带上后面你会感谢这个决定dependency groupIdorg.dom4j/groupId artifactIddom4j/artifactId version2.1.4/version /dependency dependency groupIdjaxen/groupId artifactIdjaxen/artifactId version1.2.0/version /dependencyGradle用户对应加上这两条即可。在写代码之前先把Dom4j里的核心对象对应到XML本身会更容易理解。XML里有标签、属性、文本Dom4j就提供对应的Element元素、Attribute属性、Text文本模型。它们组合构成一棵Document树Document就是整棵树的根容器。简单说Document对应一个XML文件Element对应一个标签节点Attribute对应标签上的属性Text对应标签里的文字内容。从操作上讲主要记住这几个类类/接口作用关键行为SAXReader读入XML文件read()方法返回DocumentDocument文档对象getRootElement()、selectNodes()Element元素节点addElement()、element()、elements()、elementText()Attribute属性节点通过element.attributeValue()读取DocumentHelper工厂类创建Document、创建Element、创建XPathOutputFormat输出格式控制缩进、编码XMLWriter写出XML把Document写到文件或Writer有个新手经常忽略的细节SAXReader的read()方法不只接受File还接受InputStream、Reader甚至是URL。所以在实际项目里你完全可以直接从一个输入流构建Document不需要先落盘成临时文件。关于编码问题我多说一句。很多人在读XML时遇到乱码第一反应是给SAXReader设置setEncoding但根本原因往往是文件本身的编码和XML声明不一致。比如文件实际是UTF-8但第一行声明了GBK。所以要么保证两种编码一致要么在读取前指定和文件一致的编码。4. 第一个实战读取并遍历XML理论说再多不如直接写代码。我准备了一个典型的XML样本模拟某系统中的书籍列表。这里只保留两本书实际项目里可能是上千本?xml version1.0 encodingUTF-8? books book idB001 categoryJava titleJava编程基础/title author张三/author price59.9/price /book book idB002 categoryWeb title深入浅出Spring/title author李四/author price79.0/price /book /books读取它并遍历所有book节点import org.dom4j.Document; import org.dom4j.Element; import org.dom4j.io.SAXReader; import java.io.File; import java.util.Iterator; public class XmlReadDemo { public static void main(String[] args) throws Exception { SAXReader reader new SAXReader(); Document document reader.read(new File(books.xml)); Element root document.getRootElement(); System.out.println(根节点: root.getName()); // 方式一迭代器遍历适合大数据量场景 for (IteratorElement it root.elementIterator(book); it.hasNext();) { Element book it.next(); String id book.attributeValue(id); String title book.elementText(title); String author book.elementText(author); String price book.elementText(price); System.out.println(id id , title title , author author , price price); } } }运行结果根节点: books idB001, titleJava编程基础, author张三, price59.9 idB002, title深入浅出Spring, author李四, price79.0这里的elementText()是个高频API它直接返回子标签内的文本内部帮你处理了嵌套查找比我早期手动getText()再trim好太多。如果文本里可能有空白和换行推荐用elementTextTrim()它会自动去掉首尾空白。我不建议所有场景都无脑遍历。如果你确定每本书的tag只有一个title那用elementText(title)很干净。如果同名子节点有多个那就需要elements(子节点名)拿到列表再分别处理。还有一个遍历时删除节点的经典坑。比较典型的错误写法是在迭代器循环里直接调用element.getParent().remove(element)。虽然Dom4j允许这么做但有时候你会遇到ConcurrentModificationException或者漏删的情况。最稳妥的方式是先收集要删除的节点循环结束后再统一删除ListElement removeList new ArrayList(); for (IteratorElement it root.elementIterator(book); it.hasNext();) { Element book it.next(); if (B002.equals(book.attributeValue(id))) { removeList.add(book); } } for (Element e : removeList) { e.detach(); }detach()是Dom4j的Node接口提供的“摘除”方法比通过parent.remove()更直白后面删节点的时候还会见到它。5. 第二个实战创建、修改、删除XML很多教程把重点放在读取上但实际项目里动态生成XML同样常见。比如你要给接口生成请求报文或者导出一批配置数据到文件。Dom4j最爽的地方就在这里——创建XML就像搭积木一样流畅。先看完整的创建写入文件代码import org.dom4j.Document; import org.dom4j.DocumentHelper; import org.dom4j.Element; import org.dom4j.io.OutputFormat; import org.dom4j.io.XMLWriter; import java.io.FileOutputStream; import java.io.OutputStreamWriter; import java.nio.charset.StandardCharsets; public class XmlCreateDemo { public static void main(String[] args) throws Exception { Document document DocumentHelper.createDocument(); // 根元素 Element config document.addElement(config); // 子元素链式添加属性 Element database config.addElement(database) .addAttribute(type, mysql); Element host database.addElement(host).addText(127.0.0.1); database.addElement(port).addText(3306); database.addElement(name).addText(shop); // 格式化输出 OutputFormat format OutputFormat.createPrettyPrint(); format.setEncoding(StandardCharsets.UTF_8.name()); try (FileOutputStream fos new FileOutputStream(config.xml)) { OutputStreamWriter writer new OutputStreamWriter(fos, StandardCharsets.UTF_8); XMLWriter xmlWriter new XMLWriter(writer, format); xmlWriter.write(document); xmlWriter.flush(); } } }生成的config.xml?xml version1.0 encodingUTF-8? config database typemysql host127.0.0.1/host port3306/port nameshop/name /database /config这里有两个我踩过多次的关键细节必须单独拿出来讲。第一写入文件别用FileWriter要用OutputStreamWriter并指定UTF-8。JDK的FileWriter默认依赖平台编码在Windows机器上会写出GBK编码的文件。如果XML声明里写着encodingUTF-8结果实际字节是GBK那文件到Linux服务器就产生了乱码。这个属于项目里最难排查的隐性Bug因为它本地跑起来一切正常。第二OutputFormat.createPrettyPrint()和createCompactFormat()要区分用。PrettyPrint带缩进和换行人类看着舒服适合配置文件CompactFormat把节点压成一行体积小适合报文传输。如果报文串明文传输用CompactFormat能省不少带宽。再来说修改。修改的本质是“找到节点改掉内容”。假设你想要把上面config.xml里的port改成3307并将database的type属性改成postgresqlDocument document new SAXReader().read(new File(config.xml)); Element database document.getRootElement().element(database); database.setAttributeValue(type, postgresql); database.element(port).setText(3307);setAttributeValue这个API很聪明如果属性存在就更新值如果属性不存在就自动创建。所以没必要先去判断属性是否存在。setText同理它无缝覆盖文本内容如果节点还带着一堆子节点这些子节点会被清掉所以只对叶子节点用setText。删除操作也很直接Element port database.element(port); port.detach();detach()执行后的瞬间这个节点就从父节点里摘除了内存树里那份引用也切断了父子关系。如果只是把节点引用置为null那起不到任何删除效果节点还在原来的位置。6. 进阶XPath快速定位节点遍历是“无差别扫荡”XPath则是“精准狙击”。我把XPath加入这课有一个很重要的理由当你面对一个复杂到让人头秃的XML结构时XPath可以在几行代码内跳过层层嵌套直达目标节点。Dom4j的selectNodes()和selectSingleNode()方法让我们可以把XPath表达式直接当成查询语句用。还是拿之前的books.xml来说public class XPathDemo { public static void main(String[] args) throws Exception { Document document new SAXReader().read(new File(books.xml)); // 选取所有book节点 ListNode allBooks document.selectNodes(//book); // 选取category为Java的book Node javaBook document.selectSingleNode(//book[categoryJava]); System.out.println(((Element) javaBook).elementText(title)); // 选取price大于60的book ListNode expensiveBooks document.selectNodes(//book[price 60]); for (Node node : expensiveBooks) { Element book (Element) node; System.out.println(book.attributeValue(id)); } } }XPath表达式本身不复杂但有几个要点需要刻意记忆表达式含义/books/book根路径下的book子节点//book任意层级下的book节点/books/book[1]第一个book节点XPath索引从1开始//book[id]带有id属性的book节点//book[idB001]id属性值为B001的book节点//book[price 60]price子元素值大于60的book节点//book/title/text()取所有title的文本内容XPath里的索引从1开始这一点和Java的习惯不一样我每次从0开始取结果都会一脸懵所以大家记牢。还有一个高发问题带命名空间的XML用XPath查不到节点。典型场景是SOAP协议报文和Spring配置文件ns:book xmlns:nshttp://example.com/ns ns:titleJava编程基础/ns:title /ns:book这种情况下直接写//ns:book如果没注册前缀selectNodes返回的就是空列表。解决方式有二一是创建一个Map给XPath设置NamespaceContext二是绕过命名空间用local-name()来匹配document.selectNodes(//*[local-name()book]);这个方法在很多兼容场景下最省事缺点是表达式可读性变差看你自己权衡。XPath好用但也不是银弹。如果文档结构非常深非常复杂selectNodes每执行一次都会在树上做一轮匹配计算。对于几千到几万节点的规模完全不是问题但如果XML超大且查询极其频繁建议还是提前把数据映射成Java对象后续从内存对象里操作。7. 真实项目中的应用场景与排查手册到了这里Dom4j的读写你已经会了。但一个新工具真正融入项目还需要知道它被用在哪些地方以及出了问题往哪个方向查。先说场景都是我亲历过的。第一个场景是配置文件解析。一些老系统里的XML配置需要程序启动时读取并转换成配置对象。这种场景不需要什么高级技巧就是SAXReader读进来然后遍历定义好的标签。第二个场景是接口报文处理。某个系统对外提供接口请求和响应都走XML报文里面嵌套三四层是家常便饭。报文里有消息头、消息体、扩展字段每层都要解析还要回填校验信息。这种场景用Dom4j定位节点和生成报文是非常舒服的。第三个场景是底层框架阅读。很多框架的源码里插件配置通过XML描述底层就是Dom4j解析。你学会了Dom4j再去读这些框架的源码会发现很多地方豁然开朗。第四个场景是数据交换和导出。比如你有一个列表数据需要按约定格式导出成XML发给下游或者从上游批量导入XML。这时候创建文档、写入文件的流程就直接套用第5章的代码。现在把我在报错现场积累的问题整理成一张速查表基本覆盖90%的常见报错问题现象解决方案乱码中文变成问号或火星文读写统一指定UTF-8写入用OutputStreamWriter不要用FileWriter缺少jaxen使用XPath时NoClassDefFoundError引入jaxen依赖或用local-name()绕开非法字符文本包含或时解析报错源数据先清洗转义或用CDATA包裹属性值不存在attributeValue返回null不要直接调用先判空或使用contains方法elementText返回null子节点不存在先调用element(xxx)判空节点删不掉引用置空但文件不变必须调用detach()遍历时删除ConcurrentModificationException先收集后删除或用Iterator.remove()命名空间查不到XPath返回空节点用local-name()或注册NamespaceContext工具类虽然看着简单但在工程落地时还是建议封装一下。我一般会在项目里写一个XmlUtil对外只暴露parseXml、createDoc等方法。目的有两个一是把SAXReader、OutputFormat等配置集中管理二是一旦将来解析库有变化调用方不用跟着改。这也是老手和新手的一个重要区别不把第三方依赖直接撒在业务代码里。8. 踩过几次坑之后的个人心得我写代码这些年XML解析工具换过不少但Dom4j始终是我最常用的一个。原因特别简单它把最常见、最繁琐的操作都简化到了能直接写进业务代码的程度可读性也好别人接手你代码的时候不会对着几十行重复模板发呆。如果要我给一个学习路径那一定是先照着这篇文章的示例把读写跑通再找一段真实的业务XML报文来做练习最后去读几个开源项目的XML解析部分。踩坑不要怕最常见的那几个坑我已经帮你提前标记了真再遇到新的问题先按“编码、依赖、结构”这三类对号入座大概率能找出方向。最后再分享一个小经验如果你只是要从一个XML里取出某一个字段比如只读一个版本号就别把Dom4j整套引进来直接用JDK自带的XML解析或者简单的字符串截取就够了。工具不在多用对地方才算本事。Dom4j的精华在于处理那些结构复杂、需要反复读写、层级深的XML——那才是它的主场。

相关新闻

Dom4j实战:Java XML解析、节点遍历与XPath定位全攻略

Dom4j实战:Java XML解析、节点遍历与XPath定位全攻略

翻过老项目的代码,你大概率见过这种场景:对端系统返回的不是 JSON,而是一大串结构复杂、层级好几层的 XML。你在心里骂一句“怎么还在用这玩意”,然后还是得老老实实解析。今天要聊的 Dom4j,就是 Java 生态里解析 XML …

2026/10/10 19:58:25 阅读更多 →
Mailcow 自建邮件服务器实战:Docker 部署与收发调优

Mailcow 自建邮件服务器实战:Docker 部署与收发调优

简介:Mailcow 是一套基于 Docker 容器化技术构建的开源邮件服务器解决方案,面向需要自建企业邮箱、搭建邮件系统的运维人员与开发者,帮助解决邮件收发、反垃圾、反病毒及域名认证等一体化部署难题。资源包共约 2000 个文件,整体约…

2026/10/10 19:58:25 阅读更多 →
物联网平台高并发架构实战:从千级到百万级连接的踩坑与优化

物联网平台高并发架构实战:从千级到百万级连接的踩坑与优化

物联网平台的“暗礁“:从千级到百万级连接的踩坑实录做物联网平台这件事,最难的地方从来不是把设备连上网,而是当设备数量涨上去之后,整个系统会以一种你完全没预料到的方式崩塌。我最早做的接入层只用单台节点撑几千个连接时&…

2026/10/10 19:58:25 阅读更多 →

最新新闻

647回文子串与516最长回文子序列:区间DP两种典型玩法全解析

647回文子串与516最长回文子序列:区间DP两种典型玩法全解析

各位打卡代码随想录的伙计们,第四十五天来了。今天这两道题——647 回文子串、516 最长回文子序列——看起来名字只差两个字,实际上一个是把字符串切成一段段判断"是不是回文",另一个是允许跳跃地凑出"最长回文有多长"。…

2026/10/10 21:19:06 阅读更多 →
用幸运大转盘项目掌握Python循环:for、while与range实战

用幸运大转盘项目掌握Python循环:for、while与range实战

1. 项目拆解:为什么用“幸运大转盘”讲循环在Python入门的教学顺序里,循环永远是绕不过去的坎。很多初学者学完变量、条件判断之后,一到for和while就开始晕:for循环里那个range到底怎么数数?while循环什么时候停&#…

2026/10/10 21:19:06 阅读更多 →
mcp-brasil快速上手指南:2分钟接入Claude/Cursor,免费解锁66个巴西公共数据API

mcp-brasil快速上手指南:2分钟接入Claude/Cursor,免费解锁66个巴西公共数据API

【免费下载链接】mcp-brasil MCP Server para 70 APIs pblicas brasileiras 项目地址: https://gitcode.com/gh_mirrors/mc/mcp-brasil 点击查看 免费下载 mcp-brasil 是一个开源的 MCP Server,一个命令即可把 66 个无需密钥的巴西公共数据 API&#xf…

2026/10/10 21:19:06 阅读更多 →
AI 齐套性专家 | 实战案例成果演示

AI 齐套性专家 | 实战案例成果演示

#玄宿科技#AI齐套性交付#案例分享#自主协同软件#GJB438B#Qt开发#交付文档上一期我们演示了 AI 齐套性交付专家的完整生成过程,但所用案例与真实业务贴合度不够。本期我们换了一个完全贴合业务的方向,重新生成了一整套成果:原型图、可交互程序…

2026/10/10 21:19:06 阅读更多 →
OOOSplat 处理流水线全解:FFmpeg、COLMAP 与 Brush 三大引擎如何协同工作

OOOSplat 处理流水线全解:FFmpeg、COLMAP 与 Brush 三大引擎如何协同工作

桌面应用图形学3D渲染计算机视觉 【免费下载链接】ooosplat A local desktop app that turns videos and images into 3D Gaussian Splats in one click. 项目地址: https://gitcode.com/gh_mirrors/oo/ooosplat 点击查看 免费下载 OOOSplat 是一款把视频和图片序列…

2026/10/10 21:19:06 阅读更多 →
零基础 30 分钟出片:用万相 Animate 把角色设定变成可复用的动画素材

零基础 30 分钟出片:用万相 Animate 把角色设定变成可复用的动画素材

零基础 30 分钟出片:用万相 Animate 把角色设定变成可复用的动画素材 【免费下载链接】Wan2.2-Animate-2-14B 项目地址: https://ai.gitcode.com/hf_mirrors/Wan-AI/Wan2.2-Animate-2-14B 做一条角色动画,过去意味着什么?画角色设定、…

2026/10/10 21:18:06 阅读更多 →

日新闻

卫星轨道分类全解析:从LEO到GEO的选型逻辑与工程实践

卫星轨道分类全解析:从LEO到GEO的选型逻辑与工程实践

1. 从“卫星轨道分类”这个标题说起:为什么值得花时间搞懂第一次接触“卫星轨道分类”这个概念,很多人会觉得它离自己很远——不就是天上的星星怎么转吗?但如果你正在做航天任务规划、遥感数据接收、星座设计,甚至只是准备一场航天…

2026/10/10 0:00:39 阅读更多 →
Spring AOP 核心原理与实战:从概念到日志切面落地

Spring AOP 核心原理与实战:从概念到日志切面落地

1. 从一个真实痛点说起:为什么你的代码里到处都是重复逻辑刚入行那会儿,我写过一个用户管理模块,注册、登录、改密码、注销四个接口。每个接口里都塞了几乎一样的日志打印、参数校验、事务开启和提交。当时觉得没什么,能跑就行。直…

2026/10/10 0:00:40 阅读更多 →
Python招聘数据采集与分析可视化:从采集清洗到薪资技能城市可视化全链路

Python招聘数据采集与分析可视化:从采集清洗到薪资技能城市可视化全链路

简介:这是一套面向计算机相关专业学生与项目实战学习者的Python数据采集与分析可视化完整项目,以Boss直聘岗位数据为对象,适合用作毕业设计、课程设计或期末大作业。资源包共38个文件,约246KB,以13个py源码文件为核心&…

2026/10/10 0:00:40 阅读更多 →

周新闻

KT148A语音芯片外挂8002D功放的工程实践指南

KT148A语音芯片外挂8002D功放的工程实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/10 11:14:25 阅读更多 →
LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/10 1:36:08 阅读更多 →
ARM架构深度解析:从RISC设计理念到交叉编译实战

ARM架构深度解析:从RISC设计理念到交叉编译实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/10 11:14:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/10 5:23:50 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/9 21:32:20 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/10 10:38:42 阅读更多 →