☰
ASP.NET Core Excel 导入导出服务设计
2026/10/30 1:14:34 网站建设 项目流程

简介:面向 ASP.NET Core WebAPI 开发者的 Excel 处理示例工程,聚焦服务端读取与导出 Excel 文件的常见需求,适合已具备 C# 与 WebAPI 基础、希望减少 Office Interop 依赖的中级开发者参考。压缩包共 208 个文件、约 21.89MB,包含 149 个 dll 依赖、12 个 cs 源码、11 个 json 配置及 sln/csproj 项目文件,另有少量 pdb、so、dylib、exe 等运行时与跨平台组件,整体保留了可打开的解决方案结构与构建产物。内容围绕 EPPlus 与 ExcelDataReader 两条技术路线展开,可帮助读者理解 OpenXML 读写、流式读取大文件、控制器返回文件响应等实现思路,并据此搭建自己的导入导出模块。已有 400 人学习下载,适合用作项目脚手架、技术选型对照或排错参考。

1. 一个上传下载接口,为什么值得单独抽成项目

运营在后台点「导入商品」,几秒后页面只回一句「第 37 行价格不是数字」,她改完再传一次就过了。这种体验背后通常没有多复杂的框架,而是一个把读表和写表拆干净的小服务。excel-handler 就是这样一个基于 ASP.NET Core Web API 的项目:ExcelDataReader 负责把 .xls / .xlsx 拆成一行行字符串,EPPlus 负责把内存里的集合写成带样式、能冻结首行的 xlsx,控制器只做收文件、发文件两件事。它适合谁?适合那些后台管理系统里到处散落着File.ReadAllBytes、Response.WriteFile、手写 CSV 拼接的人,把 Excel 这条链路收拢到一处,读有读的校验,写有写的样式,出问题只看一个地方。

2. 项目骨架与技术选型:EPPlus 只负责写、ExcelDataReader 只负责读

选型上没必要纠结「一个库全包」。EPPlus 的强项是生成 xlsx,样式、图表、数据透视都能做,但它读不了老的 .xls(BIFF8);ExcelDataReader 反过来,只读不写,胜在轻、快、.xls/.xlsx 通吃。两个库各管一头,边界清楚,谁出问题查谁。另一种常见做法是上 NPOI,读写一把梭,代价是 API 啰嗦、样式代码写得像操作 DOM,小项目里反而更费劲。

2.1 目录结构与职责边界

我一般把 Excel 相关逻辑放在独立的 Core 类库里,API 层只留控制器。原因很实在:解析逻辑要做单元测试,如果全塞在 Controller 里,跑一个用例得起整个 Web 主机,慢且难调。

excel-handler/ ├─ ExcelHandler.Api/ │ ├─ Controllers/ExcelController.cs # 只收文件、发文件、转错误码 │ ├─ Program.cs # DI、上传大小、编码注册 │ └─ appsettings.json ├─ ExcelHandler.Core/ │ ├─ Abstractions/IExcelReader.cs # 读的契约 │ ├─ Abstractions/IExcelWriter.cs # 写的契约 │ ├─ Import/ExcelImportService.cs # 逐行校验、收集错误行 │ ├─ Import/ImportResult.cs # { total, success, errors[] } │ ├─ Export/ProductExportService.cs # 组装列、样式、数字格式 │ ├─ Mapping/ExcelColumnAttribute.cs # 表头名 -> 属性的映射标记 │ └─ Models/ProductImportDto.cs └─ ExcelHandler.Tests/ └─ ImportTests.cs # 用样例文件跑脏数据用例

这样分层后有一个直接好处:IExcelReader换成假的实现,就能在不起 Web 主机的情况下测「第 37 行价格非法」这类分支;反过来,导出的样式对不对,单独跑一个生成文件、丢进 Excel 里肉眼看,跟接口无关。

2.2 依赖清单与版本策略

