从 EasyExcel 迁移到 Apache FastExcel:复杂表头与模板填充的完整实践
2026/9/12 9:24:39 网站建设 项目流程

去年年底我接手了一套供应链对账报表的维护,里面有一个从设计上就让人头疼的模板:四层复杂表头、跨列合并的差异对比区、一段嵌套的订单明细列表,单元格还要求自动换行。这套东西之前用的是 EasyExcel 3.x,单看导出简单列表确实很顺手,可一旦进入复杂表头导入、模板填充、嵌套 list 渲染这些场景,问题就一个接一个冒出来。最离谱的一次,模板填充后第二页的合并单元格整体错位,财务那边打印出来直接对不上数,我连着排查了两个晚上,最后定位到是填充流程和合并区域策略的冲突。从那会儿起我就开始认真考虑换掉 EasyExcel,最后把目光落在了 Apache FastExcel 上——也就是社区里常被拼成 Fesod 的那个项目。这篇博文就是这次迁移的完整记录,包括我踩过的坑、API 差异、模板填充的解法,以及最终的选型建议。

1. 在 EasyExcel 上栽过的跟头:为什么动换库的念头

1.1 复杂表头并非“开箱即用”:动态表头和合并单元格的真相

很多团队选 EasyExcel,是因为官方文档里写了一句“支持复杂表头”,但实际用起来完全不是那么回事。EasyExcel 的注解模型处理的是平铺表头,也就是一行表头对应一个字段这种常规情况。一旦表头变成两行、三行,甚至某些层级是运行时动态生成的,注解方式就失效了,你得自己在代码里构造List<List<String>>来定义表头结构。

我那个供应链对账报表的表头分四级:大区、省份、城市,下面挂着一堆指标列,中间还有一个跨多列的“对比差异”区域。第一版我用注解写死表头,上线两周后业务方说要在“城市”下面再加一个“区县”层级,表头从三级变成四级,所有索引全部偏移。改完这次之后我就学乖了,把表头改成运行时构造,但这又带来另一个问题:合并单元格在导入时不会自动展开。

这里要说明一个 EasyExcel 的底层行为:读取 Excel 时,一个合并区域只有左上角第一个单元格有值,其他单元格返回的是 null。比如“大区”列里“华东”这个值合并了 10 行,你用监听器遍历每一行,只有第一行能拿到“华东”,后面 9 行都是 null。你必须在读取阶段自己维护一个合并区域映射,把值手动填充到后面的行里。这套逻辑说起来简单,但业务表头一变,合并区域的坐标就跟着变,维护成本极高。

我后来统计了一下,这套系统里所有跟“复杂表头”相关的代码,超过一半都是在处理合并单元格和表头层级映射,而不是在写真正的业务逻辑。这让我开始反思工具选型的问题。

1.2 模板填充的合并病:正式报表里没人说的错位

如果说复杂表头还能靠堆代码解决,那模板填充的合并问题真的是让人崩溃。财务部给了一个设计好的 Excel 模板,里面有大标题、合并单元格、预设好格式的空白明细区域。数据填充用的是 EasyExcel 的 fill 功能,核心思路是模板里写占位符{.字段名},然后调用fill()把数据一行一行“灌”进去。

听起来很美好,实际跑起来就翻车。我的模板里明细区域上方有好几个合并单元格,比如“制表人”“审核人”这种跨列合并的单元格。fill 的流式填充机制是逐行向下推进的,当填充的数据量超过模板预设的明细行数时,它会在下面追加新行,但追加行并不会继承上方合并单元格的区域设定,结果就是:第一页正常,第二页开始所有合并区域集体错位,有的格子直接空了,有的把别的位置顶开了。

我在 GitHub issues 里翻到过类似反馈,核心问题在于 fill 的追加策略和 POI 底层的 merged regions 处理逻辑存在冲突,官方长期没有彻底解决。最后我实在没办法,只能在 fill 完成之后再用 POI 原生 API 把合并区域重新设一遍。既然最终还是要写 POI 代码,那我还不如直接找一个能把这套逻辑理顺的方案。

1.3 迭代停滞与社区现状:三年不更新的隐忧

还有一个现实层面的问题:EasyExcel 的新版本发布节奏已经明显放缓,很多遗留 issue 长期挂着。对一个公司核心报表模块来说,库长期不维护意味着风险在持续累积,比如新 JDK 版本的兼容性、新 POI 版本的安全漏洞、以及社区里那些“已知但没人修”的 bug。

