C# Excel 操作实战:读取、保存与格式处理完整实例
简介这份资源面向具备一定C#基础的开发者聚焦.NET Framework下通过Microsoft.Office.Interop.Excel库操作Excel文件的完整实例解决数据导入导出、报表生成与批量处理等常见需求。压缩包共58个文件约1.55MB以dll依赖库、cs源码、xml与config配置文件为主另含exe可执行程序、sln解决方案及示例xls表格结构上可直接在Visual Studio中打开运行。资源围绕Excel应用程序对象创建、工作簿打开与保存、工作表单元格读写、UsedRange遍历以及COM对象释放等环节展开并附带ThreeDataReadFormExcle项目演示多数据源工作表的读取与处理流程同时提示Interop方式在性能与Office版本兼容性上的注意事项。目前已有589人学习下载适合需要快速上手Excel互操作编程、对照源码理解资源释放与排错思路的开发者参考。1. 从一份“祖传表格”说起这个 C# Excel 操作实例到底能干什么接手过一个内部管理工具需求简单到一句话把业务部门发来的 Excel 读进来校验几列数据算完汇总再存回一个新的 Excel 发出去。听起来十分钟的活结果第一版写完测试同事拿了一份带合并单元格、日期格式混乱、还有隐藏空行的表过来程序当场翻车。后来我把读取、写入、格式处理这几块拆开重写才慢慢摸清 C# 操作 Excel 的几条主流路线。这份实例资源就是围绕这个场景整理的用 C# 完成 Excel 的读取、保存和常见操作覆盖单元格读写、区域遍历、格式设置、多工作表处理这些高频动作。它适合两类人一类是刚接触 C# 办公自动化、想找个能直接跑通的样板的新手另一类是手里有类似工具、想参考一下别人怎么组织读取和保存逻辑的开发者。下面我按“能干什么 → 怎么跑 → 怎么改”的顺序把这份实例拆开讲。2. 技术选型与目录结构先搞清楚用哪套库再动手2.1 三条主流路线这份实例走的是哪条C# 操作 Excel绕不开三种方案。第一种是微软自家的 Office Interop也就是Microsoft.Office.Interop.Excel本质是程序去驱动本机安装的 Excel 进程。优点是功能最全几乎你在 Excel 里能点的它都能干缺点是必须装 Office进程容易残留服务器上跑起来经常出玄学问题。第二种是 OLEDB把 Excel 当数据库查写 SQL 读数据轻量但只能处理数据格式、图表基本别想。第三种是第三方库比如 EPPlus、NPOI、ClosedXML 这类不依赖 Office纯托管代码跨平台也能跑。这份实例的主线是第三方库方案因为它最贴近“拿来就能跑、不挑环境”的落地需求。常见做法是选 NPOI 或 EPPlusNPOI 同时支持.xls和.xlsx老格式兼容性好EPPlus 对.xlsx的 API 更顺手链式调用写起来舒服。实例里读取和保存的核心逻辑都建立在这类库之上Interop 只作为对照提一下避免新手一上来就踩进程残留的坑。提示如果你的运行环境是服务器或容器优先选不依赖 Office 的库Interop 在无桌面环境里基本跑不动。2.2 目录结构长什么样一个能落地的 Excel 操作实例目录通常不会太复杂但分层要清楚。我一般会按下面这样组织ExcelDemo/ ├── ExcelDemo.sln ├── src/ │ ├── ExcelDemo.Core/ # 核心读写逻辑 │ │ ├── ExcelReader.cs # 读取工作簿、工作表、单元格 │ │ ├── ExcelWriter.cs # 保存新建、写入、导出 │ │ └── ExcelHelper.cs # 格式、列宽、样式等公共方法 │ ├── ExcelDemo.Models/ # 数据模型 │ │ └── RowModel.cs # 映射表格行的实体类 │ └── ExcelDemo.App/ # 控制台入口演示调用 │ └── Program.cs ├── samples/ │ ├── input.xlsx # 示例输入表 │ └── output.xlsx # 运行后生成的输出表 └── README.mdCore放纯逻辑不掺 UI 和业务Models放实体方便把一行数据映射成对象App只做调用演示。这样拆的好处是你后面要把它塞进 WinForm、WPF 还是 Web API直接引用Core就行不用改读取和保存的代码。samples目录别省放一份输入样例和一份期望输出别人拿到项目第一件事就是跑这个对比能省掉大量“你这代码到底对不对”的沟通。2.3 依赖怎么引以 NPOI 为例在ExcelDemo.Core里通过 NuGet 引入即可。命令行方式# 在 Core 项目目录下执行 dotnet add package NPOI # 如果还要处理 .xls 老格式NPOI 本身已覆盖无需额外包如果选 EPPlus包名是EPPlus但要注意它的许可证从某个版本起对商业用途有约束内部工具一般没问题对外分发前先确认一下。参数上没什么可调的装完在代码里using NPOI.SS.UserModel;就能开工。这一步的坑在于版本不同大版本的 API 有差异比如创建 workbook 的工厂方法、样式对象的获取方式网上抄的代码跑不通八成是版本对不上。实例里锁一个稳定版本别用最新预览版。3. 读取模块拆解从工作簿到单元格的完整链路3.1 打开工作簿别用 FileStream 直接怼读取的第一步是把文件读成工作簿对象。很多人图省事直接new FileStream(path, FileMode.Open)然后交给库结果文件被占用、或者遇到只读文件就崩。稳妥的写法是先把文件读进内存流再交给库解析这样原文件不会被锁也方便处理网络路径或字节流。using NPOI.SS.UserModel; public IWorkbook LoadWorkbook(string filePath) { // 先读进内存避免直接占用原文件句柄 byte[] buffer File.ReadAllBytes(filePath); using var ms new MemoryStream(buffer); // 根据扩展名决定用哪个 Workbook 实现 // .xlsx 用 XSSFWorkbook.xls 用 HSSFWorkbook IWorkbook workbook filePath.EndsWith(.xlsx) ? new XSSFWorkbook(ms) : new HSSFWorkbook(ms); return workbook; }逻辑说明File.ReadAllBytes把整个文件读进字节数组MemoryStream包一层库从内存流解析原文件句柄立刻释放。参数上filePath传绝对路径最稳相对路径容易受工作目录影响。判断扩展名这一步别偷懒.xls和.xlsx底层格式完全不同用错实现类会直接抛异常。如果文件很大几十兆以上全读进内存要留意占用这种场景可以改用流式读取或分 sheet 处理。3.2 定位工作表按名字还是按索引工作簿拿到后下一步是找对工作表。workbook.GetSheetAt(0)按索引取workbook.GetSheet(Sheet1)按名字取。索引取法简单但业务表一旦调整顺序就错位名字取法稳但名字被改也会失效。我的习惯是优先按名字取取不到再回退到索引并给出明确提示。public ISheet GetSheet(IWorkbook workbook, string sheetName) { // 优先按名称匹配匹配不到再退回第一个工作表 ISheet sheet string.IsNullOrEmpty(sheetName) ? workbook.GetSheetAt(0) : workbook.GetSheet(sheetName); if (sheet null) throw new InvalidOperationException($未找到工作表{sheetName}); return sheet; }逻辑说明GetSheet传空时走索引 0传了名字就精确匹配。返回 null 说明名字写错了这里直接抛异常比返回 null 让上层去猜要好错误信息里带上名字排查时一眼能看到问题。参数sheetName建议从配置读别硬编码在代码里业务改表名时不用重新编译。3.3 遍历行与单元格空行和合并单元格怎么处理遍历是最容易翻车的地方。sheet.LastRowNum给的是最后一行的索引但中间可能有完全空的行row.LastCellNum同理。合并单元格更麻烦只有左上角那个格子有值其余是空的。处理思路是逐行读跳过 null 行逐格读遇到合并区域时用sheet.GetMergedRegions()判断并取左上角的值。public ListRowModel ReadRows(ISheet sheet, int startRow 1) { var list new ListRowModel(); // 预取合并区域避免每格都去查 var mergedRegions sheet.NumMergedRegions; for (int r startRow; r sheet.LastRowNum; r) { IRow row sheet.GetRow(r); if (row null) continue; // 跳过整行空 var model new RowModel { // 逐列读取列索引按实际表头调整 Name GetCellValue(row.GetCell(0)), Amount GetCellValue(row.GetCell(1)), Date GetCellValue(row.GetCell(2)) }; list.Add(model); } return list; } private string GetCellValue(ICell cell) { if (cell null) return string.Empty; // 按单元格类型分别取值避免数字被读成 1.0 return cell.CellType switch { CellType.String cell.StringCellValue, CellType.Numeric DateUtil.IsCellDateFormatted(cell) ? cell.DateCellValue.ToString(yyyy-MM-dd) : cell.NumericCellValue.ToString(), CellType.Boolean cell.BooleanCellValue.ToString(), CellType.Formula cell.CellFormula, // 公式取表达式需要值就 evaluate _ string.Empty }; }逻辑说明startRow默认 1跳过表头这个参数按你的表结构调。GetCellValue是关键CellType决定取值方式——数字格如果直接ToString()可能带小数尾巴日期格要判断是不是日期格式再格式化公式格取的是表达式字符串想要计算结果得用FormulaEvaluator。合并单元格这里只预取了数量真正取值时如果发现是合并区域需要遍历mergedRegions找到覆盖当前格的那块取左上角坐标再读值这段逻辑可以单独抽一个方法实例里放在ExcelHelper中。注意LastRowNum在有些文件里会偏大因为格式残留会撑大范围读到 null 行直接跳过就行别拿它当精确行数用。4. 保存与写入模块新建、导出、格式设置一条龙4.1 新建工作簿并写入数据保存分两种一种是新建一个空工作簿从头写另一种是在已有文件上追加或修改。先看新建。核心是创建 workbook、创建 sheet、创建 row、创建 cell四层依次往下。public void WriteWorkbook(string outputPath, ListRowModel data) { IWorkbook workbook new XSSFWorkbook(); ISheet sheet workbook.CreateSheet(汇总); // 写表头 IRow header sheet.CreateRow(0); header.CreateCell(0).SetCellValue(名称); header.CreateCell(1).SetCellValue(金额); header.CreateCell(2).SetCellValue(日期); // 写数据行从第 1 行开始 for (int i 0; i data.Count; i) { IRow row sheet.CreateRow(i 1); row.CreateCell(0).SetCellValue(data[i].Name); row.CreateCell(1).SetCellValue(double.Parse(data[i].Amount)); row.CreateCell(2).SetCellValue(data[i].Date); } // 写回磁盘 using var fs new FileStream(outputPath, FileMode.Create, FileAccess.Write); workbook.Write(fs); }逻辑说明CreateSheet传的是工作表名重复名会报错需要唯一。CreateRow的索引从 0 开始表头占 0数据从 1 起。SetCellValue有多个重载字符串、数字、日期分别对应不同类型金额这里转成double是为了让 Excel 识别成数值方便后续求和。最后workbook.Write(fs)落盘FileMode.Create会覆盖同名文件如果要保留历史版本改成带时间戳的文件名。参数outputPath的目录要确保存在不存在先Directory.CreateDirectory。4.2 在已有文件上追加数据追加比新建多一步先加载已有工作簿再往指定 sheet 的末尾加行。这里要注意别把原文件的格式冲掉所以加载和保存要走同一套流。public void AppendRows(string filePath, string sheetName, ListRowModel data) { // 复用读取模块的加载逻辑 IWorkbook workbook LoadWorkbook(filePath); ISheet sheet GetSheet(workbook, sheetName); // 从现有最后一行之后开始追加 int lastRow sheet.LastRowNum; for (int i 0; i data.Count; i) { IRow row sheet.CreateRow(lastRow 1 i); row.CreateCell(0).SetCellValue(data[i].Name); row.CreateCell(1).SetCellValue(double.Parse(data[i].Amount)); } // 覆盖写回原文件 using var fs new FileStream(filePath, FileMode.Create, FileAccess.Write); workbook.Write(fs); }逻辑说明LastRowNum拿到当前最后一行索引新行从它加 1 开始。追加场景下FileMode.Create是覆盖原文件因为我们是先读进内存再整体写回原内容已经在 workbook 里了不会丢。如果担心写一半失败导致文件损坏常见做法是先写到临时文件成功后再替换原文件这个技巧在实例的ExcelHelper里可以找到对应方法。4.3 格式设置列宽、样式、数字格式数据写对了格式不对业务同事照样不认。列宽自适应、表头加粗、金额保留两位小数这几个是高频需求。public void ApplyFormat(IWorkbook workbook, ISheet sheet, int columnCount) { // 表头样式加粗、居中 ICellStyle headerStyle workbook.CreateCellStyle(); IFont font workbook.CreateFont(); font.IsBold true; headerStyle.SetFont(font); headerStyle.Alignment HorizontalAlignment.Center; IRow header sheet.GetRow(0); for (int c 0; c columnCount; c) { header.GetCell(c).CellStyle headerStyle; // 列宽自适应参数 true 表示考虑合并单元格 sheet.AutoSizeColumn(c, true); } // 金额列数字格式两位小数 ICellStyle moneyStyle workbook.CreateCellStyle(); moneyStyle.DataFormat workbook.CreateDataFormat().GetFormat(0.00); for (int r 1; r sheet.LastRowNum; r) { var cell sheet.GetRow(r)?.GetCell(1); if (cell ! null) cell.CellStyle moneyStyle; } }逻辑说明样式对象必须通过workbook.CreateCellStyle()创建不能 new否则写出去会报错。AutoSizeColumn的第二个参数控制是否把合并单元格算进去列多的时候自适应会慢可以只对关键列做。数字格式用DataFormat.GetFormat(0.00)格式字符串遵循 Excel 的规则想显示千分位就写#,##0.00。样式对象可以复用别在循环里反复创建否则文件会异常膨胀。5. 避坑与排查那些让我加班到深夜的 Excel 问题5.1 现象读出来的数字变成 “1.0” 或科学计数法原因单元格是数值类型直接ToString()会带上浮点尾巴长数字还会被转成科学计数法。解决按CellType.Numeric分支处理需要整数就用(long)cell.NumericCellValue需要保留精度就用cell.NumericCellValue.ToString(0.######)别用默认转换。5.2 现象日期读出来是一串数字比如 45000原因Excel 内部日期就是数字只是显示时套了格式。解决用DateUtil.IsCellDateFormatted(cell)判断是日期就取cell.DateCellValue再格式化不是就按普通数字处理。这个判断不能省否则纯数字列会被误判成日期。5.3 现象保存时报 “文件正被另一进程使用”原因读取时用了FileStream且没释放或者本机 Excel 还开着这个文件。解决读取走内存流用完及时Dispose保存前确认文件没被 Excel 打开。服务器场景下检查是否有残留的 Interop 进程用第三方库能从根本上避免。5.4 现象合并单元格只有第一格有值其余读出来是空原因合并区域只有左上角存值。解决读取前用sheet.NumMergedRegions和sheet.GetMergedRegion(i)建立区域索引取值时判断当前格是否落在某个区域内是就取该区域左上角的值。实例里把这段封装成了GetMergedCellValue。5.5 现象写出去的样式全丢了或者文件打不开原因样式对象没有通过 workbook 创建或者跨 workbook 复用了样式。解决每个 workbook 的样式必须由它自己CreateCellStyle生成不能从别的 workbook 拿过来用。另外写完记得workbook.Close()部分库需要流要正确关闭。6. 二次开发怎么改把实例变成你自己的工具拿到这份实例最忌讳的是直接改Program.cs里的演示代码改着改着就乱了。我的做法是先明确你要复用的边界读取逻辑、写入逻辑、格式逻辑分别在ExcelReader、ExcelWriter、ExcelHelper里业务代码只应该调用它们不该碰 NPOI 的 API。这样你换库比如从 NPOI 换到 EPPlus时只改这三个类上层不动。具体改造路径我一般按这个顺序走。第一步把RowModel换成你自己的业务实体字段对应表格列读取时的列索引改成从配置或表头动态解析别写死 0、1、2。第二步把校验逻辑抽出来读取后先过一遍校验不合格的行收集起来统一报错而不是读一行抛一个异常。第三步如果数据量大把一次性加载改成流式处理读一批写一批内存占用能降下来。第四步加日志记录读了多少行、跳了多少空行、写了多少行出问题时这些数字比堆栈有用。验证方法上我习惯准备三份测试文件一份标准表、一份带合并单元格和空行的脏表、一份超大表。标准表验证功能对不对脏表验证健壮性超大表验证性能。每次改完读取或写入逻辑三份都跑一遍对比输出和预期。这个习惯是被坑出来的——有次只测了标准表上线后遇到脏表直接崩回滚都来不及。还有一个技巧把常用的格式设置做成配置。比如哪些列是金额、哪些列是日期、表头要不要加粗写在一个简单的配置对象里ApplyFormat根据配置来套样式。这样业务换一张表改配置就行不用动代码。实例里ExcelHelper已经预留了这种结构你顺着扩就行。从那以后我每次动 Excel 相关的代码都强制先跑一遍脏表用例确认空行、合并单元格、异常格式都处理住了再提交。希望这份拆解能帮你少走点弯路把表格这件事一次做扎实。本文还有配套的精品资源点击获取