简介:这是一份面向C#/.NET开发者的EPPlus操作Excel实例包,定位为xlsx文件写入、读取与修改的入门到进阶参考,适合需要完成数据导入导出、报表生成及日常表格处理的程序员对照学习。压缩包共41个文件,约2.55MB,以6个cs源码文件和1个sln解决方案为主体,配有7个dll依赖、3个exe、若干config/xml/resx等工程与资源文件,以及1个xlsx示例工作簿,可较完整地还原一个Visual Studio示例项目。资源已有3730人学习,说明其在实际开发场景中具有一定参考价值。包内示例围绕ExcelPackage、工作表与单元格操作展开,覆盖批量写入、整行读取、内容更新与保存,并延伸到样式设置、公式和图表等用法,便于读者结合源码与示例文件理解EPPlus的常用API和项目组织方式,并留意资源释放与包对象生命周期,适合作为学习EPPlus API的实操作业底稿。
1. 从一个服务器上跑不起来的导出功能说起
前阵子帮一个做后台管理的朋友收拾导出功能:原本用 COM 调 Excel,本地测试全绿,一部署到服务器就抛0x800A03EC,查了半天才发现那台机器压根没装 Office。换成 EPPlus 之后,同一套导出逻辑不到八十行就跑通了,还顺手把导入和改动两个方向也补齐了。EPPlus 读 写 改 excel 这件事本身不难,难的是它有几个跟直觉反着来的设定:不依赖 Office 也能生成 xlsx、对象模型比你以为的多一层、5.x 之后的授权方式变了、日期和合并单元格各有各的脾气。这篇就把一个能直接跑的 EPPlus 实例从头拆一遍——怎么写、怎么读、怎么在保留原格式的前提下改,以及我在哪几个地方翻过车。适合手里有 .NET 项目、需要把 Excel 当数据出口或导入入口的后端开发。
2. 工程骨架与 EPPlus 对象模型:先搞清楚 Package、Workbook、Worksheet 的层级
动手写代码之前,值得先花十分钟把 EPPlus 的对象模型在脑子里摆正。很多人第一次用会写出package.Workbook.Worksheets[0]然后收工,等到要处理多表、要冻结窗格、要合并单元格的时候,才发现自己一直站在最外面那一层,里面还有两层没进去过。这一章先把工程目录摆出来,再把四层模型和安装配置讲透,后面的读写改都是在这个骨架上长出来的。
2.1 目录结构与依赖清单
我一般不会把 Excel 相关代码散落在 Controller 里,而是单独收一个 Core 项目出来。这样做的直接好处是:Demo、Web API、定时任务都能复用同一份导出逻辑,等到哪天要换库或者换实现,只改一个地方。目录大概是这样:
ExcelKit/ ├── ExcelKit.sln ├── src/ │ ├── ExcelKit.Core/ │ │ ├── Attributes/ExcelColumnAttribute.cs │ │ ├── Internal/ColumnMeta.cs │ │ ├── Internal/CellConverter.cs │ │ ├── Services/IExcelService.cs │ │ ├── Services/ExcelService.cs │ │ └── ExcelKit.Core.csproj │ └── ExcelKit.Demo/ │ ├── Program.cs │ ├── Models/OrderRow.cs │ └── ExcelKit.Demo.csproj └── tests/ └── ExcelKit.Tests/ExcelServiceTests.cs文件清单背后的分工很明确:Attributes放列映射的特性定义,Internal放列元数据解析和单元格类型转换这类纯工具,Services放对外接口和实现,Demo 只负责造数据、调用、看结果。把ColumnMeta和CellConverter单独抽成 internal,是因为它们不该被业务层直接 new 出来用,测试却要能覆盖到,加个InternalsVisibleTo就行。
依赖方面只有两样:目标框架和 EPPlus 本身。目标框架我倾向net8.0,语法上用得到文件作用域命名空间和可空引用类型,写起来干净;如果老项目还在net472,EPPlus 4.x 也能跑,只是语法要退回去。Core 项目不需要任何 Web 相关包,保持纯类库,这样单元测试跑起来不吃环境。
2.2 四层对象模型:ExcelPackage、ExcelWorkbook、ExcelWorksheet、ExcelRange
EPPlus 的层级是严格嵌套的,从外到内一共四层,每一层负责的事情边界很清楚:
| 层级 | 类型 | 职责 | 常见误用 |
|---|---|---|---|
| 第一层 | ExcelPackage | 代表整个 xlsx 文件,负责打开、保存、释放 | 用完不 Dispose,文件句柄泄漏 |
| 第二层 | ExcelWorkbook | 管理所有工作表、计算模式、文档属性 | 直接拿它当表格用 |
| 第三层 | ExcelWorksheet | 一张具体的表,行列、样式、合并、冻结 | 用 0 做索引 |
| 第四层 | ExcelRange | 一个或一片单元格,负责取值和赋值 | 一格一格 new 出来写,性能差 |
ExcelPackage是整个文件的容器,它实现了IDisposable,用using包起来是硬性要求。它内部持有文件流,不释放的话在 Windows 上会表现为「文件正被另一个进程使用」,删不掉也改不了,这个坑后面单独说。
ExcelWorksheet是所有实际操作的落点。取一张表的写法是package.Workbook.Worksheets["明细"]或按索引取,注意索引从 1 开始,不是 0。表名大小写敏感度不高,但重名会抛异常,Worksheets.Add("明细")加第二张同名表会直接报错,所以动态生成表名时要在外面拼个序号。
ExcelRange是第四层,ws.Cells[1, 1]或ws.Cells["A1"]返回的就是它。单个ExcelRange可以代表一个单元格,也可以代表A1:D10这样一片区域,批量赋值、批量设样式都靠它。理解这一点很关键:ws.Cells[1, 1, 10, 4].Style.Font.Bold = true一次调用就改了 40 个格子的字体,而循环写 40 次性能会差一个量级。
ws.Dimensions返回的是已使用区域的外接矩形,读取时用它判断表的边界最省事。但要注意,如果用户在某一行手动设置过格式、清空了内容,Dimensions仍会把那一行算进去,所以读完范围之后还要对每一行做有效性判断,不能光靠边界就认定数据齐整。
2.3 安装、版本选择与 LicenseContext 配置
安装命令本身没什么难度,麻烦在于版本线。EPPlus 4.x 是 LGPL,5.x 之后改成了双许可模式,非商业用途免费、商业用途需要评估授权,同时从 5.0 开始多了一个必须显式声明的LicenseContext。选版本这件事没有标准答案,我的判断标准是:内部工具、个人项目走 4.x 或新版非商用声明都行;对外交付的商业产品,先把授权这件事在立项阶段确认掉,不要等上线前才发现。
# 装到 Core 项目里,不指定版本时默认取最新稳定版 dotnet add src/ExcelKit.Core/ExcelKit.Core.csproj package EPPlus装完之后,新版必须在使用任何ExcelPackage之前把授权上下文设好,否则第一次 new 就抛InvalidOperationException,提示没设置 LicenseContext。这行代码放在哪里是个小设计问题:放在某个业务类里,换个入口忘了设就出事;我一般放在程序集加载或依赖注入注册的入口处,全局只走一次。
using OfficeOpenXml; // 版本差异说明: // 5.x / 6.x 用 LicenseContext 枚举;7.x 之后推荐用 ExcelPackage.License.SetNonCommercialPersonal(...) // 老写法在新版仍可用,只是会有过时告警,不影响编译 ExcelPackage.LicenseContext = LicenseContext.NonCommercial; var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton<IExcelService, ExcelService>();这段代码只有三行有效逻辑,但每一行都有讲究。第一行是全局静态属性,进程内设一次就够,重复设不报错但没必要。第二行之所以要放在builder之前,是因为某些注册代码在容器构建阶段就会触发ExcelPackage构造,顺序反了会偶发失败。第三行把服务注册成Singleton,是因为ExcelService本身无状态,每次导出都 new 一个ExcelPackage即可,不需要 Scoped 带来的额外开销;如果服务里缓存了什么临时文件路径,那就得改成Transient并自己管好清理。
3. 写入链路:从 List<T> 到带样式 xlsx 的完整落地
写入是三条链路里用得最多的,也是新手最容易写成「能出文件但样式一团糟」的地方。这一章按「先定列 → 再填数 → 最后调样式和输出」的顺序走,每个环节给一段能直接抄的代码,顺带说明为什么不用另一种写法。
3.1 用特性标注列,反射生成表头
表头硬编码在循环里是最快的,但只要业务方说要调顺序、改文案、加一列「备注」,你就得翻代码。用一个自定义特性把「列名、顺序、格式」这些信息挂到模型属性上,改需求时只动模型,不动导出逻辑。
using System; namespace ExcelKit.Core.Attributes; [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] public sealed class ExcelColumnAttribute : Attribute { public ExcelColumnAttribute(string header) => Header = header; // 表头文案 public string Header { get; } // 排序权重,越小越靠左 public int Order { get; set; } = int.MaxValue; // 单元格格式,例如 "yyyy-MM-dd"、"#,##0.00" public string Format { get; set; } // 固定列宽,0 表示自动 public double Width { get; set; } }特性定义本身很薄,真正干活的是解析它的那一段。用反射把公共属性扫一遍,过滤掉没打特性的,按Order排好序,缓存成列元数据列表。这里有个性能细节:反射在导出十万行时会成为瓶颈,所以元数据只解析一次,后面按索引直接取属性值。
using System.Reflection; using ExcelKit.Core.Attributes; namespace ExcelKit.Core.Internal; public sealed class ColumnMeta { public PropertyInfo Property { get; init; } public string Header { get; init; } public int Order { get; init; } public string Format { get; init; } public double Width { get; init; } // 同类型只解析一次,避免每次导出都走反射 private static readonly ConcurrentDictionary<Type, IReadOnlyList<ColumnMeta>> Cache = new(); public static IReadOnlyList<ColumnMeta> Resolve<T>() => Cache.GetOrAdd(typeof(T), static t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance) .Select(p => new { Prop = p, Attr = p.GetCustomAttribute<ExcelColumnAttribute>() }) .Where(x => x.Attr is not null) .OrderBy(x => x.Attr!.Order) .Select(x => new ColumnMeta { Property = x.Prop, Header = x.Attr!.Header, Order = x.Attr.Order, Format = x.Attr.Format, Width = x.Attr.Width }) .ToList()); }ConcurrentDictionary那层缓存不是可有可无的优化。一次导出如果按行触发反射取特性,十万行就是几十万次GetCustomAttribute,耗时能到秒级;缓存之后第一次解析完,后面都是字典查找,耗时基本可以忽略。静态字段用static匿名委托(static t =>)而非捕获外部变量,目的是避免闭包分配,这属于顺手就能做到的小事。
模型侧的写法就跟普通 DTO 没区别,只在需要导出的属性上加一行标注。日期和金额要顺手把Format写上,否则 Excel 里显示成45123这种序列号,业务方第一眼就会来问你是不是算错了。
public class OrderRow { [ExcelColumn("订单号", Order = 1, Width = 22)] public string OrderNo { get; set; } [ExcelColumn("下单日期", Order = 2, Format = "yyyy-MM-dd HH:mm")] public DateTime CreatedAt { get; set; } [ExcelColumn("金额", Order = 3, Format = "#,##0.00")] public decimal Amount { get; set; } [ExcelColumn("状态", Order = 4, Width = 10)] public string Status { get; set; } // 没打特性的属性不会进 Excel,适合放内部字段 public long InternalId { get; set; } }3.2 逐格写入与 LoadFromCollection 的取舍
EPPlus 提供了LoadFromCollection,一行代码就能把集合灌进表,看起来非常诱人。它的默认行为是:把DisplayNameAttribute的值当表头,没有就用属性名;true参数表示首行是表头;第三个参数可选表格样式。问题在于它跟我们的ExcelColumnAttribute不通,表头会变成英文属性名,所以实际项目里我更常用「手动写表头 + 逐格赋值」这一套,代价是多写十几行,收益是完全可控。
using OfficeOpenXml; using OfficeOpenXml.Style; using ExcelKit.Core.Attributes; namespace ExcelKit.Core.Services; public sealed class ExcelService : IExcelService { public byte[] Export<T>(IReadOnlyList<T> rows, string sheetName = "数据") { var cols = ColumnMeta.Resolve<T>(); using var package = new ExcelPackage(); var ws = package.Workbook.Worksheets.Add(SanitizeSheetName(sheetName)); // 第一行:表头 for (var c = 0; c < cols.Count; c++) { var cell = ws.Cells[1, c + 1]; cell.Value = cols[c].Header; cell.Style.Font.Bold = true; cell.Style.Fill.PatternType = ExcelFillStyle.Solid; cell.Style.Fill.BackgroundColor.SetColor(Color.FromArgb(0xF2, 0xF2, 0xF2)); cell.Style.HorizontalAlignment = ExcelHorizontalAlignment.Center; } // 从第二行开始填数据 for (var r = 0; r < rows.Count; r++) { for (var c = 0; c < cols.Count; c++) { var cell = ws.Cells[r + 2, c + 1]; cell.Value = cols[c].Property.GetValue(rows[r]); // 日期和金额必须显式设格式,否则 Excel 按序列号展示 if (!string.IsNullOrEmpty(cols[c].Format)) { cell.Style.Numberformat.Format = cols[c].Format; } } } // 冻结首行 + 自动筛选,十万行的表没这两个功能基本没法看 ws.View.FreezePanes(2, 1); if (rows.Count > 0) { ws.Cells[1, 1, rows.Count + 1, cols.Count].AutoFilter = true; } // 列宽:配了固定值就用固定值,没配才自动 for (var c = 0; c < cols.Count; c++) { if (cols[c].Width > 0) { ws.Column(c + 1).Width = cols[c].Width; } } ws.Cells[ws.Dimensions.Address].AutoFitColumns(8, 50); using var ms = new MemoryStream(); package.SaveAs(ms); return ms.ToArray(); } }几个参数值得单独说。FreezePanes(2, 1)的两个数字含义是「冻结到第 2 行、第 1 列之前」,也就是表头行和第 A 列固定住,滚动时不会跑丢;这两个参数是行号和列号,不是行数,写成(1, 0)是不会冻结任何东西的。AutoFilter赋值true会自动识别表头,但前提是区域的第一行确实被当成标题行,区域算错会导致筛选器位置飘到数据中间。
AutoFitColumns(8, 50)的两个参数是最小宽度和最大宽度,单位是字符数。不设上限的话,某一列只要有个超长备注,宽度就会被撑到几百,打印出来直接跨页。设 8 是为了防止「金额」这种短列被压成一条缝。这段代码的顺序也有讲究:先冻结再筛选再调宽,如果先 AutoFit 再写数据,宽度是按空表算的,结果全是一样宽。
SanitizeSheetName是我单独抽出来的小方法,作用是过滤掉 Excel 不允许出现在表名里的字符:\ / ? * [ ] :,同时把长度截到 31 个字符以内。这几种字符在 Excel 里会直接抛异常,而表名往往来自用户输入的分组名,不处理就是一颗定时炸弹。
3.3 多 Sheet 拆分与 MemoryStream 直出
单表导出满足不了真实场景。常见的需求是「按部门拆成多个 Sheet」或者「明细一个表、汇总一个表」,这时候就不能每次new ExcelPackage了,一个包内加多张表才是正解,否则会生成多个文件,前端还得打包下载。
public byte[] ExportByGroup<T>( IReadOnlyList<T> rows, Func<T, string> groupBy, string summarySheetName = "汇总") { using var package = new ExcelPackage(); var cols = ColumnMeta.Resolve<T>(); // 明细按分组拆表 foreach (var group in rows.GroupBy(groupBy)) { var ws = package.Workbook.Worksheets.Add(SanitizeSheetName(group.Key)); WriteHeader(ws, cols); var index = 0; foreach (var row in group) { WriteRow(ws, cols, row, index + 2); index++; } ws.View.FreezePanes(2, 1); } // 汇总表放在最前面,用 SUM 公式引用各明细表 var summary = package.Workbook.Worksheets.Add(summarySheetName, 1); summary.Cells[1, 1].Value = "分组"; summary.Cells[1, 2].Value = "行数"; var i = 0; foreach (var group in rows.GroupBy(groupBy)) { i++; summary.Cells[i + 1, 1].Value = group.Key; summary.Cells[i + 1, 2].Value = group.Count(); } using var ms = new MemoryStream(); package.SaveAs(ms); return ms.ToArray(); }Worksheets.Add(name, position)的第二个参数是插入位置,传 1 表示插到最前面,后面的表自动后移。汇总表用公式跨表引用时,表名里带空格或特殊字符要加单引号,='销售 部'!B2这种写法是合法的,不带引号就报错,所以表名规范化那一步最好把空格也替换掉。
输出环节,package.SaveAs(ms)之后拿ToArray()是最常见的做法,适合几十兆以内的文件。文件再大一些,直接SaveAs(stream)写到响应流里更省内存,但要注意这时候不能再using掉响应流,也不能在保存之后再追加任何东西。接口层返回byte[]的好处是调用方可以是 Web API、可以是控制台、也可以是单元测试,不跟 HTTP 绑死;如果确定只走 Web,返回Stream会更省一次拷贝,这是取舍不是对错。
4. 读取与修改:同一套对象模型的两个方向
写入是往外吐数据,读取和修改是往里灌数据,看起来是两件事,落到 EPPlus 上却共用同一套对象模型——都是先拿到ExcelWorksheet,再定位ExcelRange,区别只在于是赋值还是取值。把这两条链路放在一章讲,是因为它们踩的坑高度重合:表头位置、单元格类型、日期序列号、公式重算,这些在读取和修改里会各出现一次。
4.1 表头定位与列索引映射
用户上传的表格从来不会规规矩矩把表头放在第一行。常见情况是前三行是标题、公司名、导出时间,真正的表头在第四行;更离谱的是表头文案还带空格和换行。硬编码headerRow = 1的代码,换一个客户的模板就崩。
我的做法是在前 10 行里扫描,把每一行跟目标列名做匹配,命中数量最多的那一行认定为表头行。匹配前先归一化:去空格、去换行、全角转半角、转小写。这一步看起来很土,但能挡掉八成的「明明列名一样却匹配不上」。
private static int FindHeaderRow(ExcelWorksheet ws, IReadOnlyList<ColumnMeta> cols) { var lastRow = Math.Min(ws.Dimensions?.End.Row ?? 0, 10); var bestRow = 1; var bestHit = 0; for (var r = 1; r <= lastRow; r++) { var hit = 0; for (var c = 1; c <= ws.Dimensions.End.Column; c++) { var text = Normalize(ws.Cells[r, c].Text); if (cols.Any(x => Normalize(x.Header) == text)) hit++; } if (hit > bestHit) { bestHit = hit; bestRow = r; } } // 至少要命中一半的列,否则认为这个文件不符合模板 if (bestHit < Math.Max(1, cols.Count / 2)) throw new InvalidOperationException("未找到匹配的表头行,请检查模板"); return bestRow; } private static string Normalize(string s) => (s ?? string.Empty) .Replace(" ", string.Empty) .Replace("\n", string.Empty) .Replace("\r", string.Empty) .Replace("(", "(").Replace(")", ")") .Trim() .ToLowerInvariant();ws.Cells[r, c].Text取的是「显示值」而不是「原始值」,这一点在匹配表头时反而更合适,因为格式化后的日期会显示成2024-01-01,而Value拿到的是45292。但要注意Text依赖单元格的格式设置,如果模板里表头是合并单元格,除左上角之外全返回空串,这会影响命中计数,所以「至少命中一半」这个阈值不能定得太高。
找到表头行之后,建一张「列名 → 列号」的字典,后面的每一行都按字典取值。用字典而不是按位置取值,好处是用户把「金额」列挪到「日期」前面也不影响解析,模板改版时只要列名没变,代码一行不用动。
4.2 单元格取值、类型转换与空值兜底
ExcelRange.Value返回的是object,实际类型取决于单元格里到底是什么:数字是double,日期是double(OADate),文本是string,公式没算过是null,错误值是ExcelErrorValue。直接(DateTime)cell.Value是最常见的崩溃来源,因为 Excel 里的日期本质上是数字。
统一走一层转换器,把「原始值 + 目标类型」映射成最终值,所有边界情况集中在一处处理:
public static object ConvertTo(object raw, Type target) { if (raw is null || raw is ExcelErrorValue) return target.IsValueType ? Activator.CreateInstance(target) : null; var underlying = Nullable.GetUnderlyingType(target) ?? target; // Excel 日期是 OADate 双精度数,需要还原 if (underlying == typeof(DateTime)) { return raw switch { DateTime dt => dt, double d => DateTime.FromOADate(d), string s when DateTime.TryParse(s, out var parsed) => parsed, _ => DateTime.MinValue }; } // 金额一律走 decimal,避免 double 精度误差 if (underlying == typeof(decimal)) return Convert.ToDecimal(raw, CultureInfo.InvariantCulture); if (underlying == typeof(string)) return Convert.ToString(raw, CultureInfo.InvariantCulture)?.Trim(); if (underlying.IsEnum) return Enum.TryParse(underlying, Convert.ToString(raw), true, out var e) ? e : Activator.CreateInstance(underlying); return Convert.ChangeType(raw, underlying, CultureInfo.InvariantCulture); }DateTime.FromOADate那个分支是关键。Excel 把 1900-01-01 记作 1,往上累加,所以45292对应的是 2024-01-01。反过来的场景也常见:写入时直接给cell.Value = DateTime.Now,EPPlus 会自动把它存成 OADate,但单元格格式可能仍是「常规」,于是用户看到一串数字——所以 3.2 节里才强制设Numberformat.Format。
decimal走Convert.ToDecimal而不是先转double,是因为金额字段只要过一次浮点就可能出现0.30000000000000004这种尾巴,写进数据库之后对账能对到怀疑人生。Enum分支允许用户填中文枚举名,true参数表示忽略大小写,转换失败返回默认值而不是抛异常,导入场景里一行脏数据不该让整批任务失败。
空值和默认值的策略要在调用方定,不要在转换器里定。转换器只负责「不崩」,至于空字符串算不算无效行、金额为 0 要不要跳过,那是业务规则,放在解析循环里判断更清楚。
public List<T> Import<T>(Stream stream) where T : new() { var cols = ColumnMeta.Resolve<T>(); using var package = new ExcelPackage(stream); var ws = package.Workbook.Worksheets[1]; if (ws.Dimensions is null) return new List<T>(); var headerRow = FindHeaderRow(ws, cols); var map = BuildColumnMap(ws, headerRow, cols); // 列名 -> 列号 var result = new List<T>(ws.Dimensions.End.Row - headerRow); for (var r = headerRow + 1; r <= ws.Dimensions.End.Row; r++) { // 整行空白直接跳过,模板尾部常有一堆空行 if (IsBlankRow(ws, r, map.Values)) continue; var item = new T(); foreach (var col in cols) { if (!map.TryGetValue(col.Header, out var colIndex)) continue; var raw = ws.Cells[r, colIndex].Value; col.Property.SetValue(item, ConvertTo(raw, col.Property.PropertyType)); } result.Add(item); } return result; }new T()泛型约束让属性赋值不用反射构造,col.Property.SetValue是唯一的反射点。IsBlankRow只检查映射到的那几列,不要去查整行所有列,否则模板里带格式的空白列会让判断失效,一行空数据被当成有效行塞进集合。
4.3 打开已有文件原地改值与公式重算
修改跟读取的入口长得几乎一样,区别是构造ExcelPackage时传的是文件路径或文件流,改完调Save()。这一段最容易出问题的不是 API,是公式。
public void UpdateSummary(string filePath, decimal total) { using var package = new ExcelPackage(new FileInfo(filePath)); var ws = package.Workbook.Worksheets["汇总"] ?? throw new InvalidOperationException("缺少汇总表"); ws.Cells["B2"].Value = total; ws.Cells["B3"].Value = DateTime.Now; ws.Cells["B3"].Style.Numberformat.Format = "yyyy-MM-dd HH:mm"; // 依赖公式的单元格必须触发重算,否则保存后值还是旧的 package.Workbook.CalcMode = ExcelCalcMode.Automatic; ws.Calculate(); package.Save(); }ExcelPackage(new FileInfo(path))会把整个文件读进内存,改完Save()再写回去。ws.Calculate()只算当前表,跨表引用要用package.Workbook.Calculate()。EPPlus 自带一个公式计算引擎,SUM、AVERAGE、VLOOKUP、IF这些常用函数都支持,但不是全量支持,比较冷门的函数或者自定义函数会算不出来,结果是#NAME?。
提示:如果模板里有 EPPlus 算不了的公式,别硬算。把公式单元格留着不动,让用户在 Excel 里打开时自动重算,或者改用「写入计算结果 + 旁边保留公式文本」的方式,比强行
Calculate()之后得到一个错误值要安全。
还有一个隐蔽的点:CalcMode是工作簿级别的设置,如果原文件里被设成了Manual,你只改值不重算,保存后打开看到的还是旧数字,但公式栏里显示的公式是对的,排查起来非常费劲。所以改完值之后显式设一次Automatic再调Calculate(),这两步不能省。
4.4 大文件读取的内存取舍
EPPlus 的工作方式是把整个 xlsx 解压、解析、全量加载进内存,一个 10MB 的 xlsx 展开之后占几百兆内存是常态。几千行的业务表完全无感,十万行以上就要开始算账了。
我的经验分界线大概是:五万行以内,正常读,注意别在循环里反复调ws.Cells[r, c].Value之外的重操作;五万到二十万行,改成「只读需要的列 + 及时把结果落库 + 不保留ExcelPackage引用」;再往上,就不该用 EPPlus 硬扛了,上传环节直接引导用户传 CSV,或者用StreamReader逐行读文本自己解析,内存曲线会平得多。
// 大批量导入时,边读边写库,不要先攒成 List 再统一保存 var batch = new List<OrderRow>(2000); for (var r = headerRow + 1; r <= ws.Dimensions.End.Row; r++) { batch.Add(ParseRow(ws, r, map, cols)); if (batch.Count >= 2000) { repository.BulkInsert(batch); // 分批落库,及时释放引用 batch.Clear(); } } if (batch.Count > 0) repository.BulkInsert(batch);分批的意义不只是内存。十万行一次性BulkInsert,事务会开得很大,中间任何一行出错整批回滚,用户重传一次又要等几分钟。按 2000 行一批,出错时能定位到具体批次,也方便做断点续传。
5. 避坑与排查:LicenseContext、合并单元格、日期偏移等五个高频翻车点
这一章是我自己攒的错题本,五条都真实遇到过,每条按「现象 → 原因 → 解决」写。看完能省下的排查时间,大概比前面三章加起来还多。
5.1 抛出 LicenseContext 未设置的异常
现象:本地跑得好好的,代码一行没改,换台机器或者升级了包版本,第一次new ExcelPackage()就抛InvalidOperationException,消息里提到 LicenseContext 没有设置。
原因:EPPlus 5.0 之后引入了授权上下文的概念,必须显式声明用途,否则库拒绝初始化。老项目升版本时,编译能过、单元测试可能也过(因为测试项目里某处设过),但生产入口没设,就只在线上炸。
解决:在进程启动的最早位置设一次,别散落在业务类里。新版如果 API 有调整,按编译告警提示替换成对应的SetNonCommercial...方法。设完之后写一条集成测试,覆盖「不设任何前置条件直接导出」的路径,防止以后重构时把这行删了。
5.2 合并单元格只有左上角有值
现象:读取一张带合并表头的表,第一列能读到「客户信息」,后面几列全是null,导致列映射错位,整批数据串行。
原因:Excel 的合并单元格在文件结构里就是「左上角存值,其余格子为空」,EPPlus 忠实还原了这个事实,不会帮你把值铺开。用Cells[r, c].Value逐格取,自然只能拿到一个。
解决:取值前先判断当前格是否属于某个合并区域,是的话取该区域左上角的值。EPPlus 提供了ws.MergedCells可以枚举所有合并区域,建一个「行号:列号 → 起始格」的索引,取值时先查索引。
// 预处理:把合并区域的每个格子映射到左上角坐标 var mergedMap = new Dictionary<(int Row, int Col), (int Row, int Col)>(); foreach (var address in ws.MergedCells) { var range = new ExcelAddress(address); for (var r = range.Start.Row; r <= range.End.Row; r++) for (var c = range.Start.Column; c <= range.End.Column; c++) mergedMap[(r, c)] = (range.Start.Row, range.Start.Column); } // 取值时统一走这个方法 object GetValue(int row, int col) => ws.Cells[mergedMap.TryGetValue((row, col), out var start) ? start.Row : row, mergedMap.TryGetValue((row, col), out start) ? start.Col : col].Value;写的时候也一样:往合并区域里非左上角的格子赋值不会报错,但值会被静默丢弃,很多人以为是保存失败,其实是写错了位置。
5.3 日期读出来是 45292 这样的数字
现象:导出的表在 WPS 里显示正常,在另一台机器的 Excel 里打开,日期列全是五位数字;或者导入时DateTime.Parse直接抛格式异常。
原因:Excel 内部把所有日期存成从 1900-01-01 起算的天数,显示成什么样完全取决于单元格的数字格式。用Value取值拿到的是double,用Text取值依赖格式设置,格式一旦是「常规」,两者都是数字。
解决:写入时对日期列强制设Numberformat.Format = "yyyy-MM-dd";读取时按目标类型判断,double走DateTime.FromOADate。另外注意 1900 年被 Excel 误当成闰年的历史遗留问题,1900-02-28 之前的日期会整体偏移一天,业务上基本碰不到,但做历史数据迁移时要留个心眼。
5.4 工作表索引从 1 开始,删除行之后索引会变
现象:Worksheets[0]抛ArgumentOutOfRangeException;或者循环里DeleteRow(r)之后,后面的数据少了一半。
原因:EPPlus 沿用了 Excel 的 1-based 索引习惯,集合下标从 1 开始。删除行会让后面的行整体上移,正向循环for (r = 1; r <= lastRow; r++)每删一行就跳过一行。
解决:取表统一用表名而不是索引,表名不存在时抛出的异常信息更明确。删除行要么倒序遍历for (r = lastRow; r >= 1; r--),要么先把要删的行号收集起来,最后一次性调DeleteRow(startIndex, count)批量删,后者性能更好。
5.5 文件被占用,保存时抛 IOException
现象:package.Save()抛「文件正被另一个进程使用」,或者上传的临时文件在using结束后仍然删不掉,日积月累把磁盘塞满。
原因:两个方向。一是ExcelPackage没释放,文件流还开着;二是用户真的在 Excel 里打开了这个文件,Windows 的文件锁不允许写。
解决:所有ExcelPackage一律using,这是底线。保存到用户可能正在打开的位置时,先写临时文件再原子替换,替换失败就退化成「另存为带时间戳的新文件」并提示用户。Web 场景下上传的临时文件,用完立刻删,别指望进程退出时自动清。
var tempPath = filePath + ".tmp"; using (var package = new ExcelPackage(new FileInfo(filePath))) { // ... 改值 package.SaveAs(new FileInfo(tempPath)); } try { File.Move(tempPath, filePath, overwrite: true); } catch (IOException) { // 目标被占用,退化为另存 var fallback = $"{filePath}.{DateTime.Now:yyyyMMddHHmmss}.xlsx"; File.Move(tempPath, fallback); throw new IOException($"原文件被占用,已另存为 {fallback}"); }File.Move(..., overwrite: true)在目标被独占打开时同样会失败,所以catch分支不是保险,是必要的兜底路径。
6. 二次开发:把读写改收敛成一个可替换的导出服务
把 EPPlus 的调用散在十几个 Controller 里,是这类项目最常见的结局。等到要加「导出加个水印」或者「换一个更快的库」,你会发现改动点像蒲公英一样散开。我的做法是先定接口,再谈实现——接口只描述业务语义,不暴露任何 EPPlus 类型,这样上层永远不知道底下用的是哪个库。
public interface IExcelService { byte[] Export<T>(IReadOnlyList<T> rows, string sheetName = "数据"); byte[] ExportByGroup<T>(IReadOnlyList<T> rows, Func<T, string> groupBy, string summarySheetName = "汇总"); List<T> Import<T>(Stream stream) where T : new(); void UpdateSummary(string filePath, decimal total); }接口里刻意没有出现ExcelPackage、ExcelWorksheet,也没有SaveAs这类方法名,唯一的「泄漏」是Stream和byte[],而这两个是 .NET 基础类型,换任何实现都成立。
真正要替换实现时,改造点集中在三处:一是Export里的样式代码,如果新库的样式 API 不一样,只改这一个方法;二是Import里的类型转换,CellConverter是纯函数,跟库无关,可以原样搬走;三是UpdateSummary里的公式重算,这是 EPPlus 的特性,换库时要重新评估。
验证改造是否成功,我一般不看单元测试的覆盖率数字,而是跑三条端到端用例:一是导出 5000 行带日期和金额的数据,用 Excel 打开确认格式和冻结窗格都在;二是拿一份「表头在第 4 行、中间夹着合并单元格」的真实模板做导入,确认能正确解析且不串列;三是改一次汇总值,确认依赖它的公式跟着变了。
| 改造场景 | 改动位置 | 回归验证点 |
|---|---|---|
| 加导出水印 | Export内部,写数据之后 | 水印不遮挡表头、不影响筛选 |
| 换导出库 | ExcelService整体替换 | 三条端到端用例全过 |
| 加字段 | 模型加特性,不动服务 | 新列顺序正确、旧模板仍可导入 |
| 支持 CSV | 新增ICsvService,接口并列 | 同一份数据两种格式内容一致 |
还有个小技巧值得说:给IExcelService加一个装饰器,专门记录每次导出的行数、耗时、文件大小。EPPlus 的性能问题往往不是均匀劣化的,而是某个特定模板、某个超长文本列突然拖慢,有了这层埋点,线上出问题时能第一时间定位到是数据问题还是代码问题,而不是靠猜。
这类工具库的项目,我踩过最贵的一次坑是升级包版本之后没跑端到端用例,单元测试全绿,上线当天才发现授权上下文没设,导出接口全挂。从那以后我每次动依赖版本,都强制走一遍「造一份真实模板 → 导出 → 用 Excel 打开 → 再导回来」的闭环,不管改动多小。希望这些能帮到你,少走一遍我走过的弯路。
本文还有配套的精品资源,点击获取