ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java与Apache POI实现Word模板动态填充:占位符与表格行实战

Java与Apache POI实现Word模板动态填充:占位符与表格行实战 1. 项目概述与核心需求拆解1.1 这个需求到底在解决什么问题先别急着写代码我们把需求摊开看。你手里的任务叫“使用Java代码转Word文档之动态生成数据填充”说白了就是程序跑起来之后按需生成一份Word文档并且文档里的内容不是写死的而是根据数据动态填进去的。比如导出报表、生成合同、输出工单、批量打印奖状都属于这个范畴。这类需求在实际业务里有多常见我给你列几个真实场景电商后台的订单详情导出运营每天都点“下载对账单”OA系统里的报销单审批通过后自动生成PDF或WordHR系统批量生成员工录用通知书甚至教学管理系统给学生批量打印成绩单。这些场景背后的技术选型十有八九绕不开Java操作Word。为什么偏偏是Word而不是PDF因为业务方要求“可编辑”。PDF适合归档但客户、领导、业务同事拿过去往往还要改两笔Word才是他们熟悉的格式。所以“动态生成Word”这个需求本质上是在“模板复用”和“数据注入”之间找一个平衡点样式固定、内容可变。1.2 为什么说“动态生成数据填充”比“从零画文档”更靠谱很多新手第一次做Word导出第一反应是用Apache POI的XWPFDocument类从零开始创建一个文档对象然后逐段添加标题、逐行拼接表格。这个思路没错但只适合极其简单的场景。一旦文档结构复杂起来——带页眉页脚、嵌套表格、合并单元格、自定义字体、企业Logo——纯代码绘制会让你痛不欲生光调一个表格边框就可能让你怀疑人生。所以我始终推荐“模板优先”的方案先用Word把静态样式做好把需要动态填充的位置用占位符标记出来然后Java代码只负责“打开模板—替换占位符—保存为新文件”。这样样式在Word里调数据在Java里填各司其职。这也是本项目的核心思路。这次我带的项目正好踩遍了这个过程中的大部分坑。下面我把整个设计、代码实现、踩坑记录、排查思路全部整理出来你照着走一遍基本能覆盖日常80%的动态Word需求。1.3 适合谁看需要什么基础如果你是Java后端开发或者正在做报表导出、文档生成相关的功能这篇文章非常对症。需要的基础并不高会Spring Boot的基本用法知道Maven依赖怎么加了解POI的基本概念就够。如果你完全没接触过POI也没关系我会把涉及到的核心类拆开讲明白。如果你是非Java技术栈想了解动态生成Word的整体思路也可以看重点看第一节和第四节的方案取舍代码部分跳过不影响理解。2. 方案选型为什么不硬写代码而是用模板填充2.1 Java操作Word的主流方案横向对比在动手之前先搞清楚市面上有哪些主流方案它们各自的优缺点是什么。我在项目启动前花了不少时间做选型这里直接给你结论。方案优势劣势适用场景Apache POI免费、社区大、完全控制底层API繁琐、样式处理麻烦、内存占用高复杂表格、从零生成文档、对成本敏感FreeMarker docx模板模板语法强大、数据注入方便只适合简单占位符、复杂样式仍受限纯文本/简表填充Spire.Doc功能强、API友好商业授权、免费版有水印和页数限制预算充足、追求开发效率OpenPDF/iTextPDF处理强不是Word原生方案只要PDF不要WordAspose.Words功能极强、支持模板渲染授权费非常贵企业级、不考虑成本我在这个项目里用的是Apache POI原因很简单团队预算有限而且模板是合作方给的样式比较复杂用POI的XWPFDocument直接操作docx文件最稳。这里强调一下POI操作的是docxOffice 2007不是老式的doc。如果你的模板是doc后缀建议先用WPS或Office另存为docx否则后续会出现兼容性灾难。2.2 模板占位符设计${xxx}还是自定义标记确定用POI之后下一步是设计模板。我的做法是在Word模板中把需要动态填充的地方写成占位符比如${customerName}、${orderNo}、${detailList}。选${}语法是有讲究的。第一它和FreeMarker、Vue等模板语言的风格一致团队成员没有学习成本。第二POI遍历段落时用正则\$\{([^}])\}就能一次性提取出所有占位符匹配逻辑简单。第三Word本身不会对${}做特殊处理你把它当普通文本写进文档就行。不过这里有个大坑当你在Word里输入${customerName}时Word可能会自动开启“自动更正”功能把{或}替换成特殊格式或者在$前面加上反斜杠。所以在编辑模板时最好先把“文件—选项—校对—自动更正选项”里的相关设置关掉或者直接把占位符先写在记事本里再粘贴到Word中。占位符要注意大小写。Java代码里Map的key如果写customerName模板里也必须完全相同否则替换时就找不到对应项。我习惯定义一个常量类把所有占位符key集中管理public class PlaceholderConstants { public static final String CUSTOMER_NAME customerName; public static final String ORDER_NO orderNo; public static final String TOTAL_AMOUNT totalAmount; public static final String DETAIL_LIST detailList; // 其他占位符... }这样在Java里引用时用常量的值模板里也手动敲同样的字符串能有效避免拼写不一致。2.3 表格动态行模板里的“循环体”怎么写纯文本占位符好解决但动态表格怎么处理比如一个订单可能有5条明细另一个订单可能有10条明细表格行数不固定。这需要在模板里定义好表头和一个示例行然后在代码里根据数据动态复制行。我的做法是在模板表格的示例行每一列中都填充占位符比如${detailName}、${detailQty}、${detailPrice}。代码执行时先拿到这一行作为“模板行”然后根据明细数据的条数复制出对应数量的行再逐个替换每个单元格里的占位符。复制完成后再把模板行本身删掉。听起来简单但POI里复制Word表格行是个容易出错的点。后面我详细讲代码实现时会给你一份能直接用的复制方法。这里先提醒复制行不仅要复制单元格内容还要考虑单元格的宽度、合并状态、行高否则导出的表格会错位。3. 环境准备与POI依赖引入3.1 Maven依赖怎么配版本怎么选项目使用Maven管理依赖。POI针对docx的XWPF系列位于poi-ooxml模块中同时需要依赖poi-ooxml-lite和xmlbeans。我建议使用统一的版本号避免依赖冲突。properties poi.version5.2.5/poi.version /properties dependencies dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version${poi.version}/version /dependency /dependencies注意POI 5.x要求JDK 8如果你项目还在用JDK 7那就只能用4.x旧版。我这边统一是JDK 17所以直接用5.2.5实测稳定。如果你还需要导出图片、处理水印这类功能参考官方文档添加对应的可选依赖。基础动态文本和表格上面的依赖就够用。3.2 项目结构怎么组织从模板到输出我先给你看一个干净、好维护的包结构com.example.wordexport ├── controller │ └── WordExportController.java ├── service │ ├── WordExportService.java │ └── impl │ └── WordExportServiceImpl.java ├── utils │ ├── WordTemplateUtils.java │ ├── TableUtils.java │ └── PlaceholderConstants.java ├── model │ ├── Order.java │ └── OrderDetail.java └── template └── order_template.docxtemplate目录放模板文件。注意模板最好放在src/main/resources/templates/下这样打包后还能通过classpath读取。工具类WordTemplateUtils负责模板加载和占位符替换TableUtils负责表格动态行复制职责分离后面扩展其他模板时不用改一大片。3.3 模板文件准备实操具体操作是这样的先用Word新建一份空白文档按业务要求排好版。比如我这次做的是“采购订单生成”模板中包含文档标题采购订单订单编号、客户名称、业务员、下单日期订单明细表格列名分别是“序号、商品名称、规格、数量、单价、金额”合计金额、备注页脚第几页共几页在需要动态填充的位置直接输入占位符如订单编号处输入${orderNo}客户名称处输入${customerName}合计金额处输入${totalAmount}。表格中只保留一行用于填充明细这一行每一列都填入占位符比如序号商品名称规格数量单价金额${seq}${goodsName}${spec}${qty}${price}${amount}保存模板文件后可以先用文本编辑器比如Notepad打开docx确认一下你会发现docx本质是一个zip包${orderNo}这些占位符分散在word/document.xml文件中。这一步不是为了让你修改XML而是让你理解占位符是怎么被存储的后面排查替换失败时很有帮助。4. 核心代码实现从加载模板到数据填充4.1 加载模板从classpath读取文件先把模板文件从classpath中读出来转成InputStream再装载到XWPFDocument中。import org.apache.poi.xwpf.usermodel.XWPFDocument; import java.io.InputStream; public class WordTemplateUtils { public static XWPFDocument loadTemplate(String classpathPath) throws IOException { InputStream inputStream WordTemplateUtils.class.getClassLoader() .getResourceAsStream(classpathPath); if (inputStream null) { throw new IllegalArgumentException(模板文件不存在: classpathPath); } return new XWPFDocument(inputStream); } }调用时传入模板在resources下的路径例如templates/order_template.docx。注意XWPFDocument构造时会完整解析docx内容所以模板文件不要做得太大超过几十MB的模板加载会非常慢而且容易内存溢出。如果真有这么大的模板建议拆分文档再合并。4.2 文本占位符替换遍历段落和表格填充数据时需要同时关注两类位置文档正文段落、表格的单元格段落。XWPFDocument提供了获取所有段落和所有表格的方法但要注意正文段落和表格里的段落不是同一个集合必须分别遍历。完整替换逻辑如下import org.apache.poi.xwpf.usermodel.*; import org.apache.poi.xwpf.usermodel.XWPFTableCell; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; public class WordTemplateUtils { private static final Pattern PLACEHOLDER_PATTERN Pattern.compile(\\$\\{([^}])\\}); /** * 文本占位符替换 */ public static void replaceTextPlaceholders(XWPFDocument document, MapString, String data) { // 1. 处理正文段落 for (XWPFParagraph paragraph : document.getParagraphs()) { replaceInParagraph(paragraph, data); } // 2. 处理所有表格 for (XWPFTable table : document.getTables()) { // 2.1 处理表格上方的段落表格前可能存在独立段落但其实XWPFDocument.getParagraphs已经包含部分 // 但表格内的单元格段落必须循环处理 for (XWPFTableRow row : table.getRows()) { for (XWPFTableCell cell : row.getTableCells()) { for (XWPFParagraph paragraph : cell.getParagraphs()) { replaceInParagraph(paragraph, data); } } } } } private static void replaceInParagraph(XWPFParagraph paragraph, MapString, String data) { String text paragraph.getText(); if (text null || !PLACEHOLDER_PATTERN.matcher(text).find()) { return; } ListXWPFRun runs paragraph.getRuns(); if (runs.isEmpty()) { return; } // 关键点把占位符所在的所有run合并为一段完整文本 StringBuilder fullText new StringBuilder(); for (XWPFRun run : runs) { fullText.append(run.text()); } String replacedText fullText.toString(); for (Map.EntryString, String entry : data.entrySet()) { replacedText replacedText.replace(${ entry.getKey() }, entry.getValue()); } // 将替换后的文本写回第一个run其余run清空 // 注意这种方式保留了第一个run的样式后续run的样式会丢失。如果需要整段统一样式可以接受 // 如果各run样式不同需要更精细的处理。这里给出通用做法。 if (!replacedText.equals(fullText.toString())) { XWPFRun firstRun runs.get(0); firstRun.setText(replacedText, 0); for (int i 1; i runs.size(); i) { runs.get(i).setText(, 0); } } } }这段代码里有个非常关键的细节为什么不能直接用paragraph.replaceText()因为Word内部会把一段文字拆分成多个Run一个Run可能只包含半个词语甚至一个占位符都被拆得七零八落。比如${customerName}可能被Word拆成${custome和rName}两个Run。你如果只对每个Run单独做替换永远匹配不上完整的占位符。所以我的做法是先把所有Run拼成完整字符串做整体替换再把替换结果写回第一个Run其他Run清空。这么做会丢失除了第一个Run以外的样式差异但对于大多数业务场景一段文本的样式本来就是统一的影响不大。如果真遇到需要保留多处不同颜色的文本那就需要按占位符的起止位置定位Run区间逻辑会更复杂这里不展开。4.3 动态表格行复制与填充动态表格是最容易翻车的部分。先看代码。import org.apache.poi.xwpf.usermodel.*; import org.apache.poi.xwpf.usermodel.XWPFTableCell; import org.apache.poi.xwpf.usermodel.XWPFTableRow; import java.util.List; public class TableUtils { /** * 根据模板行复制行并填充数据 * * param table 目标表格 * param templateRow 模板行包含占位符的示例行 * param rowDataList 每行要填充的数据每条对应一行 */ public static void fillDynamicRows(XWPFTable table, XWPFTableRow templateRow, ListMapString, String rowDataList) { if (rowDataList null || rowDataList.isEmpty()) { // 没有数据时需要删除模板行或者保留一行占位这里选择删除模板行 table.removeRow(templateRow.getCtRow()); return; } // 复制出第一行数据前先把模板行作为基准复制再替换模板行本身的占位符 int templateRowIndex getRowIndex(table, templateRow); // 为每一行数据动态插入新行 for (int i 0; i rowDataList.size(); i) { MapString, String rowData rowDataList.get(i); XWPFTableRow targetRow; if (i 0) { // 第一条数据直接复用模板行 targetRow templateRow; } else { // 后续数据复制模板行 targetRow copyTableRow(table, templateRow); } // 填充这一行的单元格 fillRowData(targetRow, rowData); // 处理序号列可选 if (rowData.containsKey(seq)) { rowData.put(seq, String.valueOf(i 1)); } } // 如果数据行数大于0模板行已经被用作第一个数据行不需要额外删除 // 如果数据行数等于0上面已删除模板行 } private static int getRowIndex(XWPFTable table, XWPFTableRow row) { ListXWPFTableRow rows table.getRows(); for (int i 0; i rows.size(); i) { if (rows.get(i) row || rows.get(i).equals(row)) { return i; } } return -1; } /** * 复制表格行 */ private static XWPFTableRow copyTableRow(XWPFTable table, XWPFTableRow sourceRow) { XWPFTableRow newRow table.insertNewTableRow(table.getRows().indexOf(sourceRow) 1); newRow.getCtRow().setTrPr(sourceRow.getCtRow().getTrPr()); ListXWPFTableCell sourceCells sourceRow.getTableCells(); for (int i 0; i sourceCells.size(); i) { XWPFTableCell sourceCell sourceCells.get(i); XWPFTableCell newCell newRow.addNewTableCell(); // 复制单元格属性 if (sourceCell.getCTTc() ! null) { newCell.getCTTc().setTcPr(sourceCell.getCTTc().getTcPr()); } // 复制单元格里的段落和run for (XWPFParagraph sourceParagraph : sourceCell.getParagraphs()) { XWPFParagraph newParagraph newCell.addParagraph(); newParagraph.getCTP().setPPr(sourceParagraph.getCTP().getPPr()); for (XWPFRun sourceRun : sourceParagraph.getRuns()) { XWPFRun newRun newParagraph.createRun(); newRun.getCTR().setRPr(sourceRun.getCTR().getRPr()); newRun.setText(sourceRun.text()); } } } return newRow; } /** * 替换一行中每个单元格的占位符 */ private static void fillRowData(XWPFTableRow row, MapString, String rowData) { for (XWPFTableCell cell : row.getTableCells()) { for (XWPFParagraph paragraph : cell.getParagraphs()) { String text paragraph.getText(); if (text null || text.isEmpty()) { continue; } ListXWPFRun runs paragraph.getRuns(); if (runs.isEmpty()) { continue; } StringBuilder fullText new StringBuilder(); for (XWPFRun run : runs) { fullText.append(run.text()); } String replaced fullText.toString(); for (Map.EntryString, String entry : rowData.entrySet()) { replaced replaced.replace(${ entry.getKey() }, entry.getValue()); } if (!replaced.equals(fullText.toString())) { runs.get(0).setText(replaced, 0); for (int i 1; i runs.size(); i) { runs.get(i).setText(, 0); } } } } } }复制表格行时要注意三点。第一insertNewTableRow在指定索引后插入新行但新行的列数是0需要手动addNewTableCell补齐否则生成的表格缺列。第二复制单元格时必须同时复制tcPr单元格属性和段落属性否则边框、宽度、对齐样式全部丢失。第三合并单元格的复制非常麻烦。setTcPr复制了合并属性但合并对应的其他单元格仍然需要处理我在项目里遇到过“复制行后合并单元格错乱”的问题解决办法是如果动态行不需要合并单元格就在模板示例行中不要使用合并如果必须合并建议手动调整gridSpan和vMerge属性这块很繁琐不在本文展开。4.4 拼接完整文档并输出在Service层把这些操作串起来。这里模拟一个采购订单的业务场景。import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFTable; import org.apache.poi.xwpf.usermodel.XWPFTableRow; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.*; public class WordExportServiceImpl implements WordExportService { Override public byte[] generateOrderWord(Order order) throws IOException { // 1. 加载模板 XWPFDocument document WordTemplateUtils.loadTemplate(templates/order_template.docx); // 2. 构造文本数据 MapString, String textData new HashMap(); textData.put(PlaceholderConstants.ORDER_NO, order.getOrderNo()); textData.put(PlaceholderConstants.CUSTOMER_NAME, order.getCustomerName()); textData.put(PlaceholderConstants.ORDER_DATE, order.getOrderDate() null ? : order.getOrderDate().toString()); textData.put(PlaceholderConstants.TOTAL_AMOUNT, String.format(%.2f, order.getTotalAmount())); textData.put(PlaceholderConstants.REMARK, order.getRemark()); // 3. 替换文本占位符 WordTemplateUtils.replaceTextPlaceholders(document, textData); // 4. 处理明细表格 ListXWPFTable tables document.getTables(); if (!tables.isEmpty()) { XWPFTable detailTable tables.get(0); // 约定第一个表格是明细表格 ListMapString, String rowDataList new ArrayList(); for (OrderDetail detail : order.getDetails()) { MapString, String rowData new HashMap(); rowData.put(goodsName, detail.getGoodsName()); rowData.put(spec, detail.getSpec()); rowData.put(qty, String.valueOf(detail.getQty())); rowData.put(price, String.format(%.2f, detail.getPrice())); rowData.put(amount, String.format(%.2f, detail.getAmount())); rowData.put(seq, String.valueOf(rowDataList.size() 1)); rowDataList.add(rowData); } // 取模板示例行一般是表格中第一个表头行下方的第一行 if (detailTable.getRows().size() 2) { XWPFTableRow templateRow detailTable.getRow(1); TableUtils.fillDynamicRows(detailTable, templateRow, rowDataList); } } // 5. 输出到字节数组 ByteArrayOutputStream outputStream new ByteArrayOutputStream(); document.write(outputStream); document.close(); return outputStream.toByteArray(); } }注意我取模板示例行时写的是detailTable.getRow(1)因为表格第0行是表头第1行是存放占位符的示例行。如果你模板行不在第1行要根据你模板的实际行索引调整。最后在Controller里响应下载import org.springframework.web.bind.annotation.*; import org.springframework.http.*; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.net.URLEncoder; RestController RequestMapping(/word) public class WordExportController { private final WordExportService wordExportService; public WordExportController(WordExportService wordExportService) { this.wordExportService wordExportService; } GetMapping(/order/{orderNo}) public void exportOrder(PathVariable String orderNo, HttpServletResponse response) throws IOException { Order order orderService.getByOrderNo(orderNo); // 示例实际注入你的service byte[] content wordExportService.generateOrderWord(order); String fileName URLEncoder.encode(采购订单_ orderNo .docx, UTF-8).replaceAll(\\, %20); response.setContentType(application/vnd.openxmlformats-officedocument.wordprocessingml.document); response.setHeader(Content-Disposition, attachment; filename*UTF-8 fileName); response.getOutputStream().write(content); response.getOutputStream().flush(); } }下载文件名的中文编码问题要提一下直接用filename加文件名在部分浏览器会出现中文乱码推荐使用RFC 5987的filename*UTF-8格式同时把文件名用URLEncoder处理这样兼容性最好。4.5 合并单元格场景的一个补充方案如果你的模板表格存在跨行或跨列合并复制行会变得更加棘手。我项目中遇到的情况是明细表最后一列“备注”需要纵向合并但动态行数不定所以无法在模板里预设合并。我的处理思路是在动态行全部填充完以后再统一设置合并区域。POI里设置纵向合并用vMerge横向合并用gridSpan。举个设置纵向合并的例子// 将表格第2列的第3行到第5行进行纵向合并 CTTcPr tcPr table.getRow(2).getCell(1).getCTTc().isSetTcPr() ? table.getRow(2).getCell(1).getCTTc().getTcPr() : table.getRow(2).getCell(1).getCTTc().addNewTcPr(); tcPr.addNewVMerge().setVal(STMerge.RESTART); for (int i 3; i 5; i) { CTTcPr tempTcPr table.getRow(i).getCell(1).getCTTc().isSetTcPr() ? table.getRow(i).getCell(1).getCTTc().getTcPr() : table.getRow(i).getCell(1).getCTTc().addNewTcPr(); tempTcPr.addNewVMerge().setVal(STMerge.CONTINUE); }这段代码依赖ooxml-schemas的类需要引入额外的依赖。如果不需要纵向合并可以忽略。但如果你确实需要建议先研究一下docx的XML结构再动手盲调很容易把整个表格调崩。5. 常见问题与排查技巧实录这一部分全部来自我实际踩坑后的总结比任何官方文档都实用。5.1 占位符替换后仍然显示${xxx}怎么回事这是最常见的翻车现场。原因通常是以下几种占位符被拆分为多个Run。上面代码中已经做了合并处理但如果你的Word模板里有拼写检查或者插入了域、书签占位符可能被拆得更碎甚至每个字符一个Run。这种情况下合并整个段落Run后再替换依然有效。占位符里有空格或特殊字符。比如你在模板中敲了${customerName }中间多了个空格替换就匹配不上。从模板里复制占位符到代码中时最好用文本编辑器看一下字符串的精确内容。替换时大小写不一致。Java Map的key用customerName模板里写成${CustomerName}正则匹配能抓到但Map.get时返回null会被替换成null字符串。所以强烈建议用常量类管理key。如果实在排查不出来把模板文件用压缩工具解开直接看word/document.xml里占位符的原始样子一找一个准。5.2 替换后样式错乱字体变了我在一开始就说了runs.get(0).setText的方式会丢失除第一个Run外的样式。假如你的段落中前半段是红色加粗后半段是黑色常规合并后就会全部变成红色加粗。解决办法有两个方向一是尽量保证每个占位符单独成段或者占位符所在段落的整体样式统一。Word模板制作时在需要动态填充的地方单独起一行不要和静态文本混在一个段落里。二是实现精确的Run级替换也就是找到每个占位符在哪个Run范围内只替换对应Run。POI里XWPFRun可以设置文本和属性你可以遍历Runs根据字符位置定位占位符起点和终点然后拆分或合并Run。这个方案代码量不小但最后的样式保留效果最好。如果你有兴趣我后续可以单独写一篇Run级替换的文章。5.3 动态表格行复制后边框消失了复制行时只复制了内容和段落没有复制表格单元格属性或者没有正确复制trPr。检查一下copyTableRow方法里是否执行了这两行newRow.getCtRow().setTrPr(sourceRow.getCtRow().getTrPr()); newCell.getCTTc().setTcPr(sourceCell.getCTTc().getTcPr());另外如果模板表格本身使用了“表格样式”复制行后新行不一定继承样式最好的办法是让新行的tcPr中显式包含边框属性或者在复制后遍历新行所有单元格设置setTblBorders边框。5.4 生成的文档打开时提示“文件已损坏”这是POI写入docx时最让人崩溃的问题。常见原因在关闭XWPFDocument之前没有正确写出所有内容流或者提前关闭了InputStream。检查document.write(outputStream)是否在document.close()之前执行。模板文件本身有问题。用POI读写前先确认模板在Word里能正常打开不要用在线转换工具生成的docx。内存溢出。大文档在写入时被打断输出流不完整。解决方法是限制单次导出的数据量或者优化模板大小。我建议在Service层写完字节数组后先把文件写到本地临时目录用Word打开验证一次再返回给前端。出问题时可以快速定位是POI写坏还是网络传输问题。5.5 前端下载文件名中文乱码Controller中设置Content-Disposition时的编码格式很关键。上面代码中使用了URLEncoder.encode并且用filename*UTF-8大部分现代浏览器没问题。但某些老版本IE只认filename且要求ISO-8859-1编码这种需要做浏览器判断属于前端兼容性问题后端可以做UA判断也可以让前端接收二进制后自己处理文件名。还有一个更稳的做法前端用axios或fetch拿到blob再用URL.createObjectURL创建下载链接文件名由前端JS指定完全绕开后端文件名编码的坑。这种方式我推荐给前后端分离的项目。5.6 性能优化大批量导出如何不卡死如果业务量是几千上万条明细POI内存会急剧膨胀。我的经验是如果明细行数很多不要一次把全部数据装载到内存再写入而是分批写入。POI支持边构造边写吗对于XWPF没有类似SXSSF那样的流式API所以尽量避免单文档超过几百行。真要大批量导出建议拆分成多个文档压缩成zip下载或者改用xlsx导出SXSSF支持流式。模板越小越好越简洁越好。复杂的样式、大量图片都会拖慢处理速度。及时关闭输入输出流防止文件句柄泄漏。我这个项目实际导出的最大订单是上千行明细单文档还能扛住再往上我就建议换方案了。6. 实操心得与效果验证6.1 一次完整执行过程复盘我把整个流程在本地跑了一遍操作顺序如下第一步准备Word模板。用Word 2019创建“采购订单”模板设置好字体和表格样式。第二步在订单信息区域填入占位符明细表第一行填入表格占位符。第三步Maven引入POI依赖。第四步编写工具类和Service代码。第五步单元测试中调用Service传入模拟的Order对象生成字节数组。第六步将字节数组写到本地磁盘用Word打开查看。我没有在单元测试里直接返回给前端而是先落盘检查避免接口联调时重复定位问题。实际执行结果正常文本占位符全部替换成功动态表格生成了预期的行数金额列格式正确表格边框完好。有一个小细节如果模板里合计金额是${totalAmount}但明细行数很多金额需要在Java里先把所有明细金额累加再填入。不要把“计算合计”的重任交给Word域Word域在POI中处理非常麻烦Java算好直接填这是最简单可靠的方案。6.2 换一张模板代码要改多少我做这个项目时另一个业务方也提出要导出Word合同模板完全不同。我发现自己写的工具类可以复用90%Service层只需要改一下模板路径和数据结构。因此我建议把“文本占位符替换”和“表格动态行填充”这两个能力沉淀成通用工具类以后任何模板都能直接调用这就是工具类独立拆分的价值。具体的推广流程是新模板来了先约定好占位符规则然后在Service里装配数据再调工具类。数据装配部分是最容易变化的占位符替换和表格复制几乎可以做到零改动。6.3 常见误区与代码规范最后分享几个容易忽略的规范所有IO流都要在finally块中关闭或者用try-with-resources。不要依赖JVM回收文件句柄耗尽会让你上线后焦头烂额。模板文件不要放数据库也不要放远程文件件放jar包内的resources是最稳定的选择。如果需要运维修改模板再考虑放到配置中心或文件服务并增加缓存。生成的Word文件名如果有时间戳格式建议用yyyyMMdd_HHmmss不要用yyyy-MM-dd HH:mm:ss冒号Windows文件名不能包含冒号。生产环境一定要加日志至少打印“生成Word开始、结束、异常”方便排查大批量导出超时问题。接口务必设置超时时间导出操作不能无限等下去。根据我个人的经验动态生成Word这事说难不难但坑很碎。我从最初用纯POI从零绘制文档到后来改成模板填充方案开发效率和稳定性都提升了一个量级。如果你正准备做类似功能建议直接走模板方案把占位符约定好工具类抽象好后面接再多的模板都只是改数据装配的事情。最后送大家一个实测有效的小技巧模板里凡是动态内容一律单独占一行凡是动态表格行只保留一行作模板行。这个习惯能从根源上避开很多奇葩问题。
返回列表