(二)EasyExcel深度实践:构建报表通用神器,几行搞定读写,代码骤减,优雅至上!

3,806 阅读31分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

引言

报表的导入导出功能,这是平常很多后台业务都会涉及到的场景,而身为一名合格的后端“码仔”,面对这类需求且不说要做到跟吃饭喝水一样轻松,但至少也要做到手拿把掐、轻松拿下~

在上篇《EasyExcel框架初相识》中,我们就已经对EasyExcel的核心API做了全面讲述,同时还介绍了一下它的基本使用方式。不过之前的内容中,并没有结合真实案例去实践,所以当诸位面对诡计多端的产品、客户时,简单的案例构不成太大的参考价值。

为此,怎样让报表处理更加简单?又如何满足花样百出的业务需求?奔着这两个目标,本文就来聊下怎么封装通用的Excel工具类,从而实现几行代码搞定报表导入导出,以及结合多个不同场景的报表需求,来进行报表处理实战演示。

PS:个人编写的《技术人求职指南》小册已完结,其中从技术总结开始,到制定期望、技术突击、简历优化、面试准备、面试技巧、谈薪技巧、面试复盘、选Offer方法、新人入职、进阶提升、职业规划、技术管理、涨薪跳槽、仲裁赔偿、副业兼职……,为大家打造了一套“从求职到跳槽”的一条龙服务,同时也为诸位准备了七折优惠码:3DoleNaE,近期需要找工作的小伙伴可以点击:s.juejin.cn/ds/USoa2R3/了解详情!

一、打造通用的监听器

回想之前的《简单读取案例》,会发现使用EasyExcel解析excel文件时,针对不同的业务场景,通常需要定义不同的监听器,如:

  • 现在需要导入商品数据,就需要定义一个ProductListener
  • 现在需要导入员工数据,就需要定义一个StaffListener
  • ……

很显然,这一步会造成大量性质类似的class被定义出来,所以,能否封装一个通用监听器,以此来减少这步工作量与重复类呢?答案是当然可以,一起来看看。

1.1、通用版监听器

不管是什么业务场景下的Excel导入,都会经过“解析文件、提取数据、进行业务处理”这三步,除开第三步外,前两步逻辑是相同的,既然逻辑相同,那么自然可以抽象成通用监听器,如下:

public class CommonListener<T> extends AnalysisEventListener<T> {
    //创建list集合封装最终的数据
    private final List<T> data;

    // 字段列表
    private final Field[] fields;
    private final Class<T> clazz;
    private boolean validateSwitch = true;

    public CommonListener(Class<T> clazz) {
        fields = clazz.getDeclaredFields();
        this.clazz = clazz;
        this.data = new ArrayList<T>();
    }

    /*
     * 每解析到一行数据都会触发
     * */
    @Override
    public void invoke(T row, AnalysisContext analysisContext) {
        data.add(row);
    }

    /*
     * 读取到excel头信息时触发,会将表头数据转为Map集合
     * */
    @Override
    public void invokeHeadMap(Map<Integer, String> headMap, AnalysisContext context) {
        // 校验读到的excel表头是否与数据模型类匹配
        if (validateSwitch) {
            ExcelUtil.validateExcelTemplate(headMap, clazz, fields);
        }
    }

    /*
     * 所有数据解析完之后触发
     * */
    @Override
    public void doAfterAllAnalysed(AnalysisContext analysisContext) {}

    /*
     * 关闭excel表头验证
     * */
    public void offValidate() {
        this.validateSwitch = false;
    }

    /*
     * 返回解析到的所有数据
     * */
    public List<T> getData() {
        return data;
    }
}

上述定义了一个通用监听器,其实跟EasyExcel里提供的SyncReadListener十分类似,只不过我们这里做了两点优化:

  • ①使用泛型来代替Object,使其变得更灵活,获取数据时无需强制转换;
  • ②增加模板校验机制,检查excel头信息与数据模型类字段的匹配关系。

第一点可以参考之前的《Java泛型机制》,这里不过多对其展开,主要来看看第二点,这里所谓的模板校验机制,就是invokeHeadMap()方法里的那行代码。

首先说明下,invokeHeadMap()方法会在解析到excel表头信息时被触发,所以在这里面对解析的excel文件的进行模板校验最合适,可为啥需要校验呢?

PS:所有excel导入的业务场景,都是先下载模板,再根据模板指引填写数据,最后才上传填写好的excel文件,这是导入场景的业务流程。

因为如果你不对传入的excel文件进行模板校验,这时就算随便传个excel文件,EasyExcel也照样不会报错,而是解析出多行所有字段为空的数据,从而触发你后面的业务逻辑引发未知Bug

好了,接着来看下校验表头(模板)的validateExcelTemplate()方法实现逻辑:

@Slf4j
public class ExcelUtil {
    /*
     * 校验excel文件的表头,与数据模型类的映射关系是否匹配
     * */
    public static void validateExcelTemplate(Map<Integer, String> headMap, Class<?> clazz, Field[] fields) {
        Collection<String> headNames = headMap.values();

        // 类上是否存在忽略excel字段的注解
        boolean classIgnore = clazz.isAnnotationPresent(ExcelIgnoreUnannotated.class);
        int count = 0;
        for (Field field : fields) {
            // 如果字段上存在忽略注解,则跳过当前字段
            if (field.isAnnotationPresent(ExcelIgnore.class)) {
                continue;
            }

            ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class);
            if (null == excelProperty) {
                // 如果类上也存在忽略注解,则跳过所有未使用ExcelProperty注解的字段
                if (classIgnore) {
                    continue;
                }
                // 如果检测到既未忽略、又未映射excel列的字段,则抛出异常提示模板不正确
                throw new ExcelAnalysisException("请检查导入的excel文件是否按模板填写!");
            }

            // 校验数据模型类上绑定的名称,是否与excel列名匹配
            String[] value = excelProperty.value();
            String name = value[0];
            if (name != null && 0 != name.length() && !headNames.contains(name)) {
                throw new ExcelAnalysisException("请检查导入的excel文件是否按模板填写!");
            }
            // 更新有效字段的数量
            count++;
        }
        // 最后校验数据模型类的有效字段数量,与读到的excel列数量是否匹配
        if (headMap.size() != count) {
            throw new ExcelAnalysisException("请检查导入的excel文件是否按模板填写!");
        }
    }
}

