ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot + FreeMarker 生成 Word 文档的方案、模板与避坑指南

Spring Boot + FreeMarker 生成 Word 文档的方案、模板与避坑指南

简介:面向 Java 开发者的 Spring Boot + FreeMarker 动态生成 Word 文档实战源码包,解决在 Web 项目中按模板产出报告、合同等场景的痛点。包内以 30 个文件、约 77KB 的体积完整给出工程骨架,涵盖 java 源文件(WordService、HtmlToWordConverter 等)、ftl 模板、pom.xml 依赖及 yml/properties 配置,还包含 mvnw 等构建脚本,便于直接导入 IDE 运行。核心思路是先用 FreeMarker 渲染 HTML 结构,再通过 Apache POI 转换为 XWPFDocument,并针对图片采用 CID 内联方式嵌入 Word。目前已有 1638 人学习下载,适合熟悉 Spring Boot 但想快速掌握动态文档导出的初中级开发者参考,通过调整 ftl 模板即可扩展生成更多版式。

1. Spring Boot + FreeMarker 生成 Word 这个方案,先看清它能干什么

Spring Boot + FreeMarker 生成 Word,是我在做设备维保合同导出时反复打磨过的一条路。核心逻辑并不玄学:先用 Word 本身做好一份带占位符的模板文件,再把模板交给 FreeMarker 渲染,后端只需要把数据拼成一个 Map,最终输出一份可下载的 .doc。相比在代码里用 POI 一行行建段落、建表格、设样式,这套方案把“格式”交给业务人员维护,把“数据”留给后端组装,改一个字号、调一个行距都不需要动代码重新发版。它解决的是合同、报销单、设备台账、项目验收报告这类结构化、格式相对固定的文档导出问题。适合后端开发人员快速落地,也适合那些被“改模板就要等开发”折磨了很久的业务团队。但它的边界也很明显:不适合从零生成复杂排版、动态插图、图文混排要求极高的文档。先把这条边界想清楚,后面每一步才不容易偏。

2. 把 Word 改造成模板:XML 结构与 FreeMarker 语法落点

2.1 为什么不用 POI 硬写:模板与数据分离才是价值

第一次接触“用 Java 生成 Word”的人,大多会先搜到 POI。POI 能精确控制单元格、字体、图片,但它把文档结构和样式全部锁死在 Java 代码里。我见过最典型的一种代码,新建一个表格要写二十多行:先创建 XWPFTable,再循环创建 XWPFTableRow,再逐个 setText,最后还要设置边框和居中。业务人员提出“表格加一列”的时候,开发改代码、重新打包、再发版,来回至少半天。这种成本在小项目里尚可忍受,在模板多、字段杂的运维系统里就变成了纯消耗。

FreeMarker 的思路完全不同。它本身是文本模板引擎,不关心你生成的是 HTML、SQL 还是 Word。我们要做的,是让 Word 模板变成一段可被 FreeMarker 处理的文本,把需要动态变化的位置写${变量},把需要循环输出的位置写<#list>。这样模板文件可以由业务人员直接在 Word 里编辑,只要不破坏占位符结构,后端的渲染逻辑一行都不用改。我在实际项目里的体感是:模板字段越多的场景,这个方案的回报越大;反过来,如果只是导出一张三五行的小表格,POI 反而更直接。

需要说明的是,这里选的是 Word 2003 XML 格式。它不是 .docx,而是一种把 Word 文档保存为 XML 文本的格式,FreeMarker 能直接当纯文本处理。.docx 本质是一个 zip 压缩包,FreeMarker 没法直接读,得先解压出内部的 document.xml,这个我会在第 5 章等 docx 模板方案里展开。对于大多数内部系统、合同导出场景,Word 2003 XML 格式已经够用,而且实现成本最低。

2.2 制作模板:另存为 XML,替换占位符

操作步骤其实很机械,核心就在“另存为”这一步。我一般的做法是:

先用 Word 新建一份 .doc 文件,把样例数据写进去,比如合同编号、甲方乙方、服务明细表格,把样式调到最终想要的样子。然后把这份 .doc 另存为“Word 2003 XML 文档”,得到一个 .xml 文件。最后用文本编辑器打开这个 .xml,搜索样例文字,把样例文字替换成 FreeMarker 语法。比如把“HT-2024-0001”替换成${contractNo}。保存后把扩展名改成 .ftl,放到项目的 resources/templates 目录下。

