简介:这是一套面向ASP.NET Core WebAPI开发者的Excel处理实战项目源码,主要解决Web服务中读取和导出Excel文件的常见问题,适合需要为.NET项目增加文件处理能力的中级开发者。项目围绕EPPlus与ExcelDataReader两个库展开,可兼容旧版xls与新版xlsx格式,并通过流式读取降低大文件内存占用,应用部署无需安装Microsoft Office,也避免了Office Interop依赖。压缩包共包含208个文件、约21.89MB,其中dll文件数量最多,涵盖依赖的第三方程序集;cs源码展示控制器、读写逻辑与导出接口写法;json配置、sln与csproj工程文件便于直接打开编译或参考结构。借助本套资料,既能掌握使用ExcelPackage创建工作表、填充单元格并通过HTTP响应输出Excel文件的完整导出流程,也能学会利用ExcelReaderFactory逐行遍历读取数据的导入方式,同时了解MIME类型与Content-Disposition头的设置方法。目前已有400人学习下载,目录结构清晰,可作为实战参考和项目模板快速改造。
1. 处理 Excel 没那么简单:一个能读能导的 ASP.NET Core 接口是怎么搭出来的
做后端开发的人几乎都躲不过 Excel 导入导出这关。不管你是给某公司的内部运营系统做报表,还是帮某高校的信息中心做数据迁移,需求总是出奇地一致——用户交上来一个几百兆的.xlsx,你要读进去;系统里查出来的数据,又要变回.xlsx还给用户。中间夹着编码、日期序列号、空行、合并单元格、公式缓存这些坑。excel-handler这个项目做的就是这件事:用 ASP.NET Core WebApi 搭一套标准的导入导出接口,读取侧用 ExcelDataReader,导出侧用 EPPlus,两个库各管一段,互不干扰。适合刚接触 .NET 后端、想把 Excel 模块直接接进自己项目的人,也适合已经在做、但被各种边角问题折磨过的开发者对照参考。核心思路是:读用流式解析,写用模板填充,把坑留在库的边界外。
2. 为什么是 ExcelDataReader 配 EPPlus:取舍逻辑与项目骨架
2.1 ExcelDataReader 和 EPPlus 的分工逻辑
很多人一开始会纠结:一个库能不能把读写都干了?能,但不推荐。EPPlus 从 5.x 开始把 LicenseContext 搞得比较麻烦,而且它写文件的优势明显,读大文件时内存占用偏高,性能不如专门的读取库。ExcelDataReader 走的是流式解析路线,DataReader 模式下一行一行往外吐,几百 MB 的文件也能跑,但你要它写文件,它不做这个。
所以这个项目的分工很明确:读取统一走 ExcelDataReader,拿到数据后转成 DTO 或 DataTable;导出统一走 EPPlus,把内存里的集合直接 LoadFromCollection 或者逐行写进 Sheet。这样每个库只用它最擅长的半边,出问题也好定位——读得慢看读取层,写得慢看导出层,不用在两个库之间反复猜。
还有一层现实考量:老项目里你经常只拿到一个.xls,ExcelDataReader 原生支持.xls和.xlsx两种格式,不需要额外装 Office 或者开什么 COM 组件。这对部署在 Linux 容器里的 WebApi 尤其重要,服务器上根本没有 Excel 进程可用,纯托管代码解析几乎是唯一方案。
2.2 项目结构:Controller 薄、Service 厚
excel-handler的代码组织比较常规,但边界划分值得抄。Controller 只做两件事:接收文件、返回文件。业务解析和组装全部下沉到 Service 层,这样单元测试可以直接打在 Service 上,不必每次起 WebApi 再传文件。
常见的目录划分是这样的:
ExcelHandler/ ├── Controllers/ │ └── ExcelController.cs ├── Services/ │ ├── ExcelReaderService.cs │ └── ExcelExporterService.cs ├── Models/ │ └── ExcelRowDto.cs └── Program.csController 里注入两个 Service,一个负责导入解析,一个负责导出生成。DTO 定义你要从 Excel 里取哪些列,比如订单号、日期、金额、状态字段。这个 DTO 同时也是导出的数据源的列映射依据,一份定义两端复用。
Startup 或 Program 里注册服务时注意生命周期。ExcelDataReader 的IExcelDataReader是IDisposable的,Service 如果注册成 Singleton,内部不能长期持有 reader 实例,否则连接和文件流都得不到释放。常规做法是 Service 注册为 Scoped,每次请求创建新的实例,reader 在方法内部用using包裹,方法结束即释放。
3. 读取 xlsx:从流到 DTO 的完整链路
3.1 接收文件:IFormFile 与流式处理的边界
WebApi 接收上传文件,接口签名最常见的是IFormFile。用 IFormFile 本身没问题,但要小心它背后的实现:IFormFile的OpenReadStream()返回的是缓冲流,如果你的FileUpload有大小限制,比如某公司的网关限制 50MB,超过这个值请求就会在到达业务代码之前被拦掉。
处理大文件时,我一般会在Program.cs里做两个调整:一是把 Kestrel 的MaxRequestBodySize调大,二是关掉EnableBuffering之外的多余缓冲,让文件流直接落到磁盘临时文件,而不是全塞进内存。具体操作是:
builder.Services.Configure<KestrelServerOptions>(options => { options.Limits.MaxRequestBodySize = 200 * 1024 * 1024; // 200MB 上限,按需调整 });这段配置的意思是:允许最大 200MB 的请求体,覆盖大多数业务 Excel 场景,同时不盲目放得太大。等拿到文件流,第一步应该做格式嗅探而不是直接解析——用文件头判断到底是.xls还是.xlsx,这决定了后面调用ExcelReaderFactory.CreateReader还是CreateBinaryReader。
3.2 ExcelDataReader 读取:DataReader 模式与常见参数
ExcelDataReader 有两种用法:AsDataSet()一把梭和 DataReader 逐行读。AsDataSet()适合小文件、结构简单、不需要实时处理的情况;DataReader 模式适合大文件,因为它不要求整个文件加载到内存,逐行取数据,GC 压力小得多。
下面这段是 DataReader 模式的完整读取方法,建议直接抄:
public async Task<List<ExcelRowDto>> ReadExcelAsync(Stream fileStream, string fileExtension) { var result = new List<ExcelRowDto>(); System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); using (var reader = fileExtension == ".xls" ? ExcelReaderFactory.CreateBinaryReader(fileStream) : ExcelReaderFactory.CreateReader(fileStream)) { var dataSet = reader.AsDataSet(new ExcelDataSetConfiguration { ConfigureDataTable = _ => new ExcelDataTableConfiguration { UseHeaderRow = true, ReadHeaderRow = rowReader => { // 跳过前两行表头说明,从第三行开始读 for (var i = 0; i < 2; i++) rowReader.Read(); } } }); var table = dataSet.Tables[0]; for (var i = 0; i < table.Rows.Count; i++) { var row = table.Rows[i]; result.Add(new ExcelRowDto { OrderNo = row["订单号"]?.ToString()?.Trim(), OrderDate = ParseExcelDate(row["下单日期"]), Amount = Convert.ToDecimal(row["金额"]), Status = row["状态"]?.ToString() }); } } return result; }这段代码里有三个关键参数要说明。UseHeaderRow = true表示第一行作为列名,如果你的表头不在第一行,就用ReadHeaderRow回调手动跳行。上面的写法跳过了前两行,适合那种上面带标题说明、第三行才是真正列名的表。ParseExcelDate是自定义方法,因为 Excel 里的日期本质上是序列号,比如 45000 表示某个日期,直接ToString()拿到的不是日期字符串,需要转换,这个函数一般情况下会判断值的类型,是DateTime直接用,是double就DateTime.FromOADate转一下。
读取完成后必须释放 reader,using已经帮你做了。但文件流fileStream不要在这里Dispose,因为它是从 Controller 传进来的,释放责任在调用方,否则后续要记录文件大小或做其他处理时拿不到有效信息。
3.3 数据清洗:空行与列错位的常见处理
用过 ExcelDataReader 的人大概率见过这种场景:导出的 Excel 中间有整行空行,读进来后table.Rows.Count比预期多,或者某一行明明应该有三列,但拿到的row["列名"]是 null。这通常不是库里读错了,而是源文件本身的脏数据。ExcelDataReader 不会自动跳过空行,你需要自己过滤。
判断空行的通用做法是遍历这一行的所有单元格,如果全部为 null 或空字符串,就跳过。不建议只检查第一列,因为有些表第一列是空但后面有数据。过滤逻辑放在循环体最前面:
var isRowEmpty = table.Columns.Cast<DataColumn>() .All(col => string.IsNullOrWhiteSpace(row[col]?.ToString())); if (isRowEmpty) continue;列错位问题更隐蔽。如果用户把某几列隐藏了,row["订单号"]依然能取到,因为索引是按列名匹配的;但如果你用列索引row[0]取值,隐藏列会导致数据整体偏移。所以读取侧的原则是能用列名就不用索引,只有确定表头稳定的内部模板才用索引。
4. 导出 xlsx:EPPlus 生成与文件返回的完整链路
4.1 EPPlus 的 License 配置与 ExcelPackage 基本用法
EPPlus 从 5 版本开始分成了商业版和非商业版,代码里不设置 LicenseContext 会在首次创建ExcelPackage时抛异常。非商业用途只需要在程序入口加一行:
ExcelPackage.LicenseContext = LicenseContext.NonCommercial;如果你不确认项目是否属于商业用途,建议直接去看官网的许可说明,这行代码写在Program.cs里builder.Build()之前,确保所有请求进来之前已经设置好。
创建导出文件的常规流程是:new ExcelPackage()得到内存中的包,Workbook.Worksheets.Add("SheetName")建表,Cells.LoadFromCollection或逐行赋值写入数据,最后package.GetAsByteArray()转字节数组返回给前端。这个流程有几个注意点。
第一,LoadFromCollection需要引用OfficeOpenXml命名空间,它默认按实体类的公开属性名作为列头,如果你想让表头显示为中文,需要配合[DisplayName]特性或者在加载后手动改列名。第二,GetAsByteArray()是一次性把整个包读进内存,数据量大时内存峰值很高,如果导出几十万行,建议改成直接写文件流而不是生成 byte[]。
4.2 模板导出:用 LoadFromCollection 还是逐行写
LoadFromCollection是最快的写法,两行代码就能把 List 倒进 Sheet 里。它对简单场景、列不多、不需要样式的场景非常合适。但它的短板比较明显:列顺序固定按属性声明顺序、表头没有样式、数字格式不自动处理。
实际项目里我更多采用模板模式:提前在App_Data目录放一个.xlsx模板文件,里面设计好表头颜色、列宽、数字格式、公式列,导出时用ExcelPackage加载这个模板文件,然后从指定行开始写数据。这样做的好处是样式不丢,公式也可以预先写在模板里,新追加的数据行自动继承公式。
模板写入的核心代码:
using (var package = new ExcelPackage(new FileInfo(templatePath))) { var sheet = package.Workbook.Worksheets[0]; var startRow = 3; // 模板第3行开始写数据,前两行是标题说明 for (var i = 0; i < dataList.Count; i++) { var row = dataList[i]; sheet.Cells[startRow + i, 1].Value = row.OrderNo; sheet.Cells[startRow + i, 2].Value = row.OrderDate; sheet.Cells[startRow + i, 3].Value = row.Amount; sheet.Cells[startRow + i, 4].Value = row.Status; } sheet.Calculate(); // 重算公式,比如合计行 var bytes = package.GetAsByteArray(); }这里有个参数容易忽略:sheet.Calculate()。如果模板里有SUM这类汇总公式,Excel 打开文件后会自动计算,但如果你导出后要立刻读取计算结果(比如接口返回给前端后前端直接拿去展示),必须手动调用Calculate(),否则缓存里是上一次保存的值。
逐行写比LoadFromCollection多写几行代码,但换来了三个能力:指定列号写入、指定行号插入、循环里对每个单元格单独设置样式。这三个能力在做复杂报表时几乎是刚需。
4.3 返回文件:FileContentResult 与文件名编码
导出接口最后一步是把字节数组返回给前端。ASP.NET Core 里最直接的方式是File(bytes, contentType, fileName),这个重载会返回FileContentResult,自动设置Content-Disposition响应头。
return File(bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", $"report_{DateTime.Now:yyyyMMddHHmmss}.xlsx");文件名的中文问题要注意。FileName参数如果包含中文,框架会自动做Content-Disposition编码处理,在大部分现代浏览器上都能正常下载。但旧版 Safari 或某些国产浏览器对filename*参数支持不完整,会出现文件名乱码。稳妥做法是文件名全用 ASCII 字符集,比如report_20250216.xlsx,中文名放到响应头自定义字段里,前端 JS 拿到后自己改下载名。
还有一个通用问题:大文件导出时GetAsByteArray()会导致内存抖动。如果你确认导出行数超过十万,建议改用package.SaveAs(stream)直接把包写入Response.Body的流中,配合Response.ContentType设置,避免 byte[] 在内存里复制一份。代价是代码要多处理Content-Disposition头,但内存峰值能降一半以上。
5. 避坑指南:Excel 处理中最常见的六个翻车场景
5.1 读取 csv 文件时中文乱码
现象:用 ExcelDataReader 的CreateReader读取.csv文件,中文全部变成乱码,英文和数字正常。
原因:.csv文件本质上是一个文本文件,编码可能是 UTF-8、GBK 或 GB2312。ExcelDataReader 对.csv走的是文本解析逻辑,默认按 UTF-8 解码,遇到 GBK 编码的文件自然乱码。
解决:不要用CreateReader处理.csv,改用StreamReader并显式指定编码。如果你确定上传的文件全部来自某公司内部系统,那八成是 GBK,写法为:
using var sr = new StreamReader(fileStream, Encoding.GetEncoding("GBK"));读取后逐行Split(',')自己解析。注意Split无法正确处理单元格内包含逗号的场景,例如"张三, 李四"会被切成两列,要求不高时可以先顶着。
5.2 EPPlus 报错 License 未设置
现象:调用new ExcelPackage()时抛出LicenseException,提示You must set the license context before using EPPlus。
原因:EPPlus 5 以上版本强制要求设置LicenseContext,不设置就直接拒绝工作。
解决:按 4.1 节写的那行代码,放置位置必须是程序最早的启动路径里。如果你写在了某个静态构造函数里,而这个类是被延迟加载的,那么首次调用时依然会先触发异常。最保险的位置是Program.cs的第一行有效代码处。
5.3 导出文件打开提示文件损坏
现象:导出接口返回 200,下载下来的.xlsx文件用 Excel 打开提示文件已损坏或无法识别。
原因:八成是响应头或返回类型不对。最常见的是把FileContentResult默认的application/octet-stream当成了 Excel 类型,而前端拿到文件后没有正确声明扩展名。另一个可能是GetAsByteArray之后响应流被某些中间件修改了,比如响应压缩中间件对已经压缩过一次的数据再压缩。
解决:检查两个点。第一,Content-Type必须是application/vnd.openxmlformats-officedocument.spreadsheetml.sheet。第二,如果项目里启用了ResponseCompression,在导出接口上加[ResponseCache]或禁用压缩特性,避免Content-Encoding: gzip与二进制文件内容叠加导致解析失败。
5.4 日期读出来变成数字
现象:Excel 里的2024-05-18读出来变成了45430之类的一串数字,格式也不对。
原因:Excel 内部存储日期就是 double 序列号,显示样式是单元格格式层的事。ExcelDataReader 默认返回的是底层值,不会帮你转成 DateTime,除非你在AsDataSet配置里启用ExcelDataSetConfiguration的日期转换选项。
解决:在创建ExcelDataSetConfiguration时设置UseColumnDataType = true,让 DataTable 列保留数据类型。但仍然有个边界情况:如果某列混合了日期和文本,ExcelDataReader 会优先推断为字符串,这一列的值就会变成"2024-05-18"和"45430"混合。这种脏数据要在清洗阶段用正则^\d{5}$先判断是否序列号形态,再转日期。
5.5 读取大文件内存溢出
现象:上传 100MB 左右的.xlsx,读取阶段内存涨到 1GB 以上,进程直接OutOfMemoryException。
原因:AsDataSet()会把整个表格加载进内存,包括所有样式信息、公式链、共享字符串表。文件里的文本如果集中在少数几列但行数巨大,共享字符串表会消耗大量内存。
解决:改用 DataReader 模式逐行读,但不要调用AsDataSet(),直接reader.Read()然后每次reader.GetValue(i)取单列。这样内存占用只和单行数据量相关,不再和总行数相关。实际项目中我处理过 120MB 的订单明细表,DataReader 模式下内存稳定在 200MB 以内。
5.6 上传空文件或空表头不报错但结果为空
现象:上传了一个只有列名没有数据行的 Excel,接口返回成功,但导入的数据列表是空的,前端也没提示。
原因:代码里读表后没有判断行数,循环直接进不了,返回空 List,接口层也没有对这个场景做区分。
解决:在读取层返回结果前加一个校验,如果table.Rows.Count == 0,抛出领域异常或返回特定状态码,由 Controller 捕获后转成 HTTP 400。这样用户能明确知道是文件里没有数据,而不是误以为系统卡死。
6. 进阶:数据校验前置与长事务里的降级方案
Excel 导入模块做到后面,你会发现性能瓶颈通常在数据校验而不是文件解析本身。逐行读 Excel 很快,但每一行要查数据库、校验业务规则、判断重复记录,这些 IO 操作累加起来才慢。这个阶段值得做两件事。
第一件事是校验前置。在调用 ExcelDataReader 解析的同一遍循环里,把每行数据的业务校验结果收集到一个List<string>里,最后统一返回错误集合,而不是解析一行业务校验一行、遇到错误就中断。用户拿到错误列表可以看到所有问题行,一次性改完重新上传,而不需要改一行传一次。批量校验的代码模式是这样的:
var errors = new List<string>(); for (var i = 0; i < table.Rows.Count; i++) { if (string.IsNullOrWhiteSpace(row["订单号"]?.ToString())) errors.Add($"第{i + 1}行:订单号不能为空"); if (!decimal.TryParse(row["金额"]?.ToString(), out _)) errors.Add($"第{i + 1}行:金额格式错误"); } if (errors.Any()) return BadRequest(errors);第二件事是导出数据量过大时,用IExcelDataReader或 EPPlus 的分页写盘来降低内存峰。比如 30 万行数据要导出,不要一次性 List 全加载,分批从数据库Take查询,批量写进同一个 Sheet 的不同行段。每批写完后调用package.SaveAs(stream)把已写部分落盘,内存里只保留当前批的数据。
Excel 模块的坑基本上都是格式和边界问题,不是库本身不行。理解每个库的适用半径,读取就做流式解析,导出就做模板填充,校验单独抽一层,这个组合能撑起大多数业务系统里的导入导出需求。从那次因为日期序列号没转换导致线上报表数据全错之后,我每次处理 Excel 文件都强制走一遍流程:先定编码,再定表头位置,然后空行过滤,最后日期显式转换——这套动作已经被我抄进好几个项目的公共库里。希望帮到你,少踩几个无关的坑。
本文还有配套的精品资源,点击获取