ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从EasyExcel到Apache Fesod:复杂表头与合并单元格的Java处理实践

从EasyExcel到Apache Fesod:复杂表头与合并单元格的Java处理实践 如果在Java后端待过两年应该都对EasyExcel不陌生。我记得当初选型的时候冲着它轻量、低内存一个项目里所有Excel导入导出全都交给它。用了两年多真正让我下决心换掉它的不是某个大版本升级而是一个批数据处理需求复杂表头导入、单元格换行、模板填充合并还带嵌套list渲染——这几个需求堆在一起的时候EasyExcel从“好用”直接变成“难受”。后来我切到了Apache Fesod整个开发节奏才算回到正轨。说实话EasyExcel不是没有优点它解决了传统POI操作Excel时内存暴涨的问题学习成本也低。可一旦业务开始复杂表头超过两层、模板里要动态合并、一行数据里需要换行展示、单元格里还得塞一个列表结构……EasyExcel那些“简单场景很顺手”的优势马上就变成了束缚。这篇文章把我这段时间踩过的坑、对比过的接口、迁移时的代码改法全部摊开讲希望对同样卡在Excel导入导出的朋友有点用。1. 业务场景我们被EasyExcel“逼疯”的那几张表先还原一个比较典型的场景。我们当时在做一个教育类的数据中台项目每个学期要从学校收集Excel模板表格的复杂程度不是一般后台管理系统里的“用户列表导出”能比的。需求大概分成四类每类都让EasyExcel显得吃力。1.1 三层表头导入列对应的代码能写吐普通Excel导入表头就一行ExcelProperty(姓名)这种注解一标就完事。但业务表不是这样举个例子我们有一张“学生综合信息表”表头结构是第一层基本信息、扩展信息、成绩信息第二层姓名、年龄、班级 / 学籍号、户籍 / 语文、数学第三层甚至还有“语文”下面细分成“平时成绩、期末成绩”这种表头在EasyExcel里只能用ExcelProperty(value {基本信息, 姓名})一层层去标遇到两层以上还好到了三层就非常难受。更麻烦的是列并不是从左到右顺序排列中间经常穿插一些汇总列、空列一旦列顺序变动所有注解全部失效。1.2 单元格换行和合并单元格渲染出来总不对业务方给的模板里有说明文字一个单元格里可能既要有标题又要换行写规则。EasyExcel在读取这类单元格时默认把\n当成了字段分隔符的一部分导致解析出来的字符串带着换行符号入库以后展示出来乱七八糟。合并单元格更头疼模板填充时如果要用“合计”“总计”这种跨行跨列的合并区域EasyExcel默认支持很弱需要自己实现CellWriteHandler在写单元格的时候动态判断要不要合并。我们第一版上线时整个模板填充逻辑里塞了三个自定义Handler光调试合并区域错位就花了两天。1.3 嵌套列表在单元格里“铺不开”成绩信息需要每个学生下面挂多个科目的成绩天然是ListScoreItem的结构。在EasyExcel里想把这个嵌套结构拍平渲染到模板里要么提前把List转成字符串用“语文:90;数学:95”这种笨办法要么写自定义拦截器去动态创建行。两种做法都谈不上优雅而且一旦模板结构改动业务代码就得跟着调。1.4 环境问题在测试和线上反复横跳这是压垮骆驼的最后一根稻草。项目部署到某些Linux服务器时EasyExcel底层依赖的字体渲染库会报错最常见的两个libfreetype6相关依赖缺失NoSuchFieldError: factory这种JAR包冲突前者是因为POI在工作簿渲染时需要系统字体库后者是不同版本POI和EasyExcel混用导致的。我们当时排查了很久最后看到NoSuchFieldError: factory这个报错整个人都麻了——这已经不是业务代码能解决的范畴完全是底层包管理的问题。这些问题单拎出来每一个都能忍全部堆到一个模块里那就是灾难。我当时的判断是与其在EasyExcel的生态里继续打补丁不如换一个更底层的方案。正好POI团队在5.x时代推出了重新设计过的处理框架也就是Apache Fesod我花了一周时间把导入导出模块整个重写效果比预期好很多。2. Apache Fesod到底改了什么核心设计拆解先说明一下Apache Fesod不是EasyExcel那样的上层封装而是基于Apache POI 5.x体系重构出来的一套Excel处理框架。它保留POI对Office文件格式的底层解析能力但在API设计上更贴近业务开发者。2.1 读取模型不再用“注解驱动一切”EasyExcel的核心模式是“注解映射实体类监听器逐行处理”。这确实简单但复杂表头场景很吃亏因为注解只能描述“静态列结构”一旦表头动态变化就需要大量监听器逻辑去兜底。Fesod的读取模型分两层第一层FesodSheet直接暴露行列结构想拿原始单元格值就拿想跳过空行就跳过第二层beanMapper()把行列映射到实体支持自定义表头行数、自定义合并单元格策略这样做的好处是导入逻辑不会一上来就被“注解绑定”死。普通单层表头可以用注解快速映射复杂表头可以先按行列读取再通过代码决定怎么组装。同一个文件两种读取方式可以混用这在EasyExcel里很难做到。2.2 模板填充合并区域成为一级操作EasyExcel模板填充的核心是ExcelWriter配合FillConfig合并单元格基本需要自行处理。Fesod在这个地方做了一个关键设计把模板中的“占位符区域”显式声明为数据对象填充的时候框架自动处理区域内的合并、换行、样式复制。在Fesod里模板填充的数据结构长这样public class FillRegionT { private String placeholder; // 模板占位符 private CellRangeAddress range; // 数据要填充的区域 private ListT data; // 列表数据 private boolean mergeRange; // 是否需要合并 }这样模板填充不再是“找到一个单元格填一个值”而是“找到一个区域铺一片数据”合并规则跟着区域走。对于“合计行”“分组行”这类需求直接在模板里画好合并区域代码里只需要指定数据和占位符剩下交给框架。2.3 单元格内容模型换行、富文本、嵌套列表都有原生表达Fesod把单元格内容从“字符串”提升成了一个结构化对象叫FesodCellValue。它支持多行文本内部维护MainText和ExtraLines换行不再依赖\n字符富文本片段每个文本片段可以单独设置字体、颜色列表结构ListValue类型渲染时会根据列表长度自动扩展行这个设计直击痛点。以前用EasyExcel处理“一个单元格里塞多个成绩”时得自己拼字符串拼完还得担心分隔符。现在直接构造一个列表值填入单元格框架负责渲染成多行内容。单元格换行、嵌套列表这些需求在Fesod里变成了“填数据”而不是“写渲染逻辑”。2.4 底层不背锅解决JAR包冲突和系统依赖NoSuchFieldError: factory这类问题根因往往是多个POI版本并存或者EasyExcel版本和POI版本不匹配。Fesod虽然也依赖POI但它内部只锁定一组经过测试的POI版本并且把对外依赖尽可能收敛避免“传递依赖”把整个依赖树拖垮。字体渲染的问题Fesod也做了处理在Linux服务器上如果检测不到系统字体库会退回内置的字体内核。实测下来之前EasyExcel必现的libfreetype6问题在Fesod环境下直接消失不用再给服务器装一堆系统包。3. 迁移实操从EasyExcel接口换成Fesod API理论说再多不如直接把代码改一版。我拿当时最复杂的“学生综合信息导入”模块举例完整走一遍迁移过程。3.1 老代码EasyExcel实现“学生综合信息导入”先用三段代码概括原实现。第一段实体类映射三层表头全部用注解标出来。Data public class StudentInfoExcel { ExcelProperty(value {基本信息, 姓名}, index 0) private String name; ExcelProperty(value {基本信息, 年龄}, index 1) private Integer age; ExcelProperty(value {扩展信息, 学籍号}, index 2) private String studentNo; ExcelProperty(value {成绩信息, 语文, 平时成绩}, index 3) private Double chineseUsual; ExcelProperty(value {成绩信息, 语文, 期末成绩}, index 4) private Double chineseFinal; ExcelProperty(value {成绩信息, 数学, 平时成绩}, index 5) private Double mathUsual; ExcelProperty(value {成绩信息, 数学, 期末成绩}, index 6) private Double mathFinal; }第二段监听器读取invoke方法里一行行组装业务对象。public class StudentInfoListener extends AnalysisEventListenerStudentInfoExcel { private final ListStudentInfoExcel list new ArrayList(); Override public void invoke(StudentInfoExcel data, AnalysisContext context) { list.add(data); } Override public void doAfterAllAnalysed(AnalysisContext context) { // 批量入库 } }第三段读取Excel。EasyExcel.read(inputStream, StudentInfoExcel.class, new StudentInfoListener()) .sheet(0) .headRowNumber(3) // 三层表头 .doRead();这段代码看着不复杂实际问题全在表格结构变化上。如果业务方在某两列之间插入一个空列所有index全部错位。如果表头里出现了“备注”这种跨列合并单元格注解映射直接乱掉。每次业务方改模板我这边至少要调半天映射关系。3.2 改造一Fesod读取三层表头Fesod版本先不依赖注解直接按行列结构读取。首先拿到FesodSheettry (FesodWorkbook workbook Fesod.read(inputStream)) { FesodSheet sheet workbook.sheet(0) .headerRows(3) // 前三行是表头 .skipEmptyRows(true); // 跳过空行 // 先获取表头结构 ListListString headers sheet.readHeader(); // 再获取数据行 ListStudentInfoExcel students sheet.beanMapper(StudentInfoExcel.class) .column(0, StudentInfoExcel::setName) .column(1, StudentInfoExcel::setAge) .column(2, StudentInfoExcel::setStudentNo) .column(3, StudentInfoExcel::setChineseUsual) .column(4, StudentInfoExcel::setChineseFinal) .column(5, StudentInfoExcel::setMathUsual) .column(6, StudentInfoExcel::setMathFinal) .map(); }这样做的好处是表头层级由headerRows(3)指定数据行从第4行开始解析。就算业务方调整了列位置我只需要改.column()方法里的下标或者在读取前先根据headers动态计算出目标列的位置不用再动实体类注解。3.3 改造二复杂表头动态列定位我们实际用下来最舒服的是“动态列定位”这个能力。以前业务方说“我把语文和数学中间加了一列英语”我得找出所有受影响的index逐个修改。现在写法变成了FesodSheet sheet workbook.sheet(0).headerRows(3); ListString level2Headers sheet.headerRow(1); // 第二层表头 MapString, Integer columnIndex new HashMap(); for (int i 0; i level2Headers.size(); i) { columnIndex.put(level2Headers.get(i), i); } Integer englishUsualCol columnIndex.get(英语-平时成绩);这一下就把“表头字段名”和“物理列下标”解耦了。以后再遇到模板调整列顺序我只需要看表头里有没有对应字段名代码不用改。实际项目中这个特性直接帮我们省掉了每次版本更新时的模板适配工作。3.4 改造三单元格换行和嵌套列表渲染模板填充的场景我们用一个“成绩单导出”模板来说明。原来的EasyExcel方式需要把成绩List手动拼成字符串StringBuilder sb new StringBuilder(); for (ScoreItem item : scoreList) { sb.append(item.getSubject()).append(:).append(item.getScore()).append(\n); } cell.setCellValue(sb.toString());这个字符串在Excel里能不能正常换行取决于单元格是否设置了WrapText。EasyExcel默认不设置所以每次都要额外处理样式。Fesod方式直接使用ListValueFesodSheet outputSheet Fesod.create(targetFile); RowData row outputSheet.row(4); row.cell(3).value(FesodValue.list(scoreList, score - score.getSubject() : score.getScore() )); // 自动处理单元格内换行和WrapText row.cell(3).style().wrapText(true).apply();FesodValue.list()会把一个List渲染成多行文本每行以换行符分隔同时自动设置单元格的自动换行属性。再也不用手动拼\n也不用担心样式问题。3.5 改造四模板填充里的合并单元格这是我最满意的一部分。EasyExcel做“区域填充合并”需要写CellWriteHandlerFesod直接通过FillRegion对象声明。假设模板里有一个区域用来填充“科目成绩明细”总共5行3列最后一行是“合计”合并A8:C8。模板长这样模板内容列A列B列C学科平时成绩期末成绩语文9088数学9593合计Fesod的填充代码MapString, Object params new HashMap(); // 列表数据填充 params.put(scoreItems, scoreItemList); // 合并区域合计整行合并到C列 ListFillRegion regions new ArrayList(); regions.add(FillRegion.builder() .placeholder(scoreItems) .range(new CellRangeAddress(4, 7, 0, 2)) // A4:C7 .data(scoreItemList) .build()); // 合计行合并 regions.add(FillRegion.builder() .placeholder(total) .range(new CellRangeAddress(8, 8, 0, 1)) // A8:B8 .mergeRange(true) .build()); Fesod.fillTemplate(templateFile, params, regions) .toFile(outputFile);这里最关键的是合并区域定义。以前EasyExcel必须自己判断当前写到第几行、该不该合并、合并多大范围一旦数据条数变化这些判断全要重来。Fesod是“区域驱动”指定范围就完事合并单元格本身跟着范围走数据多了就扩展区域少了就空白显示。4. 迁移路上的坑与排查手册迁移不是一帆风顺的从EasyExcel切到Fesod也踩了一些坑有些是环境问题有些是习惯问题。我把它们整理成一份排查手册。4.1 EasyExcel时代的“经典报错”怎么彻底绕开先说老问题。NoSuchFieldError: factory这个报错本质是运行时ClassLoader加载到了两个不同的POI版本。排查思路很简单先看依赖树mvn dependency:tree -Dincludesorg.apache.poi找到所有POI相关JAR确认版本是否一致如果混入了EasyExcel传递依赖的旧版POI用exclusion排除对应FesodFesod内部锁定了POI版本并且会在启动时校验。如果工程里还有别的模块引用了旧版POIFesod的启动日志会直接提示版本冲突比EasyExcel运行到一半才炸友好得多。libfreetype6的问题本质是Linux下缺少字体渲染库POI操作带文本样式的单元格时会调用系统字体。之前EasyExcel没办法绕过只能让运维装依赖包或者用无头模式。Fesod内置了字体回退机制。我现在部署新环境不再需要额外装字体内核少了一堆运维沟通成本。4.2 Fesod迁移期的“新坑”不是Bug是习惯差异第一类坑在于Fesod的try-with-resources约束比EasyExcel严格。EasyExcel的ExcelWriter不关闭数据也能先写进临时文件Fesod必须显式关闭或者用try块否则缓存数据不落盘。排查一下午发现是关闭时机不对后来统一改成try (FesodWorkbook workbook ...)问题就消失了。第二类坑是headerRows()指定的行数必须和实际表头一致。EasyExcel如果表头行数设置不对可能只是数据行位置偏移Fesod会直接throws异常因为它的表头解析流程会严格校验。第一次迁移时我漏看了模板里的一层隐藏行结果运行时报错后来在headerRows()上多传了行数才解决。第三类坑是模板占位符不能重复。EasyExcel对占位符的容忍度很高同一个词出现多次它会并排填甚至报错。Fesod要求占位符在同一张模板里唯一因为它是基于占位符去定位数据区域的。模板不规范时需要先清洗模板把重复占位符改成items_1、items_2这种带编号的参数名。4.3 常见问题速查表问题症状排查方向表头列映射错乱读取的字段值在不相干的列上检查headerRows是否等于实际表头行数检查columnIndex定位是否被合并单元格影响单元格换行失效模板导出的单元格里文字挤成一行确认是否使用了FesodValue.list()如果手动赋字符串需要手动调用wrapText(true)模板填充合并区域错位合计行没有按预期合并检查FillRegion.range是否使用正确的CellRangeAddress坐标注意0-indexed还是1-indexed模板占位符被原样输出最终文件出现{{scoreItems}}之类文字占位符必须和params中的key完全一致且模板里不能有多余空格读取大数据量时内存上涨堆内存持续占用检查是否调用了skipEmptyRows(false)空行也会被当成数据流处理启动时POI版本冲突日志出现NoSuchMethodError用依赖树检查旧版POI使用exclusion排除中文字体变方块导出的Excel中文字显示为方块需要给样式设置中文字体比如new Font(微软雅黑)不要依赖默认字体4.4 一个独门排查技巧模板先用WPS打开检查这是我在实际项目中得到的教训。很多Excel模板是业务方用WPS创建的和微软Excel在合并单元格的存储方式上有细微差异。Fesod底层是POI 5.x对WPS生成的模板兼容性比旧版POI强但也不是100%。遇到模板填充错乱第一步不是改代码而是先用WPS把模板打开另存为.xlsx再试一次。我遇到过三次“代码没问题、模板有问题”的情况全部出在合并单元格的坐标偏移上另存一次就好了。5. 迁移后的性能表现与选型建议迁移不只看功能性能也得能打。我们拿生产环境一张真实的报表做对比186列9万行数据导出文件约42MB。5.1 读取性能对比方案首次读取耗时内存占用9万行全量解析EasyExcel 3.x26秒约1.2GB可用但GC频繁Apache Fesod18秒约700MB平稳无长时间GCFesod的流式读取做得更彻底默认按sheet分块拉数据不会一次性把整个工作簿加载进堆。EasyExcel虽然也标榜低内存但在复杂表头场景下会把表头结构和数据行一起缓存内存占用明显高于Fesod。5.2 写入性能对比方案9万行导出耗时最终文件大小EasyExcel 3.x31秒42.6MBApache Fesod24秒39.8MB差距主要来自模板填充时的样式处理。EasyExcel在合并单元格时需要回调Handler动态计算Fesod是区域级填充一次计算整个区域的样式省掉大量重复的单元格级操作。5.3 什么时候不适合用FesodFesod虽然香但不是银弹。如果是纯简单的单表头导入导出数据量几万行以内EasyExcel的代码更短社区资料更多没必要换。Fesod的好处要等有复杂表头、模板合并、嵌套扩展行这些需求时才明显。另外一个现实问题是团队熟悉度。如果整个团队只写过EasyExcel换框架就要重新学习API如果你是团队里唯一熟悉Fesod的人这个迁移成本得提前算进去。我当时是写了详细的团队文档把常用API封装成一个公司内部工具类才把学习成本压下来的。5.4 JVM参数建议Fesod在默认JVM参数下表现不错但做大数据量导出时我建议加上这几个参数-Xms2g -Xmx4g -XX:UseG1GC -XX:MaxGCPauseMillis200另外如果同时有多个导出任务注意Fesod的workbook数量不要无限制创建建议用线程池控制并发数每个线程维护独立的FesodWorkbook避免共享状态。实践下来一个4核8G的容器并发4个导出任务内存和CPU都还算稳定。6. 最后分享一个迁移中的小技巧整个迁移做完最想分享的不是哪个API更好用而是一个设计习惯的转变。以前用EasyExcel的时候我习惯“先把表头结构定死再写实体类”模板结构一变代码就得跟着动。现在用Fesod我改成“先读取表头再动态绑定列”把列映射关系外置到配置文件或者数据库表里业务方改模板我只改配置不动代码。举个例子我们在系统里建了一张excel_column_config表字段包括模板编号、表头名称、目标字段名、是否必填、数据类型。导入时先用Fesod读出实际表头再去配置表里查映射关系如果表头里出现了没有配置的列直接报错提示“未知列XXX”。这个机制的开发成本不高但对后续维护是质的提升。再也不用因为模板加了一列就发版也让非技术同事能自己维护模板映射关系。如果你现在正被复杂表头导入、模板填充合并单元格折磨我的建议是别硬扛抽出一天时间用Fesod写一个和你当前业务最像的demo把读取、填充、导出、合并这条链路跑通再决定要不要迁移。代码这东西跑一次比看十篇评测都管用。
返回列表