Spring Boot + FreeMarker 模板化生成 Word 文档实战
2026/9/2 9:49:18 网站建设 项目流程

简介:面向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 格式本质上是 OOXML(Office 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 打开报错。

我推荐的标准做法是:

  1. 用 Microsoft Word 编辑一个完整的.docx文件,把表格、字体、页眉页脚、样式都调好。
  2. 在需要填充数据的位置,先用占位文本写好,比如招商银行10000.00
  3. 把这个.docx文件另存为Word 2003 XML 文档(*.xml),这一步很关键。
  4. 用文本编辑器打开这个 XML 文件,把占位内容替换成 FreeMarker 语法,比如${bankName}${totalAmount}
  5. 把文件名后缀从.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:tc><w:p><w:r><w:t>${item.index}</w:t></w:r></w:p></w:tc> <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.quantity}</w:t></w:r></w:p></w:tc> <w:tc><w:p><w:r><w:t>${item.price}</w:t></w:r></w:p></w:tc> <w:tc><w:p><w:r><w:t>${item.subtotal}</w:t></w:r></w:p></w:tc> </w:tr> </#list>

注意这里<w:tr>是 Word 表格的行标签,<w:tc>是单元格标签,<w:p>是段落,<w:r><w: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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.freemarker</groupId> <artifactId>freemarker</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, Map<String, Object> dataModel) throws Exception { Template template = CONFIGURATION.getTemplate(templateName); StringWriter writer = new StringWriter(); template.process(dataModel, writer); return writer.toString(); } }

3.2 核心工具类:如何把渲染后的 XML 包装成 docx

FreeMarker 渲染完后,我们得到的是一个纯 XML 字符串。如果是 WordML 格式,这个 XML 本身就是完整的 Word 文档,你可以直接把它保存为.doc文件(注意是老的 Word 格式),但为了兼容性更好,我通常会把这段 XML 保存为.xml,再由用户自行打开或另存。

如果项目要求必须输出.docx文件,那我们就要换一种模板格式。前面我提过,docx 本质上是 zip 包,里面有很多 XML 文件,核心内容在word/document.xml中。所以我们的工具类要做的事情就是:

  1. 准备一个标准的.docx文件作为容器(里面不含任何动态数据,只是骨架)。
  2. 把 docx 解压到内存,替换word/document.xml的内容为 FreeMarker 渲染后的结果。
  3. 重新打包成 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, Map<String, 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 { Map<String, 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.xml,Ctrl+F 搜索w:tcBorders,看看到底是哪个单元格缺了边框定义。

经验法则是:模板中用于循环的表格行,每一个单元格都要显式声明w:tcBorders,不要依赖样式继承。像下面这段代码就是每个边框都手动指定为单线:

<w:tcPr> <w:tcBorders> <w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/> <w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/> <w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/> <w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/> </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,它会拉进来一堆视图相关的东西,兼容性问题就来了。

我建议的规避措施是:

  1. 引入 FreeMarker 时用最原始的org.freemarker:freemarker坐标,不要用 starter。
  2. 用自己封装的Configuration,不用 Spring 管理的FreeMarkerConfigurer
  3. 包名统一用jakarta.*,写 Controller 时使用jakarta.servlet.http.HttpServletResponse
  4. 如果模板有中文乱码,检查是不是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].xmlword/_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.docx

Java 里用ProcessBuilder来调外部命令就行,网上也有很多封装好的工具库,比如jodconverter(基于 OpenOffice/LibreOffice),可以很好地集成到 Spring Boot 服务中。注意服务器的内存和并发量,单线程转码没问题,并发一高要注意 LibreOffice 进程不能同时启动多个,否则会崩溃,需要使用一个队列来串行化转码任务。

5.3 推荐工具链与整合建议

我最后给出一个我实际使用过的完整工具链组合,你可以直接照抄这套组合,全部免费,都是开源组件:

用途推荐工具说明
模板编辑Microsoft Office / WPS另存为 Word 2003 XML
模板渲染FreeMarker 2.3.32+不使用 spring-boot-starter-freemarker
Word 生成自封装工具类基于 Zip 替换 document.xml
Word 转 PDFLibreOffice + jodconverter需要部署环境支持
前端预览PDF.js / Office Online先转 PDF,再给前端

如果你做的是微服务架构,可以把“模板生成 + 转 PDF”做成一个独立的文档服务,用消息队列接收请求,异步生成文件到对象存储,再回调通知业务系统。这样核心业务服务不会因为文档生成的 CPU 密集型任务而被拖垮。

6. 写在最后:我的实操心得

做模板化 Word 导出这个功能做了这么久,最大的体会是:不要和 Word 的底层层级结构对着干,去顺着它的思路做事情。也就是说,你只需要理解 Word 的 XML 本质上是“段落 + 表格 + 文本块”的层级关系,然后用模板让结构活起来就行了。不要试图用代码去“画” Word,而是让 Word 自己决定怎么画,代码只负责填数据。

另外还有一点,模板里的每个变量名,一定要提前约定好命名规范,比如统一用驼峰命名,这样模板和数据实体一一对应,不容易乱。我会尽量把变量名控制在 3 个单词以内,避免模板文件里出现一长串表达式,否则出了问题排查起来非常痛苦。

如果你正准备在项目里引入这套方案,我建议先拿一个简单的报销单练手,从模板制作到工具类封装跑通一遍,再逐步扩展到复杂合同、表格嵌套、图片插入等场景。等你把核心工具类沉淀下来,后面所有需要导出 Word 的业务,都只是加一个模板、加一个方法的事,效率提升不是一点半点。

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

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

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

立即咨询