乐趣区

关于后端:SpringBoot实现Excel导入导出性能爆表用起来够优雅

操作 Excel 实现导入导出是个十分常见的需要,之前介绍了一款十分好用的工具 EasyPoi。有读者提出在数据量大的状况下,EasyPoi 占用内存大,性能不够好。明天给大家举荐一款性能更好的 Excel 导入导出工具 EasyExcel,心愿对大家有所帮忙!SpringBoot 实战电商我的项目 mall(50k+star)地址:github.com/macrozheng/…EasyExcel 简介 EasyExcel 是一款阿里开源的 Excel 导入导出工具,具备解决疾速、占用内存小、使用方便的特点,在 Github 上已有 22k+Star,可见其十分风行。EasyExcel 读取 75M(46W 行 25 列) 的 Excel,仅需应用 64M 内存,耗时 20s,极速模式还能够更快!

集成在 SpringBoot 中集成 EasyExcel 非常简单,仅需一个依赖即可。<!–EasyExcel 相干依赖 –>
<dependency>

<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.0.5</version>

</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<Integer> {

@Override
public Class<?> supportJavaTypeKey() {
    // 对象属性类型
    return Integer.class;
}

@Override
public CellDataTypeEnum supportExcelTypeKey() {
    //CellData 属性类型
    return CellDataTypeEnum.STRING;
}

@Override
public Integer convertToJavaData(ReadConverterContext<?> context) throws Exception {
    //CellData 转对象属性
    String cellStr = context.getReadCellData().getStringValue();
    if (StrUtil.isEmpty(cellStr)) return null;
    if ("男".equals(cellStr)) {return 0;} else if ("女".equals(cellStr)) {return 1;} else {return null;}
}

@Override
public WriteCellData<?> convertToExcelData(WriteConverterContext<Integer> context) throws Exception {
    // 对象属性转 CellData
    Integer cellValue = context.getValue();
    if (cellValue == null) {return new WriteCellData<>("");
    }
    if (cellValue == 0) {return new WriteCellData<>("男");
    } else if (cellValue == 1) {return new WriteCellData<>("女");
    } else {return new WriteCellData<>("");
    }
}

}
复制代码接下来咱们在 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) {setExcelRespProp(response, "会员列表");
    List<Member> memberList = LocalJsonUtil.getListFromJson("json/members.json", Member.class);
    EasyExcel.write(response.getOutputStream())
            .head(Member.class)
            .excelType(ExcelTypeEnum.XLSX)
            .sheet("会员列表")
            .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…

下载实现后,查看下文件,一个规范的 Excel 文件曾经被导出了。

简略导入接下来咱们以会员信息的导入为例,来体验下 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) {List<Member> memberList = EasyExcel.read(file.getInputStream())
            .head(Member.class)
            .sheet()
            .doReadSync();
    return CommonResult.success(memberList);
}

}
复制代码而后在 Swagger 中测试接口,抉择之前导出的 Excel 文件即可,导入胜利后会返回解析到的数据。

简单导出当然 EasyExcel 也能够实现更加简单的导出,比方导出一个嵌套了商品信息的订单列表,上面咱们来实现下!应用 EasyPoi 实现之前咱们应用过 EasyPoi 实现该性能,因为 EasyPoi 原本就反对嵌套对象的导出,间接应用内置的 @ExcelCollection 注解即可实现,十分不便也合乎面向对象的思维。

寻找计划因为 EasyExcel 自身并不反对这种一对多的信息导出,所以咱们得自行实现下,这里分享一个我平时罕用的疾速查找解决方案的方法。咱们能够间接从开源我的项目的 issues 外面去搜寻,比方搜寻下一对多,会间接找到有无一对多导出比拟优雅的计划这个 issue。

从此 issue 的回复咱们能够发现,我的项目维护者倡议创立自定义合并策略来实现,有位回复的老哥曾经给出了实现代码,接下来咱们就用这个计划来实现下。

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

