简介:本资源是一套面向.NET开发者的EPPlus Excel操作实战示例工程,专为解决C#环境下Excel文件的写入、读取与修改等高频数据处理需求而设计,适用于中初级开发者快速掌握Office Open XML(XLSX)格式的程序化操作。压缩包共41个文件,包含6个核心C#源码文件(如Form1.cs、Program.cs)、7个运行依赖DLL、3个可执行EXE及1个示例xlsx文件,辅以配置文件(config)、项目定义(csproj)、资源文件(resx)等,完整呈现VS解决方案结构,便于直接编译运行与代码调试。资源包大小2.55MB,结构清晰、开箱即用。已有3730人学习下载,提供从基础单元格赋值、批量数据加载、公式与样式设置,到图表插入等进阶功能的完整实现逻辑,所有操作均基于EPPlus 5.x主流版本,含详细注释与典型异常处理提示,是构建报表导出、数据导入类功能的可靠参考模板。1. C#用EPPlus操作Excel:不是封装API调用,而是把Excel当内存数据库来读写改你有没有遇到过这种场景:后台服务要批量生成带公式的销售报表,导出时还得保留原模板的合并单元格、条件格式和页眉页脚;或者前端上传一个带多Sheet的财务对账表,后端得逐Sheet解析、校验逻辑、回填差异标记,最后原样返回给用户——这时候用Microsoft.Office.Interop.Excel?别试了,Windows Server 上装 Office 不仅部署麻烦,还容易因 COM 对象未释放导致进程卡死;手写 OpenXML?那得啃透 ECMA-376 标准里几百个 XML 元素嵌套规则,改个边框颜色都得查文档半小时。EPPlus 就是为这类真实业务场景而生的:它不依赖 Office 安装,纯 .NET 实现,直接操作 Excel 文件二进制流,把.xlsx当成一个结构化内存数据库来用——读是查表,写是 INSERT,改是 UPDATE WHERE。它适合所有需要在无 UI 环境(Linux Docker、Windows Service、Azure Function)下高频、稳定、可控地处理 Excel 的 C# 开发者,尤其适合从传统 WinForms 迁移过来、又不想碰 COM 黑匣子的工程师。这不是玩具库,某高校教务系统用它每晚处理 20 万行课表数据,某公司财务中台靠它支撑日均 800+ 份自动对账 Excel 的生成与回填。2. EPPlus 核心能力落地:从 NuGet 引入到 Workbook 生命周期管理2.1 初始化与版本对齐:为什么必须锁定 EPPlus 6.x 而非最新版EPPlus 在 5.x 和 6.x 之间存在重大 API 断层。6.x 彻底移除了ExcelPackage.LicenseContext = LicenseContext.NonCommercial这类旧式授权设置,改为通过ExcelPackage.DefaultSettings或构造函数传参控制;同时Worksheet.Cells["A1"].Value的类型推断行为更严格,不再隐式转换DateTime为字符串。生产环境我一律锁定6.2.11(截至 2024 年 Q2 最稳定 LTS 版),原因有三:一是该版本已修复 6.0 初期大量 Sheet 复制崩溃问题;二是对IF公式重计算支持最完整;三是避免 6.3+ 新增的IExcelRange.CopyFrom()方法引发的跨 Sheet 样式污染 bug。安装命令如下:dotnet add package EPPlus --version 6.2.11提示:若项目使用 .NET 5+,无需额外引用System.Drawing.Common;但若目标框架含.NET Framework 4.7.2,需手动添加<PackageReference Include="System.Drawing.Common" Version="6.0.0" />,否则LoadImage()会抛TypeLoadException。2.2 创建 Workbook:内存模式 vs 文件流模式的选择逻辑EPPlus 提供两种初始化方式,选错会导致后续所有操作翻车:内存模式(推荐用于生成新文件):new ExcelPackage()所有操作在内存完成,最终调用package.SaveAs(new FileInfo("out.xlsx"))一次性落盘。优势是速度快、无文件锁、适合 Web API 响应流式输出;缺点是大文件(>50MB)易触发 GC 压力。文件流模式(推荐用于修改现有模板):new ExcelPackage(new FileInfo("template.xlsx"))直接加载磁盘文件到内存,保留原始样式、公式、图表等全部元数据。关键点在于:必须确保源文件是.xlsx(而非.xls),且未被其他进程占用。若模板由 Excel 2003 保存,需先用 Excel 打开另存为.xlsx,否则EPPlus会静默忽略xl/worksheets/sheet1.xml中的条件格式节点。下面是一个典型模板填充流程:// 加载模板(注意:FileInfo 构造函数传绝对路径,相对路径易因工作目录变化失效) var templatePath = Path.Combine(AppContext.BaseDirectory, "Templates", "SalesReport_Template.xlsx"); using var package = new ExcelPackage(new FileInfo(templatePath)); // 获取第一个 Worksheet(索引从 1 开始,非 0) var ws = package.Workbook.Worksheets[1]; // 写入标题行:直接赋值,EPPlus 自动识别类型 ws.Cells["A1"].Value = "销售日期"; ws.Cells["B1"].Value = "产品编号"; ws.Cells["C1"].Value = "销售额"; // 批量写入数据:用 LoadFromCollection 比循环 Cells 更快 var salesData = GetSalesList(); // 返回 List<SalesRecord> ws.Cells["A2"].LoadFromCollection(salesData, true); // true 表示首行写属性名 // 保存为新文件(原模板不受影响) var outputPath = Path.Combine(AppContext.BaseDirectory, "Output", $"Report_{DateTime.Now:yyyyMMdd_HHmmss}.xlsx"); package.SaveAs(new FileInfo(outputPath));这段代码背后的关键逻辑是:LoadFromCollection不是简单遍历,它会扫描SalesRecord类型的每个public属性,按声明顺序映射到列,并自动处理DateTime、decimal等类型的 Excel 单元格格式(如DateTime默认设为yyyy-mm-dd格式)。若需自定义列顺序或跳过某属性,需在类上加[ExcelColumn(Order = 2)]特性。2.3 读取 Excel:如何安全解析多 Sheet 且带空行的业务表读取比写入更易踩坑。常见需求是:一个 Excel 有Sheet1: 销售明细、Sheet2: 退货记录、Sheet3: 产品主数据,需分别加载到不同DataTable。EPPlus 不提供ToDataTable()一键方法,必须手动控制边界。核心原则是:永远不用ws.Dimension.End.Row作为数据行上限——因为用户可能在最后一行下方留了几十空白行,Dimension会错误包含它们,导致for (int i = 1; i <= ws.Dimension.End.Row; i++)循环卡死。正确做法是定位“实际数据区域”:public static DataTable ReadWorksheet(ExcelWorksheet ws, int headerRow = 1) { var dt = new DataTable(); // 1. 读取表头(假设第 headerRow 行是列名) for (int col = 1; col <= ws.Dimension?.End.Column; col++) { var cell = ws.Cells[headerRow, col]; dt.Columns.Add(cell.Value?.ToString() ?? $"Column{col}", typeof(string)); } // 2. 从 headerRow+1 开始读数据,直到遇到连续 3 行全空才停止 int lastDataRow = headerRow + 1; int emptyRowCount = 0; while (emptyRowCount < 3 && lastDataRow <= ws.Dimension?.End.Row) { bool rowIsEmpty = true; for (int col = 1; col <= ws.Dimension.End.Column; col++) { if (!string.IsNullOrWhiteSpace(ws.Cells[lastDataRow, col].Text)) { rowIsEmpty = false; break; } } if (rowIsEmpty) emptyRowCount++; else { emptyRowCount = 0; // 重置计数器 // 3. 构建 DataRow:EPPlus 的 Cell.Value 是 object,需显式转 string 防 null var dr = dt.NewRow(); for (int col = 1; col <= ws.Dimension.End.Column; col++) { dr[col - 1] = ws.Cells[lastDataRow, col].Value?.ToString() ?? string.Empty; } dt.Rows.Add(dr); } lastDataRow++; } return dt; } // 使用示例 using var package = new ExcelPackage(new FileInfo("data.xlsx")); var salesDt = ReadWorksheet(package.Workbook.Worksheets["销售明细"]); var returnDt = ReadWorksheet(package.Workbook.Worksheets["退货记录"]);此方法规避了Dimension的虚假范围,用“连续空行”作为业务数据自然结束标志,符合真实 Excel 使用习惯。注意ws.Cells[row, col].Text返回的是 Excel 渲染后的字符串(如12345显示为12,345),而.Value返回原始值(12345),解析数值时务必用.Value并做类型判断。3. 修改 Excel:精准定位、公式重算与样式继承的实战策略3.1 定位目标单元格:命名区域(Named Range)比坐标更可靠硬编码"A10"或ws.Cells[10,1]是维护噩梦。当业务方说“把‘本期合计’单元格改成红色”,下次模板调整行列,你的代码就失效。EPPlus 支持 Excel 原生的“命名区域”(Named Range),这是解耦模板与代码的黄金方案。操作步骤:在 Excel 模板中选中目标单元格(如E15),点击公式栏左侧名称框,输入TotalAmountCell回车;在 C# 中通过package.Workbook.Names["TotalAmountCell"]获取其地址;修改值并设样式:// 通过命名区域获取单元格(自动适配 Sheet 变更) var name = package.Workbook.Names["TotalAmountCell"]; if (name != null && name.RefersToWorksheet != null) { var targetCell = name.RefersToWorksheet.Cells[name.RefersToAddress]; targetCell.Value = 123456.78m; targetCell.Style.Font.Color.SetColor(Color.Red); targetCell.Style.Numberformat.Format = "_($* #,##0.00_);_($* (#,##0.00);_($* \"-\"??_);_(@_)"; // 会计格式 }注意:命名区域必须在 Workbook 级别定义(非 Sheet 级别),否则package.Workbook.Names查不到。定义时不要带$符号(如=Sheet1!$E$15是错的),直接写Sheet1!E15。3.2 公式重计算:为什么.Calculate()不总生效及替代方案EPPlus 的Worksheet.Calculate()方法只重算当前 Sheet 的公式,且对跨 Sheet 引用(如=Sheet2!A1*100)支持不稳定。更严重的是:若公式依赖的单元格值是 C# 后写入的,Calculate()可能仍显示旧结果,因为 EPPlus 的计算引擎不完全模拟 Excel。血泪经验:对关键公式结果,必须用 C# 重新计算并直接写入值。例如,模板中F2单元格有公式=SUM(C2:E2),而 C2~E2 的值由代码写入:// 错误:依赖 Calculate() ws.Cells["C2"].Value = 100; ws.Cells["D2"].Value = 200; ws.Cells["E2"].Value = 300; ws.Calculate(); // F2 可能仍显示 0 或旧值 // 正确:手动计算并写入 var sum = Convert.ToDecimal(ws.Cells["C2"].Value) + Convert.ToDecimal(ws.Cells["D2"].Value) + Convert.ToDecimal(ws.Cells["E2"].Value); ws.Cells["F2"].Value = sum;此法牺牲了公式灵活性,但换来 100% 可控性。若业务强依赖公式(如动态税率计算),建议在模板中预留“参数区”,用命名区域绑定税率单元格,代码只改参数值,再调用Calculate()—— 这种混合模式更健壮。3.3 样式继承:合并单元格后如何让新内容居中且不破坏边框合并单元格(Merge = true)是高频需求,但新手常犯两个错误:一是合并后直接Cells["A1"].Value = "标题",结果文字左对齐;二是合并后设字体加粗,却发现边框消失了。根本原因是:合并操作会清除被合并区域除左上角外所有单元格的样式,只保留A1的样式。正确姿势是:// 1. 先合并(指定范围,非单个单元格) ws.Cells["A1:D1"].Merge = true; // 2. 设置合并后单元格的样式(必须对整个范围操作) ws.Cells["A1:D1"].Style.HorizontalAlignment = OfficeOpenXml.Style.ExcelHorizontalAlignment.Center; ws.Cells["A1:D1"].Style.VerticalAlignment = OfficeOpenXml.Style.ExcelVerticalAlignment.Center; ws.Cells["A1:D1"].Style.Font.Bold = true; // 3. 写入值(对合并区域任意单元格赋值都行,但习惯写左上角) ws.Cells["A1"].Value = "2024年Q2销售汇总"; // 4. 若需边框,必须显式设置(合并不继承原边框) ws.Cells["A1:D1"].Style.Border.Top.Style = OfficeOpenXml.Style.ExcelBorderStyle.Thin; ws.Cells["A1:D1"].Style.Border.Bottom.Style = OfficeOpenXml.Style.ExcelBorderStyle.Thin; ws.Cells["A1:D1"].Style.Border.Left.Style = OfficeOpenXml.Style.ExcelBorderStyle.Thin; ws.Cells["A1:D1"].Style.Border.Right.Style = OfficeOpenXml.Style.ExcelBorderStyle.Thin;关键点:Style属性必须作用于合并后的整个区域("A1:D1"),而非单个单元格。EPPlus 的样式是“区域级”而非“单元格级”,这是与 Excel UI 操作逻辑一致的设计。4. 避坑:EPPlus 读写改 Excel 的五个血泪现场4.1 现象:程序运行时抛System.IO.FileLoadException,提示“无法加载文件或程序集 'EPPlus, Version=6.2.11.0'”原因:项目中存在多个版本的 EPPlus 引用(如 A.dll 引用 6.2.11,B.dll 引用 6.0.5),.NET 运行时无法解析强名称冲突。解决:在.csproj中添加<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>,并执行dotnet restore;若仍失败,在app.config或web.config中手动添加 bindingRedirect,指向 6.2.11。4.2 现象:读取 Excel 时ws.Dimension为 null,后续所有Cells操作报NullReferenceException原因:打开的文件不是有效的.xlsx,可能是.xls、损坏文件,或文件被其他进程(如 Excel 进程)独占锁定。解决:先用File.OpenRead(path)尝试打开文件流,若抛IOException则说明被占用;用在线工具(如 https://www.xlsxvalidator.com/)验证文件有效性;确保模板用 Excel 2007+ 另存为.xlsx。4.3 现象:写入中文到 Excel 后,打开显示为乱码(如 “????”)原因:EPPlus 默认使用 UTF-8 编码,但某些老版本 Excel(尤其 Mac 版)对 UTF-8 BOM 处理异常;或单元格格式被设为“文本”导致 Unicode 字符截断。解决:在写入前强制设置单元格格式为常规:ws.Cells[row, col].Style.Numberformat.Format = "@";;若仍乱码,在保存前调用package.Stream.Position = 0;重置流位置(罕见但有效)。4.4 现象:调用package.SaveAs()后文件大小为 0 字节,或打开提示“文件已损坏”原因:ExcelPackage对象未被using正确释放,或SaveAs()后又尝试访问package.Workbook。EPPlus 的 Save 操作是流式写入,未完成即释放对象会导致写入中断。解决:必须用using包裹ExcelPackage,且SaveAs()是最后一步操作;若需保存到MemoryStream,务必在Save()后调用stream.Position = 0再读取。4.5 现象:修改公式单元格后,Calculate()返回值与 Excel 手动计算结果不一致(如日期差计算偏差 1 天)原因:EPPlus 的日期序列号基准与 Excel 不同(Excel 以 1900-01-01 为 1,EPPlus 默认以 1904-01-01 为 0);或公式中用了TODAY()等易失性函数。解决:创建ExcelPackage时显式设置基准:new ExcelPackage { Properties = { Created = DateTime.Now } };;对易失性函数,改用 C# 计算后写入值,禁用公式。5. 进阶技巧:用 EPPlus 实现 Excel 模板的“热更新”与差异对比5.1 模板热更新:不重启服务即可切换 Excel 模板业务常要求“今晚上线新模板”,但服务不能停。EPPlus 本身不支持热加载,但我们可以通过FileSystemWatcher监控模板目录,配合ConcurrentDictionary<string, Lazy<ExcelPackage>>实现无锁热替换。核心思路:将模板加载包装为Lazy<ExcelPackage>,首次访问时加载,后续复用;当文件变更时,清空对应 key 的缓存,下次访问自动加载新版。private static readonly ConcurrentDictionary<string, Lazy<ExcelPackage>> _templateCache = new(); private static readonly FileSystemWatcher _watcher = new() { Path = Path.Combine(AppContext.BaseDirectory, "Templates"), Filter = "*.xlsx", NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName }; // 初始化监听 _watcher.Changed += (s, e) => { var fileName = Path.GetFileName(e.FullPath); _templateCache.TryRemove(fileName, out _); Console.WriteLine($"Template {fileName} updated, cache cleared."); }; _watcher.EnableRaisingEvents = true; // 获取模板(线程安全) public static ExcelPackage GetTemplate(string fileName) { return _templateCache.GetOrAdd(fileName, key => new Lazy<ExcelPackage>(() => { var path = Path.Combine(AppContext.BaseDirectory, "Templates", key); return new ExcelPackage(new FileInfo(path)); })).Value; }此方案让模板变更成为运维操作,开发无需改代码。注意ExcelPackage不是线程安全的,因此每次GetTemplate()返回的是新实例,而非共享实例。5.2 Excel 差异对比:快速定位两份报表的数值/样式差异审计场景常需比对“生成报表”与“人工修正报表”的差异。EPPlus 可提取两份文件的单元格值、公式、样式,生成结构化差异报告。以下函数提取关键差异维度:public class CellDiff { public string Address { get; set; } public string ValueBefore { get; set; } public string ValueAfter { get; set; } public string FormulaBefore { get; set; } public string FormulaAfter { get; set; } public bool FontBoldChanged { get; set; } public bool FillColorChanged { get; set; } } public static List<CellDiff> CompareWorksheets(ExcelWorksheet ws1, ExcelWorksheet ws2) { var diffs = new List<CellDiff>(); var maxRow = Math.Max(ws1.Dimension?.End.Row ?? 1, ws2.Dimension?.End.Row ?? 1); var maxCol = Math.Max(ws1.Dimension?.End.Column ?? 1, ws2.Dimension?.End.Column ?? 1); for (int row = 1; row <= maxRow; row++) { for (int col = 1; col <= maxCol; col++) { var c1 = ws1.Cells[row, col]; var c2 = ws2.Cells[row, col]; var addr = $"{GetColumnName(col)}{row}"; var diff = new CellDiff { Address = addr }; // 值对比(忽略空格和大小写) diff.ValueBefore = c1.Value?.ToString(); diff.ValueAfter = c2.Value?.ToString(); if (!string.Equals(diff.ValueBefore, diff.ValueAfter, StringComparison.OrdinalIgnoreCase)) { diffs.Add(diff); continue; // 值不同,其余字段无需比 } // 公式对比 diff.FormulaBefore = c1.Formula; diff.FormulaAfter = c2.Formula; if (!string.Equals(diff.FormulaBefore, diff.FormulaAfter)) { diffs.Add(diff); continue; } // 样式对比(仅关键样式) diff.FontBoldChanged = c1.Style.Font.Bold != c2.Style.Font.Bold; diff.FillColorChanged = !c1.Style.Fill.BackgroundColor.Rgb.Equals(c2.Style.Fill.BackgroundColor.Rgb); if (diff.FontBoldChanged || diff.FillColorChanged) diffs.Add(diff); } } return diffs; } // 辅助函数:数字列号转字母(1->A, 27->AA) private static string GetColumnName(int columnNumber) { var columnName = ""; while (columnNumber > 0) { columnNumber--; columnName = (char)('A' + columnNumber % 26) + columnName; columnNumber /= 26; } return columnName; }此函数返回List<CellDiff>,可直接序列化为 JSON 供前端渲染差异表格,或生成 Excel 高亮报告。实践中,我们会在CompareWorksheets后追加ws2.Cells[addr].Style.Fill.PatternType = OfficeOpenXml.Style.ExcelFillStyle.Solid; ws2.Cells[addr].Style.Fill.BackgroundColor.SetColor(Color.Yellow);实现原文件高亮,形成闭环。从那以后我每次交付 Excel 处理模块,都强制走一遍CompareWorksheets测试用例,用两份已知差异的模板跑通再上线。不是信不过自己写的代码,而是信不过 Excel 那些藏在公式、样式、隐藏行里的玄学。希望帮到你。本文还有配套的精品资源,点击获取
简介:本资源是一套面向.NET开发者的EPPlus Excel操作实战示例工程,专为解决C#环境下Excel文件的写入、读取与修改等高频数据处理需求而设计,适用于中初级开发者快速掌握Office Open XML(XLSX)格式的程序化操作。压缩包共41个文件,包含6个核心C#源码文件(如Form1.cs、Program.cs)、7个运行依赖DLL、3个可执行EXE及1个示例xlsx文件,辅以配置文件(config)、项目定义(csproj)、资源文件(resx)等,完整呈现VS解决方案结构,便于直接编译运行与代码调试。资源包大小2.55MB,结构清晰、开箱即用。已有3730人学习下载,提供从基础单元格赋值、批量数据加载、公式与样式设置,到图表插入等进阶功能的完整实现逻辑,所有操作均基于EPPlus 5.x主流版本,含详细注释与典型异常处理提示,是构建报表导出、数据导入类功能的可靠参考模板。1. C#用EPPlus操作Excel:不是封装API调用,而是把Excel当内存数据库来读写改你有没有遇到过这种场景:后台服务要批量生成带公式的销售报表,导出时还得保留原模板的合并单元格、条件格式和页眉页脚;或者前端上传一个带多Sheet的财务对账表,后端得逐Sheet解析、校验逻辑、回填差异标记,最后原样返回给用户——这时候用Microsoft.Office.Interop.Excel?别试了,Windows Server 上装 Office 不仅部署麻烦,还容易因 COM 对象未释放导致进程卡死;手写 OpenXML?那得啃透 ECMA-376 标准里几百个 XML 元素嵌套规则,改个边框颜色都得查文档半小时。EPPlus 就是为这类真实业务场景而生的:它不依赖 Office 安装,纯 .NET 实现,直接操作 Excel 文件二进制流,把.xlsx当成一个结构化内存数据库来用——读是查表,写是 INSERT,改是 UPDATE WHERE。它适合所有需要在无 UI 环境(Linux Docker、Windows Service、Azure Function)下高频、稳定、可控地处理 Excel 的 C# 开发者,尤其适合从传统 WinForms 迁移过来、又不想碰 COM 黑匣子的工程师。这不是玩具库,某高校教务系统用它每晚处理 20 万行课表数据,某公司财务中台靠它支撑日均 800+ 份自动对账 Excel 的生成与回填。2. EPPlus 核心能力落地:从 NuGet 引入到 Workbook 生命周期管理2.1 初始化与版本对齐:为什么必须锁定 EPPlus 6.x 而非最新版EPPlus 在 5.x 和 6.x 之间存在重大 API 断层。6.x 彻底移除了ExcelPackage.LicenseContext = LicenseContext.NonCommercial这类旧式授权设置,改为通过ExcelPackage.DefaultSettings或构造函数传参控制;同时Worksheet.Cells["A1"].Value的类型推断行为更严格,不再隐式转换DateTime为字符串。生产环境我一律锁定6.2.11(截至 2024 年 Q2 最稳定 LTS 版),原因有三:一是该版本已修复 6.0 初期大量 Sheet 复制崩溃问题;二是对IF公式重计算支持最完整;三是避免 6.3+ 新增的IExcelRange.CopyFrom()方法引发的跨 Sheet 样式污染 bug。安装命令如下:dotnet add package EPPlus --version 6.2.11提示:若项目使用 .NET 5+,无需额外引用System.Drawing.Common;但若目标框架含.NET Framework 4.7.2,需手动添加<PackageReference Include="System.Drawing.Common" Version="6.0.0" />,否则LoadImage()会抛TypeLoadException。2.2 创建 Workbook:内存模式 vs 文件流模式的选择逻辑EPPlus 提供两种初始化方式,选错会导致后续所有操作翻车:内存模式(推荐用于生成新文件):new ExcelPackage()所有操作在内存完成,最终调用package.SaveAs(new FileInfo("out.xlsx"))一次性落盘。优势是速度快、无文件锁、适合 Web API 响应流式输出;缺点是大文件(>50MB)易触发 GC 压力。文件流模式(推荐用于修改现有模板):new ExcelPackage(new FileInfo("template.xlsx"))直接加载磁盘文件到内存,保留原始样式、公式、图表等全部元数据。关键点在于:必须确保源文件是.xlsx(而非.xls),且未被其他进程占用。若模板由 Excel 2003 保存,需先用 Excel 打开另存为.xlsx,否则EPPlus会静默忽略xl/worksheets/sheet1.xml中的条件格式节点。下面是一个典型模板填充流程:// 加载模板(注意:FileInfo 构造函数传绝对路径,相对路径易因工作目录变化失效) var templatePath = Path.Combine(AppContext.BaseDirectory, "Templates", "SalesReport_Template.xlsx"); using var package = new ExcelPackage(new FileInfo(templatePath)); // 获取第一个 Worksheet(索引从 1 开始,非 0) var ws = package.Workbook.Worksheets[1]; // 写入标题行:直接赋值,EPPlus 自动识别类型 ws.Cells["A1"].Value = "销售日期"; ws.Cells["B1"].Value = "产品编号"; ws.Cells["C1"].Value = "销售额"; // 批量写入数据:用 LoadFromCollection 比循环 Cells 更快 var salesData = GetSalesList(); // 返回 List<SalesRecord> ws.Cells["A2"].LoadFromCollection(salesData, true); // true 表示首行写属性名 // 保存为新文件(原模板不受影响) var outputPath = Path.Combine(AppContext.BaseDirectory, "Output", $"Report_{DateTime.Now:yyyyMMdd_HHmmss}.xlsx"); package.SaveAs(new FileInfo(outputPath));这段代码背后的关键逻辑是:LoadFromCollection不是简单遍历,它会扫描SalesRecord类型的每个public属性,按声明顺序映射到列,并自动处理DateTime、decimal等类型的 Excel 单元格格式(如DateTime默认设为yyyy-mm-dd格式)。若需自定义列顺序或跳过某属性,需在类上加[ExcelColumn(Order = 2)]特性。2.3 读取 Excel:如何安全解析多 Sheet 且带空行的业务表读取比写入更易踩坑。常见需求是:一个 Excel 有Sheet1: 销售明细、Sheet2: 退货记录、Sheet3: 产品主数据,需分别加载到不同DataTable。EPPlus 不提供ToDataTable()一键方法,必须手动控制边界。核心原则是:永远不用ws.Dimension.End.Row作为数据行上限——因为用户可能在最后一行下方留了几十空白行,Dimension会错误包含它们,导致for (int i = 1; i <= ws.Dimension.End.Row; i++)循环卡死。正确做法是定位“实际数据区域”:public static DataTable ReadWorksheet(ExcelWorksheet ws, int headerRow = 1) { var dt = new DataTable(); // 1. 读取表头(假设第 headerRow 行是列名) for (int col = 1; col <= ws.Dimension?.End.Column; col++) { var cell = ws.Cells[headerRow, col]; dt.Columns.Add(cell.Value?.ToString() ?? $"Column{col}", typeof(string)); } // 2. 从 headerRow+1 开始读数据,直到遇到连续 3 行全空才停止 int lastDataRow = headerRow + 1; int emptyRowCount = 0; while (emptyRowCount < 3 && lastDataRow <= ws.Dimension?.End.Row) { bool rowIsEmpty = true; for (int col = 1; col <= ws.Dimension.End.Column; col++) { if (!string.IsNullOrWhiteSpace(ws.Cells[lastDataRow, col].Text)) { rowIsEmpty = false; break; } } if (rowIsEmpty) emptyRowCount++; else { emptyRowCount = 0; // 重置计数器 // 3. 构建 DataRow:EPPlus 的 Cell.Value 是 object,需显式转 string 防 null var dr = dt.NewRow(); for (int col = 1; col <= ws.Dimension.End.Column; col++) { dr[col - 1] = ws.Cells[lastDataRow, col].Value?.ToString() ?? string.Empty; } dt.Rows.Add(dr); } lastDataRow++; } return dt; } // 使用示例 using var package = new ExcelPackage(new FileInfo("data.xlsx")); var salesDt = ReadWorksheet(package.Workbook.Worksheets["销售明细"]); var returnDt = ReadWorksheet(package.Workbook.Worksheets["退货记录"]);此方法规避了Dimension的虚假范围,用“连续空行”作为业务数据自然结束标志,符合真实 Excel 使用习惯。注意ws.Cells[row, col].Text返回的是 Excel 渲染后的字符串(如12345显示为12,345),而.Value返回原始值(12345),解析数值时务必用.Value并做类型判断。3. 修改 Excel:精准定位、公式重算与样式继承的实战策略3.1 定位目标单元格:命名区域(Named Range)比坐标更可靠硬编码"A10"或ws.Cells[10,1]是维护噩梦。当业务方说“把‘本期合计’单元格改成红色”,下次模板调整行列,你的代码就失效。EPPlus 支持 Excel 原生的“命名区域”(Named Range),这是解耦模板与代码的黄金方案。操作步骤:在 Excel 模板中选中目标单元格(如E15),点击公式栏左侧名称框,输入TotalAmountCell回车;在 C# 中通过package.Workbook.Names["TotalAmountCell"]获取其地址;修改值并设样式:// 通过命名区域获取单元格(自动适配 Sheet 变更) var name = package.Workbook.Names["TotalAmountCell"]; if (name != null && name.RefersToWorksheet != null) { var targetCell = name.RefersToWorksheet.Cells[name.RefersToAddress]; targetCell.Value = 123456.78m; targetCell.Style.Font.Color.SetColor(Color.Red); targetCell.Style.Numberformat.Format = "_($* #,##0.00_);_($* (#,##0.00);_($* \"-\"??_);_(@_)"; // 会计格式 }注意:命名区域必须在 Workbook 级别定义(非 Sheet 级别),否则package.Workbook.Names查不到。定义时不要带$符号(如=Sheet1!$E$15是错的),直接写Sheet1!E15。3.2 公式重计算:为什么.Calculate()不总生效及替代方案EPPlus 的Worksheet.Calculate()方法只重算当前 Sheet 的公式,且对跨 Sheet 引用(如=Sheet2!A1*100)支持不稳定。更严重的是:若公式依赖的单元格值是 C# 后写入的,Calculate()可能仍显示旧结果,因为 EPPlus 的计算引擎不完全模拟 Excel。血泪经验:对关键公式结果,必须用 C# 重新计算并直接写入值。例如,模板中F2单元格有公式=SUM(C2:E2),而 C2~E2 的值由代码写入:// 错误:依赖 Calculate() ws.Cells["C2"].Value = 100; ws.Cells["D2"].Value = 200; ws.Cells["E2"].Value = 300; ws.Calculate(); // F2 可能仍显示 0 或旧值 // 正确:手动计算并写入 var sum = Convert.ToDecimal(ws.Cells["C2"].Value) + Convert.ToDecimal(ws.Cells["D2"].Value) + Convert.ToDecimal(ws.Cells["E2"].Value); ws.Cells["F2"].Value = sum;此法牺牲了公式灵活性,但换来 100% 可控性。若业务强依赖公式(如动态税率计算),建议在模板中预留“参数区”,用命名区域绑定税率单元格,代码只改参数值,再调用Calculate()—— 这种混合模式更健壮。3.3 样式继承:合并单元格后如何让新内容居中且不破坏边框合并单元格(Merge = true)是高频需求,但新手常犯两个错误:一是合并后直接Cells["A1"].Value = "标题",结果文字左对齐;二是合并后设字体加粗,却发现边框消失了。根本原因是:合并操作会清除被合并区域除左上角外所有单元格的样式,只保留A1的样式。正确姿势是:// 1. 先合并(指定范围,非单个单元格) ws.Cells["A1:D1"].Merge = true; // 2. 设置合并后单元格的样式(必须对整个范围操作) ws.Cells["A1:D1"].Style.HorizontalAlignment = OfficeOpenXml.Style.ExcelHorizontalAlignment.Center; ws.Cells["A1:D1"].Style.VerticalAlignment = OfficeOpenXml.Style.ExcelVerticalAlignment.Center; ws.Cells["A1:D1"].Style.Font.Bold = true; // 3. 写入值(对合并区域任意单元格赋值都行,但习惯写左上角) ws.Cells["A1"].Value = "2024年Q2销售汇总"; // 4. 若需边框,必须显式设置(合并不继承原边框) ws.Cells["A1:D1"].Style.Border.Top.Style = OfficeOpenXml.Style.ExcelBorderStyle.Thin; ws.Cells["A1:D1"].Style.Border.Bottom.Style = OfficeOpenXml.Style.ExcelBorderStyle.Thin; ws.Cells["A1:D1"].Style.Border.Left.Style = OfficeOpenXml.Style.ExcelBorderStyle.Thin; ws.Cells["A1:D1"].Style.Border.Right.Style = OfficeOpenXml.Style.ExcelBorderStyle.Thin;关键点:Style属性必须作用于合并后的整个区域("A1:D1"),而非单个单元格。EPPlus 的样式是“区域级”而非“单元格级”,这是与 Excel UI 操作逻辑一致的设计。4. 避坑:EPPlus 读写改 Excel 的五个血泪现场4.1 现象:程序运行时抛System.IO.FileLoadException,提示“无法加载文件或程序集 'EPPlus, Version=6.2.11.0'”原因:项目中存在多个版本的 EPPlus 引用(如 A.dll 引用 6.2.11,B.dll 引用 6.0.5),.NET 运行时无法解析强名称冲突。解决:在.csproj中添加<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>,并执行dotnet restore;若仍失败,在app.config或web.config中手动添加 bindingRedirect,指向 6.2.11。4.2 现象:读取 Excel 时ws.Dimension为 null,后续所有Cells操作报NullReferenceException原因:打开的文件不是有效的.xlsx,可能是.xls、损坏文件,或文件被其他进程(如 Excel 进程)独占锁定。解决:先用File.OpenRead(path)尝试打开文件流,若抛IOException则说明被占用;用在线工具(如 https://www.xlsxvalidator.com/)验证文件有效性;确保模板用 Excel 2007+ 另存为.xlsx。4.3 现象:写入中文到 Excel 后,打开显示为乱码(如 “????”)原因:EPPlus 默认使用 UTF-8 编码,但某些老版本 Excel(尤其 Mac 版)对 UTF-8 BOM 处理异常;或单元格格式被设为“文本”导致 Unicode 字符截断。解决:在写入前强制设置单元格格式为常规:ws.Cells[row, col].Style.Numberformat.Format = "@";;若仍乱码,在保存前调用package.Stream.Position = 0;重置流位置(罕见但有效)。4.4 现象:调用package.SaveAs()后文件大小为 0 字节,或打开提示“文件已损坏”原因:ExcelPackage对象未被using正确释放,或SaveAs()后又尝试访问package.Workbook。EPPlus 的 Save 操作是流式写入,未完成即释放对象会导致写入中断。解决:必须用using包裹ExcelPackage,且SaveAs()是最后一步操作;若需保存到MemoryStream,务必在Save()后调用stream.Position = 0再读取。4.5 现象:修改公式单元格后,Calculate()返回值与 Excel 手动计算结果不一致(如日期差计算偏差 1 天)原因:EPPlus 的日期序列号基准与 Excel 不同(Excel 以 1900-01-01 为 1,EPPlus 默认以 1904-01-01 为 0);或公式中用了TODAY()等易失性函数。解决:创建ExcelPackage时显式设置基准:new ExcelPackage { Properties = { Created = DateTime.Now } };;对易失性函数,改用 C# 计算后写入值,禁用公式。5. 进阶技巧:用 EPPlus 实现 Excel 模板的“热更新”与差异对比5.1 模板热更新:不重启服务即可切换 Excel 模板业务常要求“今晚上线新模板”,但服务不能停。EPPlus 本身不支持热加载,但我们可以通过FileSystemWatcher监控模板目录,配合ConcurrentDictionary<string, Lazy<ExcelPackage>>实现无锁热替换。核心思路:将模板加载包装为Lazy<ExcelPackage>,首次访问时加载,后续复用;当文件变更时,清空对应 key 的缓存,下次访问自动加载新版。private static readonly ConcurrentDictionary<string, Lazy<ExcelPackage>> _templateCache = new(); private static readonly FileSystemWatcher _watcher = new() { Path = Path.Combine(AppContext.BaseDirectory, "Templates"), Filter = "*.xlsx", NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName }; // 初始化监听 _watcher.Changed += (s, e) => { var fileName = Path.GetFileName(e.FullPath); _templateCache.TryRemove(fileName, out _); Console.WriteLine($"Template {fileName} updated, cache cleared."); }; _watcher.EnableRaisingEvents = true; // 获取模板(线程安全) public static ExcelPackage GetTemplate(string fileName) { return _templateCache.GetOrAdd(fileName, key => new Lazy<ExcelPackage>(() => { var path = Path.Combine(AppContext.BaseDirectory, "Templates", key); return new ExcelPackage(new FileInfo(path)); })).Value; }此方案让模板变更成为运维操作,开发无需改代码。注意ExcelPackage不是线程安全的,因此每次GetTemplate()返回的是新实例,而非共享实例。5.2 Excel 差异对比:快速定位两份报表的数值/样式差异审计场景常需比对“生成报表”与“人工修正报表”的差异。EPPlus 可提取两份文件的单元格值、公式、样式,生成结构化差异报告。以下函数提取关键差异维度:public class CellDiff { public string Address { get; set; } public string ValueBefore { get; set; } public string ValueAfter { get; set; } public string FormulaBefore { get; set; } public string FormulaAfter { get; set; } public bool FontBoldChanged { get; set; } public bool FillColorChanged { get; set; } } public static List<CellDiff> CompareWorksheets(ExcelWorksheet ws1, ExcelWorksheet ws2) { var diffs = new List<CellDiff>(); var maxRow = Math.Max(ws1.Dimension?.End.Row ?? 1, ws2.Dimension?.End.Row ?? 1); var maxCol = Math.Max(ws1.Dimension?.End.Column ?? 1, ws2.Dimension?.End.Column ?? 1); for (int row = 1; row <= maxRow; row++) { for (int col = 1; col <= maxCol; col++) { var c1 = ws1.Cells[row, col]; var c2 = ws2.Cells[row, col]; var addr = $"{GetColumnName(col)}{row}"; var diff = new CellDiff { Address = addr }; // 值对比(忽略空格和大小写) diff.ValueBefore = c1.Value?.ToString(); diff.ValueAfter = c2.Value?.ToString(); if (!string.Equals(diff.ValueBefore, diff.ValueAfter, StringComparison.OrdinalIgnoreCase)) { diffs.Add(diff); continue; // 值不同,其余字段无需比 } // 公式对比 diff.FormulaBefore = c1.Formula; diff.FormulaAfter = c2.Formula; if (!string.Equals(diff.FormulaBefore, diff.FormulaAfter)) { diffs.Add(diff); continue; } // 样式对比(仅关键样式) diff.FontBoldChanged = c1.Style.Font.Bold != c2.Style.Font.Bold; diff.FillColorChanged = !c1.Style.Fill.BackgroundColor.Rgb.Equals(c2.Style.Fill.BackgroundColor.Rgb); if (diff.FontBoldChanged || diff.FillColorChanged) diffs.Add(diff); } } return diffs; } // 辅助函数:数字列号转字母(1->A, 27->AA) private static string GetColumnName(int columnNumber) { var columnName = ""; while (columnNumber > 0) { columnNumber--; columnName = (char)('A' + columnNumber % 26) + columnName; columnNumber /= 26; } return columnName; }此函数返回List<CellDiff>,可直接序列化为 JSON 供前端渲染差异表格,或生成 Excel 高亮报告。实践中,我们会在CompareWorksheets后追加ws2.Cells[addr].Style.Fill.PatternType = OfficeOpenXml.Style.ExcelFillStyle.Solid; ws2.Cells[addr].Style.Fill.BackgroundColor.SetColor(Color.Yellow);实现原文件高亮,形成闭环。从那以后我每次交付 Excel 处理模块,都强制走一遍CompareWorksheets测试用例,用两份已知差异的模板跑通再上线。不是信不过自己写的代码,而是信不过 Excel 那些藏在公式、样式、隐藏行里的玄学。希望帮到你。本文还有配套的精品资源,点击获取