先来看三个入参,分别是当前读到的表头集合,以及数据模型类的class对象和字段数组,这三个参数会用来校验模板的正确性。

模板的校验逻辑用到了反射机制,整个实现并不算复杂,如下:

  • ①先判断传入的数据模型类上是否添加了ExcelIgnoreUnannotated注解;
  • ②遍历了数据模型类的所有字段,先判断字段上是否存在ExcelIgnore注解;
    • 存在:说明当前遍历到的字段要被忽略,则continue跳过该字段的校验;
    • 不存在:说明该字段需要进行校验,继续向下执行;
  • ③获取字段上的ExcelProperty注解对象,判断拿到注解对象是否为空:
    • 为空:判断第①步拿到的结果,是否需要忽略未使用@ExcelProperty的字段:
      • 如需忽略则continue跳过,否则就抛出模板校验出错的异常;
    • 不为空:说明该字段需要继续进行校验,继续往下执行;
  • ④获取字段上ExcelProperty注解的value值,判断是否与读到的列名匹配:
    • 匹配:说明该字段在模板中存在,校验通过,并自增有效字段数,继续校验下个字段;
    • 不匹配:说明该字段在定义的模板中不存在,直接抛出模板校验出错异常;
  • ⑤最后再判断有效字段数量,与实际读到的列数是否一致,不一致也抛出校验出错异常。

上述便是整个校验的流程,主要就是对比了读到的excel表的各个列,是否与数据模型类里定义的匹配关系能对应上,以及实际读到的表头列数与模型类的字段数是否对应,只要存在不一致就抛出校验出错的异常。

为什么可以这么校验呢?大家回想前面导入的业务流程,所有导入动作都必须使用我们提前定义的模板,而自定义的excel模板,自然会与数据模型类完全匹配,因此,按照模板正常填写的excel文件,完全能够通过该校验机制。

这里有个小细节,ExcelProperty注解提供了列名、权重、索引三种列匹配模式,可目前只是基于列名实现了校验逻辑,所以定义的数据模型类上,不支持使用index、order来指定字段与excel列的匹配关系(也可以自行改造上面的第四步校验逻辑)。

出于校验机制的存在,所以这个通用监听器无法正常读取多行头的excel文件,如果你想要正常解析多行头文件,则可以在创建监听器之后,手动调用offValidate()方法来关闭模板校验机制,这时就能避免校验机制干扰多行头文件的读取。

PS:如果你使用的EasyExcel版本较低,解析excel数据时不会自动忽略已使用过的空行,即填写过内容又删除的数据行,在解析时仍然会被识别成一条数据,这种情况需要在监听器的invoke()方法中主动过滤。

好了,这个通用监听器做的事情非常简单,无非是将解析到的excel数据行,全都临时存储在一个集合里,但这里只是读取到的原始数据,如果需要对其进行业务逻辑处理,如校验、清洗、落库等,则需在外面调用getData()来处理(会在后续实战案例演示)。

1.2、分批处理监听器

前面封装的通用监听器,实际上只能满足数据量不大的业务场景,当数据量达到几万行、数十万、百万行时,如果再使用这个通用监听器就会存在OOM风险,Why

因为解析到的所有数据,都会暂存到内部的data集合里,直至整个文件所有数据行读取结束。对于大文件而言,这种模式会给内存造成较大的负担,一旦同时导入的excel文件数量过多,内存资源耗尽就会引发内存溢出问题,怎么办?可以选择分批处理读取到的数据。

/**
 * 分批处理监听器(适用于大数据场景)
 */
public class BatchHandleListener<T> extends AnalysisEventListener<T> {
  /*
   * 每批的处理行数(可以根据实际情况做出调整)
   * */
  private static int BATCH_NUMBER = 1000;
  
  /*
   * 临时存储读取到的excel数据
   * */
  private List<T> data;
  private int rows, batchNo;
  private boolean validateSwitch = true;
  
  /*
   * 每批数据的业务逻辑处理器
   *   说明:如果业务方法会返回结果,可以将换成Function接口,同时类上新增一个类型参数
   * */
  private final Consumer<List<T>> businessHandler;
  
  /*
   * 用于校验excel模板正确性的字段
   * */
  private final Field[] fields;
  private final Class<T> clazz;
  
  public BatchHandleListener(Class<T> clazz, Consumer<List<T>> handle) {
    // 通过构造器为字段赋值,用于校验excel文件与模板十分匹配
    this(clazz, handle, BATCH_NUMBER);
  }
  
  public BatchHandleListener(Class<T> clazz, Consumer<List<T>> handle, int batchNumber) {
    // 通过构造器为字段赋值,用于校验excel文件与模板十分匹配
    this.clazz = clazz;
    this.fields = clazz.getDeclaredFields();
    // 初始化临时存储数据的集合,及外部传入的业务方法
    this.businessHandler = handle;
    BATCH_NUMBER = batchNumber;
    this.data = new ArrayList<>(BATCH_NUMBER);
  }
  
