简介:这份资源面向具备一定C#基础、需要在.NET Framework环境下处理Excel文件的开发者,聚焦数据导入导出与报表生成等常见场景。内容围绕Microsoft.Office.Interop.Excel库展开,涵盖引入引用、创建Excel应用程序对象、打开工作簿、遍历单元格读取数据、写入单元格、保存工作簿以及关闭并释放COM对象等完整流程,并附带ThreeDataReadFormExcle项目示例,演示如何读取、处理和保存三个数据源的工作表内容。压缩包共58个文件,以dll动态库、cs源码、xml配置、exe可执行文件及sln解决方案为主,另含xls示例表格与resx资源文件,整体约1.55MB,结构清晰便于直接运行调试。目前已有589人学习下载,适合希望快速掌握C#操作Excel、理解Interop调用方式与资源释放要点的读者参考借鉴。
1. 从一次数据导入翻车说起:C# 读写 Excel 到底难在哪
某公司做设备巡检数据汇总,现场同事每天交上来几十个.xlsx文件,格式看着一样,实际千奇百怪:有的单元格是文本型数字,有的日期列被存成了字符串,还有的表格中间夹着合并单元格。A 同学第一版代码用OleDb读,本地跑得好好的,部署到服务器上直接报「未在本地计算机上注册 Microsoft.ACE.OLEDB.12.0 提供程序」,整个导入功能当场瘫痪。这就是 C# Excel 操作最典型的翻车现场——代码本身没错,错在选型没考虑运行环境。
这篇笔记就围绕「C# 读取 Excel、保存 Excel、Excel 操作实例」这条主线,把选型、读、写、改、排错串成一条能直接抄作业的路径。适合两类人:一类是刚接手数据导入导出需求、还在纠结用哪个库的开发者;另一类是已经用上某个库、但被格式兼容和部署问题反复折磨的熟手。全程不依赖 Office 安装,讲的是能在服务器上稳定跑起来的方案。
2. 选型先立住:四种主流方案的能力边界与代价
在动手写第一行代码之前,选型决定了后面 80% 的坑。C# 操作 Excel 的方案大致分四类,各自的适用场景差别很大,选错了后面全是补丁。
2.1 四种方案的横向对比
| 方案 | 是否依赖 Office | 支持 .xlsx | 支持 .xls | 读写能力 | 典型场景 |
|---|---|---|---|---|---|
| OleDb | 是(需 ACE 驱动) | 是 | 是 | 读为主,写弱 | 老系统快速读表 |
| Interop | 是(需装 Office) | 是 | 是 | 读写全 | 桌面端自动化 |
| EPPlus | 否 | 是 | 否 | 读写全 | 服务端报表生成 |
| NPOI | 否 | 是 | 是 | 读写全 | 兼容老格式的服务端 |
从这张表能看出关键分水岭:是否依赖 Office 安装。服务器上装 Office 本身就是个高风险操作,授权、稳定性、并发都是问题。所以只要你的代码要跑在服务端,OleDb 和 Interop 基本可以直接排除,除非你能接受部署时额外装驱动或 Office。
2.2 为什么服务端我一般选 NPOI 或 EPPlus
NPOI 是 Apache 协议下的开源库,最大优势是同时支持.xls和.xlsx,很多老企业的历史数据还是.xls,用它能一套代码通吃。EPPlus 的 API 更现代、链式调用顺手,生成复杂报表时写起来更舒服,但 5.0 之后改为商业授权,商用项目要留意许可证。
我一般的判断逻辑是:要读历史.xls就上 NPOI,纯.xlsx且追求开发效率就上 EPPlus。两者都不需要装 Office,部署时把 DLL 跟着发布就行。
2.3 用 NuGet 把依赖装进项目
不管选哪个,第一步都是通过 NuGet 引入。下面以 NPOI 为例,命令行和 PackageReference 两种方式都可以。
# 方式一:命令行安装(在项目目录下执行) dotnet add package NPOI # 如果需要指定版本,避免自动升级带来的 API 变动 dotnet add package NPOI --version 2.6.2<!-- 方式二:直接在 .csproj 里写 PackageReference --> <ItemGroup> <PackageReference Include="NPOI" Version="2.6.2" /> </ItemGroup>参数说明:--version建议显式指定,NPOI 在大版本间偶有 API 调整,锁版本能避免「昨天还能编译今天就报错」的玄学问题。装完后确认NPOI.dll、NPOI.OOXML.dll、NPOI.OpenXml4Net.dll等依赖都被正确还原,缺一个运行时就会抛FileNotFoundException。
提示:如果项目是 .NET Framework 且目标框架较老,注意选与框架匹配的 NPOI 版本,否则会出现程序集加载失败。
3. 读取 Excel:从打开工作簿到拿到干净数据
读是最高频的需求,也是最容易出问题的一环。核心难点不在「读出来」,而在「读出来的东西是不是你想要的类型」。
3.1 打开工作簿并定位工作表
先看最小可运行代码,把一个.xlsx读进来并遍历第一张表。
using NPOI.SS.UserModel; using NPOI.XSSF.UserModel; using System.IO; public void ReadBasic(string filePath) { // 用 FileStream 打开,using 确保句柄释放,避免文件被占用 using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read)) { // XSSFWorkbook 对应 .xlsx;若是 .xls 改用 HSSFWorkbook IWorkbook workbook = new XSSFWorkbook(fs); // 按索引取第一张表,也可以用 workbook.GetSheet("表名") ISheet sheet = workbook.GetSheetAt(0); // LastRowNum 是最后一行的索引(从 0 开始),不是行数 for (int i = 0; i <= sheet.LastRowNum; i++) { IRow row = sheet.GetRow(i); if (row == null) continue; // 空行必须判空,否则 NullReference for (int j = 0; j < row.LastCellNum; j++) { ICell cell = row.GetCell(j); string value = cell?.ToString() ?? string.Empty; Console.Write(value + "\t"); } Console.WriteLine(); } } }逻辑说明:FileStream用using包裹是血泪经验,不释放句柄会导致文件被锁,后续想删想改都失败。GetRow(i)返回可能为null,因为 Excel 允许整行留空,直接调row.LastCellNum就会崩。LastRowNum和LastCellNum都是索引语义,循环条件用<=和<要分清。
3.2 按单元格类型取值,别一律 ToString
上面用cell.ToString()图省事,但数字、日期、布尔值全被转成字符串后,后续计算和入库都会出问题。正确做法是按CellType分支处理。
public object GetCellValue(ICell cell) { if (cell == null) return null; switch (cell.CellType) { case CellType.String: return cell.StringCellValue; case CellType.Numeric: // 判断是否为日期格式,是则返回 DateTime if (DateUtil.IsCellDateFormatted(cell)) return cell.DateCellValue; return cell.NumericCellValue; case CellType.Boolean: return cell.BooleanCellValue; case CellType.Formula: // 公式单元格取缓存的计算结果,避免自己解析公式 return cell.CachedFormulaResultType == CellType.Numeric ? cell.NumericCellValue : cell.StringCellValue; case CellType.Blank: return null; default: return cell.ToString(); } }参数说明:DateUtil.IsCellDateFormatted是判断日期列的关键,Excel 内部日期本质是数字,只有格式标记才能区分「这是日期」还是「这是普通数字」。CellType.Formula分支里用CachedFormulaResultType拿缓存值,比强行求值稳妥,因为 NPOI 不会自动重算公式。
3.3 处理合并单元格与表头偏移
合并单元格是读取时最阴的坑:只有左上角那个格子有值,其余全是空。如果直接按行列读,会得到一堆null。
// 判断当前单元格是否属于合并区域,并取到区域左上角的值 public string GetMergedCellValue(ISheet sheet, int rowIndex, int colIndex) { for (int i = 0; i < sheet.NumMergedRegions; i++) { var region = sheet.GetMergedRegion(i); // 判断坐标是否落在该合并区域内 if (region.IsInRegion(rowIndex, colIndex)) { IRow firstRow = sheet.GetRow(region.FirstRow); ICell firstCell = firstRow?.GetCell(region.FirstColumn); return firstCell?.ToString() ?? string.Empty; } } // 不在任何合并区域,正常取值 return sheet.GetRow(rowIndex)?.GetCell(colIndex)?.ToString() ?? string.Empty; }逻辑说明:NumMergedRegions返回合并区域数量,遍历每个区域用IsInRegion判断坐标归属,命中就回到左上角取值。这个函数在解析复杂表头时几乎是必备的,代价是每次取值都要遍历区域,数据量大时建议先把区域列表缓存起来。
4. 写入与保存:生成报表和回写数据的完整链路
写 Excel 分两种:从零生成新文件,和在已有文件上追加修改。两者的 API 略有差异,保存时的细节也不一样。
4.1 从零生成一个带样式的报表
下面这段代码创建一个新工作簿,写入表头和数据,并给表头加粗、设背景色。
using NPOI.SS.UserModel; using NPOI.XSSF.UserModel; using NPOI.SS.Util; using System.IO; public void CreateReport(string savePath) { IWorkbook workbook = new XSSFWorkbook(); ISheet sheet = workbook.CreateSheet("汇总"); // 创建表头样式:加粗 + 灰底 + 居中 ICellStyle headerStyle = workbook.CreateCellStyle(); IFont font = workbook.CreateFont(); font.IsBold = true; headerStyle.SetFont(font); headerStyle.FillForegroundColor = IndexedColors.Grey25Percent.Index; headerStyle.FillPattern = FillPattern.SolidForeground; headerStyle.Alignment = HorizontalAlignment.Center; // 写表头 IRow headerRow = sheet.CreateRow(0); string[] headers = { "编号", "名称", "数量", "日期" }; for (int i = 0; i < headers.Length; i++) { ICell cell = headerRow.CreateCell(i); cell.SetCellValue(headers[i]); cell.CellStyle = headerStyle; } // 写数据行 IRow dataRow = sheet.CreateRow(1); dataRow.CreateCell(0).SetCellValue(1001); dataRow.CreateCell(1).SetCellValue("示例物料"); dataRow.CreateCell(2).SetCellValue(25.5); // 日期单元格要单独设格式,否则显示为数字 ICell dateCell = dataRow.CreateCell(3); dateCell.SetCellValue(System.DateTime.Now); ICellStyle dateStyle = workbook.CreateCellStyle(); dateStyle.DataFormat = workbook.CreateDataFormat().GetFormat("yyyy-MM-dd"); dateCell.CellStyle = dateStyle; // 自动列宽,让内容不被截断 for (int i = 0; i < headers.Length; i++) sheet.AutoSizeColumn(i); // 保存到文件 using (FileStream fs = new FileStream(savePath, FileMode.Create, FileAccess.Write)) { workbook.Write(fs); } }逻辑说明:样式对象要复用,CreateCellStyle每次调用都会新建一个,循环里反复创建会导致文件体积暴涨甚至超出 Excel 的样式数量上限。日期必须显式设DataFormat,否则存进去的是序列号,打开看到一串数字。AutoSizeColumn在数据量大时较慢,可以只对表头列调用或手动设宽度。
4.2 在已有文件上追加数据
回写场景要注意:不能直接覆盖原文件,否则可能读到一半写坏。稳妥做法是读进内存、改完写到临时文件、再替换。
public void AppendRows(string sourcePath, List<string[]> newRows) { IWorkbook workbook; // 先读入内存 using (FileStream fs = new FileStream(sourcePath, FileMode.Open, FileAccess.Read)) { workbook = new XSSFWorkbook(fs); } ISheet sheet = workbook.GetSheetAt(0); int lastRow = sheet.LastRowNum; foreach (var rowData in newRows) { IRow row = sheet.CreateRow(++lastRow); for (int i = 0; i < rowData.Length; i++) row.CreateCell(i).SetCellValue(rowData[i]); } // 写到临时文件,再替换原文件,避免写坏源数据 string tempPath = sourcePath + ".tmp"; using (FileStream fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write)) { workbook.Write(fs); } File.Copy(tempPath, sourcePath, overwrite: true); File.Delete(tempPath); }参数说明:File.Copy的overwrite: true保证替换成功。先写临时文件再替换是后悔药式的设计,万一写入过程异常,原文件还在。注意CreateRow(++lastRow)用的是自增后的索引,避免覆盖最后一行。
4.3 保存时的资源释放与并发注意
IWorkbook实现了IDisposable,理论上用完要释放,但 NPOI 的Write之后如果还持有流,文件仍可能被锁。经验做法是:写操作在using块内完成,块结束立即释放流。多线程并发写同一个文件必然出问题,正确姿势是每个线程写各自的临时文件,最后串行合并,或者用队列把写操作串起来。
5. 避坑与排查:那些让导入导出反复返工的问题
这一章集中记录几个高频翻车点,每条按现象、原因、解决三步走。
5.1 报「未注册 ACE.OLEDB 提供程序」
现象:本地调试正常,部署到服务器后 OleDb 读取直接抛异常。原因:服务器没装 Access Database Engine,且位数(32/64)与进程不匹配。解决:服务端场景直接换 NPOI 或 EPPlus,绕开驱动依赖;若必须用 OleDb,需安装对应位数的驱动并确认应用池位数一致。
5.2 数字列读出来变成科学计数法或丢精度
现象:身份证号、长订单号读出来变成1.23E+17或末尾变 0。原因:Excel 把纯数字存成double,超过 15 位有效数字就丢精度。解决:读取时若CellType.Numeric且业务上是编号,用cell.ToString()或NumberFormat转成文本;更根本的是让数据源把这类列设为文本格式。
5.3 日期读出来是一串数字
现象:日期列取到45000这种值。原因:没判断日期格式,直接取了NumericCellValue。解决:用DateUtil.IsCellDateFormatted(cell)判断后再取DateCellValue,参考 3.2 的分支写法。
5.4 文件被占用,删不掉也改不了
现象:程序跑完后文件仍被锁。原因:FileStream或Workbook没释放。解决:所有流用using包裹,Workbook在写完后不再持有;确认没有把IWorkbook存成静态字段长期持有。
5.5 大文件读取内存暴涨
现象:读几十万行的表,内存直接飙到几个 G。原因:XSSFWorkbook会把整个文件加载进内存。解决:改用 NPOI 的SXSSFWorkbook(写)或流式读取的XSSFReader(读),只保留滑动窗口内的行,内存占用能降一个数量级。
6. 进阶技巧:用流式读写扛住十万行数据
前面讲的都是常规做法,数据量一上来就得换思路。NPOI 提供了SXSSFWorkbook用于写大文件,它只在内存里保留最近若干行,其余刷到磁盘临时文件,最后统一写出。
using NPOI.XSSF.Streaming; using NPOI.SS.UserModel; using System.IO; public void WriteLargeFile(string savePath, int totalRows) { // 参数 100 表示内存中保留 100 行,超出部分刷盘 SXSSFWorkbook workbook = new SXSSFWorkbook(100); // 允许临时文件在 JVM/进程退出后被清理 workbook.TrackAllColumnsForAutoSizing(); ISheet sheet = workbook.CreateSheet("大数据"); for (int i = 0; i < totalRows; i++) { IRow row = sheet.CreateRow(i); row.CreateCell(0).SetCellValue(i); row.CreateCell(1).SetCellValue("行数据" + i); // 每写一批就 flush 一次,控制内存峰值 if (i % 1000 == 0) ((SXSSFSheet)sheet).FlushRows(); } using (FileStream fs = new FileStream(savePath, FileMode.Create, FileAccess.Write)) { workbook.Write(fs); } // 显式清理临时文件,否则磁盘会残留 workbook.Dispose(); }参数说明:SXSSFWorkbook(100)的构造参数是内存中保留的行数,值越小内存越省但磁盘 IO 越多,一般设 100 到 1000。FlushRows手动触发刷盘,适合在循环里定期调用。Dispose必须调用,否则临时文件不会被清理,跑几次磁盘就满了。
读取大文件则用XSSFReader配合 SAX 事件模型,只解析需要的列,不构建完整对象树。这套写法代码量比常规读取大不少,但面对十万行以上的表,它是唯一能稳住内存的选择。
我自己的习惯是:先按数据量分档,一万行以内用常规 API 图省事,超过五万行直接上流式,别等内存报警了再回头改。选型和数据量匹配,比事后调优省心得多。希望帮到你。
本文还有配套的精品资源,点击获取