news 2026/10/23 5:22:44

EPPlus实战:两万行Excel合并改列与读写改避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
EPPlus实战:两万行Excel合并改列与读写改避坑

简介:面向 .NET 平台 C# 开发者的 EPPlus 实战示例包,聚焦 Office Open XML(XLSX)表格的写入、读取与修改三类常见需求,适合需要完成数据导入导出、报表生成或批量更新表格的中初级开发者对照练习。压缩包共 41 个文件、约 2.55MB,其中 6 个 cs 源文件承载窗体与主程序逻辑,7 个 dll 提供 EPPlus 及依赖库支持,另有 sln、csproj、config 等工程配置和 resx、settings 等界面资源,含一个 xlsx 样例表格,开箱即可编译运行查看效果。目前已有 3730 人学习下载。示例覆盖 ExcelPackage 与 FileStream 的创建保存流程、工作表与单元格写入及批量加载、按行列读取、单元格内容更新,以及加粗填充样式、SUM 公式与柱状图等进阶用法,有助于理解常用 API 组织方式与资源释放要点,可作为自建工具类或改造报表模块的起点。

1. 两万行 Excel 要合并、改列又要保格式,EPPlus 是那个能一次做完的工具

业务方丢来一个两万行的 Excel,要求把三张分表并成一张、按规则改两列、公式和样式一个都不能动,第二天早上就要。这种活在 C# 里我用了很多年 EPPlus:它不依赖本机装 Office,控制台程序、Windows 服务、定时任务里都能跑,写、读、改三个动作共用同一个 ExcelPackage 对象,改完直接落盘,不用中转文件。它擅长的是结构化表格的批量生产与回填——报表导出、模板填充、数据校验、历史文件批改;不适合拿它当计算引擎去啃几百万行的宽表。下面按写入、读取、修改三条线拆开,每条都给能直接抄的代码和真踩过的坑。

2. 环境与许可:EPPlus 5 之后为什么第一行代码必须设 LicenseContext

很多人第一次用 EPPlus 不是卡在 API,而是卡在一句LicenseException。这不是配置错了,是版本分水岭带来的必答题。这一章把包装上、许可设好、对象生命周期理清,再给一段写读改都能跑通的最小骨架,后面几章都在这个骨架上加东西。

2.1 版本分水岭:4.x 与 5.x 之后是两套规矩

EPPlus 在 4.x 时代是 LGPL,代码里随便new ExcelPackage()就能跑;从 5 开始改成了双许可模式,非商业用途免费但要显式声明上下文,商业用途需要授权。这一步不做,运行时直接抛异常,而且往往是在你本地好好的、上了服务器才炸。

装包本身没难度,命令行或包管理器都行:

# 在项目目录下执行,装最新稳定版 dotnet add package EPPlus # 如果老项目被 5+ 的许可卡住、又确实不能改,可以钉死 4.x(注意 API 有差异) dotnet add package EPPlus --version 4.5.3.3

装完之后先别急着写业务,在程序入口(Main、Startup、模块初始化)加一行许可声明。非商业场景用非商业上下文;5.x/6.x 是LicenseContext属性,7 之后改成了License对象上的方法,两套写法不通用:

using OfficeOpenXml; // 5.x / 6.x 写法:进程内设置一次即可,线程安全 ExcelPackage.LicenseContext = LicenseContext.NonCommercial; // 7.x 之后的写法(二选一,按实际使用主体填) // ExcelPackage.License.SetNonCommercialPersonal("your-name"); // ExcelPackage.License.SetNonCommercialOrganization("your-org"); // 商业授权:ExcelPackage.License.SetCommercial("your-key");

参数说明就两点:LicenseContext.NonCommercial是全局静态属性,设一次对整个进程生效,不要在每个方法里重复设;如果项目里同时引用了多个版本的 EPPlus(常见于老解决方案),会出现「找不到 LicenseContext 类型」的编译错误,这时用dotnet list package --include-transitive查一下依赖树,把间接引用统一到同一主版本。

提示:许可声明建议放在最早的初始化位置,比如Program.Main的第一行。放在某个业务类的静态构造函数里,一旦那个类没被触发就先调用了别的导出方法,照样抛异常。

2.2 ExcelPackage 的生命周期:为什么它必须包在 using 里

