本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
引言
报表的导入导出功能,这是平常很多后台业务都会涉及到的场景,而身为一名合格的后端“码仔”,面对这类需求且不说要做到跟吃饭喝水一样轻松,但至少也要做到手拿把掐、轻松拿下~
在上篇《EasyExcel框架初相识》中,我们就已经对EasyExcel
的核心API
做了全面讲述,同时还介绍了一下它的基本使用方式。不过之前的内容中,并没有结合真实案例去实践,所以当诸位面对诡计多端的产品、客户时,简单的案例构不成太大的参考价值。
为此,怎样让报表处理更加简单?又如何满足花样百出的业务需求?奔着这两个目标,本文就来聊下怎么封装通用的Excel
工具类,从而实现几行代码搞定报表导入导出,以及结合多个不同场景的报表需求,来进行报表处理实战演示。
PS:个人编写的《技术人求职指南》小册已完结,其中从技术总结开始,到制定期望、技术突击、简历优化、面试准备、面试技巧、谈薪技巧、面试复盘、选
Offer
方法、新人入职、进阶提升、职业规划、技术管理、涨薪跳槽、仲裁赔偿、副业兼职……,为大家打造了一套“从求职到跳槽”的一条龙服务,同时也为诸位准备了七折优惠码:3DoleNaE
,近期需要找工作的小伙伴可以点击:s.juejin.cn/ds/USoa2R3/了解详情!
一、打造通用的监听器
回想之前的《简单读取案例》,会发现使用EasyExcel
解析excel
文件时,针对不同的业务场景,通常需要定义不同的监听器,如:
- 现在需要导入商品数据,就需要定义一个
ProductListener
; - 现在需要导入员工数据,就需要定义一个
StaffListener
; - ……
很显然,这一步会造成大量性质类似的class
被定义出来,所以,能否封装一个通用监听器,以此来减少这步工作量与重复类呢?答案是当然可以,一起来看看。
1.1、通用版监听器
不管是什么业务场景下的Excel
导入,都会经过“解析文件、提取数据、进行业务处理”这三步,除开第三步外,前两步逻辑是相同的,既然逻辑相同,那么自然可以抽象成通用监听器,如下:
public class CommonListener<T> extends AnalysisEventListener<T> {
//创建list集合封装最终的数据
private final List<T> data;
// 字段列表
private final Field[] fields;
private final Class<T> clazz;
private boolean validateSwitch = true;
public CommonListener(Class<T> clazz) {
fields = clazz.getDeclaredFields();
this.clazz = clazz;
this.data = new ArrayList<T>();
}
/*
* 每解析到一行数据都会触发
* */
@Override
public void invoke(T row, AnalysisContext analysisContext) {
data.add(row);
}
/*
* 读取到excel头信息时触发,会将表头数据转为Map集合
* */
@Override
public void invokeHeadMap(Map<Integer, String> headMap, AnalysisContext context) {
// 校验读到的excel表头是否与数据模型类匹配
if (validateSwitch) {
ExcelUtil.validateExcelTemplate(headMap, clazz, fields);
}
}
/*
* 所有数据解析完之后触发
* */
@Override
public void doAfterAllAnalysed(AnalysisContext analysisContext) {}
/*
* 关闭excel表头验证
* */
public void offValidate() {
this.validateSwitch = false;
}
/*
* 返回解析到的所有数据
* */
public List<T> getData() {
return data;
}
}
上述定义了一个通用监听器,其实跟EasyExcel
里提供的SyncReadListener
十分类似,只不过我们这里做了两点优化:
- ①使用泛型来代替
Object
,使其变得更灵活,获取数据时无需强制转换; - ②增加模板校验机制,检查
excel
头信息与数据模型类字段的匹配关系。
第一点可以参考之前的《Java泛型机制》,这里不过多对其展开,主要来看看第二点,这里所谓的模板校验机制,就是invokeHeadMap()
方法里的那行代码。
首先说明下,invokeHeadMap()
方法会在解析到excel
表头信息时被触发,所以在这里面对解析的excel
文件的进行模板校验最合适,可为啥需要校验呢?
PS:所有
excel
导入的业务场景,都是先下载模板,再根据模板指引填写数据,最后才上传填写好的excel
文件,这是导入场景的业务流程。
因为如果你不对传入的excel
文件进行模板校验,这时就算随便传个excel
文件,EasyExcel
也照样不会报错,而是解析出多行所有字段为空的数据,从而触发你后面的业务逻辑引发未知Bug
。
好了,接着来看下校验表头(模板)的validateExcelTemplate()
方法实现逻辑:
@Slf4j
public class ExcelUtil {
/*
* 校验excel文件的表头,与数据模型类的映射关系是否匹配
* */
public static void validateExcelTemplate(Map<Integer, String> headMap, Class<?> clazz, Field[] fields) {
Collection<String> headNames = headMap.values();
// 类上是否存在忽略excel字段的注解
boolean classIgnore = clazz.isAnnotationPresent(ExcelIgnoreUnannotated.class);
int count = 0;
for (Field field : fields) {
// 如果字段上存在忽略注解,则跳过当前字段
if (field.isAnnotationPresent(ExcelIgnore.class)) {
continue;
}
ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class);
if (null == excelProperty) {
// 如果类上也存在忽略注解,则跳过所有未使用ExcelProperty注解的字段
if (classIgnore) {
continue;
}
// 如果检测到既未忽略、又未映射excel列的字段,则抛出异常提示模板不正确
throw new ExcelAnalysisException("请检查导入的excel文件是否按模板填写!");
}
// 校验数据模型类上绑定的名称,是否与excel列名匹配
String[] value = excelProperty.value();
String name = value[0];
if (name != null && 0 != name.length() && !headNames.contains(name)) {
throw new ExcelAnalysisException("请检查导入的excel文件是否按模板填写!");
}
// 更新有效字段的数量
count++;
}
// 最后校验数据模型类的有效字段数量,与读到的excel列数量是否匹配
if (headMap.size() != count) {
throw new ExcelAnalysisException("请检查导入的excel文件是否按模板填写!");
}
}
}
先来看三个入参,分别是当前读到的表头集合,以及数据模型类的class
对象和字段数组,这三个参数会用来校验模板的正确性。
模板的校验逻辑用到了反射机制,整个实现并不算复杂,如下:
- ①先判断传入的数据模型类上是否添加了
ExcelIgnoreUnannotated
注解; - ②遍历了数据模型类的所有字段,先判断字段上是否存在
ExcelIgnore
注解;- 存在:说明当前遍历到的字段要被忽略,则
continue
跳过该字段的校验; - 不存在:说明该字段需要进行校验,继续向下执行;
- 存在:说明当前遍历到的字段要被忽略,则
- ③获取字段上的
ExcelProperty
注解对象,判断拿到注解对象是否为空:- 为空:判断第①步拿到的结果,是否需要忽略未使用
@ExcelProperty
的字段:- 如需忽略则
continue
跳过,否则就抛出模板校验出错的异常;
- 如需忽略则
- 不为空:说明该字段需要继续进行校验,继续往下执行;
- 为空:判断第①步拿到的结果,是否需要忽略未使用
- ④获取字段上
ExcelProperty
注解的value
值,判断是否与读到的列名匹配:- 匹配:说明该字段在模板中存在,校验通过,并自增有效字段数,继续校验下个字段;
- 不匹配:说明该字段在定义的模板中不存在,直接抛出模板校验出错异常;
- ⑤最后再判断有效字段数量,与实际读到的列数是否一致,不一致也抛出校验出错异常。
上述便是整个校验的流程,主要就是对比了读到的excel
表的各个列,是否与数据模型类里定义的匹配关系能对应上,以及实际读到的表头列数与模型类的字段数是否对应,只要存在不一致就抛出校验出错的异常。
为什么可以这么校验呢?大家回想前面导入的业务流程,所有导入动作都必须使用我们提前定义的模板,而自定义的excel
模板,自然会与数据模型类完全匹配,因此,按照模板正常填写的excel
文件,完全能够通过该校验机制。
这里有个小细节,
ExcelProperty
注解提供了列名、权重、索引三种列匹配模式,可目前只是基于列名实现了校验逻辑,所以定义的数据模型类上,不支持使用index、order
来指定字段与excel列的匹配关系(也可以自行改造上面的第四步校验逻辑)。
出于校验机制的存在,所以这个通用监听器无法正常读取多行头的excel
文件,如果你想要正常解析多行头文件,则可以在创建监听器之后,手动调用offValidate()
方法来关闭模板校验机制,这时就能避免校验机制干扰多行头文件的读取。
PS:如果你使用的
EasyExcel
版本较低,解析excel
数据时不会自动忽略已使用过的空行,即填写过内容又删除的数据行,在解析时仍然会被识别成一条数据,这种情况需要在监听器的invoke()
方法中主动过滤。
好了,这个通用监听器做的事情非常简单,无非是将解析到的excel
数据行,全都临时存储在一个集合里,但这里只是读取到的原始数据,如果需要对其进行业务逻辑处理,如校验、清洗、落库等,则需在外面调用getData()
来处理(会在后续实战案例演示)。
1.2、分批处理监听器
前面封装的通用监听器,实际上只能满足数据量不大的业务场景,当数据量达到几万行、数十万、百万行时,如果再使用这个通用监听器就会存在OOM
风险,Why
?
因为解析到的所有数据,都会暂存到内部的data
集合里,直至整个文件所有数据行读取结束。对于大文件而言,这种模式会给内存造成较大的负担,一旦同时导入的excel
文件数量过多,内存资源耗尽就会引发内存溢出问题,怎么办?可以选择分批处理读取到的数据。
/**
* 分批处理监听器(适用于大数据场景)
*/
public class BatchHandleListener<T> extends AnalysisEventListener<T> {
/*
* 每批的处理行数(可以根据实际情况做出调整)
* */
private static int BATCH_NUMBER = 1000;
/*
* 临时存储读取到的excel数据
* */
private List<T> data;
private int rows, batchNo;
private boolean validateSwitch = true;
/*
* 每批数据的业务逻辑处理器
* 说明:如果业务方法会返回结果,可以将换成Function接口,同时类上新增一个类型参数
* */
private final Consumer<List<T>> businessHandler;
/*
* 用于校验excel模板正确性的字段
* */
private final Field[] fields;
private final Class<T> clazz;
public BatchHandleListener(Class<T> clazz, Consumer<List<T>> handle) {
// 通过构造器为字段赋值,用于校验excel文件与模板十分匹配
this(clazz, handle, BATCH_NUMBER);
}
public BatchHandleListener(Class<T> clazz, Consumer<List<T>> handle, int batchNumber) {
// 通过构造器为字段赋值,用于校验excel文件与模板十分匹配
this.clazz = clazz;
this.fields = clazz.getDeclaredFields();
// 初始化临时存储数据的集合,及外部传入的业务方法
this.businessHandler = handle;
BATCH_NUMBER = batchNumber;
this.data = new ArrayList<>(BATCH_NUMBER);
}
/*
* 读取到excel头信息时触发,会将表头数据转为Map集合(用于校验导入的excel文件与模板是否匹配)
* 注意点1:当前校验逻辑不适用于多行头模板(如果是多行头的文件,请关闭表头验证);
* 注意点2:使用当前监听器的导入场景,模型类不允许出现既未忽略、又未使用ExcelProperty注解的字段;
* */
@Override
public void invokeHeadMap(Map<Integer, String> headMap, AnalysisContext context) {
if (validateSwitch) {
ExcelUtil.validateExcelTemplate(headMap, clazz, fields);
}
}
/*
* 每成功解析一条excel数据行时触发
* */
@Override
public void invoke(T row, AnalysisContext analysisContext) {
data.add(row);
// 判断当前已解析的数据是否达到本批上限,是则执行对应的业务处理
if (data.size() >= BATCH_NUMBER) {
// 更新读取到的总行数、批次数
rows += data.size();
batchNo++;
// 触发业务逻辑处理
this.businessHandler.accept(data);
// 处理完本批数据后,使用新List替换旧List,旧List失去引用后会很快被GC
data = new ArrayList<>(BATCH_NUMBER);
}
}
/*
* 所有数据解析完之后触发
* */
@Override
public void doAfterAllAnalysed(AnalysisContext analysisContext) {
// 因为最后一批可能达不到指定的上限,所以解析完之后要做一次收尾处理
if (data.size() != 0) {
this.businessHandler.accept(data);
// 更新读取到的总行数、批次数,以及清理集合辅助GC
rows += data.size();
batchNo++;
data.clear();
}
}
/*
* 关闭excel表头验证
* */
public void offValidate() {
this.validateSwitch = false;
}
public int getRows() {
return rows;
}
public int getBatchNo() {
return batchNo;
}
}
这个批量处理监听器比上一个通用监听器多了些逻辑,首先多了一个批次的概念,当解析的数据量达到给定量级时,就会触发businessHandler
业务处理器,而businessHandler
则是在监听器初始化时传入的Consumer
对象。在《Java8特性-函数式接口》里曾提到过,基于函数式接口可以让Java支持将方法作为参数传递,所以businessHandler
实际上就是具体的业务处理方法。
当执行业务逻辑结束后,会重新new
一个新的集合接收数据,而旧集合被断开引用关系后,会在短时间内被GC
,这里不使用clear()
方法的原因,是由于clear()
内部会去断开与每个元素的引用,这种方式会影响整个文件的读取性能。其次,当解析到的行数还未达到给定阈值时,会继续解析后面的数据,直到抵达阈值后重复前面的流程。
这个分批处理监听器,还重写了doAfterAllAnalysed()
方法,这个方法会在整个文件解析完成后触发,里面实现的逻辑是为了做好收尾工作,因为最后一批可能达不到指定的上限,所以解析完之后还要视情况做一次处理。不过这里用了clear()
方法,毕竟这是最后一次收尾工作,后续也不需要新集合来接收数据了,直接清理现有集合最合适。
该监听器还提供了两个API
:
getRows()
:获取当前监听器解析的总行数;getBatchNo()
:获取当前正在处理的批次数(批次号)。
这两个方法返回的值,会随着excel
文件不断解析而不断变化,当文件彻底解析完成后,调用这两个方法可以获取到总批次数以及数据总行数。好了,关于这个监听器,会在百万级大文件解析的实战中才会用到,这里大家先暂时了解即可。
二、封装Excel导出工具类
第一阶段我们封装了两个通用监听器,分别用来应对常规导入、大数据导入场景,而导出呢?我们也可以封装通用的导出方法,如下:
@Slf4j
public class ExcelUtil {
/*
* 三种excel文件类型分别对应的响应头格式
* */
private static final String XLS_CONTENT_TYPE = "application/vnd.ms-excel";
private static final String XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private static final String CSV_CONTENT_TYPE = "text/csv";
/*
* 导出excel的通用方法
* */
public static void exportExcel(Class<?> clazz, List<?> excelData, String fileName, ExcelTypeEnum excelType, HttpServletResponse response) throws IOException {
HorizontalCellStyleStrategy styleStrategy = setCellStyle();
// 对文件名进行UTF-8编码、拼接文件后缀名
fileName = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20") + excelType.getValue();
switch (excelType) {
case XLS:
response.setContentType(XLS_CONTENT_TYPE);
break;
case XLSX:
response.setContentType(XLSX_CONTENT_TYPE);
break;
case CSV:
response.setContentType(CSV_CONTENT_TYPE);
break;
}
response.setCharacterEncoding("utf-8");
response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + fileName);
ExcelWriterBuilder writeWork = EasyExcelFactory.write(response.getOutputStream(), clazz);
writeWork.registerWriteHandler(styleStrategy).excelType(excelType).sheet().doWrite(excelData);
}
/*
* 设置单元格风格
* */
public static HorizontalCellStyleStrategy setCellStyle(){
// 设置表头的样式(背景颜色、字体、居中显示)
WriteCellStyle headStyle = new WriteCellStyle();
//设置表头的背景颜色
headStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
WriteFont headFont = new WriteFont();
headFont.setFontHeightInPoints((short)12);
headFont.setBold(true);
headStyle.setWriteFont(headFont);
headStyle.setHorizontalAlignment(HorizontalAlignment.CENTER);
// 设置Excel内容策略(水平居中)
WriteCellStyle cellStyle = new WriteCellStyle();
cellStyle.setHorizontalAlignment(HorizontalAlignment.CENTER);
return new HorizontalCellStyleStrategy(headStyle, cellStyle);
}
}
相较于导入时封装的通用监听器,这个导出的通用方法显得简单许多,exportExcel()
方法只是将重复的代码做了封装,它总共接收五个入参,释义如下:
clazz
:导出excel
所需的数据模型类;data
:需要导出的数据列表;fileName
:当前导出的文件名称(不带文件后缀);excelType
:导出的文件类型(XLSX、XLS、CSV
三种);response
:网络响应对象。
了解几个参数后,再来看看内部的细节逻辑,首先会调用setCellStyle()
方法获取一个单元格风格对象,作用就是设置表头、内容的格式,如对齐方式、背景颜色、字体大小等,该对象最终会以WriteHandler
写入处理器的形式注册给Excel
写入对象。
接着基于response
对象,设置了响应的数据类型、编码格式、文件名称等,最后就基于响应对象的输出流写出了生成的excel
文件。当然,通常大文件导出并不会实时返回流给调用方,而是返回OSS
地址给前端去下载,怎么实现呢?也很简单:
public class ExcelUtil {
/*
* 上传错误的Excel文件到OSS
* */
public static String exportExcelToOSS(Class<?> clazz, List<?> excelData, String fileName, ExcelTypeEnum excelType) throws IOException {
HorizontalCellStyleStrategy styleStrategy = ExcelUtil.setCellStyle();
fileName = fileName + excelType.getValue();
File excelFile = File.createTempFile(fileName, excelType.getValue());
EasyExcelFactory.write(excelFile, clazz).registerWriteHandler(styleStrategy).sheet().doWrite(excelData);
String url = uploadFileToOss(excelFile);
if (excelFile.exists()) {
boolean flag = excelFile.delete();
log.info("删除临时文件是否成功:{}", flag);
}
return url;
}
/*
* 模拟将上传OSS文件的代码(实际使用请替换为真实上传)
* */
public static String uploadFileToOss(File file) {
String resultUrl = "";
// 省略上传至OSS的代码……
return resultUrl;
}
}
这段代码特别简单,首先会创建一个临时文件,接着基于该临时文件去写入excel
数据,而后将其上传到了OSS
这类文件存储中心,然后将创建的临时文件删除,并把上传后的链接返回了出去(这个方法可根据实际情况来改造)。
三、多业务场景案例实战
经过前面的内容熏陶后,既掌握了EasyExcel
框架的基本用法,又具备了通用的读写工具类,下面结合不同的业务需求一起实战看看。
为了更加贴合真实的业务场景,可以基于SpringBoot
快速搭建一个Web
工程,接着来创建一张表作为演示数据:
CREATE TABLE `panda` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(80) DEFAULT NULL COMMENT '名称',
`nickname` varchar(80) DEFAULT NULL COMMENT '外号',
`unique_code` varchar(20) DEFAULT NULL COMMENT '唯一编码',
`sex` tinyint(1) DEFAULT '2' COMMENT '性别,0:男,1:女,2:未知',
`height` decimal(6,2) DEFAULT NULL COMMENT '身高',
`birthday` date DEFAULT NULL COMMENT '出生日期',
`pic` varchar(255) DEFAULT NULL COMMENT '头像地址',
`level` varchar(50) DEFAULT '0' COMMENT '等级',
`motto` varchar(255) DEFAULT NULL COMMENT '座右铭',
`address` varchar(255) DEFAULT NULL COMMENT '所在地址',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`is_deleted` tinyint(1) DEFAULT '0' COMMENT '删除标识,0:正常,1:删除',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='熊猫表';
这是一张拥有十三个字段的熊猫表,下面初始化一些数据:
insert into panda(name, nickname, unique_code, sex, height, birthday, pic, level, motto, address, create_time) values
("竹子", "小竹", "P888888", 0, '179.99', '2017-07-07', NULL, "高级", "十年磨一剑,五年磨半边!", "太阳省银河市地球村888号", now()),
("花花", "阿花", "P666666", 1, '155.00', '2016-06-07', NULL, "顶级", "我爱睡觉爱我!", "太阳省银河市地球村666号", now()),
("甜甜", "肥肥", "P999999", 0, '163.43', '2020-02-02', NULL, "特级", "今天的事能拖就拖,明天的事明天再说!", "太阳省银河市地球村999号", now()),
("子竹", "小子", "P555555", 1, '188.88', '2021-08-08', NULL, "初级", "你小子!", "太阳省银河市地球村555号", now());
好了,万事俱备只欠东风,有了表结构和数据后,可以通过逆向工程快速生成MVC
的三层代码,为了节省篇幅这里就此略过,大家感兴趣可直接down
下源码:spring-boot-easy-excel。
3.1、Excel导入并批量落库
前面封装了通用监听器,下面就来试试效果,先来写一个最基本的excel
导入接口,对应的导入模板如下:
首先来定义一个与excel
模板匹配的数据模型类:
@Data
public class PandaReadModel implements Serializable {
private static final long serialVersionUID = 1L;
@ExcelProperty("名称")
private String name;
@ExcelProperty("外号")
private String nickname;
@ExcelProperty("唯一编码")
private String uniqueCode;
@ExcelProperty("性别")
private String sex;
@ExcelProperty("身高")
private BigDecimal height;
@ExcelProperty("出生日期")
@DateTimeFormat("yyyy-MM-dd")
private Date birthday;
@ExcelProperty("等级")
private String level;
@ExcelProperty("座右铭")
private String motto;
@ExcelProperty("所在地址")
private String address;
}
这是一个标准的Java类,没有任何特殊的地方,下面再来编写service
层的实现逻辑:
@Slf4j
@Service
public class PandaServiceImpl extends
ServiceImpl<PandaMapper, Panda> implements PandaService {
@Override
@Transactional(rollbackFor = Exception.class)
public void importExcelV1(MultipartFile file) {
// 创建通用监听器来解析excel文件
CommonListener<PandaReadModel> listener = new CommonListener<>(PandaReadModel.class);
try {
EasyExcelFactory.read(file.getInputStream(), PandaReadModel.class, listener).sheet().doRead();
} catch (IOException e) {
log.error("导入熊猫数据出错:{}: {}", e, e.getMessage());
throw new BusinessException(ResponseCode.ANALYSIS_EXCEL_ERROR, "网络繁忙,请稍后重试!");
}
if (excelData.size() == 0) {
throw new BusinessException(ResponseCode.ANALYSIS_EXCEL_ERROR, "请检查您上传的excel文件是否为空!");
}
// 对读取到的数据进行批量保存
List<PandaReadModel> excelData = listener.getData();
batchSaveExcelData(excelData);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void batchSaveExcelData(List<PandaReadModel> excelData) {
List<Panda> pandas = excelData.stream().map(model -> {
Panda panda = new Panda();
BeanUtils.copyProperties(model, panda, "sex");
panda.setSex(Sex.codeOfValue(model.getSex()));
panda.setCreateTime(new Date());
return panda;
}).collect(Collectors.toList());
saveBatch(pandas);
}
}
为了图省事,这里使用了MyBatis-Plus
来简化代码,不过这不重要,重点来看这个导入方法的实现逻辑,一眼看下来就能明白,首先创建了一个通用监听器,并将定义好的数据模型类传入了进去,而后对读取到的数据进行了批量保存,接着来定义接口:
@RestController
@RequestMapping("/panda")
public class PandaController {
@Resource
private PandaService pandaService;
@PostMapping("/import/v1")
public ServerResponse<Void> importExcelV1(MultipartFile file) {
if (null == file) {
throw new BusinessException(ResponseCode.FILE_IS_NULL);
}
pandaService.importExcelV1(file);
return ServerResponse.success();
}
}
没啥好说的,只是调用了一下service
方法罢了,最终来看接口调用结果:
很显然,我们正常读取了excel
数据,并将读到的数据行批量保存到了数据库里,这也是日常开发中最常见的报表导入场景。
3.2、根据搜索条件导出Excel
前面过了一个十分简单的导入场景,现在再来简单过一下根据搜索条件导出数据的例子,这也一个十分普遍的需求,先定义一个导出的数据模型类:
@Data
@ColumnWidth(10)
public class PandaExportVO implements Serializable {
private static final long serialVersionUID = 1L;
@ExcelIgnore
private Long id;
@ExcelProperty(value = "熊猫昵称", index = 0)
private String nickname;
@ExcelProperty(value = "性别", index = 1)
private Integer sex;
@ExcelProperty(value = "唯一编码", index = 2)
private String uniqueCode;
@ExcelProperty(value = "身高", index = 3)
private BigDecimal height;
@ExcelProperty(value = "出生日期", index = 4)
@DateTimeFormat("yyyy-MM-dd")
@ColumnWidth(15)
private Date birthday;
@ExcelProperty(value = "创建时间", index = 5)
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
@ColumnWidth(20)
private Date createTime;
}
当然,如果导入、导出的字段相同,你可以使用同一个类,不过个人建议是最好分开(原因后续会说),下面再来定义一个查询条件参数类:
@Data
public class PandaQueryDTO implements Serializable {
private static final long serialVersionUID = 1L;
/*
* 搜索关键字(兼容名称、外号、编码三个条件)
* */
private String keyword;
/**
* 生日开始时间(yyyy-MM-dd格式)
*/
private String startTime;
/**
* 生日结束时间(yyyy-MM-dd格式)
*/
private String endTime;
}
这个DTO
类中,总共有三个字段,支持名称、外号、编码的模糊搜索,以及基于生日按范围查询熊猫数据,接着来看service
层:
@Override
public void exportExcelByCondition(PandaQueryDTO queryDTO, HttpServletResponse response) {
List<PandaExportVO> pandas = baseMapper.selectPandas(queryDTO);
String fileName = "熊猫基本信息集合-" + System.currentTimeMillis();
try {
ExcelUtil.exportExcel(PandaExportVO.class, pandas, fileName, ExcelTypeEnum.XLSX, response);
} catch (IOException e) {
log.error("熊猫数据导出失败,{}:{}", e, e.getMessage());
throw new BusinessException("熊猫基本信息导出失败,请稍后再试!");
}
}
首先根据传入的条件查询了数据,然后起了一个文件名字,最后通过封装的ExcelUtil.exportExcel()
方法直接导出了excel
文件,来看接口的定义:
@PostMapping("/export/v1")
public void exportExcelV1(@RequestBody PandaQueryDTO queryDTO, HttpServletResponse response) {
pandaService.exportExcelByCondition(queryDTO, response);
}
这里面只是单纯调用了一下service
层的方法罢了,这时可以尝试调用接口来看看结果(未传递任何参数):
结果一目了然,这显然满足了咱们的诉求,能够正常根据条件导出表内的数据,并且也是根据模型类中,@ColumnWidth
注解指定的列宽来生成了文件。
3.3、自定义数据转换器
注意看前面的导出结果,性别这一列数据,由于咱们数据库里存的是code
,对应的枚举类如下:
@Getter
@AllArgsConstructor
public enum Sex {
MALE(0, "男"),
FEMALE(1, "女"),
UNKNOWN(2, "未知");
private final Integer code;
private final String value;
}
所以导出时也成了数字,对于数据的使用者来说,通常更喜欢“男、女”这种表现方式,那咱们该怎么转换呢?很简单,自定义一个数据转换器装载到性别字段即可:
/**
* 性别转换器
*/
public class SexConverter implements Converter<Integer> {
@Override
public WriteCellData<?> convertToExcelData(Integer code, ExcelContentProperty contentProperty,
GlobalConfiguration globalConfiguration) {
// 将库里存储的code转换为具体的性别
String value = Sex.valueOfCode(code);
return new WriteCellData<>(value);
}
}
// 使用方式如下:
@ExcelProperty(value = "性别", index = 1, converter = SexConverter.class)
private Integer sex;
这个转换器一眼就能看明白,当EasyExcel
发现某个字段装载了特定的转换器时,在写入单元格数据时,就会触发自定义的convertToExcelData()
逻辑,最终将数据转换成自定义的格式~
3.4、校验导入的Excel数据
在有些场景中,我们需要对导入的excel
数据进行校验清洗后才能使用,如果上传的excel
文件存在校验出错的信息,则需像上图一样指明错误原因,最后回传给调用方下载查看,这该怎么实现呢?如下:
@Override
@Transactional(rollbackFor = Exception.class)
public BaseImportExcelVO<String> importExcelV2(MultipartFile file) {
CommonListener<PandaReadModel> listener = new CommonListener<>(PandaReadModel.class);
try {
EasyExcelFactory.read(file.getInputStream(), PandaReadModel.class, listener).sheet().doRead();
} catch (IOException e) {
log.error("导入熊猫数据出错:{}: {}", e, e.getMessage());
throw new BusinessException(ResponseCode.ANALYSIS_EXCEL_ERROR, "网络繁忙,请稍后重试!");
}
List<PandaReadModel> excelData = listener.getData();
if (excelData.size() == 0) {
throw new BusinessException(ResponseCode.ANALYSIS_EXCEL_ERROR, "请检查您上传的excel文件是否为空!");
}
// 校验excel数据,如果校验未通过直接阻断执行,将错误信息返回给调用方
BaseImportExcelVO<String> result = validateExcelData(excelData);
if (result.getErrorFlag()) {
return result;
}
// 对校验通过的数据进行批量落库
batchSaveExcelData(excelData);
return result;
}
这段代码与最开始的导入案例,唯一的区别就是在数据读取完毕后,调用了校验数据的validateExcelData()
方法,该方法的实现为:
/*
* 校验导入的excel数据
* */
private BaseImportExcelVO<String> validateExcelData(List<PandaReadModel> excelData) {
BaseImportExcelVO<String> result = new BaseImportExcelVO<>();
boolean errorFlag = false;
List<PandaReadErrorModel> validatePandas = new ArrayList<>();
String birthdayErrorMsg = "生日不能为空;", uniCodeErrorMsg = "唯一编码重复;";
// 根据唯一编码查询库内的熊猫数
List<String> uniCodes = excelData.stream().map(PandaReadModel::getUniqueCode).collect(Collectors.toList());
List<CountVO<String, Integer>> counts = baseMapper.selectCountByUniCodes(uniCodes);
Map<String, Integer> countMap = counts.stream().collect(Collectors.toMap(CountVO::getKey, CountVO::getValue));
// 循环对excel所有数据行进行校验
for (PandaReadModel excelRow : excelData) {
String errorMsg = "";
PandaReadErrorModel errorModel = new PandaReadErrorModel();
BeanUtils.copyProperties(excelRow, errorModel);
// 如果库里对应的唯一编码能查到熊猫,说明UniqueCode重复
if (countMap.containsKey(excelRow.getUniqueCode())) {
errorMsg += uniCodeErrorMsg;
errorFlag = true;
}
// 如果导入的生日字段为空,说明对应的excel行没填写出生日期
if (null == excelRow.getBirthday()) {
errorMsg += birthdayErrorMsg;
errorFlag = true;
}
errorModel.setErrorMsg(errorMsg);
validatePandas.add(errorModel);
}
// 如果存在校验未通过的记录,则导出校验出错的数据为excel文件
if (errorFlag) {
String url, fileName = "熊猫信息导入-校验出错文件-" + System.currentTimeMillis();
try {
url = ExcelUtil.exportExcelToOSS(PandaReadErrorModel.class, validatePandas, fileName, ExcelTypeEnum.XLSX);
} catch (IOException e) {
log.error("生成熊猫导入校验出错文件失败:{}: {}", e, e.getMessage());
throw new BusinessException(ResponseCode.ANALYSIS_EXCEL_ERROR, "网络繁忙,请稍后重试!");
}
result.setResult(url);
result.setCheckMsg("文件校验未通过!");
}
result.setErrorFlag(errorFlag);
return result;
}
这个方法虽然代码看着不短,但逻辑十分简单,首先对读取到的数据进行遍历,然后根据对应的校验规则在判断每行数据,这里的校验规则有两条:
- ①每条数据填写的唯一标识,不能与库里已有的数据重复;
- ②每条数据的出生日期字段为必填项,为空则无法通过校验。
为此,循环里面的逻辑就是这两步,不过这里用到了一个PandaReadErrorModel
类:
@Data
@EqualsAndHashCode(callSuper = false)
public class PandaReadErrorModel extends PandaReadModel implements Serializable {
private static final long serialVersionUID = 1L;
// 显式将错误信息放到最后一列(第十列)
@ExcelProperty(index = 9, value = "错误信息项")
private String errorMsg;
}
该类继承了PandaReadModel
类,只是在最后增加了一个“错误信息项”字段,用于记录每行数据的校验出错信息,其次还封装了一个通用的导入结果类,如下:
@Data
public class BaseImportExcelVO<T> {
/*
* Excel导入的结果集(可以是数据本身,也可以是OSS地址)
* */
private T result;
/*
* Excel文件导入错误信息
* */
private String checkMsg;
/*
* 本次导入是否存在校验错误的标识
* */
private Boolean errorFlag;
}
这个类有结果集、错误信息以及校验出错标识三个字段,结合该类来看校验方法的后半段,在循环结束后,会判断本次校验是否存在未通过的数据,如果存在就会将标识改为true
,并且基于PandaReadErrorModel
类生成了校验出错的excel
文件,然后将其上传到了OSS
里,并将可访问的链接放入了result
字段。
再回到importExcelV2()
这个导入方法,在校验方法执行结束后,会先判断错误标,如果出错则直接将BaseImportExcelVO
对象返回给调用方,调用方就能根据result
字段里的链接下载校验出错的文件。反之,如果文件校验通过,就会将本次导入的数据批量落库。
上面这个流程,也是许多报表导入的常规流程,诸位也可以根据具体的情况做出调整,比如不生成校验出错的文件,直接将校验完成的集合塞进result
字段里返回给调用方;又或者不将错误文件上传到OSS
,而是直接基于Response
对象返回流给调用方等等。
3.5、导出Excel并统计列和
在许多导出场景中,除开要导出数据本身外,往往还需要在最后加些统计数据,如某一列的均值、总和等,而面对这种需求该怎么做呢?其实有一种奇技淫巧,即将统计行也作为一条正常的数据拼接在最后,如下:
// 根据筛选条件从数据库里查询出的数据集合
List<PandaExportVO> pandas = baseMapper.selectPandas(queryDTO);
// 求和所有熊猫的身高,并伪装成一条普通数据,加入到集合尾部
BigDecimal heightSum = pandas.stream().map(PandaExportVO::getHeight).reduce(BigDecimal.ZERO, BigDecimal::add);
PandaExportVO sumVO = new PandaExportVO();
sumVO.setUniqueCode("身高合计:");
sumVO.setHeight(heightSum);
pandas.add(sumVO);
通过这种方式,也能实现在尾部增加各种统计行数据,不过这种方式比较偏门,能够满足一定场景下的需求,但如果你尾部的统计项较多,并且需要各种单元格合并,这种手段就并不适用了,这时需要你去自定义WriteHandler
,又或者通过模板填充的方式实现(后面会讲述)。
3.6、部分字段不同的导出场景
来看这个场景,正如上图所示,目前有两个导出需求,可仅仅只有第一列字段不同罢了,这时我们需要定义两个接口吗?答案是不需要,面对这种大多数字段相同、部分字段不同的场景,可以基于多态的特性来实现,首先定义一个父类来声明公共字段:
@Data
public class PandaStatisticsExportVO implements Serializable {
private static final long serialVersionUID = 1L;
@ExcelProperty(index = 1, value = "熊数")
private Integer counting;
@ExcelProperty(index = 2, value = "身高>=170cm熊数")
private Integer heightGte170cm;
}
接着分别为两个不同的场景定义子类,即:
/**
* 熊猫统计导出VO类(根据年龄分组)
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class SexStatisticsExportVO extends PandaStatisticsExportVO implements Serializable {
private static final long serialVersionUID = 1L;
@ExcelProperty(index = 0, value = "性别分组", converter = SexConverter.class)
private Integer sex;
}
/**
* 熊猫统计导出VO类(根据等级分组)
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class LevelStatisticsExportVO extends PandaStatisticsExportVO implements Serializable {
private static final long serialVersionUID = 1L;
@ExcelProperty(index = 0, value = "等级分组")
private String level;
}
这是面向对象语言的标准写法,值得一提的是:这里合理的利用了ExcelProperty
注解的index
属性,因为两个表格中,存在差异的字段就是第一列,所以两个子类的差异字段的列索引写成0
即可。下面来看具体的service
实现:
@Override
public void exportStatisticsData(PandaStatisticsDTO statisticsDTO, HttpServletResponse response) {
List<PandaStatisticsBO> pandaStatisticsBOs = baseMapper.selectPandaStatistics(statisticsDTO);
List<PandaStatisticsExportVO> exportData;
Class<?> clazz;
// 如果是按性别分组统计,则使用性别的数据模型类
if (0 == statisticsDTO.getStatisticsType()) {
clazz = SexStatisticsExportVO.class;
exportData = pandaStatisticsBOs.stream().map(bo -> {
SexStatisticsExportVO sexVO = new SexStatisticsExportVO();
BeanUtils.copyProperties(bo, sexVO);
return sexVO;
}).collect(Collectors.toList());
}
// 如果是按等级分组统计,则使用等级的数据模型类
else if (1 == statisticsDTO.getStatisticsType()) {
clazz = LevelStatisticsExportVO.class;
exportData = pandaStatisticsBOs.stream().map(bo -> {
LevelStatisticsExportVO levelVO = new LevelStatisticsExportVO();
BeanUtils.copyProperties(bo, levelVO);
return levelVO;
}).collect(Collectors.toList());
} else {
throw new BusinessException("暂不支持这种统计方式哦~");
}
// 导出对应的excel数据
String fileName = "熊猫统计数据-" + System.currentTimeMillis();
try {
ExcelUtil.exportExcel(clazz, exportData, fileName, ExcelTypeEnum.XLSX, response);
} catch (IOException e) {
log.error("熊猫统计数据导出失败,{}:{}", e, e.getMessage());
throw new BusinessException("统计数据导出失败,请稍后再试!");
}
}
代码依旧十分简单,首先会根据参数里传入的导出类型,来选择要使用的数据模型类:
0
:代表按性别统计数据,使用SexStatisticsExportVO
模型类;1
:代表按等级统计数据,使用LevelStatisticsExportVO
模型类。
除了这一步外,其余的就是在使用封装好的工具类导出,如果大家觉得看着迷糊,可以去将源码拉下来,结合上下文一起阅读~
3.7、多行头的Excel导出
有些业务场景下,我们需要导出像上图这样的合并表头文件,即一个大列下面有多个子列,整个文件由多个大列+N多个子列组成,这种需求该怎么导出呢?其实十分简单,只靠ExcelProperty
注解就能实现,如下:
@Data
public class MultiLineHeadExportVO implements Serializable {
private static final long serialVersionUID = 1L;
@ExcelProperty(value = {"基本信息", "昵称"})
private String nickname;
@ExcelProperty(value = {"基本信息", "编码"})
private String uniqueCode;
@ExcelProperty(value = {"基本信息","性别"}, converter = SexConverter.class)
private Integer sex;
@ExcelProperty(value = {"基本信息","身高"})
private BigDecimal height;
@ExcelProperty(value = {"基本信息","出生日期"})
@DateTimeFormat("yyyy-MM-dd")
@ColumnWidth(15)
private Date birthday;
@ExcelProperty(value = {"其他信息","等级"})
private String level;
@ExcelProperty(value = {"其他信息","座右铭"})
@ColumnWidth(30)
private String motto;
@ExcelProperty(value = {"其他信息", "创建时间"})
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")
@ColumnWidth(20)
private Date createTime;
}
正如代码所示,如果你想要将某几列合并成一个大列,只需在注解的value
属性中,以数组形式传递相同的大列名即可。而EasyExcel
发现列名是数组形式时,就会尝试自动合并相同列表的字段,而导出代码无需做任何改造:
@Override
public void exportMultiLineHeadExcel(HttpServletResponse response) {
List<MultiLineHeadExportVO> pandas = baseMapper.selectAllPandas();
String fileName = "多行表头熊猫数据-" + System.currentTimeMillis();
try {
ExcelUtil.exportExcel(MultiLineHeadExportVO.class, pandas, fileName, ExcelTypeEnum.XLSX, response);
} catch (IOException e) {
log.error("多行表头熊猫数据,{}:{}", e, e.getMessage());
throw new BusinessException("数据导出失败,请稍后再试!");
}
}
直接使用前面封装的通用方法,就能导出多行表头的excel
文件。不过如果你需要动态表头,即表头里的数据需要动态写入,那这种需求也只能基于填充模式实现,本文就不对其做展开啦~
四、总结
OK,看到这里也说明本文接近了尾声,本篇总共聊了三大块内容,首先是封装读取的通用监听器,其次是封装通用的导出方法,最后是结合不同场景演示了实战案例,不过在有些特殊场景下,可能还会面临导出图片到Excel
里的需求,EasyExcel
能否实现呢?
答案是能,大家直接参考《EasyExcel官网-导出图片至Excel文件案例》,官方文档中写的十分清晰。不过个人建议不要盲目导出图片,除非你只需导出少量几张图片,否则会特别消耗内存,造成OOM风险,最好还是以链接的形式写入到Excel
。
好了,使用EasyExcel
能使报表读写操作更加轻松,以及获得更好的性能、更优秀的资源利用性,但它并不适合过于复杂的报表处理场景,例如七横八纵交叉合并的Excel
格式,解析数据也好,写出数据也罢,EasyExcel
虽然能勉强满足,但需要绕弯子才行。
面对复杂的报表,这时使用原生的POI
更合适,不仅是它拥有更丰富的API
,同时还拥有许多技术资料,对于很多复杂报表的需求,你都有可能直接在网上找到现成代码复用。同时,因为EasyExcel
本身就是基于POI
封装的框架,所以你也可以将两者结合使用,但前提是你得熟悉POI
框架的各类API
。
最后,尽管EasyExcel
比原生的POI
更适合处理大数据文件场景,可结合业务后的性能表现没想象中那么美好,所以,想要获得更极致的性能,这还需要我们从编码层面优化,例如结合异步思想、多线程技术去处理,而这些知识将在下篇中结合案例一起讲述啦~
所有文章已开始陆续同步至微信公众号:竹子爱熊猫,想在手机上便捷阅读的小伙伴可搜索关注~