(轉).NET導出Excel的四種方法及評測

原文鏈接:https://www.cnblogs.com/sdflysha/p/20190824-dotnet-excel-compare.html

.NET導出Excel的四種方法及評測

導出Excel.NET的常見需求,開源社區、市場上,都提供了不少各式各樣的Excel操作相關包。本文,我將使用NPOIEPPlusOpenXMLAspose.Cells四個市面上常見的庫,各完成一個導出Excel示例。然後對其代碼風格和性能做一個橫向比較。最後我將說出我自己的感想。

文中所有的示例代碼可以在這裏下載:
https://github.com/sdcb/blog-data/tree/master/2019/20190824-dotnet-excel-compare

NPOI

NPOI源自於Java寫的Apache POI,目前最新版本是2.4.1NPOI是開源項目,作者是華人,項目地址是:https://github.com/tonyqus/npoi

幾年前大家導出Excel都使用COM,但COM不方便,這個組件的推出無疑彌補了.NETExcel方面組件的空白,大家都說比COM好用。

NPOI還加入了.NET Core Community組織

EPPlus

EPPlus是另一個開源的Excel操作庫,目前最新版本是4.5.3.2Github地址如下:https://github.com/JanKallman/EPPlus

EPPlus僅依賴基礎類庫(BCL),完全沒有第三方包依賴,也是.NET原生庫。

EPPlus只支持導出Office 2007之後的格式,也就是xlsx。這已經是存在12年的格式了,但如果有客戶想要導出xlsEPPlus將不支持。

OpenXML

OpenXMLNuGet包全稱是DocumentFormat.OpenXml:是微軟推出的較爲低層的Excel操作庫,最新穩定版本是2.9.1OpenXML也是開源項目,地址是: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是不開源,付費的庫,但提供無限期的試用,據官方網站顯示,試用版將:

  • 限制打開文件數量100個
  • 限制使用Aspose.Cells.GridWeb功能
  • 生成的Excel將添加如下水印:

但經過我的試用,無論是並行還是串行,都沒找到限制打開文件數量100個的限制。因此,“試用版”對我們的物理限制,就只有這個水印了(當然加了這個水印客戶肯定也不會有好表情😂)。

Excel-COM

COM是隨着Excel安裝而自帶的庫,Excel的包名叫Microsoft.Office.Interop.Excel。本文不會深入解析,具體可以看這篇文檔

我想要多說兩句的是,COMold-fashion(過時)不是沒有原因的,據我所知COM有以下缺點:

  • 調用時會啓動一個進程外的excel.exe,可能因爲它爲是專門爲Office設計的(不是爲.NET集成設計的)
  • 要求目標環境安裝相關軟件,沒安裝將無法運行
  • 顯然也沒辦法跨平臺
  • 使用了大量動態/多參數接口,對開發不是很友好
  • 不像託管內存,COM對資源釋放也有要求,具體參見這篇文章

橫向比較

  NPOI EPPlus OpenXML Aspose.Cells
包依賴 有1個
封裝程度 正常 正常 低層 正常
支持格式 完善 僅xlsx 僅xlsx 完善
開源協議 Apache-2.0 LGPL MIT 不開源
收費類型 開源免費 開源免費 開源免費 試用/付費

評測說明

版本與數據

所有代碼的版本號基於上文中提到的最新穩定版本:

  NPOI EPPlus OpenXML Aspose.Cells
最新版本 2.4.1 4.5.3.2 2.9.1 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;
}

注意:

  1. 我有意使用了反射,這符合我們導出Excel代碼簡單、易學、好用、好擴展的願意;
  2. 我有意使用了泛型T,而不是實際類型,這也讓這些代碼容易擴展;
  3. 裏面的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這個前綴是從JavaPOI庫傳過來的,全稱是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.CellsEPPlus的下標是基於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);
}

