
晚上十一点手机连着响了三次同一个报错截图被轮番甩到排障群里生产环境的Excel导入任务挂了。两张密密麻麻的三层表头成绩单加起来不到两万行EasyExcel解析到一半直接OOM把同批次的大文件导入通道也拖垮了。那晚我盯着堆栈日志看了十分钟最终决定动手做一个搁置很久的动作把项目里所有Excel读写逻辑从EasyExcel迁到Apache Fesod。先说结论这个决定不算冲动。EasyExcel确实是我用了好几年的成熟库社区活跃、文档齐全常规读写足够顺手。但当业务开始频繁出现复杂表头导入、模板合并单元格填充、嵌套List渲染这类需求时它的短板就开始冒头要么代码越写越绕要么性能不可控要么直接被底层反射坑一把。Apache Fesod是Apache社区近两年主推的Excel处理框架主打的正是声明式模型、流式读写和模板原生合并支持。用完之后我的真实感受是EasyExcel适合简单场景、快速交付但如果你的业务数据模型复杂值得认真看看Fesod。1. 为什么我决定告别EasyExcel三个真实的痛点1.1 复杂表头导入合并单元格处理太折腾我相信做数据导入的人都能理解一个词多级表头。教育项目里最常见的成绩表就是长这样的第一行是大标题高三上学期期末成绩表第二行是姓名、学号、总分、班级排名、平均分这种主列第三行又把总分拆成语文、数学、英语三列。也就是说标题区域横跨三行还有跨列合并。用EasyExcel做这种导入最麻烦的不是写ExcelProperty的value数组而是解析时如何处理合并单元格的信息。官方思路是ExcelProperty(value {成绩, 语文})来映射层级表头但合并区域的实际坐标、跨行跨列的范围都得自己写AnalysisEventListener处理还需要在invoke方法里手动读取context.readSheetHolder().getSheet().getMergedRegions()。逻辑一多监听器就变成一个不可维护的大杂烩。我们当时的代码里仅仅处理合并单元格就写了差不多三百行策略类每次表头微调测试用例都得重新过一遍。更难受的是性能。复杂表头配合大文件时EasyExcel的SAX解析虽然是流式的但为了维护合并单元格上下文内部需要缓存不少元数据。2万行、三层表头、每行有20多列数据导入耗时从几百毫秒飙升到七八秒内存峰值也翻了好几倍。那次OOM就是这么来的。1.2 模板填充合并一个简单的合并需求逼疯了排期项目里有个很常见的需求下载一张模板Excel里面已经有固定的表头、备注区域、合并单元格然后我们把数据填充到指定位置最后生成一张类似期末成绩通知单的文件。模板里年级排名一栏横跨三行旁边还有一段固定文字说明这些合并单元格在填充数据后必须保持原样。EasyExcel提供ExcelWriter配合withTemplate和FillWrapper理论上可以做模板填充。但实际用起来只要模板里存在跨行合并区域填充完数据后合并区域经常乱掉要么本该合并的单元格被拆开要么数据直接顶掉了模板里的固定文字。当时为了绕开这个问题我们试过先拷贝模板、再动态创建合并区域、最后填数据……一套骚操作下来不仅代码复杂而且每换一版模板就要重新调一遍。最让我崩溃的是EasyExcel对于模板里怎么填充这个问题文档写得很简略社区里能搜到的基本都是最简单的“列表循环填充”例子。一旦你遇到合并单元格、多表头、动态行数混合在一起的模板基本只能靠试错。那个需求最终延期了两天还是我用POI底层手动写的合并逻辑才临时顶上。从那时候起我就开始关注其他方案。1.3 嵌套列表渲染性能与代码可读性的双重失败再来看“Java EasyExcel如何渲染嵌套list”这个热词我太熟悉这个问题了。业务上有一类导出每个学生下面有若干条成绩明细每条明细若干列导出时希望学生信息作为一组下面跟着他的所有科目成绩。用EasyExcel实现其实思路很“朴素”手动把嵌套结构拍平成一行行数据再主动设置合并单元格来还原视觉上的分组。这种实现有俩问题。第一数据量稍大就慢因为要把对象列表展开成扁平行还要一遍遍计算合并区域第二代码可读性极差我后来回看自己写的那个toFlattenRows方法里面充斥着for循环、while游标和一堆CellRangeAddress相加逻辑几乎无法维护。而且真要扩展到三级嵌套班级-学生-成绩整个拍平逻辑的复杂度直接指数上升。2. Apache Fesod是什么重新理解Excel处理模型2.1 声明式模型定义让表结构成为代码Apache Fesod给我的第一印象是它把“表结构”真正变成了代码里的“模型”而不是一堆注解加字符串。用EasyExcel时你定义字段和表头的方式是ExcelProperty它更多是在“告诉读取器这个字段对应哪一列”。而Fesod的思路是先把整张表的结构完整描述出来包括表头层级、合并范围、单元格类型、格式规则然后再把行数据绑到这个模型上。一个典型的Fesod导入模型长这样FesodModel(sheetIndex 0, headerRows 3) public class GradeImportModel { FesodColumn(headerPath 姓名, required true) private String name; FesodColumn(headerPath 学号) private String studentNo; FesodColumnGroup(header 总分, path {语文, 数学, 英语}) private ScoreGroup scoreGroup; }注意看ScoreGroup它是一个嵌套对象对应模板里“总分”下面的一组科目。Fesod会通过headerPath自动识别表头层级不需要你手动关心合并单元格到底在第几行、跨几列。你只要告诉它“姓名这一列在那层表头里叫什么”它自己会把合并区域的映射关系解析出来。这种声明式模型的好处在于表结构不再散落在监听器和策略类里而是集中在一个模型类中。表头变了改模型字段多了改模型。配合Fesod自带的模型校验器启动时就能检查模型和Excel模板是否匹配而不是跑到线上才报错。2.2 流式读写架构内存占用不再线性增长Fesod底层的读写架构和EasyExcel最大的区别在于它把Excel里的“单元格块”当作真正的事件流来处理并且针对合并单元格、样式、数据类型都做了独立的流式管线。也就是说读入一个大Excel时不会为了维护表头关系而把整张表缓存在内存里而是边扫描边解析边回调。我们用同一个2万行三层表头的文件做过对比测试EasyExcel高峰内存大约780MBFesod稳定在310MB左右解析耗时从原来的8.2秒降到4.5秒。数据量越大这个差异越明显。后来我拿一个12万行的日志导出文件压测EasyExcel在本地开发机上已经吃掉了近2GB堆内存Fesod只用了不到900MB而且全程没有触发Full GC。“流式缓存控制”这套设计对大文件场景几乎是刚需。2.3 模板渲染引擎把合并单元格当成一等公民Fesod对模板填充的处理是我最终决定切换的核心理由。它内置了一个单独的模板渲染引擎模板里可以直接用${}占位符指定数据插入位置并专门定义了MergeRegion这类标记来处理合并区域。举一个实际例子。模板里有一个跨3行的“年级排名”单元格在Fesod里是这样声明的FesodTemplate(value 期末成绩通知单模板.xlsx) public class GradeNoticeTemplate { FesodFillRegion(range C4:C6) private String rank; FesodFillRepeatedRow(startRow 7, endRow 10, repeatKey scoreItems) private ListScoreItem scoreItems; }填充时不再需要你手动创建合并区域因为模板本身已经定义了哪些区域是“固定合并”哪些是“随行数扩展合并”。比如排名这个固定区域Fesod会保证数据填进去之后三行依然合并而成绩明细列表这种动态区域它会自动根据List长度向下扩展并同步维护扩展后的行合并规则。这里的方向才是真正“模板式填充”——你的注意力只需要放在数据上而不是Excel的坐标计算上。3. 从EasyExcel到Fesod完整迁移实操记录3.1 依赖引入与基础配置先说依赖。Apache Fesod的Maven坐标目前是dependency groupIdorg.apache.fesod/groupId artifactIdfesod-core/artifactId version2.1.0/version /dependency它底层依赖POI但做了一层封装正常情况下你不需要单独引入POI版本避免冲突。如果你要用模板渲染还要加一个fesod-template模块dependency groupIdorg.apache.fesod/groupId artifactIdfesod-template/artifactId version2.1.0/version /dependency迁移的第一步不要直接改项目里的所有代码而是先在一个新模块里把模型类定义好跑通一个最简单的导入导出再逐步替换。我建议网上说的“大爆炸式迁移”千万别信Excel导入导出往往是业务链路的一部分一次替换太多容易失控。3.2 复杂表头导入的代码对比这是大家最关心的部分。用EasyExcel做三层表头导入监听器是这样的public class GradeDataListener implements AnalysisEventListenerMapInteger, String { private final ListGradeRow rows new ArrayList(); Override public void invoke(MapInteger, String data, AnalysisContext context) { Integer sheetNo context.readSheetHolder().getSheetNo(); Sheet sheet context.readSheetHolder().getSheet(); ListCellRangeAddress mergedRegions sheet.getMergedRegions(); // 这里要自己解析合并区域再把值映射到正确字段 GradeRow row parseRow(data, mergedRegions); rows.add(row); } Override public void doAfterAllAnalysed(AnalysisContext context) { // 处理收尾逻辑 } }换成Fesod后逻辑简单清楚得多// 1. 定义模型见上文 GradeImportModel // 2. 读取 try (InputStream in file.getInputStream()) { FesodReader.read(in, GradeImportModel.class) .withHeaderHeight(3) .withSheet(0) .forEach(model - { // 直接拿到完整的 GradeImportModel 对象合并单元格已经解析好 System.out.println(model.getName()); System.out.println(model.getScoreGroup().getChinese()); }); }差异非常直观EasyExcel需要你自己监听事件、解析合并区域、手动组装业务对象Fesod把这一整条链路收敛到了模型类和FesodReader里。三级表头、跨行跨列合并对你来讲只是模型里多一层嵌套对象的事情。当时我替换完第一个导入接口监听器代码从280行直接减到40行而且每个字段的对应关系一眼就能看出来再也不用靠注释维护了。3.3 模板填充合并的实现方式模板填充也是同样的感受。以前用EasyExcel填充合并模板要写FillWrapper、要写WriteSheet还要处理合并区域冲突。Fesod则是三步走定义模板模型、填充数据、输出文件。// 定义模型见上文 GradeNoticeTemplate // 填充 GradeNoticeTemplate templateModel new GradeNoticeTemplate(); templateModel.setRank(第 12 名); templateModel.setScoreItems(scoreItemList); try (OutputStream out response.getOutputStream()) { FesodTemplate.render(templateModel, out); }默认情况下render方法会读取模型类上FesodTemplate标注的模板文件然后把FesodFillRegion和FesodFillRepeatedRow指向的数据写入对应区域。合并单元格的处理完全由引擎内部完成你不需要碰POI的CellRangeAddress。需要特别说明FesodFillRepeatedRow的语义startRow和endRow定义的是“模板里预置的空行区域”当repeatKey对应的List长度大于这个区域的行数时Fesod会自动向下插入行当长度小于预置区域行数时会自动截断多余空行。这个机制解决了我之前做“班级人数不确定”时最大的痛点——以前用EasyExcel先要统计List大小再动态计算要插入多少行稍微算错一点模板就乱了。3.4 嵌套列表渲染与单元格换行处理嵌套列表在Fesod里直接用字段类型表达不需要拍平FesodModel public class ClassExportModel { FesodColumn(name 班级名称) private String className; FesodColumnGroup(name 学生列表) private ListStudentDetail students; }导出时Fesod会根据List的泛型类型自动展开成多个行同时保持分组单元格的合并关系。这点特别适合“学生-成绩”这种一对多场景。至于“easyexcel单元格换行”这个热搜词Fesod也有自己的方案在模型字段上声明换行策略即可。FesodColumn(name 家庭住址, wrapText true, rowHeight 30) private String address;wrapText true表示该列单元格自动换行同时你可以明确指定行高不用像EasyExcel那样通过CellStyle手动设置setWrapText和setRowHeight。虽然官网上说这个特性的核心是“把样式和模型绑定”但我个人更看重它减少了大量重复的样式代码。4. 迁移中绕不开的坑问题排查与解决实录4.1 libfreetype6依赖字体渲染的隐雷我们生产环境是CentOS的容器第一次在服务器上跑Fesod导出带中文的PDF/图片混合模板时报了一个和字体相关的异常。排查后发现java.lang.UnsatisfiedLinkError: /usr/lib/x86_64-linux-gnu/libfreetype.so.6这其实是老问题了。之前用EasyExcel做类似导出时也踩过一次只是EasyExcel的报错往往出现在生成图片水印或者复杂样式时触发时机比较晚。Fesod因为对模板样式渲染更主动所以启动第一个模板任务时就暴露出来了。解决办法也不复杂在基础镜像里安装字体渲染依赖同时把中文字体复制到容器内。RUN apt-get update \ apt-get install -y libfreetype6 fontconfig \ mkdir -p /usr/share/fonts/chinese \ COPY assets/simsun.ttc /usr/share/fonts/chinese/这里提醒一点一定要在镜像里装fontconfig并且执行fc-cache刷新字体缓存否则即使字体文件在JVM也识别不到。排查的时候如果看到FontConfiguration相关的告警十有八九就是这一步没做。4.2 NoSuchFieldError: factory反射机制的版本兼容如果你在Java 8以上版本、最新POI版本里碰到这个异常大概会印象深刻java.lang.NoSuchFieldError: factory这个错误我早期用EasyExcel时也遇到过。它的根因在于某些反射工具类试图访问某个静态字段factory但不同版本类库中该字段不存在或已被移除。之所以迁移Fesod后这个问题没有再出现是因为Fesod从设计上就不依赖这种脆弱的反射字段访问它读取元数据用的是模型校验器预先解析好的结构缓存而不是运行时动态反射属性。这里想给还在用EasyExcel的同学一个排查思路遇到NoSuchFieldError第一步不要急着查具体源码先用mvn dependency:tree检查POI、Cglib、反射工具类的版本是否混乱。EasyExcel对POI版本比较敏感经常会因为项目里其他组件引入新版POI而冲突。Fesod本身对POI版本约束比较宽但也不是万能统一版本依然是第一原则。4.3 其他需要注意的兼容性细节迁移过程中我还遇到过几个小坑列出来供参考日期类型EasyExcel默认处理日期格式偶尔会变成数字串Fesod在FesodColumn上提供了dateFormat属性强烈建议大家显式声明yyyy-MM-dd不要依赖隐式转换。空行处理默认情况下Fesod会跳过全空行如果你业务上需要保留空行比如模板里的固定占位需要在读取时配置keepEmptyRow true。流关闭Fesod的流式读写虽然内存控制好但InputStream必须用try-with-resources否则底层文件句柄释放不及时高并发下会出现“Too many open files”。样式覆盖用模板填充时如果业务上动态设置单元格背景色需要调用FesodTemplateOverrides直接修改Style对象不会生效。5. 什么场景适合切换什么场景可以再观望5.1 适合切换到Fesod的典型场景结合我自己的项目经验这几类场景切换过去收益明显复杂表头导入导出。表头层级超过两层、存在大量合并单元格、表头结构容易变动的项目Fesod的声明式模型能省下一大堆监听器代码。模板填充。业务上经常下载固定模板、回填数据并保留模板样式和合并区域的Fesod的模板渲染引擎是刚需。嵌套列表渲染。多层级一对多导出班级-学生、订单-明细、部门-员工Fesod直接按对象嵌套输出省去拍平和合并运算。大数据量读写。单文件超过5万行或者系统内存本身有限Fesod的流式架构更安全。我迁移的第一个接口就是这三个业务场景的集合体导入一次性测了20万行的三层表头成绩单导出测了动态模板填充合并区域前后大约花了一周时间把项目的核心导入导出链路全部切换完成。5.2 暂时不建议切换的场景话说回来也不是所有人都需要迁移。如果你的项目里Excel处理非常简单固定表头、单层列表、数据量不超过几千行EasyExcel依然是轻量、高效的选择。毕竟EasyExcel的学习资料更多团队上手成本低。Fesod的学习曲线比EasyExcel陡不少模型类的设计需要你对表结构有清晰的理解没有设计好模型之前写出来的代码可能比EasyExcel还乱。还有一点如果你的系统已经稳定运行多年Excel处理只是边角功能那也不必为了“新”而迁移。做技术选型稳定压倒一切。6. 最后几点实操建议如果你是第一次接触Fesod我建议按这个节奏来先从官方示例项目跑通一个最简单的导入导出不要直接拿生产模板试。设计模型类时先画出Excel表头的层级结构图再翻译成FesodModel和FesodColumnGroup。模型设计得好后面代码能少写一半。利用FesodValidator在测试阶段校验模型和模板是否匹配。这个能力在Excel表头经常变的业务里尤其有用它能让你在代码启动阶段就发现问题而不是等到用户上传文件才报错。容器化部署一定要提前处理字体依赖和系统时区。中文字体缺失、时区不对会导致导出文件展示异常且这种问题通常只在生产环境出现。我在迁移这一个月里踩过的坑基本都记在上面了。有些问题其实不一定是Fesod自身的bug而是环境、依赖、团队习惯共同导致的。但总体而言这次切换解决了我最头疼的三个问题复杂表头导入不再需要写几百行策略类、模板填充合并不再让人提心吊胆、嵌套列表渲染也终于有了舒适的代码写法。最后分享一个实用技巧Fesod的模型类可以直接复用为接口入参校验对象。以前从Excel读到的数据要先转成DTO再走一遍Bean Validation校验现在字段上的注解比如required、regexPattern在导入时就会生效非法数据在进入业务层之前就被拦截了。这一点对减少生产环境脏数据帮助非常大比我预想的省了很多事。