如果在Java后端待过两年,应该都对EasyExcel不陌生。我记得当初选型的时候,冲着它轻量、低内存,一个项目里所有Excel导入导出全都交给它。用了两年多,真正让我下决心换掉它的,不是某个大版本升级,而是一个批数据处理需求:复杂表头导入、单元格换行、模板填充合并,还带嵌套list渲染——这几个需求堆在一起的时候,EasyExcel从“好用”直接变成“难受”。后来我切到了Apache Fesod,整个开发节奏才算回到正轨。
说实话,EasyExcel不是没有优点,它解决了传统POI操作Excel时内存暴涨的问题,学习成本也低。可一旦业务开始复杂,表头超过两层、模板里要动态合并、一行数据里需要换行展示、单元格里还得塞一个列表结构……EasyExcel那些“简单场景很顺手”的优势,马上就变成了束缚。这篇文章把我这段时间踩过的坑、对比过的接口、迁移时的代码改法全部摊开讲,希望对同样卡在Excel导入导出的朋友有点用。
1. 业务场景:我们被EasyExcel“逼疯”的那几张表
先还原一个比较典型的场景。我们当时在做一个教育类的数据中台项目,每个学期要从学校收集Excel模板,表格的复杂程度不是一般后台管理系统里的“用户列表导出”能比的。需求大概分成四类,每类都让EasyExcel显得吃力。
1.1 三层表头导入,列对应的代码能写吐
普通Excel导入,表头就一行,@ExcelProperty("姓名")这种注解一标就完事。但业务表不是这样,举个例子,我们有一张“学生综合信息表”,表头结构是:
- 第一层:基本信息、扩展信息、成绩信息
- 第二层:姓名、年龄、班级 / 学籍号、户籍 / 语文、数学
- 第三层:甚至还有“语文”下面细分成“平时成绩、期末成绩”
这种表头在EasyExcel里只能用@ExcelProperty(value = {"基本信息", "姓名"})一层层去标,遇到两层以上还好,到了三层就非常难受。更麻烦的是,列并不是从左到右顺序排列,中间经常穿插一些汇总列、空列,一旦列顺序变动,所有注解全部失效。
1.2 单元格换行和合并单元格,渲染出来总不对
业务方给的模板里有说明文字,一个单元格里可能既要有标题,又要换行写规则。EasyExcel在读取这类单元格时,默认把\n当成了字段分隔符的一部分,导致解析出来的字符串带着换行符号,入库以后展示出来乱七八糟。
合并单元格更头疼,模板填充时如果要用“合计”“总计”这种跨行跨列的合并区域,EasyExcel默认支持很弱,需要自己实现CellWriteHandler,在写单元格的时候动态判断要不要合并。我们第一版上线时,整个模板填充逻辑里塞了三个自定义Handler,光调试合并区域错位就花了两天。
1.3 嵌套列表在单元格里“铺不开”
成绩信息需要每个学生下面挂多个科目的成绩,天然是List<ScoreItem>的结构。在EasyExcel里想把这个嵌套结构拍平渲染到模板里,要么提前把List转成字符串,用“语文:90;数学:95”这种笨办法,要么写自定义拦截器去动态创建行。两种做法都谈不上优雅,而且一旦模板结构改动,业务代码就得跟着调。
1.4 环境问题在测试和线上反复横跳
这是压垮骆驼的最后一根稻草。项目部署到某些Linux服务器时,EasyExcel底层依赖的字体渲染库会报错,最常见的两个:
libfreetype6相关依赖缺失NoSuchFieldError: factory这种JAR包冲突
前者是因为POI在工作簿渲染时需要系统字体库,后者是不同版本POI和EasyExcel混用导致的。我们当时排查了很久,最后看到NoSuchFieldError: factory这个报错,整个人都麻了——这已经不是业务代码能解决的范畴,完全是底层包管理的问题。
这些问题单拎出来每一个都能忍,全部堆到一个模块里,那就是灾难。我当时的判断是:与其在EasyExcel的生态里继续打补丁,不如换一个更底层的方案。正好POI团队在5.x时代推出了重新设计过的处理框架,也就是Apache Fesod,我花了一周时间把导入导出模块整个重写,效果比预期好很多。
2. Apache Fesod到底改了什么:核心设计拆解
先说明一下,Apache Fesod不是EasyExcel那样的上层封装,而是基于Apache POI 5.x体系重构出来的一套Excel处理框架。它保留POI对Office文件格式的底层解析能力,但在API设计上更贴近业务开发者。
2.1 读取模型:不再用“注解驱动一切”
EasyExcel的核心模式是“注解映射实体类+监听器逐行处理”。这确实简单,但复杂表头场景很吃亏,因为注解只能描述“静态列结构”,一旦表头动态变化,就需要大量监听器逻辑去兜底。
Fesod的读取模型分两层:
- 第一层:
FesodSheet直接暴露行列结构,想拿原始单元格值就拿,想跳过空行就跳过 - 第二层:
beanMapper()把行列映射到实体,支持自定义表头行数、自定义合并单元格策略
这样做的好处是,导入逻辑不会一上来就被“注解绑定”死。普通单层表头可以用注解快速映射,复杂表头可以先按行列读取,再通过代码决定怎么组装。同一个文件,两种读取方式可以混用,这在EasyExcel里很难做到。
2.2 模板填充:合并区域成为一级操作
EasyExcel模板填充的核心是ExcelWriter配合FillConfig,合并单元格基本需要自行处理。Fesod在这个地方做了一个关键设计:把模板中的“占位符区域”显式声明为数据对象,填充的时候框架自动处理区域内的合并、换行、样式复制。
在Fesod里,模板填充的数据结构长这样:
public class FillRegion<T> { private String placeholder; // 模板占位符 private CellRangeAddress range; // 数据要填充的区域 private List<T> data; // 列表数据 private boolean mergeRange; // 是否需要合并 }这样模板填充不再是“找到一个单元格,填一个值”,而是“找到一个区域,铺一片数据”,合并规则跟着区域走。对于“合计行”“分组行”这类需求,直接在模板里画好合并区域,代码里只需要指定数据和占位符,剩下交给框架。
2.3 单元格内容模型:换行、富文本、嵌套列表都有原生表达
Fesod把单元格内容从“字符串”提升成了一个结构化对象,叫FesodCellValue。它支持:
- 多行文本:内部维护
MainText和ExtraLines,换行不再依赖\n字符 - 富文本片段:每个文本片段可以单独设置字体、颜色
- 列表结构:
ListValue类型,渲染时会根据列表长度自动扩展行
这个设计直击痛点。以前用EasyExcel处理“一个单元格里塞多个成绩”时,得自己拼字符串,拼完还得担心分隔符。现在直接构造一个列表值,填入单元格,框架负责渲染成多行内容。单元格换行、嵌套列表这些需求,在Fesod里变成了“填数据”而不是“写渲染逻辑”。
2.4 底层不背锅:解决JAR包冲突和系统依赖
NoSuchFieldError: factory这类问题,根因往往是多个POI版本并存,或者EasyExcel版本和POI版本不匹配。Fesod虽然也依赖POI,但它内部只锁定一组经过测试的POI版本,并且把对外依赖尽可能收敛,避免“传递依赖”把整个依赖树拖垮。
字体渲染的问题,Fesod也做了处理,在Linux服务器上如果检测不到系统字体库,会退回内置的字体内核。实测下来,之前EasyExcel必现的libfreetype6问题,在Fesod环境下直接消失,不用再给服务器装一堆系统包。
3. 迁移实操:从EasyExcel接口换成Fesod API
理论说再多,不如直接把代码改一版。我拿当时最复杂的“学生综合信息导入”模块举例,完整走一遍迁移过程。
3.1 老代码:EasyExcel实现“学生综合信息导入”
先用三段代码概括原实现。
第一段:实体类映射,三层表头全部用注解标出来。
@Data public class StudentInfoExcel { @ExcelProperty(value = {"基本信息", "姓名"}, index = 0) private String name; @ExcelProperty(value = {"基本信息", "年龄"}, index = 1) private Integer age; @ExcelProperty(value = {"扩展信息", "学籍号"}, index = 2) private String studentNo; @ExcelProperty(value = {"成绩信息", "语文", "平时成绩"}, index = 3) private Double chineseUsual; @ExcelProperty(value = {"成绩信息", "语文", "期末成绩"}, index = 4) private Double chineseFinal; @ExcelProperty(value = {"成绩信息", "数学", "平时成绩"}, index = 5) private Double mathUsual; @ExcelProperty(value = {"成绩信息", "数学", "期末成绩"}, index = 6) private Double mathFinal; }第二段:监听器读取,invoke方法里一行行组装业务对象。
public class StudentInfoListener extends AnalysisEventListener<StudentInfoExcel> { private final List<StudentInfoExcel> list = new ArrayList<>(); @Override public void invoke(StudentInfoExcel data, AnalysisContext context) { list.add(data); } @Override public void doAfterAllAnalysed(AnalysisContext context) { // 批量入库 } }第三段:读取Excel。
EasyExcel.read(inputStream, StudentInfoExcel.class, new StudentInfoListener()) .sheet(0) .headRowNumber(3) // 三层表头 .doRead();这段代码看着不复杂,实际问题全在表格结构变化上。如果业务方在某两列之间插入一个空列,所有index全部错位。如果表头里出现了“备注”这种跨列合并单元格,注解映射直接乱掉。每次业务方改模板,我这边至少要调半天映射关系。
3.2 改造一:Fesod读取三层表头
Fesod版本先不依赖注解,直接按行列结构读取。首先拿到FesodSheet:
try (FesodWorkbook workbook = Fesod.read(inputStream)) { FesodSheet sheet = workbook.sheet(0) .headerRows(3) // 前三行是表头 .skipEmptyRows(true); // 跳过空行 // 先获取表头结构 List<List<String>> headers = sheet.readHeader(); // 再获取数据行 List<StudentInfoExcel> students = sheet.beanMapper(StudentInfoExcel.class) .column(0, StudentInfoExcel::setName) .column(1, StudentInfoExcel::setAge) .column(2, StudentInfoExcel::setStudentNo) .column(3, StudentInfoExcel::setChineseUsual) .column(4, StudentInfoExcel::setChineseFinal) .column(5, StudentInfoExcel::setMathUsual) .column(6, StudentInfoExcel::setMathFinal) .map(); }这样做的好处是:表头层级由headerRows(3)指定,数据行从第4行开始解析。就算业务方调整了列位置,我只需要改.column()方法里的下标,或者在读取前先根据headers动态计算出目标列的位置,不用再动实体类注解。
3.3 改造二:复杂表头+动态列定位
我们实际用下来,最舒服的是“动态列定位”这个能力。以前业务方说“我把语文和数学中间加了一列英语”,我得找出所有受影响的index,逐个修改。现在写法变成了:
FesodSheet sheet = workbook.sheet(0).headerRows(3); List<String> level2Headers = sheet.headerRow(1); // 第二层表头 Map<String, Integer> columnIndex = new HashMap<>(); for (int i = 0; i < level2Headers.size(); i++) { columnIndex.put(level2Headers.get(i), i); } Integer englishUsualCol = columnIndex.get("英语-平时成绩");这一下就把“表头字段名”和“物理列下标”解耦了。以后再遇到模板调整列顺序,我只需要看表头里有没有对应字段名,代码不用改。实际项目中,这个特性直接帮我们省掉了每次版本更新时的模板适配工作。
3.4 改造三:单元格换行和嵌套列表渲染
模板填充的场景,我们用一个“成绩单导出”模板来说明。
原来的EasyExcel方式,需要把成绩List手动拼成字符串:
StringBuilder sb = new StringBuilder(); for (ScoreItem item : scoreList) { sb.append(item.getSubject()).append(":").append(item.getScore()).append("\n"); } cell.setCellValue(sb.toString());这个字符串在Excel里能不能正常换行,取决于单元格是否设置了WrapText。EasyExcel默认不设置,所以每次都要额外处理样式。
Fesod方式直接使用ListValue:
FesodSheet outputSheet = Fesod.create(targetFile); RowData row = outputSheet.row(4); row.cell(3).value(FesodValue.list(scoreList, score -> score.getSubject() + ":" + score.getScore() )); // 自动处理单元格内换行和WrapText row.cell(3).style().wrapText(true).apply();FesodValue.list()会把一个List渲染成多行文本,每行以换行符分隔,同时自动设置单元格的自动换行属性。再也不用手动拼\n,也不用担心样式问题。
3.5 改造四:模板填充里的合并单元格
这是我最满意的一部分。EasyExcel做“区域填充+合并”需要写CellWriteHandler,Fesod直接通过FillRegion对象声明。
假设模板里有一个区域,用来填充“科目成绩明细”,总共5行3列,最后一行是“合计”,合并A8:C8。模板长这样:
模板内容:
| 列A | 列B | 列C |
|---|---|---|
| 学科 | 平时成绩 | 期末成绩 |
| 语文 | 90 | 88 |
| 数学 | 95 | 93 |
| 合计 |
Fesod的填充代码:
Map<String, Object> params = new HashMap<>(); // 列表数据填充 params.put("scoreItems", scoreItemList); // 合并区域:合计整行合并到C列 List<FillRegion> regions = new ArrayList<>(); regions.add(FillRegion.builder() .placeholder("scoreItems") .range(new CellRangeAddress(4, 7, 0, 2)) // A4:C7 .data(scoreItemList) .build()); // 合计行合并 regions.add(FillRegion.builder() .placeholder("total") .range(new CellRangeAddress(8, 8, 0, 1)) // A8:B8 .mergeRange(true) .build()); Fesod.fillTemplate(templateFile, params, regions) .toFile(outputFile);这里最关键的是合并区域定义。以前EasyExcel必须自己判断当前写到第几行、该不该合并、合并多大范围,一旦数据条数变化,这些判断全要重来。Fesod是“区域驱动”,指定范围就完事,合并单元格本身跟着范围走,数据多了就扩展区域,少了就空白显示。
4. 迁移路上的坑与排查手册
迁移不是一帆风顺的,从EasyExcel切到Fesod,也踩了一些坑,有些是环境问题,有些是习惯问题。我把它们整理成一份排查手册。
4.1 EasyExcel时代的“经典报错”怎么彻底绕开
先说老问题。NoSuchFieldError: factory这个报错,本质是运行时ClassLoader加载到了两个不同的POI版本。排查思路很简单:
- 先看依赖树:
mvn dependency:tree -Dincludes=org.apache.poi - 找到所有POI相关JAR,确认版本是否一致
- 如果混入了EasyExcel传递依赖的旧版POI,用
exclusion排除
对应Fesod:Fesod内部锁定了POI版本,并且会在启动时校验。如果工程里还有别的模块引用了旧版POI,Fesod的启动日志会直接提示版本冲突,比EasyExcel运行到一半才炸友好得多。
libfreetype6的问题,本质是Linux下缺少字体渲染库,POI操作带文本样式的单元格时会调用系统字体。之前EasyExcel没办法绕过,只能让运维装依赖包,或者用无头模式。Fesod内置了字体回退机制。我现在部署新环境,不再需要额外装字体内核,少了一堆运维沟通成本。
4.2 Fesod迁移期的“新坑”:不是Bug,是习惯差异
第一类坑在于Fesod的try-with-resources约束比EasyExcel严格。EasyExcel的ExcelWriter不关闭,数据也能先写进临时文件;Fesod必须显式关闭或者用try块,否则缓存数据不落盘。排查一下午,发现是关闭时机不对,后来统一改成try (FesodWorkbook workbook = ...),问题就消失了。
第二类坑是headerRows()指定的行数必须和实际表头一致。EasyExcel如果表头行数设置不对,可能只是数据行位置偏移,Fesod会直接throws异常,因为它的表头解析流程会严格校验。第一次迁移时我漏看了模板里的一层隐藏行,结果运行时报错,后来在headerRows()上多传了行数,才解决。
第三类坑是模板占位符不能重复。EasyExcel对占位符的容忍度很高,同一个词出现多次,它会并排填,甚至报错。Fesod要求占位符在同一张模板里唯一,因为它是基于占位符去定位数据区域的。模板不规范时,需要先清洗模板,把重复占位符改成items_1、items_2这种带编号的参数名。
4.3 常见问题速查表
| 问题 | 症状 | 排查方向 |
|---|---|---|
| 表头列映射错乱 | 读取的字段值在不相干的列上 | 检查headerRows是否等于实际表头行数,检查columnIndex定位是否被合并单元格影响 |
| 单元格换行失效 | 模板导出的单元格里文字挤成一行 | 确认是否使用了FesodValue.list(),如果手动赋字符串,需要手动调用wrapText(true) |
| 模板填充合并区域错位 | 合计行没有按预期合并 | 检查FillRegion.range是否使用正确的CellRangeAddress坐标,注意0-indexed还是1-indexed |
| 模板占位符被原样输出 | 最终文件出现{{scoreItems}}之类文字 | 占位符必须和params中的key完全一致,且模板里不能有多余空格 |
| 读取大数据量时内存上涨 | 堆内存持续占用 | 检查是否调用了skipEmptyRows(false),空行也会被当成数据流处理 |
| 启动时POI版本冲突 | 日志出现NoSuchMethodError | 用依赖树检查旧版POI,使用exclusion排除 |
| 中文字体变方块 | 导出的Excel中文字显示为方块 | 需要给样式设置中文字体,比如new Font("微软雅黑"),不要依赖默认字体 |
4.4 一个独门排查技巧:模板先用WPS打开检查
这是我在实际项目中得到的教训。很多Excel模板是业务方用WPS创建的,和微软Excel在合并单元格的存储方式上有细微差异。Fesod底层是POI 5.x,对WPS生成的模板兼容性比旧版POI强,但也不是100%。遇到模板填充错乱,第一步不是改代码,而是先用WPS把模板打开另存为.xlsx,再试一次。我遇到过三次“代码没问题、模板有问题”的情况,全部出在合并单元格的坐标偏移上,另存一次就好了。
5. 迁移后的性能表现与选型建议
迁移不只看功能,性能也得能打。我们拿生产环境一张真实的报表做对比:186列,9万行数据,导出文件约42MB。
5.1 读取性能对比
| 方案 | 首次读取耗时 | 内存占用 | 9万行全量解析 |
|---|---|---|---|
| EasyExcel 3.x | 26秒 | 约1.2GB | 可用,但GC频繁 |
| Apache Fesod | 18秒 | 约700MB | 平稳,无长时间GC |
Fesod的流式读取做得更彻底,默认按sheet分块拉数据,不会一次性把整个工作簿加载进堆。EasyExcel虽然也标榜低内存,但在复杂表头场景下,会把表头结构和数据行一起缓存,内存占用明显高于Fesod。
5.2 写入性能对比
| 方案 | 9万行导出耗时 | 最终文件大小 |
|---|---|---|
| EasyExcel 3.x | 31秒 | 42.6MB |
| Apache Fesod | 24秒 | 39.8MB |
差距主要来自模板填充时的样式处理。EasyExcel在合并单元格时需要回调Handler动态计算,Fesod是区域级填充,一次计算整个区域的样式,省掉大量重复的单元格级操作。
5.3 什么时候不适合用Fesod
Fesod虽然香,但不是银弹。如果是纯简单的单表头导入导出,数据量几万行以内,EasyExcel的代码更短,社区资料更多,没必要换。Fesod的好处要等有复杂表头、模板合并、嵌套扩展行这些需求时才明显。
另外一个现实问题是团队熟悉度。如果整个团队只写过EasyExcel,换框架就要重新学习API,如果你是团队里唯一熟悉Fesod的人,这个迁移成本得提前算进去。我当时是写了详细的团队文档,把常用API封装成一个公司内部工具类,才把学习成本压下来的。
5.4 JVM参数建议
Fesod在默认JVM参数下表现不错,但做大数据量导出时,我建议加上这几个参数:
-Xms2g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200另外,如果同时有多个导出任务,注意Fesod的workbook数量不要无限制创建,建议用线程池控制并发数,每个线程维护独立的FesodWorkbook,避免共享状态。实践下来,一个4核8G的容器,并发4个导出任务,内存和CPU都还算稳定。
6. 最后分享一个迁移中的小技巧
整个迁移做完,最想分享的不是哪个API更好用,而是一个设计习惯的转变。
以前用EasyExcel的时候,我习惯“先把表头结构定死,再写实体类”,模板结构一变,代码就得跟着动。现在用Fesod,我改成“先读取表头,再动态绑定列”,把列映射关系外置到配置文件或者数据库表里,业务方改模板我只改配置,不动代码。
举个例子,我们在系统里建了一张excel_column_config表,字段包括:模板编号、表头名称、目标字段名、是否必填、数据类型。导入时,先用Fesod读出实际表头,再去配置表里查映射关系,如果表头里出现了没有配置的列,直接报错提示“未知列:XXX”。这个机制的开发成本不高,但对后续维护是质的提升。再也不用因为模板加了一列就发版,也让非技术同事能自己维护模板映射关系。
如果你现在正被复杂表头导入、模板填充合并单元格折磨,我的建议是别硬扛,抽出一天时间,用Fesod写一个和你当前业务最像的demo,把读取、填充、导出、合并这条链路跑通,再决定要不要迁移。代码这东西,跑一次比看十篇评测都管用。