C#用Epplus封装Excel读写与图表生成的完整实践
简介在.NET项目开发中Excel文件的读取与图表生成是常见需求为此面向C#开发者封装了一套基于Epplus库的工具涵盖xlsx文件读取、写入以及折线图、曲线图生成等核心操作适合处理批量报表和数据可视化场景。压缩包共315个文件大小约35.67MB包含C#源码、解决方案与工程文件、Epplus相关动态链接库、示例xlsx和使用文档其中XML与TXT多用于配置说明整体结构清晰便于定位封装类与测试入口。目前已有131人学习浏览适合希望直接复用Excel操作代码、减少重复开发的.NET程序员。这套封装提供完整的类库与可运行示例不仅支持按路径读取工作表数据、将数据对象写入单元格还给出了折线图与曲线图的生成方法、异常处理机制并提醒不同版本Epplus存在API差异及对.NET运行环境的依赖拿到后既可以集成到实际业务系统也能参照源码自行扩展用于报表导出、数据分析或趋势展示等场景。 做C#的人十有八九都要和Excel打交道。早几年我在项目里用的是NPOI后来被Epplus吸引过去最大的感受就是API顺手——ExcelPackage直接对应一个xlsx文档Worksheet、Cells、Range的对象模型很直观样式、图表都挂在对应的对象上不用像NPOI那样在两个截然不同的APIHSSF/XSSF之间来回切换。这段时间我把常用的xlsx读取、写入、折线图和曲线图生成都封装成了一个工具类用到了报表导出、上位机数据回放、运营数据周报这几个场景里基本是拿来即用。这篇就完整分享一下这套封装的思路和代码也把许可证明坑、图表不显示数据这些问题列出来给正在做类似功能的C#开发者一个参考。1. 先理清需求为什么封装为什么选Epplus1.1 对比一圈之后Epplus赢在对象模型做Excel读写C#生态里有几条路NPOI、COM组件、ClosedXML还有Epplus。NPOI功能全但操作偏底层读写xls和xlsx要记两套API生成图表要手工维护Drawing对象代码量大COM组件是最古老的方案服务器上必须装Office并发一高就抛各种奇怪异常权限问题能折腾一整天我早就放弃了ClosedXML的API也算干净但在图表的控制粒度上不如Epplus做复杂报表时经常要绕路。Epplus的优势在于它把xlsx的zipXML结构完全封装在对象模型里ExcelPackage代表整个文件Workbook是工作簿Worksheets是工作表集合每个sheet下面有Cells和Drawings样式挂在Cell的Style上图表挂在Drawings下。这种层级结构和你打开Excel看到的界面几乎一一对应学习成本低写出来的代码也容易维护。而且它对.NET Core的支持很成熟我在上位机项目里用.NET 6调用没有任何问题。注意Epplus从5.0版本开始修改了许可证协议这是很多人入坑后才发现的雷后面单独讲。1.2 封装层的职责划分我封装这套工具类的目标很简单调用方只传路径和DataTable不碰任何Excel底层概念。整个工具类拆成三大块读取接受文件路径、sheet名返回DataTable调用方拿到的就是纯数据。写入接受DataTable、文件路径自动生成带表头样式的xlsx。图表在已有xlsx上追加折线图或曲线图指定数据区域和图表标题即可。设计上我倾向于全静态方法、无状态避免在报表服务里频繁new对象造成GC压力。所有ExcelPackage都用using包裹确保文件句柄及时释放。异常不吞直接抛给上层由业务层统一处理工具类只负责正确完成Excel层面的事。2. 读写xlsx的核心关键点2.1 许可证是第一个要解决的问题Epplus在4.5及之前是LGPL协议可以免费商用5.0开始换成了Polyform Noncommercial协议个人和非商业用途免费商业用途需要付费购买授权。这个变更让很多老项目升级时直接踩雷——一旦升级到5.0以上代码里会抛LicenseException。如果你是在自己电脑上做工具或者在评估技术选型建议按这个思路处理个人工具、内部非营利项目用5.0启动时声明NonCommercial即可。商业项目、预算充足购买Epplus商业授权获得后续版本更新和技术支持。商业项目但暂时没有预算继续锁在4.5版本功能足够稳定只是缺少新特性。不同版本设置许可证的API有差异5.x和6.x是这个写法ExcelPackage.LicenseContext LicenseContext.NonCommercial;EPPlus 7.x改成了更明确的授权设置方式需要传使用者的名字或组织名ExcelPackage.License.SetNonCommercialPersonal(你的名字);配置放在程序入口或静态构造函数里保证整个进程内只设置一次。我见过有人把这段写在每个方法里虽然不出错但很冗余。2.2 读取xlsx不要逐格去拿Valuexlsx本质是一个zip压缩包里面装着一堆xml文件Epplus替我们把解压、解析、缓存都做了。读取时的性能关键是不要逐格访问worksheet.Cells[row, col].Value——这个属性背后有定位和类型转换的开销数据量大时会明显变慢。更高效的做法是先用sheet.Dimension拿到实际数据区域然后一次性把整块区域的值取出来。Dimension对象会告诉我们数据范围用它代替UsedRange更可靠因为UsedRange在某些情况下会把只设置了格式但没有数据的行列也算进去。还有一个容易被忽略的点Cells[row, col].Value拿到的是原始值可能是double、string、DateTime而.Text拿到的是经过Excel格式化之后显示出来的字符串。判断单元格是否为空时要用Value null不能用Text去判断因为一个格式为0.00%的空单元格Text可能显示成0.00%会误导你。2.3 写入xlsx表头样式和数据填充分开做Epplus写入最简单的方式是用LoadFromDataTable(dt, true)一行代码就能把DataTable灌进工作表但这样生成的表头没有任何样式列宽也不自动调整直接交付的话观感很差。我的做法是拆成两步第一步手工写表头。遍历DataTable的Columns给每个单元格赋值把字体加粗、加上背景色和边框。第二步写数据体。数据量不大的时候逐格循环没问题但数据量达到几万行时强烈建议把数据转成二维数组一次性赋给一个矩形区域速度差别非常明显。日期列是另一个高频坑。DataTable里的DateTime类型直接赋值后xlsx里可能显示成一串数字。正确做法是单独把日期列的区域设上Numberformatworksheet.Cells[2, 3, rowCount 1, 3].Style.Numberformat.Format yyyy-mm-dd HH:mm:ss;另外写完后调用AutoFitColumns()能自动调整列宽但要注意对超长文本列自动列宽计算会消耗一些时间如果报表列很多可以只对前几列调AutoFit其余列手动指定固定宽度。3. 封装类完整实现3.1 读取方法文件路径sheet名返回DataTable我的读取方法签名是ReadToDataTable(string filePath, string sheetName null, bool hasHeader true)sheetName为空时默认取第一个工作表hasHeader控制是否把第一行当表头。public static DataTable ReadToDataTable(string filePath, string sheetName null, bool hasHeader true) { using var package new ExcelPackage(new FileInfo(filePath)); var sheet string.IsNullOrEmpty(sheetName) ? package.Workbook.Worksheets.First() : package.Workbook.Worksheets[sheetName]; if (sheet.Dimension null) return new DataTable(); var dt new DataTable(); int startRow hasHeader ? 1 : 0; int rowCount sheet.Dimension.End.Row; int colCount sheet.Dimension.End.Column; for (int col 1; col colCount; col) dt.Columns.Add(hasHeader ? sheet.Cells[1, col].Text : $Column{col}); var values sheet.Cells[sheet.Dimension.Address].Value as object[,]; for (int row 0; row rowCount - startRow; row) { var dr dt.NewRow(); for (int col 0; col colCount; col) dr[col] values[row startRow, col] ?? DBNull.Value; dt.Rows.Add(dr); } return dt; }这段代码里有几个细节用sheet.Dimension.Address一次性取整块Value转成object[,]后再遍历比逐格访问快很多空单元格用?? DBNull.Value转成DBNull这样后续做数据库操作时不会因为null引发类型判断问题列名用.Text而不是.Value是为了保证表头是字符串。如果原始表格第一行就是数据把hasHeader传false列名自动编号。3.2 写入方法DataTable转xlsx带样式和自适应列宽写入方法我做了两个重载一个只写数据一个支持指定工作表名。核心实现如下public static bool WriteDataTable(DataTable dt, string filePath, string sheetName Sheet1) { if (dt null || dt.Columns.Count 0) throw new ArgumentException(DataTable不能为空); using var package new ExcelPackage(); var sheet package.Workbook.Worksheets.Add(sheetName); for (int col 0; col dt.Columns.Count; col) { var cell sheet.Cells[1, col 1]; cell.Value dt.Columns[col].ColumnName; cell.Style.Font.Bold true; cell.Style.Fill.PatternType ExcelFillStyle.Solid; cell.Style.Fill.BackgroundColor.SetColor(Color.LightGray); cell.Style.HorizontalAlignment ExcelHorizontalAlignment.Center; } if (dt.Rows.Count 0) { object[,] data new object[dt.Rows.Count, dt.Columns.Count]; for (int row 0; row dt.Rows.Count; row) for (int col 0; col dt.Columns.Count; col) data[row, col] dt.Rows[row][col] ?? ; sheet.Cells[2, 1, dt.Rows.Count 1, dt.Columns.Count].Value data; } sheet.Cells[sheet.Dimension.Address].AutoFitColumns(); package.SaveAs(new FileInfo(filePath)); return true; }把DataTable转成object[,]再整块赋值这一步是写入性能的关键。我曾经在一个5万行、30列的报表上对比过逐格赋值耗时接近40秒改成整块赋值后压到2秒以内差距非常明显。表头样式里我用了灰色填充和加粗居中对齐满足大多数报表场景你也可以按需扩展成参数配置。3.3 折线图与曲线图生成一个方法搞定Epplus的图表功能在Worksheet.Drawings下调用AddChart可以创建Chart对象然后通过Series.Add绑定数据区域。我封装的方法支持折线图和带平滑效果的曲线图本质上是同一个方法加了一个布尔参数。public static void AddLineChart(string filePath, string sheetName, string chartTitle, string categoryRange, string valueRange, bool smooth false) { using var package new ExcelPackage(new FileInfo(filePath)); var sheet package.Workbook.Worksheets[sheetName]; var chart sheet.Drawings.AddChart(chartTitle, eChartType.LineMarkers); chart.Title.Text chartTitle; chart.SetPosition(1, 0, 8, 0); chart.SetSize(900, 450); var series chart.Series.Add(valueRange, categoryRange); series.Header chartTitle; if (series is ExcelLineChartSeries lineSeries) lineSeries.Smooth smooth; chart.XAxis.Title.Text X轴; chart.YAxis.Title.Text Y轴; chart.Legend.Position eLegendPosition.Bottom; package.Save(); }调用示例ExcelHelper.AddLineChart(report.xlsx, Sheet1, 近7日访问趋势, Sheet1!A2:A8, Sheet1!B2:B8, smooth: true);几个关键参数我解释一下。SetPosition(row, rowOffset, col, colOffset)决定图表左上角的锚点位置注意前两个参数是相对行的后两个是相对列的SetSize(width, height)是像素尺寸。Series.Add的第一个参数是值区域第二个参数是X轴分类区域这个顺序很多人第一次用会搞反结果图表只有一条折线X轴却是1、2、3的默认序号。折线图和曲线图的区别在于smooth参数。普通折线图是直线连接数据点数据波动大时看起来比较生硬把Smooth设为trueEpplus会生成贝塞尔平滑的连接曲线视觉上更柔和。我一般在展示趋势、曲线拟合这类场景用平滑模式在看精确数值对比的场景保留折线模式。如果你想用不带数据标记点的纯折线把eChartType.LineMarkers换成eChartType.Line即可。4. 实测过程中遇到的坑4.1 LicenseException升级之后直接崩有次我把项目的Epplus从4.5升到5.0启动后报表接口立刻抛异常错误信息指向许可证未设置。当时很懵因为代码完全没变后来查文档才发现是许可证策略变了。解决办法就是在程序启动时设置ExcelPackage.LicenseContext。值得提醒的是如果同一个进程里有多处入口比如Windows服务的多个线程最好把许可证设置放在static构造函数或Main方法开头确保不会有线程先于设置执行Epplus代码。对于ASP.NET Core项目可以放在Program.cs里或者在封装类的静态构造函数里设置。4.2 图表是空的或数据点全挤在左侧这个问题我排查过好几次常见原因有三个。一是Series.Add的参数顺序反了。Epplus要求第一个参数是数值区域第二个是分类区域。如果你第一个参数传的分类区域图表会把这个区域的值当成Y轴数据结果自然不对。二是区域引用格式不对。图表内部会解析区域字符串如果工作簿里有多张工作表区域字符串必须带上工作表名和单引号格式是Sheet1!A2:A8少了工作表名有时能解析有时会静默失败。三是数据区域包含空行。如果你的数据范围从A1写到了A10但实际只有5行有值剩余单元格是空字符串或公式残留图表会把这些空值当成0导致折线在尾部坍缩到0。解决办法是动态计算实际数据范围只把有数据的区域传给图表。4.3 写入大数据量时慢得离谱前面提到过用整块赋值代替逐格循环是最有效的优化手段。除此之外还有几个实战技巧写入前把工作簿的计算模式改成手动避免期间触发公式重算如果数据源是DataTable或List直接用LoadFromCollection或LoadFromDataTable再辅以样式批处理比手写循环更省事保存时用package.SaveAs到本地路径避免一直持有Stream句柄导致内存占用上涨。我实测过一份10万行数据的xlsx用object[,]整块赋值加SaveAs总耗时稳定在3到5秒完全在可接受范围。如果你遇到更极端的性能瓶颈可以考虑先写CSV再用程序转xlsx但那属于另一个话题了。4.4 把xlsx当文本文件读的乌龙这是团队里真实发生过的事有同事图省事直接用File.ReadAllLines(path)去读一个.xlsx文件结果输出一堆乱码。xlsx是zip压缩包里面是XML不是纯文本。所以封装的第一步就应该做文件格式校验扩展名必须是.xlsx而且不要用StreamReader等文本读取方式。同理写文件时也要注意目标目录是否存在否则SaveAs会抛DirectoryNotFoundException这些问题放到一个工具类里集中处理后调用方就不容易踩了。最后再分享一个收尾的小习惯这一套封装我用了快两年最大的体会是工具类一定要追求调用方无感知。后来我把生成好的xlsx改为直接返回byte[]让上层决定是落盘还是走HTTP下载报表服务的扩展性好了很多也不再被临时文件的清理逻辑纠缠。如果你也打算在项目里引入Epplus建议一开始就把读取、写入、图表这三件事拆清楚后续接新需求会从容很多。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →