☰
Java后端用JasperReports将List和Map数据导出PDF实战指南
2026/10/14 12:19:36 网站建设 项目流程

做Java后端开发这些年,和PDF导出打过不少交道。前一阵子接了一个单据打印的需求,业务方给的数据五花八门,有订单明细、审批记录、对账清单,数据源清一色的List<Map<String, Object>>,要求是套用固定版式导出PDF。我第一反应就是用JasperReports来做,这个老牌开源报表库在Java生态里活得够久,文档多、踩坑记录也多,碰上问题基本都能搜到解法。这篇文章就围绕“Java传参List和Map数据导出PDF”这个场景,把从模板设计、数据绑定到实际踩坑的完整过程写出来,给正在做类似功能的朋友一个可以直接参考的路线。

这套方案适合谁?适合那些手头已经有Java服务、报表模板经常要调格式、但不想在代码里手工画PDF的业务系统开发同学。不管你是第一次接触JasperReports,还是已经用它做过简单导出,这篇文章里关于Parameters、Fields、数据源封装和中文乱码的处理,都能帮你少走几趟弯路。

1. Java报表导出为什么我还是选JasperReports

1.1 三条常见路线的对比

先说结论:报表类的PDF导出,我基本不纠结,直接用JasperReports;但这不是说别的方案不能用,而是要看场景。

  • 用Apache POI生成Excel再转PDF。这条路在“表格型报表”里也挺常见,好处是POI对Excel的掌控力强,做单元格合并、公式、样式都比较顺手。但一旦报表里有循环明细、分组统计、跨页重复表头,POI写起来代码量会迅速膨胀,而且Excel转PDF时页面分页、列宽控制经常要反复调,效果不够稳定。
  • 用iText直接画PDF。iText的底层绘制能力很强,适合做那种版式完全由代码控制的合同、证书、票据。但它的心智模型更接近“在画布上画线”,不是“填数据出报表”。业务报表往往有模板、有数据集、有排序分组,这些用iText实现要么代码很重,要么就是重复造轮子。
  • 用JasperReports配合Jaspersoft Studio设计模板。它的核心思路是“模板写清楚长什么样,Java代码只负责把数据喂进去”。模板和你用来取数的Map、List天然契合,PDF、HTML、Excel、CSV都能导出,改版式不用动Java代码,这是业务系统里最贵的价值。

这三条路我都写过生产代码,最后留在项目里的还是JasperReports。如果你的需求是“给一堆数据套一个固定版式,导出带页眉页脚、明细循环、页码统计的PDF”,它的学习成本其实是最低的。

1.2 报表模板和代码分离的开发流程

用JasperReports做导出的完整链路,用一句话概括就是:jrxml模板 -> 编译成JasperReport对象 -> 填充数据生成JasperPrint -> 导出PDF。

这里有个容易忽略的点:jrxml本质上是XML文件,它不是报表本体,而是报表的“版式描述”。真正运行时要先把它编译成JasperReport(编译后的对象可以缓存复用),再通过JasperFillManager.fillReport()把参数和数据源塞进去,得到一个可打印的JasperPrint,最后交给JasperExportManager.exportReportToPdfFile()这类方法导出。

理解这条链路很重要,因为后面调参数、调字体、做大数据量优化,全都是在这条链路的某一个环节上做文章。比如模板里写死的中文字体,编译阶段不会报错,等到fillReport()计算文本宽度、导出PDF时才出乱码;比如fillReport()非常耗内存,但JasperReport本身可以做成全局缓存。如果你一开始就知道瓶颈可能出现在哪一段,排查起来会快很多。

2. 先搞懂JasperReports的数据传递模型:Parameters、Fields、Variables

2.1 三类数据对象的边界

第一次用JasperReports的人,很容易把$P{}、$F{}、$V{}这三种引用符号搞混。它们是报表模板里的三个数据通道,分工完全不同。

