Artykuł techniczny

Kursor wierszy pull dla XLS, XLSX, ODS i CSV w Delphi

HotXLS czyta źródła .xls, .xlsx, .xlsm, .ods, CSV i TSV przez pojedynczy kursor wierszy pull, TXLSRowCursor, którego FindFirst i FindNext przesuwają o jedną logiczną literę wiersza naraz, podczas gdy tylko ten wiersz zostaje w pamięci. Sześciowartościowa maszyna stanów oddziela przed-pierwszym od EOF, anulowanego i zawiedzionego, a starszy czytnik callbackowy jest teraz adapterem nad tym samym kursorem

Scenariusz jest znajomy każdemu, kto wysłał kiedyś funkcję importu. Przychodzi .xlsx na 200 MB, podpinasz obsługę OnCell, a pierwsze wymaganie po „przeczytaj to” brzmi „zatrzymaj się po pierwszej setce księgowań REVERSED”. Teraz kształt twojego kodu walczy z tobą: pętla mieszka w bibliotece, twoja obsługa musi podnieść flagę, każde kolejne wywołanie zwrotne i tak strzela, aż parser zauważy, a zakumulowany stan — ile trafień do tej pory, która kolumna pasowała, co robić dalej — musi mieszkać w polach klasy, która istnieje tylko po to, by dać callbackowi gdzie usiąść. Nic z tego nie jest problemem parsowania. To problem przepływu sterowania, i to właśnie ten, który kursor pull usuwa

Co naprawdę kosztuje callback push przy 200 MB

Push odwraca sterowanie, a odwrócenie jest dokładnie tym, na co nie może sobie pozwolić wywołujący filtrujący albo złączający. Przy API callbackowym biblioteka jest właścicielem pętli, więc wywołujący nie może użyć Break, nie może przeplatać dwóch źródeł, nie może oddać czytnika procedurze, która oczekuje być napędzana, i nie może wyrazić „zajrzyj do następnego wiersza, zanim zdecydujesz” bez buforowania. Kosztem nie jest przepustowość — dobrze napisana ścieżka callbackowa SAX strumieniuje dobrze — jest nim to, że każdy nietrywialny konsument wyhodowuje własną małą maszynę stanów, by symulować pętlę, którą nie wolno mu było napisać. Pomnóż to przez cztery formaty plików, każdy historycznie z własnym punktem wejścia skanowania, i semantyka filtrowania, formuł i błędów zaczyna się rozjeżdżać między nimi, co jest dokładnie tym dryfem, który HotXLS wyszedł zamknąć

Jak kursor pull zmienia twój kod wywołujący?

Oddaje ci pętlę, a z nią zwykły przepływ sterowania Pascala. TXLSRowCursor.Open przyjmuje nazwę pliku albo TStream, wykrywa format, raz ładuje współdzielone ciągi i metadane stylów dat i wybiera arkusz 1. SelectSheet (od jedynki) albo SelectSheetByName przecelowuje inny arkusz i resetuje kursor do przed-pierwszego. FindFirst i FindNext pozycjonują potem na następnym zaludnionym wierszu — wiersze bez dekodowalnych komórek są pomijane, więc RowIndex może skakać — a bieżący wiersz jest eksponowany jako CellCount, Cells[] i ValueByCol[], wszystkie od jedynki na osi kolumn. Wyjście z pętli to Break

var
  Cursor: TXLSRowCursor;
  Hits: Integer;
begin
  Cursor := TXLSRowCursor.Create;
  try
    Cursor.FirstRow := 2;        // pomiń pas nagłówka
    Cursor.IncludeColumn(1);     // dekoduj tylko te dwie kolumny
    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;               // zwykły Break; żadnej flagi abort, żadnego wartownika
        end;
      until not Cursor.FindNext;
  finally
    Cursor.Free;                 // destruktor kończy przebieg
  end;
end;

