简介面向 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 version1.0 encodingUTF-8 standaloneyes? w:document xmlns:whttp://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而是直接用MapString, Object组装因为 FreeMarker 访问 Map 的 key 非常直接模板里写${contractNo}Map 里放contractNo就能命里。对于明细列表用一个ListMapString, Object即可。MapString, Object data new HashMap(); data.put(contractNo, HT-2024-0001); data.put(projectName, 某某园区配电房年度巡检); data.put(signDate, 2024-06-18); ListMapString, Object items new ArrayList(); String[] itemNames {巡检服务, 绝缘检测, 负荷测试}; for (int i 0; i itemNames.length; i) { MapString, 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_noFreeMarker 会直接报变量找不到这个在调试阶段很常见。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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-freemarker/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-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, MapString, 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 模板区分开。文件扩展名没有强制要求但我统一用.ftlIDE 识别后能按 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 { MapString, Object data buildContractData(); String fileName URLEncoder.encode(设备维保合同.doc, StandardCharsets.UTF_8) .replace(, %20); response.setContentType(application/msword;charsetUTF-8); response.setHeader(Content-Disposition, attachment; filename*UTF-8 fileName); wordExportUtil.render(contract.ftl, data, response.getOutputStream()); } }Content-Disposition这里有一个细节值得展开。filename*UTF-8xxx是标准做法能正确处理中文文件名但为了兼容老版本浏览器常见做法是再加一个纯 ASCII 的filenamecontract_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:pw:rw:t${item.seq}/w:t/w:r/w:p /w:tc w:tc w:pw:rw:t${item.name}/w:t/w:r/w:p /w:tc w:tc w:pw:rw:t${item.amount}/w:t/w:r/w:p /w:tc /w:tr /#list #else w:tr w:tc w:pw:rw:t无明细数据/w:t/w:r/w:p /w:tc /w:tr /#if#if items??判断整个列表是否存在且不为 nullitems?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 里写contractNoMap 里放的是contract_noFreeMarker 找不到就抛异常。另一种情况是有人为了省事把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 渲染其余文件原样写回新的 zippublic void renderDocx(String templatePath, MapString, 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 里都会变成碎节点容易让生成文件体积膨胀甚至损坏。第三做一个简单的字段登记表把每个模板用到的变量名列清楚方便开发组装数据和业务修改模板时对照。这三条不花什么成本但能省掉大量排查时间。经历过一次客户催着要文件、模板却被误改的尴尬之后我就再也不敢让模板文件夹裸奔了。希望帮到你。本文还有配套的精品资源点击获取