☰
SpringBoot用Freemarker生成Word:模板化导出合同的实战方案
2026/10/16 16:52:15 网站建设 项目流程

简介:整套资源基于 Spring Boot 与 FreeMarker,面向需要为报告、合同等场景动态生成 Word 文档的 Java 开发者,演示了从引入 Apache POI 依赖、编写 FreeMarker 模板,到通过服务类渲染 HTML 并转换为带图片 Word 文件的全过程。压缩包共 30 个文件,以 java 源码、ftl 模板、xml 配置为主,另含 yml、properties 等工程配置文件,整体仅 77KB,结构紧凑,便于直接导入 IDE 运行调试。项目内置可直接运行的 WordUtil 入口类,结合 template.ftl 模板可快速验证效果,适合有一定 Spring Boot 基础的开发者作为自动化文档生成的起点。已有 1638 人学习使用;通过该示例,可掌握模板渲染与 POI 转换的关键写法,理解 CID 引用图片、内联图像插入等实现细节,并在此基础上扩展更复杂的文档功能。

1. SpringBoot 里用 Freemarker 生成 Word:先把格式问题一次治干净

做订单消息推送的时候,我遇到过最反复的事就是导出 Word 合同。明明 POI 写出来的文档能打开,页面边距、字体、行距总是差那么一点,模板一换,代码就得跟着动。后来换成 SpringBoot + Freemarker 生成 Word 的路子,问题一下子简单了:模板用 Word 本身去编辑,程序只负责往占位符里塞数据。这个方案解决的是「格式可控、批量可跑、改版不碰代码」的文档生成需求,适合订单、合同、报告、通知书这类需要频繁出正式文档的业务系统。

它的核心思路不复杂:Word 支持 XML 格式,Freemarker 擅长按模板输出文本流,两者结合就可以把一份.docx变成一份带占位符的模板文件,后端只要把数据填进去,输出的就是一份可以直接打开的 Word。对新手来说,这套路比写 POI 逐段落构建文档上手快得多;对熟手而言,它真正解决了维护成本问题。这块技术成型多年,网上资料很杂,但真正讲透模板边界和参数陷阱的不多,这篇就按我落地的路径从头捋一遍。

2. 先拆开一份 docx:模板引擎生成 Word 的原理与选型

2.1 Word 文档其实是一个 zip 包,装着一堆 XML

很多人不知道,.docx文件用压缩工具打开后,是一组 XML 文件和资源目录的集合。word/document.xml是正文,word/styles.xml是样式,word/media/放图片。也就是说,Word 文档的本质不是二进制铁板一块,而是结构化文本。Freemarker 生成 Word 的原理,就是拿这个 XML 当模板,把document.xml里的真实数据替换成 Freemarker 标签,再让 SpringBoot 的模板引擎把数据渲染进去。

反过来推导就清楚了:既然 document.xml 是纯文本,那么 Freemarker 的${name}这类占位符就可以直接写在 XML 里。渲染的时候,Freemarker 只把它当字符串模板处理,对 Word 的命名空间一无所知,但它输出的结果仍然是合法 XML。这份 XML 再打包回 docx 结构,Word 就能正常打开。整个过程不需要 POI 去构建段落和表格,格式完全由模板决定。

2.2 三条生成路线的取舍:为什么我选 Freemarker

做 Java 后端生成 Word,业界常见路线有三条。第一条是 Apache POI 直接操作 XWPFDocument,写代码生成段落和表格,优点是灵活,缺点是你得用代码描述每一个字体、间距、合并单元格,工作量爆炸。第二条是 JasperReports 配 JRXML 模板,它擅长报表,但对 Word 的复杂排版支持一般,模板编辑器也不够友好。第三条就是 Freemarker 填充 XML 模板,模板用 Word 本身来画,改格式像改文档一样简单。

三者对比下来,我一般这样选:如果每周都要换模板样式,而且模板由运营或行政同事维护,那必须走 Freemarker 这条线。他们不需要懂代码,只需要按约定保留${...}占位符,会 Word 就会改模板。如果文档结构固定且没有外部协作,POI 加上公共封装也能撑住。Freemarker 路线有个前提:数据模型要设计得规整,字段缺失时模板要能容忍,否则渲染时会直接报错。

2.3 模板引擎渲染 XML 的三个关键点

Freemarker 渲染 XML 有三个容易忽视的点。第一,XML 是严格闭合的,模板里的标签一旦嵌套错误,渲染结果就变成乱码或无法打开。第二,Freemarker 的控制指令如<#if>、<#list>本身也是标签,它们必须和 XML 标签共存,不能把一个<#if>从<w:p>中间切开。第三,输出文件的编码必须固定成 UTF-8,否则中文内容在 Word 里全是乱码。

这三个关键点决定了制作模板的基本纪律:占位符只放在<w:t>文本节点里,控制标签嵌在段落外层,生成文件时显式指定编码。后续第四章会专门演示这个边界怎么把握,这里先记住结论。

3. SpringBoot 工程里接上 FreeMarker:依赖、目录与下载接口

3.1 最小依赖组合与模板目录约定

在 SpringBoot 项目中接入 Freemarker 并不需要额外引入模板引擎的独立坐标,直接用spring-boot-starter-freemarker就行。它会自动注册 Freemarker 的Template解析能力,并扫描classpath:/templates/目录下的.ftl文件。这个 starter 同时把视图解析器也配好了,但我们这里不走视图渲染,而是直接从Template对象取字符串输出,所以不需要关心 MVC 那套。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-freemarker</artifactId> </dependency>

依赖加上后,在src/main/resources/templates/contract/下建一个order_contract.ftl,用来放订单合同模板。这样做的原因是把不同业务的模板按目录隔离,模板多了以后不会互相干扰。application.yml里一般不需要额外配置,Freemarker 默认按 UTF-8 读取模板,但如果你的模板文件里有中文且出现了乱码,就要显式声明:

spring: freemarker: charset: UTF-8 template-loader-path: classpath:/templates/ suffix: .ftl

模板加载路径要写清楚,这里的 suffix 会覆盖getTemplate时的方法名拼接逻辑。我习惯在配置里固定这两项,避免不同环境下的默认行为有差异。

3.2 一个能用的下载接口:从 Template 到 OutputStream

后端接口的职责很简单:查出业务数据,组装成一个 Map 或对象模型,交给 Freemarker 渲染,再把渲染出的字节流写回 HTTP 响应。注意这里写回的是.doc文件,不是.docx,因为这个路线使用的是 Word 2003 XML 格式,下文会详细说。整个代码可以压缩在几十行内:

