Spring Boot 集成 EasyExcel 3.x 优雅实现Excel导入导出
作者:mmseoamin日期:2023-12-25

Spring Boot 集成 EasyExcel 3.x

本章节将介绍 Spring Boot 集成 EasyExcel(优雅实现Excel导入导出)。

🤖 Spring Boot 2.x 实践案例(代码仓库)

介绍

EasyExcel 是一个基于 Java 的、快速、简洁、解决大文件内存溢出的 Excel 处理工具。它能让你在不用考虑性能、内存的等因素的情况下,快速完成 Excel 的读、写等功能。

EasyExcel文档地址:https://easyexcel.opensource.alibaba.com/

快速开始

引入依赖


    com.alibaba
    easyexcel
    3.1.3

简单导出

以导出用户信息为例,接下来手把手教大家如何使用EasyExcel实现导出功能!

定义实体类

在EasyExcel中,以面向对象思想来实现导入导出,无论是导入数据还是导出数据都可以想象成具体某个对象的集合,所以为了实现导出用户信息功能,首先创建一个用户对象UserDO实体类,用于封装用户信息:

/**
 * 用户信息
 *
 * @author william@StarImmortal
 */
@Data
public class UserDO {
    @ExcelProperty("用户编号")
    @ColumnWidth(20)
    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;
    @ExcelProperty("身高(米)")
    @NumberFormat("#.##")
    @ColumnWidth(20)
    private Double height;
    @ExcelProperty(value = "性别", converter = GenderConverter.class)
    @ColumnWidth(10)
    private Integer gender;
}

