easyexcel的个人应用

easyexcel的个人应用

背景

在个人的工作中,有个通用的功能,叫excel的导入导出,总的功能要求是前端能提供模板下载,用户按格式填充数据,就可以支持数据的导入,导出的话,用户在前台加上筛选条件后,导出excel数据。当时实现也很简单,模板下载的话,就是后台管理个模板管理功能(就跟字典表一样,一个专有名称对应一个文件,前台直接用这个名称去换文件)。而导入导出的话,用poi操作。

问题

小数据一点问题也没有,每个功能自己实现导入导出也不是不能接受。但是当数据量开始大,然后要导入导出大数据的时候,问题就开始暴露出来了。

频繁的oom

因为poi这种的话,频繁的创建销毁对象,而有些对象没有及时销毁,很容易oom。

用户体验较差

如果是小数据,简单的上传下载文件就好,用户几秒就能得到结果,但是如果是大数据,可能要几分钟,而这个时候,用户并不想再导完前一直等待。

而一些文件并不知道现在到了哪一步,这样用户就完全不知道这个导入是失败了还是单纯的还没导完。

难以查看事故现场

用户的输入输出并不一定是正常的,比如一些过大的数据,这样的话并不是每次导入都是正确的。而发生异常,用户并不能知道为什么出错了。

解决思路

频繁的oom可以换easypoi或者easyexcel来导入,这几个对大文件的导入导出的优化非常棒。

而需要查看导入进度和查看事故现场的话,这些有不同的实现。

我司在导入的时候,异步返回一个任务id给前台,前台可以根据这个任务id,调用接口,轮询任务的状态,当正常时候显示进度,失败的时候,可支持用户查看异常信息。

但是问题在于,这个easypoi或者easyexcel并没有支持这种,明显需要我们自己实现。

这个流程往往是比较固化的,后台得到文件后

1新建一个任务,

2异步开启导入流程

2.1 获取总的导入条数,更新任务的总条数

2.2 解析得到数据,更新到数据库,更新任务当前进度

2.3 发生异常后,更新任务状态为失败,写入任务失败的详细信息,回滚数据

2.4 未发生异常,更新任务状态为成功,资源回收

3返回任务id

这个每个导入任务都是这样的流程,我们很轻松的就能想到封装一下,弄个模板工具类来实现。

总的来说,可以拆分为四个服务

0 读入的服务
0.0 excel与对象的映射-> 读取注解
0.1 读取一条
0.2 读取一段
0.3 当前第几条

1.数据的持久化

1.1 数据的转化
1.2 数据的批量存储
1.3 数据的单条处理
1.4 数据的错误回滚

2.任务的更新
2.1 新建任务
2.2 任务进度的更新
2.3任务的结束
2.4 任务的异常现场

3.日志的记录
3.1 正常导入
3.2 错误

明显,easypoi和easyexcel只实现了读入的服务。那咱们自己撸其他的吧。

开始

我选用的easyexcel,毕竟是阿里的。

maven导入

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>easyexcel</artifactId>
    <version>2.1.4</version>
</dependency>

剩下的依赖,自己搞的熟的crud框架放进去。

配置也不用配,就配下数据源和web的基本配置就好,毕竟easyexcel说到底只是个读取文件的工具类。

初版demo(单纯的导入数据库)

导入的数据库ddl

