Technischer Artikel

Pull-Row-Cursor für XLS, XLSX, ODS und CSV in Delphi

HotXLS liest .xls-, .xlsx-, .xlsm-, .ods-, CSV- und TSV-Quellen über einen einzigen Pull-Row-Cursor, TXLSRowCursor, dessen FindFirst und FindNext je eine logische Zeile vorrücken lassen, während nur diese Zeile im Speicher bleibt. Eine State Machine mit sechs Werten trennt Before-First von EOF, Cancelled und Faulted, und der ältere Callback-Reader ist jetzt ein Adapter über denselben Cursor

Das Szenario kennt jeder, der ein Import-Feature ausgeliefert hat. Eine 200-MB-.xlsx kommt an, Sie verdrahten einen OnCell-Handler, und die erste Anforderung nach „lies sie“ lautet „stoppe nach den ersten hundert stornierten Buchungen“. Jetzt kämpft die Form Ihres Codes gegen Sie: Die Schleife lebt in der Bibliothek, Ihr Handler muss ein Flag setzen, jeder nachfolgende Callback feuert weiter, bis der Parser es bemerkt, und der akkumulierte Zustand — wie viele Treffer bisher, welche Spalte gematcht hat, was als Nächstes zu tun ist — muss in Feldern einer Klasse leben, die nur existiert, um dem Callback einen Sitzplatz zu geben. Nichts davon ist ein Parsing-Problem. Es ist ein Kontrollfluss-Problem, und es ist das, was ein Pull-Cursor entfernt

Was ein Push-Callback bei 200 MB wirklich kostet

Push kehrt die Kontrolle um, und Inversion ist genau das, was ein filternder oder verknüpfender Aufrufer sich nicht leisten kann. Mit einer Callback-API besitzt die Bibliothek die Schleife, also kann der Aufrufer kein Break benutzen, kann keine zwei Quellen verzahnen, kann den Reader keiner Routine übergeben, die erwartet, getrieben zu werden, und kann „vor der Entscheidung in die nächste Zeile hineinsehen“ ohne Puffern nicht ausdrücken. Der Preis ist nicht der Durchsatz — ein gut geschriebener SAX-Callback-Pfad streamt ordentlich — sondern dass jeder nicht-triviale Konsument eine kleine eigene State Machine wachsen lässt, um die Schleife zu simulieren, die ihm nicht zu schreiben erlaubt war. Das mit vier Dateiformaten multipliziert, von denen jedes historisch einen eigenen Scanning-Einsprungpunkt hat, und die Filter-, Formel- und Fehlersemantiken beginnen, zwischen ihnen auseinanderzudriften — genau der Drift, den HotXLS zu schließen sich vornahm

Wie verändert ein Pull-Cursor Ihren Aufrufcode?

Er gibt Ihnen die Schleife zurück, und damit den gewöhnlichen Pascal-Kontrollfluss. TXLSRowCursor.Open nimmt einen Dateinamen oder einen TStream entgegen, erkennt das Format, lädt Shared Strings und Date-Stil-Metadaten einmal und wählt Sheet 1. SelectSheet (einsbasiert) oder SelectSheetByName zielt auf ein anderes Worksheet um und setzt den Cursor auf Before-First zurück. FindFirst und FindNext positionieren dann auf der nächsten belegten Zeile — Zeilen ohne dekodierbare Zellen werden übersprungen, also kann RowIndex springen — und die aktuelle Zeile ist als CellCount, Cells[] und ValueByCol[] exponiert, alle einsbasiert auf der Spaltenachse. Die Schleife zu verlassen ist ein Break

var
  Cursor: TXLSRowCursor;
  Hits: Integer;
begin
  Cursor := TXLSRowCursor.Create;
  try
    Cursor.FirstRow := 2;        // den Kopf-Bereich überspringen
    Cursor.IncludeColumn(1);     // nur diese beiden Spalten dekodieren
    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;               // gewöhnliches Break; kein Abbruch-Flag, kein Sentinel
        end;
      until not Cursor.FindNext;
  finally
    Cursor.Free;                 // der Destruktor beendet den Durchlauf
  end;
