尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java用easyExcel解析固定列+动态列+合并标题的Excel模板

Java用easyExcel解析固定列+动态列+合并标题的Excel模板 1. 从业务场景说起什么样的Excel需要固定列动态列标题合并1.1 第一版需求月度绩效表导入最近接了一个导入需求看到Excel模板的第一眼我就知道这活不能按老套路写。模板长这样最上面是一行合并单元格的大标题比如“XX部门员工月度绩效表”第二行才是真正的字段名前几列是序号、姓名、部门这类固定信息后面跟着的业务指标列还会随月份不断增加。也就是说固定列、动态列、合并标题这三种让人头疼的元素一次全凑齐了。这个项目当时的任务很明确用户上传一个Excel文件我们把里面的人员信息和每个月绩效指标解析出来校验后写入业务库。第一版我选了easyExcel来解析两周不到跑通了完整流程。这篇就把第一版的做法、踩过的坑和关键代码整理出来给以后接同类型需求的人一个参考。先说清楚适用范围模板固定为两级表头第一行是大标题第二行是列名数据从第三行开始。前N列是固定字段后面所有列都属于动态字段动态列的名称和数量每次导入都可能不一样。第一版不考虑那种“三行表头、还有合并斜角”的极端情况但思路是通用的。1.2 为什么不能把Excel当普通二维表来读很多人第一次接触Excel导入下意识会把它当成二维表用POI读出来之后按行列号取值然后硬编码成DTO。这种方式对付固定列没问题但一旦遇到动态列麻烦就来了你没办法在Java实体类里为每个动态列写一个ExcelProperty字段因为列名和列数都是运行时才知道的。合并标题则是另一个坑。easyExcel在读取合并单元格时不会像人眼看到的那样把同一个标题复制到所有被合并的空白格子里它只在合并区域左上角返回真实的值其他被合并的单元格返回null。如果你直接拿表头行去匹配列名动态列就会整片错位。所以第一版的核心思路是不能依赖“固定实体映射”来读这个模板必须走监听器模式按列索引动态组装字段。easyExcel正好适合干这件事它不仅能控制表头行数还能把每一行以MapInteger, Object的形式回调出来。下面一部分我会把easyExcel读复杂表头的基本机制拆开讲清楚。2. easyExcel读复杂表头的底层思路headRowNumber与监听器2.1 为什么不用POI硬写不是POI不行而是POI写起来太啰嗦。比如要处理合并单元格你得自己去拿mergedRegions集合再判断某个单元格坐标是否落在合并区域里找到左上角的值才能补全表头。要处理日期类型还得判断CellType再区分是DateUtil.isCellDateFormatted还是纯数字稍不留神就会写出一个三百行的工具类。easyExcel底层虽然也是基于POI但它把POI的细节全部封装掉了用Sax模式流式读取对大Excel文件的内存占用很友好。更重要的是它提供了一套监听器机制你可以通过headRowNumber控制前几行算表头通过invokeHeadMap拿到表头行内容通过invoke拿到真实数据行内容。对于动态列这种需求直接把每行读成Map用索引取值比POI的Row/Cell遍历简单太多。2.2 监听器模式下表头和数据是怎么分离的easyExcel读取一个Sheet时会按照配置把最前面的若干行认定为表头之后的每一行才作为数据行回调到监听器。默认的headRowNumber是1表示第一行是表头。我们这个模板有两行表头所以必须设置成2EasyExcel.read(inputStream) .head(Map.class) .headRowNumber(2) .registerReadListener(listener) .sheet(0) .doRead();设置成2之后前两行不会进入invoke方法第三行开始才是数据。表头行会通过invokeHeadMap回调过来而且是有几行表头就回调几次。第一次回调的是第一行大标题第二次回调的是第二行字段行。这一点很关键因为我们可以把两次回调存下来再拼接成完整的两级表头结构。我建议每个字段行的列索引都用Integer类型的Map来接收。head(Map.class)这个写法比较关键它告诉easyExcel我不映射到具体实体每行数据都以Map形式返回这样动态列才有处理空间。2.3 合并单元格在easyExcel里的真实表现合并单元格的问题在第一版踩得最狠。比如第一行“XX部门员工月度绩效表”横跨了整张表easyExcel在invokeHeadMap回调里返回的Map只会在第一列的key上有一个值其他列都是null。你如果直接拿这个Map当表头用除了第一列后面所有列都会缺少一级标题。这不能怪easyExcel它只是忠实表达了单元格的值合并逻辑它替你做不了。所以在第一版里我在拿到表头Map后自己做了一步“空值继承填充”从左到右遍历如果当前列没有值就用左边最近一个有值的单元格作为当前列的值。这样跨列合并的标题就自动复制到了右侧所有被合并的单元格里后续拼接动态列名时就不会缺字段了。这里要特别注意一点空值继承只适合“一级标题连续合并”的场景。如果某个一级标题本身就是空单元格且不想和左侧内容关联那就不能盲目继承。第一版里我们把模板规范成“固定列区域一级标题可以为空动态列区域一级标题必须有合并值”这样处理起来最稳。如果你手里的模板更复杂建议在填充前先识别合并区域而不是无脑左继承。3. 固定列动态列的读取模型设计与代码骨架3.1 约定固定列区间别让代码到处散落魔法数第一版最怕过度设计所以我采用了“约定优于配置”的方式固定列永远是前3列索引分别是0序号、1姓名、2部门动态列从索引3开始。这个固定列数量通过构造函数传入监听器后续如果模板变了只需要改调用处的fixedColumnCount参数即可。固定列放到数据对象里的映射关系也写得很直白序号、姓名、部门。如果你有其他固定列比如工号、岗位、上级部门可以按顺序扩展。第一版为了快速上线没有做基于列名自动识别固定列因为列名识别如果碰上重名、合并区域、别名等情况复杂度会上升不少。代码里所有读取我都优先用data.get(index)这种方式拿单元格值。比如String serial cellToString(data.get(0)); String name cellToString(data.get(1)); String department cellToString(data.get(2));这样固定列的读取逻辑一眼能看懂也不容易被easyExcel的模型映射坑到。3.2 动态列对应的内存结构ColumnHeader与Map动态列最自然的存储方式是用LinkedHashMap列名作为key单元格值作为value。因为动态列名和值都是从Excel里解析出来的用Map可以同时解决“列数不固定”和“列名动态生成”的问题。我定义了一个ColumnHeader对象来保存每一列的元信息public class ColumnHeader { private final int index; private final String primaryTitle; // 一级标题来自第一行表头 private final String secondaryTitle; // 二级标题来自第二行表头 private final String fullName; // 拼接后的完整列名 public ColumnHeader(int index, String primaryTitle, String secondaryTitle) { this.index index; this.primaryTitle primaryTitle; this.secondaryTitle secondaryTitle; this.fullName buildFullName(primaryTitle, secondaryTitle, index); } public String getFullName() { return fullName; } }index用来对应数据行Map里的列索引primaryTitle和secondaryTitle来自两级表头fullName是真正写入动态Map的键。用完整列名而不是单纯的二级列名是为了防止不同一级标题下出现同名的二级列比如“1月销量”和“1月完成率”在第二行会有两个“1月”。3.3 数据行转行的完整处理流程数据行回调进来时invoke里的data是MapInteger, Objectkey是列索引value是单元格的值。整个转换流程是先取固定列再遍历动态列最后封装成ImportRowData对象。这段是核心逻辑Override public void invoke(MapInteger, Object data, AnalysisContext context) { if (!headerResolved) { resolveHeader(); } if (isEmptyRow(data)) { return; } if (isTotalRow(data)) { return; } ImportRowData row new ImportRowData(); currentRow; row.setRowNum(currentRow headRowNumber); row.setSerial(cellToString(data.get(0))); row.setName(cellToString(data.get(1))); row.setDepartment(cellToString(data.get(2))); MapString, String dynamicData new LinkedHashMap(); for (int i fixedColumnCount; i columnHeaders.size(); i) { ColumnHeader header columnHeaders.get(i); dynamicData.put(header.getFullName(), cellToString(data.get(i))); } row.setDynamicData(dynamicData); dataRows.add(row); }isEmptyRow判断固定列是否全空isTotalRow判断是不是“合计”这类汇总行。这些细节在后面章节会展开。总之固定列直接取前三个索引值动态列从固定列数量开始循环到列头结束非常清晰。4. 标题合并的解析策略第一版怎么做到不丢列4.1 两行表头信息的采集与整理基于headRowNumber(2)的配置监听器需要维护一个集合来接收invokeHeadMap的两次回调。第一次是合并标题行第二次是字段行。我建议用一个List保存每一行的原始表头Map然后再统一处理而不是在回调方法里边收边拼。private final ListMapInteger, String headRows new ArrayList(); Override public void invokeHeadMap(MapInteger, String headMap, AnalysisContext context) { headRows.add(headMap); if (headRows.size() headRowNumber) { resolveHeader(); } }我用headRowNumber来判断是否收齐了表头而不是硬编码2这样以后改成三行表头时只需要改配置和resolveHeader里的逻辑不需要动整体框架。第一次调用里拿到的第一行Map中只有合并区域的左上角有值。所以resolveHeader里的第一件事就是对这个Map做一次空值继承得到完整的一级标题行。第二行的字段行一般是逐列填写的不需要继承但我也建议做一次trim和null protection防止用户模板里留了空格。4.2 空值继承算法让被合并的标题“回家”空值继承算法并不复杂核心就是从左到右遍历维护一个lastValue遇到空值就用上一个非空值填充。关键是遍历的索引范围不能直接遍历headMap.entrySet()因为Map里可能只有部分列有key空值列的key也许压根不存在。我采用的方式是先找到headMap里最大的列索引然后从0循环到maxIndex逐列处理private MapInteger, String fillMergedHeadRow(MapInteger, String headRow) { MapInteger, String filled new HashMap(); int maxIndex 0; for (Integer key : headRow.keySet()) { if (key ! null key maxIndex) { maxIndex key; } } String lastValue null; for (int i 0; i maxIndex; i) { String cellValue headRow.get(i); if (cellValue ! null !cellValue.trim().isEmpty()) { lastValue cellValue.trim(); } filled.put(i, lastValue); } return filled; }这段代码有几个细节值得说。第一用maxIndex而不是headRow.size()是因为Map的size不等于最大列索引加一空值列可能没有key。第二lastValue初始为null如果一级标题行的第一列就是空的那第一列继承结果也是null后续动态列拼接时会做兜底。4.3 动态列名的拼接规则保留层级关系完整列名的拼接规则我是这样定的一级标题和二级标题用下划线连接比如“2025年1月_绩效得分”。如果二级标题为空就用一级标题代替如果一级标题为空就直接用二级标题。String fullName; if (primary ! null !primary.isEmpty() secondary ! null !secondary.isEmpty()) { fullName primary _ secondary; } else if (secondary ! null !secondary.isEmpty()) { fullName secondary; } else if (primary ! null !primary.isEmpty()) { fullName primary; } else { fullName column_ i; }万一这样拼出来还是有重复比如两个动态列都属于同一个一级标题而且二级标题也完全相同那我会在ColumnHeader的构造函数里对fullName加一个唯一后缀例如“某某指标#3”。第一版很少碰到这种情况但做了这个保护后面入库和排查数据时能少很多麻烦。5. 第一版完整实现从Listener到入库链路5.1 依赖与基础配置第一版用的是easyExcel 3.3.3这个版本在读取Map类型和监听器回调上表现稳定也是目前大多数项目里比较常见的版本。Maven依赖dependency groupIdcom.alibaba/groupId artifactIdeasyexcel/artifactId version3.3.3/version /dependency如果项目里已经引入了3.x版本也可以不换。注意不同版本之间invokeHeadMap的行为可能略有差异建议用你实际项目里的版本跑一遍尤其是确认“headRowNumber大于1时invokeHeadMap是否回调多次”这个行为。第一版基于3.3.3回调多次是没问题的。5.2 核心监听器DynamicExcelListener我把核心解析逻辑放在一个监听器类里这个类继承了AnalysisEventListenerMapInteger, Object。上面已经把主要方法拆开讲过这里拼成完整结构方便直接复用。public class DynamicExcelListener extends AnalysisEventListenerMapInteger, Object { private final int headRowNumber; private final int fixedColumnCount; private final ListMapInteger, String headRows new ArrayList(); private final ListColumnHeader columnHeaders new ArrayList(); private final ListImportRowData dataRows new ArrayList(); private boolean headerResolved false; private int currentRow 0; public DynamicExcelListener(int headRowNumber, int fixedColumnCount) { this.headRowNumber headRowNumber; this.fixedColumnCount fixedColumnCount; } Override public void invokeHeadMap(MapInteger, String headMap, AnalysisContext context) { headRows.add(headMap); if (headRows.size() headRowNumber) { resolveHeader(); } } private void resolveHeader() { if (headerResolved) { return; } MapInteger, String primaryRow fillMergedHeadRow(headRows.get(0)); MapInteger, String secondaryRow headRows.get(1); for (int i 0; i secondaryRow.size(); i) { String primary primaryRow.get(i); String secondary secondaryRow.get(i); if (secondary null || secondary.trim().isEmpty()) { secondary primary; } columnHeaders.add(new ColumnHeader(i, primary, secondary)); } headerResolved true; } Override public void invoke(MapInteger, Object data, AnalysisContext context) { if (!headerResolved) { resolveHeader(); } if (isEmptyRow(data) || isTotalRow(data)) { return; } ImportRowData row new ImportRowData(); currentRow; row.setRowNum(currentRow headRowNumber); row.setSerial(cellToString(data.get(0))); row.setName(cellToString(data.get(1))); row.setDepartment(cellToString(data.get(2))); MapString, String dynamicData new LinkedHashMap(); for (int i fixedColumnCount; i columnHeaders.size(); i) { ColumnHeader header columnHeaders.get(i); dynamicData.put(header.getFullName(), cellToString(data.get(i))); } row.setDynamicData(dynamicData); dataRows.add(row); } Override public void doAfterAllAnalysed(AnalysisContext context) { if (!headerResolved) { resolveHeader(); } } private boolean isEmptyRow(MapInteger, Object data) { for (int i 0; i fixedColumnCount; i) { Object value data.get(i); if (value ! null !value.toString().trim().isEmpty()) { return false; } } return true; } private boolean isTotalRow(MapInteger, Object data) { Object first data.get(1); if (first null) { return false; } String text first.toString().trim(); return 合计.equals(text) || 总计.equals(text); } private String cellToString(Object value) { if (value null) { return null; } if (value instanceof Number) { return new BigDecimal(value.toString()) .stripTrailingZeros() .toPlainString(); } return value.toString().trim(); } private MapInteger, String fillMergedHeadRow(MapInteger, String headRow) { MapInteger, String filled new HashMap(); int maxIndex 0; for (Integer key : headRow.keySet()) { if (key ! null key maxIndex) { maxIndex key; } } String lastValue null; for (int i 0; i maxIndex; i) { String cellValue headRow.get(i); if (cellValue ! null !cellValue.trim().isEmpty()) { lastValue cellValue.trim(); } filled.put(i, lastValue); } return filled; } public ListImportRowData getDataRows() { return dataRows; } }这里isTotalRow我用了“合计”和“总计”两个关键词做过滤因为第一版的数据来源内部Excel模板这两个词足够覆盖业务场景。如果你要处理更开放的模板建议把过滤关键词变成参数或者直接去掉这个过滤让下游业务去判断。5.3 解析入口与业务校验入口方法不需要太复杂把所有解析细节封装在Service层对外只暴露文件名、输入流、表头行数和固定列数public ListImportRowData importDynamicExcel(InputStream inputStream, int headRowNumber, int fixedColumnCount) { DynamicExcelListener listener new DynamicExcelListener(headRowNumber, fixedColumnCount); EasyExcel.read(inputStream) .head(Map.class) .registerReadListener(listener) .sheet(0) .headRowNumber(headRowNumber) .doRead(); return listener.getDataRows(); }解析完成后业务层做校验。第一版的校验只做了三件事姓名不能为空、部门不能为空、动态列值如果是数字必须在0到100之间。校验失败时我把错误信息收集到一个List里返回给前端并标出具体行号if (StringUtils.isBlank(row.getName())) { errors.add(第 row.getRowNum() 行姓名不能为空); } for (Map.EntryString, String entry : row.getDynamicData().entrySet()) { if (entry.getValue() null || entry.getValue().trim().isEmpty()) { continue; } BigDecimal score new BigDecimal(entry.getValue()); if (score.compareTo(BigDecimal.ZERO) 0 || score.compareTo(new BigDecimal(100)) 0) { errors.add(第 row.getRowNum() 行 entry.getKey() 超出范围); } }BigDecimal比较一定要用compareTo别用doubleValue去比否则遇到精度很小的数会出莫名其妙的问题。5.4 批量入库与错误反馈第一版数据量不大所以采用“先全部解析完再统一入库”的方式。入库用的是MyBatis-Plus的批量插入但我强烈建议不要一次性插入成千上万条数据。第一次上线就吃过这个亏一个月的绩效数据看起来没多少但动态列多的时候每条记录被拆成几十个字段插入SQL会非常长。后来我把批处理改成每500条提交一次saveBatch(list, 500);如果将来解析出来的数据要保存到数据库的JSON字段里那更简单直接把dynamicData转成JSON字符串存一列。这样动态列再怎么加数据库表结构都不用改业务上反而更灵活。第一版我们没有走JSON方案因为下游系统要求按列展开成宽表这是历史遗留原因不改了。6. 第一版踩坑记录比文档更值钱的细节6.1 合并单元格导致的列名错位这个坑是第一天就踩到的。第一次写解析时没有做空值继承直接把第一行Map当一级标题用结果动态列的一级标题全部向右错位了一格。原因就是easyExcel对合并单元格不填充相邻空单元格只有左上角有值。处理方式就是我前面讲的fillMergedHeadRow。这里再补充一个心得不要试图用easyExcel的配置去开启“合并单元格自动填充”我没找到这个开关。官方文档里也没有明确说最快的方式就是自己写继承逻辑十行代码搞定不要纠结。6.2 headRowNumber配置失误丢数据第一次联调时测试拿了一个三行表头的模板来试我当时headRowNumber还写死为2结果第三行的第一条数据被当成表头吞掉了。而且这种错误特别隐蔽因为列数都一样很难从结果里一眼看出少了一条数据。第一版为了解决这个问题在resolveHeader里加了一个固定列名校验第二行的第二列必须是“姓名”否则直接抛出业务异常。这样至少能挡掉一部分模板错误String nameTitle secondaryRow.get(1); if (nameTitle null || !nameTitle.contains(姓名)) { throw new BusinessException(模板表头结构不符合要求请使用标准模板); }真正的解法是让用户上传前选择一个“模板类型”每种模板有对应的表头层数但第一版没来得及做。6.3 数字类型处理99.0和科学计数法easyExcel读数字时默认会转换成Double比如Excel里填的是99读出来是99.0如果你直接toString()入库就变成了“99.0”。更麻烦的是如果单元格里是ID号或长数字可能会变成科学计数法比如1.23E10。这种数据一旦落到字符串字段里基本就废了。所以我统一用cellToString方法处理如果是Number类型先转成BigDecimal再stripTrailingZeros().toPlainString()。这样99.0会变成99科学计数法也会展开成正常字符串。这个方法一定要在所有取值入口都用上不能只处理动态列。6.4 日期单元格的格式转换动态列里偶尔会出现日期格式的数据比如“2025-01-01”。easyExcel对日期单元格的默认处理是给你一个LocalDateTime对象如果你直接toString()可能会得到2025-01-01T00:00:00这种中不中、西不西的字符串。第一版我们的业务库统一存字符串所以我在cellToString里补了一段日期格式化if (value instanceof LocalDateTime) { return ((LocalDateTime) value).format(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss)); } if (value instanceof LocalDate) { return ((LocalDate) value).format(DateTimeFormatter.ofPattern(yyyy-MM-dd)); }如果你不需要时间部分直接格式化成yyyy-MM-dd即可。记得在import里带上java.time的类。6.5 空行、合计行干扰用户上传的Excel最后一两行经常是空行或者带一个“合计”汇总行。easyExcel会把空行也回调到invoke不过那个Map要么是空的要么全是null。如果不过滤后面入库时会多出几条全空记录下游查问题的时候会特别烦。空行判断很简单只要固定列的序号、姓名、部门都是空就整行跳过。合计行的判断稍微麻烦一点因为有些模板的合计行会放在最后一个数据行下面姓名列写成“合计”。第一版直接按关键字过滤确实简单粗暴但能解决问题。如果你不希望误杀“姓名叫合计”的人那还是建议把过滤规则单独配置。6.6 大数据量下的内存与批量落库动态列多的时候ImportRowData里的Map字段会占用不少内存。比如一个Excel有5000行、50个动态列光Map的key就有25万个全部堆在内存里再去入库GC都会报警。第一版数据量小没做优化但代码结构已经留了口子在invoke里每攒满500条就触发一次批量保存然后清空dataRows。private static final int BATCH_SIZE 500; Override public void invoke(MapInteger, Object data, AnalysisContext context) { ... dataRows.add(row); if (dataRows.size() BATCH_SIZE) { flushBatch(); } } private void flushBatch() { if (dataRows.isEmpty()) { return; } // 这里调用批量入库 saveBatch(dataRows); dataRows.clear(); }监听器设计的时候不要把所有数据都无脑缓存到最后返回尤其是未来遇到几万行、几十列的Excel内存很容易扛不住。第一版为了代码简单是在doAfterAllAnalysed里统一返回的但如果你想做性能优化参考上面这种分批flush的方式就好。7. 后面可以怎么优化给第二版留的作业7.1 表头结构预检优先于解析第一版最大的教训是解析逻辑再完善如果模板格式不对结果还是一堆错位数据。第二版应该把表头结构预检放在第一步也就是在真正解析数据之前先单独读一遍表头校验固定列是否齐全、动态列是否存在、是否有重复列名。如果表头都不对直接告诉用户“模板不符合要求”别浪费时间解析完整份文件。easyExcel只读表头并不复杂注册一个只处理invokeHeadMap的监听器不关注数据行就可以。这种方式比先解析完整再回滚要优雅得多。7.2 固定列自动识别第一版固定列写死是前3列确实省事但换个模板就废了。第二版可以考虑按列名自动识别比如扫描第二行的字段名找到“姓名”和“部门”然后确定这两个关键字对应的列索引把动态列的起始位置设为部门列1。这样即使固定列在模板里挪了位置也不需要改代码。要注意的是动态识别必须处理重名列名比如模板里有两个“部门”一个叫“部门”一个叫“所属部门”那就需要更智能的匹配规则。从第一版的实际经验看识别逻辑的复杂度比想象中高建议先做“白名单列名匹配”把常用固定列名列出来匹配不到就报错。7.3 动态列元数据落库现在动态列是直接展开存进宽表一旦列数频繁变化表结构要跟着调整非常麻烦。第二版我倾向于把动态列整体转成JSON字符串存到一个类似dynamic_data的字段里同时把动态列的元数据比如列名、列类型、顺序单独存一张配置表。读取的时候解析JSON即可自由度会高很多。这样做也有代价就是下游如果是报表系统查询起来不如宽表直观。所以不能一概而论要看业务场景。第一版选择了宽表是为了对接方便但代码层面已经把动态列独立成了MapString, String将来转JSON只是序列化的问题迁移成本并不大。第一版能同时处理固定列、动态列和合并标题核心就一句话别用实体类映射老老实实用Map和列索引再自己补一下合并单元格的空值继承。这套思路在大多数复杂Excel导入场景里都通用希望这篇分享能帮后面的人少踩几个坑。
返回列表