简化后的模板内容大致长这样:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"> <w:body> <w:p> <w:r> <w:t>合同编号:${contractNo}</w:t> </w:r> </w:p> <#list items as item> <w:p> <w:r> <w:t>${item.seq}. ${item.name}:${item.amount} 元</w:t> </w:r> </w:p> </#list> </w:body> </w:document>

这段模板暴露了两个关键信息。第一,Word 生成的 XML 是有严格层级结构的,<w:t>里才是我们看到的文本,变量要替换到<w:t>里面而不是外面;第二,FreeMarker 的<#list>和</#list>必须把一整块 XML 节点包住,不能只包住<w:t>里的那点文字。很多第一次上手的人会把模板改得面目全非,比如把${contractNo}放在<w:t>外面,生成出来的文件 Word 打不开。后面第 4 章会专门说这个坑。

2.3 数据模型怎么设计:Map 嵌套与 List 循环

模板好做了,数据模型相对简单。我通常不去为每个模板建 DTO,而是直接用Map<String, Object>组装,因为 FreeMarker 访问 Map 的 key 非常直接,模板里写${contractNo},Map 里放contractNo就能命里。对于明细列表,用一个List<Map<String, Object>>即可。

Map<String, Object> data = new HashMap<>(); data.put("contractNo", "HT-2024-0001"); data.put("projectName", "某某园区配电房年度巡检"); data.put("signDate", "2024-06-18"); List<Map<String, Object>> items = new ArrayList<>(); String[] itemNames = {"巡检服务", "绝缘检测", "负荷测试"}; for (int i = 0; i < itemNames.length; i++) { Map<String, Object> item = new HashMap<>(); item.put("seq", i + 1); item.put("name", itemNames[i]); item.put("amount", String.format("%.2f", 12000 * (i + 1))); items.add(item); } data.put("items", items);

这里有两个细节值得注意。金额我统一用String.format("%.2f", ...)转成字符串,而不是把 BigDecimal 或 double 直接丢进模板。原因很简单,FreeMarker 输出 BigDecimal 时可能带科学计数法,也可能因为精度问题多出一串小数,预先格式化最省心。日期也是同理,先在 Java 侧转成2024-06-18这种固定格式,模板里就不用再写日期格式化的逻辑。Map 的 key 命名我用驼峰,比如contractNo,模板里保持完全一致。如果不小心把模板写成contract_no,FreeMarker 会直接报变量找不到,这个在调试阶段很常见。

3. Spring Boot 里把模板变成 Word 文件:依赖、工具类与下载接口

3.1 pom 依赖:两个 starter 就把能力补齐

在 Spring Boot 项目里集成的成本很低。主项目引入spring-boot-starter-freemarker,加上已有的spring-boot-starter-web,就够用了。版本号不需要显式写,交给 Spring Boot 的依赖管理统一兜底。我已经在 Spring Boot 2.x 和 3.x 上都跑过这个方案,FreeMarker 的 API 在这几个版本里差别不大。

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

这里有一个容易被忽略的点:spring-boot-starter-freemarker提供的是 Spring Boot 整合好的 FreeMarker 自动配置,默认会从 classpath 下的templates目录里找模板。但因为我后面要自定义Configuration对象,模板加载路径、异常处理策略都由自己控,所以我实际上没有依赖它的自动配置,而是用它的依赖 jar。这不算重复,只是把控制权拿回来。如果模板少、不需要特殊处理,直接用 Spring 注入FreeMarkerConfigurer也行,两条路都能跑通。

3.2 工具类:模板加载与渲染

我习惯把 Word 导出封装成一个工具类,对外只暴露一个render方法:传入模板名、数据 Map、输出流。内部负责初始化 FreeMarker 配置、加载模板、执行渲染。这样 Controller 层就只管组装数据和设置响应头,职责干净。

