ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# Excel 读写操作实战:NPOI、EPPlus、ClosedXML 选型与避坑指南

C# Excel 读写操作实战:NPOI、EPPlus、ClosedXML 选型与避坑指南

简介:这份资源面向具备一定C#基础、需要在数据处理与报表生成场景中操作Excel的开发者,围绕Excel读取、写入、保存与资源释放等核心环节给出可运行的实例代码。压缩包共58个文件,以cs源码、dll类库、xml与config配置、exe可执行程序及sln解决方案为主,另含xls示例表格与resx资源文件,整体约1.55MB,结构完整便于直接打开调试。内容基于Microsoft.Office.Interop.Excel库展开,涵盖创建Application对象、打开工作簿、遍历单元格读取数据、写入新值、保存并正确释放COM对象等关键步骤,同时涉及NPOI相关类库的引用,并提示大数据量下Interop效率偏低、需匹配Office版本等注意事项。项目示例展示了多数据源工作表的读取与处理流程,读者可据此快速搭建导入导出、数据分析等实用功能。目前已有589人学习下载,适合作为入门与排错参考。

1. 从一份 C# Excel 操作实例说起:为什么我建议你手头常备一套

接手一个数据导入模块时,最怕的不是算法难,而是业务方甩来一个几十兆的.xlsx,里面合并单元格、隐藏列、公式引用、日期格式混着来,你打开一看,连表头在第几行都不确定。这份 C# Excel 读取与保存操作实例,解决的就是这类“脏活”——它不教你 Excel 有多强大,而是把 C# 读写 Excel 的几条主流路径、参数配置和常见翻车点,用可运行的代码片段串起来。适合两类人:一是刚接触 C# 但需要快速交付数据导入导出功能的开发者;二是用过 NPOI 或 EPPlus 但总在格式兼容、内存占用上踩坑的老手。下面按“选型 → 读取 → 写入 → 避坑 → 进阶”的顺序拆开讲,每一步都落到能直接抄的代码和参数上。

2. 选型先定死:NPOI、EPPlus、ClosedXML 到底怎么选

2.1 三个库的定位差异

在 C# 里操作 Excel,绕不开三个名字:NPOI、EPPlus、ClosedXML。它们不是替代关系,而是各自卡在不同场景里。

NPOI 是 Apache 基金会的项目,最早从 Java 的 POI 移植过来,同时支持.xls(HSSF)和.xlsx(XSSF)。它的优势是“全”——老格式、公式、图表、甚至 VBA 宏都能碰一碰,而且不依赖 Office 安装。缺点是 API 偏底层,写一个带样式的单元格要十几行,新手容易写出一堆ICellStyle重复创建导致文件膨胀。

EPPlus 专注.xlsx,API 设计更现代,using语法糖用起来顺手,对 DataTable 和集合的导出支持很好。但要注意它的许可证:从 5.0 开始商业用途需要付费授权,内部工具或开源项目用旧版 4.x 没问题,商用前务必确认合规。

ClosedXML 建立在 OpenXML SDK 之上,定位是“让操作 Excel 像操作对象一样简单”。它读写.xlsx的代码量最少,适合快速做报表导出。但遇到超大文件或复杂公式链时,性能会明显掉下来,而且不支持.xls。

我一般这样选:需要兼容.xls或处理遗留文件,直接上 NPOI;纯.xlsx且追求开发效率,ClosedXML 优先;如果项目已经买了 EPPlus 授权,或者需要精细控制样式和公式计算,用 EPPlus。

2.2 环境准备与依赖引入

以 .NET 6 控制台项目为例,用 NuGet 装包。NPOI 当前稳定版是 2.7.x,ClosedXML 是 0.102.x,EPPlus 商用版 7.x。不要混装多个库的旧版本,容易出现System.Drawing.Common冲突。

