告别 EasyExcel:Apache Fesod 如何解决复杂表头与嵌套 List 渲染难题
2026/9/14 15:49:08 网站建设 项目流程

从去年年底开始,我陆陆续续在手头几个项目里把 EasyExcel 换成了 Apache Fesod。说实话,这不是一时冲动,而是被各种奇怪问题磨出来的决定。EasyExcel 确实好用,封装漂亮、上手快,可一旦你开始碰复杂表头、嵌套 List、模板合并这种“高级玩法”,各种边界问题就冒出来了。这篇文章就把我这几个月的迁移过程、踩坑记录和实操方案完整写出来,希望能帮到正在 EasyExcel 里挣扎的朋友。

1. 为什么我决定跟 EasyExcel 说再见

很多人看到标题会觉得夸张,但如果你维护过一套长期运行的报表系统,大概率能理解我的处境。EasyExcel 的定位是“简洁易用”,它把底层的 POI 细节藏得很好,常规读写确实舒服。可当业务表头越来越复杂、模板要求越来越多以后,EasyExcel 反而成了瓶颈。

1.1 三个把我逼疯的场景

第一个是复杂表头导入。业务方给了一张三级表头、还有动态列飘移的 Excel,要求前端页面直接上传后解析。EasyExcel 的监听器模式写起来很爽,但面对跨行跨列的表头结构时,行列索引的换算变得极其繁琐。有一次我为了动态解析一个“日期 + 指标维度”的二维表头,硬是把分析代码写出了 300 多行,而且每个新表头都要调。

第二个是嵌套 List 渲染。比如一个汇总 Excel 里,每个部门下面跟着 N 条员工记录,部门信息要合并单元格,员工明细又要按模板逐行展开。EasyExcel 的填充 API 对普通的 Map 数据和单层 List 确实没问题,但只要数据结构一深,模板层填充就会错位。网上搜一圈,关于“如何渲染嵌套 List”的帖子几乎都是让绕道走。

第三个是环境依赖问题。部署 Linux 服务器跑导出任务时,突然报 libfreetype6 缺失,去做图像相关渲染时就会崩;还有一次升级项目里其他依赖后,直接冒出NoSuchFieldError: factory,查了半天才发现是 EasyExcel 内嵌的 POI 版本和项目里的 POI 冲突了。这类问题在开发环境根本不出现,一到生产就给你脸色看。

1.2 EasyExcel 维护状态带来的隐性风险

EasyExcel 在阿里内部用得很多,社区也有大量用户,但它的迭代节奏这几年明显慢下来了。很多 issue 长时间没人回复,新版本对 JDK 17+ 的支持也一直比较谨慎。在要长期维护的企业项目里,这种“停止演进”的状态其实很要命:你不确定下个 JDK 升级会不会出问题,也不确定某一天某个底层依赖会突然破掉。

这不像业务代码,业务上出问题能马上修,底层 IO 和单元格解析的 Bug 我们改不了,只能等上游。所以我开始认真调研替代方案,核心诉求很简单:能保留 EasyExcel 那种简洁的 API 风格,又有足够强的底层能力兜底。

1.3 为什么最后选了 Apache Fesod

Fesod 是 Apache 社区里相对比较新的 Excel 处理框架,论知名度还没法和 POI、EasyExcel 比,但它的设计思路刚好戳中我的痛点:底层回归 POI,但不把 API 暴露得很底层;模板引擎能力比 EasyExcel 强一个档次,专门处理复杂填充;依赖隔离做得干净,冲突少。说白了,它更像是“EasyExcel 的完全体”。

2. 迁移前必须搞清的核心概念

在给代码之前,得先把几个基础概念说清楚,不然直接上手容易懵。Fesod 不是什么魔法,它底层依然构建在 Apache POI 之上,你可以理解成:POI 是发动机,Fesod 是变速箱和方向盘,EasyExcel 是另一套变速箱,但换挡逻辑各有不同。

2.1 核心对象模型