@Component public class WordExportUtil { private Configuration freemarkerConfig; @PostConstruct public void init() throws IOException { freemarkerConfig = new Configuration(Configuration.VERSION_2_3_32); freemarkerConfig.setDefaultEncoding("UTF-8"); freemarkerConfig.setClassLoaderForTemplateLoading( getClass().getClassLoader(), "templates/word"); freemarkerConfig.setTemplateExceptionHandler( TemplateExceptionHandler.RETHROW_HANDLER); } public void render(String templateName, Map<String, Object> data, OutputStream out) throws Exception { freemarkerConfig.getTemplate(templateName) .process(data, new OutputStreamWriter(out, StandardCharsets.UTF_8)); out.flush(); } }

这里面的配置项每一个都有实际意义。Configuration.VERSION_2_3_32是指定 FreeMarker 的语法兼容版本,避免不同版本之间行为差异导致模板表现不一致;setDefaultEncoding("UTF-8")保证模板和输出都用 UTF-8,中文乱码问题九成出在这个配置没写对;setClassLoaderForTemplateLoading从 classpath 读模板,打包成 jar 后模板一样能加载;RETHROW_HANDLER让模板错误直接抛出来,调试阶段千万不要换成忽略异常的处理器,否则模板写错只会得到空文件,排查起来非常痛苦。

关于模板加载路径,我用了"templates/word"作为子目录,专门放 Word 模板,和 FreeMarker 的 html 模板区分开。文件扩展名没有强制要求,但我统一用.ftl,IDE 识别后能按 FreeMarker 语法高亮,比.xml后缀直观得多。实际项目中,这个目录里可能会累积几十个模板文件,按模块分子目录更稳妥,比如templates/word/contract/、templates/word/report/。

3.3 下载接口:响应头与中文文件名

工具类准备好,接口就快了。我一般用 POST 接收请求,因为导出通常伴随着查询条件、多选 ID 之类的参数,JSON 体比 URL 参数承载能力强。生成过程直接写到HttpServletResponse的输出流里,不需要先把文件落到磁盘再删,减少无谓的 IO。

@RestController @RequestMapping("/api/word") public class WordExportController { private final WordExportUtil wordExportUtil; public WordExportController(WordExportUtil wordExportUtil) { this.wordExportUtil = wordExportUtil; } @PostMapping("/contract") public void exportContract(HttpServletResponse response) throws Exception { Map<String, Object> data = buildContractData(); String fileName = URLEncoder.encode("设备维保合同.doc", StandardCharsets.UTF_8) .replace("+", "%20"); response.setContentType("application/msword;charset=UTF-8"); response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + fileName); wordExportUtil.render("contract.ftl", data, response.getOutputStream()); } }

Content-Disposition这里有一个细节值得展开。filename*=UTF-8''xxx是标准做法,能正确处理中文文件名;但为了兼容老版本浏览器,常见做法是再加一个纯 ASCII 的filename="contract_20240618.doc",两边都写,浏览器优先认 filename*。URLEncoder.encode会把空格编码成+,在 URL 场景没问题,但放在 header 里最好替换成%20,不然某些环境下载下来的文件名会带个加号。

响应类型application/msword对应老式 .doc 文件。如果输出的是 .docx,则用application/vnd.openxmlformats-officedocument.wordprocessingml.document。这个类型不对,文件内容照样能下载,但部分前端框架会拦截或篡改响应,导致文件名丢失。

3.4 模板循环与条件:明细表格的写法

Controller 里组装好items列表后,模板就要把表格循环写出来。这里最容易出问题的不是 FreeMarker 语法,而是 XML 结构。真正从 Word 另存出来的 XML 里,一个表格的行是被<w:tr>...</w:tr>包裹的。最稳妥的做法是让<#list>直接包住整个<w:tr>,而不是把<#list>写在某个单元格内部。

<#if items?? && items?size gt 0> <#list items as item> <w:tr> <w:tc> <w:p><w:r><w:t>${item.seq}</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.amount}</w:t></w:r></w:p> </w:tc> </w:tr> </#list> <#else> <w:tr> <w:tc> <w:p><w:r><w:t>无明细数据</w:t></w:r></w:p> </w:tc> </w:tr> </#if>

