做个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 Map<String, 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, Map<String, 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 Map<String, 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, Map<String, 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. 准备数据——查询用户列表 List<UserModel> userList = userService.queryUsers(userId); // 2. 组装报表参数 Map<String, 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合并到一个输出文件里。
List<JasperPrint> 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的内部工作流——从编译、填充、导出这条链路上理解它的数据流——才能在做自定义导出器或者模板改造时不慌。希望这篇源码拆解能让你少走一些弯路,下一篇准备聊聊复杂报表模板的调试技巧,比如多数据源注入和动态列的实现,到时候见。