技術文章

Delphi 中 XLS、XLSX、ODS 與 CSV 的拉取列游標

HotXLS 透過單一拉取列游標 TXLSRowCursor 讀取 .xls、.xlsx、.xlsm、.ods、CSV 與 TSV 來源,其 FindFirstFindNext 一次前進一個邏輯列,而且記憶體中只留該列。一個六值狀態機把「首列之前」與 EOF、已取消、已故障分開,舊的回呼讀取器現在則是架在同一游標上的配接器

任何出過匯入功能的人都熟悉這個情境。一個 200 MB 的 .xlsx 送來,您接好 OnCell 處理常式,而「讀它」之後的第一個需求是「在前一百筆反轉分錄之後停止」。這時程式碼的形狀開始跟您作對:迴圈在程式庫裡,您的處理常式得舉起一面旗號,之後每個回呼仍會持續觸發,直到剖析器注意到為止;而累積的狀態——目前命中幾筆、哪一欄相符、下一步做什麼——只得放在某個類別的欄位上,而那個類別存在的唯一理由就是給回呼一個落腳處。這些沒有半件是剖析問題。它是控制流程問題,而拉取游標移除的正是它

推送回呼在 200 MB 時真正的代價

推送會反轉控制權,而反轉正是過濾型或聯結型呼叫端承擔不起的東西。用回呼 API 時,迴圈歸程式庫所有,所以呼叫端不能用 Break、不能交錯兩個來源、不能把讀取器交給一個預期由自己驅動的常式,也不能不靠緩衝就表達「先窺看下一列再決定」。代價不在吞吐量——寫得好的 SAX 回呼路徑串流得很順——而在於每個非平凡的消費者都會自己長出一個小型狀態機,去模擬那個它不被允許寫的迴圈。再乘以四種檔案格式(每種過去各有自己的掃描進入點),過濾、公式與錯誤語義就開始在彼此之間漂移,而這正是 HotXLS 著手弭平的漂移

拉取游標如何改變您的呼叫端程式碼?

它把迴圈還給您,連帶還給您普通的 Pascal 控制流程。TXLSRowCursor.Open 接受檔名或 TStream,偵測格式,一次性載入共用字串與日期樣式詮釋資料,並選取第 1 張工作表。SelectSheet(一基)或 SelectSheetByName 會重新指向另一張工作表,並把游標重設到首列之前。接著 FindFirstFindNext 會定位到下一個有內容的列——沒有可解碼儲存格的列會被跳過,所以 RowIndex 可能跳號——目前列以 CellCountCells[]ValueByCol[] 暴露,欄軸上全部一基。離開迴圈就是一個 Break

var
  Cursor: TXLSRowCursor;
  Hits: Integer;
begin
  Cursor := TXLSRowCursor.Create;
  try
    Cursor.FirstRow := 2;        // 跳過表頭帶
    Cursor.IncludeColumn(1);     // 只解碼這兩欄
    Cursor.IncludeColumn(7);
    if not Cursor.Open('postings-200mb.xlsx') then
      Exit;
    if not Cursor.SelectSheetByName('Ledger') then
      Exit;

    Hits := 0;
    if Cursor.FindFirst then
      repeat
        if VarToStr(Cursor.ValueByCol[7]) = 'REVERSED' then
        begin
          Inc(Hits);
          if Hits = 100 then
            Break;               // 普通的 Break;沒有中止旗標,沒有哨兵
        end;
      until not Cursor.FindNext;
  finally
    Cursor.Free;                 // 解構子結束這趟掃描
  end;
end;

投影與範圍在掃描前設定,而不是事後過濾。FirstRowLastRowIncludeColumnClearColumnProjectionIncludeFormulaTextDetectDatesDetectTextTypes 全部在後端內部生效,所以未選取的欄一開始就不會配置其值、公式字串或豐富文字承載——回歸測試套件以 16 KiB 的公式與快取字串證明了這點:當它們所屬的欄未被投影時,絕不會被具體化。這些選項在掃描進行中刻意凍結,在 EOF、SelectSheet 時或 Close 之後才恢復可寫,所以一次掃描絕不會混用兩套解碼契約。如果您需要的只是工作表清單而非各列內容,僅詮釋資料與選擇性工作表載入是更便宜的進入點

每種格式一個後端,各自一個掃描迴圈

