
做个Java报表真的很考验心态。一开始觉得不就是填数据、出个PDF嘛结果从模板设计到字体乱码、从数据源参数到文件流到处是坑。上一篇文章把JasperReport的基本流程和JRXML模板设计讲了这篇直接落地到Java端把我在项目里沉淀的一套工具类源码拆开揉碎讲清楚。这套工具类简单说就是封装了编译模板、填充数据、导出不同格式这三个最核心的动作外加文件下载、临时文件清理这类边角料你拿过去改改就能用不用再从零开始造轮子。1. 工具类到底在解决什么问题1.1 报表代码重复劳动的现实先聊个真实的场景。某次我接手一个后台管理系统里面密密麻麻躺着七八个报表导出接口每个接口的代码结构几乎一模一样加载JRXML文件、编译模板、组装参数Map、填充JRBeanCollectionDataSource、导出PDF或者Excel、设置响应头、最后删临时文件。代码量倒是不大但每个接口都这么干一遍问题就来了——想换模板路径策略的时候要改七八个地方想统一导出Excel加密码保护还得一路追下去。后来我实在看不下去了抽了一个周末把这堆重复逻辑收敛成一个工具类。这其实就是工具类存在的核心价值报表的职责链很长从模板加载到文件输出中间有明确固定的几步把这几个步骤封装成方法业务方只需要关心两个东西——模板在哪、数据是什么。剩下的编译、填充、导出、清理全是模板化操作。1.2 工具类设计思路的取舍一开始我参考过网上流传的各种封装方式有的把工具类做成纯静态方法有的做成Spring管理的单例Bean有的甚至直接做成微服务通过RPC调用来生成报表。我的选择是普通的final类加私有构造器全部用静态方法暴露。为什么不像做报表微服务那样搞因为这个工具类的定位就是项目内的基础组件不是独立部署的服务。把它做成Spring Bean也不是不行但静态方法有更明显的好处——不依赖Spring容器单元测试可以直接跑工具方法不会被业务逻辑连带影响。像编译模板这一步其实是无状态的编译完内存中生成的JasperReport对象可以被多个线程并发复用没必要搞成有状态的服务。整套设计围绕一个原则业务侧只和Repository数据、TemplatePath模板打交道工具类包揽剩下的体力活。1.3 为什么核心方法只有三个半如果去翻JasperReport的官方API你会发现它的能力远不止导出PDF实际上仅导出器就有十几个支持PDF、HTML、XLSX、CSV、ODT、DOCX等。但如果每个都往工具类里塞这个类就会变得特别臃肿。我的看法是工具类保持克制只封装三个高频动作编译JRXML把模板文件变成JasperReport对象填充数据把JasperReport对象加数据源变成JasperPrint对象导出文件把JasperPrint对象输出成PDF或Excel“半个”其实是处理文件下载时的公共逻辑严格来说不属于JasperReport的API但却是实际项目中跑不掉的那步——设置ContentType、处理文件名编码、流关闭。把它放进来工具类才真正完成了端到端的闭环。2. 源码拆解从模板到文件一路追着看下面这段是用得最多的编译方法。/** * 编译JRXML模板为JasperReport对象 * * param templatePath 模板路径支持classpath路径与项目外绝对路径 * return JasperReport编译后的报表对象 */ public static JasperReport compileReport(String templatePath) { try { // 优先从classpath加载 InputStream inputStream ReportTemplateLoader.load(templatePath); return JasperCompileManager.compileReport(inputStream); } catch (JRException e) { throw new ReportGenerationException(报表模板编译失败: templatePath, e); } }这里有两个细节值得单独拿出来说。第一个是模板的加载方式。JRXML文件最终发布时通常打到jar包或者classes目录里用new File(report.jrxml)这种方式是拿不到文件的必须通过ClassLoader去读classpath资源。我封装了一个ReportTemplateLoader内部优先用当前线程的上下文类加载器去getResourceAsStream找不到再用自己的ClassLoader这套双保险在SpringBoot和普通Web项目里都能稳跑。第二个是InputStream用完要记得关。上面代码为了简洁没有展示关闭逻辑实际项目里我建议用 try-with-resources 把流包起来不然文件打开多了在Windows环境下很容易爆出“文件被占用”的诡异问题。2.1 编译完成的JasperReport做好缓存这里需要聊一个减少重复编译的重要升级缓存编译结果。编译JRXML模板这一步是有开销的需要解析XML、解析样式、处理图像表达式一个复杂模板跑下来可能要个几百毫秒。如果每个请求都重新编译一次报表接口的耗时就会被拉长不少。所以在工具类里维护一个ConcurrentHashMap以模板路径为key缓存编译好的JasperReport对象。private static final MapString, JasperReport REPORT_CACHE new ConcurrentHashMap(); public static JasperReport getCompiledReport(String templatePath) { return REPORT_CACHE.computeIfAbsent(templatePath, ReportUtils::compileReport); }注意缓存一定要用ConcurrentHashMap编译报表的请求往往并发很大HashMap在多线程put时可能死循环老版本Java的经典问题ConcurrentHashMap能保证线程一致性。模板偶尔会改动调试期不想一直缓存可以再提供一个带布尔参数的刷新方法这个看项目实际需要。2.2 填充报表参数与数据源组装模板编译完下一步就是填充。这个方法是工具类的第二个核心。/** * 填充报表数据 * * param report 编译后的报表对象 * param parameters 报表参数Map对应模板里的${参数名} * param dataSource JRDataSource数据源例如JRBeanCollectionDataSource * return JasperPrint可供导出的打印对象 */ public static JasperPrint fillReport(JasperReport report, MapString, Object parameters, JRDataSource dataSource) { try { if (dataSource null) { return JasperFillManager.fillReport(report, parameters, new JREmptyDataSource()); } return JasperFillManager.fillReport(report, parameters, dataSource); } catch (JRException e) { throw new ReportGenerationException(报表数据填充失败, e); } }这里有个特别容易踩的坑数据源为空的时候不能直接传null给fillReport否则某些模板表达式比如SUM求和会报空指针或者统计异常。JasperReports官方推荐做法是传一个JREmptyDataSource它的作用是一个“空的数据源”能让报表走正常的填充逻辑只是没有任何数据行。参数Map的key要和模板里的参数名严格一致这个一致指的是区分大小写JRXML里写$P{userName}Map里就必须是userName而不是username。一次我在排查报表空白问题的时候查了很久才发现是参数大小写对不上模板表达式求值出null字段自然就空白了。2.3 导出终于到了输出文件这一步导出是工具类里分支最多的部分因为PDF和Excel的实现方式完全不同。先看PDF导出。public static boolean exportPdf(JasperPrint jasperPrint, String destFilePath) { try { JasperExportManager.exportReportToPdfFile(jasperPrint, destFilePath); return new File(destFilePath).exists(); } catch (JRException e) { throw new ReportGenerationException(PDF报表导出失败, e); } }JasperExportManager是一个便捷门面内部实现了PDF导出一行代码搞定。但这里要提前说明项目里如果只依赖了jasperreports核心包运行到导出PDF时会抛ClassNotFoundException提示找不到com.lowagie.text.Document因为PDF底层依赖iText库。解决方法是额外引入jasperreports-fonts和itext相关依赖后面常见问题里会专门讲。Excel导出就绕不开JRXlsxExporter了。public static boolean exportExcel(JasperPrint jasperPrint, String destFilePath) { try { JRXlsxExporter exporter new JRXlsxExporter(); exporter.setExporterInput(new SimpleExporterInput(jasperPrint)); exporter.setExporterOutput(new SimpleOutputStreamExporterOutput(destFilePath)); SimpleXlsxReportConfiguration configuration new SimpleXlsxReportConfiguration(); // 重要配置自动合并单元格和模板里的band区域呼应 configuration.setAutoFitPageHeight(true); configuration.setRemoveEmptySpaceBetweenColumns(true); configuration.setRemoveEmptySpaceBetweenRows(true); configuration.setDetectCellType(true); exporter.setConfiguration(configuration); exporter.exportReport(); return new File(destFilePath).exists(); } catch (JRException e) { throw new ReportGenerationException(Excel报表导出失败, e); } }Excel导出相比PDF复杂就复杂在配置项上。detectCellType一定要开不然数字会被格式化成文本导出的Excel做不了求和公式removeEmptySpaceBetweenColumns和Rows是对应着模板里Column之间的空隙如果模板里设计了很多空白区域导出的Excel就会出现大面积的空白行/列数据铺得零零散散这两个配置统一解决。2.4 Web下载场景文件名与流处理报表文件生成后最终大头往往是丢给前端下载。这里封装一个方法处理HttpServletResponse的头信息。public static void setDownloadHeaders(HttpServletResponse response, String filename, String contentType) throws UnsupportedEncodingException { // 标准过滤器会处理编码问题 response.setContentType(contentType); // 处理不同浏览器的中文文件名乱码 String encodedFilename encodeFilename(filename); response.setHeader(Content-Disposition, attachment; filename\ encodedFilename \ ; filename*UTF-8 encodedFilename); response.setCharacterEncoding(UTF-8); } private static String encodeFilename(String filename) throws UnsupportedEncodingException { return URLEncoder.encode(filename, UTF-8).replaceAll(\\, %20); }导出的时候浏览器如果弹出一串带下划线前缀的乱码文件名多半是这里没做URL编码。HTTP头部Content-Disposition接收ISO-8859-1编码中文如果不转换就会被替换成问号或者乱码。filename*参数是RFC5987规定的扩展格式现在的主流浏览器都能识别。也许你会疑惑文件名都编码了图片之类的报表资源不是也该处理吗模板里如果引用了图片路径这个工具类帮不了你它只能帮你输出报表。图片的处理在上一篇文章的JRXML里已经聊过了要么用绝对路径外部资源要么转Base64内嵌。3. 完整源码一个可直接改造的基座工具类把上述逻辑全部合到一起结构如下省略了ReportGenerationException的完整实现细节它就是一个自定义RuntimeException。public final class JasperExportUtils { private static final MapString, JasperReport REPORT_CACHE new ConcurrentHashMap(); private JasperExportUtils() { throw new AssertionError(工具类不可实例化); } /** * 编译模板并缓存。 * 缓存级别单JVM。 */ public static JasperReport loadAndCompile(String templatePath) { return REPORT_CACHE.computeIfAbsent(templatePath, path - { try (InputStream in loadTemplateAsStream(path)) { return JasperCompileManager.compileReport(in); } catch (Exception e) { throw new ReportGenerationException(模板编译失败: path, e); } }); } private static InputStream loadTemplateAsStream(String templatePath) throws FileNotFoundException { InputStream in Thread.currentThread().getContextClassLoader() .getResourceAsStream(templatePath); if (in null) { in JasperExportUtils.class.getClassLoader() .getResourceAsStream(templatePath); } if (in null) { // 兼容项目外绝对路径场景 File file new File(templatePath); if (file.exists()) { in new FileInputStream(file); } } if (in null) { throw new FileNotFoundException(模板路径不存在: templatePath); } return in; } /** * 填充数据null数据源自动替换为空数据源。 */ public static JasperPrint fill(JasperReport report, MapString, Object params, JRDataSource dataSource) { try { JRDataSource safeDataSource dataSource null ? new JREmptyDataSource() : dataSource; return JasperFillManager.fillReport(report, params, safeDataSource); } catch (JRException e) { throw new ReportGenerationException(报表填充失败, e); } } /** * 导出PDF到指定位置。 */ public static void exportPdf(JasperPrint print, String destPath) { try { JasperExportManager.exportReportToPdfFile(print, destPath); } catch (JRException e) { throw new ReportGenerationException(PDF导出失败, e); } } /** * 导出Excel到指定位置。导出后临时文件请业务方自行清理。 */ public static void exportExcel(JasperPrint print, String destPath) { try ( JRXlsxExporter exporter new JRXlsxExporter(); OutputStream out new FileOutputStream(destPath) ) { exporter.setExporterInput(new SimpleExporterInput(print)); exporter.setExporterOutput(new SimpleOutputStreamExporterOutput(out)); SimpleXlsxReportConfiguration config new SimpleXlsxReportConfiguration(); config.setOnePagePerSheet(Boolean.TRUE); config.setAutoFitPageHeight(Boolean.TRUE); config.setDetectCellType(Boolean.TRUE); config.setRemoveEmptySpaceBetweenRows(Boolean.TRUE); config.setRemoveEmptySpaceBetweenColumns(Boolean.TRUE); exporter.setConfiguration(config); exporter.exportReport(); } catch (Exception e) { throw new ReportGenerationException(Excel导出失败, e); } } public static void setDownloadHeaders(HttpServletResponse response, String filename, String contentType) { // 见上方源码此处略 } // 其余辅助方法... }补充一个try-with-resources的细节JRXlsxExporter本身实现了Closeable接口吗要看版本。较新的JasperReports版本里JRXlsxExporter实现了AutoCloseable因此可以直接放进try块确保文件流不会泄漏。如果你用的版本比较老一定要在finally里手动关闭exporter和OutputStream流不然后台日志会出现“Too many open files”的报错。我之前升级JasperReports版本时还专门为此做过一次全量回归。3.1 业务侧调用示例五分钟上手工具类写好后业务侧的调用代码大概长这样public void downloadUserReport(String userId, HttpServletResponse response) { // 1. 准备数据——查询用户列表 ListUserModel userList userService.queryUsers(userId); // 2. 组装报表参数 MapString, Object params new HashMap(); params.put(reportTitle, 用户清单报表); params.put(generatedDate, new Date()); // 3. 编译模板走缓存 JasperReport report JasperExportUtils.loadAndCompile(jasper/user_report.jrxml); // 4. 填充数据 JRBeanCollectionDataSource dataSource new JRBeanCollectionDataSource(userList); JasperPrint print JasperExportUtils.fill(report, params, dataSource); // 5. 导出临时文件 String tempPath temp/report_ System.currentTimeMillis() .pdf; JasperExportUtils.exportPdf(print, tempPath); // 6. 写响应头并输出文件流 JasperExportUtils.setDownloadHeaders(response, 用户清单报表.pdf, application/pdf); try (InputStream in new FileInputStream(tempPath); OutputStream out response.getOutputStream()) { IOUtils.copy(in, out); out.flush(); } finally { // 7. 清理临时文件——重要切记 FileUtils.deleteQuietly(new File(tempPath)); } }用JRBeanCollectionDataSource是JasperReport最方便的一种方式直接把List塞进去模板里用$F{fieldName}调用JavaBean的属性属性名和JRXML中的字段名对应。如果你用的字段名和JavaBean属性名不一致可以在模板里定义FieldMapping这个不展开但不是没有解决办法。3.2 关于数据源的另一个重要选择SQL数据源如果你不想把数据查出来再塞给报表而是想在JRXML里写SQL、用JasperReport自己的SQL数据源查库则可以用JRDataSourceUtils的getDataSource方法。但我个人很少这么干——因为报表和数据源耦合在模板里后期排查SQL问题非常痛苦业务侧也没法直观看到到底提交了什么参数。用Java侧查好数据再传数据源职责分割更清楚也方便单元测试。不过有一种场景可以例外报表的SQL特别复杂JOIN特别多业务侧用Java写SQL反而累赘那么把SQL放模板里、用JasperReport的参数传SQL条件也是一种可行的方案只是不是这里的重点。4. 这些坑我建议你直接记住4.1 模板路径加载失败的玄学我在某次部署到生产环境时就遇到过本地一切正常jar包一启动就报“找不到模板”。排查下来发现模板被打进了jar包但classpath路径写错了。这里有两个经验其一JRXML文件放在src/main/resources下文件路径用前导斜杠不行比如/jasper/report.jrxml和jasper/report.jrxml差异很大前者可能会从classpath根开始找后者在多数Web容器中也从classpath根找容易混淆建议检查一下加载细节。其二模板文件修改后重新部署缓存不失效所以你看到的老报表其实不是代码bug而是缓存还在用旧模板。4.2 中文乱码问题PDF报告里的方块字JasperReport导出PDF默认字体不支持中文会输出一堆方框。解决办法两类一类是在JRXML里给文本字段指定支持中文的字体比如STSong-Light然后需要引入iTextAsian字体包或者系统字体另一类是把字体打包成jar放在classpath里再配置fontExtensions。这个在上一篇文章模板设计里讲过工具类里能配合的就是把FontConfiguration加载到JasperReports的扩展机制中。这块要踩坑多半是依赖版本不兼容在Maven里引入字体扩展包后要记得排除重复传递的iText库。4.3 报表生成慢数据量大时怎么办有个客户报表的数据量可谓恐怖一次导出几万行JasperReport生成PDF时每页都要计算页面布局内存直接爆掉。这种情况下工具类能做的就是分页导出先把数据按每页几百条拆分分别填充成多个JasperPrint再用JRAbstractExporter把这些print合并到一个输出文件里。ListJasperPrint pages new ArrayList(); // 分批填充每一批数据量不要太大 for (List? batch : partitionData(allData, 500)) { JRDataSource ds new JRBeanCollectionDataSource(batch); JasperPrint localPrint JasperExportUtils.fill(report, params, ds); pages.add(localPrint); } // 合并导出 JRAbstractExporter exporter new JRPdfExporter(); exporter.setExporterInput(SimpleExporterInput.getInstance(pages)); // 继续配置输出...这个方案当年帮我保住了好几个日凌晨的头发。基本思路是与其让JasperReport一次性吞下所有数据不如人为控制每个JasperPrint的体量。报表引擎擅长的是渲染数据切割交给调用方。4.4 临时文件清理与并发文件冲突报表导出接口如果走的是先生成临时文件、再写回响应的路线临时文件建议带上时间戳或者UUID避免多个用户同时导出同一个报表时互相覆盖。实际工作中我就见人用过固定文件名report.pdf第一个请求还没写完第二个请求就把它覆盖了下载下来的文件是半份。用UUID之后能规避这个并发问题。生成完的临时文件一定要在finally块中删除空文件累积多了服务器磁盘飘红。如果嫌手工清理麻烦可以把临时文件统一放在一个固定目录配合Spring的调度任务每天扫一遍超过24小时的全部删掉。4.5 单元测试怎么绕过Servlet依赖工具类里setDownloadHeaders依赖HttpServletResponse接口但通过构造一个MockHttpServletResponse就能在单元测试中实例化验证。比如验证Content-Disposition头里的文件名是否正确编码不需要真正启动Web容器。这一类的单元测试对重构特别有帮助我每回调整工具类都靠它们兜底。5. 个人使用时候的几个小补充报表这套东西看着简单实际使用下来工具类的边界要想清楚。它不应该是所有报表逻辑的垃圾桶比如非常复杂的复杂表头、动态列这种还是应该在模板阶段解决或者单独写专项逻辑。工具类只保证常规路径顺畅。有个细节经常被忽略模板里如果用了子报表SubReport编译时JasperReport需要知道子报表模板的位置父模板里SubreportExpression用$P{SUBREPORT_DIR}传路径的话那么params里要带上SUBREPORT_DIR这个key否则运行期必报异常。子报表场景下的工具类封装没有太多额外工作量就是params里多塞一个路径。最后一个小技巧工具类的缓存可以做成带时间戳的版本控制即模板文件名带上版本号部署新模板时更新文件名这样就不需要清缓存。实测下来虽然粗暴但比大清缓存的运维操作要方便得多。报表这条路我是踩了不少坑才走顺的工具类也只是减少重复劳动的第一步。真正学着理解JasperReport的内部工作流——从编译、填充、导出这条链路上理解它的数据流——才能在做自定义导出器或者模板改造时不慌。希望这篇源码拆解能让你少走一些弯路下一篇准备聊聊复杂报表模板的调试技巧比如多数据源注入和动态列的实现到时候见。