ARTICLE DETAIL

资讯详情

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

.NET 7.0 文件导入接口实战:从同步到异步、校验与性能优化

.NET 7.0 文件导入接口实战:从同步到异步、校验与性能优化 1. 文件导入这事儿远没有想象中简单在做后台管理系统的时候文件导入几乎是绕不开的需求客户名单批量录入、商品SKU批量上架、订单数据迁移、老系统历史数据搬库……表面上看接收一个文件逐行往数据库里写而已但真正在 .NET 环境里把这个功能做到生产可用你会碰到一连串问题几万行数据导入会不会超时Excel 里的合并单元格和空行怎么处理用户传错模板时错误信息怎么说人话防止重复导入怎么做这些问题我几乎都在真实项目里踩过一遍。在 .NET Framework 时代我写过不少导入接口后来全面切到 .NET Core再到 .NET 7.0最大的感受是跨平台部署、依赖注入、管道式中间件、成熟的异步 I/O让文件导入这类本职工作就是读数据、写数据的功能有了干净得多的实现方式。特别在 .NET 7.0 里IFormFile的流式处理、Kestrel 的请求体限制配置、配合Minimal API或者传统 Controller 都能写得很顺手。这篇文章的核心内容就一件事用 .NET 7.0 Core 实现一个生产可用的文件导入接口。我会从接口设计思路讲起把文件接收、格式解析、行级校验、错误回执、性能优化这些环节逐一展开最后把我实际项目里踩过的坑和排查思路一并交代。适合正在用 ASP.NET Core 做管理后台、需要实现批量数据导入的开发者也适合想系统梳理一下导入流程设计逻辑的朋友参考。2. 动手编码前先定三个方向同步异步、接口契约、解析抽象很多人写导入接口容易上来就写 Controller 里的SaveChanges()但等你把代码写完了才发现当初随便定的决策会卡住后面的扩展。我建议动手前先花半小时想清楚三个方向这会直接影响整个模块的架构。2.1 同步导入还是异步导入先看数据量和用户预期同步导入最直观用户上传文件接口同步解析、校验、写入最后返回导入结果。适合数据量在几千行以内、单次处理时长几秒内能搞定的场景。比如后台管理员手动导入一份几百行的部门架构表同步完全没有问题体验反而好——上传完立刻看到结果。异步导入适合两种场景一是文件行数很多比如一次导入10万行商品数据全量校验加落库可能要一分多钟HTTP 请求根本扛不住这么久前端也会超时二是导入之后还需要触发一系列后续处理比如导入完成后通知下游系统、生成统计报表这些耗时操作不适合塞在请求链路里。我的经验是不要一开始就上异步方案异步会带来任务状态管理、结果查询接口、失败重试机制等一系列额外复杂度。如果当前业务明确是几千行级别先同步实现把接口契约设计好后期要改异步时把处理逻辑抽到一个独立的ImportService里Controller 这层只是从直接调用变成丢进任务队列改动量完全可控。文档里我说的同步异步之分本质是处理逻辑和运输层解耦这一点在项目刚开始时就要守住。2.2 接口契约设计URL、参数、返回值一次定清楚导入接口本质上是一个上传文件 返回处理结果的服务。我习惯这样设计契约接口地址POST /api/import/goods语义明确一个资源一种导入业务。入参multipart/form-data格式字段名固定为file如果有业务相关的附加参数比如导入到哪个店铺、用什么规则集用普通 form 字段传。出参统一的响应结构包含成功/失败标识、总行数、成功条数、失败条数、错误明细列表。响应结构用泛型包装全项目统一前端对接起来零成本public class ApiResultT { public bool Success { get; set; } public string Message { get; set; } public T Data { get; set; } public static ApiResultT Ok(T data, string message ok) new() { Success true, Message message, Data data }; public static ApiResultT Fail(string message) new() { Success false, Message message }; } public class ImportResult { public int TotalCount { get; set; } public int SuccessCount { get; set; } public int FailCount { get; set; } public ListImportErrorItem Errors { get; set; } new(); } public class ImportErrorItem { public int RowIndex { get; set; } // 第几行出错从1开始方便用户对照Excel public string Message { get; set; } // 错误描述要人话 }关于错误码我的建议是导入接口的返回值里不带代码级错误码比如IMPORT_FILE_EMPTY这种枚举因为前端只需要知道成功还是失败失败在哪些行、原因是什么。过度的错误码设计在这个场景里是自我感动徒增沟通成本。HTTP 状态码层面400 表示请求不合法文件缺失、格式不对、校验不通过200 表示请求处理完毕哪怕有部分行失败也在 200 里返回明细因为请求本身是成功的。2.3 解析层抽象每种文件格式是一个策略一个看起来只有 Excel 导入的需求上线之后大概率会冒出能不能支持 CSV能不能支持 xls 老格式。甚至有客户会问能不能支持我复制的表格直接粘贴。所以解析层必须抽象。我通常定义统一的IImportParser接口输出统一的数据中间结构——一个ListDictionarystring, string键是列名值是单元格文本。所有的校验逻辑只依赖这个中间结构完全不关心文件原始格式是 Excel 还是 CSVpublic interface IImportParser { bool CanHandle(string fileExtension); TaskListDictionarystring, string ParseAsync( Stream stream, CancellationToken cancellationToken); }CanHandle用来在运行时按扩展名路由到对应解析器ParseAsync只负责把文件内容变成行数据。列头和单元格约定比如日期格式、金额字段提前在模板和文档里和业务方对齐解析器内部做好容错。这个抽象的价值在后期才会体现出来当你要新增一种导入格式时写一个新解析器在依赖注入里注册一下业务校验代码一行都不用改。3. 核心实现拆解从文件落地到错误回执方向定好了代码实现层面其实是按接收 - 解析 - 校验 - 落库 - 回执这条流水线走。下面每个环节我都会给出实际可用的代码和需要注意的细节。3.1 文件接收与请求体限制设置Controller 里接收文件的写法很简单但生产环境真正要处理的是限制问题。没有限制的上传接口就是给服务器内存埋雷。Kestrel 默认的最大请求体是 30MB我记得在 .NET 7 里默认值没变但对导入场景30MB 已经偏大了一个 1MB 的 xlsx 解压后可能有几十万行。实际上绝大多数导入场景10MB 之内足够除非业务里有人非要传图片塞进 Excel这种事确实发生过真实项目里真有人把商品主图塞进 Excel 单元格里。我一般这样配置Controller 层用特性约束单接口全局配置文件兜底。[HttpPost(goods)] [RequestSizeLimit(20 * 1024 * 1024)] [RequestFormLimits(MultipartBodyLengthLimit 20 * 1024 * 1024)] public async TaskIActionResult ImportGoods( [FromForm] IFormFile file, [FromForm] long? tenantId, CancellationToken ct) { if (file null || file.Length 0) return BadRequest(ApiResultImportResult.Fail(请选择要上传的文件)); var extension Path.GetExtension(file.FileName).ToLowerInvariant(); if (!_allowedExtensions.Contains(extension)) return BadRequest(ApiResultImportResult.Fail($不支持的文件类型{extension})); // ... 后续处理 }注意CancellationToken要从请求管道传入到后续所有异步方法里。连接断开、用户取消上传时token 会触发取消避免服务端傻傻地继续跑完整个导入流程浪费 CPU 和内存。全局层面的配置在Program.cs里做builder.Services.ConfigureFormOptions(options { options.MultipartBodyLengthLimit 20 * 1024 * 1024; }); builder.WebHost.ConfigureKestrel(options { options.Limits.MaxRequestBodySize 20 * 1024 * 1024; });MultipartBodyLengthLimit管的是表单整体大小MaxRequestBodySize管的是请求体上限两者都要设否则其中一个默认值会成为隐藏的天花板。这里踩过一次坑只改了 Kestrel 的MaxRequestBodySize部署到 IIS 反向代理场景时又超限了后来排查发现是 IIS 的maxAllowedContentLength默认 30MB在起作用。生产环境如果前面挂着 Nginx 或 IIS反向代理层的请求体限制也要一起查三层限制任何一层没放开都会表现为上传一段时间后连接被重置。3.2 CSV 与 Excel 的解析实现解析这一步最能体现库选型的差异。CSV 解析不要用string.Split(,)CSV 规范里字段内可能出现逗号比如地址北京市,朝阳区还可能带引号转义。无脑 Split 碰到这类数据就是灾难。老老实实用CsvHelperNuGet 直接装dotnet add package CsvHelperpublic class CsvParser : IImportParser { public bool CanHandle(string fileExtension) fileExtension is .csv or .txt; public async TaskListDictionarystring, string ParseAsync( Stream stream, CancellationToken ct) { var result new ListDictionarystring, string(); using var reader new StreamReader(stream, detectEncodingFromByteOrderMarks: true); using var csv new CsvReader(reader, CultureInfo.InvariantCulture); csv.Read(); csv.ReadHeader(); var headers csv.HeaderRecord; while (await csv.ReadAsync()) { var row new Dictionarystring, string(StringComparer.OrdinalIgnoreCase); foreach (var header in headers) { row[header] csv.GetField(header)?.Trim() ?? string.Empty; } result.Add(row); } return result; } }这里detectEncodingFromByteOrderMarks: true很关键后文坑位篇会细说编码问题。Excel 解析.NET 生态里常用的是 NPOI 和 ClosedXML。NPOI 免费且支持 xls、xlsx 两种格式ClosedXML 只支持 xlsx 但 API 设计更现代、更易用。考虑到老系统经常还有 xls 文件存量我通常选 NPOI。NuGet 安装dotnet add package NPOIpublic class ExcelParser : IImportParser { public bool CanHandle(string fileExtension) fileExtension is .xlsx or .xls; public TaskListDictionarystring, string ParseAsync( Stream stream, CancellationToken ct) { var result new ListDictionarystring, string(); var workbook WorkbookFactory.Create(stream); var sheet workbook.GetSheetAt(0); // 只解析第一个工作表 // 找到表头行 var headerRow sheet.GetRow(0); if (headerRow null) throw new InvalidDataException(Excel 文件是空的没有表头); var headers new Liststring(); for (int c 0; c headerRow.LastCellNum; c) { var cell headerRow.GetCell(c); var header cell?.ToString()?.Trim() ?? string.Empty; headers.Add(header); } for (int r 1; r sheet.LastRowNum; r) { ct.ThrowIfCancellationRequested(); var excelRow sheet.GetRow(r); if (excelRow null) continue; // 整行为空跳过 var hasValue false; var row new Dictionarystring, string(StringComparer.OrdinalIgnoreCase); for (int c 0; c headers.Count; c) { var cell excelRow.GetCell(c); var value GetCellValue(cell); if (!string.IsNullOrEmpty(value)) hasValue true; row[headers[c]] value; } if (hasValue) result.Add(row); } return Task.FromResult(result); } private static string GetCellValue(ICell cell) { if (cell null) return string.Empty; switch (cell.CellType) { case CellType.Numeric: if (DateUtil.IsValidDate(cell.NumericCellValue)) return cell.DateCellValue?.ToString(yyyy-MM-dd HH:mm:ss) ?? string.Empty; return cell.NumericCellValue.ToString(CultureInfo.InvariantCulture); case CellType.String: return cell.StringCellValue.Trim(); case CellType.Formula: // 公式单元格尝试取缓存结果 return cell.CachedFormulaResultType switch { CellType.String cell.StringCellValue.Trim(), CellType.Numeric cell.NumericCellValue.ToString(CultureInfo.InvariantCulture), _ string.Empty }; case CellType.Boolean: return cell.BooleanCellValue.ToString(); default: return string.Empty; } } }注意几个关键处理日期单元格我最开始做导入的时候日期读取直接用ToString()结果拿到一串数字Excel 序列号后来才知道DateUtil.IsValidDateDateCellValue才能正确还原日期。Excel 的日期本质是自 1900 年起的序列号NPOI 在数字单元格上默认不会自动转日期必须显式判断。公式单元格如果用户在模板里写了个公式比如合计列CellType会是Formula直接取值拿不到结果要用CachedFormulaResultType拿 Excel 已经缓存的计算值。这又是一个实战中很常见但文档里不太提的细节。数字转字符串的格式陷阱手机号、身份证号这种长数字在 Excel 里经常被存成数值类型直接ToString()会变成科学计数法。这种问题的根治方法是让业务方在模板里把列格式设为文本但解析器这边也要做兜底——把数值统一转成无小数点的整数形式并尝试去掉末尾多余的.0。3.3 行级校验与错误收集策略解析出来的ListDictionarystring, string还是哑数据所有行都是字符串。导入校验要回答的是哪些行能入库、哪些行不能、为什么。校验逻辑我习惯按字段级校验 - 行级业务校验两层做。字段级校验处理必填、格式、长度、枚举值这类通用规则行级业务校验处理这条记录在数据库里是否重复、关联表里是否存存在这种带业务语义的规则。public class GoodsImportValidator { private readonly IGoodsRepository _goodsRepository; public GoodsImportValidator(IGoodsRepository goodsRepository) { _goodsRepository goodsRepository; } public ImportErrorItem? ValidateRow(Dictionarystring, string row, int rowIndex) { var sku row.GetValueOrDefault(SKU编号); var name row.GetValueOrDefault(商品名称); var price row.GetValueOrDefault(销售价格); // 字段级必填 if (string.IsNullOrWhiteSpace(sku)) return new ImportErrorItem { RowIndex rowIndex, Message SKU编号不能为空 }; if (string.IsNullOrWhiteSpace(name)) return new ImportErrorItem { RowIndex rowIndex, Message 商品名称不能为空 }; // 字段级格式 if (!decimal.TryParse(price, out var priceValue) || priceValue 0) return new ImportErrorItem { RowIndex rowIndex, Message 销售价格必须是大于0的数字 }; // 行级唯一性数据库里已存在则拒绝 if (_goodsRepository.ExistsBySku(sku)) return new ImportErrorItem { RowIndex rowIndex, Message $SKU {sku} 已存在请勿重复导入 }; return null; } }校验要遵循一票否决但错误只报第一个还是收集所有错误我的实践是一行内收集所有字段错误用分号拼接比如SKU编号不能为空销售价格必须是大于0的数字。这个体验比一次只报一个错好太多用户改一次文件可以少改好几轮。但要注意别报太细导致信息过载必填为空 格式非法同时报就有点噪音了所以同字段内的错误可以合并。另一个技巧把校验和数据组装分离。上面只说了校验数据组装把字符串行转成实体对象我也放在这层做而不是放到落库时这样校验未通过的记录不影响已通过记录的组装逻辑职责更干净。3.4 导入结果回执与错误文件生成导入完成后前端需要一个直观的东西告诉业务人员哪里错了。返回 JSON 错误明细是最基础的但几千行数据里有三百行错误时前端逐行渲染不现实业务人员也不可能对着网页去改数据。更实用的方案是生成一份错误标注版 Excel回传给前端下载错误行标红并在右侧追加一列错误原因。实现思路是用 NPOI 重新打开原始文件遍历错误行CellStyle设置红色背景在表头右侧写进错误信息然后以FileStreamResult返回。var memStream new MemoryStream(); workbook.Write(memStream, true); memStream.Position 0; return File(memStream, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, 导入错误标注.xlsx);这是我在实际项目里最受好评的一个细节。业务人员不需要理解第123行第4列格式错误这种技术描述他们看到的是哪一行标红了、为什么红、怎么改。文件导入这个功能到最后拼的往往不是技术而是业务体验错误反馈的形态直接决定了用户对这个功能的耐心。4. 大文件和高并发场景下的性能调优导入接口的性能瓶颈基本都在内存占用和数据库写入两处。几千行的文件怎么折腾都没事一旦行数上了五位数很多想当然的写法就会出问题。4.1 流式解析别把整个文件一次性读进内存很多初版实现喜欢先await file.CopyToAsync(ms)把整个文件灌进MemoryStream再交给解析器。小文件没问题50MB 的文件就有问题了——IIS 或 Kestrel 进程内存会瞬间飙升频繁 GC严重点直接 OOM。正确姿势是拿到IFormFile.OpenReadStream()直接传给解析器让解析器流式读取。NPOI 的WorkbookFactory.Create(stream)传一个非 seekable 流一般直接传file.OpenReadStream()在我实测中是可以的因为它内部会自己缓冲内存占用远小于整个文件副本。CSV 更是天然支持流式CsvReader本身读的就是流。另一个内存大户是中间数据解析出的ListDictionarystring, string在六万行、三四十列的情况下大概能吃掉上百 MB 内存而且全是引用类型GC 压力不小。我在高数据量项目里的做法是解析出来的数据不完全滞留内存——CSV 边读边校验边写库用类似分段管道的方式把内存峰值压住。但这样做代码复杂度会上升如果你的场景是常规的管理后台导入几千到一两万行先把整表解析到内存里完全够用不要过度设计。折中方案是加一个可配置的行数阈值超过阈值走管道式分段处理通常业务后台根本不会触到这个阈值。4.2 批量写入与事务边界逐行SaveChanges()是导入性能的头号杀手。一万行数据逐行写入保守估计几十秒到几分钟磁盘 IO 和 SQL 往返都吃不消。EF Core 的标准做法是AddRange 一次性SaveChangesvar goodsList new ListGoods(); foreach (var row in validRows) goodsList.Add(MapToEntity(row)); await _dbContext.Goods.AddRangeAsync(goodsList, ct); await _dbContext.SaveChangesAsync(ct);几万行的AddRange在 EF Core 里会生成一条巨大的INSERT语句或者多条批量插入语句实测效率能接受。但注意.SaveChanges()的默认行为是开启隐式事务的一旦中途某一行违反约束抛异常整个批次全部回滚而且你已经全有或全无地把所有行绑在了一个事务里。我这里的做法分两种情况如果导入任务本身要求要么全部成功要么全部失败那隐式事务正中下怀。但更多业务场景是能成功的尽量成功失败的行收集起来反馈给用户这种情况下我会先做完全部校验和预检查把肯定会报错的行筛掉剩下能落库的行再走批量写入——因为前面已经做了一遍完整校验和重复性检查落库阶段几乎不会触发异常这个事务就只是兜底而不是常态。真遇到并发抢插同一主键这种极端情况落入 catch 再输出一个整体失败也比每行独立开事务快一个数量级。4.3 数据量真的大到离谱再用异步任务队列如果单次导入能到几十万行起步同步方案再怎么优化也扛不住 HTTP 连接的等待。这时候把导入任务丢进队列ChannelT、Hangfire、RabbitMQ 均可接口立刻返回任务已接收ID 为 xxx前端轮询/api/import/{taskId}/status查询处理进度。用ChannelT做进程内队列是最轻量的方案var channel services.GetRequiredServiceChannelImportTask(); await channel.Writer.WriteAsync(new ImportTask { FilePath savedPath, TaskId taskId }, ct);后台消费者从Reader.ReadAllAsync()里拿任务执行导入进度和结果写进数据库或内存字典。需要注意进程内队列在应用重启时会丢任务生产环境建议落库保存任务状态队列只做触发器。我用这个方案处理过单次 25 万行的库存导入异步任务 分批写入每批次 5000 行独立事务整体耗时约 2 分钟用户体验完全不一样——提交后页面显示导入任务进行中完成率 43%而不是干瞪眼看着菊花转圈。5. 我实际项目里踩过的坑都是文档不会告诉你的这一段的价值来自真实项目里的血泪教训我按编码、Excel 结构、幂等三条线来说每条都能单独写一篇排查文章。5.1 CSV 乱码与 Excel 的长数字丢失编码问题两个典型现场场景一用户用 Excel 另存为 CSV然后用记事本打开正常传到系统里却变成乱码。根因是 Excel 生成的 CSV 走的是 GB2312/GBK 编码中文环境而程序默认按 UTF-8 解码。StreamReader的detectEncodingFromByteOrderMarks: true只能识别带 BOM 的文件Excel 导出的 CSV 不带 BOM照样识别失败。我的解法是解析前做编码探测先取前 4 个字节判断 UTF-8 BOMEF BB BF没有 BOM 时默认从 GB18030 解码并把这个规则写进接口文档里告知用户请用 UTF-8 编码另存为 CSV。CsvHelper 里指定编码using var reader new StreamReader(stream, Encoding.GetEncoding(GB18030), detectEncodingFromByteOrderMarks: true);场景二用户 Excel 里的一串 18 位身份证号或订单号导入后末尾几位变成 000。这是 Excel 数值精度的限制数字超过 15 位就会丢精度解析器怎么努力都救不回来。唯一可靠的解法是在模板源头控制在 Excel 模板里把该列格式预置为文本同时写清填写规范SKU 编号列必须是文本格式否则后 4 位会被 Excel 转成 0。这类问题属于工具本性要在需求阶段和业务方对齐不然你会面临为什么系统把我数据搞坏了的投诉而问题其实出在 Excel 端。5.2 Excel 模板里的隐形结构合并单元格、空行、公式残留合并单元格是解析时最容易串行的结构。比如表头两行合并了一个大单元格商品信息NPOI 得到的只有第一个单元格有值其余合并区域单元格值为 null。很多初级实现解析表头时直接cell.ToString()拿到一堆空字符串列名对不上校验全乱。处理办法是解析表头时对空值做向左继承for (int c 0; c headerRow.LastCellNum; c) { var header headerRow.GetCell(c)?.ToString()?.Trim() ?? string.Empty; if (string.IsNullOrEmpty(header) c 0) headers.Add(headers[c - 1]); // 合并单元格场景继承左侧列名 else headers.Add(header); }数据区内的空白行用户模板里预留了 50 行只填了 20 行剩下的空行里其实藏着样式对象GetRow(r)不为 null。前面代码里的hasValue逻辑就是为这个准备的判断整行所有字段都是空字符串才跳过不能光看行对象是否为 null。公式残留我遇到过一次导入价格时金额全部变成 0 的线上问题。排查后定位到是模板里价格列部分单元格带了平时隐藏的公式Excel 里有一个显示值但底层是公式用户手动改动单元格后公式没重算缓存结果是 0。前面解析代码里处理CellType.Formula时用CachedFormulaResultType读缓存值就来自这次教训。更稳健的做法是导入前用 NPOI 强制重算整个工作簿workbook.ForceFormulaRecalculation true;但这只对 Excel 打开时生效纯编程读取时缓存值可能不可靠。所以模板规范里我直接写了一条硬性要求所有列必须为静态值禁止使用公式并定期抽查。5.3 重复导入与接口幂等一个好习惯省一堆事用户手抖点两次提交或者导入失败后重试结果库里多了一倍数据。这就是幂等缺失的后果。最简单的方案是业务键唯一约束导入前用业务自然键SKU、身份证号、合同号查重库里已有则报错。但那只是面上解决了大多数系统里同期批量导入的数据里就自带重复这种场景还需要在导入逻辑内维护本次文件内已见集合var seenSkus new HashSetstring(); foreach (var row in rows) { var sku row.GetValueOrDefault(SKU编号); if (!seenSkus.Add(sku)) errors.Add(new ImportErrorItem { RowIndex rowIndex, Message $SKU {sku} 在本文件内重复 }); }另一个更彻底的做法是给导入请求生成一个幂等键前端提交时生成 GUID 放在 form 字段里服务端记录该 GUID 已处理过则直接返回上次结果。这个机制在前端超时重试场景下非常有用能保证即使服务端第一次已经处理完了用户重试也不会导致数据翻倍。实现成本不高就是一张表或者 Redis 里的一个 key但上线后能帮你挡掉大量我以为没提交成功所以又提交了一次的脏数据。5.4 事务与跨库写入的一致性边界最后一个坑来自一个库存导入项目导入逻辑里同时写主库存表和历史明细表明细表每天要分区归档跨了两个库甚至可能跨数据库实例MySQL SQL Server。EF Core 的隐式事务在这里就失灵了——它不是分布式事务跨库写一半挂了库存更新了但明细没写进去对不上账。这类场景我把一致性边界拆成两段主表写入用本地事务明细表写失败时记录日志并让导入任务进入人工复核状态而不是强行回滚前一段。导入这种海量操作一旦强依赖分布式事务性能会很难看可用性也下降。你要在需求评审阶段就明确一点导入数据最终一致性可以接受延迟和人工对账但绝不能接受静默丢失。把这一点和业务方达成共识写进接口文档远比你在代码里硬撑分布式事务靠谱得多。我在实际项目里的体会是文件导入接口的难点从来不在上传本身而在上传之后那一整套解析、校验、反馈、补偿的工程化设计。把上面这些环节逐个想清楚、用代码验证过、和业务方对齐过你写的导入接口才能从能跑变成好用。最后分享一个容易忽略的小技巧给导入模块加一个模板下载接口和导入接口配套发布。用户永远不记得列名规范但你把模板和填写说明一起给出去导入失败率能直接下降一半这是我在多个项目里反复验证过的经验。
返回列表