  /*
   * 读取到excel头信息时触发,会将表头数据转为Map集合(用于校验导入的excel文件与模板是否匹配)
   *   注意点1:当前校验逻辑不适用于多行头模板(如果是多行头的文件,请关闭表头验证);
   *   注意点2:使用当前监听器的导入场景,模型类不允许出现既未忽略、又未使用ExcelProperty注解的字段;
   * */
  @Override
  public void invokeHeadMap(Map<Integer, String> headMap, AnalysisContext context) {
    if (validateSwitch) {
      ExcelUtil.validateExcelTemplate(headMap, clazz, fields);
    }
  }
  
  /*
   * 每成功解析一条excel数据行时触发
   * */
  @Override
  public void invoke(T row, AnalysisContext analysisContext) {
    data.add(row);
    // 判断当前已解析的数据是否达到本批上限,是则执行对应的业务处理
    if (data.size() >= BATCH_NUMBER) {
      // 更新读取到的总行数、批次数
      rows += data.size();
      batchNo++;
  
      // 触发业务逻辑处理
      this.businessHandler.accept(data);
      // 处理完本批数据后,使用新List替换旧List,旧List失去引用后会很快被GC
      data = new ArrayList<>(BATCH_NUMBER);
    }
  }
  
  /*
   * 所有数据解析完之后触发
   * */
  @Override
  public void doAfterAllAnalysed(AnalysisContext analysisContext) {
    // 因为最后一批可能达不到指定的上限,所以解析完之后要做一次收尾处理
    if (data.size() != 0) {
      this.businessHandler.accept(data);
      // 更新读取到的总行数、批次数,以及清理集合辅助GC
      rows += data.size();
      batchNo++;
      data.clear();
    }
  }
  
  /*
   * 关闭excel表头验证
   * */
  public void offValidate() {
    this.validateSwitch = false;
  }
  
  public int getRows() {
    return rows;
  }
  
  public int getBatchNo() {
    return batchNo;
  }
}

这个批量处理监听器比上一个通用监听器多了些逻辑,首先多了一个批次的概念,当解析的数据量达到给定量级时,就会触发businessHandler业务处理器,而businessHandler则是在监听器初始化时传入的Consumer对象。在《Java8特性-函数式接口》里曾提到过,基于函数式接口可以让Java支持将方法作为参数传递,所以businessHandler实际上就是具体的业务处理方法

当执行业务逻辑结束后,会重新new一个新的集合接收数据,而旧集合被断开引用关系后,会在短时间内被GC,这里不使用clear()方法的原因,是由于clear()内部会去断开与每个元素的引用,这种方式会影响整个文件的读取性能。其次,当解析到的行数还未达到给定阈值时,会继续解析后面的数据,直到抵达阈值后重复前面的流程。

这个分批处理监听器,还重写了doAfterAllAnalysed()方法,这个方法会在整个文件解析完成后触发,里面实现的逻辑是为了做好收尾工作,因为最后一批可能达不到指定的上限,所以解析完之后还要视情况做一次处理。不过这里用了clear()方法,毕竟这是最后一次收尾工作,后续也不需要新集合来接收数据了,直接清理现有集合最合适。

该监听器还提供了两个API

  • getRows():获取当前监听器解析的总行数;
  • getBatchNo():获取当前正在处理的批次数(批次号)。

这两个方法返回的值,会随着excel文件不断解析而不断变化,当文件彻底解析完成后,调用这两个方法可以获取到总批次数以及数据总行数。好了,关于这个监听器,会在百万级大文件解析的实战中才会用到,这里大家先暂时了解即可。

二、封装Excel导出工具类

第一阶段我们封装了两个通用监听器,分别用来应对常规导入、大数据导入场景,而导出呢?我们也可以封装通用的导出方法,如下:

@Slf4j
public class ExcelUtil {

    /*
    * 三种excel文件类型分别对应的响应头格式
    * */
    private static final String XLS_CONTENT_TYPE = "application/vnd.ms-excel";
    private static final String XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
    private static final String CSV_CONTENT_TYPE = "text/csv";

  /*
   * 导出excel的通用方法
   * */
  public static void exportExcel(Class<?> clazz, List<?> excelData, String fileName, ExcelTypeEnum excelType, HttpServletResponse response) throws IOException {
    HorizontalCellStyleStrategy styleStrategy = setCellStyle();
    // 对文件名进行UTF-8编码、拼接文件后缀名
    fileName = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20") + excelType.getValue();
    switch (excelType) {
        case XLS:
            response.setContentType(XLS_CONTENT_TYPE);
            break;
        case XLSX:
            response.setContentType(XLSX_CONTENT_TYPE);
            break;
        case CSV:
            response.setContentType(CSV_CONTENT_TYPE);
            break;
    }
    response.setCharacterEncoding("utf-8");
    response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + fileName);

    ExcelWriterBuilder writeWork = EasyExcelFactory.write(response.getOutputStream(), clazz);
    writeWork.registerWriteHandler(styleStrategy).excelType(excelType).sheet().doWrite(excelData);
  }

  /*
   * 设置单元格风格
   * */
  public static HorizontalCellStyleStrategy setCellStyle(){
    // 设置表头的样式(背景颜色、字体、居中显示)
    WriteCellStyle headStyle = new WriteCellStyle();
    //设置表头的背景颜色
    headStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
    WriteFont headFont = new WriteFont();
    headFont.setFontHeightInPoints((short)12);
    headFont.setBold(true);
    headStyle.setWriteFont(headFont);
    headStyle.setHorizontalAlignment(HorizontalAlignment.CENTER);

    // 设置Excel内容策略(水平居中)
    WriteCellStyle cellStyle = new WriteCellStyle();
    cellStyle.setHorizontalAlignment(HorizontalAlignment.CENTER);
    return new HorizontalCellStyleStrategy(headStyle, cellStyle);
  }
}

