Articolo tecnico

Cursore pull di righe per XLS, XLSX, ODS e CSV in Delphi

HotXLS legge sorgenti .xls, .xlsx, .xlsm, .ods, CSV e TSV attraverso un unico cursore pull di righe, TXLSRowCursor, il cui FindFirst e FindNext avanzano di una riga logica alla volta mentre solo quella riga resta in memoria. Una macchina a stati a sei valori separa before-first da EOF, cancelled e faulted, e il più vecchio reader a callback è ora un adattatore sopra lo stesso cursore

Lo scenario è familiare a chiunque abbia spedito una funzionalità di importazione. Arriva un .xlsx da 200 MB, cablate un handler OnCell, e il primo requisito dopo "leggilo" è "fermati dopo i primi cento movimenti rovesciati". Ora la forma del vostro codice vi combatte: il loop vive dentro la libreria, il vostro handler deve alzare un flag, ogni callback successivo scatta comunque finché il parser se ne accorge, e lo stato accumulato — quanti hit finora, quale colonna ha matchato, cosa fare dopo — deve vivere in campi su una classe che esiste solo per dare al callback un posto dove sedersi. Nulla di questo è un problema di parsing. È un problema di flusso di controllo, ed è quello che un cursore pull rimuove

Quanto costa davvero una callback push a 200 MB

Il push inverte il controllo, e l'inversione è esattamente ciò che un chiamante che filtra o joina non può permettersi. Con una API a callback la libreria possiede il loop, quindi il chiamante non può usare Break, non può interpolare due sorgenti, non può consegnare il reader a una routine che si aspetta di essere guidata, e non può esprimere "sbircia la riga successiva prima di decidere" senza bufferizzare. Il costo non è il throughput — un percorso a callback SAX ben scritto streama bene — è che ogni consumatore non banale si coltiva una piccola macchina a stati propria per simulare il loop che non gli era permesso scrivere. Moltiplicate questo per quattro formati di file, storicamente ognuno con il proprio punto di ingresso di scansione, e le semantiche di filtro, formula ed errore iniziano a divergere tra loro, che è precisamente la divergenza che HotXLS si proponeva di chiudere

Come cambia un cursore pull il vostro codice chiamante?

Vi restituisce il loop, e con esso il normale flusso di controllo Pascal. TXLSRowCursor.Open accetta un nome file o uno TStream, rileva il formato, carica shared string e metadati di stile data una volta sola, e seleziona il foglio 1. SelectSheet (a base uno) o SelectSheetByName ridirige su un altro worksheet e azzera il cursore a before-first. FindFirst e FindNext poi si posizionano sulla prossima riga popolata — le righe senza celle decodificabili vengono saltate, quindi RowIndex può saltare — e la riga corrente è esposta come CellCount, Cells[] e ValueByCol[], tutti a base uno sull'asse delle colonne. Uscire dal loop è un Break

var
  Cursor: TXLSRowCursor;
  Hits: Integer;
begin
  Cursor := TXLSRowCursor.Create;
  try
    Cursor.FirstRow := 2;        // salta la banda dell'header
    Cursor.IncludeColumn(1);     // decodifica solo queste due colonne
    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 ordinario; niente flag di abort, niente sentinella
        end;
      until not Cursor.FindNext;
  finally
    Cursor.Free;                 // il distruttore termina il pass
  end;
end;

Proiezione e intervallo si impostano prima del pass, non si filtrano dopo. FirstRow, LastRow, IncludeColumn, ClearColumnProjection, IncludeFormulaText, DetectDates e DetectTextTypes sono tutti onorati dentro i backend, così una colonna non selezionata non alloca mai in partenza il proprio valore, stringa di formula o payload rich-text — la suite di regression lo dimostra con formule da 16 KiB e stringhe in cache che non vengono mai materializzate quando la loro colonna non è proiettata. Queste opzioni sono deliberatamente congelate mentre un pass è attivo e tornano scrivibili a EOF, su SelectSheet, o dopo Close, così una singola scansione non può mai mescolare due contratti di decodifica. Se vi serve solo l'inventario dei fogli anziché le righe, il caricamento solo metadati e selettivo dei fogli è il punto di ingresso più economico

Un backend per formato, un loop di scansione ciascuno