Projekcja i zakres są ustawiane przed przebiegiem, nie filtrowane potem. FirstRow, LastRow, IncludeColumn, ClearColumnProjection, IncludeFormulaText, DetectDates i DetectTextTypes są wszystkie honorowane wewnątrz backendów, więc niewybrana kolumna nigdy nie alokuje swojej wartości, ciągu formuły ani ładunku rich-text w ogóle — zestaw regresji dowodzi tego formułami 16 KiB i buforowanymi ciągami, które nigdy nie są materializowane, gdy ich kolumna nie jest rzutowana. Te opcje są celowo zamrożone, dopóki przebieg jest aktywny, i znów stają się zapisywalne przy EOF, przy SelectSheet albo po Close, więc jeden skan nigdy nie zmiesza dwóch kontraktów dekodowania. Jeśli potrzebujesz tylko inwentarza arkuszy, a nie wierszy, ładowanie tylko-metadanych i selektywne arkuszy jest tańszym punktem wejścia

Jeden backend na format, jedna pętla skanowania każdy

Każdy format ma dokładnie jeden skaner forward wewnątrz HotXLS, i zarówno kursor pull, jak i czytnik callbackowy napędzają ten sam skaner. TXLSXForwardRowBackend jest jedyną maszyną stanów SAX arkusza dla części arkuszy ECMA-376 Part 1 §18.3, trzyma czytnik XML, tabelę formuł współdzielonych i parser rich-text i przesuwa się dokładnie do jednej fizycznej granicy <row> na wywołanie. TXLSBiffForwardParser jest właścicielem globals, wyboru arkusza i przesuwu wiersza dla strumienia rekordów [MS-XLS]; uczynienie go pauzowalnym wyprodukowało najostrzejsze ograniczenie całego projektu, bo buforowana formuła ciągowa to rekord Formula, po którym natychmiast następuje rekord String, więc punkt zawieszenia per wiersz nigdy nie może wylądować między nimi. TXLSForwardTextBackend trzyma czytnika świadomego BOM, aktywny delimiter i jeden rekord logiczny — CSV węszy przecinkiem, średnikiem, tabulatorem albo pipe z pierwszego rekordu, ignorując znaki w cudzysłowach, a wielowierszowe pola w cudzysłowach są łączone przez #10, więc numer wiersza śledzi rekordy logiczne, a nie fizyczne nowe linie. TXLSForwardOdsBackend trzyma jeden fizyczny szablon wiersza dla tabel OpenDocument §9, traktuje table:number-rows-repeated jako licznik pozostałych, a nie rozwinięcie, i przesuwa się ponad pokrytymi komórkami bez emitowania wartości. Strumieniowy czytnik bezpośredni dzieli ten sam loader współdzielonych ciągów i stylów dat

Kursor wierszy pull HotXLS dysponujący do jednego skanera forward na format, backend SAX dla XLSX, parser rekordów dla BIFF, tekstowy backend węszący delimiter i szablon wiersza ODS, z czytnikiem callbackowym skonfigurowanym na wierzchu jako adapter
Każdy format ma dokładnie jeden skaner forward, i zarówno kursor pull, jak i czytnik callbackowy napędzają ten sam skaner, więc semantyka filtrowania i błędów nie może się rozjechać

Dlaczego sześć stanów zamiast jednej flagi Eof?

Bo pojedynczy boolean czyni cztery różne sytuacje nierozróżnialnymi, a wywołujący zgadują źle co do wszystkich. TXLSRowCursorState nazywa je jawnie

  • xrcsClosed — żadne źródło nie jest otwarte
  • xrcsBeforeFirst — otwarty albo przecelowany, jeszcze żaden wiersz nie przeczytany
  • xrcsActive — stoi na poprawnym wierszu
  • xrcsEof — arkusz został skonsumowany do końca
  • xrcsCancelled — wywołujący zatrzymał przebieg celowo
  • xrcsFaulted — przebieg zawiódł i pierwotny wyjątek został rzucony