HotXLS 內部每種格式恰好有一個順向掃描器,拉取游標與回呼讀取器都驅動同一個掃描器。TXLSXForwardRowBackend 是 ECMA-376 Part 1 §18.3 工作表組件唯一的工作表 SAX 狀態機,持有 XML 讀取器、共用公式表與豐富文字剖析器,每次呼叫恰好前進到一個實體 <row> 邊界。TXLSBiffForwardParser 擁有 [MS-XLS] 記錄串流的全域區、工作表選擇與列推進;讓它可暫停,是整個設計中最嚴苛的限制,因為快取字串公式是一筆 Formula 記錄緊接一筆 String 記錄,所以逐列懸停點絕不能落在兩者之間。TXLSForwardTextBackend 持有一個能辨識 BOM 的讀取器、現行分隔符與一個邏輯記錄——CSV 從第一筆記錄嗅探逗號、分號、定位字元或豎線,同時忽略引號內字元,多行引號欄位以 #10 接合,讓列號追蹤邏輯記錄而非實體換列。TXLSForwardOdsBackend 為 OpenDocument §9 表格保留單一實體列樣板,把 table:number-rows-repeated 視為剩餘計數而非展開,並直接越過被覆蓋的儲存格而不發出值。串流直接讀取器共用同一份共用字串與日期樣式載入器

HotXLS 拉取列游標分派到每種格式各一個順向掃描器:XLSX 的 SAX 後端、BIFF 的記錄剖析器、嗅探分隔符的文字後端與 ODS 的列樣板,回呼讀取器則作為配接器架在頂端
每種格式恰好有一個順向掃描器,拉取游標與回呼讀取器都驅動同一個掃描器,所以過濾與錯誤語義不會漂移分歧

為什麼用六個狀態,而不是一個 Eof 旗標?

因為單一布林會讓四種不同處境無法區分,而呼叫端對這四種全都會猜錯。TXLSRowCursorState 把它們明確命名

  • xrcsClosed——沒有開啟任何來源
  • xrcsBeforeFirst——已開啟或重新指向,尚未讀取任何列
  • xrcsActive——正停在有效的一列上
  • xrcsEof——工作表已消耗到尾端
  • xrcsCancelled——呼叫端刻意停止了這趟掃描
  • xrcsFaulted——掃描失敗,且原始例外已被擲出

最後那項區分,是在實務上真正要緊的。缺少工作表組件或掃描啟動失敗,會保留其 EReadError 並把游標移到 xrcsFaulted;它絕不被降級成一個普通 False——那種 False 會被呼叫端解讀成「這張工作表是空的」。Cancel 刻意比 Close 窄:它關閉目前的工作表後端及其解壓縮子串流,並使目前列失效,但不釋放 ZIP 封存或來源串流,而且呼叫兩次是無效操作(no-op)。取消之後,您要明確呼叫 SelectSheet 來繼續——游標不會悄悄替您重啟一趟掃描。串流擁有權遵循同一份防禦性規則:xsoBorrowed 是預設值,關閉時會還原串流位置;xsoOwned 只在 Open 已經成功之後才轉移擁有權,所以開啟失敗絕不會釋放呼叫端仍持有的串流

HotXLS 列游標的六個狀態與其間的轉換:Cancel 把進行中的掃描移到已取消、掃描啟動失敗移到已故障,以及兩者如何都與工作表尾端保持區分
六個具名狀態讓空工作表、刻意停止與掃描失敗保持可區分,這是單一 Eof 布林做不到的
var
  Cursor: TXLSRowCursor;
  Src: TFileStream;
begin
  Src := TFileStream.Create('quarter.ods', fmOpenRead or fmShareDenyWrite);
  try
    Cursor := TXLSRowCursor.Create;
    try
      // xsoBorrowed:游標絕不釋放 Src,Close 會還原
      // 呼叫 Open 當時串流所在的位置
      if not Cursor.Open(Src, xffAuto, xsoBorrowed) then
        Exit;

      if Cursor.FindFirst then
        repeat
          if UserPressedStop then
          begin
            Cursor.Cancel;   // 只關閉工作表後端及其
            Break;           // 解壓縮子串流;具冪等性
          end;
        until not Cursor.FindNext;

      case Cursor.State of
        xrcsEof:       Log('sheet consumed to the end');
        xrcsCancelled: Log('stopped by the operator');
        xrcsFaulted:   Log('pass failed; the EReadError was already raised');
      end;
    finally
      Cursor.Free;
    end;
  finally
    Src.Free;                // 仍歸我們所有、仍有效,位置已還原
  end;