看完之后咱们很容易了解解决思路,只有把订单 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<OrderData> convert(List<Order> orderList) {List<OrderData> result = new ArrayList<>();
    for (Order order : orderList) {List<Product> productList = order.getProductList();
        for (Product product : productList) {OrderData orderData = new OrderData();
            BeanUtil.copyProperties(product,orderData);
            BeanUtil.copyProperties(order,orderData);
            result.add(orderData);
        }
    }
    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<Integer> 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) {return;}

    // 获取以后 sheet
    Sheet sheet = writeSheetHolder.getSheet();

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

    if (null == pkIndex) {this.lazyInit(writeSheetHolder);
    }

    // 判断是否须要和上一行进行合并
    // 不能和题目合并,只能数据行之间合并
    if (row.getRowNum() <= 1) {return;}
    // 获取上一行数据
    Row lastRow = sheet.getRow(row.getRowNum() - 1);
    // 将本行和上一行是同一类型的数据 (通过主键字段进行判断),则须要合并
    if (lastRow.getCell(pkIndex).getStringCellValue().equalsIgnoreCase(row.getCell(pkIndex).getStringCellValue())) {for (Integer needMerIndex : needMergeColumnIndex) {CellRangeAddress cellRangeAddress = new CellRangeAddress(row.getRowNum() - 1, row.getRowNum(),
                    needMerIndex, needMerIndex);
            sheet.addMergedRegionUnsafe(cellRangeAddress);
        }
    }
}

/**
 * 初始化主键下标和须要合并字段的下标
 */
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) {
        // 获取 @ExcelProperty 注解,用于获取该字段对应在 excel 中的列的下标
        ExcelProperty easyExcelAnno = theField.getAnnotation(ExcelProperty.class);
        // 为空, 则示意该字段不须要导入到 excel, 间接解决下一个字段
        if (null == easyExcelAnno) {continue;}
        // 获取自定义的注解,用于合并单元格
        CustomMerge customMerge = theField.getAnnotation(CustomMerge.class);

        // 没有 @CustomMerge 注解的默认不合并
        if (null == customMerge) {continue;}

        for (int index = 0; index < fields.length; index++) {Cell theCell = titleRow.getCell(index);
            // 当配置为不须要导出时,返回的为 null,这里作一下判断,避免 NPE
            if (null == theCell) {continue;}
            // 将字段和 excel 的表头匹配上
            if (easyExcelAnno.value()[0].equalsIgnoreCase(theCell.getStringCellValue())) {if (customMerge.isPk()) {pkIndex = index;}

                if (customMerge.needMerge()) {needMergeColumnIndex.add(index);
                }
            }
        }
    }

    // 没有指定主键,则异样
    if (null == this.pkIndex) {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) {List<Order> orderList = getOrderList();
    List<OrderData> orderDataList = convert(orderList);
    setExcelRespProp(response, "订单列表");
    EasyExcel.write(response.getOutputStream())
            .head(OrderData.class)
            .registerWriteHandler(new CustomMergeStrategy(OrderData.class))
            .excelType(ExcelTypeEnum.XLSX)
            .sheet("订单列表")
            .doWrite(orderDataList);
}

}
复制代码在 Swagger 中拜访接口测试,导出订单列表对应 Excel;

下载实现后,查看下文件,因为 EasyExcel 须要本人来实现,比照之前应用 EasyPoi 来实现麻烦了不少。

其余应用因为 EasyExcel 的官网文档介绍的比较简单,如果你想要更深刻地进行应用的话,倡议大家看下官网 Demo。

总结体验了一把 EasyExcel,应用还是挺不便的,性能也很优良。然而比拟常见的一对多导出实现比较复杂,而且性能也不如 EasyPoi 弱小。如果你的 Excel 导出数据量不大的话,能够应用 EasyPoi,如果数据量大,比拟在意性能的话,还是应用 EasyExcel 吧。参考资料我的项目地址:github.com/alibaba/eas…官网文档:www.yuque.com/easyexcel/d…我的项目源码地址 github.com/macrozheng/…

退出移动版