Spring boot实现低代码量的Excel导入导出
[TOC]
2024年4月12日
Java的web开发需要excel的导入导出工具,所以需要一定的工具类实现,如果是使用easypoi、Hutool导入导出excel,会非常的损耗内存,因此可以尝试使用easyexcel解决大数据量的数据的导入导出,且可以通过Java8的函数式编程解决该问题。
使用easyexcel,虽然不太会出现OOM的问题,但是如果是大数据量的情况下也会有一定量的内存溢出的风险,所以我打算从以下几个方面优化这个问题:
使用Java8的函数式编程实现低代码量的数据导入
使用反射等特性实现单个接口导入任意excel
使用线程池实现大数据量的excel导入
通过泛型实现数据导出
maven导入
<!--EasyExcel相关依赖-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.0.5</version>
</dependency>
使用泛型实现对象的单个Sheet导入
先实现一个类,用来指代导入的特定的对象
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("stu_info")
@ApiModel("学生信息")
//@ExcelIgnoreUnannotated 没有注解的字段都不转换
publicclassStuInfo{
privatestaticfinallongserialVersionUID=1L;
/**
* 姓名
*/
// 设置字体,此处代表使用斜体
// @ContentFontStyle(italic = BooleanEnum.TRUE)
// 设置列宽度的注解,注解中只有一个参数value,value的单位是字符长度,最大可以设置255个字符
@ColumnWidth(10)
// @ExcelProperty 注解中有三个参数value,index,converter分别代表表名,列序号,数据转换方式
@ApiModelProperty("姓名")
@ExcelProperty(value="姓名",order=0)
@ExportHeader(value="姓名",index=1)
privateStringname;
/**
* 年龄
*/
// @ExcelIgnore不将该字段转换成Excel
@ExcelProperty(value="年龄",order=1)
@ApiModelProperty("年龄")
@ExportHeader(value="年龄",index=2)
privateIntegerage;
/**
* 身高
*/
//自定义格式-位数
// @NumberFormat("#.##%")
@ExcelProperty(value="身高",order=2)
@ApiModelProperty("身高")
@ExportHeader(value="身高",index=4)
privateDoubletall;
/**
* 自我介绍
*/
@ExcelProperty(value="自我介绍",order=3)
@ApiModelProperty("自我介绍")
@ExportHeader(value="自我介绍",index=3,ignore=true)
privateStringselfIntroduce;
/**
* 图片信息
*/
@ExcelProperty(value="图片信息",order=4)
@ApiModelProperty("图片信息")
@ExportHeader(value="图片信息",ignore=true)
privateBlobpicture;
/**
* 性别
*/
@ExcelProperty(value="性别",order=5)
@ApiModelProperty("性别")
privateIntegergender;
/**
* 入学时间
*/
//自定义格式-时间格式
@DateTimeFormat("yyyy-MM-dd HH:mm:ss:")
@ExcelProperty(value="入学时间",order=6)
@ApiModelProperty("入学时间")
privateStringintake;
/**
* 出生日期
*/
@ExcelProperty(value="出生日期",order=7)
@ApiModelProperty("出生日期")
privateStringbirthday;
}
重写ReadListener接口
@Slf4j
publicclassUploadDataListener<T>implementsReadListener<T>{
/**
* 每隔5条存储数据库,实际使用中可以100条,然后清理list ,方便内存回收
*/
privatestaticfinalintBATCH_COUNT=100;
/**
* 缓存的数据
*/
privateList<T>cachedDataList=ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);
/**
* Predicate用于过滤数据
*/
privatePredicate<T>predicate;
/**
* 调用持久层批量保存
*/
privateConsumer<Collection<T>>consumer;
publicUploadDataListener(Predicate<T>predicate,Consumer<Collection<T>>consumer) {
this.predicate=predicate;
this.consumer=consumer;
}
publicUploadDataListener(Consumer<Collection<T>>consumer) {
this.consumer=consumer;
}
/**
* 如果使用了spring,请使用这个构造方法。每次创建Listener的时候需要把spring管理的类传进来
*
* @param demoDAO
*/
/**
* 这个每一条数据解析都会来调用
*
* @param data one row value. Is is same as {@link AnalysisContext#readRowHolder()}
* @param context
*/
@Override
publicvoidinvoke(Tdata,AnalysisContextcontext) {
if(predicate!=null&&!predicate.test(data)) {
return;
}
cachedDataList.add(data);
// 达到BATCH_COUNT了,需要去存储一次数据库,防止数据几万条数据在内存,容易OOM
if(cachedDataList.size()>=BATCH_COUNT) {
try{
// 执行具体消费逻辑
consumer.accept(cachedDataList);
}catch(Exceptione) {
log.error("Failed to upload data!data={}",cachedDataList);
thrownewBizException("导入失败");
}
// 存储完成清理 list
cachedDataList=ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);
}
}
/**
* 所有数据解析完成了 都会来调用
*
* @param context
*/
@Override
publicvoiddoAfterAllAnalysed(AnalysisContextcontext) {
// 这里也要保存数据,确保最后遗留的数据也存储到数据库
if(CollUtil.isNotEmpty(cachedDataList)) {
try{
// 执行具体消费逻辑
consumer.accept(cachedDataList);
log.info("所有数据解析完成!");
}catch(Exceptione) {
log.error("Failed to upload data!data={}",cachedDataList);
// 抛出自定义的提示信息
if(einstanceofBizException) {
throwe;
}
thrownewBizException("导入失败");
}
}
}
}
Controller层的实现
@ApiOperation("只需要一个readListener,解决全部的问题")
@PostMapping("/update")
@ResponseBody
publicR<String>aListener4AllExcel(MultipartFilefile)throwsIOException{
try{
EasyExcel.read(file.getInputStream(),
StuInfo.class,
newUploadDataListener<StuInfo>(
list->{
// 校验数据
// ValidationUtils.validate(list);
// dao 保存···
//最好是手写一个,不要使用mybatis-plus的一条条新增的逻辑
service.saveBatch(list);
log.info("从Excel导入数据一共 {} 行 ",list.size());
}))
.sheet()
.doRead();
}catch(IOExceptione) {
log.error("导入失败",e);
thrownewBizException("导入失败");
}
returnR.success("SUCCESS");
}
但是这种方式只能实现已存对象的功能实现,如果要新增一种数据的导入,那我们需要怎么做呢?
可以通过读取成Map,根据顺序导入到数据库中。
通过实现单个Sheet中任意一种数据的导入
Controller层的实现
@ApiOperation("只需要一个readListener,解决全部的问题")
@PostMapping("/listenMapDara")
@ResponseBody
publicR<String>listenMapDara(@ApiParam(value="表编码",required=true)
@NotBlank(message="表编码不能为空")
@RequestParam("tableCode")StringtableCode,
@ApiParam(value="上传的文件",required=true)
@NotNull(message="上传文件不能为空")MultipartFilefile)throwsIOException{
try{
//根据tableCode获取这张表的字段,可以作为insert与剧中的信息
EasyExcel.read(file.getInputStream(),
newNonClazzOrientedListener(
list->{
// 校验数据
// ValidationUtils.validate(list);
// dao 保存···
log.info("从Excel导入数据一共 {} 行 ",list.size());
}))
.sheet()
.doRead();
}catch(IOExceptione) {
log.error("导入失败",e);
thrownewBizException("导入失败");
}
returnR.success("SUCCESS");
}
重写ReadListener接口
@Slf4j
publicclassNonClazzOrientedListenerimplementsReadListener<Map<Integer,String>>{
/**
* 每隔5条存储数据库,实际使用中可以100条,然后清理list ,方便内存回收
*/
privatestaticfinalintBATCH_COUNT=100;
privateList<List<Object>>rowsList=ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);
privateList<Object>rowList=newArrayList<>();
/**
* Predicate用于过滤数据
*/
privatePredicate<Map<Integer,String>>predicate;
/**
* 调用持久层批量保存
*/
privateConsumer<List>consumer;
publicNonClazzOrientedListener(Predicate<Map<Integer,String>>predicate,Consumer<List>consumer) {
this.predicate=predicate;
this.consumer=consumer;
}
publicNonClazzOrientedListener(Consumer<List>consumer) {
this.consumer=consumer;
}
/**
* 添加deviceName标识
*/
privatebooleanflag=false;
@Override
publicvoidinvoke(Map<Integer,String>row,AnalysisContextanalysisContext) {
consumer.accept(rowsList);
rowList.clear();
row.forEach((k,v)->{
log.debug("key is {},value is {}",k,v);
rowList.add(v==null?"":v);
});
rowsList.add(rowList);
if(rowsList.size()>BATCH_COUNT) {
log.debug("执行存储程序");
log.info("rowsList is {}",rowsList);
rowsList.clear();
}
}
@Override
publicvoiddoAfterAllAnalysed(AnalysisContextanalysisContext) {
consumer.accept(rowsList);
if(CollUtil.isNotEmpty(rowsList)) {
try{
log.debug("执行最后的程序");
log.info("rowsList is {}",rowsList);
}catch(Exceptione) {
log.error("Failed to upload data!data={}",rowsList);
// 抛出自定义的提示信息
if(einstanceofBizException) {
throwe;
}
thrownewBizException("导入失败");
}finally{
rowsList.clear();
}
}
}
这种方式可以通过把表中的字段顺序存储起来,通过配置数据和字段的位置实现数据的新增,那么如果出现了导出数据模板/手写excel的时候顺序和导入的时候顺序不一样怎么办?
可以通过读取header进行实现,通过表头读取到的字段,和数据库中表的字段进行比对,只取其中存在的数据进行排序添加
/**
* 这里会一行行的返回头
*
* @param headMap
* @param context
*/
@Override
publicvoidinvokeHead(Map<Integer,ReadCellData<?>>headMap,AnalysisContextcontext) {
//该方法必然会在读取数据之前进行
Map<Integer,String>columMap=ConverterUtils.convertToStringMap(headMap,context);
//通过数据交互拿到这个表的表头
// Map<String,String> columnList=dao.xxxx();
Map<String,String>columnList=newHashMap();
columMap.forEach((key,value)->{
if(columnList.containsKey(value)) {
filterList.add(key);
}
});
//过滤到了只存在表里面的数据,顺序就不用担心了,可以直接把filterList的数据用于排序,可以根据mybatis做一个动态sql进行应用
log.info("解析到一条头数据:{}",JSON.toJSONString(columMap));
// 如果想转成成 Map<Integer,String>
// 方案1: 不要implements ReadListener 而是 extends AnalysisEventListener
// 方案2: 调用 ConverterUtils.convertToStringMap(headMap, context) 自动会转换
}
那么这些问题都解决了,如果出现大数据量的情况,如果要极大的使用到cpu,该怎么做呢?
可以尝试使用线程池进行实现
使用线程池进行多线程导入大量数据
Java中线程池的开发与使用与原理我可以单独写一篇文章进行讲解,但是在这边为了进行好的开发我先给出一套固定一点的方法。
由于ReadListener不能被注册到IOC容器里面,所以需要在外面开启
详情可见Spring Boot通过EasyExcel异步多线程实现大数据量Excel导入,百万数据30秒
通过泛型实现对象类型的导出
public<T>voidcommonExport(StringfileName,List<T>data,Class<T>clazz,HttpServletResponseresponse)throwsIOException{
if(CollectionUtil.isEmpty(data)) {
data=newArrayList<>();
}
//设置标题
fileName=URLEncoder.encode(fileName,"UTF-8");
response.setContentType("application/vnd.ms-excel");
response.setCharacterEncoding("utf-8");
response.setHeader("Content-disposition","attachment;filename="+fileName+".xlsx");
EasyExcel.write(response.getOutputStream()).head(clazz).sheet("sheet1").doWrite(data);
}
直接使用该方法可以作为公共的数据的导出接口
如果想要动态的下载任意一组数据怎么办呢?可以使用这个方法
publicvoidexportFreely(StringfileName,List<List<Object>>data,List<List<String>>head,HttpServletResponseresponse)throwsIOException{
if(CollectionUtil.isEmpty(data)) {
data=newArrayList<>();
}
//设置标题
fileName=URLEncoder.encode(fileName,"UTF-8");
response.setContentType("application/vnd.ms-excel");
response.setCharacterEncoding("utf-8");
response.setHeader("Content-disposition","attachment;filename="+fileName+".xlsx");
EasyExcel.write(response.getOutputStream()).head(head).sheet("sheet1").doWrite(data);
}
什么?不仅想一个接口展示全部的数据与信息,还要增加筛选条件?这个后期我可以单独写一篇文章解决这个问题。
今天的分享就到这里了。