上面代码中类属性上使用了EasyExcel核心注解:

  • @ExcelProperty:核心注解,value属性可用来设置表头名称,converter属性可以用来设置类型转换器;
  • @ColumnWidth:用于设置表格列的宽度;
  • @DateTimeFormat:用于设置日期转换格式;
  • @NumberFormat:用于设置数字转换格式。
    自定义转换器

    在EasyExcel中,如果想实现枚举类型到字符串类型转换(例如gender属性:1 -> 男,2 -> 女),需实现Converter接口来自定义转换器,下面为自定义GenderConverter性别转换器代码实现:

    /**
     * Excel 性别转换器
     *
     * @author william@StarImmortal
     */
    public class GenderConverter implements Converter {
        @Override
        public Class supportJavaTypeKey() {
            return Integer.class;
        }
        @Override
        public CellDataTypeEnum supportExcelTypeKey() {
            return CellDataTypeEnum.STRING;
        }
        @Override
        public Integer convertToJavaData(ReadConverterContext context) {
            return GenderEnum.convert(context.getReadCellData().getStringValue()).getValue();
        }
        @Override
        public WriteCellData convertToExcelData(WriteConverterContext context) {
            return new WriteCellData<>(GenderEnum.convert(context.getValue()).getDescription());
        }
    }
    
    /**
     * 性别枚举
     *
     * @author william@StarImmortal
     */
    @Getter
    @AllArgsConstructor
    public enum GenderEnum {
        /**
         * 未知
         */
        UNKNOWN(0, "未知"),
        /**
         * 男性
         */
        MALE(1, "男性"),
        /**
         * 女性
         */
        FEMALE(2, "女性");
        private final Integer value;
        @JsonFormat
        private final String description;
        public static GenderEnum convert(Integer value) {
            return Stream.of(values())
                    .filter(bean -> bean.value.equals(value))
                    .findAny()
                    .orElse(UNKNOWN);
        }
        public static GenderEnum convert(String description) {
            return Stream.of(values())
                    .filter(bean -> bean.description.equals(description))
                    .findAny()
                    .orElse(UNKNOWN);
        }
    }
    
    定义接口
    /**
     * EasyExcel导入导出
     *
     * @author william@StarImmortal
     */
    @RestController
    @RequestMapping("/excel")
    public class ExcelController {
        @GetMapping("/export/user")
        public void exportUserExcel(HttpServletResponse response) {
            try {
                this.setExcelResponseProp(response, "用户列表");
                List userList = this.getUserList();
                EasyExcel.write(response.getOutputStream())
                        .head(UserDO.class)
                        .excelType(ExcelTypeEnum.XLSX)
                        .sheet("用户列表")
                        .doWrite(userList);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
        /**
         * 设置响应结果
         *
         * @param response    响应结果对象
         * @param rawFileName 文件名
         * @throws UnsupportedEncodingException 不支持编码异常
         */
        private void setExcelResponseProp(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");
        }
        
        /**
         * 读取用户列表数据
         *
         * @return 用户列表数据
         * @throws IOException IO异常
         */
        private List getUserList() throws IOException {
            ObjectMapper objectMapper = new ObjectMapper();
            ClassPathResource classPathResource = new ClassPathResource("mock/users.json");
            InputStream inputStream = classPathResource.getInputStream();
            return objectMapper.readValue(inputStream, new TypeReference>() {
            });
        }
    }
    
    测试接口

    运行项目,通过 Postman 或者 Apifox 工具来进行接口测试

    注意:在 Apifox 中访问接口后无法直接下载,需要点击返回结果中的下载图标才行,点击之后方可对Excel文件进行保存。

    接口地址:http://localhost:8080/excel/export/user

    测试EasyExcel导出接口

    用户列表

    复杂导出

    由于 EasyPoi 支持嵌套对象导出,直接使用内置 @ExcelCollection 注解即可实现,遗憾的是 EasyExcel 不支持一对多导出,只能自行实现,通过此issues了解到,项目维护者建议通过自定义合并策略方式来实现一对多导出。

    数据平铺效果

    解决思路:只需把订单主键相同的列中需要合并的列给合并了,就可以实现这种一对多嵌套信息的导出

    自定义注解

    创建一个自定义注解,用于标记哪些属性需要合并单元格,哪个属性是主键:

    /**
     * 用于判断是否需要合并以及合并的主键
     *
     * @author william@StarImmortal
     */
    @Target({ElementType.FIELD})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface ExcelMerge {
        /**
         * 是否合并单元格
         *
         * @return true || false
         */
        boolean merge() default true;
        /**
         * 是否为主键(即该字段相同的行合并)
         *
         * @return true || false
         */
        boolean isPrimaryKey() default false;
    }
    
    定义实体类

    在需要合并单元格的属性上设置 @ExcelMerge 注解,二级表头通过设置 @ExcelProperty 注解中 value 值为数组形式来实现该效果:

    /**
     * @author william@StarImmortal
     */
    @Data
    public class OrderBO {
        @ExcelProperty(value = "订单主键")
        @ColumnWidth(16)
        @ExcelMerge(merge = true, isPrimaryKey = true)
        private String id;
        @ExcelProperty(value = "订单编号")
        @ColumnWidth(20)
        @ExcelMerge(merge = true)
        private String orderId;
        @ExcelProperty(value = "收货地址")
        @ExcelMerge(merge = true)
        @ColumnWidth(20)
        private String address;
        @ExcelProperty(value = "创建时间")
        @ColumnWidth(20)
        @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
        @ExcelMerge(merge = true)
        private Date createTime;
        @ExcelProperty(value = {"商品信息", "商品编号"})
        @ColumnWidth(20)
        private String productId;
        @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;
    }
    
    数据映射与平铺

    导出之前,需要对数据进行处理,将订单数据进行平铺,orderList为平铺前格式,exportData为平铺后格式:

    数据平铺

    自定义单元格合并策略

    当 Excel 中两列主键相同时,合并被标记需要合并的列:

    /**
     * 自定义单元格合并策略
     *
     * @author william@StarImmortal
     */
    public class ExcelMergeStrategy implements RowWriteHandler {
        /**
         * 主键下标
         */
        private Integer primaryKeyIndex;
        /**
         * 需要合并的列的下标集合
         */
        private final List mergeColumnIndexList = new ArrayList<>();
        /**
         * 数据类型
         */
        private final Class elementType;
        public ExcelMergeStrategy(Class elementType) {
            this.elementType = elementType;
        }
        @Override
        public void afterRowDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row, Integer relativeRowIndex, Boolean isHead) {
            // 判断是否为标题
            if (isHead) {
                return;
            }
            // 获取当前工作表
            Sheet sheet = writeSheetHolder.getSheet();
            // 初始化主键下标和需要合并字段的下标
            if (primaryKeyIndex == null) {
                this.initPrimaryIndexAndMergeIndex(writeSheetHolder);
            }
            // 判断是否需要和上一行进行合并
            // 不能和标题合并,只能数据行之间合并
            if (row.getRowNum() <= 1) {
                return;
            }
            // 获取上一行数据
            Row lastRow = sheet.getRow(row.getRowNum() - 1);
            // 将本行和上一行是同一类型的数据(通过主键字段进行判断),则需要合并
            if (lastRow.getCell(primaryKeyIndex).getStringCellValue().equalsIgnoreCase(row.getCell(primaryKeyIndex).getStringCellValue())) {
                for (Integer mergeIndex : mergeColumnIndexList) {
                    CellRangeAddress cellRangeAddress = new CellRangeAddress(row.getRowNum() - 1, row.getRowNum(), mergeIndex, mergeIndex);
                    sheet.addMergedRegionUnsafe(cellRangeAddress);
                }
            }
        }
        /**
         * 初始化主键下标和需要合并字段的下标
         *
         * @param writeSheetHolder WriteSheetHolder
         */
        private void initPrimaryIndexAndMergeIndex(WriteSheetHolder writeSheetHolder) {
            // 获取当前工作表
            Sheet sheet = writeSheetHolder.getSheet();
            // 获取标题行
            Row titleRow = sheet.getRow(0);
            // 获取所有属性字段
            Field[] fields = this.elementType.getDeclaredFields();
            // 遍历所有字段
            for (Field field : fields) {
                // 获取@ExcelProperty注解,用于获取该字段对应列的下标
                ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class);
                // 判断是否为空
                if (null == excelProperty) {
                    continue;
                }
                // 获取自定义注解,用于合并单元格
                ExcelMerge excelMerge = field.getAnnotation(ExcelMerge.class);
                // 判断是否需要合并
                if (null == excelMerge) {
                    continue;
                }
                for (int i = 0; i < fields.length; i++) {
                    Cell cell = titleRow.getCell(i);
                    if (null == cell) {
                        continue;
                    }
                    // 将字段和表头匹配上
                    if (excelProperty.value()[0].equalsIgnoreCase(cell.getStringCellValue())) {
                        if (excelMerge.isPrimaryKey()) {
                            primaryKeyIndex = i;
                        }
                        if (excelMerge.merge()) {
                            mergeColumnIndexList.add(i);
                        }
                    }
                }
            }
            // 没有指定主键,则异常
            if (null == this.primaryKeyIndex) {
                throw new IllegalStateException("使用@ExcelMerge注解必须指定主键");
            }
        }
    }
    
    定义接口

    将自定义合并策略 ExcelMergeStrategy 通过 registerWriteHandler 注册上去:

    /**
     * EasyExcel导入导出
     *
     * @author william@StarImmortal
     */
    @RestController
    @RequestMapping("/excel")
    public class ExcelController {
        @GetMapping("/export/order")
        public void exportOrderExcel(HttpServletResponse response) {
            try {
                this.setExcelResponseProp(response, "订单列表");
                List orderList = this.getOrderList();
                List exportData = this.convert(orderList);
                EasyExcel.write(response.getOutputStream())
                        .head(OrderBO.class)
                        .registerWriteHandler(new ExcelMergeStrategy(OrderBO.class))
                        .excelType(ExcelTypeEnum.XLSX)
                        .sheet("订单列表")
                        .doWrite(exportData);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
        /**
         * 设置响应结果
         *
         * @param response    响应结果对象
         * @param rawFileName 文件名
         * @throws UnsupportedEncodingException 不支持编码异常
         */
        private void setExcelResponseProp(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");
        }
    }
    
    测试接口

    运行项目,通过 Postman 或者 Apifox 工具来进行接口测试

    注意:在 Apifox 中访问接口后无法直接下载,需要点击返回结果中的下载图标才行,点击之后方可对Excel文件进行保存。

    接口地址:http://localhost:8080/excel/export/order

    测试EasyExcel导出接口

    订单列表

    简单导入

    以导入用户信息为例,接下来手把手教大家如何使用EasyExcel实现导入功能!

    /**
     * EasyExcel导入导出
     *
     * @author william@StarImmortal
     */
    @RestController
    @RequestMapping("/excel")
    @Api(tags = "EasyExcel")
    public class ExcelController {
        
        @PostMapping("/import/user")
        public ResponseVO importUserExcel(@RequestPart(value = "file") MultipartFile file) {
            try {
                List userList = EasyExcel.read(file.getInputStream())
                        .head(UserDO.class)
                        .sheet()
                        .doReadSync();
                return ResponseVO.success(userList);
            } catch (IOException e) {
                return ResponseVO.error();
            }
        }
    }
    

    在这里插入图片描述

    参考资料

    • 项目地址:https://github.com/alibaba/easyexcel
    • 官方文档:https://www.yuque.com/easyexcel/doc/easyexcel
    • 一对多导出优雅方案:https://github.com/alibaba/easyexcel/issues/1780