Spring Boot + Thymeleaf + Flying Saucer:纯Java实现HTML转PDF导出
2026/9/8 6:54:02 网站建设 项目流程

在 Java 后端里处理 PDF 导出,绕不开三件事:先把页面内容用 Thymeleaf 渲染成结构化 HTML,再把 HTML 干干净净地转成 PDF,最后解决那些说不清道不明的中文字体和分页问题。我最近几个报表导出项目全程用 Spring Boot + Thymeleaf + Flying Saucer 这套组合,一句话概括就是:用写网页的方式写 PDF,在 Java 进程内不依赖任何外部浏览器完成 PDF 生成。

如果你已经在用 iText 手写文档对象,或者被 wkhtmltopdf、无头 Chrome 的环境依赖折磨过,这篇文章正好帮你打开另一条路。我会从方案选型、Maven 依赖、字体处理、模板编写、核心封装、CSS 兼容性、常见坑位完整梳理一遍,每段都有可以直接抄作业的代码和配置。不管你是第一次接触 PDF 导出,还是已经踩了几个坑正在找解法,这套流程都能给到实用的参考。

1. 方案选型:为什么偏偏是这三件套

1.1 PDF 生成的几种常见思路

Java 生态里做 PDF,主流方案大概分三类。

第一类是直接用 iText、OpenPDF、PDFBox 这类底层库,在代码里创建 Document 对象、手动加 Paragraph、加 Table,一个字一个字地拼文档。优点是控制力极强,缺点是开发效率实在低。一个稍微像样的销售订单,包含标题、客户信息、商品表格、金额汇总、盖章区域,用代码拼下来至少两三百行,而且后续改排版等于重写,维护成本很高。

第二类是模板引擎方案,先用 Freemarker、Velocity、Thymeleaf 渲染 HTML,再用工具把 HTML 转 PDF。这里又分两派:一派是常见的专业 HTML 转 PDF 工具,另一派就是 Flying Saucer。Flying Saucer(原 xhtmlrenderer)用 Java 实现 XHTML + CSS 2.1 解析,借助底层 iText 2.x 生成 PDF,说白了就是喂给它一个 HTML 字符串,它给你吐出一个 PDF 流,排版工作全部交给模板和 CSS 解决。

第三类是调用浏览器无头模式,比如 Puppeteer 无头 Chrome 打印 PDF。这种方案对 CSS 支持最好,前端怎么画 PDF 就怎么长,但缺点也很致命:Java 服务端要起一个 Chromium 进程,环境依赖重,并发导出时内存和 CPU 开销极大,压测时很容易把服务拖垮。

对比下来,Spring Boot 项目里如果 PDF 模板偏报表型,布局以表格和简单块级元素为主,Thymeleaf + Flying Saucer 的性价比最高。它不依赖外部进程、不依赖系统已安装的浏览器,纯 Java 跑,部署方式和普通 Spring Boot 服务完全一致,Docker 镜像里也不用额外装任何东西。

提示:如果你的 PDF 排版需要 flex、grid、圆角阴影这类现代 CSS 特性,那 Flying Saucer 不是最佳选择,建议直接上无头浏览器方案。Flying Saucer 的 CSS 支持停留在 2.1 时代,这是它的边界,后文我会详细讲。

1.2 Flying Saucer 与同类工具的参数对比

为了让你更清楚地做决策,我整理了一张对比表。先说清楚,这张表只针对 Java 服务端场景,不涉及桌面端解决方案。

工具依赖外部进程CSS 支持中文支持分页控制适合场景
iText / OpenPDF无,纯代码布局需注册字体手动控制复杂表单、填表盖章
Flying SaucerCSS 2.1 大部分需注册字体中等,靠 CSS 分页报表、订单、清单
wkhtmltopdf是,需安装工具CSS 2.1 + 部分 CSS3依赖系统字体比较好内容较复杂的单页报告
无头 Chrome (Puppeteer)是,需安装 ChromiumCSS3 基本全支持依赖系统字体很好高保真前端页面转 PDF

Flying Saucer 的核心竞争力就是零外部依赖 + 纯 Java 渲染。这在容器化部署、微服务隔离、云函数环境下优势非常明显。你不需要在 Dockerfile 里额外装浏览器,也不用担心生产环境缺某个系统库。它的劣势同样明确:CSS 版本老,JavaScript 完全不执行,所以不要在 PDF 模板里写任何 JS 逻辑,写了也白写。

