开源轮子 - EasyExcel02(深入实践)

EasyExcel02 - 深入实践

本文整理自掘金大佬 - 竹子爱熊猫

https://juejin.cn/post/7405158771017220131

文章目录

一:通用监听器

使用EasyExcel解析excel文件时,针对不同的业务场景,通常需要定义不同的监听器,如:

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

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

1:通用监听器

不管是什么业务场景下的Excel导入,都会经过"解析文件、提取数据、进行业务处理"这三步

除开第三步外,前两步逻辑是相同的,既然逻辑相同,那么自然可以抽象成通用监听器

java 复制代码
package com.example.bootrocketmq.study.wheel.easyexcel;

import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;

import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * @author cui haida
 * 2024/12/22
 */
public class CommonListener<T> extends AnalysisEventListener<T> {

    //创建list集合封装最终的数据
    private final List<T> data;

    // 字段列表
    private final Field[] fields;
    // 类
    private final Class<T> clazz;
    // 是否对excel的有效性进行校验,如果设置为true, 将会检验excel模板的头和数据模型类字段是否匹配
    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) {
            // 如果需要进行正确性校验,将会进行工具方法进行excel模板校验 -> header和数据模型类字段关系校验
            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的头信息和数据模型字段的匹配关系

为什么要有第二个呢?

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

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

java 复制代码
package com.example.bootrocketmq.study.wheel.easyexcel;

import com.alibaba.excel.annotation.ExcelIgnore;
import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.exception.ExcelAnalysisException;
import lombok.extern.slf4j.Slf4j;

import java.lang.reflect.Field;
import java.util.Collection;
import java.util.Map;

/**
 * @author cui haida
 * 2024/12/22
 */
@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 && !name.isEmpty() && !headNames.contains(name)) {
                throw new ExcelAnalysisException("请检查导入的excel文件是否按模板填写!");
            }
            // 更新有效字段的数量
            count++;
        }
        // 最后校验数据模型类的有效字段数量,与读到的excel列数量是否匹配
        if (headMap.size() != count) {
            throw new ExcelAnalysisException("请检查导入的excel文件是否按模板填写!");
        }
    }
}

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

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

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

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

2:分批处理监听器

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

因为解析到的所有数据,都会暂存到内部的data集合里,直至整个文件所有数据行读取结束

对于大文件而言,这种模式会给内存造成较大的负担,一旦同时导入的excel文件数量过多,内存资源耗尽就会引发内存溢出问题,怎么办?可以选择分批处理读取到的数据

java 复制代码
package com.example.bootrocketmq.study.wheel.easyexcel;

import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.event.AnalysisEventListener;

import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;

/**
 * @author cui haida
 * 2024/12/22
 */
public class BatchHandleListener<T> extends AnalysisEventListener<T> {

    // 每次处理的行数(每次处理1000行 - 批次大小)
    private static int BATCH_NUMBER = 1000;
    // 临时存储读取到的excel数据
    private List<T> data;
    // 行数和批次编号
    private int rows, batchNo;
    // 是否要进行excel模板校验,如果需要校验,将validateSwitch设置为true
    private boolean validateSwitch = true;

    /*
     * 每批数据的业务逻辑处理器
     * 说明:如果业务方法会返回结果,可以将换成Function接口,同时类上新增一个类型参数
     **/
    private final Consumer<List<T>> businessHandler;

    /*
     * 用于校验excel模板正确性的字段
     **/
    private final Field[] fields;
    private final Class<T> clazz;

    /**
     * @author cui haida
     * 方法编写日期: 2024/12/22
     * desc -> 构造方法1 - 使用的批次大小为默认的1000条
     */
    public BatchHandleListener(Class<T> clazz, Consumer<List<T>> handle) {
        // 通过构造器为字段赋值,用于校验excel文件与模板十分匹配
        this(clazz, handle, BATCH_NUMBER);
    }