end;

Projektion und Bereich werden vor dem Durchlauf gesetzt, nicht nachträglich gefiltert. FirstRow, LastRow, IncludeColumn, ClearColumnProjection, IncludeFormulaText, DetectDates und DetectTextTypes werden alle in den Backends beachtet, sodass eine nicht gewählte Spalte ihren Wert, ihren Formelstring oder ihr Rich-Text-Payload erst gar nicht allokiert — die Regression-Suite beweist das mit 16-KiB-Formeln und gecachten Strings, die nie materialisiert werden, wenn ihre Spalte nicht projiziert ist. Diese Optionen sind während eines aktiven Durchlaufs bewusst eingefroren und werden am EOF, bei SelectSheet oder nach Close wieder beschreibbar, sodass ein Scan nie zwei Dekodierungsverträge mischen kann. Wenn Sie nur das Sheet-Inventar brauchen statt der Zeilen, ist Metadata-only- und selektives Sheet-Laden der günstigere Einsprungpunkt

Ein Backend pro Format, eine Scanning-Schleife je

Jedes Format hat genau einen Forward-Scanner in HotXLS, und sowohl der Pull-Cursor als auch der Callback-Reader treiben denselben Scanner an. TXLSXForwardRowBackend ist die einzige Worksheet-SAX-State-Machine für ECMA-376 Part 1 §18.3 Sheet-Parts, hält den XML-Reader, die Shared-Formula-Tabelle und den Rich-Text-Parser und rückt pro Aufruf exakt eine physische <row>-Grenze vor. TXLSBiffForwardParser besitzt die Globals, die Sheet-Auswahl und das Zeilen-Vorrücken für den [MS-XLS]-Record-Stream; ihn pausierbar zu machen erzeugte die schärfste Einschränkung des ganzen Designs, denn eine gecachte String-Formel ist ein Formula-Record, unmittelbar gefolgt von einem String-Record, also darf ein Pausierpunkt pro Zeile nie zwischen die beiden fallen. TXLSForwardTextBackend hält einen BOM-bewussten Reader, das aktive Trennzeichen und einen logischen Record — CSV erschnüffelt Komma, Semikolon, Tab oder Pipe aus dem ersten Record und ignoriert dabei Anführungszeichen, und mehrzeilige quoted Felder werden mit #10 verbunden, sodass die Zeilennummer logischen Records folgt statt physischen Zeilenumbrüchen. TXLSForwardOdsBackend hält eine einzige physische Zeilenvorlage für OpenDocument-§9-Tabellen, behandelt table:number-rows-repeated als Restzähler statt als Expansion und rückt über abgedeckte Zellen hinweg, ohne Werte auszugeben. Der Streaming-Direct-Reader teilt denselben Shared-String- und Date-Stil-Loader

Der HotXLS-Pull-Row-Cursor dispatcht zu je einem Forward-Scanner pro Format, einem SAX-Backend für XLSX, einem Record-Parser für BIFF, einem Trennzeichen-erschnüffelnden Text-Backend und einer ODS-Zeilenvorlage, mit dem Callback-Reader obendrauf als Adapter konfiguriert
Jedes Format hat genau einen Forward-Scanner, und sowohl der Pull-Cursor als auch der Callback-Reader treiben denselben Scanner an, sodass Filter- und Fehlersemantik nicht auseinanderdriften können

Warum sechs States statt einem Eof-Flag?

Weil ein einziges Boolean vier verschiedene Situationen ununterscheidbar macht, und Aufrufer sich bei allen vergreifen. TXLSRowCursorState benennt sie explizit

  • xrcsClosed — keine Quelle ist offen
  • xrcsBeforeFirst — geöffnet oder umgezielt, noch keine Zeile gelesen
  • xrcsActive — steht auf einer gültigen Zeile
  • xrcsEof — das Sheet wurde bis zum Ende konsumiert
  • xrcsCancelled — der Aufrufer hat den Durchlauf bewusst gestoppt
  • xrcsFaulted — der Durchlauf scheiterte, und die ursprüngliche Exception wurde geworfen

