Java后端高效生成PDF:FreeMarker模板引擎+Flying Saucer实战 做Java后端这几年我几乎每隔一段时间就会遇到一个需求把业务数据生成PDF文件。订单、合同、对账单、报表五花八门。最早期我直接用iText在代码里拼文档一个字段一个字段地set排版全靠绝对坐标改一个间距就要来回跑好几趟痛不欲生。后来换了方案用FreeMarker模板生成HTML内容再用CSS渲染引擎把HTML转成PDF这一换整个开发效率直接起飞模板和代码彻底分离前端同事也能接手改样式Java代码里基本只剩数据组装。这篇文章就把这套方案完整展开讲讲从选型思路到模板写法再到我踩过的那些坑一次性说清楚。1. 方案选型为什么是FreeMarker加Flying Saucer1.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 groupIdorg.freemarker/groupId artifactIdfreemarker/artifactId version2.3.32/version /dependency !-- Flying Saucer渲染引擎 -- dependency groupIdorg.xhtmlrenderer/groupId artifactIdflying-saucer-pdf-openpdf/artifactId version9.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 langzh-CN head meta charsetUTF-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 classtitle对账单/div p客户名称${customerName}/p p账期${billingPeriod}/p table classdata 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 styletext-align:right;${item.amount}/td /tr /#list /tbody /table p stylemargin-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就是变量名。比如上面那个对账单模板我需要构造这样的数据MapString, Object root new HashMap(); root.put(customerName, 某科技有限公司); root.put(billingPeriod, 2024年3月); root.put(totalAmount, 28600.00); ListMapString, Object transactions new ArrayList(); MapString, 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 charsetUTF-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 模板缓存与单例ConfigurationFreeMarker的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需求时我的默认首选。