做Java后端这几年,我几乎每隔一段时间就会遇到一个需求:把业务数据生成PDF文件。订单、合同、对账单、报表,五花八门。最早期我直接用iText在代码里拼文档,一个字段一个字段地set,排版全靠绝对坐标,改一个间距就要来回跑好几趟,痛不欲生。后来换了方案,用FreeMarker模板生成HTML内容,再用CSS渲染引擎把HTML转成PDF,这一换,整个开发效率直接起飞,模板和代码彻底分离,前端同事也能接手改样式,Java代码里基本只剩数据组装。这篇文章就把这套方案完整展开讲讲,从选型思路到模板写法,再到我踩过的那些坑,一次性说清楚。
1. 方案选型:为什么是FreeMarker加Flying Saucer
1.1 条条大路通PDF,选对了省一半功夫
PDF生成在Java生态里从来不止一条路,我简单对比过几种主流做法,各有各的适用场景,但放到“业务报表、合同单据”这类场景下,差别非常大。
第一种是用iText硬编码。iText功能是真的强,能精确控制每个元素的位置、字体、颜色,理论上没有它画不出来的东西。但问题是,工作量和可维护性都很糟糕。一张稍微复杂点的对账单,表格、合计、备注、页眉页脚,代码随便就写几百行。业务字段一旦调整,比如加一列、换个分组方式,你就得去代码里找对应的坐标和set方法,测试一次PDF要跑一遍程序,效率极低。而且iText在商用场景下还有授权问题,除了AGPL版本,很多公司用起来都要掂量。
第二种是JasperReports。它自带了报表模板设计器,做中国式复杂报表确实很成熟,像分组统计、交叉表、子报表这些都有现成能力。但它的短板也很明显,模板文件是.jrxml格式,学习成本不低,设计器那套操作也不是所有开发都熟悉。如果只是生成一个格式相对固定的PDF,用JasperReports有点大材小用,而且自定义样式的灵活性反而受限。
第三种就是标题里说的方案:FreeMarker模板 + Flying Saucer渲染。FreeMarker负责把数据模型填充进HTML模板,生成标准的HTML字符串,然后Flying Saucer在内存里把HTML解析成PDF。这套路线的核心价值在于:HTML和CSS是所有人都熟悉的语言,模板就是网页,样式就是样式表,前端同事可以直接参与。业务上任何版式调整,改模板就行,不用动Java代码。我实际用下来,这种“模板归模板、代码归代码”的拆分方式,在频繁变更需求的业务项目中特别合适。
1.2 FreeMarker的不可替代之处
FreeMarker作为模板引擎,最大的优点是它不绑定任何输出格式。它就是一个纯文本生成器,模板里写什么就输出什么,所以天然适合生成HTML。相比直接用字符串拼接HTML片段,FreeMarker提供了变量、循环、条件判断、宏、include等语法,代码可读性和维护性完全不在一个层级。
比如对账单里循环订单明细,模板里就是一个<#list>标签,数据是空的时候用<#if>判断一下显示“暂无记录”,复杂统计用<#assign>先算好再输出。这些逻辑如果放在Java代码里拼字符串,拼接过程会和业务逻辑混在一起,写起来难受,改起来更难受。FreeMarker把表现逻辑放到了模板层,Java代码只关心数据组装,各司其职。
再一个关键点是模板引擎有成熟的缓存机制。FreeMarker的Configuration加载模板之后,模板会被解析成内部数据结构缓存起来,下次直接用缓存副本,不会重复读文件和解析。高并发场景下这点很重要,性能表现远好于每次手动拼接字符串。
1.3 CSS渲染引擎的选型考量
HTML转PDF这一步,可选的渲染引擎不算多,Flying Saucer是最常用也最稳的一个。它的原理是把CSS 2.1规范映射到PDF布局上,支持大部分常见样式属性,比如字体、颜色、边框、背景色、浮动、紧排、table布局。虽然不支持flex、grid这些现代布局(后面会细说),但对付合同、对账单这种结构性文档,完全没有问题。
选Flying Saucer还有个隐藏优势:它是基于OpenPDF的,后者是iText老版本的开源分支,许可证相对友好,商用不用像新版iText那样担心AGPL约束。我在多个实际项目里用过这条技术路线,稳定性是经得起考验的。另一个可对比的开源组件是wkhtmltopdf,它用QtWebKit渲染,CSS支持更强,但它是独立进程调用,部署要装系统依赖,不像Flying Saucer是一个纯Java库,打进去就能跑。项目里能用纯Java解决的事,我一般不会去引入额外的系统级依赖。
2. 环境搭建:依赖配置与版本兼容
2.1 Maven依赖清单
整套方案需要引入两个核心库:FreeMarker和Flying Saucer。以下是我项目中一直沿用的依赖组合,亲测稳定:
<!-- FreeMarker模板引擎 --> <dependency> <groupId>org.freemarker</groupId> <artifactId>freemarker</artifactId> <version>2.3.32</version> </dependency> <!-- Flying Saucer渲染引擎 --> <dependency> <groupId>org.xhtmlrenderer</groupId> <artifactId>flying-saucer-pdf-openpdf</artifactId> <version>9.1.22</version> </dependency>注意flying-saucer-pdf-openpdf这个坐标,它内部会传递依赖引入OpenPDF,不需要再手动加openpdf的依赖。如果只引入flying-saucer-core,那只是HTML解析和布局部分,没有PDF输出能力,必须配合flying-saucer-pdf-openpdf才能调用ITextRenderer。
2.2 版本兼容性避坑
版本看起来简单,实际踩坑的人不在少数。网上很多老教程用的是flying-saucer-pdf加itext2.1.7的组合,这个组合已经是十年前的产物了,itext2.1.7是LGPL授权的老版本,功能上也能跑,但长期没人维护,遇到新JDK版本可能会有兼容问题。我现在更推荐用openpdf分支,它在老iText基础上做了社区维护,修了不少bug,还适配了新版JDK。
从JDK 11开始,Flying Saucer和OpenPDF都需要注意模块化相关的问题,比如某些内部API访问受限。如果遇到IllegalAccessError之类的异常,优先检查JDK版本,必要时可以换用支持更高JDK的版本组合。个人经验是JDK 8和JDK 11下这套依赖组合表现最稳定,再高版本先跑一个小demo验证一下比较稳妥。
另外还有一点,模板里如果字体名用了中文,比如font-family: 宋体,Windows本地跑没问题,但部署到Linux服务器上就会找不到字体。这个问题的解决方案在后面“字体篇”专门展开,这里先留个印象:字体问题一定是PDF从HTML转换中最高频的故障来源,没有之一。
3. 模板编写:从HTML到PDF的分页与样式
3.1 模板结构与布局要点
FreeMarker模板写起来跟普通HTML几乎没有差别,核心就是:把动态数据用${变量}占位,用<#list>遍历集合,用<#if>控制条件,然后设计好CSS样式。这里我整理了一个对账单模板的最小骨架,方便直观理解:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"/> <style> @page { size: A4; margin: 15mm 12mm; } body { font-family: "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; font-size: 11px; color: #333; } table.data { width: 100%; border-collapse: collapse; margin-top: 10px; } table.data th, table.data td { border: 1px solid #666; padding: 5px 8px; text-align: left; } table.data th { background-color: #f2f2f2; } .title { text-align: center; font-size: 20px; font-weight: bold; margin-bottom: 20px; } </style> </head> <body> <div class="title">对账单</div> <p>客户名称:${customerName}</p> <p>账期:${billingPeriod}</p> <table class="data"> <thead> <tr> <th>序号</th> <th>交易时间</th> <th>交易类型</th> <th>金额(元)</th> </tr> </thead> <tbody> <#list transactions as item> <tr> <td>${item_index + 1}</td> <td>${item.tradeTime}</td> <td>${item.tradeType}</td> <td style="text-align:right;">${item.amount}</td> </tr> </#list> </tbody> </table> <p style="margin-top:10px;">合计金额:<strong>${totalAmount}</strong> 元</p> </body> </html>这个模板里的<thead>部分不是摆设,Flying Saucer渲染PDF时,如果表格跨页,会自动把<thead>里的内容重复显示在新一页的表格顶部。这个能力做多页对账单和合同条款明细时特别有用,不用手动去计算“当前页应该从第几行开始”,引擎全帮你处理好了。
3.2 中文字体:最大的一道坎
中文字体的处理是整个方案里最容易被忽略、也最容易出问题的环节。直接在HTML里写font-family: "宋体",Windows开发机上大概率没问题,因为系统里有这个字体。但一旦部署到Linux服务器,服务器上没有安装中文字体,PDF渲染出来的中文就全是方框或者直接空白。
解决办法有两个思路。第一个是系统级方案:在服务器上安装中文字体包,比如CentOS上装fontconfig和文泉驿、Noto CJK字体,然后模板里font-family写成对应的字体名。这个办法简单,但依赖服务器环境,换一台服务器就得再装一遍,自动化运维上有些麻烦。
第二个是代码级方案,我更喜欢:把中文字体文件放到项目资源目录,启动时用FontFactory注册字体。在模板里仍然写font-family: "Noto Sans CJK SC",然后代码里用FontFactory.register("/fonts/NotoSansSC-Regular.otf", "Noto Sans CJK SC")注册字体名称,渲染时Flying Saucer就会使用这个注册的字体。这样不管程序部署到哪台机器,只要字体文件跟着jar走,中文显示就一定有保障。注意注册的名称必须和CSS里的font-family完全一致,多一个空格都匹配不上。
3.3 CSS支持范围与分页控制
Flying Saucer的CSS支持水平大致相当于CSS 2.1,不支持flex布局、grid布局、position:fixed这些后续规范里的东西。模板里布局尽量用table、float、inline-block来搞定。我刚开始用的时候踩过flex不生效的坑,排查了半天发现元素全部纵向堆叠了,后来统一改用table布局才解决。
分页控制有几个实用技巧可以记一下。page-break-before: always可以让某个区块强制从新页开始,比如合同模板里“附件”、“签章页”适合这样处理。page-break-inside: avoid可以避免一个div或表格行被拦腰截断,这对保持条目完整性很重要。元素的margin、padding在布局转换时基本都能生效,但要注意float元素的负margin在某些情况下会有兼容问题,实际使用中尽量避免。
4. 核心代码实现:从数据模型到PDF文件
4.1 数据模型准备
FreeMarker模板的数据模型是一个Map或者POJO包装的root对象。最简单的做法是构造一个Map,把模板里用到的所有变量都放进去,key就是变量名。比如上面那个对账单模板,我需要构造这样的数据:
Map<String, Object> root = new HashMap<>(); root.put("customerName", "某科技有限公司"); root.put("billingPeriod", "2024年3月"); root.put("totalAmount", "28600.00"); List<Map<String, Object>> transactions = new ArrayList<>(); Map<String, Object> t1 = new HashMap<>(); t1.put("tradeTime", "2024-03-01 09:12"); t1.put("tradeType", "项目预付款"); t1.put("amount", "12000.00"); transactions.add(t1); // 继续添加其他交易记录... root.put("transactions", transactions);模板里${item.amount}这种点号语法,底层对应的是Map的get("amount")调用,所以构造Map数据完全够用。如果数据结构复杂,也可以用自定义类,只要属性有getter即可。FreeMarker对空值处理比较宽松,默认空值输出为空字符串,但如果开启了classic_compatible模式或者用了strict语法,空值会直接报错,这个看你的配置,模板里最好用${item.amount!""}加上默认值兜底。
4.2 FreeMarker配置与模板加载
模板文件通常放在src/main/resources/templates下,用类路径方式加载。核心配置如下:
Configuration cfg = new Configuration(Configuration.VERSION_2_3_32); cfg.setDefaultEncoding("UTF-8"); cfg.setClassLoaderForTemplateLoading( Thread.currentThread().getContextClassLoader(), "/templates"); cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); cfg.setNumberFormat("0.########");编码配置成UTF-8是必须的,否则模板里的中文在生成HTML时就会变成乱码,后面PDF当然也是乱的。setNumberFormat("0.########")是防止FreeMarker把数字格式化成带千分符的字符串,比如12000.00变成12,000.00,这个在很多场景下不是我们想要的。如果你用Java的BigDecimal作为金额字段,最好在模板里用${item.amount?string("0.00")}显式控制格式化,免得样式不受控。
需要说明的是,Configuration是线程安全的,可以在整个应用中作为单例使用。每次生成PDF时,cfg.getTemplate("invoice.ftl")获取模板对象,模板缓存由FreeMarker管理,重复获取不会重复解析文件。
4.3 渲染HTML并输出PDF
这一部分就是整个流程的核心链路,代码不多,但每一步都不能错:
// 1. 渲染模板获得HTML字符串 Template template = cfg.getTemplate("billing.ftl"); StringWriter writer = new StringWriter(); template.process(root, writer); String html = writer.toString(); // 2. 用Flying Saucer渲染PDF try (OutputStream os = new FileOutputStream("/tmp/billing.pdf")) { ITextRenderer renderer = new ITextRenderer(); renderer.setDocumentFromString(html); renderer.layout(); renderer.createPDF(os); }setDocumentFromString只是解析HTML并建立文档模型,layout()执行布局计算,createPDF才真正生成PDF字节流。顺序不能乱,漏掉layout()直接createPDF的话,输出的PDF大概率是空白的。我见过不止一个同学在这里踩坑。
需要注意,setDocumentFromString默认会把HTML当成XHTML解析,模板里所有标签必须闭合,<br>要写成<br/>,<meta charset="UTF-8">要写成自闭合形式,否则解析器可能报错或产生奇怪的布局问题。这也是模板编写时最容易忽略的细节之一。
4.4 Web场景下的文件下载输出
如果PDF不是落盘而是直接在浏览器里下载,代码稍微调整一下,输出流换成HttpServletResponse的响应流即可:
response.setContentType("application/pdf"); response.setHeader("Content-Disposition", "attachment; filename=\"billing_" + orderNo + ".pdf\""); ITextRenderer renderer = new ITextRenderer(); renderer.setDocumentFromString(html); renderer.layout(); renderer.createPDF(response.getOutputStream());Content-Disposition里如果文件名涉及中文,要处理URL编码,否则浏览器下载时文件名会乱码。一般用URLEncoder.encode(fileName, "UTF-8")后再拼上去。在实际项目中,我还会在响应头里加Cache-Control: no-cache,避免浏览器缓存了旧的PDF文件,用户刷新后看不到最新对账单。
5. 实战中最常踩的坑与排查手册
5.1 中文乱码或方块字排查思路
PDF里中文显示成小方块或者直接消失,几乎无一例外都是字体问题。排查步骤也很固定:第一步确认模板中font-family指定的字体名是不是系统里存在的,或者有没有通过FontFactory注册过;第二步确认字体文件本身可用,可以用系统的字体查看工具打开验证一下;第三步确认注册字体时名称匹配,大小写、空格都要和CSS里完全一致。
这里有个细节:FontFactory.register和renderer.getFontResolver().addFont是两种不同的注册方式,前者是全局静态注册,后者只对当前renderer实例生效。我一般用FontFactory.register在启动时全局注册,用得最多的字体就那几个,注册一次永久生效。代码如下:
FontFactory.register("/fonts/NotoSansSC-Regular.otf", "Noto Sans CJK SC"); FontFactory.register("/fonts/NotoSansSC-Bold.otf", "Noto Sans CJK SC Bold");如果模板里用了粗体字,光注册常规字体是不行的,还得注册对应的Bold字体文件,否则粗体样式渲染不出来,或者会在某些环境下直接报字体找不到的异常。
5.2 表格边框缺失和背景色不生效
Flying Saucer对CSS选择器的支持还算全面,但有一点经常搞混:border-collapse: collapse必须设置在table元素上,而不是td上。如果表格边框时有时无,先检查这个属性有没有漏。另外background-color在tr上的兼容性不如直接设置在td上,跨页时表头背景色偶尔会丢,稳妥的写法是把背景色同时写在th和td上。
更隐秘的一个坑是,border里如果只写了颜色简写,比如border: 1px #ccc,省略了solid这种边框样式关键字,部分版本会当作默认样式处理导致边框不显示。写的时候务必写全:border: 1px solid #ccc。
5.3 图片加载不出或跨机器路径失效
模板里如果用了图片,比如公司Logo、签章图片,最怕遇到相对路径问题。setDocumentFromString解析HTML时,图片的src如果写的是相对路径,它没有基准URL可以解析,图片自然显示不出来。解决方式有两种:一是图片放到项目静态资源里,用file:///绝对路径引用;二是启动时把图片复制到临时目录,动态拼接绝对路径。
如果是网络图片,直接用HTTP URL也可以,但需要注意Flying Saucer的setDocumentFromString不会自动加载网络图片,需要额外配置setResourceResolver并配合baseURL来加载。我印象里这个配置比较绕,实际项目中如果能用本地图片就别用网络图片。
5.4 分页后内容和页码怎么处理
分页是PDF生成中最容易“看着不对劲”的环节。多页表格跨页时,表头用<thead>包住可以自动重复,这是模板层就能解决的事。但页码和页脚这类页面级元素,模板里加不了,需要在Flying Saucer的ITextRenderer上做处理。
Flying Saucer本身不直接支持页脚模板,但OpenPDF提供了底层扩展点。我常用的方案是监听PDF文档事件,在onEndPage回调里获取PdfWriter,用ColumnText在指定坐标处写入页码。这部分代码相对底层,可读性差一些,但效果稳定。还有一种更轻量的做法是:在模板底部用position: running()配合页边距设置生成页脚,不过这个特性依赖更深,实现门槛更高,入门阶段建议先用事件监听方案。
5.5 问题排查速查表
| 症状 | 优先排查方向 | 常见原因 |
|---|---|---|
| 中文显示为方块或空白 | 字体注册与字体名匹配 | 服务器缺字体、未注册字体、名称不符 |
| PDF内容空白无文字 | 调用顺序或字体注册 | layout()未调用、字体全部失效 |
| 表格边框缺失 | table与td的样式 | 未设置border-collapse:collapse |
| 图片不显示 | 路径解析方式 | 相对路径无基准URL |
| 页面大小不对 | @page规则与PDF页面设置 | 未定义@page或用错单位 |
| 换行位置错乱 | CSS兼容性 | 使用了flex/grid等不支持的布局 |
| 生成的PDF无法打开 | 流资源管理 | 输出流未关闭或提前关闭 |
6. 性能优化与批量生成
6.1 模板缓存与单例Configuration
FreeMarker的Configuration和Template对象都是线程安全的,可以放心复用。项目里应该把Configuration配置成单例,用Spring的话就是注册一个@Bean,而不是每次生成PDF时都new一个。每次newConfiguration意味着每次都要重新读取freemarker.jar里的默认配置,没有利用模板缓存,性能浪费很大。
我自己测算过一个场景:1000份对账单并发生成,单例Configuration复用时,模板获取和解析的耗时几乎可以忽略;但每次重建Configuration的话,光模板解析就要多花好几秒。高并发场景下完全不是一个量级。
6.2 批量生成的资源管理
批量生成PDF时,最忌讳的是在循环里创建大量中间对象却不释放。StringWriter和ITextRenderer都是重量级对象,尤其后者内部会创建文档处理器和字体对象,频繁创建销毁会产生大量GC压力。建议做法是:每个请求或任务一个renderer实例,用完之后显式关闭或置空;如果是批量生成不同订单的PDF,在循环内复用renderer实例,只更新HTML字符串,这样对象创建开销会小很多。
内存占用也要留意。如果一批订单很大,每次生成的HTML字符串和PDF字节流都很大,可以考虑边生成边写入文件,而不是全部放进内存再统一处理。比如用FileOutputStream逐份写入,生成一份写一份,内存峰值会稳定很多。
6.3 大报表的分片与合并思路
超过几百页的大报表直接渲染,很容易把内存吃满,Flying Saucer在我实际压测中,500页以上的文档会看到明显的内存涨幅。如果业务真需要这种级别的PDF,可以考虑把数据分片,每片生成独立PDF文件,最后用OpenPDF的PdfCopy合并。合并过程要注意:每份片段的页面大小必须一致,页边距不同会导致拼接后出现空白页或排版错乱。
这里有个经验:分片粒度控制在50~100页一份比较合适,合并后的PDF体积和内存占用都比较可控。当然这个下限取决于服务器配置,我给的是参考值,具体还是要压测一下心里才有底。
最后想吐槽的
做的项目越多,越觉得“把模板和代码分离”这个设计原则在PDF生成这件事上体现得淋漓尽致。FreeMarker加Flying Saucer这套方案,最大的价值不是某个API多好用,而是它让一个后端项目里的PDF模块变得可维护、可交付。业务方要改样式?改模板。要加一个字段?改模板。Java代码基本不动。曾经那些用iText硬编码的日子,改一个PDF样式要花半天,现在基本10分钟搞定。
最后分享一个小技巧:如果模板里有很多动态计算,比如对账单要统计每个客户的应收总额,与其在Java里提前算好,不如直接在模板里用<#assign>配合<#list>动态累加,这样Java代码里的数据模型更干净,模板也保持了“所见即所得”的观感。万一业务规则有变,改模板比改代码再发版要快得多。这个方案我已经在多个项目里反复使用,目前它是后端接PDF需求时我的默认首选。