Spring Boot PDF导出实战:从选型到性能优化 大家做 Spring Boot 项目大概率都会碰到一个看上去很莫名但排期又很紧的需求来一份 PDF 导出。我第一次接到这种需求的时候场景特别具体——客户要把系统里的订单确认单下载下来打印盖章然后走线下流程。当时第一反应是用前端window.print()去打一个页面但被对方一句“要像正规格式文件那样”怼了回来。后来我才意识到从服务端生成 PDF 才是这一类场景的标准答案而且一旦摸清套路后面再遇到报表、发票、对账单全都是同一套代码逻辑的变体。这篇就把 Spring Boot 做 PDF 导出的完整思路写出来包括技术选型、最小可跑工程、中文字体修复、数据渲染、性能优化还有我踩过的那些坑。这个主题适合两类人一类是刚接触 Spring Boot被“导出 PDF”需求卡住需要一份能直接抄作业的代码另一类是已经能导出简单 PDF但遇到中文乱码、大列表内存溢出、文件名编码问题想系统性地补一课。我会尽量把每个关键步骤背后的原因也讲清楚因为这类问题在网上搜到的碎片化答案实在太多了真正能让人举一反三的内容反而很少。1. 为什么服务端要自己生成 PDF订单、报表、发票背后的真实需求1.1 项目里最常见的三种 PDF 场景我观察到绝大多数项目的 PDF 导出需求是这几种第一种是业务单据比如订单确认单、发货单、报价单特点是字段固定、版式要求严格客户可能要打印出来签字盖章第二种是统计报表比如每月的销售汇总、用户活跃报表特点是数据量大、表格为主经常还要按时间范围筛选第三种是合同或协议类文件这类往往不只是导出还涉及模板套用、水印、电子签章复杂度会高一个级别。这三种场景的共同点是什么都是“数据在服务端格式要固定结果要可存档、可打印”。前端打印的方式面对这种需求其实很脆弱不同浏览器打印出来的分页效果不一致用户随便缩放一下版式就乱了而且你没办法保证打印出来的文件在公司流程里具备统一的存档格式。所以后端直接生成 PDF 文件是最稳的交付形态。1.2 服务端生成相比前端打印/Word 导出的优势有人可能会问用户把数据导出成 Excel 或者 Word 不也挺好确实Excel 适合做数据二次加工Word 适合写长文档。但这两者在“表单单据”和“正式报表”场景下都有问题格式容易被人为修改跨设备打开字体不一致而且客户那边的业务系统未必都装了 Office。PDF 最大的优点就是“所见即所得”在任何设备上打开的样子都一样也不容易被误改。服务端生成 PDF 的另一个好处是逻辑集中。比如权限校验、数据脱敏、金额格式化这些规则放在后端一起处理前端只需要接收一个文件流然后触发下载。如果将来要对接短信通知、邮件推送、归档系统后端直接把这个 PDF 文件作为附件发出去就行前端完全不用参与。所以总结下来服务端生成 PDF 不是一个“要不要做”的问题而是一旦遇到正式业务场景就绕不开的基础能力。2. 方案选型iText、OpenPDF、PDFBox、JasperReports、HTML 转 PDF 到底选谁2.1 每条技术路线的脾气性格Spring Boot 后端生成 PDF主流方案大概五条路iText、OpenPDF、PDFBox、JasperReports、HTML 转 PDF比如 Flying Saucer、openhtmltopdf、wkhtmltopdf。iText 是 Java 生态里资历最老的 PDF 库API 能力非常完整从最简单的文本输出到复杂的表单填充、数字签名、水印、二维码都能做。但它从 5.x 之后分了两条线老版本 iText5 是 AGPL 协议商业闭源项目要买商业许可iText7 是重写后的版本模块化设计功能更现代同样涉及商业授权问题。很多团队在内部系统或者对协议不敏感的项目里直接用 iText5但如果要发布商业产品授权这块一定得提前看清楚。OpenPDF 是 iText 的一个开源分支API 和 iText5 高度兼容代码风格几乎无缝迁移协议是 LGPL/MPL比 AGPL 友好很多。如果你的需求是“生成带表格的 PDF、加图片、加页眉页脚”OpenPDF 完全够用而且不用担心商业授权问题。我在很多项目里最终选的就是 OpenPDF参数和我下面要写的代码在 iText5 下也基本通用。PDFBox 是 Apache 基金会的项目协议是 Apache 2.0非常宽松。但 PDFBox 的定位更偏底层它的强项是 PDF 解析、表单字段填写、文本提取、对已有 PDF 做处理。如果你想从零开始“画”一份排版精致的报表PDFBox 需要自己计算坐标和排版开发成本明显更高。所以它适合做 PDF 的工具类操作不适合做主流的报表生成方案。JasperReports 是专门的报表引擎自带可视化模板设计器还支持图表、子报表、条件样式能做出非常复杂的报表。但代价是学习曲线比较陡模板文件本身也是一套 XML 体系团队里最好有人专门负责模板维护。如果你的项目是一个 BI 系统或者后台管理端报表种类很多、样式很复杂JasperReports 值得考虑如果只是导出几个固定单据用它会显得有点重。HTML 转 PDF 这条路思路是先用 HTML/CSS 写好模板再用工具把 HTML 渲染成 PDF。它的好处非常明显前端同学可以无缝参与排版能力强CSS 能实现各种复杂布局。我用的比较多的是 openhtmltopdf它底层是 Flying Saucer 的升级版对 CSS 2.1 支持得不错。但要小心两个问题一是对 CSS3 新特性的支持有限复杂的 Flex/Grid 布局很容易翻车二是分页控制没有 iText 那么精细“某一页固定有多少行”这种需求实现起来很别扭。2.2 一张表把选型看明白方案开源协议上手难度中文支持报表排版适合场景iText5AGPL商用需授权中等需配置字体强单据、水印、签名、二维码iText7AGPL商用需授权中等偏上需配置字体强复杂 PDF 处理模块化OpenPDFLGPL/MPL中等需配置字体强iText5 的替代协议更友好PDFBoxApache 2.0中高支持较底层弱PDF 解析、表单填充、文本提取JasperReportsLGPL较高支持很强复杂报表、图表、模板化报表HTML 转 PDF视具体组件而定低天然支持中排版灵活、适合前端参与的场景2.3 我为什么多数项目里选 OpenPDF / iText5 系就我个人的经验中小型项目里 70% 的 PDF 导出需求都集中在“生成一份格式规整、可打印、可存档的表格类文件”上并不需要报表引擎那种重量级能力。OpenPDF / iText5 系的 API 足够直接创建一个 Document往里面加段落、加表格最后输出到文件流整个心智模型非常朴素团队里任何 Java 开发都能快速接手。另外iText 系的底层排版能力几乎没对手。你可以精确控制页面大小、页边距、单元格宽度、字体大小、图片位置这些在 HTML 转 PDF 时往往要绕很多弯路。而且它的生态很成熟网上搜到的问题案例基本覆盖了所有常见坑出了问题你能很快找到答案。所以下面的实战代码我都以 OpenPDF 为例和 iText5 的 API 完全兼容你如果已经在用 iText5直接照着写没问题。3. 最小的可用版本Spring Boot 工程里让第一个 PDF 从浏览器下载3.1 Maven 依赖和目录准备我们先用一个最小的工程跑通“浏览器访问接口下载到一个 PDF 文件”。打开pom.xml添加 OpenPDF 依赖dependency groupIdcom.github.librepdf/groupId artifactIdopenpdf/artifactId version1.3.30/version /dependency如果你用的是 iText5就把坐标换成dependency groupIdcom.itextpdf/groupId artifactIditextpdf/artifactId version5.5.13.3/version /dependency这两个坐标下面的 API 写起来几乎一样都是com.lowagie.text.Document/com.itextpdf.text.Document这套OpenPDF 保留的包名是com.lowagie.text.*iText5 是com.itextpdf.text.*导入的时候注意区分就行。3.2 PdfService从 Document 到 PdfWriter 的固定套路新建一个PdfService核心逻辑就四步创建一个 Document、创建一个 PdfWriter 并关联输出流、打开 Document、往里填充内容后关闭。public void exportSimplePdf(OutputStream outputStream) throws Exception { // 1. 创建文档对象A4 纸大小页边距左右上下各 50、50、50、50 Document document new Document(PageSize.A4, 50, 50, 50, 50); // 2. 创建 PdfWriter绑定输出流 PdfWriter writer PdfWriter.getInstance(document, outputStream); // 3. 打开文档开始写入 document.open(); // 4. 写入内容 document.add(new Paragraph(Hello PDF, Spring Boot!)); // 5. 关闭文档数据才真正 flush 到输出流 document.close(); }这段代码是整个 PDF 导出的骨架。注意最后那个document.close()一定不能省很多人导出后文件是空的或者打不开十有八九就是流没关好。Document 关闭时会触发资源释放和内容输出漏掉它等于白跑一趟。3.3 Controller 里响应头不要填错Content-Type 和 Content-Disposition接着写一个 Controller把 PDF 直接写到HttpServletResponse的输出流里RestController RequestMapping(/api/pdf) public class PdfController { private final PdfService pdfService; public PdfController(PdfService pdfService) { this.pdfService pdfService; } GetMapping(/simple) public void exportSimple(HttpServletResponse response) throws Exception { // 告诉浏览器这是一个 PDF 文件 response.setContentType(application/pdf); // 告诉浏览器以附件形式下载而不是直接在浏览器里打开 String fileName simple.pdf; response.setHeader(Content-Disposition, attachment; filename\ fileName \); pdfService.exportSimplePdf(response.getOutputStream()); } }两个响应头的分工要搞清楚Content-Type告诉浏览器“这个响应的内容是 PDF”Content-Disposition告诉浏览器“这是一个附件主文件名叫什么”。如果只设置Content-Type不设置Content-Disposition不同浏览器表现不一样有的会直接内嵌打开有的会乱码。所以这两个头我建议每次都成对设置。3.4 浏览器验证直接访问下载地址启动 Spring Boot浏览器访问http://localhost:8080/api/pdf/simple正常情况下会直接下载一个simple.pdf打开后能看到一行 “Hello PDF, Spring Boot!”。这一步跑通之后PDF 导出这个需求的地基就算是打好了。后面所有的复杂功能都是在这个骨架之上往 Document 里添加更丰富的内容。我自己带新人的时候都会要求先把这一步跑通因为后续很多问题本质上是“基础骨架没搭对”导致的排查起来反而更乱。4. 内容装配段落、表格、图片、页眉页脚怎么组合得像一份正式文件4.1 排版的基本单位Paragraph、Font、ChunkPDF 内容在 iText 系里是典型的树形结构Document 下面挂 ParagraphParagraph 下面可以挂 ChunkFont 则控制文字的字体、字号、颜色、样式。Font titleFont new Font(FontFactory.getFont(SansSerif).getBaseFont(), 18, Font.BOLD); Paragraph title new Paragraph(订单确认单, titleFont); title.setAlignment(Element.ALIGN_CENTER); title.setSpacingAfter(20); document.add(title);实际排版时我的习惯是所有用到的 Font 提前声明成变量而不是每次 new Paragraph 的时候都现写。这样能保证全文标题、正文字体统一后续要调字号只改一个地方就行。setSpacingAfter控制段落后面的间距这个属性在排版里非常重要否则段落挤在一起会显得很业余。4.2 表格是报表导出的主角PdfPTable 列宽、合并、背景色报表场景里最核心的内容就是表格。iText 里的PdfPTable用起来并不复杂但有几个习惯值得养成PdfPTable table new PdfPTable(4); table.setWidthPercentage(100); table.setWidths(new float[]{2f, 3f, 2f, 2f}); // 表头单元格 PdfPCell headerCell new PdfPCell(new Phrase(订单编号, contentFont)); headerCell.setBackgroundColor(BaseColor.LIGHT_GRAY); headerCell.setHorizontalAlignment(Element.ALIGN_CENTER); table.addCell(headerCell); // 其他表头单元格同理... // 数据行 table.addCell(new Phrase(SO2024001, contentFont)); table.addCell(new Phrase(小米智能门锁, contentFont)); table.addCell(new Phrase(399.00, contentFont)); table.addCell(new Phrase(2024-03-18, contentFont)); document.add(table);几个关键点setWidthPercentage(100)让表格占满整个正文区域否则表格默认宽度可能比页面窄很多。setWidths按比例分配每一列宽度。比如{2f, 3f, 2f, 2f}表示第一列占 2 份、第二列占 3 份这样你就能控制商品名称列比订单编号列宽一些。单元格合并用setColspan比如跨两列表头PdfPCell mergeCell new PdfPCell(new Phrase(合并单元格, contentFont)); mergeCell.setColspan(2); mergeCell.setHorizontalAlignment(Element.ALIGN_CENTER); table.addCell(mergeCell);这里有个很坑的细节PdfPTable是按“添加单元格”的顺序从左到右填的如果你先 add 了一个 colspan2 的单元格接下来要继续 add 两个单元格来补齐同一行的剩余列否则它会自动换行导致表格行错位。4.3 图片与 Logo 的插入套路正式单据基本都要放公司 Logo 或二维码。iText 里插入图片的核心是Image对象Image logo Image.getInstance(/logo.png); logo.scaleToFit(120, 50); logo.setAlignment(Element.ALIGN_LEFT); document.add(logo);注意Image.getInstance的参数可以是文件路径、URL也可以是byte[]。如果图片是从数据库 Blob 字段读出来的直接传byte[]最方便。scaleToFit(120, 50)是等比例缩放图片避免原图太大把页面撑爆。二维码生成在业务单据里非常常见配合 ZXing 库生成一个BufferedImage再转成 OpenPDF 的Image插到单据角落BitMatrix bitMatrix new QRCodeWriter().encode(http://example.com/order/123, BarcodeFormat.QR_CODE, 200, 200); BufferedImage qrImage MatrixToImageWriter.toBufferedImage(bitMatrix); Image qr Image.getInstance(qrImage, null); qr.scaleToFit(80, 80);4.4 页眉页脚与页码事件PdfPageEventHelper页眉页脚不是普通内容它跟页面结构绑定需要注册一个页面事件。最常见的是在每页底部居中显示“第 X 页 / 共 Y 页”。public class FooterPageEvent extends PdfPageEventHelper { private final Font footerFont; public FooterPageEvent(Font footerFont) { this.footerFont footerFont; } Override public void onEndPage(PdfWriter writer, Document document) { Rectangle pageSize writer.getPageSize(); String footerText String.format(第 %d 页 / 共 %d 页, writer.getPageNumber(), writer.getPageNumber()); ColumnText.showTextAligned( writer.getDirectContent(), Element.ALIGN_CENTER, new Phrase(footerText, footerFont), (pageSize.getLeft() pageSize.getRight()) / 2, pageSize.getBottom(30), 0); } }然后在创建 PdfWriter 时注册PdfWriter writer PdfWriter.getInstance(document, outputStream); writer.setPageEvent(new FooterPageEvent(footerFont));这里有一个小技巧onEndPage是在每一页结束的时候触发的所以哪怕内容自动分页了每一页也都会有页脚。页眉、水印也是同样的原理用writer.getDirectContent()拿到底层内容流然后画文字或图形。水印通常会画在内容下层使用getDirectContentUnder()而不是getDirectContent()这样水印不会盖住正文。5. 中文乱码是新手的高频事故我复现并修复完整路径5.1 事故现场PDF 里中文全部变成黑方块第一次在 Spring Boot 里导出 PDF 时我写完中文段落满心欢喜地下载打开结果 PDF 里的中文全部变成一排排的小方框英文和数字都正常。当时第一反应是“编码不对”于是把字符串new String(str.getBytes(UTF-8), UTF-8)这类代码来回试完全没用。后来才理解问题根本不在字符串编码而在字体。5.2 根因iText 默认字体不包含中文字形iText 系默认的字体是 Helvetica这套字体只包含拉丁字符集没有中文字形。PDF 本质上是一个“字体字形”的描述文件当文字引用的字体里没有对应的字形时查看器就会显示一个占位方块也就是我们看到的乱码。所以解决方案只有一条给 PDF 指定一个包含中文字形的字体。5.3 两种修法内置字体包 vs 系统字体注册修复方式有两种。第一种是使用 iText 自带的亚洲字体包通过 iTextAsian 的方式指定BaseFont baseFont BaseFont.createFont(STSong-Light, UniGB-UCS2-H, BaseFont.NOT_EMBEDDED); Font font new Font(baseFont, 12, Font.NORMAL);这种方式不需要额外的字体文件但不同环境的渲染效果可能不太一样字体样式也很有限。第二种方式更推荐把字体文件放进项目的resources/fonts目录然后通过类路径加载BaseFont baseFont BaseFont.createFont(/fonts/SourceHanSansCN-Regular.ttf, BaseFont.IDENTITY_H, BaseFont.NOT_EMBEDDED); Font font new Font(baseFont, 12, Font.NORMAL);这段代码的意思是从 classpath 根目录下的fonts目录读取思源黑体字体文件IDENTITY_H表示按 Unicode 编码映射字形NOT_EMBEDDED表示不把字体文件嵌入 PDF。这里我建议把NOT_EMBEDDED改成BaseFont.EMBEDDED也就是把字体嵌入 PDF这样即使打开 PDF 的机器上没装这个字体显示也不会乱。代价是 PDF 文件体积会大一些但为了兼容性是值得的。我把BaseFont.createFont这一步封装成一个工具方法避免到处重复public class PdfFontUtil { private static Font normalFont; private static Font boldFont; public static Font getNormalFont() { if (normalFont null) { normalFont createFont(12, Font.NORMAL); } return normalFont; } public static Font getBoldFont() { if (boldFont null) { boldFont createFont(12, Font.BOLD); } return boldFont; } private static Font createFont(float size, int style) { try { BaseFont baseFont BaseFont.createFont(/fonts/SourceHanSansCN-Regular.ttf, BaseFont.IDENTITY_H, BaseFont.EMBEDDED); return new Font(baseFont, size, style); } catch (Exception e) { throw new RuntimeException(字体加载失败, e); } } }注意如果你把“宋体”这几个字直接写死在代码里而部署服务器上根本没有安装宋体那导出的 PDF 在本地打开依然会乱。为了避免这种环境依赖我的习惯是直接把开源字体文件放进项目的 resources/fonts 目录打包进 jar谁运行都不依赖操作系统字体。字体版权也要留意商业项目优先使用思源黑体、文泉驿等开源许可字体不要轻易打包微软雅黑这类商业字体。5.4 文字加粗和字体文件的关系用自定义字体后还有一个隐藏坑new Font(baseFont, 12, Font.BOLD)并不会真正加粗因为思源黑体 Regular 字重里本来就没有粗体字形iText 不会自动帮你做一个假粗体效果最终显示结果跟普通字体没区别。想要真正的粗体文字需要单独引入一个粗体字体文件BaseFont boldBaseFont BaseFont.createFont(/fonts/SourceHanSansCN-Bold.ttf, BaseFont.IDENTITY_H, BaseFont.EMBEDDED); Font boldFont new Font(boldBaseFont, 12, Font.NORMAL);这是我踩过最深的坑之一。之前做合同导出标题用Font.BOLD设置后出来跟正文一模一样我还以为是字体没生效排查了半天才发现是“字重”这个环节的问题。正确做法是为不同字重分别加载字体文件而不是靠 style 参数解决。6. 从数据库到 PDF查询、循环、渲染一个真实报表6.1 Service 层与 Mapper 层的基本结构前面只是静态内容演示。实际业务里PDF 里的表格数据绝大多数来自数据库。我们用一个“员工报表导出”的例子来演示完整链路Controller 接收查询参数Service 调用 Mapper 查询数据库然后把查询结果渲染到 PDF。GetMapping(/employee/report) public void exportEmployeeReport( RequestParam(required false) String department, HttpServletResponse response) throws Exception { String fileName 员工报表.pdf; response.setContentType(application/pdf); response.setHeader(Content-Disposition, attachment; filename*UTF-8 URLEncoder.encode(fileName, UTF-8).replaceAll(\\, %20)); ListEmployeeDTO employees employeeService.queryEmployees(department); pdfService.exportEmployeeReport(response.getOutputStream(), employees); }Service 里做的事情其实很固定查询、格式化、填充表格。不要想着在 Mapper XML 里把金额格式化好格式化是表现层的事让 SQL 只做数据查询。6.2 数据格式化金额、日期、空值数据库里查出来的原始字段一般不能直接往 PDF 里放。金额是BigDecimal日期是LocalDateTime还有可能某些字段是 null。我的习惯是统一在渲染前做一次 DTO 转换。String formatAmount(BigDecimal amount) { if (amount null) { return 0.00; } return amount.setScale(2, RoundingMode.HALF_UP).toString(); } String formatDateTime(LocalDateTime dateTime) { if (dateTime null) { return ; } return dateTime.format(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss)); }表格循环填充的时候把格式化后的字符串直接放入单元格PdfPTable table new PdfPTable(5); table.setWidthPercentage(100); table.setWidths(new float[]{2f, 3f, 2f, 2f, 3f}); String[] headers {工号, 姓名, 部门, 薪资, 入职时间}; for (String header : headers) { PdfPCell cell new PdfPCell(new Phrase(header, boldFont)); cell.setBackgroundColor(BaseColor.LIGHT_GRAY); cell.setHorizontalAlignment(Element.ALIGN_CENTER); table.addCell(cell); } for (EmployeeDTO employee : employees) { table.addCell(new Phrase(employee.getEmployeeNo(), normalFont)); table.addCell(new Phrase(employee.getName(), normalFont)); table.addCell(new Phrase(employee.getDepartment(), normalFont)); table.addCell(new Phrase(formatAmount(employee.getSalary()), normalFont)); table.addCell(new Phrase(formatDateTime(employee.getHireDate()), normalFont)); } document.add(table);6.3 当一个查询结果是几万行先别急着一股脑 List如果数据量只有几百行直接把结果 List 全部加载进内存没问题。但如果一张表有几十万行一次性SELECT *然后塞进内存再全部渲染到 PdfPTable内存很容易扛不住。后面第八节我会专门讲批量导出的性能优化这里先给一个原则不要为了导出功能把整表数据读进 JVM。能分页就分页能在数据库层过滤就在数据库层过滤。一个折中做法是限制最大导出量。在 Controller 入口判断如果查询结果超过比如 5 万条直接拒绝导出并返回提示让用户增加筛选条件。这种做法虽然“不高级”但在很多企业内部系统里非常实用能避免大量资源浪费。6.4 导出的文件要存档服务端落盘还是只给流有的需求不只是下载还要求“导出过的文件要在服务器留底”。这种情况下就不能只往response.getOutputStream()写还要同时写一份到磁盘。String filePath /data/pdf/employee_report_20240318.pdf; try (InputStream pdfStream exportEmployeeReportToByteArray(employees); FileOutputStream fileOut new FileOutputStream(filePath)) { pdfStream.transferTo(fileOut); }或者更简单一点先写文件再返回下载File file new File(filePath); try (FileOutputStream fileOut new FileOutputStream(file)) { Document document new Document(PageSize.A4); PdfWriter.getInstance(document, fileOut); document.open(); // 渲染内容... document.close(); } response.setContentType(application/pdf); response.setHeader(Content-Disposition, attachment; filename\employee_report.pdf\); Files.copy(file.toPath(), response.getOutputStream());这种方式适合一次性导出后还需要归档的场景。但要注意给文件命名时加上日期或唯一 ID避免覆盖同时定期清理临时文件。7. 前端下载与文件名中文乱码axios blob 方案最稳7.1 后端文件名编码的一个小细节后端Content-Disposition里的文件名如果直接放中文不同浏览器解析结果不一样经常会出现“下载后的文件名变成一堆 %E4%B8%AD%E6%96%87”这种情况。正确做法是使用 RFC 5987 规定的格式filename*UTF-8URL编码后的文件名。String fileName 员工报表.pdf; String encodedFileName URLEncoder.encode(fileName, UTF-8).replaceAll(\\, %20); response.setHeader(Content-Disposition, attachment; filename*UTF-8 encodedFileName);URLEncoder.encode默认把空格编码成但在 URL 参数和 Content-Disposition 里空格要用%20所以需要replaceAll(\\, %20)这一步。这算是一个比较隐蔽的细节很多老代码直接返回原始中文也能在部分浏览器上工作但换一个浏览器就露馅。7.2 前端用 window.open 还是 blob 下载如果导出接口是 GET 请求且不需要带 Authorization 请求头window.open(/api/pdf/employee/report?departmentIT)是最省事的做法。但实际项目中很多接口需要登录令牌需要用 axios 带请求头这时候window.open就没法处理了得用 blob 方式axios({ url: /api/pdf/employee/report, method: get, params: { department: IT }, responseType: blob, headers: { Authorization: Bearer token } }).then(response { const blob new Blob([response.data], { type: application/pdf }); const url URL.createObjectURL(blob); const link document.createElement(a); link.href url; // 从响应头解析文件名 const disposition response.headers[content-disposition]; let fileName download.pdf; if (disposition disposition.includes(filename*)) { const match disposition.match(/filename\*UTF-8(.)/i); if (match) { fileName decodeURIComponent(match[1]); } } link.download fileName; link.click(); URL.revokeObjectURL(url); });这里response.data是 Blob一定要在请求上配responseType: blob否则 axios 会把它当文本处理文件内容就损坏了。7.3 我踩过的文件名解码坑用response.headers[content-disposition]解析文件名时坑在于如果后端接口做了跨域CORS浏览器默认不暴露Content-Disposition响应头给前端 JS。你需要在后端 CORS 配置里把这个头加到exposedHeadersConfiguration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:3000) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .exposedHeaders(Content-Disposition); } }如果没有配置exposedHeaders前端的response.headers[content-disposition]会一直为 undefined解析逻辑不生效下载到本地就成了download.pdf这个默认名。这个坑看起来很不起眼但排查起来非常费时间因为后端明明设置了这个头前端怎么拿都是空。后来我学到的经验是文件名涉及跨域时要么配置exposedHeaders要么后端干脆把文件名放在响应体 JSON 里返回前端从 JSON 里读——后者更省心但需要和接口约定好。另一个小坑是decodeURIComponent。前端拿到的是后端URLEncoder.encode编码后的文件名如果不解码下载的文件名会是一串%E5%91%98%E5%B7%A5...。这个我用过一次之后就成了肌肉记忆了。8. 大批量导出的性能与内存优化别把 JVM 压垮8.1 内存为什么爆byte[] 中转很贵我看到很多人的第一版导出代码长这样先导出一个byte[]再返回ResponseEntitybyte[]。在数据量小的时候没问题但到了几万行表格byte[]在堆内存里占用的空间非常可观再来几个并发请求JVM 直接出 OutOfMemoryError。// 不推荐大数据量时容易内存溢出 byte[] pdfBytes pdfService.exportEmployeeReport(employees); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_PDF) .header(Content-Disposition, attachment; filename\employee.pdf\) .body(pdfBytes);正确的思路是不要让 PDF 内容在内存中完整累积直接把PdfWriter关联到HttpServletResponse.getOutputStream()边生成边写。8.2 分页查询 边写边刷出直接写 ServletOutputStream即使直接把流写出去如果查询结果还是一个大 List数据量一大内存照样危险。更稳妥的组合拳是数据库分页查询每查出一页就往 Document 里填充一页然后释放引用。public void exportLargeReport(OutputStream outputStream, String department) throws Exception { Document document new Document(PageSize.A4.rotate(), 30, 30, 30, 30); PdfWriter writer PdfWriter.getInstance(document, outputStream); document.open(); // 表头 PdfPTable table new PdfPTable(8); table.setWidthPercentage(100); table.setWidths(new float[]{2f, 3f, 2f, 2f, 3f, 3f, 2f, 3f}); String[] headers {工号, 姓名, 部门, 薪资, 入职时间, 手机号, 状态, 备注}; for (String header : headers) { PdfPCell cell new PdfPCell(new Phrase(header, boldFont)); cell.setBackgroundColor(BaseColor.LIGHT_GRAY); table.addCell(cell); } // 分页查询每页 2000 条 int pageSize 2000; int pageNum 1; while (true) { ListEmployeeDTO pageData employeeMapper.queryEmployeePage(department, pageNum, pageSize); if (pageData null || pageData.isEmpty()) { break; } for (EmployeeDTO employee : pageData) { table.addCell(new Phrase(employee.getEmployeeNo(), normalFont)); table.addCell(new Phrase(employee.getName(), normalFont)); table.addCell(new Phrase(employee.getDepartment(), normalFont)); table.addCell(new Phrase(formatAmount(employee.getSalary()), normalFont)); table.addCell(new Phrase(formatDateTime(employee.getHireDate()), normalFont)); table.addCell(new Phrase(employee.getPhone(), normalFont)); table.addCell(new Phrase(employee.getStatus(), normalFont)); table.addCell(new Phrase(employee.getRemark() null ? : employee.getRemark(), normalFont)); } // 每批数据加上一页避免表格无限长 document.newPage(); pageNum; } document.add(table); document.close(); }注意这里table对象要跨循环使用但document.add(table)放在最后。如果你在海量数据时每个单元格都新建Paragraph和Phrase对象数量会很大。可以复用字体对象new Phrase(text, normalFont)的normalFont是全局单例就不会有什么问题。使用PageSize.A4.rotate()可以把页面变横向数据列特别多、表格拉太宽的时候横向页面非常有用。这是处理“列数多导致表格挤成一团”的最直接手段。8.3 超时与异步导出一小时长任务别让浏览器等如果导出一份报表要几十秒甚至几分钟直接同步返回接口会遇到两个问题一是网关或服务器往往有超时限制请求超过一定时间就被切断了二是用户可能中途关闭页面浪费了这次计算。对这种耗时任务建议走异步任务 下载中心的模式。基本流程是用户点击导出后端把任务丢进线程池立即返回“任务已提交”后台线程生成 PDF 后写到磁盘或对象存储前端轮询任务状态任务完成后展示“点击下载”。Service public class AsyncExportService { private final ExecutorService executorService Executors.newFixedThreadPool(4); public Long submitExport(ExportRequest request) { Long taskId System.currentTimeMillis(); executorService.submit(() - { try (FileOutputStream fileOut new FileOutputStream(/data/export/ taskId .pdf)) { exportLargeReport(fileOut, request.getDepartment()); } catch (Exception e) { log.error(导出失败, e); } }); return taskId; } }这个模式在企业级系统里几乎是标配。用户不用盯着进度导好了之后会收到通知体验比同步等待好很多。8.4 超出阈值时的产品策略下载中心异步导出的配套方案是“下载中心”把导出记录存一张表字段包含文件名、文件路径、导出人、导出时间、状态处理中/成功/失败。前端提供一个页面展示历史导出记录用户可以随时下载。这个方案既解决了超时问题又给用户提供了“历史记录”的便利属于一箭双雕。我见过不少团队一开始用同步接口后来数据量上来之后不断放宽服务器超时时间甚至改成 10 分钟、20 分钟结果服务器线程被长任务占用其他接口全部卡死。最终还是要回到异步 下载中心这条路。如果你现在接的需求数据量可能超过 1 万行我建议直接上异步方案不要走同步导出再返工的弯路。9. 整套方案的坑清单和下一步扩展方向9.1 高频问题整理的对照表现象根因解决办法PDF 中文乱码默认字体不含中文字形注册中文字体文件建议打包进 jar下载文件名乱码Content-Disposition 编码不规范使用 RFC 5987 的 filename* 格式前端拿不到 Content-DispositionCORS 未暴露响应头配置 exposedHeaders(Content-Disposition)表格行错位colspan 单元格没补足列数按行填充时检查每行单元格总数大量数据导出时 OOM一次加载全部数据 byte[] 中转分页查询 直接写 ServletOutputStream自定义字体加粗无效普通字重文件里没有粗体字形单独加载 Bold 字体文件Linux 服务器导出乱码服务器未安装中文字体使用 resources/fonts 下的字体文件而非系统字体导出接口超时同步执行耗时过长异步任务 下载中心这份清单是我在项目里实际遇到过的不是背八股文。其中“中文字体”和“文件名编码”这两个问题几乎每个新接触 PDF 导出的同事都会踩一遍建议提前预防。9.2 进阶HTML 模板转 PDF、JasperReports、图表与电子签章当需求从“导出报表”升级到“导出合同”“导出招投标文件”时我建议评估 HTML 模板转 PDF 的方案。思路是先用 FreeMarker 渲染好 HTML再用 openhtmltopdf 转 PDF// FreeMarker 渲染 HTML 字符串再用 openhtmltopdf 转 PDF String html FreeMarkerTemplateUtils.processTemplateIntoString(template, dataModel); try (OutputStream outputStream response.getOutputStream()) { PdfRendererBuilder builder new PdfRendererBuilder(); builder.useFastMode(); builder.withHtmlContent(html, baseUrl); builder.toStream(outputStream); builder.run(); }HTML 转 PDF 对复杂排版、文本换行、图片嵌入的处理能力很强尤其是合同这种条目变化多、需要动态条件的文本比用 iText 一行一行 add 段落要高效得多。它的弱项是精确分页控制比如“表格头跨页重复”虽然 openhtmltopdf 支持thead重复但效果有时候不如 iText 的setHeaderRows那么顺手。如果你们项目里报表特别多、图表也多可以评估 JasperReports。模板设计器里能直接拖拽字段和图表数据源也和 Spring Boot 集成得不错。代价是模板维护有学习成本报表改版需要专人负责。图表导出我一般会先考虑 ECharts 生成图片再插入 PDF。后端用 headless 浏览器把图表渲染成图片或者用 JFreeChart 服务端生成图表图片再放进 PdfPTable 或者直接document.add(image)。电子签章则需要了解 iText 的 PdfStamper、数字证书签名相关 API非对称加密部分要另外规划。9.3 最后说一点我自己的习惯做 PDF 导出这个功能我现在的固定流程是这样先确认这份 PDF 的使用场景和阅读对象如果是表单类单据优先用 iText / OpenPDF如果是文本型合同优先用 HTML 模板转 PDF如果是复杂汇总报表用 JasperReports然后先把最小流程跑通再谈中文、字体、样式最后在写业务代码之前先想清楚数据量级和超时策略。另外我有一个推荐的做法把 PDF 生成独立成一个PdfExportService不要在 Controller 里堆业务逻辑。Service 只负责接收数据并输出到 OutputStream上层是走同步下载还是异步任务、要不要落盘都由 Controller 或调度层决定。这样将来换技术方案、调整模板改动范围都能控制在最小。字体文件我始终建议放进项目资源目录并配上注释说明字体来源和许可。团队里新同事接手时不用去猜服务器上装了什么字体也不用担心本地能跑、线上乱码这类环境差异问题。这些小习惯会让你少熬很多夜。