CREATE TABLE user (
id varchar(255) COLLATE utf8mb4_general_ci NOT NULL,
name varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
sex int DEFAULT NULL COMMENT ‘性别,1为男,2为女’,
email varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
age int DEFAULT NULL,
create_time datetime DEFAULT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

然后就是监听器了(记得查看easyexcel的文档 https://www.yuque.com/easyexcel/doc/easyexcel)

package com.example.exceldemo.core.myImport;

import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import com.example.exceldemo.entity.User;
import com.example.exceldemo.mapper.UserMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;
import java.util.List;

/**
 * @description:
 * @author: wzy
 * @create: 2020-12-18 11:43
 **/
public class UserListener extends AnalysisEventListener<User> {
    private static final Logger LOGGER = LoggerFactory.getLogger(UserListener.class);
    /**
     * 每隔5条存储数据库,实际使用中可以3000条,然后清理list ,方便内存回收
     */
    private static final int BATCH_COUNT = 100;
    List<User> list = new ArrayList<User>();
    /**
     * 假设这个是一个DAO,当然有业务逻辑这个也可以是一个service。当然如果不用存储这个对象没用。
     */
    private UserMapper mapper;
    public UserListener(UserMapper mapper) {
        // 这里是demo,所以随便new一个。实际使用如果到了spring,请使用下面的有参构造函数
        this.mapper=mapper;
    }
    /**
     * 这个每一条数据解析都会来调用
     *
     * @param data
     * @param context
     */
    @Override
    public void invoke(User data, AnalysisContext context) {
        LOGGER.info("解析到一条数据:{}", data);
        list.add(data);
        // 达到BATCH_COUNT了,需要去存储一次数据库,防止数据几万条数据在内存,容易OOM
        if (list.size() >= BATCH_COUNT) {
            saveData();
            // 存储完成清理 list
            list.clear();
        }
    }
    /**
     * 所有数据解析完成了 都会来调用
     *
     * @param context
     */
    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {
        // 这里也要保存数据,确保最后遗留的数据也存储到数据库
        saveData();
        LOGGER.info("所有数据解析完成!");
    }
    /**
     * 加上存储数据库
     */
    private void saveData() {
        LOGGER.info("{}条数据,开始存储数据库!", list.size());
        mapper.batchInsert(list);
        LOGGER.info("存储数据库成功!");
    }
}

测试一下

 @Test
    public void simpleRead() {
        // 有个很重要的点 DemoDataListener 不能被spring管理,要每次读取excel都要new,然后里面用到spring可以构造方法传进去
        // 写法1:
        String fileName = “demo" + File.separator + "demo.xlsx";

        ExcelReader excelReader = null;
        try {
            excelReader = EasyExcel.read(fileName, User.class, new UserListener()).build();
            ReadSheet readSheet = EasyExcel.readSheet(0).build();
            excelReader.read(readSheet);
        } finally {
            if (excelReader != null) {
                // 这里千万别忘记关闭,读的时候会创建临时文件,到时磁盘会崩的
                excelReader.finish();
            }
        }
    }

简简单单的读demo,然后问题是什么呢。

1.我每个读入的时候,都要写个listener。而每个lister都大同小异。

2.我写导入的时候,流的处理也要写一遍。

如果我只是单纯的什么都不要,只要方便的导入,该怎么改。

第二版demo(简便的导入)

导入的数据库处理说到底就是提供一个批量的处理服务的接口,那么我们就可以想到匿名内部类,想到consumer。

package com.example.exceldemo.core.myImport;

import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;

/**
 * @description: 简单的导入,无需考虑新建任务,无需考虑日志,无需考虑回滚
 * @author: wzy
 * @create: 2020-12-19 00:21
 **/
public class EasyListener<T> extends AnalysisEventListener<T> {
    private static final Logger LOGGER = LoggerFactory.getLogger(EasyListener.class);
    /**
     * 每隔5条存储数据库,实际使用中可以3000条,然后清理list ,方便内存回收
     */
    private int BATCH_COUNT = 1000;
    List<T> list = new ArrayList<T>();
    /**
     * 假设这个是一个DAO,当然有业务逻辑这个也可以是一个service。当然如果不用存储这个对象没用。
     */
    private Consumer<List<T>> mapper;

    public EasyListener(Consumer<List<T>> mapper) {
        // 这里是demo,所以随便new一个。实际使用如果到了spring,请使用下面的有参构造函数
        this.mapper=mapper;
    }


    public EasyListener setBatchCount(int batchCount){
        this.BATCH_COUNT=batchCount;
        return this;
    }

    /**
     * 这个每一条数据解析都会来调用
     *
     * @param data
     * @param context
     */
    @Override
    public void invoke(T data, AnalysisContext context) {
        LOGGER.info("解析到一条数据:{}", data);
        list.add(data);
        // 达到BATCH_COUNT了,需要去存储一次数据库,防止数据几万条数据在内存,容易OOM
        if (list.size() >= BATCH_COUNT) {
            saveData();
            // 存储完成清理 list
            list.clear();
        }
    }
    /**
     * 所有数据解析完成了 都会来调用
     *
     * @param context
     */
    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {
        // 这里也要保存数据,确保最后遗留的数据也存储到数据库
        saveData();
        LOGGER.info("所有数据解析完成!");
    }
    /**
     * 加上存储数据库
     */
    private void saveData() {
        LOGGER.info("{}条数据,开始存储数据库!", list.size());
        mapper.accept(list);
        LOGGER.info("存储数据库成功!");
    }
}
package com.example.exceldemo.core.myImport;

import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelReader;
import com.alibaba.excel.read.metadata.ReadSheet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.List;
import java.util.function.Consumer;

/**
 * @description:
 * @author: wzy
 * @create: 2020-12-18 12:21
 **/
public class EasyImportUtil<T>  {
    public String fileName;
    public Class clazz;
    private Consumer<List<T>> mapper;
    private Integer sheetNo;
    private static final Logger LOGGER = LoggerFactory.getLogger(EasyImportUtil.class);
    public EasyImportUtil(String fileName, Class clazz, Consumer<List<T>> mapper, Integer sheetNo) {
        this.fileName = fileName;
        this.clazz = clazz;
        this.mapper = mapper;
        this.sheetNo = sheetNo;
    }

    public void simpleRead(){
        // 有个很重要的点 DemoDataListener 不能被spring管理,要每次读取excel都要new,然后里面用到spring可以构造方法传进去
        // 写法1:

//        // 这里 需要指定读用哪个class去读,然后读取第一个sheet 文件流会自动关闭
//        EasyExcel.read(fileName, User.class, new UserListener(mapper)).sheet().doRead();

        // 写法2:

        ExcelReader excelReader = null;
        try {
            excelReader = EasyExcel.read(fileName, clazz, new EasyListener<T>(list->{
               mapper.accept(list);
            })).build();
            ReadSheet readSheet = EasyExcel.readSheet(0).build();
            excelReader.read(readSheet);
        } catch (Exception e){
            LOGGER.error("导入数据失败{}",e);
        }
        finally {
            if (excelReader != null) {
                // 这里千万别忘记关闭,读的时候会创建临时文件,到时磁盘会崩的
                excelReader.finish();
            }
        }
    }
}

测试一下


public void easyRead() {
        // 有个很重要的点 DemoDataListener 不能被spring管理,要每次读取excel都要new,然后里面用到spring可以构造方法传进去
        // 写法1:
        String fileName =  "D://demo.xlsx";
//        // 这里 需要指定读用哪个class去读,然后读取第一个sheet 文件流会自动关闭
//        EasyExcel.read(fileName, User.class, new UserListener(mapper)).sheet().doRead();



        EasyImportUtil<UserVO> easyImportUtil = new EasyImportUtil<UserVO>(fileName, UserVO.class, list -> {
            List<User> collect = list.stream().map(
                    vo -> {
                        User user = new User();
                        user.setId(UUID.randomUUID().toString());
                        user.setAge(vo.getAge());
                        user.setEmail(vo.getEmail());
                        user.setName(vo.getName());
                        user.setCreateTime(vo.getCreateTime());
                        user.setSex(vo.getSex());
                        return user;
                    }
            ).collect(Collectors.toList());
            mapper.batchInsert(collect);
        },0);
        easyImportUtil.simpleRead();

    }

这样的话,如果没什么别的要求的话,这样的一个导入导出,就非常轻松了。

但是需求往往不会这样简单。

第三版demo(导入,带有进度条的那种)

package com.example.exceldemo.core.myImport;

import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;
import com.alibaba.excel.read.metadata.holder.ReadSheetHolder;
import com.example.exceldemo.core.myImport.Interface.TaskService;
import com.example.exceldemo.entity.ExcelTask;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;

/**
 * @description: 正常导入,新建任务,失败不会滚
 * @author: wzy
 * @create: 2020-12-19 00:30
 **/
public class NormalListener<T> extends AnalysisEventListener<T> {
    private static final Logger LOGGER = LoggerFactory.getLogger(NormalListener.class);
    /**
     * 每隔100条存储数据库,实际使用中可以3000条,然后清理list ,方便内存回收
     */
    private static final int BATCH_COUNT = 100;
    private int current=0;
    private int total=0;
    private TaskService taskService;
    private ExcelTask task;

    List<T> list = new ArrayList<T>();
    /**
     * 假设这个是一个DAO,当然有业务逻辑这个也可以是一个service。当然如果不用存储这个对象没用。
     */
    private Consumer<List<T>> mapper;

    public Integer getTotal(){
        return this.total;
    }

    public Integer getCurrent(){
        return this.current;
    }


    public NormalListener(Consumer<List<T>> mapper, TaskService taskService, ExcelTask task) {
        // 实际使用如果到了spring,请使用下面的有参构造函数
        this.mapper=mapper;
        this.taskService=taskService;
        this.task=task;
    }


    @Override
    public void invokeHeadMap(Map<Integer, String> headMap, AnalysisContext context) {
        ReadSheetHolder readSheetHolder = context.readSheetHolder();
        Integer approximateTotalRowNumber = readSheetHolder.getApproximateTotalRowNumber();
        Integer headRowNumber = readSheetHolder.getHeadRowNumber();
        this.total=approximateTotalRowNumber-headRowNumber;
        this.task.setTotal(this.total);
        taskService.createTask(task);
    }


    /**
     * 这个每一条数据解析都会来调用
     *
     * @param data
     * @param context
     */
    @Override
    public void invoke(T data, AnalysisContext context) {
        LOGGER.info("解析到一条数据:{}", data);
        list.add(data);
        this.current=this.current+1;
        // 达到BATCH_COUNT了,需要去存储一次数据库,防止数据几万条数据在内存,容易OOM
        if (list.size() >= BATCH_COUNT) {
            saveData();
            // 存储完成清理 list
            list.clear();
        }
    }
    /**
     * 所有数据解析完成了 都会来调用
     *
     * @param context
     */
    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {
        // 这里也要保存数据,确保最后遗留的数据也存储到数据库
        saveData();
        LOGGER.info("所有数据解析完成!");
        taskService.successTask(task.getId());
    }
    /**
     * 加上存储数据库
     */
    private void saveData() {
        LOGGER.info("{}条数据,开始存储数据库!", list.size());
        mapper.accept(list);

        taskService.updateProgress(task.getId(),current);
        LOGGER.info("存储数据库成功!");
    }
}
package com.example.exceldemo.core.myImport;

import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelReader;
import com.alibaba.excel.read.metadata.ReadSheet;
import com.example.exceldemo.core.myImport.Interface.TaskService;
import com.example.exceldemo.entity.ExcelTask;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.List;
import java.util.function.Consumer;

/**
 * @description:
 * @author: wzy
 * @create: 2020-12-19 00:57
 **/
public class NormalImportUtil<T> {
    public String fileName;
    public Class clazz;
    private Consumer<List<T>> mapper;
    private Integer sheetNo;
    private static final Logger LOGGER = LoggerFactory.getLogger(NormalImportUtil.class);
    private TaskService taskService;
    private ExcelTask task;
    public NormalImportUtil(String fileName, Class clazz, Consumer<List<T>> mapper, Integer sheetNo, TaskService taskService, ExcelTask task) {
        this.fileName = fileName;
        this.clazz = clazz;
        this.mapper = mapper;
        this.sheetNo = sheetNo;
        this.taskService=taskService;
        this.task=task;
    }

    public String simpleRead(){
        // 有个很重要的点 DemoDataListener 不能被spring管理,要每次读取excel都要new,然后里面用到spring可以构造方法传进去
        // 写法1:

//        // 这里 需要指定读用哪个class去读,然后读取第一个sheet 文件流会自动关闭
//        EasyExcel.read(fileName, User.class, new UserListener(mapper)).sheet().doRead();

        // 写法2:

      new Thread(()->{
          ExcelReader excelReader = null;
          NormalListener<T> listener =new NormalListener<T>(list->{
              mapper.accept(list);
          },taskService,task);

          try {
              excelReader = EasyExcel.read(fileName, clazz, listener).build();
              ReadSheet readSheet = EasyExcel.readSheet(0).build();
              excelReader.read(readSheet);
          } catch (Exception e){
              LOGGER.error("导入数据失败{}",e);
              taskService.updateProgress(task.getId(),listener.getCurrent());
              taskService.failedTask(task.getId(),e.getMessage());
          }
          finally {
              if (excelReader != null) {
                  // 这里千万别忘记关闭,读的时候会创建临时文件,到时磁盘会崩的
                  excelReader.finish();
              }
          }
      }).start();
        return task.getId();
    }

}

package com.example.exceldemo.core.myImport.Interface;

import com.example.exceldemo.entity.ExcelTask;

/**
 * @description: 任务服务
 * @author: wzy
 * @create: 2020-12-18 23:43
 **/
public interface TaskService {
   void createTask(ExcelTask task);
   void updateProgress(String taskId,Integer progress);
   void successTask(String taskId);
   void failedTask(String taskId,String reason);
}
package com.example.exceldemo.core.myImport.Impl;

import com.example.exceldemo.core.myImport.Interface.TaskService;
import com.example.exceldemo.entity.ExcelTask;

/**
 * @description:
 * @author: wzy
 * @create: 2020-12-19 00:53
 **/
public class TaskServiceImpl implements TaskService {
    @Override
    public void createTask(ExcelTask task) {
        System.out.println("新建任务为"+task);
    }

    @Override
    public void updateProgress(String taskId, Integer progress) {
        System.out.println("当前任务Id为"+taskId+"进度为"+progress);
    }

    @Override
    public void successTask(String taskId) {
        System.out.println("当前任务Id为"+taskId+"进度成功");
    }

    @Override
    public void failedTask(String taskId, String reason) {
        System.out.println("当前任务Id为"+taskId+"进度成功");
    }
}

测试一下

 public String simpleRead() {
        // 有个很重要的点 DemoDataListener 不能被spring管理,要每次读取excel都要new,然后里面用到spring可以构造方法传进去
        // 写法1:
        String fileName = "D://demo.xlsx";
//        // 这里 需要指定读用哪个class去读,然后读取第一个sheet 文件流会自动关闭
//        EasyExcel.read(fileName, User.class, new UserListener(mapper)).sheet().doRead();

        // 写法2:
        TaskService taskService = new TaskServiceImpl();
        ExcelTask task = new ExcelTask();
        task.setId(UUID.randomUUID().toString());
        task.setTaskName("测试任务");
        task.setTaskType("1");


        NormalImportUtil<UserVO> normalImportUtil = new NormalImportUtil<UserVO>(fileName, UserVO.class, list -> {
            List<User> collect = list.stream().map(
                    vo -> {
                        User user = new User();
                        user.setId(UUID.randomUUID().toString());
                        user.setAge(vo.getAge());
                        user.setEmail(vo.getEmail());
                        user.setName(vo.getName());
                        user.setCreateTime(vo.getCreateTime());
                        user.setSex(vo.getSex());
                        return user;
                    }
            ).collect(Collectors.toList());
            mapper.batchInsert(collect);
        }, 0, taskService, task);
        return normalImportUtil.simpleRead();




    }

这个思路就是组合进一个任务服务,任务服务用接口规范,然后读入服务通过传入的任务服务实现,更新任务就好。

但是这个时候我遇到了几个问题,一个是,任务的创建在什么时候,是任务参数传进来就开始创建任务,然后开启读取服务的时候,更新任务的总条数吗,还是在读取服务的时候,便初始化任务进度以及创建任务?

这个问题在于任务服务往往是一个数据库服务,后续可能是分布式服务里调用其他服务,那么这个任务的创建并不是百分百可靠。那么就应该是创建任务的时候返回id,只有任务成功创建的时候,才有任务id。而是读取服务之前创建还是开始读取任务服务之后创建,这个表明了是否成功开启了读取服务。

这个我最终选择了读取任务前创建任务并返回任务id,因为读取服务是异步的,这个意味着你并不一定能获取到异步线程里的任务状态,而在任务前创建任务,你可以创建的是一个刚初始化的任务,然后你在异步线程里可以更新这个任务状态。

第二个难点在于,总进度怎么获取,当时可奇怪了,easyexcel没有获取总条数的接口吗,我找不到啊,教程翻来覆去,百度来百度去没找到,然后再监听器里发现了个读取头文件的回调函数,里面有个context上下文,里面有个废弃了的获取全部行数接口,那为什么废弃了呢,那肯定有代替的实现了,发现里面有个sheetholder,明摆着的,这个是对应的sheet的上下文,里面果然有全部的读取行和头行数。那么任务进度也知道了。后续就按行或者按批次来更新任务进度就好。

第四版demo(导入,带有进度条,带有错误条数的,可查看错误的详细信息的)

第五版demo(导入,带有进度条的,错误回滚的,带有错误信息的)

总结

说到底,我的这些都是封装,利用组合和模板模式来简化开发,我也只是抛出个引子,希望的是简便开发。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值