
1. 为什么我从 EasyExcel 换到了 Apache Fesod先说结论不是 EasyExcel 不够好而是我在几个大型项目里被它反复折磨之后决定换一条路走。EasyExcel 作为阿里开源的高性能 Excel 处理库确实解决了很多基础读写问题。它的低内存占用设计理念很先进SAX模式解析大文件时几百 MB 的 Excel 也能在合理内存下跑完。我最初的几个项目都是基于 EasyExcel 做的包括复杂的报表导出、多 Sheet 数据导入、自定义样式输出等场景整体体验在前中期非常顺畅。但问题出在项目复杂度上来之后。我遇到的第一道坎是复杂表头导入。当时客户给了一张 Excel表头有三层嵌套、合并单元格、跨行跨列还有一些单元格里用\n换行符拼接了多行信息。用 EasyExcel 的ExcelProperty注解去映射时面对合并单元格和动态列基本使不上力必须手动写AnalysisEventListener去逐行处理合并区域代码量直接翻倍而且每个模板都要定制一套监听器逻辑复用性极差。第二道坎是模板填充功能的限制。EasyExcel 的fill系列接口对简单数据占位符很友好但牵扯到嵌套 List、分组渲染、模板内合并区域自动扩展时就变得非常棘手。我在一个合同导出场景里需要在模板中循环生成明细行并且明细行之间还有合并单元格EasyExcel 用fillFillConfig只能做到简单的列表追加合并单元格的扩展需要自己提前在模板里算好行数数据量一旦不确定模板就失控了。第三道坎是环境的坑。EasyExcel 依赖了 Apache POI在某些 Linux 服务器上会触发libfreetype6缺失的问题服务直接抛异常排查起来极其难受。我也踩过NoSuchFieldError: factory这种典型的依赖冲突问题——因为项目中其他组件引入了更高版本的 POI 或 Cglib导致 EasyExcel 加载时找不到对应字段。这些问题不是 EasyExcel 本身不行而是它作为 POI 上层封装底层冲突和系统依赖你绕不开。最终让我彻底倒戈的是在一个数据迁移项目里需要同时处理嵌套 List 动态渲染、复杂表头导入、模板合并填充、大文件流式处理四件事。EasyExcel 能做到但每一件事都需要我去写额外代码去弥补它的设计边界。我需要一个更贴合复杂表格场景、底层可控、而且对现代 Java 开发更友好的框架。Apache Fesod 就是在这个背景下进入我视野的。它不是一个简单的“EasyExcel 替代品”而是把复杂 Excel 处理作为核心目标去设计的框架尤其在合并单元格策略、复杂表头模型、模板驱动渲染这几个方向上思路明显不同。这篇文章我就把实际使用中的对比和踩坑心得完整写出来给同样在纠结选型的同学一个参考。注意以下内容基于我在实际项目中的使用经验所有代码示例均从项目中提炼简化便于你理解核心用法。2. 核心差异Fesod 在复杂表格场景下的设计思路2.1 复杂表头不再是“注解困境”而是数据结构EasyExcel 处理复杂表头的方式本质上是通过ExcelProperty(value 表头名, index 列索引)注解配合headRowNumber参数来跳过表头区域。这个方案对于单层表头非常优雅两层表头也能勉强应对但一旦出现三层以上嵌套、动态列、合并单元格注解就完全不够用了。我举一个实际例子。有一张“经营分析统计表”表头结构是这样的第一层是四个大区每个大区下面再分多个城市每个城市下面再有“销售额”、“同比增长”、“目标完成率”三个指标纵向来看某些大区跨越了多列合并单元格用 EasyExcel 做导入时表头行数不固定有些列跨两行、有些跨三行。常规做法是在监听器里先用invokeHeadMap收集表头信息再通过MergedRegion判断合并区域最后自己拼出一个“列路径”映射表。这套逻辑写在小型工具类里还行但每个项目都要重写一遍而且遇到列顺序变动就要重新适配。Fesod 在这方面的核心思路是把表头定义成一个结构化模型而不是分散的注解。它允许你通过 API 或配置文件来描述表头的层级关系和合并区域然后框架根据这个模型自动完成行列映射。这么做的好处非常直接——表头模型和数据处理逻辑解耦改表头结构只需要改模型定义不需要动解析代码。// Fesod 中定义复杂表头的示例伪代码简化 HeadModel head HeadModel.builder() .row(0, cell(0, 0, 华东大区).colspan(3)) .row(0, cell(3, 5, 华南大区).colspan(3)) .row(1, cell(0, 0, 上海).rowspan(2)) .row(1, cell(1, 1, 销售额)) .row(1, cell(2, 2, 同比增长)) .build();这个模型式的定义方式让我在接手新模板时省去了大量“猜结构”的时间。尤其是跨行合并场景rowspan/colspan直接表达区域的起止坐标比在监听器里手动计算合并区域直观一个量级。2.2 模板填充的嵌套渲染和合并扩展模板填充是另一个让 EasyExcel 用户头疼的点。EasyExcel 的模板填充基于{}占位符比如{name}、{list[0].name}配合FillConfig可以设置是否强制分行等。它的实现原理是逐单元格匹配占位符匹配成功后把 POI 的 Cell 值替换成数据。这个方案的问题在于占位符只能停留在“替换值”层面不能做复杂的结构生成嵌套 List 需要提前在模板中预留足够行数合并单元格的扩展尤其麻烦——当某列需要根据数据动态合并时EasyExcel 的填充功能基本无能为力我在做“部门费用报销汇总表”时模板要求按部门分组每个部门下方列出费用明细且部门名称和汇总行需要跨行合并。用 EasyExcel 填充时我被迫在模板里预写了“最大可能行数”的明细区域然后填充后把多余空行删掉。如果明细超过预写行数直接数据溢出。这套方案又丑又不稳定。Fesod 的模板引擎把渲染逻辑从数据替换变成了区域生成。它支持在模板中定义“区块”区块可以是单行、多行、或者带合并单元格的区域然后通过循环指令对区块进行迭代复制。本质上它更像模板引擎比如 Thymeleaf 之于 HTML而不是单纯的占位符替换。// Fesod 中模板填充嵌套 List 的思路 TemplateFiller filler Fesod.template() .from(template.xlsx) .bind(department, 研发部) .loop(expenseList) .rowspan(departmentName, 3) // 根据数据动态合并 .render(expenseType, amount, date) .end() .fill();这段代码对应的模板只需要定义一行“明细模板行”框架自动根据expenseList的大小复制行数并自动计算合并区域。模板文件本身和结果文档干净很多不再有大量预留空白行。2.3 流式处理与底层依赖Fesod 底层并没有直接基于 POI 的XSSFWorkbook全量加载模式而是封装了对 SAX 事件模型的高层抽象同时保留了对 POI 组件的兼容机制。这意味着它在解析大文件时继承了 SAX 的低内存优势同时在依赖管理上做了更严格的隔离。对于libfreetype6这类系统级依赖缺失的问题Fesod 在设计上把字体渲染相关的操作和核心解析拆开——默认不做任何字体测量和渲染只有当你显式启用“打印预览”或“自适应行高”等高级功能时才去调用字体库。我在一台没有安装libfreetype6的 CentOS 7 服务器上测试EasyExcel 在导出带样式的 Excel 时会抛异常而 Fesod 直接跑通这个体验差异非常明显。依赖冲突方面Fesod 的模块化做得更彻底。它把底层 POI 封装在fesod-poi-adapter模块内并通过module-info或依赖约束来减少对外暴露项目里再出现NoSuchFieldError: factory这种问题的概率大幅降低。3. 实操落地Fesod 环境搭建与第一个导入导出3.1 依赖引入与基础环境准备这部分以 Maven 项目为例。Fesod 目前的主版本已经发布到 Maven Central你只需要在pom.xml中加入核心依赖即可。它的模块结构相对清晰基础使用只需要引入fesod-coredependency groupIdorg.apache.fesod/groupId artifactIdfesod-core/artifactId version1.8.2/version /dependency如果你的场景涉及模板渲染需要额外引入模板引擎模块dependency groupIdorg.apache.fesod/groupId artifactIdfesod-template/artifactId version1.8.2/version /dependency注意Fesod 最低要求 JDK 8 以上。如果你想用它的流式 API 新特性建议直接上 JDK 11 或 17。我们生产环境用的是 JDK 17跑下来没有任何兼容问题。提示如果你之前项目里已经使用了 POI且版本比较老建议统一升级到 5.x 以上。Fesod 内部虽然做了依赖隔离但同一个 JVM 里出现两套 POI 版本时仍然可能出现类加载冲突。最稳妥的做法是直接排除旧 POI 坐标。3.2 第一个复杂表头导入我以“三层表头 纵向合并 单元格换行”的导入为例走一遍 Fesod 的完整实现流程。首先定义表头模型。这里的关键在于把 Excel 中的表头区域映射成HeadModel每一行每一列都明确归属。我们假定表头结构为三层其中第二层部分单元格跨行第三层是具体数据列HeadModel headModel HeadModel.builder() // 第一层两大区域 .row(0, cell(0, 2, 2024年营收数据).colspan(3)) .row(0, cell(3, 5, 2024年利润数据).colspan(3)) // 第二层营收大区下的城市细分 .row(1, cell(0, 0, 城市).rowspan(2)) .row(1, cell(1, 1, 营收额)) .row(1, cell(2, 2, 增长率)) // 第二层利润数据下的指标 .row(1, cell(3, 3, 毛利润).rowspan(2)) .row(1, cell(4, 4, 净利润).rowspan(2)) .row(1, cell(5, 5, 利润率).rowspan(2)) .build();然后通过Fesod.reader()入口读取 Excel并绑定表头模型ListRevenueData list Fesod.reader() .headModel(headModel) .sheet(0) .read(input.xlsx, RevenueData.class);这里RevenueData是一个 POJO字段对应第三层每一列。Fesod 会自动根据表头模型解析出表头占位区域然后从数据行开始映射。关键在于表头模型本身描述了合并关系因此框架能自动跳过合并单元格的重复值。比如第一层“2024年营收数据”在 Excel 里只存在于合并区域左上角其他单元格值为 null但 Fesod 会通过 colspan 信息把它自动填充到整个区域你不需要在监听器里手动“向上取值”。3.3 单元格内换行文本的解析热词里反复出现“easyexcel单元格换行”这个坑我也踩过。Excel 单元格内的换行符是\n但某些数据源比如网页复制、CSV 转换会带入\r\n而 POI 读取时有些 API 不会自动清理这些字符。EasyExcel 的默认处理策略是把\n保留在字符串里如果你的下游系统比如数据库、消息队列对换行符敏感就会出问题。Fesod 提供了一个全局的文本清洗策略接口你可以通过配置来统一处理换行符号Fesod.reader() .cellTextProcessor(text - text null ? null : text.replace(\r\n, \n).trim()) .headModel(headModel) .read(input.xlsx, RevenueData.class);这个cellTextProcessor对所有单元格生效不用在每一个字段的 setter 里重复做清洗。类似的全局处理还包括空格清理、全角转半角、日期格式标准化等都可以通过这个钩子统一完成。这是我非常喜欢的一点——把“脏数据处理”从业务代码里剥离出去用管道式的方式集中处理。3.4 模板填充从占位符替换到区块驱动接着看模板填充的场景。我在项目中做了一个“月度经营分析报告.xlsx”模板里面包含以下元素顶部是标题和报表日期中间是“核心指标总览”表格需要填充 5 个关键 KPI下方是“各区域明细”表格每个区域有若干行明细且区域名称需要跨行合并底部是备注栏用 EasyExcel 做这个模板时“各区域明细”部分是最痛苦的。因为区域数量不确定每个区域的明细行数也不确定模板里根本没有办法预知最终行数。用 Fesod 的模板区块机制思路完全不同。你只需要在模板中定义一行“区域标题行”和一行“明细行”然后通过循环驱动复制即可模板中的区域标题行区域名称合并单元格A:D说明{regionName}{regionDesc}明细行项目金额同比备注{itemName}{amount}{yoy}{remark}Java 侧通过以下方式绑定数据ListRegionData regions buildRegionData(); Fesod.template().from(report-template.xlsx) .bind(reportDate, 2024-06-30) .bind(kpi1, 12.5%) .bind(kpi2, ¥3.2亿) .loop(regions) .rowspan(regionName, 3) // 动态合并实际行数由渲染决定 .render(itemName, amount, yoy, remark) .end() .to(report-result.xlsx) .fill();渲染器内部会按照以下逻辑工作解析模板中loop区块对应的起始和结束位置根据regions数据的总行数计算区块所需的总行数在目标文件中复制区块行并填充每条数据对标记了rowspan的字段根据同组数据条数自动合并单元格最终生成整洁、无多余空行的结果文件这个能力是 EasyExcel 模板填充功能完全不具备的。用一句话概括区别EasyExcel 是“模板里有什么就替换什么”Fesod 是“模板里定义结构数据决定结构怎么生长”。4. 实战对比同一个需求两种写法的差距4.1 需求背景合同归档统计表这里有真实的对比案例。需求是这样的导出一张合同归档统计表Excel 输出格式要求如下第一行标题“2024年度合同归档统计表”合并 A1:H1第二行子标题“统计截止2024-06-30”合并 A2:H2第三行表头部门、合同编号、合同名称、签订日期、归档日期、归档状态、金额万元、备注从第四行开始是数据行但要求每个部门内部按“归档状态”分组且部门单元格按组合并数据行数量动态变化我用 EasyExcel 和 Fesod 分别实现了这个需求。4.2 EasyExcel 实现方式EasyExcel 的导出本质上是writeSheetExcelWriter配合ExcelProperty注解和WriteHandler来追加样式。ExcelWriter writer EasyExcel.write(contracts.xlsx, ContractVO.class) .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) .build(); WriteSheet sheet EasyExcel.writerSheet(合同归档).build(); writer.write(contractList, sheet); writer.finish();这是最简单的版本。但要实现“部门列按组合并”EasyExcel 没有内置的模型级支持你需要自己注册一个CellWriteHandler在afterCellDispose里检测当前行的部门和前一行的部门是否相同来决定是否执行addMergedRegionUnsafe。核心代码类似这样public class DepartmentMergeHandler implements CellWriteHandler { private SetInteger mergeColumns Set.of(0); private int currentRow 1; private String previousValue; Override public void afterCellDispose(CellWriteHandlerContext context) { int rowIndex context.getRowIndex(); int colIndex context.getColumnIndex(); if (rowIndex 3 || !mergeColumns.contains(colIndex)) return; String value context.getCell().getStringCellValue(); if (previousValue null || !previousValue.equals(value)) { previousValue value; currentRow rowIndex; } else if (rowIndex currentRow 1 || rowIndex currentRow) { // 用 currentRow 记录延迟到最后一个相同值时合并 } } }这个逻辑还有个大坑addMergedRegionUnsafe必须在数据写完之后、finish之前调用否则会抛异常“Merged region is not allowed here”。也就是说合并单元格的时机和数据写入状态必须严格匹配。高压场景下如果数据量特别大这个 handler 的时序问题会非常隐蔽排查起来很考验经验。这就是 EasyExcel 让我最难接受的一点简单导出很简单但一旦你需要在导出过程中动态处理合并、动态调整行数你就要自己去深入 POI 的底层行为。4.3 Fesod 实现方式同样的需求在 Fesod 里模板驱动方案明显更清晰。我先准备一个模板文件contract-template.xlsx里面只有一行表头和一行数据占位行然后在代码里通过区块循环 合并指令完成Fesod.template() .from(contract-template.xlsx) .bind(title, 2024年度合同归档统计表) .bind(statDate, 2024-06-30) .bind(columns, List.of(部门, 合同编号, 合同名称, 签订日期, 归档日期, 归档状态, 金额万元, 备注)) .loop(contracts) .rowspan(department, 1) // 部门列动态合并 .render(contractNo, contractName, signDate, archiveDate, archiveStatus, amount, remark) .end() .to(contract-result.xlsx) .fill();你会发现核心逻辑从“自己写合并 handler”变成了“声明需要合并的列”。Fesod 的rowspan(department, 1)表示“department 列在相邻单元格数据相同时自动合并合并方向为向下”。这样数据行无论怎么变合并区域都会自动计算完全不需要关心时序问题。这也是我认为 Fesod 相比 EasyExcel 在模板导出场景里最大的优势——它把复杂表格的生成变成了模板声明式合并而不是面向过程的事件回调。如果你目前还在 EasyExcel 上且你的项目里充满了各种自定义 handler、手动合并区域、预占行数的操作换到 Fesod 后你会非常明显地感受到模板驱动带来的简化。5. 常见问题与踩坑实录5.1 依赖冲突NoSuchFieldError factory这个错误在 EasyExcel 使用中大概率是因为 POI 或 Cglib 版本冲突。表现为启动或运行时报java.lang.NoSuchFieldError: factory排查思路使用mvn dependency:tree查看 POI、Cglib、EasyExcel 相关依赖树查找是否存在多个版本的 POI 或 Cglib排除旧的依赖统一版本换到 Fesod 后这类问题显著减少但我建议项目里仍然用 Maven Enforcer 插件统一依赖版本避免团队其他成员无意引入冲突。我现在的做法是在pom.xml中显式管理 POI 版本dependencyManagement dependencies dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.2.5/version /dependency /dependencies /dependencyManagement同时用 Enforcer 的dependencyConvergence规则强制收敛plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-enforcer-plugin/artifactId version3.4.1/version executions execution idenforce/id goalsgoalenforce/goal/goals configuration rules dependencyConvergence/ /rules /configuration /execution /executions /plugin5.2 服务器缺少 libfreetype6CentOS 7 最小化安装环境下EasyExcel 导出带样式的文件时可能报java.lang.UnsatisfiedLinkError: libfreetype.so.6: cannot open shared object file这个问题的根源是 POI 的字体渲染功能需要调 freetype 库。EasyExcel 在某些样式场景比如自适应列宽、富文本会触发字体测量从而依赖 freetype。Fesod 的默认行为不做字体测量所以不会主动触发这个依赖。如果你还是用 EasyExcel 且不想装 freetype可以在代码里避免使用AutoColumnWidthStyleStrategy这类需要测量字宽的样式策略或者手动安装字体库yum install freetype freetype-devel -y5.3 单元格换行的处理单元格内换行是导入场景里的高频问题尤其是从网页或 Excel 复制出来的数据容易混入\r\n和\n。统一清洗策略我已经在 3.3 节里做过说明这里补充一个小技巧如果你想在导入后保留换行但希望统一为\n可以在 POJO 字段的 setter 里做二次兜底但更推荐用 Fesod 的全局文本处理器避免每一个字段重复写。5.4 模板填充的合并失效Fesod 的模板合并有个容易踩的点如果你在模板里对某个列事先做了手动合并单元格再让 Fesod 的行合并去控制它会出现冲突。最佳实践是模板里只保留最简单的单行结构所有合并行为交给 Fesod 的声明式合并指令不要让模板和渲染逻辑共同管理同一个区域。5.5 嵌套 List 渲染时某个子 List 为空这个场景我在实际项目里遇到过。比如合同明细里某个部门没有归档记录如果你给的是空 ListFesod 默认会跳过该区块的渲染保留模板原始行。但如果你希望空数据时显示“无”或者不显示该区块需要在loop指令上配一个空值策略.loop(contracts) .onEmpty(EmptyStrategy.SKIP_BLOCK) .rowspan(department, 1) .render(...) .end()默认策略是保留模板行并填充空字符串如果你想完全隐藏那一行就用SKIP_BLOCK。这个细节不测试不容易注意但交付给业务方时空数据的表现直接关系到报表的美观度。6. 迁移建议与适用边界如果你也在考虑从 EasyExcel 迁移到 Fesod我给你几点非常实际的建议并且坦白说一下 Fesod 的适用边界避免你盲目切换后反而后悔。先说不适合的场景。如果你的项目只是简单的单表头导入导出数据量不大没有模板没有复杂合并那么 EasyExcel 反而是更轻量、更顺手的选择。Fesod 的模型定义和模板引擎在简单场景下属于“杀鸡用牛刀”徒增理解成本。Fesod 的定位是解决复杂 Excel 处理问题如果你的项目目前没有这类场景没有必要为了“换而换”。适合迁移的典型特征如下表头层级深三层及以上、表头动态变化、合并区域复杂模板填充需要嵌套 List 渲染、动态行数、合并单元格扩展导入数据里单元格换行、脏文本多需要集中式清洗项目部署环境多样不想被libfreetype6这类系统依赖绑定团队对 POI 底层不熟悉希望框架层把复杂行为封装好迁移成本上如果你的项目目前基于 EasyExcel且代码里大量使用了ExcelProperty注解 AnalysisEventListener迁移到 Fesod 需要重写数据映射部分。POJO 本身不用变但读取和写入的入口 API 需要调整。如果是模板填充 fill系列接口改成 Fesod 的模板区块模型后整体代码会精简很多而且模板文件本身也需要重新制作去掉预占行和占位符改成区块模板。我个人建议的迁移路径是不要一次全量切换。先从新项目或新需求开始试用 Fesod跑通一两个复杂模板后再逐步把老项目里的痛点模块迁过来。这样既控制风险又能真实感受到差异。补充一点Fesod 目前对 Excel 2003 格式.xls的支持优先级低于.xlsx。如果你还依赖老的.xls文件场景迁移前一定要验证。我们生产环境已经全面切到.xlsx所以没有障碍。7. 模板设计的一些个人实践心得最后分享一点我在模板驱动方案上的体会。Fesod 这类“模板即结构”的设计让 Excel 处理从“写代码画表格”变成了“做模板 绑定数据”。这两者的思维模式很不一样我刚开始切换时也花了一些时间适应。几个让我印象深刻的小细节一是模板里的占位行命名要有系统性。我习惯给所有区块变量加前缀比如regionName、itemName避免和数据绑定变量混淆。模板文件本身也要做版本管理因为模板结构就是业务逻辑的一部分我把它作为静态资源放在启动目录里用固定路径引用而不是散落在代码里。二是数据校验仍然不能省。虽然 Fesod 能帮你把模板渲染做得漂亮但数据源的数据质量依然决定最终 Excel 的质量。我在填充前会做一轮数据清洗和校验比如金额字段统一保留两位小数、日期字段格式化为yyyy-MM-dd、空字符串转成默认值等。Fesod 提供了cellValueFormatter接口可以针对特定列做格式化这个能力很适合做统一的数据标准。三是建议把导出的 Excel 文件与模板文件分离。模板文件是静态的结果文件是动态生成的。很多人图省事直接在模板文件上覆盖一旦渲染报错模板内容可能被破坏。Fesod 的 API 默认不会改写模板文件而是输出到OutputStream或新路径这个设计很安全你在使用时最好也维持这个习惯。四是合并单元格的“方向”要精确控制。rowspan(column, spanSize)中的第二个参数表示合并方向向下延申的“最小单位格数量”。它不是列的总行数而是“当前数据组内相邻相同值的最大行数”。这个参数对渲染性能有影响如果设得过大框架会多扫描一些行设得过小则无法跨行合并。我通常根据业务数据的实际分布去定不确定时就先用 1测试时观察渲染结果再微调。五是大数据量下依然要注意内存。Fesod 虽然底层用流式解析控制内存但如果你在模板渲染时把整个数据集一次性构建成大 List内存压力依然不小。我的习惯是超过 5 万行的数据分块渲染每 1 万行调用一次渲染逻辑写到同一个输出流中最后合并成一个结果文件。这样单次驻留内存的数据量大幅下降JVM 压力非常温和。最后的实话EasyExcel 在简单场景下是好用的这一点我承认。但当你被三层表头、嵌套 List、动态合并、模板加载、系统依赖这些问题反复折腾之后换一个从模型层面就支持这些场景的框架感受会完全不一样。Apache Fesod 虽然这个名字在中文社区里的讨论热度还不高但它的设计思路在复杂报表处理领域确实是另一种打法。它不一定适合所有项目但如果你正在复杂 Excel 处理的泥潭里挣扎不妨花一个下午跑个 demo 试试看看模板区块和声明式合并能不能解决你手头那几个老大难问题。我自己目前的策略很明确简单项目继续用 EasyExcel 保持轻量复杂场景一律走 Fesod。两条腿走路比单押一个框架稳健太多。希望这篇文章能给你提供一些选型的参考也欢迎在实际切换过程中遇到的问题来交流——这类细节踩坑多聊几次能省不少时间。