从设计者的角度出发理解源码–Easy-Excel
引言
都说阅读源码是一个程序员升级的必经之路,笔者也阅读了不少源码,但很长时间感受源码带给我的提升没有预期的那么大,所以我有了个新点子,假如我能知道一个开源项目作者是如何思考的,是否能更接近这个目标了呢?于是有了此文。
本文以比较流行的Excel解析框架,Easy-excel为基础,我尽量试着推倒作者在每个环节的思考,以此方式来阅读源码,和解析源码的设计思想。
为什么要写Easy-excel
作者在写Easy-excel时,正是POI大行其道的时候,其令人诟病的特点就是难用,内存OOM,性能差等问题。所以笔者推测,作者应该当时正在做数据处理相关内容,且深受其害,于是,作者拍案而起,发誓要写一个,易用,高性能,内存使用率高的Excel处理框架,并且具备比较好的扩展性让使用者随意扩展能力。
贴一段使用POI解析Excel的代码,大家感受一下:
public void testReadUsersExcel() throws IOException {
// 指定excel文件,创建缓存输入流
BufferedInputStream inputStream = new BufferedInputStream(new FileInputStream("users.xlsx"));
// 直接传入输入流即可,此时excel就已经解析了
XSSFWorkbook workbook = new XSSFWorkbook(inputStream);
// 选择要处理的sheet名称
XSSFSheet sheet = workbook.getSheet("my-sheet");
// 第一行表头,单独处理,迭代遍历sheet剩余的每一行
for (int rowNum = 0; rowNum < sheet.getPhysicalNumberOfRows(); rowNum++) {
if (rowNum == 0) { // 读取第一行(表头)
XSSFRow head = sheet.getRow(rowNum);
String headColumn_1 = head.getCell(0).getStringCellValue();
String headColumn_2 = head.getCell(1).getStringCellValue();
String headColumn_3 = head.getCell(2).getStringCellValue();
String headColumn_4 = head.getCell(3).getStringCellValue();
String headStr = String.format("%s\t%s\t%s\t%s", headColumn_1, headColumn_2, headColumn_3, headColumn_4);
System.out.println(headStr);
} else { // 非表头(注意读取的时候要注意单元格内数据的格式,要使用正确的读取方法)
XSSFRow row = sheet.getRow(rowNum);
int id = (int) row.getCell(0).getNumericCellValue();
String name = row.getCell(1).getStringCellValue();
int age = (int) row.getCell(2).getNumericCellValue();
String addr = row.getCell(3).getStringCellValue();
String rowContent = String.format("%s\t%s\t%s\t%s", id, name, age, addr);
System.out.println(rowContent);
}
}
workbook.close();
inputStream.close();
}
概括一下作者写这个框架的可能原因如下:
-
POI太难用:功能虽然齐全,但是太难用了。
-
POI扩展性不行:POI的扩展性也不太行,程序员想要在特定的生命周期里加点业务代码,很费劲,需要做很多遍历。
-
POI参数配置麻烦:使用者在使用的时候,需要像Spring框架那样创建一个非常大的Config对象,或者配置文件,太不友好。
读流程设计
与原先的POI框架相比我需要解决三个问题
- API友好
- 内存使用率
- 业务扩展性
API友好
哎,POI最大的问题就是使用太麻烦了,想获取点数据不断for循环遍历!
有了:
-
构造流程,使用Builder设计模式,构造执行器对象。
-
业务流程,也通过Builder这种模式传入特定的扩展类,植入特定生命周期中,执行。
这是之前看到的ZK使用时候的API交互,非常友好,将默认参数的设置自动设置掉,程序员只需要关注必要的参数的设置。
我可以学习一下这种模式。
Builder设计模式的精髓,是将入参的填入与设置入参方法具体的业务含义分离开,让用户知道在做什么,但是不需要知道如何做的,做了哪些事,所以很多框架通常喜欢采用Builder设计模式,用户感知只调用了一次Build方法,却构造出了一个完整复杂的对象。
哎,使用起来还是不行,纯的builder设计好像不能从用户易用性角度给出使用者好的体验,因为参数太多了,他需要先从无数的参数中识别出那些他需要和不需要的,这该如何做?!
有了:
-
我可以将使用者对某个参数或者方法的的使用频次让其更接近使用者一点,换言之就是将不常用但是必不可少的参数设置放到抽象类,将常用的参数设置放到子类。
-
我可以多抽象几层,一个通用的抽象类,一个读流程抽象类,一个写流程抽象类,然后再往下读流程抽象类可以更具使用场景再实现几个具体子类,这些具体子类就是直接面向使用者的API。
-
然后使用一个工厂类,将所有的Builder子类封装为工厂方法统一提供API服务。
如此一来,程序员最经常使用的API和入参均是对他们最友好的,一些不常用的参数和能力隐藏在最里面的抽象类。
//最顶层Builder的抽象类
com.alibaba.excel.metadata.AbstractParameterBuilder
//writeBuilder抽象类,如下方法分别是:注册Handler方法
com.alibaba.excel.write.builder.AbstractExcelWriterParameterBuilder
com.alibaba.excel.write.builder.AbstractExcelWriterParameterBuilder#registerWriteHandler
//readBuilder抽象类,如下俩个方法分别是:注册Listener实现类
com.alibaba.excel.read.builder.AbstractExcelReaderParameterBuilder
com.alibaba.excel.read.builder.AbstractExcelReaderParameterBuilder#registerReadListener
// ReaderBuilder,如下的方法名称我不说你们应该也应该能能看得出来了
com.alibaba.excel.read.builder.ExcelReaderBuilder
com.alibaba.excel.read.builder.ExcelReaderBuilder#sheet()
com.alibaba.excel.read.builder.ExcelReaderBuilder#doReadAll
// WriteBuilder,如下的方法名称我不说你们也应该能看得出来了
com.alibaba.excel.write.builder.ExcelWriterSheetBuilder
com.alibaba.excel.write.builder.ExcelWriterSheetBuilder#sheetNo
com.alibaba.excel.write.builder.ExcelWriterSheetBuilder#doWrite(java.util.Collection<?>)
// 最后使用EasyExcelFactory,将如上Builder做统一入口的封装
com.alibaba.excel.EasyExcelFactory
com.alibaba.excel.EasyExcelFactory#write(java.io.File)
com.alibaba.excel.EasyExcelFactory#read(java.io.File)
这样一来,越靠近使用者的API是他们最经常用的,我在命名和使用场景在这方面多下点功夫
最终API如下:
// 读流程
List<Map<Integer, Object>> list = EasyExcel.read(TestFileUtil.getPath() + "compatibility/t01.xls").sheet()
.doReadSync();
// 写流程
EasyExcel.write().file(file).head(AnnotationData.class).sheet().doWrite(dataStyle());
UML图如下:
内存使用率
POI框架经常被人诟病的是JVM内存溢出,以及内存使用率低
其实POI有俩种模式:usermodel,eventusermodel,默认使用的是usermodel,usermodel使用起来简单一些,但是它是一次性奖内容读取到JVM中,导致我们经常遇到的问题,而后者eventusermodel则是基于事件模式,一次读取一条数据,但是API复杂,但是它处理速度快,占用内存少。
有了:
-
我可以基于POI的eventusermodel模式,来对它进行封装抽象对使用者提供友好的API,这样内存OOM问题解决了。
-
使用Listener模式,读取具体数据时,触发Listener回调,这样内存里,只有当前使用的这条数据相关对象,其他对象都可以让JVM先回收掉。
// 如下是POI框架的eventusermodel写法
public void execute() {
XlsReadWorkbookHolder xlsReadWorkbookHolder = xlsReadContext.xlsReadWorkbookHolder();
MissingRecordAwareHSSFListener listener = new MissingRecordAwareHSSFListener(this);
xlsReadWorkbookHolder.setFormatTrackingHSSFListener(new FormatTrackingHSSFListener(listener));
EventWorkbookBuilder.SheetRecordCollectingListener workbookBuildingListener =
new EventWorkbookBuilder.SheetRecordCollectingListener(
xlsReadWorkbookHolder.getFormatTrackingHSSFListener());
xlsReadWorkbookHolder.setHssfWorkbook(workbookBuildingListener.getStubHSSFWorkbook());
HSSFEventFactory factory = new HSSFEventFactory();
HSSFRequest request = new HSSFRequest();
request.addListenerForAllRecords(xlsReadWorkbookHolder.getFormatTrackingHSSFListener());
try {
factory.processWorkbookEvents(request, xlsReadWorkbookHolder.getPoifsFileSystem());
} catch (IOException e) {
throw new ExcelAnalysisException(e);
}
}
哎,另一个读取性能问题,是由于反复创建临时对象,GC过于频繁导致的,这应该如何优化呢?
有了:
-
我可以使用一个Context对象,这个作为上下文,维护,管理整个执行流程中的可复用对象。
-
特殊业务中的复用象可以使用Map数据结构预加载。
-
POI是完全对Excel做的模型映射,有很多格式化的属性,在读取场景下是不需要的,我可以基于Excel设计一套精简的Bean,只包含数据,省去样式属性
// xlsReadContext 是流程执行上下文,存储了流程执行中所有公共的复用对象。
// XLS_RECORD_HANDLER_MAP 是一个业务工具的容器Map,采用预加载的方式加载。
public class XlsSaxAnalyser implements HSSFListener, ExcelReadExecutor {
private final XlsReadContext xlsReadContext;
private static final Map<Short, XlsRecordHandler> XLS_RECORD_HANDLER_MAP = new HashMap<Short, XlsRecordHandler>(32);
}
自定义的ReadSheet,ReadWorkbook,ReadSheet类
// Read sheet, sheet类
public class ReadSheet {
private Integer sheetNo;
private String sheetName;
}
// 类似的还有如下class
com.alibaba.excel.read.metadata.ReadWorkbook
com.alibaba.excel.read.metadata.ReadSheet
哎,执行读取写入逻辑需要大量判重key的逻辑,Map结构在数据量大的时候寻址速度好像下降比较厉害!
有了:
- 我可以现将Map的key转化为Set,需要判断Key是否存在,也基本使用Set,然后再进行接下来的业务
// 由于Map Key方法的包含性能较差,所以在这里创建一个keySet
Set<String> beanKeySet = new HashSet<>(beanMap.keySet());
// 防止重复工作
private Set<Integer> hasReadSheet;
业务扩展性
读数据和遍历语法树类似,都是访问某一块固定数据,我记得语法树遍历一般是用Vistor或者Listener设计模式,其中Listener无需设定返回值,灵活性更强一些,那我这边也用Listener设计模式来增加扩展性吧,这些用户实现的Listener,只要实现不同的生命周期接口,即可实现在特定实际触发特定类型的Listener实现类。
Listener的来源分为俩部分
- 用户入参传入:一部分是由入参将实现类传入(使用者)
- 框架默认的Listener:比如ModelBuildEventListener(框架内)
最终的所有的Listener类统一扭转到Holder的readListenerList属性中,事件的触发统一入口为:DefaultAnalysisEventProcessor,不同的生命周期触发不同函数
// 结束解析Sheet生命周期回调
com.alibaba.excel.read.processor.DefaultAnalysisEventProcessor#endSheet
// 触发所有readListener 的extra事件
com.alibaba.excel.read.processor.DefaultAnalysisEventProcessor#dealExtra
如下就是实现之后的API使用
EasyExcelFactory.read(file).registerReadListener(new ListHeadDataListener()).sheet().doRead();
哎,好像写法上还是差了一些,使用者每次都需要new 一个ListHeadDataListener.java文件,体验不太行!如果直接让程序员传入一个Lambda表达式就能够描述行为那就好了!
有了:
- 桥接设计模式+Consumer接口
实现一个特殊的PageReadListener子类,该子类的其中一个属性是Consumer。
public class PageReadListener<T> implements ReadListener<T> {
// 消费者
private final Consumer<List<T>> consumer;
public PageReadListener(Consumer<List<T>> consumer) {
this(consumer, BATCH_COUNT);
}
如此一来,使用者就可以用以下方式写业务代码了:
EasyExcelFactory.read(file07, CacheData.class, new PageReadListener<DemoData>(dataList -> {
Assertions.assertNotNull(fieldThreadLocal.get());
})).sheet().doRead();
写流程设计
对于写Excel流程,POI性能还好,就是API使用比较不友好,对于写方面,我的重心主要放在
- 业务扩展性
- API的友好
这俩个方面就行了,API的设计思路,可以基于上面读取的Fluent思路,但是略有区别,写的流程与读不一样,要做的事也不一样,读流程只要单纯访问数据就可以了,但写流程,需要写格式,合并单元格,Excel样式编辑,表头设置,数据填充,等等,所以写流程这个动作扩展性需要考虑的点会更多。
业务扩展性
哎,怎么将写的业务的各个节点设计成方便拓展呢?!
有了:
-
使用责任链设计模式,将业务Handler实现类(扩展点),依据不同的类型组装成不同的责任链,只需要在特定实际触发特定的责任链即可。
-
如果有新业务想增加新的生命周期时机,只需添加新的责任链即可。
-
使用统一的触发工具类,维护统一触发入口和触发执行逻辑。
Handler是用来做数据处理的具体的实现类,数据处理工作和只是遍历数据的Listenner还是有区别的,前面Hander处理过后的结会直接对下面的Handler处理的数据产生影响。
所以它和Listener模式最大的区别是:直接影响原始数据,并且它对于生命周期的扩展性需要要高于Listenner模式。
它的来源也由俩部分组成
- 用户入参传入:使用者传入入参。
- 框架默认的Handler:框架内部会有一些自生扩展实现,比如合并单元格,比如默认样式等等。
同时在数据结构的处理上也不同,与Listener只会单纯的用一个List维护不同,Handler会依据不同的业务类型构造出不同类型的责任链,一个责任链对应于一个流程生命周期的时机,通过触发不同的责任链,实现触发不同时机的拓展,同时也方便生命周期的拓展。
如下不同生命周期的责任链:
// 如下是各个生命周期的责任链
public WorkbookHandlerExecutionChain ownWorkbookHandlerExecutionChain;
public SheetHandlerExecutionChain ownSheetHandlerExecutionChain;
public WorkbookHandlerExecutionChain workbookHandlerExecutionChain;
public SheetHandlerExecutionChain sheetHandlerExecutionChain;
public RowHandlerExecutionChain rowHandlerExecutionChain;
public CellHandlerExecutionChain cellHandlerExecutionChain;
统一的触发入口:
com.alibaba.excel.util.WriteHandlerUtils
// 创建Workbook之前
com.alibaba.excel.util.WriteHandlerUtils#beforeWorkbookCreate(com.alibaba.excel.write.handler.context.WorkbookWriteHandlerContext)
// 创建Workbook之后
com.alibaba.excel.util.WriteHandlerUtils#afterWorkbookCreate(com.alibaba.excel.write.handler.context.WorkbookWriteHandlerContext)
// 创建单元格之前
com.alibaba.excel.util.WriteHandlerUtils#beforeCellCreate
// 创建单元格之后
com.alibaba.excel.util.WriteHandlerUtils#afterCellCreate
使用样例如下:
// 执行写入代码
private void workbookWrite(File file) {
WriteHandler writeHandler = new WriteHandler();
EasyExcel.write(file).head(WriteHandlerData.class).registerWriteHandler(writeHandler).sheet().doWrite(data());
writeHandler.afterAll();
}
多种Excel格式解析设计
哎,要读写的版本太多了,Excel版本有03,07版,还有CSV文件,不管在读还是写,都需要做特定的逻辑区分,以及为以后留足扩展空间,该如何做?
有了:使用桥接模式,但需要抽象俩层
-
解析逻辑所需要事情。
-
解析文件所需要做的事情。
这俩者看起来相似,实则不同,第一个是解析器要做的事,第二个是解析一个文件要做的事。
// 如下分别对应如上俩个逻辑
com.alibaba.excel.analysis.ExcelAnalyser
com.alibaba.excel.analysis.ExcelReadExecutor
/**
* Excel file Executor
* excel 文件执行器
* @author Jiaju Zhuang
*/
public interface ExcelReadExecutor {
List<ReadSheet> sheetList();
void execute();
}
// 03,07,CSV版本分别实现自己的实现类
com.alibaba.excel.analysis.v03.XlsSaxAnalyser
com.alibaba.excel.analysis.v07.XlsxSaxAnalyser
com.alibaba.excel.analysis.csv.CsvExcelReadExecutor
使用桥接思想,使文件执行器实现类织入ExcelAnalyser执行器
/**
* @author jipengfei
*/
public class ExcelAnalyserImpl implements ExcelAnalyser {
private static final Logger LOGGER = LoggerFactory.getLogger(ExcelAnalyserImpl.class);
private AnalysisContext analysisContext;
private ExcelReadExecutor excelReadExecutor;;
}
数据类型转换设计
哎,原先的PIO的JAVA类型太固定了,如果Excel表希望与JavaBean能一一对应起来,不管是写还是读,都以JavaBean的形式返回给程序员就好了!
有了:
- 我只需要给JavaBean上的具体属性通过注解打上标记。
- 在加载Bean的时机,使用注解来作为渲染参数寻找特定的渲染转化器,完成转化逻辑。
// 类型转化器抽象接口
com.alibaba.excel.converters.Converter
// 转化器实现子类
com.alibaba.excel.converters.bigdecimal.BigDecimalBooleanConverter
com.alibaba.excel.converters.bigdecimal.BigDecimalNumberConverter
com.alibaba.excel.converters.bigdecimal.BigDecimalStringConverter
com.alibaba.excel.converters.biginteger.BigIntegerBooleanConverter
com.alibaba.excel.converters.biginteger.BigIntegerNumberConverter
// 转化器加载器,将所有的Converter,为了提升性能,在类加载阶段全部加载到内存Map
com.alibaba.excel.converters.DefaultConverterLoader
// 转化器工具包(封装转化逻辑)
com.alibaba.excel.util.ConverterUtils
// 基础注解
com.alibaba.excel.metadata.property.ExcelContentProperty
com.alibaba.excel.annotation.ExcelProperty
// 如下为注解的关键属性
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface ExcelProperty {
... 省略其他属性
// 返回转换器,定义一个默认值为 AutoConverter.class 的类作为注解的属性,用于强制当前字段使用这个转换器
Class<? extends Converter<?>> converter() default AutoConverter.class;
...
}
UML图如下
在这里插入图片描述
资源操作设计
哎,我的输入源读取源,可能来自不同的渠道,有可能是文件,可能是网络资源,这些资源如果没有办法统一入口的话,我在内部逻辑封装会打折扣的,怎么办呢?
有了:
- 设计一个套接口命名为Holder,所有的资源读写,获取资源状态等操作都基于这个接口。
- 根据不同的操作需求将Holder派生出各种子类,WriteHolder,ReadHolder,这些子类根据对资源不同的使用场景场景按需实例化。
- 所有Holder是同一个统一配置资源入口,即用户在初始化时候只需要初始化该Holder的实例,根据不同的场景使用它作为构造函数参数去初始化其他类型的Holder。
如此一来,对资源的读写和获取资源状态等属性,在我的代码中,可以统一使用接口来编写统一逻辑,也方便后续资源类型的拓展。
// 这三个是最底层的Holder抽象类
com.alibaba.excel.metadata.Holder
com.alibaba.excel.metadata.ConfigurationHolder
com.alibaba.excel.metadata.AbstractHolder
//如下俩个是继承AbstractHolder的读写操作Holder
com.alibaba.excel.write.metadata.holder.AbstractWriteHolder
com.alibaba.excel.read.metadata.holder.AbstractReadHolder
// 如下是是继承AbstractReadHolder的不同使用场景的读Holder(写Holder类似省略)
com.alibaba.excel.read.metadata.holder.ReadSheetHolder
com.alibaba.excel.read.metadata.holder.ReadWorkbookHolder
// 如下是继承ReadSheetHolder,ReadWorkbookHolder,各种不同类型文件的Holder的具体实现
com.alibaba.excel.read.metadata.holder.csv.CsvReadSheetHolder
com.alibaba.excel.read.metadata.holder.xls.XlsReadSheetHolder
com.alibaba.excel.read.metadata.holder.xlsx.XlsxReadSheetHolder
com.alibaba.excel.read.metadata.holder.csv.CsvReadWorkbookHolder
com.alibaba.excel.read.metadata.holder.xls.XlsReadWorkbookHolder
com.alibaba.excel.read.metadata.holder.xlsx.XlsxReadWorkbookHolder
UML图如下:
生命周期扩展设计
哎,代码用了这么多的设计,拆的太散了,这么散的代码,如何对特定的时机,进行统一生命周期的触发呢?!
有了:
-
所有的资源访问,都经过一层Holder(资源包裹对象) ,这样可以在资源操作(读,写)细节的前后进行一些特殊前置和后置的操作。
-
我可以将所有的需要回调的对象,维护在之前约定的全局上下文(Context)中,在任意地方的代码想要获取到这些Listener,Handler,都可以方便获取。
-
这些触发Listener,Handler的逻辑可以统一维护成一个 **流程执行器,实现这部分代码的复用,因为还涉及到异常处理,责任链节点后移等等操作。
其实,流程执行器+逻辑拓展实现(Handler+Listener)= 模板方法设计模式,只不过将传统模板方法设计模式中的通用业务逻辑,使用流程执行器来维护了而已,扩展灵活性更强一些。
// 上下文抽象接口
com.alibaba.excel.context.AnalysisContext
// 上下文实现类
com.alibaba.excel.context.AnalysisContextImpl
// holder为资源访问器
com.alibaba.excel.metadata.ConfigurationHolder
com.alibaba.excel.read.metadata.holder.ReadWorkbookHolder
com.alibaba.excel.read.metadata.holder.ReadSheetHolder
com.alibaba.excel.read.metadata.holder.ReadRowHolder
com.alibaba.excel.read.metadata.holder.ReadHolder
//生命周期流程执行器
com.alibaba.excel.read.processor.AnalysisEventProcessor
UML图如下:
关于框架命名
哎:这个名字EasyExcelFactory,好像怪怪的,对使用者不太友好,这么怪的名字也不利于框架的传播,但是这个类的功能确实是工厂,直接改名也不太好!
有了:
- 实现一下子类叫EasyExcel,继承EasyExcelFactory
// @author jipengfei
public class EasyExcel extends EasyExcelFactory {}
结语
不知不觉讲到现在,也确实收获了一些跟之前不一样的认知,看来之前的猜测是对的。
忘记了在某个地方看到的一句话:现在的框架都是在使用者对现状不满意的情况下,穷尽自己的所学提出了自己的解决方案
Redis,Nginx,Kafka,Hbase,无一不是如此,其实就是先发现了问题,然后再尝试去解决问题,解决的好,解决的优雅,在行业内传开,于是就变成了行业主流方案。所以,如果我们能够把作者经历的问题,都经历一遍,可能确实是一个比较好的感受框架的创造思路的过程。