类型引用写法来源典型用途
Parameters$P{paramName}外部通过Map传入,可传任意Java对象标题、时间、查询条件、字符串、List
Fields$F{fieldName}数据源字段,和每条记录的列对应明细行里的订单号、金额、数量
Variables$V{varName}报表引擎计算产生的变量计数、求和、页码、分组统计

Parameters和Fields是最容易混淆的:外部传参的键一律是Parameters,而数据源里逐行迭代的列才是Fields。举例来说,模板里写$P{companyName}是取调用方传进去的公司名;写$F{orderNo}则是在每行明细里取当前记录的订单号。

Variables不需要你从Java侧传,它通常是表达式引擎算出来的。比如在报表的summary段放一个Variable做SUM($F{amount}),就能统计所有明细行的金额合计。这三个通道搞清楚之后,模板就不会出现“明明传了参数但模板里取不到”的困惑,因为绝大多数取不到的问题,不是代码传错了,而是你在模板里用错了引用类型。

2.2 为什么List和Map在实际项目里这么吃香

模板字段和数据源字段是对应关系,而Java侧传递数据最常见的方式无非三种:JavaBean对象、List<Map>、单个Map参数。

JavaBean方式的问题是字段名和类型都被类定义锁死了。业务系统里数据经常来自多张表关联查询,为了一个报表专门建一个DTO,字段一多光映射就得写半天。List<Map<String, Object>>则灵活得多——查询结果本身就是Map结构,组装时往里put就行,不需要为每个报表建类。

还有一个现实原因:很多报表数据是从已有接口或数据库查询拿来的,结果集天然就是List<Map>。你用JasperReports的JRMapCollectionDataSource类就能把这个集合直接包装成报表引擎认识的数据源,模板里$F{字段名}自动从Map取值。搭配一个Map参数放置查询条件、标题、时间范围,报表模板的对外接口就非常简单了:进来一个List<Map>,出去一个PDF。

这个组合在业务系统里几乎成了标准姿势:用Map传报表级参数,用List<Map>传明细数据源。接下来就按这个姿势写一个能直接跑的完整例子。

3. 直接可跑的完整示例:Java传List和Map导出PDF

3.1 工程依赖与版本选择

先说版本。我建议直接用6.x的最新稳定版,比如6.20.6,API稳定、网上资料多。不同大版本之间API差异不小,但下面这个例子的核心调用路径在6.x系列里基本不变。

<dependency> <groupId>net.sf.jasperreports</groupId> <artifactId>jasperreports</artifactId> <version>6.20.6</version> </dependency>

这里要提醒一句:jasperreports的PDF导出能力依赖iText相关的底层库。老的6.5、6.0时代通常要额外引入com.lowagie:itext:2.1.7,新版本对OpenPDF的集成更顺滑。如果在运行到JRPdfExporter时遇到ClassNotFoundException: com.lowagie.text.DocumentException,就说明底层PDF依赖没补齐,按你用的JasperReports版本对应的官方依赖说明补上即可。

3.2 设计jrxml模板

下面是一个极简模板:顶部显示报表标题和打印日期,中间是一个三列的明细表,最后是金额合计。把它存成order_report.jrxml。