# 创建项目 dotnet new console -n ExcelDemo cd ExcelDemo # 方案一:NPOI(支持 xls 和 xlsx) dotnet add package NPOI --version 2.7.1 # 方案二:ClosedXML(仅 xlsx,代码最简) dotnet add package ClosedXML --version 0.102.2 # 方案三:EPPlus(仅 xlsx,注意许可证) dotnet add package EPPlus --version 7.1.3

装完后检查.csproj里的TargetFramework,如果是netstandard2.0或net48,NPOI 需要额外加System.Text.Encoding.CodePages包,否则读中文会乱码。这个坑后面避坑章节还会展开。

提示:三个库不要同时引入同一个项目,除非你明确知道自己在做适配层。依赖冲突时优先保留 NPOI,因为它的依赖树最干净。

3. 读取实战:从文件流到 DataTable 的完整链路

3.1 NPOI 读取 xlsx 并转 DataTable

先看最常用的场景:用户上传一个.xlsx,后台解析成DataTable再入库。下面这段代码处理了表头行、空行跳过、单元格类型判断三个关键点。

using NPOI.SS.UserModel; using NPOI.XSSF.UserModel; using System.Data; public DataTable ReadExcelWithNpoi(string filePath) { var dt = new DataTable(); using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read); IWorkbook workbook = new XSSFWorkbook(fs); // .xls 用 HSSFWorkbook ISheet sheet = workbook.GetSheetAt(0); // 默认读第一个 Sheet // 1. 定位表头行:跳过前 N 行空行或说明行 int headerRowIndex = 0; for (int i = sheet.FirstRowNum; i <= sheet.LastRowNum; i++) { var row = sheet.GetRow(i); if (row != null && row.Cells.Any(c => c.CellType != CellType.Blank)) { headerRowIndex = i; break; } } // 2. 构建 DataTable 列 IRow headerRow = sheet.GetRow(headerRowIndex); for (int c = 0; c < headerRow.LastCellNum; c++) { var cell = headerRow.GetCell(c); string colName = cell?.ToString()?.Trim() ?? $"Col{c}"; if (string.IsNullOrEmpty(colName)) colName = $"Col{c}"; // 防止重复列名导致 DataTable 报错 while (dt.Columns.Contains(colName)) colName += "_1"; dt.Columns.Add(colName); } // 3. 逐行填充数据 for (int r = headerRowIndex + 1; r <= sheet.LastRowNum; r++) { IRow row = sheet.GetRow(r); if (row == null) continue; var dr = dt.NewRow(); bool hasValue = false; for (int c = 0; c < dt.Columns.Count; c++) { ICell cell = row.GetCell(c); if (cell == null) continue; switch (cell.CellType) { case CellType.String: dr[c] = cell.StringCellValue; break; case CellType.Numeric: // 判断是否为日期格式 if (DateUtil.IsCellDateFormatted(cell)) dr[c] = cell.DateCellValue; else dr[c] = cell.NumericCellValue; break; case CellType.Boolean: dr[c] = cell.BooleanCellValue; break; case CellType.Formula: // 公式取缓存值,避免重复计算 dr[c] = cell.CachedFormulaResultType == CellType.Numeric ? cell.NumericCellValue : cell.StringCellValue; break; default: dr[c] = cell.ToString(); break; } hasValue = true; } if (hasValue) dt.Rows.Add(dr); } return dt; }

逻辑说明:XSSFWorkbook对应.xlsx,HSSFWorkbook对应.xls,用错会直接抛InvalidFormatException。表头定位那段是为了应对“前几行是标题说明”的常见情况,headerRowIndex找到第一个非空行。列名去重是因为DataTable不允许同名列,而 Excel 里经常出现两个“备注”。单元格类型判断里,DateUtil.IsCellDateFormatted是 NPOI 提供的日期识别方法,不加这个判断,日期会变成一串数字(比如 45123)。