Ogni formato ha esattamente uno scanner forward dentro HotXLS, e sia il cursore pull sia il reader a callback guidano quello stesso scanner. TXLSXForwardRowBackend è l'unica macchina a stati SAX di worksheet per le parti sheet di ECMA-376 Part 1 §18.3, e trattiene il reader XML, la tabella delle formule condivise e il parser rich-text, avanzando di esattamente un confine fisico <row> per chiamata. TXLSBiffForwardParser possiede i global, la selezione del foglio e l'avanzamento di riga per lo stream di record [MS-XLS]; renderlo pausabile ha prodotto il vincolo più tagliente dell'intero progetto, perché una formula con stringa in cache è un record Formula immediatamente seguito da un record String, quindi un punto di sospensione per riga non deve mai cadere tra i due. TXLSForwardTextBackend conserva un reader consapevole del BOM, il delimitatore attivo e un record logico — il CSV fiuta virgola, punto e virgola, tab o pipe dal primo record ignorando i caratteri tra virgolette, e i campi tra virgolette multi-riga vengono uniti con #10 così il numero di riga segue i record logici anziché i newline fisici. TXLSForwardOdsBackend conserva un singolo template di riga fisica per le tabelle OpenDocument §9, tratta table:number-rows-repeated come un conteggio residuo anziché un'espansione, e avanza oltre le celle coperte senza emettere valori. Il reader diretto in streaming condivide lo stesso loader di shared string e stile data

Il cursore pull di righe HotXLS che dispatcha a uno scanner forward per formato, un backend SAX per XLSX, un parser di record per BIFF, un backend testuale che fiuta i delimitatori e un template di riga ODS, con il reader a callback configurato sopra come adattatore
Ogni formato ha esattamente uno scanner forward, e sia il cursore pull sia il reader a callback guidano quello stesso scanner, così le semantiche di filtro ed errore non possono divergere

Perché sei stati invece di un solo flag Eof?

Perché un singolo booleano rende quattro situazioni diverse indistinguibili, e i chiamanti indovinano male su tutte. TXLSRowCursorState le nomina esplicitamente

  • xrcsClosed — nessuna sorgente è aperta
  • xrcsBeforeFirst — aperto o ridiretto, nessuna riga ancora letta
  • xrcsActive — in piedi su una riga valida
  • xrcsEof — il foglio è stato consumato fino alla fine
  • xrcsCancelled — il chiamante ha fermato il pass deliberatamente
  • xrcsFaulted — il pass è fallito e l'eccezione originale è stata sollevata

Quell'ultima distinzione è quella che conta in produzione. Una parte di worksheet mancante o un inizio di pass fallito conserva il proprio EReadError e porta il cursore a xrcsFaulted; non viene mai degradato in un semplice False che un chiamante leggerebbe come "questo foglio era vuoto". Cancel è deliberatamente più stretto di Close: chiude il backend del worksheet corrente e il suo substream inflate e invalida la riga corrente, ma non rilascia l'archivio ZIP né lo stream sorgente, e chiamarlo due volte è un no-op. Dopo un cancel riprendete chiamando esplicitamente SelectSheet — il cursore non riavvierà quietly un pass per vostro conto. La proprietà degli stream segue la stessa regola difensiva: xsoBorrowed è il default e ripristina la posizione dello stream alla chiusura, xsoOwned trasferisce la proprietà solo dopo che Open è già riuscito, così un open fallito non libera mai uno stream che il chiamante trattiene ancora

I sei stati del cursore di righe HotXLS con le transizioni tra loro, che mostrano Cancel che porta un pass attivo a cancelled, un inizio di pass fallito che lo porta a faulted, e come entrambi restino distinti dalla fine del foglio
Sei stati nominati tengono un foglio vuoto, un arresto deliberato e un pass fallito distinguibili, cosa che un singolo booleano Eof non può fare
var
  Cursor: TXLSRowCursor;
  Src: TFileStream;
begin
  Src := TFileStream.Create('quarter.ods', fmOpenRead or fmShareDenyWrite);
  try
    Cursor := TXLSRowCursor.Create;
    try
      // xsoBorrowed: il cursore non libera mai Src, e Close ripristina la
      // posizione che lo stream aveva quando Open è stato chiamato
      if not Cursor.Open(Src, xffAuto, xsoBorrowed) then
        Exit;

      if Cursor.FindFirst then
        repeat
          if UserPressedStop then
          begin
            Cursor.Cancel;   // chiude il backend del worksheet e il suo
            Break;           // substream inflate soltanto; idempotente
          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;                // ancora nostra, ancora valida, posizione ripristinata
  end;
end;

Prendere in prestito la riga corrente senza copiarla