<?xml version="1.0" encoding="UTF-8"?> <jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd" name="order_report" pageWidth="595" pageHeight="842" columnCount="1" language="java"> <parameter name="title" class="java.lang.String"/> <parameter name="printDate" class="java.util.Date"/> <field name="orderNo" class="java.lang.String"/> <field name="customerName" class="java.lang.String"/> <field name="amount" class="java.math.BigDecimal"/> <title> <band height="40"> <staticText> <reportElement x="0" y="0" width="200" height="25"/> <text><![CDATA[订单明细表]]></text> </staticText> <textField> <reportElement x="400" y="0" width="120" height="25"/> <textElement/> <textFieldExpression><![CDATA[$P{title}]]></textFieldExpression> </textField> <textField> <reportElement x="400" y="25" width="120" height="15"/> <textFieldExpression><![CDATA[$P{printDate}]]></textFieldExpression> </textField> </band> </title> <columnHeader> <band height="20"> <staticText> <reportElement x="0" y="0" width="100" height="20"/> <text><![CDATA[订单号]]></text> </staticText> <staticText> <reportElement x="120" y="0" width="140" height="20"/> <text><![CDATA[客户名称]]></text> </staticText> <staticText> <reportElement x="280" y="0" width="80" height="20"/> <text><![CDATA[金额]]></text> </staticText> </band> </columnHeader> <detail> <band height="20"> <textField> <reportElement x="0" y="0" width="100" height="20"/> <textFieldExpression><![CDATA[$F{orderNo}]]></textFieldExpression> </textField> <textField> <reportElement x="120" y="0" width="140" height="20"/> <textFieldExpression><![CDATA[$F{customerName}]]></textFieldExpression> </textField> <textField> <reportElement x="280" y="0" width="80" height="20"/> <textFieldExpression><![CDATA[$F{amount}]]></textFieldExpression> </textField> </band> </detail> <summary> <band height="25"> <staticText> <reportElement x="0" y="0" width="100" height="20"/> <text><![CDATA[合计]]></text> </staticText> <textField> <reportElement x="280" y="0" width="80" height="20"/> <textFieldExpression><![CDATA[$V{amountSum}]]></textFieldExpression> </textField> </band> </summary> <summary> <band height="25"> <textField> <reportElement x="280" y="0" width="80" height="20"/> <textFieldExpression><![CDATA[$V{amountSum}]]></textFieldExpression> </textField> </band> </summary> </jasperReport>

模板里出现了$V{amountSum},但我们还没有定义它。JasperReports里汇总变量可以不在XML里显式声明,而是通过表达式计算自动生成。不过为了稳妥,更常见的是在jasperReport节点下定义一个variable:

<variable name="amountSum" class="java.math.BigDecimal" calculation="Sum"> <variableExpression><![CDATA[$F{amount}]]></variableExpression> </variable>

加上这个定义后,summary段里的$V{amountSum}就会累加所有amount字段。这里有个常见误区:变量定义虽然写在field声明后面,但它的计算发生在填充阶段,不需要Java侧额外传值。

3.3 Java侧组装数据和导出

模板有了,接下来是Java侧的代码。我把整个流程写在一个方法里:组装报表参数、构造明细数据集、填充、导出PDF文件。

