大数据量导入导出解决方案-EasyPoi

背景:项目中可能需要用到大数量的导入导出,处理的数据量可能达到了100w及以上,该文章针对该问题,提出解决方案-easypoi。

一、项目使用框架

后端: Springboot + Maven + MybatisPlus + Mysql

前端:Vite + Vue3 + + ElementPlus

代码见:gitee.com/blog-materi...

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、导出的时候是动态表头。

文章下方留言或者私信,如果需要的话,博主补充一篇博客,详细说明动态表头导出的解决方案

相关推荐
【D'accumulation】3 分钟前
令牌主动失效机制范例(利用redis)注释分析
java·spring boot·redis·后端
小叶学C++9 分钟前
【C++】类与对象(下)
java·开发语言·c++
2401_8543910813 分钟前
高效开发:SpringBoot网上租赁系统实现细节
java·spring boot·后端
Cikiss21 分钟前
微服务实战——SpringCache 整合 Redis
java·redis·后端·微服务
wxin_VXbishe22 分钟前
springboot合肥师范学院实习实训管理系统-计算机毕业设计源码31290
java·spring boot·python·spring·servlet·django·php
Cikiss23 分钟前
微服务实战——平台属性
java·数据库·后端·微服务
无敌の星仔32 分钟前
一个月学会Java 第2天 认识类与对象
java·开发语言
OEC小胖胖36 分钟前
Spring Boot + MyBatis 项目中常用注解详解(万字长篇解读)
java·spring boot·后端·spring·mybatis·web
2401_857617621 小时前
SpringBoot校园资料平台:开发与部署指南
java·spring boot·后端
quokka561 小时前
Springboot 整合 logback 日志框架
java·spring boot·logback