    /**
     * @author cui haida
     * 方法编写日期: 2024/12/22
     * desc ->  构造方法2 - 传入具体使用的批次大小
     */
    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.isEmpty()) {
            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对象。
  • 当执行业务逻辑结束后,会重新new一个新的集合接收数据,而旧集合被断开引用关系后,会在短时间内被GC,这里不使用clear()方法的原因,是由于clear()内部会去断开与每个元素的引用,这种方式会影响整个文件的读取性能。其次,当解析到的行数还未达到给定阈值时,会继续解析后面的数据,直到抵达阈值后重复前面的流程。
  • 这个分批处理监听器,还重写了doAfterAllAnalysed()方法,这个方法会在整个文件解析完成后触发,里面实现的逻辑是为了做好收尾工作,因为最后一批可能达不到指定的上限,所以解析完之后还要视情况做一次处理。不过这里用了clear()方法,毕竟这是最后一次收尾工作,后续也不需要新集合来接收数据了,直接清理现有集合最合适。

该监听器还提供了两个API

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

这两个方法返回的值,会随着excel文件不断解析而不断变化,当文件彻底解析完成后,调用这两个方法可以获取到总批次数以及数据总行数。

关于这个监听器,会在百万级大文件解析的实战中才会用到,先暂时了解即可。

二:封装excel导出工具类

上面listener是导入的时候用到的,那么如果是导出的时候呢?

java 复制代码
package com.example.bootrocketmq.study.wheel.easyexcel;

import com.alibaba.excel.EasyExcelFactory;
import com.alibaba.excel.support.ExcelTypeEnum;
import com.alibaba.excel.write.builder.ExcelWriterBuilder;
import com.alibaba.excel.write.metadata.style.WriteCellStyle;
import com.alibaba.excel.write.metadata.style.WriteFont;
import com.alibaba.excel.write.style.HorizontalCellStyleStrategy;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;
import java.util.List;

/**
 * @author cui haida
 * 2024/12/22
 * 导出工具类
 */
public class ExcelExportUtil {
    /*
     * 三种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的通用方法
     * clazz -> 导出excel所需的数据模型类
     * execlData -> 需要导出的数据列表
     * filenName -> 当前导出的文件名称(不带文件后缀)
     * execlType -> 导出的文件类型(xlsx, xls, csv)
     * response -> 网络相应对象
     **/
    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;
            default:
        }
        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);
    }
}

接着基于response对象,设置了响应的数据类型、编码格式、文件名称等,最后就基于响应对象的输出流写出了生成的excel文件。

当然,通常大文件导出并不会实时返回流给调用方,而是返回OSS地址给前端去下载

java 复制代码
/**
 * @author cui haida
 * 方法编写日期: 2024/12/22
 * desc ->  上传文件到oss
 * @param clazz -> 导出excel所需的数据模型类
 * @param excelData -> 需要导出的数据列表
 * @param excelType -> excel的类型
 * @param fileName -> excel文件名称
 */
public static String exportExcelToOSS(Class<?> clazz, List<?> excelData, String fileName, ExcelTypeEnum excelType) throws IOException {
    HorizontalCellStyleStrategy styleStrategy = 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文件的代码(实际使用请替换为真实上传)
 * @param file 文件对象
 **/
public static String uploadFileToOss(File file) {
    String resultUrl = "";
    // 省略上传至OSS的代码......
    return resultUrl;
}

三:项目实践

1: excel导入并进行批量落库

为了更加的贴合业务环境,创建对应的数据库表进行测试

sql 复制代码
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='熊猫表';

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

sql 复制代码
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());

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

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

java 复制代码
package org.example.myeasyexcel.model.excel;

import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.format.DateTimeFormat;
import lombok.Data;

import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;

/**
 * @author cui haida
 * 2024/12/23
 * 熊猫导出类
 */
@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层的实现逻辑:

java 复制代码
@Override
@Transactional(rollbackFor = Exception.class)
public void importExcelV1(MultipartFile file) {
    // 创建通用监听器来解析excel文件
    CommonListener<PandaReadModel> listener = new CommonListener<>(PandaReadModel.class);
    try {
        // 从excel模板中读取数据
        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.isEmpty()) {
        throw new BusinessException(ResponseCode.ANALYSIS_EXCEL_ERROR, "请检查您上传的excel文件是否为空!");
    }
    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);
}

接着定义controller层

java 复制代码
@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();
  }
}