To ostatnie rozróżnienie jest tym, co ma znaczenie w produkcji. Brakująca część arkusza albo zawiedziony start przebiegu zachowuje swój EReadError i przesuwa kursor do xrcsFaulted; nigdy nie jest degradowany do zwykłego False, które wywołujący przeczytałby jako „ten arkusz był pusty”. Cancel jest celowo węższy niż Close: zamyka backend bieżącego arkusza i jego substream rozompresowywania i unieważnia bieżący wiersz, ale nie uwalnia archiwum ZIP ani strumienia źródłowego, a wywołanie go dwa razy to no-op. Po anulowaniu wznawiasz przez jawne wywołanie SelectSheet — kursor nie zrestartuje po cichu przebiegu za ciebie. Własność strumienia trzyma tę samą defensywną regułę: xsoBorrowed jest domyślne i przywraca pozycję strumienia przy zamknięciu, xsoOwned przekazuje własność dopiero po tym, jak Open już się powiodło, więc nieudane otwarcie nigdy nie uwalnia strumienia, którego wywołujący wciąż się trzyma

Sześć stanów kursora wierszy HotXLS z przejściami między nimi, pokazujące Cancel przenoszący aktywny przebieg do anulowanego, zawiedziony start przebiegu przenoszący go do faulted i jak oba pozostają odrębne od końca arkusza
Sześć nazwanych stanów utrzymuje pusty arkusz, celowe zatrzymanie i zawiedziony przebieg rozróżnialnymi, czego pojedynczy boolean Eof nie potrafi
var
  Cursor: TXLSRowCursor;
  Src: TFileStream;
begin
  Src := TFileStream.Create('quarter.ods', fmOpenRead or fmShareDenyWrite);
  try
    Cursor := TXLSRowCursor.Create;
    try
      // xsoBorrowed: kursor nigdy nie uwalnia Src, a Close przywraca
      // pozycję, którą strumień miał, gdy wywołano Open
      if not Cursor.Open(Src, xffAuto, xsoBorrowed) then
        Exit;

      if Cursor.FindFirst then
        repeat
          if UserPressedStop then
          begin
            Cursor.Cancel;   // zamyka backend arkusza i jego
            Break;           // substream rozompresowywania; idempotentne
          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;                // wciąż nasz, wciąż ważny, pozycja przywrócona
  end;
end;

Wypożyczanie bieżącego wiersza bez kopiowania

IXLSRowCursorView oddaje wiersz innej procedurze bez duplikowania tablicy komórek. Widok trzyma współdzieloną straż trzymającą wskaźnik kursora plus licznik pokoleń UInt64; przesuwanie, wybór arkusza, anulowanie, zamykanie i niszczenie kursora wszystkie inkrementują to pokolenie, a niszczenie dodatkowo czyści właściciela straży. Więc nieświeży widok nie może czytać uwolnionej pamięci: Valid to sonda bez wyjątków, którą możesz wywołać w każdej chwili, podczas gdy każdy inny członek najpierw waliduje i rzuca EXLSRowCursorViewInvalidated. Bądź szczery co do tego, czym ten kontrakt jest — to fail-fast czasu życia, nie gwarancja bezpieczeństwa wątków, i nie licencjonuje czytania wiersza z drugiego wątku, podczas gdy pierwszy przesuwa kursor

var
  View: IXLSRowCursorView;
  Cell: TXLSRowCursorCell;
  I: Integer;
