
简介面向正在用 C# 处理 Excel 的 .NET 开发者这份资料围绕 NPOI 库给出可直接参考的读写示例覆盖旧版 .xls 与新版 .xlsx 两种格式既适合刚接触 NPOI 的新手入门也适合需要封装通用工具类的中级开发者对照借鉴。压缩包共 15 个文件以 12 个 dll 依赖库为主另有 2 个 cs 源码与 1 个 xml 配置整体约 2.19MBdll 提供 NPOI 各模块及 JSON、压缩等支撑能力cs 文件则呈现工具类的组织方式。内容涉及工作簿与工作表创建、单元格写入与遍历读取、字体边框对齐等样式设置、文件保存和异常处理并按 2012Version 与 201607Version 分版本实现便于比较不同 NPOI 版本下的 API 差异及 HSSF、XSSF 两套模型。已有 6358 人学习可为项目中的 Excel 导入导出、报表生成与工具类封装提供参考。1. 别急着装 Office先看清 C# NPOI 操作Excel 到底解决什么问题后台要导一份对账单服务器上没装 OfficeMicrosoft.Office.Interop.Excel一跑就抛 0x800A03EC这个场景我见过不止一次。换个角度看C# NPOI 操作Excel 最省心的地方恰恰是它根本不碰 Office——纯托管库在进程里直接拼 xls/xlsx 的二进制结构服务器装不装 Office 都能跑。它一套 API 覆盖两种格式老的.xls走 HSSF新的.xlsx走 XSSF数据量大再换 SXSSF 流式写十万行也不至于把内存吃穿。适合做报表导出、批量数据导入、模板填充的后台服务、桌面工具和接口项目如果你只是偶尔手动改个表那确实没必要上它。2. 技术选型与工程结构HSSF、XSSF、SXSSF 三套模型怎么分工打开 NPOI 的命名空间会看到一堆HSSF开头的类第一反应通常是“这不是 Java 那套的翻版吗”。确实接口模型沿用了IWorkbook/ISheet/IRow/ICell这层抽象好处是同一份业务代码换个实现类就能读写不同格式坏处是新手很容易在new的那一行就把类型写死后面想兼容.xls就得大改。选型这一步基本决定了后面会不会返工所以拆代码之前先把模型说清楚。2.1 三种工作簿模型的边界模型对应格式内存形态适用量级主要限制HSSFWorkbook.xls全量驻留内存几万行以内单表 65536 行、256 列XSSFWorkbook.xlsxDOM 全量驻留十万行内看堆大小行数越多内存线性上涨SXSSFWorkbook.xlsx滑动窗口 磁盘临时文件百万行级已刷出的行不能再改、不能删行我的默认规则很粗暴对方没明确要求.xls一律XSSFWorkbook导出预估超过五万行直接上SXSSFWorkbook只有对接老系统、对方还在用旧版表格软件时才回头用HSSF。这条规则能省掉九成的兼容性扯皮。SXSSF 的代价必须提前说清楚。它把已经写出去的行刷到磁盘临时文件内存里只留窗口内的那几百行所以你别想回头sheet.GetRow(0)去改第一行——那时候它已经在临时文件里了GetRow直接返回null。需要回头改的东西合计行、页脚汇总要么放最后写要么在窗口还没刷出去之前处理掉。这个限制踩过一次就记住了。还有一个反直觉的点读文件时判断格式不要靠文件后缀用WorkbookFactory.Create(stream)让它自己嗅探。业务方发过来的“对账单.xls”里装着的其实是 HTML 表格这种事我一年能遇到两回。2.2 示例工程的分层与目录结构示例工程我按“核心逻辑不依赖 UI、不依赖文件系统”来切目录长这样NpoiExcelDemo/ ├── NpoiExcelDemo.sln ├── src/ │ ├── NpoiExcelDemo.Core/ # 只做 Excel 读写不放业务 │ │ ├── ExcelReader.cs │ │ ├── ExcelWriter.cs │ │ ├── ExcelStyleFactory.cs │ │ ├── ExcelColumnAttribute.cs │ │ └── Models/OrderRow.cs │ ├── NpoiExcelDemo.Console/ # 命令行入口跑两个示例 │ │ └── Program.cs │ └── NpoiExcelDemo.Tests/ # xUnit用内存流做往返测试 │ └── RoundTripTests.cs ├── samples/ │ ├── legacy.xls # 老格式样本 │ └── modern.xlsx # 新格式样本 └── templates/ └── 对账单模板.xlsxCore里我坚持不让IWorkbook泄漏出去。对外方法签名一律是Stream进、Stream或ListT出业务层根本不需要using NPOI.SS.UserModel;。这么做有两个直接好处单测里传一个MemoryStream就能跑完整链路不落盘Web 上传场景拿到IFormFile.OpenReadStream()可以直接喂进去不用先存临时文件再删。samples目录的作用容易被低估。.xls和.xlsx在单元格类型、日期序列号、公式缓存上的行为并不完全一致光靠猜没用必须有一份真实的旧格式文件反复验证。templates目录则是给“模板填充”这种需求准备的——表头、样式、合并单元格都在模板里做好代码只负责往里塞数据行改样式的时候不用改代码。3. 从零跑通装包、写第一个 xlsx、再读回 xls这一章的操作照着敲就能跑起来目标是把“写出去”和“读进来”这两条链路都走通一遍中间不留黑匣子。3.1 建工程与引包# 建解决方案和两个项目 dotnet new sln -n NpoiExcelDemo dotnet new classlib -n NpoiExcelDemo.Core -o src/NpoiExcelDemo.Core dotnet new console -n NpoiExcelDemo.Console -o src/NpoiExcelDemo.Console # 挂到解决方案下 dotnet sln add src/NpoiExcelDemo.Core src/NpoiExcelDemo.Console # NPOI 只需要装在 Core 里Console 通过项目引用拿到 dotnet add src/NpoiExcelDemo.Core package NPOI dotnet add src/NpoiExcelDemo.Console reference src/NpoiExcelDemo.Core这里面几个参数值得说明。-o是指定输出目录把项目塞进src/是习惯不塞也能跑。dotnet add package NPOI不写版本号默认拉最新稳定版如果目标框架是netstandard2.0要给老项目引用加-f netstandard2.0或者直接改 csproj 里的TargetFramework。Framework 4.x 的老项目不走 CLI在包管理器里装完记得看一眼依赖项——NPOI 会顺带拉一个压缩库发布的时候别只拷了NPOI.dll就以为完事了缺依赖的报错信息通常很含糊只说“未能加载文件或程序集”排查起来全靠猜。提示目标框架选net8.0还是netstandard2.0取决于调用方。库给多个项目共用就选netstandard2.0纯新项目选net8.0能用上Span之类的新 API。3.2 写出第一个 .xlsxNPOI 的层级就四层工作簿 → 工作表 → 行 → 单元格。下面这段是最小闭环using NPOI.SS.UserModel; using NPOI.XSSF.UserModel; public static void WriteSimple(string path) { // 1. 工作簿XSSF 对应 .xlsx IWorkbook workbook new XSSFWorkbook(); ISheet sheet workbook.CreateSheet(对账单); // 2. 表头行 IRow header sheet.CreateRow(0); string[] titles { 订单号, 客户, 金额, 下单时间 }; for (int i 0; i titles.Length; i) { header.CreateCell(i).SetCellValue(titles[i]); } // 3. 数据行 IRow row sheet.CreateRow(1); row.CreateCell(0).SetCellValue(SO-2024001); row.CreateCell(1).SetCellValue(某客户); row.CreateCell(2).SetCellValue(1280.50); // 数值走 double 重载 row.CreateCell(3).SetCellValue(DateTime.Now); // 日期直接给 DateTime // 4. 列宽单位是 1/256 个字符宽度20 * 256 20 字符 sheet.SetColumnWidth(0, 20 * 256); using var fs new FileStream(path, FileMode.Create, FileAccess.Write); workbook.Write(fs); }逐段说一下。CreateSheet传的名字就是 Excel 底部的标签名超过 31 个字符或含[]:*?/\会直接抛异常。CreateCell(i)的索引从 0 开始跳着创建也允许比如只创建第 0 和第 5 个中间的空单元格读回来是CellType.Blank。金额这一行是关键SetCellValue(1280.50)存的是数值Excel 里能参与求和如果图省事写成SetCellValue(1280.50)存的是文本Excel 会在左上角给你标个绿色小三角用户一点“转换为数字”全部格式又乱掉血泪经验。日期同理SetCellValue(DateTime)会把它转成序列号存储显示格式由单元格样式的DataFormat决定不设样式就是默认的日期显示。最后workbook.Write(fs)这个重载在不同版本上行为有差异有的会把流关掉有的不会。不确定的话就在Write之后立刻fs.Flush()并且不要指望后面还能继续往这个流里写东西。3.3 用 WorkbookFactory 统一读 .xls 和 .xlsx读这边最容易犯的错是写死new XSSFWorkbook(fs)遇到.xls就抛Invalid header signature。正确姿势是工厂加接口using NPOI.SS.UserModel; public static void ReadAny(string path) { using var fs new FileStream(path, FileMode.Open, FileAccess.Read); // 关键工厂根据文件头自动选 HSSF 还是 XSSF IWorkbook workbook WorkbookFactory.Create(fs); for (int s 0; s workbook.NumberOfSheets; s) { ISheet sheet workbook.GetSheetAt(s); // LastRowNum 是最后一行索引空表返回 -1不能直接当行数 for (int r sheet.FirstRowNum; r sheet.LastRowNum; r) { IRow row sheet.GetRow(r); if (row null) continue; // 整行被删过就是 null不判会 NRE for (int c 0; c row.LastCellNum; c) { ICell cell row.GetCell(c); Console.Write(ExcelReader.GetCellText(cell) \t); } Console.WriteLine(); } } }WorkbookFactory.Create支持传Stream或FileInfo内部会读文件头判断格式比看后缀靠谱得多。FirstRowNum和LastRowNum都返回索引空表或者全空行的情况下LastRowNum可能是-1、0或者你以为的行数写for循环时用并且判row null这两道保险缺一不可。GetSheetAt是按顺序取GetSheet(名字)是按名取。名字取不到会返回null而不是抛异常后面直接.GetRow()就炸了所以按名取的时候记得判空。3.4 跑起来看一眼dotnet run --project src/NpoiExcelDemo.Console -- ./out/demo.xlsx ./samples/legacy.xls参数依次是输出路径和要读的旧格式样本。跑完先确认三件事out/demo.xlsx能被正常打开、金额那一列右对齐且能求和、legacy.xls的中文没有乱码。这三条过了说明环境、依赖、编码都没问题后面再改代码就是纯业务活了。4. 核心模块拆解取值、样式、大文件、格式差异这一段是真正要反复用的东西。示例里那几个方法写短是因为把所有坑都收进了这几个方法里。4.1 单元格取值CellType 分支一个都不能漏读 Excel 最典型的故障是“日期变成 45000 这种数字”根子在CellType.Numeric—— 日期在底层就是数字靠样式区分。所以取值方法必须写成这样public static string GetCellText(ICell cell) { if (cell null) return string.Empty; switch (cell.CellType) { case CellType.String: return cell.StringCellValue; case CellType.Numeric: // 日期底层是序列号必须靠单元格样式判断直接 ToString 会得到 45000 if (DateUtil.IsCellDateFormatted(cell)) return cell.DateCellValue?.ToString(yyyy-MM-dd HH:mm:ss) ?? ; return cell.NumericCellValue.ToString(); case CellType.Boolean: return cell.BooleanCellValue ? TRUE : FALSE; case CellType.Formula: // 公式要按缓存结果类型再分一次支不能直接取 StringCellValue switch (cell.CachedFormulaResultType) { case CellType.Numeric: return DateUtil.IsCellDateFormatted(cell) ? cell.DateCellValue?.ToString(yyyy-MM-dd) ?? : cell.NumericCellValue.ToString(); case CellType.String: return cell.StringCellValue; default: return cell.ToString() ?? ; } case CellType.Blank: return string.Empty; case CellType.Error: // 把 #DIV/0! 这类错误码还原成可读文本 return FormulaError.ForInt(cell.ErrorCellValue).String; default: return cell.ToString() ?? string.Empty; } }三个地方值得单独讲。DateUtil.IsCellDateFormatted(cell)是个静态工具方法内部读的是单元格样式的DataFormat字符串判断是不是日期/时间格式比自己写正则靠谱。CellType.Formula分支里必须先看CachedFormulaResultType—— 公式单元格的StringCellValue只有在缓存结果是字符串时才有效缓存是数字你却去取字符串拿到的是空串或者直接抛异常这种问题在日志里只表现为“某几列读出来是空的”非常难定位。FormulaError.ForInt把0x07这种错误码转成#DIV/0!报表里带着错误码有时候反而是有用的信息别急着当成异常吞掉。注意DateCellValue在较新版本里是DateTime?老版本可能是DateTime。如果编译报“无法将 DateTime 转换为 DateTime?”说明你手上的版本比较旧去掉?.就行。4.2 样式复用ICellStyle 是共享池不是随手 new 的样式是 NPOI 里最容易被写成性能问题的模块。工作簿内部维护一个样式池你每CreateCellStyle()一次就多占一个槽位.xls上限大约 4000 个.xlsx大约 64000 个超了直接抛异常而且文件体积会以肉眼可见的速度膨胀——见过一个导出的表 800KB 数据配 60MB 样式就是这么来的。正确做法是做个样式工厂按 key 缓存public sealed class ExcelStyleFactory { private readonly IWorkbook _wb; private readonly Dictionarystring, ICellStyle _cache new(); public ExcelStyleFactory(IWorkbook wb) _wb wb; public ICellStyle Money(string format #,##0.00) { if (_cache.TryGetValue(format, out var cached)) return cached; var style _wb.CreateCellStyle(); // 每调一次就占一个样式槽 style.DataFormat _wb.CreateDataFormat().GetFormat(format); style.Alignment HorizontalAlignment.Right; style.BorderBottom BorderStyle.Thin; style.VerticalAlignment VerticalAlignment.Center; _cache[format] style; return style; } }CreateDataFormat().GetFormat(#,##0.00)里的格式串跟 Excel 自定义格式语法一致0.00%、yyyy/m/d、#,##0;(#,##0)这些都能直接用格式串本身也是被缓存的同一个串多次调用返回同一个对象。Money方法用格式串当缓存 key是因为同一个工作簿里不同列可能需要不同小数位用字符串做 key 天然支持多套格式共存。用法上先取一次样式循环里反复赋给不同单元格var styles new ExcelStyleFactory(workbook); var moneyStyle styles.Money(); foreach (var item in rows) { var cell row.CreateCell(2); cell.SetCellValue(item.Amount); cell.CellStyle moneyStyle; // 赋值不是 new }4.3 SXSSF 流式导出十万行不能再用 XSSF数据量上去以后XSSFWorkbook把所有行都留在内存里十万行乘以十几列堆基本就撑不住了。换成 SXSSFpublic static void WriteLarge(string path, IEnumerableOrderRow rows, int windowSize 500) { // windowSize内存里保留的行数超出的会被刷到磁盘临时文件 var workbook new SXSSFWorkbook(windowSize); try { var sheet workbook.CreateSheet(明细); int r 0; foreach (var item in rows) { var row sheet.CreateRow(r); row.CreateCell(0).SetCellValue(item.OrderNo); row.CreateCell(1).SetCellValue(item.CustomerName); row.CreateCell(2).SetCellValue(item.Amount); // 注意这一行一旦被刷出去后面 GetRow(r) 就取不到了 } using var fs new FileStream(path, FileMode.Create, FileAccess.Write); workbook.Write(fs); fs.Flush(); } finally { workbook.Close(); workbook.Dispose(); // 必须显式调用否则临时文件会留在系统临时目录 } }windowSize这个参数是内存和磁盘 IO 的平衡点。设太小比如 100刷盘频繁写十万行能明显感觉到慢设太大比如 5000内存优势就没那么明显了。我一般按 500 起步实测导出耗时偏高再往上调。Dispose()这一步不能省。SXSSF 在系统临时目录里建的文件只有Dispose才会清理进程长时间跑批又不释放临时目录迟早被塞满然后你会收获一堆“磁盘空间不足”的告警排查半天发现是自己导出的表。4.4 两种格式的行为差异能力.xlsHSSF.xlsxXSSF / SXSSF单表最大行655361048576单表最大列25616384单元格样式上限约 4000约 64000文件本质二进制复合文档zip 包 若干 xml新函数支持只有老函数集基本都能用读入内存占用相对低相对高这张表最有用的两行是行数上限和样式上限。行数超了不会报错会直接截断一百万条数据导出成.xls最后只剩六万五而且没有任何提示——这种 bug 上线之后才被发现返工成本极高。样式上限则是另一个方向.xls到 4000 就抛InvalidOperationException堆栈里看不到你的业务代码只看到 NPOI 内部的Workbook.CreateCellStyle。5. 避坑与排查五个真踩过的问题5.1 日期读出来是 45000 这样的整数现象导入的对账单里时间列全是五位数用户以为数据错了。 原因CellType.Numeric分支直接NumericCellValue.ToString()没判断日期格式。日期在 Excel 里本质是从 1900-01-01 起算的天数45000就是某个真实日期。 解决取值统一走 4.1 那个GetCellTextNumeric分支先DateUtil.IsCellDateFormatted(cell)再决定是格式化日期还是原样输出数字。5.2 导出的文件打不开提示“发现不可读取的内容”现象浏览器下载下来的 xlsxExcel 打开报修复提示点“是”之后内容可能还在也可能丢了。 原因九成是流被提前关掉或者Position没归零。workbook.Write(stream)在某些版本会关闭传入的流之后Response.Body再写就写不进去了另一种是同一个MemoryStream写完没Position 0就直接当读的源用。 解决写文件的流自己using管好生命周期别依赖库来关Web 场景用MemoryStream写完先ToArray()再输出读之前一律stream.Position 0。5.3 大批量导出时内存飙升甚至 OOM现象导五万行的时候进程内存涨到 2G 以上容器直接被 kill。 原因用了XSSFWorkbook所有行和单元格对象都驻留在堆里一个单元格对象几十字节五万行乘二十列就是一千万个对象。 解决换成SXSSFWorkbook窗口设 500 左右顺手检查一下是不是在循环里CreateCellStyle那个也是内存大户。5.4 读 .xls 报 Invalid header signature现象文件后缀明明是.xlsnew XSSFWorkbook(fs)直接抛异常。 原因一种是用错了实现类.xls得用 HSSF另一种更阴——某些系统导出的“xls”其实是 HTML 表格或者 CSV 改了后缀二进制头根本不是 OLE 复合文档。 解决读入口统一用WorkbookFactory.Create(fs)让库自己判断如果工厂也认不出来用十六进制工具看一眼文件头D0 CF 11 E0是真的 xlsPK是 xlsxhtml或者table就是伪装的得先当文本解析再转。5.5 单元格样式莫名不生效现象设置了边框和背景色打开文件发现只有第一行有后面全是白板。 原因ICellStyle是工作簿级别的共享对象但很多人习惯在循环里CreateCellStyle()之后只对当前单元格赋值同时又在别处CloneStyleFrom覆盖了属性结果样式对象被复用后互相污染。 解决样式只在工厂里创建一次循环里只做cell.CellStyle cachedStyle赋值确实需要“继承再加一点差异”的用CloneStyleFrom克隆出一个新对象并单独缓存别在原对象上改。6. 二次开发把示例收成一层能复用的导入导出示例代码写到后面你会发现每个业务实体都在重复“读表头 → 找列 → 转类型”这套动作。我一般会加一个特性加反射的映射层让业务代码只写实体。[AttributeUsage(AttributeTargets.Property)] public sealed class ExcelColumnAttribute : Attribute { public ExcelColumnAttribute(string title) Title title; public string Title { get; } } public sealed class OrderRow { [ExcelColumn(订单号)] public string OrderNo { get; set; } ; [ExcelColumn(金额)] public decimal Amount { get; set; } [ExcelColumn(下单时间)] public DateTime CreatedAt { get; set; } }读取端靠表头文本反查属性允许列顺序和实体属性顺序不一致public static ListT ReadT(Stream stream) where T : new() { var map typeof(T).GetProperties() .Select(p (Prop: p, Attr: p.GetCustomAttributeExcelColumnAttribute())) .Where(x x.Attr ! null) .ToDictionary(x x.Attr!.Title, x x.Prop); IWorkbook wb WorkbookFactory.Create(stream); ISheet sheet wb.GetSheetAt(0); IRow header sheet.GetRow(sheet.FirstRowNum); // 表头文本 - 列索引 var index new Dictionarystring, int(); for (int c 0; c header.LastCellNum; c) { var title ExcelReader.GetCellText(header.GetCell(c)).Trim(); if (!string.IsNullOrEmpty(title) map.ContainsKey(title)) index[title] c; } var list new ListT(); for (int r sheet.FirstRowNum 1; r sheet.LastRowNum; r) { var row sheet.GetRow(r); if (row null) continue; var entity new T(); foreach (var (title, prop) in map) { if (!index.TryGetValue(title, out var c)) continue; var text ExcelReader.GetCellText(row.GetCell(c)); if (string.IsNullOrWhiteSpace(text)) continue; // 可空类型要剥一层Convert.ChangeType 不认 NullableT var target Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType; prop.SetValue(entity, Convert.ChangeType(text, target)); } list.Add(entity); } return list; }改完之后业务侧就一行var rows ExcelImporter.ReadOrderRow(stream);字段增减只改实体上的特性不用碰解析代码。验证方式我用往返测试不落盘、跑得快[Fact] public void Xlsx_RoundTrip_ShouldKeepAmount() { var ms new MemoryStream(); using (var wb new XSSFWorkbook()) { var sheet wb.CreateSheet(t); var head sheet.CreateRow(0); head.CreateCell(0).SetCellValue(订单号); head.CreateCell(1).SetCellValue(金额); var data sheet.CreateRow(1); data.CreateCell(0).SetCellValue(SO-1); data.CreateCell(1).SetCellValue(99.5); wb.Write(ms); } ms.Position 0; // 不归零读出来就是空的 var rows ExcelImporter.ReadOrderRow(ms); Assert.Single(rows); Assert.Equal(99.5m, rows[0].Amount); }这个测试我建议再补三个边界样本一个带合并单元格的表头、一个日期列为空的行、一个金额列是公式的表。前两个验证判空逻辑第三个验证CachedFormulaResultType分支。这三个样本跑通线上八成的导入投诉基本能提前挡掉。从那以后我每次接 Excel 相关的需求都会先问清三句话读进来的是什么格式、写出去给谁用、最大多少行然后才动手写第一行代码。希望帮到你。本文还有配套的精品资源点击获取