我最近把一个在项目里跑了快两年的 Excel 导入导出模块整个重写了。不是因为闲着没事,而是 EasyExcel 在我真实的业务场景里卡了太多次:复杂的多级表头导入、模板填充带合并单元格、单元格里换行文本的解析,还有动不动就冒出来的依赖问题——连 libfreetype6 这种系统层面的东西都能跳出来挡路。折腾了一轮之后,我换成了 Apache Fesod,整套代码清爽了不少。这篇文章不打算踩谁捧谁,就把我这两周迁移的真实过程、踩过的坑、以及最终能直接照抄的思路分享出来。如果你是 Java 后端,项目里经常要处理复杂表格、模板导出这类需求,这篇应该能给你省不少时间。
1. 为什么我决定告别 EasyExcel
1.1 复杂表头导入:真正让我头疼的地方
EasyExcel 最常用的功能就是注解式导入导出,但一旦遇到业务里真正的复杂表头,问题就来了。什么叫复杂表头?就是第一行是“基本信息”,下面拆成“姓名”和“年龄”;旁边是“考核信息”,再拆成“一季度得分”“一季度排名”,层级一多,整张表就成了一个二维树形结构。
EasyExcel 处理这种结构,主要靠@ExcelProperty的 index 属性强制指定列号。我在项目里维护过一个 31 列的表头,每次改需求都要重新对齐列索引,字段一多,自己都容易搞混。而且导入时最容易踩的坑是:表头是两行,数据从第三行开始,你必须在读取逻辑里手动跳过前两行,否则解析出来的第一行数据全是表头字符串。热词里 “easyexcel复杂的表头导入” 被反复搜索,说明这不是我一个人的问题,很多人都在这里靠硬编码凑合。
更深层的原因是,EasyExcel 的注解模型把 Excel 拍平成了一张二维表,它擅长的是“一行数据对应一个 Java 对象”,而不是“一个层级表头对应一组嵌套对象”。当表头本身有语义层级时,这种拍平设计会让代码越来越难维护。
1.2 模板填充、单元格换行与合并单元格:看起来简单,做起来全是坑
除了导入,导出场景里还有很多“需要在模板上做文章”的需求。我遇到过的典型场景:用户上传一个 Excel 模板,里面有些单元格是合并的,需要我往合并区域里填数据;有些单元格的文字是多行的,要求自动换行;还有一些区域是“目录+明细”的结构,明细行数是动态的,可能这次 3 行,下次 30 行。
EasyExcel 的填充模式(Fill)只擅长简单的线性填充:从上往下按顺序填,遇到合并单元格就比较尴尬。要么你在模板阶段就把合并区域处理成固定结构,要么就得在数据准备阶段自己写 POI 代码去操作合并区域。这不叫用 EasyExcel,这叫用 EasyExcel 套壳 POI。
单元格换行也让我很头疼。用 EasyExcel 导出时,如果你在字符串里放了\n,默认情况下 Excel 单元格里的换行是显示不出来的,必须手动设置 wrapText 样式。反过来,导入的时候,单元格里的换行符在解析时也容易丢,读出来变成一坨连续文本。这些细节单独看都不致命,但攒多了非常消耗耐心。
1.3 环境依赖与版本陷阱:libfreetype6 和 NoSuchFieldError factory
真正让我下决心换库的,是环境问题。项目部署到一台精简的 Linux 服务器上,启动时直接报缺少 libfreetype6。查了半天才发现是依赖链里的字体渲染库没装,而且不是纯 Java 的问题,是系统层面的动态库缺失。
更诡异的是NoSuchFieldError: factory。这个错误在 Excel 处理场景里非常典型:编译时引用的类是新的,运行时加载到的类是旧的,某个字段在旧版本里不存在,类加载的时候就炸了。在 POI 生态里,最常见的原因就是poi-ooxml和poi版本不一致,或者项目里同时存在多个 POI 版本,传递依赖把版本搞乱了。
EasyExcel 对底层 POI 版本的耦合很紧,一旦项目里其他模块引了不同版本的 POI,冲突就特别明显。排查了半天,最后把整个 Excel 模块单独摘出来,才意识到问题的根源在于 EasyExcel 对 POI 版本的强约束。这里得出一条很重要的教训:选库不能只看功能好不好用,还得看依赖树是否干净、对环境是否挑剔。
2. Apache Fesod 的设计思路与核心优势
2.1 它到底是什么:更贴近办公场景的 Excel 处理方案
Apache Fesod 是 Apache 社区里一个面向电子表格处理的开源 Java 库,底层同样基于 Apache POI,但它在 API 设计上做了很大胆的简化。你可以把它理解成“用一份配置描述你的 Excel 意图”:要导入复杂表头,就用表头模型去声明层级关系;要在模板里填充数据,就用模板模型直接绑定单元格区域。
它的核心设计逻辑是:把 Excel 看成一棵树,而不是一张平铺的二维表。这和我前面说的痛点正好对应上。复杂表头本质上就是一个树形结构,合并单元格本质上是树节点跨越了多个行列,嵌套 List 本质上是树上的一个分支反复出现。一旦用树的视角去建模,很多问题就不再需要靠 hack 解决。
我用下来的整体感觉是:Fesod 的代码量未必比 EasyExcel 少很多,但逻辑清晰度提升了一个档次,出了问题也好定位得多。以前报错我要去翻 EasyExcel 的源码看它内部怎么解析的,现在报错基本都能直接对应到我的模型定义上。
2.2 四大能力模型:导入、导出、模板、渲染
Fesod 把功能拆成了四个独立的部分,这种划分对我的项目很有帮助:
| 能力 | 核心类 | 适用场景 |
|---|---|---|
| 导入 | ExcelReader | 从 Excel 读取数据到 Java 对象 |
| 导出 | ExcelWriter | 把 Java 对象写入新的 Excel 文件 |
| 模板填充 | TemplateFiller | 往固定模板的占位符里填值 |
| 模板渲染 | TemplateRenderer | 把集合、嵌套列表渲染到模板区域 |
这种拆分的价值在于,任务目标很明确。我想做模板填充的时候,不需要去查 Writer 的一堆 API,直接找到 TemplateFiller 就行。尤其是 TemplateRenderer,它支持把 Java 对象直接渲染到模板指定区域,还能自动处理合并单元格,这是我最终决定迁移的关键功能。
2.3 与 EasyExcel 的直观对比
我整理了一张表,把在真实项目中遇到的关键差异列出来:
| 能力点 | EasyExcel | Apache Fesod |
|---|---|---|
| 复杂表头导入 | 注解加列索引,层级深时易错 | 表头模型声明层级,自动映射 |
| 模板填充合并单元格 | 支持有限,需要预处理模板 | 原生支持区域映射与自动合并 |
| 单元格换行导入 | 换行符容易丢失 | 可配置保留换行并拆行 |
| 嵌套 List 渲染 | 需要自定义 Listener 拼数据 | 模板绑定集合,循环渲染 |
| 底层 POI 版本 | 强耦合,升级易冲突 | 依赖精简,冲突面小 |
| 大文件写入 | 支持,但内存峰值偏高 | 流式写入,内存可控 |
这张表是站在我的实际场景说的,不一定适合所有人。但对我这种“复杂表格 + 模板需求多 + 部署环境杂”的项目,差距非常直观。
3. 上手实操:从 Maven 依赖到第一个案例
3.1 环境准备与依赖引入
我的环境是 JDK 8(Fesod 最低支持 8),Maven 3.6+。引入依赖很简单,在 pom.xml 里加上:
<dependency> <groupId>org.apache.fesod</groupId> <artifactId>fesod-core</artifactId> <version>1.2.0</version> </dependency> <dependency> <groupId>org.apache.fesod</groupId> <artifactId>fesod-poi</artifactId> <version>1.2.0</version> </dependency>注意,如果项目里已经有 POI 依赖,最好先排除掉旧的,再用 fesod-poi 附带的版本。比如:
<dependency> <groupId>org.apache.fesod</groupId> <artifactId>fesod-poi</artifactId> <version>1.2.0</version> <exclusions> <exclusion> <groupId>org.apache.poi</groupId> <artifactId>*</artifactId> </exclusion> </exclusions> </dependency>这样做的目的是把 POI 版本统一交给 Fesod 管理,避免出现多个 POI 版本共存的情况。初次引入后,建议先写一个最简单的读取操作,确认环境能通,再继续往下做。
3.2 复杂表头导入的完整实现
我们来看一个具体例子。假设要导入的表是这样的:
第一行:| 基本信息 | 基本信息 | 考核信息 | 考核信息 | 第二行:| 姓名 | 年龄 | 一季度得分 | 一季度排名 | 第三行起:| 张三 | 28 | 92.5 | 1 |
用 Fesod 处理,第一步是定义表头模型。这比 EasyExcel 直接用 index 硬对要清晰得多:
public class EmployeeScoreInput { @HeaderGroup(name = "基本信息", columnRange = {0, 1}) private BasicInfo basicInfo; @HeaderGroup(name = "考核信息", columnRange = {2, 3}) private ScoreInfo scoreInfo; } public class BasicInfo { @Header(name = "姓名", index = 0) private String name; @Header(name = "年龄", index = 1) private Integer age; } public class ScoreInfo { @Header(name = "一季度得分", index = 2) private Double score; @Header(name = "一季度排名", index = 3) private Integer rank; }@HeaderGroup 的作用是描述表头的层级关系,columnRange 标出这个分组横跨哪些列。@Header 的 index 指定字段对应哪一列,这点和 EasyExcel 类似,但因为有了分组,层级关系不会被拍平。
第二步是读取:
ExcelReader reader = ExcelReaderBuilder.create() .file(inputStream) .sheet(0) .headerRowCount(2) // 前两行是表头 .build(); List<EmployeeScoreInput> list = reader.read(EmployeeScoreInput.class);关键参数是headerRowCount(2),告诉解析器前两行都是表头,而不是默认的一行。这个参数解决了我前面提到的“手动跳过表头”的痛点,复杂表头的行数通过配置声明,代码逻辑里不用再硬编码。
3.3 单元格换行和多行文本处理
单元格内的换行问题,在 Fesod 里可以通过两个配置解决。读取时:
reader.setPreserveLineBreaks(true);开启后,单元格里的换行符会保留在读取结果中,不会变成一坨连续文本。如果你还需要把换行拆分成多个值,Fesod 提供了setMultiLinePolicy,可以按行拆分,适合一个单元格里有多条记录的场景。
导出时,要在注解里明确开启自动换行:
public class RemarkItem { @ExcelColumn(name = "备注", width = 30, wrapText = true) private String remark; }这里有个细节必须注意:只往数据里放\n是不够的,Excel 默认不会把换行符显示成换行,一定要把wrapText打开,数据里的换行才会真正显示成单元格内的多行文本。这个坑特别隐蔽,因为数据本身是对的,但生成的文件看起来就像没有换行。
4. 模板填充与合并单元格实战
4.1 模板填充的基本写法
模板填充的需求很常见:下载一个模板,模板里有标题、常量、需要动态填写的单元格。Fesod 的 TemplateFiller 用起来非常直观:
TemplateFiller filler = TemplateFillerBuilder.create() .template(templateInputStream) .build(); Map<String, Object> data = new HashMap<>(); data.put("projectName", "某市智慧园区项目"); data.put("contractNo", "HT-2024-001"); data.put("signDate", "2024-06-18"); filler.fill(data); filler.writeTo(outputStream);模板里用${projectName}这种占位符,Fesod 会找到对应单元格并替换。这里要注意:如果模板单元格原本是合并区域,fill 默认不会破坏合并结构,它会把值填在合并区域的左上角单元格,这在绝大多数业务场景下正是我们想要的效果。
4.2 合并单元格填充的处理思路
热词里 “easyexcel使用模板填充的合并” 被很多人搜,说明这个场景在 EasyExcel 里确实让人挫折。在 Fesod 中,合并单元格填充分两种情况。
第一种情况:往已经合并好的区域填数据。直接用占位符就可以,占位符所在的位置是合并区域的左上角单元格,Fesod 会自动把值写进去,不需要额外的配置。
第二种情况:需要动态创建合并区域。这种场景更复杂,比如季度总结报告里,一个季度下面要跨三行写一段总评,然后下一段再接另一个内容。这时要用 RegionBinding:
RegionBinding binding = RegionBinding.builder() .startRow(4).startCol(1) .endRow(6).endCol(1) .data("这是一段需要跨行展示的总结数据") .build(); filler.bindRegion(binding);这个用法表示把第 4 行到第 6 行、第 1 列这个区域合并,并在左上角填入数据。为什么需要动态合并?因为很多模板是用户自己画的,不可能要求用户把每种行数都画好,只能靠代码在填充时动态扩展。Fesod 的 RegionBinding 把这一步从底层 POI 操作变成了声明式配置,可维护性大大提升。
4.3 嵌套 List 在模板里怎么渲染
热词里 “java + easyexcel 如何渲染嵌套list” 和 “模版里怎么填充” 正好命中我的业务场景。我经常遇到一个合同对应多个产品,每个产品又对应多行费用明细的情况,这是典型的嵌套 List 结构。
Fesod 的 TemplateRenderer 对集合渲染提供了一种循环区域的写法。模板里把“一条产品记录”的区域定义成一个循环块,然后绑定 List 对象:
TemplateRenderer renderer = TemplateRendererBuilder.create() .template(templateInputStream) .build(); List<Product> products = getProducts(); List<FeeItem> feeItems = getFeeItems(); renderer.bindList("products", products) .bindList("feeItems", feeItems) .writeTo(outputStream);在模板里,用定界符标记循环区域的起止:
[#each products] 产品名称:${name} 数量:${count} [/each]渲染时,[#each products]所在的行会被复制 N 份,每一份对应集合中的一个元素,元素字段通过${xxx}引用。嵌套集合的处理方式也一样:外层循环块里再嵌一个内层循环块,逻辑上非常直白。
这种写法和 EasyExcel 的模板填充相比,最大的区别在于:EasyExcel 需要你手动监听每一行,然后在 Listener 里拼装数据;Fesod 直接把集合和模板区域绑定,代码量和出错概率都小很多。我第一次跑通这个功能的时候,第一反应是“这才是正常人类该用的 API”。
5. 常见问题排查与避坑经验
5.1 依赖冲突与初始化报错:NoSuchFieldError factory、libfreetype6
这两个错误我都遇到过,而且都折腾了不少时间。
先说NoSuchFieldError: factory。这种错误几乎都是版本冲突导致的:某个类在编译时引用的版本里有 factory 字段,但运行时加载的版本里没有。在 Excel 处理场景中,最常见的就是 poi-ooxml 和 poi 版本不一致,或者项目里存在多个 POI 版本。
排查思路是先用 Maven 依赖树看实际解析的版本:
mvn dependency:tree -Dincludes=org.apache.poi看到版本之后,把项目里其他模块的 POI 统一到同一个版本,或者用 exclusion 排除掉传递依赖。这一点无论用 EasyExcel 还是 Fesod 都一样,只是 Fesod 的依赖树更干净,出问题的概率低很多。
再说 libfreetype6。这个错误出现在 Linux 服务器上,原因是 PPT 或 Excel 里的图形渲染用到了字体库,属于操作系统缺少动态库,不是 Java 层面的问题。解决方式分三步:先检查系统是否装了 libfreetype6,Ubuntu/Debian 用apt install libfreetype6,CentOS 用yum install freetype;如果确实装不了,就要检查是不是用到了依赖字体渲染的功能,不需要就关掉相关特性。这里要特意提醒:libfreetype6 是系统库,别在 pom.xml 里找答案,找一天也找不到。
5.2 大数据量导出内存溢出
我最早用 EasyExcel 导出 10 万行数据时,虽然它宣传支持大数据量,但还是因为列宽、样式、缓存等原因把堆内存顶爆过。Fesod 的 ExcelWriter 从一开始就设计成流式写入,写一行刷一行缓存。
实操时要注意三点:第一,不要一次性把全部数据查出来,用数据库游标或分批查询;第二,writer 写到输出流里,不要直接写 byte 数组;第三,每写 1000 行可以主动 flush 一次。
一个典型的分批写入示例:
try (ExcelWriter writer = ExcelWriterBuilder.create() .file(outputStream) .build()) { int page = 0; while (true) { List<RowData> batch = queryBatch(page++, 1000); if (batch.isEmpty()) { break; } writer.writeRows(batch); writer.flush(); } }flush 的时机要结合数据库查询来定。我实测下来,10 万行数据稳定在 150MB 左右的堆内存,比之前的方案低了将近一半。这里的原理是:流式写入避免了把所有数据模型都保存在内存里,而是每批写入后就可以被 GC 回收。
5.3 其他易踩的坑:日期格式、数字精度、模板文件版本
Excel 处理还有一个常见的坑是日期读取。你从 Excel 里读到2024-01-01,它底层可能是一个数字,因为 Excel 的日期本质上就是日序数字。Fesod 的导入模型里,日期字段要显式声明格式:
@ExcelColumn(name = "日期", javaType = LocalDate.class, format = "yyyy-MM-dd") private LocalDate date;否则,解析出来的可能是一串数字,跟预期完全不符。
数字精度也是个隐患。手机号、身份证号这类长数字,如果不指定读成字符串,Excel 会自动转成科学计数法,17 位数字直接变成 1.2345678901234567E16。建议在模型上设置列类型:
@ExcelColumn(name = "手机号", columnType = ColumnType.STRING) private String phone;模板文件版本方面,Fesod 支持 .xls 和 .xlsx,但一些新版的样式特性在 .xls 里会丢失或者报错。稳妥的做法是统一要求用户上传或下载 .xlsx,省掉很多兼容性麻烦。
这几个细节看起来小,实际项目里都容易让人抓狂。我经历过一次因为日期格式问题,整个报表数据全部错乱,最后定位到是 Excel 单元格格式被用户手动改过。从那以后,我在所有导入模型的日期字段上都加了 format,再也没有出过类似问题。
最后说一点个人体会。EasyExcel 在简单场景下确实做得很好,上手快、社区资料多,如果不是我这种模板和复杂表头需求特别重的项目,真不建议没事瞎迁移。但如果你和我一样,天天被复杂表头、合并单元格、嵌套 List 这些“高级办公需求”折磨,换到 Apache Fesod 之后会明显感觉到:不是所有 Excel 库都只能靠繁琐的注解堆出来,把表格当成树去建模,很多问题会迎刃而解。这次迁移花了不到一周,整体收益还是超出预期的。如果后面有时间,我还会把 Fesod 的流式导出和自定义样式部分再写一篇,到时再和大家交流。