一支報表工作跑了一年都好好的。它建立一份活頁簿,把查詢回傳的東西填進工作表,然後存檔。接著,一位手上有五年歷史資料的客戶要求完整匯出,列數跨過一百萬,而這個行程早在檔案落到磁碟之前就以記憶體不足的錯誤死掉了。程式碼本身沒有錯。它把整份活頁簿抱在 RAM 裡,好在最後一刻序列化出去,而它需要的記憶體,是跟著它被要求寫出的列數一起等步成長的
解法不是換一台更大的機器,而是換一種寫入模型。HotXLS 的串流直寫器會在列陸續到來時逐步輸出 OOXML 套件,所以它用掉的記憶體不取決於您寫了多少列。它是串流讀取器在寫入端的對應物:讀取器走過一張巨大的工作表而不建立儲存格樹,寫入器則產生一張這樣的工作表,同樣不建立儲存格樹
為什麼一般的存檔路徑會隨資料成長
一般的 TXLSXWorkbook 路徑會先建起一套完整的物件模型。每個儲存格連同它的值、型別與樣式參照,都以物件的形式活在記憶體裡,直到您呼叫存檔,那時整棵樹才被序列化進套件。當您想讀一張工作表、編輯它、重新計算再寫回去時,這個模型是對的,因為編輯要的正是對任意儲存格的隨機存取。但當您只是把列往一個方向倒進去、從不回頭看時,它就是錯的模型,因為您付出了讓每一列常駐的代價,卻換不到任何好處。一百萬列的物件就是一百萬列的物件,不管您有沒有再回去看它們
串流寫入器把那棵樹拿掉了。儲存格一被寫入,就變成工作表組件中的位元組,而那些位元組會被交給 zip 輸出。唯一會成長的緩衝是工作表串流,而且它是在輸出端成長,不是以活的 Delphi 物件形式堆在堆積上。常駐的只有固定的一小撮記帳資料:工作表名稱、幾個旗標、目前的列號、一個儲存格計數器。這一組東西從第一列到第一千萬列都不會變
共用字串表是陷阱,行內字串是出路
多數串流式 XLSX 寫入器都做得不錯,直到它們碰上文字。OOXML 格式通常把字串存在共用字串表中:每個不重複的字串只寫進另一個組件一次,而每個裝著該字串的儲存格帶的是指向該表的索引,而不是文字本身。對於滿是重複標籤的檔案,這是個很好的空間最佳化,也是標準存檔路徑採用的預設值。但對串流寫入器來說,問題非常殘酷。要去重,這張表就得在整趟工作期間常駐,因為任何還沒寫到的列都可能重複某個早已寫出之列裡的字串,而唯有一份記錄所有見過字串的完整記憶體對映,才能指派正確的索引。於是,串流寫入器唯一串流不了的結構,恰恰就是本來該讓檔案變小的那個結構。文字量大的資料,會打敗您原本要的串流
直寫器則完全繞過那張表。字串以行內方式寫出,成為 t="inlineStr" 儲存格,文字直接以 <is><t> 元素坐在儲存格裡。沒有表要累積,也沒有見過字串的對映要保留,所以文字欄花的記憶體不比數值欄多。這筆交換很明確,值得說白。行內字串會在文字出現的每個地方重複同一段文字,所以含有大量相同標籤的檔案,在磁碟上會比共用字串版本更大。您用檔案大小買到了固定的記憶體用量。對一趟單向匯出而言,這是交換中划算的那一邊,而且 zip 壓縮在輸出途中本來就吸收掉了大部分重複
樣式表最後才出現,且只帶一種日期格式
樣式帶來的張力和字串一樣。活頁簿透過樣式組件參照它的格式設定,而串流寫入器沒辦法讓一份不斷成長的樣式調色盤,跟上早已排出去的儲存格。直寫器的回應方式,是把樣式表維持得又小又固定,並在關閉時才輸出它,而不是一開頭就寫。一種預設儲存格格式涵蓋普通儲存格。一種日期數值格式涵蓋日期,以 yyyy-mm-dd 的格式碼登錄在儲存格格式清單中一個已知的位置上
那個日期格式,正是 WriteDateTime 要獨立成一個呼叫的原因。Excel 沒有原生的日期型別;日期是一個穿著日期格式的數字。WriteDateTime 把值寫成單純的序號,並為儲存格標上那唯一的日期樣式,讓試算表把它呈現成日期,而不是一個五位數的整數。它寫出的序號對往返很重要。它在 1900 日期系統下直接儲存 TDateTime 值,這與一般 TXLSXWorkbook 存檔路徑採用的慣例相同。由於兩條路徑對序號的認知一致,串流寫入器產出的檔案,用 HotXLS 讀取器讀回來、或用 Excel 開啟時,日期都與您的本意相符,寫入端與讀取端之間不會出現差一天或紀元錯亂的意外
順序是強制的,因為位元組已經出去了
串流是用一條您必須遵守的規則,換來它的記憶體特性。輸出邊走邊排出,不能回頭修改,所以每樣東西都必須按它在檔案中出現的順序寫入。在一列之內,儲存格要按欄號遞增排列。在一張工作表之內,列要遞增排列。沒有任何緩衝能讓寫入器事後幫您把儲存格排序,因為您片刻前關掉的那一列,早已是 zip 串流中的位元組,再也搆不著了。在同一列中先給它第 5 欄再給第 2 欄,輸出就是壞的,因為寫入器就是照您給的順序原樣輸出您給的東西
列的 API 為常見情況準備了一點便利。AddRow 接受以 1 為起點的列索引,但傳 0 表示取用前一列之後的下一列,所以循序填資料時不必自己追蹤並傳入一個遞增的計數器。每次 AddRow 都會關掉它前面那一列,每次 AddSheet 都會關掉它前面那張工作表,所以您從來不需要明確結束一列或一張工作表。您開始下一個,寫入器就替您把開著的結構收尾
逸出處理發生在文字進入 XML 的地方
您寫入的任何文字都會變成 XML 文件的一部分,所以那五個預先定義的 XML 實體必須被逸出,否則只要某個值含有 ampersand 或角括號,套件當下就無效了。寫入器會替您在行內字串文字與公式文字這兩處逸出 &、<、>、" 與 ',那正是呼叫端提供的字元會落進標記中的地方。您傳入原始的 WideString,寫入器負責把它變安全。像 Smith & Co <Ltd> 這樣的產品名稱,或是一條參照到帶引號工作表名稱的公式,出來都是良構的 XML,您這邊完全不必自行逸出
生命週期,以及為什麼 Destroy 仍會關閉套件
把套件收尾,才會寫出活頁簿組件、樣式組件、內容型別與關聯組件,最後還有 zip 的中央目錄。這些工作發生在 Close 裡。一份從未關閉的套件是一個不完整的 zip,沒有任何試算表程式打得開,所以關閉不是可有可無的善後,它是讓檔案有效的那個步驟。為了防範在錯誤路徑上忘了呼叫 Close,若套件仍開著,Destroy 會盡力執行一次關閉,所以就算例外跳過了明確的呼叫,釋放寫入器也不會漏掉底層的 zip 物件。可靠的寫法仍然是那套普通的 Delphi 慣例:在 try 裡寫入、呼叫 Close,並在 finally 中釋放
從頭到尾串流一張大工作表
這項工作的形狀是:開始、加一張工作表、倒入列、關閉。下面的範例先寫一列標題,再寫一長串具型別的資料列,混合了字串、數字、一條沒有快取結果的公式,以及一個日期。它處理十列與處理一千萬列所用的記憶體是一樣的,因為每個儲存格一寫下就離開,前往 zip 串流
uses
lxDirectWrite;
procedure StreamReport(const Path: string; RowCount: Integer);
var
W: TXLSDirectWriter;
I: Integer;
begin
W := TXLSDirectWriter.Create;
try
W.BeginFile(Path);
W.AddSheet('Sales');
// 標題列,依欄號遞增寫出
W.AddRow(1);
W.WriteString(1, 'Item');
W.WriteString(2, 'Qty');
W.WriteString(3, 'Price');
W.WriteString(4, 'Total');
W.WriteString(5, 'Date');
// 資料列;傳 0 給 AddRow 會自動取用下一列
for I := 1 to RowCount do
begin
W.AddRow(0);
W.WriteString(1, 'Item ' + IntToStr(I));
W.WriteNumber(2, I);
W.WriteNumber(3, 1.5 + (I mod 10));
W.WriteFormula(4, Format('B%d*C%d', [I + 1, I + 1]));
W.WriteDateTime(5, EncodeDate(2026, 1, 1) + I);
end;
W.Close; // 為套件收尾
finally
W.Free;
end;
end;
要加第二張工作表,只要在繼續之前再呼叫一次 AddSheet,寫入器會在開啟第二張時關掉第一張。布林旗標用 WriteBoolean,它寫出的是具型別的布林儲存格,而不是文字 "True"。若您想確認檔案健全且能往返,屬性 CellCount 會回報寫出了多少個儲存格,而用串流讀取器把結果讀回來,應該會回報同樣的總數
// 資料工作表之後,再來一張具型別旗標的工作表
W.AddSheet('Flags');
W.AddRow(1);
W.WriteString(1, 'Name');
W.WriteString(2, 'Active');
W.AddRow(0);
W.WriteString(1, 'alpha');
W.WriteBoolean(2, True);
WriteLn(Format('wrote %d cells', [W.CellCount]));
寫到串流而不是檔案,程式碼一模一樣,只是把 BeginFile 換成 BeginStream,這讓伺服器能把活頁簿送進 HTTP 回應或記憶體串流,不必在磁碟上留下暫存檔。寫入器並不擁有您傳入的串流,所以它的生命週期仍由您掌控
當這項工作是一個依需求建置活頁簿的伺服器端點時,伺服器與批次工作的串流寫入一文中的模式,示範了如何把它接進請求處理常式與排程匯出。當問題轉為超大活頁簿在讀與寫兩端的整體代價時,Delphi 中的大型活頁簿效能涵蓋了時間與記憶體究竟花在哪裡。串流直寫器隨適用於 Delphi 與 C++Builder 的 HotXLS Delphi 元件出貨,與本部落格其他文章介紹的完整讀取、編輯與存檔 API 並肩