EEC vs easyexcel

2020/03/03 Excel

本系列主要介绍Easyexcel和EEC的功能并从便利性、性能、内存等多方面全面的进行评测。

1. 开始

关于easyexcel

easyexcel是alibaba开发的快速、简单、且避免OOM的java处理Excel工具,于2018.2在github上开源。它是在Apache POI基础上包装而来,主要解决Apache POI高内存且API臃肿的诟病,easyexcel提供了比原生的POI简结很多的接口,读写Excel文件均可以一行代码完成,目前(2020.3)github上有13k个Star和3.4K个Fork。

引用作者总结核心原理:

  1. 文件解压、读取通过文件形式
  2. 避免将全部数据一次加载到内存(采用sax模式一行一行解析并使用观察者的模式通知处理)
  3. 抛弃不重要的数据(忽略样式,字体,宽度等数据)

点击这里查看作者原文

关于EEC

EEC是国内一个个人开发者开发并于2017.10月在github开源,EEC的底层并没有使用Apache POI包,所有的底层读写代码均由作者实现,事实上EEC仅依懒dom4j和slf4j,前者用于小文件xml读取,后者统一日志接口。

核心原理:

  1. 不缓存数据或少量缓存
  2. 使用分片来处理较大的数据
  3. 单元格样式仅使用一个int值来保存,极大缩小内存使用
  4. 使用迭代模式读取行内容,不会将整个文件读入到内存

简单总结两个工具的不同:

  • 底层不同,easyexcel底层使用Apache POI,EEC使用IO/NIO
  • easyexcel最低支持JDK7,EEC最低支持JDK8
  • easyexcel简化了接口使得像设置样式这种基本功能非常困难,EEC默认带有便于阅读的样式也提供方法设置其它样式
  • easyexcel读取文件时忽略样式和字体也没有办法直接获取单元格的公式。
  • easyexcel对常用类型缺少支持(char, Timestamp, Time, LocalDate, LocalDateTime, LocalTime),如果实体类中有这些类型就必须为这些类型编写自定义Converter

相比之下EEC更接近于Apache POI,而easyexcel更关注单元格的值而忽略其它不太关心的数据。

2. 写文件

2.1 少量数据

对于少量数据可以直接将内容放到数组/集合中一次写入,两个工具都能做到一行代码完成数据写入。下面展示两者的实现方式,代码中出现的defaultTestPath是文件路径事先已创建好。

easyexcel可以将文件直接写入OutputStream或磁盘

public void test5(List<Item> data) {
    EasyExcel.write(defaultTestPath.resolve("test5.xlsx").toString(), LargeData.class).sheet().doWrite(data);
}

test5.xlsx

EEC同样可以写入OutputStream或磁盘,使用writeTo方法指定输出位置

public void test6(List<Item> data) throws IOException {
    new Workbook("test6").addSheet(new ListSheet<>(data)).writeTo(defaultTestPath);
}

test6.xlsx

2.2 写多个worksheet页

两个工具都提供便利的方法实现多worksheet页写入,基本可以使用一行代码搞定。

easyexcel通过创建多个WriteSheet来实现

public void test7() {
    EasyExcel.write(defaultTestPath.resolve("test7.xlsx").toString()).build()
        .write(checks(), EasyExcel.writerSheet("帐单表").build())
        .write(customers(), EasyExcel.writerSheet("客户表").build())
        .write(c2CS(), EasyExcel.writerSheet("用户客户关系表").build())
        .finish();
}

test7.xlsx

EEC通过addSheet方法添加多个worksheet,看上去更直观更容易理解。

public void test8() throws IOException {
    new Workbook("test8")
        .addSheet(new ListSheet<>("帐单表", checks()))
        .addSheet(new ListSheet<>("客户表", customers()))
        .addSheet(new ListSheet<>("用户客户关系表", c2CS()))
        .writeTo(defaultTestPath);
}

