C#封装EPPlus:实现Excel读写与折线图/曲线图生成 简介C#结合Epplus库操作Excelxlsx的封装资源面向需要报表导出、数据分析及图表展示的.NET开发者覆盖读取、写入与折线图/曲线图生成等高频场景。压缩包共315个文件大小35.67MB以xml、dll、cs源文件为主另含xlsx示例文件、png示意图与txt/说明文档构成“源码-依赖-样例-文档”的完整结构便于直接参考或集成。目前已有131人学习下载。资源重点封装了Excel读写的通用方法可传入文件路径快速访问工作表、行列与单元格也支持多类型数据精确写入图表部分针对趋势展示做了专门封装只需指定数据与图表类型即可生成折线图或曲线图并插入工作表中。同时体现异常处理与扩展性设计遇到文件缺失或格式错误能给出友好提示开发者可在此基础上按业务继续扩展减少重复劳动。 做C#这块时间长了尤其是写过几年上位机和数据管理系统的朋友应该都有一个共同的体会Excel导出、导入这个需求基本是躲不掉的。业务方说“给我导个报表”领导说“把这个数据整理成表格发我”客户说“要把设备采集的数据能导出来分析”。一开始我用CSV糊弄可一旦遇到格式要求、图表要求CSV就完全顶不住了。后来转用EPPlus发现这东西确实好用但用久了又觉得烦——因为每次处理xlsx都要反复写一堆读取循环、样式设置、图表配置代码越写越臃肿而且每个人写的风格还不一样维护起来特别痛苦。这篇博文就是从我自己的项目实践整理出来的我用C#对EPPlus做了一层封装把xlsx的读取、写入以及折线图和曲线图生成统一封装成一套简洁的方法。这样业务代码只关心数据不用关心Excel细节。如果你也在做C#相关的数据导出、报表生成或者想在上位机项目里直接把实时数据画成图表这篇内容应该能给你一个可以直接抄作业的方案。1. 选型EPPlus之前的纠结很多新人拿到“操作Excel”这个需求第一反应是搜“C# Excel库”然后会看到NPOI、Aspose.Cells、EPPlus还有一个老古董Microsoft.Office.Interop.Excel。这几种方案各有各的坑我在踩过一圈之后才锁定EPPlus先说结论再讲理由。1.1 放弃COM组件和NPOI的真实原因先说Microsoft.Office.Interop.Excel。它的本质是调用本机安装的Office软件来操作Excel也就是说目标电脑和服务器必须装着Office。这在开发机上挺方便但部署到客户现场就麻烦了要不要装Office装哪个版本装了之后DCOM权限怎么配服务端跑着跑着Excel进程崩溃卡死怎么办我见过不少项目被这个com组件坑到怀疑人生它非常适合个人桌面电脑上用但放到需要稳定运行的业务系统里基本就是定时炸弹。NPOI是Java POI项目的.NET移植优点是完全免费而且不需要Office环境。但我实际用下来觉得它的API风格太“Java”了写起来啰嗦尤其要生成图表时NPOI的图表支持非常弱基本上要靠手工拼XML那个操作真的是能把人逼疯。如果你的需求只是简单读写表格NPOI还凑合一旦涉及图表、样式、透视表这些高级特性开发效率会直线下降。1.2 EPPlus的核心优势与许可证提醒EPPlus是基于OpenXML协议的.NET库不需要装Excel跨平台API设计得很符合C#开发者的直觉。它内置了图表、数据透视表、样式、公式计算等能力而且性能也不错算是.NET生态里做Excel报表的“六边形战士”。不过必须提醒一句EPPlus从4.5版本开始变更了许可证使用的是Polyform Noncommercial License非商业场景免费商业使用需要购买授权。这是个非常容易踩的合规问题。很多同行早期用4.x版本习惯了升级到5.x/6.x之后突然遇到LicenseException一脸懵。解决方案一般有两个要么在非商业项目里设置ExcelPackage.LicenseContext LicenseContext.NonCommercial要么公司有预算时直接购买商业授权要么继续锁在4.5以下的老版本但这样会失去新特性。我自己的建议是不管项目性质如何开工前先把许可证这条确认清楚免得做到一半被法务或者技术排查找上门。2. 封装设计的整体思路EPPlus本身功能很强大但直接用原生API写业务代码还是会有大量重复。比如读取一个表格要处理空行、格式转换、合并单元格写入一个表格要处理表头样式、列宽、自动筛选生成图表要配数据源、坐标轴、图例。这些逻辑每个项目都要写一遍于是我决定做一层封装。2.1 先把调用方想清楚设计这个封装时我反复问自己一个问题业务程序员拿到这个库他最想写怎样的代码顺着这个思路我抽象出三个核心动作读取给一个文件路径和Sheet名返回DataTable或者List 。写入给一个DataTable或者List 指定文件路径自动建表并写入。画图给一组数据和图表参数在指定Sheet上生成折线图或曲线图。接口定了之后就好办多了。内部实现无论怎么改调用方完全不用关心。比如未来我把底层从EPPlus换成别的库对上层业务代码几乎是无感知的。这里有一个很关键的封装决策所有文件操作都统一用FileInfo而不是字符串路径。原因是EPPlus的ExcelPackage构造函数接受FileInfo它在解析路径、处理相对路径时更规整而且天然支持后续的文件流操作。如果你在封装里到处传字符串路径后面还得自己处理Path.GetFullPath之类的逻辑不如一开始就统一。2.2 核心类与接口约定我定义了一个静态类ExcelHelper主要方法大体是这样public static class ExcelHelper { // 读取返回DataTable public static DataTable ReadToDataTable(string filePath, string sheetName null); // 写入DataTable写入xlsx可指定Sheet名 public static void WriteDataTable(DataTable dt, string filePath, string sheetName Sheet1); // 写入泛型集合写入xlsx public static void WriteListT(ListT data, string filePath, string sheetName Sheet1); // 图表折线图/曲线图 public static void AddLineChart(string filePath, string sheetName, Dictionarystring, Listdouble seriesData, Liststring categories, string chartTitle, bool smooth false, string chartSheetName null); }这只是一个外部形态真正的实现远比这几个方法复杂。比如在ReadToDataTable内部要处理空Sheet、找不到Sheet、单元格类型转换在WriteListT内部要利用反射读取泛型对象的属性名作为表头。这些细节我会在下面几节展开。3. 读取xlsx的细节实现读取是相对基础的一块但越是基础越容易出错。最典型的场景是生产系统导出的Excel里有空行、有合并单元格、有各种日期格式直接按固定列索引读很容易踩到坑。3.1 基础读取从ExcelWorksheet到DataTable用EPPlus读取一个Sheet核心代码如下using OfficeOpenXml; // 5.0及以上版本必须设置许可证上下文 ExcelPackage.LicenseContext LicenseContext.NonCommercial; using (var package new ExcelPackage(new FileInfo(filePath))) { var worksheet string.IsNullOrEmpty(sheetName) ? package.Workbook.Worksheets[1] : package.Workbook.Worksheets[sheetName]; if (worksheet null) throw new Exception($未找到Sheet: {sheetName}); var dt new DataTable(worksheet.Name); var dimension worksheet.Dimension; if (dimension null) return dt; // 第一行作为列名 foreach (var cell in worksheet.Cells[1, 1, 1, dimension.End.Column]) dt.Columns.Add(cell.Text); // 从第二行开始读数据 for (int row 2; row dimension.End.Row; row) { if (IsRowEmpty(worksheet, row, dimension.End.Column)) continue; var dataRow dt.NewRow(); for (int col 1; col dimension.End.Column; col) dataRow[col - 1] worksheet.Cells[row, col].Text; dt.Rows.Add(dataRow); } return dt; }这段代码看起来挺标准但有两个细节要注意。第一worksheet.Dimension如果你不提前判断当Sheet是空的时候直接访问Dimension.End.Row会抛空引用异常。第二读取单元格用了.Text而不是.Value因为.Text返回的是单元格格式化后的字符串比如日期列在Excel里显示成“2024-01-15”.Text就直接拿到“2024-01-15”而.Value拿到的是Excel内部的OADate序列号比如“45221”这种数字对业务方来说完全不友好。3.2 三个最容易翻车的读取场景第一个是空行判断。我见过太多人直接判断第一列是否为空结果数据刚好第一列有空值整行就被跳过了。我的做法是把整行所有列的文本拼起来判断是否全为空private static bool IsRowEmpty(ExcelWorksheet worksheet, int row, int endCol) { for (int col 1; col endCol; col) { if (!string.IsNullOrWhiteSpace(worksheet.Cells[row, col].Text)) return false; } return true; }第二个是日期列。如果Excel里的日期列是真正的日期格式用.Text拿到的字符串格式是“2024/1/15”还是“2024-01-15”取决于单元格的数字格式。更稳妥的做法是在读取前明确约定业务方在Excel里把日期列设置成文本格式或者我们读取后用DateTime.TryParse尝试转换确保拿到的是标准化的DateTime对象。如果你拿到的是OADate序列号记得用DateTime.FromOADate(Convert.ToDouble(rawValue))转换。第三个是合并单元格。Excel的合并单元格值只存在区域左上角的那个单元格里其他区域如果直接按行列去读拿到的都是空字符串。比如产品名称列做了一个从第2行到第5行的合并单元格你遍历到第3、4、5行时读到的产品名称全是空的。解决思路有两种一是用worksheet.Cells[row, col].Merge属性判断是否在合并区域内一旦发现合并就向上找合并区域的起始单元格取值二是读取前先对合并单元格做“向下填充”把值填满整个合并区域。第一种更通用但实现复杂一点。3.3 读取性能大数据量时的优化如果你只是读几百行数据上面那个双层循环完全没有问题。但如果数据量到了几万行、几十万行逐单元格读取就会变得非常慢。我有一次处理一个5万行、30列的生产记录表用逐单元格读法跑了将近1分钟后来做了两层优化使用worksheet.Cells.Value一次性取出整个二维数组在内存里循环避免反复访问Excel对象的开销。关闭事件和屏幕刷新虽然EPPlus不需要屏幕刷新但可以通过package.Workbook.Worksheets级别的操作减少内部开销。优化后同样数据量基本能压缩到2秒以内。所以如果你封装读取方法建议内部先探测数据规模超过某个阈值就切到数组批量读取模式。4. 写入xlsx的细节实现写入比读取更难的一点是你不仅要考虑数据还要考虑格式。因为业务方最讨厌看见一坨没排版的数据表头不加粗、列宽不一致、数字不带千分位这在展示层是灾难。4.1 从DataTable到工作表的快速写入EPPlus提供了一个很省事的扩展方法LoadFromDataTable。它能一次性把整个DataTable写进工作表比逐单元格赋值高效很多。基本代码如下using (var package new ExcelPackage()) { var worksheet package.Workbook.Worksheets.Add(Sheet1); worksheet.Cells[A1].LoadFromDataTable(dt, true); // 设置表头样式 using (var range worksheet.Cells[1, 1, 1, dt.Columns.Count]) { range.Style.Font.Bold true; range.Style.Fill.PatternType ExcelFillStyle.Solid; range.Style.Fill.BackgroundColor.SetColor(Color.LightGray); range.Style.HorizontalAlignment ExcelHorizontalAlignment.Center; } // 自动列宽 worksheet.Cells[worksheet.Dimension.Address].AutoFitColumns(); package.SaveAs(new FileInfo(filePath)); }LoadFromDataTable的第二个布尔参数表示是否把列名写为表头。这里有个小坑如果DataTable的列名是英文比如ProductName而我们需要中文表头“产品名称”直接LoadFromDataTable就不合适了。我的方案是先手动写一行中文表头再从第二行开始逐列赋值或者干脆用反射把实体属性上的[DisplayName]特性读取出来做表头。后者更工程化适合实体类已经定义好的项目。4.2 样式与细节别让报表显得太业余样式这块体面的报表至少要处理三件事。第一表头样式。表头加粗、加背景色、加边框这些都能让表格清晰很多。我习惯把表头背景色设置成浅灰色或者淡蓝色而不是纯黑色因为纯黑底白字打印起来太浪费墨。第二列宽。AutoFitColumns()在英文内容下效果不错但中文场景下经常偏窄尤其是带长文本的列。我在实际项目中很少完全依赖自动列宽而是先AutoFitColumns再对指定列做二次调整比如把“备注”这类列手动设成50把ID列设成8保证表格既不过分拥挤也不至于太松散。第三数字格式。写金额、百分比、小数时如果直接写double原始值Excel会显示一长串比如1234.5678甚至1234.5678000001。正确做法是在写入前用worksheet.Cells[D2:D100].Style.Numberformat.Format #,##0.00设置数字格式。这个事看似很小但对报表的专业度影响极大。尤其要注意浮点精度问题写入前对数据做Math.Round(value, 2)既能保证显示正确也能避免Excel计算时出现0.30000000000000004这种怪异结果。4.3 大数据量写入从几秒到几百毫秒LoadFromDataTable在数据量上万时性能还可以但到10万行以上也会开始变慢。更高效的做法是直接用二维数组赋值给整个Rangevar dataArray new object[dt.Rows.Count, dt.Columns.Count]; for (int i 0; i dt.Rows.Count; i) for (int j 0; j dt.Columns.Count; j) dataArray[i, j] dt.Rows[i][j]; worksheet.Cells[2, 1].LoadFromArrays(dataArray);LoadFromArrays是EPPlus里专门用来批量写二维数组的方法比逐单元格赋值要快一个量级。如果你的数据源不是DataTable而是ListT可以先反射转换成object[,]再走同样路径。另外一个经验是如果是一次性导出大数据量不要边写边设置样式先把所有数据写进去最后统一设置Range的样式否则性能会急剧恶化。5. 生成折线图与曲线图图表部分是很多人的盲区。EPPlus支持很多图表类型但API有点绕尤其在5.x和6.x之间还有差异。我在封装的迭代过程中也被坑过几次下面把核心逻辑说清楚。5.1 图表必须挂在Drawing上EPPlus里的图表不是独立文件而是挂在工作表的Drawings集合里。你可以理解为Excel里的“浮动图形图层”图表和数据可以放在同一个Sheet也可以放在单独的一个Sheet。创建折线图的典型代码是var chart worksheet.Drawings.AddChart(chartSales, eChartType.Line); chart.Title.Text 月度销售趋势; chart.SetPosition(2, 0, 6, 0); // 左上角位置 chart.SetSize(800, 400); var series chart.Series.Add(worksheet.Cells[B2:B13], worksheet.Cells[A2:A13]); series.Header 销售额;AddChart方法接受两个参数图表名称Sheet内唯一和图表类型。eChartType.Line是折线图eChartType.LineMarkers是带数据标记的折线图。Series.Add的第一个参数是Y值区域第二个参数是X轴类别区域。这里有个反直觉的坑图表系列的数据源必须指向工作表中实际存在的单元格区域不能直接传一个List数组。也就是说你要画图数据必须先写进工作表的某个区域然后再通过worksheet.Cells[B2:B13]这种方式告诉EPPlus“从哪块区域取数”。所以封装时我的逻辑是先把数据写到一个隐藏Sheet或者数据区域再创建图表引用它。画完之后这个数据区域可以隐藏掉避免用户看到一堆辅助数据。5.2 折线图和曲线图的真正区别很多人以为“折线图”和“曲线图”是两种不同的图表类型其实在EPPlus里它们都是LineChart。唯一的区别在于系列有没有开启平滑曲线。开启smooth之后原本的折线会变成圆滑的贝塞尔曲线视觉上更柔和常用于趋势类展示。具体代码是在拿到ExcelLineChartSeries之后设置Series.Smooth truevar lineSeries (ExcelLineChartSeries)chart.Series.Add(rangeY, rangeX); lineSeries.Smooth true; // false就是普通折线图换句话说我的封装里加了一个bool smooth参数内部就是把LineChart系列的Smooth属性设一下。这里有个细节AddChart返回的ExcelChart对象Series.Add返回的是ExcelChartSeries需要把它强制转换成ExcelLineChartSeries才能访问Smooth。如果类型不匹配说明你创建图表时用的枚举类型不对。5.3 图表参数的可配置化实际业务场景中图表的标题、X轴名称、Y轴名称、图例位置每个项目要求都不一样。我在封装里把这些都作为可选参数暴露出来。例如chart.XAxis.Title.Text 月份; chart.YAxis.Title.Text 金额; chart.Legend.Position eLegendPosition.Right;还有个容易被忽略的地方是网格线。默认生成的图表自带横向网格线做趋势图还好但如果客户要求简洁风记得把网格线关掉chart.YAxis.MajorGridlines.Fill.Color Color.Transparent;图表位置和大小的设置也要注意。SetPosition和SetSize在EPPlus里是按像素算的SetPosition(row, rowOffsetPixels, col, colOffsetPixels)表示图表左上角距离某行某列交点的偏移量。如果你希望图表完全覆盖在某几个固定单元格区域还得按单元格的宽高估算偏移这个在封装阶段至少要做到“能放对位置、能被用户拖动微调”不用追求像素级精确。6. 常见异常与掉坑实录这一节是我最想写的。很多坑网上文档里根本不提只有实际做了才碰得到。整理成速查表方便你写代码的时候对照。6.1 LicenseExceptionEPPlus 5的许可证异常这是最常见的异常。现象是代码运行到new ExcelPackage()时直接抛LicenseException提示需要设置LicenseContext。网上很多老教程没有这一行因为它们在写4.x版本。解决办法很简单ExcelPackage.LicenseContext LicenseContext.NonCommercial;这行要在创建ExcelPackage实例前执行。我建议在封装类的静态构造函数里设置一次而不是在每个方法里重复写。如果你用的不是最新包还有一种做法是写配置文件但静态构造函数显式赋值最直观排查起来也最快。6.2 生成的文件Excel打开提示损坏这个问题我排查过很久最终发现原因多种多样但最常见的有三类。一是文件路径的扩展名和实际内容不一致比如保存的文件扩展名是.xls但内容实际是xlsx格式Excel打开时就会提示“文件格式与扩展名不匹配”。二是写入图表时chart.Series.Add指向的数据区域不存在或者引用错误导致生成的OpenXML内容异常。三是保存过程中有异常发生但没有正常关闭文件流文件没有完整写入。这个问题的排查思路是用记事本或者解压工具直接打开生成的xlsx文件看看[Content_Types].xml和xl/charts/目录下的内容是否完整。如果对OpenXML不熟也可以写一个自动校验的小逻辑生成后用ExcelPackage重新打开一次文件能打开就说明结构基本没问题。6.3 图表不显示或只有坐标轴没有线出现这种情况九成都是数据区域引错了。EPPlus的图表引用单元格区域时字符串要传绝对引用比如Sheet1!$B$2:$B$13或者直接用worksheet.Cells[B2:B13]。如果传成相对引用B2:B13某些版本下不会生效图表会显示空白。另外如果数据源区域里包含空单元格折线图会在空值处断开。如果需要连续曲线要么把空值用#N/A代替Excel图表默认忽略#N/A不画线要么在写入数据时对空值做填充。6.4 性能问题导出10万行卡死EPPlus虽然性能不错但如果你用逐单元格组合样式10万行能写到你怀疑人生。我踩过最大的坑是在循环里给每个单元格设置边框和字体结果导出10000行用了将近3分钟。后来改成批量设置Range样式整个操作缩短到3秒以内。所以性能优化的核心原则就是能用Range批量操作绝不逐单元格操作。包括样式、数字格式、列宽都要合并成一个Range统一设置。另外如果你的数据源是DataTable最好把DataTable中的ColumnName和实际Excel列对应关系先处理好避免写入时做二次映射否则表格大了之后光是映射关系就够你折腾的。6.5 跨平台部署时的注意事项EPPlus是纯托管库部署到Linux和Docker容器里没有问题不需要安装Excel。但有两点要注意第一在Linux环境操作高并发的Excel生成时要注意临时目录的权限EPPlus在写入大文件时会用到临时文件/tmp目录没权限就报错。第二文件路径分隔符不要硬编码Windows的\用Path.Combine或/否则在Linux上会找不到文件。7. 封装库的后续扩展思路根据我这段时间的使用体验这个封装库在实际项目中还可以继续演进。目前我新增的一个能力是模板化导出先创建一个带好样式、图表占位、公式的Excel模板文件然后EPPlus打开模板用真实数据填充指定位置。这种方式特别适合周报、月报这类固定格式的场景比代码里写死样式要灵活得多前端做好Excel模板后扔给后端填数效率和美观度都能兼顾。还有一个方向是支持多Sheet报表。比如一个数据分析报告里第一页放运营汇总数据第二页放明细第三页放趋势图。目前的封装单方法处理单Sheet后续可以考虑增加一个“报表描述对象”把页面结构、数据源、图表位置统一描述然后一键生成整个工作簿。这种改进对调用方来说几乎是零学习成本。最后再分享一个小技巧因为EPPlus的核心操作都是围绕ExcelPackage展开的封装里一定要保证using或者Dispose正确否则文件流不释放生成完文件还处于占用状态后面再读取就会报“文件被另一个进程使用”。我自己的做法是统一走using块并且在写文件之前先判断目标文件是否存在存在则备份重命名避免覆盖失败把原文件搞坏。这一套封装用下来最大的感受就是Excel操作并不难难的是把各种边界情况都处理好。如果你们项目里也经常被Excel需求和图表需求反复折腾建议早点做一层这样的封装把这篇文章里的几个坑提前规避掉能省下不少排查和加班的时间。本文还有配套的精品资源点击获取