简介:这是一份面向ASP.NET Core WebAPI开发者的Excel处理实战资源,聚焦如何使用EPPlus与ExcelDataReader两个开源库完成Excel文件的读取与导出,解决Web应用中的常见表格处理需求。资源以Visual Studio工程形式提供,项目名为excel-handler,共208个文件,压缩包约21.89MB。其中149个dll对应EPPlus、ExcelDataReader等第三方依赖及其传递引用,帮助离线还原构建环境;12个cs为控制器、启动类与核心逻辑源码;另有sln、csproj、json等工程与配置说明文件,以及pdb调试符号和cache编译缓存,可直接打开、编译并运行示例接口。内容完整呈现了通过FileStream打开文件、ExcelReaderFactory创建读取器逐行遍历单元格,以及利用ExcelPackage创建工作表、写入数据并借助MemoryStream输出xlsx的详细链路;同时给出HTTP响应中MIME类型与Content-Disposition文件名的设置方式,并延伸讨论大文件流式处理、内存占用控制与错误处理思路,这些关键代码均来自可直接运行的示例接口,降低了工程落地门槛。已有400人学习下载,适合希望在服务器端摆脱Office Interop依赖、快速集成Excel读写功能的.NET后端开发者复用。
1. excel-handler: Web API 里读写 Excel 的务实组合方案
做接口开发时,最烦的工作之一就是在 Web API 里和 Excel 打交道。导入时要解析用户上传的 xlsx,导出时要生成带格式的报表,而且这两件事的需求往往同时出现在一个项目里。excel-handler 这个项目给出的答案是:读取用 ExcelDataReader,导出用 EPPlus,各自只做自己最擅长的事。这套组合在 .NET 生态里很常见,但真正落地时,库的授权边界、流式读取的内存占用、导出时的样式丢失等问题,都会让新手翻车。这篇笔记会从选型讲到实现,再把我踩过的几个坑完整写出来。适合正在搭建导入导出功能、或准备从 NPOI 迁移过来的 ASP.NET Core 开发。
2. EPPlus 与 ExcelDataReader 的选型逻辑:一个管格式,一个管速度
2.1 EPPlus 的定位:样式渲染是它的护城河
EPPlus 是一个基于 OpenXML 的 Excel 操作库,核心卖点是表格样式、合并单元格、图表、公式计算这些复杂能力。在生成报表的场景下,直接用 EPPlus 可以省掉手工拼接 Office Open XML 的工作。你不需要关心 XML 命名空间、单元格引用关系,一个 ExcelPackage 对象就能把整个工作簿组织起来。
但用 EPPlus 的前提是注意它的许可证边界。非商业场景或者满足特定条件的商业项目可以免费使用,但很多企业内部系统如果不符合条件,就需要付费。这个授权问题在实际项目里常被忽略,等上线审计时才被发现,那时候再换库的代价就大了。
提示:引入 EPPlus 前先确认当前版本对应的是哪个许可证协议。
我在实际项目里的判断标准是:如果只是做数据导出,没有复杂样式需求,可以先评估替代方案;如果报表里要放图表和数据透视表,EPPlus 基本是首选,因为其他开源库在这两块的实现成熟度有明显差距。
2.2 ExcelDataReader 的定位:流式解析是它的核心优势
ExcelDataReader 是另一个方向的库——它不做样式,只负责把 xlsx / xls 读成 DataSet 或者流式读取。它的优势是内存占用低,因为它是边读边解,而不是把整个文件结构一次性载入内存。这个特点在导入功能里非常关键。我曾经在一个导入接口里先用了 EPPlus 去读 Excel,结果一个 50MB 的 xlsx 文件,服务器内存直接涨了两三百 MB。换成 ExcelDataReader 之后,内存峰值降到了原来的三分之一左右。
我一般会用 ExcelDataReader 来读用户上传的文件,把数据转换成业务对象后,再做校验和落库。它支持的格式包括 .xlsx、.xls 以及加密文件(需要额外传入密码),用起来也简单。对大多数后台管理系统来说,导入功能走这条路足够稳。
2.3 为什么不能把读取和导出都压给同一个库
这里有一个常见的疑问:既然 EPPlus 也能读文件,ExcelDataReader 能不能配合生成文件?答案是能,但代价不小。
EPPlus 读取时会把整个文件载入工作区,对大文件不友好,而且它没有提供"只读模式"这种轻量入口。ExcelDataReader 则天生不带样式能力,用它导出只能生成最基础的 CSV 类似的表格,格式控制完全谈不上。
所以 excel-handler 的思路是拆开两个库各自负责一段:读取路径走 ExcelDataReader,导出路径走 EPPlus。这样做的好处是职责清晰,出问题时不用在一个库里翻来翻去。而且两个库的更新节奏不一样,独立演进比绑在一起舒服得多。
| 维度 | ExcelDataReader | EPPlus |
|---|---|---|
| 核心能力 | 快速读取并转 DataSet | 样式、图表、公式、复杂格式 |
| 内存表现 | 流式读取,峰值较低 | 全量加载,大文件吃内存 |
| 对 .xls 的支持 | 支持旧格式 | 新版只支持 .xlsx 系列 |
| 授权模式 | 开源宽松许可 | 商业场景需确认协议 |
| 适合场景 | 导入、解析、数据迁移 | 报表导出、模板套打 |
从表里能看明白:两者不是替代关系,而是互补关系。这个项目选型的第一原则就是把"读"和"写"分开,避免后期替换库时牵连一大片业务代码。
还有一种场景不建议用这两个库:如果只是要导出 CSV,直接用 StringBuilder 拼文本反而更快更省内存。EPPlus 生成的 xlsx 本质是 zip 压缩包,在线导出大量纯数据时,压缩本身就有 CPU 开销。CSV 虽然被 Excel 打开时会提示编码兼容性,但对于后台管理系统的"一键导出"来说,很多时候用户根本不关心格式,只关心数据对不对。这个判断不需要一开始就做死,可以在实测后决定。
3. 读取 Excel 的完整链路:IFormFile 到 DataSet 怎么搭
3.1 项目结构与依赖注册
在 ASP.NET Core Web API 里,首先需要在项目文件里加入两个 NuGet 包:ExcelDataReader 和 ExcelDataReader.DataSet。后者才是能把 Excel 内容填充到 DataSet 的关键扩展。如果只装主包,你会发现撑死了拿到流对象,拿不到表格结构。
常见做法是在 Program.cs 里注册相关服务,比如配置一个 ExcelReadService 作用域服务。我一般会把文件解析逻辑单独写成服务类,不让控制器里直接堆积解析代码。
builder.Services.AddScoped<IExcelReadService, ExcelReadService>();这里用 AddScoped 而不是 Singleton,是因为解析服务内部如果持有临时状态(比如当前工作表的游标),多请求并发时会串数据。按请求隔离生命周期更稳。
3.2 读取管道实现:IFormFile → DataSet
控制器接收 IFormFile 后,核心逻辑放在服务里。下面是我常用的读取代码骨架:
public async Task<DataSet> ReadExcelAsync(IFormFile file, bool hasHeader = true) { await using var stream = file.OpenReadStream(); // 直接使用文件流,避免整文件读入内存 System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); using var reader = ExcelReaderFactory.CreateReader(stream, new ExcelReaderConfiguration { // 旧版 xls 需要指定编码,xlsx 不需要 FallbackEncoding = System.Text.Encoding.GetEncoding("gb2312") }); var dataset = reader.AsDataSet(new ExcelDataSetConfiguration { ConfigureDataTable = _ => new ExcelDataTableConfiguration { // 第一行是否作为列名 UseHeaderRow = hasHeader, // 空单元格处理策略 EmptyColumnNamePrefix = "Col_" } }); return dataset; }逻辑说明:OpenReadStream() 返回的是一个非托管流,用 await using 确保方法结束后释放文件句柄。ExcelReaderFactory.CreateReader 会根据文件头自动判断是 xls 还是 xlsx,不需要手动按扩展名分支。AsDataSet 是扩展方法,内部会按工作表逐个生成 DataTable。最关键的坑是枚举那行 EncodingProvider 注册——在 .NET Core 里默认不带非 UTF-8 编码支持,不注册的话读取含中文的旧 xls 会乱码,这是很多新手第一次跑通后才发现的问题。
参数说明:hasHeader 对应 UseHeaderRow,设为 true 时第一行会被当作列名。EmptyColumnNamePrefix 用于处理表头为空的情况,否则生成列名会是空字符串,后续写业务代码时很容易踩 KeyNotFoundException。FallbackEncoding 只在 ExcelDataReader 无法从文件头推断编码时生效,不要依赖它处理所有乱码问题。
3.3 去空行、去隐藏表与数据清洗
真实项目里用户的 Excel 往往不干净,常见做法是对 DataSet 做一次预处理:过滤全是空值的行,去掉 sheet 名里的空格,把 DBNull 统一转成空字符串。这几个步骤能省掉后面大量业务校验,尤其是"导入成功率"这类指标需要精确统计时,数据清洗必须先做完再进业务逻辑。
public DataTable CleanTable(DataTable source) { var target = source.Clone(); foreach (DataRow row in source.Rows) { var isEmpty = row.ItemArray.All(v => v == DBNull.Value || string.IsNullOrWhiteSpace(v?.ToString())); if (isEmpty) continue; target.ImportRow(row); } return target; }这个方法的逻辑是逐行检查所有单元格,全是空值的行直接丢弃。用 Clone 而不是 new DataTable 是为了保留列结构和类型。对于导入场景,这一步能帮业务层省掉大量 null 判断,也避免空行被当成有效数据写进数据库。
这里还要提一嘴类型转换:ExcelDataReader 返回的 DataTable 里,列类型默认是根据单元格内容推断的,同一个 Excel 列里如果既有数字又有文本,推断出的类型可能变成 double,这样银行的账号这类长数字会被抹掉精度。处理办法是读取后对关键列做一次显式转换:要么在业务层遍历 DataRow 用 Convert.ToString,要么在读取配置里设置 ReadOption。我现在的习惯是导入文件前先出一个字段模板,模板里写明每列是文本还是数字,读完后统一按模板转换,这样接口的可维护性好很多。
3.4 异常处理:文件损坏、空表与超大文件
读取时最常见的三个异常。第一个是文件根本不是 Excel,比如用户把 txt 改成 xlsx 再上传,ExcelDataReader 会抛异常,我会在最外层捕获并返回 400 错误。第二个是工作表一个都没有,DataSet.Tables.Count 为 0,这时候应该直接提示"文件中没有数据表",别再往下走了。第三个是文件超过几十 MB,建议在 controller 层做大小限制,而不是让解析服务硬扛。
if (file.Length > 50 * 1024 * 1024) { throw new InvalidOperationException("文件超过 50MB,请拆分后上传"); }参数说明:50MB 是经验值,实际要根据服务器内存配置调整。如果机器只有 2GB 内存,20MB 就该限制了,不然多个请求同时进来,进程直接被打死。
4. 导出 Excel 的完整链路:EPPlus 生成带样式报表的关键点
4.1 ExcelPackage 生命周期与 using 规范
EPPlus 的入口是 ExcelPackage,它内部持有整个工作簿对象。这个类实现了 IDisposable,所以使用上必须用 using 包裹,否则文件流不会被释放,连续导出几次后就会出现文件占用,Linux 容器里还会表现为导出的文件内容缺失。
using var package = new ExcelPackage(); var sheet = package.Workbook.Worksheets.Add("报表"); // 填充数据... var bytes = package.GetAsByteArray();GetAsByteArray 会把整个工作簿压缩成字节数组返回;如果文件很大,可以考虑直接用 package.SaveAs(stream) 输出到响应流,避免中间再拷贝一次大字节数组。我实测过,一个 2 万行的报表,GetAsByteArray 方式的内存峰值比 SaveAs 高大约 80MB,这个差距在低配服务器上非常明显。
4.2 数据填充与样式控制
EPPlus 里最常用的操作是逐单元格赋值或者用 LoadFromDataTable 批量导入。对于有特定格式要求的列,比如金额、日期,赋值后需要手动设置 NumberFormat。
using var package = new ExcelPackage(); var sheet = package.Workbook.Worksheets.Add("月度报表"); // 列宽和标题样式 sheet.Cells["A1:E1"].Merge = true; sheet.Cells["A1"].Value = "2024 月度销售数据"; sheet.Cells["A1"].Style.Font.Bold = true; sheet.Cells["A1"].Style.HorizontalAlignment = OfficeOpenXml.Style.ExcelHorizontalAlignment.Center; // 表头 var headers = new[] { "订单号", "客户名", "金额", "日期", "状态" }; for (int i = 0; i < headers.Length; i++) { sheet.Cells[2, i + 1].Value = headers[i]; sheet.Cells[2, i + 1].Style.Font.Bold = true; } // 数据填充:从第3行开始 int row = 3; foreach (var item in orderList) { sheet.Cells[row, 1].Value = item.OrderNo; sheet.Cells[row, 2].Value = item.CustomerName; sheet.Cells[row, 3].Value = item.Amount; sheet.Cells[row, 3].Style.NumberFormat = "#,##0.00"; sheet.Cells[row, 4].Value = item.BizDate.ToString("yyyy-MM-dd"); sheet.Cells[row, 5].Value = item.StatusText; row++; } sheet.Cells[2, 1, row - 1, 5].AutoFitColumns();逻辑说明:先合并标题行,再写表头,再按行填充数据。金额列的 NumberFormat 设置为千分位两位小数,日期则直接转成字符串赋值,避免 EPPlus 对 DateTime 的默认显示格式和客户端不一致。这里面有个血泪经验:导出接口如果数据量过万,除非必要,不要给每个单元格都设置独立样式。EPPlus 的样式对象是共享的,每设置一次 Style 属性,内部就可能创建新的样式记录,文件体积和生成时间都会膨胀。
参数说明:AutoFitColumns 会在导出前自动调整列宽,缺点是数据多时耗时明显。超过 5000 行时建议去掉,改用手动列宽,否则导出接口会慢到让人想删库跑路。手动列宽用 sheet.Column(i).Width 设置,数值范围一般在 8 到 50 之间。
4.3 返回值与 Content-Type
导出接口不能直接返回 DataTable,需要把字节流包装成文件响应。这里有一个容易忽视的细节:MIME 类型和文件名编码。
[HttpPost("export")] public IActionResult Export(ExportRequest request) { var data = _reportService.GetData(request); var bytes = _excelExportService.BuildExcel(data); return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", $"报表_{DateTime.Now:yyyyMMdd}.xlsx"); }逻辑说明:File 方法第一个参数是字节数组,第二个是 xlsx 的 MIME 类型,第三个是下载文件名。文件名里带中文时需要依赖浏览器自动处理 URL 编码,绝大多数现代浏览器都能正确下载。注意:如果接口在网关后面,要确认网关没有对响应体做二次压缩,否则文件可能损坏。
4.4 模板复用的思路
对于格式比较固定的报表,比如带公司 logo、页眉页脚的月度对账单,每次都用代码把样式画一遍不现实。常见做法是预先做好一个模板 xlsx,导出时用 ExcelPackage 打开模板、替换数据区域,再另存。这样样式问题交回给设计同事处理,代码只关心数据写入。
using var package = new ExcelPackage(new FileInfo(templatePath)); var sheet = package.Workbook.Worksheets[0]; // 直接往模板预留的行里塞数据这里有个细节:模板文件建议按静态文件放在 wwwroot 或专门目录,避免每次编译都复制。另外模板里的公式会在 Excel 打开时重新计算,但 EPPlus 本身不重算公式,必要时需要手动调用 sheet.Calculate()。如果模板负责的报表带有汇总行,这个坑尤其值得提前测一遍。
5. 常见问题排查:格式兼容、内存与数据边界的四个坑
5.1 xls 读取时流位置导致的空数据
现象:用 ExcelDataReader 读取 .xls 文件,返回的 DataSet 有表结构但行数为零,而且不报任何异常,排查起来特别隐蔽。
原因:.xls 是复合文档格式,读取器和流内部有位置游标。如果读取前对 stream 做了 Seek 或者过滤操作,比如想先读文件头判断扩展名,可能导致库读取到错误的位置,表结构读出来了,数据行却丢了。
解决:拿到 IFormFile 后不要在解析前对流做任何 Seek,直接 OpenReadStream() 给 ExcelReaderFactory。如果确实需要预读文件头,先用一个独立的 MemoryStream 复制,不要让原始流被移动过。这个坑我踩过一次之后,所有解析入口都加了一条注释:禁止对流做任意 Seek。
5.2 EPPlus 在 Linux 容器里导出缺少字体
现象:同一套导出代码在 Windows 上正常,部署到 Linux 容器后生成的 xlsx 里中文变成方块,而且服务日志里没有任何报错。
原因:EPPlus 在导出图表时才依赖 GDI+ 字体渲染,但部分版本在调整列宽、计算样式时也会触发字体测量。容器里没有中文字体,测量结果全是占位符,字体嵌入就失败了。
解决:Dockerfile 里安装中文字体包,或者把字体文件复制进镜像并在导出处指定字体路径。再一个省事方案是用模板文件而不是动态创建样式,模板里已经把字体定死了,这个问题出现概率会低很多。
提示:这个坑在纯 Linux 容器环境里几乎必现,建议在 CI 阶段就加一个导出冒烟测试,专门验证中文渲染。
5.3 大文件导入导致内存暴涨的"黑匣子"
现象:导入接口在压力测试时内存一路飙升,最后进程被 OOM 杀掉,日志里看不出具体是哪一行的问题,就像突然触发了不可见的炸弹。
原因:用了 EPPlus 读文件,且同时保留了 IFormFile、DataSet、逐行业务对象三份数据在内存里。数据量一上来,几份拷贝同时存在就爆了。尤其是 DataSet 里的 DataTable 默认会做字符串驻留,重复率高的文本列会加速内存消耗。
解决:读取路径统一走 ExcelDataReader 的流式读取;解析完一行数据后立刻转成业务对象并手动释放临时 DataTable;如果单表超过十万行,改为分批读取并边读边写库,不要等整个文件解析完再统一落库。具体拆分的批次大小我一般选 5000 行,既能保持事务的粒度,又不至于频繁提交。
5.4 金额和日期格式被 Excel 自动"修正"
现象:导出的 Excel 中,长度较长的数字串(如银行卡号、身份证号)显示成科学计数法;日期显示成序列数字,用户一脸懵。
原因:Excel 对纯数字有隐式类型推导,超过 11 位后转科学计数法;后端生成的字符串如果被 Excel 判定为数字类型,原样保留的格式就失效了。
解决:长数字列在赋值时统一用字符串类型,并设置单元格格式为文本;日期在导出前先格式化成 yyyy-MM-dd HH:mm:ss 字符串。文本格式的设置代码我每次都写:
sheet.Cells[row, 6].Value = accountNo; // 必须是字符串 sheet.Cells[row, 6].Style.NumberFormat = "@";逻辑说明:NumberFormat 设为 "@" 是把单元格强制设为文本格式,这样就算内容是纯数字,Excel 也不会再做类型推断。这个写法对手机号、税号、物流单号都适用。顺带说一句,如果这一列还要参与 Excel 内的公式计算,那就不能设成文本,需要单独保留一个数字列。
5.5 导出文件在手机上打不开
现象:用户用手机浏览器下载后提示"文件已损坏",但电脑上打开正常,直接断定后端代码有问题。
原因:响应头缺少 Content-Length 或浏览器拿到的 MIME 类型不对,导致 App 判断文件类型失败。部分安卓浏览器对中文文件名处理不友好,会在下载过程中把链接截断,拿到半个文件。
解决:显式指定 Content-Type 为 xlsx 对应的 MIME,并在 File 方法中传下载文件名。如果目标用户大量使用安卓浏览器,可以改用英文文件名再加一个 filename* 参数做兼容。
6. 几个提效技巧:从能用变成好用
6.1 用 LoadFromDataTable 批量写入
逐行赋值在数据量小的时候没毛病,但报表导出动辄几千行时,逐行写会放大 EPPlus 的单元格创建开销。用 LoadFromDataTable 能一次性把整个表格灌进去,性能差距可能有三到五倍。
sheet.Cells["A3"].LoadFromDataTable(dataTable, true, OfficeOpenXml.Table.TableStyles.Light9);参数说明:第二个参数 printHeaders 控制是否把 DataTable 的列名写入第一行;第三个参数是内置表格样式,不需要时可以传 null。这里留一个扩展思路:可以先在工作表上画好目标区域的样式,再用 LoadFromDataTable 时把样式参数设为 null,数据就只负责填充,样式不会被库自带的表格样式覆盖。
6.2 异步化读取与超时兜底
Web API 的请求默认受服务器超时限制,大文件解析本身是 CPU 密集加 IO 密集的混合操作,容易让请求响应变慢。常见做法是 controller 方法改成 async,并把解析服务里的同步调用放到线程池里跑;如果业务量再大,考虑用队列加后台任务处理,接口只返回一个任务 ID。
[HttpPost("import")] public async Task<IActionResult> ImportAsync(IFormFile file) { if (file == null || file.Length == 0) return BadRequest("未接收到文件"); var result = await Task.Run(() => _excelReadService.ReadExcelAsync(file)); return Ok(new { tables = result.Tables.Count }); }注意:Task.Run 在这里只是为了让出请求线程,真正耗时还是在服务内部。如果接口超时依旧,就要往前端加进度提示,而不是硬撑。这个设计原则在文件解析这类需求里尤其重要:导入体验的关键不是响应有多快,而是失败时用户能否知道发生了什么。
6.3 模板文件预加载
格式固定的报表,把模板读入内存一次并放在缓存里,之后每次导出都从缓存复制一份,能省掉模板 IO 的时间。要注意的是 ExcelPackage 对象不能并发复用,但可以先读取成字节数组存到 MemoryCache,导出时再从字节数组 new ExcelPackage。
var templateBytes = await _cache.GetOrCreateAsync("report_template", async entry => { entry.SlidingExpiration = TimeSpan.FromHours(1); return await File.ReadAllBytesAsync(templatePath); }); using var package = new ExcelPackage(new MemoryStream(templateBytes));这段做法的核心就一点:模板内容不变,就让它留在内存里,别每次请求都去读磁盘。写法上需要注意缓存键的设计,不同业务线的模板要分开命名,别混用一个键。
写这个 excel-handler 的过程里,我最深的体会是:读和写是两个不同世界,库的选型一定要围绕真实数据量来。之前我图省事,用同一个库同时处理导入导出,结果导入时内存被撑爆,导出时样式又各种丢;从那以后,我每次搭导入导出功能都会先问一句:数据量级是多少、样式要求有多高,然后才决定走哪条路,并且强制在本地准备一个 30MB 以上的测试文件专门验证内存和超时表现。希望这些经验能帮到你。
本文还有配套的精品资源,点击获取