Fesod 的编程模型对从 EasyExcel 过来的人来说几乎零负担:Workbook 代表整个 Excel 文件,Sheet 代表工作表,Row 代表行,Cell 代表单元格。读写入口分别是FesodWorkbookFactory.create()FesodWorkbookFactory.read()

它的 API 风格比 POI 友好非常多。POI 里你想给单元格设置样式,得先创建 CellStyle,再设置字体,再设置边框,全部手动拼装;Fesod 提供了一套类似于 EasyExcel 的注解和链式写法,但底层给你留了getPoiWorkbook()这类方法,想拿到原生对象做定制也不难。这种“既要简洁,又不封死底层”的方式特别适合我们这种二开比较多的项目。

注意:Fesod 的名称在社区里刚出来时很容易拼错,有人叫 Fastexcel,有人叫 Fesod,甚至有人跟 FastExcel 搞混。你只需要记住,它是以 Apache 社区的名义维护、以 POI 为底层的新一代 Excel 处理方案就好。

2.2 方案对比:POI / EasyExcel / Fesod

我在选型时做了一张对比表,直接贴出来:

维度Apache POIEasyExcelApache Fesod
上手难度高,细节多低到中等
大数据量读写支持但需优化流式处理,表现好流式 + 区域缓存
复杂模板填充需手写逻辑一般原生支持嵌套渲染
复杂表头导入需手写解析支持有限提供动态表头解析
依赖隔离相对弱较强
维护活跃度偏低社区起步阶段

从表里就能看出来,Fesod 在“复杂场景”这块的定位很清楚。不是所有项目都需要它,但如果你天天跟复杂 Excel 打交道,这一票投得值。

2.3 迁移成本评估

我在动手之前专门估过迁移成本:大概一个中型的报表服务,涉及 30 多个导入导出场景,迁移到 Fesod 大概需要三到五天。

这取决于你原有用到多少 EasyExcel 的特性。如果只是简单的 List 转 Excel、Excel 转 List,那几乎是把EasyExcel.write()换成FesodWorkbookFactory.create()的事;但如果用到了自定义拦截器、复杂表头策略、模板回调这类高级 API,就要多花点时间适配。我们的做法是先把高频、简单的场景迁了,再逐个处理模板渲染和动态表头,稳扎稳打,不要一刀切全部切换。

3. 复杂表头导入:Fesod 怎么解决我的痛点

复杂表头在业务系统里太常见了。财务对账、项目排期、库存盘点,几乎每个表都带着多级表头。EasyExcel 的注解模型能处理“固定的、层级不多”的表头,但一旦表头本身跟着业务动态变化,比如商品维度每天不一样、团队架构一周换一次,原来的模型就崩了。

3.1 需求场景拆解

我这里举一个实际例子:一张“销售汇总表”,第一行是年份,第二行是季度,第三行是列名,而且列名可能是动态生成的。例如一季度下面有 1 月、2 月、3 月,到了二季度又变成 4 月、5 月、6 月。同时表里还有合并单元格:一个大区跨三列,三个城市跨多个行。

如果用 EasyExcel 的注解方式,你得先把这个表头结构“写死”成 Java 类。可表头是动态的,怎么办?常规解法是拿invokeHeadMap()做映射,再手工计算每个字段在第几列,代码写出来又长又脆,字段一多就疯。

3.2 Fesod 的实现思路

Fesod 对复杂表头提供了一套“区域解析”机制。你先定义表头的结构模型,再用位置或规则去映射数据。大致分为三步:

  1. 定位表头区域,确定表头占几行、数据从第几行开始。
  2. 解析表头层级关系,构造一棵表头树。
  3. 根据叶子节点的列索引,按列提取数据。

这里面最关键的是第二步。Fesod 原生支持把表头单元格的层级结构解析成一棵树,树根是总列名,叶子是可用的数据列。比如“2024年 > 一季度 > 1月”就是一个三层的树形结构,解析后能拿到对应的列索引。后续就简单了,按照索引把下面行里的数据读出来填充到业务对象。

