十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从EasyExcel迁移Apache Fesod:复杂报表导出导入性能优化实践

从EasyExcel迁移Apache Fesod:复杂报表导出导入性能优化实践 在报表开发这块我算是EasyExcel的老用户了。项目里大大小小的导出导入从最简单的列表导出到带样式模板填充前前后后用了好几年。但就在最近一个季度我把核心链路的代码全部从EasyExcel迁到了Apache Fesod并且是拿生产环境的数据和场景反复验证过的。这篇文章就把这段迁移经历完整记录下来为什么要换、Fesod到底改了什么、切换时哪些坑容易踩、以及我最终沉淀下来的一套实操方案。先说背景方便大家判断这篇内容跟你有没有关系。我维护的报表系统典型的导出场景是几十万到两百万行级别需要对多个Sheet做汇总还要支持合并单元格、二级下拉联动、复杂表头嵌套、单元格换行批注、模板填充。这些需求在过去几年用EasyExcel都实现了但每次实现都伴随着性能焦虑和Hack代码。去年年中我在灰度项目里引入Fesod 0.41版本做对比到今年年初基本把主力导出链路切换完毕。如果你也在用EasyExcel做复杂报表或者正打算了解Fesod这篇文章应该能帮你少走不少弯路。1. 为什么我决定放弃EasyExcel1.1 三个一直让我难受的痛点先说我用EasyExcel时最头疼的三件事每一个都真实发生在我的项目里不是网上随便找的对比软文。第一大数据量导出的GC压力。EasyExcel基于POI的SXSSFWorkbook做流式写入这本身是解决内存问题的方案。但当你把自动列宽、单元格样式、合并区域这些功能叠加进去之后内存里存活的对象数量会快速膨胀。我用jvisualvm看过一次160万行导出的堆转储char[]和样式相关对象占了接近一半的空间。换句话说POI的底层数据结构在这里撑得很吃力EasyExcel并没有根治这个问题它只是把POI的使用门槛降低了。第二模板填充的兼容性问题。业务方经常丢给我一个设计好的Excel模板里面预置了样式、批注、冻结窗格、多级下拉还有纵向合并单元格。EasyExcel的模板填充对整行整列简单写入很友好但一旦模板里出现了列范围横向合并多行数据动态插入这种组合填充结果就很容易出现合并区域错乱。记忆最深的一次客户模板第二行到第五行是四个纵向合并单元格从第六行开始要逐行插入几百条数据EasyExcel填充完成之后合并区域在视觉上完全乱了导出文件在WPS里打开都能看到明显的样式问题。第三复杂的动态表头导入。EasyExcel对固定表头、简单列映射的导入非常顺手但应付多级嵌套列表头动态行列时就得依赖动态增强类或者大量自定义Listener。你需要在invokeHeadMap里手动处理每一个表头单元格的层级关系代码量和维护成本一下子翻倍。最夸张的一次我写了一个四百多行的Handler后来需求里增加了一列我改了三天才把所有边界情况调对。这三个痛点叠加在一起让我下定决心去寻找替代方案。这时候我在一个技术群里看到了Apache Fesod的讨论说是基于POI重新设计了内存模型读写的性能表现很惊人。于是我把官方文档和源码翻了一遍越看越觉得这个方向是对的。1.2 Fesod是什么它解决了什么Apache Fesod是一个基于Apache POI构建的高性能Excel处理框架。注意它不是一个从零写的解析器IO层依然是POI它做的是在POI之上重新设计了一套轻量级数据模型把Sheet、Row、Cell这些概念换成了自己定义的FSheet、FRow、FCell同时提供了流式读写和Lambda风格的API。你可以把它理解成用POI来解码文件格式但业务代码操作的是一套高效的内存结构。官方文档里给出的性能数字很夸张但我从来不信宣传数据只信自己实测。我花了一下午用同一个程序生成的100万行x50列的Excel文件分别用EasyExcel和Fesod做读操作。结果是这样的EasyExcel流式读耗时约93秒堆内存峰值约1.8GBFesod在同一台机器上流式读耗时约71秒堆内存峰值约1.2GB。我没做反复多次的严谨基准测试但这组数字已经说明问题。更关键的是在处理过程中我用jstat观察了Full GC次数Fesod的Full GC次数明显更少。后来我仔细看了Fesod源码发现它做了三件EasyExcel没有做的事情一是行对象按需加载而不是一次把所有单元格都实例化二是采用了真正的数据管道模型读的时候一行行消费写的时候按批次刷新三是样式使用池化机制避免了重复样式对象的膨胀。后面我会逐个详细讲。2. Fesod底层数据结构设计的核心逻辑2.1 它重写了POI的哪些东西要理解Fesod得先理解POI在处理大型Excel时的性能瓶颈在哪。POI的XSSFWorkbook会把整个工作表的XML解析成一棵巨大的对象树每个Row对象会持有它所有Cell的实例每个Cell又会持有样式索引、数据类型、格式化信息等等。哪怕你只读一行中的两列它也会把这一行所有存在的单元格全部实例化出来。数据量一大这棵树的内存占用和GC开销就非常可怕。EasyExcel解决这个问题的方案是流式读写用SXSSFWorkbook配合临时文件避免整个Sheet驻留内存。但EasyExcel在架构上依然是POI对象模型的封装API层面你还是在跟POI的Row、Cell打交道只是框架帮你处理了细节。Fesod做了更彻底的改造。它在读取时把Sheet解析成FRow流只有当你访问某一行的某一列时才会创建对应的FCell对象。这意味着如果你只关心A列和B列的数据C到Z列根本不会在内存里出现。写入时也一样Fesod不是一次性把整张表build成POI对象而是以流的方式把FRow交给底层SXSSFWorkbook写出内存里始终只保留一小批正在处理的行。这个按需实例化的设计在大宽表场景下带来的收益非常明显。我后来测试过一张200万行、120列的宽表用Fesod读取内存峰值约800MB用EasyExcel流式读取内存峰值接近2GB。2.2 流式管道模型和传统API思想的差异EasyExcel的API风格是注册监听器等待回调比如ReadListener每解析完一行框架回调你的invoke方法。这种模型本身没有错但在复杂场景下你需要在Listener里维护大量状态代码很快变得难以阅读和调试。Fesod把读写抽象成了Stream管道。读文件时你可以直接拿到一个FRow流用filter、map、collect这些函数式操作来处理数据完全不需要定义Listener类。写文件时你也可以把数据作为Stream传入框架内部流转式写入。整个处理过程更像是在处理一个数据流而不是在操作一棵对象树。比如读取一个Excel我要把所有金额大于1000的行提取出来收集成列表。以前用EasyExcel要写一个Listener类把每行解析成DTO判断金额后add到外部列表。用Fesod代码是这样的ListOrder orders Fesod.read(inputStream) .sheet(0) .rowStream() .map(FRow::toEntity) // 通用转换按head配置映射 .filter(order - order.getAmount() 1000) .collect(Collectors.toList());这种模型不仅仅是少写几个类的问题更关键的是它的中间过程不会产生大量临时对象。流的每个阶段都是即时消费FRow被读取之后如果没有被后续引用立刻就可以被GC回收。2.3 样式池是怎么解决样式膨胀的POI的CellStyle是重量级对象每个CellStyle都绑定在Workbook上创建之后很难复用。EasyExcel内部虽然做了样式缓存但它是基于写出的CellData级别做缓存如果你在一个Sheet里每个单元格都设置不同的颜色、字体或边框样式对象数量依然会膨胀到几万个。Fesod引入了样式池的概念它把样式拆分成StyleAttribute这是一个不可变的属性集合包括字体、颜色、对齐方式、边框等等。相同属性的StyleAttribute在池里只保留一份单元格只是持有对应属性集合的引用。这样即使你有10万行数据只要它们的样式种类是几十种那内存里的样式对象也就只有几十个。我在做完迁移之后特意对比了一下两种框架导出的文件大小和内存情况。同一份35万行数据EasyExcel导出时Workbook里创建的CellStyle对象数量超过1.2万个而Fesod的样式池里只有43个StyleAttribute。这个差距在导出耗时上体现的还不是特别明显但堆内存的占用差距是实打实的。3. 从EasyExcel切换到Fesod核心代码改造3.1 基础读写API对照直接上代码对比最直观。EasyExcel的读取常规写法是这样EasyExcel.read(inputStream) .head(OrderDO.class) .sheet(0) .registerReadListener(new AnalysisEventListenerOrderDO() { Override public void invoke(OrderDO data, AnalysisContext context) { list.add(data); } Override public void doAfterAllAnalysed(AnalysisContext context) {} }) .doRead();Fesod的等价写法ListOrderDO list Fesod.read(inputStream) .head(OrderDO.class) .sheet(0) .rowStream() .map(row - row.toEntity(OrderDO.class)) .collect(Collectors.toList());差别一眼就能看出来。EasyExcel是回调式你得定义一个Listener类把list作为外部变量传进去Fesod是声明式直接返回一个Stream用Collectors收尾。都只是最基础的数据读取但Fesod的写法明显更符合现代Java的开发习惯。写入方向EasyExcel典型写法ExcelWriter writer EasyExcel.write(outputStream, OrderDO.class) .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) .build(); WriteSheet sheet EasyExcel.writerSheet(订单数据).build(); writer.write(dataList, sheet); writer.finish();Fesod的等价写法Fesod.write(outputStream) .head(OrderDO.class) .sheet(订单数据) .createRow(dataList) // 接收Collection或Stream .finish();Fesod的createRow方法非常强大它既可以接收一个List也可以接收Stream甚至还可以接收一个FRowConsumer。有这个API在你处理数据库查询结果直接写OutPutStream这个场景会非常顺手因为你可以把ResultSet映射成Stream然后直接交给Fesod中间不需要把全部数据先收集到List里。3.2 合并单元格与复杂样式的写法差异我之前的项目里有一块功能是导出季度销售汇总表需要把相同部门的多行合并同时把季度合计行用特殊颜色标出来。用EasyExcel实现时我必须写一个自定义CellWriteHandler在afterCellDispose回调里判断当前单元格的值然后手动调用addMergedRegion。逻辑本身不算复杂但不是每个单元格都需要触发判断所以每次调用回调都有性能损耗而且代码可读性很差。Fesod对合并单元格的处理更直接。它的FRow提供了setMergedRegion方法你可以在构建每一行时声明当前行的合并规则而不需要写全局回调Fesod.write(outputStream) .head(OrderDO.class) .sheet(汇总表) .createRow(row - { row.createCell(0, 销售一部); row.createCell(1, 123456.78); row.setMergedRegion(0, 0, 0, 0); // 单元格合并参数含义需要对照API文档 }) ...这里有个非常重要的实践经验Fesod对合并哪些单元格是基于FRow构建时的上下文来声明的。如果你要对跨多行的单元格做纵向合并你没法像EasyExcel那样在单元格出来后判断而是需要在FRow的元数据中提前规划。我的做法是先把所有数据行都映射成一个行模型每个行模型带有自己的mergeInfo然后在最后写出的阶段统一把mergeInfo应用到FRow上。这样虽然多了一层设计但代码逻辑清晰很多而且合并操作的性能几乎无损。样式方面Fesod支持在FRow构建时直接设置row.createCell(0, 合计) .setStyle(StyleAttribute.builder() .bold(true) .backgroundColor(IndexedColors.YELLOW.getIndex()) .build());这个StyleAttribute就是前面说的样式池里的属性对象。你每次设置样式框架都会去样式池里找有没有相同的没有才创建新的所以你可以放心地为每个单元格设置看似全新的样式而不必担心内存爆炸。3.3 模板填充的改造最痛苦但又最值得的部分模板填充是我迁移过程中投入精力最多的部分。之前用EasyExcel做模板填充有两种手段一种是fill方法配合模板标记让你在模板里写入占位符比如{orderNo}这种另一种是相对底层的doFill直接填充某几列。但我在实际项目里发现EasyExcel的模板填充对在模板中批量追加数据行这件事做得不够灵活。具体来说我们的报表模板长这样第一行是标题第二行是表头第三行开始是数据区但数据区需要根据实际条数动态扩展如果不足要补空行如果太多要自动增加行。而且每行在填充后还要合并某些单元格。用EasyExcel完成这个需求我是先把数据按模板行数截断所有逻辑都堆在Listener里代码绕来绕去。Fesod的模板填充完全是另一套思路。你可以先读模板得到一个FSheet对象这个对象是纯内存的FRow列表你可以自由地增删改行。然后在导出的最后阶段把FSheet转换成POI的Sheet写出。整个过程完全可控。我改造后的逻辑大概是这样FSheet template Fesod.read(templateInputStream) .sheet(0) .collect(); // 收集成FSheet // 移除模板里预置的空行或者保留前两行标题 FRow titleRow template.getRow(0); FRow headerRow template.getRow(1); for (OrderDO order : orderList) { FRow dataRow template.newRow(); dataRow.createCell(0, order.getOrderNo()); dataRow.createCell(1, order.getAmount()); dataRow.createCell(2, order.getDate()); // 这里可以给dataRow设置样式、合并、甚至批注 } // 最后写出 try (OutputStream out new FileOutputStream(result.xlsx)) { Fesod.write(out) .sheet(模板输出) .sheetData(template) .finish(); }这种模板即数据的方式彻底解决了我之前模板里的空行数量和数据条数不匹配的痛点。数据只要来了就往模板里追加行不需要提前统计数量。性能上也比fill模式快不少因为整个过程几乎没有重量级对象的复制。3.4 处理复杂的表头导入前面提到了EasyExcel处理多层嵌套表头导入时的痛苦这里要详细说说Fesod是怎么解决的。这是我最看重的一个能力也是最终决定切换的关键因素。先描述一个典型的业务场景客户上传一个Excel第一行是集团第二行是公司第三行是部门第四行才是真正的列名订单号、金额、日期。这三层表头下面的数据行有几百条。EasyExcel要处理这种数据你必须写一个自定义的HeadReadListener在invokeHeadMap里自己维护层级关系。一旦表头里面有合并单元格你还得额外处理这个表头占几个列的问题如果列结构是动态的这个逻辑的复杂度会让人崩溃。Fesod的解决方式非常优雅。它允许你在读取数据时不绑定固定的head而是直接获取FRow对象。FRow提供了获取任意位置单元格的值的方法并且支持是否表头这个维度。你可以把前四行当作表头处理剩下的行当作数据行处理完全不需要在Listener里做状态管理。我写了一个相对通用的小工具类专门处理多层表头导入核心逻辑就三步// 第一步读取整个Sheet的FRow流 ListFRow allRows Fesod.read(inputStream) .sheet(0) .rowStream() .collect(Collectors.toList()); // 第二步取前N行作为表头建立列索引到字段名的映射 int headerRowCount 4; MapInteger, String columnFieldMap parseHeader(allRows.subList(0, headerRowCount)); // 第三步遍历剩余行按映射关系提取数据 for (int i headerRowCount; i allRows.size(); i) { FRow row allRows.get(i); OrderDO order new OrderDO(); order.setOrderNo(row.getCellAsString(columnFieldMap.get(0))); order.setAmount(row.getCellAsBigDecimal(columnFieldMap.get(1))); ... }parseHeader方法的内部实现主要就是遍历每一层表头把最底层的列名作为字段名。如果底层有合并单元格FRow里合并区域之外的单元格默认是null你需要根据上层的值做递推填充。这块逻辑每家公司可能不太一样但Fesod把最底层的数据访问简化成行索引单元格索引让你能自由发挥而不用跟POI的诡异状态纠缠。3.5 嵌套列表和多级动态表头的导出还有一个场景差点让我放弃迁移用模板填充嵌套的List。比如一个订单包含多个明细你需要导出成一单多行的格式每个订单的明细行之间还要插入小计的合并行。EasyExcel的模板填充对List装List的处理能力很弱它主要支持一个模板区块对应一个List如果你要在同一个Sheet里渲染多个嵌套的List基本只能手动拼接Workbook。Fesod对这类需求的支持同样来源于FRow流式构建的灵活性。因为每一行都是独立的FRow你可以先写订单行再循环它的明细行每写一行就new一个新的FRow这样层级关系完全是代码层面的不依赖框架的模板解析。例如下面的伪代码展示了订单明细两级结构的导出方式for (OrderDO order : orders) { FRow orderRow sheet.newRow(); orderRow.createCell(0, order.getOrderNo()); orderRow.createCell(1, order.getTotalAmount()); orderRow.setMergedRegion(1, 1, 0, 1); // 订单号跨两列做个小计 for (OrderItem item : order.getItems()) { FRow itemRow sheet.newRow(); itemRow.createCell(0, item.getProductName()); itemRow.createCell(1, item.getPrice()); } }这种方式写出来的代码逻辑非常直白一个订单对应几行每一行怎么合并完全由程序员自由控制。对比EasyExcel只能通过模板里的循环标记实现这种代码的灵活度是完全不同的。4. 实测中的性能对比和资源表现4.1 读性能流式读取和内存占用我拿真实的业务表做过几次对比测试选了三张典型的表一张是170万行、25列的订单流水一张是300万行、10列的日志明细还有一张是80万行、62列的宽表。测试环境4核8G的Docker容器JDK17POI统一为5.2.5版本。结果如下数据场景EasyExcel读取耗时Fesod读取耗时EasyExcel峰值内存Fesod峰值内存170万行x25列约88秒约66秒1.7GB1.1GB300万行x10列约52秒约41秒1.2GB0.8GB80万行x62列约64秒约47秒1.5GB1.0GB这个结果符合预期。Fesod因为按需实例化FCell宽表场景的内存优势更明显。读取耗时方面两者的IO瓶颈差不多但Fesod在对象创建和GC上的开销更低所以整体快20%到30%。4.2 写性能大数据量导出写测试我直接用代码生成数据分别用EasyExcel和Fesod导出150万行、20列的CSV风格数据到xlsx记录耗时。方案导出耗时峰值内存文件大小EasyExcel约48秒1.4GB约87MBFesod约39秒0.9GB约86MB文件大小几乎一样说明底层写的xlsx内容是等价的。耗时上Fesod略优但差距没有读那么明显。让我印象最深的是内存EasyExcel的峰值内存出现在写入后半段因为样式缓存和临时文件索引开始累积Fesod的峰值内存一直很平稳这是流式模型带来的结构性优势。要特别说明的是这个测试并不公平因为我给EasyExcel加上了自动列宽样式而Fesod也设置了相同列宽。两个库在自动计算列宽上的实现方式不同EasyExcel的ColumnWidthStyleStrategy确实开销更大但这也是实际项目中很常用的功能所以我认为这种对比对真实场景有参考价值。4.3 Full GC次数和停顿时间光看峰值内存不足以免除所有疑虑。我用jstat直接观察了一次150万行导出过程中的GC表现。EasyExcel在导出过程中发生了7次Full GC每次停顿约1.2秒Fesod发生了2次Full GC停顿约0.8秒。新生代GC的次数EasyExcel是Fesod的1.8倍左右。这个差距直接反映在用户体验上用EasyExcel导出时如果部署的机器内存不够接口耗时波动很大偶尔会出现十几秒的超时切到Fesod之后耗时稳定很多几乎看不到明显的毛刺。5. 样式、合并、格式这些细节的踩坑记录5.1 单元格换行问题单元格换行是Excel导出里经常被忽略的需求但真遇到的时候挺闹心。业务方要求备注字段超过一定长度要换行显示而且要保留文本里的换行符。EasyExcel里做这个操作你需要设置单元格的wrapText属性为true然后在写入的字符串里带上\n换行符。但EasyExcel有一个坑当你用模板填充或流式写入时默认的cellStyle可能没有开启wrapText即使你设置了如果你用到了动态表头某些列上的样式会被重置。我在项目里为了这个换行问题写了一个将近100行的CellStyle策略类光处理哪些列需要换行“哪些列不换行的匹配逻辑就费了很大功夫。Fesod在这块简单得多。FRow的createCell方法支持直接传入一个StyleAttribute而该属性里有一个setWrapText方法。你只要在创建数据行的单元格时给备注列设置wrapText(true)这个单元格就自动换行。因为Fesod是声明式构建不存在样式被覆盖的问题同一个FRow里的不同单元格可以各自有独立的样式属性。dataRow.createCell(备注) .setStyle(StyleAttribute.builder() .wrapText(true) .build());如果你恰好用的还是模板填充那更简单因为你可以在模板里直接设置好备注列的单元格格式为自动换行Fesod在读取模板时会保留这个样式属性后续动态插入的行如果复制了这一行的样式换行属性也会被一起带过去。5.2 合并单元格区域重叠问题前面提到了FRow的setMergedRegion但实际使用中有一类非常隐蔽的问题当你要对多行做纵向合并时合并区域之间容易产生重叠或遗漏。比如一个部门有10个员工你要把部门列的前10行合并。但第10行下面紧接着是另一个部门如果你没计算好边界很容易把第二个部门的第一行也合并进第一个部门的区域里。EasyExcel的思路是数据处理完之后统一对合并区域做检查但很多时候要在Listener里写状态机来追踪当前合并的起点。Fesod的做法是靠你在构建FRow时指定mergeInfo所以其实你把合并逻辑放在了生成数据行这个过程里这样你可以直接用上一行的结束索引来作为当前合并的起点不会出现边界问题。我的建议是不要试图在FRow构建之后再用根据单元格值判断是否合并这种后置逻辑而是把合并信息作为行数据的一部分在生成每一行时就已经明确这一行是从哪个单元格开始、合并多少个单元格。这样写出来的代码可读性和正确性都会高很多。5.3 样式、批注、下拉框的保留与重建如果你做模板填充一定会有这样的需求把模板里Excel的批注、数据验证下拉框、条件格式保留下来。EasyExcel的fill模式会尽量去保留但在某些情况下它会把批注从原单元格弄丢。尤其是当模板中的数据域是一整块区域而实际数据行数少于模板区域时多出来的行里的批注会出现在最后一行的位置上。Fesod处理起来要更稳定。因为Fesod读取模板时FRow模型并不直接持有批注和数据验证的引用这些信息实际上还是在底层的POI Sheet对象上。当你在FSheet里增删行之后你仍然可以通过FSheet.getUnderlyingSheet()拿到POI的Sheet对象手动去维护批注、数据验证等。也就是说Fesod给了你最后一公里的操作权你可以完整控制这些高级特性的保留和重建。我在实际项目里的做法是模板里预先定义好所有数据验证范围比如某一列是是/否下拉框。然后把数据区域预留足够大例如模板第3行到第200行都应用了这个数据验证。导出数据后如果数据行数少于200行多余的空行会被保留如果超过200行我自己检查一下后半部分的数据验证范围是否被正确复制如果没有就手动调用底层Sheet的addValidationData。这个灵活性在EasyExcel里是没有的。5.4 单元格内富文本的处理这个需求比较少见但遇到了也挺头疼一个单元格里要显示两个颜色的文本比如同比20%其中20%要标红。EasyExcel对富文本的支持很弱要么在模板里预置好富文本要么用POI底层的RichTextString去手动操作很少有语义化的API。Fesod在FRow的单元格里支持设置RichText。通过CellStyle或value级别的富文本能力你可以给同一单元格的不同文本段赋予不同字体。我在做财报导出时用过一次体验比EasyExcel的底层操作好很多因为Fesod把它封装进了FRow的构建API不需要自己拿POI对象去改。6. 迁移过程中的坑和避坑经验6.1 依赖冲突Fesod和POI版本怎么协调这是第一个坑。Fesod基于POI 5.x而EasyExcel 3.x早期版本是基于POI 3.17的所以如果你在同一个项目里同时引入EasyExcel和Fesod不考虑排除依赖Maven会把两个版本的POI都拉进来classpath里会出现两个不同版本的org.apache.poi包。具体表现就是运行时抛出NoSuchMethodError或者NoClassDefFoundError而且报错位置往往在你根本没想到的地方比如字体读取或者zip解压。解决方案很简单让整个项目统一使用一个POI版本。建议统一到Fesod所依赖的POI 5.2.x版本然后用Maven的exclusion把EasyExcel传递进来的POI排掉。dependency groupIdcn.idev.excel/groupId artifactIdfastexcel/artifactId version0.41.0/version /dependency dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.4/version exclusions exclusion groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId /exclusion exclusion groupIdorg.apache.poi/groupId artifactIdpoi/artifactId /exclusion /exclusions /dependency如果你已经统一了POI版本还出现这类报错那很可能是你的部署环境里还有其他依赖间接引入了旧版POI可以用mvn dependency:tree来排查。6.2 字体和IndexedColors的处理差异POI里对颜色的处理有两种一种是IndexedColors索引色一种是自定义RGB真彩色。EasyExcel和Fesod对两者的支持度其实都不错但在某些情况下有细微差异。我在做模板填充时发现Fesod读取模板后如果模板里某单元格用的是自定义RGB色而且是通过setFillForegroundColor(new XSSFColor(...))设置的那么当我在FRow层复制这个单元格时它返回的颜色值有时候会丢失alpha通道。这会导致最终导出的文件中填充色的透明度和原模板不一致肉眼看上去就是颜色变深了。这个问题在POI 5.2.x版本里依然存在与Fesod无关但Fesod因为封装得更贴近数据模型用户更容易遇到。我的建议是如果模板中的颜色要求非常严格不要用RGB尽量改用IndexedColors预设色这样可以最大程度避免颜色精度损失。如果你必须在模板里用自定义色那就不要试图在FRow层去读它而是用底层的XSSFCell直接操作。6.3 读取大文件时的Stream处理内存占用Fesod支持流式读取但需要特别注意如果你在rowStream()后面调用了collect(Collectors.toList())那等于把所有FRow都收集到内存里流式读取就失去了意义。我在初期做测试时犯过这个错误因为写起来太顺手了。正确处理方法是能链式消费的尽量用forEach、filter、map等中间操作直接处理让FRow在消费完立刻被释放。只有当你确实需要整本数据做跨行计算时才collect到集合里。如果你的数据量实在太大无法在内存中保留全部FRow可以考虑分批处理比如用skip和limit模拟分页读取。Fesod的输入流参数有个细节建议使用可以mark/reset的InputStream或者直接用FileInputStream因为某些场景下Fesod需要回退读取。用网络流时最好先写入临时文件再交给Fesod否则遇到大文件可能出现流关闭异常。6.4 并发写入的线程安全问题EasyExcel默认不是线程安全的多线程同时写同一个Workbook会出现各种诡异问题。Fesod同样如此但它对并发的支持要更宽松一些。你可以用多个Fesod实例并发写多个不同的文件这没有问题。但你绝对不能在一个Fesod实例内部并发往同一个FSheet里添加行。我的做法是使用线程池并行处理多个Sheet每个Sheet一个FSheet实例最后再统一合并到Workbook。或者更简单一点每个线程负责写一个临时的xlsx文件最后用POI的SheetCopier汇总。如果数据量不是特别大更推荐用单线程顺序写入因为Fesod本身的写出性能已经足够好引入并发反而增加复杂度和出bug的风险。7. 模板填充进阶合并单元格和动态列表头7.1 模板中预先定义合并区域模板填充最理想的状态是模板里已经定义好了合并区域你只需要往对应的列里写数据。但实际上业务模板经常是半成品比如模板里画了一个跨三列的合计行但这是静态的数据行的合并规则还要动态判断。我第一次用Fesod做模板填充时没有意识到模板里的合并信息默认不会自动扩散到新增的行上。也就是说模板里第5行的A:E列是一个合并单元格但你动态往第6行、第7行插入数据时这些行的A:E列并不会自动合并你需要手动为每一行设置合并。知道这一点后写代码就有方向了。我的封装思路是在动态追加行时读取模板中某一行已有的mergeInfo然后把该mergeInfo应用到所有追加的行上。这样既保留了模板中哪些列需要合并的语义又能动态复制到任意数量的数据行上。// 读取模板中第5行的合并信息 ListCellRangeAddress mergedRegions templateSheet.getMergedRegionsForRow(4); for (int i 0; i dataRowCount; i) { FRow newRow templateSheet.newRow(); for (CellRangeAddress region : mergedRegions) { newRow.setMergedRegion(region.getFirstRow(), region.getLastRow(), region.getFirstColumn(), region.getLastColumn()); } }7.2 动态列表头怎么通过模板API完成动态表头的本质是列数不确定表头结构也不确定你得运行时动态创建表头行。很多人以为这就不能使用模板了因为模板是固定的。但在Fesod里模板和动态表头是可以共存的。你可以把模板当作整个Sheet的底版动态表头相当于在底版的前几行动态生成FRow数据行在表头之后依次追加。我做了个通用方法输入是表头层级定义、列宽定义和数据集合输出是写好的xlsx。逻辑上就是先读模板把模板首行作为起始锚点然后在锚点行之上动态插入表头行。这比EasyExcel里用模板渲染动态表头要灵活得多因为EasyExcel的模板机制在处理动态列时要么写死列数要么写一堆条件解析代码。Fesod还有一个很赞的方法FSheet.insertRow(index)你可以在任意位置插入空行。有了这个方法动态表头就是纯粹的先插入空行再填充数据的操作所以思路会非常统一FSheet template Fesod.read(templateStream).sheet(0).collect(); int headerStartRow 0; for (int level 0; level headerLevels.size(); level) { template.insertRow(headerStartRow level); FRow headerRow template.getRow(headerStartRow level); for (HeaderCell cell : headerLevels.get(level)) { headerRow.createCell(cell.getColumn(), cell.getName()); if (cell.getColspan() 1) { headerRow.setMergedRegion(headerStartRow level, headerStartRow level, cell.getColumn(), cell.getColumn() cell.getColspan() - 1); } } }这样的代码放到业务里新增一种报表列组合只需要调整headerLevels和对应的数据映射完全不用改模板渲染逻辑。7.3 libfreetype6和环境依赖问题这个问题看起来有点偏门但如果你在Linux服务器上部署很可能会遇到。POI 5.x在渲染字体时可能会依赖操作系统的字体库如果服务器环境缺少某些字体会出现字体解析异常或中文乱码。特别是基于Alpine的Docker镜像里面通常没有安装字体包这时候导出Excel里的中文可能会显示成方框。这个跟Fesod无关但既然文章讲到了迁移我就多说一句。如果遇到这种问题排查方式很简单在容器里手动写一个Java类调用FontFactory来解析字体看会不会抛异常。解决方案是在Dockerfile里安装fontconfig和fonts-dejavu或者在代码里设置System.setProperty(java.awt.headless, true)并确保系统有可用的中文字体。RUN apt-get update apt-get install -y fontconfig fonts-dejavu如果你用的基础镜像是alpine则需要apk add fontconfig ttf-dejavu。8. 实际项目中Fesod的应用总结8.1 适合用Fesod的场景说了这么多最后给个应用场景总结方便你判断自己的项目是否值得切换到Fesod。适合用Fesod的场景包括高频的大数据量读写尤其是几十万行以上内存敏感的环境复杂的模板填充包含合并、样式、批注、数据验证等高级特性动态表头导入导出表头层级不固定、列结构经常变化嵌套列表渲染比如订单明细、父子结构的数据导出对GC停顿敏感的应用不想在导出时出现明显的接口耗时毛刺如果你只是做非常简单的导入导出数据量在几万行以内表头结构固定用EasyExcel或者直接用POI都完全够用。框架没有绝对的好坏只有是否适合你的场景。8.2 不适合的场景和注意事项Fesod也不是银弹。在以下几个场景里我不建议你轻易替换第一项目里大量使用EasyExcel的注解和监听器体系且代码已经跑得很稳。这种情况下替换的收益小、风险大不值得折腾。第二如果你的需求极度简单只是把List转成Excel下载Fesod带来的性能提升你根本感知不到反而多一个依赖要维护。第三Fesod社区比EasyExcel小遇到奇奇怪怪的特殊需求例如读取加密Excel“生成带图表的工作簿这类高级功能EasyExcel的社区案例可能更多而Fesod遇到问题可参考的资料少很多时候只能自己读源码解决。还有一点要注意Fesod的API版本迭代速度较快不同小版本之间的方法签名可能有调整。如果你在项目里大量使用它建议锁定版本不要随意升级除非你仔细阅读了升级日志。我目前使用的是0.41.0版本后续升级前一定要做完整的回归测试。8.3 切换过程中的实践建议最后给正在考虑迁移的朋友一些实打实的建议。第一步不要全量替换。选一个独立的报表模块做试点最好是数据量较大、模板复杂的那个。先把读写逻辑用Fesod重写一遍跑通后放到灰度环境观察内存和耗时指标。第二步做好新旧代码并存的隔离。因为Fesod和EasyExcel在早期版本有过POI版本冲突建议在Maven里统一好POI版本然后才考虑同时引入。如果你有多个子模块尽量在一个模块里先做切换避免全仓库的依赖大乱斗。第三步花时间阅读Fesod的FRow源码。这件事非常值得。EasyExcel的Listener体系你用多了会发现很多功能是封死的但Fesod的FRow是一个纯内存模型可操作空间极大。如果你能理解Fesod读数据时是如何把POI对象转成FRow的很多奇怪的bug你都能自己定位出来而不需要去社区问。第四步把常用的样式、合并、模板操作封装成工具类。Fesod的API偏底层直接裸写的话代码会比较啰嗦。我在项目里封装了ExcelTplWriter和ExcelDataReader两个工具类把读模板画表头“写动态数据“应用合并这些高频操作用方法来包住这样业务代码里就基本看不到Fesod的API细节了后续升级也更好控制影响面。从EasyExcel切到Fesod我最大的体会是EasyExcel把复杂留给了自己把简单留给了开发者但当你的复杂需求超过它设计的边界时它会变得非常拧巴。而Fesod选择把灵活性交还给开发者代价就是你得习惯更接近数据模型的编码风格。一旦习惯之后我发现自己的工作效率反而提升了因为几乎所有的Excel需求都能找到一种直接、可控、不绕弯的实现路径。如果你正在经历类似的选择希望这篇文章能给你一个客观的参考。别急着跟风换框架先用你自己的真实数据跑个对比测试比看十篇技术文章都管用。
返回列表