
接手这个任务前我已经有两三个月没碰MS这个项目的源码了。MS是我们内部一直在用的一套数据管理平台平时主要是手工录入和单条维护这次的需求很明确给它加一个新接口支持自动导入。说白了就是用户不想再一条条录了把Excel丢给系统系统自己解析、自己校验、自己落库最后告诉我们哪些行成功、哪些行失败。改老系统源码比新写一个接口麻烦得多。新写接口你只管接口本身改成老系统要考虑的点就多了新代码不能破坏老逻辑、不能影响现有表结构、不能把事务搞乱还得顺手处理历史遗留的代码风格问题。这篇就当一次完整的手术记录聊聊我怎么梳理老代码、怎么定新接口的边界、怎么写进去、怎么测以及中途踩过的几个坑。适合接手过老项目、要在现有系统上做增量开发的兄弟参考也适合第一次在公司内部系统里加接口的新手看。1. 需求拆解先搞懂“自动导入”到底要什么1.1 从一句话需求到接口边界“加个自动导入接口”这句话放在需求文档里就一行但是落到开发上至少要拆成五件事接收文件、解析内容、逐行校验、数据落库、结果回执。缺了任何一环这个接口都不算完整。接收文件用户得能把Excel或者CSV传上来所以接口必须是文件上传的方式而不是JSON传字符串。解析内容导入模板有固定的表头每一列对应MS业务表的一个字段需要把文件里的数据读成Java对象或Map。逐行校验不是所有行都能直接进库比如必填字段为空、日期格式不对、库存编码不存在这些都要在这一步拦下来。数据落库校验通过的行要写入MS的核心业务表而且要保证事务安全不能写到一半崩了留下半截数据。结果回执用户需要知道哪些行失败了、失败原因是什么否则就等于没有反馈。把这一句话需求拆成这五块之后整个接口的边界就清楚了。我特别想强调一点自动导入不是“把文件读出来然后挨个insert”这么简单真正的工作量大多在解析和校验上落库反而是最顺手的一步。很多新手写导入接口只关心insert结果上线后用户反馈“导入完发现全是脏数据”就是因为校验这层没做扎实。需求方一开始还说“最好能自动识别表头”被我否掉了。自动识别听着高级实际上会让解析逻辑变得不可控。模板定了就是定了模板外的字段不认。这就是接口边界的问题——你把边界划得越清楚后面测试和排错就越省心不能什么都想在代码里做判断。1.2 设计取舍为什么第一版选同步而不是异步自动导入是有两种做法的一种是接口同步处理文件传上来接口内部直接解析、校验、落库最后把完整结果返回给前端另一种是异步任务接口只负责接收文件并生成一个批次号后台慢慢处理前端轮询状态。我第一版选了同步。理由是MS当前业务表的数据量并不夸张单次导入撑死几千行。这个量级用同步处理用户等待时间也就两三秒完全在可接受范围内。而且同步接口有个非常实在的好处你可以在一次请求里把成功和失败的行全部算清楚直接返回给前端前端不需要额外写轮询逻辑。那什么时候该切异步我的判断标准很简单——单次导入行数超过一万或者解析和校验逻辑很重再或者文件本身就特别大。这几个场景下同步会让HTTP连接挂太久中间网络一抖连接断了前端拿不到结果用户只会觉得“系统卡死了”。异步模式虽然引入了状态管理但用户体验是稳定的不会出现超时这种尴尬事。还有一个细节值得单独说事务粒度。同步接口里如果整批数据包在一个大事务里几千行倒还好万一中间某一行数据库层面报错整批回滚用户辛辛苦苦准备的导入文件就全废了。所以我把事务粒度控制在“逐行事务”和“批量事务”之间具体做法后面章节会展开。这里先说结论——导入类接口的事务宁可细分不要一条大事务从头包到尾。2. 源码结构梳理改代码前先看懂老代码2.1 读目录结构确认模块边界改老系统源码第一步一定是读代码不是写代码。我拿到MS源码后先花了大半个小时走读目录把整个项目的分层摸清楚。MS不是那种很标准的Spring Boot工程它有些历史沉淀但整体上还是controller、service、dao三段式。controller层是REST接口入口service层是业务逻辑dao层走MyBatis的mapper。这个分层虽然老但结构清楚新接口插进去不会破坏原有结构。我建议动手改之前至少要在本地把项目跑起来然后看三样东西项目的启动类和配置文件确认用的什么数据库、什么端口、有没有注册中心。controller包下已有哪些接口命名风格是什么样的方便新接口保持一致。service层哪些类已经在处理数据导入相关逻辑这是我们这次改动的核心参照物。读代码的时候我一直在想一个问题MS现有的数据录入是手工的那它一定已经有了一套“把表单数据变成业务对象再落库”的逻辑这套逻辑能不能直接复用在自动导入上答案是可以而且必须复用。你不复用老逻辑自己另写一套很容易出现“老接口录入的数据和新接口导入的数据规则不一致”的情况这是做增量开发最容易犯的错。2.2 找到现有导入逻辑复用老代码而不是重写MS里有个DataEntryService负责手工录入物料数据的业务处理。里面有一个saveOrUpdate方法接收一个物料数据的DTO做了一些默认值填充、格式校验然后调用mapper写库。这个saveOrUpdate就是我要复用的核心逻辑。自动导入接口在解析完Excel的每一行之后最终也要把这一行数据封装成同样的DTO然后掉进同一个saveOrUpdate方法里处理。这样设计的一个好处是老的录入规则、校验逻辑在自动导入场景下天然一致不用双头维护。另一个值得复用的是工具类。MS旧代码里已经有一个ExcelUtil原本是用来做模板下载的。我看了一圈发现它只处理了导出没有解析逻辑于是我在新接口里引入了Hutool的ExcelReader做解析。这里说一下选型原因MS本身是老项目我不太想引入POI那一大坨依赖自己手写Workbook遍历Hutool的ExcelReader底层封装了POI但API要友好得多直接readAll就能把整个sheet读成ListList 对导入场景够用了。复制一个工具类小方法再调一两个老service的方法这算是“最小侵入”的改法。老代码的模块边界没动新增的类全部放在一个新的import包下面跟老的controller、service在代码结构上是平行的。这样万一新接口出问题回滚也简单——只回滚新增的文件不影响老功能。2.3 梳理整条数据流把每一层要动的点列出来源码走读的最后一步是把整条数据流画在脑子里。这一步特别重要因为你要知道自己每一层要改什么、不动什么。MS这条导入链路的数据流是这样的前端把Excel文件上传到Controller。Controller接收MultipartFile把文件交给ImportService。ImportService调用ExcelReader解析出ListList 。每一行数据先做格式校验、必填校验然后组装成物料DTO。调用老的DataEntryService.saveOrUpdate方法落库。收集所有行的成功/失败信息封装成统一的ImportResult返回给前端。我看了这个链路之后决定新代码只动前三层第5步直接调老service。为什么第4步的校验不放进老的saveOrUpdate里因为老方法是给手工录入用的手工录入时前端已经做了必填校验后端校验相对宽松。自动导入面对的是文件数据不可控的东西太多了必须在service层把校验做严格把问题行拦在前面再进入老逻辑。这个数据流梳理还有一个作用方便排错。等接口上线之后用户报“导入失败了”你能沿着这个链路一层层排查——是Controller没收到文件是解析乱码是校验拦截还是落库报错每一层都有明确的日志输出点。顺带吐槽一下MS老项目里日志打得特别稀疏我在新增代码里把关键节点都加了日志。改老系统日志宁可多打也不能少打不然线上出了问题根本没法定位。这个习惯后来帮了我大忙后面排查问题章节会提到。3. 新接口实现从控制器到数据层的完整落地3.1 接口定义与入参设计接口我定义为POST /api/ms/v1/data/import请求格式是multipart/form-data。之所以不用JSON是因为要传文件文件只能用二进制流上传。入参只有两个参数名类型必填说明fileMultipartFile是Excel模板文件支持.xls/.xlsx/.csvbatchNoString否批次号不带则服务端自动生成batchNo是干嘛用的做幂等控制。自动导入最怕用户手一抖点两次提交文件被重复导入业务表里多出一堆重复数据。前端每次打开导入弹窗时向后端拉一个批次号提交时带上。服务端在Redis里记一下batchNo是否已处理已处理就直接返回上一次的结果不再重复解析。这个设计看着简单但解决一个很实际的痛点。我见过太多系统没有幂等控制导入接口被重复调用后数据直接翻倍最后只能靠人去数据库里手工清。尤其是导入这种操作用户因为网络原因重试是常态所以幂等必须做。接口的响应体是一个统一的结果结构{ code: 0, message: success, data: { batchNo: 20240520153000123, total: 1280, success: 1256, fail: 24, failList: [ { row: 13, reason: 物料编码不能为空 } ] } }我把failList直接返回给前端前端可以在页面上展示一个失败明细表告诉用户第几行失败、为什么失败。这个反馈对用户非常重要省掉他们自己猜问题的时间。3.2 Controller和Service层的核心代码Controller层没什么花头就是一个文件接收和结果返回的壳子PostMapping(/api/ms/v1/data/import) public ResultImportResult importData( RequestParam(file) MultipartFile file, RequestParam(value batchNo, required false) String batchNo) { return importService.importFromExcel(file, batchNo); }真正的工作量全在ImportService里。我贴一下核心处理逻辑的思路不是完整代码但结构就是这样的Service public class ImportServiceImpl implements ImportService { Autowired private DataEntryService dataEntryService; Resource private StringRedisTemplate stringRedisTemplate; Override public ResultImportResult importFromExcel(MultipartFile file, String batchNo) { // 1. 幂等检查批量号已处理则直接返回历史结果 String resultKey ms:import:result: batchNo; if (stringRedisTemplate.hasKey(resultKey)) { return Result.success(JSON.parseObject( stringRedisTemplate.opsForValue().get(resultKey), ImportResult.class)); } // 2. 解析Excel ListListString rows ExcelReader.readAll(file); if (rows null || rows.size() 2) { return Result.fail(文件内容为空或缺少表头); } // 3. 表头校验模板第一行必须匹配 if (!validateHeader(rows.get(0))) { return Result.fail(表头不符合模板要求); } // 4. 逐行处理收集结果 ImportResult result new ImportResult(); result.setBatchNo(batchNo); result.setTotal(rows.size() - 1); int successCount 0; ListFailItem failList new ArrayList(); for (int i 1; i rows.size(); i) { ListString row rows.get(i); try { MaterialDTO dto buildDTO(row); dataEntryService.saveOrUpdate(dto); successCount; } catch (Exception e) { failList.add(new FailItem(i 1, e.getMessage())); } } result.setSuccess(successCount); result.setFail(failList.size()); result.setFailList(failList); // 5. 结果写入Redis同时设置过期时间 stringRedisTemplate.opsForValue().set(resultKey, JSON.toJSONString(result), Duration.ofHours(24)); return Result.success(result); } }这里有一个细节很多人会忽略每一行数据调saveOrUpdate时我用了try-catch把单行异常包裹住了。这样一个文件里某一行数据有问题只记录失败原因不影响其他行正常导入。很多导入接口写得太“脆”一行报错整个文件中断前功尽弃。导入工具就该具备“脏行隔离”能力。3.3 事务粒度与批量插入的平衡上面代码里每一行saveOrUpdate涉及到的数据库操作默认是单独事务。单独事务的好处是错误隔离但坏处是性能差。几千行数据逐条插入每条都走完整的事务提交速度会明显变慢。我在MS实际导入场景里测过两千行数据逐条插入耗时大概在6秒左右其实还能接受。但如果未来数据量涨到一万行这个耗时就会变成你不想等的数字。所以我在代码里做了一个折中把saveOrUpdate里的写库操作改为批量提交。具体做法是把解析出来的DTO攒到一个ArrayList里每攒够200行调mapper的batchInsert方法一次性插入。批量插入的SQL用MyBatis的foreach标签拼接一次insert带200条value。这个改动能把插入耗时降到原来的五分之一左右。但批量插入有个副作用如果第150条数据因为唯一键冲突报错这一批200条全部失败你很难定位到底是哪一条出的问题。所以我对进入批量插入前的数据先做了一次基础校验把能预判的问题必填为空、格式错误在校验阶段就拦截掉。剩下能过基础校验的数据唯一键冲突的概率就比较低了。这里我想分享一个实际经验批量插入的批次大小不是越大越好。我测过500条一批性能提升就不明显了反而内存里积压的DTO对象更多。200条一批是一个比较稳的中间值既不会频繁网络往返也不会因为一批太大导致内存暴涨。不同项目可能不一样但你可以先按这个值起步再根据实际压测结果调整。事务和批量结合之后整个导入结果的一致性模型是这样的每一个批次200行是一个小事务一个批次成功则这200行全进库一个批次失败则该批次全部回滚并且把这200行全部计入失败清单。这样做既能保证单批内一致又能让失败的批次不影响其他批次继续导入。测试下来效果不错我拿了一份1280行的真实数据做验证成功1256行失败24行总耗时3.2秒用户侧体感就是“点了导入几秒后看到了结果”。这个表现完全满足需求。3.4 模板下载接口一起补上有人会问你给自动导入做了模板校验用户上哪儿拿模板我记得MS原本有一个模板下载的接口但只支持老的手工录入模板表头字段跟自动导入模板不一样。所以这次我给自动导入也补了一个模板下载GET /api/ms/v1/data/import/template这个接口直接返回一个已经填写好表头的空Excel文件前端在导入弹窗里放一个“下载模板”按钮引导用户使用标准模板。模板下载和自动导入是配套的没有模板下载的导入接口是耍流氓。模板内容我定义为这些字段物料编码、物料名称、规格型号、库存单位、安全库存、存放位置、备注。每一个字段都在表头里加了示例比如物料编码下面写“C001”日期格式统一为“2024-05-20”。用户照着示例填不容易出错。4. 测试、排错与上线记录4.1 先当用户把自己测一遍接口写完之后我没有直接让测试同学上而是先把自己当成第一个用户把常见的异常场景过了一遍。自测清单是这样的空文件上传应该返回“文件内容为空”的提示。模板不对表头缺少“物料编码”字段要返回明确的表头错误提示。必填字段为空某一行物料编码没填那一行应该出现在失败明细里。日期格式错误如果模板里有日期列格式不对要被校验拦下来。正常数据导入全部成功返回success等于total。混合数据导入一部分正常一部分有问题验证脏行隔离是否生效。重复提交同一个batchNo提交两次第二次直接返回第一次的结果。这七个场景全部测完后我又专门测了一个编码边界用户上传的Excel里的列顺序跟模板不一致会怎样。比如说模板是A、B、C三列用户上传的是A、C、B。我的解析逻辑是按列索引取值不是按表头名取值所以列序不一致就会错位。为了根治这个坑我把解析逻辑改成先读表头行建立“字段名到列索引”的映射然后按字段名取值不再假设列的顺序。这样用户即使把列拖乱了解析结果也不受影响。这个改动很小但让接口的容错性好了一个档次。CSV文件还有一个单独的坑字符集。Windows下用Excel另存的CSV默认是GBK编码而Java读取时默认用UTF-8读出来全是乱码。我在读取CSV时先做了一次BOM检测和编码判断如果是GBK就先转成UTF-8再解析。这个坑不踩过一次你根本想不到。4.2 实测中遇到的典型问题和排查思路上线前测试阶段我记录了几个印象最深的问题整理成一张速查表现象直接原因排查过程与解法导入后物料名称全是乱码Excel是CSV格式GBK编码被按UTF-8读取先看文件字节识别BOM和编码统一转UTF-8再解析部分行静默丢失无成功也无失败记录空行被当成有数据解析进入校验后异常被吞掉解析时跳过全空白行并在循环里打日志记录跳过的行号重复点击导入数据翻倍用户用同一个文件提交两次batchNo是服务端每请求重新生成的改造前端弹窗打开时拉取批次号并复用后端加Redis幂等校验某批200行全部回滚且失败明细太长该批内有唯一键冲突批量插入一个异常回滚整批批量前增加唯一键预检把重复的编码单独挑出来提前拦截导入接口超时网关报504一次上传了5MB的Excel解析全部行很耗时限制文件大小最大2MB并对超过5000行的文件提示拆分导入第一个乱码问题我印象最深。当时用户用Excel另存为CSV上传导入完成后打开业务数据一看汉字全变成了“锟斤拷”那种经典乱码。排查顺序是这样的先用文本编辑器打开原始文件确认编码是GBK再看代码里读取CSV时没有指定字符集默认用了UTF-8。修复就是在读取时用InputStreamReader包裹文件流显式指定charset。第二个问题也很有代表性。解析出来的List里混着空行空行的字段全是null进入校验后部分校验逻辑直接抛异常被catch捕获后记录为失败行。原本用户只有1200条数据最终统计却是1201行多出来那一条就是文件末尾的空行。修复就是读Excel时过滤全空白行一行逻辑就解决了。4.3 上线前最后检查一遍代码测试通过后我整理了一份上线检查清单这些点都是我过去踩过坑总结出来的接口鉴权新接口必须走MS现有的登录鉴权不能在接口上放个匿名访问的口子。加接口时顺手检查了token校验的注解是不是加上了。文件大小限制在Spring的配置里给MultipartFile设置max-file-size2MB防止超大文件直接把内存打爆。这个限制值可以调但必须有。日志输出关键步骤全部有日志包括“开始解析文件”“校验拦截XX行”“第Y批写入成功”“最终成功X条失败Z条”。线上排查问题就靠这些日志。幂等数据的过期时间Redis里的导入结果设置了24小时过期避免长期占内存。超过24小时用户重试就会重新解析一次这个时间窗口是跟业务确认过的。权限控制导入操作是写操作不是所有角色都可以调用。我在接口里加了权限校验只有“数据管理员”这个角色能导入数据。普通用户只能看结果。回滚预案新增的所有类都在一个独立的import包下面不修改老类。万一上线后有问题直接回滚新增文件即可对老功能零影响。这几个点每一个都对应一个线上事故。早年间我没给新接口加鉴权结果被同事用curl直接调了他们权限外的导入接口好在只是测试数据没有造成破坏。后面凡是新增接口鉴权是第一优先级绝对不裸奔。踩过几次坑之后的一点个人体会这次给MS加自动导入接口前后大概用了三天时间真正写代码不到一天剩下两天全在走读源码和测试排错。老项目的增量开发就是这样写新功能只占一小部分更多的是理解现有系统和保护现有功能。我最想分享的一个经验是老系统的代码能复用的坚决复用。我这次没有动老的DataEntryService一行代码只是从新接口调它就让老规则在新场景下天然生效也把改动风险控制在了最小范围。很多人接手老项目第一反应是“这代码写得太烂了我要重写”我劝你忍住。重写意味着你要重新验证所有历史行为风险成倍增加而老代码再怎么难看它至少是在线上跑着的、被时间验证过的。第二个经验是关于导入接口的心智模型。导入接口本质上是一个“批量处理任务”你要牢牢记住三件事第一单行失败不能拖垮整批第二重复提交必须被拦下第三用户必须得到失败原因。这三点做到位导入接口就算成功了一半。最后再分享一个小技巧如果你要给老系统加接口先在项目里搜一下“import”关键词看看已有的导入逻辑是怎么处理的。大部分老系统都有过类似的导入导出功能哪怕逻辑很简陋也能给你提供字段映射、校验规则、错误提示的参考。站在老代码的肩膀上改比从零想一套方案省太多事了。