<#if items??>判断整个列表是否存在且不为 null,items?size gt 0再判断有没有元素,避免空白数据时生成一个空表格。FreeMarker 的??判空、gt比较大小,这两个语法在模板里很常用。另外注意,FreeMarker 对 null 默认是抛异常而不是输出空白,所以模板里凡是可能为空的字段,都建议带上!'',例如${remark!''}。别嫌麻烦,这行代码能省掉你大量“字段没值导致整个导出失败”的线上事故。

4. 生成 Word 的 5 个经典翻车现场:排查顺序与修复方法

文件下载下来却打不开、打开乱码、表格错位,这些坑我在项目里全踩过。如果生成结果不对,我建议按这个顺序排查:先看文件本身是不是合法的 XML,再看编码是否统一为 UTF-8,然后看模板里 FreeMarker 标签是否破坏了结构,最后核对数据模型里字段名和模板里的占位符是否一致。前两类问题多数在配置层面,后两类问题多数在模板层面。

4.1 双击文件提示“格式与扩展名不匹配”

现象:浏览器下载后,用 Word 打开弹出“文件格式与扩展名不匹配”,点是能打开,但内容可能莫名其妙少了段落。原因基本在模板 XML 结构被 FreeMarker 破坏了。最常见的是<#if>判断把<w:p>段落标签切成了两半,比如判断写在<w:r>中间,关闭标签又跑到另一个<w:p>后面,XML 就变得不合法。解决方法是把 FreeMarker 指令放在完整的 XML 节点外面,宁可多包一层判断,也不要插在标签内部。排查时可以先把生成的 .doc 文件用文本编辑器打开,看 XML 声明、根节点</w:document>是否完整,或者用浏览器直接打开该文件,浏览器会直接提示第几行第几列格式错误。这是最快定位的方式。

4.2 中文全部乱码或出现“锟斤拷”

现象:英文和数字正常,中文变成乱码,严重时一堆“锟斤拷”。原因十有八九是编码不一致。FreeMarker 的setDefaultEncoding没有设成 UTF-8,或者模板文件在保存时用了系统默认的 GBK 编码,又或者输出流在包装时用了平台默认编码。解决方法是三板斧:模板文件另存时选择 UTF-8 编码;freemarkerConfig.setDefaultEncoding("UTF-8")必须写;渲染输出时用new OutputStreamWriter(out, StandardCharsets.UTF_8)。三条同时满足,乱码基本不会再出现。有个细节,从 Word 另存的 XML 文件默认可能是gb2312或gbk,另存后要用编辑器转换一次,这一步很多人会漏。

4.3 表格循环错行、多出空行

现象:明细数据第一行正常,从第二行开始跑到上一行的单元格里,或者每行数据之间多出大量空白段落。原因多半是<#list>没有包住整个<w:tr>,只包住了部分单元格,导致 Word 解析时把表格行拆开了。另一个隐蔽原因是模板里<#list>标签和<w:tr>之间有换行,FreeMarker 默认会把指令外的空白原样输出到 XML 里,多出来的换行符被 Word 解析成了空段落。解决方法是把<#list>和</#list>紧贴<w:tr>,中间不留换行。如果模板里空行已经出现,可以在 Configuration 上开启<#t>标签去掉指令间空白,但最可靠的做法还是保持模板文件干净,把指令行和结构行对齐缩进。

4.4 长文本换行丢失,所有内容挤成一段

现象:数据库里存了一段带回车换行的备注,生成到 Word 里后变成一整段,之前排版好的行都没了。原因很简单,FreeMarker 输出的是纯文本字符串,字符串里的\n在 Word 的 XML 里不会被解析成换行。Word 的换行有两种,一是<w:br/>行内换行,二是拆分成多个<w:p>段落换行。解决方法是组装数据时把字符串里的\n替换成<w:br/>,并确保这段文本所在的<w:t>是合法的。注意这里的转义要处理<、>、&等特殊字符,否则插入的<w:br/>会被 FreeMarker 当成普通文本原样输出。我一般会写一个小方法,专门把用户输入的文本转换成 Word XML 安全的片段,里面统一处理换行、尖括号和和号。

4.5 字段没值直接报错或残留${xxx}占位符