IXLSRowCursorView consegna una riga a un'altra routine senza duplicare l'array di celle. La vista conserva una guardia condivisa che tiene il puntatore del cursore più un contatore di generazione UInt64; avanzare, selezionare un foglio, cancellare, chiudere e distruggere il cursore incrementano tutti quella generazione, e la distruzione in più azzera il proprietario della guardia. Così una vista stantia non può leggere memoria liberata: Valid è una sonda senza eccezioni che potete chiamare in qualsiasi momento, mentre ogni altro membro valida prima e solleva EXLSRowCursorViewInvalidated. Siate onesti su cosa sia questo contratto — è fail-fast sul lifetime, non una garanzia di thread-safety, e non autorizza a leggere una riga da un secondo thread mentre il primo avanza il cursore

var
  View: IXLSRowCursorView;
  Cell: TXLSRowCursorCell;
  I: Integer;
begin
  if Cursor.FindFirst then
    repeat
      View := Cursor.CurrentRowView;      // prende in prestito; nessun array di celle copiato
      for I := 0 to View.CellCount - 1 do
      begin
        Cell := View.Cells[I];
        if Cell.HasFormula and not Cell.FormulaTextAvailable then
          UseCachedResult(Cell.Value)     // le letture forward BIFF conservano il
        else if Cell.Kind = xdkEmpty then //   risultato in cache, non i token
          UseStyleOnly(Cell.StyleIndex)   // Blank / MulBlank sono celle reali
        else
          UseValue(Cell.Col, Cell.Value);
      end;
    until not Cursor.FindNext;

  // L'interfaccia sopravvive al loop, ma la riga dietro di essa no
  if not View.Valid then    // Valid non lancia mai; Cells[] ora lancerebbe
    View := nil;            // EXLSRowCursorViewInvalidated
end;

PeakRowBufferedBytes, e cosa gli è permesso dimostrare

PeakRowBufferedBytes esiste per dimostrare che la memoria segue la larghezza della riga anziché il numero di righe. Accumula i record di cella, i Variant, le stringhe di formula e i payload rich-text della riga di output corrente e incorpora il working set specifico del formato — il record logico CSV, il template di riga fisica ODS, il picco di record BIFF, o la cella raw XLSX attualmente in decodifica. Leggetelo insieme a SheetPassesStarted, che conta quanti pass di worksheet sono effettivamente iniziati. Due avvertenze lo tengono onesto: la cifra è una stima, non una contabilità heap esatta, ed è monotona dall'Open più recente, quindi è uno strumento di debugging e regression anziché un misuratore in tempo reale. Per il quadro più ampio di dove vanno tempo e byte su libri molto grandi, vedere le prestazioni su cartelle di lavoro grandi in Delphi

Un confronto HotXLS che mostra un caricamento dell'intero foglio che tiene residente ogni riga contro il cursore pull che trattiene solo la riga corrente più un working set di formato, che è ciò che PeakRowBufferedBytes accumula e riporta
PeakRowBufferedBytes accumula la riga di output corrente più il working set specifico del formato, così la memoria segue quanto è larga una riga anziché quante righe ha il foglio

Il reader push è diventato un adattatore, e cosa il cursore non farà

TXLSForwardReader non porta più punti di ingresso di scansione separati per XLSX, BIFF e testo. Configura un cursore, lo percorre, e traduce la riga corrente negli eventi OnSheet e OnCell, ed è per questo che le due facciate non possono più divergere su filtro, stato delle formule o gestione degli errori. Due conseguenze valgono la pena di essere note prima di aggiornare: la callback SheetIndex è ora uniformemente a base uno su TXLSForwardReader (TXLSDirectReader conserva il suo esistente contratto di eventi a base zero), e OnSheet scatta prima di SelectSheet, quindi impostare SkipSheet significa che la parte del worksheet non viene mai aperta o decompressa affatto. I confini sono altrettanto espliciti: la cartella di lavoro non va modificata mentre un pass è attivo, la cancellazione richiede un riavvio esplicito, e il percorso forward BIFF non decompila mai i token delle formule, così le celle formula classic riportano HasFormula true con FormulaTextAvailable false e consegnano il risultato in cache invece di inventare una stringa di formula vuota. Il cursore di righe e il suo adattatore hanno passato 1.298 controlli su Delphi Win32 e Win64 più il pacchetto statico C++Builder 37.0 Win64

Se state pesando un cursore pull contro il loader che avete ora, la domanda da porsi non è quale parsi più veloce ma quale vi lascia scrivere la condizione di uscita di cui avete davvero bisogno. Dettagli completi del componente, versioni IDE supportate e licenze sono sulla pagina del componente foglio di calcolo HotXLS per Delphi