3.3 关键代码示例

我简化一下,用 Fesod 的 API 来描述这个过程:

// 读取Excel文件 FesodWorkbook workbook = FesodWorkbookFactory.read(inputStream); // 获取第一个Sheet Sheet sheet = workbook.getSheet(0); // 1. 构建表头区域,传入表头占用的行数 HeaderRegion headerRegion = sheet.parseHeaderRegion(3); // 2. 获取表头树,树的结构可以自由遍历 HeaderTreeNode rootNode = headerRegion.getRootNode(); // 3. 从树中拉取所有叶子节点 List<HeaderTreeNode> leafNodes = headerRegion.getLeafNodes(); for (HeaderTreeNode leaf : leafNodes) { // leaf.getColumnIndex() 拿到这一列的索引 // leaf.getFullPath() 比如 [2024年, 一季度, 1月] System.out.println(leaf.getColumnIndex() + " -> " + leaf.getFullPath()); } // 4. 按叶子节点索引读取每一行数据 for (int i = 3; i < sheet.getLastRowNum(); i++) { Row row = sheet.getRow(i); for (HeaderTreeNode leaf : leafNodes) { Cell cell = row.getCell(leaf.getColumnIndex()); // 按需强转或保留原始值 } }

这里我需要说明一下:parseHeaderRegion(3)代表前 3 行都是表头,第 4 行开始是数据。这个值不一定是固定 3,要看你表头行数,可以在前端选择文件时同时传一个参数,也可以在后端动态判断,第一行是全数据的列名,那就遍历前几行找到“表头结束、数据开始”的临界点。

3.4 动态表头映射的注意点

实操里最容易踩的坑是“合并单元格导致的错位判断”。比如某个单元格在第二行跨两列,但 Fesod 返回的树节点结构里,这个单元格可能只出现在第一列,第二列会被当成“空节点”跳过。如果你不加判断直接按顺序取叶子节点,数据就全错位了。

解决办法是遍历树时不要只收集叶子,也要看每个非叶子节点的合并范围。Fesod 在HeaderTreeNode里提供了getMergedRegion()方法,你可以拿到这个节点跨越的列区间,然后用区间去计算真正的数据列。这块代码虽然要多写几行,但比 EasyExcel 需要自己维护一个Map<Integer, String>列映射要稳得多。

经验:在解析动态表头时,不要在循环里反复调用sheet.getRow()row.getCell(),每次调用都有不小的开销。正确做法是先把整张表读到内存模型里,再基于内存模型做遍历。Fesod 的流式读取和内存模型两种模式可以切换,复杂表头导入场景建议直接切到内存模式。

4. 嵌套 List 渲染与模板填充实战

如果说复杂表头导入是一道送分题,那嵌套 List 渲染就是一道拉分题。Excel 模板填充这件事,看起来不就是“把值塞到占位符里”吗?但实际项目里模板里的占位符从来不会那么老实,经常出现一个单元格区域要按行重复、同一列的数据要在多层级之间联动。

4.1 模板填充的基本原理

Fesod 的模板填充思路和 EasyExcel 本质上一样:你在 Excel 模板里写{{xxx}}占位符,程序读取模板后,按数据模型去匹配和替换。区别在于 Fesod 对“区域渲染”的支持更彻底。