参数怎么改:如果 Sheet 名固定,把GetSheetAt(0)换成GetSheet("Sheet1");如果数据从第二行开始但第一行是合并单元格,headerRowIndex的循环条件要相应调整。

3.2 ClosedXML 读取:代码量减半的写法

同样的需求,ClosedXML 写起来更短,适合快速验证。

using ClosedXML.Excel; using System.Data; public DataTable ReadExcelWithClosedXml(string filePath) { var dt = new DataTable(); using var workbook = new XLWorkbook(filePath); var ws = workbook.Worksheet(1); // 第一个工作表 // 找到第一个非空行作为表头 var headerRow = ws.RowsUsed().First(); foreach (var cell in headerRow.CellsUsed()) { string colName = cell.GetString().Trim(); if (string.IsNullOrEmpty(colName)) colName = $"Col{cell.Address.ColumnNumber}"; while (dt.Columns.Contains(colName)) colName += "_1"; dt.Columns.Add(colName); } // 从表头下一行开始读 foreach (var row in ws.RowsUsed().Skip(1)) { var dr = dt.NewRow(); for (int c = 1; c <= dt.Columns.Count; c++) { var cell = row.Cell(c); dr[c - 1] = cell.IsEmpty() ? DBNull.Value : cell.Value; } dt.Rows.Add(dr); } return dt; }

逻辑说明:RowsUsed()自动跳过完全空行,First()拿到第一个有数据的行。cell.Value返回object,日期、数字、文本都能直接塞进DataRow,不用手动判断类型。但要注意,ClosedXML 对公式单元格默认返回计算后的值,如果公式没被 Excel 计算过(比如文件是程序生成的),可能拿到空值。

参数调整:Worksheet(1)里的数字是工作表位置,不是名称;如果表头不在第一行,把First()换成Skip(n).First()。

3.3 读取时的内存与流处理

大文件读取时,XSSFWorkbook会把整个文件加载进内存,几百兆的.xlsx能吃掉几个 G。NPOI 提供了SXSSFWorkbook用于写入,但读取侧没有真正的流式 API。常见做法是:先用FileStream打开,限制并发读取数量,或者把大文件拆成多个小 Sheet 分批处理。

// 限制 NPOI 读取时的临时文件策略(针对 xlsx) var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read); IWorkbook workbook = new XSSFWorkbook(fs); // 读取完成后及时释放 fs.Dispose(); workbook.Close();

如果只是统计行数或抽样,不要用LastRowNum遍历全部,改用sheet.GetRowEnumerator()逐行读,读到满足条件就 break。

4. 写入与保存:样式、公式、大数据量导出

4.1 NPOI 写入带样式的 xlsx

导出报表时,表头加粗、列宽自适应、日期格式化是基本要求。下面这段代码创建一个新工作簿,写入表头和数据,并设置列宽。

using NPOI.SS.UserModel; using NPOI.XSSF.UserModel; using NPOI.SS.Util; public void WriteExcelWithNpoi(string filePath, DataTable dt) { IWorkbook workbook = new XSSFWorkbook(); ISheet sheet = workbook.CreateSheet("数据"); // 表头样式 ICellStyle headerStyle = workbook.CreateCellStyle(); IFont headerFont = workbook.CreateFont(); headerFont.IsBold = true; headerFont.FontHeightInPoints = 11; headerStyle.SetFont(headerFont); headerStyle.FillForegroundColor = IndexedColors.Grey25Percent.Index; headerStyle.FillPattern = FillPattern.SolidForeground; headerStyle.BorderBottom = BorderStyle.Thin; // 写表头 IRow headerRow = sheet.CreateRow(0); for (int c = 0; c < dt.Columns.Count; c++) { ICell cell = headerRow.CreateCell(c); cell.SetCellValue(dt.Columns[c].ColumnName); cell.CellStyle = headerStyle; } // 日期样式 ICellStyle dateStyle = workbook.CreateCellStyle(); dateStyle.DataFormat = workbook.CreateDataFormat().GetFormat("yyyy-mm-dd"); // 写数据 for (int r = 0; r < dt.Rows.Count; r++) { IRow row = sheet.CreateRow(r + 1); for (int c = 0; c < dt.Columns.Count; c++) { ICell cell = row.CreateCell(c); object val = dt.Rows[r][c]; if (val is DateTime dateVal) { cell.SetCellValue(dateVal); cell.CellStyle = dateStyle; } else if (val is double dblVal) cell.SetCellValue(dblVal); else if (val is bool boolVal) cell.SetCellValue(boolVal); else cell.SetCellValue(val?.ToString() ?? ""); } } // 列宽自适应(NPOI 需要手动计算) for (int c = 0; c < dt.Columns.Count; c++) { sheet.AutoSizeColumn(c); int width = sheet.GetColumnWidth(c); sheet.SetColumnWidth(c, Math.Min(width + 512, 255 * 256)); // 最大 255 字符 } using var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write); workbook.Write(fs); workbook.Close(); }

逻辑说明:CreateCellStyle和CreateFont必须从workbook创建,不能跨工作簿复用。AutoSizeColumn在 NPOI 里对中文字符支持一般,算出来的宽度可能偏窄,所以加 512 的余量。SetColumnWidth的单位是 1/256 字符宽,上限 255*256。

参数调整:如果数据量超过 10 万行,用SXSSFWorkbook替代XSSFWorkbook,它会把行刷到磁盘临时文件,内存占用稳定在几十兆。

// 大数据量写入:保留 100 行在内存,其余刷盘 IWorkbook workbook = new SXSSFWorkbook(100); // ... 写入逻辑相同 ((SXSSFWorkbook)workbook).Dispose(); // 清理临时文件

4.2 ClosedXML 写入与公式设置

ClosedXML 写公式很直观,直接给单元格赋字符串"=SUM(A1:A10)"即可。

using ClosedXML.Excel; public void WriteExcelWithClosedXml(string filePath, DataTable dt) { using var workbook = new XLWorkbook(); var ws = workbook.Worksheets.Add("报表"); // 写表头 for (int c = 0; c < dt.Columns.Count; c++) { var cell = ws.Cell(1, c + 1); cell.Value = dt.Columns[c].ColumnName; cell.Style.Font.Bold = true; cell.Style.Fill.BackgroundColor = XLColor.LightGray; } // 写数据 for (int r = 0; r < dt.Rows.Count; r++) { for (int c = 0; c < dt.Columns.Count; c++) { ws.Cell(r + 2, c + 1).Value = dt.Rows[r][c] switch { DateTime d => d, double dbl => dbl, bool b => b, _ => dt.Rows[r][c]?.ToString() ?? "" }; } } // 添加汇总行公式 int lastDataRow = dt.Rows.Count + 1; int sumCol = dt.Columns.Count + 1; ws.Cell(lastDataRow + 1, 1).Value = "合计"; ws.Cell(lastDataRow + 1, sumCol).FormulaA1 = $"SUM({ws.Cell(2, sumCol).Address}:{ws.Cell(lastDataRow, sumCol).Address})"; ws.Columns().AdjustToContents(); // 自动列宽 workbook.SaveAs(filePath); }

逻辑说明:AdjustToContents比 NPOI 的AutoSizeColumn对中文支持更好,但数据量大时耗时明显,建议只在列数少时用。FormulaA1设置公式后,ClosedXML 不会立即计算公式值,打开 Excel 时才会算;如果需要程序内取值,得用cell.Value触发重算或调用workbook.RecalculateAllFormulas()。

4.3 保存时的文件占用与覆盖问题

写入时最常见的翻车是“文件被另一个进程占用”。原因通常是FileStream没释放,或者 Excel 还开着目标文件。解决方式:用using包裹所有流,写入前检查文件是否存在并尝试删除。

string path = @"C:\temp\output.xlsx"; if (File.Exists(path)) { try { File.Delete(path); } catch (IOException) { throw new Exception("文件被占用,请关闭 Excel 后重试"); } } using var fs = new FileStream(path, FileMode.CreateNew, FileAccess.Write); workbook.Write(fs);

注意:FileMode.Create会覆盖但不清空原文件句柄,如果之前有未释放的读流,仍可能报错。用CreateNew配合显式删除更稳。

5. 避坑与排查:这五个问题我几乎每次都能遇到

5.1 中文乱码:现象是读出来全是问号或方块

原因:NPOI 在 .NET Core 下默认编码不含 GBK,读取.xls或某些带中文的.xlsx时,cell.StringCellValue返回乱码。解决:在程序启动时注册编码提供程序。

System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance);

这行代码放在Main方法第一行,或者任何读取操作之前。只加一次,全局生效。

5.2 日期变成数字:45123 这种值

原因:Excel 内部用双精度浮点数存日期,1900 年 1 月 1 日是 1。NPOI 的NumericCellValue直接返回这个数字。解决:用DateUtil.IsCellDateFormatted(cell)判断,再用cell.DateCellValue取DateTime。ClosedXML 的cell.Value会自动转换,但前提是单元格格式被识别为日期。

5.3 公式单元格读到空值

原因:程序生成的 Excel 文件里,公式没有缓存计算结果,CachedFormulaResultType是Blank。解决:读取时判断cell.CellType == CellType.Formula,如果缓存为空,用workbook.GetCreationHelper().CreateFormulaEvaluator().Evaluate(cell)强制计算。但注意,复杂公式或外部引用可能算不出来,这时只能提示用户手动打开保存一次。

5.4 大文件 OutOfMemoryException

现象:读 50MB 的.xlsx直接崩,内存飙到 2G 以上。原因:XSSFWorkbook全量加载。解决:换SXSSFWorkbook写入;读取侧用FileStream配合XSSFWorkbook但限制并发,或者把文件拆成多个小 Sheet 分批读。如果只是抽取部分列,用sheet.GetRow(r).GetCell(c)按需读,不要先转DataTable。

5.5 列宽自适应对中文无效

现象:AutoSizeColumn后中文列还是显示####。原因:NPOI 的宽度计算基于字体像素,中文字符宽度是英文的两倍,但算法没考虑。解决:手动设置宽度,或者用SetColumnWidth(c, 20 * 256)给固定值。ClosedXML 的AdjustToContents稍好,但遇到合并单元格也会失准。

6. 进阶技巧:用 DataTable 做中间层统一读写接口

如果你项目里同时存在 NPOI 和 ClosedXML,或者未来可能换库,建议抽一层IExcelService接口,入参和出参都用DataTable。这样业务代码不依赖具体库,换实现只改一个类。

public interface IExcelService { DataTable Read(string filePath, int sheetIndex = 0); void Write(string filePath, DataTable data, string sheetName = "Sheet1"); } // NPOI 实现 public class NpoiExcelService : IExcelService { /* 前面章节的代码 */ } // ClosedXML 实现 public class ClosedXmlExcelService : IExcelService { /* 前面章节的代码 */ }

调用时用工厂或依赖注入决定用哪个。我一般会在配置文件里加一个ExcelProvider节点,值为Npoi或ClosedXml,运行时反射创建实例。这样测试环境用 ClosedXML 快速跑,生产环境切 NPOI 保兼容。

另一个技巧是读取时先做一次“预检”:用workbook.NumberOfSheets确认 Sheet 数量,用sheet.GetRow(0)确认表头行是否存在,避免直接GetRow(0).GetCell(0)空引用。这个习惯帮我省过很多次半夜排查。

从那以后我每次接 Excel 相关的需求,都强制先跑一遍“预检 + 小样本读取”,确认格式和编码没问题再写全量逻辑。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进