Bài viết kỹ thuật

Pull row cursor cho XLS, XLSX, ODS và CSV trong Delphi

HotXLS đọc các nguồn .xls, .xlsx, .xlsm, .ods, CSV và TSV thông qua một pull row cursor duy nhất, TXLSRowCursor, mà FindFirstFindNext của nó tiến từng logical row một trong khi chỉ row đó nằm trong bộ nhớ. Một state machine sáu giá trị tách before-first khỏi EOF, cancelled và faulted, và reader kiểu callback cũ giờ là một adapter bọc trên cùng cursor đó

Kịch bản này quen thuộc với bất kỳ ai từng tung một tính năng import. Một tệp .xlsx 200 MB tới, bạn nối một handler OnCell, và yêu cầu đầu tiên sau "đọc nó" là "dừng sau một trăm bút toán đảo đầu tiên". Lúc này cấu trúc code của bạn chống lại chính bạn: vòng lặp nằm trong thư viện, handler của bạn phải giơ cờ, mọi callback sau đó vẫn bắn cho tới khi parser để ý, và trạng thái tích lũy — đã trúng bao nhiêu, cột nào khớp, bước tiếp theo làm gì — phải nằm trong các field của một class tồn tại chỉ để cho callback có chỗ ngồi. Không thứ gì trong đó là bài toán parsing. Đó là bài toán control-flow, và đó chính là thứ pull cursor gỡ bỏ

Push callback thực trả giá bao nhiêu ở 200 MB

Push đảo ngược control, và sự đảo ngược đó đúng là thứ một caller lọc hay nối dữ liệu không thể gánh. Với API callback, thư viện sở hữu vòng lặp, nên caller không thể dùng Break, không thể đan hai nguồn vào nhau, không thể trao reader cho một routine mong được chủ động điều khiển, và không thể diễn đạt "nhìn trước row kế tiếp rồi mới quyết định" nếu không buffer. Cái giá không nằm ở throughput — một đường callback SAX viết tốt stream rất ổn — mà ở chỗ mọi consumer không tầm thường đều mọc thêm một state machine nhỏ của riêng nó để giả lập vòng lặp mà nó không được phép tự viết. Nhân điều đó với bốn định dạng tệp, mỗi cái vốn có entry point quét riêng, và ngữ nghĩa lọc, công thức lẫn lỗi bắt đầu trôi dần giữa chúng — chính là độ trôi mà HotXLS nhắm khép lại

Pull cursor đổi code gọi của bạn ra sao?

Nó trả vòng lặp lại cho bạn, kèm theo đó là control flow Pascal đời thường. TXLSRowCursor.Open nhận một tên tệp hoặc một TStream, nhận diện định dạng, nạp shared string và metadata kiểu ngày một lần, rồi chọn sheet 1. SelectSheet (1-based) hay SelectSheetByName nhắm lại worksheet khác và reset cursor về before-first. FindFirstFindNext sau đó đặt vị trí trên row có dữ liệu kế tiếp — các row không có ô giải mã được bị bỏ qua, nên RowIndex có thể nhảy — và row hiện tại được phơi ra qua CellCount, Cells[]ValueByCol[], tất cả đều 1-based trên trục cột. Rời vòng lặp chỉ là một Break

var
  Cursor: TXLSRowCursor;
  Hits: Integer;
begin
  Cursor := TXLSRowCursor.Create;
  try
    Cursor.FirstRow := 2;        // bỏ qua dải header
    Cursor.IncludeColumn(1);     // chỉ decode hai cột này
    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 đời thường; không cờ abort, không sentinel
        end;
      until not Cursor.FindNext;
  finally
    Cursor.Free;                 // destructor kết thúc pass
  end;
end;

Phép chiếu và phạm vi được đặt trước pass, chứ không lọc sau. FirstRow, LastRow, IncludeColumn, ClearColumnProjection, IncludeFormulaText, DetectDatesDetectTextTypes đều được tôn trọng bên trong các backend, nên một cột không được chọn không bao giờ cấp phát giá trị, chuỗi công thức hay payload rich-text ngay từ đầu — bộ regression chứng minh điều đó với công thức 16 KiB và chuỗi cache không bao giờ được hiện thực hóa khi cột của chúng không được chiếu. Các tùy chọn này được cố ý đóng băng trong khi pass đang chạy và chỉ mở khóa ghi lại tại EOF, khi SelectSheet, hay sau Close, để một lượt quét không bao giờ trộn hai hợp đồng decode. Nếu bạn chỉ cần danh mục sheet chứ không cần các row, nạp chỉ metadata và chọn sheet riêng lẻ là cửa nhập rẻ hơn

Mỗi định dạng một backend, mỗi cái một vòng quét

