C#读取Excel实战:NPOI选型、常见坑与组件封装
简介面向C#初学者与.NET开发者的Excel读取小程序演示如何将Excel工作表数据载入DataSet便于后续绑定DataGridView等界面展示。项目同时涉及Microsoft Office Interop与EPPlus/NPOI等第三方库的对比思路可帮助理解不同方案的适用场景。压缩包共21个文件大小约40KB包含6个cs源码文件、3个exe可执行程序、2个resx资源文件、2个resources资源文件、2个pdb调试符号以及sln/suo/csproj等工程配置和txt说明目录结构紧凑便于直接打开学习。已有320人浏览学习。通过完整工程可掌握C#读写Excel的基础流程、DataSet与DataTable的内存数据组织方式以及WinForms中数据绑定显示的操作要点适合作为入门练习或数据导入模块的参考模板。1. C#读取Excel小程序能跑通只是开始能落地才算做完这个标题看起来人畜无害实际上一接需求就容易翻车。C#读取Excel最常见的使用场景是运营台账导入、财务对账、老系统数据迁移第一版在本地通常几分钟就跑通了但部署到服务器、换个没装 Office 的机器、文件改成 .xls 后缀程序立刻罢工。我见过不少同事卡在同一个点上不是不会写代码而是选错了读取方案。先说结论。做一个真正能反复用、敢交给别人的 C#读取Excel 小程序我默认选 NPOI 这类纯托管方案免费开源、不需要装 Office、同时兼容 .xls 和 .xlsx。真正麻烦的不是“读出单元格”而是日期被读成 44927、合并单元格只剩左上角有值、公式取不到结果这些真实表格里天天遇到的事。下面按“选型 → 最小代码 → 复杂表格处理 → 踩坑记录 → 封装”的顺序把一个临时小工具做成一个干净可复用的读取组件。2. 读 Excel 前先选型四种方案在依赖、License 和部署上的真实差异2.1 四种方案的分水岭Office 依赖、License 和平台做 C#读取Excel 之前先想清楚一件事目标机器上有没有 Office如果答案是不确定那 Office.COM 互操作这条路基本可以放弃。它在本机测试时手感很好一旦放到 Windows Server 或者一台精简版办公电脑上轻则弹窗重则崩溃还伴随着 COM 进程回收、权限报错这类说不清道不明的玄学问题。我看到的新手代码里用 Office COM 的比例仍然很高大多是照着本机环境顺手选的。除了 COM 之外实际可以拿上台面的方案主要有四种方案是否需要 Office支持格式License / 授权适合场景Office.COMInterop.Excel需要xls / xlsx随 Office 授权仅限本机一次性脚本不推荐交付NPOI不需要xls / xlsx开源免费默认选择Web、工具、服务端都可用EPPlus不需要仅 xlsx新版商用受限需确认授权需要复杂样式、图表写回时考虑ClosedXML不需要仅 xlsx开源免费新项目、API 友好但 xls 不支持这个表是选型的核心。如果你的使用者还守着老系统偶尔会传一个 .xls 上来ClosedXML 和 EPPlus 在这条路上就直接出局剩下 NPOI 最省心。2.2 我默认用 NPOI 的两个理由xls 兼容和免安装部署NPOI 是纯托管实现底层不碰 COM不读注册表不依赖 Office 安装状态。这意味着发布时只需要把 DLL 一起拷过去甚至可以自包含部署服务器上有没有办公软件完全不影响。这一点在服务端场景里是决定性的。另一个理由是 xls 兼容。现在很多系统导出的已经是 xlsx但银行回单、政务平台、老旧 ERP 导出的仍是 .xls 二进制格式。NPOI 提供两套入口HSSF 处理 .xlsXSSF 处理 .xlsx并且可以用 WorkbookFactory 按文件头自动识别调用方不用关心扩展名是真是假。安装很简单在项目目录执行dotnet add package NPOI如果是传统的 .NET Framework 项目在包管理器控制台里执行Install-Package NPOI装最新稳定版即可不用刻意固定旧版本。NPOI 对 .NET Framework 4.6.2 和 .NET 6 都支持得很稳只要项目不是停留在 .NET Framework 4.0 这种远古版本一般不会有依赖问题。2.3 按场景选型速查表三类常见需求对应哪套方案第一类给运营做本机导入小工具使用者经常甩来不明来源的表格xls、xlsx 混着给。选 NPOIWorkbookFactory 自动识别格式这是最稳的兜底。第二类程序不仅要读还要往 Excel 里写回带样式、图表、透视表的内容。EPPlus 功能确实全但商业项目要先确认 License如果只是写简单报表NPOI 的样式能力虽然弱一些但够用且没有授权坑。第三类Web 服务端接收用户上传的 Excel解析后入库。必须选不需要 Office 的方案NPOI 或 ClosedXML 都行如果还想限制只能上传 .xlsxClosedXML 也很好用。但考虑到老用户手里可能还有 .xls我仍然倾向 NPOI。3. 用 NPOI 跑通最小读取程序从 NuGet 到 DataTable 只要 40 行代码3.1 项目准备目标框架与 NuGet 安装新建一个 Console 项目做验证最合适。WinForms 或 ASP.NET Core 项目里逻辑也是一样的只是入口不同。目标框架建议 .NET 6 以上语法上用得上 using 声明和可空类型如果公司还在维护 .NET Framework 4.6.2 的老工程下文代码同样能编译只有少量语法要微调。安装完 NPOI 后确认引用里能看到 NPOI.dll。一个常见的失败现象是 NuGet 装完了代码却找不到命名空间这时先确认项目真的引用了包再看是否有多个目标框架导致引用错位。这一步骤不用花太多心思装完能 using 就说明环境通了。3.2 最小读取代码WorkbookFactory 自动识别 xls / xlsx下面这段是我每次验证环境用的最小程序直接贴到 Program.cs 里跑using NPOI.SS.UserModel; using System; using System.IO; class Program { static void Main(string[] args) { string path C:\temp\demo.xlsx; using var fs new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); // 根据文件头自动区分 .xls(HSSF) 与 .xlsx(XSSF) IWorkbook workbook WorkbookFactory.Create(fs); ISheet sheet workbook.GetSheetAt(0); // 取第一个工作表 for (int r 0; r sheet.LastRowNum; r) { IRow row sheet.GetRow(r); if (row null) continue; // 这一行没有定义任何单元格直接跳过 // 这里只读前 5 列做演示实际按业务需求调整 for (int c 0; c 5; c) { ICell cell row.GetCell(c); Console.Write(${cell?.ToString() ?? 空}\t); } Console.WriteLine(); } } }逻辑上分三段打开文件流、创建 workbook、按行按列读取。WorkbookFactory.Create 是这套方案里最值得依赖的入口它会检查文件流的前几个字节如果是 OLE2 容器就走 HSSF如果是 ZIP 容器xlsx 本质是压缩包就走 XSSF调用方完全不用管后缀名。参数上有几个点先说清。FileShare.ReadWrite 表示允许其他进程在文件打开期间继续读写这是为了应对 Excel 正开着文件的情况后面避坑章会专门展开。sheet.LastRowNum 是最后一个有内容的行的下标从 0 开始数如果工作表为空它的值是 -1。row.GetCell(c) 在单元格不存在时返回 null 而不是抛异常所以用?.ToString()和?? 空兜底。3.3 把结果装进 DataTable表头行与数据行分离实际导入程序里很少直接逐行打印通常要把结果塞进 DataTable再批量入库或做校验。常见做法是默认第一行为表头从第二行开始读数据static DataTable ReadSheetToTable(ISheet sheet) { var table new DataTable(); IRow header sheet.GetRow(0); for (int c 0; c header.LastCellNum; c) { string colName header.GetCell(c)?.ToString() ?? $列{c 1}; table.Columns.Add(colName); } for (int r 1; r sheet.LastRowNum; r) { IRow row sheet.GetRow(r); if (row null) continue; object[] values new object[table.Columns.Count]; for (int c 0; c values.Length; c) { ICell cell row.GetCell(c); values[c] cell?.ToString(); } table.Rows.Add(values); } return table; }表头行的 LastCellNum 是“最后一列的列号加 1”直接拿它做列数初始化。数据行里某一列没有内容时GetCell 返回 null这一格保持 DBNull入库时能正确落成 NULL。这里有个容易忽略的问题表头有空单元格时列名会被自动命名为“列N”后面读数据再按列名取就会对不上。稳妥做法是在调用之前先打印一遍列名人工核对或者干脆让使用者按固定模板填表。3.4 第一个版本跑通后的验证清单跑通最小程序后别急着写业务逻辑先做三件事。第一准备一个 xlsx 和一个 xls 各测一遍确认 WorkbookFactory 自动识别正常。第二打印 DataTable 的行列数和 Excel 原表的行列数对比很多表格里存在完全空白的行跳过之后行号就对不齐了。第三找一个带日期、带合并单元格、带公式的真实表格试读这三种格子是后面要解决的主要矛盾。4. 把真实表格读干净日期序列号、公式缓存和合并单元格三个口子4.1 日期被读成 44927判断日期格式再取 DateTimeExcel 里的日期本质上是个数字从 1900 年 1 月 1 日算起的序列号。所以直接把 Numeric 单元格 ToString()读出来的是 44927 这种数字而不是“2023-01-01”。这在用户眼里属于低级错误但在程序眼里只是格式问题。正确做法是先判断这个 Numeric 单元格是不是日期格式if (cell.CellType CellType.Numeric DateUtil.IsCellDateFormatted(cell)) { DateTime date cell.DateCellValue; Console.WriteLine(date.ToString(yyyy-MM-dd)); }DateUtil.IsCellDateFormatted 依赖的是单元格的数字格式比如单元格显示为“yyyy/m/d”或“m月d日”它都会返回 true。拿到 DateCellValue 之后再按业务需要格式化成字符串或直接存 DateTime。如果不想逐格判断NPOI 还提供了 DataFormatter它按单元格的显示格式把内容格式化成字符串var formatter new DataFormatter(); string text formatter.FormatCellValue(cell);用 DataFormatter 输出日期会保留表格里用户看到的样式但有个代价输出的一定是字符串。后续如果要做区间筛选、排序或和数据库日期字段比对还得把字符串反解析成 DateTime。我的建议是读取阶段保留真实类型展示阶段再用 DataFormatter。4.2 公式单元格取不到结果用 FormulaEvaluator 拿计算值真实表格里经常有“合计”“差额”这类公式格。NPOI 读取时这种单元格的 CellType 是 Formula直接 ToString() 可能返回空字符串也可能返回公式文本取决于这个文件保存时有没有写入缓存结果。常见做法是对公式单元格做一次求值if (cell.CellType CellType.Formula) { // 整个 sheet 共用一个 evaluator不要每格都创建 IFormulaEvaluator evaluator workbook.GetCreationHelper().CreateFormulaEvaluator(); CellValue value evaluator.Evaluate(cell); Console.Write(value?.NumberValue ?? value?.StringValue ?? ); }Evaluator 能处理 SUM、IF、VLOOKUP 这类常规函数。要注意几点如果公式引用了另一个尚未打开的工作簿求值会失败如果公式依赖的单元格本身也是公式求值顺序会影响结果。更省事的做法是要求表格提供方交付前把公式列“粘贴为值”但这在真实协作环境里很难强制执行所以程序里保留 evaluator 分支才是稳妥的。4.3 合并单元格只有左上角有值按区域补齐取值合并单元格是导入场景里最容易产生诡异 bug 的元凶。读取时Excel 文件里只有合并区域的左上角单元格存了值其余单元格物理上不存在数据GetCell 返回 null。于是用户看到的是一个大格子里写着“部门A”程序读到同一行后面几列全是空。处理思路是先拿合并区域列表再判断目标坐标落在哪个区域里然后取左上角的值using NPOI.SS.Util; static string GetMergedCellValue(ISheet sheet, int rowIndex, int colIndex) { // 先查这一格是否属于某个合并区域 foreach (CellRangeAddress region in sheet.MergedRegions) { if (region.IsInRange(rowIndex, colIndex)) { IRow firstRow sheet.GetRow(region.FirstRow); ICell firstCell firstRow?.GetCell(region.FirstColumn); return firstCell?.ToString() ?? ; } } // 不是合并区域走普通读取 IRow row sheet.GetRow(rowIndex); ICell cell row?.GetCell(colIndex); return cell?.ToString() ?? ; }这里的原则是“合并区域的左上角值代表整个区域”业务上通常成立。需要注意表头区域也常被合并造成第一行的部分列没有列名读取前最好先打印表头或者要求模板作者不要合并表头这是成本最低的解决方案。4.4 空行与空单元格别用 row null 判断整行前面代码里if (row null) continue只能跳过“完全没有定义单元格”的行。但很多工具导出的表格整行带着边框和样式只是里面没填数据GetRow 返回的是非 null 的空壳行。这时需要按内容判断static bool IsRowEmpty(IRow row, int startCol, int endCol) { for (int c startCol; c endCol; c) { ICell cell row.GetCell(c); if (cell ! null !string.IsNullOrWhiteSpace(cell.ToString())) return false; } return true; }调用时把行号和判断结果一起打出日志可以快速发现哪些行被跳过了。还有一点要注意跳过空行会让 DataTable 的行号和 Excel 行号对不上业务方拿 Excel 里的第 88 行来问异常数据程序里却是第 85 行排查非常被动。我的习惯是读取时把 Excel 原始行号作为第一列存下来入库排错时直接按行号反查。这不是必须的设计但能在处理真实表格时省下大量沟通成本。5. 读取 Excel 的 5 个踩坑记录现象、原因与解决办法5.1 .xls 文件用 XSSFWorkbook 打开直接抛 POIXMLException现象用户发来一个 .xls 文件程序用 XSSFWorkbook 去加载抛异常报错信息类似 “POIXMLException: 无法打开包”。原因.xls 是 OLE2 二进制容器XSSFWorkbook 只能读 xlsx 这种 ZIP 容器。两者底层格式完全不同用扩展名判断也不可靠因为有人会把 .xls 文件改名成 .xlsx。解决别自己猜格式用 WorkbookFactory.Create(stream) 自动识别。它按文件头魔数判断容器类型比你判断扩展名靠谱得多。5.2 日期入库变成 1900 年格式化前的类型判断丢了现象程序读到的日期变成了 “1900-1-25” 或者一个长数字入库后日期全部错乱。原因日期在 Excel 里以序列号存储直接 ToString() 拿到的就是数字。如果没有用 DateUtil.IsCellDateFormatted 判断就当作普通数字处理等于把日期当成计数。解决读取 Numeric 单元格时先做日期格式判断命中日期格式就用 DateCellValue 取 DateTime入库前统一转成数据库日期类型。这个分支必须在读取阶段完成不要拖到入库前再处理否则类型信息已经在 DataTable 里丢了。5.3 公式单元格读出来是空白现象Excel 里明明显示“合计 1000”程序读完这个格子是空字符串。原因公式单元格在文件里存的是公式文本和可选的缓存值。如果文件是由程序生成的、没有经过 Excel 重新计算保存缓存值为空直接 ToString() 自然拿不到结果。解决对 CellType.Formula 的格子额外走一次 FormulaEvaluator如果公式依赖外部链接evaluator 也救不了只能要求上游把公式替换成静态值。先把这层兼容写在读取组件里比每次都去催业务方改文件省心得多。5.4 合并的表头导致列名重复与数据错位现象表头有两行第一行是合并的“基本信息”第二行才是真正的字段名程序读出来后列名叫“基本信息”数据从第三行开始结果整表错位。原因读取组件按单行表头设计没考虑多级表头。合并单元格的左上角有值其它列在表头行是空列名自动变成“列N”后续按列名取数自然对不上。解决两个思路。一是读取参数里加 headerRow支持指定第几行作为表头。二是从制度上解决给使用者提供标准导入模板明确禁止合并表头多级表头由程序按“最后一行表头 上级表头拼接”处理。真实项目中模板约束通常比代码兜底效果好得多。5.5 Excel 正开着文件程序读不了现象用户一边开着 Excel 编辑一边点程序里的导入按钮程序报“文件正由另一进程使用因此该进程无法访问此文件”。原因默认的 FileStream 打开方式可能是 FileShare.None其他进程不能读。Excel 打开文件时会持有自己的句柄和程序的打开模式冲突。解决打开文件流时显式指定 FileShare.ReadWriteusing var fs new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);注意这代表你接受“读到的是磁盘上最后保存的版本”用户没点保存的改动读不到。对导入场景这是可接受的至少不会因为文件占用直接崩溃。不要为了读最新的未保存数据去挂钩子那是把简单问题复杂化。6. 别把读取写成一次性代码封装一个通用 Excel 读取组件6.1 暴露一个入口方法文件路径 表名 表头行三个参数校验环境跑通、踩坑记录记完最后一步是把它收敛成一个公共方法让业务代码一行调用public static DataTable ReadExcelToDataTable( string filePath, string sheetName null, int headerRow 0) { using var fs new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); using IWorkbook workbook WorkbookFactory.Create(fs); ISheet sheet string.IsNullOrEmpty(sheetName) ? workbook.GetSheetAt(0) : workbook.GetSheet(sheetName); if (sheet null) throw new ArgumentException($找不到工作表{sheetName}); DataTable table new DataTable(); // 1. 按 headerRow 解析列名 // 2. 从 headerRow 1 开始逐行读取 // 3. 日期单元格用 DateUtil 判断并保留 DateTime // 4. 公式单元格走 FormulaEvaluator // 5. 合并单元格用 MergedRegions 补齐 // 6. 记录 Excel 原始行号到最后一列 return table; }三个参数刚好覆盖 80% 的调用场景表名决定读哪张表表头行解决前两行是说明文字的情况。返回 DataTable 也可以改成 ListDictionarystring, object看业务偏好。封装的价值在于日期、公式、合并格这些坑只在组件里修一次后续所有调用方都受益。6.2 再强调一次模板思维先定表头规范再写读取程序员总想做出万能读取器什么表都能读做过几个真实项目之后我的习惯反过来了先定义模板再按模板写读取。给业务方一张带字段约束的模板用数据有效性限制日期格式、下拉选项固定枚举值程序按列名取数复杂度直接降一半。读取组件做得再强也扛不住业务方随手造一张列名乱写的表。我最早接导入需求时花了一周时间兼容各种奇葩表格后来改成强制模板一天就写完了。格子的类型判断和合并处理还是保留但那是为了容忍小误差不是为了兜底乱表格。希望你下次接这类需求时能少踩一个坑。本文还有配套的精品资源点击获取