Excel导出

2,176 阅读11分钟

EasyExcel、Hutool Excel 工具类和 EasyPOI对比

EasyExcel:

  1. EasyExcel 是阿里巴巴开源的一款简单易用、高性能的 Excel 处理工具。
  2. 它基于流式操作的思想,支持读取大量数据时的低内存消耗和高效率。
  3. EasyExcel 提供了丰富的功能和灵活的 API,可以实现读取、写入、转换 Excel 数据等操作。
  4. EasyExcel 的特点是简单易用、性能优越,适合处理大量数据的场景。
  5. EasyExcel是一款阿里开源的Excel导入导出工具,具有处理快速、占用内存小、使用方便的特点

Hutool Excel 工具类:

  1. Hutool 是一个 Java 工具库,其中包含了很多实用的工具类,包括 Excel 工具类。
  2. Hutool Excel 工具类提供了基本的读取和写入 Excel 文件的功能,支持多种格式(如 XLS、XLSX 等)。
  3. Hutool Excel 工具类相对较轻量,适合简单的 Excel 处理需求。

EasyPOI:

  1. EasyPOI 是一个 Apache POI 的封装库,用于简化 Apache POI 的使用。
  2. Apache POI 是 Apache 软件基金会的一个开源项目,用于处理 Microsoft Office 格式的文档,包括 Excel、Word 和 PowerPoint。
  3. EasyPOI 在 Apache POI 的基础上进行了封装和简化,提供了更易用的 API 和更高层次的抽象,以便于读取和写入 Excel 文件。
  4. EasyPOI 功能较为丰富,支持复杂的数据导入导出、样式设置等操作。

EasyExcel 是一个专注于处理 Excel 的工具库,性能优越且易用;Hutool Excel 工具类提供了基本的 Excel 读写功能;而 EasyPOI 则是 Apache POI 的封装库,提供更高层次的抽象和功能。你可以根据自己的需求选择适合的工具库来处理 Excel 文件。

依据当前项目需求,如果并发量不大、数据量也不大,但是需要导出 excel 的文件样式千差万别,那么推荐用 easypoi;反之,使用 easyexcel 。 EasyExcel摒弃了绝大部分业务属性,当遇到业务处理的场景时,它会遇到非常多无法施展拳脚的时刻。

EasyExcel 适用场景:

  • 处理大量数据:EasyExcel 基于流式操作的思想,内存消耗低,适合处理大量数据的场景。
  • 简单易用:EasyExcel 提供了简洁易用的 API,使得读取、写入和转换 Excel 数据变得更加简单和高效。
  • 性能优越:EasyExcel 在处理大量数据时表现出较好的性能。

EasyPOI 适用场景:

  • 复杂数据导入导出:EasyPOI 在 Apache POI 的基础上进行了封装,提供了更高层次的抽象和功能,适用于处理复杂的数据导入导出场景。
  • 样式设置和模板导出:EasyPOI 提供了丰富的样式设置功能,可以对导出的 Excel 进行定制化的样式设置,同时支持模板导出。

性能对比:

  • EasyExcel 在处理大量数据时,由于其流式操作的设计和优化,通常表现出较好的性能,可以有效地减少内存消耗。
  • EasyPOI 作为 Apache POI 的封装库,相对而言,在处理大量数据时可能会有一些性能上的差异,但对于一般规模的数据处理场景,性能差异可能不太明显。

EasyExcel 适用于处理大量数据和简单易用的场景,而 EasyPOI 则适用于复杂数据导入导出和样式设置等需求。在性能方面,由于 EasyExcel 的优化设计,通常具有更好的性能表现。但对于一般规模的数据处理,两者的性能差异可能并不明显,可以根据具体需求选择合适的工具库。

性能:

  1. easypoi的解析方式是dom解析,把结果一次都读入内存操作,占用内存大,并发量上来的时候就会出现OOM
  2. easyexcel运用的SAX的解析方式,数据量比较大的时候,easyexcel 速度有着明显优势

相比 EasyPoi,EasyExcel 的处理数据性能非常高,读取 75M (46W行25列) 的Excel,仅需使用 64M 内存,耗时 20s,极速模式还可以更快!

image.png

服务器配置:2核16g内存

