十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ASP.NET Core Excel导入导出方案选型与实战

ASP.NET Core Excel导入导出方案选型与实战 简介这份教程面向需要快速掌握Excel文件读写能力的ASP.NET Core开发人员解决了在项目中通过后端代码实现xlsx格式数据导入导出的常见需求。文档围绕EPPlus.Core组件展开先介绍从NuGet安装依赖库以及Linux、Mac环境下libgdiplus的安装注意事项再完整演示导出流程包括创建工作表、设置表头与单元格值、加粗字体、生成临时文件并返回下载导入部分则包含上传文件、保存至服务器、用ExcelPackage读取并遍历行列数据的实现同时提示文件缺失或格式错误的异常处理建议。文档对两条主线的关键代码做了逐步解释便于初学者照做也适合作为项目开发时的快速参考。整个示例项目结构清晰控制器与视图代码可直接复用。资源共1个PDF文件大小仅43KB内容精炼实用。已有2868人学习下载尤其适合需要在数据迁移、报表导出等场景中快速集成Excel读写能力的开发者。1. ASP.NET Core 里做 Excel 导入导出先想清楚要解决什么问题在很多业务系统里Excel 的导入导出从来不是“给个按钮能下载就行”那么简单。你面对的可能是几万行数据的导出超时、用户上传的 xlsx 模板格式不对、日期字段读出来变成一串数字或者是并发导出时服务器内存直接被打满。这些问题的根子往往不在 Excel 本身而在你选的库、你写的流式处理方式以及你对 xlsx 文件结构的基本认识。ASP.NET Core 平台上做 Excel 导入导出最常见的落地方案是围绕 NPOI、EPPlus 和 MiniExcel 这三个开源库展开。它们都能读写 xlsx但各自的性能特点、API 风格和许可证约束差别很大。这篇文章就顺着“导出 xlsx、导入 xlsx、处理大文件、解决常见坑”这条主线把一套能直接落地的方案讲清楚。适合正在做 Web 后台功能、需要给业务方提供 Excel 导入导出能力的 .NET 开发人员也适合想搞清楚这些库之间边界的技术负责人。2. 选库与 xlsx 原理为什么不能只盯着“哪个库好用”2.1 xlsx 文件拆开看一个 ZIP 包里装着什么xlsx 格式本质上是一个 ZIP 压缩包里面按 Open XML 规范组织了一系列 XML 文件。核心的几个部分分别是[Content_Types].xml、xl/workbook.xml、xl/worksheets/sheet1.xml以及样式定义文件xl/styles.xml。单元格的数值、字符串、公式分别存储在不同的 XML 节点里字符串通常还会被放到xl/sharedStrings.xml中做去重这就是为什么有些 Excel 文件体积不大但打开很慢。理解这个结构对后续排错很有用。比如你导出的文件用 Excel 打开提示“文件已损坏”大概率不是数据算错了而是某个 XML 节点没写全或者[Content_Types].xml里漏掉了工作表声明。再比如你发现导出的文件特别大可能是因为用了共享字符串导致的写入开销也可能是每个单元格都带了一大段冗余样式。2.2 NPOI、EPPlus、MiniExcel 怎么选先说历史包袱。NPOI 是从 POI 移植过来的老牌库优点是免费开源、同时支持 xls 和 xlsxAPI 风格偏向底层的 Workbook/Sheet/Cell 模型适合对 Excel 结构有控制欲的人。EPPlus 在 4.5 版本之前是 LGPL 协议之后改成了 Polyform Noncommercial 1.0.0 协议商业项目如果要继续用新版需要购买许可证。MiniExcel 是这几年比较流行的轻量库核心卖点是“低内存、高性能”它不构建完整的 Excel 对象模型而是直接通过流式读写 XML所以对内存占用控制得非常好。选型时我一般会看三个维度是否要操作模板文件、数据量级有多大、许可证是否符合公司合规要求。如果要基于一个漂亮的模板填充数据EPPlus 的模板处理能力最舒服如果是纯数据导出且数据量在十万行以上MiniExcel 的流式写法和低内存特性更合适如果只是内部系统用且不想纠结许可证NPOI 最稳。2.3 最小实现用 NPOI 导出一个最简单的 xlsx先看一个最基础的导出动作把内存中的数据表写成一个 xlsx 文件并返回到浏览器下载。using NPOI.SS.UserModel; using NPOI.XSSF.UserModel; var workbook new XSSFWorkbook(); var sheet workbook.CreateSheet(订单数据); var headerRow sheet.CreateRow(0); headerRow.CreateCell(0).SetCellValue(订单号); headerRow.CreateCell(1).SetCellValue(客户名); headerRow.CreateCell(2).SetCellValue(金额); var row sheet.CreateRow(1); row.CreateCell(0).SetCellValue(SO-2024001); row.CreateCell(1).SetCellValue(张三); row.CreateCell(2).SetCellValue(1999.99); using var ms new MemoryStream(); workbook.Write(ms); ms.Seek(0, SeekOrigin.Begin); return File(ms, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, 订单.xlsx);这段代码做了三件事创建XSSFWorkbook对象对应 xlsx 格式如果是 xls 则用HSSFWorkbook在工作簿里建 Sheet 并写入表头和一行数据最后把整个工作簿写入MemoryStream并封装成文件响应返回。注意返回的 MIME 类型必须是application/vnd.openxmlformats-officedocument.spreadsheetml.sheet否则浏览器可能直接把它当成普通二进制文件下载或者 Excel 打开时提示格式不匹配。这只是一个教学级的最小示例真正生产环境里你需要在每个单元格设置列宽、自适应行高等额外处理。但核心流程不会有变化无论用哪个库最终都要把工作簿对象序列化到流里再由 ASP.NET Core 的File方法返回。3. 用 NPOI 实现 Excel xlsx 导出的完整流程3.1 导出 Controller 的接口设计实际项目中导出接口的入参通常是查询条件而非直接传数据。比如订单导出前端传一个时间范围后端去数据库查然后把查询结果写入 Excel 返回。[HttpGet(export/orders)] public async TaskIActionResult ExportOrders(DateTime start, DateTime end) { var data await _orderService.QueryByRangeAsync(start, end); var bytes ExcelExporter.ExportOrderSheet(data); return File(bytes, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, $订单_{start:yyyyMMdd}_{end:yyyyMMdd}.xlsx); }注意文件名里拼接了日期范围这个细节在业务上很实用用户下载后能直接从文件名看出数据范围。如果文件名里有特殊字符建议用UrlEncoder处理一下某些浏览器对中文文件名和特殊字符的解析有兼容性问题。3.2 给表格加上样式、列宽和公式Excel 导出不可能只有纯文本通常要有表头背景色、列宽自适应、金额列保留两位小数甚至要加上合计行。以 NPOI 为例样式和公式都是独立在数据填充之外的逻辑需要在写单元格之前先创建对应的CellStyle。public static byte[] ExportOrderSheet(ListOrderDto data) { var workbook new XSSFWorkbook(); var sheet workbook.CreateSheet(订单明细); var headerStyle workbook.CreateCellStyle(); headerStyle.FillForegroundColor NPOI.HSSF.UserModel.HSSFColor.LightBlue.Index; headerStyle.FillPattern FillPattern.SolidForeground; var font workbook.CreateFont(); font.IsBold true; headerStyle.SetFont(font); var headerRow sheet.CreateRow(0); string[] headers { 订单号, 客户名, 订单金额, 下单时间 }; for (int i 0; i headers.Length; i) { var cell headerRow.CreateCell(i); cell.SetCellValue(headers[i]); cell.CellStyle headerStyle; sheet.SetColumnWidth(i, 20 * 256); } for (int r 0; r data.Count; r) { var row sheet.CreateRow(r 1); row.CreateCell(0).SetCellValue(data[r].OrderNo); row.CreateCell(1).SetCellValue(data[r].CustomerName); row.CreateCell(2).SetCellValue((double)data[r].Amount); row.CreateCell(3).SetCellValue(data[r].CreateTime.ToString(yyyy-MM-dd HH:mm:ss)); } var sumRow sheet.CreateRow(data.Count 1); sumRow.CreateCell(1).SetCellValue(合计); sumRow.CreateCell(2).SetCellFormula($SUM(C2:C{data.Count 1})); using var ms new MemoryStream(); workbook.Write(ms); ms.Seek(0, SeekOrigin.Begin); return ms.ToArray(); }这里有一个关键的坑SetColumnWidth传入的是字符宽度的 256 倍不是像素。20 * 256表示这一列约能容纳 20 个字符。如果不做转换直接传 20列宽会变成几乎看不见的一列。另一点是CreateCell(3)写入日期时用了ToString格式化而不是直接传DateTime类型。NPOI 对原生DateTime的处理需要额外设置CellStyle.DataFormat否则写进单元格的可能是DateTime的 OADate 序列化数值用户打开 Excel 会看到一串小数。新手在这里最容易踩坑。3.3 大数据量导出的内存控制与超时规避当数据量上升到十万行这个量级XSSFWorkbook会把整个工作簿的结构都保存在内存中一个 10 万行、20 列的导出内存占用可能超过 500MB。这在小内存的服务器上非常致命甚至会导致进程被 OOM。常见的规避思路是使用SXSSFWorkbookPOI 的流式版本NPOI 里对应的实现是XSSFWorkbook没有流式模式但可以通过“只保留必要数据、分批次写入”来减轻压力。如果坚持用 NPOI可以换一种思路把大表拆成多个 Sheet每个 Sheet 写到 20000 行就切下一个因为Workbook内部对 Sheet 的管理比单 Sheet 超大数据要分散一些。更推荐的做法是引入 MiniExcel 来做大数据量导出它的流式写入机制不会在内存里构建完整文档树写入几千行和几十万行的内存增长曲线非常平缓。你可以在 MiniExcel 的官方文档里看到它的基准测试结果同数据量下内存占用只有 NPOI 的几分之一。4. 用 MiniExcel 实现 Excel xlsx 导入的解析与校验4.1 从 IFormFile 到数据集看一种最直观的写法导入的逻辑和导出正好相反前端上传一个 xlsx 文件后端解析文件内容把每一行映射成业务模型再做校验和落库。MiniExcel 的Query方法可以帮你把 Sheet 里的每一行直接映射成IDataReader或动态类型写法非常简洁。[HttpPost(import/orders)] public async TaskIActionResult ImportOrders(IFormFile file) { if (file null || file.Length 0) return BadRequest(请上传文件); var ext Path.GetExtension(file.FileName).ToLower(); if (ext ! .xlsx ext ! .xls) return BadRequest(仅支持 xlsx 或 xls 文件); using var stream file.OpenReadStream(); var rows MiniExcel.Query(stream); var orderList new ListOrderDto(); var errors new Liststring(); int rowIndex 1; foreach (var row in rows) { rowIndex; // row 是动态类型列名对应 Excel 表头 var orderNo row.订单号?.ToString()?.Trim(); if (string.IsNullOrEmpty(orderNo)) { errors.Add($第 {rowIndex} 行订单号不能为空); continue; } if (orderList.Any(o o.OrderNo orderNo)) { errors.Add($第 {rowIndex} 行订单号重复); continue; } orderList.Add(new OrderDto { OrderNo orderNo, CustomerName row.客户名?.ToString(), Amount Convert.ToDecimal(row.金额) }); } if (errors.Count 0) return BadRequest(new { message 校验未通过, errors.Take(10) }); await _orderService.BatchInsertAsync(orderList); return Ok(new { count orderList.Count }); }MiniExcel.Query返回的是IEnumerabledynamic它的特性是延迟执行。也就是在foreach里每迭代一行才真正从流里解析一行。这个设计加重了写校验逻辑时的注意义务如果你在校验失败时需要提前终止直接break就行后面没迭代到的行不会占用内存。但这也带来一个副作用——你无法在foreach之前得知总行数。如果业务上需要在导入前就告诉用户“这个文件有 5000 行”你要么先做一次rows.Count()强制迭代要么用MiniExcel.Query的useHeaderRow参数配合读两次流。4.2 处理 Excel 单元格类型和日期格式的常见问题Excel 解析里最头疼的不是数据取不出来而是取出来的类型和你想的不一样。典型的场景有三个手机号、身份证号这类长数字被读成科学计数法单元格里存的是数字但业务字段是字符串转出来带了一堆小数位日期列格式化之后变成 OADate 数值。第一个场景的最佳解法不是在后端解析时补救而是在前端调整 Excel 模板格式。让用户在录入前把手机号列设为文本格式从源头上避免数字截断。如果数据已经上传了只能通过解析库的GetValue或类型转换做处理。对于日期MiniExcel 提供了一个ExcelOpenXml的配置项你可以显式指定列的类型。更稳妥的做法是读取单元格的底层类型再决定转换逻辑如果拿到的是数字且业务上是日期字段用DateTime.FromOADate转回日期。var rawDate row.下单时间; DateTime dt; if (rawDate is double odata) { dt DateTime.FromOADate(odata); } else if (rawDate is DateTime dateTime) { dt dateTime; } else { DateTime.TryParse(rawDate?.ToString(), out dt); }这段代码没有魔法就是常规的防御式编程先把动态值拆出可能的类型分支再逐层尝试转换。实际项目里可以把它封装成ExcelValueConverter工具类统一处理所有字段的类型转换避免在业务代码里到处写类型判断。4.3 导入时的数据校验与事务边界导入功能最容易被业务方挑战的是“批量导入的数据哪些成功了、哪些失败了”。生产级的方案一般是先做完整文件校验把错误整理成“行号错误原因”的清单返回给前端只有全部数据校验通过才写入数据库写入时如果数据量不大用事务包住以确保要么全成功要么全回滚。如果数据量在几千行以内EF Core 的AddRange加单次SaveChanges不会有太大性能问题。超过一万行就要考虑分批插入或者用SqlBulkCopy。无论用哪种方式都要注意把校验逻辑和写入事务分开先校验后写入的基本顺序不要颠倒。4.4 处理用户上传的异常文件导入上线后你会遇到各种没预料到的文件空文件、只有表头没有数据的文件、Excel 公式计算结果为空的行、隐藏行列导致多出来的空数据。这些都有固定的排错套路。先用MiniExcel.RowCount判断行数是否为 0解析时用try-catch捕获异常对 Excel 文件结构损坏的情况返回“文件解析失败”而不是 500解析完的数据做一次空行过滤。var rows MiniExcel.Query(stream).Where(r r.订单号 ! null || r.客户名 ! null || r.金额 ! null).ToList();这里有一个容易忽略的细节Where条件必须覆盖所有业务字段否则用户上传了一个第一列全空的文件你过滤不掉后续校验又识别不出来最终落库时主键冲突或者产生脏数据。5. 复杂表头、合并单元格与数据校验的实战加固5.1 二级表头怎么实现实际业务里很多模板的第一行是大类名第二行才是具体字段比如“订单信息”下面分“订单号”“客户名”“财务信息”下面分“金额”“税率”。用 NPOI 做这种表头不复杂先创建两行把合并单元格的区域指定清楚再在第二行写入具体字段。sheet.CreateRow(0).CreateCell(0).SetCellValue(订单信息); sheet.CreateRow(0).CreateCell(3).SetCellValue(财务信息); sheet.AddMergedRegion(new NPOI.SS.Util.CellRangeAddress(0, 0, 0, 1)); sheet.AddMergedRegion(new NPOI.SS.Util.CellRangeAddress(0, 0, 2, 3)); var row2 sheet.CreateRow(1); row2.CreateCell(0).SetCellValue(订单号); row2.CreateCell(1).SetCellValue(客户名); row2.CreateCell(2).SetCellValue(金额); row2.CreateCell(3).SetCellValue(税率);AddMergedRegion的参数含义是“起始行、结束行、起始列、结束列”。CellRangeAddress(0, 0, 0, 1)表示把第 0 行的第 0 列到第 1 列合并。这个写法的细节是合并之后只能往单元格区域的左上角写值其他格子的值即使写了也不会显示。所以上面代码里创建了两个合并区域覆盖四列但只在左上角各写了一个值。5.2 用 NPOI 读取复杂模板时的单元格定位导入的复杂表头文件就不能用 MiniExcel 的按表头映射了因为表头不在第一行或者表头是跨行合并的自动映射会错位。这时要退回 NPOI 的行列定位方式手动指定哪一行是数据起点哪一列是哪个字段。var sheet workbook.GetSheetAt(0); var dataStartRow 2; for (int r dataStartRow; r sheet.LastRowNum; r) { var row sheet.GetRow(r); if (row null) continue; var orderNo row.GetCell(0)?.ToString()?.Trim(); var amount row.GetCell(2)?.NumericCellValue; }这里一定要做row.GetCell(index)?.ToString()的空值判断因为 Excel 里一个行对象可能是稀疏的中间某列没有写入过单元格时GetCell返回 null直接调用会抛NullReferenceException。5.3 导入模板下载与导入校验的配合机制业务上常见的做法是先在页面上提供一个“下载导入模板”按钮模板里带表头校验规则和下拉选项用户基于模板填写数据再上传。模板下载本质上就是一次带格式的导出可以在模板里把必填列用黄色背景标记、把数据类型列设置好单元格格式。这个机制能大幅降低导入校验的错误率。与其在后端写一堆“第几行格式错误”的提示不如让用户从一开始就不可能填错。尤其是Excel 下拉列表怎么根据前一个选项确定后面选择的内容这类需求在 Excel 模板里通过数据验证实现二级联动比后端报错再让用户改效率高得多。6. 处理大文件上传和流式写回的进阶技巧6.1 调整 Kestrel 和 IIS 的上传限制导入功能上线后碰到的第一个问题往往不是解析报错而是文件根本传不上去。ASP.NET Core 默认的请求体大小限制是 30MB而一张正常的 xlsx 表格稍微带点图片或格式就能轻松超过这个数。builder.WebHost.ConfigureKestrel(options { options.Limits.MaxRequestBodySize 100 * 1024 * 1024; }); // 如果部署在 IIS 下还要在 web.config 中设置 // requestLimits maxAllowedContentLength104857600 /MaxRequestBodySize单位是字节100 * 1024 * 1024对应 100MB。Kestrel 的限制在代码里就能改但 IIS 的限制在 web.config 里两者需要同时改否则会有一个先拦截你的请求。6.2 MiniExcel 流式写入防止服务器内存暴涨导出大文件和导入大文件是镜像的两个问题导入卡在上传导出卡在内存。MiniExcel 提供一个SaveAs的流式重载可以把数据一行一行写入文件流配合 ASP.NET Core 的IAsyncEnumerable能让导出的内存占用维持在非常低的水平。public async IAsyncEnumerabledynamic FetchOrdersAsync(DateTime start, DateTime end) { await foreach (var order in _orderService.StreamByRangeAsync(start, end)) { yield return new { 订单号 order.OrderNo, 客户名 order.CustomerName, 金额 order.Amount }; } } [HttpGet(export/orders-stream)] public async Task ExportOrdersStream(DateTime start, DateTime end) { Response.ContentType application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; Response.Headers[Content-Disposition] $attachment; filename\orders_{start:yyyyMMdd}.xlsx\; await MiniExcel.SaveAsAsync(Response.Body, FetchOrdersAsync(start, end)); }这段代码的核心价值在于yield return配合SaveAsAsync的逐个写入数据库查一条写一条内存里永远只有当前这一行的数据。代价是流式写法对 Service 层的查询方法有要求必须支持IAsyncEnumerable或至少逐行返回数据不能一次性把所有结果ToList。6.3 分布式部署下导出文件的落盘策略最后提一个架构层面的问题。如果你的服务做了负载均衡、多实例部署导出功能要特别小心“把文件写到本地磁盘”的做法。用户请求被分发到实例 A 生成文件下载时被转发到实例 BB 的磁盘上没有这个文件下载就失败了。常见的方案有两个一是把生成的文件直接作为响应流返回不落盘这也是上文所有示例采用的方案二是必须落盘时把文件写到共享存储或对象存储下载接口统一从存储服务取文件。大部分场景下方案一已经足够了xlsx 文件生成后立刻流式返回给浏览器进程重启或实例切换都不会影响用户体验。真正需要挑战的是业务数据行数特别大的场景。如果需要一次导出百万行方案一会因为生成时间过长导致客户端连接超时必须改成“异步生成任务 轮询下载”的模式。但在做这个设计之前先反问业务方一句Excel 单表最多支持 104 万行用户真的需要一次导出 104 万行吗很多时候他们只需要一个带汇总统计的报表而不是全量原始数据。把需求聊清楚往往比把技术方案做复杂更有效。本文还有配套的精品资源点击获取
返回列表