简介这份资源面向需要在 ASP.NET Core WebAPI 项目中处理 Excel 的 .NET 开发者聚焦读取与导出两类常见需求。它结合 EPPlus 与 ExcelDataReader 两个库演示如何在不依赖 Office Interop 的情况下完成 OpenXML 与旧版 BIFF8 格式文件的解析并借助流式读取降低大文件的内存占用适合已具备一定 C# 与 WebAPI 基础、希望补齐文件处理能力的开发者参考。压缩包共 208 个文件约 21.89MB以 149 个 dll 依赖库为主辅以 12 个 cs 源码、11 个 json 配置、若干 so、dylib 跨平台运行库及 pdb 调试符号另含 sln 解决方案与 csproj 项目文件构成可直接运行的完整示例工程。目前已有 401 人学习下载。读者可从中获得读取与导出 Excel 的完整代码骨架、控制器写法与响应头设置范例并据此扩展错误处理、数据校验与分批导出等细节。1. 从一张对账单说起这个项目到底解决什么问题上个月帮朋友处理一个财务对账的活儿对方甩过来一个 20MB 的 xlsx里面十几个 sheet每个 sheet 几万行还夹着合并单元格和日期格式的坑。用 Python 的 pandas 读吧环境装了半天用现成的在线工具吧数据又不敢往上传。最后我翻出一个 ASP.NET Core WebAPI 的小项目两个接口就把「读」和「写」都办了——这就是 excel-handler 这类项目的价值所在它不搞花活就是把 Excel 的导入导出做成一个能直接部署的 HTTP 服务。这个项目基于 ASP.NET Core WebAPI 搭建读取侧用 ExcelDataReader导出侧用 EPPlus一个负责把 xlsx/xls 流式解析成结构化数据一个负责把内存里的对象写成带样式的表格文件。适合谁适合手上有一堆 Excel 要批量处理、又不想每次写脚本的后端开发者也适合想学「文件流怎么在 WebAPI 里走通」的 C# 新手。下面我按「能干什么 → 怎么跑 → 怎么改」的顺序把这个项目拆开讲。2. 技术选型与项目骨架为什么是 EPPlus 加 ExcelDataReader2.1 两个库各管一段别混着用很多人第一次做 Excel 功能会想找一个库把读写全包了。这个项目偏偏用了两个原因很实在读和写的诉求根本不一样。ExcelDataReader 的定位是「只读、轻量、快」。它把 xlsx 当成一个压缩包里的 XML 流来解析不加载样式、不建对象模型内存占用低读几万行也不容易爆。缺点是它只能读写不了也不支持公式计算。EPPlus 的定位是「写为主、带样式」。它能创建工作簿、设置列宽、加表头颜色、写公式、做数据验证导出的文件打开就是能直接给人看的。缺点是它会把整个工作簿建在内存里数据量特别大时要注意分批。所以这个项目的分工很清晰导入走 ExcelDataReader导出走 EPPlus。你在代码里会看到两个完全独立的服务类互不依赖这也是它好改的原因——哪天你想把导出换成别的库导入部分一行不用动。提示ExcelDataReader 读 xls 和 xlsx 都行但读 xls 需要额外装ExcelDataReader.DataSet包项目里一般会一起引上。2.2 目录结构长什么样一个典型的 ASP.NET Core WebAPI 项目骨架加上 Excel 相关的服务层大致是这样excel-handler/ ├── Controllers/ │ └── ExcelController.cs # 两个接口导入、导出 ├── Services/ │ ├── IExcelReaderService.cs │ ├── ExcelReaderService.cs # ExcelDataReader 封装 │ ├── IExcelExportService.cs │ └── ExcelExportService.cs # EPPlus 封装 ├── Models/ │ └── ImportRowDto.cs # 导入行的数据模型 ├── Program.cs # 服务注册、中间件 ├── appsettings.json └── excel-handler.csproj这个分层不是摆设。Controller 只负责收文件、返结果真正的解析逻辑全在 Services 里。你二次开发时想加一个「导入后校验手机号」的规则改 Service 就行Controller 不用碰。Program.cs 里通过依赖注入把两个 Service 注册成 Scoped请求进来各拿一个实例互不干扰。2.3 依赖注入和上传大小限制两个容易漏的配置项目能跑起来的前提是这两处配置到位。第一处是服务注册// Program.cs var builder WebApplication.CreateBuilder(args); builder.Services.AddControllers(); // 注册两个 Excel 服务Scoped 表示每个请求一个实例 builder.Services.AddScopedIExcelReaderService, ExcelReaderService(); builder.Services.AddScopedIExcelExportService, ExcelExportService(); // 放开上传大小限制默认只有 30MB 左右 builder.Services.ConfigureFormOptions(options { options.MultipartBodyLengthLimit 100 * 1024 * 1024; // 100MB }); var app builder.Build(); app.MapControllers(); app.Run();MultipartBodyLengthLimit这个参数是血泪经验默认值在 Kestrel 下大约 30MB你传一个 50MB 的 Excel 会直接收到 413而且报错信息不明显新手很容易以为是代码问题。改成 100MB 后还要注意反向代理层比如 Nginx也有自己的client_max_body_size两边都得放开。第二处是Program.cs里如果用了AddControllersController 上的[FromForm] IFormFile file才能正常绑定。少写这一行接口能编译通过但一调用就返回 415这个坑我踩过不止一次。3. 导入链路拆解一个上传的 Excel 怎么变成 List3.1 Controller 层收文件只做三件事导入接口的 Controller 写得很薄就三件事判断文件非空、校验扩展名、把流交给 Service。[ApiController] [Route(api/[controller])] public class ExcelController : ControllerBase { private readonly IExcelReaderService _reader; public ExcelController(IExcelReaderService reader) { _reader reader; } [HttpPost(import)] public async TaskIActionResult Import([FromForm] IFormFile file) { // 1. 空文件直接拒 if (file null || file.Length 0) return BadRequest(文件为空); // 2. 只认 xls / xlsx防止有人传个 exe 改后缀 var ext Path.GetExtension(file.FileName).ToLower(); if (ext ! .xls ext ! .xlsx) return BadRequest(只支持 xls/xlsx); // 3. 打开流交给 Service注意用 using 保证释放 using var stream file.OpenReadStream(); var rows await _reader.ReadAsync(stream); return Ok(new { count rows.Count, data rows }); } }file.OpenReadStream()返回的是只读流ExcelDataReader 正好只需要读。这里用using很关键——IFormFile 的流如果不释放在高并发下会占着临时文件句柄跑一段时间就出现「文件被占用」的异常。ReadAsync返回的是ListImportRowDto具体怎么解析在下一节。3.2 Service 层ExcelDataReader 的读取套路ExcelDataReader 的用法有个固定套路先CreateReader拿到 reader再AsDataSet或者逐行Read()。这个项目用的是逐行读因为要映射到自定义 DTO逐行更灵活。public class ExcelReaderService : IExcelReaderService { public TaskListImportRowDto ReadAsync(Stream stream) { var result new ListImportRowDto(); // 关键必须显式指定 ExcelReaderConfiguration // 否则某些带密码或格式怪的文件会抛异常 using var reader ExcelReaderFactory.CreateReader(stream, new ExcelReaderConfiguration { FallbackEncoding Encoding.GetEncoding(GB2312) // 兼容老 xls 中文 }); // 默认读第一个 sheet多 sheet 场景见 3.3 reader.Read(); // 第一行是表头先读掉 while (reader.Read()) { // 按列索引取值GetValue 返回 object要自己转 var dto new ImportRowDto { Name reader.GetValue(0)?.ToString()?.Trim(), Phone reader.GetValue(1)?.ToString()?.Trim(), Amount Convert.ToDecimal(reader.GetValue(2) ?? 0), CreatedAt reader.GetDateTime(3) // 日期列专用方法 }; result.Add(dto); } return Task.FromResult(result); } }几个参数值得说清楚。FallbackEncoding是给老式 xls 用的xlsx 内部是 UTF-8 不需要但项目里统一加上没坏处。GetValue(index)返回object空单元格返回 null所以后面要接?.ToString()和?? 0兜底。日期列千万别用GetValue再Convert.ToDateTime因为 Excel 的日期本质是个数字序列号直接转会得到 1900 年附近的怪值必须用GetDateTime(index)这是导入功能里最高频的翻车点。3.3 多 sheet 和表头不固定的处理真实业务里一个文件多个 sheet 很常见。ExcelDataReader 的 reader 支持NextResult()切换 sheet可以这样改do { var sheetName reader.Name; // 当前 sheet 名 reader.Read(); // 跳过表头 while (reader.Read()) { // 按 sheetName 走不同的映射逻辑 if (sheetName 订单) { /* 映射订单 DTO */ } else if (sheetName 客户) { /* 映射客户 DTO */ } } } while (reader.NextResult()); // 还有下一个 sheet 就继续NextResult()返回 false 表示没有更多 sheet 了。这里要注意每个 sheet 读完后 reader 的位置会重置到该 sheet 的第一行所以每个 sheet 开头都要再Read()一次跳表头。如果表头行数不固定有的文件前两行是标题可以加个参数headerRowIndex循环Read()到指定行再开始取数据。这种「表头偏移」在对接第三方导出的文件时几乎必遇提前留个参数位能省很多事。4. 导出链路拆解EPPlus 怎么把对象写成能看的表4.1 从 List 到工作簿的基本流程导出的入口同样在 Controller接收查询条件或者干脆接收一个 DTO 列表返回FileContentResult。核心逻辑在 ExportService 里EPPlus 的写法是「建包 → 建表 → 填数据 → 存流」。public class ExcelExportService : IExcelExportService { public byte[] Export(ListImportRowDto data) { // 1. 建工作簿using 保证释放 using var package new ExcelPackage(); var ws package.Workbook.Worksheets.Add(数据); // 2. 写表头 ws.Cells[1, 1].Value 姓名; ws.Cells[1, 2].Value 手机号; ws.Cells[1, 3].Value 金额; ws.Cells[1, 4].Value 创建时间; // 3. 循环写数据行号从 2 开始 for (int i 0; i data.Count; i) { var row i 2; ws.Cells[row, 1].Value data[i].Name; ws.Cells[row, 2].Value data[i].Phone; ws.Cells[row, 3].Value data[i].Amount; ws.Cells[row, 4].Value data[i].CreatedAt; } // 4. 存成字节数组返回 return package.GetAsByteArray(); } }Cells[行, 列]是 EPPlus 的坐标写法从 1 开始不是 0这点和数组习惯相反第一次写容易错位。GetAsByteArray()把整个包序列化成 byte[]Controller 再包成File(bytes, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, 导出.xlsx)返回。MIME 类型那一长串别手打复制粘贴最稳写错了浏览器可能不触发下载。4.2 加样式表头加粗、列宽自适应、数字格式能导出只是及格导出的文件能不能直接发给业务方看的是样式。EPPlus 的样式 API 很直观// 表头区域加粗 背景色 using (var header ws.Cells[1, 1, 1, 4]) { header.Style.Font.Bold true; header.Style.Fill.PatternType ExcelFillStyle.Solid; header.Style.Fill.BackgroundColor.SetColor(Color.LightGray); } // 金额列保留两位小数 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();AutoFitColumns()对纯英文效果好中文因为字符宽度问题经常偏窄实际项目里我一般会在自适应后再手动ws.Column(1).Width 2补一点。Numberformat.Format用的是 Excel 自己的格式串#,##0.00是千分位加两位小数写错成0.00就没有千分位了这种细节业务方一眼就能看出来。4.3 大数据量导出别一次性全塞内存EPPlus 把整个工作簿建在内存里10 万行以上就要小心了。常见的做法是分批写、或者用它的流式 API。简单场景下至少要做到「查询分页、写一批清一批引用」别在循环里再 new 一堆临时对象。如果数据量真的很大可以考虑导出 CSV 代替 xlsx——CSV 没有样式但可以流式写内存占用几乎为零。这个取舍在项目里没有写死属于二次开发时按业务量决定的部分。5. 避坑与排查这几个错误我几乎每次都遇到5.1 上传报 413代码却没问题现象本地 Postman 传小文件正常传 40MB 的文件直接 413日志里看不到进入 Controller 的记录。原因请求在 Kestrel 或反向代理层就被拦了根本没到你的代码。Kestrel 默认MaxRequestBodySize约 30MBNginx 默认client_max_body_size是 1MB。解决Kestrel 侧在 Program.cs 里配MultipartBodyLengthLimit或者对单个接口用[RequestSizeLimit(100_000_000)]Nginx 侧改client_max_body_size 100m;后 reload。两边都改完再测。5.2 日期读出来是 1900 年现象Excel 里明明是 2024-05-01导入后变成 1900-01-01 附近的数字。原因Excel 内部把日期存成序列号GetValue拿到的是 double直接Convert.ToDateTime会按 OADate 解释出错。解决日期列统一用reader.GetDateTime(index)如果列里混着文本日期先判断reader.GetFieldType(index)是 DateTime 走专用方法是 string 走DateTime.Parse。5.3 中文乱码尤其是老 xls现象xlsx 正常xls 里的中文变成问号或乱码。原因老 xls 的编码不一定是 UTF-8ExcelDataReader 默认按 UTF-8 解遇到 GB2312 编码的文件就乱。解决CreateReader时传ExcelReaderConfiguration { FallbackEncoding Encoding.GetEncoding(GB2312) }。注意 .NET Core 默认不带 GB2312需要在 csproj 里加System.Text.Encoding.CodePages包并在启动时Encoding.RegisterProvider(CodePagesEncodingProvider.Instance)少这一步会抛「找不到编码」的异常。5.4 导出的文件打不开提示格式错误现象下载下来的 xlsx 用 Excel 打开报「文件格式或扩展名无效」。原因多半是 Controller 返回时 MIME 类型写错或者把 byte[] 又当字符串处理了一遍。解决确认返回的是File(bytes, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, fileName)fileName 带.xlsx后缀。别用Content(byte[])那样浏览器会当文本处理。5.5 并发导入时偶发「文件被占用」现象压测或多人同时导入偶尔抛 IOException提示文件正被另一进程使用。原因IFormFile.OpenReadStream()的流没释放或者 Service 里把流存到了静态字段。解决所有流用using包住Service 不要持有流的引用超过方法作用域。如果确实需要异步后台处理先把流CopyToAsync到一个临时文件或 MemoryStream再关掉原始流。6. 二次开发把这两个接口改成你自己的业务6.1 换 DTO 和映射规则是最常见的改法这个项目的导入导出都围绕ImportRowDto转你要接自己的业务第一步就是换模型。比如做员工导入把 DTO 改成EmployeeDto { Name, IdCard, Department, HireDate }然后改 ReaderService 里的列索引映射。这里有个技巧别把列索引写死成 0、1、2改成从表头名反查列号文件列顺序变了也不怕。// 先扫表头建立 列名 - 索引 的字典 var headerMap new Dictionarystring, int(); for (int c 0; c reader.FieldCount; c) headerMap[reader.GetValue(c)?.ToString()?.Trim()] c; // 之后按名字取列顺序随便变 var name reader.GetValue(headerMap[姓名])?.ToString();FieldCount是当前行的列数表头行扫一遍就能建好映射。这个改法让导入对「列顺序调整」免疫对接外部文件时特别有用。6.2 加校验层别让脏数据进库导入接口现在只做了文件级校验行级校验手机号格式、金额非负、必填项应该在 Service 里加。我的习惯是解析完先不落库返回一个「校验结果」结构包含成功行和错误行带行号和原因让前端展示出来让用户改。这样比直接抛异常友好得多也避免导入一半失败留下脏数据。6.3 导出加模板比纯代码拼样式省事如果导出格式很固定比如固定表头、固定列宽、带公司 logo与其用代码一行行设样式不如做一个模板 xlsx 放在项目里导出时用 EPPlus 打开模板、往指定单元格填数据。EPPlus 支持new ExcelPackage(new FileInfo(template.xlsx))填完再存。这样样式调整交给会用 Excel 的人你只管填数据分工清楚。6.4 验证改造是否成功的三个检查点改完之后别只看接口返回 200。第一用 Excel 实际打开导出文件看中文、日期、金额格式对不对第二拿一个列顺序打乱、带空行的文件测导入看会不会崩第三并发压一下导入接口看临时文件有没有残留。这三步走完基本能覆盖 90% 的线上问题。说个我自己的教训早期做导出功能我只在 Swagger 里看返回的字节长度觉得有内容就成了结果业务方打开发现表头是乱码——因为我把中文表头写在了没设编码的字符串里。从那以后我每次改完 Excel 相关代码都强制用真实 Excel 打开导出的文件看一眼再拿一个「脏文件」跑一遍导入两步都过了才敢提交。希望这个习惯也能帮到你少在格式问题上返工。本文还有配套的精品资源点击获取