import net.sf.jasperreports.engine.JRParameter; import net.sf.jasperreports.engine.JasperExportManager; import net.sf.jasperreports.engine.JasperFillManager; import net.sf.jasperreports.engine.JasperPrint; import net.sf.jasperreports.engine.JasperReport; import net.sf.jasperreports.engine.data.JRMapCollectionDataSource; import net.sf.jasperreports.engine.util.JRLoader; import java.math.BigDecimal; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; public class PdfExporter { public void exportOrderPdf() throws Exception { // 1. 编译并加载模板,生产环境建议缓存JasperReport对象 JasperReport jasperReport = (JasperReport) JRLoader.loadObjectFromFile("/path/to/order_report.jasper"); // 2. 报表级参数:Map传参 Map<String, Object> params = new HashMap<>(); params.put("title", "2024年6月订单"); params.put("printDate", new java.util.Date()); // 3. 明细数据:List<Map> 模拟查询结果 List<Map<String, Object>> dataList = new ArrayList<>(); Map<String, Object> row1 = new HashMap<>(); row1.put("orderNo", "ORD-20240601-001"); row1.put("customerName", "某商贸公司"); row1.put("amount", new BigDecimal("199.90")); dataList.add(row1); Map<String, Object> row2 = new HashMap<>(); row2.put("orderNo", "ORD-20240601-002"); row2.put("customerName", "某科技公司"); row2.put("amount", new BigDecimal("599.00")); dataList.add(row2); // 4. 将List<Map>包装成JR数据源 JRMapCollectionDataSource dataSource = new JRMapCollectionDataSource(dataList); // 5. 填充报表 JasperPrint jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource); // 6. 导出PDF JasperExportManager.exportReportToPdfFile(jasperPrint, "/path/to/order_report.pdf"); } }

注意JRLoader.loadObjectFromFile()加载的是编译后的.jasper文件。如果你在本地测试只有jrxml,可以先用JasperCompileManager.compileReportToFile("order_report.jrxml")编译一次。实际项目里我建议把编译和加载做成两段:开发期临时编译,运行期直接加载已编译文件并做缓存。

3.4 导出时几个值得注意的解析细节

  • 参数类型要和模板里parameter节点的class保持一致。模板里声明java.util.Date,Java侧传一个Long时间戳进去,填充时不会立刻报错,但表达式求值可能拿到完全错误的值,排查起来很隐蔽。
  • Map里的key一定要和模板field的name完全一致,大小写敏感。HashMap底层不保证顺序,但取值靠key,顺序无关;可读性上最好用LinkedHashMap,保证生成数据源时的字段顺序稳定。
  • 金额字段建议在Java侧就转成BigDecimal,模板里再用$F{amount}直接显示。如果传的是字符串,排序、合计、格式化都会变成字符串操作,后面做统计会踩坑。
  • printDate这类日期参数,在模板里直接输出Date对象,默认格式未必好看。可以在模板的textFieldExpression里用new SimpleDateFormat("yyyy-MM-dd").format($P{printDate}),但更推荐在模板的textElement里指定pattern="yyyy-MM-dd",这样格式控制留在模板层,Java侧只传Date。

4. 传参导出PDF时最容易踩的坑,我把排查过程写在这里

4.1 中文乱码根因与字体解决方案

JasperReports导出PDF的中文乱码,基本可以归因于一件事:PDF文件里没有嵌入能显示中文的字体。iText系PDF导出引擎在找不到中文字体时,要么用默认字库里的英文字形填充,要么直接丢字,表现出来就是方格或者乱码。

网上能看到的最老套方案,是在模板里给textField设置:

<font fontName="宋体" pdfFontName="STSong-Light" pdfEncoding="UniGB-UCS2-H" isPdfEmbedded="true"/>

这套配置在老版本里能跑,但它依赖iText内置的CID字体,遇到生僻字、特殊符号照样出问题。而且fontName="宋体"依赖运行环境安装了宋体,Linux服务器上可能根本没有这个字体。

我更推荐用字体扩展(Font Extensions)方案,把字体文件打包进classpath,真正做到“换环境不乱码”。做法分三步:

先在src/main/resources/fonts/目录放一个中文字体文件,比如simsun.ttf;再在fonts.xml里声明字体家族:

<fontFamilies> <fontFamily name="SimSun"> <normal>fonts/simsun.ttf</normal> <pdfEncoding>Identity-H</pdfEncoding> <pdfEmbedded>true</pdfEmbedded> </fontFamily> </fontFamilies>

最后在classpath根目录的jasperreports.properties里注册这个字体扩展:

net.sf.jasperreports.extension.registry.factory.simple.font.families=net.sf.jasperreports.extensions.FontExtensionsRegistryFactory net.sf.jasperreports.extension.simple.font.families.simsun=fonts/fonts.xml

注册完成后,模板里的文字控件把fontName设置成SimSun即可。这里有个经验:同一个模板里最好统一使用注册的字体名,不要有的控件用宋体、有的用SimSun,否则很可能出现“部分中文正常,部分中文变方块”的诡异现象。

4.2 List字段名取不到值的真实案例

有次我遇到一个怪问题:Java侧明明是row.put("customerName", "某公司"),模板里也是$F{customerName},但导出的PDF里客户名称一列全是空白,订单号却能正常显示。当时排查了很久,最后发现是数据库查询返回的Map里key叫CUSTOMER_NAME,我组装时没用新Map转字段名,直接把查询结果丢给了JRMapCollectionDataSource。

JRMapCollectionDataSource取值是按$F{字段名}精确匹配Map的key,大小写极其敏感。你模板里写customerName,数据源里只有CUSTOMER_NAME,引擎不会帮你做驼峰转换,而是静默返回空值。这比直接报错更坑,因为报表看起来是“生成成功”的。

解决办法是统一约定:在Service层组装数据时,所有key统一转成模板字段名;或者干脆在SQL里给列起别名,让查询结果的key天然对应模板的field。我建议用别名,因为少一次Map遍历转换,代码也更清晰。

4.3 参数值为null、报表为空页的处理

报表参数传null,问题往往不在Java侧报错,而在填充阶段表达式求值时报空指针,或者生成一个莫名其妙的空页。比如模板里写:

<textFieldExpression><![CDATA[$P{remark}.toUpperCase()]]></textFieldExpression>

如果传入的remark是null,填充时直接抛NullPointerException。这种表达式建议改成:

<textFieldExpression><![CDATA[$P{remark} == null ? "" : $P{remark}.toUpperCase()]]></textFieldExpression>

还有一种常见场景:查询条件没有匹配到任何数据,dataList为空。这时JRMapCollectionDataSource传进去,JasperReports会认为数据源里没有任何记录,detail段不渲染,但模板如果有title段和summary段,还是可能生成一个只有标题和合计为0的PDF。如果业务上要求“没数据就不导出”,可以在Java侧先判断dataList.isEmpty()再决定是否执行填充;如果要求导出空报表,也可以用JREmptyDataSource配合参数来控制。

需要注意的是,模板里如果数据源是空,JRMapCollectionDataSource可能无法提供字段元数据。这时有些表达式即使不依赖数据也会因为引擎尝试验证字段类型而报错。稳妥做法是用JREmptyDataSource,并保证field的class在模板里有显式声明。

4.4 动态列:模板固定列名解决不了怎么办

有同学会问,需求里的列不是固定的,比如客户要求“导出的PDF列可以根据传入的字段列表动态生成”,这种情况JasperReports还能用吗?

能,但不能直接套固定模板。固定模板的好处是列宽、样式都在jrxml里写好了,代价是模板的列被写死。动态列的处理思路一般是:在Java侧动态生成jrxml字符串,再编译成JasperReport。也就是说,根据传入的列名数组,动态拼出field、columnHeader、detail段的XML,再交给JasperCompileManager.compileReport()编译。

这样做当然比固定模板麻烦一些,但胜在灵活。需要自己控制列宽分配、表头文字转义等问题。如果动态列的复杂度很高,我会劝业务方重新考虑一下需求:报表类需求优先做“固定列+可选显示”的模式,只有列的组合方式特别自由时再上动态模板。

5. 大数据量导出时的内存与性能优化

5.1 别一次性把十万行都塞进List

最理想的导出方式是“小数据量无所谓,大数据量要分层”。一两百行的报表,直接在内存里new ArrayList没任何问题;但到了几万、几十万行,整条链路的内存消耗会非常吓人:查询结果放在List<Map>里是一份,JRMapCollectionDataSource包装后是一份,填充过程中报表引擎还要为每个文本字段计算位置和尺寸,内存很容易翻几倍。

我自己做过一次压测,5万行明细、每行10个字段,机器8G内存,直接一次填充差点触发OOM。后来改成分页查询,用自定义的JRDataSource实现“按需取数”,内存才降下来。

JRDataSource接口很简单:

import net.sf.jasperreports.engine.JRDataSource; import net.sf.jasperreports.engine.JRException; import net.sf.jasperreports.engine.JRField; public class CursorDataSource implements JRDataSource { private final ResultSet rs; public CursorDataSource(ResultSet rs) { this.rs = rs; } @Override public boolean next() throws JRException { try { return rs.next(); } catch (Exception e) { throw new JRException(e); } } @Override public Object getFieldValue(JRField jrField) throws JRException { try { return rs.getObject(jrField.getName()); } catch (Exception e) { throw new JRException(e); } } }

这段代码的核心价值:数据始终待在数据库游标里,JasperReports需要一行,next()就从游标推进一行,getFieldValue()只取当前行需要的字段。和List<Map>方案相比,省掉了整个集合的驻留内存。代价是数据库连接需要保持到填充完成,别在填充前把Connection关了。

5.2 模板缓存与并发的取舍

JasperCompileManager.compileReport()编译模板是一个很重的过程,一次编译可能要几百毫秒到几秒,取决于模板复杂度。如果每个请求都重新编译,接口的响应时间会很难看。

正确做法是把编译出的JasperReport对象做缓存。JasperReport在填充时只读,可以多线程安全共享;真正需要隔离的是JasperPrint,因为填充过程会往里面写入本次打印的数据。所以常见的并发模型是:全局缓存JasperReport,每个请求单独创建参数Map、单独填充、单独导出。

缓存实现甚至可以不用缓存框架,一个ConcurrentHashMap就够:

private static final Map<String, JasperReport> REPORT_CACHE = new ConcurrentHashMap<>(); private JasperReport getReport(String path) throws JRException { return REPORT_CACHE.computeIfAbsent(path, p -> { try { return (JasperReport) JRLoader.loadObjectFromFile(p); } catch (JRException e) { throw new RuntimeException(e); } }); }

注意computeIfAbsent里如果加载失败,异常会被包成RuntimeException,调用方要做好兜底。模板文件更新时记得清缓存,可以在后台管理页面加一个“刷新报表缓存”的操作,比重启服务方便得多。

5.3 输出文件还是输出流,别搞反了

导出PDF的最后一步有两条路:exportReportToPdfFile()直接写文件,或者JasperExportManager.exportReportToPdfStream()输出到OutputStream。很多导出接口的最终目的是让前端下载文件,不少人会想:我直接把PDF写到服务器临时目录,再返回下载链接。

这在单机低并发下没什么问题,但一旦并发上来,临时文件清理就成了隐患:清理早了文件还没下载完,清理晚了磁盘被塞满。我更推荐接口直接往HttpServletResponse的输出流里写,浏览器端拿到的就是一个完整的PDF响应流,服务器不落盘。

JasperExportManager.exportReportToPdfStream(jasperPrint, response.getOutputStream());

要记得设置响应头:

response.setContentType("application/pdf"); response.setHeader("Content-Disposition", "attachment; filename=report.pdf");

文件名里的中文最好做一下URL编码,否则部分浏览器下载时文件名会乱码。

6. 我最后留下的固定套路

做JasperReports报表导出这几年,我给自己总结了一套固定流程,新项目直接套用,基本没出过大问题。

第一,模板设计阶段就和业务方确认清楚“哪些列是固定的、哪些是动态的、列宽大概多少”。动态列一旦混进来,模板复杂度会成倍上升,能避免尽量避免。第二,Java侧永远只暴露一个“参数Map + 数据List”的接口风格,字段别名在SQL层统一,不让脏key混进报表层。第三,字体问题在项目初始化时就配好Font Extensions,不要在出乱码之后才想起来补。第四,导出接口预留一个“预览HTML”的模式,JasperReports同样支持exportReportToHtmlFile,开发联调时看HTML比反复导出PDF、打开PDF快得多。

最后再分享一个调试技巧:模板里某个字段显示不对时,先用JasperPrint的JRPrintText对象把每个textField的真实文本值打出来看看,确认是填充时取数不对,还是导出PDF时渲染不对。这条链路一旦分开定位,很多“诡异”问题其实都只是数据源里key对不上而已。

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

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

立即咨询