背景:项目中可能需要用到大数量的导入导出,处理的数据量可能达到了100w及以上,该文章针对该问题,提出解决方案-easypoi。
一、项目使用框架
后端: Springboot + Maven + MybatisPlus + Mysql
前端:Vite + Vue3 + + ElementPlus
easypoi官网:doc.wupaas.com/docs/easypo...
二、项目依赖
xml
<!-- mysql -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.2.0</version>
</dependency>
<!-- mybatis-plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.5</version>
</dependency>
<!-- easypoi -->
<dependency>
<groupId>cn.afterturn</groupId>
<artifactId>easypoi-spring-boot-starter</artifactId>
<version>4.4.0</version>
</dependency>
<!-- hutool -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.25</version>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
三、添加数据库相关文件
数据库脚本见仓库中 db 文件夹。创建数据库及数据库表。然后生成相关 entity、mapper等文件。 如果是使用 IDEA 的小伙伴,这里推荐一个生成插件。MyBatisX。前端见 ui 文件夹
四、导入
1、下载easypoi导入模板
使用注解方式,下载easypoi模板。
1.1、创建excel实体类
java
@Data
public class EasypoiExcel {
/** 学生名称 */
@Excel(name = "学生名称", width = 30)
private String name;
/** 学号 */
@Excel(name = "学号", width = 30)
private String studentNum;
/** 学生性别 */
@Excel(name = "学生性别", width = 30, replace = {"男_1", "女_2"}, addressList = true)
private String sex;
/** 学生年龄 */
@Excel(name = "学生年龄", width = 10)
private Integer age;
/** 出生日期 */
@Excel(name = "出生日期", width = 30, format = "yyyy-MM-dd")
private Date birthday;
}
@Excel注解说明:
- name:导出列名称
- width:列宽
- format:时间格式化
- replace:值的替换,配合 addressList = true,参数使用,可以实现列下拉选择值。效果如下图。这样在导入文件时,如果选择男,则后台接收到的参数就是1。
1.2、创建控制器
java
@RestController
@RequestMapping("/easypoi")
public class EasypoiController {
@Resource
private EasypoiService easypoiService;
/**
* 下载导入模版
* @author LP to 2024/2/27
*/
@RequestMapping("/download")
public void easypoiDownload(HttpServletResponse response) {
ExportParams params = new ExportParams();
try {
ServletOutputStream outputStream = response.getOutputStream();
Workbook workbook = ExcelExportUtil.exportExcel(params, EasypoiImportExcel.class, Lists.newArrayList());
workbook.write(outputStream);
outputStream.close();
} catch (Exception e) {
throw new BizException("下载easypoi导入模板失败");
}
}
}
1.3、前端代码
添加axios配置
javascript
import axios from "axios";
const service = axios.create({
baseURL: '/api',
})
// 添加响应拦截器
service.interceptors.response.use(function (response) {
return response.data;
}, function (error) {
return Promise.reject(error);
});
export default service
注意:这里有跨域问题,所以要在vite.config.js文件里配置proxy,解决跨域问题,以下是解决跨域问题的主要代码
javascript
...
server: {
proxy: {
'/api': {
target: "http://127.0.0.1:8080",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
}
}
...
添加api请求配置文件
javascript
import axios from '@/axios'
// 下载模板
export function easypoiDownloadApi() {
return axios({
url: '/easypoi/download',
method: 'post',
responseType: 'blob'
})
}
添加导出事件。这里导出文件的实现方式是,获取后端接口的文件流。然后将文件流转为Blob对象,并使用URL.createObjectURL()方法创建一个URL地址。然后出发click()点击事件触发下载
vue
<template>
<el-card shadow="never" class="border-0">
<div class="flex mb-4">
<el-button type="primary" @click="easypoiDownload">
<el-icon class="mr-1">
<Download/>
</el-icon>
下载模板
</el-button>
</el-card>
</template>
<script setup>
import {easypoiDownloadApi} from "@/api/easypoi.js";
const easypoiDownload = () => {
easypoiDownloadApi()
.then(resp => {
const downloadElement = document.createElement('a')
const href = window.URL.createObjectURL(new Blob([resp], {type: 'application/vnd.ms-excel'}))
downloadElement.href = href
downloadElement.download = '学生信息.xlsx'
document.body.appendChild(downloadElement)
downloadElement.click() // 点击下载
document.body.removeChild(downloadElement) // 下载完成移除元素
window.URL.revokeObjectURL(href) // 释放掉blob对象
})
}
</script>
2、导入easypoi
先准备一份100w条数据的excel。小技巧:使用excel模拟数据,比如学生名称,在第二行设置张三1,复制该单元格,然后在选择A2:A1000001,Ctrl+C。复制完之后,鼠标放到第二行右下角,出现+号双击。1~100w的数据就出来了
2.1、创建excel实体类
java
@Data
@EqualsAndHashCode(callSuper = true)
public class EasypoiImportExcel extends EasypoiExcel{
@Excel(name = "处理信息", width = 35, orderNum = "99")
private String errorMsg;
}
2.2、创建控制器
java
@RequestMapping("/import")
public ResultData<?> easypoiImport(MultipartFile file) {
if (file.isEmpty()) {
throw new BizException("导入文件为空");
}
easypoiService.easypoiImport(file);
return ResultData.success();
}
2.3、实现类
java
@Service
@Slf4j
@AllArgsConstructor
public class EasypoiServiceImpl implements EasypoiService {
private final ApplicationContext applicationContext;
@Override
public void easypoiImport(MultipartFile file) {
try {
long startTime = System.currentTimeMillis();
// 实际处理数据
EasypoiStudentListener listener = applicationContext.getBean(EasypoiStudentListener.class);
ExcelImportUtil.importExcelBySax(new ByteArrayInputStream(file.getBytes()), EasypoiImportExcel.class, new ImportParams(), listener);
log.info("总耗时:{}", System.currentTimeMillis() - startTime);
} catch (Exception e) {
throw new BizException("导入异常");
}
}
}
EasypoiStudentListener类:
java
@Slf4j
@Service
@Scope("prototype")
public class EasypoiStudentListener implements IReadHandler<EasypoiImportExcel> {
/** 成功数据集合 */
private final CopyOnWriteArrayList<EasypoiImportExcel> successList = new CopyOnWriteArrayList<>();
/** 失败数据集合 */
private final CopyOnWriteArrayList<EasypoiImportExcel> failList = new CopyOnWriteArrayList<>();
/** 批处理条数 */
private final static int BATCH_COUNT = 20000;
@Resource
private ThreadPoolExecutor easypoiStudentImportThreadPool;
@Resource
private StudentMapper studentMapper;
/**
* 解析数据
*/
@Override
public void handler(EasypoiImportExcel importExcel) {
// 参数校验
if (StringUtils.isBlank(importExcel.getName())) {
importExcel.setErrorMsg("学生名称不能为空");
failList.add(importExcel);
return;
}
successList.add(importExcel);
if (successList.size() >= BATCH_COUNT) {
saveDate();
}
}
/**
* 处理完成所有数据之后调用
*/
@Override
public void doAfterAll() {
saveDate();
}
/**
* 保存信息
*/
private void saveDate() {
// 拆分list,每个list 2000 条数据
List<List<EasypoiImportExcel>> lists = ListUtil.split(successList, 2000);
final CountDownLatch countDownLatch = new CountDownLatch(lists.size());
for (List<EasypoiImportExcel> list : lists) {
easypoiStudentImportThreadPool.execute(() -> {
try {
studentMapper.insertBatch(list.stream().map(o -> {
Student student = new Student();
student.setNo(IdUtil.getSnowflakeNextId());
student.setName(o.getName());
student.setStudentNum(o.getStudentNum());
student.setAge(o.getAge());
student.setSex(o.getSex());
student.setBirthday(o.getBirthday());
return student;
}).collect(Collectors.toList()));
} catch (Exception e) {
log.error("启动线程失败,e:{}", e.getMessage(), e);
} finally {
countDownLatch.countDown();
}
});
}
// 等待所有线程执行完
try {
countDownLatch.await();
} catch (Exception e) {
log.error("等待所有线程执行完异常,e:{}", e.getMessage(), e);
}
// 提前将不再使用的集合清空,释放资源
successList.clear();
failList.clear();
lists.clear();
}
}
EasypoiStudentListener类逻辑详解: easypoi实现IReadHandler接口,数据一条一条读取。将每条读取的数据通过一个list集合存储。成功的数据存入successList,失败的数据存入failList。每存储2w条之后,进行一次数据库存储操作。然后清除集合,这样可以避免内存溢出。将2w条数据,分成20份,每份1000条数据。使用线程池处理这20份数据。并且每次插入数据库时,使用批量插入。这里使用CountDownLatch类,保证线程全部执行完成之后,将不再使用的集合清空,释放资源 注意:这里使用线程安全的CopyOnWriteArrayList。因为这里使用多线程处理list,如果使用ArrayList,数据会错乱。另外线程池的大小我是使用:CPU数(16) * 2 + 1。所以我这里分了20份数据。各位可以按电脑配置进行更改
2.4、创建前端代码
java
<template>
<el-button type="primary" @click="dialogVisible = true">
<el-icon class="mr-1">
<Upload/>
</el-icon>
导入
</el-button>
<el-dialog
title="导入"
v-model="dialogVisible"
:open="openDialog"
width="30%">
<el-upload
class="pt-5"
ref="formRef"
action="#"
accept=".xlsx,.xls"
:auto-upload="false"
:file-list="fileList"
:http-request="handleFileSuccess"
:on-change="handlerFileChange"
:limit="1"
>
<el-button :icon="Upload" type="primary"
>选择文件
</el-button>
<template #tip>
<div class="el-upload__tip">
<span>仅允许导入xls、xlsx格式文件。</span>
</div>
</template>
</el-upload>
<div class="mt-10">
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" @click="onSubmit" :loading="importButtonLoading">确 定</el-button>
</div>
</el-dialog>
</template>
<script setup>
const dialogVisible = ref(false);
const importButtonLoading = ref(false);
const formRef = ref();
const fileList = ref();
const openDialog = () => {
importButtonLoading.value = false;
formRef.value.clearFiles();
}
const handlerFileChange = (file, fileListVal) => {
fileList.value = fileListVal;
};
/** 文件上传成功处理 */
const handleFileSuccess = async data => {
const formData = new FormData();
formData.append("file", data.file);
await easypoiImportFileApi(formData).then(resp => {
if (resp.result) {
ElMessage.success(resp.msg)
dialogVisible.value = false
} else {
ElMessage.error(resp.msg)
}
importButtonLoading.value = false
}).catch(err => {
ElMessage.error(err.msg)
});
};
const onSubmit = () => {
if (!fileList.value || fileList.value.length === 0) {
ElMessage({
message: "请选择文件",
type: "error"
});
return;
}
importButtonLoading.value = true
formRef.value.submit();
};
</script>
添加api请求配置文件
javascript
import axios from '@/axios'
export const easypoiImportFileApi = (data) => {
return axios({
url: '/easypoi/import',
method: 'post',
data,
header: {
headers: {
"Content-Type": "multipart/form-data"
}
}
});
};
注意:导入的文件大于20M,所以需要在application.yml文件中配置:
java
spring:
# 设置单个文件大小
servlet:
multipart:
max-file-size: 200MB
#设置单次请求文件的总大小
max-request-size: 200MB
至此使用easypoi导入大批量数据已经完成。经过测试,导入100w条数据,只解析文件,用时/ms: 解析文件 + 插入数据库,用时/ms 总共耗时大概接近9分钟左右,公司业务对于这个时间勉强能接受,如果业务需要做数据重复校验,这个时间会更长。如果公司业务告诉你,一次性要导入几百万条数据,这个时候该怎么办呢?让你们公司处理数据的人把表数据多分几份把。球球了。不要难为我们了。
五、导出
5.1、创建excel实体类
java
@Data
public class EasypoiExcel {
/** 学生名称 */
@Excel(name = "学生名称", width = 30)
private String name;
/** 学号 */
@Excel(name = "学号", width = 30)
private String studentNum;
/** 学生性别 */
@Excel(name = "学生性别", width = 30, replace = {"男_1", "女_2"}, addressList = true)
private String sex;
/** 学生年龄 */
@Excel(name = "学生年龄", width = 10)
private Integer age;
/** 出生日期 */
@Excel(name = "出生日期", width = 30, format = "yyyy-MM-dd")
private Date birthday;
}
5.2、创建控制器
java
@PostMapping("/export")
public void easypoiExport(HttpServletResponse response) {
try {
long startTime = System.currentTimeMillis();
response.setContentType("text/csv");
ServletOutputStream outputStream = response.getOutputStream();
CsvExportParams csvExportParams = new CsvExportParams();
CsvExportUtil.exportCsv(csvExportParams, EasypoiExcel.class, easypoiStudentExportHandler, null, outputStream);
outputStream.flush();
outputStream.close();
log.info("导出耗时/ms:" + (System.currentTimeMillis() - startTime));
} catch (Exception e) {
log.error("easypoi导出失败,e:{}", e.getMessage(), e);
}
}
CsvExportUtil.exportCsv()方法参数说明:
- CsvExportParams:导出Csv表格标题属性
- Class<?>:导出实体类
- IExcelExportServer:导出实现类,使用分页方式,从第一页开始。这样能使用分页查询数据,避免一次性查询大量数据存入内存中,导致内存溢出
- queryParams:请求参数,在这里传入请求参数,在实现的时候,第一个接收的参数就是传入的请求参数
- OutputStream:输出流
每次查询 10w 条数据,导出 100w 条数据平均耗时在1分钟左右。
5.3、创建导出实现类
java
@Service
public class EasypoiStudentExportHandler implements IExcelExportServer {
/**
* 每次分页查询条数
*/
private final static Integer PAGE_SIZE = 100000;
@Resource
private StudentMapper studentMapper;
/**
* 查询数据接口
* @param queryParams 查询条件
* @param page 当前页数从1开始
*/
@Override
public List<Object> selectListForExcelExport(Object queryParams, int page) {
Page<Student> pageStu = new Page<>(page, PAGE_SIZE);
QueryWrapper<Student> qw = new QueryWrapper<>();
qw.select("name", "student_num", "age", "sex", "birthday");
qw.orderByAsc("student_num");
Page<Student> studentPage = studentMapper.selectPage(pageStu, qw);
List<Object> resultObjLs = Lists.newArrayList();
if (CollectionUtils.isNotEmpty(studentPage.getRecords())) {
resultObjLs.addAll(
studentPage.getRecords().stream().map(o -> {
EasypoiExcel excel = new EasypoiExcel();
excel.setName(o.getName());
excel.setStudentNum(o.getStudentNum());
excel.setAge(o.getAge());
excel.setSex(o.getSex());
excel.setBirthday(o.getBirthday());
return excel;
}).toList()
);
}
return resultObjLs;
}
}
5.4、前端代码
java
<template>
<el-button type="primary" @click="easypoiExport">
<el-icon class="mr-1">
<Download/>
</el-icon>
导出
</el-button>
</template>
<script setup>
const easypoiExport = () => {
easypoiExportApi()
.then(resp => {
const downloadElement = document.createElement('a')
const href = window.URL.createObjectURL(new Blob([resp]))
downloadElement.href = href
downloadElement.download = 'easypoi导出.csv'
document.body.appendChild(downloadElement)
downloadElement.click() // 点击下载
document.body.removeChild(downloadElement) // 下载完成移除元素
window.URL.revokeObjectURL(href) // 释放掉blob对象
})
}
</script>
api请求配置文件
java
import axios from '@/axios'
export const easypoiExportApi = () => {
return axios({
url: '/easypoi/export',
method: 'post',
responseType: 'blob'
})
}
以上就使用 easypoi 实现了大数据量的导入和导出。如果有什么疑问,欢迎在评论区下留言交流。
扩展问题
1、在微服务架构里,可能在导入的时候,需要跨服务查询信息,如,订单和商品表在不同的服务,导出订单数据时,需要查询订单所购买的商品信息。
思路:1、在订单表里冗余存储所需商品信息的字段。 2、批量远程调用并对比。比如:每导出1000比订单,记录这1000比订单的id和商品id,然后再筛选出这一千比订单的商品id,批量远程调用查询商品id,远程接口返回商品id和商品信息,存入集合中。拿着这两个集合去对比。需要注意的是,这种方式,一次性批量调用的值不宜过大。
2、导出的时候是动态表头。
文章下方留言或者私信,如果需要的话,博主补充一篇博客,详细说明动态表头导出的解决方案