注意,Aspose.CellsExcel軟件一樣,提供了Sheet1/Sheet2/Sheet3三個默認的工作表,因此取這三個工作表時,不要創建,而是取出來。

性能測試結果如下:

次數 分配內存 內存提高 耗時
1 404,004,944 3,619,520 3316
2 357,931,648 6,048 2078
3 357,934,744 7,216 2007
4 357,933,376 6,280 2017
5 357,933,360 6,424 2007

Aspose.Cells首次佔用內存385MB,用於3.3秒,後面每次降低爲內存341MB,用時2.0秒。

總結

四種導出Excel庫的橫向評測數據如下,數據取5次數值的內存消耗中位數,百分比以EPPlus的測試數據爲100%基準:

  分配內存 內存佔比 耗時 耗時佔比
基線(僅反射) 9,853,936 1.85% 133 4.82%
NPOI 1,589,237,064 297.83% 10355 375.32%
EPPlus 533,598,440 100% 2759 100%
OpenXML 555,985,936 104.19% 3884 140.78%
Aspose.Cells 357,933,360 67% 2007 72.74%

可以得出以下結論:

  1. Demo基於反射,但反射總損耗的性能不高,內存、耗時均不超過5%;
  2. NPOI的性能表現是所有項目中最差的,每次需要分配1.5GB的內存和超過10秒的耗時;
  3. EPPlus表現不錯,內存和耗時在開源組中表現最佳;
  4. 收費的Aspose.Cells表現最佳,內存佔用最低,用時也最短;
  5. 較爲底層的OpenXML表現非常一般,比EPPlus要差,更不能與收費的Aspose相提並論;

我的感想

在真的願意嘗試一下之前,人們很容易相信自己的直覺。底層庫,通常能帶來更大的可擴展性,能做出上層庫很難做的事來。底層庫有時性能會更快,就像更底層的C/C++比上層的JavaScript更快一樣。但事情也不都如此,如

  • 更高層的React.js能在性能上將較底層的DOM操作比下去
  • 數據庫基於集合的操作也比基於遊標的操作要快得多

在導出Excel這個例子中,我瞭解到Excelxlsx格式是非常複雜的、多個xml的集合。如果基於xml做抽象——也是很正常的做法,拼出6萬/10列的數據,需要至少60萬個xml標籤做拼接,很顯然這需要分配/浪費大量內存,因此性能上不來。

我基於以下幾點無責任猜測:Aspose內部可能沒xml做抽象,而是純數據做抽象(就像React.js那樣),然後再統一寫入到Excel文件。因此性能可以達到其它庫達不到的目標:

  1. Aspose.Cellsxml等實現相關技術隻字未提(可能因爲要支持多種文件格式);
  2. Aspose.Cells是先在內存中創建,再寫入文件/流(NPOI也是);
  3. Aspose.Cells創建Excel時要求客戶直接使用Workbook類(NPOI也是);
  4. Aspose.Cells完全隱藏了Excel的位置(如B3)信息,下標從0開始(NPOI也是)

比較這幾點,NPOI也與Aspose.Cells有幾分相似,但導出不到6MBExcel它內存分配居然高達1.5GB,是後者的444%!畢竟迭代更新了這麼多年了,代碼質量我相信應該沒問題。因此我再次無責任推測:這可能因爲它是從Java那邊移植過來的。

我的選擇/推薦

在我做這個性能評測前,我一直使用的是EPPlus,因爲我不喜歡NPOI有第三方依賴,也不喜歡NPOI那些“XSSF”之類的前綴命名,也顯然不會去費心思寫那麼多費力不討好的OpenXML代碼。

更別提這次評測發現EPPlus的性能確實不錯,唯一的缺點就是它單元格下標從1開始的設計。即便如此,我還是首選推薦EPPlus

近期也經常使用Aspose.Cells這種商業庫,它的功能強大,API清晰好用,這個評測也證明它的性能卓越。除了高昂的價格,沒別的缺點了。乃有錢客戶/老闆的不二之選!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章