相较于导入时封装的通用监听器,这个导出的通用方法显得简单许多,exportExcel()方法只是将重复的代码做了封装,它总共接收五个入参,释义如下:

  • clazz:导出excel所需的数据模型类;
  • data:需要导出的数据列表;
  • fileName:当前导出的文件名称(不带文件后缀);
  • excelType:导出的文件类型(XLSX、XLS、CSV三种);
  • response:网络响应对象。

了解几个参数后,再来看看内部的细节逻辑,首先会调用setCellStyle()方法获取一个单元格风格对象,作用就是设置表头、内容的格式,如对齐方式、背景颜色、字体大小等,该对象最终会以WriteHandler写入处理器的形式注册给Excel写入对象。

接着基于response对象,设置了响应的数据类型、编码格式、文件名称等,最后就基于响应对象的输出流写出了生成的excel文件。当然,通常大文件导出并不会实时返回流给调用方,而是返回OSS地址给前端去下载,怎么实现呢?也很简单:

public class ExcelUtil {
  /*
   * 上传错误的Excel文件到OSS
   * */
  public static String exportExcelToOSS(Class<?> clazz, List<?> excelData, String fileName, ExcelTypeEnum excelType) throws IOException {
    HorizontalCellStyleStrategy styleStrategy = ExcelUtil.setCellStyle();
    fileName = fileName + excelType.getValue();
    File excelFile = File.createTempFile(fileName, excelType.getValue());
    EasyExcelFactory.write(excelFile, clazz).registerWriteHandler(styleStrategy).sheet().doWrite(excelData);
    String url = uploadFileToOss(excelFile);

    if (excelFile.exists()) {
      boolean flag = excelFile.delete();
      log.info("删除临时文件是否成功:{}", flag);
    }
    return url;
  }

  /*
   * 模拟将上传OSS文件的代码(实际使用请替换为真实上传)
   * */
  public static String uploadFileToOss(File file) {
    String resultUrl = "";
    // 省略上传至OSS的代码……
    return resultUrl;
  }
}

这段代码特别简单,首先会创建一个临时文件,接着基于该临时文件去写入excel数据,而后将其上传到了OSS这类文件存储中心,然后将创建的临时文件删除,并把上传后的链接返回了出去(这个方法可根据实际情况来改造)。

三、多业务场景案例实战

经过前面的内容熏陶后,既掌握了EasyExcel框架的基本用法,又具备了通用的读写工具类,下面结合不同的业务需求一起实战看看。

为了更加贴合真实的业务场景,可以基于SpringBoot快速搭建一个Web工程,接着来创建一张表作为演示数据:

CREATE TABLE `panda` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `name` varchar(80) DEFAULT NULL COMMENT '名称',
  `nickname` varchar(80) DEFAULT NULL COMMENT '外号',
  `unique_code` varchar(20) DEFAULT NULL COMMENT '唯一编码',
  `sex` tinyint(1) DEFAULT '2' COMMENT '性别,0:男,1:女,2:未知',
  `height` decimal(6,2) DEFAULT NULL COMMENT '身高',
  `birthday` date DEFAULT NULL COMMENT '出生日期',
  `pic` varchar(255) DEFAULT NULL COMMENT '头像地址',
  `level` varchar(50) DEFAULT '0' COMMENT '等级',
  `motto` varchar(255) DEFAULT NULL COMMENT '座右铭',
  `address` varchar(255) DEFAULT NULL COMMENT '所在地址',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `is_deleted` tinyint(1) DEFAULT '0' COMMENT '删除标识,0:正常,1:删除',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='熊猫表';

这是一张拥有十三个字段的熊猫表,下面初始化一些数据:

insert into panda(name, nickname, unique_code, sex, height, birthday, pic, level, motto, address, create_time) values
("竹子", "小竹", "P888888", 0, '179.99', '2017-07-07', NULL, "高级", "十年磨一剑,五年磨半边!", "太阳省银河市地球村888号", now()), 
("花花", "阿花", "P666666", 1, '155.00', '2016-06-07', NULL, "顶级", "我爱睡觉爱我!", "太阳省银河市地球村666号", now()), 
("甜甜", "肥肥", "P999999", 0, '163.43', '2020-02-02', NULL, "特级", "今天的事能拖就拖,明天的事明天再说!", "太阳省银河市地球村999号", now()), 
("子竹", "小子", "P555555", 1, '188.88', '2021-08-08', NULL, "初级", "你小子!", "太阳省银河市地球村555号", now());

好了,万事俱备只欠东风,有了表结构和数据后,可以通过逆向工程快速生成MVC的三层代码,为了节省篇幅这里就此略过,大家感兴趣可直接down下源码:spring-boot-easy-excel

3.1、Excel导入并批量落库

前面封装了通用监听器,下面就来试试效果,先来写一个最基本的excel导入接口,对应的导入模板如下:

导入模板-V1

首先来定义一个与excel模板匹配的数据模型类:

@Data
public class PandaReadModel implements Serializable {
    private static final long serialVersionUID = 1L;

    @ExcelProperty("名称")
    private String name;

    @ExcelProperty("外号")
    private String nickname;

    @ExcelProperty("唯一编码")
    private String uniqueCode;

    @ExcelProperty("性别")
    private String sex;

    @ExcelProperty("身高")
    private BigDecimal height;