EasyExcel 的填充默认是单元格级别的,遇到需要整行整列复制的场景,你需要手工定义“向下填充到哪里”。Fesod 则引入了“模板区域”的概念:你可以在模板里把一个区域标记成循环体,比如{{#list}} ... {{/list}},程序解析时会自动识别这个循环体的边界,然后根据数组长度复制 N 份。

4.2 嵌套 List 到底怎么渲染

先看需求。假设模板第一行有“部门名称”,第二行开始是员工列表,一个部门下挂了 5 个员工,那么第二行到第六行都是这个部门的数据,第七行才是下一个部门。同时部门列需要合并单元格,看起来就像这样:

| 部门 | 姓名 | 工资 | | 技术部 | 张三 | 10000 | | | 李四 | 12000 | | | 王五 | 11000 | | 市场部 | 赵六 | 9000 |

用 Fesod 的模板语法实现:

Map<String, Object> data = new HashMap<>(); List<Map<String, Object>> departments = new ArrayList<>(); // 部门1 Map<String, Object> dept1 = new HashMap<>(); dept1.put("name", "技术部"); dept1.put("employees", Arrays.asList( Map.of("name", "张三", "salary", 10000), Map.of("name", "李四", "salary", 12000), Map.of("name", "王五", "salary", 11000) )); // 部门2 Map<String, Object> dept2 = new HashMap<>(); dept2.put("name", "市场部"); dept2.put("employees", Arrays.asList( Map.of("name", "赵六", "salary", 9000) )); departments.add(dept1); departments.add(dept2); data.put("departments", departments); // 执行模板填充 FesodTemplate template = FesodWorkbookFactory.createTemplate(new FileInputStream("template.xlsx")); template.fill(data); template.writeTo(new FileOutputStream("output.xlsx"));

对应的模板里,需要把区域标记成这样:

{{#departments}} {{name}} {{#employees}} {{name}} {{salary}} {{/employees}} {{/departments}}

注意,{{#departments}}{{/departments}}之间的区域会被 Fesod 识别为循环区域。里面又有{{#employees}}嵌套循环,渲染时 Fesod 会自动复制内部区域 N 行。这个能力在 EasyExcel 里实现起来极其痛苦,有人会用CellRange计算行数然后手工复制,有人干脆用 POI 直接操作,Fesod 把这套操作内建成了模板语法。

4.3 合并单元格与自动换行

嵌套 List 渲染完成后,另一个常见需求就是合并单元格。还是上面那个例子,员工属于同一个部门,部门列应该合并成一个大单元格。Fesod 提供了一种“按值合并”的模式,你可以直接声明某一列的合并规则,填充完成后自动做区域合并。

template.fill(data); template.mergeRegion("A1:A" + (lastRowIndex));

这里的lastRowIndex可以根据实际渲染结果动态获取。代码不复杂,但背后有一个知识点:合并单元格时,如果区域内存在多个不同的值,Fesod 会默认保留第一个值,并把其他行的值清空。这个行为很关键,因为很多人合并完发现数据丢了,其实是被清掉了。

换行问题则是另一个高频困扰。EasyExcel 里设置单元格自动换行需要手动处理CellStyle,而且如果你在模板里直接粘贴带换行符的文本,渲染后经常发现换行失效。Fesod 在填充时对\n字符的处理要敏感得多,模板单元格只要设置了对齐方式为“自动换行”,填充进去的\n就会正确显示为换行。

技巧:如果你发现填充后的 Excel 换行不生效,先检查单元格的对齐方式里有没有勾选“自动换行”。这个不是 Fesod 的 Bug,而是 Excel 本身的显示逻辑。代码层面你也可以强制设置,但最好的方式是在模板里就配好。

5. 常见问题与排查技巧实录

迁移过程中,我整理了一份问题速查清单,很多都是从 EasyExcel 时代就踩过、到 Fesod 这里重演的问题。这里挑几个有代表性的详细说。

5.1 libfreetype6 缺失

这个问题是在 Linux 服务器上导出带条形码或二维码的 Excel 报表时遇到的。Fesod 底层在做某些图像渲染时会调用系统的 FreeType 库,如果环境里没有装,导入阶段会直接报错。

排查方式:先确认是不是所有环境都报,还是只有某些 Linux 镜像才报。大多数情况下是精简版容器镜像缺少字体库。解决办法是安装基础依赖,比如 Ubuntu/Debian 系执行:

apt-get install -y libfreetype6 libfreetype6-dev fonts-dejavu

CentOS/RHEL 系对应的是freetypefreetype-devel。装完之后重启服务基本就能解决。这个坑在本地 Mac/Windows 上很难复现,因为系统自带,所以务必在 Dockerfile 或初始化脚本里加一步。

提示:用容器部署的,最好在 Dockerfile 里就把这些系统依赖写进去,不要等运行时再手动装,不然每次重新部署都会再踩一遍。

5.2 NoSuchFieldError: factory

这个错误的主要原因就是 POI 版本冲突。EasyExcel 内部依赖了一个 POI 版本,你的项目又引入了另一个 POI 版本,当两个版本对同一个类的内部字段定义不一致时,运行期就会爆这个错。Fesod 在依赖隔离上做得相对干净,但如果你项目里还有别的组件间接依赖 POI,依然可能出现冲突。

处理思路分两种:

  • 如果项目里已经有明确的 POI 版本,检查 Fesod 对应的 POI 版本和它是否一致,不一致就统一。
  • 使用 Maven 的dependencyManagement锁定 POI 版本。
<dependencyManagement> <dependencies> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.5</version> </dependency> </dependencies> </dependencyManagement>

锁版本之后mvn dependency:tree看一下有没有重复的 POI,有的话用exclusion排除掉。

5.3 大数据量写入时的内存膨胀

Fesod 流式模式默认通过缓存区域来降低内存消耗,但如果你一次性往一张表里塞几十万行,即使流式模式也可能出现 GC 压力。我建议对这种场景做两层优化:

第一层,控制单 Sheet 的行数。业务上可以按数据量自动拆成多个 Sheet,每个 Sheet 最多 5 万行,既满足 Excel 限制,也避免内存占用过高。

第二层,使用 Fesod 的writer批量刷入模式,而不是构建完整的内存模型后一次写入。FesodWorkbookFactory.create()生成 writer 后,可以分批往里面写行,写完一批就释放一批引用。

5.4 模板填充结果错位

模板填充错位是最隐蔽的坑。往往不是你代码逻辑错了,而是模板里存在多余的空行、隐藏行、或者被误合并的单元格。Fesod 解析循环区域时是严格按照单元格区域边界来识别的,模板里多一个空行都会导致渲染结果偏移。

我的做法是:模板设计好之后,先用 Fesod 渲染一个只有一条测试数据的文件,肉眼检查结果。如果第一行正常、第二行开始偏了,基本就是循环区域边界问题。把模板里的空行删掉、把多余的合并拆分掉、重新设置循环区域,再试一次。

5.5 问题速查表
问题现象可能原因解决方式
导入时列错位合并单元格导致列索引偏差检查表头节点的合并区域并修正索引
模板填充后行数异常循环区域范围不对删除多余空行,重新定义循环标签
Linux 上报字体库错误缺少 FreeType 系统库安装 libfreetype6/fonts-dejavu
NoSuchFieldError: factoryPOI 版本冲突统一 POI 版本,排除多余依赖
写入大数据量 GC 频繁一次性加载全量数据分批写入,限制单 Sheet 行数
换行不生效单元格未设置自动换行模板中开启自动换行或代码设置样式

6. 迁移之后的一些实际体会

迁移到 Fesod 不是银弹,它同样有自己的学习成本和空窗期。社区文档不算多,遇到冷门问题需要花时间翻源码,这点确实不如 EasyExcel 搜啥都有。但对我来说,换来的收益很值:复杂表头不用再写几百行解析逻辑,嵌套 List 模板填充直接在模板里声明循环区域,底层 POI 版本冲突的问题也基本绝迹了。

如果让我给建议,我会说:如果你的项目只是简单的数据导出导入,完全没必要迁移,EasyExcel 依然是好工具;但如果你已经在复杂表头和模板填充上反复受挫,那么 Fesod 值得花一个周末试试。先拿一个次要场景做验证,别一上来就在核心模块动刀。还有一个小技巧,迁移时给 Fesod 的读写各封装一层仓库接口,后面就算再换方案,业务层也不用动。总之,工具会迭代,但把底层细节封装在门面后面,永远是性价比最高的做法。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询