ExcelPackage实现了IDisposable,内部持有文件流和一大坨 XML 节点缓存。不释放最直接的后果是文件句柄不归还,同一进程里第二次打开同一个文件就报「文件正由另一进程使用」。这不是玄学,是 Windows 文件共享模式的硬约束。

标准写法是构造时传FileInfo,using结束自动释放:

using OfficeOpenXml; // 打开已有文件:构造即读取,Dispose 时释放文件句柄 using (var package = new ExcelPackage(new FileInfo(@"D:\data\orders.xlsx"))) { var ws = package.Workbook.Worksheets[0]; Console.WriteLine(ws.Name); } // 到这里文件句柄一定归还 // 新建空工作簿:不传参,SaveAs 时才落盘 using (var package = new ExcelPackage()) { package.Workbook.Worksheets.Add("Sheet1"); package.SaveAs(new FileInfo(@"D:\data\new.xlsx")); }

参数上有三个容易忽略的点。第一,new ExcelPackage(FileInfo)是打开并锁定文件,构造完成后到Dispose之前,这个文件不能再用别的进程写;如果你只是要读,务必要在using块内把数据取干净,别把ws对象带到块外面去用。第二,SaveAs到已存在的文件会直接覆盖,没有确认提示,路径拼错就是数据没了。第三,如果传入的是Stream而不是FileInfo,包不会主动关流,需要自己管Stream的释放,我一般只在 Web 接口返回文件流时用这种写法。

还有一种情况是「先读后写同一个文件」。正确姿势是同一个package里改完直接Save(),不要开两个包:

using (var package = new ExcelPackage(new FileInfo(path))) { var ws = package.Workbook.Worksheets["明细"]; ws.Cells[2, 1].Value = "已处理"; package.Save(); // 原地保存,不需要 SaveAs }

Save()与SaveAs()的区别在于:前者写回构造时那个文件,后者写到新路径。批量任务里我倾向用SaveAs输出到临时目录,校验通过后再原子替换,避免写到一半进程挂了留下半个坏文件。

2.3 最小闭环:写一段、读一段、改一段

在展开细节之前,先把写、读、改三条路各跑通一次,确认环境和许可是通的。这段代码可以直接建个控制台项目贴进去跑。

using OfficeOpenXml; ExcelPackage.LicenseContext = LicenseContext.NonCommercial; // 5.x/6.x var path = Path.Combine(Path.GetTempPath(), "demo.xlsx"); // 1) 写:新建工作簿,塞两行数据 using (var package = new ExcelPackage()) { var ws = package.Workbook.Worksheets.Add("明细"); ws.Cells["A1"].Value = "订单号"; ws.Cells["B1"].Value = "金额"; ws.Cells["A2"].Value = "SO-001"; ws.Cells["B2"].Value = 1280.5m; package.SaveAs(new FileInfo(path)); } // 2) 读:拿到单元格原始值和显示文本 using (var package = new ExcelPackage(new FileInfo(path))) { var ws = package.Workbook.Worksheets["明细"]; Console.WriteLine($"{ws.Cells[2, 1].Text} / {ws.Cells[2, 2].Value}"); } // 3) 改:定位到已有单元格回写,再保存 using (var package = new ExcelPackage(new FileInfo(path))) { var ws = package.Workbook.Worksheets["明细"]; ws.Cells[2, 2].Value = 1999m; // 直接覆盖原值 ws.Cells[3, 1].Value = "SO-002"; // 追加一行 ws.Cells[3, 2].Value = 860m; package.Save(); }

逻辑说明:第一段用无参构造建空包,Worksheets.Add("明细")返回新建的 sheet 对象,索引从 1 开始。第二段的Cells[2, 2].Text是经过数字格式渲染后的字符串(比如千分位会带上逗号),.Value是底层对象(这里是decimal或double)。第三段是原地改写的核心——new ExcelPackage(FileInfo)打开后,所有单元格都在内存里,改完Save()落盘。

参数说明:Cells[行, 列]的行列都从 1 开始,不是 0;Cells["B2"]这种 A1 地址写法和索引写法等价,前者可读性好、后者适合循环。Value是object类型,赋值时装箱,读的时候要自己判类型转换,这一点在第 4 章会展开。

3. 写入:从空工作簿到一张能直接发给业务方的表

跑通骨架之后,真正的活是把数据写得又快又好看。写入这块的坑集中在两处:一是逐格赋值在大数据量下慢得离谱,二是样式和数字格式设错导致业务方打开看到一串####或者科学计数法。这一章把定位方式、批量赋值、样式、数字格式、公式和数据验证一次讲透。

3.1 四种单元格定位写法与批量赋值

EPPlus 定位单元格至少有四种写法,性能差异在数据量大时非常明显:

var ws = package.Workbook.Worksheets.Add("明细"); int rows = 20000, cols = 6; // 写法一:A1 地址字符串,可读性最好,适合零散单元格 ws.Cells["A1"].Value = "订单号"; // 写法二:行列索引,适合循环 ws.Cells[1, 1].Value = "订单号"; // 写法三:区域 + 二维数组,批量写入的唯一正确姿势 var body = new object[rows, cols]; for (int i = 0; i < rows; i++) { body[i, 0] = $"SO-{i + 1:D6}"; body[i, 1] = DateTime.Today.AddDays(-i); body[i, 2] = "华东"; body[i, 3] = 12.5m; body[i, 4] = 100 + i % 50; body[i, 5] = 0.13m; } ws.Cells[2, 1, rows + 1, cols].Value = body; // 一次赋值,覆盖 20000×6 区域 // 写法四:整行/整列访问,适合设样式不适合读数据 ws.Column(1).Style.Font.Name = "微软雅黑";

逻辑说明:写法三的Cells[起始行, 起始列, 结束行, 结束列]返回一个ExcelRange,给它的Value赋一个object[,]时,EPPlus 会按区域一次性铺开,避免了两万次属性访问和样式重算。这是血泪经验——早期我写循环逐格赋值,两万行六列要跑十几秒,改成二维数组之后降到一秒以内。

参数说明:二维数组的维度必须和区域严格对应,行数或列数不匹配会抛ArgumentException,而且报错信息不会告诉你差多少,自己先算清楚。数组元素类型建议统一用object,混用decimal、DateTime、string都没问题,但别塞null进去当占位——null会被写成空单元格,后续读取判断要多一层,我一般用string.Empty兜底。

还有一点:给区域赋值之后再单独改其中某一格,是完全允许的,不会触发重排。批量铺数据、再局部修补,是导出场景最省事的组合。

3.2 表头样式、列宽与数字格式

数据写进去只是半成品,业务方打开第一眼看到的是表头。样式设置建议集中在一段代码里,按「字体 → 对齐 → 填充 → 边框」的顺序写,可读性好也不容易漏。

var header = ws.Cells[1, 1, 1, cols]; header.Style.Font.Bold = true; header.Style.Font.Size = 11; header.Style.HorizontalAlignment = ExcelHorizontalAlignment.Center; header.Style.VerticalAlignment = ExcelVerticalAlignment.Center; header.Style.Fill.PatternType = ExcelFillStyle.Solid; header.Style.Fill.BackgroundColor.SetColor(Color.FromArgb(0xF3, 0xF6, 0xFC)); header.Style.Border.Bottom.Style = ExcelBorderStyle.Thin; header.Style.Border.Bottom.Color.SetColor(Color.FromArgb(0xBF, 0xBF, 0xBF)); // 行高与列宽:AutoFit 在列数多、单元格文本长的时候会明显变慢 ws.Row(1).Height = 22; ws.Column(1).Width = 16; ws.Column(2).Width = 12; // 数字格式:这一块设错,业务方看到的就是 1.25E+11 ws.Cells[2, 2, rows + 1, 2].Style.Numberformat.Format = "yyyy-mm-dd"; ws.Cells[2, 4, rows + 1, 4].Style.Numberformat.Format = "#,##0.00"; ws.Cells[2, 6, rows + 1, 6].Style.Numberformat.Format = "0.0%"; ws.Cells[2, 1, rows + 1, 1].Style.Numberformat.Format = "@"; // 文本,防长编号变科学计数法

逻辑说明:Style是整块区域的样式对象,赋值会向下传递到区域内每个单元格,所以尽量用区域而不是逐格设。Numberformat.Format接受的是 Excel 的自定义格式字符串,不是 .NET 的格式串,两者语法不同,{0:N2}这种写法在这里无效。

常用格式对照如下,设之前先确认值本身存对了:

场景格式串关键前提
日期yyyy-mm-dd单元格值必须是DateTime,不是字符串
金额#,##0.00值存decimal,别存带币种符号的字符串
百分比0.0%值存0.135,存13.5会显示成 1350%
长编号@强制文本,否则 18 位订单号会被截断成科学计数法
负数标红#,##0.00;[Red]-#,##0.00分号前正数、分号后负数,两段格式

参数说明:列宽单位是「字符数」近似值,Width = 16大约能放下 8 个汉字或 16 个半角字符,别按像素理解。AutoFitColumns()确实存在,但它在列多、文本长时会遍历内容重新计算,两万行以上的表能卡住好几秒,我的习惯是固定宽 + 关键列手动调,不用 AutoFit。

注意:日期列如果值是从数据库读出来的字符串(比如"2024-05-01"),设格式串是没用的,Excel 只会原样显示字符串。必须先DateTime.Parse转成DateTime再赋值。

3.3 公式、数据验证与冻结窗格

导出表里经常要带合计行和下拉选项,EPPlus 对这两块支持得比较完整。公式的关键是理解「相对引用会按行偏移」这件事。

// 公式:给整列赋同一个公式,相对引用会自动按行下移 ws.Cells[2, 7, rows + 1, 7].Formula = "D2*E2"; // 等价写法:R1C1 记法,RC[-3] 表示「左边第 3 列」 ws.Cells[2, 7, rows + 1, 7].FormulaR1C1 = "RC[-3]*RC[-2]"; // 合计行:放在最后一行 int totalRow = rows + 2; ws.Cells[totalRow, 1].Value = "合计"; ws.Cells[totalRow, 4].Formula = $"SUM(D2:D{rows + 1})"; ws.Cells[totalRow, 4].Style.Numberformat.Format = "#,##0.00"; ws.Cells[totalRow, 1, totalRow, 7].Style.Font.Bold = true; // 数据验证:给「状态」列加下拉,限制只能选两个值 var dv = ws.DataValidation.AddListDataValidation(ws.Cells[2, 8, rows + 1, 8]); dv.Formula.Values.Add("已付款"); dv.Formula.Values.Add("未付款"); dv.ShowErrorMessage = true; dv.ErrorTitle = "取值不合法"; dv.Error = "只能选择列表中的选项"; // 冻结首行 + 加筛选,两万行数据没这两样基本没法用 ws.View.FreezePanes(2, 1); ws.Cells[1, 1, 1, cols].AutoFilter = true;

逻辑说明:给一个区域赋Formula时,EPPlus 会把每个单元格的地址按相对关系重写一遍,所以D2*E2到第 100 行会自动变成D100*E100。如果你写的是$D$2*E2,绝对引用的那一半不会动。R1C1 写法更适合在循环里拼公式,因为不需要知道自己处在第几行。

参数说明:FreezePanes(2, 1)的两个参数是「冻结到第几行、冻结到第几列」,传(2, 1)表示第 1 行固定、第 1 列固定,滚动时始终可见。AutoFilter加在表头区域即可,EPPlus 会自己推断数据范围。

数据验证的Formula.Values是把选项硬编码进文件,选项多了(超过十几个)就不合适,常见做法是在隐藏 sheet 里存一列选项,然后用Formula.ExcelFormula指向那个区域:

// 选项存在隐藏 sheet 的 A1:A50 var hidden = package.Workbook.Worksheets.Add("选项"); hidden.Cells["A1"].Value = "待审核"; hidden.Hidden = eWorkSheetHidden.Hidden; dv.Formula.ExcelFormula = "=选项!$A$1:$A$50";

这里有个细节:公式里引用中文 sheet 名不需要加引号,但如果 sheet 名带空格或特殊字符,必须写成='Sheet Name'!$A$1:$A$50,少了单引号整个验证就失效,而且不会有任何报错提示。

4. 读取与修改:把 Excel 当数据源再回写

读取比写入更容易出事,因为写入时数据是你给的、格式是你定的,读取时面对的是别人手工编辑过的文件:合并单元格、中间空行、格式混乱、数字存成文本。这一章先把边界判定和类型转换讲清楚,再落到回写、插入行和大文件的内存账。

4.1 Dimension 边界与空行空列陷阱

读一个工作表,第一件事是确定数据范围。EPPlus 用Dimension表示已使用区域,但它有两个坑:空表时它是null,中间有零散格式时它会比实际数据大一圈。

using (var package = new ExcelPackage(new FileInfo(path))) { var ws = package.Workbook.Worksheets["明细"]; var dim = ws.Dimension; // 空表:Dimension 为 null,直接取 End.Row 会空引用 if (dim == null) { Console.WriteLine("工作表是空的"); return; } int lastRow = dim.End.Row; int lastCol = dim.End.Column; Console.WriteLine($"已用区域 {dim.Address},共 {lastRow} 行 {lastCol} 列"); // 逐行读,跳过整行为空的记录 for (int r = 2; r <= lastRow; r++) { var orderNo = ws.Cells[r, 1].Text?.Trim(); if (string.IsNullOrEmpty(orderNo)) continue; // 空行直接跳过 var amountObj = ws.Cells[r, 4].Value; decimal amount = amountObj == null ? 0m : Convert.ToDecimal(amountObj); // ... 组装到列表 } }

逻辑说明:dim.Address返回类似A1:H20001的字符串,排查问题时打印出来最快。循环里用.Text读订单号而不是.Value,是因为业务方经常把编号列设成文本格式,.Value拿到的是string,.Text拿到的是显示的字符串,两者在纯文本列上一致,但前者可能因为类型推断出意外结果,读文本列用.Text更稳。

参数说明:Dimension的边界包含所有「有过内容或格式」的单元格,所以有人在第 5000 行刷了个底色,End.Row就变成 5000,你的循环会多跑几千次空判断。这种情况我一般不信Dimension,改成从下往上找第一个非空行:

int realLastRow = lastRow; while (realLastRow > 1 && string.IsNullOrEmpty(ws.Cells[realLastRow, 1].Text)) realLastRow--;

合并单元格是另一个常见陷阱:合并区域只有左上角那个格子有值,其余格子.Value全是null。要读合并区的值,得先拿到合并范围:

// 判断当前格是否在合并区域内,是的话取左上角的值 var merged = ws.MergedCells[r, 1]; if (merged != null) { var first = new ExcelAddress(merged).Start; value = ws.Cells[first.Row, first.Column].Text; }

这段逻辑我一般封装成一个静态方法,读业务表时统一走它,省得每个字段都判断一次。

4.2 强类型映射:一行一个对象

业务代码里直接用ws.Cells[r, c]会污染一大片,改成「列索引映射到属性」的写法,后续加列、改列顺序都不容易出错。

public class OrderRow { public string OrderNo { get; set; } = ""; public DateTime OrderDate { get; set; } public string Region { get; set; } = ""; public decimal Amount { get; set; } public int Qty { get; set; } public string Status { get; set; } = ""; } // 表头名 → 属性名,一次配置长期复用 private static readonly Dictionary<string, Action<OrderRow, string>> Map = new() { ["订单号"] = (o, v) => o.OrderNo = v, ["下单日期"] = (o, v) => o.OrderDate = DateTime.TryParse(v, out var d) ? d : default, ["区域"] = (o, v) => o.Region = v, ["金额"] = (o, v) => o.Amount = decimal.TryParse(v, out var m) ? m : 0m, ["数量"] = (o, v) => o.Qty = int.TryParse(v, out var q) ? q : 0, ["状态"] = (o, v) => o.Status = v, };

逻辑说明:用字典把「表头文字」映射到「赋值动作」,好处是列顺序变了也不影响,找不到的表头直接忽略,比硬编码列号的容错性高一个量级。业务方临时插一列「备注」,老代码完全不用改。

参数说明:读值统一用.Text,因为TryParse处理的是字符串。日期列要注意,如果单元格是标准日期格式,.Text返回的是格式化后的字符串(比如2024/5/1),DateTime.TryParse在大多数区域设置下能解析;但如果单元格是数字格式的日期序列号,.Text会返回一个纯数字,这种情况得先判Value的类型:

var cell = ws.Cells[r, 2]; DateTime date = cell.Value switch { DateTime dt => dt, double d => DateTime.FromOADate(d), // Excel 日期序列号 string s => DateTime.TryParse(s, out var p) ? p : default, _ => default };

DateTime.FromOADate是处理 Excel 日期序列号的标准做法,1900 年 1 月 1 日对应 1,这个偏移量是 Excel 的历史遗留,别自己减天数。

4.3 回写已有文件:定位、插入行与公式重算

改文件的需求比新建复杂,因为你要在不破坏原有结构的前提下动它。最常见的三种操作:改某个单元格的值、在指定位置插入一行、删除一行。

using (var package = new ExcelPackage(new FileInfo(path))) { var ws = package.Workbook.Worksheets["明细"]; // 1) 按条件定位后回写:把金额大于 1000 的标记成「重点」 for (int r = 2; r <= ws.Dimension?.End.Row; r++) { if (decimal.TryParse(ws.Cells[r, 4].Text, out var amt) && amt > 1000) ws.Cells[r, 9].Value = "重点"; } // 2) 在第 5 行前插入 2 行,第三个参数让新行复制第 4 行的样式 ws.InsertRow(5, 2, 4); ws.Cells[5, 1].Value = "SO-NEW-01"; ws.Cells[6, 1].Value = "SO-NEW-02"; // 3) 删除第 100 行 ws.DeleteRow(100, 1); // 4) 让公式按新结构重算一次 package.Workbook.Calculate(); package.Save(); }

逻辑说明:InsertRow(5, 2, 4)的三个参数分别是「插入位置」「插入行数」「从哪一行复制样式」,第三个参数不传的话新行是白底无边框的,插在中间会非常突兀。DeleteRow(100, 1)从第 100 行开始删 1 行,下方的行会自动上移。

参数说明:插入和删除行之后,原本引用了下方区域的公式不一定会自动调整,特别是跨 sheet 引用和命名区域引用。Workbook.Calculate()会触发一次全量公式计算,但它是基于 EPPlus 内置的计算引擎,覆盖不了所有 Excel 函数(比如部分动态数组函数),所以我的习惯是:改完先存一份副本,用 Excel 打开确认公式结果对不对,再决定要不要在代码里做二次修正。

回写场景还有个隐性问题:如果目标文件是别人正在编辑的,Save()会抛IOException。这个在下一章的避坑里细说。

4.4 大文件的内存账:EPPlus 不是流式读取器

必须说清楚一件事:EPPlus 打开 xlsx 时会把整个文件的 XML 解压、解析、加载成对象树,全程在内存里。它不是 SAX 式的流式解析,行数上去之后内存占用会明显高于文件体积本身。

// 反面示例:一次性加载 50 万行的宽表 using (var package = new ExcelPackage(new FileInfo(hugePath))) { var ws = package.Workbook.Worksheets[0]; // 此处内存峰值可能是文件体积的若干倍,32 位进程基本必挂 }

正确的应对不是调参数,而是换策略。第一,如果只是取数入库,用分段读取:每次只new ExcelPackage打开文件、读完需要的行区间就释放,配合Skip/Take分批处理。第二,如果字段结构固定、只需要值不需要样式,考虑用更轻的读取方案先把数据抽成 CSV,再用 EPPlus 处理格式。第三,实在要在 EPPlus 里做,尽量在using块内把数据转成List<T>就结束,别把ExcelWorksheet对象往外传,那等于把整个文件钉在内存里。

// 分段处理:每批 5000 行,处理完释放 const int batch = 5000; for (int start = 2; ; start += batch) { using var package = new ExcelPackage(new FileInfo(path)); var ws = package.Workbook.Worksheets[0]; int end = Math.Min(start + batch - 1, ws.Dimension?.End.Row ?? 1); if (start > end) break; var chunk = new List<OrderRow>(); for (int r = start; r <= end; r++) { /* 映射到 OrderRow */ } SaveBatch(chunk); // 落库或写下一段 }

这个写法每次循环都重新打开文件,看起来浪费,但内存峰值被压在单批数据量上,跑几十万行反而更稳。代价是文件被重复解析,总耗时会变长,属于拿时间换内存的典型取舍。

5. 避坑排查:EPPlus 最容易翻车的五个现场

下面五条都是我在真实项目里遇到过、并且复现过的。每条按「现象 → 原因 → 解决」写,对照自己的代码看,能省掉不少排查时间。

5.1 保存时报「文件正由另一进程使用」

现象:本地调试一切正常,部署到服务器后偶发IOException: 文件“xxx.xlsx”正由另一进程使用,因此该进程无法访问此文件。

原因:目标文件被 Excel 客户端打开着,或者上一次请求的ExcelPackage没有释放、文件句柄还挂着。EPPlus 打开文件时用的共享模式不允许其他进程写入,只要有人开着这个文件,Save()必然失败。

解决:分两层。代码层,所有ExcelPackage一律包using,包括异常路径;同时在保存前用FileShare探测一次可写性:

private static bool CanWrite(string path) { try { using var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None); return true; } catch (IOException) { return false; } }

业务层,不要直接往用户正在看的文件上写。改成「写临时文件 → 校验 →File.Replace原子替换」,替换失败时把临时文件留着,报错信息里带上临时路径,用户至少能找到自己那份数据。

5.2 空表读取出 NullReferenceException

现象:读取逻辑上线后,某些文件一进来就抛空引用,堆栈指向ws.Dimension.End.Row。

原因:Dimension在两种情况下是null——工作表完全空白,或者内容被清空但格式还在(某些工具导出的空 sheet 就是这种)。直接链式访问End必然炸。

解决:所有取Dimension的地方统一加判空,封装成一个扩展方法,业务代码里不再直接摸Dimension:

public static (int Rows, int Cols) GetBounds(this ExcelWorksheet ws) { var d = ws.Dimension; return d == null ? (0, 0) : (d.End.Row, d.End.Column); }

拿到(0, 0)就走「文件无数据」的分支,返回明确的业务错误,而不是让空引用冒到上层被当成系统故障。

5.3 日期读出来是一串数字

现象:Excel 里明明显示2024-05-01,代码读出来是45413。

原因:.Value返回的是单元格的底层存储值,日期在 xlsx 里本质是数字加格式;.Text返回的是按格式渲染后的字符串。用.Value读日期列、又没做类型判断,拿到的就是序列号。

解决:按 4.2 里的模式匹配写法处理,DateTime直接用、double走DateTime.FromOADate、string走TryParse。另外注意一个边界:FromOADate对 1900 年 3 月 1 日之前的日期有历史 bug(Excel 认为 1900 年是闰年),业务数据一般不涉及,但做历史档案迁移时会撞上。

5.4 两万行导出跑了十几秒甚至卡死

现象:导出接口响应时间随行数线性上升,两万行要十几秒,五万行直接超时。

原因:三个高频错误叠加——逐格ws.Cells[r, c].Value = ...赋值、循环里设样式、对大区域调AutoFitColumns()。每一次单元格访问都会触发内部索引查找,样式设置还会触发样式表合并,量级一上来就是灾难。

解决:数据用二维数组一次性铺(3.1 的写法三),样式用区域批量设(3.2 的写法),不用AutoFitColumns改用手动列宽。如果表里有公式,把公式赋到整个区域而不是逐格赋,EPPlus 会自动做相对引用偏移。改完之后同样两万行六列,我这边从十几秒降到一秒以内,差别就是这么直接。

5.5 本地能跑,服务器抛 LicenseException

现象:开发机上是老版本 EPPlus(4.x),代码里没有许可声明;发布到新环境时包还原拉到了 5.x 以上,启动即抛LicenseException。

原因:4.x 到 5.x 的许可模型变了,老代码没有设置LicenseContext,新版本运行时校验不通过。这种情况在「本地包缓存有旧版本、CI 环境干净」的组合下特别容易发生。

解决:在项目文件里显式钉死版本,避免不同环境还原出不同主版本:

<ItemGroup> <PackageReference Include="EPPlus" Version="6.2.10" /> </ItemGroup>

同时在程序入口设好许可上下文,并且在 CI 里加一步启动自检——跑一个只创建空ExcelPackage的冒烟测试,把许可问题挡在发布之前,而不是等业务方第一个反馈。

6. 收尾这一手:用模板 + 自检清单把导出做成可交付

写到这儿,前面都是「怎么把数据塞进去」,真正让导出稳定的是最后这一步:别用代码拼表头,用模板。

我的做法是让业务方提供一份他们认可的 xlsx 作为模板,表头、列宽、打印区域、页脚全在里面,代码只负责从第 2 行开始填数据。这样格式调整不需要改代码、不需要重新发版,业务方自己改模板文件就行。

// 打开模板,取第一个 sheet,从第 2 行开始填 using var package = new ExcelPackage(new FileInfo(templatePath)); var ws = package.Workbook.Worksheets[0]; var data = LoadOrders(); var body = new object[data.Count, 6]; for (int i = 0; i < data.Count; i++) { body[i, 0] = data[i].OrderNo; body[i, 1] = data[i].OrderDate; body[i, 2] = data[i].Region; body[i, 3] = data[i].Amount; body[i, 4] = data[i].Qty; body[i, 5] = data[i].Status; } ws.Cells[2, 1, data.Count + 1, 6].Value = body; // 模板里预留的合计行用命名区域定位,避免行数变化后公式错位 var totalRow = data.Count + 2; ws.Cells[totalRow, 4].Formula = $"SUM(D2:D{data.Count + 1})"; package.SaveAs(new FileInfo(outputPath));

这里有个关键点:模板里如果有预留的合计行,填完数据后它会被数据行「推下去」,所以合计公式的位置必须按实际行数算,不能写死。用命名区域(Workbook.Names)定位更稳,模板改了行号也不用动代码。

导出前我固定过一遍下面这张清单,五项都过才发出去:

检查项判定方式漏掉的后果
长编号列格式抽查 18 位订单号是否显示为文本变成1.23E+17,业务方直接退回
日期列类型用 Excel 打开确认是日期不是文本无法排序、无法做透视
空行处理中间空行是否已跳过合计偏小、对账对不上
公式范围合计行公式是否覆盖到最后一行数据少算最后几行,且没人看得出来
文件句柄导出后原文件能否被重命名说明有句柄泄漏,跑几天就锁死

从那以后我每次写导出,都强制走一遍「模板 + 二维数组 + 清单自检」这条路径,哪怕是最简单的十行报表也不省。因为翻车从来不是因为逻辑复杂,而是因为某个 18 位编号变成了科学计数法,然后整批数据被人当成错的。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 5:22:43

大模型参数高效微调全解析:从LoRA到QLoRA的实战指南

很多朋友第一次接触大模型微调时&#xff0c;第一反应都是“把全量参数扔进去&#xff0c;让模型重新跑一遍”。真跑起来才发现&#xff0c;一个70亿参数规模的模型&#xff0c;全参微调光优化器状态就要占掉好几张A100的显存&#xff0c;普通人连门槛都摸不到。这也是参数高效…

作者头像 李华
网站建设 2026/10/23 5:22:31

AI写作提示词5大结构,让文案告别平庸

别再对着对话框敲“你好&#xff0c;帮我写一篇文案”了。我见过太多人第一次用AI写作工具&#xff0c;开场白几乎一模一样&#xff1a;先礼貌问候&#xff0c;再丢一个模糊需求&#xff0c;最后拿到一篇正确但毫无灵魂的“标准答案”。问题从来不在AI&#xff0c;而在你提问的…

作者头像 李华
网站建设 2026/10/23 5:21:47

工业机器人离线编程与仿真:核心原理、选型与落地实战全解析

很多人第一次接触工业机器人离线编程与仿真时&#xff0c;容易把它理解成"在电脑上模拟机器人动一动"&#xff0c;以为这只是一款三维动画工具。实际上&#xff0c;离线编程解决的是现代智能制造里一个非常现实的问题&#xff1a;如何在不占用真实产线、不停机的条件…

作者头像 李华
网站建设 2026/10/23 5:21:09

零成本搭建RAG智能客服:Ollama+Milvus+DeepSeek实战

"零成本"这三个字&#xff0c;放在智能客服项目的语境里&#xff0c;以前基本是句玩笑话。早些年做知识库问答&#xff0c;要么按 Token 付费调大模型 API&#xff0c;要么咬咬牙买卡做微调&#xff0c;再算上工程开发量&#xff0c;一个客服机器人没几万块下不来。但…

作者头像 李华
网站建设 2026/10/23 5:16:55

二手车价格评估API接口设计与Java落地实践

去年真正把一套二手车价格评估API接口从设计到上线跑通之后&#xff0c;我对"精准估值"这四个字的理解彻底变了。以前觉得估个价嘛&#xff0c;无非是查一下同年份同车况卖多少钱&#xff0c;拿个平均数就完了。真到了Java后端要从零实现这套接口的时候才发现&#x…

作者头像 李华
网站建设 2026/10/23 5:16:38

YOLOv10多行为检测实战:从数据集构建到UI部署全流程

在计算机视觉项目里&#xff0c;“一网打尽”式的多行为检测比单一目标检测要麻烦得多&#xff0c;尤其是把吸烟、喝水、手机使用这三类动作放在同一个模型里去识别。这个项目标题其实就已经把技术栈和交付物说得比较清楚了&#xff1a;基于YOLOv10做行为检测&#xff0c;配套的…

作者头像 李华