
简介这份资源面向需要在Java环境中批量生成Word文档的开发者尤其是使用Apache POI处理Office文件的初中级程序员。它围绕“模板数据”的思路演示如何读取预先设计好的.docx模板替换段落、表格中的占位符并输出个性化文档适用于报告、合同等自动化生成场景。压缩包共11个文件约7.37MB包含5个jar依赖包poi、poi-ooxml、xmlbeans、dom4j等、3个java源码示例、2个docx模板以及1张示意图覆盖从依赖引入到文档读写的完整链路。目前已有4138人学习下载说明该方案在实际开发中具有较高参考价值。读者可从中获取可运行的模板替换代码、XWPFDocument与XWPFTable的操作范例以及处理复杂表格和资源关闭的排错思路快速搭建自己的文档生成工具。1. 从一份“套模板”需求说起poi 生成word 到底能省掉多少手工活合同、周报、验收单、通知书——只要格式固定、字段可变就有人在一遍遍复制粘贴改内容。我见过一个后台系统运营每天要出两百份格式完全一致的确认函手动改到怀疑人生。后来用 Apache POI 的 XWPF 模块配合一份预置的 .docx 模板把变量替换进去批量生成压缩包下载整个流程从两小时压到三分钟。这就是 poi 生成word 最典型的落地场景模板不动数据在动。它适合谁适合已经能写 Java、手上有固定 Word 版式、需要程序化填充字段的开发者。不适合想从零画复杂图表、做像素级排版的人——POI 对 Word 的控制力远不如对 Excel 那么细腻这是选型前必须认清的边界。下面把我拆过的模板填充方案完整走一遍包括依赖、模板语法、代码骨架和几个让我翻过车的坑。2. 环境与依赖XWPF 和 HWPF 怎么选版本怎么锁2.1 为什么是 XWPF 而不是 HWPFPOI 操作 Word 有两个入口HWPF 对应 .doc97-2003 二进制格式XWPF 对应 .docxOOXML 格式。现在绝大多数模板都是 .docxXWPF 的 API 也更完整段落、表格、页眉页脚、图片都能碰。HWPF 维护活跃度低遇到复杂 .doc 容易出玄学问题。所以除非甲方硬塞给你一批 .doc 老文件否则一律走 XWPF。选型确认后依赖要锁版本。POI 各模块版本必须一致混用会出现NoSuchMethodError这种运行时才炸的问题。常见做法是用 Maven 统一管理dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependency !-- poi-ooxml 会传递引入 poi 核心包无需重复声明 --参数说明poi-ooxml是操作 .docx 的主包内部依赖poi、poi-ooxml-lite、xmlbeans等。版本号建议固定不要用LATEST否则不同环境拉到的版本不一致本地能跑服务器报错。如果项目里已经有其他组件依赖了旧版 POI用mvn dependency:tree查一下冲突必要时用exclusions排掉旧版。2.2 模板文件放哪、怎么读模板不要放在 classpath 里用getResourceAsStream直接读——XWPFDocument 从流构造后如果还要写回流关闭时机不好控制。我一般把模板放独立目录用绝对路径或配置项指定// 模板路径从配置读取避免硬编码 String templatePath config.getTemplateDir() /confirm_template.docx; try (FileInputStream fis new FileInputStream(templatePath); XWPFDocument doc new XWPFDocument(fis)) { // 后续替换逻辑 // ... try (FileOutputStream fos new FileOutputStream(outputPath)) { doc.write(fos); } }逻辑说明XWPFDocument实现了Closeable用 try-with-resources 确保底层 XML 结构释放。先读模板构造文档对象替换完再写出到目标路径。注意输出流要在文档对象还活着的时候写顺序反了会写出空文件。参数上templatePath和outputPath建议都走配置方便测试环境和生产环境切换目录。3. 模板占位符设计用 {{key}} 还是 ${key}替换逻辑怎么写3.1 占位符格式的选择模板里怎么标记变量直接决定替换代码的复杂度。常见三种写法${name}、{{name}}、name。${}和某些模板引擎语法撞车在 Word 里容易被自动更正干扰。我一般用{{name}}双花括号在正文里显眼运营编辑模板时不容易误删正则匹配也干净。但有个前提占位符必须写在同一个 run 里。Word 的段落由多个 run 组成你看到的{{name}}在底层可能被拆成{{、name、}}三个 run——这是 POI 替换最经典的翻车点。解决办法有两个一是编辑模板时一次性输入完整占位符不要中途改字体二是代码里做跨 run 合并。先看基础替换/** * 替换段落中的占位符 * param paragraph 段落对象 * param data 键值对key 不带花括号 */ private void replaceInParagraph(XWPFParagraph paragraph, MapString, String data) { // 先把段落所有 run 的文本拼起来判断是否含占位符 StringBuilder fullText new StringBuilder(); for (XWPFRun run : paragraph.getRuns()) { fullText.append(run.getText(0) null ? : run.getText(0)); } String text fullText.toString(); if (!text.contains({{)) { return; // 没有占位符跳过 } // 逐个 key 替换 for (Map.EntryString, String entry : data.entrySet()) { String placeholder {{ entry.getKey() }}; if (text.contains(placeholder)) { text text.replace(placeholder, entry.getValue() null ? : entry.getValue()); } } // 把替换后的文本写回第一个 run清空其余 run if (paragraph.getRuns().size() 0) { XWPFRun firstRun paragraph.getRuns().get(0); firstRun.setText(text, 0); for (int i paragraph.getRuns().size() - 1; i 0; i--) { paragraph.removeRun(i); } } }逻辑说明先把段落内所有 run 的文本拼成一个完整字符串在这个字符串上做替换再把结果塞回第一个 run删掉多余的 run。这样即使占位符被拆散也能正确替换。参数上data的 key 不带花括号方法内部自动补{{}}。run.getText(0)的参数 0 表示取第一个文本片段一个 run 可能有多个片段这里简化处理。提示这种“合并再写回”的方式会丢失段落内不同 run 的字体差异。如果模板里一段话中部分文字加粗、部分不加粗替换后格式会统一成第一个 run 的样式。对格式要求高的场景需要按 run 边界精细处理或者约定占位符独占一个 run。3.2 表格里的占位符怎么处理表格是模板填充的重灾区。XWPFTable 由行和单元格组成单元格里又是段落。遍历逻辑要三层嵌套// 遍历文档所有表格 for (XWPFTable table : doc.getTables()) { for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { // 单元格内可能有多个段落 for (XWPFParagraph p : cell.getParagraphs()) { replaceInParagraph(p, data); } } } }逻辑说明doc.getTables()只返回顶层表格嵌套表格需要递归进单元格再取getTables()。参数上没什么可调的关键是别漏掉单元格里的段落——有人直接对 cell 调getText()再 setText那样会丢掉段落格式。如果表格行需要动态增删比如明细行数量不定那就不只是替换要配合table.createRow()和table.removeRow()做行复制这块复杂度上一个台阶建议模板里预留一行“样板行”代码复制它再填充。4. 避坑与排查五个让我加班到凌晨的 POI 问题4.1 占位符替换后格式全乱现象替换完打开文档原本加粗的字段变成普通字体或者整段字体统一了。原因前面说的合并 run 再写回丢失了 run 级格式。解决如果格式要求严格改用“按 run 逐个匹配”策略——遍历 run对每个 run 的文本单独替换不跨 run 合并。代价是占位符被拆散时匹配不到所以模板编辑规范要跟上占位符必须连续输入、不换字体。4.2 生成的文档打开提示“内容有问题”现象Word 弹出“发现不可读取的内容”点修复后内容还在但提示烦人。原因通常是写出时流没关干净或者文档对象在写出后被修改。解决确保doc.write(fos)之后先关fos再关doc用 try-with-resources 的嵌套顺序控制。另外检查模板本身是否损坏——用 Word 打开模板另存一次再试。4.3 中文乱码或方框现象替换进去的中文显示成问号或方框。原因字体问题。模板里占位符所在 run 的字体不支持中文或者代码 setText 时没指定字体。解决在模板里把占位符的字体设成宋体或微软雅黑替换后继承该字体。如果代码里新建 run要显式run.setFontFamily(宋体)。4.4 页眉页脚里的占位符没被替换现象正文都替换了页眉的公司名还是{{company}}。原因doc.getParagraphs()和doc.getTables()不包含页眉页脚。解决单独取页眉页脚遍历// 处理页眉 for (XWPFHeader header : doc.getHeaderList()) { for (XWPFParagraph p : header.getParagraphs()) { replaceInParagraph(p, data); } for (XWPFTable t : header.getTables()) { // 同样遍历表格 } } // 页脚同理用 doc.getFooterList()4.5 大文档生成慢、内存飙高现象批量生成几百份时 GC 频繁甚至 OOM。原因每份都重新读模板、构造 XWPFDocument对象没及时释放。解决模板只读一次缓存成字节数组每次生成用new XWPFDocument(new ByteArrayInputStream(templateBytes))构造避免重复 IO。生成完立即 close不要攒着。如果并发高用线程池控制同时生成的数量POI 对象不是线程安全的别共享。5. 进阶技巧动态表格行与批量生成的收尾习惯模板里明细行数量不固定是 poi 生成word 里最考验人的部分。我的做法是在模板中放一行“样板行”占位符写成{{item_name}}、{{item_qty}}代码里找到这行按数据条数复制// 假设样板行是表格第 2 行索引 1 XWPFTableRow templateRow table.getRow(1); int dataSize itemList.size(); // 先按需插入新行 for (int i 1; i dataSize; i) { // 复制样板行的 XML 结构 XWPFTableRow newRow table.insertNewTableRow(1 i); newRow.getCtRow().set(templateRow.getCtRow().copy()); } // 再逐行填充 for (int i 0; i dataSize; i) { XWPFTableRow row table.getRow(1 i); MapString, String rowData new HashMap(); rowData.put(item_name, itemList.get(i).getName()); rowData.put(item_qty, String.valueOf(itemList.get(i).getQty())); for (XWPFTableCell cell : row.getTableCells()) { for (XWPFParagraph p : cell.getParagraphs()) { replaceInParagraph(p, rowData); } } } // 删掉原始样板行如果它没被数据覆盖 if (dataSize 0) { table.removeRow(1); }逻辑说明getCtRow().copy()是底层 XML 级别的复制能保留行内所有格式比手动 createRow 再设样式靠谱。参数上样板行索引要按实际模板调整插入位置从样板行之后开始。注意insertNewTableRow只是插入空行必须紧接着 copy XML否则新行是空的。批量生成时我习惯把每份文档单独写出到以业务编号命名的文件最后用 ZIP 打包。验证环节别偷懒随机抽三份打开检查字段是否对位、表格行数是否正确、页眉有没有漏。从那以后我每次改完模板都强制走一遍“三份抽检”因为 POI 的坑往往藏在你看不见的 run 边界里。希望帮到你。本文还有配套的精品资源点击获取