技術文章

在 Delphi 中以串流方式讀取大型 XLSX 檔案而不載入記憶體

包含百萬列與十幾個欄位的試算表,是資料庫報表作業中非常普遍的匯出結果。如果以一般的方式開啟,也就是將整個活頁簿載入到 TXLSWorkbook 中,該處理程序就必須在執行第一行業務邏輯之前,將這千百萬個儲存格全部具現化為作用中的物件。磁碟上的檔案可能是六十 MB 的壓縮 XML。它展開成的物件樹可能會大上幾倍,並且因為該模型在設計上是隨機存取的,所以全部內容都必須同時常駐在記憶體中。對於一份您只打算從頭讀到尾然後就丟棄的報表來說,這是在您根本不需要的結構上耗費了大量的記憶體。

還有第二種處理同一檔案的方式。這方式不是建立模型,而是僅單向向前掃描工作表的 XML,一次處理一個儲存格,並在您檢查過後讓每個儲存格流過。不會有任何資料累積。無論工作表有一千列還是一千萬列,記憶體佔用都能保持近乎恆定,因為讀取器除了目前正在解析的部分和幾個小型的查詢表之外,不會保留任何內容。這正是 HotXLS 直接讀取器(direct reader)的運作方式,本文的其餘部分將探討它為何能保持小巧,以及它能為您帶來什麼優勢。

為何記憶體內模型無法擴展

XLSX 檔案是依據 ECMA-376 規範所描述的 XML 封裝 ZIP 壓縮檔。每個工作表都有自己的獨立部分,即 xl/worksheets/sheetN.xml,而其中每一列都是一個包含 <c> 儲存格元素的 <row> 元素。常規的載入路徑會讀取該部分,並為每個儲存格建構一個可定址的物件,這樣您之後就可以呼叫 Cells[12345, 7] 並在恆定時間內獲得結果。隨機存取正是活頁簿模型的意義所在,這也正是讓編輯、公式評估和設定樣式變得方便的原因。

代價是隨機存取要求所有內容必須同時存在。您無法對一個僅部分建置的結構進行索引。因此,完整載入的峰值記憶體是儲存格數量的函式,而在擁有數百萬個已填入儲存格的工作表上,這個函式所達到的數值,會是您的服務不樂見的,特別是當有數個這樣的任務在一台共用機器上同時執行時。當您實際需要的存取模式是循序時,為隨機存取付出代價,等同於為您不會使用的功能買單。

不建構節點樹的單向 SAX 掃描

直接讀取器會開啟 ZIP 封裝,並使用 SAX 風格的提取解析器(pull parser)來走訪每個工作表部分。這裡的 SAX 意味著解析器會在遇到解析事件時(如起始元素、文字段、結束元素)進行報告,然後繼續前進。它不會在後台保留任何節點樹。讀取器透過 r 屬性追蹤目前的列與欄,隨著事件的到來,收集儲存格的類型、樣式索引、值以及公式文字,而當看到結尾的 </c> 標籤時,它會發射一個儲存格並將其遺忘。下一個儲存格則會重複使用相同的一組區域變數。

因為儲存格之間不保留任何內容,記憶體佔用不會隨著儲存格數量的增加而成長。這是個值得保留的特性。一張兩百列的工作表和一張兩千萬列的工作表,在讀取器上花費的常駐記憶體是一樣的,它們之間的差異僅在於掃描執行所需的時間。您放棄了隨機存取這項模型的亮點功能,作為回報,您得到了一個不受儲存格數量突破的記憶體上限。

什麼會常駐,以及為何是這兩個部分

掃描並非完全無狀態的,而其中的例外情況很有啟發性。有兩個小表格必須在整個過程中保留在記憶體內,因為沒有它們,單一儲存格本身並未攜帶足夠的資訊供解釋。

第一個是共用字串表(shared string table)。在 SpreadsheetML 中,文字儲存格並不儲存其自身的文字。它帶有 t="s" 以及一個數字酬載,該酬載是指向 xl/sharedStrings.xml 的索引,這是一份經過重複資料刪除處理、包含活頁簿中每個不同字串的單一清單。對於相同標籤在數千列中重複出現的檔案來說,這是一個很好的空間交換,但這意味著讀取器必須預先載入該字串表並保持其常駐,因為任何工作表中任何位置的儲存格都可能參考其中的任何條目。該表的大小取決於相異字串的數量,而非儲存格數量,因此即使在巨大的工作表上,它的大小也依然適中。

第二個是來自樣式部分的數字格式對應。數字儲存格和日期儲存格在傳輸位元組上是完全相同的:兩者都是純數字,因為在 SpreadsheetML 中,日期只是一個序列的日數。唯一區別它們的是儲存格的樣式,該樣式透過 xl/styles.xml 中的 cellXfs 指向數字格式的 id。為了將日期報告為日期而非原始的序列號,讀取器會載入該樣式至格式的對應表並保持常駐。檔案中的其他所有內容,即構成大部分位元組的實際儲存格資料,都會以串流方式流過而不被儲存。

每個儲存格皆報告一種種類與值

每個發射出的儲存格都會以 TXLSDirectCell 記錄的型式到達。它攜帶工作表索引和名稱、以 1 為基底的列與欄、語意的 Kind、作為 VariantValue、沒有前導等號的 Formula 文字以及原始的 StyleIndex。該種類為 xdkNumberxdkStringxdkBooleanxdkDatexdkError 其中之一,因此您可以針對儲存格代表的意義進行分支處理,而不需要從屬性重新推導。公式儲存格會報告其快取結果的種類,並在旁邊附上公式文字,因此一個計算出來的總數會作為一個數字傳遞過來,同時還會告訴您它是如何產生的。

