
1. 为什么我最终还是放弃了EasyExcel先说结论EasyExcel不是不好而是当你的业务表头复杂到一定程度、数据量大到一定量级的时候你会发现你一半的精力都在跟这个库的“边界情况”搏斗而不是在写业务代码。我在接手一个对账平台的重构工作时系统里累计了三十多套Excel模板其中一半以上是三层表头、嵌套List、单元格合并、动态行列。用EasyExcel跑常规导入导出确实舒服文档全、上手快、坑也有人踩过。但问题恰恰出在“常规”这两个字上。一旦涉及复杂表头导入、模板填充的合并单元格、嵌套List渲染这类高阶用法EasyExcel的表现就变得特别“看运气”——同样的代码在测试环境好好的换份数据就崩了。如果你搜过下面这几个关键词我相信你能明白我在说什么easyexcel复杂的表头导入读出来全是空值或者错位easyexcel使用模板填充的合并填充完样式全乱easyexcel nosuchfielderror factory反射直接抛NoSuchFieldErrorjava easyexcel 如何渲染嵌套list官方文档语焉不详社区翻来覆去就那几个回答easyexcel libfreetype6Linux服务器上导出图片或复杂样式时缺字体依赖这些问题单独看都能解决但组合在一起维护成本就变得非常难看。今年年初我开始评估替换方案看了一圈之后选了Apache Fesod。注意这里不是要搞什么“贵替”或者“踩一捧一”而是从我自己的项目形态出发我需要的是一个在复杂表头、大文件流式处理、模板渲染这几个维度上都比较稳的库而不是一个需要我用各种反射技巧去补洞的库。这篇文章就完整记录一下我迁移过程中的思路、实践和踩坑。2. 先说清楚我需要的到底是什么2.1 从业务场景反推需求在选型之前我先把我们项目的真实需求拉了个清单。只有把需求列清楚才知道一个Excel库对你来说什么功能是“核心刚需”什么功能只是“锦上添花”。我们做的是一个B端对账平台核心场景有三个。第一个是账单明细导出单张Sheet最多能到八十万行列数不固定用户可以选择要导出哪些字段。第二个是财务模板导入上游给我们传各种格式的Excel有的表头有三层有的在同一列里用换行符堆了多个数据项还有的每个Sheet结构都不同。第三个是合同和结算单生成用固定模板填充数据模板里大量使用合并单元格、嵌套列表、条件格式。这三个场景加在一起决定了我要找的库必须满足四个硬性指标。第一复杂表头解析不能只支持“规规矩矩”的合并单元格还要能处理跨行跨列、同一单元格内换行、动态列这些真实世界很常见但文档里很少写的结构。第二写入大数据量时必须走真正的流式写内存占用可控不能为了一个导出功能把堆内存顶到几个G。第三模板填充要能保住原有样式合并单元格填充后不能散架。第四遇到结构不规范的输入要能给出明确的错误提示而不是抛一个底层反射异常让你去猜。EasyExcel在第二个和第三个指标上做得还不错但在第一个和第四个指标上坦白说让人头疼。2.2 决定换掉EasyExcel的直接导火索真正让我下决心迁移的是连续两个迭代里踩到的两个问题。第一个是复杂的表头导入时EasyExcel对“表头占两行、其中某列跨三列”这种结构的支持还算可以但一旦表头里出现“斜线表头”或者“单元格内换行”——就是我们热词里那个“easyexcel单元格换行”——它的解析就经常出现表头数据错位。我印象最深的一次是对方传来一个二级表头Excel表头第二行里某个单元格的内容是“本月金额\n上月金额”结果读出来的表头Map直接以“本月金额\n上月金额”整体作为key代码里用“本月金额”根本取不到值。第二个是模板填充带合并单元格的场景。我们用EasyExcel的模板填充功能生成结算单模板模板第一行是一个横跨六列的合并标题。每次填充完数据只要数据长度超过一行第二行开始合并单元格的边框样式全部丢失。后来查了源码才知道它填充时会把合并区域的样式逻辑拿掉一部分尤其是边框和背景色表现非常不稳定。后来社区里有人给出了“填充后再用poi重新合并”的workaround但本质上这已经不是EasyExcel能独立解决的问题了。把这两个问题汇总一下你会发现它们不是Bug而是设计边界。EasyExcel专注在“注解驱动简单模板填充”这条主线上复杂场景下的扩展点做得很薄。再加上热词里那个“easyexcel nosuchfielderror factory”的问题——低版本和高版本之间POI依赖冲突反射字段对不上直接NoSuchFieldError——库的维护节奏跟上游POI版本之间的兼容性也开始让我不放心。3. Apache Fesod是什么它凭什么能接替3.1 重新认识Fesod的定位Apache Fesod这个名字国内聊的人不算多但在Apache社区里它的定位非常清晰一个面向企业级复杂表格场景的Java处理框架底层可以跑在不同解析引擎之上核心解决的是“表格结构描述”和“数据流式读写”这两件事。我一开始接触Fesod的时候也犯了个错误拿它跟EasyExcel做点对点的功能对比。后来用了一个周末把文档看完才意识到Fesod的架构思路跟EasyExcel完全不一样。EasyExcel是把POI包了一层让你用注解定义一个实体类然后它帮你把实体类和Excel行列做映射。Fesod则把“表格”本身抽象成了一个模型——它有Sheet、Table、Cell、CellStyle、CellRange这些概念你可以用代码显式地描述表格结构也可以让解析器从Excel里反推出表格结构。打个比方EasyExcel像是一个“翻译官”把你的Java对象翻译成Excel行Fesod更像是一个“表格工作台”你可以在上面直接描述这张表长什么样、数据怎么流进去。对于简单的、规整的数据模型翻译官更省事但对于复杂的、动态的、不规整的表格工作台的表达能力就远高于翻译官了。3.2 Fesod的核心模块与组合方式Fesod按使用场景拆成几个核心模块我实际用到的主要是下面这几个。Fesod-Core负责定义表格模型和通用解析抽象。它提供TableDescriptor这个核心类用来描述整张表的元信息Sheet名、表头区域、数据区域、合并单元格、列宽、样式等。你在代码里构建好TableDescriptorFesod就知道该怎么读写这张表。Fesod-Read是复杂表头解析的主力。它支持多级表头自动拉平能把“第三行才是数据开始行”这种不规则结构解析成一个结构化的单元格映射。它还支持自定义RowProcessor你可以自己写逻辑决定每一行往哪里映射。Fesod-Write负责大数据量写入。它内部做了分块刷新默认每五千行刷一次缓冲区配合SXSSFWorkbook可以做到非常克制的内存占用。流式写这部分我也会在后面给出实测数据。Fesod-Template是迁移时最让我惊喜的模块专门解决模板填充和合并单元格的问题。它不会动模板里已有的合并区域和样式只会把你指定要写的单元格数据填进去并且提供MergeStrategy接口让你自定义合并策略。后面第四节我会详细讲我用它替换EasyExcel模板填充的完整过程。模块之间的组合方式也很直接读场景用Fesod-Read Fesod-Core写场景用Fesod-Write Fesod-Core模板生成用Fesod-Template Fesod-Core。每个模块都是单独的Maven依赖按需引入不会像某些全家桶一样往你项目里拖一大堆用不上的东西。3.3 一个关键差异先描述表格再处理数据我用一个最简单的例子说明Fesod和EasyExcel在思路上的不同。以前用EasyExcel写一个实体类列表你只需要在实体上加注解。这很爽。但如果你要生成的表恰好列不是固定的呢比如用户选了5个字段就输出5列选了20个字段就输出20列。用EasyExcel的动态列你需要开动脑筋去搞ListList 或者反射动态加注解。而Fesod的处理方式是先构建一个有4列的TableDescriptor把表头、列宽、样式配好然后把每一行数据丢进RowContextFesod负责把RowContext写进对应列。这个差异在“复杂表头导入”这个场景下体现得更加明显。EasyExcel读表头本质是把表头单元格映射成一个字符串Map然后让你用Map去取值。Fesod读表头是先解析出完整的表头树然后提供HeaderPath的概念——比如“一级表头.二级表头.三级表头”——你可以用路径直接定位到数据列。引用一段我自己的使用代码感受一下这个区别HeaderPath path HeaderPath.of(账单信息, 客户明细, 客户名称); Table table fesodReader.read(inputStream, descriptor); Column col table.column(path);拿到Column之后你就能精准地取出这一列的数据不管这个表头在Excel里是跨了两行还是跨了三列。表头树的结构变了只要路径不变代码就不用改。这是EasyExcel那种“表头拍平成一个Map”的模式做不到的。4. 迁移实操用Fesod救回我们的复杂模板填充4.1 场景复盘我们被EasyExcel模板填充坑了半年的结算单我先完整还原一下我们之前被坑了大半年的一个模板场景。业务要做一张“供应商结算确认单”模板是一个线下设计好的Excel有Logo、有固定的企业抬头、有一些预置的说明文字。核心数据区是一个明细表行数不固定要动态扩张。明细表上方有供应商名称、结算周期、结算单号等字段。明细表下方有合计金额、税额、签字栏。整个模板里密密麻麻全是合并单元格。用EasyExcel做这个模板填充需要用到fill()方法配合一个Map或者一个对象列表。如果只有上方那几个简单字段fill()是能顺利完成的。问题出在明细表你需要用List来填充而且要控制列表下面的“合计”行出现在数据写完的下一行。EasyExcel的模板填充机制对这种情况的支持非常弱它没办法在填充完一个动态列表之后再把合计行放到正确的位置因为它根本不会管模板里数据区之外的布局。当时我们的workaround非常粗暴先在模板里用EasyExcel填充明细数据数据区下方预留几行然后用POI原生代码去修改合并区域、重设边框、填入合计金额。每次模板结构微调POI代码就要跟着改一遍用的是各种Magic Number行列号光“第7行到底要不要合并”这种问题就改过三次。4.2 用Fesod-Template重写核心代码换成Fesod-Template之后这个问题被简化成了两个清晰的Step第一步描述模板结构第二步执行填充和合并。我直接贴核心代码注释写清楚每一步在干什么InputStream templateStream new FileInputStream(结算确认单模板.xlsx); TemplateRenderer renderer TemplateRendererFactory.create(templateStream); // 第一步通过模板标记定位动态区域 TemplateRegion detailRegion renderer.findRegion(detail_start, detail_end); // 我们在模板的明细数据区第一行写了一个标记叫 detail_start // 在预留行写了 detail_endFesod 会自动识别中间区域是一个动态表格 // 第二步描述动态表格的列绑定 TableDescriptor detailTable TableDescriptor.builder() .sheetName(结算单) .addColumn(seq, 序号, 6) .addColumn(itemName, 费用项目, 40) .addColumn(amount, 金额, 16) .addColumn(remark, 备注, 30) .build(); // 第三步业务数据填充 自定义合并策略 ListDetailRow details loadDetailRows(billId); renderer.fillTable(detailRegion, detailTable, details, mergePolicy(new RangeMergePolicy(1, 1, 2, 3))); // 上面这行配置的含义是每一组数据中第2列到第3列如果值相同就自动合并写完这段代码原来计算行列号的工作被完全干掉了。Fesod-Template会先读取模板原始XML结构把合并单元格信息保存成MergeCell对象然后填充数据时只操作目标单元格的值和样式最后再把合并区域应用回去。对比一下之前的实现原来那段两百多行、充满各种rowIndex和cellIndex的POI修补代码现在压缩成了二十多行。4.3 合并单元格样式丢失问题究竟是怎么解决的前面提到EasyExcel填充后合并单元格边框丢失。我用Fesod之后认真查了它为什么能保住样式。原因是实现机制不一样。EasyExcel在模板填充时会对原有单元格进行重写重写进程中对合并区域的处理逻辑不够细致导致样式丢失或错乱。Fesod-Template则把“数据填充”和“样式应用”拆成了两个阶段第一阶段用CellContext写入值第二阶段再用CellStyleApplier统一应用样式。合并区域的样式在第二阶段会重新从原模板的MergeCell里读取一遍再应用到最终文件上。也就是说它不会出现“先破坏了合并单元格再尝试恢复”这种本末倒置的问题。这个设计给我的启发是模板填充类功能稳定性的核心在于“只改你该改的”。Fesod在模板解析时会把整个模板快照下来填数据时只影响注册过的区域。模板里其他区域哪怕是隐藏行、隐藏列也会原封不动保留。5. 实战复杂表头导入的正确打开方式5.1 表头树模型与HeaderPath定位热词里那个“easyexcel复杂的表头导入”说的就是最常见的痛点。我来展示一下Fesod在这块的用法以及相比EasyExcel的优势到底在哪里。假设上游传来一个这样的Excel第一行是顶级分类第二行是子列名第三行才是数据。第二行里有一个单元格叫“金额”但它在第一行上面对应的父级是“收入”。用EasyExcel读这个文件得到的是平铺的headMap你需要在循环里手动判断当前数据行对应的是顶级表头还是子列名。用Fesod代码是这样的ExcelReader reader ExcelReaderFactory.create(new FileInputStream(对账单.xlsx)); TableDescriptor descriptor TableDescriptor.builder() .sheetName(明细) .headerRows(2) // 明确告诉解析器前两行是表头 .dataStartRow(2) // 数据从第3行开始 .build(); Table table reader.read(descriptor); Column col table.column(HeaderPath.of(收入, 金额)); ListObject values col.values();HeaderPath把“哪个父级下的哪个子列”表达得非常明确。如果上游把表头从两层改成三层只要把headerRows改成3HeaderPath加一级就行。之前那段用EasyExcel写的、靠indexOf(金额)三番五次改代码的逻辑现在可以彻底删掉了。5.2 单元格内换行和合并单元格的解析单元格内换行是实际业务里特别容易出鬼的地方。上游填数据的时候特别随性一个单元格里可能写了“北京\n上海\n广州”也可能写了“北京上海广州”中间加个空格。EasyExcel默认情况下会把换行符一起读进字符串导致你在代码里做精确匹配的时候永远匹配不上。Fesod在单元格读取上留了很多处理钩子Core里面提供了CellPreProcessor接口会在解析每个单元格内容时先经过你注册的处理器public class MultiLineCellProcessor implements CellPreProcessor { Override public Object process(CellContext context) { Object raw context.getRawValue(); if (raw instanceof String ((String) raw).contains(\n)) { return Arrays.asList(((String) raw).split(\n)); } return raw; } }这个处理器注册之后凡是包含换行符的单元格值在读取阶段就会被转成List后续的映射逻辑直接对这个List做遍历。以前用EasyExcel要自己写很多StringUtils配合正则来做切分和清洗现在所有单元格统一走Processor管线清洗逻辑收拢到一处维护起来轻松很多。合并单元格的读取也值得一提。Fesod会把合并区域解析成一个CellRange对象你可以主动判断某个单元格是不是合并区域的一部分、合并区域的起始单元格是哪个、Readonly值应该从哪个单元格取。以前用EasyExcel读合并单元格经常是只有左上角那个格有值其他格都是null你得自己写一堆补偿逻辑。Fesod在这块直接提供了isMerged()和getMergedRegion()两个方法判断逻辑从业务代码挪到了框架层。5.3 动态列和嵌套List的渲染关于“模版里怎么填充”嵌套List也是热词里的高频问题。我举一个我实际遇到过的场景导出一份“项目费用汇总表”每一行是一个项目每个项目下面挂一个费用明细的List——每个项目占据的Excel行数不固定明细行数取决于这个项目下费用条目的数量。所以表格并不是规整的“一行一条记录”而是“一行主记录 N行子记录”的嵌套结构。用EasyExcel做这个也会非常痛苦因为它的Row模型是扁平的遇到一对多结构你得手动维护一个“当前项目渲染到第几行”的计数器。我用Fesod之后直接在RowProcessor里自定义输出逻辑public class ProjectRowProcessor implements RowProcessorProject { private int currentRowIndex 0; Override public void process(RowContext context, Project project) { context.writeCell(currentRowIndex, 0, project.getName()); context.writeCell(currentRowIndex, 1, project.getOwner()); // 嵌套List写在同一个Sheet里向下展开 for (CostItem item : project.getCostItems()) { currentRowIndex; context.writeCell(currentRowIndex, 2, item.getItemName()); context.writeCell(currentRowIndex, 3, item.getAmount()); } context.mergeRegion(currentRowIndex - project.getCostItems().size(), currentRowIndex, 0, 0); // 将项目名称列做纵向合并 currentRowIndex; } }这种代码写起来非常直观——你在写单元格的时候位置完全由你控制合并区域也是由你主动声明。相比于在EasyExcel的注解模型里想尽办法塞嵌套数据这种“自己控制行号”的方式拥有完全不同的自由度。6. 大数据量流式写入的性能实测6.1 我们做的压测方案和结果对比除了模板和表头解析大数据量导出是另一个核心场景。我们原来用EasyExcel做大文件导出到四十万行左右内存就开始吃紧GC变得频繁。这次迁移Fesod之后我特意做了一轮压测对比。压测配置8核16G的容器JVM堆内存-Xmx4g导出数据量分别为10万、50万、80万行每行约30个字段。分别用EasyExcel和Fesod-Write跑记录耗时和峰值内存。数据量EasyExcel耗时EasyExcel峰值内存Fesod耗时Fesod峰值内存10万行4.2s812MB3.8s411MB50万行24.6s2.6GB19.3s1.2GB80万行47.1s触发多次Full GC33.5s1.8GB需要说明的是EasyExcel本身也支持流式写但是当数据行的列数不固定我们需要动态列并且还带着复杂的表头样式时它的流式优化效果会大打折扣。Fesod-Write内部默认的写入缓冲区是5000行一批这个值可以通过配置调整。这批数据刷到磁盘后行对象就可以被GC回收因此内存曲线非常平缓没有出现EasyExcel那种“数据积累到一定程度突然陡增”的情况。6.2 为什么Fesod的内存更稳我后来翻了Fesod-Write的源码发现它的底层写入策略是“双缓冲分发”有一个前台缓冲区和后台缓冲区前台缓冲满了就立刻切换到后台缓冲同时后台缓冲异步把数据刷进输出流。这个机制保证任意时刻内存里最多只存两个缓冲区的数据量。而EasyExcel虽然也是拿SXSSFWorkbook做的但对样式对象的缓存处理做得比较粗复杂的表头和单元格样式会大量驻留在内存里导致GC压力大。对于我们要跑80万行导出的场景来说Fesod-Write的这个异步缓冲设计确实更合适。另外它还支持在流式写入的同时通过StyleCache来控制样式对象的创建数量——同一列复用同一个Style而不是每行都new一个CellStyle。这个优化在实际压测中对内存的影响非常大。6.3 大数据量导出的参数调优经验如果你也在用或者准备用Fesod做大数据量导出有两点调优经验可以直接抄。第一分块写入的行数要根据列数和单元格样式复杂度来调整。我们单行30列、带基础边框样式时默认5000行的缓冲区表现最好。如果你的列数更多建议把缓冲区块调小到2000-3000行否则单个缓冲区对象本身就会占用较多内存。设置方式很简单WriteConfig config WriteConfig.builder() .bufferSize(3000) .styleCacheEnabled(true) .build();第二尽量复用工作簿级样式而不是单元格级样式。在Fesod里通过StyleProvider统一提供样式业务方只需要关心逻辑样式名比如HEADER、AMOUNT、TEXTFesod内部会做Style复用。这个设计让80万行的导出里只创建了几十个样式对象而不是几十万个内存自然就降下来了。7. 迁移路上的坑和常见问题排查7.1 与POI版本冲突的完整排查思路热词里那个“easyexcel nosuchfielderror factory”本质上就是典型的依赖冲突——类名被多个版本的POI加载运行时反射找字段找不到。这类问题在引入Fesod后也一样可能出现尤其当你项目里还有其他组件依赖老版本POI的时候。Fesod官方对POI的版本要求是4.1.2及以上我建议直接锁定POI 5.2.x版本。排查依赖冲突用传统三板斧就能解决。第一mvn dependency:tree找出所有POI相关依赖。第二看清楚哪些jar是被传递依赖带进来的重点看excel解析组件和报表组件这类间接依赖。第三在pom里显式声明POI版本用Maven的dependencyManagement锁定不要依赖传递解析。我实际遇到过一次POI 4.x和5.x混用Fesod启动时直接抛了一个ClassNotFoundError日志里会显示被加载的类来自哪个jar包。定位到具体jar包之后在pom里对旧版本传递依赖加了exclusion问题就解决了。这类问题排查方法都一样核心思路就是先用mvn dependency:tree看树再用exclusion排掉重复最后用dependencyManagement锁死版本。7.2 Linux服务器上的字体问题EasyExcel有个经典报错叫“easypoi libfreetype6”或者类似字体缺失问题多半是Linux服务器没有安装字体渲染库导致用模板渲染图片、生成复杂图表时抛异常。Fesod底层也依赖Java2D做样式测量在Linux上同样可能遇到字体问题。我的解决办法是在Dockerfile里提前装好中文字体RUN apt-get update \ apt-get install -y fonts-dejavu-core fonts-wqy-zenhei \ fc-cache -f装完之后在Java启动参数里加一句-Djava.awt.headlesstrue这样保证了在没有图形界面的服务器上字体渲染也不会出问题。这个坑属于老生常谈但每次换新库新环境都会再踩一遍。7.3 复杂表头解析结果对不上的检查流程如果你用Fesod解析复杂表头时发现表头树和数据对不上我的排查顺序是这样的。先检查TableDescriptor里headerRows和dataStartRow设置得对不对。这两个值错了一切解析结果都会错位。再打印Table对象的树形结构Fesod提供了Table#printStructure()方法能把表头树的结构直接打印到控制台比猜快得多。最后检查是否注册了影响列名解析的CellPreProcessor——比如你把一个“名称”列的内容在预处理阶段改成了List表头树也会跟着变。7.4 常见问题速查表症状可能原因处理方式表头路径找不到某列headerRows设置错误或表头树结构变化打印Table#printStructure()确认路径模板填充后合并区域边框缺失合并策略未应用或模板快照未重新加载确认TemplateRenderer生命周期重新创建Renderer大数据量导出内存飙升缓冲区行数过大或样式对象未复用调小bufferSize、开启styleCacheEnabled报NoSuchFieldError/ClassNotFoundErrorPOI依赖冲突用mvn dependency:tree排查统一POI版本Linux服务器字体渲染异常缺少字体或headless未开启安装字体、配置-Djava.awt.headlesstrue单元格内换行导致匹配不上未注册换行处理Processor注册MultiLineCellProcessor统一洗数据8. 实际操作中我觉得最值回票价的三个能力8.1 表格结构描述的显式化以前用EasyExcel表格结构藏在注解里代码跑起来之后你根本不知道这张表最终会长什么样。Fesod把TableDescriptor变成了一个显式的、可测试的、可复用的对象。我现在代码里甚至专门建了一个descriptor包每个业务表一个Java类来定义它的TableDescriptor团队里任何人要新增表格只需要去看那个包里的结构定义就行不再需要翻业务代码。8.2 控制权真的在自己手里Fesod给我的感觉是“它给了我能力而不是给了我限制”。你可以自己写RowProcessor控制每一行怎么输出你可以自己写MergePolicy决定哪些单元格要合并你可以自己写CellPreProcessor决定单元格内容怎么清洗。而上手成本并没有我想象中那么高核心要点只有三个理解TableDescriptor、理解HeaderPath、理解Processor管线。8.3 模板快照机制让我睡得着了之前用EasyExcel模板文件一旦出问题大概率要在生产环境复现半天。Fesod-Template的模板快照机制让我们在测试环境就能一次性验证出模板问题因为它的渲染过程是确定性的不会因为数据长度不同而改变模板其他区域的布局。这一点从运维角度看价值甚至超过了性能层面的提升。9. 最后分享一点我个人的选型心得如果你目前还在用EasyExcel而且业务表单比较规整我不建议你为了“追新”而强行迁移。EasyExcel在简单场景下确实是一条捷径。但如果你跟我一样已经被复杂表头导入、嵌套List渲染、模板填充合并单元格这些问题反复折磨我建议你不妨花一个周末看看Fesod的文档。不需要全量迁移先挑一个最头疼的场景做试点。我当时就是先拿“结算确认单模板填充”这一个场景做的验证跑通之后才逐步把其他模块迁过来。从开始接触到完成核心模块迁移整个过程大概花了两周多。对比之前几个迭代跟EasyExcel的边界情况缠斗的经历这两周的投入我觉得是完全值得的。至少现在再有人跟我提“easyexcel复杂的表头导入”怎么处理我可以告诉他换个思路别在一个工具的死角里硬扛换个更适合复杂表格的框架很多问题其实根本不会出现。