现象:模板渲染抛异常,日志里写着The following has evaluated to null or missing,或者生成的文件里直接出现${contractNo}这串字符。原因基本是数据模型里 key 拼写不匹配,template 里写contractNo,Map 里放的是contract_no,FreeMarker 找不到就抛异常。另一种情况是有人为了省事把TemplateExceptionHandler换成了忽略异常的处理器,结果不报了,但没数据的地方直接把占位符原样输出。我的习惯是:key 命名全项目统一用驼峰,模板和 Map 双端保持一致;模板中对可能为 null 的字段一律加!'';调试阶段保持RETHROW_HANDLER,让问题在最开始就暴露出来,而不是等到用户下载文件后才惊呼“这里面怎么有段乱码”。

5. 再进一步:从 2003 XML 迁到 docx 模板,维护成本还能再降

5.1 docx 解压模板法:绕开格式修复提示

如果你被“文件格式与扩展名不匹配”这个提示烦够久了,或者客户那边严格要求输出 .docx,那就该换到 docx 模板方案。docx 的本质是一个 zip 压缩包,里面是多个 XML 和资源文件。做一个 docx 模板的思路是:先用 Word 生成一份 .docx,把占位符写进去,比如${contractNo};然后改后缀为 .zip 解压,把word/document.xml拿出来,单独用 FreeMarker 渲染;渲染完放回 zip 重新打包。这样生成的 .docx 不会触发修复提示,样式、页眉页脚、图片也能完整保留。

用命令行的方式能快速理解这个过程:

cp template.docx template.zip mkdir tpl && cd tpl unzip ../template.zip # 用文本编辑器打开 word/document.xml,把 ${contractNo} 替换成期望的模板语法 cd .. zip -r new.docx tpl/*

这适合快速验证,但生产环境里不可能每次导出都靠命令行。Java 侧的做法是按 zip 条目读取原始 docx,命中word/document.xml时执行 FreeMarker 渲染,其余文件原样写回新的 zip:

public void renderDocx(String templatePath, Map<String, Object> data, ByteArrayOutputStream out) throws Exception { try (ZipInputStream zin = new ZipInputStream(new FileInputStream(templatePath)); ZipOutputStream zout = new ZipOutputStream(out)) { ZipEntry entry; while ((entry = zin.getNextEntry()) != null) { byte[] bytes = readAll(zin); if ("word/document.xml".equals(entry.getName())) { String xml = new String(bytes, StandardCharsets.UTF_8); Template tpl = new Template("docXml", new StringReader(xml), freemarkerConfig); bytes = tpl.toString(); } zout.putNextEntry(new ZipEntry(entry.getName())); zout.write(bytes); zout.closeEntry(); } } } private byte[] readAll(InputStream in) throws IOException { ByteArrayOutputStream buffer = new ByteArrayOutputStream(); byte[] chunk = new byte[4096]; int n; while ((n = in.read(chunk)) != -1) { buffer.write(chunk, 0, n); } return buffer.toByteArray(); }

这段代码的价值在于:静态图片可以直接靠原模板保留,不再需要自己在代码里插入;生成的是标准 docx,打开不再提示修复;带格式的复杂模板也能正常渲染。代价是模板每次改了,要重新解压、替换、打包,不能直接在项目里改一个 .xml 完事。但这几步可以写成一个脚本文件,业务改完模板后跑一次,成本很低。

5.2 模板维护的三个实用习惯

模板多了以后,维护成本往往大于代码成本。我自己吃过大亏:有一次业务为了一个字体颜色改动,直接在 Word 里整段复制粘贴,把原来的占位符搞没了,导出文件质量参差不齐。后来我定了三条规矩,团队里一直遵守到现在。

第一,模板文件名带版本号,比如contract_v1.ftl、contract_v2.docx,改动格式不覆盖旧版,出问题能立刻回退。第二,模板里只保留必要样式和占位符,不要手工加一堆隐藏段落、多余文本框,那些东西在 XML 里都会变成碎节点,容易让生成文件体积膨胀甚至损坏。第三,做一个简单的字段登记表,把每个模板用到的变量名列清楚,方便开发组装数据和业务修改模板时对照。这三条不花什么成本,但能省掉大量排查时间。经历过一次客户催着要文件、模板却被误改的尴尬之后,我就再也不敢让模板文件夹裸奔了。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进