    @ExcelProperty("出生日期")
    @DateTimeFormat("yyyy-MM-dd")
    private Date birthday;

    @ExcelProperty("等级")
    private String level;

    @ExcelProperty("座右铭")
    private String motto;

    @ExcelProperty("所在地址")
    private String address;
}

这是一个标准的Java类,没有任何特殊的地方,下面再来编写service层的实现逻辑:

@Slf4j
@Service
public class PandaServiceImpl extends 
        ServiceImpl<PandaMapper, Panda> implements PandaService {
    
  @Override
  @Transactional(rollbackFor = Exception.class)
  public void importExcelV1(MultipartFile file) {
    // 创建通用监听器来解析excel文件
    CommonListener<PandaReadModel> listener = new CommonListener<>(PandaReadModel.class);
    try {
      EasyExcelFactory.read(file.getInputStream(), PandaReadModel.class, listener).sheet().doRead();
    } catch (IOException e) {
      log.error("导入熊猫数据出错:{}: {}", e, e.getMessage());
      throw new BusinessException(ResponseCode.ANALYSIS_EXCEL_ERROR, "网络繁忙,请稍后重试!");
    }
    
    if (excelData.size() == 0) {
      throw new BusinessException(ResponseCode.ANALYSIS_EXCEL_ERROR, "请检查您上传的excel文件是否为空!");
    }

    // 对读取到的数据进行批量保存
    List<PandaReadModel> excelData = listener.getData();
    batchSaveExcelData(excelData);
  }

  @Override
  @Transactional(rollbackFor = Exception.class)
  public void batchSaveExcelData(List<PandaReadModel> excelData) {
    List<Panda> pandas = excelData.stream().map(model -> {
      Panda panda = new Panda();
      BeanUtils.copyProperties(model, panda, "sex");
      panda.setSex(Sex.codeOfValue(model.getSex()));
      panda.setCreateTime(new Date());
      return panda;
    }).collect(Collectors.toList());
    saveBatch(pandas);
  }
}

为了图省事,这里使用了MyBatis-Plus来简化代码,不过这不重要,重点来看这个导入方法的实现逻辑,一眼看下来就能明白,首先创建了一个通用监听器,并将定义好的数据模型类传入了进去,而后对读取到的数据进行了批量保存,接着来定义接口:

@RestController
@RequestMapping("/panda")
public class PandaController {
  @Resource
  private PandaService pandaService;

  @PostMapping("/import/v1")
  public ServerResponse<Void> importExcelV1(MultipartFile file) {
    if (null == file) {
      throw new BusinessException(ResponseCode.FILE_IS_NULL);
    }
    pandaService.importExcelV1(file);
    return ServerResponse.success();
  }
}

没啥好说的,只是调用了一下service方法罢了,最终来看接口调用结果:

导入结果-V1

很显然,我们正常读取了excel数据,并将读到的数据行批量保存到了数据库里,这也是日常开发中最常见的报表导入场景。

3.2、根据搜索条件导出Excel

前面过了一个十分简单的导入场景,现在再来简单过一下根据搜索条件导出数据的例子,这也一个十分普遍的需求,先定义一个导出的数据模型类:

@Data
@ColumnWidth(10)
public class PandaExportVO implements Serializable {
    private static final long serialVersionUID = 1L;

    @ExcelIgnore
    private Long id;

    @ExcelProperty(value = "熊猫昵称", index = 0)
    private String nickname;

    @ExcelProperty(value = "性别", index = 1)
    private Integer sex;
  
    @ExcelProperty(value = "唯一编码", index = 2)
    private String uniqueCode;

    @ExcelProperty(value = "身高", index = 3)
    private BigDecimal height;

    @ExcelProperty(value = "出生日期", index = 4)
    @DateTimeFormat("yyyy-MM-dd")
    @ColumnWidth(15)
    private Date birthday;

    @ExcelProperty(value = "创建时间", index = 5)
    @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
    @ColumnWidth(20)
    private Date createTime;
}

当然,如果导入、导出的字段相同,你可以使用同一个类,不过个人建议是最好分开(原因后续会说),下面再来定义一个查询条件参数类:

@Data
public class PandaQueryDTO implements Serializable {
    private static final long serialVersionUID = 1L;

    /*
    * 搜索关键字(兼容名称、外号、编码三个条件)
    * */
    private String keyword;

    /**
     * 生日开始时间(yyyy-MM-dd格式)
     */
    private String startTime;

    /**
     * 生日结束时间(yyyy-MM-dd格式)
     */
    private String endTime;
}

这个DTO类中,总共有三个字段,支持名称、外号、编码的模糊搜索,以及基于生日按范围查询熊猫数据,接着来看service层:

@Override
public void exportExcelByCondition(PandaQueryDTO queryDTO, HttpServletResponse response) {
    List<PandaExportVO> pandas = baseMapper.selectPandas(queryDTO);
    String fileName = "熊猫基本信息集合-" + System.currentTimeMillis();
    try {
        ExcelUtil.exportExcel(PandaExportVO.class, pandas, fileName, ExcelTypeEnum.XLSX, response);
    } catch (IOException e) {
        log.error("熊猫数据导出失败,{}:{}", e, e.getMessage());
        throw new BusinessException("熊猫基本信息导出失败,请稍后再试!");
    }
}

首先根据传入的条件查询了数据,然后起了一个文件名字,最后通过封装的ExcelUtil.exportExcel()方法直接导出了excel文件,来看接口的定义:

@PostMapping("/export/v1")
public void exportExcelV1(@RequestBody PandaQueryDTO queryDTO, HttpServletResponse response) {
    pandaService.exportExcelByCondition(queryDTO, response);
}

