点击这里查看原文
本系列主要介绍Easyexcel和EEC的功能并从便利性、性能、内存等多方面全面的进行评测。
1. 开始
关于Easyexcel
easyexcel是alibaba开发的快速、简单、且避免OOM的java处理Excel工具,于2018.2在github上开源。它是在Apache POI基础上包装而来,主要解决Apache POI高内存且API臃肿的诟病,easyexcel提供了比原生的POI简结很多的接口,读写Excel文件均可以一行代码完成,目前(2020.4)github上有14.2k个Star和3.7K个Fork。
引用作者总结核心原理:
- 文件解压、读取通过文件形式
- 避免将全部数据一次加载到内存(采用sax模式一行一行解析并使用观察者的模式通知处理)
- 抛弃不重要的数据(忽略样式,字体,宽度等数据)
点击这里查看作者原文
关于EEC
EEC是国内一个个人开发者开发并于2017.10月在github开源,EEC的底层并没有使用Apache POI包,所有的底层读写代码均由作者实现,事实上EEC仅依懒dom4j和slf4j,前者用于小文件xml读取,后者统一日志接口。
核心原理:
- 不缓存数据或少量缓存
- 使用分片来处理较大的数据
- 单元格样式仅使用一个int值来保存,极大缩小内存使用
- 使用迭代模式读取行内容,不会将整个文件读入到内存
简单总结两个工具的不同:
- 底层不同,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);
}
EEC同样可以写入OutputStream
或磁盘,使用writeTo
方法指定输出位置
public void test6(List<Item> data) throws IOException {
new Workbook("test6").addSheet(new ListSheet<>(data)).writeTo(defaultTestPath);
}
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();
}
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);
}
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提供另一种方案,用户可以使用StatementSheet
和ResultSetSheet
两种方式,它们的工作方式是将SQL和参数交给EEC,EEC内部去查询并使用游标做到取一个值写一个值,省掉了将表数据转为Java实体的过程。
3. 读文件
easyexcel在读文件时使用ReadListener
来监听每行数据,这样可以做到边解析文件边做业务逻辑(插库或其它),不用把文件解析完成后再做业务逻辑,以下是解析图示:
EEC采用迭代模式,同样做到边解析文件边做业务逻辑,解决POI的高内存问题。
从两者图示大致可以看出两者的设计与写文件时正好相反。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示例
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);
}
最终生成文件如下
5. 后记
读excel时不要试图将数据转为Map类型,因为每个Map都需要保存表头和单元格值,这将极大的消耗内存。
简单总结: easyexcel和EEC两个工具都极大的简化了java操作excel,从原本Apache POI繁锁的API和高内存中解脱出来。其中easyexcel支持更低的JDK版本,而EEC使用了更灵活的设计模式,同时所有的底层代码均是独立实现,意味着它的依懒非常小。但是EEC现阶段鲜有人使用有很多BUG也就无从发现,稳定性还有待考验。