☰
Java用easyExcel解析固定列+动态列+合并标题的Excel模板
2026/10/8 8:35:02 网站建设 项目流程

1. 从业务场景说起:什么样的Excel需要固定列+动态列+标题合并

1.1 第一版需求:月度绩效表导入

最近接了一个导入需求,看到Excel模板的第一眼,我就知道这活不能按老套路写。模板长这样:最上面是一行合并单元格的大标题,比如“XX部门员工月度绩效表”,第二行才是真正的字段名,前几列是序号、姓名、部门这类固定信息,后面跟着的业务指标列还会随月份不断增加。也就是说,固定列、动态列、合并标题这三种让人头疼的元素,一次全凑齐了。

这个项目当时的任务很明确:用户上传一个Excel文件,我们把里面的人员信息和每个月绩效指标解析出来,校验后写入业务库。第一版我选了easyExcel来解析,两周不到跑通了完整流程。这篇就把第一版的做法、踩过的坑和关键代码整理出来,给以后接同类型需求的人一个参考。

先说清楚适用范围:模板固定为两级表头,第一行是大标题,第二行是列名,数据从第三行开始。前N列是固定字段,后面所有列都属于动态字段,动态列的名称和数量每次导入都可能不一样。第一版不考虑那种“三行表头、还有合并斜角”的极端情况,但思路是通用的。

1.2 为什么不能把Excel当普通二维表来读

很多人第一次接触Excel导入,下意识会把它当成二维表,用POI读出来之后按行列号取值,然后硬编码成DTO。这种方式对付固定列没问题,但一旦遇到动态列,麻烦就来了:你没办法在Java实体类里为每个动态列写一个@ExcelProperty字段,因为列名和列数都是运行时才知道的。

合并标题则是另一个坑。easyExcel在读取合并单元格时,不会像人眼看到的那样把同一个标题复制到所有被合并的空白格子里,它只在合并区域左上角返回真实的值,其他被合并的单元格返回null。如果你直接拿表头行去匹配列名,动态列就会整片错位。

所以第一版的核心思路是:不能依赖“固定实体映射”来读这个模板,必须走监听器模式,按列索引动态组装字段。easyExcel正好适合干这件事,它不仅能控制表头行数,还能把每一行以Map<Integer, 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,表示第一行是表头。我们这个模板有两行表头,所以必须设置成2:

EasyExcel.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是Map<Integer, Object>,key是列索引,value是单元格的值。整个转换流程是:先取固定列,再遍历动态列,最后封装成ImportRowData对象。

这段是核心逻辑:

@Override public void invoke(Map<Integer, 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))); Map<String, 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 List<Map<Integer, String>> headRows = new ArrayList<>(); @Override public void invokeHeadMap(Map<Integer, 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 Map<Integer, String> fillMergedHeadRow(Map<Integer, String> headRow) { Map<Integer, 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> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>3.3.3</version> </dependency>

如果项目里已经引入了3.x版本,也可以不换。注意不同版本之间invokeHeadMap的行为可能略有差异,建议用你实际项目里的版本跑一遍,尤其是确认“headRowNumber大于1时,invokeHeadMap是否回调多次”这个行为。第一版基于3.3.3,回调多次是没问题的。

5.2 核心监听器DynamicExcelListener

我把核心解析逻辑放在一个监听器类里,这个类继承了AnalysisEventListener<Map<Integer, Object>>。上面已经把主要方法拆开讲过,这里拼成完整结构,方便直接复用。

public class DynamicExcelListener extends AnalysisEventListener<Map<Integer, Object>> { private final int headRowNumber; private final int fixedColumnCount; private final List<Map<Integer, String>> headRows = new ArrayList<>(); private final List<ColumnHeader> columnHeaders = new ArrayList<>(); private final List<ImportRowData> 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(Map<Integer, String> headMap, AnalysisContext context) { headRows.add(headMap); if (headRows.size() >= headRowNumber) { resolveHeader(); } } private void resolveHeader() { if (headerResolved) { return; } Map<Integer, String> primaryRow = fillMergedHeadRow(headRows.get(0)); Map<Integer, 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(Map<Integer, 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))); Map<String, 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(Map<Integer, 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(Map<Integer, 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 Map<Integer, String> fillMergedHeadRow(Map<Integer, String> headRow) { Map<Integer, 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 List<ImportRowData> getDataRows() { return dataRows; } }

这里isTotalRow我用了“合计”和“总计”两个关键词做过滤,因为第一版的数据来源内部Excel模板,这两个词足够覆盖业务场景。如果你要处理更开放的模板,建议把过滤关键词变成参数,或者直接去掉这个过滤,让下游业务去判断。

5.3 解析入口与业务校验

入口方法不需要太复杂,把所有解析细节封装在Service层,对外只暴露文件名、输入流、表头行数和固定列数:

public List<ImportRowData> 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.Entry<String, 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(Map<Integer, 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即可,自由度会高很多。

这样做也有代价,就是下游如果是报表系统,查询起来不如宽表直观。所以不能一概而论,要看业务场景。第一版选择了宽表,是为了对接方便,但代码层面已经把动态列独立成了Map<String, String>,将来转JSON只是序列化的问题,迁移成本并不大。

第一版能同时处理固定列、动态列和合并标题,核心就一句话:别用实体类映射,老老实实用Map和列索引,再自己补一下合并单元格的空值继承。这套思路在大多数复杂Excel导入场景里都通用,希望这篇分享能帮后面的人少踩几个坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询