晚上十一点,手机连着响了三次,同一个报错截图被轮番甩到排障群里:生产环境的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高峰内存大约780MB,Fesod稳定在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 List<ScoreItem> scoreItems; }填充时不再需要你手动创建合并区域,因为模板本身已经定义了哪些区域是“固定合并”,哪些是“随行数扩展合并”。比如"排名"这个固定区域,Fesod会保证数据填进去之后三行依然合并;而"成绩明细列表"这种动态区域,它会自动根据List长度向下扩展,并同步维护扩展后的行合并规则。这里的方向才是真正“模板式填充”——你的注意力只需要放在数据上,而不是Excel的坐标计算上。
3. 从EasyExcel到Fesod:完整迁移实操记录
3.1 依赖引入与基础配置
先说依赖。Apache Fesod的Maven坐标目前是:
<dependency> <groupId>org.apache.fesod</groupId> <artifactId>fesod-core</artifactId> <version>2.1.0</version> </dependency>它底层依赖POI但做了一层封装,正常情况下你不需要单独引入POI版本,避免冲突。如果你要用模板渲染,还要加一个fesod-template模块:
<dependency> <groupId>org.apache.fesod</groupId> <artifactId>fesod-template</artifactId> <version>2.1.0</version> </dependency>迁移的第一步,不要直接改项目里的所有代码,而是先在一个新模块里把模型类定义好,跑通一个最简单的导入导出,再逐步替换。我建议网上说的“大爆炸式迁移”千万别信,Excel导入导出往往是业务链路的一部分,一次替换太多容易失控。
3.2 复杂表头导入的代码对比
这是大家最关心的部分。用EasyExcel做三层表头导入,监听器是这样的:
public class GradeDataListener implements AnalysisEventListener<Map<Integer, String>> { private final List<GradeRow> rows = new ArrayList<>(); @Override public void invoke(Map<Integer, String> data, AnalysisContext context) { Integer sheetNo = context.readSheetHolder().getSheetNo(); Sheet sheet = context.readSheetHolder().getSheet(); List<CellRangeAddress> 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 List<StudentDetail> 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)在导入时就会生效,非法数据在进入业务层之前就被拦截了。这一点对减少生产环境脏数据帮助非常大,比我预想的省了很多事。