服务配置:java最大堆栈内存为1g

  • EasyExcel 阿里维护的一款导出框架,导出的为xlsx,在单sheet页上,理论上没有行数限制
  • EasyPoi 这是比较常见的,导出格式为xls,在条数超过6万行,会增加sheet页
  • CSV导出,更常见了,一种以逗号分割格式的文件,不存在行数限制

image.png

image.png

Hutool导出Excel,导多个Sheet页

工具类

public class HuExcelUtils {
    
    /** 
     * 导出多个 Sheet 页
     * @param response
     * @param sheetList 页数据
     * @param fileName 文件名
     */
    public static void exportExcel(HttpServletResponse response, List<SheetDTO> sheetList, String fileName) {
        ExcelWriter bigWriter = ExcelUtil.getBigWriter();
        // 重命名第一个Sheet的名称,不然会默认多出一个Sheet1的页
        bigWriter.renameSheet(0, sheetList.get(0).getSheetName());
        for (SheetDTO sheet : sheetList) {
            // 指定要写出的 Sheet 页
            bigWriter.setSheet(sheet.getSheetName());
            Integer[] columnWidth = sheet.getColumnWidth();
            if (columnWidth == null || columnWidth.length != sheet.getFieldAndAlias().size()) {
                // 设置默认宽度 
                for (int i = 0; i < sheet.getFieldAndAlias().size(); i++) {
                    bigWriter.setColumnWidth(i, 25);
                }
            } else {
                // 设置自定义宽度 
                for (int i = 0; i < columnWidth.length; i++) {
                    bigWriter.setColumnWidth(i, columnWidth[i]);
                }
            }
            // 设置字段和别名
            bigWriter.setHeaderAlias(sheet.getFieldAndAlias());
            // 设置只导出有别名的字段
            bigWriter.setOnlyAlias(true);
            // 设置默认行高
            bigWriter.setDefaultRowHeight(18);
            // 设置冻结行
            bigWriter.setFreezePane(1);
            // 一次性写出内容,使用默认样式,强制输出标题
            bigWriter.write(sheet.getCollection(), true);
            // 设置所有列为自动宽度,不考虑合并单元格
//            bigWriter.autoSizeColumnAll();
        }
        
        ServletOutputStream out = null;
        try {
            //response为HttpServletResponse对象
            response.setContentType("application/vnd.ms-excel;charset=utf-8");
            response.setHeader("Content-Disposition", 
                    "attachment;filename=" + 
                            URLEncoder.encode(fileName + DateUtil.today() + ".xlsx", "UTF-8"));
            out = response.getOutputStream();
            bigWriter.flush(out, true);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            // 关闭writer,释放内存
            bigWriter.close();
        }
        //此处记得关闭输出Servlet流
        IoUtil.close(out);
    }
}

Excel - Sheet页

/**
 * Excel - Sheet页
 */
public class SheetDTO implements Serializable {

    private static final long serialVersionUID = 1L;
    
    /** sheet页名称 */
    private String sheetName;
    
    /** 
     * 字段和别名,如果使用这个,properties 和 titles可以不用处理 
     * Map<字段, 别名>  如:Map<"name", "姓名">
     */
    private Map<String, String> fieldAndAlias;
    
    /**
     * 列宽<br/>
     * 设置列宽时必须每个字段都设置才生效(columnWidth.size = fieldAndAlias.size)
     */
    private List<Integer> columnWidth;
    
    /** 数据集 */
    private Collection<?> collection; 
    
    public SheetDTO() {
        
    }
    
    /**
     * 
     * @param sheetName      sheet页名称
     * @param fieldAndAlias  字段和别名
     * @param collection     数据集
     */
    public SheetDTO(String sheetName, Map<String, String> fieldAndAlias, Collection<?> collection) {
        super();
        this.sheetName = sheetName;
        this.fieldAndAlias = fieldAndAlias;
        this.collection = collection;
    }

    public String getSheetName() {
        return sheetName;
    }

    public void setSheetName(String sheetName) {
        this.sheetName = sheetName;
    }

    public Map<String, String> getFieldAndAlias() {
        return fieldAndAlias;
    }

    public void setFieldAndAlias(Map<String, String> fieldAndAlias) {
        this.fieldAndAlias = fieldAndAlias;
    }