type
  TReportScan = class
    procedure OnCell(Sender: TObject; const Cell: TXLSDirectCell;
      var Abort: Boolean);
  end;

procedure TReportScan.OnCell(Sender: TObject; const Cell: TXLSDirectCell;
  var Abort: Boolean);
begin
  case Cell.Kind of
    xdkString:  AccumulateLabel(Cell.Row, Cell.Col, VarToStr(Cell.Value));
    xdkNumber:  AddToTotals(Cell.Col, Double(Cell.Value));
    xdkDate:    NoteWhen(Cell.Row, VarToDateTime(Cell.Value));
    xdkBoolean: FlagRow(Cell.Row, Boolean(Cell.Value));
    xdkError:   LogBadCell(Cell.Row, Cell.Col, VarToStr(Cell.Value));
  end;
end;

區分日期與數字

關於日期的問題值得深入探討,因為這正是大多數簡單的掃描器會出錯的地方。數字儲存格上沒有日期類型。一個儲存著序列值 46000 的儲存格,可以是一個數量、一個價格,或者是 2025 年 2 月 17 日,而檔案只能透過儲存格樣式所對應到的數字格式 id 來告訴您。ECMA-376 保留了一個區塊的內建格式 id,其意義在所有符合規範的生產者中都是固定的,而包含日期的 id 落於兩個範圍:標準日期與時間格式為 14 到 22,而如 [h]:mm:ss 等經過時間格式則為 45 到 47。當 DetectDates 開啟時(預設為開啟),讀取器會將每個數字儲存格的樣式解析為它的格式 id,若一個儲存格的 id 落於那些保留範圍內,就會被報告為 xdkDate,並且它的 Value 會被預先轉換成 Delphi 的 TDateTime。自訂格式同樣會被檢查,藉由檢查格式代碼內的日期和時間標記,但保留範圍才是可靠的骨幹。若將 DetectDates 關閉,樣式表甚至不會被載入,每個數字儲存格都會以 xdkNumber 的型式傳遞過來,且掃描過程會稍微精簡。

跳過工作表並提早中止

循序掃描有個隨機存取無法匹敵的隱藏優勢:您可以停止。OnSheet 事件會在每個工作表開啟前觸發,並給您兩個切換選項。將 SkipSheet 設為 true,整個部分就不會被解析,這就是您如何在多工作表的活頁簿中,只掃描您關心的工作表,而不必付出讀取其餘內容代價的方式。將 Abort 設為 true,整個掃描就會立即結束。OnCell 事件帶有其自身的 Abort,因此,您可以在找到您所尋找的內容(特定的列、一個標記值、標頭區塊的結尾)時馬上停止,而不必讀取剩下的數百萬個儲存格。在單向掃描中,中止是完全沒有代價的,因為您跳過的工作是尚未發生的工作。

procedure TReportScan.OnSheet(Sender: TObject; SheetIndex: Integer;
  const SheetName: WideString; var SkipSheet: Boolean; var Abort: Boolean);
begin
  // 僅掃描 "Data" 工作表;保留其餘的未讀取
  SkipSheet := SheetName <> 'Data';
end;

不使用處理常式計算儲存格數

一個近期的改進值得一提,因為它將一個常見的問題變成了單一且低成本的呼叫。讀取器會計算經過的每個已填入儲存格,且無論是否附加了 OnCell 處理常式它都會這樣做。早期,在未設定處理常式的情況下,已填入儲存格的數量傳回會是零,因為計數是發射的副作用。現在,計數已經獨立於發射。這意味著您可以詢問這個活頁簿實際上包含多少個已填入的儲存格,並只需付出一次完全沒有回呼的掃描代價即可獲得答案。ReadFileReadStream 都會以 Int64 傳回該總數,並且在事後可以透過 CellCount 屬性取得相同的數字。若傳回 -1 則表示檔案無法開啟,或它不是 OOXML 封裝。

var
  Reader: TXLSDirectReader;
  Populated: Int64;
begin
  Reader := TXLSDirectReader.Create;
  try
    // 無 OnCell 處理常式:純粹的已填入儲存格普查,依然保持近乎恆定的記憶體
    Populated := Reader.ReadFile('quarterly_export.xlsx');
    if Populated < 0 then
      raise Exception.Create('不是可讀取的 XLSX 封裝')
    else
      Writeln(Format('%d 個已填入儲存格 (CellCount = %d)',
        [Populated, Reader.CellCount]));
  finally
    Reader.Free;
  end;
end;

對於完整的掃描,您同樣附上處理常式並以完全相同的方式呼叫 ReadFile。與完整載入的對比才是重點所在:將 quarterly_export.xlsx 載入到活頁簿會將每個儲存格擴展成常駐物件並全部保留,而直接讀取器只保留共用字串與樣式表,同時讓一千兩百萬個儲存格一次一個地流過您的 OnCell。每個儲存格執行的運算不會留下任何東西,因此峰值記憶體是由活頁簿的相異字串數目決定的,而非由它的列數決定。

當任務是將大型活頁簿讀取一次並萃取或總結其內容時,直接讀取器正是適合的工具。相反的,若您需要完整模型的隨機存取,但希望它在處理大型檔案時能正常運作,我們關於在 Delphi 中處理大型活頁簿效能的筆記中的調整方式則涵蓋了那條路徑。而當方向相反,是產生大量輸出而非消耗時,針對伺服器批次任務的串流寫入導覽將相同的恆定記憶體原則應用到了寫入上。這三者都作為 Delphi 與 C++Builder 的 HotXLS 元件的一部分提供,並且與本部落格其他地方涵蓋的讀取、寫入、公式以及格式化 API 齊名。