end;

不複製而借用目前列

IXLSRowCursorView 把一列交給另一個常式,而不複製儲存格陣列。視圖存放一個共用守衛,內含游標指標加上一個 UInt64 世代計數器;推進、選工作表、取消、關閉與終結游標都會遞增該世代,終結還會額外清除守衛的擁有者。因此過時的視圖不可能讀到已釋放的記憶體:Valid 是您隨時可呼叫、不會擲出例外的探測,其他每個成員則先驗證、再擲出 EXLSRowCursorViewInvalidated。請誠實看待這份契約的本質——它是生命週期速敗,不是執行緒安全保證,也不授權您在第一個執行緒推進游標的同時,從第二個執行緒讀取同一列

var
  View: IXLSRowCursorView;
  Cell: TXLSRowCursorCell;
  I: Integer;
begin
  if Cursor.FindFirst then
    repeat
      View := Cursor.CurrentRowView;      // 借用;不複製任何儲存格陣列
      for I := 0 to View.CellCount - 1 do
      begin
        Cell := View.Cells[I];
        if Cell.HasFormula and not Cell.FormulaTextAvailable then
          UseCachedResult(Cell.Value)     // BIFF 順向讀取保留的是
        else if Cell.Kind = xdkEmpty then //   快取結果,而非語彙
          UseStyleOnly(Cell.StyleIndex)   // Blank / MulBlank 是真實的儲存格
        else
          UseValue(Cell.Col, Cell.Value);
      end;
    until not Cursor.FindNext;

  // 介面比迴圈長命,但它背後的列不然
  if not View.Valid then    // Valid 絕不擲出例外;此刻 Cells[] 會擲出
    View := nil;            // EXLSRowCursorViewInvalidated
end;

PeakRowBufferedBytes,以及它被允許證明什麼

PeakRowBufferedBytes 的存在是為了證明記憶體追蹤的是列寬度,而非列數量。它累計目前輸出列的儲存格記錄、Variant、公式字串與豐富文字承載,並併入格式專屬的工作集——CSV 的邏輯記錄、ODS 的實體列樣板、BIFF 的記錄峰值,或 XLSX 正在解碼中的原始儲存格。請與 SheetPassesStarted 一起讀取,後者計算實際開始了多少趟工作表掃描。兩點注意事項讓這件事保持誠實:此數字是估計值,不是精確的堆積核算;而且它自最近一次 Open 以來是單調的,所以它是偵錯與回歸工具,而非即時量表。若想了解在超大型活頁簿上時間與位元組流向何處的更完整圖像,見Delphi 中的大型活頁簿效能

HotXLS 的對照:整張工作表載入讓每一列常駐記憶體,相對於拉取游標只保留目前列加上一個格式工作集,而這正是 PeakRowBufferedBytes 累計並回報的東西
PeakRowBufferedBytes 累計目前輸出列加上格式專屬工作集,所以記憶體追蹤的是一列有多寬,而非工作表有多少列

推送讀取器變成了配接器,以及游標不會做的事

TXLSForwardReader 不再帶有各自獨立的 XLSX、BIFF 與文字掃描進入點。它設定一個游標、走訪它,並把目前列轉譯成 OnSheetOnCell 事件,這正是兩個門面在過濾、公式狀態或錯誤處理上再也不會漂移分歧的原因。升級前有兩個後果值得知道:回呼的 SheetIndexTXLSForwardReader 上現在統一為一基(TXLSDirectReader 保留其既有零基事件契約),而且 OnSheetSelectSheet 之前觸發,所以設定 SkipSheet 代表該工作表組件根本不會被開啟或解壓縮。邊界同樣明確:掃描進行中不得修改活頁簿、取消之後必須明確重啟,而 BIFF 順向路徑絕不反編譯公式語彙,所以傳統公式儲存格回報 HasFormula 為真、FormulaTextAvailable 為假,並把快取結果交給您,而不是捏造一個空的公式字串。列游標與其配接器在 Delphi Win32 與 Win64 上通過了 1,298 項檢查,外加 C++Builder 37.0 Win64 靜態套件

如果您正在拉取游標與現有載入器之間權衡,該問的問題不是誰剖析得比較快,而是誰能讓您寫出您真正需要的結束條件。完整的元件細節、支援的 IDE 版本與授權方式,都在 HotXLS Delphi 試算表元件頁面