包不多,但有两个地方必须提前想清楚:EPPlus 的授权,以及 .xls 的编码支持。

<ItemGroup> <!-- 读:.xls / .xlsx 都走它,注意它不支持写 --> <PackageReference Include="ExcelDataReader" /> <PackageReference Include="ExcelDataReader.DataSet" /> <!-- 写:只负责 xlsx,.xls 不在它的能力范围内 --> <PackageReference Include="EPPlus" /> <!-- .xls 的代码页支持,缺了读老文件直接抛异常 --> <PackageReference Include="System.Text.Encoding.CodePages" /> </ItemGroup>

版本号不写在这里,统一丢进Directory.Packages.props锁死,示例里省略。理由是一次线上事故:本地dotnet restore拉到了新的主版本,EPPlus 从 4.x 跨到 5.x 之后,new ExcelPackage()直接抛LicenseException,本地能跑、CI 挂掉,查了半天才发现是版本漂移。从那次以后,凡是用到带授权条款的库,我都把版本钉住再写代码。

几个包的职责一句话说清:

包负责不负责
ExcelDataReader把工作表读成行、列、原始值写文件、样式
EPPlus生成 xlsx、设置样式与格式读 .xls
System.Text.CodePages提供 936 等代码页编码业务逻辑

授权这块要提醒一句:EPPlus 从 5.x 起改为 Polyform 非商业授权,商用项目要么买授权,要么留在旧版 LGPL 分支上,要么换 NPOI。这个决策不该由写代码的人拍板,但必须在上线前摆到台面上,别等发版前一天才发现。

2.3 启动与请求管道配置

三件事必须在Program.cs里做完:注册编码提供程序、放开上传大小、注册读写服务。

using System.Text; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core; var builder = WebApplication.CreateBuilder(args); // .xls 是 BIFF 格式,里面存的是代码页编号;不注册这一行,读中文老文件会抛 // NotSupportedException: No data is available for encoding 936 Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); builder.Services.AddControllers(); builder.Services.AddScoped<IExcelReader, ExcelDataReaderImpl>(); // 每次请求独立实例,内部持有流 builder.Services.AddScoped<IExcelWriter, EpplusExcelWriter>(); // 这两处都要放开,只改一处照样 413 builder.Services.Configure<FormOptions>(o => o.MultipartBodyLengthLimit = 50L * 1024 * 1024); builder.Services.Configure<KestrelServerOptions>(o => o.Limits.MaxRequestBodySize = 50L * 1024 * 1024); var app = builder.Build(); app.MapControllers(); app.Run();

CodePagesEncodingProvider那行必须在任何读文件代码之前执行,放构造函数里、放方法里都会漏,最稳的是Program.cs顶部。MultipartBodyLengthLimit管的是表单解析层,MaxRequestBodySize管的是 Kestrel 接收层,两层各有一道闸,只改一层就会看到「本地好好的、部署上去 413」这种玄学现象。50MB 是个经验值:一万行的 xlsx 通常几兆,留这么大余量是为了应对带图片的表格,真到几百兆就该走分片或异步任务了,让用户盯着浏览器转圈不是个好设计。

3. 读 Excel:ExcelDataReader 从流到实体的完整链路

读这条链路可以拆成三步:打开工作簿拿到 reader,把每个单元格转成字符串,再按列映射成 DTO 并逐行校验。三步各自有坑,混在一起写就会出现「不知道是文件坏了还是数据不对」。

3.1 打开工作簿与遍历工作表

上传上来的IFormFile直接OpenReadStream()交给 reader,不落盘。落盘的好处是能留档排查,坏处是磁盘和清理逻辑都得管,小项目里直接读流更省事。

