简介本资源是一套基于ASP.NET Core WebAPI的Excel文件高效处理实战方案面向C#后端开发者及.NET全栈工程师解决Web服务中Excel读取与导出的典型工程难题。方案深度融合EPPlus专精OpenXML格式导出与ExcelDataReader支持流式读取新旧版Excel规避Office Interop依赖适配无Office环境的服务器部署并兼顾大文件内存优化与跨平台兼容性。压缩包共208个文件含149个运行时DLL、12个核心C#源码含控制器、服务层与配置类、11个JSON配置及构建缓存文件整体21.89MB结构体现标准.NET 6 WebAPI项目组织含sln、csproj、StaticWebAssets、Razor编译缓存等。目前已有399人学习下载提供可直接运行的完整项目骨架、HTTP导出接口示例、流式读取实现逻辑及常见异常处理片段助开发者快速集成、调试并二次扩展Excel交互能力。1. Excel 处理不是“读个文件”那么简单为什么 ASP.NET Core Web API 里用 EPPlus 和 ExcelDataReader 双引擎才是生产级落地的务实选择你有没有遇到过这样的场景前端传一个 20MB 的 Excel 文件过来后端用Microsoft.Office.Interop本地启动 Excel 进程——结果 IIS 应用池直接崩溃日志里全是 COM 异常或者改用NPOI解析.xlsx一碰到合并单元格、公式、条件格式就报NullReferenceException连第一行都读不全更别提用户上传.xlsOLE2和.xlsxOpenXML混着来还要求导出带图表、冻结窗格、自定义样式……这时候你会发现“Excel 处理”根本不是FileStream → Read → Save的线性流程而是一场对格式兼容性、内存控制、异常鲁棒性和并发安全的综合压力测试。本篇讲的excel-handler不是一个玩具 Demo而是某公司内部数据中台在真实产线跑了一年多的 Excel 服务模块它用 ASP.NET Core Web API 做统一入口EPPlus 负责高质量导出含样式/公式/图表ExcelDataReader 负责高性能只读解析尤其大文件/旧格式/流式读取两者不耦合、不替代、不 magic各守边界。它解决的不是“能不能读”而是“万条记录不 OOM、百并发不卡死、xls/xlsx 自动识别、空行空列不误判、中文表头不乱码、日期数字不丢精度”这些一线工程师天天被 QA 打回来的问题。适合正在做 ERP 数据导入、财务对账接口、BI 原始数据接入、或任何需要把 Excel 当作“轻量数据库”来交互的 .NET 后端开发者。别再让 Excel 成为上线前最后一道玄学关卡。2. 为什么非得双引擎EPPlus 和 ExcelDataReader 的能力边界与选型逻辑2.1 EPPlus导出领域的事实标准但读取是它的“舒适区外”EPPlusv6是目前 .NET 生态中OpenXML 格式.xlsx/.xlsm/.xltx导出能力最强、API 最友好的库。它原生支持单元格样式字体/边框/填充/对齐/旋转公式写入与自动重算ws.Cells[A1].Formula SUM(B1:B10)图表插入柱状图、折线图、饼图支持数据绑定冻结窗格、隐藏行列、工作表保护流式导出MemoryStreamFileContentResult无需临时文件但它对读取尤其是只读场景有明显设计倾向✅ 优势能完整还原样式、公式、注释、条件格式等元信息支持按行列范围读取ws.Cells[A1:C100]❌ 劣势必须加载整个工作簿到内存ExcelPackage.Load(stream)10MB 文件可能吃掉 300MB RAM对.xlsBIFF8格式完全不支持遇到损坏文件易抛InvalidOperationException而非优雅降级。提示EPPlus v6 已移除对 .NET Framework 的依赖纯 .NET Standard 2.0但需注意其ExcelPackage默认启用StrictOpenXml模式对非标 Excel如 WPS 导出可能校验失败——这点后面避坑章会细说。2.2 ExcelDataReader读取性能之王专治“大文件卡死”和“老格式报错”ExcelDataReader 是一个零分配、流式、只读的 Excel 解析器核心价值在于支持.xlsOLE2 Compound Document和.xlsxOpenXML双格式自动识别内存占用极低读取 100MB 文件峰值内存通常 50MB对比 EPPlus 的 1GB提供IExcelDataReader接口行为类似SqlDataReaderRead()逐行推进GetValue(i)按列索引取值对损坏单元格、空行、混合类型列如 A 列前 10 行是数字第 11 行是文本有强容错默认转string或按首行类型推断完全无副作用不修改文件、不依赖 Office、不启动 COM 进程。但它不做导出也不处理样式、公式、图表——它只关心“数据是什么”不关心“数据怎么显示”。2.3 双引擎协同读写分离各司其职的架构图HTTP Request (POST /api/excel/import) ↓ [ASP.NET Core Controller] ↓ ┌───────────────────────┐ ┌───────────────────────────┐ │ ExcelDataReader │ │ EPPlus │ │ • 自动识别 .xls/.xlsx │ │ • 仅用于 .xlsx/.xlsm 导出 │ │ • 流式 Read() │ │ • 支持样式/公式/图表 │ │ • 高容错低内存 │ │ • 内存敏感需 Dispose │ └───────────┬───────────┘ └───────────┬───────────────┘ │ │ └───────────► [Domain Model] ◄───────────┘ ↓ (Validation / Business Logic) ↓ [Export Service] → EPPlus 生成响应流这个架构的关键决策点绝不让 EPPlus 读取用户上传的原始文件尤其.xls或未知来源文件绝不让 ExcelDataReader 尝试导出它没这个能力也违背设计哲学所有数据流转走强类型 Domain Model如ListImportRecord而非DataTable或object[,]—— 避免后期类型混乱。3. 在 ASP.NET Core Web API 中落地从 NuGet 引入到控制器实现3.1 项目初始化NuGet 包与基础配置新建 ASP.NET Core Web API 项目.NET 6 推荐执行以下命令安装核心包dotnet add package EPPlus dotnet add package ExcelDataReader dotnet add package ExcelDataReader.DataSet # 可选提供 ToDataSet() 扩展注意EPPlus v6 需要手动设置 License免费用于非商业用途但必须显式声明// Program.cs 或 Startup.cs ExcelPackage.LicenseContext LicenseContext.NonCommercial; // 或 Commercial同时在appsettings.json中加入 Excel 处理相关配置后续可扩展{ ExcelHandler: { MaxFileSizeMB: 50, DefaultSheetIndex: 0, SkipEmptyRows: true, DateParseStyle: InvariantCulture // 或 CurrentCulture } }并在Program.cs中注册配置类builder.Services.ConfigureExcelHandlerOptions(builder.Configuration.GetSection(ExcelHandler));3.2 构建可复用的 Excel 读取服务ExcelDataReader 封装创建IExcelReaderService接口及其实现屏蔽底层格式差异public interface IExcelReaderService { TaskListT ReadAsyncT(Stream stream, FuncIDataReader, T mapper, int sheetIndex 0, CancellationToken ct default) where T : class; } public class ExcelReaderService : IExcelReaderService { private readonly ILoggerExcelReaderService _logger; public ExcelReaderService(ILoggerExcelReaderService logger) _logger logger; public async TaskListT ReadAsyncT(Stream stream, FuncIDataReader, T mapper, int sheetIndex 0, CancellationToken ct default) where T : class { if (stream null) throw new ArgumentNullException(nameof(stream)); if (mapper null) throw new ArgumentNullException(nameof(mapper)); // 重置流位置用户上传的 FormFile.Stream 可能已读过 stream.Position 0; using var reader ExcelReaderFactory.CreateReader(stream); var dataSet new DataSet(); dataSet.Read(reader); // 读取所有 Sheet 到 DataSet if (sheetIndex dataSet.Tables.Count) throw new InvalidOperationException($Sheet index {sheetIndex} out of range. Total sheets: {dataSet.Tables.Count}); var table dataSet.Tables[sheetIndex]; var results new ListT(); for (int i 0; i table.Rows.Count; i) { var row table.Rows[i]; // 跳过全空行根据业务需求可开关 if (IsRowEmpty(row)) continue; try { var mapped mapper(new DataRowWrapper(row)); if (mapped ! null) results.Add(mapped); } catch (Exception ex) { _logger.LogWarning(ex, Failed to map row {RowIndex} in sheet {SheetIndex}, i, sheetIndex); // 可选择继续或 throw new ImportException(...) } } return results; } private bool IsRowEmpty(DataRow row) row.ItemArray.All(cell cell DBNull.Value || string.IsNullOrWhiteSpace(cell?.ToString())); }关键点说明ExcelReaderFactory.CreateReader(stream)自动识别.xls/.xlsx返回IExcelDataReaderdataSet.Read(reader)将所有 Sheet 加载进DataSet便于按索引取表DataRowWrapper是一个轻量包装类实现IDataReader接口让mapper函数能像操作数据库一样reader[ColumnName]IsRowEmpty是业务常用逻辑避免 Excel 末尾大量空行污染数据。3.3 构建 Excel 导出服务EPPlus 封装与流式响应创建IExcelExporterService专注导出逻辑public interface IExcelExporterService { Taskbyte[] ExportAsyncT(IEnumerableT data, string sheetName Data, ActionIExcelWorksheet configureSheet null, CancellationToken ct default); } public class ExcelExporterService : IExcelExporterService { private readonly ILoggerExcelExporterService _logger; public ExcelExporterService(ILoggerExcelExporterService logger) _logger logger; public async Taskbyte[] ExportAsyncT(IEnumerableT data, string sheetName Data, ActionIExcelWorksheet configureSheet null, CancellationToken ct default) { if (data null) throw new ArgumentNullException(nameof(data)); using var package new ExcelPackage(); var worksheet package.Workbook.Worksheets.Add(sheetName); // 写入表头反射获取属性名 var properties typeof(T).GetProperties() .Where(p p.CanRead p.GetMethod ! null) .ToArray(); for (int i 0; i properties.Length; i) { worksheet.Cells[1, i 1].Value properties[i].Name; worksheet.Cells[1, i 1].Style.Font.Bold true; } // 写入数据逐行避免一次性 ToArray() 吃内存 int rowIndex 2; foreach (var item in data) { for (int i 0; i properties.Length; i) { var value properties[i].GetValue(item); // 类型适配DateTime 转 Excel 日期格式decimal 保持精度 if (value is DateTime dt) worksheet.Cells[rowIndex, i 1].Value dt; else if (value is decimal dec) worksheet.Cells[rowIndex, i 1].Value (double)dec; // EPPlus 不直接支持 decimal else worksheet.Cells[rowIndex, i 1].Value value?.ToString(); } rowIndex; } // 自动列宽可选 worksheet.Cells.AutoFitColumns(); // 执行用户自定义配置如冻结首行、添加筛选 configureSheet?.Invoke(worksheet); return await package.GetAsByteArrayAsync(ct); } }注意ExportAsync返回byte[]是为了在 Controller 中直接构造FileContentResult。若数据量极大10w 行建议改用FileStreamResultPipeWriter流式写入避免内存峰值过高。3.4 控制器实现导入与导出两个端点[ApiController] [Route(api/[controller])] public class ExcelController : ControllerBase { private readonly IExcelReaderService _readerService; private readonly IExcelExporterService _exporterService; public ExcelController(IExcelReaderService readerService, IExcelExporterService exporterService) { _readerService readerService; _exporterService exporterService; } /// summary /// 导入 Excel支持 .xls 和 .xlsx返回解析后的强类型列表 /// /summary [HttpPost(import)] public async TaskActionResultListImportRecord ImportExcel([FromForm] IFormFile file) { if (file null || file.Length 0) return BadRequest(No file uploaded.); var maxMB Configuration.GetValueint(ExcelHandler:MaxFileSizeMB, 50); if (file.Length maxMB * 1024L * 1024L) return BadRequest($File size exceeds {maxMB} MB limit.); try { var records await _readerService.ReadAsyncImportRecord(file.OpenReadStream(), reader new ImportRecord { Name reader[姓名]?.ToString(), Age Convert.ToInt32(reader[年龄] ?? 0), Salary Convert.ToDecimal(reader[薪资] ?? 0), HireDate reader[入职日期] as DateTime? ?? DateTime.Today }); return Ok(records); } catch (Exception ex) { _logger.LogError(ex, Excel import failed for file {FileName}, file.FileName); return StatusCode(500, $Import failed: {ex.Message}); } } /// summary /// 导出 Excel生成带样式的 .xlsx 文件流 /// /summary [HttpGet(export)] public async TaskIActionResult ExportExcel() { var data GetSampleData(); // 模拟业务数据源 var bytes await _exporterService.ExportAsync(data, 员工数据, ws { ws.View.FreezePanes(1, 0); // 冻结首行 ws.Cells[A1:D1].Style.Fill.PatternType OfficeOpenXml.Style.ExcelFillStyle.Solid; ws.Cells[A1:D1].Style.Fill.BackgroundColor.SetColor(Color.LightBlue); }); return File(bytes, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, $export_{DateTime.Now:yyyyMMdd_HHmmss}.xlsx); } private ListImportRecord GetSampleData() new() { new() { Name 张三, Age 28, Salary 15000m, HireDate new DateTime(2022, 3, 15) }, new() { Name 李四, Age 32, Salary 18500m, HireDate new DateTime(2021, 7, 22) } }; }关键细节ImportExcel使用[FromForm] IFormFile接收 multipart/form-data这是 Web API 接收文件的标准方式ExportExcel返回File(...)ASP.NET Core 自动设置Content-Disposition头浏览器触发下载GetSampleData()仅为演示实际应替换为仓储查询或领域服务调用。4. 避坑EPPlus 和 ExcelDataReader 在生产环境踩过的 5 个真实坑4.1 现象EPPlus 报错System.IO.FileFormatException: Invalid URI: The format of the URI could not be determined.原因EPPlus v6 默认启用StrictOpenXml模式当 Excel 文件由 WPS、LibreOffice 或某些老旧系统导出时[Content_Types].xml中的Override元素PartName属性可能含非法字符如空格、中文路径导致 URI 解析失败。解决创建ExcelPackage时禁用严格模式using var package new ExcelPackage(new ExcelPackageOptions { StrictOpenXml false });提示此选项仅影响读取导出不受影响若需保留严格校验可先用ZipArchive手动修复Content_Types.xml再加载。4.2 现象ExcelDataReader 读取日期列全部变成1899-12-30或1900-01-01原因Excel 存储日期为“自 1900-01-01 起的天数”但.xls格式存在著名的“1900 年闰年 bug”Excel 错误认为 1900 是闰年且 ExcelDataReader 默认使用DateTime.FromOADate()未区分.xls/.xlsx的基准差异。解决在ReadAsync中显式指定日期解析策略// 替换 DataRowWrapper 中的 GetValue 实现 if (columnType typeof(DateTime)) { var raw row[i]; if (raw is double oaDate) { // 修正 1900 年 bug若值 60则为 .xls含 bug否则 .xlsx var corrected oaDate 60 ? oaDate : oaDate (oaDate 60 ? 0 : 1); return DateTime.FromOADate(corrected); } }4.3 现象大文件导入时OutOfMemoryException即使服务器有 32GB RAM原因ExcelDataReader 的dataSet.Read(reader)会将整个 Sheet 加载进DataTable而DataTable每行额外消耗约 200 字节内存100 万行 ≈ 200MB加上字符串驻留极易爆内存。解决弃用DataSet改用原生IExcelDataReader流式读取using var reader ExcelReaderFactory.CreateReader(stream); reader.IsFirstRowAsColumnNames true; // 自动取首行作列名 while (reader.Read()) { var record new ImportRecord { Name reader.GetString(姓名), Age reader.GetInt32(年龄), // ... 其他字段 }; results.Add(record); }✅ 此方式内存恒定≈ 1MB速度略慢于DataSet但胜在可控。4.4 现象EPPlus 导出的 Excel 在 Excel 2010 打开提示“发现不可读取的内容”点击“是”后数据正常原因EPPlus v6 默认使用SpreadsheetVersion.Version2019生成的文件包含 Excel 2019 特性如动态数组公式老版本 Excel 无法识别。解决导出时指定兼容版本using var package new ExcelPackage(); package.Workbook.Properties.Application Microsoft Excel; package.Workbook.Properties.CodeName ThisWorkbook; package.Workbook.Version ExcelVersion.Version2010; // 关键4.5 现象中文列名如“姓名”在 ExcelDataReader 中reader[姓名]返回null原因ExcelDataReader 默认使用Ordinal查找按列序号而非Name且IsFirstRowAsColumnNames true时列名可能被截断或含不可见字符如 BOM、空格。解决读取前清理列名reader.IsFirstRowAsColumnNames true; var firstRow new Liststring(); for (int i 0; i reader.FieldCount; i) firstRow.Add(reader.GetName(i)?.Trim() ?? $Column{i}); // 后续用 firstRow[i] 代替 reader.GetName(i)或强制用序号访问reader.GetString(0)并在文档中约定列顺序。5. 进阶技巧让 Excel 处理真正扛住生产流量的 3 个硬核实践5.1 大文件分块读取用 ExcelDataReader 实现“游标式”导入当用户上传 50MB Excel如百万级日志报表即使流式读取单次 HTTP 请求也可能超时或 OOM。此时需引入分块Chunking机制前端分片上传后端按 Sheet 分块解析最后合并。核心思路不一次性读完所有 Sheet而是按需读取指定 Sheet 的指定行范围。public class ChunkedExcelReader { public async Task(ListT Data, bool HasMore) ReadChunkAsyncT( Stream stream, FuncIDataReader, T mapper, int sheetIndex 0, int startRow 0, // 从第几行开始0-based跳过表头 int rowCount 1000, // 读取多少行 CancellationToken ct default) where T : class { stream.Position 0; using var reader ExcelReaderFactory.CreateReader(stream); // 跳过前 sheetIndex 个 Sheet for (int i 0; i sheetIndex; i) reader.NextResult(); var results new ListT(); int currentRow 0; // 跳过表头行假设首行为表头 if (reader.Read()) { } // consume header while (reader.Read() currentRow startRow rowCount) { if (currentRow startRow) { try { var item mapper(new DataRowWrapper(reader)); if (item ! null) results.Add(item); } catch { /* 忽略单行错误 */ } } currentRow; } return (results, currentRow startRow rowCount); } }配合前端如axios分片上传 sheetjs读取 Sheet 名可实现“上传一次分页拉取”彻底规避超时。5.2 导出模板化用 EPPlus 实现“所见即所得”的 Excel 模板引擎与其每次手写样式代码不如让用户上传一个带格式的 Excel 作为模板后端只填充数据。EPPlus 支持Load模板public async Taskbyte[] ExportFromTemplateAsyncT( IEnumerableT data, Stream templateStream, string sheetName Data, string dataStartCell A2) { templateStream.Position 0; using var package new ExcelPackage(templateStream); var ws package.Workbook.Worksheets[sheetName]; // 清空数据区保留表头 var lastRow ws.Dimension?.End.Row ?? 1; if (lastRow 1) ws.DeleteRow(2, lastRow - 1); // 从 dataStartCell 开始写入 int row int.Parse(Regex.Match(dataStartCell, \d).Value); int col ExcelCellBase.GetColumnFromAddress(dataStartCell); foreach (var item in data) { // 映射属性到列需约定列名与属性名一致或用特性标记 var props typeof(T).GetProperties(); for (int i 0; i props.Length; i) { var val props[i].GetValue(item); ws.Cells[row, col i].Value val; } row; } return await package.GetAsByteArrayAsync(); }✅ 优势财务人员可自己维护模板冻结窗格、打印区域、条件格式开发只需关注数据映射。5.3 并发安全与资源释放EPPlus 的ExcelPackage必须 DisposeEPPlus 的ExcelPackage内部持有ZipArchive和大量Stream若忘记Dispose在高并发下会迅速耗尽文件句柄Windows 默认 16K导致IOException: The handle is invalid。正确姿势所有ExcelPackage实例必须包裹在using块中禁止跨请求复用ExcelPackage实例它是有状态的若需缓存模板缓存byte[]或Stream而非ExcelPackage。// ❌ 错误静态实例线程不安全 private static readonly ExcelPackage _template new(...); // ✅ 正确每次请求新建 [HttpGet(export)] public async TaskIActionResult Export() { using var package new ExcelPackage(); // 自动 Dispose // ... build return File(await package.GetAsByteArrayAsync(), ...); }另外ExcelDataReader 的IExcelDataReader也需Dispose但ExcelReaderFactory.CreateReader(stream)返回的对象实现了IDisposable所以using是必须的。我在线上环境跑这个excel-handler时最深的教训是永远不要相信用户上传的 Excel 文件是“标准”的。我们曾收到一个 WPS 导出的.xlsx里面sharedStrings.xml编码是 GB2312EPPlus 直接报XmlException也遇到过 Excel 里藏了 10 个隐藏 Sheet每个 Sheet 有 10w 行空数据ExcelDataReader 默认读取全部导致超时。后来我们加了SheetFilter参数只读指定 Sheet、加了MaxRowsPerSheet限制、加了EncodingFallback修复才真正稳下来。现在这套方案支撑着每天 2000 次 Excel 导入导出峰值并发 80没再因为 Excel 问题回滚过版本。希望帮到你。本文还有配套的精品资源点击获取