    public List<Integer> getColumnWidth() {
        return this.columnWidth;
    }

    public void setColumnWidth(List<Integer> columnWidth) {
        this.columnWidth = columnWidth;
    }

    public Collection<?> getCollection() {
        return collection;
    }

    public void setCollection(Collection<?> collection) {
        this.collection = collection;
    }

}

示例Controller

// 示例Controller
public Class TestController {

    /**
     * 条件全部导出
     */
    @ResponseBody
    @RequestMapping("/export_all")
    public void export_all(HttpServletResponse response) { 
        List<Map<String, Object>> listData1 = xxxService.listData1();
        List<Map<String, Object>> listData2 = xxxService.listData2();
        List<Map<String, Object>> listData3 = xxxService.listData3();

		Map<String, String> map1 = new LinkedHashMap<String, String>();
		map1.put("store_name", "客户名称");
		map1.put("store_out_trade_no", "客户编码");
		map1.put("store_contract_year", "年份");
		map1.put("business_dept_name", "所属事业部");
		
		Map<String, String> map2 = new LinkedHashMap<String, String>();
		map2.put("store_name", "客户名称");
		map2.put("store_out_trade_out", "客户编码");
		map2.put("store_contract_year", "年份");
		map2.put("store_name", "关联客户名称");
		map2.put("store_out_trade_out", "关联客户编码");
		
		Map<String, String> map3 = new LinkedHashMap<String, String>();
		map3.put("store_name", "客户名称");
		map3.put("store_out_trade_out", "客户编码");
		map3.put("store_contract_year", "年份");
		map3.put("name", "重要负责人姓名");
		map3.put("position", "重要负责人职位");

		List<SheetDTO> arrayList = new ArrayList<SheetDTO> ();
    	arrayList.add(new SheetDTO("客户信息", map1, listData1));
        arrayList.add(new SheetDTO("关联客户信息", map2, listData2));
        arrayList.add(new SheetDTO("重要负责人信息", map3, listData3));

        HuExcelUtils.exportExcel(response, arrayList, "客户信息导出");
    }

}

EasyExcel 实现多个Sheet页导出

当需要处理大量数据并进行分页查询时,为了提高效率和减少内存消耗,可以使用游标方式进行数据库分页查询。游标方式允许通过每次查询一小批数据来逐步获取全部数据。

以下是一个示例代码,展示如何使用游标方式进行50万条数据的分页查询:

int pageSize = 1000; // 每页查询的数据量

// 获取总记录数
long totalCount = yourDataRepository.count();

List<List<Object>> dataList = new ArrayList<>();
dataList.add(Arrays.asList("表头1", "表头2", "表头3")); // 添加表头

int totalPages = (int) Math.ceil((double) totalCount / pageSize);

for (int currentPage = 1; currentPage <= totalPages; currentPage++) {
    try (Cursor<YourDataModel> cursor = yourDataRepository.findAllByQueryWithProjection(pageSize, (currentPage - 1) * pageSize)) {
        while (cursor.hasNext()) {
            YourDataModel data = cursor.next();
            List<Object> rowData = Arrays.asList(data.getField1(), data.getField2(), data.getField3());
            dataList.add(rowData);
        }
    }
}

在上述示例中,我们首先计算总页数,并创建一个空的 dataList 列表,并添加表头。

然后,在每一页中,我们使用游标方式进行数据库查询。yourDataRepository.findAllByQueryWithProjection() 方法具有游标方式的查询功能,根据指定的分页大小和偏移量进行查询。