我是在查资料的过程中了解到 FastExcel 这个项目的。它的发起者里有 EasyExcel 的原核心成员,目标就是解决这些问题,并且项目已经捐赠给 Apache 软件基金会进入孵化阶段。社区里有人写成 Fesod,有人写 FastExcel,其实就是同一个东西。下面我会用 FastExcel 这个正式称呼来讲。

2. FastExcel 是什么:Apache 孵化项目与 EasyExcel 的血缘

2.1 它从哪里来:与 EasyExcel 的传承关系

FastExcel 的定位是 EasyExcel 的继任者,继承了“内存友好、API 简单”的设计理念,同时把 EasyExcel 遗留的问题重新梳理了一遍。底层依然构建在 Apache POI 之上,所以它对 POI 生态的兼容性是天然的。

对使用者来说,最关心的不是它背后的组织架构,而是 API 兼容程度。我的实测结论是:绝大多数 EasyExcel 代码可以直接替换,核心 API 名称和写法几乎一一对应。比如EasyExcel.read()对应FastExcel.read()EasyExcel.write()对应FastExcel.write(),注解模型也基本保持一致。这意味着迁移更像是一次“替换包名 + 修细节”的操作,而不是推翻重来。

2.2 环境准备与 Maven 坐标:迁移前必须做好的三件事

迁移前先把环境理清楚,否则后面全是坑。

第一,JDK 版本。FastExcel 要求 JDK 8 以上,但如果你在用比较新的 POI 版本,建议直接上 JDK 11 或 17。我在 JDK 8 上跑过,能用,但遇到某些 POI 版本会有告警,日常开发没影响,生产环境建议用 LTS 版本。

第二,Maven 版本。官方要求 Maven 3.6+,我用的是 3.9.x,没有遇到问题。如果你还在用 3.5 之前的版本,建议先升级,否则拉取依赖时可能出现解析问题。

第三,依赖坐标。FastExcel 的 GAV 坐标类似下面这样:

<dependency> <groupId>cn.idev.excel</groupId> <artifactId>fastexcel</artifactId> <version>替换成官方最新版本</version> </dependency>

注意版本号一定要去官方仓库查最新版,不要照抄博文里的占位符。同时,因为 FastExcel 底层依赖 POI,你要检查项目里有没有其他组件也引了 POI,避免多个 POI 版本冲突。

2.3 是否真的“零成本迁移”:接口兼容性速览

我用一个表格列出我实际迁移时对应关系,方便你评估:

EasyExcel 用法FastExcel 对应用法差异说明
EasyExcel.read(inputStream)FastExcel.read(inputStream)方法形态基本一致
EasyExcel.write(outputStream)FastExcel.write(outputStream)方法形态基本一致
@ExcelProperty注解同名注解,包路径不同需替换 import
.sheet().doReadSync().sheet().doReadSync()使用方式相同
.sheet().fill().sheet().fill()填充逻辑相似
ExcelWriter.finish()ExcelWriter.finish()注意调用顺序更严格

整体来说,80% 的代码可以通过批量替换搞定,剩下 20% 集中在包名替换、模板填充逻辑调整和数据格式处理的细节上。下面我挑几个最容易出问题的场景展开讲。

3. 核心 API 迁移对照:导出、导入的差异清单

3.1 动态表头与复杂表头的导入实现

先看最常见的静态表头导入,FastExcel 的写法和 EasyExcel 几乎一样:

// 静态表头,直接映射实体类 List<RowData> list = FastExcel.read(inputStream) .head(RowData.class) .sheet() .doReadSync();

但碰上动态表头,就不存在“开箱即用”了,你需要手动构造表头,并按行读取:

List<List<String>> dynamicHead = buildDynamicHead(); // 根据业务动态生成 FastExcel.read(inputStream) .sheet() .head(dynamicHead) .registerReadListener(new AnalysisEventListener<Map<Integer, Object>>() { @Override public void invoke(Map<Integer, Object> row, AnalysisContext context) { // 手动处理每一行数据 } }) .doRead();

这里的AnalysisEventListener在 FastExcel 里的包路径和 EasyExcel 不同,但使用逻辑是相同的。比较关键的是合并单元格展开的处理。我建议封装一个工具方法,在所有导入流程里统一调用:

private void fillMergedRegionValues(Sheet sheet, List<CellData> rows) { // 遍历所有合并区域 for (CellRangeAddress region : sheet.getMergedRegions()) { int firstRow = region.getFirstRow(); int lastRow = region.getLastRow(); int firstCol = region.getFirstColumn(); int lastCol = region.getLastColumn(); // 取左上角单元格的值 Object value = getCellValue(sheet.getRow(firstRow).getCell(firstCol)); // 将值填充到合并区域内的所有单元格 for (int rowIdx = firstRow; rowIdx <= lastRow; rowIdx++) { for (int colIdx = firstCol; colIdx <= lastCol; colIdx++) { setCellValue(sheet.getRow(rowIdx).getCell(colIdx), value); } } } }

这个思路不难,但很多人一开始根本想不到。如果你在做一个复杂表头导入功能,建议一上来就把合并区域展开做成基础能力,不要等业务方反馈“数据怎么少了”再补救。

3.2 单元格换行与文本控制的处理差异

“单元格换行”是个典型的高频问题,也是热词里反复出现的。Excel 里的换行不是存一个\n就行的,它要求单元格开启自动换行属性(wrapText),并且文本里包含换行符。用 FastExcel 写入时,可以通过注解控制:

public class OrderRow { @ExcelProperty(value = "商品信息", index = 0) @ContentStyle(wrapped = true) private String productInfo; }

@ContentStyle(wrapped = true)对应到底层就是设置 POI 的CellStyle.setWrapText(true)。如果你不用注解,也可以在拦截器里统一设置:

WriteCellStyle contentStyle = new WriteCellStyle(); contentStyle.setWrapped(true);

但真正容易踩坑的是导入侧。用户手动在 Excel 里换行后,你读到的字符串可能是\r\n,也可能是\n,不同操作系统下不一样。如果你要用换行符做 split 或 trim,直接按\n切会留下\r,看起来很脏。我一般这样处理:

String[] lines = cellValue.split("\\r?\\n"); for (String line : lines) { String trimmed = line.trim(); // 逐行处理 }

另外,开启 wrapText 后,如果行高没有设置,内容会显示不全。数据量不大时可以在写入前设一个相对宽的行高系数,按内容长度估算。这个没有精确公式,我的经验值是每个换行符加 0.4~0.6 倍默认行高,再取最大值。

3.3 大数据量写入的性能表现:内存和耗时实测数据

换库之前我最担心的是性能,毕竟存量有一套 30 万行 × 25 列的导出任务。我做了同数据量的对比测试,结果是:两者耗时基本持平,FastExcel 的堆内存峰值略低,大约低了 10%~15%,但差距不算巨大。

让我真正觉得舒服的是 FastExcel 对大数据量写入的机制更透明。EasyExcel 在超大数据量下依赖 POI 的 SXSSF 模式,临时文件会写入系统临时目录。如果你的服务器/tmp分区不大,并发导出时很容易把磁盘写满,然后整批任务卡死。这个坑我在生产环境踩过,后来是通过把临时目录指向专门的数据分区解决的:

java -Djava.io.tmpdir=/data/excel_tmp -jar your-app.jar

换成 FastExcel 之后,这个机制依然存在,但它的内部策略更稳定,不会因为单个 sheet 的数据量波动而频繁触发文件切换。实测下来,同样是 30 万行导出,临时目录的写入峰值小了不少。

4. 模板填充与嵌套 list 渲染:最容易翻车的地方

4.1 带合并单元格的模板填充:从错乱到稳定的排查过程

这是整个迁移里最有价值的一段经历,我完整还原一下排查链条。

现象:模板第一页填充正常,第二页开始合并单元格错位,有的区域被截断,有的区域直接被顶到错误位置。

排查第一步:怀疑模板本身。我用 WPS 新建了一个干净模板,只放一个合并单元格和一个占位符,结果问题依旧。排除模板设计问题。

排查第二步:怀疑 fill 的追加机制。我仔细读了 fill 的流程,发现当数据超过模板预置的明细行数时,引擎会复制最后一行的样式和结构来追加新行。但复制操作并不会同步修正上方已有的合并区域,导致后续写入位置偏移。这是底层机制问题。

排查第三步:改用填充后重新设置合并区域。我在 fill 完成之后,拿到生成的 Workbook,遍历所有合并区域,根据实际写入的数据行数重新计算坐标,再设置一次。核心逻辑是:

// fill 结束后,从 workBook 中拿到 sheet Sheet sheet = workBook.getSheetAt(0); // 清除原有合并区域 for (CellRangeAddress region : sheet.getMergedRegions()) { sheet.removeMergedRegion(region); } // 根据实际数据行数重新合并 CellRangeAddress newRegion = new CellRangeAddress( titleStartRow, titleEndRow, 0, lastColumn); sheet.addMergedRegion(newRegion);

这里有个细节:removeMergedRegion之后,原合并区域的值只保存在左上角单元格里,其他单元格是空的,重设合并区域之前要把这些值重新填一遍,否则会丢数据。

这个方案稳定跑了一个月,后面所有带合并单元格的模板都沿用这个思路。如果你用 FastExcel,也是一样的处理路径,因为底层都是 POI 的合并区域模型。

4.2 Java 对象嵌套 list 怎么填:从“手写字符串”到模型化渲染

社区热词里“java + easyexcel 如何渲染嵌套 list”“模版里怎么填充”这类问题特别多。我的经验是:不要在模板里试图表达“复杂对象层级”,模板引擎只认平铺字段,你必须把嵌套结构先拍平。

举个例子,模板里要展示一个订单,每个订单下有多个商品。对象结构是这样的:

public class Order { private String orderNo; private List<OrderItem> items; }

模板里如果直接写{.orderNo}是没问题的,但商品明细是列表,你得先把它转换成平铺结构:

List<Map<String, Object>> flatRows = new ArrayList<>(); for (Order order : orders) { for (OrderItem item : order.getItems()) { Map<String, Object> row = new HashMap<>(); row.put("orderNo", order.getOrderNo()); row.put("goodsName", item.getGoodsName()); row.put("qty", item.getQty()); flatRows.add(row); } }

然后模板里用一整行占位符来表示明细行。关键是:这一行不要只放一个占位符,而是要把所有字段的占位符放在同一行,填充引擎才会把每个 Map 元素渲染成一行数据。这个机制在 EasyExcel 和 FastExcel 里是相同的。

我建议在项目里统一封装一个TemplateData的转换层,所有写模板的业务对象都先转成List<Map<String, Object>>,再交给填充引擎。这样模板改动时,只需要改转换层,业务代码不会跟着抖。

4.3 复杂表头场景下的填充顺序陷阱

还有一个很隐蔽的问题,同时涉及“复杂表头”和“模板填充”两个场景。有些模板的表头本身就是动态的,比如某些批次多了几列,某些批次表头多了一行。如果 fill 的数据区起始行是按固定值写死的,表头一变,数据就全部错位。

我的解决办法是在模板底部放一个隐藏标记行,里面写一个约定好的字符串,比如__END__,代码里先读取这个标记所在的行,用它减去固定偏移得到数据区的起始行:

int dataStartRow = 0; for (Row row : sheet) { Cell c = row.getCell(0); if (c != null && "__END__".equals(c.getStringCellValue())) { dataStartRow = row.getRowNum() - 1; // 标记行上面一行就是数据最后一行 break; } }

这样无论表头怎么变,只要隐藏标记在,数据区起始位置就能动态算出来。这个技巧帮我躲过了好几次“表头微调导致整张报表错乱”的坑。

5. 迁移过程中的踩坑记录与排查链路

5.1 Maven 依赖冲突:别让老版本注释残留污染新项目

热词里有一个“easyexcel nosuchfielderror factory”,看到这个我就想起来自己刚开始迁移时的经历。当时新项目里同时存在老 EasyExcel 的传递依赖和新 FastExcel 的依赖,运行时报了一个NoSuchFieldError: factory,定位了半天才发现是 CGLIB/ASM 版本不匹配。POI 的某些功能依赖 CGLIB 做动态代理,版本冲突就报这种错。

排查链路很简单:

mvn dependency:tree -Dincludes=org.apache.poi mvn dependency:tree -Dincludes=cglib

把输出里的 POI 版本和 CGLIB 版本列出来,统一到同一版本线。

处理方式是:保留 FastExcel 的依赖,在 pom 里对老 EasyExcel 的传递依赖做 exclude:

<dependency> <groupId>cn.idev.excel</groupId> <artifactId>fastexcel</artifactId> <version>最新版本</version> <exclusions> <exclusion> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> </exclusion> </exclusions> </dependency>

迁移期间两个库并存是常态,但一定要清楚哪些模块在还老代码、哪些模块已经切到新库,建议用 package 或模块边界隔离开,不要在同一段业务代码里混用。

5.2 测试环境正常生产环境慢:线程池与临时目录的坑

性能问题的排查往往比功能问题更让人头疼。我遇到的一个场景是:测试环境导出 30 万行数据只要 30 秒,生产环境同样的数据量却要 2 分钟,而且并发一高就全员超时。

排查过程分两步。第一步看 CPU,正常;第二步看磁盘,发现/tmp目录使用率接近 100%。原因是 SXSSF 模式会在临时目录不断写入和删除临时文件,多线程并发导出时临时文件来不及清理,磁盘满了以后所有写入操作都开始阻塞。

解决方案就是前面提到的-Djava.io.tmpdir=/data/excel_tmp,同时给导出接口加上信号量控制并发数:

private final Semaphore exportLimiter = new Semaphore(4); public void export(OutputStream os) { exportLimiter.acquire(); try { // 执行导出 } finally { exportLimiter.release(); } }

限流到 4 个并发之后,生产环境的导出耗时回落到 40 秒左右,系统整体也稳定了。这个经验跟用哪个 Excel 库关系不大,但很多人会忽略。

5.3 新老 API 混用:一个实际迁移案例

分享一个具体的迁移案例。我们有一个老的导出方法,用的是 EasyExcel 的经典写法:

EasyExcel.write(outputStream, OrderRow.class) .sheet("订单列表") .doWrite(orderList);

迁移到 FastExcel 后,改法非常直接:

FastExcel.write(outputStream, OrderRow.class) .sheet("订单列表") .doWrite(orderList);

看起来就是替换了类名,但有两个细节要小心。

一是finish()的调用顺序。FastExcel 在流式写大数据量时,对finish()的调用时机更严格,如果你在doWrite之后还往输出流写东西,必须先调用finish()再关流,否则可能丢数据。

二是注解的包路径。@ExcelPropertycom.alibaba.excel.annotation换到了新包,批量替换时要注意不要只替换 import 而忽略了其他地方对注解全限定名的引用。

我建议迁移时先挑一个最简单的导出接口跑通全链路,再逐步替换复杂场景。不要一次性把所有模块都改了,出了问题很难定位。

6. 到底该不该换:选型建议与我的最终结论

6.1 什么场景必须换,什么场景可以继续留在 EasyExcel

我用一个表格列出我的判断标准,方便你对照自己的项目:

场景建议原因
简单的列表导入导出(无合并、无复杂表头)可不换新老库都能胜任,迁移收益不大
复杂表头导入(动态层级、合并单元格)建议换新库在这个场景下的 API 设计更顺手,社区反馈修复更及时
模板填充且包含合并单元格必须换这是老库长期未解决的核心痛点
嵌套 list 渲染模板建议换底层机制相同,但新库的文档和案例更完整
老项目锁定版本、无法升级依赖不要换没必要为了换而换,增加团队负担
新项目立项直接用新库长期风险更低,社区活跃度明显更高

6.2 迁移成本清单与团队协作建议

迁移成本没有你想的那么高,但也不是改几个 import 就完事。我按实际工作量列一个清单:

工作项估算人天说明
依赖替换与编译修复0.5主要是包名和坐标调整
基础导入导出回归1覆盖简单列表、动态表头两个场景
模板填充改造2~3合并单元格处理逻辑要重写
单元测试补充1建议对每个填充模板做一个自动化断言
生产灰度与监控1先灰度一部分报表,观察内存和耗时

团队协作上,我踩过一个教训:迁移期间老代码和新代码混用,又没有人记录哪些模块已经切换,结果有一次误改了老模块的导出逻辑,差点发布事故。建议迁移期间搞一个简单的模块清单,标记每个模块的迁移状态,切换一个标记一个。

6.3 我的实际操作体会

迁移完成之后一个月,我陆续处理了四个动态复杂表头需求,全部都在预期时间内交付。如果还在老库上,至少有两个需求要花大量时间处理合并单元格的问题。但我最明显的感受不是性能提升,而是心智负担的下降:FastExcel 的维护者是真的在响应 issue,遇到问题能找到人,而不是在 GitHub 上翻了一年没人回的旧帖。

最后再分享一个小的技术体会:不管用哪个 Excel 库,我都建议在项目里做一层薄薄的封装,把“读 Excel”“写 Excel”“模板填充”统一收敛到几个内部接口里。这次从 EasyExcel 迁到 FastExcel,就是因为我们之前封装了一层,真正改业务代码的地方很少。那层封装才是这次迁移成本低的最大功臣。如果你还没做这层封装,建议在下次动 Excel 相关代码时顺手加上,哪怕只是包一层门面类,将来换库或者升级版本都会从容得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询