EasyExcel02 - 深入实践
本文整理自掘金大佬 - 竹子爱熊猫
文章目录
- [EasyExcel02 - 深入实践](#EasyExcel02 - 深入实践)
-
- 一:通用监听器
- 二:封装excel导出工具类
- 三:项目实践
-
- [1: excel导入并进行批量落库](#1: excel导入并进行批量落库)
- 2:根据检索条件导出excel
- 3:校验写入的excel的数据
- 4:部分字段不同的导出场景
- 5:多行头的excel导出
一:通用监听器
使用EasyExcel
解析excel
文件时,针对不同的业务场景,通常需要定义不同的监听器,如:
- 现在需要导入商品数据,就需要定义一个
ProductListener
; - 现在需要导入员工数据,就需要定义一个
StaffListener
; - ......
很显然,这一步会造成大量性质类似的class
被定义出来,所以,能否封装一个通用监听器,以此来减少这步工作量与重复类呢
1:通用监听器
不管是什么业务场景下的Excel
导入,都会经过"解析文件、提取数据、进行业务处理"这三步
除开第三步外,前两步逻辑是相同的,既然逻辑相同,那么自然可以抽象成通用监听器
java
package com.example.bootrocketmq.study.wheel.easyexcel;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* @author cui haida
* 2024/12/22
*/
public class CommonListener<T> extends AnalysisEventListener<T> {
//创建list集合封装最终的数据
private final List<T> data;
// 字段列表
private final Field[] fields;
// 类
private final Class<T> clazz;
// 是否对excel的有效性进行校验,如果设置为true, 将会检验excel模板的头和数据模型类字段是否匹配
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) {
// 如果需要进行正确性校验,将会进行工具方法进行excel模板校验 -> header和数据模型类字段关系校验
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的头信息和数据模型字段的匹配关系
为什么要有第二个呢?
所有excel
导入的业务场景,都是先下载模板,再根据模板指引填写数据,最后才上传填写好的excel
文件,这是导入场景的业务流程
如果你不对传入的excel
文件进行模板校验,这时就算随便传个excel
文件,EasyExcel
也照样不会报错,而是解析出多行所有字段为空的数据,从而触发你后面的业务逻辑引发未知Bug
java
package com.example.bootrocketmq.study.wheel.easyexcel;
import com.alibaba.excel.annotation.ExcelIgnore;
import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.exception.ExcelAnalysisException;
import lombok.extern.slf4j.Slf4j;
import java.lang.reflect.Field;
import java.util.Collection;
import java.util.Map;
/**
* @author cui haida
* 2024/12/22
*/
@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 && !name.isEmpty() && !headNames.contains(name)) {
throw new ExcelAnalysisException("请检查导入的excel文件是否按模板填写!");
}
// 更新有效字段的数量
count++;
}
// 最后校验数据模型类的有效字段数量,与读到的excel列数量是否匹配
if (headMap.size() != count) {
throw new ExcelAnalysisException("请检查导入的excel文件是否按模板填写!");
}
}
}
先来看三个入参,分别是当前读到的表头集合,以及数据模型类的class对象和字段数组,这三个参数会用来校验模板的正确性。
-
注意点一:
ExcelProperty
注解提供了列名、权重、索引三种列匹配模式,可目前只是基于列名实现了校验逻辑,所以定义的数据模型类上,不支持使用index、order
来指定字段与excel列的匹配关系(也可以自行改造上面的第四步校验逻辑) -
注意点二:出于校验机制的存在,所以这个通用监听器无法正常读取多行头的
excel
文件,如果你想要正常解析多行头文件,则可以在创建监听器之后,手动调用offValidate()
方法来关闭模板校验机制,这时就能避免校验机制干扰多行头文件的读取 -
注意点三:如果你使用的
EasyExcel
版本较低,解析excel
数据时不会自动忽略已使用过的空行,即填写过内容又删除的数据行,在解析时仍然会被识别成一条数据,这种情况需要在监听器的invoke()
方法中主动过滤
2:分批处理监听器
前面封装的通用监听器,实际上只能满足数据量不大的业务场景,当数据量达到几万行、数十万、百万行时,如果再使用这个通用监听器就会存在OOM
风险
因为解析到的所有数据,都会暂存到内部的data
集合里,直至整个文件所有数据行读取结束
对于大文件而言,这种模式会给内存造成较大的负担,一旦同时导入的excel
文件数量过多,内存资源耗尽就会引发内存溢出问题,怎么办?可以选择分批处理读取到的数据
java
package com.example.bootrocketmq.study.wheel.easyexcel;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/**
* @author cui haida
* 2024/12/22
*/
public class BatchHandleListener<T> extends AnalysisEventListener<T> {
// 每次处理的行数(每次处理1000行 - 批次大小)
private static int BATCH_NUMBER = 1000;
// 临时存储读取到的excel数据
private List<T> data;
// 行数和批次编号
private int rows, batchNo;
// 是否要进行excel模板校验,如果需要校验,将validateSwitch设置为true
private boolean validateSwitch = true;
/*
* 每批数据的业务逻辑处理器
* 说明:如果业务方法会返回结果,可以将换成Function接口,同时类上新增一个类型参数
**/
private final Consumer<List<T>> businessHandler;
/*
* 用于校验excel模板正确性的字段
**/
private final Field[] fields;
private final Class<T> clazz;
/**
* @author cui haida
* 方法编写日期: 2024/12/22
* desc -> 构造方法1 - 使用的批次大小为默认的1000条
*/
public BatchHandleListener(Class<T> clazz, Consumer<List<T>> handle) {
// 通过构造器为字段赋值,用于校验excel文件与模板十分匹配
this(clazz, handle, BATCH_NUMBER);
}
/**
* @author cui haida
* 方法编写日期: 2024/12/22
* desc -> 构造方法2 - 传入具体使用的批次大小
*/
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.isEmpty()) {
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
对象。 - 当执行业务逻辑结束后,会重新
new
一个新的集合接收数据,而旧集合被断开引用关系后,会在短时间内被GC
,这里不使用clear()
方法的原因,是由于clear()
内部会去断开与每个元素的引用,这种方式会影响整个文件的读取性能。其次,当解析到的行数还未达到给定阈值时,会继续解析后面的数据,直到抵达阈值后重复前面的流程。 - 这个分批处理监听器,还重写了
doAfterAllAnalysed()
方法,这个方法会在整个文件解析完成后触发,里面实现的逻辑是为了做好收尾工作,因为最后一批可能达不到指定的上限,所以解析完之后还要视情况做一次处理。不过这里用了clear()
方法,毕竟这是最后一次收尾工作,后续也不需要新集合来接收数据了,直接清理现有集合最合适。
该监听器还提供了两个API
:
getRows()
:获取当前监听器解析的总行数;getBatchNo()
:获取当前正在处理的批次数(批次号)。
这两个方法返回的值,会随着excel
文件不断解析而不断变化,当文件彻底解析完成后,调用这两个方法可以获取到总批次数以及数据总行数。
关于这个监听器,会在百万级大文件解析的实战中才会用到,先暂时了解即可。
二:封装excel导出工具类
上面listener是导入的时候用到的,那么如果是导出的时候呢?
java
package com.example.bootrocketmq.study.wheel.easyexcel;
import com.alibaba.excel.EasyExcelFactory;
import com.alibaba.excel.support.ExcelTypeEnum;
import com.alibaba.excel.write.builder.ExcelWriterBuilder;
import com.alibaba.excel.write.metadata.style.WriteCellStyle;
import com.alibaba.excel.write.metadata.style.WriteFont;
import com.alibaba.excel.write.style.HorizontalCellStyleStrategy;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;
import java.util.List;
/**
* @author cui haida
* 2024/12/22
* 导出工具类
*/
public class ExcelExportUtil {
/*
* 三种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的通用方法
* clazz -> 导出excel所需的数据模型类
* execlData -> 需要导出的数据列表
* filenName -> 当前导出的文件名称(不带文件后缀)
* execlType -> 导出的文件类型(xlsx, xls, csv)
* response -> 网络相应对象
**/
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;
default:
}
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);
}
}
接着基于response
对象,设置了响应的数据类型、编码格式、文件名称等,最后就基于响应对象的输出流写出了生成的excel
文件。
当然,通常大文件导出并不会实时返回流给调用方,而是返回OSS
地址给前端去下载
java
/**
* @author cui haida
* 方法编写日期: 2024/12/22
* desc -> 上传文件到oss
* @param clazz -> 导出excel所需的数据模型类
* @param excelData -> 需要导出的数据列表
* @param excelType -> excel的类型
* @param fileName -> excel文件名称
*/
public static String exportExcelToOSS(Class<?> clazz, List<?> excelData, String fileName, ExcelTypeEnum excelType) throws IOException {
HorizontalCellStyleStrategy styleStrategy = 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文件的代码(实际使用请替换为真实上传)
* @param file 文件对象
**/
public static String uploadFileToOss(File file) {
String resultUrl = "";
// 省略上传至OSS的代码......
return resultUrl;
}
三:项目实践
1: excel导入并进行批量落库
为了更加的贴合业务环境,创建对应的数据库表进行测试
sql
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='熊猫表';
这是一张拥有十三个字段的熊猫表,下面初始化一些数据:
sql
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());
前面封装了通用监听器,下面就来试试效果,先来写一个最基本的excel导入接口,对应的导入模板如下:
首先来定义一个与excel模板匹配的数据模型类:
java
package org.example.myeasyexcel.model.excel;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.format.DateTimeFormat;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* @author cui haida
* 2024/12/23
* 熊猫导出类
*/
@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层的实现逻辑:
java
@Override
@Transactional(rollbackFor = Exception.class)
public void importExcelV1(MultipartFile file) {
// 创建通用监听器来解析excel文件
CommonListener<PandaReadModel> listener = new CommonListener<>(PandaReadModel.class);
try {
// 从excel模板中读取数据
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.isEmpty()) {
throw new BusinessException(ResponseCode.ANALYSIS_EXCEL_ERROR, "请检查您上传的excel文件是否为空!");
}
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);
}
接着定义controller层
java
@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();
}
}
调用外部接口就可以发现已经成功的读取了数据并
2:根据检索条件导出excel
导出也非常的容易,先定义导出模型类:
java
package org.example.myeasyexcel.model.vo;
import com.alibaba.excel.annotation.ExcelIgnore;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.format.DateTimeFormat;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import lombok.Data;
import org.example.myeasyexcel.converter.SexConverter;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* @author cui haida
* 2024/12/22
*/
@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, converter = SexConverter.class)
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;
}
当然,如果导入、导出的字段相同,你可以使用同一个类,不过个人建议是最好分开
下面再来定义一个查询条件参数类:
java
package org.example.myeasyexcel.model.dto;
import lombok.Data;
import java.io.Serializable;
/**
* @author cui haida
* 2024/12/23
*/
@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层:
java
/**
* 根据条件导出熊猫信息到Excel
*
* @param queryDTO 包含查询条件的PandaQueryDTO对象,用于筛选要导出的熊猫信息
* @param response HttpServletResponse对象,用于将Excel文件作为HTTP响应返回
*
* 本方法首先根据查询条件从数据库中筛选出相应的熊猫信息,然后利用ExcelUtil工具类
* 将这些信息导出为Excel文件,并通过HTTP响应返回给客户端如果导出过程中发生IO异常,
* 则记录错误日志并抛出业务异常,提示导出失败
*/
@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("熊猫基本信息导出失败,请稍后再试!");
}
}
对应的工具方法在上面已经说明,这里在描述一遍
java
package org.example.myeasyexcel.util;
import com.alibaba.excel.EasyExcelFactory;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.annotation.ExcelIgnore;
import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.exception.ExcelAnalysisException;
import com.alibaba.excel.support.ExcelTypeEnum;
import com.alibaba.excel.write.builder.ExcelWriterBuilder;
import com.alibaba.excel.write.metadata.style.WriteCellStyle;
import com.alibaba.excel.write.metadata.style.WriteFont;
import com.alibaba.excel.write.style.HorizontalCellStyleStrategy;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.example.myeasyexcel.enums.ExcelTemplate;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Field;
import java.net.URLEncoder;
import java.util.*;
/**
* @author cui haida
* 2024/12/23
*/
@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的通用方法
* clazz:传入excel导出的VO类
* data:传入需要导出的数据列表
* fileName:当前导出的excel文件名称(不带文件后缀)
* excelType:导出的文件类型
* response:网络响应对象
* */
public static void exportExcel(Class<?> clazz, List<?> excelData, String fileName, ExcelTypeEnum excelType, HttpServletResponse response) throws IOException {
HorizontalCellStyleStrategy styleStrategy = setCellStyle();
setResponse(fileName, excelType, response);
ExcelWriterBuilder writeWork = EasyExcelFactory.write(response.getOutputStream(), clazz);
writeWork.registerWriteHandler(styleStrategy).excelType(excelType).sheet().doWrite(excelData);
}
/*
* 初始化模板填充导出场景的写对象
* */
public static ExcelWriter initExportFillWriter(String fileName, ExcelTypeEnum excelType, ExcelTemplate template, HttpServletResponse response) throws IOException {
setResponse(fileName, excelType, response);
return EasyExcelFactory.write(response.getOutputStream())
.excelType(excelType)
.withTemplate(template.getPath()).build();
}
/*
* 设置通用的响应头信息
* */
public static void setResponse(String fileName, ExcelTypeEnum excelType, HttpServletResponse response) {
// 对文件名进行UTF-8编码、拼接文件后缀名
try {
fileName = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20") + excelType.getValue();
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
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);
}
/*
* 设置单元格风格
* */
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);
}
/*
* 上传错误的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 = "https://juejin.cn/user/862486453028888/posts";
// 省略上传至OSS的代码......
return resultUrl;
}
/*
* 校验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;
// 如果EasyExcel框架版本较低,请把这行代码放开,并将有效的字段列表返回,用于空值校验
// List<Field> validFields = new ArrayList<>(fields.length);
for (Field field : fields) {
// 忽略序列化ID字段
if ("serialVersionUID".equals(field.getName())) {
continue;
}
// 如果字段上存在忽略注解,则跳过当前字段
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++;
// validFields.add(field);
}
// 最后校验数据模型类的有效字段数量,与读到的excel列数量是否匹配
if (headMap.size() != count) {
throw new ExcelAnalysisException("请检查导入的excel文件是否按模板填写!");
}
}
/**
* 判断整行单元格数据是否均为空(依赖于validateExcelTemplate()方法返回的有效字段列表)
* 说明:该方法适用于低版本的EasyExcel读取数据时校验,因为低版本的不会自动忽略空行
*/
public static <T> boolean rowIsNull(T data, List<Field> validFields) {
if (data instanceof String) {
return "".equals(data);
}
try {
List<Boolean> fieldNulls = new ArrayList<>(validFields.size());
for (Field field : validFields) {
field.setAccessible(true);
Object value = field.get(data);
if (Objects.isNull(value)) {
fieldNulls.add(Boolean.TRUE);
} else {
fieldNulls.add(Boolean.FALSE);
}
}
return fieldNulls.stream().allMatch(Boolean.TRUE::equals);
} catch (Exception e) {
log.error("读取数据行[{}]解析失败: {}", data, e.getMessage());
}
return true;
}
}
最后编写controller层,就是简单的对service层进行调用
java
@PostMapping("/export/v1")
public void exportExcelV1(@RequestBody PandaQueryDTO queryDTO, HttpServletResponse response) {
pandaService.exportExcelByCondition(queryDTO, response);
}
3:校验写入的excel的数据
在有些场景中,我们需要对导入的excel数据进行校验清洗后才能使用
如果上传的excel文件存在校验出错的信息,则需像上图一样指明错误原因,最后回传给调用方下载查看,这该怎么实现呢?如下:
java
@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.isEmpty()) {
throw new BusinessException(ResponseCode.ANALYSIS_EXCEL_ERROR, "请检查您上传的excel文件是否为空!");
}
// 校验excel数据,如果校验未通过直接阻断执行,将错误信息返回给调用方
BaseImportExcelVO<String> result = validateExcelData(excelData);
if (result.getErrorFlag()) {
return result;
}
// 对校验通过的数据进行批量落库
batchSaveExcelData(excelData);
return result;
}
/*
* 校验导入的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);
// ExcelUtil.exportExcel(PandaReadErrorModel.class, validatePandas, fileName, ExcelTypeEnum.XLSX, response);
} 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;
}
4:部分字段不同的导出场景
来看这个场景,正如上图所示,目前有两个导出需求,可仅仅只有第一列字段不同罢了,这时我们需要定义两个接口吗?答案是不需要,面对这种大多数字段相同、部分字段不同的场景,可以基于多态的特性来实现,首先定义一个父类来声明公共字段:
java
@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;
}
接着分别为两个不同的场景定义子类,即:
java
/**
* 熊猫统计导出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实现:
java
@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模型类。
5:多行头的excel导出
有些业务场景下,我们需要导出像上图这样的合并表头文件,即一个大列下面有多个子列,整个文件由多个大列+N多个子列组成,这种需求该怎么导出呢?
其实十分简单,只靠ExcelProperty注解就能实现,如下:
java
@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发现列名是数组形式时,就会尝试自动合并相同列表的字段,而导出代码无需做任何改造:
java
@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("数据导出失败,请稍后再试!");
}
}