调用外部接口就可以发现已经成功的读取了数据并

2:根据检索条件导出excel

导出也非常的容易,先定义导出模型类:

java 复制代码
package org.example.myeasyexcel.model.vo;

import com.alibaba.excel.annotation.ExcelIgnore;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.format.DateTimeFormat;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import lombok.Data;
import org.example.myeasyexcel.converter.SexConverter;

import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;

/**
 * @author cui haida
 * 2024/12/22
 */
@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, converter = SexConverter.class)
    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;
}

当然,如果导入、导出的字段相同,你可以使用同一个类,不过个人建议是最好分开

下面再来定义一个查询条件参数类:

java 复制代码
package org.example.myeasyexcel.model.dto;

import lombok.Data;

import java.io.Serializable;

/**
 * @author cui haida
 * 2024/12/23
 */
@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层:

java 复制代码
/**
 * 根据条件导出熊猫信息到Excel
 * 
 * @param queryDTO 包含查询条件的PandaQueryDTO对象,用于筛选要导出的熊猫信息
 * @param response HttpServletResponse对象,用于将Excel文件作为HTTP响应返回
 * 
 * 本方法首先根据查询条件从数据库中筛选出相应的熊猫信息,然后利用ExcelUtil工具类
 * 将这些信息导出为Excel文件,并通过HTTP响应返回给客户端如果导出过程中发生IO异常,
 * 则记录错误日志并抛出业务异常,提示导出失败
 */
@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("熊猫基本信息导出失败,请稍后再试!");
    }
}

对应的工具方法在上面已经说明,这里在描述一遍

java 复制代码
package org.example.myeasyexcel.util;

import com.alibaba.excel.EasyExcelFactory;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.annotation.ExcelIgnore;
import com.alibaba.excel.annotation.ExcelIgnoreUnannotated;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.exception.ExcelAnalysisException;
import com.alibaba.excel.support.ExcelTypeEnum;
import com.alibaba.excel.write.builder.ExcelWriterBuilder;
import com.alibaba.excel.write.metadata.style.WriteCellStyle;
import com.alibaba.excel.write.metadata.style.WriteFont;
import com.alibaba.excel.write.style.HorizontalCellStyleStrategy;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.example.myeasyexcel.enums.ExcelTemplate;

import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.Field;
import java.net.URLEncoder;
import java.util.*;

/**
 * @author cui haida
 * 2024/12/23
 */
@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的通用方法
     *   clazz:传入excel导出的VO类
     *   data:传入需要导出的数据列表
     *   fileName:当前导出的excel文件名称(不带文件后缀)
     *   excelType:导出的文件类型
     *   response:网络响应对象
     * */
    public static void exportExcel(Class<?> clazz, List<?> excelData, String fileName, ExcelTypeEnum excelType, HttpServletResponse response) throws IOException {
        HorizontalCellStyleStrategy styleStrategy = setCellStyle();
        setResponse(fileName, excelType, response);
        ExcelWriterBuilder writeWork = EasyExcelFactory.write(response.getOutputStream(), clazz);
        writeWork.registerWriteHandler(styleStrategy).excelType(excelType).sheet().doWrite(excelData);
    }

    /*
     * 初始化模板填充导出场景的写对象
     * */
    public static ExcelWriter initExportFillWriter(String fileName, ExcelTypeEnum excelType, ExcelTemplate template, HttpServletResponse response) throws IOException {
        setResponse(fileName, excelType, response);
        return EasyExcelFactory.write(response.getOutputStream())
                .excelType(excelType)
                .withTemplate(template.getPath()).build();
    }

    /*
     * 设置通用的响应头信息
     * */
    public static void setResponse(String fileName, ExcelTypeEnum excelType, HttpServletResponse response) {
        // 对文件名进行UTF-8编码、拼接文件后缀名
        try {
            fileName = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20") + excelType.getValue();
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
        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);
    }

    /*
     * 设置单元格风格
     * */
    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);
    }

    /*
     * 上传错误的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 = "https://juejin.cn/user/862486453028888/posts";
        // 省略上传至OSS的代码......
        return resultUrl;
    }

    /*
     * 校验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;
        // 如果EasyExcel框架版本较低,请把这行代码放开,并将有效的字段列表返回,用于空值校验
        // List<Field> validFields = new ArrayList<>(fields.length);
        for (Field field : fields) {
            // 忽略序列化ID字段
            if ("serialVersionUID".equals(field.getName())) {
                continue;
            }
            // 如果字段上存在忽略注解,则跳过当前字段
            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++;
            // validFields.add(field);
        }
        // 最后校验数据模型类的有效字段数量,与读到的excel列数量是否匹配
        if (headMap.size() != count) {
            throw new ExcelAnalysisException("请检查导入的excel文件是否按模板填写!");
        }
    }

    /**
     * 判断整行单元格数据是否均为空(依赖于validateExcelTemplate()方法返回的有效字段列表)
     * 说明:该方法适用于低版本的EasyExcel读取数据时校验,因为低版本的不会自动忽略空行
     */
    public static <T> boolean rowIsNull(T data, List<Field> validFields) {
        if (data instanceof String) {
            return "".equals(data);
        }
        try {
            List<Boolean> fieldNulls = new ArrayList<>(validFields.size());
            for (Field field : validFields) {
                field.setAccessible(true);
                Object value = field.get(data);
                if (Objects.isNull(value)) {
                    fieldNulls.add(Boolean.TRUE);
                } else {
                    fieldNulls.add(Boolean.FALSE);
                }
            }
            return fieldNulls.stream().allMatch(Boolean.TRUE::equals);
        } catch (Exception e) {
            log.error("读取数据行[{}]解析失败: {}", data, e.getMessage());
        }
        return true;
    }
}

最后编写controller层,就是简单的对service层进行调用

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

3:校验写入的excel的数据

在有些场景中,我们需要对导入的excel数据进行校验清洗后才能使用

如果上传的excel文件存在校验出错的信息,则需像上图一样指明错误原因,最后回传给调用方下载查看,这该怎么实现呢?如下:

java 复制代码
@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.isEmpty()) {
        throw new BusinessException(ResponseCode.ANALYSIS_EXCEL_ERROR, "请检查您上传的excel文件是否为空!");
    }

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

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

/*
 * 校验导入的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);
//                ExcelUtil.exportExcel(PandaReadErrorModel.class, validatePandas, fileName, ExcelTypeEnum.XLSX, response);
        } 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;
}

4:部分字段不同的导出场景

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

java 复制代码
@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;
}

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

java 复制代码
/**
 * 熊猫统计导出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实现:

java 复制代码
@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模型类。

5:多行头的excel导出

有些业务场景下,我们需要导出像上图这样的合并表头文件,即一个大列下面有多个子列,整个文件由多个大列+N多个子列组成,这种需求该怎么导出呢?

其实十分简单,只靠ExcelProperty注解就能实现,如下:

java 复制代码
@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发现列名是数组形式时,就会尝试自动合并相同列表的字段,而导出代码无需做任何改造:

java 复制代码
@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("数据导出失败,请稍后再试!");
    }
}
相关推荐
GraduationDesign2 分钟前
基于SpringBoot的蜗牛兼职网的设计与实现
java·spring boot·后端
今天不学习明天变拉吉11 分钟前
大批量数据导入接口的优化
java·excel
小手cool12 分钟前
取多个集合的交集
java
全栈老实人_14 分钟前
农家乐系统|Java|SSM|VUE| 前后端分离
java·开发语言·tomcat·maven
customer0817 分钟前
【开源免费】基于SpringBoot+Vue.JS安康旅游网站(JAVA毕业设计)
java·vue.js·spring boot·后端·kafka·开源·旅游
点点滴滴的记录34 分钟前
Java的CompletableFuture实现原理
java·开发语言·javascript
xiaolingting35 分钟前
Java 引用是4个字节还是8个字节?
java·jvm·引用·指针压缩
一只傻小白,40 分钟前
JAVA项目中freemarker静态模板技术
java·开发语言
袁庭新40 分钟前
Spring Boot项目接收前端参数的11种方式
java·springboot·袁庭新·如何接收前端数据·boot接收数据
机跃41 分钟前
递归算法常见问题(Java)
java·开发语言·算法