
简介针对ASP.NET Core开发者的Excel xlsx导入导出实践指南围绕EPPlus.Core库提供跨平台方案。文档从创建ASP.NET Core项目、NuGet安装EPPlus.Core开始逐步演示导出功能新建Worksheet、写入表头与数据、设置样式并保存文件再通过File方法返回下载导入功能则包含IFormFile接收上传、存储临时文件、读取Worksheets并遍历Cells读取数据另说明了Linux需安装libgdiplus等兼容性问题。整个资源为单个PDF文档约43KB内容精炼且附有可直接运行的C#代码片段适合需要快速在ASP.NET Core项目中集成Excel读写能力的开发者亦可用于数据迁移、备份与分析等场景。已有2869人学习作为入门实例具备较高实用参考价值。1. 服务端做 xlsx 导入导出别装 Office也别迷信 Interop给 ASP.NET Core 后端加一个 Excel 导入导出功能听起来就是把表格读出来、写回去真正动手才发现连环坑。有人在 Windows 上用 Interop.Excel 调 Office COM一部署到 Linux 容器就抛异常有人不管授权随手选了一个 Excel 处理框架做到一半才被告知商用要买证书更别说中文文件名乱码、日期读成科学计数法这类看似小事实则劝退的问题。这里要展开的是 ASP.NET Core 里导入导出 Excel xlsx 文件最常见的做法用 NPOI 这类开源库直接读写 xlsx 文件本身不依赖 Office 环境做成可部署、可维护的接口。适合正在做报表导出、台账批量导入和后台管理系统的 .NET 开发者。2. 选型先于编码四个 C# Excel 处理框架怎么选NPOI 凭什么够用接到需求先选框架而不是先写代码这是我的一贯顺序。C# 生态里做 xlsx 处理的框架不少各自的授权、内存模型和使用体感差很多选错后面返工成本很高。2.1 授权与内存模型一张表看清边界先把几个常用框架摆在桌上对比注意重点是授权和部署边界而不是功能数。框架授权适合场景注意点NPOIApache-2.0xls/xlsx 读写、报表导出、导入解析传统 API内存占用偏高导大数据用 SXSSFClosedXMLMIT小到中型 xlsx、链式操作整文件加载几万行以后内存涨得明显EPPlus商业授权个人/非商业免费图表、透视表、样式丰富的报表商用要买 License别到快上线才发现MiniExcelMIT大文件流式读写、导入场景API 轻导出功能相对基础Interop.ExcelWindows 专属不建议用于服务端要装 Office容器里几乎不可用选型不能只看功能满不满足还要看部署环境。如果目标是 Linux Docker 容器Interop 直接排除如果目标是给客户交付客户环境装没装 Office 你控制不了所以 NPOI、MiniExcel 这类不依赖宿主程序的库才是主路。EPPlus 功能最全但授权金和合规审查绕不过去小公司项目没必要赌这个风险尤其不要在一个共享进程里偷偷用。2.2 最小可用项目用 NPOI 跑通第一个导出先装包在项目根目录执行dotnet add package NPOI然后写一个最简单的导出 Action。NPOI 里 XSSFWorkbook 对应 .xlsxHSSFWorkbook 对应老的 .xls下面示例只做 xlsx。[HttpGet(export/demo)] public IActionResult ExportDemo() { var workbook new XSSFWorkbook(); var sheet workbook.CreateSheet(示例); var header sheet.CreateRow(0); header.CreateCell(0).SetCellValue(编号); header.CreateCell(1).SetCellValue(名称); var row sheet.CreateRow(1); row.CreateCell(0).SetCellValue(1); row.CreateCell(1).SetCellValue(示例数据); var ms new MemoryStream(); workbook.Write(ms); ms.Position 0; workbook.Dispose(); return File(ms, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, demo.xlsx); }这段代码做了四件事创建内存工作簿、建 Sheet、写表头和一行数据、把工作簿写入 MemoryStream 并通过 File 返回给浏览器。需要特别注意MemoryStream 不要用 using 包住FileStreamResult 在响应写完时会自己释放传入的流。如果你在 Action 里提前 Dispose 了它用户下载到的往往是 0 字节文件或者直接报“流已关闭”。这个坑我见过不止一次很多入门示例都写错了。提示动作方法里不要手动释放要返回的 MemoryStream交给 FileStreamResult 处理。workbook 则可以放心在 Write 完之后 Dispose。Content-Type 必须用application/vnd.openxmlformats-officedocument.spreadsheetml.sheet这是 OOXML 的注册 MIME 类型。用错类型有的浏览器会直接打开乱码有的企业下载网关会拦下来。2.3 为什么不用 Microsoft.Office.Interop.Excel每次提到 Excel 相关功能总有人问能不能直接调 Interop。我的结论很直接服务端别用。第一宿主机必须装完整 OfficeLinux 容器里根本没得跑第二COM 组件在 ASP.NET Core 进程里有线程模型约束并发一上来经常出现“无法获取 COM 对象”的玄学错误第三Office 组件有会话隔离崩溃会影响同一台机器上的其他业务。还有人会把客户端“Excel 加载项被禁用”这类问题归到服务端其实两者没什么关系。服务端根本不启动 Excel 程序只是解析 xlsx 这个 zip 压缩包里的 XML加载项被禁用是 Office 客户端自己的事。想通这一点你就明白为什么服务端团队几乎只选开源库。Interop 唯一的合理使用场景是给最终用户电脑上写本地 Office 自动化脚本而不是跑在 Web 服务里。3. 导出 xlsx 到浏览器从零手写可落地的导出接口最小示例能下载一个文件但距离“能交给用户”还差得远。这一章处理三个高频问题中文文件名不乱码、表头不是白底黑字的临时文件、数据量大时不把内存打爆。3.1 输出流与响应头让浏览器正确下载而不是乱码直接File(ms, contentType, 中文报表.xlsx)也能跑但中文文件名在 Content-Disposition 里经常被转成乱码老版本浏览器和企业下载工具尤其敏感。我一般会自己构造 ContentDispositionusing Microsoft.Net.Http.Headers; using System.Net.Mime; [HttpGet(export/report)] public IActionResult ExportReport() { var workbook new XSSFWorkbook(); workbook.CreateSheet(数据); var ms new MemoryStream(); workbook.Write(ms); ms.Position 0; workbook.Dispose(); var contentDisposition new ContentDispositionHeaderValue(attachment) { FileNameStar 2024年度报表.xlsx, FileName report.xlsx }; Response.Headers[HeaderNames.ContentDisposition] contentDisposition.ToString(); return File(ms, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); }FileNameStar 遵循 RFC 5987支持 UTF-8 编码现代浏览器优先读它FileName 留一个纯 ASCII 的备用名给不支持 FileNameStar 的工具兜底。这个方案兼容性比只传一个中文文件名好很多尤其用户环境里混着 WPS、旧 Office 和国产浏览器时。注意Response.Headers 里设置 ContentDisposition 时不要在 return File 的三参数重载里再传 fileDownloadName两者会冲突浏览器可能拿到两个 filename 参数下载行为变得不可预期。3.2 表头样式、日期格式与列宽拿出去不像临时文件用户拿到 Excel 第一眼看的是表头是否加粗、列宽是否合适、日期是不是一串数字。NPOI 里这些都靠单元格样式对象控制var headerStyle workbook.CreateCellStyle(); headerStyle.FillForegroundColor IndexedColors.Grey25Percent.Index; headerStyle.FillPattern FillPattern.SolidForeground; var headerFont workbook.CreateFont(); headerFont.Bold true; headerFont.FontHeightInPoints 11; headerStyle.SetFont(headerFont); var header sheet.CreateRow(0); header.CreateCell(0).SetCellValue(日期); header.CreateCell(1).SetCellValue(金额); header.GetCell(0).CellStyle headerStyle; header.GetCell(1).CellStyle headerStyle; sheet.SetColumnWidth(0, 14 * 256); sheet.SetColumnWidth(1, 12 * 256); var dateStyle workbook.CreateCellStyle(); dateStyle.DataFormat workbook.CreateDataFormat().GetFormat(yyyy-MM-dd); var row sheet.CreateRow(1); var dateCell row.CreateCell(0); dateCell.SetCellValue(DateTime.Now); dateCell.CellStyle dateStyle;SetColumnWidth 的单位是 1/256 字符宽度14 乘 256 表示这一列大约能显示 14 个字符。日期单元格不要自己 ToString而是设置 DataFormat 后把 DateTime 塞进去Excel 会按显示格式呈现。我踩过的版本是直接把日期 ToString 后按 StringCellValue 写入用户拿到的文件里就是个普通文本后续排序、透视表全受影响。3.3 数据量大的导出策略SXSSF 与分批写导出两三万行时XSSFWorkbook 的所有行都驻留内存服务器内存很容易被顶上去。NPOI 提供了 SXSSFWorkbook它的窗口模式只保留最近 N 行在内存其余行刷到磁盘临时文件写完再合并。常见做法是[HttpGet(export/large)] public IActionResult ExportLarge() { var workbook new SXSSFWorkbook(500); var sheet workbook.CreateSheet(大数据); for (int i 0; i 50000; i) { var row sheet.CreateRow(i); row.CreateCell(0).SetCellValue(i 1); row.CreateCell(1).SetCellValue($第{i 1}行); } var ms new MemoryStream(); workbook.Write(ms); ms.Position 0; workbook.Dispose(); return File(ms, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, large.xlsx); }构造参数 500 表示内存窗口大小也就是同一时刻最多保留 500 行。导 5 万行时内存增长比 XSSFWorkbook 平缓得多。要记得在写完、Position 归零之后调用 workbook.Dispose()否则 SXSSF 的临时文件可能留在服务器临时目录里时间久了磁盘会被塞满。还有一类导出不走内存流先写本地临时文件再用 PhysicalFile 返回。内存紧张时可以这么做但要注意 finally 里清理临时文件。我一般只在文件超过 20MB 或并发导出压力大时才切到这个方案。4. 导入 xlsx从上传到落库的完整链路导入比导出多两道坎文件从哪来、读出来的数据怎么校验。这一章按实际项目最常见的链路写前端 multipart 上传、服务端临时落盘、NPOI 解析、逐行校验、返回错误明细。4.1 接收 IFormFile 与临时落盘先防内存溢出再谈解析ASP.NET Core MVC 里接收文件最简单的方式是绑定 IFormFile[HttpPost(import)] public async TaskIActionResult Import(IFormFile file) { if (file null || file.Length 0) return BadRequest(请上传 xlsx 文件); var ext Path.GetExtension(file.FileName).ToLowerInvariant(); if (ext ! .xlsx ext ! .xlsm) return BadRequest(仅支持 xlsx 或 xlsm 文件); var tempPath Path.Combine(Path.GetTempPath(), ${Guid.NewGuid():N}{ext}); try { using (var fs File.Create(tempPath)) { await file.CopyToAsync(fs); } var errors ParseExcel(tempPath); if (errors.Count 0) return BadRequest(new { message 导入校验失败, errors }); return Ok(new { message 导入成功 }); } finally { if (File.Exists(tempPath)) File.Delete(tempPath); } }有人说直接用 file.OpenReadStream() 传给 NPOI 不就行了但 Kestrel 接收 multipart 时大文件会在内存里缓冲50MB 的 Excel 上传一次进程内存就可能涨上去。先落临时盘再用 FileStream 打开内存占用可控很多。扩展名这里我故意收下了 .xlsm它和 .xlsx 的包结构基本一致后面会解释原因。tempPath 用 GUID 起名避免多个用户上传同名文件互相覆盖也顺带处理了文件名里的路径穿越风险。4.2 逐行读取与单元格类型硬校验别信 Excel 给的类型xlsx 的本质是多个 XML 文件打包成的 zip每个单元格在 XML 里有类型标记但用户在单元格里敲什么并不受控。同样是“10001”可能是数字类型可能是文本类型也可能是公式计算出来的。读取时要做类型分支ParseExcel 内部按这个结构处理private static Liststring ParseExcel(string tempPath) { var errors new Liststring(); using var fs new FileStream(tempPath, FileMode.Open, FileAccess.Read, FileShare.Read); var workbook new XSSFWorkbook(fs); try { var sheet workbook.GetSheetAt(0); for (int i 1; i sheet.LastRowNum; i) { var row sheet.GetRow(i); if (row null) continue; var cell row.GetCell(0); if (cell null) continue; switch (cell.CellType) { case CellType.String: Console.WriteLine(cell.StringCellValue); break; case CellType.Numeric: if (DateUtil.IsCellDateFormatted(cell)) Console.WriteLine(cell.DateCellValue); else Console.WriteLine(cell.NumericCellValue); break; case CellType.Formula: var cachedType cell.CachedFormulaResultType; if (cachedType CellType.String) Console.WriteLine(cell.StringCellValue); else Console.WriteLine(cell.NumericCellValue); break; } } } finally { workbook.Dispose(); } return errors; }sheet.LastRowNum 是最后一行下标不是总行数循环从 1 开始是因为第 0 行是表头。GetRow 可能返回 nullExcel 里有空行时工作表不会塌掉所以 row null 要直接跳过。公式单元格读取的是 CachedFormulaResultType也就是用户在 Excel 里最后看到的那个计算结果服务端不会去重新计算公式。提示不要用 cell.ToString() 拼字符串。数字单元格走 ToString 会带出科学计数法日期单元格输出的是 Excel 序列号文本单元格又带上不必要的引号。正确做法是上面这种显式类型分支。4.3 校验失败怎么反馈行号、列名与错误信息一起返回导入场景里最怕“失败”更怕“失败但不告诉用户哪一行错”。常见做法是把错误收集到列表最后一次性返回var errors new Liststring(); for (int i 1; i sheet.LastRowNum; i) { var row sheet.GetRow(i); if (row null) continue; var name GetCellString(row.GetCell(1)); if (string.IsNullOrWhiteSpace(name)) { errors.Add($第 {i 1} 行名称不能为空); continue; } }这里有个体验细节不要遇到第一个错误就 return而是一次收集完所有错误再返回用户改一次就能重新上传不用反复修反复传。行号用 i 1因为第 0 行是表头用户看到“第 18 行出错”能直接定位到 Excel 里的第 18 行。GetCellString 是个辅助方法内部处理文本、数字、日期、公式四种情况建议放进公共类里复用private static string GetCellString(ICell cell) { if (cell null) return string.Empty; switch (cell.CellType) { case CellType.String: return cell.StringCellValue; case CellType.Numeric: if (DateUtil.IsCellDateFormatted(cell)) return cell.DateCellValue?.ToString(yyyy-MM-dd) ?? string.Empty; return cell.NumericCellValue.ToString(CultureInfo.InvariantCulture); case CellType.Formula: var cachedType cell.CachedFormulaResultType; if (cachedType CellType.String) return cell.StringCellValue; return cell.NumericCellValue.ToString(CultureInfo.InvariantCulture); case CellType.Boolean: return cell.BooleanCellValue.ToString(); default: return string.Empty; } }CultureInfo.InvariantCulture 很重要否则在中文、德语等 locale 下小数点可能变成逗号double 转出来就成了“1,5”落库时又是一次事故。如果你在做“Excel 导入数据库”这类批量录入这个辅助方法直接拿去用可以把大部分类型坑挡在业务代码外面。5. 避坑手册xlsx 导入导出最常见的 5 个坑这些坑不是教科书里的是线上用户帮我踩出来的。每条按现象、原因、解决来写。5.1 下载的 Excel 打不开文件格式或文件扩展名无效现象用户端双击下载的 xlsxOffice 弹出“Excel 无法打开文件因为文件格式或文件扩展名无效”。如果你的接口刚上线第一个报这个 bug 的往往是业务方最信任的那个客户。原因一般有三类一是上传文件把 .xls 改名成 .xlsx扩展名和真实格式不匹配二是服务端把字节流二次写入时用了错误的编码比如 File.WriteAllText 写二进制三是下载时响应头 Content-Type 配错让浏览器把文件存成了 .html 或 .txt。这类问题的根源都是“只看扩展名不看文件头”。解决第一道关看文件头。xlsx 本质是 zip 包文件前四字节是 PK\x03\x04十六进制 50 4B 03 04解析前先读文件头不是 zip 直接拒绝第二道关交给 NPOI 自己去试开抛 POIXMLException 就提示“文件内容不是有效的 xlsx”。我自己倾向第一道关就拦截错误提示对用户更友好也节省了后续解析的开销。5.2 数字列读成科学计数法和精度丢失现象导入身份证号、设备编号落库后发现变成“1.23457E17”或者末几位变成 0。原因Excel 单元格是数字类型NPOI 读 NumericCellValue 拿到 doubledouble 只有约 15 到 16 位有效数字18 位身份证号必然丢精度科学计数法只是连带表现。解决要求用户把这列在 Excel 里设置成“文本”格式再填文本单元格会走 StringCellValue 分支原样保留。如果用户改不了习惯服务端补救只能靠 DataFormattervar formatter new DataFormatter(); string value formatter.FormatCell(cell);DataFormatter 会按单元格的显示格式去格式化文本列不动日期列输出成用户看到的格式串这比手写一堆类型分支省心。但要注意 DataFormatter 对已经丢精度的数字也救不回来真正治本的办法是模板里预置文本格式并让录入人员不要去改单元格格式。5.3 WPS 另存的 xlsx格式说变就变现象用户本地用 WPS 编辑后上传程序解析出的列对不上或者服务端下载的文件拿到 WPS 里开提示格式异常。热词里“wps xlsx格式全变xlsm”说的就是这类事。原因xlsx 和 xlsm 的包结构几乎一样只是 content type 里是否包含宏标记不同。WPS 有时按“是否含宏”来决定扩展名或者用户手动把扩展名改了服务端如果只认 .xlsx 就把这类文件拒之门外。另一个现象是 WPS 保存的日期、超链接和 Office 有细微差异但不影响 NPOI 解析主干数据。解决接收端同时接受 .xlsx 和 .xlsm统一交给 XSSFWorkbook 解析上传控件限制 accept 为 .xlsx,.xlsm 并提示“另存时别改格式”。如果还要细粒度校验可以在解析后检查第一个 Sheet 的表头是否与预期列名一致不一致就报“表头不对请使用标准模板”提前挡掉格式漂移的脏数据。5.4 大文件导入内存暴涨现象上传一个 80MB 的 xlsx服务器进程内存从 300MB 飙到 1.2GB再传一个直接 OOM。原因IFormFile 在 Kestrel 接收阶段被缓冲XSSFWorkbook 默认把整份 Sheet 的 XML 解压到内存日志输出时又拼接了大字符串三重叠加。解决先做两道拦截再谈解析。第一道是 Kestrel 端限制上传大小[RequestSizeLimit(100 * 1024 * 1024)]第二道是业务端判断文件长度超过阈值直接拒绝导入避免把压力引到解析层。解析时务必先临时落盘再用 FileStream 打开能避开 multipart 缓冲。如果几十 MB 文件是常态就要考虑 NPOI 的 XSSFReader SAX 模式逐行回调而不是整表加载。日常项目我更倾向先限制文件大小和并发数SAX 模式留给真正要啃超大文件时再用。5.5 日期列读到一串数字现象Excel 里明明是 2024-06-01导入后得到 45289 这样的数字。原因Excel 日期底层是 OADate 序列号1900 年 1 月 1 日对应 1NPOI 只有在该单元格的显示格式被识别为日期时才会把 NumericCellValue 转换成 DateTime。如果单元格格式被设成“常规”或“文本”读出来的就是原始数字。解决读取层用 DateUtil.IsCellDateFormatted 判断是日期就取 DateCellValue否则按字符串处理模板层在表头下方把日期列预设成 yyyy-MM-dd 格式并要求用户“不合并单元格、不设文本格式”。如果确实拿到了序列号DateTime.FromOADate(45289) 能转回来但这是后悔药不值得作为主路径。配合 5.2 的 DataFormatter日期问题基本能一次清干净。6. 进阶用法模板导出、校验矩阵和我的一点验证习惯基础链路跑通后还要想怎么做得更省力。模板导出是我优先推荐的方向。6.1 模板导出让业务方自己改模板不动代码报表需求里常见“格式按我给的模板来”。如果每个字段都在代码里 SetupCell业务方改一版模板你就要改一次代码。常见做法是把设计好的 xlsx 放到 wwwroot/templates 下NPOI 直接打开它往里填数using var templateFs new FileStream( Path.Combine(env.WebRootPath, templates/月度报表.xlsx), FileMode.Open); var workbook new XSSFWorkbook(templateFs); var sheet workbook.GetSheetAt(0); var row sheet.GetRow(3); // 预留给数据的固定行 row.GetCell(0).SetCellValue(销售部); row.GetCell(1).SetCellValue(128000); var ms new MemoryStream(); workbook.Write(ms); ms.Position 0; workbook.Dispose(); return File(ms, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, 月度报表.xlsx);注意模板里预留给数据的行不能是合并单元格合并行用 NPOI 写入时容易串位“插入行”而不是“覆盖行”的模板设计要谨慎因为插入会改变后续行的引用服务端难以精确控制。模板路径放在 WebRootPath 下部署时随站点一起发布别写死绝对路径。6.2 这个方向值不值得投入值得而且十有八九不是一次性需求。只要业务在收表格、发报表导入导出就会持续迭代。但投入要有度中小项目引出数据用 NPOI 直接写就够不要为了“高性能”一开始就上 SAX 事件模型模板导出能覆盖大部分定制格式需求剩下的交给业务方改模板而不是你改代码。给一份我每次改完都会过的验证清单导出文件分别用 WPS 和 Office 打开确认扩展名、表头、日期显示都对。导入测试至少覆盖全空行、合并单元格、公式单元格、文本型数字、日期列。上传大小限制和文件名安全校验必须有文件名消毒不能用 Path.GetFileName 一个方法糊弄。日志记录原始文件名、总行数、成功行数用户说“数据没进来”时有后悔药可查。这几个验证点不复杂但能挡掉绝大多数线上反馈。我的习惯是每次导完先自己在浏览器下载再用 WPS 打开看一遍再换 Office 看一遍导入则固定用一个带坑的测试文件来回测。别嫌麻烦Excel 格式里的玄学比想象的少多数都是类型、编码和格式一致性这三个老问题。把这些前置判断做进代码后面的维护成本会低很多。希望这些方法和翻车经验能帮到你少走几趟弯路。本文还有配套的精品资源点击获取