test8.xlsx

2.3 大数据量

数据量较大时我们无法将数据全部装载到内存,此时需要分批写文件,好在easyexcel和EEC均支持分片处理做到边读数据边写文件。

easyexcel写大文件需要指定一个模板文件,然后调用fill方法循环写数据,需要注意如果数据量超出excel单页上限会抛异常

public void test1() {
    ExcelWriter excelWriter = EasyExcel.write(defaultTestPath.resolve("Large easyexcel.xlsx").toFile())
        .withTemplate(defaultTestPath.resolve("temp.xlsx").toFile()).build();
    WriteSheet writeSheet = EasyExcel.writerSheet().build();
    for (int j = 0; j < 100; j++) {
        excelWriter.fill(data(), writeSheet);
    }
    excelWriter.finish();
}

EEC分片写大文件时需要继承ListSheet<T>ListMapSheet然后重写more方法并返回批量数据

public void test2() {
    new Workbook("Large EEC").addSheet(new ListSheet<LargeData>() {
        int n = 0;
        @Override
        public List<LargeData> more() {
            return n++ < 100 ? data() : null;
        }
    }).writeTo(defaultTestPath);
}

这里的data()方法模拟取数据过程,返回List<LargeData>类型。类似如下代码

private List<LargeData> data() {
    List<LargeData> list = new ArrayList<>();
    for (int i = 0; i < 1000; i++) {
        LargeData largeData = new LargeData();
        list.add(largeData);
        largeData.setStr1("str1-" + i);
        largeData.setStr2("str2-" + i);
        largeData.setStr3("str3-" + i);
        largeData.setStr4("str4-" + i);
        largeData.setStr5("str5-" + i);
    }
    return list
}

以上是两个工具类处理大文件的不同方式,easyexcel采用push方式主动向ExcelWriter推送数据,EEC采用pull方式由工具决定何时拉取下一块数据,返回空数组或null时表明没有更多数据,所以这里要注意控制分页参数防止出现死循环。

对于数据量巨大且使用关系型数据库的场景,EEC提供另一种方案,用户可以使用StatementSheetResultSetSheet两种方式,它们的工作方式是将SQL和参数交给EEC,EEC内部去查询并使用游标做到取一个值写一个值,省掉了将表数据转为Java实体的过程。

3. 读文件

easyexcel在读文件时使用ReadListener来监听每行数据,这样可以做到边解析文件边做业务逻辑(插库或其它),不用把文件解析完成后再做业务逻辑,以下是解析图示:

easyexcel解析图示

EEC采用迭代模式,同样做到边解析文件边做业务逻辑,解决POI的高内存问题。

EEC解析图示

从两者图示大致可以看出两者的设计与写文件时正好相反。easyexcel通过监听主动把行数据推给用户,EEC这边需要用户主动拉数据,只有当用户真正需要某行数据时才去解析它们来实现延迟读取。主动推数据给用户有一个问题是这个数据可能不是用户想要的,而这部分解析就是多余的。

3.1 easyexcel读文件

public void test3() {
    EasyExcel.read(defaultTestPath.resolve("Large easyexcel.xlsx").toFile(), LargeData.class,
    new AnalysisEventListener<LargeData>() {

        @Override
        public void invoke(LargeData data, AnalysisContext context) {
            // 业务处理
        }

        @Override
        public void doAfterAllAnalysed(AnalysisContext context) { }
    }).headRowNumber(1).sheet().doRead();
}

你需要实现一个ReaderListener来处理行数据。

3.2 EEC读文件

try (ExcelReader reader = ExcelReader.read(defaultTestPath.resolve("Large easyexcel.xlsx"))) {
    reader.sheet("帐单表") // 解析指定worksheet
        .flatMap(Sheet::dataRows) // 只取数据行,跳过表头
        .map(row -> row.to(LargeData.class)) // 转为实体对象
        .forEach(o -> {
            // 业务处理
        });
} catch (IOException e) {
    e.printStackTrace();
}

