Spring boot實(shí)現(xiàn)低代碼量的Excel導(dǎo)入導(dǎo)出
[TOC]
2024年4月12日
Java的web開發(fā)需要excel的導(dǎo)入導(dǎo)出工具,所以需要一定的工具類實(shí)現(xiàn),如果是使用easypoi、Hutool導(dǎo)入導(dǎo)出excel,會(huì)非常的損耗內(nèi)存,因此可以嘗試使用easyexcel解決大數(shù)據(jù)量的數(shù)據(jù)的導(dǎo)入導(dǎo)出,且可以通過(guò)Java8的函數(shù)式編程解決該問(wèn)題。
使用easyexcel,雖然不太會(huì)出現(xiàn)OOM的問(wèn)題,但是如果是大數(shù)據(jù)量的情況下也會(huì)有一定量的內(nèi)存溢出的風(fēng)險(xiǎn),所以我打算從以下幾個(gè)方面優(yōu)化這個(gè)問(wèn)題:
使用Java8的函數(shù)式編程實(shí)現(xiàn)低代碼量的數(shù)據(jù)導(dǎo)入
使用反射等特性實(shí)現(xiàn)單個(gè)接口導(dǎo)入任意excel
使用線程池實(shí)現(xiàn)大數(shù)據(jù)量的excel導(dǎo)入
通過(guò)泛型實(shí)現(xiàn)數(shù)據(jù)導(dǎo)出
maven導(dǎo)入
<!--EasyExcel相關(guān)依賴-->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.0.5</version>
</dependency>
使用泛型實(shí)現(xiàn)對(duì)象的單個(gè)Sheet導(dǎo)入
先實(shí)現(xiàn)一個(gè)類,用來(lái)指代導(dǎo)入的特定的對(duì)象
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("stu_info")
@ApiModel("學(xué)生信息")
//@ExcelIgnoreUnannotated 沒(méi)有注解的字段都不轉(zhuǎn)換
publicclassStuInfo{
privatestaticfinallongserialVersionUID=1L;
/**
* 姓名
*/
// 設(shè)置字體,此處代表使用斜體
// ?? @ContentFontStyle(italic = BooleanEnum.TRUE)
// 設(shè)置列寬度的注解,注解中只有一個(gè)參數(shù)value,value的單位是字符長(zhǎng)度,最大可以設(shè)置255個(gè)字符
@ColumnWidth(10)
// @ExcelProperty 注解中有三個(gè)參數(shù)value,index,converter分別代表表名,列序號(hào),數(shù)據(jù)轉(zhuǎn)換方式
@ApiModelProperty("姓名")
@ExcelProperty(value="姓名",order=0)
@ExportHeader(value="姓名",index=1)
privateStringname;
/**
* 年齡
*/
// ?? @ExcelIgnore不將該字段轉(zhuǎn)換成Excel
@ExcelProperty(value="年齡",order=1)
@ApiModelProperty("年齡")
@ExportHeader(value="年齡",index=2)
privateIntegerage;
/**
* 身高
*/
//自定義格式-位數(shù)
// ?? @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;
/**
* 入學(xué)時(shí)間
*/
//自定義格式-時(shí)間格式
@DateTimeFormat("yyyy-MM-dd HH:mm:ss:")
@ExcelProperty(value="入學(xué)時(shí)間",order=6)
@ApiModelProperty("入學(xué)時(shí)間")
privateStringintake;
/**
* 出生日期
*/
@ExcelProperty(value="出生日期",order=7)
@ApiModelProperty("出生日期")
privateStringbirthday;
}
重寫ReadListener接口
@Slf4j
publicclassUploadDataListener<T>implementsReadListener<T>{
/**
* 每隔5條存儲(chǔ)數(shù)據(jù)庫(kù),實(shí)際使用中可以100條,然后清理list ,方便內(nèi)存回收
*/
privatestaticfinalintBATCH_COUNT=100;
/**
* 緩存的數(shù)據(jù)
*/
privateList<T>cachedDataList=ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);
/**
* Predicate用于過(guò)濾數(shù)據(jù)
*/
privatePredicate<T>predicate;
/**
* 調(diào)用持久層批量保存
*/
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,請(qǐng)使用這個(gè)構(gòu)造方法。每次創(chuàng)建Listener的時(shí)候需要把spring管理的類傳進(jìn)來(lái)
*
* @param demoDAO
*/
/**
* 這個(gè)每一條數(shù)據(jù)解析都會(huì)來(lái)調(diào)用
*
* @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);
// 達(dá)到BATCH_COUNT了,需要去存儲(chǔ)一次數(shù)據(jù)庫(kù),防止數(shù)據(jù)幾萬(wàn)條數(shù)據(jù)在內(nèi)存,容易OOM
if(cachedDataList.size()>=BATCH_COUNT) {
try{
// 執(zhí)行具體消費(fèi)邏輯
consumer.accept(cachedDataList);
}catch(Exceptione) {
log.error("Failed to upload data!data={}",cachedDataList);
thrownewBizException("導(dǎo)入失敗");
? ? ? ? ?? }
// 存儲(chǔ)完成清理 list
cachedDataList=ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);
? ? ?? }
?? }
/**
* 所有數(shù)據(jù)解析完成了 都會(huì)來(lái)調(diào)用
*
* @param context
*/
@Override
publicvoiddoAfterAllAnalysed(AnalysisContextcontext) {
// 這里也要保存數(shù)據(jù),確保最后遺留的數(shù)據(jù)也存儲(chǔ)到數(shù)據(jù)庫(kù)
if(CollUtil.isNotEmpty(cachedDataList)) {
try{
// 執(zhí)行具體消費(fèi)邏輯
consumer.accept(cachedDataList);
log.info("所有數(shù)據(jù)解析完成!");
}catch(Exceptione) {
log.error("Failed to upload data!data={}",cachedDataList);
// 拋出自定義的提示信息
if(einstanceofBizException) {
throwe;
? ? ? ? ? ? ?? }
thrownewBizException("導(dǎo)入失敗");
? ? ? ? ?? }
? ? ?? }
?? }
}
Controller層的實(shí)現(xiàn)
@ApiOperation("只需要一個(gè)readListener,解決全部的問(wèn)題")
@PostMapping("/update")
@ResponseBody
publicR<String>aListener4AllExcel(MultipartFilefile)throwsIOException{
try{
EasyExcel.read(file.getInputStream(),
StuInfo.class,
newUploadDataListener<StuInfo>(
list->{
// 校驗(yàn)數(shù)據(jù)
// ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?? ValidationUtils.validate(list);
// dao 保存···
//最好是手寫一個(gè),不要使用mybatis-plus的一條條新增的邏輯
service.saveBatch(list);
log.info("從Excel導(dǎo)入數(shù)據(jù)一共 {} 行 ",list.size());
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?? }))
.sheet()
.doRead();
}catch(IOExceptione) {
log.error("導(dǎo)入失敗",e);
thrownewBizException("導(dǎo)入失敗");
? ? ?? }
returnR.success("SUCCESS");
?? }
但是這種方式只能實(shí)現(xiàn)已存對(duì)象的功能實(shí)現(xiàn),如果要新增一種數(shù)據(jù)的導(dǎo)入,那我們需要怎么做呢?
可以通過(guò)讀取成Map,根據(jù)順序?qū)氲綌?shù)據(jù)庫(kù)中。
通過(guò)實(shí)現(xiàn)單個(gè)Sheet中任意一種數(shù)據(jù)的導(dǎo)入
Controller層的實(shí)現(xiàn)
@ApiOperation("只需要一個(gè)readListener,解決全部的問(wèn)題")
@PostMapping("/listenMapDara")
@ResponseBody
publicR<String>listenMapDara(@ApiParam(value="表編碼",required=true)
@NotBlank(message="表編碼不能為空")
@RequestParam("tableCode")StringtableCode,
@ApiParam(value="上傳的文件",required=true)
@NotNull(message="上傳文件不能為空")MultipartFilefile)throwsIOException{
try{
//根據(jù)tableCode獲取這張表的字段,可以作為insert與劇中的信息
EasyExcel.read(file.getInputStream(),
newNonClazzOrientedListener(
list->{
// 校驗(yàn)數(shù)據(jù)
// ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?? ValidationUtils.validate(list);
// dao 保存···
log.info("從Excel導(dǎo)入數(shù)據(jù)一共 {} 行 ",list.size());
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?? }))
.sheet()
.doRead();
}catch(IOExceptione) {
log.error("導(dǎo)入失敗",e);
thrownewBizException("導(dǎo)入失敗");
? ? ?? }
returnR.success("SUCCESS");
?? }
重寫ReadListener接口
@Slf4j
publicclassNonClazzOrientedListenerimplementsReadListener<Map<Integer,String>>{
/**
* 每隔5條存儲(chǔ)數(shù)據(jù)庫(kù),實(shí)際使用中可以100條,然后清理list ,方便內(nèi)存回收
*/
privatestaticfinalintBATCH_COUNT=100;
privateList<List<Object>>rowsList=ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);
privateList<Object>rowList=newArrayList<>();
/**
* Predicate用于過(guò)濾數(shù)據(jù)
*/
privatePredicate<Map<Integer,String>>predicate;
/**
* 調(diào)用持久層批量保存
*/
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標(biāo)識(shí)
*/
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("執(zhí)行存儲(chǔ)程序");
log.info("rowsList is {}",rowsList);
rowsList.clear();
? ? ?? }
?? }
@Override
publicvoiddoAfterAllAnalysed(AnalysisContextanalysisContext) {
consumer.accept(rowsList);
if(CollUtil.isNotEmpty(rowsList)) {
try{
log.debug("執(zhí)行最后的程序");
log.info("rowsList is {}",rowsList);
}catch(Exceptione) {
log.error("Failed to upload data!data={}",rowsList);
// 拋出自定義的提示信息
if(einstanceofBizException) {
throwe;
? ? ? ? ? ? ?? }
thrownewBizException("導(dǎo)入失敗");
}finally{
rowsList.clear();
? ? ? ? ?? }
? ? ?? }
?? }
這種方式可以通過(guò)把表中的字段順序存儲(chǔ)起來(lái),通過(guò)配置數(shù)據(jù)和字段的位置實(shí)現(xiàn)數(shù)據(jù)的新增,那么如果出現(xiàn)了導(dǎo)出數(shù)據(jù)模板/手寫excel的時(shí)候順序和導(dǎo)入的時(shí)候順序不一樣怎么辦?
可以通過(guò)讀取header進(jìn)行實(shí)現(xiàn),通過(guò)表頭讀取到的字段,和數(shù)據(jù)庫(kù)中表的字段進(jìn)行比對(duì),只取其中存在的數(shù)據(jù)進(jìn)行排序添加
/**
* 這里會(huì)一行行的返回頭
*
* @param headMap
* @param context
*/
@Override
publicvoidinvokeHead(Map<Integer,ReadCellData<?>>headMap,AnalysisContextcontext) {
//該方法必然會(huì)在讀取數(shù)據(jù)之前進(jìn)行
Map<Integer,String>columMap=ConverterUtils.convertToStringMap(headMap,context);
//通過(guò)數(shù)據(jù)交互拿到這個(gè)表的表頭
// ? ? ?? Map<String,String> columnList=dao.xxxx();
Map<String,String>columnList=newHashMap();
columMap.forEach((key,value)->{
if(columnList.containsKey(value)) {
filterList.add(key);
? ? ? ? ?? }
? ? ?? });
//過(guò)濾到了只存在表里面的數(shù)據(jù),順序就不用擔(dān)心了,可以直接把filterList的數(shù)據(jù)用于排序,可以根據(jù)mybatis做一個(gè)動(dòng)態(tài)sql進(jìn)行應(yīng)用
log.info("解析到一條頭數(shù)據(jù):{}",JSON.toJSONString(columMap));
// 如果想轉(zhuǎn)成成 Map<Integer,String>
// 方案1: 不要implements ReadListener 而是 extends AnalysisEventListener
// 方案2: 調(diào)用 ConverterUtils.convertToStringMap(headMap, context) 自動(dòng)會(huì)轉(zhuǎn)換
?? }
那么這些問(wèn)題都解決了,如果出現(xiàn)大數(shù)據(jù)量的情況,如果要極大的使用到cpu,該怎么做呢?
可以嘗試使用線程池進(jìn)行實(shí)現(xiàn)
使用線程池進(jìn)行多線程導(dǎo)入大量數(shù)據(jù)
Java中線程池的開發(fā)與使用與原理我可以單獨(dú)寫一篇文章進(jìn)行講解,但是在這邊為了進(jìn)行好的開發(fā)我先給出一套固定一點(diǎn)的方法。
由于ReadListener不能被注冊(cè)到IOC容器里面,所以需要在外面開啟
詳情可見Spring Boot通過(guò)EasyExcel異步多線程實(shí)現(xiàn)大數(shù)據(jù)量Excel導(dǎo)入,百萬(wàn)數(shù)據(jù)30秒
通過(guò)泛型實(shí)現(xiàn)對(duì)象類型的導(dǎo)出
public<T>voidcommonExport(StringfileName,List<T>data,Class<T>clazz,HttpServletResponseresponse)throwsIOException{
if(CollectionUtil.isEmpty(data)) {
data=newArrayList<>();
? ? ?? }
//設(shè)置標(biāo)題
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);
?? }
直接使用該方法可以作為公共的數(shù)據(jù)的導(dǎo)出接口
如果想要?jiǎng)討B(tài)的下載任意一組數(shù)據(jù)怎么辦呢?可以使用這個(gè)方法
publicvoidexportFreely(StringfileName,List<List<Object>>data,List<List<String>>head,HttpServletResponseresponse)throwsIOException{
if(CollectionUtil.isEmpty(data)) {
data=newArrayList<>();
? ? ?? }
//設(shè)置標(biāo)題
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);
?? }
什么?不僅想一個(gè)接口展示全部的數(shù)據(jù)與信息,還要增加篩選條件?這個(gè)后期我可以單獨(dú)寫一篇文章解決這個(gè)問(wèn)題。
今天的分享就到這里了。