Die letzte Unterscheidung ist die, die in der Produktion zählt. Ein fehlender Worksheet-Part oder ein gescheiterter Durchlaufstart behält sein EReadError und versetzt den Cursor in xrcsFaulted; es wird nie zu einem schlichten False herabgestuft, das ein Aufrufer als „dieses Sheet war leer“ lesen würde. Cancel ist bewusst schmaler als Close: Er schließt das aktuelle Worksheet-Backend und seinen Inflate-Substream und invalidiert die aktuelle Zeile, gibt aber weder das ZIP-Archiv noch den Quellstream frei, und ihn zweimal aufzurufen ist ein No-op. Nach einem Cancel setzen Sie fort, indem Sie explizit SelectSheet aufrufen — der Cursor startet keinen Durchlauf stillschweigend für Sie neu. Stream-Ownership folgt derselben defensiven Regel: xsoBorrowed ist der Default und stellt die Stream-Position beim Schließen wieder her, xsoOwned überträgt die Ownership erst, nachdem Open bereits erfolgreich war, sodass ein gescheitertes Open nie einen Stream freigibt, den der Aufrufer noch hält

Die sechs States des HotXLS-Row-Cursors mit den Übergängen zwischen ihnen, das zeigt, wie Cancel einen aktiven Durchlauf zu Cancelled versetzt, ein gescheiterter Durchlaufstart ihn zu Faulted versetzt, und wie sich beide vom Sheet-Ende unterscheiden
Sechs benannte States halten ein leeres Sheet, einen bewussten Stopp und einen gescheiterten Durchlauf unterscheidbar, was ein einziges Eof-Boolean nicht kann
var
  Cursor: TXLSRowCursor;
  Src: TFileStream;
begin
  Src := TFileStream.Create('quarter.ods', fmOpenRead or fmShareDenyWrite);
  try
    Cursor := TXLSRowCursor.Create;
    try
      // xsoBorrowed: Der Cursor gibt Src nie frei, und Close stellt die
      // Position wieder her, die der Stream hatte, als Open aufgerufen wurde
      if not Cursor.Open(Src, xffAuto, xsoBorrowed) then
        Exit;

      if Cursor.FindFirst then
        repeat
          if UserPressedStop then
          begin
            Cursor.Cancel;   // schließt nur das Worksheet-Backend und
            Break;           // seinen Inflate-Substream; 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;                // noch unser, noch gültig, Position wiederhergestellt
  end;
end;

Die aktuelle Zeile leihen, ohne sie zu kopieren

IXLSRowCursorView reicht eine Zeile an eine andere Routine weiter, ohne das Zellarray zu duplizieren. Der View speichert einen geteilten Guard, der den Cursor-Zeiger plus einen UInt64-Generationszähler hält; Vorrücken, Sheet-Auswahl, Cancel, Schließen und Zerstören des Cursors inkrementieren alle diese Generation, und die Zerstörung räumt zusätzlich den Guard-Owner ab. So kann ein veralteter View keinen freigegebenen Speicher lesen: Valid ist eine exception-freie Sonde, die Sie jederzeit aufrufen können, während jedes andere Mitglied zuerst validiert und EXLSRowCursorViewInvalidated wirft. Seien Sie ehrlich, was dieser Vertrag ist — es ist Lifetime-Fail-fast, keine Thread-Safety-Garantie, und er lizenziert nicht, aus einem zweiten Thread eine Zeile zu lesen, während der erste den Cursor vorrückt

var
  View: IXLSRowCursorView;
  Cell: TXLSRowCursorCell;
  I: Integer;