public sealed class ExcelDataReaderImpl : IExcelReader { public SheetData Read(Stream stream) { // FallbackEncoding 只对 .xls 生效;.xlsx 是 OpenXML,走 UTF-8 不受影响 using var reader = ExcelReaderFactory.CreateReader(stream, new ExcelReaderConfiguration { FallbackEncoding = Encoding.GetEncoding(936), // 简体中文老表格常见 LeaveOpen = false // 读完顺便关流 }); var sheetNames = new List<string>(); var rows = new List<string[]>(); do { sheetNames.Add(reader.Name); var isHeader = true; while (reader.Read()) { if (isHeader) { isHeader = false; continue; } // 第一行当表头跳过 if (reader.FieldCount == 0) continue; var cells = new string[reader.FieldCount]; for (var i = 0; i < reader.FieldCount; i++) cells[i] = ReadCell(reader, i); // 整行空白直接丢,Excel 里最末尾经常有一堆空行 if (cells.All(string.IsNullOrWhiteSpace)) continue; rows.Add(cells); } } while (reader.NextResult()); // 多个 Sheet 时继续下一个 return new SheetData(sheetNames, rows); } }

CreateReader会嗅探文件头,所以不用先判断扩展名是 xls 还是 xlsx,交给它自己认。do...while (NextResult())这个循环容易漏,很多人只读第一个 Sheet,结果用户把数据放在第二个标签页里,反馈「导入成功但一条数据都没有」——这种血泪经验一次就够。FieldCount取的是当前行的列数,不同行可能不一致,读的时候按行取而不是按表头取,才不会越界。

3.2 单元格取值:类型推断与精度

reader.GetValue(i)返回的是object,Excel 单元格的真实类型会原样带出来:日期是DateTime,数字是double,布尔是bool,文本是string。直接ToString()会把价格变成19.899999999999999。

private static string ReadCell(IExcelDataReader reader, int column) { if (reader.IsDBNull(column)) return string.Empty; return reader.GetValue(column) switch { // Excel 内部用浮点存日期,序列号转出来就是 DateTime,统一格式化省得前端再猜 DateTime dt => dt.ToString("yyyy-MM-dd HH:mm:ss"), // 价格、数量这类数值走定点格式,避免 19.9 变成 19.899999999999999 double d => d.ToString("0.############", CultureInfo.InvariantCulture), bool b => b ? "是" : "否", _ => reader.GetValue(column)?.ToString()?.Trim() ?? string.Empty }; }

"0.############"里小数点后 12 个#是经验取法:够长,能容纳大部分业务精度,又不会把整数补成一串零。CultureInfo.InvariantCulture不能省,在有地区设置的机器上,ToString()可能输出19,9,后面decimal.Parse就炸了。另一个常被忽略的点是长数字:订单号20240101000001如果在 Excel 里被存成数值,读出来就是2.0240101000001E+13,精度已经丢了,这时候只能让模板把该列设置成文本格式,代码侧无解。

3.3 映射到 DTO 并返回逐行错误清单

导入接口的返回值决定了用户能不能自己修数据。我一般返回三个数:总行数、成功行数、错误列表,错误里带行号、列名和原值。

public ImportResult ImportProducts(Stream stream) { var sheet = _reader.Read(stream); var result = new ImportResult { Total = sheet.Rows.Count }; for (var i = 0; i < sheet.Rows.Count; i++) { var lineNo = i + 2; // 表头占了第 1 行 var cells = sheet.Rows[i]; if (cells.Length < 3) { result.Errors.Add(new RowError(lineNo, "-", "列数不足 3 列,请勿删除模板列")); continue; } if (!decimal.TryParse(cells[2], NumberStyles.Number, CultureInfo.InvariantCulture, out var price)) { result.Errors.Add(new RowError(lineNo, "价格", $"「{cells[2]}」不是合法数字")); continue; } if (price <= 0) { result.Errors.Add(new RowError(lineNo, "价格", "必须大于 0")); continue; } result.Items.Add(new ProductImportDto { Code = cells[0], Name = cells[1], Price = price }); result.Success++; } // 错误最多回 200 条,再多前端渲染不动,也没人看得完 if (result.Errors.Count > 200) result.Errors = result.Errors.Take(200).ToList(); return result; }

这里有个取舍要讲清楚:出错的行是跳过还是整批回滚。做商品导入我倾向「收集完一起返回、由用户确认后再写库」,因为一次导几千行,中途失败一半最难受;做配置类的小表则直接整批校验通过才落库。lineNo = i + 2这个偏移量别算错,行号对不上,用户找半天找不到那一行,反馈就变成「你们系统有问题」。错误条数封顶 200 也是踩出来的,某次把 8000 条错误全塞进 JSON,响应体十几兆,浏览器直接卡死。

4. 导出 Excel:EPPlus 建表、样式与流式下载

导出看着比导入简单,其实坑在内存和文件名上。几千行用byte[]一把梭没问题,十万行就会看到内存曲线陡增。

4.1 控制器:把生成结果交给 File()

生成和响应分开,控制器只负责包装 HTTP 语义。

[HttpGet("export")] public IActionResult Export([FromQuery] string? keyword) { var items = _repository.Query(keyword); // 数据层自己控制条数上限 var bytes = _exporter.Build(items); // Core 层返回 byte[] var fileName = $"商品清单_{DateTime.Now:yyyyMMddHHmmss}.xlsx"; return File( bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName); }

File()的第三个参数是下载文件名,ASP.NET Core 会自动按 RFC 5987 生成filename*=UTF-8''...,中文不会乱码,不用自己拼Content-Disposition。MIME 类型别写成application/octet-stream,否则部分浏览器会当成未知文件提示「可能有害」。前端如果拿不到文件名,问题通常出在读响应头:跨域时要Access-Control-Expose-Headers: Content-Disposition,拿到后还要decodeURIComponent一次。

4.2 表头样式、列宽与数字格式

样式代码不值得省,一个能直接交付给运营的表格,和裸数据表格的观感差距很大。

public byte[] Build(IReadOnlyList<Product> items) { using var package = new ExcelPackage(); var ws = package.Workbook.Worksheets.Add("商品"); var headers = new[] { "编号", "名称", "价格", "上架时间" }; for (var c = 0; c < headers.Length; c++) ws.Cells[1, c + 1].Value = headers[c]; var row = 2; foreach (var item in items) { ws.Cells[row, 1].Value = item.Code; ws.Cells[row, 2].Value = item.Name; ws.Cells[row, 3].Value = item.Price; ws.Cells[row, 4].Value = item.OnSaleAt; row++; } // 表头:加粗 + 浅灰底 + 居中 using (var head = ws.Cells[1, 1, 1, headers.Length]) { head.Style.Font.Bold = true; head.Style.HorizontalAlignment = ExcelHorizontalAlignment.Center; head.Style.Fill.PatternType = ExcelFillStyle.Solid; head.Style.Fill.BackgroundColor.SetColor(Color.FromArgb(0xF2, 0xF2, 0xF2)); } ws.View.FreezePanes(2, 1); // 冻结首行,滚动时表头不动 ws.Column(3).Style.Numberformat.Format = "#,##0.00"; // 价格两位小数带千分位 ws.Column(4).Style.Numberformat.Format = "yyyy-mm-dd hh:mm"; ws.Cells[ws.Dimension.Address].AutoFitColumns(10, 40); // 最小 10、最大 40 字符宽 return package.GetAsByteArray(); }

几个参数值得单独说。FreezePanes(2, 1)的第一个参数是行,第二列是列,写(2, 1)表示第 1 行冻结——注意它传的是「从第几行开始解冻」,不是「冻结几行」,写错一位就变成冻两行。AutoFitColumns(10, 40)的上下限很关键:不设上限,遇到超长商品名会把列撑到几百字符宽,用户横向拉动到怀疑人生。数字格式用#,##0.00而不是在代码里Math.Round,让显示归显示、存储归存储,导出后用户还要做透视表,值被截断就麻烦了。

4.3 十万行导出:改成分批写响应流

GetAsByteArray()会把整个工作簿序列化进内存,再复制一份给响应体,峰值内存大约是文件大小的两到三倍。数据量上到十万行级别,就得改成边查边写、直接落响应流。

[HttpGet("export/stream")] public async Task ExportStream(CancellationToken ct) { Response.ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; Response.Headers.ContentDisposition = $"attachment; filename*=UTF-8''{Uri.EscapeDataString("商品清单.xlsx")}"; using var package = new ExcelPackage(); var ws = package.Workbook.Worksheets.Add("商品"); ws.Cells[1, 1].Value = "编号"; ws.Cells[1, 2].Value = "名称"; ws.Cells[1, 3].Value = "价格"; var row = 2; await foreach (var batch in _repository.QueryBatches(size: 1000, ct)) // 分批从库里捞 { foreach (var item in batch) { ws.Cells[row, 1].Value = item.Code; ws.Cells[row, 2].Value = item.Name; ws.Cells[row, 3].Value = item.Price; row++; } } // EPPlus 4.x 只有同步 SaveAs;升到支持异步的版本后换成 await package.SaveAsAsync(...) package.SaveAs(Response.Body); }

QueryBatches用yield return配合 EF Core 的AsAsyncEnumerable()实现,每次只把一千条实体留在内存里,工作簿本身仍是全量构建的——Excel 的 xlsx 是 zip 包,理论上可以边写边冲,但 EPPlus 不支持真正的流式写出,所以这条路只能省数据层的内存,省不了工作簿那部分。真到内存吃紧,方案是拆分文件:按月份或按分类生成多个 xlsx 打成一个 zip,比硬扛一个大文件稳。另外这种直接写Response.Body的接口没法返回错误 JSON,一旦中途抛异常,客户端收到的是半截损坏文件,所以要么前置校验全部通过,要么改回「先生成到临时文件再下载」的老办法。

5. Excel 读写最容易翻车的 5 个点:现象、原因和改法

5.1 一调用就抛 LicenseException

现象:本地跑得好好的,部署到另一台机器上,导出接口 500,日志里是LicenseException: Please set the ExcelPackage.LicenseContext property。 原因:EPPlus 5.x 之后需要显式声明使用场景,没声明就拒绝工作;4.x 不需要,所以从旧项目拷贝代码过来时最常撞上。 解决:在Program.cs里统一设置一次ExcelPackage.LicenseContext = LicenseContext.NonCommercial;。但这只是让代码跑起来,商用场景该买授权还是要买,别拿这行配置当免死金牌,合规问题不是技术能遮的。

5.2 读 .xls 报「No data is available for encoding 936」

现象:.xlsx 上传正常,一传 .xls 就 500,堆栈里是NotSupportedException。 原因:.NET Core 之后默认不再内置代码页编码,BIFF 格式的文件头里存的是代码页号,拿不到就解不出来。 解决:注册CodePagesEncodingProvider.Instance(见 2.3),并在ExcelReaderConfiguration里指定FallbackEncoding。这一步放在Program.cs顶部最保险,放在某个静态构造函数里迟早会遇到「第一次调用没问题、并发下偶发失败」的诡异情况。

5.3 上传稍大的文件直接 413

现象:几百 KB 的文件没事,几兆的表单一传就413 Request Entity Too Large,或者Multipart body length limit exceeded。 原因:Kestrel 的MaxRequestBodySize和表单解析的MultipartBodyLengthLimit是两道独立的闸,默认都比较保守;如果前面还挂了反向代理,代理层还有第三道。 解决:两处配置都改(见 2.3),同时确认代理层的上传限制。控制器上再加[RequestSizeLimit]或[DisableRequestSizeLimit]做局部覆盖,这样不同接口能有不同上限,导图片的接口和导表格的接口不该共用一个值。

5.4 第二次读同一个流,读到的是空数据

现象:同一个Stream先算 MD5 再解析,解析出来 0 行;或者先解析一遍预览、再解析一遍入库,第二遍全空。 原因:流读完位置在末尾,不Seek回去就再也读不到东西;IFormFile.OpenReadStream()每次是新流,问题多出现在自己缓存的MemoryStream上。 解决:复用前统一stream.Position = 0,或者干脆复制一份到MemoryStream各读各的。顺带一提,LeaveOpen参数要想清楚,设成false时 reader 释放会把你的流一起关掉,后面再想复用就直接ObjectDisposedException。

5.5 价格和日期读出来是另一副样子

现象:表里写19.90,读出来是19.9;表里写2024-01-01,读出来是45292;订单号变成2.02E+13。 原因:Excel 单元格有真实类型,日期本质是序列号,数字是 IEEE 754 双精度,长数字被当成数值存储时精度就没了;而ToString()又按当前区域格式化,小数点可能变逗号。 解决:取值时按类型分支处理(见 3.2),数值统一用InvariantCulture加定点格式;日期列在导入模板里明确要求文本格式,或者在读取时判断reader.GetFieldType(i) == typeof(double)且列语义是日期时用DateTime.FromOADate转。最省事的办法是给用户一份带格式的模板,把该是文本的列提前设成文本,比在代码里做各种猜测可靠。

6. 二次开发:把写死的列索引改成表头名映射

原项目里最容易改出问题的地方,是cells[0]、cells[2]这种硬编码列索引。业务方一句「我想把价格列挪到名称前面」,代码就得跟着改、跟着发版。我一般第二步就把它换掉,用特性标注每个字段对应的表头名,再按表头做映射。

[AttributeUsage(AttributeTargets.Property)] public sealed class ExcelColumnAttribute : Attribute { public ExcelColumnAttribute(string title) => Title = title; public string Title { get; } public bool Required { get; init; } } public sealed class ProductImportDto { [ExcelColumn("商品编号", Required = true)] public string Code { get; set; } = ""; [ExcelColumn("商品名称", Required = true)] public string Name { get; set; } = ""; [ExcelColumn("价格")] public decimal Price { get; set; } [ExcelColumn("上架时间")] public DateTime? OnSaleAt { get; set; } }

映射时用表头这行建立「列名 → 属性」的字典,再按名字取值,列顺序变了也不影响:

var map = typeof(T).GetProperties() .Select(p => (Prop: p, Attr: p.GetCustomAttribute<ExcelColumnAttribute>())) .Where(x => x.Attr is not null) .ToDictionary(x => x.Attr!.Title.Trim(), x => x.Prop, StringComparer.OrdinalIgnoreCase); // header 是解析出来的第一行,后面按 map 找属性,缺列在这里就能直接报出来 var missing = map.Values .Where(p => p.GetCustomAttribute<ExcelColumnAttribute>()!.Required) .Where(p => !header.Contains(p.GetCustomAttribute<ExcelColumnAttribute>()!.Title)) .Select(p => p.GetCustomAttribute<ExcelColumnAttribute>()!.Title);

这套改造的验证方法很简单:准备三份文件——正常顺序、打乱顺序、删掉一个必填列,跑单元测试断言前两份成功、第三份返回明确的缺列提示。再加一份带脏数据的(价格写「待定」、日期写「下周」),确认错误行号准确。做完这三步,后面无论业务加多少列,只需要往 DTO 上加一行特性。

我踩过一次很典型的坑:改完映射后忘了给ToDictionary加StringComparer.OrdinalIgnoreCase,用户在模板里把「商品编号」写成「商品编号 」(尾部多个空格),直接抛「重复键」异常,日志里完全看不出是空格问题,翻了两小时。从那以后我每次做表头映射,都强制先Trim()再比、进程内加不区分大小写,并且专门写一个带多余空格的用例。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询