begin
  if Cursor.FindFirst then
    repeat
      View := Cursor.CurrentRowView;      // wypożycza; żadna tablica komórek nie jest kopiowana
      for I := 0 to View.CellCount - 1 do
      begin
        Cell := View.Cells[I];
        if Cell.HasFormula and not Cell.FormulaTextAvailable then
          UseCachedResult(Cell.Value)     // odczyty forward BIFF trzymają
        else if Cell.Kind = xdkEmpty then //   buforowany wynik, nie tokeny
          UseStyleOnly(Cell.StyleIndex)   // Blank / MulBlank to prawdziwe komórki
        else
          UseValue(Cell.Col, Cell.Value);
      end;
    until not Cursor.FindNext;

  // Interfejs przeżywa pętlę, ale wiersz za nim nie
  if not View.Valid then    // Valid nigdy nie rzuca; Cells[] teraz by rzuciło
    View := nil;            // EXLSRowCursorViewInvalidated
end;

PeakRowBufferedBytes i co wolno mu dowodzić

PeakRowBufferedBytes istnieje po to, by dowodzić, że pamięć śledzi szerokość wiersza, a nie liczbę wierszy. Kumuluje rekordy komórek, Varianty, ciągi formuł i ładunki rich-text bieżącego wiersza wyjściowego i włącza formatowo-specyficzny zestaw roboczy — logiczny rekord CSV, fizyczny szablon wiersza ODS, pik rekordów BIFF albo surową komórkę XLSX właśnie dekodowaną. Czytaj go razem z SheetPassesStarted, które liczy, ile przebiegów arkuszy faktycznie się zaczęło. Dwa zastrzeżenia utrzymują to uczciwie: liczba jest oszacowaniem, nie dokładną księgowością sterty, i jest monotoniczna od ostatniego Open, więc to instrument debugowania i regresji, a nie żywy wskaźnik. Dla szerszego obrazu, gdzie idzie czas i bajty na bardzo dużych skoroszytach, zobacz wydajność dużych skoroszytów w Delphi

Porównanie HotXLS pokazujące ładowanie całego arkusza trzymające każdy wiersz rezydentnie kontra kursor pull trzymający tylko bieżący wiersz plus jeden formatowy zestaw roboczy, co dokładnie kumuluje i raportuje PeakRowBufferedBytes
PeakRowBufferedBytes kumuluje bieżący wiersz wyjściowy plus formatowo-specyficzny zestaw roboczy, więc pamięć śledzi, jak szeroki jest wiersz, a nie ile wierszy arkusz ma

Czytnik push stał się adapterem i czego kursor nie zrobi

TXLSForwardReader nie niesie już osobnych punktów wejścia skanowania XLSX, BIFF i tekstowego. Konfiguruje kursor, przechodzi go i tłumaczy bieżący wiersz na zdarzenia OnSheet i OnCell, dlatego dwie fasady nie mogą się już rozjeżdżać na filtrowaniu, stanie formuł ani obsłudze błędów. Dwa skutki są warte znajomości przed uaktualnieniem: callbackowy SheetIndex jest teraz jednolicie od jedynki na TXLSForwardReader (TXLSDirectReader zachowuje swój istniejący kontrakt zdarzeń od zera), a OnSheet strzela przed SelectSheet, więc ustawienie SkipSheet znaczy, że część arkusza nigdy nie jest otwierana ani rozompresowywana w ogóle. Granice są równie jawne: skoroszyt nie może być modyfikowany, dopóki przebieg jest aktywny, anulowanie wymaga jawnego restartu, a ścieżka forward BIFF nigdy nie dekompiluje tokenów formuł, więc klasyczne komórki formuł raportują HasFormula prawdziwe z FormulaTextAvailable fałszywym i podają buforowany wynik, zamiast wymyślać pusty ciąg formuły. Kursor wierszy i jego adapter przeszły 1 298 kontroli na Delphi Win32 i Win64 plus statycznym pakiecie C++Builder 37.0 Win64

Jeśli wahasz się między kursorem pull a loaderem, który masz teraz, pytanie nie brzmi, który parsuje szybciej, tylko który pozwala ci napisać warunek wyjścia, którego naprawdę potrzebujesz. Pełne szczegóły komponentu, wspierane wersje IDE i licencjonowanie są na stronie komponentu arkuszowego HotXLS Delphi