简介面向使用ASP.NET Core的开发者提供基于WebAPI的Excel读写方案。整合EPPlus与ExcelDataReader两个主流库演示了通过REST接口导出和读取Excel文件避免依赖Office组件适合Web服务中的报表导出、批量数据导入等场景。资源打包为ZIP共208个文件含12个C#源码文件、项目工程文件、配置文件以及149个依赖库整体约21.89MB为可直接编译运行的示例项目。目前已有400人学习下载。通过该资源可掌握EPPlus创建WorkSheet、填充单元格、保存xlsx的完整流程理解ExcelDataReader流式读取大容量表格的机制同时参考控制器的MIME类型与Content-Disposition设置能显著降低实际集成过程中的出错风险。示例代码覆盖了文件流处理、HTTP响应封装、Excel格式兼容等关键细节并提供了VS解决方案文件便于整体定位与调试。无论是初学者还是需要快速交付功能的开发人员都能从中获得可复用的代码骨架。1. 这项目到底是干嘛的给后端一个现成的 Excel 读写通道接了个给系统加批量导入的功能不想让逻辑散得到处都是最后拿excel-handler这个基于 ASP.NET Core Web API 的项目直接改了改就用上了。它做的事情非常聚焦用 ExcelDataReader 把上传的 .xlsx/.xls 解析成 C# 对象用 EPPlus 把查询结果导出成 Excel 文件流整个链条封装成一个 WebApi 服务调用方只需要发 HTTP 请求。它不是什么重型框架就是一个干净的中间层。适合正在做数据迁移、后台管理系统的报表导入导出、或者需要定时生成 Excel 下发到业务方的后端开发者尤其是想把 Excel 处理逻辑从业务代码里剥出去的那类人。这篇我直接按拆项目的方式把技术选型、目录结构、核心代码、运行方式和改造成点逐一讲清楚。2. 架构与核心模块拆解EPPlus 写、ExcelDataReader 读分工明确2.1 为什么是这两个库先说我选型时的判断。Excel 处理在 .NET 生态里可选项很多NPOI 也能读写但用起来模板味道重导出样式控制不够顺手。excel-handler的做法是读写分离读文件用 ExcelDataReader它是一套流式读取方案不把整个 Excel 加载成一个大对象对内存友好写文件用 EPPlus它基于 OpenXML样式、公式、图表支持得很全导出体验更接近操作真实 Excel。这个分工有一个实际好处读取场景关注的是“原样拿数据”包括老版本 .xls 的兼容性导出场景关注的是“排版和格式”包括列宽、合并单元格、表头样式。一个项目里同时引两个库各管一段不互相干扰代码结构也清楚。2.2 目录结构一个可以直接跑的 WebApi 项目长什么样excel-handler/ │ ├─ Controllers/ │ └─ ExcelController.cs # WebApi 入口接收上传和导出请求 │ ├─ Services/ │ ├─ IExcelService.cs # 接口定义 Read 和 Export 方法 │ └─ ExcelService.cs # 核心实现ExcelDataReader 读取、EPPlus 导出 │ ├─ Models/ │ ├─ ImportResult.cs # 通用返回模型包裹数据行和错误信息 │ └─ ExportRequest.cs # 导出请求的入参模型 │ ├─ Program.cs # 服务注册、管道配置 ├─ appsettings.json # 连接串、EPPlus License 配置 └─ excel-handler.csproj # NuGet 包引用这是一个典型的 Controller Service 分层。Controller 只负责接收 HTTP 请求、做参数校验、返回响应状态码所有 Excel 操作都收敛在 ExcelService 里。这样做的直接好处是如果哪天上层换成了 GRPC 或者消息队列来触发导入导出底层 ExcelService 基本不用动。我一般拿到这种工程第一件事也是看 Services 目录业务密度都在这里Controller 基本就是薄薄一层。2.3 读取端实现ExcelDataReader 的流式解析读取流程的代码是整个项目最值得抄的部分核心就一个方法public async TaskImportResult ReadExcelAsync(IFormFile file) { var result new ImportResult(); // IFormFile 本身就是流直接交给 ExcelReaderFactory using var stream file.OpenReadStream(); using var reader ExcelReaderFactory.CreateReader(stream); var config new ExcelDataSetConfiguration { ConfigureDataTable _ new ExcelDataTableConfiguration { UseHeaderRow true, // 把第一行当成列名 EmptyRowCount 2 // 连续两个空行就认为表格结束 } }; var dataSet reader.AsDataSet(config); var sheet dataSet.Tables[0]; // 默认取第一个工作表 foreach (DataRow row in sheet.Rows) { var item new Dictionarystring, object(); foreach (DataColumn col in sheet.Columns) { item[col.ColumnName] row[col]; } result.Rows.Add(item); } return result; }这里有几个参数要说明。UseHeaderRow true表示 Excel 第一行是列标题解析后生成的 DataTable 的列名就是表头文字后面通过col.ColumnName取数非常直观。EmptyRowCount 2是绝大多数导入场景的常规设定空行容易混在数据中间连续两个空行基本可以确定数据区结束了再往下的可能是备注或者预留行读进来反而是脏数据。AsDataSet()是 ExcelDataReader.DataSet 扩展包提供的方法它把整个工作簿转成 DataSet 结构每个工作表对应一个 DataTable。跳过空行的逻辑其实还隐藏了一个细节单个空行在数据区中间是合法的很多 Excel 模板就是隔行填充的直接把空行全跳掉会导致行号和源文件对不上。EmptyRowCount这种“连续空行才算结束”的设定恰好避开这个问题。2.4 导出端实现EPPlus 生成 Excel 文件流导出侧的核心代码长这样public async Taskbyte[] ExportExcelAsync(ExportRequest request) { // EPPlus 5 必须声明 LicenseContext否则会抛异常 ExcelPackage.LicenseContext LicenseContext.NonCommercial; using var package new ExcelPackage(); var worksheet package.Workbook.Worksheets.Add(request.SheetName); // 写表头 for (int i 0; i request.Headers.Length; i) { worksheet.Cells[1, i 1].Value request.Headers[i]; } // 写数据行 int rowIndex 2; foreach (var row in request.Rows) { for (int i 0; i request.Headers.Length; i) { worksheet.Cells[rowIndex, i 1].Value row[i]; } rowIndex; } worksheet.Cells[1, 1, 1, request.Headers.Length].Style.Font.Bold true; worksheet.Cells.AutoFitColumns(); return await package.GetAsByteArrayAsync(); }说完逻辑再解释两个关键参数。worksheet.Cells[1, i 1]是 EPPlus 的行列索引方式行 1 列 1 就是 A1 单元格行 2 列 1 是 A2。Cells[1, 1, 1, request.Headers.Length]表示从 A1 到最后一列表头的整段区域对这种区域设置Style.Font.Bold是很常见的表头加粗做法。AutoFitColumns()会自动按内容调整列宽不调的话导出文件打开后普遍挤在一起阅读体验很差。最后GetAsByteArrayAsync()返回的是文件字节数组Controller 层拿到后可以直接包成File()响应也可以落盘灵活性足够。2.5 接口约定与返回模型Controller 层暴露了两个接口路径简单直接[HttpPost(import)] public async TaskIActionResult Import(IFormFile file) [HttpPost(export)] public async TaskIActionResult Export([FromBody] ExportRequest request)ImportResult带三个字段是否成功、解析出的行数、数据行列表。ExportRequest带三个字段Sheet 名称、表头数组、数据行数组。接口设计上有一点很务实ExportRequest的数据行直接用Listobject[]不依赖任何强类型实体意味着这个接口可以同时给订单导出、用户导出、日志导出用前端传什么就导什么通用性极强。代价是没有编译期类型检查但作为导出工具类这个取舍值得。3. 本地跑通从拉代码到看到第一份导出文件3.1 环境要求与依赖安装跑这个项目先确认环境。需要一个 .NET SDK6.0 或者 8.0 都能编译项目没有用到特别新版本的语言特性只要是 LTS 版本都稳。IDE 随便Visual Studio 2022 或者 VS Code 装 C# 插件都行我没用任何全局工具或额外服务所以环境准备非常简单。依赖有三个在 csproj 里按需引入PackageReference IncludeEPPlus Version5.8.3 / PackageReference IncludeExcelDataReader Version3.6.0 / PackageReference IncludeExcelDataReader.DataSet Version3.6.0 /这里注意一个组合问题ExcelDataReader负责读取流但AsDataSet()方法其实在ExcelDataReader.DataSet扩展包里忘记引扩展包会在编译期报AsDataSet不存在。另外 EPPlus 从 5.0 开始不再是完全免费的商用场景需要购买授权本地开发用LicenseContext.NonCommercial没问题但上线部署前要评估一下许可证类型。3.2 启动项目的三种方式第一种命令行启动最直接cd excel-handler dotnet restore dotnet run服务默认监听http://localhost:5000看到控制台输出Now listening on: http://localhost:5000就是起来了。第二种Visual Studio 用户直接 F5 运行IIS Express 会随机分配一个端口比如http://localhost:5298调试断点更顺手。第三种如果要跑在固定端口可以在 launchSettings.json 里改 applicationUrl也可以在项目根目录跑dotnet run --urls http://localhost:8080个人习惯是直接指定 8080因为 5000 经常被别的进程占用端口冲突是新手最容易蒙的情况。3.3 接口调用示例与参数说明项目启动后先准备一份测试 Excel第一行写标题下面写几行数据然后调导入接口curl -X POST http://localhost:8080/api/excel/import \ -F file/tmp/demo.xlsx响应是一个 JSON 数组每个元素是一个字典键是表头值是单元格内容。如果表头叫姓名、城市返回就是[ { 姓名: 张三, 城市: 杭州 }, { 姓名: 李四, 城市: 上海 } ]再用一个 JSON 调导出接口curl -X POST http://localhost:8080/api/excel/export \ -H Content-Type: application/json \ -d { sheetName: 用户报表, headers: [姓名, 城市], rows: [[张三, 杭州], [李四, 上海]] } \ -o output.xlsx返回的文件流直接落盘成output.xlsx。整个流程不需要任何前端页面用 curl 就能把读写链路完全验证一遍。以下是 ExportRequest 的核心参数说明参数类型说明sheetNamestring生成的 Excel 工作表名称默认 Sheet1headersstring[]表头数组决定列数和顺序rowsobject[][]数据行行列索引需要和 headers 对齐这个接口调试起来最省心的地方是返回状态码和错误信息都不含糊文件上传失败时中间件会返回 400记录在哪一层出错都有日志可查。4. 避坑指南导入导出最常见的 5 个坑4.1 EPPlus 启动直接抛异常许可证上下文未配置现象项目第一次跑起来调用导出接口立刻收到 500日志显示LicenseContext相关异常。原因EPPlus 5.0 之后强制要求声明使用场景。解决每次使用前设置ExcelPackage.LicenseContext LicenseContext.NonCommercial或者放到静态构造函数里确保只执行一次我放到 Export 方法前每次执行更稳妥。4.2 读取 .xls 老格式时流被占用现象导入接口收到 .xls 格式的旧文件时偶发文件被占用错误偶尔又能成功。原因file.OpenReadStream()直接打开流ExcelDataReader 底层在读取 .xls 时需要随机访问流如果流不支持 seek 就会出问题。解决先把 IFormFile 复制到 MemoryStream 再解析using var ms new MemoryStream(); await file.CopyToAsync(ms); ms.Position 0; using var reader ExcelReaderFactory.CreateReader(ms);从那以后看到FileStream或IFormFile直接套 memory stream 成了习惯省得读取旧格式时随机挂掉。4.3 UseHeaderRow 导致最终数据错位现象Excel 第一行的第二列是空值解析出来之后所有行都少了一列数据对不上。原因UseHeaderRow true时列名空着会被自动命名为Column1、Column2但程序按col.ColumnName取数时和实际数据错位。解决解析前先遍历列头遇到空值直接往回填一个占位名或者干脆严格要求模板带头。4.4 数字类型的001导入后变成 1现象导入身份证号、银行卡号这些以数字开头但带前导零的字段读进来全变成了 1、001 变成 1。原因Excel 单元格本身存储为数值格式ExcelDataReader 按底层类型返回不会自动识别它是编号。解决读取端对特定列做一次数据格式还原用value.ToString().PadLeft(原长, 0)补零或者调用导出时强制设置 NumberFormat。4.5 导出中文文件名变成下划线现象用 curl 导出指定output-报表.xlsx落盘后发现文件名叫output_.xlsx。原因HTTP 响应头Content-Disposition里直接带中文没有做 RFC 5987 编码。解决用filename*UTF-8编码文件名.NET里用UrlEncoder.Default.Encode处理一下文件名再放进响应头。5. 二次开发把通用工具改造成业务服务拿到这个项目不要直接开跑先想清楚你的导入导出是给内部用还是给外部系统用这会决定你怎么改。如果只是内部后台解析 Excel现成的 Controller 层就够了如果要做成公共服务我一般会往两个方向改一是把导入结果从Dictionary转成强类型对象二是把导出内容换成模板文件。先说强类型转化。Dictionarystring, object的好处是通用但到业务层就得写一堆Convert.ToInt32(row[age])。我习惯加一个泛型扩展方法public static T? GetT(this Dictionarystring, object row, string key) { if (!row.ContainsKey(key)) return default; var value row[key]; if (value null || value DBNull.Value) return default; return (T)Convert.ChangeType(value, typeof(T)); }业务代码里直接row.Getint(age)或row.Getstring(name)类型转换收敛在一个方法里全项目的取值逻辑不会满天飞。第二件事是模板导出。很多报表有固定表头和样式直接用代码一行行写样式很痛苦。EPPlus 支持从现有 Excel 模板加载using var templateStream System.IO.File.OpenRead(templates/report.xlsx); using var package new ExcelPackage(templateStream); var worksheet package.Workbook.Worksheets[Sheet1]; // 在指定区域填充数据保留模板里预置的样式模板里把边框、合并单元格、列宽都先做好代码只负责填数据导出效果直接上一个档次。这个方法对月度报表、对账单这类重复性很高的导出场景非常适用。最后说说异步。EPPlus 的写入操作本身是内存操作包一层async/await并不能带来真实性能提升真正的瓶颈在数据来源——如果是从数据库读取几万行再导出瓶颈在数据库查询和对象映射。我做数据导出时通常先做查询分页每批 5000 行往 worksheet 写边查边写。这套项目用下来最大的感受是读写分离的设计确实省心。从那以后我每次接到导入需求都会先确认列头是不是固定模板、有没有前导零字段、需不需要保留空行这些问题在项目里都提前考虑过就不用在线上排查时手忙脚乱。希望帮到你。本文还有配套的精品资源点击获取