Mỗi định dạng có đúng một forward scanner bên trong HotXLS, và cả pull cursor lẫn callback reader đều điều khiển chính scanner đó. TXLSXForwardRowBackend là state machine SAX duy nhất cho các sheet part ECMA-376 Part 1 §18.3, giữ XML reader, bảng shared-formula và parser rich-text, và nó tiến đúng một biên <row> vật lý mỗi lần gọi. TXLSBiffForwardParser sở hữu globals, chọn sheet và việc tiến row cho stream bản ghi [MS-XLS]; việc làm cho nó tạm dừng được tạo ra ràng buộc gắt nhất trong toàn bộ thiết kế, vì một công thức chuỗi cache là một bản ghi Formula theo ngay sau đó là bản ghi String, nên điểm treo từng row không bao giờ được đặt giữa hai bản ghi đó. TXLSForwardTextBackend giữ một reader biết BOM, delimiter đang dùng và một logical record — CSV dò phẩy, chấm phẩy, tab hay pipe từ bản ghi đầu tiên trong khi bỏ qua ký tự trong ngoặc kép, và các trường ngoặc kép nhiều dòng được nối bằng #10 để số hàng đuổi theo logical record thay vì ký tự xuống dòng vật lý. TXLSForwardOdsBackend giữ một template row vật lý duy nhất cho các bảng OpenDocument §9, coi table:number-rows-repeated là một số đếm còn lại chứ không phải một phép mở rộng, và tiến qua các ô được phủ mà không phát giá trị. Streaming direct reader dùng chung bộ nạp shared-string và date-style đó

Pull row cursor của HotXLS điều phối tới một forward scanner mỗi định dạng: backend SAX cho XLSX, parser bản ghi cho BIFF, backend văn bản dò delimiter và template row ODS, với callback reader được cấu hình bên trên như một adapter
Mỗi định dạng có đúng một forward scanner, và cả pull cursor lẫn callback reader đều điều khiển chính scanner đó, nên ngữ nghĩa lọc và xử lý lỗi không thể trôi tách nhau

Vì sao sáu trạng thái thay vì một cờ Eof?

Vì một boolean duy nhất khiến bốn tình huống khác nhau không thể phân biệt, và caller đoán sai cả bốn. TXLSRowCursorState gọi tên chúng một cách tường minh

  • xrcsClosed — chưa mở nguồn nào
  • xrcsBeforeFirst — đã mở hay đã nhắm lại đích, chưa đọc row nào
  • xrcsActive — đang đứng trên một row hợp lệ
  • xrcsEof — sheet đã bị tiêu thụ đến tận cùng
  • xrcsCancelled — caller chủ động dừng pass
  • xrcsFaulted — pass thất bại và ngoại lệ gốc đã được raise

Phân biệt cuối cùng là thứ có ý nghĩa trong production. Một sheet part thiếu hay một lần khởi động pass thất bại giữ nguyên EReadError của nó và đưa cursor về xrcsFaulted; nó không bao giờ bị hạ cấp thành một False trơn mà caller sẽ đọc là "sheet này rỗng". Cancel được cố ý thu hẹp hơn Close: nó đóng backend worksheet hiện tại cùng inflate substream của nó và vô hiệu row hiện tại, nhưng không giải phóng kho ZIP hay stream nguồn, và gọi nó hai lần là no-op. Sau một cancel, bạn tiếp tục bằng cách gọi SelectSheet tường minh — cursor sẽ không lặng lẽ khởi động lại pass thay bạn. Quyền sở hữu stream đi theo cùng quy tắc phòng thủ: xsoBorrowed là mặc định và khôi phục vị trí stream khi đóng, xsoOwned chỉ chuyển quyền sở hữu sau khi Open đã thành công, nên một lần mở thất bại không bao giờ giải phóng một stream mà caller vẫn đang giữ

Sáu trạng thái của row cursor HotXLS cùng các chuyển tiếp giữa chúng, cho thấy Cancel đưa một pass đang chạy về cancelled, một lần khởi động pass thất bại đưa nó về faulted, và cả hai vẫn tách bạch khỏi hết sheet
Sáu trạng thái có tên giữ cho một sheet rỗng, một lần dừng chủ ý và một pass thất bại luôn phân biệt được — điều mà một cờ Eof đơn lẻ không làm nổi
var
  Cursor: TXLSRowCursor;
  Src: TFileStream;
begin
  Src := TFileStream.Create('quarter.ods', fmOpenRead or fmShareDenyWrite);
  try
    Cursor := TXLSRowCursor.Create;
    try
      // xsoBorrowed: cursor không bao giờ giải phóng Src, và Close khôi phục
      // vị trí stream có được khi Open được gọi
      if not Cursor.Open(Src, xffAuto, xsoBorrowed) then
        Exit;

      if Cursor.FindFirst then
        repeat
          if UserPressedStop then
          begin
            Cursor.Cancel;   // chỉ đóng backend worksheet và
            Break;           // inflate substream của nó; idempotent
          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;                // vẫn của ta, vẫn hợp lệ, vị trí đã khôi phục
  end;
end;

Mượn row hiện tại mà không sao chép nó

