从EasyExcel到Apache Fesod:复杂Excel表格处理的迁移实践 📅 发布时间:2026/9/14 16:18:02 👁 浏览次数: 先说下我的背景免得大家觉得我是拿了钱在写软文。我在一个做供应链系统的团队里日常导出报表、导入结算单、套打合同模板这类活儿几乎天天都有。用了差不多两年EasyExcel说实话它在常规需求上确实帮了大忙上手快、文档全、社区例子一堆。但真正把我逼走的是那些“常规需求之外”的边角场景多层嵌套表头的导入、模板里合并单元格后的动态填充、嵌套List渲染、NoSuchFieldError factory这种离奇报错。我花了一整个通宵去排查一个POI版本冲突第二天又花了一下午处理客户发来的带换行符的单元格读出来的数据列全乱了。那段时间我就在想有没有一个框架能在EasyExcel的“简单”和POI的“底层可控”之间再往表格语义化方向多走一步。后来我在开源社区翻到了Apache Fesod试用了一个多月完成了三个真实项目的迁移。这篇文章就把我迁移过程中的对比、踩坑、核心代码都掏出来给同样被复杂表格折磨过的Java后端开发做个参考。1. 用EasyExcel的日子顺手的部分和卡脖子的部分1.1 说真的EasyExcel并不差先给EasyExcel一个客观评价。它在常规的“对象列表导出成Excel”“简单表格导入成对象列表”这些场景下体验确实没得挑。你只需要定义一个实体类字段上加几个注解然后一行代码调用写操作或者读操作数据就进去了。它底层基于SAX模式解析读大文件时内存占用比POI的DOM模式低很多这也是它当年能火起来的关键原因。我在前一个项目里用EasyExcel做月度对账单导出几十万行数据分sheet写实测内存占用比之前用POI裸写少了一半还多。单论读写性能和API友好度EasyExcel在轻量场景里是合格的。但这个“合格”是有前提的——你的表结构要足够规整表头是单层的列顺序是固定的单元格里不会出现奇奇怪怪的换行和合并。一旦超出这个前提EasyExcel就开始变得不“Easy”了。1.2 让我下定决心换掉它的四件事第一件事是版本冲突。当时项目里另一个模块引了POI 5.xEasyExcel还在走3.x的POI 3.17依赖启动时直接抛NoSuchFieldError factory。这个报错网上搜一圈全是“降级POI版本”“排除依赖”之类的方案但降级POI又会影响到另一个模块的WPS转换功能。最后我只能单独给EasyExcel的依赖做隔离用Shade插件重写了POI包名。那几天我整个人都是麻的。第二件事是复杂表头导入。客户给的结算明细表表头有三层第一层是公司名合并单元格第二层是月份分组第三层才是字段名。EasyExcel的ExcelProperty注解从诞生起就是为“平铺表头”设计的多层表头最多通过value数组做标题嵌套导入时仍然期望底层字段是唯一的。客户那张表里不同分组下的“金额”字段标题一模一样导入解析直接乱套。第三件事是单元格内换行。表格里“备注”列经常有人用AltEnter换行读出来的字符串本身没问题但如果下游用固定列宽解析或者做行分隔就会踩坑。更麻烦的是合并单元格中的换行文本读取时合并区域的非首行返回的全是null你得自己写代码做“向下填充”逻辑。第四件事是模板填充时的合并单元格失效。我们做合同套打时模板里有一列是多行合并的“甲方签字”用EasyExcel的填充功能往里写数据后合并单元格的样式和边框全乱了。网上查了一圈发现这属于EasyExcel模板填充的一个老毛病官方Issue里挂了很久都没彻底修好。这四件事叠加起来我彻底放弃了“修修补补再三年”的念头开始认真调研Apache Fesod。2. Apache Fesod的设计思路它到底比EasyExcel强在哪2.1 它不是POI“换皮”而是换了一套思维第一次读Fesod文档时我最大的感受是它没有把Excel当成一个“二维单元格数组”而是当成一个“有结构的表格对象”。什么意思呢比如定义一个导入模板你需要描述的不仅是“哪个字段对应第几列”还要描述“表头从第几行开始”“哪些列属于同一个分组”“合并单元格应该由哪一列驱动向下填充”。这是一种表格语义化的建模思路。它把POI里关于行列坐标、合并区域、样式读写这些琐碎细节全部收拢到框架内部对外只暴露行、列、分组、层级这些业务概念。你写的代码不是“我在第3行第2列写个值”而是“我在‘金额’字段下写个值这个字段归属‘入库统计’分组表头合并由框架自动处理”。这个抽象层级恰好是EasyExcel没有做透、POI又不敢做得太上层的地方。Fesod选择把这一层做深于是复杂表头、动态模板、嵌套结构这些需求就天然地有了用武之地。2.2 整体模块和核心概念Fesod的代码结构大致分三块基础模块处理工作簿的读写、样式、合并单元格等底层操作兼容xls和xlsx格式。绑定模块通过注解或编码方式把Java对象的字段与Excel的列、行、分组关系绑定起来类似EasyExcel的ExcelProperty但表达能力更强。模板模块专攻带样式模板的填充支持动态行展开、合并单元格跟随等场景。在实际写代码时最常用到的概念有三个SheetTable标注这个对象对应哪个sheetExcelField标注字段对应哪个列标题或者列下标MergeField标注哪些字段需要合并单元格、合并方向是纵向还是横向。这套设计的直接好处是你在导入时不再需要对合并单元格做“读取后手动填充”在导出时也不用自己拼合并区域的坐标。坐标计算和区域合并全部是框架算好的你只管描述规则。2.3 和EasyExcel的API风格差异EasyExcel的风格是“链式调用回调监听器”读Excel时你需要写一个AnalysisEventListener在invoke回调里一行一行收数据再在doAfterAllAnalysed里做收尾。写起来虽然不复杂但监听器的生命周期是框架管理的你得小心不要在回调里做太多重量级操作否则解析性能会被拖垮。Fesod的API风格更接近Spring生态的“声明式”写法读操作是直接返回一个ListT你不需要关心框架内部什么时候触发回调。它内部用的是流式解析但对外收了复杂度这对我来说省了很多“模板代码”。用惯了Spring Boot自动装配的人上手Fesod基本没有心理负担。3. 实战迁移我把一个导入导出模块从EasyExcel改成了Fesod3.1 依赖引入和版本选择我目前用的是Fesod 0.9.2版本Maven依赖长这样dependency groupIdorg.apache.fesod/groupId artifactIdfesod-core/artifactId version0.9.2/version /dependency dependency groupIdorg.apache.fesod/groupId artifactIdfesod-binding/artifactId version0.9.2/version /dependencyFesod对底层POI的版本要求比较宽松官方推荐POI 5.2.x以上这样和我项目里其他模块的POI依赖就不会再打架了。这里有个小建议如果你项目里同时存在多个Excel框架最好在引入Fesod之前先执行一次mvn dependency:tree看看当前POI的依赖树尽量统一到同一大版本上避免出现类加载或反射调用不一致的问题。3.2 实体类的注解改造原来EasyExcel的实体类长这样public class SettlementItem { ExcelProperty(结算单号) private String orderNo; ExcelProperty(客户名称) private String customerName; ExcelProperty(金额) private BigDecimal amount; }改成Fesod后我额外要声明表头开始行和分组信息SheetTable(sheetIndex 0, headerRow 2) public class SettlementItem { ExcelField(value 结算单号, group 基础信息) private String orderNo; ExcelField(value 客户名称, group 基础信息) private String customerName; ExcelField(value 金额, group 财务信息) private BigDecimal amount; ExcelField(value 备注, group 财务信息) private String remark; }注意headerRow 2这个字段它告诉框架第三行才是真正的字段标题行前面两行是分组表头Fesod会自动处理表头合并区域的样式和层级关系。我在EasyExcel里要手写一堆CellRangeAddress才能实现的表头合并在这边只需要标一个行号。3.3 导出API对比EasyExcel的导出ExcelWriter writer EasyExcel.write(outputStream, SettlementItem.class) .sheet(结算明细) .build(); WriteSheet sheet EasyExcel.writerSheet(结算明细).build(); writer.write(dataList, sheet); writer.finish();Fesod的导出FesodWorkbook workbook FesodWorkbook.create(); SheetModel sheet workbook.createSheet(结算明细, SettlementItem.class); sheet.writeRows(dataList); workbook.writeTo(outputStream);代码量上看差不多但有个细节差别Fesod的SheetModel在创建时就会根据实体类注解生成初始表头包括分组行和合并区域。也就是说如果我的表头有三层EasyExcel要在ExcelProperty里用value数组去模拟层级而Fesod用group字段去描述归属表达起来更直观。3.4 导入API对比EasyExcel的导入需要监听器EasyExcel.read(inputStream, SettlementItem.class, new AnalysisEventListenerSettlementItem() { Override public void invoke(SettlementItem item, AnalysisContext context) { list.add(item); } Override public void doAfterAllAnalysed(AnalysisContext context) { // 收尾 } }).sheet().doRead();Fesod直接返回集合SheetReaderSettlementItem reader FesodWorkbook.open(inputStream) .sheet(0, SettlementItem.class); ListSettlementItem list reader.readAll();更重要的是readAll()返回的列表已经处理好了合并单元格向下填充的逻辑。凡是合并区域里的非首行单元格Fesod默认会取合并区域左上角的值填进去。这一步省掉了我之前维护的“手工填充合并单元格”工具类。4. 四个硬核需求的实现对比4.1 复杂表头导入这是我最痛的一个需求。客户那张结算表表头分布如下| 公司名称合并两列 | 1月数据合并两列 | 2月数据合并两列 | | 序号 | 金额 | 序号 | 金额 |在EasyExcel里我最后的妥协方案是表头行全部隐藏掉用headRowNumber指定从第一个数据行开始读然后字段和列下标硬编码。这方案能跑但完全不可维护客户一旦新增月份列Java代码就得跟着改。Fesod的解法是声明式分组表头SheetTable(sheetIndex 0, headerRow 1) public class MonthlyData { ExcelField(value 公司名称, group 基础, rowMerge true) private String companyName; ExcelField(value 序号, group 1月数据) private Integer month1Seq; ExcelField(value 金额, group 1月数据) private BigDecimal month1Amount; ExcelField(value 序号, group 2月数据) private Integer month2Seq; ExcelField(value 金额, group 2月数据) private BigDecimal month2Amount; }框架会先去表头区域扫描所有标题文本把“1月数据”这个分组下的“序号”“金额”和Java字段建立映射。整个映射关系和信息是解耦的以后客户要加“3月数据”我只需要在实体类里加两个字段不用去改解析逻辑。实际测试中这个分层表头解析对单元格跨列合并的识别率很关键。Fesod的做法是在扫描表头文本时结合合并区域上下文可以判断“序号”到底是归属“1月数据”还是“基础信息”分组。EasyExcel的ExcelProperty虽然也支持嵌套标题但对“同一标题重复出现”这种情况是无解的因为它的设计前提是标题文本必须唯一。4.2 模板填充加动态行循环加合并单元格先说场景合同套打时模板正文有一段“供货清单”清单行数不固定每一行有三列“商品名、数量、单价”模板里这些行是空白的但和上方“合同标题”有样式上的统一要求。同时最后一行的“甲方签字”单元格是合并的必须在清单数据填完后这个合并单元格依然保持合并状态和边框。EasyExcel的模板填充用fill(list)可以自动向下按列表长度展开行但它的实现是复制整行模板样式。遇到被合并的单元格区域时样式复制逻辑经常出错导致“甲方签字”这个合并区域被拆散。这个问题在EasyExcel的GitHub Issue列表里挂了很久稳定复现。Fesod在模板填充这块做了个改进支持在模板行上通过{listField}语法声明“这个区域是动态列表”并且允许在这个区域之外保留静态合并单元格。示例模板大概长这样合同编号{contractNo} 供货清单 {goodsList} [商品名] [数量] [单价] 甲方签字合并单元格区域注意{}和{}的区别。{contractNo}是普通变量替换{goodsList}是列表展开标记。每次展开一行时框架只会复制这个标记所在行的连续单元格区域不会波及下方“甲方签字”的合并区域。这是我在EasyExcel里折腾最久的需求到了Fesod这边一个标记就解决了。填充字段的代码是TemplateFiller filler FesodWorkbook.template(templateStream) .bind(contractNo, contract.getNo()) .bindList(goodsList, goodsList) .fill(); filler.writeTo(outputStream);bindList内部会对List的每个元素生成一行并把合并单元格的状态做重新计算。实测在1000行清单数据下模板填充耗时大概在400毫秒左右样式保持完整和我之前用POI手写复制行样式的时间差不多但代码量从一百多行缩到了几行。4.3 单元格内换行文本的处理这个场景看起来小但它很恶心。比如“备注”列里写了第一行内容 第二行内容用EasyExcel读进来值是第一行内容\n第二行内容。本身没错但如果你把这个值写回数据库再渲染到Web页面换行符可能会被转成空格或者触发XSS处理起来很麻烦。更头疼的是如果这个单元格里文本量太多EasyExcel一个单元格只返回字符串内容换行符和缩进空格混在一起你根本不知道原文排版长什么样。Fesod在文本解析时做了一个小改进ExcelField上可以声明keepLineBreak true或者trimLineBreak true前者保留原样换行后者自动去掉换行符并把多行文本合并成空格分隔。默认是保留原样。我在导入客户备注时会对文本做一次标准化处理——先把\r\n统一成\n再按业务需要决定合并还是保留。如果你还在用EasyExcel处理这类数据我建议至少写一个统一的后处理读出来之后用text.replaceAll(\\r\\n, \n)做一次换行符归一化然后根据下游用途决定要不要把\n替换成空格。这个处理在Fesod里变成了注解配置不会漏在业务代码里到处散落。4.4 嵌套List渲染再聊一个典型的“List里套List”场景导出一份销售订单订单头有订单号、客户、总金额订单明细有商品、数量、单价一个订单可能有多条明细。在Sheet上我们希望订单头占一行明细行缩进显示在下面每个订单组之间空一行。EasyExcel原生不支持这种嵌套结构的导出。常规做法是先把对象扁平化成一个ListRowDTO每条明细复制一份订单头字段导出时再按订单号做一组合并单元格让视觉上“合并”成一组。这种方案能跑但大量重复数据会拖慢写入速度合并单元格的代码也容易出边界问题。Fesod里有一个NestedField的设计允许在实体类里直接声明子列表public class OrderVO { ExcelField(订单号) private String orderNo; ExcelField(客户) private String customerName; NestedField(header 商品明细, itemType OrderItemVO.class) private ListOrderItemVO items; }导出时SheetModel会先输出一行订单头然后遍历items逐行输出并且自动计算缩进列。订单头和明细之间还能插入一个空行做视觉分隔。这套逻辑用EasyExcel写要三四十行工具代码Fesod注解解决关键是嵌套List的渲染顺序和合并逻辑都在框架内部统一管理不会出现每个开发自己写一套导致格式不统一的问题。5. 迁移升级后我踩过的新坑和适配经验5.1 模板文件的隐藏样式会被悄悄丢掉这是迁移后第一个坑。原来用EasyExcel填充模板模板里的Excel Conditional Formatting条件格式在填充后还能正常保留。换到Fesod后我发现某些带条件格式的模板填充完单元格底色判断失效了。排查半天发现是模板文件里存在“定义名称”引用Fesod复制行时没有把对应区域的“定义名称”一起带过来。解决方法是在模板设计阶段尽量不跨区域引用“定义名称”条件格式作用范围也不要跨越动态行区域。如果确实需要可以在填充完成后用POI的底层API再补一次条件格式。5.2 大数据量读取代宽对内存的影响Fesod默认在读入时是一次性构建模型对象列表也就是说readAll()返回的List是常驻内存的。几十万行数据一次性读入内存占用会比EasyExcel的“流式监听器手动分批处理”要高一些。不过Fesod也提供了readPage(pageIndex, pageSize)方法可以分批读取。我现在的做法是超过5万行的导入一律分页读取每页处理完立刻提交数据库防止JVM堆被压爆。这里提个性能数据供参考我在8G堆内存的机器上用Fesod读10万行20列的xlsx文件单页5000行总耗时约3.2秒内存峰值约700MB还是能接受的。5.3 日期和数字格式的匹配规则要提前约定EasyExcel对日期类型的处理靠DateTimeFormat注解但遇到Excel里存的“2024/1/1”这种文本格式比较依赖POI的类型判断。Fesod对日期字段做了更严格的类型推断如果目标字段是LocalDate而单元格存的是数字比如Excel自动转换的日期数字它也能正确转换。但如果单元格存的是“2024年1月1日”这种自定义格式文本就需要你手动指定ExcelField(datePattern yyyy年M月d日)。我在实际项目中总结了一条经验合作方发来的模板一定要在规格文档里写清楚日期列的数据类型。Excel的日期在UI上长得一样底层可能是文本、可能是数字解析结果天差地别。用Fesod之后这个规范直接落到了注解上写代码的人不用去猜。5.4 动态模板循环区域里不能嵌套另一个{}这是我用Fesod写一个复杂合同模板时踩的坑我在{goodsList}这一行里又写了一个{goodsList.price}的变量引用结果填充后只有第一行数据正确后续行的价格列全是空。查了下文档才发现动态列表的字段引用应该直接写在单元格内部用{price}这种相对变量名不需要再带列表前缀。改完后一切正常。如果你是从EasyExcel填充语法转过来的一定要注意这个区别EasyExcel的{xxx}变量在fill操作中是全局查找Fesod的{}列表展开后列表内部字段是相对当前行的。5.5 和EasyExcel混用时注意线程安全我这边存量系统还有老模块在用EasyExcel新模块用Fesod。同一个Tomcat进程里两个框架同时跑目前没发现冲突。但要注意Fesod的FesodWorkbook不是线程安全的同一个工作簿实例不能并发写。我处理办法是每线程每次操作都新建实例或者用ThreadLocal管理模板流确保一个请求用一个独立的Workbook对象。6. 关于选型我给同行的几点实在建议如果你现在的项目只用Excel做简单的导入导出表头平铺、数据量适中、模板也没有特殊合并需求那继续用EasyExcel完全没问题。它轻、快、社区熟网上随便一搜就有解决方案团队招聘成本也低。真正需要考虑Fesod的是下面几种情况表头超过两层尤其是“同一标题在多个分组下重复出现”。模板填充需要处理动态行和静态合并单元格共存。数据结构里有“订单头订单明细”这种嵌套List不想费力做扁平化。项目里POI版本和EasyExcel有严重冲突已经踩过NoSuchFieldError这类坑。团队愿意为“声明式表格建模”多花一点学习成本换取长期维护效率。我个人在实际项目里引入Fesod时走的是渐进式替换路线先拿一个报表导出试试水跑通后替换一个复杂表头导入最后再把模板套打切过去。一步到位换掉所有模块风险太高也没必要。最后分享一个小技巧不管用哪个Excel框架都建议在实体类的ExcelField注解旁边保留业务字段的中文注释并在headerRow、group这些元信息发生变化时主动通知下游对接方。表格映射这种东西最怕的不是写不出来而是改的时候没人知道改了以后影响哪些模板。用Fesod之后这些映射关系更加集中反而让我们团队的合作方更容易对齐预期。要不要把你的项目也拿一个复杂模板试试水就看你自己权衡了。