这里面只是单纯调用了一下service层的方法罢了,这时可以尝试调用接口来看看结果(未传递任何参数):

导出结果-V1

结果一目了然,这显然满足了咱们的诉求,能够正常根据条件导出表内的数据,并且也是根据模型类中,@ColumnWidth注解指定的列宽来生成了文件。

3.3、自定义数据转换器

注意看前面的导出结果,性别这一列数据,由于咱们数据库里存的是code,对应的枚举类如下:

@Getter
@AllArgsConstructor
public enum Sex {
    MALE(0, "男"),
    FEMALE(1, "女"),
    UNKNOWN(2, "未知");

    private final Integer code;
    private final String value;
}

所以导出时也成了数字,对于数据的使用者来说,通常更喜欢“男、女”这种表现方式,那咱们该怎么转换呢?很简单,自定义一个数据转换器装载到性别字段即可:

/**
 * 性别转换器
 */
public class SexConverter implements Converter<Integer> {
    @Override
    public WriteCellData<?> convertToExcelData(Integer code, ExcelContentProperty contentProperty,
                                               GlobalConfiguration globalConfiguration) {
        // 将库里存储的code转换为具体的性别
        String value = Sex.valueOfCode(code);
        return new WriteCellData<>(value);
    }
}

// 使用方式如下:
@ExcelProperty(value = "性别", index = 1, converter = SexConverter.class)
private Integer sex;

这个转换器一眼就能看明白,当EasyExcel发现某个字段装载了特定的转换器时,在写入单元格数据时,就会触发自定义的convertToExcelData()逻辑,最终将数据转换成自定义的格式~

3.4、校验导入的Excel数据

导入校验出错

在有些场景中,我们需要对导入的excel数据进行校验清洗后才能使用,如果上传的excel文件存在校验出错的信息,则需像上图一样指明错误原因,最后回传给调用方下载查看,这该怎么实现呢?如下:

@Override
@Transactional(rollbackFor = Exception.class)
public BaseImportExcelVO<String> importExcelV2(MultipartFile file) {
    CommonListener<PandaReadModel> listener = new CommonListener<>(PandaReadModel.class);
    try {
        EasyExcelFactory.read(file.getInputStream(), PandaReadModel.class, listener).sheet().doRead();
    } catch (IOException e) {
        log.error("导入熊猫数据出错:{}: {}", e, e.getMessage());
        throw new BusinessException(ResponseCode.ANALYSIS_EXCEL_ERROR, "网络繁忙,请稍后重试!");
    }

    List<PandaReadModel> excelData = listener.getData();
    if (excelData.size() == 0) {
        throw new BusinessException(ResponseCode.ANALYSIS_EXCEL_ERROR, "请检查您上传的excel文件是否为空!");
    }

    // 校验excel数据,如果校验未通过直接阻断执行,将错误信息返回给调用方
    BaseImportExcelVO<String> result = validateExcelData(excelData);
    if (result.getErrorFlag()) {
        return result;
    }

    // 对校验通过的数据进行批量落库
    batchSaveExcelData(excelData);
    return result;
}

这段代码与最开始的导入案例,唯一的区别就是在数据读取完毕后,调用了校验数据的validateExcelData()方法,该方法的实现为:

/*
* 校验导入的excel数据
* */
private BaseImportExcelVO<String> validateExcelData(List<PandaReadModel> excelData) {
    BaseImportExcelVO<String> result = new BaseImportExcelVO<>();
    boolean errorFlag = false;
    List<PandaReadErrorModel> validatePandas = new ArrayList<>();
    String birthdayErrorMsg = "生日不能为空;", uniCodeErrorMsg = "唯一编码重复;";

    // 根据唯一编码查询库内的熊猫数
    List<String> uniCodes = excelData.stream().map(PandaReadModel::getUniqueCode).collect(Collectors.toList());
    List<CountVO<String, Integer>> counts = baseMapper.selectCountByUniCodes(uniCodes);
    Map<String, Integer> countMap = counts.stream().collect(Collectors.toMap(CountVO::getKey, CountVO::getValue));

    // 循环对excel所有数据行进行校验
    for (PandaReadModel excelRow : excelData) {
        String errorMsg = "";
        PandaReadErrorModel errorModel = new PandaReadErrorModel();
        BeanUtils.copyProperties(excelRow, errorModel);
        // 如果库里对应的唯一编码能查到熊猫,说明UniqueCode重复
        if (countMap.containsKey(excelRow.getUniqueCode())) {
            errorMsg += uniCodeErrorMsg;
            errorFlag = true;
        }
        // 如果导入的生日字段为空,说明对应的excel行没填写出生日期
        if (null == excelRow.getBirthday()) {
            errorMsg += birthdayErrorMsg;
            errorFlag = true;
        }
        errorModel.setErrorMsg(errorMsg);
        validatePandas.add(errorModel);
    }

    // 如果存在校验未通过的记录,则导出校验出错的数据为excel文件
    if (errorFlag) {
        String url, fileName = "熊猫信息导入-校验出错文件-" + System.currentTimeMillis();
        try {
            url = ExcelUtil.exportExcelToOSS(PandaReadErrorModel.class, validatePandas, fileName, ExcelTypeEnum.XLSX);
        } catch (IOException e) {
            log.error("生成熊猫导入校验出错文件失败:{}: {}", e, e.getMessage());
            throw new BusinessException(ResponseCode.ANALYSIS_EXCEL_ERROR, "网络繁忙,请稍后重试!");
        }
        result.setResult(url);
        result.setCheckMsg("文件校验未通过!");
    }
    result.setErrorFlag(errorFlag);
    return result;
}

