去年下半年,我把项目里所有 Excel 导入导出相关的代码,从 EasyExcel 整体迁移到了 Apache Fesod。这不是标题党,是真刀真枪换了一遍。项目里有一个报表模块,需要处理多级表头、动态列、多数据块模板填充、单元格合并这些场景。用 EasyExcel 的时候,功能确实能实现,但代码越写越别扭,各种自定义策略类和注解反射让我排查问题的成本直线上升。换到 Fesod 之后,最直观的感受是:复杂 Excel 操作终于可以用一种可读、可控、可维护的方式写了。这篇博文就来完整复盘这次迁移,包括我从 EasyExcel 切走的真实原因、两者在底层设计上的差异、迁移的关键步骤,以及我在新方案里遇到的坑和解决方案。如果你也在被复杂 Excel 需求反复折腾,这篇内容应该能给你不少参考。
1. 为什么我从 EasyExcel 切到了 Apache Fesod
1.1 EasyExcel 确实优秀,但它的边界问题越来越明显
先说清楚,EasyExcel 不是一个烂工具。恰恰相反,它在简单导入导出场景下的体验是非常好的,注解一键映射、几行代码搞定读写、低内存的 SAX 读取模式,这些都是实打实的优势。我最早接手报表模块时也是直接用 EasyExcel 快速交付了第一版。但用了两年多之后,我发现项目里越来越多的需求开始逼近 EasyExcel 的能力边界,而且每逼近一次,就要绕一次路。
第一个痛点是复杂的表头导入。EasyExcel 的注解映射方式在处理单层表头、标准二维表时非常顺手,但遇到三级以上的复合表头、动态列、表头里带合并单元格这种需求时,@ExcelProperty注解就变得非常笨拙。你需要拆父类、拆子类、理解索引分配规则,最后代码里全是注解,业务逻辑反而被淹没了。更要命的是,动态列的场景几乎没法用注解舒服地解决,只能退回到AnalysisEventListener手动拼行数据,那 EasyExcel 的"配置化"优势就完全没了。
第二个痛点是模板填充。我们模块里大量使用 Excel 模板,需要在预制的模板里填数据、带合并单元格、带嵌套列表。EasyExcel 的模板填充功能说实话一直比较"原教旨":支持最简单的占位符替换,也支持{xxx}这种列表扩展,但一旦模板里合并单元格和数据列表同时存在,填充结果经常出现错位。你可能只是想让列表从第二行开始扩展,结果合并单元格区域纹丝不动,数据却挤出了格子。至于嵌套 List 渲染这种需求,EasyExcel 用起来相当费劲,模板里要精心设计布局,代码侧还要自己计算行偏移。
第三个是版本升级的阵痛。不知道读者有没有遇到过NoSuchFieldError: factory这种运行时反射异常。这是因为 EasyExcel 底层依赖 Apache POI,而 POI 的版本迭代非常激进,EasyExcel 在多个版本里对 POI 内部 API 有依赖,一旦你的项目里其他库把 POI 版本拉到新版本,或者本身就是版本冲突,运行时就会炸。我踩过不止一次,每次都得在dependency:tree里翻半天才能定位问题。
1.2 Fesod 解决了我最头疼的几个问题
Apache Fesod 是一款基于 Apache POI 底层能力构建的 Excel 处理框架,设计目标很直接:把 POI 强大但繁琐的原生 API 包一层更现代、更显式的壳,同时保留对复杂结构的完全控制权。我最早接触它是在一个开源社区的项目里,后来发现它处理复杂表头和模板渲染的能力非常符合我的需求,才决定在正式项目中试水。
Fesod 和 EasyExcel 最核心的设计差异在于:EasyExcel 偏向"注解驱动 + 约定优于配置",你告诉它类和字段,它帮你完成映射;而 Fesod 偏向"流式操作 + 显式控制",你直接操作行、列、单元格、合并区域、样式,每一步都看得见摸得着。对于简单场景,前者确实快,对于复杂场景,后者的可控性优势就体现出来了——不需要反编译去看注解处理逻辑,不需要猜测框架内部对合并区域做了什么事,代码本身就是最直接的描述。
另外 Fesod 对模板填充和合并单元格的处理机制更成熟。它把"模板渲染"和"区域合并"拆成了独立的能力节点,数据动态扩展时合并区域可以跟随数据行同步调整,这就避免了我之前用 EasyExcel 时那种"手动算行号然后补合并"的脏代码。嵌套 List 的渲染,Fesod 提供递归渲染模型,一个订单下挂多个商品明细的这种结构在模板里可以直接声明子数据块,代码侧不需要自己维护复杂的行偏移计数器。
2. 复杂表头与合并单元格:两种方案的底层逻辑差异
2.1 注解映射的便捷,反而成了复杂表头的瓶颈
我用 EasyExcel 写过三级表头,读者应该能理解那种痛苦。第一级是大类,第二级是分组,第三级才是真正的数据列。用注解实现时,你要建三个 Java 类,每个类继承上一层类,然后在子类字段上用@ExcelProperty(value = "三级名字", index = 2)这种方式,靠索引把每一列的归属关系理清楚。代码写完主体部分之后,你还需要为每个合并单元格写策略类,在CellWriteHandler里逐个判断当前输出位置是不是需要合并的表头行。
这种方案的第一个问题是:表头一旦是动态的,比如后端返回的列的集合内容是不确定的,你就无法用固定注解类表达,必须动态构建表头。EasyExcel 里动态表头可以传入List<List<String>>的 head 参数,但一旦动态列和合并单元格结合,你又要同时写afterCellDisposed的合并策略,代码复杂度明显上升。我印象最深的一次,一个动态列加多行表头合并的需求,我写了将近 200 行策略代码,后面维护的人看着直摇头。
Fesod 的处理方式是直接面向"结构"操作。表头本质上就是一堆单元格和合并区域,你用 Fesod 的 API 创建一行、一行的单元格,设置样式,然后把需要合并的区域显式声明出来。比如我要做一个三级的动态表头,逻辑就变成:先计算列的总数量;然后创建第一行,写入大类名称;创建第二行,写入分组名称;创建第三行,写入具体的动态列名;最后用一个List<Region>把这些需要跨行跨列的单元格全部合并起来。这套模型的直观之处在于,你写的每一行代码都能对应到 Excel 里真实存在的一个元素,不像注解那样需要"翻译"。
2.2 合并单元格的本质和 Fesod 的合并模型
很多同学对合并单元格的理解停留在"把几个格子变成一个格子",其实 Excel 的合并机制包含两层:单元格内容区域合并和样式区域合并。如果你只是调用了合并区域的 API,但是没有把边框、背景色应用到整个合并区域的所有单元格,那合并后视觉上还是会出现半截边框或颜色断层。EasyExcel 的注解式合并策略经常会遇到这个问题,因为它自动合并时会默认只把第一个单元格的样式带过去。
Fesod 的合并模型对我这种有"强迫症"的开发者就友好得多。它要求你在创建合并区域时,同时定义这个区域内所有单元格的样式,你可以理解为Region本身携带了一套样式模板,区域内所有格子都会应用这套模板。这个设计逻辑和 POI 的 setRegionStyle 思路是一致的,只是 Fesod 把它放到了更早的建模阶段,不需要你在合并完成之后再补一轮样式轮询。
我举一个实际例子。我们的月度报表表头是这样的:第一行是"XX 集团月度经营分析表",横跨全表;第二行是"编制部门:财务部"和"编制日期:2025-XX-XX",分别跨几列。以前用 EasyExcel,这种"单行横跨 + 局部跨列"的组合要写两个策略类,还要处理不同列宽下的边框补偿。换到 Fesod 以后,我只需要先创建一行一行的单元格,然后定义两个Region,一个region(0,0,0,colCount-1)用于标题,一个region(1,0,1,2)和region(1,3,1,5)用于部门信息。代码清晰到可以直接当文档用。
2.3 嵌套 List 渲染:填模板时最容易翻车的场景
嵌套 List 渲染是我这次迁移的一个重要触发点。业务上非常常见:导出一份订单,表头是订单信息,下面跟着这个订单的所有商品明细。用一个 Excel 模板表示,就是模板第一块区域写订单号、客户名、下单时间,第二块区域一个商品列表的表头加一行占位符,期望渲染时订单数据填一次,商品列表根据订单下商品数量动态撑开。
以前用 EasyExcel 的模板填充做这个,最麻烦的是如何控制"订单数据只填第一行"而"商品数据从第二行开始动态展开"。EasyExcel 的默认逻辑是如果模板里有{.list}这种列表表达式,它会把整个模板区域按照列表长度循环平移,但平移过程中如果你在模板里合并过单元格,合并区域不一定跟着平移。我曾经遇到过合并边框错乱、数据从中间某一行开始填充这种诡异问题,调试起来非常费力。
Fesod 对嵌套渲染的处理思路完全不同。它不依赖模板里的特殊占位符语法去做自动平移,而是把模板中每个"数据块"抽象成可递归渲染的单元。主数据(订单)是一个块,子数据(商品明细)是另一个块,子块挂在主块内部的某个"槽位"上。渲染时 Fesod 会先计算子块渲染后的行数,再动态扩展现有区域,并在扩展过程中自动把合并区域同步拉伸或复制。我用这套机制重写订单导出之后,代码少了一百多行,逻辑反而更清晰了。
3. 迁移实操:从 EasyExcel 到 Fesod 的关键步骤
3.1 依赖配置:先解决 POI 版本冲突
迁移的第一步是调整依赖。EasyExcel 内部会传递引入 Apache POI 的某些版本,Fesod 同样构建在 POI 之上,所以两块同时出现在项目里时,很容易因为 POI 版本不一致或类被重复加载出现诡异问题。我的建议是:迁移期间直接去掉 EasyExcel 的依赖,统一由 Fesod 引入它期望的 POI 版本。
在pom.xml里,我最终保留了 Fesod 的依赖,并对 POI 相关依赖做了显式声明和版本锁定。具体版本号会根据你使用的 Fesod 版本对应,我这边是用 Maven 的 properties 统一管理:
<properties> <poi.version>5.2.5</poi.version> </properties> <dependencies> <dependency> <groupId>org.apache.fesod</groupId> <artifactId>fesod-core</artifactId> <version>2.1.0</version> </dependency> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi</artifactId> <version>${poi.version}</version> </dependency> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>${poi.version}</version> </dependency> </dependencies>如果项目里还有别的库也依赖 POI,建议先在根 POM 里用dependencyManagement把版本锁死,避免子模块各自拉版本。排查依赖树用mvn dependency:tree -Dincludes=org.apache.poi,能快速看到哪些路径有重复。这一点操作其实和用 EasyExcel 时遇到的问题完全一样,只是 Fesod 暴露出来的 POI 依赖路径更干净,没有额外的阴影包,定位起来更直接。
3.2 导出改造:从注解映射到流式工作簿
最常见的导出场景从 EasyExcel 迁移到 Fesod,核心变化是从"类注解映射"变成"手动建模"。EasyExcel 导出的典型写法是:
EasyExcel.write(outputStream, UserVO.class) .sheet("用户列表") .doWrite(userList);这段代码在 UserVO 上有一堆@ExcelProperty注解,框架自动读取字段名和注解属性,完成表头和数据区的渲染。迁移到 Fesod 之后,同样的导出我可以这样写:
try (FesodWorkbook workbook = FesodWorkbook.create()) { FesodSheet sheet = workbook.createSheet("用户列表"); // 设置表头 FesodRow headerRow = sheet.row(0); headerRow.cell(0, "用户ID").style(headerStyle); headerRow.cell(1, "用户姓名").style(headerStyle); headerRow.cell(2, "注册时间").style(headerStyle); // 填充数据 int rowIndex = 1; for (UserVO user : userList) { FesodRow dataRow = sheet.row(rowIndex++); dataRow.cell(0, user.getId()); dataRow.cell(1, user.getName()); dataRow.cell(2, user.getRegisterTime()); } workbook.write(outputStream); }这段代码看起来比 EasyExcel 啰嗦,但好处是每一步都明确。如果你觉得手动写太累,Fesod 也支持基于 Java Bean 的字段映射,只需要传入一个配置好的MappingModel,但我在实际项目中还是倾向于至少表头部分手动建模。因为自动映射在字段少时很香,字段一多、需求一变,注解和实体的耦合问题又会回来。
3.3 导入改造:从监听器回调到流式解析
导入模型的迁移逻辑类似。EasyExcel 导入的标准姿势是写一个AnalysisEventListener,在invoke方法里一条一条处理读取到的行数据:
EasyExcel.read(inputStream, UserVO.class, new AnalysisEventListener<UserVO>() { @Override public void invoke(UserVO user, AnalysisContext context) { list.add(user); } @Override public void doAfterAllAnalysed(AnalysisContext context) { // 全部读完 } }).sheet().doRead();这种监听器模型本身不复杂,但有一个让我头疼的问题:如果你想在读取过程中动态判断某列的数据格式,或者某行的列数和其他行不同,你需要在invoke里不断拼接上下文信息,代码会越来越乱。Fesod 的导入解析是流式事件模型,你可以返回布尔值决定是否继续读取,也可以直接拿到原始单元格对象:
FesodReader reader = FesodReader.create(inputStream); reader.sheet("用户列表", (rowIndex, row) -> { if (rowIndex == 0) { return; // 跳过表头行 } String id = row.cell(0).asString(); String name = row.cell(1).asString(); // 逐行处理数据 }); reader.process();这里row.cell(index)返回的是一个FesodCell对象,你可以调用.asString()、.asInt()、.asDate()等方法做类型转换,也可以调用cell.isMerged()判断这个单元格是否是合并单元格的一部分。这种能力在做复杂表头导入时非常关键——系统里经常出现"合并单元格跨三行,只需要在第一次出现时读取数据"这样的需求,EasyExcel 需要你自行判断行号,Fesod 直接给了原生的判断方法。
3.4 模板填充场景的重写:从占位符到数据块
模板填充是这次迁移收益最明显的地方。传统的 EasyExcel 模板填充大概长这样:
Map<String, Object> data = new HashMap<>(); data.put("orderNo", "ORD20250101"); data.put("customerName", "张三"); data.put("productList", productList); EasyExcel.write(outputStream).withTemplate(templateStream) .sheet().doFill(data);这个方案能跑通简单场景,但遇到模板里有合并单元格块、嵌套数据块时就非常难受。Fesod 的模板引擎提供的方案是"数据块 + 槽位",我在实际项目中用下来感觉这套思路更接近"把模板当作一个结构化容器"。
举个具体例子。模板第一行是订单编号占位,第二行到第四行是一个合并单元格区域"收货地址",第五行开始是商品明细表头,第六行是商品明细占位行。Fesod 里你可以在模板中定义一个主数据块和一个子数据块,子块挂载到主块的下方,并且声明商品明细列表和订单的映射关系:
TemplateModel model = TemplateModel.create(templateStream); model.set("orderNo", "ORD20250101"); model.set("receiverAddress", "北京市海淀区XX路XX号"); // 这是一个嵌套数据块:loopItems 对应模板中定义的子块占位 model.block("items", productList, (item, context) -> { context.set("productCode", item.getCode()); context.set("productName", item.getName()); context.set("quantity", item.getQuantity()); }); model.renderTo(outputStream);实际渲染时,Fesod 会根据items区块渲染出的行数,自动调整后续内容的位置和已经存在的合并区域范围。它会把模板中预置的商品明细表头区域保留在原位,然后在数据区逐行展开。我们订单导出模板里有一个跨 5 列、跨 1 行的表头合并区,以前用 EasyExcel 填充这个模板时,数据行一多表头就错位,换到 Fesod 之后没再出过问题。
4. 踩坑实录:迁移过程中遇到的典型问题
4.1 NoSuchFieldError: factory 的根因和排查思路
迁移过程中,我第一周就被一个运行时异常卡住了:项目启动后执行导出功能,直接抛NoSuchFieldError: factory。这个异常是典型的类加载冲突——某个类在编译时依赖的字段在运行时环境中被替换成了没有该字段的版本。
EasyExcel 时代就遇到过这个问题,当时是 EasyExcel 自带了一个旧版本的 POI 类,和项目里另一个库引入的新版本 POI 冲突。理论上迁移到 Fesod 后,依赖应该更干净,但我又踩了一次,这次是项目内的一个报表工具包传递依赖了 POI 3.17,而 Fesod 需要 POI 5.x。排查方法其实就三件事:
第一,用mvn dependency:tree查看所有 POI 相关依赖路径,重点看哪些包的groupId是org.apache.poi,确认有没有多个版本。第二,如果出现多个版本,在根dependencyManagement里统一版本号。第三,检查运行时 ClassLoader 实际加载的是哪个版本的类,可以直接在启动参数里加-verbose:class,也可以写一段临时代码打印XSSFWorkbook.class.getProtectionDomain().getCodeSource().getLocation()。
我这次定位到问题后,把项目里所有显式引入的 POI 坐标全部锁到 5.2.5,问题立即消失。有一点提醒大家:Fesod 对 POI 版本的兼容范围有限,不要盲目升级到最新版,先查看 Fesod 官方文档的版本矩阵,再决定锁定哪个 POI 版本。
4.2 Linux 服务器上字体渲染报错和 libfreetype6
第二个坑是服务器环境的问题。本地开发在 Windows 上一切正常,部署到 Linux 容器后,导出小文件的 Excel 时偶尔报错,日志里能看到java.lang.UnsatisfiedLinkError,指向 freetype 相关方法。这个问题不是 EasyExcel 独有,所有基于 POI 的框架都会碰到——POI 在计算单元格宽度、行高、字体渲染时需要调用系统字体库,Linux 环境下如果缺少对应的 freetype 库,就会导致渲染失败。
那次排查花了不少时间。因为报错信息有时不在导出方法里,而是在后续的workbook.write(outputStream)阶段,非常隐晦。后来在容器里执行ldconfig -p | grep freetype,发现容器镜像里根本没有libfreetype6。解决方案也简单,Dockerfile 里加上一行:
RUN apt-get update && apt-get install -y libfreetype6 fontconfig如果是 CentOS 系,对应的是yum install freetype fontconfig。除了运行库,字体本身也建议装一下,比如fonts-dejavu-core,避免生成的 Excel 在服务器端处理时中文变成方块。我自己在 Dockerfile 里固定装了libfreetype6和fonts-dejavu-core之后,这个报错再没出现过。
4.3 单元格换行后行高不自动撑开
部门里有人提交过一个工单,说导出的 Excel 里某列文字内容太长,单元格里明明设置了口自动换行,但行高没有跟着变,文字被截断了。这个问题表面上是"行高自适应"问题,实际上牵涉到 Excel 的行高计算逻辑。Excel 的行高默认是固定值,即使单元格开启了WrapText,也不会自动把行高撑到刚好容纳所有文字,而是需要显式设置行高。
用 EasyExcel 时也遇到过类似问题,当时我的解决方式是手工估算行高,按字符数除以每行可容纳字符数再乘单个行高。这种估算在字体和字号变化后经常不准。Fesod 里提供了一个更优雅的方案:在单元格样式设置时可以配置行高自动调整策略,它会基于单元格内容和字体度量计算期望行高:
style.wrapText(true).autoHeight(true);实测下来,Fesod 的行高计算在字体度量方面比我自己估算准得多,中文环境下基本不会再出现截断。有一点要注意,自动行高只对普通单元格有效,合并单元格区域内的行高自适应,Fesod 目前也只会针对合并区域的首行做计算,如果你在合并区域正中间的文字特别长,还是要手动指定整体行高。这也是合理的,因为 Excel 本身对合并区域的行高处理就有限制。
4.4 模板填充时合并区域和动态数据错位
最后说一个模板渲染层面的问题。我们在模板里设计了一个"备注"区域,这个区域跨了 4 列、占 2 行,合并单元格是预置在模板文件里的。需求是:每次填充数据时,备注文字长度不确定,希望备注区域能根据文字多少自动扩展高度,且底部的"编制/审核"行要自动往下顺延。
第一次用 Fesod 实现这个功能时,我天真的以为只要备注数据足够长,合并区域就会自动拉高。跑起来发现,里面的文字是被完整写入了,但合并高度不会自动增加,下边的"编制/审核"行直接顶在备注文字上,整个版面全乱了。
后面翻文档才发现,Fesod 虽然支持合并区域在数据块扩充时同步调整,但"区域高度跟随内容自动变化"这个功能需要你显式配置一个动态区域策略,你要告诉它:这个区域是可扩展的、优先级是高于后续行的、扩展之后后续行要往下平移。配置后效果就正常了:
DynamicRegion region = DynamicRegion.create() .range(5, 0, 6, 3) .expandWhenContentOverflows(true); model.addDynamicRegion(region);这里range里的参数是起始行、起始列、结束行、结束列,expandWhenContentOverflows控制内容溢出时是否自动撑高区域。我后来复盘了一下,这种"默认保守、显式启用动态扩展"的设计其实是对的,如果所有合并区域都默认跟随内容动,模板结构很容易被意外的数据破坏。只是文档里没有特别醒目地提到这个开关,我踩了一次坑才记住。
5. 迁移之后:关于选型和工具链的一些思考
5.1 从维护性角度看,显式 API 比注解更安全
整个迁移过程中,我翻看以前用 EasyExcel 写的代码,最大的问题是"隐式行为太多"。一个@ExcelProperty注解背后涉及索引分配、表头创建、类型转换、合并策略等多个环节,框架在运行时替你做掉了大量事情。这带来的结果是:代码短、开发快,但出问题时排查链路长、改动风险大。尤其是在多人协作的项目里,新来的同事面对一个带注解的实体类,很难快速判断它最终导出的 Excel 长什么样。
Fesod 这种流式 API 虽然代码量大一些,但胜在"所见即所得"。你在代码里看到的sheet.row(0).cell(0, "表头")就是 Excel 里的第一行第一列。这种显式化让 review 也变得更顺畅,我部门里代码评审时,大家看到 Fesod 代码基本不用多做解释,而以前 EasyExcel 的复杂策略经常要专门开会讲一讲。
5.2 哪些场景不建议迁移
当然不是所有项目都应该从 EasyExcel 换到 Fesod。如果你的业务场景就是简单的单表导入导出,表头固定、没有模板填充、没有动态列,那 EasyExcel 的简易性和低门槛依然是优势。迁移是有成本的,团队需要学习新 API、重写已有代码、重新做一轮回归测试。不要为了技术新鲜感去迁移一个运行良好的模块。
我的建议是:把"是否存在复杂表头、动态列、嵌套数据块、合并区域联动、模板引擎"作为评估标准。如果这些需求一个都没有,继续用 EasyExcel 完全没问题;一旦出现两个以上,就可以开始考虑 Fesod。我们报表模块就是典型案例,里面几乎每张表都有多级表头和合并单元格,换过去之后代码量虽然略增,但可维护性和稳定性都有明显提升。
5.3 一个小技巧:导出前先用可视化小工具验证模板结构
最后分享一个实操技巧。模板填充类需求调试时,最痛苦的是"代码写了但不知道模板到底长什么样"。我后来养成一个习惯:写模板时顺便用一个小工具把模板转成 JSON 结构,输出每个单元格的坐标、值、合并区域信息,类似这样:
TemplateInspector.inspect(templateStream, System.out); // 输出示例 // Row[0]: (0,0)="XX集团月度经营分析表", region=(0,0)-(0,5) // Row[1]: (1,0)="编制部门", (1,1)="财务部", region=(1,0)-(1,1)这个工具并不是 Fesod 自带的,是我基于 Fesod 的读取模型写的一个二十来行的调试方法。好处是你可以直观看到模板里哪些区域是合并的、哪些单元格有异常空值,排查错误的速度快好几倍。类似这种小工具,用 EasyExcel 的时候也可以写,只是没有 Fesod 的单元格对象那么方便。这一趟迁移下来,我对 Excel 处理工具的选型有了更务实的判断。凡事先想清楚需求边界,再决定用哪套工具,而不是别人说好用就直接上。如果你的项目正在被复杂的表头、合并单元格和嵌套模板折磨,我建议关注一下 Apache Fesod,也许它就是你缺的那块拼图。