简介:面向ASP.NET Core WebAPI开发者的Excel处理工具包,基于EPPlus与ExcelDataReader两大开源库,解决Web应用中Excel读取、导出及避免服务器安装Office的常见需求,适用于需要构建数据导入导出接口的中高级.NET后端工程师。压缩包共208个文件,约21.89MB,内含Visual Studio解决方案与项目源码(.cs、.csproj、.sln),以及配套的dll运行库、json配置文件等,其中dll为EPPlus、ExcelDataReader等依赖程序集,cs为控制器与业务逻辑示例,json为应用配置,整体是一个可直接打开运行的完整WebAPI项目。已有400人学习下载,资源提供从文件流创建、读取Excel行数据到生成xlsx并返回客户端的完整代码路径,涵盖流式处理、MIME类型与Content-Disposition响应头设置、内存高效利用等要点,并包含上传校验、分批处理等扩展思路,可帮助开发者快速集成Excel功能并规避常见坑点。
1. Excel 读写这件小事:为什么 Web API 里最容易翻车的反而是它
用 ASP.NET Core Web API 做订单管理后台时,Excel 导入导出一开始被当成边角料,功能排期永远在最后。结果上线第一周,翻车最多的恰恰是它:EPPlus 导出的文件在 WPS 里打开提示格式异常,ExcelDataReader 读老系统导出的 .xls 文件全是乱码,两万行订单导出让进程内存一路飙到 600MB。
后来我把这套 excel-handler 方案拆出来重做,原则很简单:导出统一走 EPPlus,导入统一走 ExcelDataReader。它解决的不是「能不能读写」这种入门问题,而是服务端批量读写时的真实边界:内存流怎么给才不损坏文件、中文文件名响应头怎么写、老格式编码怎么注册、日期序列号怎么转换、大数据量怎么控内存。
适合正在做管理后台、报表模块或数据导入功能的 ASP.NET Core 开发者,尤其是被 Excel 兼容性问题坑过、想找一份能直接照抄参考实现的人。
2. 选型与架构:EPPlus 和 ExcelDataReader 一个写一个读,边界在哪
2.1 两个库的定位:导出与解析各管一段
先说结论:能用 EPPlus 写就不拿它读,能用 ExcelDataReader 读就不拿它写。这两条边界在 excel-handler 里是强制的,因为这俩库的擅长方向天然互补,硬要一个库干两件事,迟早要在某个数据量级上吃亏。
EPPlus 的主场是写。单元格样式、合并、公式、图表、模板填充,这些导出链路里高频用到的能力,它的 API 设计最顺手。但它的读取能力相对弱,读操作会把整个工作表一次性载入内存,文件稍大就吃不消;而且 EPPlus 5.x 起改了授权模式,非商业场景必须显式设置 LicenseContext,商业场景要严格按授权要求购买。选型时这一条要先确认清楚,别等代码写完才想起来。
ExcelDataReader 的主场是读。它按流式逐行解析文件,不会把整个 Excel 一次性塞进内存,同时支持 .xls 和 .xlsx 两种格式,老系统的二进制 BIFF 文件也能解。缺点是完全没有写能力,定位就是纯解析器。把 ExcelDataReader 读出来的 DataSet 再写回 Excel,得自己拼列拼行,极其别扭。
| 对比项 | EPPlus | ExcelDataReader |
|---|---|---|
| 定位 | 导出为主,读取为辅 | 读取为主,不支持写 |
| 格式 | .xlsx(新版不读 .xls) | .xls 与 .xlsx 均可 |
| 内存模型 | 整表载入内存 | 流式逐行解析 |
| 扩展能力 | 样式、公式、合并、模板 | 无 |
| 授权 | 5.x 起需设置 LicenseContext | 宽松许可,无额外约束 |
我也试过用 NPOI 一把梭的方案,读写都靠它,但复杂表头样式写起来太啰嗦,维护成本高;还见过有人在服务器上直接开 COM 调 Excel 接口,这在容器化环境基本跑不通,依赖桌面组件不说,并发一高就崩。选两库分立,本质是把「流的解析能力」和「样式的生成能力」各自发挥到极致。
NuGet 装包很直接,四个包一次装齐:
dotnet add package EPPlus dotnet add package ExcelDataReader dotnet add package ExcelDataReader.DataSet dotnet add package System.Text.Encoding.CodePagesExcelDataReader.DataSet 是独立包,提供 AsDataSet() 扩展方法,把工作表转成 DataSet 用;System.Text.Encoding.CodePages 是给 .xls 老文件用的,没它读 GBK 编码的中文必乱码,第 4 章会展开。装完包,如果是 EPPlus 5.x 及以上,Program.cs 里第一件事就是把 LicenseContext 设好:
// Program.cs using OfficeOpenXml; ExcelPackage.LicenseContext = LicenseContext.NonCommercial;这行不写,new ExcelPackage() 直接抛异常。国内很多教程停留在 4.x 时代不写这行,照抄到新版本就翻车。
提示:NonCommercial 只覆盖非商业场景,商业项目请按 EPPlus 的授权条款确认后再上线,别心存侥幸。
2.2 接口设计:读、写、模板下载三件事拆开做
我一开始想把读和写揉进同一个 Service,后来发现不行:两者的异常处理、参数校验、日志关注点完全不同。读入要处理空行、类型转换、重复数据;导出要处理样式、列宽、大结果集。揉在一起,改一处崩另一处。最终拆成三个接口,对应三个真实场景:
- GET /api/excel/export:按条件导出数据
- POST /api/excel/import:上传并解析 Excel
- GET /api/excel/template:下载带格式的导入模板
对应的项目结构:
excel-handler/ Controllers/ ExcelController.cs Services/ IExcelExportService.cs ExcelExportService.cs IExcelImportService.cs ExcelImportService.cs Models/ ExportRequest.cs ImportResult.csController 只做参数接收和校验,真正逻辑在 Service,接口抽象就两个:
public interface IExcelExportService { Task<byte[]> BuildOrderExcelAsync(DateTime startDate, DateTime endDate); } public interface IExcelImportService { Task<ImportResult> ParseOrderExcelAsync(Stream stream); }导出 Service 返回 byte[],Controller 拿到字节数组直接拼 File 响应;导入 Service 接收 Stream,返回 ImportResult,里面装着成功行数、失败行数和每行的错误原因,前端拿这份明细提示用户修正。这样 Controller 永远不用知道 Excel 长什么样,后续想在导出前加工数据、导入后落库,都不用动接口层。
三个接口里「下载模板」最容易被忽略,但实际价值很大:模板里把列名、必填项、单元格格式都预设好,用户在源头就少出错,导入端也省掉很多脏数据清理。我通常会把模板版本号放进文件名,比如 order_import_v2.xlsx,避免用户拿旧模板传新数据。
3. 用 EPPlus 导出:内存流、响应头与大数据量的三个注意点
3.1 导出接口代码骨架:从数据到 ExcelPackage
先看最常用的导出接口,把订单数据按时间范围导出成 xlsx:
[HttpGet("export")] public async Task<IActionResult> ExportOrders( [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) { var data = await _orderRepository.GetForExportAsync(startDate, endDate); using var package = new ExcelPackage(); var sheet = package.Workbook.Worksheets.Add("订单明细"); // 表头 string[] headers = { "订单号", "客户名称", "金额", "下单时间", "状态" }; for (int col = 0; col < headers.Length; col++) { sheet.Cells[1, col + 1].Value = headers[col]; } // 数据从第 2 行开始写 int row = 2; foreach (var order in data) { sheet.Cells[row, 1].Value = order.OrderNo; sheet.Cells[row, 2].Value = order.CustomerName; sheet.Cells[row, 3].Value = order.Amount; sheet.Cells[row, 4].Value = order.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss"); sheet.Cells[row, 5].Value = order.StatusText; row++; } // 按内容自适应列宽 sheet.Cells[1, 1, row - 1, headers.Length].AutoFitColumns(); // 一次性转成字节数组返回 var bytes = package.GetAsByteArray(); var fileName = $"订单导出_{DateTime.Now:yyyyMMdd_HHmmss}.xlsx"; return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", fileName); }几个关键点。using var package保证 ExcelPackage 用完即释放,导出不释放会有文件句柄滞留问题。package.GetAsByteArray()是这里最不容易出错的做法,它把内存流内部数据完整序列化出来,完全不用操心流的当前位置。新手最常见的错误是拿package.Stream直接传给File(),返回时流位置不对,下载下来的文件打不开——这个坑后文还会展开。
日期字段我建议写入时就格式化成字符串。直接赋 DateTime 类型也能显示,但客户端打开后的格式依赖本机区域设置,不如ToString("yyyy-MM-dd HH:mm:ss")稳定。金额字段保持数值类型,别先ToString("0.00")再赋值,否则 Excel 里是文本型数字,后续做 SUM 会得到 0。
提示:凡是导出的文件打不开,先检查 Content-Type 和流的 Position 两件事,九成问题出在这。
3.2 响应头里的中文文件名:Content-Disposition 的坑
File(bytes, contentType, fileName)会自动生成 Content-Disposition 响应头,但对中文文件名,框架会分两段写:filename="export.xlsx" 和 filename*=UTF-8''%E8%AE%A2%E5%8D%95...。现代浏览器和 WPS 会读 filename* 那段,于是中文文件名正常显示;但少数老客户端只读 filename 段,拿到的就是 export.xlsx。
我一般显式控制这个响应头,保证降级路径也可读:
var cd = new System.Net.Http.Headers.ContentDispositionHeaderValue("attachment"); cd.FileNameStar = fileName; // UTF-8 编码的完整文件名,客户端优先读这段 cd.FileName = "export.xlsx"; // ASCII 兜底文件名,老客户端读这段 Response.Headers.ContentDisposition = cd.ToString();FileNameStar 赋值中文没问题,标准库会自动做 URL 编码。Content-Type 也要配对,xlsx 和 xls 的 MIME 类型不一样,写错轻则浏览器不渲染,重则被安全网关直接拦下:
| 文件格式 | MIME 类型 |
|---|---|
| .xlsx | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
| .xls | application/vnd.ms-excel |
| .csv | text/csv |
导出接口最好再显式声明[Produces("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")],避免内容协商出意外。如果接口协商到 JSON 或默认格式,客户端收到 406 之类的响应会一脸懵。
3.3 大数据量导出:别逐行赋值,也别无脑 AutoFit
导出几千行时逐行 Cells 赋值没问题,但到了五万行以上,逐行赋值加每次设样式,耗时和内存都会明显上涨。数据量大时,我一般把数据装进 DataTable,再一次性灌入工作表:
var dt = new DataTable(); dt.Columns.Add("订单号", typeof(string)); dt.Columns.Add("金额", typeof(decimal)); dt.Columns.Add("下单时间", typeof(string)); foreach (var order in data) { dt.Rows.Add(order.OrderNo, order.Amount, order.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")); } using var package = new ExcelPackage(); var sheet = package.Workbook.Worksheets.Add("订单明细"); sheet.Cells["A1"].LoadFromDataTable(dt, true);LoadFromDataTable的第二个参数表示是否把 DataTable 列名作为表头写出,传 true 即可。它内部按数组批量写入,比逐行设单元格值快,实测几万行差距能到三倍以上。代价是样式控制弱一些,表头格式、列宽得写完后单独处理。
另一个经验是AutoFitColumns()慎用在大表上。这个方法是逐个单元格量宽度再算最优列宽,五万行十列就是五十万次测量,导出接口响应时间肉眼可见地变慢。我的习惯是:小表(几百行)用 AutoFit,大表直接手写列宽,或者干脆不设列宽让用户自己拉。
最后是物理边界:Excel 单表上限 1048576 行,超过这个数之前就要做分片导出或提示用户缩小查询范围,硬塞进去 Excel 会直接报错,这是导出的底线,躲不开。真遇到百万级数据,别走 Excel 了,直接生成 CSV 压缩包更现实。
4. 用 ExcelDataReader 导入:流式解析、类型映射与 .xls 兼容
4.1 上传接口与 DataSet 转换:先落盘还是先读流
导入接口接收 IFormFile,标准做法是拷贝到 MemoryStream 后用 ExcelDataReader 解析:
[HttpPost("import")] [RequestSizeLimit(50 * 1024 * 1024)] public async Task<IActionResult> ImportOrders(IFormFile file) { if (file == null || file.Length == 0) return BadRequest("文件为空"); var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); if (ext is not (".xls" or ".xlsx")) return BadRequest("仅支持 .xls 或 .xlsx 文件"); // .xls 老格式需要注册编码提供程序,否则中文乱码 Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); using var stream = new MemoryStream(); await file.CopyToAsync(stream); stream.Position = 0; using var reader = ExcelReaderFactory.CreateReader(stream); var ds = reader.AsDataSet(new ExcelDataSetConfiguration { ConfigureDataTable = _ => new ExcelDataTableConfiguration { UseHeaderRow = true } }); var rows = ParseDataTable(ds.Tables[0]); return Ok(new { total = rows.Count, rows = rows.Take(100) }); }CopyToAsync 到 MemoryStream 之后必须stream.Position = 0,不然 CreateReader 从流尾部开始读,读出来是空数据集。这个细节很多人踩过。ext is not (".xls" or ".xlsx")是 C# 9 的模式匹配写法,老项目里换成等价的原生判断即可。
超过 50MB 的大文件,拷进 MemoryStream 会占掉 50MB 内存,再用 AsDataSet 全量转 DataTable,内存峰值轻松到几百 MB。我的取舍是:50MB 以下走内存流,操作最简洁;以上先落盘到临时文件,再用文件流读取,GC 压力小很多:
var tempPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}{ext}"); using (var fs = System.IO.File.Create(tempPath)) { await file.CopyToAsync(fs); } using var stream = System.IO.File.OpenRead(tempPath); using var reader = ExcelReaderFactory.CreateReader(stream); // 后续解析逻辑与上面一致临时文件用完记得删,建议用 try-finally 或注册一个后台清理任务,不然 temp 目录会被导入文件塞满。
4.2 类型映射:日期序列号、空值和文本数字
ExcelDataReader 按单元格类型把值读成 double、string、bool 或 DateTime。最容易翻车的是日期:源文件单元格是「日期」格式时读出来是 DateTime;源文件里是手敲的 44562 这种数字时,读出来是 double,要手动转。我一般在 Service 里做一层统一转换:
private static DateTime? ConvertToDateTime(object? raw) { if (raw == null || raw == DBNull.Value) return null; if (raw is DateTime dt) return dt; // OADate 序列号:Excel 内部以 1900-01-01 为起点的天数计数 if (raw is double oaDate) return DateTime.FromOADate(oaDate); if (DateTime.TryParse(raw.ToString(), out var parsed)) return parsed; return null; }DateTime.FromOADate是 Excel 日期序列号的标准转换函数。Excel 的 1900 闰年 bug 会导致 1900 年 2 月 29 日这种不存在的日期也能被表示,业务里极少遇到,但要清楚有这个历史遗留。
另一个高频坑是文本型数字和数值型数字。身份证号、订单号这类超过 15 位的数字,Excel 的 double 精度只保留 15 位有效数字,源文件里一旦是数值格式,第 16 位开始就丢了。这种数据必须在源头约束:导出端把这类列设为文本格式,导入端校验时看类型——是 double 且位数超 15 就直接报错,让用户回源文件改格式。
// 导出身份证列时,提前把单元格格式设为文本 sheet.Cells[row, 4].Style.Numberformat.Format = "@"; sheet.Cells[row, 4].Value = order.IdCard;数字格式标记@让 Excel 把后续写入的值当文本处理,导入端读出来就是 string,不丢精度。
4.3 .xls 兼容与上传限制:编码、格式嗅探与请求上限
ExcelDataReader 不靠扩展名判断格式,它读文件头签名来判断是 OOXML 还是 BIFF,所以扩展名改错了也能读。但有两个前提:一是 .xls 老文件的字符串编码依赖本地区域设置,必须注册 CodePagesEncodingProvider,否则中文变乱码;二是文件本身损坏或伪造扩展名时 CreateReader 可能抛异常,接口要捕获并返回友好错误,别让解析异常直接变成 500。
我还会在 Program.cs 里统一限制上传体积,防恶意大文件拖垮进程:
// Program.cs builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(options => { options.MultipartBodyLengthLimit = 50 * 1024 * 1024; });这个配置和 Controller 上的[RequestSizeLimit]是两道独立限制。另外新版 ASP.NET Core 的 Kestrel 默认请求体上限约 30MB,如果没放开MaxRequestBodySize,请求在到达 Controller 之前就被 Kestrel 拦下,你在 Controller 里设的 50MB 根本不生效。三个限制一起确认,才算把上传边界锁死。
注意:MultipartBodyLengthLimit、RequestSizeLimit、Kestrel MaxRequestBodySize 是三个独立限制,要同时放开。
5. 避坑指南:这套 excel-handler 里的五个典型翻车现场
以下五个问题都是我在落地这套方案过程中真实遇到过、并且花时间查过根因的,按现象到解决的顺序记录。
5.1 EPPlus 5.x 一启动就抛 LicenseException
现象:new ExcelPackage() 直接抛异常,项目跑不起来。
原因:EPPlus 5.x 起授权策略变更,要求应用在启动时显式声明 LicenseContext,不声明就按非法状态处理。
解决:Program.cs 顶部加ExcelPackage.LicenseContext = LicenseContext.NonCommercial;。注意 NonCommercial 定义是「非商业免费」,商业项目要按授权条款自行确认。我见过有项目引用了老封装再升级 EPPlus,整个导出模块集体罢工,查了半天就是 LicenseContext 没设。
5.2 导出的文件在客户端打开提示格式损坏
现象:接口返回 200,文件也下载成功,但用户用 Excel 或 WPS 打开弹「文件格式或扩展名无效」。
原因:九成是 Content-Type 写成了 application/octet-stream,或者用了package.Stream直接返回——流位置没归零,客户端拿到的是截断数据。
解决:统一用package.GetAsByteArray()转字节数组,再走File(bytes, contentType, fileName);手动控制响应头时确保 Content-Type 是标准值。后来我在导出 Service 里加了一道自检:每次导出后先看字节数组前四个字节,确认存在 PK\x03\x04 的 zip 签名再返回,从那以后这个坑再没出现。
5.3 .xls 上传文件中文乱码
现象:老系统导出的 .xls 文件,导入后中文字段变成「锟斤拷」或「鎴戠殑」这类乱码。
原因:.xls 是二进制 BIFF 格式,字符串按系统代码页(常见 GBK)存储,ExcelDataReader 默认只注册了 UTF-8 相关编码,字节被按错误编码解释了。
解决:在 Program.cs 或接口开头调用Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);,注册完整代码页支持。注册必须发生在 CreateReader 之前,全应用注册一次最省心。把 .xls 另存为 .xlsx 能绕开,但作为接收方你没有控制权,服务端必须兼容。
5.4 数字列读出来变成科学计数法
现象:导入的金额 12345678.9 读出来是 1.23456789E7,身份证号读出来是 1.23456E17,精度已经丢了。
原因:ExcelDataReader 把数值型单元格读成 double,double 对 15 位以上有效数字精度不足。科学计数法只是 ToString 的默认形式,精度丢失才是真问题。
解决:分两层。身份证号等长数字必须源头做成文本列(导出设Numberformat.Format = "@"),导入端读到 double 且位数超长直接拒绝;金额等数值类型读取后转 decimal 再入库,JSON 序列化也精确。导入端拿到 double 序列化回 JSON 大概率以科学计数法出现,要么 Service 层统一转 string/decimal,要么在 DTO 上用序列化特性控制。
5.5 上传大文件后内存只涨不跌
现象:连续导入几个 80MB 的 xlsx,进程内存冲到 1GB,GC 后也降不下来。
原因:IFormFile 全量拷进 MemoryStream,AsDataSet 又把整个表加载成 DataTable,一份文件在内存里存了三份;加上异常路径流没释放,内存自然只涨不跌。
解决:超过 50MB 落盘再读。数据本身大时不用 AsDataSet,改流式逐行读取,边读边批量写库:
using var stream = System.IO.File.OpenRead(tempPath); using var reader = ExcelReaderFactory.CreateReader(stream); // 第一行作为表头 var headers = new List<string>(); if (reader.Read()) { for (int i = 0; i < reader.FieldCount; i++) headers.Add(reader.GetValue(i)?.ToString() ?? $"列{i + 1}"); } var buffer = new List<Dictionary<string, object?>>(); int rowCount = 0; while (reader.Read()) { var record = new Dictionary<string, object?>(); for (int i = 0; i < reader.FieldCount; i++) record[headers[i]] = reader.GetValue(i); buffer.Add(record); rowCount++; // 每 500 条批量落库一次,避免一直积在内存里 if (buffer.Count >= 500) { await _orderRepository.BatchInsertAsync(buffer); buffer.Clear(); } } if (buffer.Count > 0) await _orderRepository.BatchInsertAsync(buffer);流式模式的内存占用基本恒定,跟文件大小无关。这已经是我处理大文件导入的默认形态,小文件才走 AsDataSet 的省事路线。
6. 进阶:模板导出、合并表头与一个导入补救技巧
6.1 预置模板:把样式交给运营而不是代码
有些导出要求带指定列宽、颜色、页眉,用代码一点一点设太累。我的做法是先在 Excel 里做好模板,放到 wwwroot/templates 下,EPPlus 加载模板后填值:
using var package = new ExcelPackage(new FileInfo("wwwroot/templates/order_template.xlsx")); var sheet = package.Workbook.Worksheets[0]; sheet.Cells["C2"].Value = companyName; // 模板里预留的命名单元格 sheet.Cells["C3"].Value = DateTime.Now.ToString("yyyy-MM-dd"); int startRow = 5; // 模板预留的明细起始行 for (int i = 0; i < items.Count; i++) { sheet.Cells[startRow + i, 1].Value = items[i].OrderNo; sheet.Cells[startRow + i, 2].Value = items[i].Amount; } return File(package.GetAsByteArray(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "模板导出.xlsx");收益是样式改动不用发布代码。建议给模板关键单元格提前命名,代码用sheet.Names["CompanyName"]定位,比硬编码行列号更耐插行。
6.2 合并单元格、公式与表头错位的补救
汇总报表常用合并标题和合计公式,EPPlus 两行代码搞定:
sheet.Cells[1, 1, 1, 4].Merge = true; // 合并第 1 行 1~4 列 sheet.Cells[1, 1].Value = "月度订单汇总"; sheet.Cells[1, 1].Style.HorizontalAlignment = ExcelHorizontalAlignment.Center; int totalRow = startRow + items.Count; sheet.Cells[totalRow, 2].Formula = $"SUM(B{startRow}:B{totalRow - 1})"; package.Workbook.Calculate(); // 5.x 后走 workbook 级重算写公式必须调用 Calculate 重算一次,否则打开文件可能显示 0。EPPlus 4.x 用的是 worksheet.Calculate(),5.x 之后推荐 workbook 级计算,老代码升级时注意这个 API 变化。
另一个高频场景是导入模板第一行是合并大标题、第二行才是列名,UseHeaderRow=true 会把标题当表头,列名全部错位。补救办法是关掉自动表头,手动取第 1 行做列名、第 2 行起做数据,把表头行号做成配置项而不是写死。对接第三方模板时尤其有用——对方的 Excel 通常都有封面行、说明行,列名位置不规范是常态。
从那以后我做 Excel 相关改动,上线前都会强制走一遍自检:导出先本地打开看格式,再模拟客户端下载看响应头,最后用一份带脏数据的模板跑导入看类型转换报错。这套流程帮我挡掉过好几次线上事故,希望帮到你。
本文还有配套的精品资源,点击获取