1.3 为什么模板引擎选 Thymeleaf

Spring Boot 里模板引擎的选择其实不少,Freemarker、Velocity、Thymeleaf 都能干活。我选 Thymeleaf 的原因有两点。

第一层是生态契合。Thymeleaf 是 Spring Boot 官方推荐的模板引擎,与 Spring MVC 集成非常顺畅。Spring Boot 的自动配置里直接带 ThymeleafAutoConfiguration,你在 templates 目录下放一个 html 文件,注入 TemplateEngine,调用 process 方法就能拿到渲染后的字符串。这套流程几乎不需要额外配置,开箱即用。

第二层是模板表达能力。Thymeleaf 的 th:each、th:if、th:with、#numbers、#strings 这些工具函数处理报表数据非常顺手。比如订单明细循环、金额格式化、空值兜底,在模板里三五行写完,比在 Java 代码里用 StringBuilder 拼字符串清爽太多。

另外,Thymeleaf 模板本身就是 HTML 文件,写模板的时候直接用浏览器打开就能看效果。我在开发报表模板时有个固定习惯:先造一份假数据渲染成 HTML,直接在浏览器里检查样式对不对,再调整 CSS,最后才接 Flying Saucer 转 PDF。这个工作流比纯代码画 PDF 友好太多了。

2. 环境准备与工程搭建

2.1 Maven 依赖配置

直接上 Maven 依赖,核心就一个 flying-saucer-pdf,外加 Spring Boot 自带的 Web 和 Thymeleaf。我的 pom.xml 里相关部分是这样写的:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.xhtmlrenderer</groupId> <artifactId>flying-saucer-pdf</artifactId> <version>9.1.22</version> </dependency>

注意版本。Flying Saucer 目前在 Maven 中央仓库主推 9.x 分支,9.1.22 是我最近一次用到的版本,兼容性没问题。网上大量博客写的是 1.x 或 2.x,那是非常老的版本,不建议再用。如果你在 9.x 里遇到某些类找不到的情况,大概率是下载包不完整或者本地仓库缓存问题,clean 一下再重新拉取。

flying-saucer-pdf 9.x 内部把 iText 2.1.7 的类重命名并打包进自己的 jar 里了,所以不用再手动引入 iText。这一点算是很大的改善,老版本经常因为 iText 冲突出现 NoClassDefFoundError,新版省了不少心。

注意:不要在你的项目里同时引入高版本 iText 和 flying-saucer-pdf,两者工具类路径如果有冲突,运行时会出现各种诡异异常。我建议彻底依赖 Flying Saucer 自带的渲染能力,如果确实需要 iText 原生功能,再单独评估是否切换方案。

2.2 Thymeleaf 模板目录与资源规划

按 Spring Boot 默认约定,页面模板放在 src/main/resources/templates 下。为了和普通网页区分,我习惯建一个子目录 pdf 专门放 PDF 模板,这样目录结构一目了然:

src/main/resources/templates/pdf/ ├── order.html └── settlement.html

静态资源目录我建议单独建 src/main/resources/pdf-assets,放置 PDF 模板里用到的图片、CSS、字体。为什么不用默认的 static 目录?因为 Flying Saucer 解析 HTML 时需要 baseUrl 来拼接相对路径的图片和 CSS,如果你把图片放到 static 下,拼接路径时会多绕一层。创建 PDF 时给 renderer.setDocumentFromString(html, baseUrl) 传入一个指向 pdf-assets 目录的 file:/// 路径,模板里写相对路径就行。

字体文件我习惯放在 src/main/resources/fonts 下,打包时它会一起进 classpath。运行时用 Spring 的 ClassPathResource 拿到真实文件路径再注册给 Flying Saucer,避免硬编码外部绝对路径导致部署环境不一致。后文字体章节会给完整代码。

2.3 中文字体处理——最容易翻车的一环

如果你跳过字体配置,直接拿含中文的 HTML 去生成 PDF,出来的文件里中文十有八九是方框或者空白。原因是 Flying Saucer 底层调用 iText 渲染文本时,需要一个支持中文字符集的字体,否则字符映射不上。

解决办法是注册系统字体或项目内置字体。我推荐把字体文件放到项目里,这样不受生产操作系统环境限制。常见的开源中文字体里,思源黑体(Source Han Sans)和思源宋体(Source Han Serif)比较靠谱,文件体积大一点,但胜在稳定。这类字体是 OFL 协议,商用没有后顾之忧。