begin
  if Cursor.FindFirst then
    repeat
      View := Cursor.CurrentRowView;      // leiht; kein Zellarray wird kopiert
      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-Forward-Reads behalten das
        else if Cell.Kind = xdkEmpty then //   gecachte Ergebnis, nicht die Tokens
          UseStyleOnly(Cell.StyleIndex)   // Blank / MulBlank sind echte Zellen
        else
          UseValue(Cell.Col, Cell.Value);
      end;
    until not Cursor.FindNext;

  // Das Interface überlebt die Schleife, aber die Zeile dahinter nicht
  if not View.Valid then    // Valid wirft nie; Cells[] würde jetzt werfen
    View := nil;            // EXLSRowCursorViewInvalidated
end;

PeakRowBufferedBytes — und was er beweisen darf

PeakRowBufferedBytes existiert, um zu demonstrieren, dass der Speicher der Zeilenbreite folgt statt der Zeilenzahl. Er akkumuliert die Zell-Records, Variants, Formelstrings und Rich-Text-Payloads der aktuellen Ausgabezeile und faltet den formatspezifischen Working Set ein — den CSV-Logikrecord, die ODS-physische Zeilenvorlage, den BIFF-Record-Peak oder die XLSX-Rohzelle, die gerade dekodiert wird. Lesen Sie ihn zusammen mit SheetPassesStarted, das zählt, wie viele Worksheet-Durchläufe tatsächlich begannen. Zwei Einschränkungen halten ihn ehrlich: Die Zahl ist eine Schätzung, keine exakte Heap-Bilanz, und sie ist monotonisch seit dem letzten Open, also ein Debugging- und Regressionsinstrument statt einer Live-Messuhr. Für das breitere Bild, wohin Zeit und Bytes bei sehr großen Büchern gehen, siehe Large-Workbook-Performance in Delphi

Ein HotXLS-Vergleich, der ein Whole-Sheet-Laden, das jede Zeile resident hält, gegen den Pull-Cursor stellt, der nur die aktuelle Zeile plus einen Format-Working-Set hält, was PeakRowBufferedBytes akkumuliert und meldet
PeakRowBufferedBytes akkumuliert die aktuelle Ausgabezeile plus den formatspezifischen Working Set, also folgt der Speicher der Zeilenbreite statt der Zeilenzahl des Sheets

Der Push-Reader wurde ein Adapter — und was der Cursor nicht tut

TXLSForwardReader trägt keine separaten XLSX-, BIFF- und Text-Scanning-Einsprungpunkte mehr. Er konfiguriert einen Cursor, wandert ihn durch und übersetzt die aktuelle Zeile in OnSheet- und OnCell-Events, weshalb die beiden Fassaden bei Filterung, Formel-State oder Fehlerbehandlung nicht mehr auseinanderdriften können. Zwei Konsequenzen sind vor dem Upgrade wissenswert: Der Callback-SheetIndex ist jetzt auf TXLSForwardReader einheitlich einsbasiert (TXLSDirectReader behält seinen bestehenden nullbasierten Event-Vertrag), und OnSheet feuert vor SelectSheet, sodass das Setzen von SkipSheet bedeutet, dass der Worksheet-Part nie geöffnet oder dekomprimiert wird. Die Grenzen sind genauso explizit: Die Arbeitsmappe darf während eines aktiven Durchlaufs nicht verändert werden, Canceln verlangt einen expliziten Neustart, und der BIFF-Forward-Pfad dekompiliert nie Formel-Tokens, also melden classic Formelzellen HasFormula true mit FormulaTextAvailable false und reichen Ihnen das gecachte Ergebnis, statt einen leeren Formelstring zu erfinden. Der Row-Cursor und sein Adapter bestanden 1.298 Checks auf Delphi Win32 und Win64 plus dem C++Builder-37.0-Win64-statischen Paket

Wenn Sie einen Pull-Cursor gegen den Loader abwägen, den Sie jetzt haben, lautet die Frage nicht, welcher schneller parst, sondern welcher Sie die Austrittsbedingung schreiben lässt, die Sie tatsächlich brauchen. Vollständige Komponentendetails, unterstützte IDE-Versionen und Lizenzierung finden Sie auf der HotXLS Delphi spreadsheet component page