通过循环遍历游标结果集,将每个数据项转换为对应的 List<Object>,然后将其添加到 dataList 中。 请根据你的实际需求和数据源进行适当调整,并使用合适的数据库操作库来支持游标方式的查询。

    /**
     * 导出多个sheet页数据
     *
     * @param response
     */
    @GetMapping("/exportMultiSheet")
    public void exportMultiSheet(HttpServletResponse response) {
        try {
            //第一个Sheet页数据
            List<List<Object>> dataList = new ArrayList<>();
            List<Object> headList = new ArrayList<>(Arrays.asList(new Object[]{"表头1", "表头2", "表头3"}));
            dataList.add(headList);
 
            //第二个Sheet页数据
            List<List<Object>> dataList2 = new ArrayList<>();
            List<Object> headList2 = new ArrayList<>(Arrays.asList(new Object[]{"表头1", "表头2", "表头3", "表头4"}));
            dataList2.add(headList2);
            String fileName = new String("文件名称.xls".getBytes(), "UTF-8");
            response.addHeader("Content-Disposition", "filename=" + fileName);
            //设置类型,扩展名为.xls
            response.setContentType("application/vnd.ms-excel");
 
            //将数据写入sheet页中
            ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).build();
            WriteSheet writeSheet1 = EasyExcel.writerSheet(0, "sheet1").build();
            WriteSheet writeSheet2 = EasyExcel.writerSheet(1, "sheet2").build();
            excelWriter.write(dataList, writeSheet1);
            excelWriter.write(dataList2, writeSheet2);
            excelWriter.finish();
            response.flushBuffer();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

HSSF、XSSF、SXSSF、对Excel的区别,以及对应不同后缀名Excel(xls,xlsx)的使用

POI提供了HSSF、XSSF以及SXSSF三种方式操作Excel。他们的区别如下:

HSSF:是操作Excel97-2003版本,扩展名为.xls。

XSSF:是操作Excel2007版本开始,扩展名为.xlsx。

SXSSF:是在XSSF基础上,POI3.8版本开始提供的一种支持低内存占用的操作方式,扩展名为.xlsx。

其次,大家需要了解下Excel不同版本的一些区别,这些限制其实间接的局限了POI提供的API功能。

1、支持的行数、列数

Excel97-2003版本,一个sheet最大行数65536,最大列数256。

Excel2007版本开始,一个sheet最大行数1048576,最大列数16384。

2、文件大小

.xlsx文件比.xls的压缩率高,也就是相同数据量下,.xlsx的文件会小很多。

3、兼容性

Excel97-2003版本是不能打开.xlsx文件的。

Excel2007开始的版本是可以打开.xls文件的。

根据以上内容,大家可以根据自己的需求进行选择,当然海量数据的导出肯定是推荐SXSSF的方式。编码过程中,其实不同方式的使用方式基本相同,所以互相切换也是比较简单的,只要把带有前缀的接口改成对应的就行了。如:

HSSF对应:HSSFWorkbook、HSSFSheet、HSSFRow、HSSFCell……

XSSF对应:XSSFWorkbook、XSSFSheet、XSSFRow、XSSFCell……

SXSSF对应:SXSSFWorkbook、Sheet、Row、Cell……

规律还是挺明显的吧,除了workbook,SXSSF的接口都不带前缀,另外两种方式的各个接口都带有对应的前缀。

Spring Boot 集成 EasyExcel 3.x 优雅实现Excel导入导出

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

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>easyexcel</artifactId>
    <version>3.1.3</version>
</dependency>

简单导出

定义实体类

在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<Integer> {
    @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<Integer> 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<UserDO> 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<UserDO> getUserList() throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        ClassPathResource classPathResource = new ClassPathResource("mock/users.json");
        InputStream inputStream = classPathResource.getInputStream();
        return objectMapper.readValue(inputStream, new TypeReference<List<UserDO>>() {
        });
    }
}

测试接口

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

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

复杂导出

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

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

自定义注解

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

/**
 * 用于判断是否需要合并以及合并的主键
 *
 * @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为平铺后格式:

image.png

自定义单元格合并策略

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

/**
 * 自定义单元格合并策略
 *
 * @author william@StarImmortal
 */
public class ExcelMergeStrategy implements RowWriteHandler {

    /**
     * 主键下标
     */
    private Integer primaryKeyIndex;

    /**
     * 需要合并的列的下标集合
     */
    private final List<Integer> 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<OrderDO> orderList = this.getOrderList();
            List<OrderBO> 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文件进行保存。

image.png

简单导入

/**
 * 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<UserDO> userList = EasyExcel.read(file.getInputStream())
                    .head(UserDO.class)
                    .sheet()
                    .doReadSync();
            return ResponseVO.success(userList);
        } catch (IOException e) {
            return ResponseVO.error();
        }
    }
}

参考:(543条消息) Spring Boot 集成 EasyExcel 3.x 优雅实现Excel导入导出_@excelcollection easyexcel_人人都在发奋的博客-CSDN博客