公司动态
Spring Boot + FreeMarker 模板化生成 Word 文档实战
简介面向Java后端开发者的FreeMarker生成Word示例项目致力于解决Spring Boot应用中按模板动态输出Word文档、并内嵌图片的实际难题。项目覆盖从依赖配置、模板编写、数据模型填充到HTML转Word的完整流程核心逻辑经过精简封装代码结构清晰适合需要快速集成报表、合同、说明书等文档生成模块的开发者借鉴。压缩包共30个文件大小仅77KB包含Java源码、FreeMarker模板文件、Maven与项目配置文件等其中*.ftl模板可直接改造*.java示例演示图片以CID方式嵌入的要点配套的yml、properties配置便于不同环境下部署调试。使用Apache POI处理Word的docx格式并在模板中预留变量与图片占位可轻松替换为真实业务数据。已有1634人学习浏览适合具备一定Spring Boot基础、希望掌握Word模板生成技巧的中级开发者可按需扩展模板样式与业务字段减少从零搭建的工作量。 我做了好几年的 Java 后端Spring Boot项目里遇到的最多的需求之一就是把业务数据塞进一个固定格式的Word文件里比如合同、体检报告、报销单、验收单。早年我都是用 POI 一个单元格一个单元格去画代码又臭又长格式稍微变动一点就要改半天。后来换了思路用FreeMarker模板引擎来做这件事简直打开新世界的大门。这篇文章就是把我这些年用 Spring Boot FreeMarker 生成 Word 的完整经验整理出来包含模板制作、核心工具类、代码实操、以及我踩过的各种坑尤其是热词里提到的“Word表格双线变单线”、“Spring Boot 版本太高”这类问题。1. 项目概述与核心方案选型1.1 业务场景与需求解析先聊清楚这个需求到底解决什么问题。大多数系统里数据是结构化的存在数据库里但用户要的往往是排版精美、可以直接打印或归档的 Word 文档。比如一份采购合同里面有甲方乙方信息、采购明细表格、总价大写、落款日期这些数据都在系统里但合同模板是法务部门定死的不允许随意改动格式。如果你用 Java 代码直接去控制 Word 的每个段落、每个表格线框那是一场灾难。因为 Word 格式本质上是 OOXMLOffice Open XML底层是一堆 XML 标签你用 POI 去操作它相当于直接用代码去改一个复杂的 XML 文件结构工作量大且容易出错。免费且高效的做法就是模板化先用 Word 做好模板文件把需要动态替换的地方挖空再用 FreeMarker 这个模板引擎去填充数据。这样格式调整交回给业务人员程序员只负责传数据分工清晰效率极高。1.2 为什么 FreeMarker 是最好的选择之一你可能要问Java 生态里生成 Word 的方案并不少比如 POI、iText、Aspose.Words为什么我重点推荐 FreeMarker我列一个对比表你就明白了方案上手难度模板可维护性样式保真度成本Apache POI 手动构建高代码量大差改样式要改代码中等需要自己控制免费iText PDF中但生成的是 PDF一般高但不可编辑免费Aspose.Words低功能全好高商业授权很贵FreeMarker XML模板低好Word里直接改高所见即所得免费FreeMarker 方案的核心思路是“曲线救国”Word 文档本身可以保存为 XML 格式而 FreeMarker 天生就是处理文本模板的我们只要把 Word 模板的 XML 内容当作 FreeMarker 模板把${变量}和#list标签混进 XML 里渲染后再把结果包装成 Word 文件。整个过程不需要任何额外商业依赖纯 Spring Boot 开源组件就能搞定。2. 模板制作与细节处理2.1 如何制作一个合格的 Word 模板这个方案最关键的一步其实是第一次模板文件的制作。很多人一开始就在这一步翻车做出来的模板不是 FreeMarker 渲染不了就是 Word 打开报错。我推荐的标准做法是用 Microsoft Word 编辑一个完整的.docx文件把表格、字体、页眉页脚、样式都调好。在需要填充数据的位置先用占位文本写好比如招商银行、10000.00。把这个.docx文件另存为Word 2003 XML 文档*.xml这一步很关键。用文本编辑器打开这个 XML 文件把占位内容替换成 FreeMarker 语法比如${bankName}、${totalAmount}。把文件名后缀从.xml改为.ftl放入 Spring Boot 的templates目录或 classpath 下。这里有个细节Word 2003 XML也叫 WordML和 docx 内部的 XML 格式不同但 FreeMarker 只需要把它当纯文本处理无所谓哪种格式。我推荐 WordML 格式的原因是它的标签结构相对清晰而且保留了绝大多数文档格式信息兼容性也够好。如果你非要直接操作.docx内部的document.xml也不是不行但 docx 是一个 zip 包你需要解压、修改、再压缩步骤要复杂不少而且容易因为压缩方式不对导致文件损坏。我建议新手直接从 WordML 格式入手。2.2 模板中的占位符与循环表格处理模板做好之后怎么把动态内容写进去这就要用到 FreeMarker 的语法了。我用常见的“合同 明细列表”模板来举例。假设模板里需要展示一个采购清单表格字段包括序号、物品名称、数量、单价、小计。那在 XML 模板里你需要用#list标签把这整行表格数据包起来。核心代码如下#list itemList as item w:tr w:tcw:pw:rw:t${item.index}/w:t/w:r/w:p/w:tc w:tcw:pw:rw:t${item.name}/w:t/w:r/w:p/w:tc w:tcw:pw:rw:t${item.quantity}/w:t/w:r/w:p/w:tc w:tcw:pw:rw:t${item.price}/w:t/w:r/w:p/w:tc w:tcw:pw:rw:t${item.subtotal}/w:t/w:r/w:p/w:tc /w:tr /#list注意这里w:tr是 Word 表格的行标签w:tc是单元格标签w:p是段落w:rw:t是文本。如果你是在 WordML 文件里改标签结构会稍有不同但思路一致。处理普通变量如合同编号${contractNo}、总金额${totalAmount}的话直接把原来 Word 里的占位文本替换成${}语法即可。重点提醒在 XML 模板里和这两个字符是标签分隔符不能直接出现在内容中。如果你要输出比较大小的符号比如“保修期 ≥ 3 年”那么模板里要写成gt;这样的转义形式。2.3 处理合并单元格和表格线型问题热词里有一条“word表格双线变单线”这个问题非常典型。原因出在复制模板行的时候只有第一个单元格带了完整的边框样式定义后面的行或列因为用了合并单元格或者格式继承导致渲染后边框线丢失或者变成默认单线。解决办法有两个在模板里循环体的这一行不要使用合并单元格。你可以先做一个正常的 5 列表格需要合并的单元格是表头表头写在#list的外面不参与循环。如果你的业务诉求是“每一行都要合并某几列”那就需要在 XML 里给对应单元格加上w:vMerge纵向合并或者w:gridSpan标签。我建议在设计模板阶段就尽量避免在循环行里用合并单元格让合并只发生在静态区域。这样处理最简单也不会出现线型不对的问题。如果实在避不开那就老老实实研究w:tcPr节点下的边框配置把w:tcBorders里的上下左右线型都显式指定为single不要指望 Word 自动继承。3. 实操过程与核心代码实现3.1 项目依赖和基础配置我们先搭一个最基础的 Spring Boot Web 项目Maven 依赖只需要两个核心的其他按需添加dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.freemarker/groupId artifactIdfreemarker/artifactId /dependency注意spring-boot-starter-freemarker这个 starter 里面自带 FreeMarker 依赖但它默认的模板文件后缀是.ftlh为了规避 HTML 安全问题。如果你只是做 Word 生成不想被视图解析器干扰我建议直接用原生的freemarker依赖自己创建一个Configuration对象指向 classpath 下的模板目录。这样最干净也不会和 Spring MVC 的试图解析冲突。为什么不建议直接用 starter因为spring-boot-starter-freemarker主要是给页面渲染用的它会自动配置FreeMarkerConfigurer并和 Spring MVC 集成。如果你还要用它来生成 Word就需要再创建一个独立的Configuration稍微有点多余。另外热词里提到“springboot版本太高”我猜测是有人在最新版本 Spring Boot 下遇到了 FreeMarker 模板加载不出或渲染异常的问题。从 Spring Boot 3.x 开始底层是 Jakarta EE 9官方对 FreeMarker 的配置类也做了调整如果你用的是javax.*包路径的旧代码就会报ClassNotFoundException。处理方式很粗暴核心逻辑不要依赖 Spring Boot 的自动配置自己 new 一个Configuration这是最不容易出问题的方案。下面是我一直在用的独立的 FreeMarker 配置方法import freemarker.template.Configuration; import freemarker.template.Template; import java.io.StringWriter; import java.util.Map; public class FreeMarkerUtil { private static final Configuration CONFIGURATION new Configuration(Configuration.VERSION_2_3_32); static { CONFIGURATION.setDefaultEncoding(UTF-8); // 设置模板加载路径这里以 classpath:/templates/ 为例 CONFIGURATION.setClassLoaderForTemplateLoading( FreeMarkerUtil.class.getClassLoader(), templates); } public static String renderTemplate(String templateName, MapString, Object dataModel) throws Exception { Template template CONFIGURATION.getTemplate(templateName); StringWriter writer new StringWriter(); template.process(dataModel, writer); return writer.toString(); } }3.2 核心工具类如何把渲染后的 XML 包装成 docxFreeMarker 渲染完后我们得到的是一个纯 XML 字符串。如果是 WordML 格式这个 XML 本身就是完整的 Word 文档你可以直接把它保存为.doc文件注意是老的 Word 格式但为了兼容性更好我通常会把这段 XML 保存为.xml再由用户自行打开或另存。如果项目要求必须输出.docx文件那我们就要换一种模板格式。前面我提过docx 本质上是 zip 包里面有很多 XML 文件核心内容在word/document.xml中。所以我们的工具类要做的事情就是准备一个标准的.docx文件作为容器里面不含任何动态数据只是骨架。把 docx 解压到内存替换word/document.xml的内容为 FreeMarker 渲染后的结果。重新打包成 zip输出为.docx。这段代码有点绕但我总结了一个可以直接复用的工具类你只需要传入模板相对路径和数据模型Map即可import java.io.*; import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; public class WordGenerator { /** * 根据 docx 模板和动态数据生成 word 文件 * param templatePath classpath 下的模板路径如 templates/invoice.docx * param dataModel FreeMarker 数据模型 * param outputPath 输出文件路径 */ public static void generateDocx(String templatePath, MapString, Object dataModel, String outputPath) throws Exception { // 1. 读取模板 docx压缩包 InputStream templateStream WordGenerator.class.getClassLoader().getResourceAsStream(templatePath); if (templateStream null) { throw new FileNotFoundException(模板文件不存在: templatePath); } // 2. 用 FreeMarker 渲染 document.xml 内容 // 注意这里需要把 docx 里的 word/document.xml 先用模板语法改写成 ftl // 实际操作时文档.xml 里面已经是模板语法所以我们要把压缩包里的 // 这份 document.xml 读出来再交给 FreeMarker 处理。 // 为了简化这里我们直接用 FreeMarkerUtil 渲染一份字符串 String renderedXml FreeMarkerUtil.renderTemplate( document.xml.ftl, dataModel); // 3. 使用 ZipInputStream 读入模板将 word/document.xml 替换 try (ZipInputStream zin new ZipInputStream(templateStream); ZipOutputStream zout new ZipOutputStream(new FileOutputStream(outputPath))) { ZipEntry entry; while ((entry zin.getNextEntry()) ! null) { String name entry.getName(); if (word/document.xml.equals(name)) { // 替换核心内容 zout.putNextEntry(new ZipEntry(name)); zout.write(renderedXml.getBytes(StandardCharsets.UTF_8)); } else { // 其他文件原样复制 zout.putNextEntry(new ZipEntry(name)); byte[] buffer new byte[1024]; int len; while ((len zin.read(buffer)) 0) { zout.write(buffer, 0, len); } } zout.closeEntry(); } } } }代码逻辑很简单核心就三步读模板、渲染 XML、替换打包。实际项目中我一般会把这个工具类的方法参数再细化一下比如支持多个模板变量列表、支持自定义输出文件名等但整体骨架不变。3.3 Controller 接口与前端下载后端接口就很好写了。接收业务数据转成 Map调用工具类生成文件然后把文件以流的形式返回给前端让浏览器自动下载import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.Map; RestController RequestMapping(/word) public class WordExportController { GetMapping(/export) public void exportWord(javax.servlet.http.HttpServletResponse response) throws Exception { MapString, Object data new HashMap(); data.put(contractNo, HT-2024-001); data.put(bankName, 招商银行); data.put(totalAmount, 12,500.00元); // ... 更多业务数据 String outputPath System.getProperty(java.io.tmpdir) /contract.docx; WordGenerator.generateDocx(templates/contract.docx, data, outputPath); response.setContentType(application/vnd.openxmlformats-officedocument.wordprocessingml.document); response.setHeader(Content-Disposition, attachment; filename java.net.URLEncoder.encode(合同.docx, UTF-8)); // 写文件流 try (java.io.InputStream is new java.io.FileInputStream(outputPath)) { org.springframework.util.StreamUtils.copy(is, response.getOutputStream()); } } }如果你用的是 Spring Boot 3.x 或更高的自带容器把javax.servlet换成jakarta.servlet即可。前端触发这个接口后浏览器会直接下载文件。3.4 数据模型与嵌套循环的组装模板里如果有多层级的数据结构比如“一个订单下面有多个商品商品下面又有多个批次”那么数据模型就需要用嵌套的 List。FreeMarker 对嵌套结构支持得很好模板里可以写两层#list。比如#list orderList as order w:tr w:tc${order.orderNo}/w:tc w:tc #list order.items as product ${product.name} (${product.quantity}) #if product_has_next、/#if /#list /w:tc /w:tr /#list这里有个小技巧product_has_next是 FreeMarker 内置变量用来判断循环是否到了最后一个元素。这样你就能在同一个单元格里输出多个产品并用顿号分隔而不会渲染出多余的标点。我通常在 Service 层都会把查询出来的实体对象转换成一个专门的模板数据类或者叫 VO里面属性名和模板变量名完全对应。这样的好处是模板清晰不会在 XML 里写user.userInfo.name这种一长串导航式取值也方便别人维护模板。4. 常见问题与排查技巧实录4.1 Word 表格双线变单线问题这个问题的根源我在前面分析过这里再展开说一下排查思路。当你发现渲染出来的 Word 表格边框线不统一时直接打开生成的文件把它解压后缀.docx改成.zip用文本编辑器打开word/document.xmlCtrlF 搜索w:tcBorders看看到底是哪个单元格缺了边框定义。经验法则是模板中用于循环的表格行每一个单元格都要显式声明w:tcBorders不要依赖样式继承。像下面这段代码就是每个边框都手动指定为单线w:tcPr w:tcBorders w:top w:valsingle w:sz4 w:space0 w:color000000/ w:left w:valsingle w:sz4 w:space0 w:color000000/ w:bottom w:valsingle w:sz4 w:space0 w:color000000/ w:right w:valsingle w:sz4 w:space0 w:color000000/ /w:tcBorders /w:tcPr如果你的表格本身是双线边框要检查模板里是不是用了表格样式Table Style而在复制行的时候没有把样式带过去。最直接的办法就是做成“无样式表格”所有边框手动设置虽然前期麻烦但后续渲染绝对稳定。4.2 渲染后内容出现 XML 特殊字符错误数据里如果包含、、、引号等字符直接放进 XML 会导致文件结构被破坏。比如用户输入了一个“A B”不加处理的话Word 打开就会报错。解决方案是在渲染之前对所有“带用户输入且不是模板变量”的文本做转义。FreeMarker 在输出时可以用#escape指令或者在 Model 传值的时候提前转义。我实测好用的办法是写一个简单的工具方法覆盖 String 类型的输入值public static String xmlEscape(String value) { if (value null) { return ; } return value .replace(, amp;) .replace(, lt;) .replace(, gt;) .replace(\, quot;) .replace(, apos;); }但是要注意顺序一定要第一个替换否则会把已经转义好的实体再转一次出现amp;lt;这种双重转义的诡异情况。4.3 Spring Boot 版本太高引起的坑顺着热词“springboot版本太高”这个话题说我见过太多人用的还是传统 servlet 项目一升级到 Spring Boot 3.x 就发现各种依赖冲突。你会发现 FreeMarker 模板引擎本身对 servlet 容器没有依赖但如果你用了spring-boot-starter-freemarker它会拉进来一堆视图相关的东西兼容性问题就来了。我建议的规避措施是引入 FreeMarker 时用最原始的org.freemarker:freemarker坐标不要用 starter。用自己封装的Configuration不用 Spring 管理的FreeMarkerConfigurer。包名统一用jakarta.*写 Controller 时使用jakarta.servlet.http.HttpServletResponse。如果模板有中文乱码检查是不是setDefaultEncoding(UTF-8)没设以及模板文件本身的编码是否为 UTF-8。这样做完之后Spring Boot 2.x 和 3.x 的差异对你来说就不是什么大问题了因为你的代码根本没有和 Spring 深度绑定真正做到了“一次编写到处运行”。4.4 模板加载不到报 TemplateNotFoundException这种问题通常不是路径写错而是ClassLoader加载路径不对。用setClassLoaderForTemplateLoading时路径不要以/开头比如写templates而不是/templates。如果你想用绝对路径从磁盘加载模板那就改成CONFIGURATION.setDirectoryForTemplateLoading(new File(/opt/templates/));这种方式适用于模板文件在外部配置中心或者经常被业务人员修改的场景比如你有套模板管理系统Word 模板可以上传到服务器磁盘上然后代码实时加载。项目初期你可以先用 classpath 内的模板后面再慢慢优化成磁盘加载。4.5 Spring Boot 项目热部署后模板不刷新调试模板时最烦人的就是改了.ftl文件需要重启服务才能生效。FreeMarker 默认是有缓存机制的。在开发环境你可以关掉模板缓存CONFIGURATION.setTemplateUpdateDelay(0);生产环境再把缓存打开默认值避免每次请求都解析模板文件性能更好。我还见过有人把模板路径做成动态的数据库里存了多个版本号前端选择不同版本号后端加载不同的模板文件。这个思路也可以做法就是在上面的配置里每次生成前用getTemplate动态传文件名而不是写死一个模板名。5. 方案扩展图片、PDF转换与推荐工具链5.1 如何在 Word 模板中插入动态图片热词里有“vue3 导出word”、“markdown转word工作流coze”这些看得出来很多人都想把复杂内容搞进 Word。图片插入则是另一个刚需。在 FreeMarker 模板 docx 方案里动态插入图片比较麻烦因为 docx 的图片是独立的二进制资源文件默认放在word/media/目录下你无法在 document.xml 里直接放图片内容。我的常用方案有两种如果图片是固定不变的比如公司 logo直接在模板里放好不用动它。如果图片是动态生成的比如二维码、签名照片就先把图片文件放到服务器的临时目录然后在模板的 XML 里用w:drawing标签引用图片路径最后打包 docx 时把图片文件一并塞进压缩包。第二种方式实现起来比较繁琐需要在[Content_Types].xml和word/_rels/document.xml.rels里注册图片关系。我没法在这里把所有代码贴完但核心还是解压、添加文件、重新打包的路子。如果只是简单场景我更推荐把二维码先生成好再用 POI 或者 docx4j 等库去替换图片而不是在 FreeMarker 模板里硬塞。5.2 Java 端 Word 转 PDF 的联动扩展很多人生成 Word 之后下一步就想要一个 PDF 版本用于在线预览。热词里也多次出现“java word转pdf”。我这边测试过比较稳定的方案是用LibreOffice的命令行工具很多服务器上都有装。生成完 docx 之后直接调用如下的命令soffice --headless --convert-to pdf --outdir /output/dir /temp/contract.docxJava 里用ProcessBuilder来调外部命令就行网上也有很多封装好的工具库比如jodconverter基于 OpenOffice/LibreOffice可以很好地集成到 Spring Boot 服务中。注意服务器的内存和并发量单线程转码没问题并发一高要注意 LibreOffice 进程不能同时启动多个否则会崩溃需要使用一个队列来串行化转码任务。5.3 推荐工具链与整合建议我最后给出一个我实际使用过的完整工具链组合你可以直接照抄这套组合全部免费都是开源组件用途推荐工具说明模板编辑Microsoft Office / WPS另存为 Word 2003 XML模板渲染FreeMarker 2.3.32不使用 spring-boot-starter-freemarkerWord 生成自封装工具类基于 Zip 替换 document.xmlWord 转 PDFLibreOffice jodconverter需要部署环境支持前端预览PDF.js / Office Online先转 PDF再给前端如果你做的是微服务架构可以把“模板生成 转 PDF”做成一个独立的文档服务用消息队列接收请求异步生成文件到对象存储再回调通知业务系统。这样核心业务服务不会因为文档生成的 CPU 密集型任务而被拖垮。6. 写在最后我的实操心得做模板化 Word 导出这个功能做了这么久最大的体会是不要和 Word 的底层层级结构对着干去顺着它的思路做事情。也就是说你只需要理解 Word 的 XML 本质上是“段落 表格 文本块”的层级关系然后用模板让结构活起来就行了。不要试图用代码去“画” Word而是让 Word 自己决定怎么画代码只负责填数据。另外还有一点模板里的每个变量名一定要提前约定好命名规范比如统一用驼峰命名这样模板和数据实体一一对应不容易乱。我会尽量把变量名控制在 3 个单词以内避免模板文件里出现一长串表达式否则出了问题排查起来非常痛苦。如果你正准备在项目里引入这套方案我建议先拿一个简单的报销单练手从模板制作到工具类封装跑通一遍再逐步扩展到复杂合同、表格嵌套、图片插入等场景。等你把核心工具类沉淀下来后面所有需要导出 Word 的业务都只是加一个模板、加一个方法的事效率提升不是一点半点。本文还有配套的精品资源点击获取