IXLSRowCursorView trao một row cho routine khác mà không nhân bản mảng ô. View giữ một guard dùng chung chứa con trỏ cursor cộng một bộ đếm generation UInt64; việc tiến, chọn sheet, cancel, đóng và hủy cursor đều tăng generation đó, và việc hủy còn xóa cả chủ guard. Nhờ vậy một view cũ kỹ không thể đọc bộ nhớ đã giải phóng: Valid là phép dò không bao giờ văng ngoại lệ mà bạn có thể gọi bất cứ lúc nào, còn mọi thành viên khác kiểm tra trước rồi raise EXLSRowCursorViewInvalidated. Hãy trung thực về bản chất hợp đồng này — đó là vòng đời fail-fast, không phải cam kết thread-safety, và nó không cấp phép đọc một row từ thread thứ hai trong khi thread đầu đang tiến cursor

var
  View: IXLSRowCursorView;
  Cell: TXLSRowCursorCell;
  I: Integer;
begin
  if Cursor.FindFirst then
    repeat
      View := Cursor.CurrentRowView;      // mượn; không mảng ô nào được sao chép
      for I := 0 to View.CellCount - 1 do
      begin
        Cell := View.Cells[I];
        if Cell.HasFormula and not Cell.FormulaTextAvailable then
          UseCachedResult(Cell.Value)     // forward read BIFF giữ
        else if Cell.Kind = xdkEmpty then //   kết quả cache, không phải token
          UseStyleOnly(Cell.StyleIndex)   // Blank / MulBlank là ô thật
        else
          UseValue(Cell.Col, Cell.Value);
      end;
    until not Cursor.FindNext;

  // Interface sống lâu hơn vòng lặp, nhưng row đằng sau thì không
  if not View.Valid then    // Valid không bao giờ raise; Cells[] lúc này sẽ raise
    View := nil;            // EXLSRowCursorViewInvalidated
end;

PeakRowBufferedBytes, và những gì nó được phép chứng minh

PeakRowBufferedBytes tồn tại để chứng minh bộ nhớ đi theo độ rộng hàng chứ không phải số hàng. Nó cộng dồn các bản ghi ô, Variant, chuỗi công thức và payload rich-text của row đầu ra hiện tại, rồi gộp thêm working set riêng của định dạng — logical record của CSV, template row vật lý của ODS, đỉnh bản ghi của BIFF, hay ô thô XLSX đang được decode. Hãy đọc nó cùng SheetPassesStarted, thứ đếm bao nhiêu pass worksheet thực sự bắt đầu. Hai lưu ý giữ cho chỉ số này trung thực: con số là một ước lượng, không phải kế toán heap chính xác, và nó đơn điệu tăng kể từ Open gần nhất, nên nó là công cụ debug và regression chứ không phải đồng hồ đo trực tiếp. Để có bức tranh rộng hơn về thời gian và bộ nhớ đi đâu trên những cuốn sách rất lớn, xem hiệu năng workbook lớn trong Delphi

So sánh của HotXLS giữa một load cả sheet giữ mọi row thường trú và pull cursor chỉ giữ row hiện tại cộng một working set của định dạng — thứ mà PeakRowBufferedBytes cộng dồn và báo cáo
PeakRowBufferedBytes cộng dồn row đầu ra hiện tại cộng working set riêng của định dạng, nên bộ nhớ đi theo độ rộng của một row chứ không theo số row của sheet

Push reader hóa thành adapter, và những gì cursor không làm

TXLSForwardReader không còn mang các entry point quét riêng cho XLSX, BIFF và văn bản. Nó cấu hình một cursor, đi hết cursor đó, và dịch row hiện tại thành các sự kiện OnSheetOnCell, đó là lý do hai mặt ngoài không thể trôi tách nhau về lọc, trạng thái công thức hay xử lỗi nữa. Hai hệ quả đáng biết trước khi nâng cấp: SheetIndex của callback giờ nhất quán 1-based trên TXLSForwardReader (TXLSDirectReader giữ hợp đồng sự kiện 0-based hiện có của nó), và OnSheet bắn trước SelectSheet, nên đặt SkipSheet nghĩa là phần worksheet không bao giờ được mở hay giải nén. Ranh giới cũng tường minh không kém: workbook không được sửa trong khi pass đang chạy, cancel đòi hỏi khởi động lại tường minh, và đường forward BIFF không bao giờ decompile token công thức, nên ô công thức classic báo HasFormula true với FormulaTextAvailable false và trao cho bạn kết quả cache thay vì bịa một chuỗi công thức rỗng. Row cursor cùng adapter của nó đã qua 1.298 phép kiểm tra trên Delphi Win32 và Win64 cộng gói tĩnh C++Builder 37.0 Win64

Nếu bạn đang cân một pull cursor với loader đang có, câu hỏi cần hỏi không phải cái nào parse nhanh hơn mà là cái nào cho phép bạn viết điều kiện thoát mà bạn thực sự cần. Chi tiết component đầy đủ, các phiên bản IDE được hỗ trợ và licensing nằm trên trang HotXLS Delphi spreadsheet component