EEC引入java8的stream+lambda功能,你可以像操作集合类一样来操作Excel,而不用担心OOM发生。

3.3 读取多个worksheet页

两个工具都提供方便的多worksheet读取,可以看示例

easyexcel示例

public void test9() {
    ExcelReader excelReader = EasyExcel.read(defaultTestPath.resolve("test7.xlsx").toFile(), simpleListener).headRowNumber(0).build();
    List<ReadSheet> sheets = excelReader.excelExecutor().sheetList();
    sheets.forEach(sheet -> {
        System.out.println("----------" + sheet.getSheetName() + "-----------");
        excelReader.read(sheet);
    });
}

// 输出内容
----------帐单表-----------
{0=1.0, 1=100.8}
{0=2.0, 1=34.2}
{0=3.0, 1=983.0}
----------客户表-----------
{0=1001.0, 1=张三}
{0=1002.0, 1=李四}
----------用户客户关系表-----------
{0=1.0, 1=1001.0}
{0=2.0, 1=1002.0}
{0=3.0, 1=1002.0}

EEC示例

@Test public void test10() {
    try (ExcelReader reader = ExcelReader.read(defaultTestPath.resolve("test8.xlsx"))) {
        reader.sheets()
            .peek(sheet -> System.out.println("----------" + sheet.getName() + "-----------"))
            .flatMap(Sheet::rows)
            .forEach(System.out::println);
    } catch (IOException e) {
        e.printStackTrace();
    }
}

// 输出内容
----------帐单表-----------
id | total
1 | 100.8
2 | 34.2
3 | 983
----------客户表-----------
id | name
1001 | 张三
1002 | 李四
----------用户客户关系表-----------
ch_id | cu_id
1 | 1001
2 | 1002
3 | 1002

操作都还算方便,相较easyexcel来说EEC要更简单一点,如果不输出worksheet名那么一行命令就可以完成输出reader.sheets().flatMap(Sheet::rows).forEach(System.out::println);

4. EEC更多使用方式

由于EEC采用迭代模式因此可以使用JDK8的Stream全部功能,下面展示一些常用功能。

4.1 将内容转为集合

数据量小的时候可以将数据全部放入内存像下面这样

try (ExcelReader reader = ExcelReader.read(defaultTestPath.resolve("Large easyexcel.xlsx"))) {
    List<LargeData> list = reader.sheets().flatMap(Sheet::dataRows)
        .map(row -> row.to(LargeData.class)).collect(Collectors.toList());
    
    // 保存到数据库
    save(list);
} catch (IOException e) {
    e.printStackTrace();
}

当然我们谁也无法预料文件中有多少数据量,直接转为集合可能产生OOM,此时你可以通过sheet#getDimension方法先获取Worksheet的维度再按实际情况来选择执行方式,就像下面这样:

public void test11() {
    try (ExcelReader reader = ExcelReader.read(defaultTestPath.resolve("test8.xlsx"))) {
        Sheet firstSheet = reader.sheet(0);

        Dimension dimension = firstSheet.getDimension();
        // lastRow - firstRow = 数据行的行数,不包含header
        if (dimension.lastRow - dimension.firstRow > 1000) {
            // 如果数据量超过1千则选择流式处理,forEach里也可以收集一定量的实体再批量处理
            firstSheet.dataRows().map(row -> row.too(Check.class)).forEach(check -> {
                // TODO 业务处理
            });
        } else {
            // 数据量小于1千则直接转为集合处理
            List<Check> checks = firstSheet.dataRows().map(row -> row.to(Check.class)).collect(Collectors.toList());
            // TODO 业务处理
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

4.2 取单列数据

EEC提供与JDBC类似的接口,用户可以使用row.getX(columnNumber)获取指定位置的值,对于读非规则表格或非表格时这是非常有效的。

示例:获取”二年级学生.xlsx”中所有学生的姓名并去重

try (ExcelReader reader = ExcelReader.read(defaultTestPath.resolve("二年级学生.xlsx"))) {
    List<String> names = reader.sheet(0) // 只取第一个worksheet页
        .dataRows()
        .map(row -> row.getString("姓名")) // 只取姓名列
        .distinct() // 去重
        .collect(Collectors.toList());
    // 业务处理

} catch (IOException e) {
    e.printStackTrace();
}

4.3 过滤某些行

我相信很多时候都会遇到这样的需求,我们仅需要处理满足某些要求的数据而过滤掉检查失败的数据,这时候filter就派上用场了

比如我们需要打印帐单页金额大于100的记录

public void test9() {
    try (ExcelReader reader = ExcelReader.read(defaultTestPath.resolve("test8.xlsx"))) {
        reader.sheet("帐单表")
            .dataRows()
            .filter(row -> row.getDouble("total") > 100.0)
            .forEach(System.out::println);
    } catch (IOException e) {
        e.printStackTrace();
    }
}

// 输出结果
1 | 100.8
3 | 983.0

4.4 其它一些亮眼功能

EEC还有一些比较亮眼的功能如高亮,水印等一些实用的功能,下面代码展示如何将低于60分的学生标红且将分数显示为不及格

public void testStyleConversion() throws IOException {
    new Workbook("testStyleConversion") // 文件名
        .setCreator("奈留·智库") // 作者
        .setCompany("Copyright (c) 2020") // 公司名
        .setWaterMark(WaterMark.of("Secret")) // 水印
        .setAutoSize(true) // 自动计算列宽
        .addSheet(new ListSheet<>("期末成绩", Student.randomTestData(20)
            , new org.ttzero.excel.entity.Sheet.Column("学号", "id", int.class)
            , new org.ttzero.excel.entity.Sheet.Column("姓名", "name", String.class)
            , new org.ttzero.excel.entity.Sheet.Column("成绩", "score", int.class)
                // 低于60分显示`不及格`
                .setProcessor(n -> n < 60 ? "不及格" : n)
                // 低于60分单元格标红
                .setStyleProcessor((o, style, sst) -> {
                    if ((int)o < 60) {
                        style = Styles.clearFill(style) | sst.addFill(new Fill(Color.red));
                    }
                    return style;
                })
            )
        )
        .writeTo(defaultTestPath);
}

上面添加了作者、公司名以及水印,最终生成文件如下

在0.4.7版本中还开放了更多文件属性,比如title(标题),subject(主题),keywords(标记),category(类别),description(备注),version(版本)等信息

示例代码

@Test public void testSpecifyCore() throws IOException {
    Core core = new Core();
    core.setCreator("一名光荣的测试人员");
    core.setTitle("空白文件");
    core.setSubject("主题");
    core.setCategory("IT;木工");
    core.setDescription("为了艾尔");
    core.setKeywords("机枪兵;光头");
    core.setVersion("1.0");
    core.setRevision("1.2");
    core.setLastModifiedBy("TTT");
    
    new Workbook("Specify Core").setCore(core)
        .addSheet(new EmptySheet()).writeTo(defaultTestPath);
}

生成文件 更多属性

5. 后记

读excel时不要试图将数据转为Map类型,因为每个Map都需要保存表头和单元格值,这将极大的消耗内存。

简单总结: easyexcel和EEC两个工具都极大的简化了java操作excel,从原本Apache POI繁锁的API和高内存中解脱出来。其中easyexcel支持更低的JDK版本,而EEC使用了更灵活的设计模式,同时所有的底层代码均是独立实现,意味着它的依懒非常小。但是EEC现阶段鲜有人使用有很多BUG也就无从发现,稳定性还有待考验。

下一篇将对比两者在各数据量下的读写性能

Search

    Table of Contents