ARTICLE DETAIL

资讯详情

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

Excel导入分类模块实战:EasyExcel多层校验与批量落库全解析

Excel导入分类模块实战:EasyExcel多层校验与批量落库全解析 做后台开发这几年凡是有数据录入需求的业务系统基本都逃不过导入这道工序。最近手头项目正好在做导入分类模块功能代码这个任务把整个从设计到落地的过程梳理了一遍。这里头最核心的技术难点其实不在怎么写导入而在分类模块怎么拆——分类体系没设计好导入功能写完就是噩梦的开始。这篇就围绕这个场景把项目里实际用到的方案、踩过的坑、以及我个人的经验心得做个完整复盘希望能给正在做类似功能的同学少走几步弯路。1. 内容整体设计与思路拆解1.1 需求本质导入不是一个功能是一套流程刚接到导入分类模块功能代码这个标题时如果把思路局限在写一个解析Excel的方法后面的路基本就走窄了。从热搜词里也能看出来大家真正关心的是excel导入数据库csv文件导入easyexcel复杂表头导入这类问题本质上都指向同一个核心如何把松散的线下数据安全、准确地变成系统能用的结构化数据。我这次的需求背景是这样的系统里已经有一套完整的分类树大类套小类最多支持四层日常运营从线下Excel收集了一堆新分类需要批量灌进系统。这就要求导入功能模块必须同时解决三件事数据从文件到内存的解析读Excel/CSV解析后到数据库落库前的校验查重复、查层级、查必填落库时和既有分类数据的整合挂到正确的父节点下这三件事缺一不可。很多初写导入功能的人只做了第一件Excel读完了、数据塞到List里就以为大功告成结果上线第二天数据就乱了。再从热搜词里看到easypoi导入easyexcel复杂表头导入这类词高频出现说明在当前的技术选型背景下EasyExcel已经成为Java后端做导入的事实标准。我这次也沿用了这个方案配合Apache Commons CSV做纯CSV场景的补充基本覆盖了实际业务里90%的导入诉求。1.2 分类模块为什么单独拎出来做分类模块和普通业务数据最大的不同在于它有层级关系。普通导入只要保证字段对应正确、类型转换成功就行分类导入还额外要求父分类必须存在否则子分类挂哪同层兄弟分类的名称不能重否则用户选分类时分不清删除父分类时子分类怎么办这个属于后端逻辑但导入时就要考虑分类状态的一致性分类编码如果存在就必须全局唯一所以做导入功能之前分类模块本身的表结构和Service层接口必须先稳定下来。我这里有现成的分类表结构id、parent_id、name、code、sort_order、status、level一共7个核心字段。表结构不复杂但parent_id和level的组合决定了导入校验逻辑必须写清楚。这里有个很关键的取舍为什么导入逻辑不直接写在Controller里而是拆成模块独立的Service因为导入功能除了被这次需求使用后续还可能被定时任务调用、被接口对接调用、甚至被另一个导入功能复用比如导入商品时顺便创建新分类。单独抽成一个ClassificationImportService注入到现有分类Service之上既不影响原业务又能让导入逻辑具备复用性——这是我这次设计时最看重的一点。1.3 方案选型为什么不用POI直接硬写可能有人会说导入Excel不就是Apache POI读个文件、循环拿数据嘛确实简单的几行数据可以这么干。但一旦表头复杂、数据量大、需要逐行校验并返回精确到行号的错误信息时POI裸写的代码会变得非常难维护。我选EasyExcel的核心原因有三个底层基于SAX模式解析读取大量数据时内存占用低这点对大数据量导入极其关键通过注解直接映射实体字段表头对列名的解析能力很强支持自定义监听器AnalysisEventListener逐行读取时可以边读边校验攒一批再批量入库性能好而分类模块的复杂之处恰恰在于校验逻辑多。用EasyExcel监听器模式能在每一行读入时立刻做前置校验格式对不对、父分类在不在、编码有没有重复不用先把百万行数据全部读到内存再处理——这一步直接决定了导入功能扛不扛得住真实业务量。2. 核心细节解析与实操要点2.1 数据模型设计导入模板的字段怎么定做导入功能第一件事不是写代码而是定模板。模板字段定得不好后面所有校验逻辑都是在打补丁。我这次定的导入模板长这样Excel第一行是表头列名是否必填说明分类名称必填当前节点的名称同层级下不能重复父分类编码条件必填顶级分类可为空子分类必须以编号形式挂载父分类名称条件必填若填了编码则可不填二选一优先编码分类编码选填系统内全局唯一不填则自动生成排序值选填默认0数值越小越靠前状态选填可填 启用/停用默认启用这里有两个细节值得展开为什么父分类编码和父分类名称同时保留因为线下填表的人往往是业务人员而不是技术人员。有的人手里只有分类名称清单没有编码有的人系统里查得到编码。两种人都要兼容所以导入逻辑里做了编码优先编码没填或者查不到时再用名称去匹配父节点。名称匹配只做兄弟层级内的精确匹配避免重名导致挂错父节点。分类编码不填怎么办我设计的是编码为空则自动生成生成规则是CAT_ 时间戳 4位随机数保证并发导入也不会撞码。如果填了编码就做一次全表唯一性校验库里已有相同编码就直接报错。模板定好后我在项目里用EasyExcel注解直接映射这个实体类ExcelProperty(value 分类名称, index 0)这类注解写法注意index属性必须显式指定尤其是表头有合并单元格的情况。如果你不用index而用value匹配表头一旦有细微空格差异读取就会失败——这个坑我踩过后面在问题排查里细说。2.2 校验逻辑的分层设计导入模块最容易犯的一个错误是把所有校验堆在一个方法里几十个if-else层层嵌套后期看代码的人直接崩溃。我这次把校验拆成了三层第一层格式校验这层是硬性检查不涉及数据库交互必填列是否为空状态列是否填了枚举允许的值启用/停用排序值是否是合法数字编码是否符合长度和字符规范比如只允许字母、数字、下划线格式校验如果在监听器里做就不需要大量内存缓存数据。遇到格式错误时立即记录错误信息行号 列名 错误原因做完整行跳过处理。第二层依赖校验这层要查数据库。核心逻辑是父节点和重名判断如果父分类编码非空用编码查库查不到则报错父分类编码不存在如果父分类编码为空但名称非空查库时限定层级条件并且是精确匹配同层下名称不能重复这里必须强调的是同一父节点下的子节点中名称不能重复而不是全局名称不能重复因为不同父节点下允许同名子分类的存在这一点业务上很常见比如多个大类下都有其他分类第三层交叉校验这层主要处理文件内部数据的关系容易被忽略。比如同一个导入文件里出现了两行第一行电子产品—手机—苹果手机 第二行手机—苹果手机这时如果把各行独立看待第二行的父分类手机在第一行里是作为子分类出现的库里此刻还没有手机这个节点。如果不做文件内缓存第二行就会报父分类不存在。这一步极其关键。我当时的方案是在监听器里维护一个内存Mapkey是父节点标识编码名称value是本批次里该父节点下的子节点名称集合边读边追加先用文件内数据兜底再查库补充。这样的处理让同一Excel内先建父再建子的逻辑自动成立不用要求业务人员严格按层级顺序填表。这层我记得在实际业务中问过很多人大多数系统的导入功能都没有处理这个边界场景结果就是每次导入批量报父分类不存在逼着人手工排序Excel体验极差。2.3 导入模式先校验后落库 vs 边读边入库这里我要做一个明确的经验总结成熟的分类模块导入一定不要用边读边入库的模式。很多新手喜欢在EasyExcel监听器里读一行、插一条代码确实是短了但后果是文件读到第50行发现错误时前49行已经写进数据库了整个文件的导入处于半成功状态。用户也不知道前面是应该回滚还是保留后面再改数据重新导入时又撞上重复校验越想越乱。我的做法是两阶段阶段一读取校验通过EasyExcel监听器把文件完整读一遍每行数据都经过三层校验。校验通过的行放进List校验失败的行记录错误明细。这一步不操作数据库。阶段二批量落库如果错误行数大于0默认策略是错误行不导入正确行导入并把错误明细整理好返回给前端下载。如果错误数量太多超过了总行数的10%我会在代码里加一个保护性配置触发整文件拒绝导入的开关防止数据大面积损坏。这样设计的好处是每条数据的错误都能在导入前一次性暴露。用户拿到错误清单逐行修改后重新上传不会出现数据库里插了一半数据的尴尬。做批处理类功能这种验证与执行分离的思想是通用的无论是Excel导入、接口对接还是定时任务都建议照这个思路来。2.4 数据处理质量Excel模板本身也要防呆前面说的是代码逻辑模板本身同样要做防御设计。实际项目里我发现业务人员填Excel时经常会在名称前后敲空格、把数字列存成文本格式、甚至用中文括号替代英文括号。这些不起眼的小问题能让导入成功率直线下降。我这次做模板防呆用了几个技巧数据有效性下拉状态列设置下拉列表启用/停用减少手打枚举值的出错率必填项单元格填充底色比如黄色让人一眼就知道哪几列不能空表头冻结、加筛选方便大数据量录入时自查代码里统一做trim处理字符串列全部去首尾空格数字列在模板里设置单元格格式为文本避免长编码被Excel自动转成科学计数法比如分类编码123456789012345直接变成1.23457E14这是Excel经典坑这些看起来都是小事但上线后节省下来的沟通成本非常可观。记住一个原则能用模板约束的绝不留到代码里做异常处理。3. 实操过程与核心环节实现3.1 项目工程结构划分分享下我当时实际的工程模块划分给个可直接参考的骨架。常规Maven工程下我按职责分了三个包com.xxx.project ├── controller // 导入接口入口负责接收MultipartFile ├── service │ ├── ClassificationService // 既有分类管理服务 │ ├── ClassificationImportService // 导入专用服务 │ └── listener │ └── ClassificationExcelListener // EasyExcel监听器 ├── model │ ├── dto │ │ ├── ClassificationImportDTO // 导入模型对应Excel行 │ │ └── ImportResultDTO // 导入结果成功数失败明细 │ └── entity │ └── Category // 分类实体 └── util └── ImportUtils // 通用导入工具这个划分的要点是导入专用Service和原有业务Service分开二者通过方法调用串联但不互相污染。这样以后如果要把导入逻辑复用给定时任务或者换成其他数据源比如读CSV、读接口报文只需要替换监听器那一层校验和落库逻辑完全不动。3.2 核心代码实现——EasyExcel监听器下面贴几段我当时实际用到的核心代码配上注释和关键思路。导入DTO定义Data public class ClassificationImportDTO { ExcelProperty(value 分类名称, index 0) private String name; ExcelProperty(value 父分类编码, index 1) private String parentCode; ExcelProperty(value 父分类名称, index 2) private String parentName; ExcelProperty(value 分类编码, index 3) private String code; ExcelProperty(value 排序值, index 4) private Integer sortOrder; ExcelProperty(value 状态, index 5) private String status; }需注意这里ExcelProperty的index要与模板列顺序严格对应。用index有个好处即使表头中文名字微调比如分类名称改成名称只要列顺序不变导入依然能成功。反过来如果用value匹配表头一变就挂了。监听器核心逻辑public class ClassificationExcelListener extends AnalysisEventListenerClassificationImportDTO { /** 校验通过的数据列表 */ private final ListClassificationImportDTO validList new ArrayList(); /** 校验失败明细key是行号value是错误描述 */ private final MapInteger, String errorMap new TreeMap(); /** 文件内部同级节点名称缓存用于交叉校验 */ private final MapString, SetString siblingNameCache new HashMap(); private final ClassificationService classificationService; private final int totalLimit; public ClassificationExcelListener(ClassificationService classificationService, int totalLimit) { this.classificationService classificationService; this.totalLimit totalLimit; } // 每解析一行数据easyexcel会自动回调这个方法 Override public void invoke(ClassificationImportDTO data, AnalysisContext context) { Integer rowNo context.readRowHolder().getRowIndex() 1; // 获取物理行号方便定位错误 // 前置保护如果错误行太多直接终止读取避免无意义的内存消耗 if (errorMap.size() totalLimit) { throw new ExcelAnalysisStopException(错误行数超限终止解析); } // 先去首尾空格这个操作对所有字符串字段都要做 data.setName(trimToEmpty(data.getName())); data.setParentCode(trimToEmpty(data.getParentCode())); data.setParentName(trimToEmpty(data.getParentName())); data.setCode(trimToEmpty(data.getCode())); // 三层校验 String errorMsg validateRow(data, rowNo); if (StringUtils.isNotEmpty(errorMsg)) { errorMap.put(rowNo, errorMsg); } else { validList.add(data); // 写入文件内同级缓存供后续行交叉校验使用 String key buildSiblingKey(data.getParentCode(), data.getParentName()); siblingNameCache.computeIfAbsent(key, k - new HashSet()).add(data.getName()); } } private String validateRow(ClassificationImportDTO data, Integer rowNo) { // 1. 格式校验 if (StringUtils.isBlank(data.getName())) { return 分类名称不能为空; } if (StringUtils.isNotBlank(data.getSortOrder()) !NumberUtils.isDigits(data.getSortOrder())) { return 排序值必须为数字; } // 状态字段枚举校验 if (StringUtils.isNotBlank(data.getStatus()) !Arrays.asList(启用, 停用).contains(data.getStatus())) { return 状态字段只能填启用/停用; } // 2. 依赖校验父分类是否存在优先编码、其次名称 if (StringUtils.isNotBlank(data.getParentCode())) { Category parent classificationService.findByCode(data.getParentCode()); if (parent null) { return 父分类编码不存在 data.getParentCode(); } } else if (StringUtils.isNotBlank(data.getParentName())) { Category parent classificationService.findByNameAndLevel(data.getParentName()); if (parent null) { return 父分类名称不存在 data.getParentName(); } } // 3. 编码唯一性校验 if (StringUtils.isNotBlank(data.getCode()) classificationService.existsByCode(data.getCode())) { return 分类编码已存在 data.getCode(); } // 4. 同层级重名校验先查库再查文件内缓存 String siblingKey buildSiblingKey(data.getParentCode(), data.getParentName()); boolean dupInDb classificationService.existsSiblingName(siblingKey, data.getName()); boolean dupInFile siblingNameCache.containsKey(siblingKey) siblingNameCache.get(siblingKey).contains(data.getName()); if (dupInDb || dupInFile) { return 同层级下分类名称重复 data.getName(); } return null; } // 监听器读取完成后的回调此时数据已全部解析和校验完毕 Override public void doAfterAllAnalysed(AnalysisContext context) { // 其实落库逻辑我放在Service层处理这里的回调只做数据交接 // 如果需要在此处做一些汇总统计也可以在这里写 } // 构造同层节点的缓存key父编码为空时用父名称为key private String buildSiblingKey(String parentCode, String parentName) { if (StringUtils.isNotBlank(parentCode)) { return CODE: parentCode; } return NAME: parentName; } private String trimToEmpty(String str) { return str null ? : str.trim(); } }3.3 落库逻辑与事务控制监听器里拿到了validList之后Service层的落库逻辑就相对简单了。但我特别强调一下事务和批量插入的设计这是最容易写出性能问题的环节。Service public class ClassificationImportService { Resource private ClassificationService classificationService; Resource private CategoryMapper categoryMapper; Transactional(rollbackFor Exception.class) public ImportResultDTO importExcel(MultipartFile file) throws IOException { ClassificationExcelListener listener new ClassificationExcelListener(classificationService, 100); // 用EasyExcel读取自动回调监听器 EasyExcel.read(file.getInputStream(), ClassificationImportDTO.class, listener) .sheet(0) .headRowNumber(1) .doRead(); // 校验阶段完成整理结果 ImportResultDTO result new ImportResultDTO(); result.setErrorMap(listener.getErrorMap()); ListClassificationImportDTO validList listener.getValidList(); if (CollectionUtils.isEmpty(validList)) { result.setSuccessCount(0); return result; } // 批量落库为了性能100条一批先拼接parentId再insert ListCategory insertList new ArrayList(); for (ClassificationImportDTO dto : validList) { Category category new Category(); category.setName(dto.getName()); // 处理父节点返回真正的parentId Long parentId resolveParentId(dto); category.setParentId(parentId); // 层级 父层级 1 category.setLevel(parentId null ? 1 : classificationService.getLevelById(parentId) 1); // 编码为空时自动生成 category.setCode(StringUtils.isNotBlank(dto.getCode()) ? dto.getCode() : genCode()); category.setSortOrder(dto.getSortOrder() null ? 0 : dto.getSortOrder()); category.setStatus(停用.equals(dto.getStatus()) ? 0 : 1); insertList.add(category); // 攒够100条就批量insert一次 if (insertList.size() % 100 0) { categoryMapper.batchInsert(insertList); insertList.clear(); } } if (!insertList.isEmpty()) { categoryMapper.batchInsert(insertList); } result.setSuccessCount(validList.size()); return result; } }这里有两个点值得细说。为什么用Transactional包整个方法监听器阶段虽然没写库但校验时查了库查询本身不会对数据有一致性影响事务加上主要是为了保证落库阶段中途出错的话所有批量插入的脏数据能一并回滚。注意EasyExcel的doRead过程本身是不在事务里的所以我的写法是先完整解析完、再在事务里insert这样如果落库一半发现异常回滚的只是insert这一部分文件解析状态也不会丢失。batchInsert 100条一批的依据是什么这一批次的量不是拍脑袋定的。MySQL默认max_allowed_packet通常是16M到64M不等100条分类数据每条约1KB远低于上限不会撑爆报文同时100条的网络往返次数只有全量insert的百分之一性能提升明显。Oracler或PostgreSQL用户也可以参考这个量级结合自己表字段大小调整。3.4 CSV场景怎么兼容做完了Excel导入实际项目里还有一个高频场景是CSV导入。热搜词里mysql导入csv出现频率也很高说明很多人确实需要直接处理CSV。CSV本身没有Excel那么复杂的格式规则但有两个坑很典型字符编码和字段含逗号。我的处理方式是CSV场景直接复用ClassificationExcelListener那一套校验逻辑只是解析层换成Apache Commons CSV将解析结果包装成ClassificationImportDTO再走同一套validate。这样校验代码只需要写一遍两种文件格式都能复用后续维护也轻松。编码方面我做了自动嗅探先读文件头几个字节判断UTF-8 BOM没有BOM就尝试用UTF-8解码解不出来再退回GBK。这个逻辑在Windows环境下特别重要因为国内很多业务人员用Excel另存为CSV时生成的都是GBK编码直接按UTF-8解析全变乱码。4. 常见问题与排查技巧实录4.1 问题一EasyExcel读取时表头匹配不上数据全是null这个问题的典型现象是日志里行数读到了但每条记录的字段全是null。排查思路先看是不是表头和实体注解的值不完全一致。前面提过如果用了value匹配比如ExcelProperty(分类名称)而Excel模板里表头实际是分类名称必填匹配就失败。这种情况我后来统一改成用index定位直接规避。另外有些人在Excel表头里加了换行符或者列的顺序调换过用index方案就完全不慌。如果确定是用的index还出现null排查第二个地方headRowNumber是否设置正确。如果模板是两行表头比如跨行合并单元格默认headRowNumber1就会把第二行表头也当成数据解析。我的模板虽然是一行表头但做统一封装时依然显式指定了headRowNumber(1)防止后续模板调整后踩坑。4.2 问题二invalid zip archive: could not find EOCD 导入报错这个报错在热搜词里也出现了而且很有代表性。原因很简单EasyExcel本质上是解析xlsx压缩包格式的如果上传的文件其实是CSV文件但后缀改成了.xlsx或者文件头被损坏了EasyExcel就会报这个错。我的排查经验先用文本编辑器打开该文件看头部是不是PKxlsx压缩包的文件头。如果看到的是纯文本格式数据基本就是伪xlsx大概率是拿WPS或者老版本Office另存时格式混乱了。处理办法是让业务人员重新用标准格式另存或者后端直接改用CSV解析通道。另一种可能前端上传时对文件做了Base64编码后端没解码就传给了EasyExcel也会报类似的流解析错误。这个属于常见的传输问题排查时优先确认入参MultiPartFile没错、文件流没有被提前消费。4.3 问题三导入1000条数据耗时极长我一开始写导入时是单条insert循环插1000条数据要跑十几秒SQL日志刷屏。后来改成MyBatis的batchInsert100条一批性能直接从十几秒降到了两秒以内。这里有个容易被忽略的实现细节MyBatis的ExecutorType要设置成BATCH如果仍然是SIMPLE执行器batchInsert里的foreach本质还是逐条执行单个SQL没变短。用了BATCH执行器后PreparedStatement会复用网络交互次数大幅下降效率就上来了。另外大数据量导入时临时关闭分类表上的非必要索引也是个优化技巧。导入大量数据时每插一条索引树就要更新一次索引太多会明显拖慢批量插入。生产环境如果允许导入前先drop掉状态、排序这类非核心索引导入完成后重建——但这个操作需要DBA审批开发环境测试时可以自行验证。4.4 问题四时区和日期格式导致的导入脏数据这条其实适用于任何带时间字段的导入场景。Excel里日期本质上是序列号比如45293这种解析出来的值往往是45293.0或者2024-01-01 00:00:00这种带时间部分的结果。如果数据库字段是date类型插入时可能报格式错误。我这次分类模块虽然没有日期字段但相邻的扩展属性里有生效时间这种列处理方式值得顺带一提DTO里用String接收日期字段解析后再用DateTimeFormatter统一format一次。切记不要直接拿String往LocalDateTime里转Excel的格式千奇百怪有的带T、有的带空格、有的只有年月日统一处理才能兜住各种脏数据格式。4.5 问题五模板里的下拉数据有效性在导入时失效有些业务场景里模板确实做了下拉选项但用户依旧能通过粘贴的方式填进白名单外的值。比如状态列下拉只允许启用/停用但用户从旧表复制过来一堆正常禁用保存时Excel根本不会拦截。所以代码里的枚举校验一定不能省也千万不要因为做了下拉菜单就跳过校验。我见过不止一个项目因为过度信任Excel模板导致库里出现启动这种错别字的枚举值最后清洗数据时苦不堪言。4.6 避坑总结导完以后必须做的三件事导入功能不是文件读进来、数据写进去就算完事。我在每次上线导入功能时还会强制要求自己做好三件收尾操作导入结果必须有可追溯的日志。哪怕只是简单的log记录谁在什么时间导入了哪个文件、成功多少、失败多少、异常明细存哪了。出了问题才好定位。文件要有备份归档。原文件上传后挪到OSS或服务器磁盘的备份目录至少保留30天。数据出了问题还能拿原始文件复盘。失败清单要方便用户重新导入。我习惯把失败明细生成一个带错误标记的Excel返回给前端用户修改后可以直接二次上传而不是只给一段红字报错然后让人干瞪眼。5. 写在后面关于导入功能的一些个人体会这次把导入分类模块功能代码这个任务完整拆解下来最深的体会是导入功能写起来不难难的是把各种意外情况都考虑进去。一个看似简单的导入Excel功能背后涉及模板设计、字段映射、多层校验、性能优化、异常兜底、编码兼容等一连串问题。任何一个环节没做好用户使用时的体验就是为什么我导不进去为什么导进去的数据是乱的。我个人在实际项目里反复踩坑后总结出的最大心得是始终把用户手里的数据是不可控的作为编程前提。不要假设用户一定按模板填、一定不填错格式、一定不用特殊字符。所有能防御的地方都提前防御所有能给用户反馈错误的地方都给出精确到行号的提示导入功能才算真正落地。如果后续你还要扩展这个模块有两个方向可以继续做一是导入模板的动态配置化让运营人员自己在后台配置模板字段而不是每次改字段都要改代码发版二是导入进度的实时反馈超过几千行的文件导入时可以基于WebSocket推送进度条提升大数据量场景的使用体验。这些都是在现有导入模块之上很好的演进方向值得有精力的同学继续深入。最后再分享一个小技巧写导入功能时建议从一开始就把所有常量比如批次大小100、错误行超限阈值10%、日期格式pattern、编码前缀CAT_都提到配置文件或Constants类里。我见过太多人把这些数值硬编码在业务代码里后续调参时全局搜索替换极易改漏。一个干净的配置让导入模块的可维护性上升不止一个档次这个习惯越早养成越好。
返回列表