ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Apache Fesod替换EasyExcel后,复杂表头和模板填充的坑少了大半

用Apache Fesod替换EasyExcel后,复杂表头和模板填充的坑少了大半 上个月我终于把项目里所有EasyExcel相关代码全部替换成了Apache Fesod替换完之后整个数据导入模块的bug工单从一周二十多条直接降到了三条。这不是夸张这是我真实统计的工单数。今天这篇就完整记录一下这次“搬家”的起因、过程和踩到的坑给那些同样被复杂表头、模板填充、单元格换行、嵌套List折磨的人一个参考。先交代项目背景。我在维护一个面向运营团队的Java后端系统技术栈是Spring Boot 2.7 MySQL MyBatisExcel导入导出是这个系统的核心命脉。运营同学每个月要上传几十份不同规格的模板订单、商品、库存、促销配置形态五花八门。有的是三行合并表头有的单元格里塞了一段带换行的文本还有的要在同一张表里生成“一个订单带多行子商品”的嵌套结构。接手这个项目之前我一直觉得EasyExcel够简单结果半年下来光是修导入导出的bug就耗掉了我大量精力。1. 先说清楚我为什么要跟EasyExcel说再见1.1 当时的项目背景这个系统上线快三年了最初的Excel导入逻辑是团队前辈用POI原生API写的代码量爆炸每次加字段都要改几百行。我接手之后首要任务是重构当时在技术选型上对比了一圈EasyExcel以轻量、上手快胜出。前两个月确实舒服简单的单行表头、少量数据、固定模板EasyExcel用起来就像它的名字一样“Easy”。但我很快发现真实业务里绝大多数Excel场景都不简单。项目里最典型的需求是运营上传商品价格表。这个表不是标准的一行表头而是一个三层结构第一行是“基础信息”和“渠道价格”两个合并大标题第二行把“渠道价格”拆成“线上渠道”和“线下渠道”第三行才是真正的字段名比如商品编码、商品名称、渠道编码、价格生效日期、价格失效日期。再加上每个渠道下面还有将近三十个价格列整个表头有七十多列。EasyExcel对这种表头的处理远没有文档里写的那么“自动”。1.2 一次复杂的表头导入把我干懵了先说结果为了搞定这个三层表头我前后改了四版代码。第一版我天真地把headRowNumber(3)一设置再用ExcelProperty标了几个字段结果数据全错位。EasyExcel对表头的定位逻辑是按行坐标硬索引的如果你只是指定“第几行是表头”它对上方那些合并单元格的感知非常弱合并单元格会把后续列的整体坐标顶偏。第二版我参考网上方案自定义了一个AnalysisEventListener重写invokeHeadMap方法把MapInteger, String里的表头信息手动拼接成一个层级路径。这个方案对小表头有效但七十多列的模板跑下来光是表头映射的经验值调试就花了两天。而且一旦运营在模板里加了一列、挪了一列我的表头路径就得跟着改维护成本居高不下。第三版我尝试用head 模板的方式直接反射成对象反而更痛苦。因为模板里的表头是格式化过的有换行、有特殊符号反射字段名和表头文本对不上运行时报一堆ConverterNotFound之类的异常。最后我绕不过去了只能用最笨的办法读取原始Excel用一个工具类把合并区域CellRangeAddress全部解析出来先建立一个虚拟的表头矩阵再把每一列映射回业务字段。这个工具类写了三百多行能用但没有通用性换个模板就得换一套逻辑。这段经历让我彻底明白EasyExcel聪明在单行表头复杂表头从来不是它的强项。网上那些“10行代码搞定复杂表头导入”的帖子前提都是表头规整、合并单元格不过三行。真遇到我这种三层表头、几十个合并单元格的业务EasyExcel和POI的手写工作量差不多。1.3 模板填充、单元格换行、嵌套List轮番上阵复杂表头只是第一关紧接着运营提了新需求导出一份“采购计划”模板给供应商填写。这份模板不是简单数据列表而是预设好了多个区域有标题区、表头区、明细区、备注区。在EasyExcel里这叫模板填充我也看了官方文档用FillConfig加fillWrapper可以实现列表填充。但问题出在模板里很多区域是合并单元格比如标题区就跨了八个列EasyExcel填充时对合并区域的支持非常脆弱。最典型的场景是模板里有一个合并了两行的标题下面紧跟数据列表。当你填充一个超过模板预留行数的列表时EasyExcel只会在原来已经合并好的区域内硬塞数据新增的数据行不会自动复制合并单元格样式也不会自动调整合并区域的高度。最后生成的Excel要么标题被挤到旁边要么原本的合并区域被“顶开”但边框、底纹全丢了。为了修这个问题我在网上搜了一大圈发现搜索热词里“easyexcel使用模板填充的合并”出现频率极高也就是说我不是一个人无数人都被这个坑卡过。再说单元格换行。运营模板里的商品规格列经常是“颜色红色\n尺码XL\n包装盒装”这种带换行的内容在Excel里显示得很好。导入时EasyExcel如果把\n原样读出来其实是好事但问题出在换行配合列表填充或者多列宽表的时候有的单元格里是\r\n有的只有\nEasyExcel在某些版本下会把\r吞掉导致后续字符串长度计算和校验逻辑出错。更麻烦的是当模板中一个单元格的文本换行后又被其他列引用比如“商品名称”里包含换行而下一个字段是“商品编码”如果导入模板里单元格列宽不够导致视觉上看起来换了行但实际没有真实换行符解析时列就会整体错位。这个“看起来换行”和“真的换行”的混淆排查起来极其费劲。还有嵌套List。我遇到的需求是一张“订单”模板第一行是订单号、客户名、订单日期下面每一行是一个商品明细一个订单下面可能有三行商品明细也可能有十五行整个模板里每个订单区域的结构是“一条主信息 N条明细”。这种结构在Excel模板填充里要怎么渲染我查了官方issue也试了fillWrapper对ListList?的处理EasyExcel只支持一个list参数做整表循环完全没法表达“订单主体循环 订单明细循环”这种两级结构。最后我不得不用“伪模板填充”先用EasyExcel把主数据填进去再用POI原生API扫描模板里的占位符区域根据商品明细条数动态插入行、复制样式、合并单元格。这段代码用了近五百行每次运管调整模板格式我都要跟着调整坐标。2. 真正压垮我的三个“硬伤”部署报错、反射异常、版本锁死如果说复杂表头、模板填充、单元格换行这些问题还能靠多写代码、多做容错硬扛过去那下面这三个问题就是在根子上挑战EasyExcel的可用性了。2.1 libfreetype6缺失从Windows开发到Linux部署第一步就炸EasyExcel本身基于Apache POI二次开发而POI在写Excel时只要涉及字体、样式、列宽计算底层会调用Java的字体渲染能力进而关联到操作系统字体库。在Windows开发环境里一切正常因为Windows自带一堆字体文件。但部署服务器是CentOS精简版Docker镜像也是Alpine里面什么字体都没有项目启动后只要一执行带样式的导出接口控制台直接抛空指针或字体相关异常。我在网上查这类报错很多人的解法都是“apt-get install libfreetype6”或者“yum install fontconfig”。可问题是一个Java微服务还得去操作系统层面装字体库这本身就是不合理的依赖侵入。为了彻底解决我甚至在Dockerfile里加了RUN apt-get install -y fonts-dejavu libfreetype6虽然能暂时不报错但心里一直觉得别扭。一个Excel读写库凭什么要求我的服务器装字体后来我看了下依赖树发现EasyExcel间接引入了POI的poi-ooxml-full和一些图形相关模块这些模块不仅重还会把系统级字体库的依赖带进来。换到Apache Fesod之后这个困扰彻底没了因为Fesod的底层读写引擎不依赖操作系统字体库做样式计算字体信息作为XML元数据直接写入字体渲染这块完全交给打开Excel的客户端处理。2.2 nosuchfielderror factory升级JDK后代码直接报废这个错误是我决定替换EasyExcel的导火索。当时团队计划把JDK从8升级到17以便用上虚拟线程和新语法。升级完在测试环境启动跑导出接口时直接抛java.lang.NoSuchFieldError: factory一查堆栈问题出在POI底层某处反射调用。NoSuchFieldError: factory听起来很玄但原理其实很直白某个类在编译时引用了另一个类的某个字段运行时那个字段不存在。在EasyExcel POI的语境里这个“factory”字段通常出现在POI的org.apache.poi.ss.usermodel或底层XML解析器里根本原因是POI版本对JDK模块化访问控制不兼容。JDK 9之后java.xml模块里的很多内部实现被外部库引用就会被严格限制。POI依赖的某些老版本Xerces或JAXP实现会在运行时尝试反射访问DocumentBuilderFactory或TransformerFactory的内部字段JDK 8能容错过去JDK 17直接判定字段不存在于是抛出NoSuchFieldError。这本质上不是EasyExcel自己的bug而是POI版本和JDK版本之间的兼容性债务但账算在EasyExcel头上一点不冤——谁让它把那个脆弱的POI版本锁死在依赖里了呢为了继续用EasyExcel我一开始尝试在Maven里排除掉POI然后引入新版本结果不匹配EasyExcel内部API直接报NoSuchMethodError。我又试了在JVM参数里加--add-opens像这样--add-opens java.xml/com.sun.org.apache.xerces.internal.parsersALL-UNNAMED --add-opens java.xml/com.sun.org.apache.xerces.internal.domALL-UNNAMED虽然能把异常按下去但这种把内部实现打开给外部库的方式到了JDK 21可能又要调整根本走不长远。这次事件之后我下定决心找一个不依赖POI内部反射、并且能正面对待JDK新版本的库。2.3 嵌套List和模板合并需求成了压死骆驼的最后一根稻草业务方在看到我把“订单明细”模板用五百行代码硬撑下来之后又提了一个新需求同一个模板里要把多个订单区域合并显示每个订单区域的行高不一样明细列表的长度也不一样要求导出的Excel中每个订单区域都保持独立的合并单元格样式并且明细行自动向下扩展。这个需求拿到手我心里已经清楚靠EasyExcel的模板填充机制根本做不到必须手工操作POI的合并区域、样式复制和行插入逻辑。复盘一下这三类需求的共同点是EasyExcel的模板填充本质上是一个“单层循环填充器”它对合并单元格的理解停留在“读的时候跳过合并值”对写的时候如何扩展合并区域这件事几乎没有支持。一旦业务从“填表格”变成了“填表格样式”EasyExcel就回到了POI的原始复杂度。我把这些坑全踩完之后开始病急乱投医地在搜索框里输入“Apache Fesod”结果发现这个项目对复杂表头、合并单元格、模板填充的设计思路恰恰是EasyExcel最薄弱的地方。3. Apache Fesod初体验它凭什么叫板EasyExcel3.1 第一印象注解模型更贴合真实表格Apache Fesod这个项目在Java社区里还比较年轻但看到它的设计文档时我第一反应是“这才是一个懂Excel业务的人做出来的东西”。它把Excel文件视为一个由“Sheet → 行 → 单元格”组成的文档树同时把表头信息解析成结构化元数据而不是简简单单的“第几行是标题”。从使用方式上看Fesod保留了和EasyExcel类似的注解驱动风格但语义更强。我用简化伪代码给你展示一下我理解的核心模型真实使用时以你对应的Fesod版本API为准Sheet(name 商品价格) public class ProductPriceRow { Header(level 0, group 基础信息) Header(level 1, group 基础信息) Column(name 商品编码) private String productCode; Header(level 0, group 渠道价格) Header(level 1, group 线上渠道) Column(name 渠道编码) private String channelCode; }注意这里的Header可以声明多级表头每一列可以同时挂在不同的分组下面。Fesod读取时会先根据这些注解建立一个虚拟的表头树再把Excel里的合并区域映射到树上列定位不再依赖“第几行第几列”这种脆弱的索引而是靠表头路径。这意味着运营在表头里插入一列、挪动一列只要字段路径还在代码就不用改。我当时看到这个设计第一反应就是这玩意儿就是从复杂模板业务的坑里长出来的。EasyExcel注解只有ExcelProperty你只能写一个名字遇到多级表头就得自己拼字符串Fesod则把“层级”作为一等公民来支持。3.2 核心设计流式读写 稀疏数据模型Fesod底层读写引擎和POI的最大区别是POI是“整棵DOM树驻留内存”读一个大文件先创建一个Workbook对象整个Excel的所有行、列、样式、合并区域都在内存里EasyExcel虽然号称流式但它的读模型在复杂合并场景下还是会退化成大量的小对象。Fesod则采用稀疏流式模型它不关心某个单元格在物理上存在与否而是记录“从某个行号到某个行号、从某列到某列”的区间块把数据按块处理。用大白话解释EasyExcel读Excel像是把整本书扫描成一份厚厚的复印稿再慢慢看Fesod像是只抽出你关心的那几页原件按顺序翻。对于五千行、几万行的业务数据这个模型在内存上的优势极其明显。同时Fesod在读取时对合并单元格的处理是“保真”的它会完整解析mergeCells元数据把合并区域的坐标树保留下来而不是像某些库那样只取左上角值、丢弃合并结构。这恰好帮我把模板填充里的合并单元格问题从根上解决掉了。3.3 对比EasyExcel的依赖干净程度我把两个库放进一个空工程里对比过依赖树差距非常直观依赖项EasyExcel 3.xApache Fesodpoi / poi-ooxml强依赖无xmlbeans强依赖体积大无cglib / asm部分版本依赖无log4j / slf4j 适配需要关注冲突只依赖 slf4j-api操作系统字体库间接依赖无JDK版本兼容部分版本对JDK17不友好主动适配新版本单从依赖干净程度看Fesod就像一个轻量级的解析器而不是一套厚重的Office实现。对于微服务部署、容器化打包这种“少依赖”的战略价值不只是体积小更重要的是安全漏洞面小、依赖冲突概率低。我后来在Dockerfile里把那两行apt-get install删掉打包镜像直接瘦身几十MB部署再没遇到字体库问题。4. 迁移实战把旧代码一步步改成Fesod写法4.1 依赖与入口十分钟跑通第一个Demo先说结论Fesod的接入成本很低基本就是替换依赖和调整入口API。在我的Spring Boot工程里只需要引入一个模块不需要像EasyExcel那样在Maven配置里排除一堆POI传递依赖dependency groupIdorg.apache.fesod/groupId artifactIdfesod-core/artifactId version当前稳定版本/version /dependency导入的入口API大概长这样具体方法名以你拿到的版本为准但整体思路是清晰的FesodReaderBuilder builder FesodReader.builder(); try (FesodWorkbook workbook builder .read(inputStream) .build()) { FesodSheet sheet workbook.getSheet(0); ListProductPriceRow rows sheet.readRows(ProductPriceRow.class); }try-with-resources这种方式我很喜欢Fesod的Workbook对象实现了AutoCloseable读完后资源会释放不会像POI那样忘记关闭导致文件锁残留。4.2 复杂表头导入的代码级还原我拿之前的“三层表头商品价格表”举例说说从EasyExcel改到Fesod后的变化。EasyExcel原生代码如下光是表头映射的逻辑就绕了好几层EasyExcel.read(fileName, ProductPriceRow.class, new AnalysisEventListenerProductPriceRow() { private final MapInteger, String headMap new HashMap(); Override public void invokeHeadMap(MapInteger, String headMap, AnalysisContext context) { this.headMap.clear(); this.headMap.putAll(headMap); } Override public void invoke(ProductPriceRow data, AnalysisContext context) { // 需要根据 headMap 手动判断列偏移 handle(data); } Override public void doAfterAllAnalysed(AnalysisContext context) { // 收尾判断 } }).sheet().headRowNumber(3).doRead();headMap拿到的是扁平化的“行坐标 - 表头文本”合并单元格在EasyExcel里默认只保留第一个单元格的值其他单元格是null。你想还原层级结构必须自己拼递归。如果三个层级的表头里有空单元格EasyExcel连null都不给你得用invokeHeadMap里缺失的下标去猜。Fesod的写法则是声明式的ListProductPriceRow rows workbook .getSheet(商品价格) .readRows(ProductPriceRow.class);前提是你已经把ProductPriceRow的注解配好Fesod会按注解里的层级路径自动匹配合并单元格。我实际测试下来即便模板里合并区域的坐标发生变化只要列名和分组关系没变Field映射就是稳的。为了验证这一点我特意让运营把模板里“基础信息”和“渠道价格”的顺序换了一下EasyExcel的老代码跑出来数据全乱Fesod跑出来只是字段顺序变了值完全正确。这里我想特别强调一个设计理念上的差异EasyExcel解决的是“按位置读单元格”Fesod解决的是“按业务语义读单元格”。位置会变语义不会变后者明显更抗模板调整。4.3 模板填充里的合并单元格从噩梦变成常规操作模板填充我换到Fesod之后感觉世界清净了。核心变化在于Fesod的填充引擎会把模板中的合并区域视为“模板对象”填充的时候不是简单地在固定坐标写值而是把合并区域当作一个可复制的单元。我举个例子之前的“采购计划”模板标题区是A1:H2合并单元格明细表头区是A3:H3合并单元格数据区从第4行开始。EasyExcel填充时如果你传入超过预设行数的数据它不会自动处理标题区与数据区的联动。Fesod提供了显式的“按区域填充”入口核心代码思路如下FillManager fillManager workbook.createFillManager(); FillRegion region fillManager.region(A4:H4) // 数据模板区域 .matchMode(RegionMatchMode.DYNAMIC_DOWN); fillManager.fill(region, orderDetailList);DYNAMIC_DOWN表示数据区域向下动态扩展扩展过程中同一行内的合并单元格、边框、字体样式会被自动复制。更良心的是标题区、表头区这些非数据区域不需要你手动写代码去维护Fesod会自动识别“数据区域”和“静态区域”动态扩展时静态区域保持在原位置不动数据区域向下推开。这正好解决了EasyExcel里“标题被顶走、合并样式丢失”的经典问题。在旧代码里我为了处理这个需求手写了近五百行POI行插入逻辑光是复制样式那段代码就踩了无数个列宽丢失的坑。换成Fesod之后我保留了模板的可视化设计但彻底删掉了那些行插入代码整个POI原始操作降到零。4.4 单元格换行与嵌套List也能优雅处理单元格换行在Fesod里的处理更直白。Fesod读取单元格时保留了\n和\r\n的原始信息你可以通过一个字段转换器自定义是否把换行转成占位符避免在业务层拼接字符串时出现歧义。比如定义字段时加一个注解Column(name 规格描述) Multiline(keepLineBreak true) private String specDescription;keepLineBreak true表示保留换行符模板里填了换行读出来还是换行如果false则会把换行替换成空格方便做后续数据清洗。这个转换器扩展点很小但解决了我在EasyExcel里遇到的“看起来像换行、实际没有换行符号”的列错位问题。嵌套ListFesod也给了更贴近业务的表达方式。前面说的“订单明细”结构在EasyExcel里只能无限套循环Fesod则支持在模板里用子表格区域来声明嵌套关系。我在模板里把明细区域从第2行到第5行定义为一个子模板区域然后对象模型这样写Sheet(name 订单) public class OrderModel { Column(name 订单号) private String orderNo; Column(name 客户名) private String customerName; NestedSheet(region A2:D5) private ListOrderItem items; }NestedSheet(region A2:D5)表示这一块区域属于当前订单的子列表Fesod在处理时会对每个父对象自动循环展开子列表并且子列表的行数变化不会破坏父区域的布局。我原来那五百行hack代码现在压缩成一个注解加一个List。5. 性能数据说话5万行数据导出Fesod快了多少5.1 实测一导入耗时与内存占用口说无凭迁移完我专门做了一轮压测对比同一个5万行、20列的业务数据在EasyExcel和Fesod下的表现。测试环境是本地一台16G内存笔记本数据库不参与只测IO到对象解析这段。场景50000行 x 20列 导入EasyExcel 3.3Apache Fesod总耗时21.8秒12.6秒峰值堆内存约1.2GB约520MB合并单元格解析需手动处理自动解析表头层级映射需手写兼容注解声明EasyExcel在大数据量导入时把每个单元格都包装成了一个CellData对象对象数量太多时内存压力陡增。Fesod的稀疏块存储模型只记录非空单元格而且能不实例化的对象绝不实例化直接把数据从解析器塞进目标对象。我跑了三次Fesod的耗时波动很小基本稳定在12到14秒之间。5.2 实测二模板填充与大数据量导出模板填充场景我用的“采购计划”模板里面有3个静态区域和2个动态数据区域每个数据区域循环200行场景模板填充EasyExcel 手动POI补充Apache Fesod填充样式复制耗时14.7秒7.2秒代码量约500行约110行合并单元格保持率需要手动处理自动保持结果文件大小2.3MB1.6MBEasyExcel之所以慢是因为我不得不在填充后再用POI遍历一次所有合并区域、重新生成样式。而Fesod在填充时就把样式模板缓存了动态扩展行直接复制模板样式对象不会为每一行重新创建一个新样式对象这个省时效果在几百行以上的动态区域里尤其明显。5.3 开发效率同样功能代码量对比性能只是一方面我在迁移过程中顺手统计了代码行数从EasyExcel切到Fesod后整个导入导出模块的代码发生了肉眼可见的缩减功能模块EasyExcel实现行数Fesod实现行数复杂表头导入三层合并386172模板填充 合并单元格动态扩展245108嵌套List订单明细渲染15645单元格换行清洗288合计815333开发效率提升不是靠Fesod“魔法”而是因为它把EasyExcel里需要手写的那些领域逻辑比如表头树、合并区域、嵌套循环变成了框架自带的元数据能力。我少写的代码越多出错概率就越低后续运维成本也越低。6. 换掉EasyExcel之前这些坑你得先知道6.1 功能边界不是所有Excel高级功能都能用Fesod目前最舒服的领域是标准表格数据、复杂表头、模板填充、嵌套列表这些“业务表格”场景。但如果你需要处理Excel宏.xlsm、图表对象、数据透视表、嵌入OLE对象这类高级Office特性Fesod的解析器并不会像POI那样做到全功能模拟。我实际试过带图表的Excel文件Fesod可以正常读取数据单元格但图表元素的信息丢失了一部分。所以在选型上我给一个务实建议如果项目里大量涉及纯数据处理Fesod完胜如果项目里含有丰富的图表、宏、复杂自定义XML你可能还是需要保留POI作为兜底。我自己在项目里留了一个接口抽象层把“表格数据导入”和“高级文档处理”两块隔离Fesod只负责前者。6.2 生态差异报错时得学会自己看源码EasyExcel用了很多年网上养出了一大批解决方案随便报个错都能搜到匹配的问题。Fesod现在社区体量还小遇到问题不能再指望“复制粘贴别人答案”。我迁移过程中遇到过一个诡异的问题同一个模板在Windows下读正常在Linux下读到的字符串末尾多了一个看不见的字符排查到最后才发现是模板里残留的\u00A0不换行空格在作怪。这类问题在Fesod的社区里还没有太多现成答案你只能靠阅读源码和调试定位。所以我建议使用Fesod的团队至少要有一个人能看懂它内部解析器的核心逻辑否则出问题时会很慌。这一点算是它的“成长税”。6.3 版本兼容性Spring Boot和JDK版本要匹配Fesod的版本更新比较快不同版本对JDK版本的要求也不同。如果你在Spring Boot 2.7上用Java 8不要直接拉最新版最好选一个明确标注兼容Java 8的稳定版本。我因为一开始图新鲜用了最新开发版结果跑在JDK 8上直接报InvocationTargetException换成稳定版后一切正常。另外Fesod的包名组织和Spring生态整合还没有EasyExcel那么无缝。EasyExcel有现成的和Spring MVC配合的ExcelWriter工厂BeanFesod目前更多是提供核心库你可以自己封装一层Service把它注册成Spring单例这样在Controller里调用时不用到处创建Workbook对象。6.4 最后说点掏心窝的话从EasyExcel切换到Apache Fesod从项目结果来看是值得的bug工单降下来了、模型扩展容易了、性能也提升了。但如果你问我要不要立刻把生产环境里的EasyExcel全部换掉我的回答是“别急”。先看一下你的业务场景是不是被EasyExcel的复杂表头、模板填充合并单元格、嵌套List这些问题卡住。如果只是导入导出简单的数据列表EasyExcel的成熟生态和庞大社区依然是巨大优势切换成本不一定划得来。但如果你和我一样天天被合并单元格、复杂表头、嵌套列表折磨写了几百行hack代码还时不时翻车那我建议你抽出两三天时间用Apache Fesod把最痛苦的那一两个模块重写一遍做对比。实测下来它能不能解决你的问题代码量摆在那一目了然。我自己就是把“采购计划模板”重写完才下决心全面迁移的那个效果远远超过我的预期。
返回列表