☰
ASP.NET Core中Excel导入导出方案:ExcelDataReader与EPPlus结合
2026/9/26 5:02:16 网站建设 项目流程

简介:面向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 { Task<ImportResult> ImportAsync(Stream fileStream, string fileName, ImportOptions options, CancellationToken ct); Task<ExportResult> ExportAsync<T>(IEnumerable<T> data, ExportOptions options, CancellationToken ct); } public class ImportResult { public int TotalRows { get; set; } public int SuccessRows { get; set; } public List<string> 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"; }

接口的泛型导出ExportAsync<T>有讲究:EPPlus 的LoadFromCollection可以直接反射实体属性名作为列头,但属性名未必是用户想看到的 Excel 表头,所以我在ExportOptions里放一个字典做列名映射。ImportOptions里主要放HasHeaderRow、SheetIndex、ColumnMap,ColumnMap 负责把 Excel 列名映射到实体属性。这样设计后,后续换组件、加缓存、做异步任务都只改实现类。

2.3 数据模型映射:DataSet、DataTable 到实体类的转换约定

ExcelDataReader 默认把每个 Sheet 转成DataTable,列名来自第一行(如果开启了UseHeaderRow)。DataTable 本身不是强类型,所以 excel-handler 的核心工作量其实在“把 DataTable 的行转成实体对象”这一层。这里最容易踩的坑是:Excel 里的空行、合并单元格、数字列被读成 double,日期列被读成序列号字符串。

约定越简单越不容易出错。我常用的一套约定是:导入时只认表头文字,不认列序号;实体属性上挂一个ExcelColumnAttribute,标注对应的表头名。转换时逐列比对,遇到空字符串按 null 处理,遇到数字先尝试转成目标类型,失败则记到错误列表而不是直接抛异常。导出的列顺序由属性声明顺序决定,或用ExportOptions.ColumnMap显式指定顺序和表头名。这套约定让导入导出两侧共用一个模型,不用维护两套映射。

3. 导入接口落地:用 ExcelDataReader 解析上传的 xlsx

3.1 Controller 层接收 IFormFile:验证扩展名和大小

导入接口的 Controller 代码很短,但验证必须做全。只检查扩展名不够,还要检查IFormFile.Length,防止用户传一个 0 字节文件;同时设置一个合理的大小上限,ASP.NET Core 默认的MultipartBodyLengthLimit是 128MB,但 Excel 导入一般 20MB 以内就够了,超出的直接拒绝。

[HttpPost("import")] [RequestSizeLimit(20 * 1024 * 1024)] public async Task<IActionResult> 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 Dictionary<string, 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 创建读取器:流位置与 LeaveOpen

file.OpenReadStream()返回的流 Position 默认在 0,但如果你在 Controller 里先读了文件头做校验,或者经过了某个中间件,流的 Position 就可能不在起点。ExcelDataReader 不会自动帮你 Seek,所以实现ImportAsync的第一步就是确保流从头开始。

public async Task<ImportResult> 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 取舍:什么时候该关掉 UseHeaderRow

UseHeaderRow是一把双刃剑。开启后DataTable.ColumnName变成表头文字,代码可读性好;但遇到以下情况必须关掉:第一行是标题大标题、第一行有合并单元格、多级表头。这种情况下,开启UseHeaderRow会导致列名变成空字符串或Column1这类自动名,后面的映射全部错位。

我的经验是:ImportOptions里加一个RawMode开关,默认 false。当业务方明确说“Excel 第一行不是表头”时,前端传RawMode=true,导入逻辑用列序号访问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 List<string>(); // 读取表头行 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 Dictionary<string, 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 初始化 ExcelPackage:LicenseContext 必须显式设置

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 Task<ExportResult> ExportAsync<T>(IEnumerable<T> data, ExportOptions options, CancellationToken ct) { using var package = new ExcelPackage(); var sheet = package.Workbook.Worksheets.Add(options.SheetName ?? "Sheet1"); var columns = options.ColumnMap ?? BuildColumnMapFromType<T>(); 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 Task<IActionResult> Export(CancellationToken ct) { var data = await _employeeService.GetAllAsync(ct); var options = new ExportOptions { FileName = $"员工名单_{DateTime.Now:yyyyMMddHHmmss}.xlsx", SheetName = "员工", ColumnMap = new Dictionary<string, string> { ["姓名"] = "Name", ["工号"] = "EmployeeNo", ["入职日期"] = "HireDate" }, DateColumns = new List<int> { 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默认约 30MB,IIS 的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.webServer><security><requestFiltering>节点下,改了之后要回收应用程序池才能生效。这个坑所以常见,是因为很多人只改了 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 成为你自己项目里的顺手工具。

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

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

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

立即咨询