
去年年底我接手了一套供应链对账报表的维护里面有一个从设计上就让人头疼的模板四层复杂表头、跨列合并的差异对比区、一段嵌套的订单明细列表单元格还要求自动换行。这套东西之前用的是 EasyExcel 3.x单看导出简单列表确实很顺手可一旦进入复杂表头导入、模板填充、嵌套 list 渲染这些场景问题就一个接一个冒出来。最离谱的一次模板填充后第二页的合并单元格整体错位财务那边打印出来直接对不上数我连着排查了两个晚上最后定位到是填充流程和合并区域策略的冲突。从那会儿起我就开始认真考虑换掉 EasyExcel最后把目光落在了 Apache FastExcel 上——也就是社区里常被拼成 Fesod 的那个项目。这篇博文就是这次迁移的完整记录包括我踩过的坑、API 差异、模板填充的解法以及最终的选型建议。1. 在 EasyExcel 上栽过的跟头为什么动换库的念头1.1 复杂表头并非“开箱即用”动态表头和合并单元格的真相很多团队选 EasyExcel是因为官方文档里写了一句“支持复杂表头”但实际用起来完全不是那么回事。EasyExcel 的注解模型处理的是平铺表头也就是一行表头对应一个字段这种常规情况。一旦表头变成两行、三行甚至某些层级是运行时动态生成的注解方式就失效了你得自己在代码里构造ListListString来定义表头结构。我那个供应链对账报表的表头分四级大区、省份、城市下面挂着一堆指标列中间还有一个跨多列的“对比差异”区域。第一版我用注解写死表头上线两周后业务方说要在“城市”下面再加一个“区县”层级表头从三级变成四级所有索引全部偏移。改完这次之后我就学乖了把表头改成运行时构造但这又带来另一个问题合并单元格在导入时不会自动展开。这里要说明一个 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 groupIdcn.idev.excel/groupId artifactIdfastexcel/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 几乎一样// 静态表头直接映射实体类 ListRowData list FastExcel.read(inputStream) .head(RowData.class) .sheet() .doReadSync();但碰上动态表头就不存在“开箱即用”了你需要手动构造表头并按行读取ListListString dynamicHead buildDynamicHead(); // 根据业务动态生成 FastExcel.read(inputStream) .sheet() .head(dynamicHead) .registerReadListener(new AnalysisEventListenerMapInteger, Object() { Override public void invoke(MapInteger, Object row, AnalysisContext context) { // 手动处理每一行数据 } }) .doRead();这里的AnalysisEventListener在 FastExcel 里的包路径和 EasyExcel 不同但使用逻辑是相同的。比较关键的是合并单元格展开的处理。我建议封装一个工具方法在所有导入流程里统一调用private void fillMergedRegionValues(Sheet sheet, ListCellData 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 ListOrderItem items; }模板里如果直接写{.orderNo}是没问题的但商品明细是列表你得先把它转换成平铺结构ListMapString, Object flatRows new ArrayList(); for (Order order : orders) { for (OrderItem item : order.getItems()) { MapString, 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的转换层所有写模板的业务对象都先转成ListMapString, 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 -Dincludesorg.apache.poi mvn dependency:tree -Dincludescglib把输出里的 POI 版本和 CGLIB 版本列出来统一到同一版本线。处理方式是保留 FastExcel 的依赖在 pom 里对老 EasyExcel 的传递依赖做 excludedependency groupIdcn.idev.excel/groupId artifactIdfastexcel/artifactId version最新版本/version exclusions exclusion groupIdcom.alibaba/groupId artifactIdeasyexcel/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()再关流否则可能丢数据。二是注解的包路径。ExcelProperty从com.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 相关代码时顺手加上哪怕只是包一层门面类将来换库或者升级版本都会从容得多。