ASP.NET Core中Excel导入导出方案:ExcelDataReader与EPPlus结合
简介面向ASP.NET Core WebAPI开发者的Excel处理示例项目围绕EPPlus与ExcelDataReader两个核心库演示通过Web接口完成Excel数据导入导出并覆盖旧版BIFF8与新版OpenXML格式的读取场景。示例工程以ExcelHandler.Api为主项目包含控制器方法、上传读取逻辑与下载导出逻辑适合需要集成Excel导入导出、避开Office COM依赖的.NET后端开发者参考。 压缩包为zip格式共208个文件以DLL依赖库、C#源代码、JSON配置及项目工程文件为主总体积21.89MB。包内含sln解决方案与csproj工程文件目录结构清晰便于定位关键代码并对照发布配置目前已有399人学习下载。 资源覆盖从FileStream打开Excel、通过ExcelReaderFactory.CreateReader遍历行列数据到利用ExcelPackage创建工作表、填充单元格内容并通过HTTP响应返回xlsx的完整编码过程同时展示了MIME类型、Content-Disposition响应头设置及流式内存处理细节方便迁移到实际WebAPI项目中。1. excel-handler 到底是什么读 Excel 与写 Excel 为什么要用两套库接手 Excel 导入导出需求时大多数人的第一反应是找 EPPlus 一把梭但做到第二个项目就会撞上它的读取限制EPPlus 只支持 .xlsx碰到 .xls 老文件直接抛异常而 ExcelDataReader 恰好擅长把各种 Excel 格式读成 DataSet却不负责生成文件。excel-handler 这个方向的核心思路就是用 ASP.Net Core Webapi 做宿主让 ExcelDataReader 只干读取解析的活EPPlus 只干写入导出的活再通过一个接口把两者收敛成同一个服务。这样做的好处是读和写的失败路径互不牵连替换组件时不用改 Controller而且遇到大文件、多 Sheet、格式兼容问题时能分别排查。适合被 Excel 导入导出反复折磨的后端开发者也适合想在自己项目里固化一套通用 Excel 处理能力的团队。2. 先想清楚 excel-handler 的分层接口、泛型和两种工具的边界2.1 为什么读取不选 EPPlus 而选 ExcelDataReader很多人不知道 EPPlus 在 5.0 之后虽然开源协议改成了 Polyform Noncommercial但官方依然明确不支持 .xls只认 .xlsx。你的业务如果对接的是财务、ERP、人事系统导出的老格式EPPlus 读取就会直接死在第一步。ExcelDataReader 则原生支持 .xls、.xlsx、.csv并且在读取时对内存的控制比 EPPlus 更细可以按行流式读取不用把整个工作簿都载入 DataSet。常见做法是读取入口用 ExcelDataReader 的ExcelReaderFactory.CreateReader拿到流式读取器再按需调用AsDataSet()或者逐行Read()写入出口用 EPPlus 的ExcelPackage生成 .xlsx。这样分工的好处是写入端可以利用 EPPlus 的样式、公式、数据验证这些强项而读取端不会因为一个 50MB 的 .xls 文件把 API 进程的内存打爆。如果你的团队只处理 .xlsx 且文件不大那用 EPPlus 读取也不是不能用但 excel-handler 的通用定位决定了它必须把兼容性放在前面。2.2 用接口把导入导出收敛到一个服务里我一般会先定义IExcelHandler把导入导出都放进去。不要一上来就写具体的 EPPlus 或 ExcelDataReader 代码而是先约定好输入输出。导入侧接收IFormFile和映射配置返回一个标准结果对象导出侧接收数据集合和导出选项返回一个包含字节数组和文件名的 DTO。这样 Controller 里永远不会出现ExcelPackage或IExcelDataReader这两个具体类型。public interface IExcelHandler { TaskImportResult ImportAsync(Stream fileStream, string fileName, ImportOptions options, CancellationToken ct); TaskExportResult ExportAsyncT(IEnumerableT data, ExportOptions options, CancellationToken ct); } public class ImportResult { public int TotalRows { get; set; } public int SuccessRows { get; set; } public Liststring Errors { get; set; } new(); public DataTable? Preview { get; set; } } public class ExportResult { public byte[] FileBytes { get; set; } public string FileName { get; set; } public string ContentType { get; set; } application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; }接口的泛型导出ExportAsyncT有讲究EPPlus 的LoadFromCollection可以直接反射实体属性名作为列头但属性名未必是用户想看到的 Excel 表头所以我在ExportOptions里放一个字典做列名映射。ImportOptions里主要放HasHeaderRow、SheetIndex、ColumnMapColumnMap 负责把 Excel 列名映射到实体属性。这样设计后后续换组件、加缓存、做异步任务都只改实现类。2.3 数据模型映射DataSet、DataTable 到实体类的转换约定ExcelDataReader 默认把每个 Sheet 转成DataTable列名来自第一行如果开启了UseHeaderRow。DataTable 本身不是强类型所以 excel-handler 的核心工作量其实在“把 DataTable 的行转成实体对象”这一层。这里最容易踩的坑是Excel 里的空行、合并单元格、数字列被读成 double日期列被读成序列号字符串。约定越简单越不容易出错。我常用的一套约定是导入时只认表头文字不认列序号实体属性上挂一个ExcelColumnAttribute标注对应的表头名。转换时逐列比对遇到空字符串按 null 处理遇到数字先尝试转成目标类型失败则记到错误列表而不是直接抛异常。导出的列顺序由属性声明顺序决定或用ExportOptions.ColumnMap显式指定顺序和表头名。这套约定让导入导出两侧共用一个模型不用维护两套映射。3. 导入接口落地用 ExcelDataReader 解析上传的 xlsx3.1 Controller 层接收 IFormFile验证扩展名和大小导入接口的 Controller 代码很短但验证必须做全。只检查扩展名不够还要检查IFormFile.Length防止用户传一个 0 字节文件同时设置一个合理的大小上限ASP.NET Core 默认的MultipartBodyLengthLimit是 128MB但 Excel 导入一般 20MB 以内就够了超出的直接拒绝。[HttpPost(import)] [RequestSizeLimit(20 * 1024 * 1024)] public async TaskIActionResult Import(IFormFile file, CancellationToken ct) { if (file null || file.Length 0) return BadRequest(上传文件为空); var ext Path.GetExtension(file.FileName).ToLowerInvariant(); if (ext is not (.xls or .xlsx or .csv)) return BadRequest($不支持的文件类型: {ext}); await using var stream file.OpenReadStream(); var options new ImportOptions { HasHeaderRow true, SheetIndex 0, ColumnMap new Dictionarystring, string { [姓名] Name, [工号] EmployeeNo, [入职日期] HireDate } }; var result await _handler.ImportAsync(stream, file.FileName, options, ct); return Ok(result); }[RequestSizeLimit]是单个接口级别的限制优先级高于全局配置。如果部署在 IIS 后面还需要同步修改 web.config 里的maxAllowedContentLength否则 IIS 会在请求到达 Kestrel 之前就丢出 404.13。ColumnMap的 key 是 Excel 表头文字value 是实体属性名这个映射在导入逻辑里会逐一匹配。3.2 用 ExcelReaderFactory 创建读取器流位置与 LeaveOpenfile.OpenReadStream()返回的流 Position 默认在 0但如果你在 Controller 里先读了文件头做校验或者经过了某个中间件流的 Position 就可能不在起点。ExcelDataReader 不会自动帮你 Seek所以实现ImportAsync的第一步就是确保流从头开始。public async TaskImportResult ImportAsync(Stream fileStream, string fileName, ImportOptions options, CancellationToken ct) { if (fileStream.CanSeek) fileStream.Position 0; using var reader ExcelReaderFactory.CreateReader(fileStream); var dataSet reader.AsDataSet(new ExcelDataSetConfiguration { ConfigureDataTable _ new ExcelDataTableConfiguration { UseHeaderRow options.HasHeaderRow } }); var table dataSet.Tables[options.SheetIndex]; return ConvertTableToResult(table, options.ColumnMap); }CreateReader会自己嗅探文件格式不需要你根据扩展名指定。AsDataSet是一次性把整个工作簿读进内存适合单次导入如果文件特别大建议改用reader.Read()逐行循环。UseHeaderRow true会让 ExcelDataReader 把第一行当作列名并在内部去掉重复列名默认加 _1 后缀如果你的表头有合并单元格这里提前在 Excel 侧处理掉比在代码里兼容要省事得多。3.3 AsDataSet 取舍什么时候该关掉 UseHeaderRowUseHeaderRow是一把双刃剑。开启后DataTable.ColumnName变成表头文字代码可读性好但遇到以下情况必须关掉第一行是标题大标题、第一行有合并单元格、多级表头。这种情况下开启UseHeaderRow会导致列名变成空字符串或Column1这类自动名后面的映射全部错位。我的经验是ImportOptions里加一个RawMode开关默认 false。当业务方明确说“Excel 第一行不是表头”时前端传RawModetrue导入逻辑用列序号访问table.Rows[i][0]这种形式然后由业务代码自己去拼对象。如果你用AsDataSet且关闭UseHeaderRow列名默认为 0、1、2 这样的序号索引代码需要额外映射一份“第几列对应哪个字段”的配置。private DataTable BuildTableWithRawMode(DataSet dataSet, int sheetIndex) { var table dataSet.Tables[sheetIndex]; for (int i 0; i table.Columns.Count; i) { table.Columns[i].ColumnName $Col_{i}; } return table; }在做这个转换时记得把列的ColumnName改成可读的标记否则调试时看 DataTable 的列名全是数字很难判断数据是否错位。另外AsDataSet配置里还有一个FilterSheet委托可以在数据加载前过滤掉不需要的 Sheet但大多数场景下 Sheet 数量有限过滤的意义不大。3.4 逐行读取并组装实体处理日期和空值的样例代码如果文件行数在 10 万以上AsDataSet会导致 API 内存暴涨。更适合的路径是用ExcelDataReader的逐行读取接口一边读一边转实体不保留整个 DataTable。这里要注意Excel 的日期列读出来是 double 序列号需要通过reader.GetFieldType或尝试reader.GetDateTime来解析而空单元格在不同列类型下可能是 DBNull 或空字符串。using var reader ExcelReaderFactory.CreateReader(fileStream); var headers new Liststring(); // 读取表头行 if (options.HasHeaderRow) { reader.Read(); for (int i 0; i reader.FieldCount; i) headers.Add(reader.GetValue(i)?.ToString() ?? $Col_{i}); } while (reader.Read()) { var row new Dictionarystring, object(); for (int i 0; i reader.FieldCount; i) { var value reader.GetValue(i); row[headers[i]] value is DBNull ? null : value; } // 按 ColumnMap 映射到实体 var entity MapToEntity(row, options.ColumnMap); if (entity null) { result.Errors.Add($第 {reader.Depth 1} 行映射失败); continue; } // 业务处理保存实体 result.TotalRows; }reader.Depth返回的是当前行号从 0 开始比我们自己计数更准确。MapToEntity内部做日期转换时不要直接Convert.ToDateTime因为 ExcelDataReader 对日期有两种表现真正的时间戳会返回DateTime但某些 csv 转换场景会返回字符串“2025-01-01”。建议统一走TryParseDateTime辅助方法先认DateTime类型再解析字符串再尝试 OADate 序列号转换。这样三套日期格式都能收下来但要注意 OADate 的序列号基准是 1899-12-30不是 1900-01-01转换时算错一天是常见的事。4. 导出接口落地用 EPPlus 生成 .xlsx 并返回到浏览器4.1 初始化 ExcelPackageLicenseContext 必须显式设置EPPlus 从 5.0 开始强制要求设置LicenseContext否则直接抛异常。很多人部署到服务器上才发现开发环境好好的、生产环境导出接口 500原因就是没设这个配置。我一般放在 Program.cs 里统一设置using OfficeOpenXml; var builder WebApplication.CreateBuilder(args); ExcelPackage.LicenseContext LicenseContext.NonCommercial;LicenseContext.NonCommercial意味着非商业场景免费如果是企业内部商业系统需要评估对应的商用授权。这个设置是进程级的所以放在启动时一次性配置。另一个细节是如果项目里同时引用了多个版本的 EPPlus运行时可能只有一个版本生效NuGet 引用时务必要检查依赖链避免传递引用把旧版 EPPlus 拉进来。4.2 写入表头与格式化LoadFromCollection 和列宽设置EPPlus 写数据的常见做法是LoadFromCollection但直接用的结果往往很粗糙列头是属性名、列宽没调整、日期没有格式。我在ExportAsync里会先手动写一行表头再LoadFromCollection这样能完整控制样式。public async TaskExportResult ExportAsyncT(IEnumerableT data, ExportOptions options, CancellationToken ct) { using var package new ExcelPackage(); var sheet package.Workbook.Worksheets.Add(options.SheetName ?? Sheet1); var columns options.ColumnMap ?? BuildColumnMapFromTypeT(); for (int i 0; i columns.Count; i) { sheet.Cells[1, i 1].Value columns[i].Key; sheet.Cells[1, i 1].Style.Font.Bold true; } var rowIndex 2; foreach (var item in data) { for (int i 0; i columns.Count; i) { var prop typeof(T).GetProperty(columns[i].Value); var value prop?.GetValue(item); sheet.Cells[rowIndex, i 1].Value value; } // 日期列单独设置格式 sheet.Cells[rowIndex, options.DateColumns].Style.Numberformat.Format yyyy-MM-dd; rowIndex; if (rowIndex % 5000 0) await Task.Yield(); } sheet.Cells.AutoFitColumns(); var bytes await package.GetAsByteArrayAsync(ct); return new ExportResult { FileBytes bytes, FileName options.FileName }; }AutoFitColumns在数据量小的时候好用但超过 1 万行会明显拖慢速度。我一般会加一个AutoFit开关大数据量导出时关掉自动列宽改用固定列宽。GetAsByteArrayAsync会把整个包序列化到内存如果数据量特别大建议直接package.SaveAs(stream)写到FileStream避免申请一块大字节数组。4.3 返回文件的三种方式FileStreamResult、字节数组和 Content-Disposition导出接口返回文件给浏览器Controller 里有几种写法。字节数组最直接但大文件会多占用一份内存FileStreamResult更友好但要注意流的生命周期。文件名带中文时必须做编码处理否则浏览器下载下来的文件名是乱码。[HttpPost(export)] public async TaskIActionResult Export(CancellationToken ct) { var data await _employeeService.GetAllAsync(ct); var options new ExportOptions { FileName $员工名单_{DateTime.Now:yyyyMMddHHmmss}.xlsx, SheetName 员工, ColumnMap new Dictionarystring, string { [姓名] Name, [工号] EmployeeNo, [入职日期] HireDate }, DateColumns new Listint { 3 } }; var result await _handler.ExportAsync(data, options, ct); var contentDisposition new ContentDispositionHeaderValue(attachment) { FileNameStar result.FileName, FileName export.xlsx }; Response.Headers[Content-Disposition] contentDisposition.ToString(); return File(result.FileBytes, result.ContentType); }FileNameStar是 RFC 5987 标准写法浏览器会优先识别这个字段并做 UTF-8 解码中文文件名就不会乱码。FileName留一个纯 ASCII 的兜底值兼容老浏览器。Response.Headers的赋值必须在File返回前完成否则响应头已经发出去了就晚了。用FileResult时ASP.NET Core 会自动处理 ETag 和缓存但如果你手动设了Content-Disposition不要再重复设置Content-Type避免出现两个响应头。4.4 大数据量导出分批写入和内存考量当导出行数超过 5 万你会在生产环境观察到 API 内存曲线直线上升。EPPlus 的ExcelPackage默认把工作簿内容保存在内存里写入时可以用package.Workbook.Worksheets.Add后直接操作Cells[row, col]这个过程本身不是内存大户真正的大户是GetAsByteArrayAsync和LoadFromCollection的反射缓存。我的做法是大于 1 万行的导出不返回byte[]而是直接用FileStream写临时文件返回PhysicalFileResult。临时文件用完就删但这个删除动作要放在OnCompleted回调里否则响应还没发完文件就被删了。var tempFile Path.Combine(Path.GetTempPath(), ${Guid.NewGuid():N}.xlsx); await using (var fs new FileStream(tempFile, FileMode.Create)) { await package.SaveAsAsync(fs, ct); } Response.OnCompleted(() { try { System.IO.File.Delete(tempFile); } catch { /* 忽略清理失败 */ } }); return PhysicalFile(tempFile, result.ContentType, result.FileName);SaveAsAsync写入流是边写边落盘内存占用比GetAsByteArrayAsync低很多。临时目录要放在有足够磁盘空间的分区Path.GetTempPath()在 Windows 上默认是 C 盘如果服务器 C 盘空间紧张建议单独配一个导出临时目录。这个方案里还有一个容易被忽略的坑Kestrel 默认对响应体大小没有限制但反向代理如 Nginx有proxy_buffering大文件下载时 Nginx 默认会缓冲到磁盘需要确认代理层不会超时或占用太多磁盘。5. 避坑与排查从部署到下载Excel 导入导出最容易翻车的地方5.1 EPPlus 抛出 LicenseException现象、原因、解决现象本地跑得好好的发布 webapi 项目到服务器后一调导出接口就 500日志里出现LicenseException: The license context is not set。原因EPPlus 5.x 将 LicenseContext 改为强制显式设置且这个设置在 appsettings.json 里配了也不生效必须在进程启动时代码赋值。解决在Program.cs里加入ExcelPackage.LicenseContext LicenseContext.NonCommercial;并且确认项目里没有第二个旧版 EPPlus 通过传递依赖被引用。排查时可以看启动日志如果 EPPlus 版本被静默升级或降级LicenseContext 的类型可能对不上编译期不报错运行期就抛异常。5.2 读取 .xls 报错或读出来全是空流位置和分隔符的问题现象同一个 Excel 文件用 ExcelDataReader 读取上次还是好的这次读出来 DataTable 里全是空行或者直接抛ArgumentException。原因绝大多数是流的Position不在 0或者文件流已经被上一次读取消费掉了。ExcelDataReader 不会自动 Seek如果你在读取前做过StreamReader包装它会提前读掉缓冲区。解决在CreateReader前判断CanSeek把 Position 归零如果流已经不可 Seek把文件复制到内存流再交给 ExcelDataReader。另一个场景是 csv 文件ExcelDataReader 默认按逗号分隔如果你的 csv 是分号分隔或带 BOM读取结果会串列这时要在ExcelReaderConfiguration里设置Encoding和AutodetectSeparators。5.3 导出文件名在浏览器乱码Content-Disposition 的编码问题现象接口返回 200文件也能打开但浏览器下载下来的文件名是%E5%91%98%E5%B7%A5.xlsx或____.xlsx。原因Content-Disposition里用了错误的编码方式。解决不要用FileName直接塞中文要设置FileNameStar并且不要在编程里手动做 URL 编码ContentDispositionHeaderValue内部会处理。注意FileNameStar的值是未编码的中文文件名如果你传入的是已经UrlEncode过的字符串文件名会变成双重编码、显示带%号。5.4 发布 webapi 项目后上传大文件被拒Kestrel 与 IIS 限制现象开发环境上传 30MB 的 Excel 没问题发布到 Windows 服务器 IIS 后上传接口直接返回 404.13 或 413。原因Kestrel 有MaxRequestBodySize默认约 30MBIIS 的maxAllowedContentLength默认 30MB两层限制任何一个触发都会拒绝请求。解决在Program.cs里调整 Kestrel 限制同时改web.config里的maxAllowedContentLength并且 Controller 上的[RequestSizeLimit]必须小于或等于这两个值。用 webapi 模拟器调试时看到返回 413 就要立刻意识到是请求体限制而不是代码逻辑问题。builder.WebHost.ConfigureKestrel(options { options.Limits.MaxRequestBodySize 50 * 1024 * 1024; });web.config的配置要放在system.webServersecurityrequestFiltering节点下改了之后要回收应用程序池才能生效。这个坑所以常见是因为很多人只改了 Kestrel 的配置忘了 IIS 层还有一道关卡。5.5 webapi 模拟器看不到导出文件内容其实是没看响应头或用错了参数现象用 Postman、Apifox 这类 webapi 模拟器调导出接口返回的 Body 是一堆乱码或二进制有人以为接口写错了。原因导出接口返回的是文件流模拟器只是把字节按文本显示了。解决在模拟器里不要看 Body 渲染要看响应头里的Content-Disposition和Content-Type然后把响应保存为文件再打开验证。如果是 POST 导出还要确认模拟器传的 body 参数名与 Controller 的[FromBody]或IFormFile参数名一致常见错误是前端传的是 JSON后端却用IFormFile接收导致文件内容压根没到 API。6. 收尾把 excel-handler 的输出校验做成一个自动化小流程excel-handler 这个方向做到后面真正拉开差距的不是读写代码本身而是输出校验。我有一次在生成报表时把某个字段在ColumnMap里配错了列导出的 Excel 能打开、内容也不报错但业务方一核对就发现数据错位。后来我给自己定了一条规矩所有导出接口在交付前必须跑一遍“写后读校验”——用 EPPlus 生成文件后再用 ExcelDataReader 把文件读回来对比每个 Sheet 的列数和首行数据确认列顺序和值类型符合预期。校验脚本可以做成一个 ASP.NET Core 的集成测试也可以是一个独立控制台方法。核心逻辑是导出完拿到byte[]直接放进MemoryStream交给 ExcelDataReader 的CreateReader读一遍断言列头数组等于options.ColumnMap.Keys再随机抽三行数据比对主键列的值。这样每一次改动 ColumnMap 或调整导出格式都能在 CI 里自动发现错位而不是让业务方去肉眼看 Excel。另一个值得做的验证是导出文件的体积和行数通过读取reader.RowCount断言导出行数等于查询结果行数能同时拦住“查询漏了数据”和“导出中途被截断”两类问题。现在我在新的 Webapi 项目里都会要求把 Excel 读写封装成独立服务并附上一条自动校验脚本。这条路跑顺之后Excel 导入导出就不再是每个迭代都提心吊胆的“黑匣子”而是一个有测试兜底的普通接口。希望这个方案能帮你少踩几个 EPPlus 和 ExcelDataReader 的坑让 excel-handler 成为你自己项目里的顺手工具。本文还有配套的精品资源点击获取