.NET导出Excel的四种方法及评测

前言
导出Excel是.NET的常见需求,开源社区、市场上,都提供了不少各式各样的Excel操作相关包。本文,我将使用NPOI、EPPlus、OpenXML、Aspose.Cells四个市面上常见的库,各完成一个导出Excel示例。然后对其代码风格和 性能 做一个横向比较。最后我将说出我自己的感想。
文中所有的示例代码可以在这里下载: https://github.com/sdcb/blog-data/tree/master/2019/20190824-dotnet-excel-compare
NPOI
NPOI源自于Java的Apache POI( https://poi.apache.org/ ),目前最新版本是2.4.1。NPOI是开源项目,作者是华人( https://github.com/tonyqus/ ),项目地址是: https://github.com/tonyqus/npoi 。
几年前大家导出Excel都使用COM,但COM不方便,这个组件的推出无疑弥补了.NET在Excel方面组件的空白,大家都说比COM好用。
NPOI还加入了.NET Core Community组织,项目地址是: https://github.com/dotnetcore/NPOI 。
EPPlus
EPPlus是另一个开源的Excel操作库,目前最新版本是4.5.3.2。Github地址是: https://github.com/JanKallman/EPPlus 。
EPPlus仅依赖基础类库BCL,完全没有第三方包依赖,也是.NET原生库。
EPPlus只支持导出Office 2007之后的格式,也就是xls x 。这已经是存在12年的格式了,但如果有客户想要导出xls,EPPlus将不支持。
OpenXML
OpenXML的NuGet包全称是DocumentFormat.OpenXml:是 微软 推出的较为低层的Excel操作库,最新稳定版本是2.9.1。OpenXML也是开源项目,地址是: https://github.com/OfficeDev/Open-XML-SDK 。
从该项目的名字可以看出,OpenXML比较涉及底层,因此很容易令人浮想联翩,感觉它的性能、速度很 可能 是最快的, 但真的如此吗 ?
Aspose.Cells
这是Aspose Pty Ltd公司推出的Excel操作库。它是众多Aspose File Format API产品其中之一。目前最新版本是19.8.0(基于年/月)。Aspose提供了应有尽有的文件格式支持,除了.NET外,Aspose还提供了C++和Java的包。
据我所知Aspose的客户支持服务也不错,客户提出的问题经常可以在下一次发布时解决。
Aspose.Cells是不开源,付费的库,但提供 无限期 的试用,据[官方网站]( https://docs.aspose.com/display/cellsnet/Licensing#Licensing-EvaluationVersionLimitations )显示,试用版将
- 限制打开文件数量100个
- 限制使用Aspose.Cells.GridWeb功能
- 生成的Excel将添加如下水印:

但经过我的试用,无论是并行还是串行,都没找到 限制打开文件数量100个 的限制。因此,“试用版”对我们的物理限制,就只有这个水印了(当然加了这个水印,客户肯定也不会有好表情?)。
Excel-COM
COM是随着Excel安装而自带的库,Excel的包名叫Microsoft.Office.Interop.Excel。本文不会深入解析,具体可以看[这篇文档]( https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/interop/how-to-access-office-onterop-objects )。
我想要多说两句的是,COM的old-fashion(过时)不是没有原因的,据我所知COM有以下缺点:
- 调用时会启动一个进程外的excel.exe,可能因为它为是专门为Office设计的(不是为.NET集成设计的)
- 要求目标环境安装相关软件,没安装将无法运行
- 显然也没办法跨平台
- 使用了大量动态/多参数接口,对开发不是很友好
- 不像托管内存,COM对资源释放也有要求,具体参见[这篇文章]( https://www.breezetree.com/blog/common-mistakes-programming-excel-with-c-sharp )
横向比较
|
NPOI |
EPPlus |
OpenXML |
Aspose |
---|---|---|---|---|
包依赖 |
有1个 |
无 |
无 |
无 |
封装程度 |
正常 |
正常 |
低层 |
正常 |
支持格式 |
完善 |
仅xlsx |
仅xlsx |
完善 |
开源协议 |
Apache-2.0 |
LGPL |
MIT |
不开源 |
收费类型 |
免费 |
免费 |
免费 |
收费 |
评测说明
版本与数据
所有代码的版本号基于上文中提到的最新稳定版本:
包 |
最新稳定版本号 |
---|---|
NPOI |
2.4.1 |
EPPlus |
4.5.3.2 |
OpenXML |
2.9.1 |
Aspose.Cells |
19.8.0 |
数据全部基于我上篇文章使用的6万条/10列的数据,总共数据量19,166 KB。所有数据可以从这里下载: https://github.com/sdcb/blog-data/tree/master/2019/20190821-generate-lorem-data
环境
项目 |
值 |
---|---|
CPU |
E3-1230 v3 @ 3.30GHz |
内存 |
24GB DDR3-1600 MHz (8GBx3) |
操作系统 |
Windows 10 1903 64位 |
电源选项 |
已设置为“高性能” |
软件 |
LINQPad 6.0.18 |
运行时环境 |
.NET Core 3.0-preview8-28405-07 |
注意,LINQPad设置了optimize+,代码都是优化后执行的;代码都指定了Util.NewProcess = true;,确保每次运行都会在新进程中运行,不会互相影响。
我的性能测试函数介绍
IEnumerable<object> Measure(Action action, int times = 5)
return Enumerable.Range(1, times).Select(i =>
var sw = Stopwatch.StartNew();
long memory1 = GC.GetTotalMemory(true);
long allocate1 = GC.GetTotalAllocatedBytes(true);
action();
long allocate2 = GC.GetTotalAllocatedBytes(true);
long memory2 = GC.GetTotalMemory(true);
sw.Stop();
return new
次数 = i,
分配内存 = (allocate2 - allocate1).ToString("N0"),
内存提高 = (memory2 - memory1).ToString("N0"),
耗时 = sw.ElapsedMilliseconds,
}
除了时间,内存占用实际也是非常非常重要、但容易被人忽略的性能指标。大家都以为“内存不值钱”,但——
- 一旦访问量大,内存就会 瞬间 上涨,导致频繁GC,导致性能下降;
- 内存高也会导致服务器分页,这时性能就会急剧下降;
- 吞吐量下降会导致队列排满,此时服务器就会报503等错误,客户就发现服务器“宕机了”。
(提示:除非你的客户 真 的愿意 多 花钱 再 升级一下服务器,否则不要提“内存不值钱”。)
在我的性能测试函数中,使用了如下两个函数来测试内存占用:
- GC.GetTotalAllocatedBytes(true) 获取 分配内存 大小
- GC.GetTotalMemory(true) 获取 占用内存 大小
占用内存 可能会比 分配内存 小,因为存在垃圾回收(GC),但GC会影响性能。
通过调用Measure函数,可以测得传入的action的耗时和内存占用。默认会调用5次,可以从5次测试结果中取出能反映性能的值。
测试基准
string Export<T>(List<T> data, string path)
PropertyInfo[] props = typeof(User).GetProperties();
string noCache = null;
for (var i = 0; i < props.Length; ++i)
noCache = props[i].Name;
for (var i = 0; i < data.Count; ++i)
for (var j = 0; j < props.Length; ++j)
noCache = props[j].GetValue(data[i]).ToString();
return noCache;
}
注意:
- 我有意使用了反射,这符合我们导出Excel代码简单、易学、好用、好扩展的愿意;
- 我有意使用了泛型T,而不是实际类型,这也让这些代码容易扩展;
- 里面的noCache用来规避编译器优化删除代码的行为
测试结果:
次数 |
分配内存 |
内存提高 |
耗时 |
---|---|---|---|
1 |
9,863,520 |
8,712 |
156 |
2 |
9,852,592 |
0 |
138 |
3 |
9,852,592 |
0 |
147 |
4 |
9,873,096 |
9,240 |
136 |
5 |
9,853,936 |
776 |
133 |
可见,基于反射操作6万/10列数据,每次需要分配约9MB内存,但这些内存都会被快速GC,最终内存提高较少。这些使用反射的代码运行耗时在130ms-150ms左右。
各个库的使用和性能表现
NPOI
void Export<T>(List<T> data, string path)
IWorkbook workbook = new XSSFWorkbook();
ISheet sheet = workbook.CreateSheet("Sheet1");
var headRow = sheet.CreateRow(0);
PropertyInfo[] props = typeof(User).GetProperties();
for (var i = 0; i < props.Length; ++i)
headRow.CreateCell(i).SetCellValue(props[i].Name);
for (var i = 0; i < data.Count; ++i)
var row = sheet.CreateRow(i + 1);
for (var j = 0; j < props.Length; ++j)
row.CreateCell(j).SetCellValue(props[j].GetValue(data[i]).ToString());
using var file = File.Create(path);
workbook.Write(file);
}
注意:
- 里面用到了XSSFWorkBook,其中XSSF这个前缀是从Java的POI库传过来的,全称是XML SpreadSheet Format。 这种前缀在NPOI包中很常见。
- XSSFWorkbook提供了bool Dispose()方法,但它未实现(因此千万别调用它):

性能测试结果:
次数 |
分配内存 |
内存提高 |
耗时 |
---|---|---|---|
1 |
1,598,586,416 |
537,048 |
6590 |
2 |
1,589,239,728 |
7,712 |
10155 |
3 |
1,589,232,056 |
-5,368 |
10309 |
4 |
1,589,237,064 |
7,144 |
10355 |
5 |
1,589,245,000 |
9,560 |
10594 |
分配内存稳定在1.48GB的样子,首次内存会提高524KB左右,后面趋于稳定。首次耗时6秒多,后面稳定在10秒多。
EPPlus
void Export<T>(List<T> data, string path)
using var stream = File.Create(path);
using var excel = new ExcelPackage(stream);
ExcelWorksheet sheet = excel.Workbook.Worksheets.Add("Sheet1");
PropertyInfo[] props = typeof(User).GetProperties();
for (var i = 0; i < props.Length; ++i)
sheet.Cells[1, i + 1].Value = props[i].Name;
for (var i = 0; i < data.Count; ++i)
for (var j = 0; j < props.Length; ++j)
sheet.Cells[i + 2, j + 1].Value = props[j].GetValue(data[i]);
excel.Save();
}
注意,不同于NPOI/Aspose.Cells,EPPlus的下标是基于1的(而不是0)。
次数 |
分配内存 |
内存提高 |
耗时 |
---|---|---|---|
1 |
534,970,328 |
156,048 |
3248 |
2 |
533,610,232 |
14,896 |
2807 |
3 |
533,595,936 |
7,648 |
2853 |
4 |
533,590,776 |
4,408 |
2742 |
5 |
533,598,440 |
11,280 |
2759 |
分配内存约508MB,耗时首次稍长,约3.2秒,后面稳定在2.7-2.8秒。
OpenXML
void Export<T>(List<T> data, string path)
using SpreadsheetDocument excel = SpreadsheetDocument.Create(path, SpreadsheetDocumentType.Workbook);
WorkbookPart workbookPart = excel.AddWorkbookPart();
workbookPart.Workbook = new Workbook();
WorksheetPart worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
worksheetPart.Worksheet = new Worksheet(new SheetData());
Sheets sheets = excel.WorkbookPart.Workbook.AppendChild<Sheets>(new Sheets());
Sheet sheet = new Sheet
Id = excel.WorkbookPart.GetIdOfPart(worksheetPart),
SheetId = 1,
Name = "Sheet1"
sheets.Append(sheet);
SheetData sheetData = worksheetPart.Worksheet.GetFirstChild<SheetData>();
PropertyInfo[] props = typeof(User).GetProperties();
{ // header
var row = new Row() { RowIndex = 1 };
sheetData.Append(row);
row.Append(props.Select((prop, i) => new Cell
CellReference = ('A' + i - 1) + row.RowIndex.Value.ToString(),
CellValue = new CellValue(props[i].Name),
DataType = new EnumValue<CellValues>(CellValues.String),
sheetData.Append(data.Select((item, i) =>
var row = new Row { RowIndex = (uint)(i + 2) };
row.Append(props.Select((prop, j) => new Cell
CellReference = ('A' + j - 1) + row.RowIndex.Value.ToString(),
CellValue = new CellValue(props[j].GetValue(data[i]).ToString()),
DataType = new EnumValue<CellValues>(CellValues.String),
return row;
excel.Save();
}
注意,因为`OpenXML`比较偏低层,东西比较复杂,所以我们慢慢说:
- 对于一些对象,它需要创建相应的Part,如WorksheetPart;
- Excel可以使用SharedStringTable来共享变量值,适合相同字符串非常多的场景。 但此示例共享变量值收益很低,但会极大地增加代码复杂性(普通用户可能很难写出),因此本示例未使用SharedStringTable;
- 它基于单元格位置标识,如B3(第三行第二列),因此索引方式比EPPlus/NPOI都要复杂;
- 代码示例中使用'A' + i - 1来计算位置标识,因此这个示例不能用于超过26列(字母数)的数据;
- 代码使用LINQ(而不是循环)来枚举所有行/列,可以让代码在已经非常复杂的情况下,更简洁一点; 经测试,将LINQ改成for循环对性能结果变化影响 极其微小 。
测试结果如下:
次数 |
分配内存 |
内存提高 |
耗时 |
---|---|---|---|
1 |
556,937,896 |
145,832 |
4009 |
2 |
555,981,216 |
312 |
3783 |
3 |
555,985,936 |
2,760 |
3884 |
4 |
555,984,384 |
1,872 |
3869 |
5 |
555,989,120 |
3,880 |
3704 |
内存占用约530MB左右,第一次比后面多1MB的样子,耗时3.7-4.0秒之间。
Aspose.Cells
void Export<T>(List<T> data, string path)
using var excel = new Workbook();
Worksheet sheet = excel.Worksheets["Sheet1"];
PropertyInfo[] props = typeof(User).GetProperties();
for (var i = 0; i < props.Length; ++i)
sheet.Cells[0, i].Value = props[i].Name;
for (var i = 0; i < data.Count; ++i)
for (var j = 0; j < props.Length; ++j)
sheet.Cells[i + 1, j].Value = props[j].GetValue(data[i]);
excel.Save(path);