SpringBoot 实现 Excel 导入导出,性能爆表,用起来够优雅!

矫情吗;* 2023-09-26 17:15 120阅读 0赞

EasyExcel简介

EasyExcel是一款阿里开源的Excel导入导出工具,具有处理快速、占用内存小、使用方便的特点,在Github上已有22k+Star,可见其非常流行。

EasyExcel读取75M(46W行25列)的Excel,仅需使用64M内存,耗时20s,极速模式还可以更快!

32c17b3707eeaf3fe3161f43ef528507.png

集成

在SpringBoot中集成EasyExcel非常简单,仅需一个依赖即可。

  1. <!--EasyExcel相关依赖-->
  2. <dependency>
  3. <groupId>com.alibaba</groupId>
  4. <artifactId>easyexcel</artifactId>
  5. <version>3.0.5</version>
  6. </dependency>

使用

EasyExcel和EasyPoi的使用非常类似,都是通过注解来控制导入导出。接下来我们以会员信息和订单信息的导入导出为例,分别实现下简单的单表导出和具有一对多关系的复杂导出。

简单导出

我们以会员信息的导出为例,来体验下EasyExcel的导出功能。

  • 首先创建一个会员对象Member,封装会员信息,这里使用了EasyExcel的注解;

    /**

    • 购物会员
    • Created by macro on 2021/10/12.
      */
      @Data
      @EqualsAndHashCode(callSuper = false)
      public class Member {
      @ExcelProperty(“ID”)
      @ColumnWidth(10)
      private Long id;
      @ExcelProperty(“用户名”)
      @ColumnWidth(20)
      private String username;
      @ExcelIgnore
      private String password;
      @ExcelProperty(“昵称”)
      @ColumnWidth(20)
      private String nickname;
      @ExcelProperty(“出生日期”)
      @ColumnWidth(20)
      @DateTimeFormat(“yyyy-MM-dd”)
      private Date birthday;
      @ExcelProperty(“手机号”)
      @ColumnWidth(20)
      private String phone;
      @ExcelIgnore
      private String icon;
      @ExcelProperty(value = “性别”, converter = GenderConverter.class)
      @ColumnWidth(10)
      private Integer gender;
      }
  • 上面代码使用到了EasyExcel的核心注解,我们分别来了解下:

    • @ExcelProperty:核心注解,value属性可用来设置表头名称,converter属性可以用来设置类型转换器;
    • @ColumnWidth:用于设置表格列的宽度;
    • @DateTimeFormat:用于设置日期转换格式。
  • 在EasyExcel中,如果你想实现枚举类型到字符串的转换(比如gender属性中,0->男1->女),需要自定义转换器,下面为自定义的GenderConverter代码实现;

    /**

    • excel性别转换器
    • Created by macro on 2021/12/29.
      */
      public class GenderConverter implements Converter {
      @Override
      public Class<?> supportJavaTypeKey() {

      1. //对象属性类型
      2. return Integer.class;

      }

      @Override
      public CellDataTypeEnum supportExcelTypeKey() {

      1. //CellData属性类型
      2. return CellDataTypeEnum.STRING;

      }

      @Override
      public Integer convertToJavaData(ReadConverterContext<?> context) throws Exception {

      1. //CellData转对象属性
      2. String cellStr = context.getReadCellData().getStringValue();
      3. if (StrUtil.isEmpty(cellStr)) return null;
      4. if ("男".equals(cellStr)) {
      5. return 0;
      6. } else if ("女".equals(cellStr)) {
      7. return 1;
      8. } else {
      9. return null;
      10. }

      }

      @Override
      public WriteCellData<?> convertToExcelData(WriteConverterContext context) throws Exception {

      1. //对象属性转CellData
      2. Integer cellValue = context.getValue();
      3. if (cellValue == null) {
      4. return new WriteCellData<>("");
      5. }
      6. if (cellValue == 0) {
      7. return new WriteCellData<>("男");
      8. } else if (cellValue == 1) {
      9. return new WriteCellData<>("女");
      10. } else {
      11. return new WriteCellData<>("");
      12. }

      }
      }

  • 接下来我们在Controller中添加一个接口,用于导出会员列表到Excel,还需给响应头设置下载excel的属性,具体代码如下;

    /**

    • EasyExcel导入导出测试Controller
    • Created by macro on 2021/10/12.
      */
      @Controller
      @Api(tags = “EasyExcelController”, description = “EasyExcel导入导出测试”)
      @RequestMapping(“/easyExcel”)
      public class EasyExcelController {

      @SneakyThrows(IOException.class)
      @ApiOperation(value = “导出会员列表Excel”)
      @RequestMapping(value = “/exportMemberList”, method = RequestMethod.GET)
      public void exportMemberList(HttpServletResponse response) {

      1. setExcelRespProp(response, "会员列表");
      2. List<Member> memberList = LocalJsonUtil.getListFromJson("json/members.json", Member.class);
      3. EasyExcel.write(response.getOutputStream())
      4. .head(Member.class)
      5. .excelType(ExcelTypeEnum.XLSX)
      6. .sheet("会员列表")
      7. .doWrite(memberList);

      }

      /**

      • 设置excel下载响应头属性
        /
        private void setExcelRespProp(HttpServletResponse response, String rawFileName) throws UnsupportedEncodingException {
        response.setContentType(“application/vnd.openxmlformats-officedocument.spreadsheetml.sheet”);
        response.setCharacterEncoding(“utf-8”);
        String fileName = URLEncoder.encode(rawFileName, “UTF-8”).replaceAll(“\+”, “%20”);
        response.setHeader(“Content-disposition”, “attachment;filename
        =utf-8’’” + fileName + “.xlsx”);
        }
        }
  • 运行项目,通过Swagger测试接口,注意在Swagger中访问接口无法直接下载,需要点击返回结果中的下载按钮才行,访问地址:http://localhost:8088/swagger-ui/

8110f3b1a8244b579ca79c6da54dcc28.png

  • 下载完成后,查看下文件,一个标准的Excel文件已经被导出了。

b8dd9ae6a4358b207ca1ced1ba48c868.png

简单导入

接下来我们以会员信息的导入为例,来体验下EasyExcel的导入功能。

  • 在Controller中添加会员信息导入的接口,这里需要注意的是使用@RequestPart注解修饰文件上传参数,否则在Swagger中就没法显示上传按钮了;

    /**

    • EasyExcel导入导出测试Controller
    • Created by macro on 2021/10/12.
      */
      @Controller
      @Api(tags = “EasyExcelController”, description = “EasyExcel导入导出测试”)
      @RequestMapping(“/easyExcel”)
      public class EasyExcelController {

      @SneakyThrows
      @ApiOperation(“从Excel导入会员列表”)
      @RequestMapping(value = “/importMemberList”, method = RequestMethod.POST)
      @ResponseBody
      public CommonResult importMemberList(@RequestPart(“file”) MultipartFile file) {

      1. List<Member> memberList = EasyExcel.read(file.getInputStream())
      2. .head(Member.class)
      3. .sheet()
      4. .doReadSync();
      5. return CommonResult.success(memberList);

      }
      }

  • 然后在Swagger中测试接口,选择之前导出的Excel文件即可,导入成功后会返回解析到的数据。

f4ff1b34840f4a39a55f4c3b0f7d3479.png

复杂导出

当然EasyExcel也可以实现更加复杂的导出,比如导出一个嵌套了商品信息的订单列表,下面我们来实现下!

使用EasyPoi实现

之前我们使用过EasyPoi实现该功能,由于EasyPoi本来就支持嵌套对象的导出,直接使用内置的@ExcelCollection注解即可实现,非常方便也符合面向对象的思想。

36f0b7d88bdd1d700a5d9299b06d2a46.png

寻找方案

由于EasyExcel本身并不支持这种一对多的信息导出,所以我们得自行实现下,这里分享一个我平时常用的快速查找解决方案的办法。

我们可以直接从开源项目的issues里面去搜索,比如搜索下一对多,会直接找到有无一对多导出比较优雅的方案这个issue。

从此issue的回复我们可以发现,项目维护者建议创建自定义合并策略来实现,有位回复的老哥已经给出了实现代码,接下来我们就用这个方案来实现下。

eb47ab59c08b462faa6fb47eb2572d5f.png

解决思路

为什么自定义单元格合并策略能实现一对多的列表信息的导出呢?首先我们来看下将嵌套数据平铺,不进行合并导出的Excel。

a88ce5892f33472092d134306adb123a.png

看完之后我们很容易理解解决思路,只要把订单ID相同的列中需要合并的列给合并了,就可以实现这种一对多嵌套信息的导出了。

实现过程

  • 首先我们得把原来嵌套的订单商品信息给平铺了,创建一个专门的导出对象OrderData,包含订单和商品信息,二级表头可以通过设置@ExcelProperty的value为数组来实现;

    /**

    • 订单导出
    • Created by macro on 2021/12/30.
      */
      @Data
      @EqualsAndHashCode(callSuper = false)
      public class OrderData {
      @ExcelProperty(value = “订单ID”)
      @ColumnWidth(10)
      @CustomMerge(needMerge = true, isPk = true)
      private String id;
      @ExcelProperty(value = “订单编码”)
      @ColumnWidth(20)
      @CustomMerge(needMerge = true)
      private String orderSn;
      @ExcelProperty(value = “创建时间”)
      @ColumnWidth(20)
      @DateTimeFormat(“yyyy-MM-dd”)
      @CustomMerge(needMerge = true)
      private Date createTime;
      @ExcelProperty(value = “收货地址”)
      @CustomMerge(needMerge = true)
      @ColumnWidth(20)
      private String receiverAddress;
      @ExcelProperty(value = {“商品信息”, “商品编码”})
      @ColumnWidth(20)
      private String productSn;
      @ExcelProperty(value = {“商品信息”, “商品名称”})
      @ColumnWidth(20)
      private String name;
      @ExcelProperty(value = {“商品信息”, “商品标题”})
      @ColumnWidth(30)
      private String subTitle;
      @ExcelProperty(value = {“商品信息”, “品牌名称”})
      @ColumnWidth(20)
      private String brandName;
      @ExcelProperty(value = {“商品信息”, “商品价格”})
      @ColumnWidth(20)
      private BigDecimal price;
      @ExcelProperty(value = {“商品信息”, “商品数量”})
      @ColumnWidth(20)
      private Integer count;
      }
  • 然后将原来嵌套的Order对象列表转换为OrderData对象列表;

    /**

    • EasyExcel导入导出测试Controller
    • Created by macro on 2021/10/12.
      */
      @Controller
      @Api(tags = “EasyExcelController”, description = “EasyExcel导入导出测试”)
      @RequestMapping(“/easyExcel”)
      public class EasyExcelController {
      private List convert(List orderList) {
      1. List<OrderData> result = new ArrayList<>();
      2. for (Order order : orderList) {
      3. List<Product> productList = order.getProductList();
      4. for (Product product : productList) {
      5. OrderData orderData = new OrderData();
      6. BeanUtil.copyProperties(product,orderData);
      7. BeanUtil.copyProperties(order,orderData);
      8. result.add(orderData);
      9. }
      10. }
      11. return result;
      }
      }
  • 再创建一个自定义注解CustomMerge,用于标记哪些属性需要合并,哪个是主键;

    /**

    • 自定义注解,用于判断是否需要合并以及合并的主键
      */
      @Target({ElementType.FIELD})
      @Retention(RetentionPolicy.RUNTIME)
      @Inherited
      public @interface CustomMerge {

      /**

      • 是否需要合并单元格
        */
        boolean needMerge() default false;

        /**

      • 是否是主键,即该字段相同的行合并
        */
        boolean isPk() default false;
        }
  • 再创建自定义单元格合并策略类CustomMergeStrategy,当Excel中两列主键相同时,合并被标记需要合并的列;

    /**

    • 自定义单元格合并策略
      /
      public class CustomMergeStrategy implements RowWriteHandler {
      /*

      • 主键下标
        */
        private Integer pkIndex;

        /**

      • 需要合并的列的下标集合
        */
        private List needMergeColumnIndex = new ArrayList<>();

        /**

      • DTO数据类型
        */
        private Class<?> elementType;

        public CustomMergeStrategy(Class<?> elementType) {
        this.elementType = elementType;
        }

        @Override
        public void afterRowDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row, Integer relativeRowIndex, Boolean isHead) {
        // 如果是标题,则直接返回
        if (isHead) {

        1. return;

        }

        // 获取当前sheet
        Sheet sheet = writeSheetHolder.getSheet();

        // 获取标题行
        Row titleRow = sheet.getRow(0);

        if (null == pkIndex) {

        1. this.lazyInit(writeSheetHolder);

        }

        // 判断是否需要和上一行进行合并
        // 不能和标题合并,只能数据行之间合并
        if (row.getRowNum() <= 1) {

        1. return;

        }
        // 获取上一行数据
        Row lastRow = sheet.getRow(row.getRowNum() - 1);
        // 将本行和上一行是同一类型的数据(通过主键字段进行判断),则需要合并
        if (lastRow.getCell(pkIndex).getStringCellValue().equalsIgnoreCase(row.getCell(pkIndex).getStringCellValue())) {

        1. for (Integer needMerIndex : needMergeColumnIndex) {
        2. CellRangeAddress cellRangeAddress = new CellRangeAddress(row.getRowNum() - 1, row.getRowNum(),
        3. needMerIndex, needMerIndex);
        4. sheet.addMergedRegionUnsafe(cellRangeAddress);
        5. }

        }
        }

        /**

      • 初始化主键下标和需要合并字段的下标
        */
        private void lazyInit(WriteSheetHolder writeSheetHolder) {

        // 获取当前sheet
        Sheet sheet = writeSheetHolder.getSheet();

        // 获取标题行
        Row titleRow = sheet.getRow(0);
        // 获取DTO的类型
        Class<?> eleType = this.elementType;

        // 获取DTO所有的属性
        Field[] fields = eleType.getDeclaredFields();

        // 遍历所有的字段,因为是基于DTO的字段来构建excel,所以字段数 >= excel的列数
        for (Field theField : fields) {

        1. // 获取@ExcelProperty注解,用于获取该字段对应在excel中的列的下标
        2. ExcelProperty easyExcelAnno = theField.getAnnotation(ExcelProperty.class);
        3. // 为空,则表示该字段不需要导入到excel,直接处理下一个字段
        4. if (null == easyExcelAnno) {
        5. continue;
        6. }
        7. // 获取自定义的注解,用于合并单元格
        8. CustomMerge customMerge = theField.getAnnotation(CustomMerge.class);
        9. // 没有@CustomMerge注解的默认不合并
        10. if (null == customMerge) {
        11. continue;
        12. }
        13. for (int index = 0; index < fields.length; index++) {
        14. Cell theCell = titleRow.getCell(index);
        15. // 当配置为不需要导出时,返回的为null,这里作一下判断,防止NPE
        16. if (null == theCell) {
        17. continue;
        18. }
        19. // 将字段和excel的表头匹配上
        20. if (easyExcelAnno.value()[0].equalsIgnoreCase(theCell.getStringCellValue())) {
        21. if (customMerge.isPk()) {
        22. pkIndex = index;
        23. }
        24. if (customMerge.needMerge()) {
        25. needMergeColumnIndex.add(index);
        26. }
        27. }
        28. }

        }

        // 没有指定主键,则异常
        if (null == this.pkIndex) {

        1. throw new IllegalStateException("使用@CustomMerge注解必须指定主键");

        }

        }
        }

  • 接下来在Controller中添加导出订单列表的接口,将我们自定义的合并策略CustomMergeStrategy给注册上去;

    /**

    • EasyExcel导入导出测试Controller
    • Created by macro on 2021/10/12.
      */
      @Controller
      @Api(tags = “EasyExcelController”, description = “EasyExcel导入导出测试”)
      @RequestMapping(“/easyExcel”)
      public class EasyExcelController {

      @SneakyThrows
      @ApiOperation(value = “导出订单列表Excel”)
      @RequestMapping(value = “/exportOrderList”, method = RequestMethod.GET)
      public void exportOrderList(HttpServletResponse response) {

      1. List<Order> orderList = getOrderList();
      2. List<OrderData> orderDataList = convert(orderList);
      3. setExcelRespProp(response, "订单列表");
      4. EasyExcel.write(response.getOutputStream())
      5. .head(OrderData.class)
      6. .registerWriteHandler(new CustomMergeStrategy(OrderData.class))
      7. .excelType(ExcelTypeEnum.XLSX)
      8. .sheet("订单列表")
      9. .doWrite(orderDataList);

      }
      }

  • 在Swagger中访问接口测试,导出订单列表对应Excel;

602f9e8c88b0f8cf01a31a492b6c9082.png

  • 下载完成后,查看下文件,由于EasyExcel需要自己来实现,对比之前使用EasyPoi来实现麻烦了不少。

0bda5f3068989522fd642d86ced92b6b.png

其他使用

由于EasyExcel的官方文档介绍的比较简单,如果你想要更深入地进行使用的话,建议大家看下官方Demo。

3afc24bc93e64c118f010404bdf6717f.png

总结

体验了一把EasyExcel,使用还是挺方便的,性能也很优秀。但是比较常见的一对多导出实现比较复杂,而且功能也不如EasyPoi 强大。如果你的Excel导出数据量不大的话,可以使用EasyPoi,如果数据量大,比较在意性能的话,还是使用EasyExcel吧。

发表评论

表情:
评论列表 (有 0 条评论,120人围观)

还没有评论,来说两句吧...

相关阅读