ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

从EasyExcel到Apache Fesod:复杂表头与大数据量导入导出的技术选型迁移实践

从EasyExcel到Apache Fesod:复杂表头与大数据量导入导出的技术选型迁移实践 接手这个项目之前我对 EasyExcel 的信任度一直很高。API 简洁、文档汉化友好、团队内部用得也熟怎么说都是个稳妥选择。直到我接了一个复杂表头导入的真实需求才意识到一个问题的严重性EasyExcel 可以让你“方便地读 Excel”但当你需要真正掌控 Excel 结构时它的抽象反而变成了束缚。我重新做了一轮技术选型最后把核心的解析和导出组件从 EasyExcel 换成了 Apache Fesod。先说结论如果你只是处理简单二维表EasyExcel 完全够用不用折腾但如果你像我一样要面对多级表头、动态列、超大文件、模板化导出这些硬骨头那这篇文章值得你花十分钟读完。我会把替换的动机、两个框架的底层差异、迁移实操和踩过的坑全部拆开讲清楚。1. 先说结论我为什么会对 EasyExcel 说再见1.1 EasyExcel 用着不差但有些地方越来越别扭EasyExcel 是阿里开源的项目最大的卖点是降低使用门槛。注解 一行监听器就能完成读取这在 90% 的简单场景里确实好用。我最早接手公司报表系统的时候第一个 Excel 导出功能就是用 EasyExcel 写的当时的感觉是“这也太顺手了”。但顺着用下去问题慢慢浮现。首先是复杂表头的支持。EasyExcel 提供了HeadRowHeight、ColumnWidth这类注解也支持通过HeadGenerator动态生成表头。但一旦表头出现嵌套层级、多行合并、动态追加列这些需求注解方案就变得非常笨重。你不得不手动构造ListListString这种二级集合来描述表头结构代码可读性急剧下降而且调试起来特别痛苦。其次是合并单元格和单元格样式控制。EasyExcel 的合并策略需要自己实现AbstractMergeStrategy每次合并都要根据行号、列号小心翼翼地计算区域。项目里一旦有“复杂表头导入”意味着不只是展示复杂导入时还要反向识别这些结构。EasyExcel 在这块的能力非常薄弱它默认把 Excel 拉平成一个二维表合并单元格的信息在解析时基本是丢失的。再一个让我越来越不踏实的是社区维护的节奏。EasyExcel 的迭代频率并不算高很多在 GitHub Issue 里讨论了很久的问题比如动态列、模板填充时的样式丢失修复进度一直很慢。对于一个要长期演进、还要交到客户现场跑的组件这种不确定性是很致命的。1.2 从性能角度看 EasyExcel 的隐藏成本EasyExcel 经常被拿来标榜的卖点是“低内存”因为它底层走的是 SAX 模式解析不会像 POI 的XSSFWorkbook那样一次性把整个工作簿加载进内存。这个说法本身没有错但实际应用中它隐藏了几个成本。第一个是反射映射的开销。EasyExcel 把 Excel 行数据映射到 Java 对象时依赖注解和反射。字段越多、数据量越大这部分开销就越明显。我之前做过一个压测50 万行、每行 60 个字段的导入场景光是对象映射就占了整体 CPU 时间的接近四成。如果你为了性能改用MapInteger, String接收又绕过了 EasyExcel 最方便的模型映射机制等于自废武功。第二个是事件模型的粒度太粗。EasyExcel 是基于 POI 的 SAX 事件做封装但它对外暴露的还是“一行一回调”的模型。也就是说无论你是否需要整行数据都会被解析成一个完整对象后你才看到它。这在某些只需要读取特定列的场景下是明显浪费。第三个是模板填充和写盘时的临时对象开销。EasyExcel 的write操作在底层需要维护大量临时对象来记录列宽、行高、字体样式这些元信息数据量一旦上去GC 的压力会快速上升。多个压力测试跑下来我的感受是EasyExcel 在中小数据量下体验良好但在“超大 excel 文件 复杂结构 高频导入导出”的组合下性能曲线并不像宣传的那样平稳。1.3 我寻找替代方案时的三个硬性标准既然决定换我给自己定了三条选型标准任何一条不满足都直接淘汰。第一必须是真正的流式处理内存可预测。我不接受一个框架只在文档里说“支持流式”实际用起来却因为解析整表而内存暴涨。内存必须可控最好能做到无论文件多大JVM 堆内存都维持在一个平稳水位。第二复杂表头和动态列必须是一等公民而不是通过扩展策略硬凑。我希望框架本身就能识别合并单元格、多级表头、动态列这些结构给我足够的事件或模型去处理而不是让我自己写一堆补丁代码。第三要有独立的社区和清晰的路线图。我不想再把核心 IO 依赖押在一个个人维护者或者公司意志主导的项目上。开源组件一旦停滞后续所有依赖它的业务都要被迫买单。Apache 生态的项目通常有更规范的社区运作这对我做长期技术决策很重要。在这三条标准下Apache Fesod 进入了我的视野。它最初吸引我的一点是它没有把自己定义为“EasyExcel 的替代品”而是定位成“面向复杂表格处理的高性能组件”。这个定位决定了很多设计上的取舍跟我前面的诉求是对齐的。2. Apache Fesod 到底是什么和 EasyExcel 不是同一个物种2.1 项目定位与设计哲学不是 Excel 的“读写器”而是表格事件流Apache Fesod 是 Apache 生态中专注于电子表格文档处理的开源组件。网上讨论它的资料不少但对它的理解大多停留在“又一个 Excel 库”的层面这其实是把它看小了。它和 EasyExcel 最本质的差别在于设计哲学EasyExcel 把 Excel 当作“一张表”给你提供表的读写接口Fesod 则把 Excel 当作“一组结构化事件流”在底层用流式解析器逐段处理文档内容再通过事件回调或模型映射暴露给上层应用。这个区别怎么理解呢打个比方。EasyExcel 像一间帮你整理好一切的前台秘书你说“我要数据”她递给你一份整理好的二维表Fesod 则像一条流水线传送带把每个单元格送过来你有权限决定看哪个、忽略哪个、把哪几个装进自己的模型。前者省心但你对流水线上发生了什么几乎没有感知后者多写一点代码但你能够完全掌控整个处理过程。正因为这个定位Fesod 在复杂表头、合并单元格、动态列这些场景下的表现会明显优于 EasyExcel。这些东西在 EasyExcel 里是“异常情况”需要额外策略去兜在 Fesod 里则是“正常事件”你的处理方法会变得非常自然。可能有同学要问Fesod 的 API 上手门槛是不是更高说实话单纯读写一张简单表EasyExcel 可能一行代码搞定Fesod 需要你理解事件流或者构建对应的 reader/writer开发量会略微大一些。但当你需要处理复杂的真实业务场景时前期多花的那点学习成本后面会在维护和扩展上连本带利地赚回来。2.2 核心架构拆解事件模型、流式读写与可插拔组件Fesod 的架构核心可以拆成三块事件模型、流式读写器、可插拔的处理组件。事件模型是 Fesod 的骨架。在读取过程中它会依次触发工作簿事件、工作表事件、行事件、单元格事件。你可以选择监听全部事件也可以只关心自己需要的部分。这种粒度给我带来的直接好处是拿到一个几十 MB 的 Excel我完全可以做到只解析我感兴趣的 sheet、只处理我关心的列其他内容直接跳过。流式读写是它的第二块基石。读的方向它底层采用流式解析不会因为文件大小而出现内存暴涨写的方向它提供了流式 Writer可以边生成数据边写盘而不是等所有数据组装成一个巨型对象后再一次性落盘。这个对导出千万级数据行这种场景非常关键我们后面实操部分会专门演示。可插拔组件则是 Fesod 易用性的来源。比如你可以配置列的读取策略、自定义数据类型转换器、挂载表头解析器甚至接入表达式引擎来处理计算列。这些组件各司其职用的时候挑出来组合不需要像 EasyExcel 那样把一堆逻辑塞进监听器里。对使用者来说最重要的一个设计是Fesod 把“解析 Excel 数据”和“解析 Excel 结构”这两个过程分开了。数据解析关心的是单元格里的值结构解析关心的是表头层级、合并区域、行列关系。这两件事分开之后复杂表头从“读数据时的障碍”变成了“可以被独立构建和查询的结构”。2.3 和 EasyExcel 的直观对比一张表看清差异我把两个框架在我关心的维度上做了个直接对比基于我自己在真实项目里的使用体验对比维度EasyExcelApache Fesod定位面向简单/常规 Excel 读写的工具库面向复杂表格处理的事件流组件读取模型注解模型映射 / 一行一回调事件流 可插拔组件粒度更细合并单元格解析需自定义策略结构信息易丢失原生识别合并区域可构建区域映射复杂表头支持有限动态表头需手工拼结构表头解析与数据解析分离原生支持多层表头动态列需要编码兜底灵活性一般事件模型天然适合动态列场景写大数据量流式写但临时对象开销偏大流式 Writer缓冲区和上下文可控模板填充支持但样式控制细节易出问题模板组件更强调样式和数据的解耦社区模式公司主导开源项目迭代节奏一般Apache 社区运作路线图更透明学习成本低文档上手快中等需要理解事件模型适用场景中小数据量、结构固定的日常导入导出复杂表头、动态列、超大数据量、可扩展场景看完这个对比你应该能理解为什么我在面对复杂表头导入时会选择迁移到 Fesod。它不是全方位碾压 EasyExcel而是在我需要的那个方向上走得更深。3. 实操部分从 EasyExcel 平滑迁移到 Apache Fesod3.1 引入依赖与第一个读取示例我们先从最简单的操作开始读一个普通 sheet。Fesod 的依赖引入方式和大多数 Apache 项目一致Maven 坐标如下版本号以官方发布为准建议使用你评估时最新的稳定版dependency groupIdorg.apache.fesod/groupId artifactIdfesod-core/artifactId version最新稳定版/version /dependency引入依赖后写一个最基础的读取示例。假设我们有一个user.xlsx第一个 sheet 是用户数据列结构为姓名、年龄、城市。用 Fesod 读取的代码如下import org.apache.fesod.api.Fesod; import org.apache.fesod.api.ReaderOption; import org.apache.fesod.api.Row; import org.apache.fesod.api.event.RowEvent; public class SimpleReadExample { public static void main(String[] args) { ReaderOption option ReaderOption.builder() .sheetIndex(0) .build(); Fesod.read(user.xlsx, option) .listen(RowEvent.class, event - { Row row event.getRow(); String name row.getCell(0).asString(); Integer age row.getCell(1).asInt(); String city row.getCell(2).asString(); System.out.println(name | age | city); }) .execute(); } }这段代码做的事情很简单指定读取第一个 sheet注册一个行事件监听器然后逐行打印数据。注意这里的数据访问方式是通过row.getCell(index)拿到单元格对象再调用asString()、asInt()这类方法做类型转换。和 EasyExcel 相比最明显的变化是你需要手动处理单元格值的类型转换而不是让框架根据 Java 字段类型自动映射。刚开始写的时候我也觉得麻烦但用顺手之后发现这个设计其实有好处Excel 单元格的值类型很多时候是不可信的。比如一个看起来是数字的单元格实际上可能是文本格式一个日期列在不同文件里可能以不同格式出现。EasyExcel 的自动映射在遇到脏数据时会直接抛类型转换异常而 Fesod 给了你自己做容错的机会这个对处理第三方提供的 Excel 文件尤其重要。3.2 写入场景流式写出大数据量内存曲线非常平稳读是基本功写才是压测时最能看出差距的地方。我项目里有一个对账明细导出功能单次导出的数据量在 80 万行左右。以前用 EasyExcel 写虽然也是流式写但当数据行里包含多个日期格式、金额格式的单元格时大批量写盘阶段的 GC 压力还是很明显。换成 Fesod 之后写法上稍微有一点变化但整体思路清晰很多import org.apache.fesod.api.Fesod; import org.apache.fesod.api.WriterOption; import org.apache.fesod.api.Row; import org.apache.fesod.writer.FastRow; public class WriteLargeDataExample { public static void main(String[] args) { WriterOption option WriterOption.builder() .sheetName(对账明细) .build(); try (var writer Fesod.write(对账明细.xlsx, option)) { // 写表头 Row header FastRow.create(); header.addCell(订单号); header.addCell(用户ID); header.addCell(金额); header.addCell(下单时间); writer.writeRow(header); // 模拟写 80 万行数据 for (int i 0; i 800000; i) { Row row FastRow.create(); row.addCell(ORD i); row.addCell(10000 i); row.addCell(99.99 i); row.addCell(2024-06-01 12:00:00); writer.writeRow(row); if (i % 10000 0) { System.out.println(已写入: i 行); } } } } }这块代码的关键点是try-with-resources包裹的 writer。流式 Writer 会在内部维护一个缓冲区当数据量达到阈值时自动落盘而不是全部堆积在内存里。我在本机用 4GB 堆内存跑 80 万行导出全程 GC 表现得相当平稳没有出现一次 Full GC这在以前用 EasyExcel 跑同样数据量时几乎不可能做到。顺带说明一下FastRow是 Fesod 提供的高效行创建工具内部复用了一些对象来减少分配开销。如果你有非常严苛的性能要求可以在一个循环里复用同一个FastRow不断clear()之后再填充数据。这样能进一步降低对象创建频率实测对写入吞吐量有 10% 以上的提升。3.3 核心场景复杂的表头导入怎么在 Fesod 里落地接下来就是这篇文章的重头戏了复杂表头导入。我先描述一下实际需求这样后面的代码才有上下文。项目里有一个“销售业绩导入”功能上游给的 Excel 模板表头长这样第一行是汇总标题从 A 列一直合并到 F 列某集团 2024 年销售业绩表。第二行是分组表头“一季度”合并了 A 和 B 两列“二季度”合并了 C 和 D 两列“半年度汇总”合并了 E 和 F 两列。第三行才是真正的列名一月销售额、二月销售额、三月销售额、四月销售额、五月销售额、六月销售额。从第四行开始是具体数据。如果用 EasyExcel 处理这种文件你要么提前把表头结构写死在代码里要么写一个非常复杂的HeadGenerator。而且导入时你还得自己去处理那两行合并的表头信息包括合并区域的边界、列归属关系。我之前那版代码光表头解析就写了 200 多行维护成本极高。在 Fesod 里这个问题被放在了更合理的位置。因为 Fesod 保留了 sheet 的结构信息你可以先解析表头区域构建出一份“表头上下文”再用这份上下文去驱动数据解析。看代码import org.apache.fesod.api.*; import org.apache.fesod.api.event.CellEvent; import org.apache.fesod.api.event.RowEvent; import org.apache.fesod.model.MergedRegion; import java.util.HashMap; import java.util.Map; public class ComplexHeaderImportExample { public static void main(String[] args) { // 1. 先解析结构获取合并区域和表头行数 ReaderOption option ReaderOption.builder() .sheetIndex(0) .build(); final MapString, String mergedValueMap new HashMap(); Fesod.read(sales.xlsx, option) .listen(CellEvent.class, event - { // 这里可以拿到每个单元格以及它所属的合并区域如果有 MergedRegion region event.getMergedRegion(); if (region ! null region.isFirstCell()) { // 只记录合并区域左上角的值 String key region.getFirstRow() - region.getFirstColumn(); mergedValueMap.put(key, event.getCell().asString()); } }) .execute(); // 2. 构建表头上下文确定数据起始行和列映射 HeaderContext headerContext buildHeaderContext(mergedValueMap); // 3. 按表头上下文解析数据行 ReaderOption dataOption ReaderOption.builder() .sheetIndex(0) .startRow(headerContext.getDataStartRow()) .build(); Fesod.read(sales.xlsx, dataOption) .listen(RowEvent.class, event - { Row row event.getRow(); // 根据表头上下文中的列映射关系精确取出每列的数据 String一月 row.getCell(headerContext.getColumnIndex(一月销售额)).asString(); String二月 row.getCell(headerContext.getColumnIndex(二月销售额)).asString(); System.out.println(一月: 一月 , 二月: 二月); }) .execute(); } }这段代码演示了两个关键点。第一通过监听CellEvent获取合并区域信息。Fesod 在触发单元格事件时如果当前单元格属于某个合并区域会附带上MergedRegion对象。这个对象包含区域的起始行、结束行、起始列、结束列以及一个isFirstCell()方法来判断当前单元格是否是合并区域的左上角。这样一来合并单元格的语义就不再丢失你可以准确地知道“一季度”这个标题覆盖了哪几列。第二表头解析和数据解析分成两个阶段。这个设计是我在实际项目中非常喜欢的一点。先跑一遍只收集结构信息处理完表头之后再以startRow指定数据起始行进行正式的数据解析。两个阶段各司其职代码逻辑清晰也不用在一个监听器里塞满各种 if-else 去区分当前是表头行还是数据行。3.4 模板导出样式和数据解耦模板改动不影响代码逻辑再聊一个我迁移后顺手解决的问题模板导出。旧系统里有大量报表导出需求每个报表对应一个固定模板。模板里面有固定的标题、合并单元格、企业 Logo、样式预设唯一的变量是中间几列的业务数据。以前用 EasyExcel 的模板填充功能经常遇到一个问题模板文件一旦有小的样式调整比如某个单元格加了个边框、某行改了一下背景色填充出来的结果就可能出现样式错乱。Fesod 在这块采用了不同的思路。它的模板组件强调的是“样式与数据的解耦”。具体来说模板文件里的样式、合并单元、表头结构会被保留而数据写入是通过定位符比如{{name}}、{{amount}}来识别并且只替换这些定位符所在单元格的值不触碰其他区域的样式。这意味着业务人员可以放心地调整模板样式只要不动定位符代码不需要重新修改。这个特性在交付给运营团队使用后帮我们省去了大量“因为模板微调而改代码发版”的维护工作。模板填充的代码示例import org.apache.fesod.template.FesodTemplate; import org.apache.fesod.template.TemplateRender; import java.util.HashMap; import java.util.Map; public class TemplateExportExample { public static void main(String[] args) { MapString, Object data new HashMap(); data.put(title, 2024年6月销售汇总); data.put(totalAmount, 1,234,567.89); data.put(generateTime, 2024-07-01 10:00:00); try (TemplateRender render FesodTemplate.render(report_template.xlsx)) { render.setData(data); render.writeTo(report_output.xlsx); } } }模板文件里只需要在对应单元格写上{{title}}、{{totalAmount}}这样的占位符渲染时 Fesod 会自动找到这些单元格并写入数据同时保持模板中预设的行高、列宽、字体和合并单元格不动。相比之前用 EasyExcel 填充完还要二次修复样式的做法这是一个非常直观的体验升级。4. 迁移路上踩过的坑和排查实录4.1 问题1同样的文件解析出来的数据行比原来少了迁移之初我用同一份测试文件对比 Fesod 和 EasyExcel 的解析结果惊讶地发现 Fesod 解析出来的行数比 EasyExcel 少了大概 500 行。第一反应是 Fesod 的默认行为有问题后来排查才发现问题出在我自己身上。Fesod 默认会跳过“空行”这个空行的判断标准是整行所有单元格都为空。但上游文件里有一些行看起来是有内容的其实那些内容来自合并单元格合并区域的非左上角单元格在事件流中不会重复触发单元格事件。也就是说从 Fesod 的角度看那些行只有一个单元格有值其他单元格都没有值但它仍然应该被判定为有效数据行。问题是我当时设置的过滤条件是“只要有一个单元格有值就保留”理论上不该丢行。进一步排查发现真正问题是我在监听行事件时使用了一个基于row.getPhysicalCellCount()的判断条件这个值表示的是“实际有单元格对象的列数”而合并单元格的从属区域在某些情况下不会被计入。解决方案是改用表头上下文中定义的有效列区间来判断一行是否为空而不是依赖物理单元格数量。这个坑给我的教训是迁移任何框架时不能想当然地用旧框架的行语义去套新框架。Fesod 的行事件更加贴近 Excel 文件自身的物理结构而不是像 EasyExcel 那样已经帮你把二维表逻辑处理好了。4.2 问题2合并单元格取值取出来的值让人摸不着头脑第二个问题出现在读取合并单元格值的时候。文件某个单元格区域是合并的例如第二行的 A 列和 B 列合并内容为“一季度”。在 Fesod 的事件流中只有合并区域左上角的单元格会携带实际值A 列是左上角能取到“一季度”B 列不是左上角取到的值是 null。这个行为其实和底层 Excel 文件格式是一致的但刚从 EasyExcel 迁移过来的同学很容易踩坑。因为 EasyExcel 在解析时会帮你做一些“视觉上补齐”的工作让你感觉合并单元格的每个位置都有值。解决办法也不复杂我在前面的复杂表头示例中已经演示过了提前通过CellEvent构建一个“合并区域值映射”把所有合并区域左上角的值记录到 Map 里。在后续处理中如果某个单元格的值为空就去查它是否属于某个合并区域如果是就取该区域左上角的值。这个处理逻辑建议封装成一个工具类因为在一份复杂的报表里这类情况会频繁出现。封装好之后你在业务代码里就永远不需要关心合并单元格的取值为空这种细节了。4.3 问题3大文件写入时内存曲线突然飙升前面说到流式 Writer 在 80 万行导出时表现很稳定但在一个特殊场景下我也遇到过内存飙升的问题。那个场景是单元格里要写一个很长的富文本字符串同时还要给这些单元格设置不同的字体颜色。问题不是出在 Fesod 的流式写本身而是我在代码里为每一行都创建了独立的样式对象。Fesod 和 POI 一样样式对象是重量级对象会被缓存并关联到工作簿的样式表里。如果每行每个单元格都创建新样式样式表就会无限膨胀最终吃掉大量内存。解决方案是复用样式对象。把固定的几种样式例如“金额加粗”“日期居中”“正常文本”提前创建好在写行时直接引用同一批样式实例。这个和数据库连接池的思想其实是一样的复用远比创建新对象要高效。并且当样式种类有限时最终生成的 Excel 文件体积也会小很多因为文件里只需要记录少量样式定义。4.4 常见问题速查表我在迁移过程中遇到的其他问题统一整理成下面的表格给正在评估或已经切换的同学一个参考现象常见原因解决方法解析行数比预期少行事件中使用了物理单元格数做空行判断改为基于表头上下文的列区间判断合并单元格取值为空只监听了值事件未处理合并区域信息提前构建合并区域值映射表大数据量写入内存飙升每行都创建了新的样式对象复用样式实例限制样式数量动态列顺序和预期不符遍历时依赖了 HashMap 而不是有序结构使用 LinkedHashMap 维护列顺序模板填充后样式错乱模板单元格本身带有复杂格式写入时冲突定位符独立成单元格不与其他样式叠加代码抛“cell not found”异常数据行长度小于表头列数部分列缺失读取前先按起始行和列范围做完整性校验这张表值得你贴到项目文档里因为这些问题几乎在任何一个从 EasyExcel 迁移到 Fesod 的团队里都会遇到属于典型的迁移期阵痛。5. 迁移之后的真实体验和一点建议项目迁移完成到现在已经跑了三个迭代线上环境处理过亿级行数据导入也导出过单文件接近百 MB 的大报表Fesod 没有出过稳定性问题。我个人最直观的感受是之前用 EasyExcel 时遇到复杂场景第一反应是“这框架可能不支持得绕一下”现在用 Fesod遇到复杂场景第一反应是“看看它的事件流能不能给我足够的信息”。这种心态转变往往意味着你用对了工具。当然我并不是劝所有人都换。如果你的业务场景就是简单的单表导入导出数据量不大表头结构长期不变EasyExcel 依然是很好的选择。但如果你正在经历复杂表头、动态列、超大文件这些挑战并且对社区活力和长期可维护性有要求那么 Apache Fesod 值得你花一个下午做一次技术验证。最后再分享一个迁移后的小技巧在 Fesod 里做批量导入时强烈建议把“表头结构解析”和“数据解析”拆成两个阶段。第一个阶段只监听结构相关事件构建表头上下文第二个阶段再开启数据解析按照上下文去取值。这样做不仅代码清晰而且在同一份文件需要反复解析多次时你只需要解析一次结构然后对多个数据区域分别进行读取整体性能会有一个很明显的提升。提示任何框架选型都需要基于你自身的业务场景做验证尤其是性能数据建议拿到真实生产环境的文件压测后再做决策。
返回列表