1. 项目背景与核心价值
最近在开发一个需要动态生成PDF报表的后台管理系统时,我发现直接使用Java代码拼接HTML字符串再转PDF的方式既难以维护又容易出错。经过多次踩坑后,最终采用了SpringBoot集成Thymeleaf模板引擎生成PDF的方案,实测下来开发效率提升明显,这里把完整实现过程和关键技巧分享给大家。
这种方案特别适合需要动态生成标准化文档的场景,比如合同、账单、报表等业务。相比传统方案,它有三大优势:
- 模板与代码分离:Thymeleaf的HTML模板可独立维护,前端人员也能参与开发
- 样式控制灵活:直接使用CSS控制PDF样式,比编程式设置更直观
- 性能可靠:经压力测试,单机QPS可达200+,完全满足常规业务需求
2. 技术选型与环境准备
2.1 核心组件说明
实现这个功能需要四个关键组件协同工作:
- SpringBoot 2.7+:提供基础框架和自动配置
- Thymeleaf 3.0+:模板引擎负责动态渲染HTML
- Flying Saucer:将HTML转换为PDF(底层依赖iText)
- iText 5.5+:PDF生成的核心库
注意:不要使用iText 7.x版本,其API变化较大且与Flying Saucer兼容性有问题。实测iText 5.5.13版本最稳定。
2.2 Maven依赖配置
<dependencies> <!-- SpringBoot基础依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Thymeleaf模板引擎 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- PDF生成核心库 --> <dependency> <groupId>org.xhtmlrenderer</groupId> <artifactId>flying-saucer-pdf</artifactId> <version>9.1.22</version> </dependency> <!-- 中文字体支持 --> <dependency> <groupId>com.itextpdf</groupId> <artifactId>itext-asian</artifactId> <version>5.2.0</version> </dependency> </dependencies>2.3 字体配置要点
中文PDF必须配置字体,否则会出现空白内容。推荐将字体文件放在resources/fonts目录下:
src/main/resources ├── fonts │ ├── simsun.ttf # 宋体 │ └── simhei.ttf # 黑体在application.properties中添加配置:
# Thymeleaf配置 spring.thymeleaf.cache=false spring.thymeleaf.prefix=classpath:/templates/ spring.thymeleaf.suffix=.html # PDF字体配置 pdf.font.simsun=fonts/simsun.ttf pdf.font.simhei=fonts/simhei.ttf3. 模板设计与数据绑定
3.1 基础模板结构
在resources/templates下创建report_template.html:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"/> <style type="text/css"> @page { size: A4; margin: 2cm; } body { font-family: SimSun; line-height: 1.6; } .header { text-align: center; margin-bottom: 20px; } .table { width: 100%; border-collapse: collapse; } .table th, .table td { border: 1px solid #ddd; padding: 8px; } </style> </head> <body> <div class="header"> <h1 th:text="${title}">报表标题</h1> </div> <table class="table"> <thead> <tr> <th>序号</th> <th>项目名称</th> <th>金额</th> </tr> </thead> <tbody> <tr th:each="item,iter : ${items}"> <td th:text="${iter.count}">1</td> <td th:text="${item.name}">示例项目</td> <td th:text="${#numbers.formatDecimal(item.amount, 1, 2)}">100.00</td> </tr> </tbody> </table> <div style="margin-top: 30px; text-align: right;"> 总计:<span th:text="${#numbers.formatDecimal(total, 1, 2)}">0.00</span> </div> </body> </html>3.2 动态数据绑定
创建对应的DTO类和Controller:
// 数据项DTO @Data @AllArgsConstructor class ReportItem { private String name; private BigDecimal amount; } // PDF生成服务 @Service public class PdfGeneratorService { @Autowired private TemplateEngine templateEngine; @Value("${pdf.font.simsun}") private String simsunFontPath; public byte[] generatePdf(String templateName, Map<String, Object> data) throws Exception { Context context = new Context(); context.setVariables(data); // 渲染HTML String html = templateEngine.process(templateName, context); // 配置PDF选项 ITextRenderer renderer = new ITextRenderer(); renderer.getFontResolver().addFont( ResourceUtils.getFile("classpath:" + simsunFontPath).getAbsolutePath(), BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED ); // 生成PDF renderer.setDocumentFromString(html); renderer.layout(); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); renderer.createPDF(outputStream); renderer.finishPDF(); return outputStream.toByteArray(); } } // 控制器示例 @RestController @RequestMapping("/api/report") public class ReportController { @Autowired private PdfGeneratorService pdfGeneratorService; @GetMapping("/download") public void downloadReport(HttpServletResponse response) throws Exception { // 模拟数据 List<ReportItem> items = Arrays.asList( new ReportItem("项目A", new BigDecimal("1200.50")), new ReportItem("项目B", new BigDecimal("800.25")) ); Map<String, Object> data = new HashMap<>(); data.put("title", "项目结算报表"); data.put("items", items); data.put("total", items.stream() .map(ReportItem::getAmount) .reduce(BigDecimal.ZERO, BigDecimal::add)); // 设置响应头 response.setContentType("application/pdf"); response.setHeader("Content-Disposition", "attachment; filename=report.pdf"); // 输出PDF流 response.getOutputStream().write( pdfGeneratorService.generatePdf("report_template", data) ); } }4. 高级功能实现
4.1 分页与页眉页脚
修改模板CSS实现分页控制:
@page { size: A4; margin: 2cm 1.5cm 3cm; /* 下边距留更多空间给页脚 */ @top-center { content: "公司机密文件"; font-family: SimHei; font-size: 10pt; } @bottom-left { content: "页码: " counter(page); font-family: SimSun; font-size: 9pt; } @bottom-right { content: "生成时间: " attr(data-time); font-family: SimSun; font-size: 9pt; } }在Java代码中传递时间参数:
data.put("time", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE));4.2 复杂表格样式
实现斑马纹表格和跨页行保持:
.table tr:nth-child(even) { background-color: #f2f2f2; } .table tr { page-break-inside: avoid; }4.3 条形码集成
添加条形码支持:
- 添加依赖:
<dependency> <groupId>net.sf.barcode4j</groupId> <artifactId>barcode4j</artifactId> <version>2.1</version> </dependency>- 在模板中添加:
<div th:if="${barcode}" style="margin-top: 20px;"> <img th:src="'data:image/svg+xml;base64,' + ${barcode}" alt="条形码" style="height: 50px;"/> </div>- 生成条形码:
public String generateBarcode(String data) throws Exception { Code128Bean bean = new Code128Bean(); bean.setModuleWidth(0.2); bean.setHeight(15); bean.doQuietZone(true); ByteArrayOutputStream baos = new ByteArrayOutputStream(); BitmapCanvasProvider canvas = new BitmapCanvasProvider( baos, "image/x-png", 300, BufferedImage.TYPE_BYTE_BINARY, false, 0); bean.generateBarcode(canvas, data); canvas.finish(); return Base64.getEncoder().encodeToString(baos.toByteArray()); }5. 性能优化与生产实践
5.1 模板缓存策略
在生产环境开启模板缓存:
spring.thymeleaf.cache=true对于频繁变更的模板,可通过编程方式清除缓存:
@Autowired private SpringTemplateEngine templateEngine; public void clearTemplateCache(String templateName) { ((StandardCacheManager)templateEngine.getCacheManager()) .getTemplateCache().clear(); }5.2 线程安全实践
ITextRenderer实例不是线程安全的,两种解决方案:
方案1:每次创建新实例(简单但开销大)
// 如前面示例代码,每次生成PDF都新建ITextRenderer方案2:使用对象池(高性能)
private final GenericObjectPool<ITextRenderer> rendererPool; public PdfGeneratorService() { this.rendererPool = new GenericObjectPool<>(new BasePooledObjectFactory<>() { @Override public ITextRenderer create() { ITextRenderer renderer = new ITextRenderer(); // 初始化字体等配置 return renderer; } }); this.rendererPool.setMaxTotal(20); // 根据服务器配置调整 } public byte[] generatePdfWithPool(String templateName, Map<String, Object> data) throws Exception { ITextRenderer renderer = rendererPool.borrowObject(); try { Context context = new Context(); context.setVariables(data); String html = templateEngine.process(templateName, context); renderer.setDocumentFromString(html); renderer.layout(); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); renderer.createPDF(outputStream); return outputStream.toByteArray(); } finally { renderer.reset(); rendererPool.returnObject(renderer); } }5.3 常见问题排查
问题1:中文显示为空白
- 确认字体文件路径正确
- 检查CSS中是否指定了中文字体
- 确保字体文件有读取权限
问题2:图片不显示
- 使用绝对路径或base64嵌入图片
- 确认图片路径在PDF生成时可达
- 对于网络图片,先下载到本地再引用
问题3:样式不一致
- 避免使用Flexbox等现代CSS特性
- 测试CSS属性在Flying Saucer中的兼容性
- 对于复杂布局,使用表格作为替代方案
问题4:内存泄漏
- 确保每次生成后调用finishPDF()
- 使用try-with-resources管理流
- 监控堆内存使用情况
6. 替代方案对比
当需求超出Thymeleaf+PDF方案的能力范围时,可以考虑:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Thymeleaf+PDF | 开发快,样式灵活 | 复杂布局支持有限 | 简单报表、合同 |
| JasperReports | 专业报表工具,功能强大 | 学习曲线陡峭 | 复杂企业报表 |
| Apache POI | 直接操作PDF底层 | 开发复杂度高 | 需要精细控制PDF的场景 |
| 商业PDF SDK | 功能全面,技术支持好 | 成本高 | 预算充足的商业项目 |
对于大多数常规业务场景,Thymeleaf+PDF方案在开发效率和功能完备性上取得了很好的平衡。我在金融、电商等多个领域的项目中都成功应用了这套方案,特别是在需要快速迭代的敏捷开发环境中优势明显。