ARTICLE DETAIL

资讯详情

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

ASP.NET Core Excel导入导出实战:ExcelDataReader与EPPlus流式处理方案

ASP.NET Core Excel导入导出实战:ExcelDataReader与EPPlus流式处理方案 简介本资源面向ASP.NET Core WebAPI开发者聚焦Web应用中Excel文件的读取与导出场景适合具备一定C#基础、希望摆脱Office Interop依赖的中高级工程师参考学习。包内共208个文件以149个dll运行库、12个cs源码、11个json配置及so、pdb、exe等编译产物为主压缩包约21.89MB完整保留了可运行的工程结构与依赖环境。资源围绕EPPlus与ExcelDataReader两大库展开前者负责创建、修改并导出OpenXML格式Excel可设置样式与公式后者支持BIFF8与OpenXML格式的流式读取无需将整个文件载入内存利于处理大文件。通过控制器方法返回File结果、设置MIME类型与Content-Disposition响应头即可将生成的表格直接推送给客户端。目前已有399人学习下载读者可借此掌握读取与导出双流程的实现思路、内存优化方式及错误处理与数据验证的落地要点。1. 从一次线上事故说起为什么 Excel 导入导出值得单独造一个 handler去年双十一前夜运营后台的批量导入功能突然集体超时。排查下来不是数据库慢而是 ExcelDataReader 在读取一个 8 万行的 xlsx 时把内存吃到了 2GGC 一停整个 Pod 就被 OOMKill。这件事之后我把项目里所有散落的 Excel 读写逻辑抽成了一个独立的excel-handler模块用 ASP.NET Core WebAPI 做统一入口读用 ExcelDataReader写用 EPPlus把流式读取、格式校验、异步导出全部收口。这篇笔记讲的就是这套方案怎么落地。它解决的核心问题有三个一是把「读」和「写」的职责分开读走轻量流式、写走模板化生成二是把 Excel 的脏数据挡在业务层之外空行、合并单元格、日期格式错乱都在 handler 里消化掉三是让导出不再阻塞请求线程大文件走后台任务加轮询。适合正在做后台管理系统、报表平台、数据中台的 .NET 开发者尤其是被「excel 无法打开文件因为文件格式或文件扩展名无效」和「c# 无法读取 excel 中的数据并打印」这类问题反复折磨过的人。下面从选型、读、写、避坑到进阶一步步拆开。2. 选型先立住EPPlus 与 ExcelDataReader 到底谁读谁写2.1 两个库的定位差异与许可证边界EPPlus 和 ExcelDataReader 经常被混用但它们的强项完全不同。ExcelDataReader 是纯读取库只做一件事把 xlsx、xls、csv 解析成IDataReader或DataSet它不依赖 Office Interop也不需要装 Excel内存占用低适合流式读取大文件。EPPlus 是完整的读写库能创建、修改、设置样式、生成图表、做数据验证导出场景基本靠它。许可证是选型时绕不开的坎。EPPlus 从 5.0 开始转为 Polyform Noncommercial 许可证商业项目使用需要购买商业授权如果预算敏感或者只是内部工具可以评估 ClosedXML 或 NPOI 作为替代。ExcelDataReader 一直是 MIT 许可证商用无压力。我一般会这样分工导入接口只引 ExcelDataReader导出接口只引 EPPlus两个库不互相污染将来换任何一个都不影响另一边。提示不要为了省事在导入时也用 EPPlus它的Load会把整个工作簿加载进内存大文件场景下和 Interop 一样危险。2.2 项目结构与依赖注入的最小配置先建一个 ASP.NET Core WebAPI 项目把 handler 拆成IExcelReaderService和IExcelWriterService两个接口分别对应读和写。这样做的原因是导入和导出的生命周期不同读是请求内同步完成写可能走后台队列混在一起会导致依赖注入时把不需要的库也加载进来。dotnet new webapi -n ExcelHandler.Api cd ExcelHandler.Api dotnet add package ExcelDataReader dotnet add package ExcelDataReader.DataSet dotnet add package EPPlus安装完成后在Program.cs里注册服务。注意 ExcelDataReader 本身是静态入口不需要注册为服务但为了可测试性我习惯包一层。// Program.cs var builder WebApplication.CreateBuilder(args); builder.Services.AddScopedIExcelReaderService, ExcelReaderService(); builder.Services.AddScopedIExcelWriterService, ExcelWriterService(); builder.Services.AddControllers(); // 导入接口限制请求体大小防止超大文件打爆内存 builder.Services.ConfigureFormOptions(o { o.MultipartBodyLengthLimit 20 * 1024 * 1024; // 20MB }); var app builder.Build(); app.MapControllers(); app.Run();这里的关键参数是MultipartBodyLengthLimit默认值在部分版本里是 128MB对 Excel 导入来说太宽松了。设成 20MB 是个经验值超过这个大小的文件应该走分片或异步导入而不是硬扛。AddScoped而不是AddSingleton是因为读取过程中会持有流和临时状态单例容易出并发问题。3. 读 Excel用 ExcelDataReader 做流式解析与类型归一3.1 流式读取的最小可用代码导入接口的第一原则是「不把整个文件读进内存」。ExcelDataReader 的CreateReader接受一个Stream配合AsDataSet或直接遍历Read()都能做到逐行消费。下面是一个把上传文件解析成ListDictionarystring, object的最小实现。public async TaskListDictionarystring, object ReadAsync(IFormFile file) { // 用内存流承接上传内容注意这里仍然会占用文件大小的内存 using var stream new MemoryStream(); await file.CopyToAsync(stream); stream.Position 0; var config new ExcelReaderConfiguration { // 空行是否保留false 表示跳过完全空白的行 FallbackEncoding Encoding.UTF8, LeaveOpen false }; using var reader ExcelReaderFactory.CreateReader(stream, config); var result new ListDictionarystring, object(); var headers new Liststring(); // 第一行作为表头 if (reader.Read()) { for (int i 0; i reader.FieldCount; i) { headers.Add(reader.GetValue(i)?.ToString()?.Trim() ?? $Column{i}); } } while (reader.Read()) { var row new Dictionarystring, object(); for (int i 0; i headers.Count; i) { // 越界保护防止某些行字段数少于表头 row[headers[i]] i reader.FieldCount ? reader.GetValue(i) : null; } result.Add(row); } return result; }逻辑上分三段先把上传流拷贝到内存流并复位指针因为IFormFile的流只能读一次然后用CreateReader拿到 reader第一行单独读出来当表头最后逐行Read()填充字典。参数FallbackEncoding在 xlsx 里其实用不到但处理老式 xls 时能兜底乱码。LeaveOpen false让 reader 释放时自动关流避免句柄泄漏。注意MemoryStream承接上传内容这一步文件多大就占多大内存。如果导入文件经常超过 50MB应该改成把上传流直接传给CreateReader或者先落盘再读。3.2 日期、数字与合并单元格的类型归一Excel 最坑的地方是类型不固定。同一个「日期」列有的单元格是DateTime有的是字符串2024/1/1还有的是数字序列号45292。如果不做归一业务层拿到的就是一堆object后面Convert.ToDateTime随时炸。private object NormalizeCell(object value, string header) { if (value null || value is DBNull) return null; // 数字序列号转日期Excel 的日期本质是 1900-01-01 起的天数 if (value is double d header.Contains(日期)) { return DateTime.FromOADate(d); } if (value is DateTime dt) return dt; var text value.ToString()?.Trim(); if (string.IsNullOrEmpty(text)) return null; // 字符串日期尝试解析失败就原样返回交给业务层报错 if (header.Contains(日期) DateTime.TryParse(text, out var parsed)) { return parsed; } // 数字列去掉千分位逗号 if (header.Contains(金额) || header.Contains(数量)) { var cleaned text.Replace(,, ); if (decimal.TryParse(cleaned, out var num)) return num; } return text; }这段代码的核心是「按表头语义做转换」而不是盲目Convert.ChangeType。DateTime.FromOADate处理的是 Excel 内部把日期存成数字的情况45292对应 2024-01-01。金额列去掉逗号是因为很多财务导出的表格会带千分位。合并单元格在 ExcelDataReader 里表现为只有左上角有值、其余为 null所以读取后需要做一次「向下填充」否则分组统计会丢数据。3.3 把解析结果映射成强类型 DTO拿到ListDictionarystring, object之后下一步是映射成 DTO 并做校验。这一步不要省否则脏数据会一路流到数据库。public class OrderImportDto { public string OrderNo { get; set; } public DateTime OrderDate { get; set; } public decimal Amount { get; set; } } public (ListOrderImportDto valid, Liststring errors) Map( ListDictionarystring, object rows) { var valid new ListOrderImportDto(); var errors new Liststring(); for (int i 0; i rows.Count; i) { var row rows[i]; var dto new OrderImportDto(); // 必填校验 if (!row.TryGetValue(订单号, out var no) || no null) { errors.Add($第 {i 2} 行订单号为空); continue; } dto.OrderNo no.ToString(); dto.OrderDate row[日期] as DateTime? ?? default; dto.Amount row[金额] as decimal? ?? 0; valid.Add(dto); } return (valid, errors); }行号从i 2开始是因为表头占第一行这样报错信息能和用户在 Excel 里看到的行号对上减少沟通成本。校验失败的行不阻断整体导入而是收集错误统一返回这是批量导入的常见做法。4. 写 Excel用 EPPlus 做模板化导出与异步落盘4.1 从零生成一个带样式的导出文件导出比导入简单但细节更多。EPPlus 的入口是ExcelPackage所有操作都在using块里完成最后SaveAs或GetAsByteArray。public byte[] Export(ListOrderExportDto data) { 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 金额; using (var range ws.Cells[1, 1, 1, 3]) { range.Style.Font.Bold true; range.Style.Fill.PatternType ExcelFillStyle.Solid; range.Style.Fill.BackgroundColor.SetColor(Color.LightGray); } // 数据行 for (int i 0; i data.Count; i) { var row i 2; ws.Cells[row, 1].Value data[i].OrderNo; ws.Cells[row, 2].Value data[i].OrderDate; ws.Cells[row, 2].Style.Numberformat.Format yyyy-mm-dd; ws.Cells[row, 3].Value data[i].Amount; ws.Cells[row, 3].Style.Numberformat.Format #,##0.00; } // 自动列宽注意数据量大时这步很慢 ws.Cells[ws.Dimension.Address].AutoFitColumns(); return package.GetAsByteArray(); }关键参数是Numberformat.Format日期列不设格式会显示成数字序列号金额列不设格式会丢千分位。AutoFitColumns在数据超过几千行时性能急剧下降生产环境建议改成固定列宽或者只对表头做自适应。4.2 用模板文件保留复杂格式纯代码画样式适合简单表格但遇到带 logo、多级表头、条件格式的报表手写样式会写到怀疑人生。更稳的做法是准备一个.xlsx模板代码只负责填数据。public byte[] ExportFromTemplate(ListOrderExportDto data, string templatePath) { var templateBytes File.ReadAllBytes(templatePath); using var package new ExcelPackage(new MemoryStream(templateBytes)); var ws package.Workbook.Worksheets[订单明细]; // 从第 3 行开始填前两行是模板的表头和说明 const int startRow 3; for (int i 0; i data.Count; i) { var row startRow i; ws.Cells[row, 1].Value data[i].OrderNo; ws.Cells[row, 2].Value data[i].OrderDate; ws.Cells[row, 3].Value data[i].Amount; } // 如果模板预置了公式行需要手动扩展公式范围 ws.Cells[startRow, 4, startRow data.Count - 1, 4].Formula $C{startRow}*0.13; return package.GetAsByteArray(); }模板方案的好处是样式、打印区域、页眉页脚全部在 Excel 里调好代码只关心数据。注意Formula的赋值方式EPPlus 不会自动帮你把公式往下拖必须显式指定范围。另外模板文件要设为「内容」或「嵌入资源」发布时才不会丢。4.3 大文件导出走后台任务加轮询同步导出 10 万行会让请求线程挂住几十秒前端超时、网关断连都是常事。我的做法是导出接口只返回一个任务 ID实际生成放到IHostedService或队列里前端轮询状态。[HttpPost(export/async)] public IActionResult ExportAsync([FromBody] ExportRequest req) { var taskId Guid.NewGuid().ToString(N); _queue.Enqueue(new ExportJob(taskId, req)); return Accepted(new { taskId }); } [HttpGet(export/status/{taskId})] public IActionResult GetStatus(string taskId) { var job _store.Get(taskId); if (job null) return NotFound(); return Ok(new { job.Status, job.DownloadUrl }); }Guid.NewGuid().ToString(N)生成 32 位无连字符的 ID方便做文件名。任务状态存在内存或 Redis 里生成完成后把文件写到临时目录或对象存储返回下载地址。这里要注意临时文件的清理策略否则磁盘会被慢慢吃满。5. 避坑与排查Excel 导入导出最常见的 5 个翻车现场5.1 现象上传后报「文件格式或扩展名无效」原因通常是前端把文件当二进制流上传时没带正确的Content-Type或者用户把.csv改名成.xlsx。ExcelDataReader 靠文件头魔数判断格式扩展名骗不过它。解决方式是在读取前先校验文件头xlsx 的前两字节是PKxls 是D0 CF 11 E0。private bool IsValidExcel(Stream stream) { var header new byte[4]; stream.Read(header, 0, 4); stream.Position 0; // xlsx: 50 4Bxls: D0 CF 11 E0 return (header[0] 0x50 header[1] 0x4B) || (header[0] 0xD0 header[1] 0xCF); }5.2 现象日期列读出来是 45292 这样的数字原因就是前面说的 Excel 内部日期存储机制单元格格式设成日期但底层是 double。解决方式是在归一化时判断列名含「日期」且值为 double用DateTime.FromOADate转换。如果列名不含日期关键词就需要业务层配置列类型映射表不能靠猜。5.3 现象EPPlus 导出后打开提示「文件已损坏」多数是GetAsByteArray之前 package 被提前释放或者返回时Content-Type设成了application/octet-stream但文件名没带.xlsx。解决方式是确保using块内完成所有操作再取字节数组返回时用File(bytes, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, 导出.xlsx)。5.4 现象大文件导入时内存暴涨被 OOMKill根因是用MemoryStream承接上传流或者用AsDataSet一次性加载。解决方式是直接拿IFormFile.OpenReadStream()传给CreateReader并且逐行Read()而不是AsDataSet。如果业务允许还可以限制单次导入行数超过阈值提示用户分批。5.5 现象合并单元格导致分组数据缺失ExcelDataReader 对合并单元格只在左上角返回值其余为 null。解决方式是在读取后做一次向下填充遍历每一行如果某列值为 null 且上一行同列有值就继承上一行的值。这个逻辑要放在归一化之前否则类型转换会先失败。6. 进阶技巧用列映射配置把 handler 做成通用组件走到这里读和写都通了但每个新业务都要改一遍NormalizeCell和Map维护成本会越来越高。我的做法是引入一份列映射配置把「表头名 → 字段名 → 类型 → 是否必填」抽成 JSONhandler 只负责按配置解析。{ entity: Order, columns: [ { header: 订单号, field: OrderNo, type: string, required: true }, { header: 下单日期, field: OrderDate, type: date, required: true }, { header: 金额, field: Amount, type: decimal, required: false } ] }解析时用反射或表达式树把字典映射到 DTO类型转换按type字段走对应分支。这样新增一个导入功能只需要加一份配置不用动 handler 代码。代价是反射有性能开销10 万行以上建议用Expression.Lambda编译成委托缓存起来。验证这套方案是否真的通用我一般会做三件事拿一个 5 万行的真实业务文件跑一遍看内存峰值和耗时故意传一个列名对不上、日期格式混乱的文件看错误提示是否精确到行号再传一个空文件和一个只有表头的文件看边界处理是否崩溃。这三关过了基本就能放心交给业务方用。我自己踩过最深的一个坑是早期图省事在导入接口里同时用了 EPPlus 和 ExcelDataReader结果两个库对同一个流的读取位置互相干扰排查了一整天才发现是流指针没复位。从那以后我给自己定了个习惯一个接口只碰一个 Excel 库读就是读写就是写边界清晰了玄学问题自然就少了。希望帮到你。本文还有配套的精品资源点击获取
返回列表