字体注册代码的核心思路是,字体文件从 classpath 里复制出来,然后调用 resolver.addFont():

public void registerFonts(ITextFontResolver resolver) throws Exception { File hei = copyFontFromClasspath("fonts/SourceHanSansCN-Regular.otf"); resolver.addFont(hei.getAbsolutePath(), BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED); File heiBold = copyFontFromClasspath("fonts/SourceHanSansCN-Bold.otf"); resolver.addFont(heiBold.getAbsolutePath(), BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED); File song = copyFontFromClasspath("fonts/SourceHanSerifCN-Regular.otf"); resolver.addFont(song.getAbsolutePath(), BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED); } private File copyFontFromClasspath(String path) throws IOException { ClassPathResource resource = new ClassPathResource(path); File tempFile = File.createTempFile("font", ".otf"); try (InputStream in = resource.getInputStream()) { Files.copy(in, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING); } tempFile.deleteOnExit(); return tempFile; }

BaseFont.IDENTITY_H 是 iText 中表示 Unicode 横向编码的常量,配合 NOT_EMBEDDED 表示不嵌入字体文件。如果你希望 PDF 在别人机器上也保持完全一致的显示效果,可以改成 EMBEDDED,但文件体积会大不少。顺带说一句,OTF 格式在 Flying Saucer 里偶尔会有解析问题,如果遇到字体加载失败,优先转成 TTF 再试。 如果你的项目对体积敏感,也可以用精简字体子集,但那是另一个话题了,报表场景下我一般直接用全量字体文件,省心。

3. 核心代码实现

3.1 封装 PDF 导出服务

开发 PDF 导出功能,我不建议在每个接口里堆重复代码,应该封装一个 PdfExportService,把模板渲染和 PDF 生成统一收敛。核心逻辑很简单:接收模板名称和数据模型,用 Thymeleaf 渲染出 HTML 字符串,再用 Flying Saucer 渲染成 PDF 输出流。

下面是一段经过验证的代码,我把注释写得很详细:

@Component public class PdfExportService { private final TemplateEngine templateEngine; public PdfExportService(TemplateEngine templateEngine) { this.templateEngine = templateEngine; } public void writePdf(String templateName, Map<String, Object> model, OutputStream outputStream) throws Exception { Context context = new Context(); context.setVariables(model); String html = templateEngine.process(templateName, context); ITextRenderer renderer = new ITextRenderer(); ensureFontRegistered(renderer); renderer.setDocumentFromString(html, PDF_ASSETS_BASE_URL); renderer.layout(); renderer.createPDF(outputStream); renderer.finishPDF(); } public String renderHtml(String templateName, Map<String, Object> model) { Context context = new Context(); context.setVariables(model); return templateEngine.process(templateName, model); } private static volatile boolean fontRegistered = false; private synchronized void ensureFontRegistered(ITextRenderer renderer) throws Exception { if (!fontRegistered) { ITextFontResolver resolver = renderer.getFontResolver(); registerFonts(resolver); fontRegistered = true; } } }

细看这段代码,有几个关键点值得解释。

第一,baseUrl 参数。setDocumentFromString 的第二个参数,我传的是 pdf-assets 目录的 file:/// 绝对路径。这个值建议配置成 Spring 配置项,通过 application.yml 注入,换环境不用改代码。生产环境这个路径要确保有访问权限。

第二,字体注册的并发控制。ensureFontRegistered 用了 volatile + synchronized 双重检查,目的是避免每次导出都重复注册字体,同时也保证并发场景下不会重复执行 addFont。这个优化在小并发下感知不明显,但高并发导出时能省不少 IO。

第三,finishPDF 的调用。createPDF 之后如果不调用 finishPDF,在部分场景下输出流结尾可能不完整。虽然单文档场景下 createPDF 内部会调用 finishPDF,但养成习惯显式调用更稳妥,尤其在后续要扩展页脚水印时。

3.2 Controller 接口与响应头设置

有了封装服务,Controller 层就非常薄了。下面是一个标准的订单 PDF 导出接口:

@GetMapping("/order/{id}/pdf") public ResponseEntity<byte[]> exportOrderPdf(@PathVariable Long id) throws Exception { Order order = orderService.getById(id); Map<String, Object> model = new HashMap<>(); model.put("order", order); model.put("createdAt", LocalDate.now()); ByteArrayOutputStream baos = new ByteArrayOutputStream(); pdfExportService.writePdf("pdf/order", model, baos); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF); headers.setContentDispositionFormData("attachment", "order-" + id + ".pdf"); return new ResponseEntity<>(baos.toByteArray(), headers, HttpStatus.OK); }

这里响应头有两个细节。第一个是 Content-Type 必须设置成 application/pdf,否则浏览器可能当成文本直接打开乱码。第二个是 Content-Disposition 用 attachment 指定下载文件名,如果想让浏览器内联预览,改成 inline 即可。

对于超大 PDF,建议不要用 ByteArrayOutputStream 全部载入内存,而是直接把输出流交给 HttpServletResponse。比如 Controller 接收 HttpServletResponse 参数,把 response.getOutputStream() 传给 PdfExportService,这样大文件导出时内存占用更可控。不过如果是普通企业应用,导出文件一般也就几百 KB 到几 MB,ByteArrayOutputStream 完全够用。

3.3 Controller 与 Service 的职责划分

我在很多项目里见过一个问题:Controller 里又是查库、又是算金额、又是拼模型,最后还要处理输出流,这种代码十有八九撑不过几次迭代就变成一坨。

我推荐的分层规则很明确:Controller 只负责接收请求参数、设置响应头;PDF 导出的数据组装逻辑放在 Service;模板渲染和 PDF 生成完全收敛在 PdfExportService。这样做的好处是,同一套 PDF 逻辑可以被多个接口复用,比如订单单个导出、订单批量导出可以共用同一个模板和渲染服务。

还有一个小技巧,建议在 Service 层留一个"渲染 HTML 但不转 PDF"的方法。我在前文的 PdfExportService 里已经写了 renderHtml 方法。开发调试时非常有用:在接口后面加个 debug=true 参数直接返回 HTML,浏览器打开就能看排版效果,不需要每次改完模板都下载 PDF 再打开 PDF 查看。这个习惯帮我节省了巨量调试时间。

4. 样式兼容实战

4.1 Flying Saucer 的 CSS 支持边界

Flying Saucer 对 CSS 2.1 的支持比较完整,但对 CSS3 的支持非常有限。它不认识 flex,不认识 grid,不认识 position fixed,也很少支持 border-radius 和 box-shadow。这些在普通网页里天天用的属性,在 Flying Saucer 里写了等于白写。

所以设计 PDF 模板时,一定要用老派的流式布局加表格布局思维。简单的页面结构用 div 块从上往下排,复杂的对齐关系用 table 控制。table 虽然被前端嫌弃多年,但它在 PDF 渲染引擎里是最稳定的排版工具,没有之一。

我常用的几大块写法:

  • 整页容器:不需要 max-width,默认宽度即可,通过 @page 控制纸张和边距
  • 页眉:放在表格第一行的 td 里,或者用 position running(Flying Saucer 支持该特性,但语法比较特殊)
  • 两栏区域:用 table 的两列,不要用 float 或 flex
  • 间距:用 margin 和 padding,不要用 gap,gap 在 Flying Saucer 里经常不生效

4.2 分页与页码控制的正确姿势

分页是 PDF 报表里绕不开的话题。Flying Saucer 支持 CSS 2.1 里的 page-break-before 和 page-break-after,这两个属性告诉渲染引擎在哪里切断页面。比如每一章前面加 page-break-before: always,就能强制从新页开始。

@page 规则里可以做边距设置:

@page { size: A4; margin: 20mm 15mm 20mm 15mm; }

页码方面,Flying Saucer 提供了一些特殊 CSS 函数,比如 content: counter(page) 和 content: counter(pages)。一个我在项目中实测可用的分页示例:

@page { size: A4; margin: 25mm 15mm 20mm 15mm; @bottom-center { content: "第 " counter(page) " / " counter(pages) " 页"; font-size: 9px; color: #666; } }

这个写法在 Flying Saucer 9.x 中可以正常工作,但有个前提,HTML 文档必须声明为 XHTML 格式,DOCTYPE 最好是 XHTML 1.0 strict,不然某些 CSS 规则解析会异常。这一点很容易被忽略,我第一次用的时候整整折腾了一个下午,最后才发现是 DOCTYPE 的问题。

4.3 常用 PDF 模板骨架与样式示例

下面给一个可以直接复制的 PDF 模板骨架,包含报表最常见的几类元素:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8" /> <title>订单报表</title> <style> @page { size: A4; margin: 20mm 15mm; @bottom-center { content: "第 " counter(page) " / " counter(pages) " 页"; font-size: 9px; color: #666; } } body { font-family: "Source Han Sans CN", "思源黑体", sans-serif; font-size: 12px; color: #333; line-height: 1.6; } .header { text-align: center; margin-bottom: 15px; } .header h1 { font-size: 22px; margin-bottom: 5px; } table { width: 100%; border-collapse: collapse; margin-bottom: 10px; } th, td { border: 1px solid #000; padding: 6px 8px; text-align: left; } th { background-color: #f2f2f2; } .text-right { text-align: right; } .total-row { font-weight: bold; margin-top: 10px; } </style> </head> <body> <div class="header"> <h1>销售订单</h1> <p>订单编号:<span th:text="${order.orderNo}">ORDER123</span></p> <p>下单时间:<span th:text="${#temporals.format(order.createdAt, 'yyyy-MM-dd HH:mm')}">2025-01-01 10:30</span></p> </div> <table> <thead> <tr> <th>序号</th> <th>商品名称</th> <th>数量</th> <th>单价</th> <th>小计</th> </tr> </thead> <tbody> <tr th:each="item, stat : ${order.items}"> <td th:text="${stat.count}">1</td> <td th:text="${item.productName}">测试商品</td> <td class="text-right" th:text="${item.quantity}">2</td> <td class="text-right" th:text="${#numbers.formatDecimal(item.price, 1, 2)}">100.00</td> <td class="text-right" th:text="${#numbers.formatDecimal(item.amount, 1, 2)}">200.00</td> </tr> </tbody> </table> <div class="total-row"> <p>总金额:<span th:text="${#numbers.formatDecimal(order.totalAmount, 1, 2)}">1000.00</span> 元</p> </div> </body> </html>

注意模板里所有 CSS 属性都保守一点,优先用 2.1 支持的老属性。比如不要用 display: flex,不要用 position: sticky,不要用 CSS 变量。模板写完后先在浏览器里看一眼大概布局,再跑一次 PDF 转换确认最终效果。这个流程能避免很多返工。

4.4 页眉页脚与水印的扩展实现

如果要给 PDF 加页眉页脚内容,比如公司名称、文档编号、水印,Flying Saucer 的方案相对传统。页眉页脚除了 @page 里的 bottom-center 写法,还能通过 position: running 把文档流中的元素指定为页眉或页脚。

举个例子,把订单编号放进页脚右侧:

.order-no { position: running(orderNo); } @page { @bottom-right { content: element(orderNo); } }

然后在 HTML 里给任意一个元素加上 class:

<div class="order-no" th:text="${order.orderNo}">ORDER123</div>

这个 div 在正文中不会显示,但会出现在每页页脚的指定位置。这个特性 Flying Saucer 支持,不过语法比较偏门,用的时候要仔细测试不同页的效果。水印的做法通常是通过绝对定位一个半透明的大字或者图片铺在页面中间,配合 @page margin box 实现,复杂一点的建议直接在后处理阶段用 PDF 库再叠一层。

5. 常见问题与排查技巧实录

5.1 中文全部变成方框或空白

这是遇到最多的一个问题。排查路径很固定:第一步,确认字体文件能读出来;第二步,确认 addFont 执行成功;第三步,确认 CSS 里 font-family 的名称和字体实际名称对得上。

我自己踩过一次最冤枉的坑,是字体文件放在 resources 下,本地开发时 ClassPathResource 能拿到绝对路径,但打成 jar 包后,getFile() 会抛异常,因为 jar 包里的文件不是传统文件系统文件。解决办法就是我前面代码里写的那样,把字体提取到临时目录再注册,这个写法在本地和 jar 包部署下都稳定。

另一个容易忽略的是字体族名称大小写。Flying Saucer 对字体名称匹配是区分大小写的,如果 CSS 里写 Microsoft YaHei,实际字体内部名称是 Microsoft YaHei,但你在某个配置里写成了 microsoft yahei,就会匹配不上。稳妥起见,CSS 的 font-family 写一个主要的,再加一个常见 fallback 兜底。

5.2 表格边框显示不全或消失

Flying Saucer 对 border-collapse: collapse 的支持有历史问题,某些版本下会导致 td 边框丢失。最直接的应对策略是每个单元格都显式声明 border,包括颜色和宽度,不要只给 table 设置边框。另外一个细节是,td 里不要连续出现空内容,如果没有数据就写一个非断行空格,否则单元格高度可能塌陷。

如果遇到表格跨页时边框断裂,可以在 thead 上设置重复表头。Flying Saucer 支持 thead 跨页重复显示,只要把表头放在 thead 里,分页时会自动在新页顶部绘制表头。这个特性配合 page-break-inside: avoid 可以很大程度提升跨页表格的可读性。

5.3 图片不显示

Flying Saucer 加载图片依赖 baseUrl。如果你用相对路径,而 baseUrl 没设置或目录不对,图片就会空白。调试方式分两步:先确认 HTML 字符串里 img 标签的 src 是相对路径,再确认传给 setDocumentFromString 的 baseUrl 目录里真的存在这个资源。

还有一个高频问题:图片有路径但生成后不显示。这种情况多半是 img 标签没有显式指定宽高。Flying Saucer 在 layout 阶段如果拿不到图片的渲染尺寸,最后生成的 PDF 里图片会是 0 宽 0 高。我在写模板时给所有 img 都带 width 和 height 属性,哪怕只写一句 style="width: 100px; height: 50px;",也能避开这个坑。

5.4 分页后内容被截断或被页眉遮挡

分页调整没有银弹。我的经验是少用 CSS 魔法,多靠自然流分页。确需强制分页时,用 page-break-before: always 或 page-break-after: always。如果出现某一行文字被截断成两半跨页,可以在 tr 或 p 上设置 page-break-inside: avoid,让这行整体移到下一页。

另外,表格 td 里设置 min-height 有时也会干扰分页计算。建议把高度控制都转成 padding 和 line-height,减少分页时的意外。还有一例:@page 的 margin 设置和页面底部边注重叠时,页脚可能遮挡正文。解决方案是加大 @page 的底部 margin,留出足够空间给页脚。

5.5 并发导出时的内存与性能优化

Flying Saucer 是纯 Java 渲染,内存占用和 HTML 复杂度、图片数量高度相关。导出频率低时完全不用在意,但接口一旦被高频调用,就要做限制和缓存。

我的处理方式有几个。第一,PDF 文件用 ByteArrayOutputStream 输出没问题,但超大文件建议直接写 HttpServletResponse 的输出流,避免同时撑多份内存。第二,模板里如果引用了静态图片,图片文件可以加本地缓存,避免 Repeatedly 读磁盘。第三,在服务端做并发控制,用 Semaphore 或线程池限制同时执行的 PDF 导出任务数,防止几十个导出请求同时跑然后把堆内存打爆。

我在实际项目里的做法是,用 Spring 的 TaskExecutor 把导出请求排队,超过 N 个并发直接抛异常提示用户稍后再试。这个 N 要根据服务器内存来定,一般 2C4G 的机器并发导出控制在 5 个以内比较稳。毕竟导出本身是 IO 密集 + CPU 密集的任务,没必要让几十个任务互相抢资源。

6. 我最后想唠叨的几点

用了这么多年 PDF 生成,最大的体会是:方案要跟着需求走,不要盲目追求技术上的炫酷。如果你的报表只需要 A4 纸、表格、地址、签名这些传统元素,Flying Saucer 完全够用,而且能省掉一堆外部依赖。如果你的需求慢慢复杂到要动态图表、二维码、自定义页脚、封面页,那就要提前设计好架构,把 PDF 生成独立成模块,方便后续切换渲染引擎。

还有一点,不管用哪种方案,一定要在开发期留一个"直接看渲染后 HTML"的便利通道。我习惯在导出接口后面加一个 debug 参数,返回渲染后的 HTML 字符串而不是 PDF,浏览器打开就能看到模板效果。这个习惯帮我省下了大量调试时间,强烈建议试试。

另外别忽视模板命名和目录规划。一个项目里塞了十几个 PDF 模板之后,如果没有清晰的结构,光是找模板文件就够让人头疼。我的习惯是按业务域建子目录,一个业务域一个目录,模板名和接口名保持一致,后期维护起来省心很多。

最后再分享一个小技巧:PDF 导出这种功能,一定要在开发初期就把日志打全一点。模板名、数据模型 key、baseUrl、字体注册路径,这些关键信息都加到日志里。线上出问题的时候,这些日志能帮你省下大把排查时间。细节决定成败,PDF 导出尤其如此。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询