@GetMapping("/order/contract") public void exportOrderContract(HttpServletResponse response, @RequestParam String orderId) throws Exception { // 1. 业务数据查询,这里省略 Service 层 OrderModel order = orderService.getOrderDetail(orderId); // 2. 组装模板数据模型 Map<String, Object> data = new HashMap<>(); data.put("orderNo", order.getOrderNo()); data.put("customerName", order.getCustomerName()); data.put("totalAmount", order.getTotalAmount()); data.put("items", order.getItems()); // 3. 获取模板并渲染 Configuration cfg = new Configuration(Configuration.VERSION_2_3_32); cfg.setDefaultEncoding("UTF-8"); cfg.setClassForTemplateLoading(this.getClass(), "/templates/contract/"); Template template = cfg.getTemplate("order_contract.ftl"); response.setContentType("application/msword"); response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode(orderId + "-contract.doc", "UTF-8")); try (Writer out = new OutputStreamWriter(response.getOutputStream(), "UTF-8")) { template.process(data, out); out.flush(); } }

这里最值得说明的是编码问题。Configuration和Writer都显式指定了 UTF-8,缺一个都可能在 Windows 上打开时出现中文乱码。setClassForTemplateLoading指向的是类路径下的模板目录,注意路径开头有没有/会影响实际加载位置,我用的是带/的写法。

3.3 数据模型设计的三个习惯

Freemarker 的数据模型本质上是一个 Map,键名直接对应模板里的${}变量名。我一般坚持三个习惯:一是模型里只放模板用到的字段,避免把整个数据库实体塞进去;二是金额、日期在服务层先格式化好,模板里不再做复杂转换;三是集合字段名用复数,与模板里的<#list items as item>保持直观对应。这些习惯在模板维护阶段能省大量沟通成本。

这里有个容易踩的细节:如果模板里访问了一个不存在的属性,Freemarker 默认抛异常,整个下载会失败。所以要么在模板里对每个占位符加!''默认值,要么在数据组装时保证所有键都存在。我通常两件事都做,下面第四章会演示具体写法。

4. 从一份 docx 到一份 ftl:模板制作与参数落地全流程

4.1 三步做出第一份可用模板

制作 Freemarker 的 Word 模板,核心不在代码,而在模板文件本身的制作方式。完整的流程是:先用 Word 画好一份带真实数据的示例文档,另存为 Word 2003 XML 格式(.xml),然后用文本编辑器打开,找到真实数据所在的位置,替换成 Freemarker 标签,最后把文件扩展名改成 .ftl 放回模板目录。

这里有个关键选择:为什么选 Word 2003 XML 而不是直接改 docx 里的 document.xml?因为 docx 内部的 document.xml 标签更碎,一个中文词可能被拆成多个<w:t>片段,占位符替换起来非常痛苦。Word 2003 XML 的文本节点相对规整,虽然也有样式标签干扰,但整体上更适合手工改造。下面是一段另存后处理完的模板片段:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?mso-application progid="Word.Document"?> <w:wordDocument xmlns:w="urn:schemas-microsoft-com:office:word"> <w:body> <wx:sect> <w:p> <w:r> <w:t>订单编号:${orderNo}</w:t> </w:r> </w:p> <w:p> <w:r> <w:t>客户名称:${customerName}</w:t> </w:r> </w:p> <w:p> <w:r> <w:t>应付金额:${totalAmount}</w:t> </w:r> </w:p> </wx:sect> </w:body> </w:wordDocument>

文件头部的<?mso-application progid="Word.Document"?>不能删,它告诉 Windows 这个 XML 应该用 Word 打开。但注意:这段 XML 如果直接用 Word 打开,会显示源码;当它被 Freemarker 渲染后,以.doc下载时,Word 才会识别为文档。所以下载文件名后缀写成.doc而不是.xml,这一点从一开始就要定好。

4.2 表格多行数据怎么循环:list 指令的正确位置

合同里出现最多的就是商品明细表。表格循环的难点不在于写<#list>,而在于 Freemarker 指令在 XML 结构中的位置。表格的每一行是由<w:tr>标签包住的,我们要循环的是整个<w:tr>,而不是只循环里面的<w:t>文本。做法是:在 Word 里先画好表头和一行示例数据,另存为 XML 后,把示例数据那一整行<w:tr>用<#list>包起来。

<w:tbl> <w:tr> <w:tc><w:p><w:r><w:t>商品名称</w:t></w:r></w:p></w:tc> <w:tc><w:p><w:r><w:t>规格</w:t></w:r></w:p></w:tc> <w:tc><w:p><w:r><w:t>数量</w:t></w:r></w:p></w:tc> </w:tr> <#list items as item> <w:tr> <w:tc><w:p><w:r><w:t>${item.name}</w:t></w:r></w:p></w:tc> <w:tc><w:p><w:r><w:t>${item.spec}</w:t></w:r></w:p></w:tc> <w:tc><w:p><w:r><w:t>${item.quantity}</w:t></w:r></w:p></w:tc> </w:tr> </#list> </w:tbl>

这里最容易被忽略的是<#list>和</#list>之间不能有任何多余的 XML 标签夹在<w:tr>之外却又在表格里,否则 Word 会报“表格结构损坏”。我通常是这样检查的:把模板文件当普通 XML 看一遍,确认<#list>直接包住<w:tr>后,内部保持完整标签闭合。另外,<#list>指令不要写在注释里,Freemarker 我不太建议用注释包代码,实际排查时很容易被绕晕。

4.3 空值保护与默认值:让模板对数据误差更宽容

数据模型里字段不全,是生产环境最常见的炸点。字段不存在时 Freemarker 抛InvalidReferenceException,接口直接 500。做过的项目里,模板的每个占位符都加了!默认值,这样可以保证即使某条数据字段缺失,也能生成一份内容不完整的文档,而不是让整个导出功能挂掉。

<w:p> <w:r> <w:t>客户电话:${customerPhone!"无"}</w:t> </w:r> </w:p> <#if customerRemark?exists> <w:p> <w:r> <w:t>备注:${customerRemark}</w:t> </w:r> </w:p> </#if>

${customerPhone!"无"}表示字段为空时输出“无”字;<#if customerRemark?exists>表示只有当字段存在时才输出整段。这两个写法覆盖了大部分空值场景。需要注意?exists和??的区别,前者在老版本 Freemarker 里更常用,后者是推荐的新写法,我这里用??更规范:<#if customerRemark??>。如果你用的是 2.3.2x 以上版本,?exists依然兼容,但新代码尽量写??。

4.4 动态表格的样式细节:边框、宽度与单元格合并

表格循环本身不复杂,但替换成<#list>后常常出现整行样式丢失、边框变细或宽度变化。原因是 Word 转 XML 后,每一行的<w:trPr>里定义了行高、单元格宽度等属性,而模板里的示例行只有一行,循环出去的每一行都复制了这行的属性,数据一多,宽度可能不一致。解决方式是用 Word 的“重复标题行”功能,设置表头行属性,同时对明细行的<w:tcPr>里的宽度设置成固定值,不要用自动。

<w:trPr> <w:trHeight w:val="400"/> <w:tblHeader/> </w:trPr>

<w:tblHeader/>表示这一行是表头,分页时自动重复;<w:trHeight>把行高固定,避免内容撑破版式。另一个常见问题是单元格纵向合并显示不正常,这是因为 XML 里合并标记<w:vMerge w:val="restart"/>在循环后错位了。我的建议是模板里先不要做跨行合并,等循环稳定了再手工处理合并的<w:tcPr>,否则每次数据行数变化,合并状态都要重新验证。

5. 生成 Word 的避坑清单:模板爆炸、表格变形与乱码排查

5.1 下载下来的文件提示“文件格式与扩展名不匹配”

现象:浏览器下载后,文件名是.doc,双击打开却弹提示说文件格式与扩展名不匹配,按“是”才能打开,或不弹提示但打开全是 XML 源码。原因:响应里缺了 MIME 声明,或者模板文件头部的<?mso-application progid="Word.Document"?>被 Freemarker 注释掉了。还有一个容易被忽略的场景:你用.ftl输出的是纯 XML 文本,但响应头写成了application/octet-stream,Windows 就不认这个格式。解决:响应头固定用application/msword;模板文件头部这行声明不要动;确认下载文件名以.doc结尾,而不是.xml。这个坑我踩过不下三次,每次都是因为改模板时不小心删了<?mso-application?>。

5.2 表格循环后出现“表格损坏”或首行变成乱码

现象:数据行一多,Word 提示“表格损坏,是否修复”,修复后表格结构错乱,或者循环出来的第一行样式和其他行不一样。原因:<#list>包裹的<w:tr>外层残留了 Word 自动生成的w:trPr以外的标签,或者<w:tbl>末尾有空的<w:tr>残留在</#list>之外。模板合并单元格时,<w:vMerge>标记在循环里会被复制到每一行,导致多行执行了“继续合并”的逻辑。解决:将模板 XML 放到格式化工具里检查,确保<#list>与</#list>之间的子节点全部闭合;同时把示例行的w:vMerge属性删掉,等渲染后再看输出 XML 是否正常。这套检查动作我固化成了脚本,每次改完模板先跑一遍格式校验再上线。

5.3 中文内容导出变成问号或乱码

现象:页面上看着正常,Word 打开后中文全变成???或乱码。原因:数据从一个系统传过来时已经是乱码,这个好排查;但更隐蔽的是模板文件本身被保存成了 GBK 编码,Freemarker 按 UTF-8 读取,所有中文全废。另外,响应流没有指定编码时,Tomcat 默认按 ISO-8859-1 输出,也会乱。解决:模板文件统一用 UTF-8 保存,Configuration里显式setDefaultEncoding("UTF-8"),输出Writer也指定 UTF-8;如果数据源头是数据库,确认连接串里characterEncoding=utf-8。这三处全对了,基本不存在乱码空间。实在不放心,生成完先用文本编辑器打开.doc看下中文源码,能看懂就不是乱码。

5.4 动态图片死活不显示,只显示红叉或空白

现象:合同里需要放客户签名或商品图片,模板里能用<w:drawing>插入静态图片,但数据一换,图片区域空白或红叉。原因:Freemarker 渲染的是 XML 文本,不能直接把图片字节流嵌进 XML 里。图片在 docx 里是作为独立资源文件存在word/media/目录中的,document.xml 只是引用关系。解决:常见的可靠做法有两种:一是把图片转成 Base64 字符串,通过<w:binData>内嵌到模板里,适合小体积签名图;二是生成完文本后再用 POI 打开.doc插入图片,但这破坏了纯模板方案。我一般推荐第一种,模板里预留 Base64 占位符,代码里把图片文件读成 Base64 塞进去,格式稳定且不需要额外依赖。

5.5 模板改了不生效:浏览器缓存与模板热加载

现象:本地改了.ftl,重启后接口下载的却还是旧内容,甚至改的字段完全没变化。原因:Freemarker 默认缓存模板,开发环境没开热加载,模板加载一次后就驻留内存;同时浏览器对相同 URL 的下载响应有缓存,第二次下载直接拿的是缓存内容。解决:开发环境在application.yml里设置spring.freemarker.cache: false,模板文件即时生效;前端下载链接加时间戳参数,比如contract?orderId=xx&ts=1234567,绕开浏览器缓存。上生产时再把缓存打开,用版本号管理模板目录,避免模板被意外覆盖。

6. 把模板能力再放大:批量生成、宏封装与验证手段

做到这一步,单模板单接口已经能跑了。真正让 Freemarker 方案发挥价值的是批量能力和复用能力。批量生成的思路很简单:循环调用模板渲染逻辑,每条数据输出成一个文件,再把多个文件打包成 zip 响应给前端。常见做法是先渲染所有.doc到临时目录,然后用ZipOutputStream打包,最后删除临时文件。这样实现一个“导出全部订单合同”的功能只要加一层循环。

模板复用方面,Freemarker 的宏<#macro>可以封装“标准段落”和“表格行”这些高频片段。比如多个模板都要输出“客户信息块”,就抽成一个宏,传入客户对象,内部处理空值和格式。我一般把宏单独放在一个common_macro.ftl里,其他模板用<#import>引入。这个习惯在模板数量超过 10 个后收益非常明显,改一次客户信息格式,所有合同模板同步更新。

验证手段上,我每次交付前会做三件事。第一,用文本编辑器打开渲染后的.doc,确认没有残存的${或<#标签,这代表数据全部替换成功。第二,用 Word 打开文件,检查表格边框、分页和页眉页脚;如果 Word 提示修复,导出 XML 再用工具重新检查一遍。第三,写一个自动化冒烟脚本,准备两三条不同数据量的测试数据,覆盖空值、长文本、超多行表格,跑一遍下载接口断言文件非空且包含关键字。这个脚本固定进 CI,之后模板改动就不会再慌。

现在我的习惯是:模板制作完成后,文件名里带上业务名和版本,比如order_contract_v2.ftl,旧的模板不删,只换加载路径。这样即使新模板有问题,也能一键回退。这套方案在几个项目里都稳定跑了大半年,维护模板的人从开发换到了运营,也没再出过格式性的问题。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询