资讯动态

从EasyExcel到Apache Fesod:稳定Excel导入导出迁移实战

发布时间:2026/9/14 17:03:05 来源:尧图企业网站定制
过去大半年我一边在报表服务里维护着基于EasyExcel的导入导出模块一边看着社区里关于EasyExcel维护状态的讨论越来越多。真正压垮我的是一连串生产环境问题NoSuchFieldError: factory出现的频率越来越高Docker镜像里缺libfreetype6导致图片导出反复失败复杂表头和嵌套List场景还得靠手写解析。折腾了两周后我决定把导出服务切到 Apache Fesod。严格来说这个名字指的是基于 Apache 2.0 协议的新Excel处理库社区里也常写作 Fastexcel/FastExcel不少中文文章习惯在名字前加个 Apache 前缀。为了叙述统一下面我都叫它 Fesod。如果你正在用 EasyExcel或者正被 Excel 导入导出的各种“玄学报错”折磨这篇内容值得看完。我会把迁移的完整过程、核心代码、踩坑记录都摊开说清楚尤其是复杂表头、嵌套 List、模板合并这几个高频场景给你一套可以落地的方案。1. 先聊清楚EasyExcel问题到底出在哪儿1.1 不是“崩了”是“悬着”先说一个判断EasyExcel 本身不是烂项目它最大的价值在于把 Apache POI 的繁琐API封装成了注解式用法让业务开发几行代码就能搞定读写。但2023年下半年之后社区里肉眼可见地出现了一股不安情绪PR 合并变慢Issue 区大量问题无人回复新版本发布节奏也变得飘忽不定。我们项目里同时存在 EasyExcel 3.1.2 和 3.3.x 两个版本的间接依赖不同模块导出的行为都不一样排错排到最后才发现在依赖传递上版本被覆盖了。这件事让我意识到“稳定”并不等于“代码还能跑”而是意味着你对这个库未来的演进有掌控力。我当时面对的现实是升级新版本可能导致未知问题不升又担心老版本被新环境淘汰。每一轮 Spring Boot 大版本升级、JDK 版本升级我都要先把易用性的回归测试做一遍这种隐性成本非常磨人。1.2 我被 NoSuchFieldError 和 libfreetype6 轮番折磨真正逼我下决心换库的是三个生产问题接连出现。第一个是NoSuchFieldError: factory。这个报错在 EasyExcel 和 POI 版本混用的场景下非常经典。现场堆栈大概长这样java.lang.NoSuchFieldError: factory at com.alibaba.excel.util.ListUtils.newArrayList(ListUtils.java:45) at com.alibaba.excel.context.AnalysisContextImpl.buildCurrentReadList(...)问题不是 EasyExcel 代码逻辑写错了而是它在内部用了 ASM/CGLib 做字节码操作和字段缓存。当 Maven 依赖树上同时出现多个版本的 ASM 类库时JVM 加载了先找到的那个版本而代码里需要的factory字段恰好在新版本里被移除或移动了包路径运行时就抛出NoSuchFieldError。我用mvn dependency:tree -Dverbose排查发现项目里同时存在 asm 5.2 和 asm 7.0中间还夹着一个老旧的 cglib。如果你只能在pom.xml里不断做exclusion那说明问题不是依赖冲突本身而是库的底层实现方式让这类冲突特别容易爆发。第二个是libfreetype6相关错误。我们的导出服务跑在 Docker 容器里一旦导出内容包含二维码、图片或者带粗体的富文本就可能在运行时报错提示无法加载 freetype 库。这是因为 Excel 的图片渲染和字体测量在部分底层路径上依赖系统原生库。修改 DockerfFile 可以解决但为了一个 Excel 库去动整个基础镜像在不少团队里是需要走审批和回归流程的代价比想象中高。第三个是功能空白。复杂表头、模板填充、动态合并这些高频需求EasyExcel 不是不能做而是需要你去翻 Issue、查源码、自己写辅助类。尤其是动态表头导入注解式模型完全帮不上忙最后只能回到AnalysisEventListener手动处理行列映射的老路。这等于是把一个“封装好的库”用回了半成品状态。1.3 我算了一笔成本账2024年下半年某个周五我又一次在处理NoSuchFieldError同事在群里说了一句“要不别修了换个库吧。”我起初觉得这是逃避问题但当天晚上认真复盘后发现过去一年在 EasyExcel 上做的额外开发和排障加起来至少占两个完整迭代。如果换一个 API 风格相似、维护状态更活跃的库迁移成本反而更低。也就是从那天起我开始认真接触 Fesod。2. Fesod给我的第一印象同样能写少了很多玄学报错2.1 它是什么以及我为什么敢在生产环境试先对齐一下称呼。标题里的 Apache Fesod准确说是基于 Apache 2.0 协议的新一代 Excel 读写方案和社区里常说的 FastExcel/Fastexcel 是同一套东西。它不是 Apache 软件基金会的官方项目只是因为协议是 Apache 2.0很多文章习惯在名字前面加个前缀。我团队内部为了顺口统一叫它 Fesod下面代码示例也用这个命名。我敢在生产环境试主要基于三点第一Fesod 的入口 API 设计高度接近 EasyExcel核心模型的替换成本低第二它实现了流式读写支持大数据量场景第三它在反射和字段处理上降低了字节码生成占比对我这种被 ASM 冲突坑过的人来说这一点是决定性的。2.2 最小导出程序差别很小在 EasyExcel 里写一个 List 的导出核心就两行。Fesod 也基本是同样手感入口类换成Fesod模型注解依然可以用类似ExcelProperty的方式Data HeadRowHeight(30) public class OrderExportVO { ExcelProperty(value 订单号) private String orderNo; ExcelProperty(value 下单时间) private LocalDateTime createTime; ExcelProperty(value 金额) private BigDecimal amount; } ListOrderExportVO orders orderService.queryToday(); try (OutputStream out response.getOutputStream()) { Fesod.write(out) .head(OrderExportVO.class) .sheet(今日订单) .doWrite(orders); }如果你原来是 EasyExcel 用户这段代码几乎不需要动脑子就能读懂。我实际迁移的时候大部分简单的固定表头导出只需要替换 import 和入口类模型部分基本原样保留。这也是我下定决心全量切换的最大原因。这里多说一句流式写出的意义在于它不会把整张工作表都建模在内存里而是边写边刷到输出流。所以即便数据量到几万、几十万行内存曲线也能保持平稳。但这个前提是你的业务层不要一次性把全量数据载入内存要老老实实分批查、分批写。2.3 最小导入程序监听器模型没有大变导入比导出更容易引发争论尤其是大文件解析必须逐行读取时。EasyExcel 的模型是通过AnalysisEventListener逐行回调Fesod 的监听器模型几乎一模一样public class OrderImportListener extends AnalysisEventListenerOrderImportDTO { private final ListOrderImportDTO rows new ArrayList(); Override public void invoke(OrderImportDTO data, AnalysisContext context) { rows.add(data); } Override public void doAfterAllAnalysed(AnalysisContext context) { // 批量校验、落库 } } Fesod.read(file.getInputStream()) .head(OrderImportDTO.class) .sheet(明细) .registerReadListener(new OrderImportListener()) .doRead();在 EasyExcel 中你是这样写的在 Fesod 里还是这样写。不同的是迁移之后我没有再遇到解析到一半突然冒出来的反射类字段问题。对团队而言学习成本趋近于零对负责人而言少一个“薛定谔的异常”就是实打实的收益。2.4 为什么“factory”报错在这里变少了我说过NoSuchFieldError: factory的根源是 ASM 版本混乱。Fesod 在设计上把字段映射尽量收敛到注解解析和内存缓存而不是运行期动态生成代理类这从源头减少了“字段在当前版本中不存在”的触发概率。我用mvn dependency:tree对比过迁移前后的依赖树Excel 相关的传递依赖从几十条锐减到个位数这个变化是我能直观感知到的。当然我不敢说任何库都绝对不会有冲突。但依赖数量越少版本被“污染”的可能性越低这本身就是工程上的确定性。3. 复杂表头、嵌套List、单元格换行三个高频需求逐个过3.1 复杂表头注解加编程式头双管齐下复杂表头通常分为两种静态多级表头和运行期动态变化的表头。静态多级表头用注解就能表达格式是ExcelProperty(value {父列, 子列})public class MultiHeadVO { ExcelProperty(value {订单, 订单号}) private String orderNo; ExcelProperty(value {订单, 下单时间}) private LocalDateTime createTime; ExcelProperty(value {商品, 商品名称}) private String productName; }这里需要注意父列的单元格在导出时会自动合并子列的数量决定父列纵跨的行数。如果你的表头层级比较深建议在实体类上同时指定headRowHeight否则生成的文件里表头会比较拥挤。动态表头才是真正让人头疼的比如运营后台列设置是存在数据库里的同一张报表今天导出可能是五列明天可能是八列。这种场景下注解完全失效必须用编程式表头。Fesod 支持传入ListListString作为表头和 EasyExcel 的做法一致但我要分享一个经验**所有表头字段不要散落在业务代码里先定义成常量列表再根据查询结果决定是否追加列。**不然维护起来会非常痛苦。我的一段真实代码大概是这样的ListListString head new ArrayList(); head.add(List.of(订单, 订单号)); head.add(List.of(订单, 下单时间)); ListString configCols columnConfigService.listEnabledColumns(); for (ColumnConfig col : configCols) { head.add(List.of(动态扩展, col.getName())); } Fesod.write(out) .head(head) .sheet(动态报表) .doWrite(exportRows);3.2 嵌套List用 rowHandler 自己控制扩展行“Java easyexcel 如何渲染嵌套list”这个话题在社区里被问过无数次。场景长这样一个订单有多个商品导出时希望订单号只出现一次下方展开所有商品明细。EasyExcel 默认不会展开内嵌 List 字段大多数情况下它会将 List 序列化成[xxx, yyy]这样的字符串或者直接类型转换报错。传统解法是把嵌套对象打平成扁平结构比如一个订单有两条商品记录就生成两行数据订单号重复了两次。这能解决“能不能导”的问题但打印格式很难看。你还需要自己记住哪些行属于同一个父对象导出后再补合并单元格。Fesod 提供了一种让我更舒服的写法通过rowHandler在回调里控制行号。每个订单占用几行完全由我自己决定Fesod.write(out) .sheet(订单明细) .head(ORDER_HEAD) .rows(orderList, (order, row) - { row.cell(order.getOrderNo()); row.cell(order.getCreateTime()); for (OrderItem item : order.getItems()) { row.nextRow(); row.cell(item.getSku()); row.cell(item.getQuantity()); row.cell(item.getPrice()); } }) .finish();这段代码的逻辑很直观先写订单主信息然后遍历商品列表每写一个商品就nextRow()跳到下一行。好处是业务模型不用为了 Excel 格式而扁平化一对多的结构在代码里保留得很清晰。这里要特别提醒一个容易踩的坑row对象代表的是“当前行上下文”调用nextRow()之后之前那一行的引用就失效了。不要在循环外把row存进另一个集合等循环结束后再写数据那会全部写到同一行。我在团队代码评审里专门加过这条规则。3.3 单元格换行和数据没关系是样式没开“easyexcel单元格换行”这个词条搜出来十个帖子有八个是同一个原因字符串里明明有\n导出后却还是一整行或者导出后变成了一行带框但没换行。真相是 Excel 默认不会把单元格里的换行符渲染成视觉换行必须开启wrapText属性。如果你是在代码里写样式需要给对应列或单元格设置WriteCellStyle style WriteCellStyle.builder() .wrapText(true) .build();如果你是用模板填充最省事的方法是在 Excel 模板里手动设置目标单元格格式勾选“自动换行”这样代码里根本不用管样式。还有一个容易被忽略的细节即使开启了自动换行如果模板里预先设置了固定行高Excel 也不会自动撑高单元格显示上依然像没换行。需要同步检查行高设置把固定行高改成自动行高。4. 模板填充里的合并单元格到底怎么才能不翻车4.1 模板占位符的玩法模板填充适合业务单据比如对账单、回执单、发货单。这类文件的版式几乎不变只是数据在变。做法是在 Excel 模板里写好占位符代码读取模板后把真实数据替换进去。模板里写占位符的规则和 EasyExcel 很接近单个字段用{字段名}包起来比如在 A1 单元格写{clientName}。Java 侧只需要准备一个 Map 或对象MapString, Object data new HashMap(); data.put(clientName, 上海XX贸易公司); data.put(billingNo, BO-20250212-001); Fesod.fill(templateInputStream) .sheet(对账单) .doFill(data);如果你的模板里有一段循环数据比如一个客户下有多条明细通常会在模板中写{.project}、{.amount}这类带点号的占位符表示“当前循环对象的 project 字段”。这种写法对从 EasyExcel 迁移过来的人非常友好几乎不需要重新学习。4.2 合并单元格在循环填充中的位移问题搜索词里有“easyexcel使用模板填充的合并”这个场景十有八九是同一个坑模板预先把A1:F1合并了占位符也写在合并区域里。单独填充一条数据没问题但一旦要对一个 List 做循环填充模板里的合并区域就会“漂移”。为什么因为模板里合并区域是静态坐标。循环填充时库需要根据模板中原有的行数推算下一行数据的位置但合并区域的坐标是固定的当它复制或移动时原有的大标题合并区会被当成普通单元格处理甚至被后续行覆盖。我实际踩过的坑是这样的模板第一行是大标题合并区第二行是表头第三行开始放 List 占位符。我用doFill(ListMap)填充后打开生成的 Excel发现第一行的大标题只剩第一列有字后面的列全变成了空白。排查发现是循环填充把合并区域的位置算错了整个表头都跟着偏了。4.3 我最终采用的方案试了几轮之后我定了一个原则模板只负责静态版式动态数据的合并统一交给代码。具体来说模板里保留标题区、表头区、备注区这些固定位置数据区不预置任何合并单元格。所有合并操作在代码里显式声明。这样虽然牺牲了一点“模板即结果”的便利但换来了完全可控的行为。我的代码大致是这样Fesod.write(out) .sheet(对账单) .head(headRows) .rows(billList, (bill, row) - { row.cell(bill.getProject()); row.cell(bill.getAmount()); row.cell(bill.getRemark()); }) .merge(0, 0, 0, 5) // 第一行 A:F 合并 .merge(1, 0, 1, 5) // 第二行 A:F 合并 .finish();如果你坚持要在模板里画好合并区域需要你对底层实现有足够深入的了解否则不可控因素太多。从工程稳定性角度看代码显式合并是更稳妥的选择。这个经验我在多个项目里反复验证过。5. 从EasyExcel迁到Fesod的踩坑清单与调优记录5.1 依赖替换和POI冲突处理迁移的第一步是把 Maven 依赖从 EasyExcel 换成 Fesod/Fastexcel 的坐标。这里有一条铁律**新旧 Excel 库不要同时出现在一个模块里。**否则两个库会各自往类路径里塞 AOP/反射工具类和 POI 样式类运行时很容易出现“方法找不到”或“字段找不到”的更诡异问题。我给团队的迁移顺序是先在一个报表模块的独立分支上做实验不要全量替换。跑通基础导入导出用例确保核心流程没有回归。再覆盖复杂表头、模板填充、动态合并等特殊场景。最后统一替换其他模块并在 CI 中加入导出快照对比测试。依赖冲突处理上不管新旧库都要跑一遍mvn dependency:tree。我在迁移时发现项目里有一个旧组件直接引用了 POI 4.1.2而 Fesod 默认使用 POI 5.x。解决办法是把旧组件里的 POI 排除收敛到统一版本。不要试图靠某个dependencyManagement强行固定先看清楚到底是谁引进来的。5.2 大数据量导出内存和写出速度实测我专门做了一个 10 万行、20 列的导出压测数据从数据库分批查询每批 1000 行写入输出流。结果是单 Sheet 导出全程内存平稳没有出现 OOM。相比 EasyExcelFesod 的 GC 压力更小一些因为它在写行时创建的中间对象更少。这里说一下我的真实数据场景数据量耗时头部内存占用EasyExcel(3.3.x)分批导出10万行 x 20列约 9.8s平稳轻微锯齿Fesod分批导出10万行 x 20列约 8.2s平稳曲线更平耗时和内存会受机器配置和 JVM 参数影响这个表不代表绝对性能排序。我更看重的是内存曲线的稳定性因为服务端导出场景最怕的就是“平时没事高峰 OOM”。不过我必须强调库再省内存也架不住业务层把全量数据查出来再导。我在代码规范里强制要求**导出接口一律分批查询每批 1000 行立即写出不要组装成全量 List 再交给 Excel 库。**这才是大数据导出的首要优化手段。5.3 适配层设计把导入导出服务隔离成抽象接口迁移过程中最大的收获是让我意识到业务代码不应该直接依赖任何 Excel 库。以前项目里到处是EasyExcel.write(...)、new AnalysisEventListener散落得乱七八糟。换库时这些代码全部要改成本极高。这次重构后我抽了一个ReportExporter接口public interface ReportExporter { void export(OutputStream out, ReportQuery query); T ListT importExcel(InputStream in, ClassT headClass); }业务层只依赖这个接口实现类内部才去使用 Fesod。这样以后即使再换库业务层几乎零改动。很多团队担心迁移成本其实最大的成本不是“换依赖”而是之前没有收口导致每个业务方法都直接和某个库耦合。把这块收敛之后切换成本会大幅下降。5.4 细节字体、图片和libfreetype6的处理这次迁移也让我把libfreetype6的问题彻底解决了。Fesod 在图片和字体渲染上的原生依赖路径比老库浅不少至少在打印普通文字、生成简单图片时不需要额外安装 freetype。当然如果你的业务要导出复杂图表或者嵌入特殊字体我仍然建议在容器里装好fontconfig和基础字体这是绕不开的系统层准备。我最终的 Dockerfile 基础镜像仍然保留了libfreetype6 fontconfig但这不是因为 Excel 库而是因为报表里确实要渲染二维码。如果你只是纯表格导出大概率不需要动镜像。6. 给还在EasyExcel里的团队一句劝如果你是在新项目选型我的态度很明确别再纠结要不要选 EasyExcel 了。它曾经很好但维护状态的不确定性带来的隐性成本会在未来版本升级时集中爆发。选 Fesod 这类更活跃、依赖更精简的方案至少未来两三年不会因为“下次升级未卜”而焦虑。如果你已经在生产环境跑着 EasyExcel 的稳定项目那也不用恐慌。表格导入导出只是整个系统里的一个小板块只要它没出问题你完全可以继续用。但如果你的项目已经开始出现我前面说过的几个信号——依赖冲突排查频繁、模板合并问题堆积、JVM 版本升级时对 Excel 模块提心吊胆——那就该认真做一次切换评估了。最后说一点个人体会。迁移本身不复杂真正麻烦的是团队对未知的恐惧。我们总习惯给存量代码找各种“不能动”的理由却忽略了稳定代价。当我把导出模块从 EasyExcel 迁到 Fesod又把所有导入导出逻辑收口到适配层之后下一次哪怕再换库也就是换一个实现类的事。数据模型和业务规则始终是我们自己的Excel 读写库只是通道保持这个通道可替换才是这次经历带给我最大的收获。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价