这个方法虽然代码看着不短,但逻辑十分简单,首先对读取到的数据进行遍历,然后根据对应的校验规则在判断每行数据,这里的校验规则有两条:

  • ①每条数据填写的唯一标识,不能与库里已有的数据重复;
  • ②每条数据的出生日期字段为必填项,为空则无法通过校验。

为此,循环里面的逻辑就是这两步,不过这里用到了一个PandaReadErrorModel类:

@Data
@EqualsAndHashCode(callSuper = false)
public class PandaReadErrorModel extends PandaReadModel implements Serializable {
    private static final long serialVersionUID = 1L;

    // 显式将错误信息放到最后一列(第十列)
    @ExcelProperty(index = 9, value = "错误信息项")
    private String errorMsg;
}

该类继承了PandaReadModel类,只是在最后增加了一个“错误信息项”字段,用于记录每行数据的校验出错信息,其次还封装了一个通用的导入结果类,如下:

@Data
public class BaseImportExcelVO<T> {

  /*
   * Excel导入的结果集(可以是数据本身,也可以是OSS地址)
   * */
  private T result;

  /*
   * Excel文件导入错误信息
   * */
  private String checkMsg;

  /*
   * 本次导入是否存在校验错误的标识
   * */
  private Boolean errorFlag;
}

这个类有结果集、错误信息以及校验出错标识三个字段,结合该类来看校验方法的后半段,在循环结束后,会判断本次校验是否存在未通过的数据,如果存在就会将标识改为true,并且基于PandaReadErrorModel类生成了校验出错的excel文件,然后将其上传到了OSS里,并将可访问的链接放入了result字段。

再回到importExcelV2()这个导入方法,在校验方法执行结束后,会先判断错误标,如果出错则直接将BaseImportExcelVO对象返回给调用方,调用方就能根据result字段里的链接下载校验出错的文件。反之,如果文件校验通过,就会将本次导入的数据批量落库。

上面这个流程,也是许多报表导入的常规流程,诸位也可以根据具体的情况做出调整,比如不生成校验出错的文件,直接将校验完成的集合塞进result字段里返回给调用方;又或者不将错误文件上传到OSS,而是直接基于Response对象返回流给调用方等等。

3.5、导出Excel并统计列和

导出尾行统计求和

在许多导出场景中,除开要导出数据本身外,往往还需要在最后加些统计数据,如某一列的均值、总和等,而面对这种需求该怎么做呢?其实有一种奇技淫巧,即将统计行也作为一条正常的数据拼接在最后,如下:

// 根据筛选条件从数据库里查询出的数据集合
List<PandaExportVO> pandas = baseMapper.selectPandas(queryDTO);

// 求和所有熊猫的身高,并伪装成一条普通数据,加入到集合尾部
BigDecimal heightSum = pandas.stream().map(PandaExportVO::getHeight).reduce(BigDecimal.ZERO, BigDecimal::add);
PandaExportVO sumVO = new PandaExportVO();
sumVO.setUniqueCode("身高合计:");
sumVO.setHeight(heightSum);
pandas.add(sumVO);

通过这种方式,也能实现在尾部增加各种统计行数据,不过这种方式比较偏门,能够满足一定场景下的需求,但如果你尾部的统计项较多,并且需要各种单元格合并,这种手段就并不适用了,这时需要你去自定义WriteHandler,又或者通过模板填充的方式实现(后面会讲述)。

3.6、部分字段不同的导出场景

部分字段不同场景

来看这个场景,正如上图所示,目前有两个导出需求,可仅仅只有第一列字段不同罢了,这时我们需要定义两个接口吗?答案是不需要,面对这种大多数字段相同、部分字段不同的场景,可以基于多态的特性来实现,首先定义一个父类来声明公共字段:

@Data
public class PandaStatisticsExportVO implements Serializable {
    private static final long serialVersionUID = 1L;

    @ExcelProperty(index = 1, value = "熊数")
    private Integer counting;

    @ExcelProperty(index = 2, value = "身高>=170cm熊数")
    private Integer heightGte170cm;
}

接着分别为两个不同的场景定义子类,即:

/**
 * 熊猫统计导出VO类(根据年龄分组)
 */
@Data
@EqualsAndHashCode(callSuper = true)
public class SexStatisticsExportVO extends PandaStatisticsExportVO implements Serializable {
    private static final long serialVersionUID = 1L;

    @ExcelProperty(index = 0, value = "性别分组", converter = SexConverter.class)
    private Integer sex;
}

/**
 * 熊猫统计导出VO类(根据等级分组)
 */
@Data
@EqualsAndHashCode(callSuper = true)
public class LevelStatisticsExportVO extends PandaStatisticsExportVO implements Serializable {
  private static final long serialVersionUID = 1L;

  @ExcelProperty(index = 0, value = "等级分组")
  private String level;
}

这是面向对象语言的标准写法,值得一提的是:这里合理的利用了ExcelProperty注解的index属性,因为两个表格中,存在差异的字段就是第一列,所以两个子类的差异字段的列索引写成0即可。下面来看具体的service实现:

