Java后端PDF导出实战:FreeMarker+HTML转PDF方案与踩坑记录
说了句“最后再说一句”,但实际上这是个 Java 导出 PDF 的技术分享场景,不需要刻意安排一句文案,“最后再说一句”反而显得刻意。但既然明确要求了,我们就自然地在正文引用这句开头。
Java 后端做 PDF 导出,需求其实很常见:合同、对账单、报名表、检测报告……但如果想把数据塞进一张固定样式的 PDF,很多同学第一反应是用 iText 一个坐标一个坐标地画,或者去啃 JasperReport 的报表设计器。我这边有个生产环境已经稳定跑了两年的导出系统,核心凭证模块用的是 FreeMarker 渲染 HTML 模板,再用 Flying Saucer + OpenPDF 输出 PDF。这套组合表面像“野路子”,实际用下来开发效率极高,也踩了一堆文档搜不到、只能靠读源码定位的坑。今天这篇就是把这两年积攒下来的方案细节和踩坑实录完整写出来,给准备做导出功能的同学一个参考。
1. 方案选型:为什么我用 FreeMarker + HTML + PDF 转换
1.1 主流 Java PDF 导出方案对比
做技术选型前,先看清楚市面上常见的四条路:JasperReports、Apache POI + Word 转 PDF、iText 直接画坐标、FreeMarker/Thymeleaf 渲染 HTML 再转 PDF。这四条路我都做过 POC,各有各的问题。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| JasperReports | 功能强,有可视化设计器、图表、报表引擎 | 学习曲线陡,JRXML 模板升级兼容难,调试麻烦 | 复杂专业报表、固定样式要求极高的金融单据 |
| Apache POI + Word 转 PDF | 可复用 Word 模板,业务方可以直接编辑 Word | 依赖 Office 环境或 LibreOffice,样式映射容易丢失 | 企业合同、服务商配合改格式 |
| iText 7 + 手绘布局 | 灵活,完全控制输出位置 | 开发成本高,改布局需要改代码 | 动态版式复杂,且样式不常改 |
| FreeMarker + HTML 转 PDF | 模板像网页,浏览器可预览,数据与样式分离 | CSS 兼容有限,需要处理字体和分页 | 对账单、报告、证书、凭证等固定版式动态数据 |
JasperReports 不是不好,而是太重。对于“每个季度改一次模板样式”的业务,用 JasperReports 就得让 Java 开发陪着报表设计器来回调。而 iText 画坐标的方式,每改一次版式都要重新定位所有文本和线条,稍微复杂一点的单据,代码量能吓跑新人。
我最后选择 FreeMarker + HTML 转 PDF,最大的原因是:模板开发可以像写网页一样迭代。设计稿出来,先写一个静态 HTML 看效果,然后塞入 FreeMarker 变量,再跑一个转换工具类生成 PDF。整个链路里,前端能帮忙调样式,后端只负责数据模型和产出接口,分工非常清晰。
1.2 最终选型:用 HTML 当模板,拿 PDF 当输出
这套方案的原理其实不复杂。FreeMarker 负责把动态数据填入 HTML 模板,生成一段完整的、带样式的 HTML 字符串;再用 PDF 渲染引擎把这段 HTML 解析成 PDF 文档。中间产物是 HTML,最终交付物是 PDF。
我常用一个类比解释这种模式:把 HTML 模板当成“冲印照片的底片”,PDF 就是“最终拿给客户看的照片”,而数据则是“被冲印到照片上的人脸”。底片决定了布局,数据决定了内容,两者互不干扰。
从工程层面看,这个模式有三个实实在在的好处:
一是布局可预览。开发阶段直接把 FreeMarker 渲染后的 HTML 字符串扔到浏览器里,基本能看出最终 PDF 的版型骨架,不需要每次生成 PDF 再用阅读器打开。
二是模板复用和组织简单。FreeMarker 支持 <#include>、<#macro>、<#list>,像对账单这种由多个区块组成的文档,可以拆成表头宏、明细模板、备注块,按需拼接。
三是部署轻量。不依赖 Office 套件、不依赖外部服务,只要 JVM 里能加载字体文件就能出 PDF,这一点在容器化部署时尤其省心。
2. 落地步骤:依赖、模板与核心代码
2.1 依赖长这样,版本注意别踩雷
我用的 PDF 渲染引擎是 Flying Saucer 的 openpdf 分支,flying-saucer-pdf-openpdf。为什么不用原始 iText?因为早期 iText 版本涉及商业授权条款,社区维护的开源版本更稳妥。OpenPDF 是 iText 4 的社区分支,继续维护且没有商业授权顾虑。
需要注意 flying-saucer-pdf-openpdf 和 flying-saucer-pdf 类名不同。前者依赖 OpenPDF,核心类是 ITextRenderer;后者对应的是旧版 iText。网上很多教程两者混着写,照抄后经常出现 NoClassDefFoundError。
OpenPDF 1.3.30 这个版本和 Flying Saucer 9.1.22 的兼容性,我实测是没问题的。曾经为了升级字体渲染性能,尝试过更高版本的 OpenPDF,结果某些样式渲染行为变了,后来就锁死了这两个版本在生产使用。如果你不想折腾,就按我上面的版本组合来。
2.2 写一个最小可用的 PDF 模板
FreeMarker 模板文件同样放在 resources/templates/pdf 下,文件名后缀 .ftl。来看一个简化版的模板 report.ftl:
这个模板看着简单,但有一个必须记住的坑:Flying Saucer 内部用 XML 解析器处理 HTML,所以标签必须闭合,属性必须带引号,<br> 写成 <br/>。 曾经一个同事在模板里写了 <br>,开发环境用浏览器打开没问题,一调 PDF 接口直接报解析异常,排查了大半天才定位到是这个空标签没闭合。
<#list items as it> 这种写法告诉我们,模板里不能写死任何业务数据。所有动态内容都通过 model 传入,模板只负责展示逻辑。
2.3 核心导出工具类(可直接抄)
生产环境的工具类会比这里复杂,但核心骨架就是下面这段:
这里有几个细节值得展开说:
第一,FREEMARKER_CFG 必须是全局单例。如果每次导出都 new Configuration(),模板缓存会完全失效,性能会断崖式下降,后面第三节还会聊到并发问题。
第二,addFont 方法每次创建渲染器时都要调用,但实际字体文件的读取成本可以通过外部缓存优化。生产环境我维护了一个静态 Set<String> 记录已注册的字体路径,虽然每次还是要调 addFont,但内部会按路径判断,重复注册的开销会小很多。
第三,字体路径尽量不要从 jar 包内 getResource().getPath() 拼。最稳妥的方案是把字体放到 classpath,然后用 ClassPathResource 或 ClassLoader.getResourceAsStream 输出到临时文件再交给 addFont,避免 jar 包内路径和文件系统中的空格问题。
2.4 中文字体:漏了它全是乱码
HTML 里的 font-family: SimSun 只是个名字,最终 PDF 渲染引擎必须能找到对应的字体文件。Flying Saucer 的 FontResolver 会把 CSS 里的字体名映射到系统字体或注册字体,映射不到就会用默认字体,而默认字体通常不支持中文,结果就是满屏“口口口”。
处理方式分两步:
第一步,在项目 resources/fonts 下放一个 TrueType 中文字体,比如 simsun.ttf。为什么强调 .ttf?因为 .ttc 是字体集合,某些版本的 OpenPDF 读 .ttc 会报错或者只能读取第一个字体,兼容性比较差。如果只能拿到 .ttc,可以先用字体转换工具转成 .ttf 再放进项目。
第二步,在渲染前调用:
IDENTITY_H 表示用 Unicode 编码方式映射文字,NOT_EMBEDDED 表示不把字体文件嵌进 PDF 里。如果业务要求 PDF 必须在其他环境一致显示,可以用 BaseFont.EMBEDDED,但文件会大出不少,我见过一份对账单从 200KB 涨到 8MB,就是因为嵌入了完整字体,所以生产上默认是不嵌入。
3. 生产环境两年积累的坑
3.1 中文乱码,比你想的复杂
字体注册是对付乱码的基础,但真实生产环境里这不是唯一原因。我曾经遇到过一种非常隐蔽的乱码:模板里写的是 ,渲染出来的 PDF 里变成了奇怪的空白块。排查后发现,XML 解析器把 HTML 实体 直接当作字符实体转换,在 OpenPDF 字体映射里找不到对应的字形,就渲染成乱码。
从那以后,模板里我统一用空格和 padding 来控制间距,尽量避免 。另外,如果数据字段里带有 HTML 标签,比如用户填的备注是 <b>加急</b>,也会被解析成真实标签,轻则排版混乱,重则整个结构崩掉,这个问题后面第五节会专门讲。
还有一个经验:FreeMarker 的 Configuration 必须设置 setDefaultEncoding("UTF-8"),模板文件本身也必须以 UTF-8 编码保存。有人模板文件是 UTF-8,但 IDE 没有正确识别,导出时中文直接乱码。定位方式很简单,把渲染后的 HTML 字符串打印出来看中文是否正常,如果 HTML 正常而 PDF 乱,就是字体问题;如果 HTML 就已经乱,立即检查模板文件的编码。
3.2 CSS 兼容性:千万别拿浏览器标准写模板
这是这套方案最大的坑,也是新人最常踩的坑。Flying Saucer 底层的 CSS 解析器对 CSS 2.1 的支持都不完整,Flex、Grid、position: fixed、border-radius、box-shadow 这些现代 CSS 特性基本别想用。
我踩过一次很离谱的:想用 position: fixed 在每一页底部加一个页脚,结果这个 div 只出现在最后一页的底部,而不是每一页重复。后来才明白,Flying Saucer 对固定定位的支持非常弱,position: fixed 在 PDF 分页时并不会像浏览器那样“固定视口”。
这种情况下,我的布局原则是:
- 能用表格就用表格,
table + td是 Flying Saucer 支持最稳定的布局方式; - 不用 Flex、Grid,不要指望 float 一定能按预期排;
- 不要用
position: fixed,页眉页脚用@page规则做; - 背景图片偶尔会消失,尽量用纯色背景。
如果确实需要跨页表格,给 table 加 -fs-table-paginate: paginate;,至少能让表头在每页重复,这个属性在生产环境实测是有效的。
3.3 页码页脚和 @page,这样设置才有效
没有页码的正式文档是不完整的。Flying Saucer 支持 CSS @page 规则里的页边距盒子,可以做页码和页脚:
把这套 CSS 放在模板的 <style> 里,渲染出来的 PDF 就会自动带上页码,不需要自己在 Java 代码里拼接。这个特性在高版本 Flying Saucer 中稳定可用,但用 HtmlConverter.convertToPdf 就未必支持,所以生产环境我坚持用 ITextRenderer 而不是 HtmlConverter。
如果还要加公司 Logo 作为页眉,推荐用页边距盒子:
但这里有个问题:页边距盒子的 content: url() 只支持绝对路径或 file: 协议,classpath 下的图片需要提前输出到临时目录。所以生产上更简单的做法是把页眉信息直接放在模板正文第一行,配合 -fs-table-paginate 让表头在每页重复,效果也够用。
3.4 并发导出:系统卡顿的真正原因
刚上线那会儿,导出一张 PDF 没什么感觉。等用户量涨起来,同一个接口同时来了十几个导出请求,CPU 直接飙升,其他普通接口也跟着超时。排查到最后,问题出在三个地方:
第一,原来的代码每次请求都 new Configuration(),模板缓存完全没生效,Freemarker 每次都要重新解析 .ftl 文件。
第二,addFont 每次导出都做一次字体文件 IO,十几个请求并发时,磁盘和内存都扛不住。
第三,导出是 CPU 密集型操作,没有做并发控制,所有请求一窝蜂冲进 layout() 和 createPDF(),内存瞬间耗尽。
改进方案很直接:
- 全局复用
Configuration单例; - 字体文件用常量路径注册,保留静态判断避免重复 IO;
- 导出接口增加
Semaphore限制并发数,比如最大同时 4 个 PDF 渲染任务,其余请求排队或走异步任务表; - 真正的大批量导出,从同步接口改为异步任务,生成完成后发送下载通知。
改了之后,系统在高并发时段再也没有因为导出接口挂掉过。
3.5 模板缓存:改模板不生效的真相
Freemarker 默认会缓存模板。开发环境为了改模板即时生效,需要设置:
但在生产环境,如果你把模板文件放在 classpath 下,发布后改了模板文件而不重新打包,程序读到的永远是旧模板。这个坑有两种解法:
一种是模板放在外部目录,不上 jar。通过 cfg.setDirectoryForTemplateLoading(new File("/data/pdf-templates")) 加载,部署时单独覆盖模板文件。这种方案适合业务方频繁改模板、不想每次发版的情况。
另一种是模板仍然进 jar,但发布时用版本号区分模板文件名,比如 report_v3.ftl。代码里配置模板版本号,切换版本时改配置重新加载。这种方式更安全,因为模板和代码一起走版本管理,不会出现线上模板和接口逻辑对不上的情况。
我个人更推荐第二种。因为模板本质上是代码的一部分,脱离版本管理很容易埋雷。
4. 典型问题排查实录(当速查表用)
4.1 表格边框时有时无
有段时间业务反馈 PDF 里表格的右侧边框偶尔消失。排查流程下来,发现是两个原因叠加:
table设置了width: 100%,但内容过长时表格溢出页面边界,右侧边框被裁掉;border-collapse: collapse在 Flying Saucer 里实现不完整,跨页时有些行的边框不渲染。
我的改法很简单:
然后去掉 border-collapse: collapse,改为每个单元格独立设置边框。这样虽然代码冗余一点,但渲染结果稳定。另外给 table 加上 HTML 属性 border="1" 作为兜底,这样即使某些 CSS 没生效,原始边框属性也能保护一下。
4.2 几万条明细直接 OOM,怎么办
线上出过一次事故:某个业务导出月度明细,数据量 5 万行。Freemarker 渲染出一段近 10MB 的 HTML 字符串,然后一次性交给 ITextRenderer 转 PDF,JVM 内存飙到 2GB,接口超时,连带整个服务被 GC 拖垮。
痛定思痛,三个优化:
首先,模板端控制每页数据量。不要把所有明细全部塞进一个 HTML 表格,而是分段渲染。如果数据结构本身允许分页,可以每页 N 行,然后通过 page-break-before: always 强制分页。5 万行拆成每页 50 行,就是 1000 页,PDF 虽然页数多,但内存压力小得多。
其次,HTML 字符串不要用 StringWriter 整个留在内存里。改成把渲染结果写入临时文件:
最后,数据查询不能一次性 SELECT * FROM detail WHERE order_id = ? 拿全部 5 万条。最好是分批查询,每次只取当前页需要的数据放入 model。虽然模板代码稍微复杂一点,但内存稳定性提升非常明显。
4.3 用户填的数据把 PDF 搞得乱七八糟
用户备注或者商品名称里如果包含 &、<、>,直接放进模板会破坏 HTML 结构。最常见的场景:用户填了一个“测试 < 5 分钟”,模板里渲染成 ${remark},最终 HTML 变成 <div>测试 < 5 分钟</div>,XML 解析器看到 < 5 分钟 这种未闭合语法,直接解析报错。
解决办法是强制转义。FreeMarker 里对所有用户数据字段加 ?html:
更彻底一点,在模板开头用 <#escape> 包裹全局:
这样每个变量输出都会自动经过 HTML 转义,省得每个字段都写一遍 ?html。
如果业务字段本身就想存富文本,比如包含加粗或换行的备注,则不能直接转义。这时应该走白名单:只保留 <b>、<br/>、<p> 等少数标签,其余标签全部过滤掉。否则用户填一个 <table> 就能把整个 PDF 版式撑爆。
4.4 Linux 上中文方块字排查
本地 Windows 开发环境一切正常,部署到 CentOS 7 上之后,导出的 PDF 中文全变成方块。这个问题的根源就是服务器没有中文字体。
排查步骤我列成清单:
- 先看服务器上有没有中文字体:
fc-list :lang=zh,没有输出就说明系统缺字体; - 有权限直接装:
yum install fontconfig wqy-zenhei; - 不想依赖系统环境,就把字体文件放进项目
resources/fonts/simsun.ttf,用代码注册。
这里要注意一个细节:getResource("/fonts/simsun.ttf").getPath() 在 jar 包内能拿到路径,但如果项目打成 fat jar,或者部署目录带空格,这个方法会失效。我的经验是用 Spring 的 ClassPathResource 把字体流拷贝到临时文件再注册:
这样字体一定拿得到、一定读得进来,linux 和 windows 双环境通吃。
5. 如果重新做一次,我会这样优化
5.1 模板纳入版本管理,并提供预览
第一版时模板基本是“写死在代码里改一版发一版”,等业务方参与进来后,问题就来了:业务方要调样式,前端不能直接看 PDF,只能反复导出来发截图,效率很低。
后期我加了一个“模板预览”接口:入参是模板名和 JSON 数据模型,直接返回 FreeMarker 渲染后的 HTML。前端拿到这个 HTML 在浏览器里查看版式,需要调整时不用再去生成 PDF,开发效率提升了不止一倍。
所有模板文件也全部纳入代码仓库,跟着 Git 版本走。线上模板变了,代码版本一定对应一个明确的模板版本,出现问题时可以快速回退。
5.2 不同业务用不同导出策略
两年运行下来,我把导出任务分了四类,对应不同处理策略:
- 低频小文件(比如单张回执):同步导出,客户端直接下载;
- 高频小文件(比如用户自己的报告):增加进程内缓存,重复生成条件一致时直接返回缓存 PDF;
- 低频大文件(比如月度汇总):异步任务,生成完成后通知用户;
- 高频大文件(比如运营批量导出):限制数据规模,强制拆分导出任务,每次最多 2 万条。
这个矩阵看起来简单,但能帮我们把有限的服务器资源花在刀刃上。之前出现过所有人都在导出大文件导致 OOM 的情况,做了策略分级后再也没有复现。
5.3 要不要换个 Chromium 渲染引擎
如果模板对 CSS 的依赖已经复杂到 HTML 方案完全撑不住,比如需要 Flex 布局、精细的 CSS 背景、或者现代伪元素,那 Flying Saucer 这套方案确实到头了。这个时候我会考虑用无头浏览器方案。
具体就是用 Playwright 或者 Spring Boot 集成 headless Chrome,加载一个模板 URL,然后调用浏览器打印页面的能力生成 PDF。优点是现代 CSS 几乎全部支持,效果和浏览器看到的一模一样。缺点是部署重量上来了,内存占用更高,并发能力也更弱,而且打包一个 Chromium 到 Docker 镜像,体积会膨胀不少。
我的观点是:ToB 后台导出的凭证、报告、对账单,HTML 转 PDF 完全够用;如果是做面向 C 端的精美票据,需要像素级还原设计稿,那再用 Chromium 路线。不要一开始就引入重型依赖,先把方案跑通,再按复杂度升级。
跑了两年的体会是:模板导出 PDF 本身不是核心技术难点,真正的难点在于把模板开发流程、字体环境、并发策略和异常处理做透。如果你刚开始做这个功能,别急着上重型报表引擎,先从 HTML + CSS 模板入手,把上面这些坑提前埋好,后面会省很多时间。最后再说一句,每次发布完模板,找一个小数据手工跑一份对比,把生成的 PDF 和上一版做一次文本差异比对,很多线上问题其实能提前发现。