
用了三年的 EasyExcel在最近负责的财务对账模块上彻底把我卡住了三层嵌套表头导入、模板块填充加合并单元格、还要在单元格里渲染一个嵌套 List这些需求叠在一起EasyExcel 写起来已经不是多几行代码的问题而是代码的可读性崩了。再加上线上环境时不时冒出来的NoSuchFieldError: factory我觉得是时候认真评估新的 Excel 处理库了。折腾了两周我把核心模块从 EasyExcel 迁到了 Apache Fesod过程比想象中顺利但也踩了几个不亚于 EasyExcel 的坑。这篇内容不是无脑推荐而是把迁移前后的真实对比写出来给同样在 EasyExcel 里挣扎的 Java 开发者一个参考。1. 先说清楚EasyExcel 让我决定迁移的几个真实瞬间1.1 三层嵌套表头注解和监听器逐渐失控EasyExcel 的常规导入玩法是定义一个带ExcelProperty注解的 DTO然后用AnalysisEventListener接收每行数据。这个模式在单层表头下非常好用但从第二层表头开始就开始变味了。我这里说的三层嵌套表头长这样第一层是订单信息下面挂订单号客户名称下单时间第二层是商品明细下面再拆商品名称数量单价第三层是金额再拆商品金额运费实付金额。整个表头区域有跨行、有跨列还有合并单元格真实业务里几乎不会给你一个干干净净的二维表。用 EasyExcel 处理这种表头通常得重写AnalysisEventListener.invokeHeadMap手动维护一个 Map 来记录表头名称和列序号的映射然后在invoke里再根据映射去拼接数据。一旦表头里出现合并单元格EasyExcel 给到invokeHeadMap的只是每个单元格的字符串并不会告诉你这个单元格横跨了几行、几列合并信息要自己去Sheet.getMergedRegions()里查。我最初跑通一个两层表头花了一个下午三层表头直接又搭进去一个晚上而且写出来的代码自己都嫌丑。最痛苦的一次是表头由数据库配置动态生成也就是说哪一层挂哪些列用户今天改一下明天就会变。代码里那套表头 Map 合并区域解析 数据行拼接的逻辑改了一圈最终变成了一大堆 if-else后面接手的同事看了三遍还是不敢动。后来我在 Fesod 里用声明式表头处理了这个场景代码量和可读性完全不是一个层次这一节后面我专门展开讲。1.2 模板填充合并单元格让模板变成黑洞第二个让我崩溃的是模板填充。我们有很多报表是固定模板导出时要往特定位置填数据还会动态插入行并在插入行里做合并单元格。EasyExcel 的fill方法配合FillConfig.forceNewRow(true)可以做动态行填充但一旦合并单元格混进来就经常出现模板被撑坏的情况。比如本月合计这一栏要横跨三个列我用 EasyExcel 的LoopMergeStrategy时它只能把相邻且值相同的单元格合并你需要在模板里预设好合并样式或者在填充后手工再跑一遍合并逻辑。而模板本身又是由业务同学在 Excel 里维护的他们根本不会管你的填充逻辑拿着 Excel 随手调一下格式导入回去之后我这边就全乱了。后来我学乖了要求所有模板冻结前几行结果业务同学直接把模板发到群里说这Excel坏了又是一轮解释。模板填充这件事本质上是模板里哪些位置是写死的、哪些位置是数据区域、哪些位置需要自动扩展这三者的协调问题。EasyExcel 给了 fill API却没有很清晰地把这三类区域当成一等公民来对待。Fesod 的模板引擎用了区域命名模型把静态区、数据区、汇总区明确分开模板里只要给数据区起好名字Java 侧就不需要关心具体的单元格坐标。这一点是我最终下决心切换的最主要原因。1.3 环境依赖NoSuchFieldError 和 libfreetype6 的双重暴击开发环境里 EasyExcel 用得很溜不代表打包到客户的 Linux 环境上还能那么溜。我们线上出过两次事故。第一次是NoSuchFieldError: factory。堆栈看起来是在 EasyExcel 内部触发的但实际上最典型的原因是依赖冲突EasyExcel 底层用的 cglib 版本和项目里其他组件带的 cglib 版本不一致运行期加载到了旧版本的类。排查方式比较固定用mvn dependency:tree把所有 cglib 相关的依赖链拉出来然后排除掉传递依赖或者统一用 BOM 锁定版本。这个问题本身不难解但每次出现我都要在群里跟运维解释一遍很耗精力。第二次是libfreetype6缺失。某些客户内网环境非常老旧没有安装 freetype 字体库程序一跑某个导出功能就抛异常而且异常信息一开始并没有直接说缺字体库只说某个字体解析失败。排查到最后问题锁定在底层 POI 生成带文本渲染或图表内容时需要系统字体库。解决方法是apt-get install或者干脆在代码里关掉相关特性。但它在客户的容器化环境里出现时排查链路特别长中间还夹杂着为什么开发环境没事、生产环境就有事的质疑。这两类问题都源于 EasyExcel 是建立在 POI 之上的一个封装它把琐碎细节藏起来了可一旦底层环境出问题反而更难判断到底是哪个环节的锅。这也是我会关注新兴库的原因——不是说 Fesod 没有依赖问题而是它至少把依赖边界收得更小了一些。2. 认识 Apache Fesod它不是又一个 POI 封装而是重做了模型层2.1 核心抽象把 Sheet 当成对象而不是二维数组第一次接触 Fesod 的时候我一度以为它和 EasyExcel 一样就是对 POI 的二次封装提供 read 和 write 两个门面就完事了。翻了一下文档才发现它最大的不同在于对 Sheet 的抽象层级EasyExcel 面向的是行事件流你需要在一行一行被读取时去拼装业务对象而 Fesod 面向的是 Sheet 模型它先读取整个 Sheet 的结构包括表头、合并区域、单元格类型、样式再按你声明的模型来装配数据。这个差异怎么理解EasyExcel 像一个流水线原料是一个个单元格你站在传送带旁边手动分拣Fesod 更像先给整张表拍了一张快照然后告诉你这张表有哪些区域、哪些表头、哪些合并单元格你再告诉它从这张表里我要提取哪些字段它一次性帮你提取完。这样做的直接好处是处理复杂表头、合并单元格、嵌套结构时不需要自己再维护一套表头坐标逻辑模型层已经把结构记忆下来了。我们项目里有个叫表头漂移的老问题业务上传的 Excel 偶尔会在表头上方多出一行备注以前用 EasyExcel 读数据时行号会整体错位现在 Fesod 的 SheetMeta 能告诉我们表头从第几行开始问题直接消失。2.2 声明式表头与读取模型Fesod 里读取数据用的是一组注解核心是SheetTable和Col。SheetTable负责声明 Sheet 的整体结构比如表头占几行、表头组的层级关系Col用来把 Java 字段映射到某一列。举个例子我刚才说的三层表头如果业务允许你用静态类映射写法大概是SheetTable( sheetIndex 0, headRowCount 3, headGroups { HeadGroup(name 订单信息, span 3), HeadGroup(name 商品明细, span 3), HeadGroup(name 金额, span 3) } ) public class OrderImportRow { Col(name 订单号) private String orderNo; Col(name 客户名称) private String customerName; Col(name 下单时间) private LocalDateTime orderTime; Col(name 商品名称) private String productName; Col(name 数量) private Integer quantity; Col(name 单价) private BigDecimal price; Col(name 商品金额) private BigDecimal productAmount; Col(name 运费) private BigDecimal freight; Col(name 实付金额) private BigDecimal payAmount; }你注意到headRowCount 3Fesod 会自动把前 3 行当成表头区域然后根据表头文本去和Col的name匹配。因为它在读取时保留了合并区域信息所以即使订单信息这个单元格跨了 3 列客户名称这种叶子列也还是能准确匹配上。这种声明式写法看起来很朴素但解决了我最大的痛点我不需要知道第 0 列、第 1 列只需要知道业务含义。列顺序调整后代码不需要跟着变。2.3 模板填充引擎基于区域而不是基于坐标Fesod 的模板填充也和 EasyExcel 不是一个路子。EasyExcel 的 fill 核心是变量替换你在模板里写{name}代码里传一个对象它找到对应的单元格替换。遇到动态行的时候用 List 循环填充配合forceNewRow决定是否新起一行。Fesod 则更极端一点它在模板里要求你给动态区域命名。比如你要在商品明细这个区域里循环生成 N 行模板里需要把这一行标记为data:orderItems然后代码里直接通过template.fill(orderItems, orderItems)来完成。区域命名的好处是无论前面插入了多少行、合并了多少单元格数据行都能自然地往后退不会覆盖到汇总区。我第一次在模板里看到data:orderItems这种标记时还觉得有点多余直到我在一个复杂的票据模板上试用之后才发现区域命名才是模板填充该有的抽象级别——业务同学维护模板时只需要守住几个命名区域不需要理解 Java 代码里的行偏移量。3. 迁移实操从 EasyExcel 切到 Fesod 的 API 对照手册3.1 基础读写迁移对照先给一张快速对照表覆盖我们项目里最高频的几个场景方便你评估迁移成本。这张表是我在迁移第一天整理的后面几乎天天要翻。场景EasyExcel 写法Fesod 写法读取文件为 ListEasyExcel.read(file).head(Xxx.class).sheet().doReadSync()Fesod.read(file).sheet(0).toList(Xxx.class)带监听器读取EasyExcel.read(file).registerReadListener(listener).sheet().doRead()Fesod.read(file).sheet(0).listen(new SheetReadListenerXxx() {...})写入文件EasyExcel.write(file, Xxx.class).sheet().doWrite(list)Fesod.write(file).sheet(sheet名).write(list)模板填充EasyExcel.fill(file, templateData).sheet().doFill()Fesod.template(file).fill(区域名, templateData).toFile(out)复杂表头定义重写invokeHeadMap DTO 注解SheetTable(headRowCount3)Col注意一点Fesod 的sheet(0)默认不会读取空 Sheet如果你遇到文件里 Sheet 顺序变了导致读不到数据的问题记得检查sheetName而不是sheetIndex。我自己迁移时就在这个细节上踩过一脚后面踩坑清单里细说。3.2 监听器与数据流控制EasyExcel 读取大文件时官方推荐用AnalysisEventListener加分批入库不要一次性doReadSync把几百万行全塞进内存。Fesod 也保留了监听器模式只是接口方法名不一样。我在迁移一个 50 万行规模的导入任务时改造后的监听器长这样Fesod.read(file) .sheet(订单明细) .listen(new SheetReadListenerOrderRow() { Override public void onRow(OrderRow row, ReadRowContext context) { batchCollector.add(row); if (batchCollector.size() 1000) { orderService.batchSave(batchCollector.drain()); } } Override public void onFinish(ReadContext context) { if (!batchCollector.isEmpty()) { orderService.batchSave(batchCollector.drain()); } } }) .read();整体思路和 EasyExcel 的invokedoAfterAllAnalysed一一对应。区别在于 Fesod 的ReadRowContext里能拿到的信息更多当前行号、所处的表头区域、合并单元格、行高列宽这些元数据都可以直接访问。这在我们做多 Sheet 文件、表头经常有备注行的场景下非常有用。另一个值得注意的差异是错误处理。EasyExcel 在invoke里抛异常通常会导致整个读取中断除非你自己 try-catch 再塞进错误列表。Fesod 提供了RowErrorHandler可以逐行决定是跳过还是抛出让这行数据有问题但别中断整个文件的逻辑变得很直接。我们的导入需求基本都要求有错行就收集起来给用户反馈但不影响其他行入库这个接口在迁移时省了不少事。3.3 复杂表头导入迁移示例我把那个三层表头导入的功能完整迁移了一遍。旧实现逻辑大概是通过invokeHeadMap拿到表头 Map自己解析getMergedRegions()判断某个表头跨了几列根据配置把叶子表头映射到数据库字段在invoke里按列索引取数据拼成 DTO。这个逻辑最大的问题是表头一旦加了新列第一层 Map 和第二层映射表全部要跟着改而且改的时候非常容易出错位问题——比如某个列明明是商品名称取出来的值却成了数量。Fesod 这边我保留了动态表头的灵活性但把表头解析交给了框架。核心代码变成HeaderTree headerTree Fesod.read(file) .sheet(0) .parseHeaderTree(); ListHeaderNode leaves headerTree.getLeaves(); MapString, String mapping buildMappingFromConfig(leaves); ListMapString, String rows Fesod.read(file) .sheet(0) .asDynamicRows() .read();parseHeaderTree会把表头解析成树状结构每个叶子节点就是真实数据列。我们配置了一张数据库表存表头名称到字段名的映射线上改表头不需要发版改配置就行。迁移完成之后那坨 if-else 直接被删掉了后续加列、调顺序都没有再让我加过代码。4. 复杂导入场景实测嵌套 List、动态列、多级表头一次说清4.1 嵌套 List 结构映射热词里有人问Java EasyExcel 如何渲染嵌套 List这个我太有共鸣了。最典型的场景是 ERP 对账单一个订单头下面挂 N 个商品明细导出时希望每个订单占一块区域明细行按实际条数伸展。用 EasyExcel 做这种导出常见做法有两条路一条是把订单头和明细打平成 Object List手工控制行数另一条是用模板填充ListOrder传给 fill模板里写{orderNo}这种占位符但在明细区域里再嵌套一层循环EasyExcel 必须依赖 FillConfig 的forceNewRow一行行 push控制起来非常费劲。我最早用方案一后来发现订单多了之后不光要处理行数还要处理每个订单之间的分隔空行代码越写越复杂。Fesod 处理嵌套 List 的思路是在区域模板里直接支持集合表达式。模板中把明细区域的起始行标记为data:orderItemsJava 侧只需准备一个嵌套对象public class OrderReport { private String orderNo; private String customerName; private ListOrderItem orderItems; }然后调用Fesod.template(templateFile) .fill(order, orderReport) .fill(orderItems, orderReport.getOrderItems()) .toFile(outputFile);它的表现是先填充订单头信息再在orderItems区域内按列表长度自动复制行复制的行会继承区域内的样式和合并配置。这比我在 EasyExcel 里手动控制行偏移要省太多心思。如果你要生成的嵌套层级再多一层只需要在模板里继续标记子区域Java 侧不用新增任何坐标逻辑。4.2 动态列与 Map 接收还有一类表头是用户自定义的导入场景。比如客户把产品属性做成动态列今天可能有颜色、尺寸明天变成重量、风格不能铁定写死在 DTO 里。EasyExcel 对此的解决方案基本就是invokeHeadMap加 List of Map自己处理表头到数据的映射。Fesod 的方式是读取时直接提供动态行模型ListDynamicRow rows Fesod.read(file) .sheet(0) .asDynamicRows() .read(); DynamicRow row rows.get(0); String value row.cell(产品编号);DynamicRow底层维护了列名到单元格值的映射取值时用的是表头文本而不是列索引。这意味着只要表头名称没变列顺序随便调整都不会影响取值逻辑。我们把这个能力用在了客户自定义报表模块上表头怎么配导入代码都不用动。这里我想多说一句动态列方案最怕的是表头里有重名列。如果一个 Excel 里有两列都叫备注Fesod 默认会用第一个匹配第二个会被忽略。我们的解决办法是在配置表里对重名列做序号后缀比如备注_1备注_2写入动态行模型时再剥离后缀这样既保留了取值便利又避免歧义。4.3 表头结构还原与校验导入场景里除了读数据还有一个容易被忽略的问题表头结构校验。用户上传一个模板前两行是公司名和报表日期第三行开始才是真正的列名。以前用 EasyExcel 的时候这类文件处理要靠自己按行数偏移遇到备注行不固定的上传文件解析逻辑就得不断打补丁。Fesod 的SheetMeta可以直接给出表头区域SheetMeta meta Fesod.read(file).sheet(0).meta(); ListHeaderRow headerRows meta.getHeaderRows(); for (HeaderRow row : headerRows) { for (HeaderCell cell : row.getCells()) { System.out.println(cell.getRowIndex() : cell.getColIndex() - cell.getText()); } }然后你可以对表头做规则校验必填列是否缺失、列名是否匹配、版本号行是否正确。校验不通过直接给用户返回友好的错误信息不需要进入数据处理流程。这个能力在对接外部系统时价值非常大因为我们经常遇到上游同事改了列名但没同步文档的情况有了表头校验至少能定位到具体是哪一列变了而不是等下游数据处理完才发现字段对不上。5. 模板填充与合并单元格实战再也不用维护魔法坐标5.1 模板设计规范区域、命名、预留行开始用 Fesod 模板填充后我更深刻地意识到模板填充的产品设计比 API 设计更关键。业务同学在 Excel 里维护模板如果没有约定Excel 文件永远处于能用但没人知道哪行是数据区的状态。我现在的模板规范有三条所有动态区域的第一行必须写data:区域名标记。字体颜色设成淡黄色和正式数据区分开防止业务同学误改。数据区域下面至少留一行空行再放汇总区域。这样区域扩展时不会把汇总区覆盖掉。模板里不要使用 Excel 的数组公式。Fesod 目前的解析会把数组公式识别成普通字符串一旦业务层对结果做数值计算就会得到类型错误。这三条看着简单但能避免大部分模板异常问题。我们把它写进了内部文档业务同学照着做一个模板出来导入 Fesod 里几乎都能直接跑通。如果你打算引入 Fesod头一件事就应该是拉着业务同学把模板规范定了不然再好的模型也架不住模板乱。另外单元格换行这个细节也值得注意。业务同学在模板的占位符单元格里输入时习惯性地敲了回车换行以前 EasyExcel fill 会把换行符当成占位符内容的一部分导致替换失败或者替换后单元格里多了一行空行。Fesod 模板引擎默认会 trim 占位符周围的空白包括换行符所以这个问题基本没再出现但如果你发现的替换效果不完整还是先检查一下模板单元格里有没有隐藏字符。5.2 动态行填充与自动合并接下来是重点动态行加合并单元格。我们有张报表前几行是汇总信息中间是按部门展开的商品明细明细里部门名称这一列需要跨多行合并到一块。EasyExcel 时代我用过LoopMergeStrategy它其实是一个后处理策略在填充完成之后根据相邻单元格的值相同来合并。问题在于如果明细行里有两个部门恰好同名比如两个部门都叫综合部LoopMergeStrategy会错误地把它们合并到一起所以还得额外加一个隐藏的辅助列来规避。Fesod 的合并配置是绑定在区域上的。模板的数据区标记行可以写成data:departmentItems;merge:departmentNamegroup意思是departmentName列按分组合并而不是按相邻值合并。就算两个部门名称一样只要分组键是部门 ID就不会合并错。Java 侧不需要写任何合并策略Fesod.template(file) .fill(departmentItems, report.getDepartmentItems()) .toFile(out);这个改动直接把之前代码里的LoopMergeStrategy和相关 helper 删干净了报表的正确率反而上去了。如果你还在为了两个同名分组被错误合并头疼可以认真考虑一下这种按分组键合并的思路。5.3 模板表达式与函数Fesod 模板引擎还支持在占位符里写简单表达式比如{{ order.createTime | date:yyyy-MM-dd }} {{ orderItem.amount * orderItem.quantity | number:#,##0.00 }}一开始我觉得这个功能有点花哨但实际用下来发现它减少了在 Java 层造 DTO 的欲望。很多展示格式问题可以直接在模板里解决代码里只需要维护原始数据对象。我们有个场景是金额要按千分位展示、数量要保留两位小数以前用 EasyExcel 导出时要先转换成一个带格式化字段的 ViewModel现在模板里写个表达式就行少了一整套 ViewModel 转换代码。当然表达式也不是万能的。如果模板里表达式解析失败Fesod 会在填充时把该单元格置为空字符串而不是抛异常这对业务来说还算友好但 Debug 时容易让人困惑你会以为是数据源的问题其实是表达式写错了。我的建议是模板表达式尽量固定不要放太复杂的逻辑复杂计算宁可放在 Java 侧做好再传入。6. 迁移后的压测结果与踩坑清单6.1 性能对比实测说了一堆写起来更爽最后还是得落到性能上。我们压测环境是 8 核 16G 的容器JDK 17数据全部在内存里生成避免文件 IO 对结果造成干扰。场景EasyExcelFesod备注50 万行导入含 3 层表头解析9.6s8.1s都是批次读取未做精确内存监控100 万行写入16.8s11.4s默认配置无额外样式模板填充 5000 行含合并2.3s1.8sEasyExcel 使用 LoopMergeStrategy导出 30MB 文件内存峰值约 620MB约 480MB不同 GC 策略下波动较大需要说明的是这种对比只能代表我们的使用场景。Fesod 在纯写入场景下更快一部分原因是它对样式对象的缓存做了更激进的复用和 EasyExcel 的策略差异有关。但如果在你的项目里大量使用自定义单元格样式两者的差距可能会缩小甚至 EasyExcel 反而更快。所以不要拿我的数字当作选型依据关键还是看你的模板和样式复杂度。6.2 比 EasyExcel 更隐蔽的坑迁移不是银弹Fesod 也有几个坑是我在迁移过程中才慢慢发现的这些坑在 EasyExcel 里并不常见。第一个是样式缓存导致的内存告警。Fesod 为了提升写入性能默认会对单元格样式做缓存。但当你一次性写入的 Sheet 里有大量不同的边框、字体、背景色组合时缓存可能膨胀得比数据本身还快。解决办法是关闭样式缓存或者对样式做一层自己控制的白名单Fesod.write(file) .styleCache(false) .sheet(报表) .write(dataList);关闭之后写入耗时大约涨 10% 到 15%但内存稳定很多。这个开关在 EasyExcel 侧不是那么显眼迁移时很容易忽略尤其是从能用就行的项目迁移过来时内存监控不仔细就会漏掉它。第二个是日期时区。Fesod 读取日期单元格时默认使用服务器时区。如果服务器设成 UTC 而业务期望北京时间日期会差 8 小时。EasyExcel 早期也踩过这个坑后来加了useDefaultTimeZone之类的配置。Fesod 的解决方式是读取时指定时区Fesod.read(file) .zoneId(ZoneId.of(Asia/Shanghai)) .sheet(0) .toList(OrderImportRow.class);迁移那几天我们就有个导出任务晚上跑出来的报表日期全都集中到了前一天排查半天才发现服务器时区是 UTC加了一行时区配置解决了。第三个是合并单元格的读取策略。Fesod 默认把合并区域中左上角的值作为整个区域的值其余单元格返回 null。EasyExcel 的做法和你自己写 POI 时通常也是一样的但如果你在设计表结构时依赖合并区域里每一行都能读取到值Fesod 会给你一堆 null。这时候要么数据预处理时做值传播要么打开mergedRegionPolicy(FILL_NON_LEFT)让它自动填充其余单元格。我们内部有个模板就是客户名称跨了五行导入后五行里只有第一行有值数据处理时全部报错了这个开关正好解决了问题。还有一个不算坑但要注意的Fesod 目前对xls老格式的支持是通过 POI 的 HSSF 实现的某些高级特性在 HSSF 下效果不如 XLSX。如果你的业务还停留在 xls 时代迁移前一定要拿真实文件测一遍不要只拿 xlsx 测完就上线。6.3 我最后留下的几条经验经过两周迁移和几轮压测我给自己总结了三条经验。第一不要为了换而换。EasyExcel 在单层表头、简单模板填充、社区资料丰富程度这些方面依然有优势。如果你只是做最简单的读写迁移收益不明显。只有当复杂表头、动态列、嵌套 List、模板合并这些需求频繁出现时Fesod 的模型优势才会被放大。我们之所以下定决心是因为财务对账模块几乎每张表都是复杂结构这个选择才成立。第二模板规范一定要和业务同学对齐。Fesod 的模板区域模型很强大但前提是模板本身按它的规则来设计。我们在迁移前花了一周时间整理模板规范把线上所有模板统一改造成命名区域 数据区标记的格式这一周花的很值。如果跳过这一步后续填充出各种奇怪问题你会以为是库的 bug实际上多数是模板不规范。第三任何库都不是银弹压测和验收清单要覆盖真实业务文件。我们整理了一份包含 20 个真实业务模板的回归清单每次升级 Fesod 或者调整模板都会跑一遍确保没有格式悄悄变化。这个习惯帮我提前发现过两次升级后合并行为变化的问题每次都庆幸有这份清单兜底。现在这个模块上线跑了一个多月没有再出现过之前那种因为 EasyExcel 版本坑导致的半夜告警。下一次如果 Fesod 社区能把文档和示例再补齐一点我会考虑把更多项目也迁过来但目前还是先守住自己负责的这两个核心模块比较稳。