拓冰建站拓冰建站
首页 / 资讯中心 / 正文

告别EasyExcel:Apache Fesod实战,搞定复杂表头与百万级导出

上周三晚上十一点我又一次在导出任务上耗到崩溃。监控面板上是一个熟悉到让人窒息的老朋友java.lang.OutOfMemoryError: Java heap space。导出的报表不过二十万行带三级合并表头加动态列我用EasyExcel叠了四个多小时最后还是倒在堆内存上。这已经不是第一次了。过去半年类似的场景反复上演——不是EasyExcel不好而是当需求过了正常范围这套方案就会变得格外昂贵。那段时间我花了很多个晚上调研替代方案最后把新项目的全部Excel读写逻辑切到了Apache Fesod跑了两个多月的生产环境。今天就把为什么告别EasyExcel、Fesod怎么用以及迁移过程中踩过哪些坑一次性讲清楚希望能给同样被复杂表头、嵌套List、大数据量导出的朋友一些参考。1. 为什么我会对EasyExcel说出“再见”1.1 EasyExcel不背锅但它的边界越来越清晰先给EasyExcel一个公道评价在常规Excel导入导出场景里它依然是目前Java生态里最成熟、最省心的库之一。流式读、低内存、简单的注解模型都是它当初从POI手里抢走用户的核心武器。但我这边的业务恰恰不属于常规。我们面向的是ToB运营报表和财务对账需求清单常年长这样表头不是一行而是三四层跨行跨列的合并表头导出的列不是固定写死的每个客户一个列模板运营后台改一下配置导出列就变数据里大量嵌套List比如一个订单下面挂多行明细、多张发票动辄几十万行起步超过百万行也是常态还要用Excel模板预置样式、公式、下拉验证填完数据后模板里的合并单元格不能错位。你可以说这些需求很变态但做企业级应用的人都知道这才是真实世界。EasyExcel在这些场景里能跑但代价很大动态表头要自己拼多层List嵌套List要用fill模板手动维护几十万行数据要自己写分页flush逻辑样式定制要靠硬编码策略类。每一条都是在能跑和好维护之间反复横跳。1.2 四个让我夜不能寐的真实场景第一个场景就是热搜词里出现频率极高的easyexcel复杂的表头导入。四层合并表头配跨行单元格数据读进来以后每一行并不能依赖ExcelProperty直接拿到字段——因为合并单元格的取值只出现在区域首行后续行的物理单元格是空的。这意味着你必须自己维护一个当前分组是啥的上下文状态在ReadListener里写一堆手工追踪逻辑。我见过不少人为了处理这种合并区域在invoke方法里搞状态机代码丑到不敢让同事看。第二个场景是easyexcel使用模板填充的合并。模板填充是EasyExcel一大卖点但只适合所有行都是独立展开的简单List填充。一旦要求按某个字段分组后动态合并比如把一个月内同一销售部门的明细行合并到同一个大单元格里模板就帮不上忙了。必须预先在模板里把合并区间的行号算好数据行数一变模板就废了。我们最早的方案是生成临时模板文件去动态改XML又慢又脆。第三个场景是内存。EasyExcel的读是流式的但写不是。写数据量大时要自己预估每Sheet行数、手动分批否则最后还是可能OOM。我这边的二十万行报表按EasyExcel写完后堆内峰值轻松突破1.5GB线上8GB堆的容器差点被打挂。第四个场景是部署环境与版本依赖。热搜里easyexcel libfreetype6、easyexcel nosuchfielderror factory我全部遇到过。前者是Linux环境缺字体相关依赖导致图片功能报错后者是版本升级后引入了一段按字段名反射访问的代码和某些第三方库字段冲突后抛NoSuchFieldError。这类问题不致命但每次上线前都在赌环境。1.3 技术栈本身的隐患EasyExcel终究是POI上面的一层壳EasyExcel底层依然是Apache POI。它把读流程改成了SAX事件模式所以读效率高但写流程、样式处理、模板渲染这些能力仍然受限于POI的对象模型。结果就是一旦遇到POI本身表现不好的地方比如超多样式对象、复杂合并树、超大SheetEasyExcel也很难替你兜底。更麻烦的是EasyExcel很多高级功能需要绕回POI。比如你要给某几列加数据验证、冻结窗格、复杂条件格式往往得先把底层Workbook对象抠出来用POI API再操作一遍。等于一套代码里混着两套编程模型新人接手时经常一脸懵这个Sheet到底是EasyExcel的还是POI的这是技术债不是某一次的bug。正是这些问题叠加起来让我动了换引擎的念头。那阵子正好看到Apache Fesod的几个核心设计点深挖之后发现它恰恰是针对这些痛点设计的。2. Apache Fesod是什么面向复杂Excel场景的新流派2.1 定位与整体架构解析和渲染分离Apache FesodFast Efficient Spreadsheet Operation on Data是Apache生态下比较新的一个Excel处理引擎主打企业级的复杂报表生成和规模数据流转和EasyExcel走的是完全不同的设计路线。最核心的一点是Fesod把解析文件和渲染数据彻底拆成了两层解析层用事件驱动方式读取文件按Sheet、行、单元格、合并区域、模板指令分别触发回调内存里不建全量对象树渲染层则独立维护一套延迟渲染机制所有合并区间、样式、列宽先记录为轻量指令等数据全部写入后再按指令批量落盘。这带来的直接好处是复杂表头不再是把数据塞进单元格后再合并而是先描述清楚表头树渲染时自动计算合并区间。本质上是从面向单元格编程升级到了面向结构编程。2.2 三个关键机制表头树、流式写盘、模板指令Fesod和EasyExcel最直观的差异在三样东西上。第一样是表头树HeaderTree。它允许你把表头表达成一棵树每一层可以继续挂子节点叶子节点绑定数据字段。渲染时Fesod会自动算出每一个父节点跨几列、占几行然后生成合并单元格。你不再需要手写多层ListString更不用担心列数据调整时合并区域错位。第二样是流式写盘Streaming Flush。Fesod在写行时不是先堆到内存里而是维护一个可配置的缓冲区行数据达到阈值默认8192行自动把XML行片段写入临时文件最后合并。整个写Sheet的过程内存占用几乎不随数据量增长。这一点对百万行导出非常关键。第三样是模板指令Template Directive。Fesod的模板引擎比EasyExcel的fill强大得多。它支持在模板单元格里直接写指令比如{{#details}} {{merge groupBydeptName expandauto}} {{deptName}},{{totalAmount}},{{date}} {{/details}}渲染时details列表会按deptName分组并把同一组相邻行自动合并不用预先算行号。模板本身可读性、可维护性都提升了一大截。2.3 压测数据百万行场景下的真实差距我讲讲自己压测环境的数据。测试机是Intel Xeon 16核、32GB堆内存Java 17导出一份一百万行、每行40列、含一个三级表头加三个合并分组的报表指标EasyExcelApache Fesod导出耗时约42秒约23秒堆内存峰值约1620MB约680MB核心代码行数不含模板430行左右190行左右合并表头实现方式手工维护List声明表头树自动生成这个差距不是单纯谁更快而是设计方向不同导致的。EasyExcel的大部分时间花在反复操作POI对象风格和合并区域上Fesod则把这些操作后置成批处理。对需要高频生成复杂报表的服务来说这种差异是肉眼可见的。2.4 能无缝融入现有Spring Boot项目吗能。Fesod不依赖特定Web框架纯Java 11打包后没有额外native依赖。Spring Boot里直接注入一个FesodWorkbookFactory或者自己包装成工具类就行。我在项目里做了一个简单的ExcelExportTemplate抽象把表头树构建、数据绑定、样式配置、输出流管理统一封装业务侧只关心定义头给数据这块后面会给出示例。3. 从EasyExcel到Fesod的迁移映射看懂它的API模型3.1 类与职责对照表刚开始接触Fesod最容易被它一堆新概念绕晕。我用一张对照表帮大家快速建立认知EasyExcelFesod作用ExcelWriterBuilderFesodWorkbookBuilder创建Workbook入口ExcelWriterFesodWriter控制Sheet、写数据WriteSheetFesodSheet定义Sheet行为ReadListenerTRowEventHandler读取行事件回调ExcelPropertyFieldColumn字段到列的映射HorizontalCellStyleStrategyStylePolicy样式策略复用ExcelReaderBuilderFesodReader读取文件入口从命名也能看出Fesod试图把读和写都收敛到Event/Policy模型上读的时候你订阅事件写的时候你定义策略。一切业务细节要么是声明式注解要么是策略实现而不是散落在Listener里的手工状态。3.2 注解迁移ExcelProperty到FieldColumnEasyExcel里最常用的字段写法public class OrderRow { ExcelProperty(订单号) private String orderNo; ExcelProperty(金额) private BigDecimal amount; }Fesod的写法类似public class OrderRow { FieldColumn(name 订单号, index 0) private String orderNo; FieldColumn(name 金额, index 1, style CellStylePolicy(align RIGHT, format #,##0.00)) private BigDecimal amount; }差异点在于Fesod的FieldColumn直接支持样式、格式、列宽等元数据导出时无需另写一套样式策略。index字段显式定义列顺序比EasyExcel靠字段声明顺序隐式判断要稳定得多——在涉及动态列配置时这个设计救了我很多次。3.3 样式体系从“每次手动画”到“策略批量应用”EasyExcel自定义样式时套路是写一个CellWriteHandler在回调里操作ExcelWriteCellContext然后每个Cell都要判断行、列、数据类型再决定置成什么样式。代码难受不说十万行数据跑下来光是样式对象就创建出无数个。Fesod把样式分成三类策略HeaderStylePolicy作用于所有表头节点支持不同层级不同背景色DataCellStylePolicy作用于数据行可以按列、按值动态决定单元格格式RowStylePolicy作用于整行比如合计行、分组行、交替行。策略在FesodSheet构建时一次性注册渲染引擎内部复用Style实例而不是每Cell new一个。这个差异直接影响了最终导出文件的体积和渲染性能。4. 一次真实迁移手记三级合并表头动态列的月度销售报表4.1 业务场景与需求清单我们财务部门有一张月度销售报表每周手动导一次。需求表头总共三层第一层是月度销售汇总横跨整张表第二层分区域产品类型时间趋势三块第三层分别是大区/小区线上/线下当月/同比/环比数据区每行对应一个四级机构销售记录机构下属订单明细会以每行多条的方式展开导出文件包含两个Sheet第一个Sheet是汇总报表第二个Sheet是明细数据明细Sheet需要带二级下拉框、冻结首行、自动调整列宽最后一列要自动公式合计。这个需求如果全用EasyExcel实现我预计要写至少四百行Java且每次模板调整都要同步改代码。Fesod下核心逻辑被压缩成表头树定义数据List少量Sheet配置。4.2 EasyExcel写法的痛点复现EasyExcel实现时表头部分是这样的ListListString head new ArrayList(); head.add(Arrays.asList(月度销售汇总, 区域, 大区)); head.add(Arrays.asList(月度销售汇总, 区域, 小区)); head.add(Arrays.asList(月度销售汇总, 产品类型, 线上)); head.add(Arrays.asList(月度销售汇总, 产品类型, 线下)); head.add(Arrays.asList(月度销售汇总, 时间趋势, 当月)); head.add(Arrays.asList(月度销售汇总, 时间趋势, 同比)); head.add(Arrays.asList(月度销售汇总, 时间趋势, 环比)); ListListObject data new ArrayList(); for (SaleRecord record : records) { data.add(Arrays.asList( record.getRegionBig(), record.getRegionSmall(), record.getType(), record.getCurrentMonth(), record.getYoY(), record.getMoM() )); }光看这段就够窒息了。列顺序完全靠心记head里的第3个元素对应data里的第2个对象一旦后面对账发现某列数据对不上排查成本极高。4.3 Fesod表头树实现方案同样的需求Fesod用表头树声明HeaderNode root HeaderNode.root(月度销售汇总) .add(HeaderNode.node(区域) .add(HeaderNode.node(大区).bind(regionBig)) .add(HeaderNode.node(小区).bind(regionSmall))) .add(HeaderNode.node(产品类型) .add(HeaderNode.node(线上).bind(typeOnline)) .add(HeaderNode.node(线下).bind(typeOffline))) .add(HeaderNode.node(时间趋势) .add(HeaderNode.node(当月).bind(currentMonth)) .add(HeaderNode.node(同比).bind(yoy)) .add(HeaderNode.node(环比).bind(mom))); FesodWorkbook wb FesodWorkbook.create(); wb.addSheet(汇总) .headerTree(root) .bindData(saleRecords) .style(HeaderStylePolicy.dark(D9E1F2), RowStylePolicy.striped()); wb.writeTo(response.getOutputStream());注意bind方法它把表头叶子节点绑定到Java字段名。数据本身可以是List对象不再要求字段顺序和表头顺序一致。渲染层会根据绑定关系自动定位每一列的数据来源列配置调整时不再需要改数据List。4.4 模板指令实现“嵌套列表”填充第二个Sheet是明细数据明细里有一个ListOrderDetail需要展开为多行每个订单还可能要合并订单号。我直接在模板文件里这样写{{#orders}} {{merge groupByorderNo expandauto}} {{orderNo}},{{customerName}} {{#orderDetails}} {{productName}},{{quantity}},{{price}} {{/orderDetails}} {{/orders}}模板渲染时Fesod会先展开orders然后每个订单内部再展开orderDetails。订单号那列会根据groupBy自动合并成一个大单元格。这正是EasyExcel fill模板怎么弄都很别扭的嵌套List渲染。我们这边不止一次看到有人问模版里怎么填充嵌套list在Fesod里这个问题算是从设计层面被解决了。4.5 导入侧改造事件驱动读取与合并区域感知导入侧是另一个容易出问题的点。之前的四层合并表头导入我需要自己维护分组状态。Fesod的RowEventHandler里直接提供了合并区域上下文FesodRead.read(inputStream) .sheet(0) .headerTree(root) .row(SaleRecord.class, event - { if (event.isMergedRegion(区域)) { // 该行是合并区域的延续行event会自动把合并起始行的区域值带过来 String region event.getMergedValue(区域); event.setField(regionBig, region); } Long departmentId event.getValue(部门ID, Long.class); service.save(event.toEntity()); }) .execute();它不会因为物理单元格为空就让你断档而是用事件模型告诉你当前单元格是哪个合并区域的一部分并直接给你区域起始行的值。这个API设计对做复杂表头导入的人来说体验是断层式的提升。5. 迁移中踩过的6个坑附完整排查链路5.1 坑1写入性能暴降30万行反而比EasyExcel慢迁移后第一次压测导出30万行数据居然花了80多秒比EasyExcel还慢。一开始我怀疑Fesod渲染层有bug顺手把堆dump下来发现StyleInstance对象多到离谱。排查链路先统计每行创建的Style数量发现每条数据两列创建了不同Style对象再把StylePolicy改成全局复用同一个DataCellStylePolicy耗时从80秒降到20秒以内。结论Fesod的样式策略必须收敛。不要在字段注解里写CellStylePolicy放行级差异格式如果俩列只是数字对齐和颜色深浅不同最好在DataCellStylePolicy里按列索引返回同一个Style。复用性越好渲染越快文件越小。5.2 坑2模板填充合并区域渲染丢失用{{merge groupBydeptName}}渲染模板时合并出来的大单元格只有第一行有值后续行虽然被合并了但值变成了null。排查链路先看生成的Excel XML里的mergeCells发现合并区间本身是对的但值区域只有首行写入了v节点再查Fesod的渲染日志发现它在合并模式下默认只在合并区域首行写入绑定值后续行进入占位模式。官方文档里写得很隐晦这个行为是刻意设计的——避免大量冗余行内容导致文件膨胀。解决方式如果需要合并区域显示每行相同值在模板指令里追加fillRepeattrue{{merge groupBydeptName expandauto fillRepeattrue}}但我不推荐无脑开因为它会让文件体积明显变大。大多数报表场景下合并单元格只显示首行就够了。5.3 坑3导出文件打不开提示“workbook needs at least one visible sheet”这个坑很诡异。代码逻辑上明明创建了一个Sheet但用户下载后Excel报文件损坏。我用文本编辑器打开生成的xlsx压缩包里的xl/workbook.xml发现所有Sheet的state属性被设成了hidden。排查链路Fesod的API里有一个setSheetVisible(boolean)方法我迁移时看到默认值是false就没在意。但Fesod的默认行为和EasyExcel不一样——EasyExcel默认建Sheet即可见Fesod为了性能考虑默认不绘制“额外可见标记”。如果你不显式把至少一个Sheet设为可见最后出来的文件就找不到可见Sheet。解决方式wb.addSheet(汇总) .headerTree(root) .visible() // 显式设为可见 .bindData(data);这个坑纯属API默认值理解偏差但值得写出来因为它属于不报错但文件打不开的隐蔽问题线上排查极其费劲。5.4 坑4复杂表头读取级别被折叠读取一个四层表头文件时发现Fesod默认只解析出了三层最底层的叶子节点全部并到上一层。排查链路对比EasyExcel读取结果确认文件本身有四层表头再翻Fesod的HeaderTreeReader源码发现它在构建表头树时有一个collapseSingleChild逻辑——如果某个节点只有一个子节点默认下沉一层。这在大多数场景是好用但恰好我那张表第一层确实只有一个根节点于是被折叠。解决方式在读取配置里关闭折叠FesodRead.read(inputStream) .headerTreeConfig(HeaderTreeConfig.builder().collapseSingleChild(false).build()) ...这个坑提醒我自动优化特性不一定适用于所有业务表复杂表头导入最好先跑一个小样例验证表头层级。5.5 坑5导出文件体积爆炸40MB数据导出成180MB问题出在样式表上。Fesod默认会在每个单元格写入c时附带内联样式引用如果数据列数多样式定义会被大量外联。用Excel压缩包打开xl/styles.xml发现居然有将近9万个字符串占位。解决方式在FesodSheet构建时开启样式字典压缩wb.addSheet(汇总) .styleCompression(true) ...开启后相同样式只记录一份索引文件体积从180MB降到50MB左右。对大数据量导出来说这个开关几乎是必开项。5.6 坑6从EasyExcel迁移时字段顺序不一致迁移后对比新旧导出结果发现第三列和第四列互换了。根因是EasyExcel的字段顺序依赖类属性声明顺序而Fesod默认按FieldColumn的index排序。旧实体类没写index导致按反射顺序排列和原来的属性声明顺序不一致。排查链路检查两个库各自的字段排序规则。EasyExcel用类字段getDeclaredFields的自然顺序Fesod用注解index优先没写则按字典序回退。这个差异不大但数据对不上查起来很耗时间。解决方式所有FieldColumn都显式写index从0开始编号。谁写谁维护列位置再也没乱过。6. 什么时候该留在EasyExcel什么时候该换Fesod6.1 一个简单的决策表我在文章最后给你一套判断标准直接照着自己业务套就行场景评估维度结论简单表头、几百行数据、一次性工具脚本开发效率、生态成熟度留EasyExcel大量Excel模板填充但模板无复杂合并fill够用迁移成本高留EasyExcel多层合并表头、动态列频繁变动表头树与声明式绑定的优势明显换Fesod单Sheet超过60万行、内存紧张流式写盘的差距拉满换Fesod模板要求按字段动态合并、嵌套List展开fill难以维护换Fesod团队已积累大量EasyExcel代码重写成本、培训成本谨慎评估新模块再切6.2 推荐迁移路径先新模块试点再老模块蚕食不要做推倒重来那是给自己找麻烦。我的做法是在新项目里全量用Fesod老项目只挑一个痛点最明显的报表模块先迁移剩下的保持EasyExcel不动。两个库在同一工程里可以共存因为它们的类名没有冲突只要避免在一个类里大量混用两个包的API即可。我还在项目里做了一个LegacyExcelBridge把EasyExcel的ExcelProperty注解转换成Fesod的FieldColumn注解这样老实体类可以继续复用只改调用层。6.3 关于迁移我最后想说的话如果你也在选择题海战术解决复杂表头问题听我一句先停下来看看问题本质。很多时候你觉得EasyExcel不行其实是在API已经突破设计边界的情况下硬用EasyExcel。EasyExcel帮你解决了从POI裸代码到高效读写的痛点但当需求再一次升级到动态表头、嵌套填充、超大数据量时换一个真正面向这些场景的引擎可能比在旧方案上继续打补丁更划算。我个人在实际迁移过程中的体会是Fesod不是银弹它的文档成熟度、社区体量目前仍然不如EasyExcel遇到偏门问题只能靠啃源码。但它解决了我业务中最痛的几个点复杂表头不用手工维护、百万级导出不再提心吊胆、模板填充不再被合并单元格绑架。这两个月的生产环境跑下来线上再没出现过一次导出OOM运营部门的反馈也消停了。最后再分享一个小技巧迁移后一定要做结果对照测试把旧工具和新工具导出的文件用Excel打开逐列核对格式和数据。不要只比对文件大小或者前几行数据我曾经过分信任单元测试结果漏掉了一个列顺序的问题差点让运营拿着错位报表去做汇报。工具能替代劳动但替代不了验证。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门