很多团队的第一反应是"导出HTML然后改后缀名"。没错,这招对付简单表格管用,但一旦遇到页眉页脚、复杂表格合并、嵌入图片、分节符这些Word原生特性,HTML方案就彻底歇菜了。
今天深入聊聊Java生态里三种主流的Word文档处理方案:docx4j、poi-tl、FreeMarker模板引擎。不是简单的API罗列,而是从底层原理到实战踩坑,帮你真正理解每个方案的边界在哪。
先搞清楚:docx到底是什么
在聊工具之前,先理解Word文档的本质。一个 .docx 文件本质上是一个 ZIP压缩包,里面装了一堆XML文件和资源文件。
解压一个docx看看:
document.docx├── [Content_Types].xml # 内容类型声明├── _rels/│ └── .rels # 全局关系├── word/│ ├── document.xml # 主文档内容(核心!)│ ├── styles.xml # 样式定义│ ├── header1.xml # 页眉│ ├── footer1.xml # 页脚│ ├── media/│ │ ├── image1.png # 嵌入的图片│ │ └── image2.jpeg│ ├── _rels/│ │ └── document.xml.rels # 文档内部关系(图片引用等)│ └── numbering.xml # 编号/列表定义└── docProps/ ├── app.xml # 文档属性 └── core.xml # 核心元数据所有的文字、表格、图片引用、格式信息,最终都序列化在 word/document.xml 里。这个XML遵循的是 OOXML(Office Open XML)标准,由微软制定,体量庞大——光 document.xml 一个文件,一个稍微复杂的报告就可能有好几万行。
理解了这一点,你就能理解为什么不同的方案会有如此巨大的差异——本质上是操作XML的抽象层级不同。
一、docx4j:直接操控OOXML的瑞士军刀
1.1 它是什么
docx4j是一个底层OOXML操作库,使用JAXB(Java Architecture for XML Binding)把Word的XML结构映射成Java对象。你可以理解为:它把Word XML里的每一个标签都变成了一个Java类。
<w:p>→org.docx4j.wml.P(段落)<w:r>→org.docx4j.wml.R(文本运行)<w:t>→org.docx4j.wml.Text(文本内容)<w:tbl>→org.docx4j.wml.Tbl(表格)<w:tr>→org.docx4j.wml.Tr(表格行)<w:tc>→org.docx4j.wml.Tc(表格单元格)
这是最接近"手写XML"的方式,只不过用Java对象包装了一下。
1.2 解析Word文档的利器
docx4j最擅长的场景是读取和解析已有Word文档。比如从一份几百页的检测报告中提取所有表格数据:
// 加载Word文档WordprocessingMLPackagewordMLPackage= WordprocessingMLPackage.load(newFile("report.docx"));MainDocumentPartmainPart= wordMLPackage.getMainDocumentPart();// 获取所有内容元素List<Object> content = mainPart.getContent();// 遍历查找表格for (Object obj : content) {Objectunwrapped= XmlUtils.unwrap(obj);if (unwrapped instanceof Tbl) {Tbltable= (Tbl) unwrapped;// 提取表格数据for (Object rowObj : table.getContent()) {if (XmlUtils.unwrap(rowObj) instanceof Tr) {Trrow= (Tr) XmlUtils.unwrap(rowObj); List<String> rowData = newArrayList<>();for (Object cellObj : row.getContent()) {if (XmlUtils.unwrap(cellObj) instanceof Tc) {Tccell= (Tc) XmlUtils.unwrap(cellObj); rowData.add(getCellText(cell)); } }// 处理这一行数据... } } }// 识别段落标题if (unwrapped instanceof P) {Pparagraph= (P) unwrapped;Stringtext= getParagraphText(paragraph);PPrppr= paragraph.getPPr();if (ppr != null && ppr.getPStyle() != null) {Stringstyle= ppr.getPStyle().getVal();if ("Heading1".equals(style)) {// 这是一级标题 } } }}注意那个 XmlUtils.unwrap()——这是docx4j里非常重要的一步。因为JAXB绑定,很多元素被包裹在 JAXBElement 里,必须 unwrap 才能拿到真实类型。这是新手最容易踩的坑之一。
1.3 生成文档:能做但痛苦
docx4j生成文档的问题在于抽象层级太低。看一个完整的例子——创建一个带样式的表格:
publicvoidcreateStyledTable(WordprocessingMLPackage wordMLPackage) {ObjectFactoryfactory= Context.getWmlObjectFactory();// 创建表格Tbltable= factory.createTbl();// 设置表格宽度TblPrtblPr= factory.createTblPr();TblWidthtblWidth= factory.createTblWidth(); tblWidth.setType("dxa"); // DXA单位(1/20磅) tblWidth.setW("9000"); tblPr.setTblW(tblWidth);// 设置表格边框TblBordersborders= factory.createTblBorders(); addBorder(borders, STBorder.SINGLE, 4, "000000", "top"); addBorder(borders, STBorder.SINGLE, 4, "000000", "bottom"); addBorder(borders, STBorder.SINGLE, 4, "000000", "left"); addBorder(borders, STBorder.SINGLE, 4, "000000", "right"); addBorder(borders, STBorder.SINGLE, 4, "000000", "insideH"); addBorder(borders, STBorder.SINGLE, 4, "000000", "insideV"); tblPr.setTblBorders(borders); table.setTblPr(tblPr);// 创建表头行TrheaderRow= factory.createTr(); addStyledCell(headerRow, "设备名称", true, "4472C4", "FFFFFF"); addStyledCell(headerRow, "IP地址", true, "4472C4", "FFFFFF"); addStyledCell(headerRow, "设备类型", true, "4472C4", "FFFFFF"); table.getContent().add(headerRow);// 创建数据行TrdataRow= factory.createTr(); addStyledCell(dataRow, "核心交换机", false, null, null); addStyledCell(dataRow, "192.168.1.1", false, null, null); addStyledCell(dataRow, "网络设备", false, null, null); table.getContent().add(dataRow);// 添加到文档 wordMLPackage.getMainDocumentPart().getContent().add(table);}privatevoidaddStyledCell(Tr row, String text, boolean bold, String bgColor, String fontColor) {ObjectFactoryfactory= Context.getWmlObjectFactory();Tccell= factory.createTc();// 单元格背景色if (bgColor != null) {TcPrtcPr= factory.createTcPr();Shdshd= factory.createShd(); shd.setFill(bgColor); tcPr.setShd(shd); cell.setTcPr(tcPr); }// 创建文本Pparagraph= factory.createP();Rrun= factory.createR();Textt= factory.createText(); t.setValue(text);// 加粗if (bold) {RPrrpr= factory.createRPr(); rpr.setB(factory.createBooleanDefaultTrue()); run.setRPr(rpr); }// 字体颜色if (fontColor != null) {RPrrpr= run.getRPr();if (rpr == null) rpr = factory.createRPr();Colorcolor= factory.createColor(); color.setVal(fontColor); rpr.setColor(color); run.setRPr(rpr); } run.getContent().add(t); paragraph.getContent().add(run); cell.getContent().add(paragraph); row.getContent().add(cell);}一个3列2行的带样式表格,用了将近100行代码。
在我之前的项目里,原始的文档生成Service有 2453行,其中大部分就是在和这些底层对象打交道。维护这种代码的心理成本极高——改一个表格样式要在七八层嵌套的对象图里找到正确的位置。
1.4 图片操作:需要理解OPC包结构
docx4j里插入图片之所以复杂,是因为Word把图片当作独立的"Part"来管理(还记得前面说的ZIP结构吗?),图片本身存在 word/media/ 目录下,然后在 document.xml 里通过关系ID引用。
publicvoidaddImage(WordprocessingMLPackage wordMLPackage, byte[] imageBytes)throws Exception {// 1. 确定图片类型StringmimeType="image/png";Stringsuffix="png";// 2. 创建图片Part并加入文档包BinaryPartAbstractImageimagePart= BinaryPartAbstractImage .createImagePart(wordMLPackage, imageBytes);// 3. 创建内联图片引用(含大小设置)Inlineinline= BinaryPartAbstractImage.createImageInline( wordMLPackage, imagePart,"image description", "image filename",1, // id cx * 3600, // 宽度(EMU单位,1英寸=914400 EMU) cy * 3600, // 高度false// 是否链接 );// 4. 把图片引用插入段落ObjectFactoryfactory= Context.getWmlObjectFactory();Rrun= factory.createR();Drawingdrawing= factory.createDrawing(); drawing.getAnchorOrInline().add(inline); run.getContent().add(drawing);Pparagraph= factory.createP(); paragraph.getContent().add(run); wordMLPackage.getMainDocumentPart().getContent().add(paragraph);}注意EMU(English Metric Units)这个单位——1英寸 = 914400 EMU。这个转换关系不知道的话,图片大小永远调不对。
1.5 依赖冲突:真正的深坑
docx4j最让人头疼的不是API复杂,而是它的依赖树极其庞大。
跑一下 mvn dependency:tree,看看docx4j-core都拉了什么:
org.docx4j:docx4j-core:8.3.10├── org.docx4j:docx4j-JAXB-ReferenceImpl├── jakarta.xml.bind:jakarta.xml.bind-api├── org.glassfish.jaxb:jaxb-runtime├── org.slf4j:slf4j-api├── org.apache.commons:commons-lang3└── xalan:xalan-interpretive(被docx4j shaded) └── 包含 org.docx4j.org.apache.xml.utils.PrefixResolver其中 xalan-interpretive 是docx4j通过shade(重命名包名)方式内嵌的,类名从 org.apache.xml.utils.PrefixResolver 变成了 org.docx4j.org.apache.xml.utils.PrefixResolver。这个类是 NamespacePrefixMapper 的依赖,而后者是JAXB处理XML命名空间的核心组件。
实际项目中踩的真实坑:
引入poi-tl后,poi-tl自带的 jaxb-impl:2.2.3-1(通过传递依赖链 aliyun-sdk-oss → jersey-json → jaxb-impl 引入)覆盖了docx4j使用的 jaxb-runtime。这个旧版本的JAXB实现走了一条不同的初始化路径,触发了 NamespacePrefixMapper 的调用,而 PrefixResolver 因为之前pom.xml里排除了xalan而缺失,导致 NoClassDefFoundError。
更恐怖的是,因为文档导入是 CompletableFuture.runAsync() 异步执行的,而调用方只 catch(Exception) 不 catch(Error)(NoClassDefFoundError 继承自 Error),异常被静默吞掉,表现为"卡住不动"。排查这个花了一整天。
修复方法:
<!-- 不要排除xalan!docx4j的NamespacePrefixMapper依赖它 --><dependency><groupId>org.docx4j</groupId><artifactId>docx4j-core</artifactId><version>8.3.10</version><!-- 千万不要加 xalan 的 exclusion --></dependency>1.6 小结
docx4j就像C语言级别的指针操作——强大、精确,但容易出错。它的定位应该是:
解析和读取Word文档的首选,生成文档的备选。
二、poi-tl:模板驱动的Word生成引擎
2.1 设计哲学
poi-tl的核心思想是关注点分离:让非技术人员(产品经理、业务人员)用Word设计模板,让开发人员只关注数据准备。
底层基于Apache POI(Java生态里最成熟的Office文件处理库),但poi-tl在POI之上封装了一套模板语法,让你完全不需要接触POI的API。
2.2 模板语法全览
基本文本替换:
模板里写 {{varName}},Java里 put("varName", value),就这么简单。
条件渲染:
{{?isPassed}}检测结果:通过。{{/isPassed}}{{^isPassed}}检测结果:未通过,存在以下问题...{{/isPassed}}列表循环(行级):
这是poi-tl最强大的特性之一。在Word表格里,用 {{#list}} 和 {{/list}} 包裹一行,poi-tl会自动把这一行复制N份:
| 序号 | 设备名称 | IP地址 | 类型 ||------|----------|--------|------|| {{#devices}} | | | || {{index}} | {{name}} | {{ip}} | {{type}} || {{/devices}} | | | |渲染后自动变成多行:
| 序号 | 设备名称 | IP地址 | 类型 || 1 | 核心交换机 | 192.168.1.1 | 网络设备 || 2 | 防火墙 | 192.168.1.2 | 安全设备 || 3 | 服务器 | 192.168.1.100 | 计算设备 |列表循环(列级):
LoopColumnTableRenderPolicypolicy=newLoopColumnTableRenderPolicy();Configureconfig= Configure.builder() .bind("months", policy) .build();嵌套循环:
// 外层循环设备,内层循环每个设备的端口HackLoopTableRenderPolicypolicy=newHackLoopTableRenderPolicy();Configureconfig= Configure.builder() .bind("devices", policy) .build();图片:
{{@logo}}// 本地图片data.put("logo", Pictures.ofLocal("logo.png").size(200, 100).create());// 网络图片data.put("topo", Pictures.ofUrl("http://example.com/topo.png").size(600, 400).create());// 流式图片data.put("chart", Pictures.ofStream(chartInputStream, PictureType.PNG).size(400, 300).create());超链接:
{{+link}}data.put("link", HyperlinkText.of("点击访问").link("https://example.com"));嵌套文档:
{{+detail}}// 可以把另一个Word模板的渲染结果嵌入进来data.put("detail", Includes.ofLocal("detail-template.docx") .setRenderData(detailData) .create());2.3 完整实战示例
以一个典型的"调查表"为例,这是一个包含文本替换、表格循环、图片的复杂文档:
@Service@RequiredArgsConstructorpublicclassSurveyFormDataPreparer {privatefinal xxService deviceService;privatefinal xxxService projectService;public Map<String, Object> prepareData(String projectId) {Projectproject= projectService.getById(projectId);DeviceAssetasset= deviceService.getByProjectId(projectId); Map<String, Object> data = newHashMap<>();// 1. 基本文本替换 data.put("projecxxtName", project.getName()); data.put("levelxx", project.getLevel()); data.put("datxxe", LocalDate.now().toString()); data.put("orgNamexx", project.getOrgName());// 2. 复选框(1=选中,0=未选中) data.put("isInternet", "1".equals(project.getIsInternet()) ? "☑" : "☐"); data.put("isCloud", "1".equals(project.getIsCloud()) ? "☑" : "☐");// 3. 表格列表 - 物理环境 List<Map<String, Object>> envList = asset.getEnvs().stream() .map(env -> { Map<String, Object> item = newHashMap<>(); item.put("name", env.getName()); item.put("location", env.getLocation()); item.put("area", env.getArea()); item.put("desc", env.getDescription());return item; }).collect(Collectors.toList()); data.put("envList", envList);// 4. 表格列表 - 网络设备 List<Map<String, Object>> devList = asset.getDevices().stream() .map(dev -> { Map<String, Object> item = newHashMap<>(); item.put("name", dev.getName()); item.put("ip", dev.getIpAddress()); item.put("model", dev.getModel()); item.put("vendor", dev.getVendor());return item; }).collect(Collectors.toList()); data.put("devList", devList);// 5. 图片 - 网络拓扑图if (StringUtils.isNotBlank(project.getTopoImageUrl())) {byte[] imageBytes = downloadImage(project.getTopoImageUrl()); data.put("topoImg", Pictures.ofBytes(imageBytes, PictureType.PNG) .size(500, 350).create()); } else { data.put("topoImg", "(无拓扑图)"); }return data; }}渲染:
@ServicepublicclassWordExportFacadeService {publicbyte[] generateDocument(String projectId, Integer fileType) {// 根据fileType选择Preparer准备数据 Map<String, Object> data = selectPreparer(fileType).prepareData(projectId);// 获取模板路径StringtemplatePath= getTemplatePath(fileType);// 配置表格循环策略Configureconfig= Configure.builder() .bind("envList", newLoopRowTableRenderPolicy()) .bind("devList", newLoopRowTableRenderPolicy()) .bind("serverList", newLoopRowTableRenderPolicy()) .bind("dbList", newLoopRowTableRenderPolicy()) .bind("bizList", newLoopRowTableRenderPolicy()) .build();// 渲染XWPFTemplatetemplate= XWPFTemplate.compile(templatePath) .render(data, config);// 输出为byte[]ByteArrayOutputStreamout=newByteArrayOutputStream(); template.writeAndClose(out);return out.toByteArray(); }}同样的功能,docx4j需要2453行代码,poi-tl只需要300行。 差距主要在表格循环和图片处理上——poi-tl把这两项操作的复杂度从O(N²)降到了O(1)。
2.4 模板制作最佳实践
1. 占位符命名规范:
{{sysName}} // 简单文本,驼峰命名{{@topoImg}} // 图片统一加Img后缀{{#deviceList}} // 列表统一加List后缀{{?isPassed}} // 布尔值统一用is前缀2. Word里写占位符的技巧:
关键: Word会把一个完整的占位符 {{sysName}} 拆成多个XML节点(因为拼写检查、格式变化等原因),导致poi-tl识别不到。
解决方法:先在纯文本编辑器里写好占位符,再整体粘贴到Word里。 绝对不要在Word里逐字输入 {{sysName}}。
如果已经出现识别不到的情况,可以用poi-tl自带的模板检查工具:
// 开启模板检查Configureconfig= Configure.builder() .useSpringEL() // 支持SpEL表达式 .build();// 或者直接调用模板验证XWPFTemplate.compile("template.docx").render(newHashMap<>());3. 空值处理:
poi-tl默认遇到null值会保留原始占位符。建议统一处理:
// 方式1:全局配置Configureconfig= Configure.builder() .defaultChar("—") // null值显示为"—" .build();// 方式2:数据准备时手动处理data.put("remark", StringUtils.defaultString(sys.getRemark(), "无"));4. 表格合并单元格:
poi-tl原生不支持动态合并单元格。如果需要,可以用自定义RenderPolicy:
publicclassMergeCellRenderPolicyextendsAbstractRenderPolicy {@OverridepublicvoiddoRender(RunTemplate runTemplate, Object data, XWPFTemplate template) {// 获取当前行和表格XWPFTableRowcurrentRow= runTemplate.getRun().getTableRow();XWPFTabletable= currentRow.getTable();// 根据数据逻辑合并单元格// ...// 清除占位符文本 runTemplate.getRun().setText("", 0); }}2.5 性能考量
poi-tl基于Apache POI,模板渲染时会把整个文档加载到内存。对于几百页的大型文档,内存占用可能达到几百MB。
优化策略:
// 1. 图片压缩:不要直接用原始截图data.put("topoImg", Pictures.ofBytes(compressImage(rawBytes), PictureType.JPEG) .size(500, 350).create());// 2. 列表数据分批处理(如果数据量特别大)// poi-tl目前不支持流式渲染,但可以在数据准备阶段做分页// 3. 模板缓存:同一个模板不要反复compile// 建议用Spring的单例Bean缓存模板编译结果2.6 小结
poi-tl用模板思想把Word生成的复杂度降了一个数量级。它的定位是:
格式固定、内容动态的文档生成首选方案。
三、FreeMarker模板:老牌方案的荣光与无奈
3.1 xdocreport:FreeMarker + Word的桥梁
在Java生态里,FreeMarker做Word文档的标准方案是通过 xdocreport 库。它的原理是:
用Word创建 .docx模板,在需要动态内容的位置写 FreeMarker 语法(${variable}、<#list>、<#if>)xdocreport 把 docx 文件当作ZIP解压,找到 word/document.xml用FreeMarker引擎渲染这个XML 把渲染后的XML重新打包成docx
<dependency><groupId>fr.opensagres.xdocreport</groupId><artifactId>fr.opensagres.xdocreport.document.docx</artifactId><version>2.0.1</version></dependency><dependency><groupId>fr.opensagres.xdocreport</groupId><artifactId>fr.opensagres.xdocreport.template.freemarker</artifactId><version>2.0.1</version></dependency>3.2 基本用法
// 加载模板(docx文件,里面已包含FreeMarker语法)InputStreamtemplateStream=newFileInputStream("template.docx");// 创建报告对象IXDocReportreport= XDocReportRegistry.getRegistry() .loadReport(templateStream, TemplatesKind.Freemarker);// 准备数据IContextcontext= report.createContext();context.put("projectName", "XX信息系统");context.put("evalDate", "2026-05-26");// 列表数据List<Device> devices = getDeviceList();context.put("devices", devices);// 渲染输出OutputStreamout=newFileOutputStream("output.docx");report.process(context, out);模板文件(在Word里直接编辑,使用FreeMarker语法):
项目名称:${projectName}检查日期:${evalDate}设备清单:<#list devices as device> ${device_index + 1}. ${device.name} - ${device.ip}</#list>3.3 图片处理
xdocreport支持图片插入,但需要在模板里预留特殊字段:
// Java端IProviderProviderproviders=newPropertiesDataProvider();IImageProviderimageProvider=newFileImageProvider(newFile("topo.png"), true// 是否保持比例);providers.put("topoImage", imageProvider);模板里需要使用xdocreport的特殊语法,不像poi-tl那样简洁直观。
3.4 深坑:Word XML的"格式碎裂"
这是FreeMarker + Word方案最核心的问题,也是我最终选择迁移到poi-tl的根本原因。
问题根源: Word在编辑过程中,会根据拼写检查、输入法切换、格式变更等操作,把一个"看起来连续"的字符串在XML层面拆成多个 <w:r>(Run)节点。
比如你在Word里打了 ${projectName},你以为XML里是:
<w:r><w:t>${projectName}</w:t></w:r>但实际上Word可能把它拆成:
<w:r><w:t>$</w:t></w:r><w:r><w:t>{</w:t></w:r><w:r><w:t>project</w:t></w:r><w:r><w:t>Name}</w:t></w:r>甚至更碎片化。FreeMarker引擎无法识别这种被拆散的占位符,渲染直接失败。
解决方案: 有一些workaround,但都不可靠:
用Word的"域"功能:插入一个MACROBUTTON域来包裹变量——操作复杂,非技术人员不会 用插件:有些Word插件可以合并XML Run——额外依赖,不通用 手动编辑XML:用解压工具打开docx,直接编辑 document.xml——回到了手写XML的时代
3.5 模板维护的噩梦
在之前的项目里,我们用xdocreport生成了十几种不同类型的文档。每次业务需求变更(加个字段、改个表格结构),维护流程是:
业务人员在Word里修改格式 开发人员另存为XML,检查FreeMarker语法是否被破坏 如果被破坏,手动在XML里修复 测试生成效果 格式不对?回到第2步
一个"表格加一列"的需求,可能要来回改3-4次模板才能搞定。
而且因为模板本质上是一个XML文件,代码review完全无法覆盖——你没法在Git diff里直观地看到Word格式的变化。
3.6 小结
FreeMarker + xdocreport方案的定位:
适用于格式极简、改动频率低的文档生成。一旦格式复杂或需求频繁变化,维护成本指数级上升。
四、三大方案深度对比
4.1 技术架构对比
4.2 功能对比
{{var}} | ${var} | ||
{{#list}} | <#list> | ||
<#list> | |||
Pictures.ofXxx() | |||
{{?flag}} | <#if> | ||
Includes | <#include> | ||
4.3 依赖体量对比
docx4j-core: 8.3.10├── 传递依赖约 40+ 个JAR├── 含JAXB全套实现├── 含shaded的xalan└── 总大小约 15MBpoi-tl: 1.12.2├── 传递依赖约 15 个JAR├── Apache POI 5.x├── 含xml-apis(建议排除)└── 总大小约 8MBxdocreport: 2.0.1├── 传递依赖约 10 个JAR├── FreeMarker└── 总大小约 3MB4.4 性能对比
实测数据(生成一份30页的综合报告):
FreeMarker最快是因为它只是文本替换,不涉及Word对象模型。但这个速度优势在实际项目中意义不大——瓶颈通常在数据库查询和图片下载上。
五、实战选型与迁移经验
5.1 实际项目中的迁移路径
原始状态:docx4j做生成 + xdocreport做简单模板 = 4274行代码
迁移目标:docx4j仅做解析 + poi-tl做生成
迁移步骤:
第一步: 添加poi-tl依赖,排除冲突
<dependency><groupId>com.deepoove</groupId><artifactId>poi-tl</artifactId><version>1.12.2</version><exclusions><exclusion><groupId>xml-apis</groupId><artifactId>xml-apis</artifactId></exclusion><exclusion><groupId>xml-apis</groupId><artifactId>xml-apis-ext</artifactId></exclusion></exclusions></dependency>第二步: 修改所有模板文件占位符格式
从 ${varName} 改为 {{varName}},从 FreeMarker 的 <#list> 改为 poi-tl 的 {{#list}}。17个模板文件,逐个改。
第三步: 拆分巨型Service
原来的 WordDocumentExportService(2453行)按文档类型拆分成多个Preparer:
service/word/├── xxxExportFacadeService.java // 入口路由(~100行)├── xxxDataPreparer.java // 公共数据准备(~200行)├── xxxFormDataPreparer.java // 系统调查表(~300行)├── xxxPlanDataPreparer.java // 项目计划书(~200行)├── xxxPlanDataPreparer.java // 评估方案(~400行,最复杂)├── xxxDataPreparer.java // 差距分析报告(~300行)├── xxxxDataPreparer.java // 委托协议书(~100行)├── xxxHelper.java // 图片处理工具(~80行)└── xxxHelper.java // 复选框工具(~100行)第四步: 数据准备逻辑改造
从"遍历XML节点填充数据"改为"构建Map传给模板引擎"。核心变化:
// 之前(docx4j):操作XML对象ObjectFactoryfactory= Context.getWmlObjectFactory();Trrow= factory.createTr();Tccell= factory.createTc();Rrun= factory.createR();Texttext= factory.createText();text.setValue(device.getName());// ... 省略样式设置代码 ...// 之后(poi-tl):准备数据MapMap<String, Object> item = newHashMap<>();item.put("name", device.getName());deviceList.add(item);第五步: 处理依赖冲突
这是最耗时的步骤。需要用 mvn dependency:tree -Dverbose 逐层分析冲突,手动排除冲突依赖,然后逐个功能测试确认没有副作用。
5.2 docx4j和poi-tl共存的配置
<!-- docx4j:仅用于导入/解析 --><dependency><groupId>org.docx4j</groupId><artifactId>docx4j-core</artifactId><version>8.3.10</version><!-- 注意:不要排除xalan,NamespacePrefixMapper依赖它 --></dependency><dependency><groupId>org.docx4j</groupId><artifactId>docx4j-JAXB-ReferenceImpl</artifactId><version>8.3.10</version></dependency><!-- poi-tl:用于文档生成 --><dependency><groupId>com.deepoove</groupId><artifactId>poi-tl</artifactId><version>1.12.2</version><exclusions><!-- xml-apis覆盖JDK内置org.w3c.dom,导致docx4j JAXB挂住 --><exclusion><groupId>xml-apis</groupId><artifactId>xml-apis</artifactId></exclusion><exclusion><groupId>xml-apis</groupId><artifactId>xml-apis-ext</artifactId></exclusion></exclusions></dependency>5.3 排查依赖冲突的方法论
当你的项目引入多个XML处理库后,如果出现奇怪的 NoClassDefFoundError 或 ClassNotFoundException,按以下步骤排查:
1. 确认冲突的类来自哪个JAR:
# 在IDEA里:Ctrl+N 搜索类名# 或用mvn命令:mvn dependency:tree -Dincludes=*:xalan*mvn dependency:tree -Dincludes=*:jaxb*2. 检查是否有多个版本:
mvn dependency:tree -Dverbose | grep jaxb-impl如果看到多个版本的 jaxb-impl,就是冲突了。
3. 检查shade/重命名:
docx4j内部shade了很多依赖,包名从 org.apache 变成了 org.docx4j.org.apache。如果你看到的错误路径包含 org.docx4j.org.apache,说明是shade后的类。
4. 异步任务的Error吞没:
如果你的任务跑在 CompletableFuture.runAsync() 里,一定要 catch(Throwable) 而不是 catch(Exception)。NoClassDefFoundError 继承自 Error,不是 Exception,会被静默吞掉。
// 错误写法:捕获不到ErrorCompletableFuture.runAsync(() -> {try { doSomething(); } catch (Exception e) { // NoClassDefFoundError不会被捕获! log.error("失败", e); }});// 正确写法CompletableFuture.runAsync(() -> {try { doSomething(); } catch (Throwable e) { // Error和Exception都能捕获 log.error("失败", e); }});六、选型决策树
根据你的实际场景,按这个决策树选型:
你需要做什么?├── 读取/解析已有Word文档│ └── → docx4j(唯一选择)│├── 生成格式固定的文档│ ├── 格式复杂(表格合并、图片、页眉页脚)│ │ └── → poi-tl│ ├── 格式简单(纯文本+简单表格)│ │ ├── 团队里有FreeMarker经验│ │ │ └── → FreeMarker + xdocreport│ │ └── 团队里没人用过FreeMarker│ │ └── → poi-tl(学习成本更低)│ └── 需要像素级格式控制│ └── → docx4j(做好写大量代码的准备)│├── 既要解析又要生成│ └── → docx4j(解析)+ poi-tl(生成)│ 注意处理好依赖冲突│└── 团队全是不懂技术的业务人员 └── → poi-tl(模板直接在Word里做)七、总结
| docx4j | |
| poi-tl | |
| FreeMarker |
在实际项目中,我推荐 docx4j解析 + poi-tl生成 的组合方案。两者互补,各司其职。唯一需要注意的是Maven依赖冲突——按本文给出的配置排除冲突项,基本可以避免踩坑。
如果你的项目已经在用 FreeMarker + xdocreport,迁移到 poi-tl 的投入产出比是值得的。模板从 ${} 改成 {{}} 的工作量不大,但换来的是:
模板可以直接在Word里编辑,不再需要碰XML 代码量减少一个数量级 维护成本从"每次改模板都是修行"变成"改个占位符的事"
踩过的坑都写在文章里了,希望能帮你省几个通宵。
夜雨聆风