@Override
public void exportStatisticsData(PandaStatisticsDTO statisticsDTO, HttpServletResponse response) {
    List<PandaStatisticsBO> pandaStatisticsBOs = baseMapper.selectPandaStatistics(statisticsDTO);

    List<PandaStatisticsExportVO> exportData;
    Class<?> clazz;
    // 如果是按性别分组统计,则使用性别的数据模型类
    if (0 == statisticsDTO.getStatisticsType()) {
        clazz = SexStatisticsExportVO.class;
        exportData = pandaStatisticsBOs.stream().map(bo -> {
            SexStatisticsExportVO sexVO = new SexStatisticsExportVO();
            BeanUtils.copyProperties(bo, sexVO);
            return sexVO;
        }).collect(Collectors.toList());
    }
    // 如果是按等级分组统计,则使用等级的数据模型类
    else if (1 == statisticsDTO.getStatisticsType()) {
        clazz = LevelStatisticsExportVO.class;
        exportData = pandaStatisticsBOs.stream().map(bo -> {
            LevelStatisticsExportVO levelVO = new LevelStatisticsExportVO();
            BeanUtils.copyProperties(bo, levelVO);
            return levelVO;
        }).collect(Collectors.toList());
    } else {
        throw new BusinessException("暂不支持这种统计方式哦~");
    }

    // 导出对应的excel数据
    String fileName = "熊猫统计数据-" + System.currentTimeMillis();
    try {
        ExcelUtil.exportExcel(clazz, exportData, fileName, ExcelTypeEnum.XLSX, response);
    } catch (IOException e) {
        log.error("熊猫统计数据导出失败,{}:{}", e, e.getMessage());
        throw new BusinessException("统计数据导出失败,请稍后再试!");
    }
}

代码依旧十分简单,首先会根据参数里传入的导出类型,来选择要使用的数据模型类:

  • 0:代表按性别统计数据,使用SexStatisticsExportVO模型类;
  • 1:代表按等级统计数据,使用LevelStatisticsExportVO模型类。

除了这一步外,其余的就是在使用封装好的工具类导出,如果大家觉得看着迷糊,可以去将源码拉下来,结合上下文一起阅读~

3.7、多行头的Excel导出

多行表头导出

有些业务场景下,我们需要导出像上图这样的合并表头文件,即一个大列下面有多个子列,整个文件由多个大列+N多个子列组成,这种需求该怎么导出呢?其实十分简单,只靠ExcelProperty注解就能实现,如下:

@Data
public class MultiLineHeadExportVO implements Serializable {
    private static final long serialVersionUID = 1L;

    @ExcelProperty(value = {"基本信息", "昵称"})
    private String nickname;

    @ExcelProperty(value = {"基本信息", "编码"})
    private String uniqueCode;

    @ExcelProperty(value = {"基本信息","性别"}, converter = SexConverter.class)
    private Integer sex;

    @ExcelProperty(value = {"基本信息","身高"})
    private BigDecimal height;

    @ExcelProperty(value = {"基本信息","出生日期"})
    @DateTimeFormat("yyyy-MM-dd")
    @ColumnWidth(15)
    private Date birthday;

    @ExcelProperty(value = {"其他信息","等级"})
    private String level;

    @ExcelProperty(value = {"其他信息","座右铭"})
    @ColumnWidth(30)
    private String motto;

    @ExcelProperty(value = {"其他信息", "创建时间"})
    @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
    @ColumnWidth(20)
    private Date createTime;
}

正如代码所示,如果你想要将某几列合并成一个大列,只需在注解的value属性中,以数组形式传递相同的大列名即可。而EasyExcel发现列名是数组形式时,就会尝试自动合并相同列表的字段,而导出代码无需做任何改造:

@Override
public void exportMultiLineHeadExcel(HttpServletResponse response) {
    List<MultiLineHeadExportVO> pandas = baseMapper.selectAllPandas();
    String fileName = "多行表头熊猫数据-" + System.currentTimeMillis();
    try {
        ExcelUtil.exportExcel(MultiLineHeadExportVO.class, pandas, fileName, ExcelTypeEnum.XLSX, response);
    } catch (IOException e) {
        log.error("多行表头熊猫数据,{}:{}", e, e.getMessage());
        throw new BusinessException("数据导出失败,请稍后再试!");
    }
}

直接使用前面封装的通用方法,就能导出多行表头的excel文件。不过如果你需要动态表头,即表头里的数据需要动态写入,那这种需求也只能基于填充模式实现,本文就不对其做展开啦~

四、总结

OK,看到这里也说明本文接近了尾声,本篇总共聊了三大块内容,首先是封装读取的通用监听器,其次是封装通用的导出方法,最后是结合不同场景演示了实战案例,不过在有些特殊场景下,可能还会面临导出图片到Excel里的需求,EasyExcel能否实现呢?

答案是能,大家直接参考《EasyExcel官网-导出图片至Excel文件案例》,官方文档中写的十分清晰。不过个人建议不要盲目导出图片,除非你只需导出少量几张图片,否则会特别消耗内存,造成OOM风险,最好还是以链接的形式写入到Excel

好了,使用EasyExcel能使报表读写操作更加轻松,以及获得更好的性能、更优秀的资源利用性,但它并不适合过于复杂的报表处理场景,例如七横八纵交叉合并的Excel格式,解析数据也好,写出数据也罢,EasyExcel虽然能勉强满足,但需要绕弯子才行。

面对复杂的报表,这时使用原生的POI更合适,不仅是它拥有更丰富的API,同时还拥有许多技术资料,对于很多复杂报表的需求,你都有可能直接在网上找到现成代码复用。同时,因为EasyExcel本身就是基于POI封装的框架,所以你也可以将两者结合使用,但前提是你得熟悉POI框架的各类API

最后,尽管EasyExcel比原生的POI更适合处理大数据文件场景,可结合业务后的性能表现没想象中那么美好,所以,想要获得更极致的性能,这还需要我们从编码层面优化,例如结合异步思想、多线程技术去处理,而这些知识将在下篇中结合案例一起讲述啦~

所有文章已开始陆续同步至微信公众号:竹子爱熊猫,想在手机上便捷阅读的小伙伴可搜索关注~