Technical Article

Scrivere XLSX da un milione di righe in Delphi a memoria costante

Un lavoro di reportistica funziona bene per un anno. Costruisce una cartella di lavoro, riempie un foglio con ciò che restituisce la query e lo salva. Poi un cliente con cinque anni di cronologia chiede un'esportazione completa, il conteggio delle righe supera il milione e il processo si interrompe con un errore di esaurimento della memoria molto prima che il file raggiunga il disco. Non c'era nulla di sbagliato nel codice. Teneva semplicemente in RAM l'intera cartella di lavoro per poterla serializzare alla fine, e la memoria necessaria cresceva di pari passo con il numero di righe che gli veniva chiesto di scrivere

La soluzione non è una macchina più grande. È un modello di scrittura diverso. Lo scrittore diretto in streaming in HotXLS emette il pacchetto OOXML in modo incrementale man mano che arrivano le righe, perciò la memoria che utilizza non dipende dal numero di righe che si scrivono. È la controparte in scrittura del lettore streaming: laddove il lettore percorre un foglio enorme senza costruire un albero di celle, lo scrittore ne produce uno anch'esso senza costruire alcun albero di celle

Perché il normale percorso di salvataggio cresce con i dati

Il normale percorso TXLSXWorkbook costruisce prima un modello a oggetti completo. Ogni cella, con il suo valore, tipo e riferimento di stile, vive come un oggetto in memoria finché non si chiama il salvataggio, momento in cui l'intero albero viene serializzato nel pacchetto. Quel modello è quello giusto quando si desidera leggere un foglio, modificarlo, ricalcolarlo e riscriverlo, perché l'accesso casuale a qualsiasi cella è esattamente ciò di cui ha bisogno la modifica. È quello sbagliato quando si stanno riversando righe in una direzione senza mai guardare indietro, perché si paga per mantenere ogni riga residente per nessun vantaggio. Un milione di righe di oggetti è un milione di righe di oggetti sia che le si rivisiti o meno

Lo scrittore streaming rimuove l'albero. Non appena una cella viene scritta, diventa un insieme di byte nella parte del foglio di lavoro e quei byte vengono consegnati all'output zip. Il flusso del foglio di lavoro è l'unico buffer che cresce, e cresce sul lato dell'output, non come oggetti Delphi attivi nell'heap. Ciò che rimane residente è una quantità fissa di contabilità: i nomi dei fogli, alcuni flag, il numero di riga corrente, un contatore di celle. Quell'insieme non cambia tra la riga uno e la riga dieci milioni

La tabella delle stringhe condivise è la trappola e le stringhe inline sono la via d'uscita

La maggior parte degli scrittori XLSX in streaming si comporta bene finché non incontra del testo. Il formato OOXML normalmente memorizza le stringhe in una tabella di stringhe condivise: ogni stringa distinta viene scritta una volta in una parte separata, e ogni cella che contiene quella stringa porta con sé un indice nella tabella invece del testo. È una buona ottimizzazione dello spazio per i file pieni di etichette ripetute, ed è l'impostazione predefinita utilizzata dal normale percorso di salvataggio. Il problema per uno scrittore in streaming è brutale. Per deduplicare, la tabella deve rimanere residente per l'intero lavoro, perché qualsiasi riga ancora a venire potrebbe ripetere una stringa di una riga già scritta, e solo una mappa in memoria completa delle stringhe già viste può assegnare l'indice corretto. Quindi l'unica struttura che uno scrittore in streaming non può trasmettere è proprio la struttura che dovrebbe rimpicciolire il file. I dati ricchi di testo vanificano lo streaming per cui eri venuto

Lo scrittore diretto elude completamente la tabella. Le stringhe vengono scritte in linea (inline), come celle t="inlineStr" il cui testo si trova direttamente all'interno della cella con un elemento <is><t>. Non c'è alcuna tabella da accumulare né alcuna mappa di stringhe viste da conservare, quindi le colonne di testo non costano più memoria di quelle numeriche. Il compromesso è esplicito e vale la pena dichiararlo apertamente. Le stringhe inline ripetono lo stesso testo ovunque si presenti, perciò un file con molte etichette identiche sarà più grande su disco rispetto all'equivalente con stringhe condivise. Si spende la dimensione del file per acquistare memoria costante. Per un'esportazione a singolo passaggio (one-pass) questo è il lato giusto del compromesso e la compressione zip assorbe comunque gran parte delle ripetizioni in uscita

La tabella degli stili arriva alla fine, con un formato data

Gli stili presentano la stessa tensione delle stringhe. Una cartella di lavoro fa riferimento alla propria formattazione tramite una parte degli stili, e uno scrittore in streaming non può mantenere una tavolozza di stili crescente al passo con le celle che ha già scaricato (flushed). Lo scrittore diretto risponde a questo problema mantenendo la tabella degli stili piccola e fissa, emettendola alla chiusura anziché in anticipo. Un formato di cella predefinito copre le celle ordinarie. Un formato numerico di data copre le date, registrato con un codice di formato yyyy-mm-dd in una posizione nota nell'elenco dei formati di cella

Quel formato della data è il motivo per cui WriteDateTime esiste come chiamata a sé stante. Excel non ha un tipo di data nativo; una data è un numero che indossa un formato di data. WriteDateTime scrive il valore come un semplice numero seriale e contrassegna la cella con quell'unico stile di data, in modo che il foglio di calcolo la renda come data anziché come intero a cinque cifre. Il numero seriale che scrive è importante per il round-tripping. Memorizza il valore TDateTime direttamente sotto il sistema di date del 1900, che è la stessa convenzione utilizzata dal normale percorso di salvataggio TXLSXWorkbook. Poiché entrambi i percorsi concordano sul numero seriale, un file prodotto dallo scrittore streaming viene riletto dal lettore HotXLS e si apre in Excel con le date previste, senza alcun errore di uno (off-by-one) o sorprese sull'epoca tra lo scrittore e il lettore

L'ordine è obbligatorio, perché i byte sono già andati

Lo streaming acquista il suo profilo di memoria con una regola che bisogna onorare. L'output viene emesso man mano che si procede e non può essere rivisitato, quindi tutto deve essere scritto nell'ordine in cui appare nel file. All'interno di una riga, le celle vanno in ordine di colonna crescente. All'interno di un foglio, le righe vanno in ordine crescente. Non esiste alcun buffer che permetta allo scrittore di ordinare le celle a posteriori, perché la riga chiusa un attimo fa è già diventata byte nel flusso zip e non è più raggiungibile. Se gli si passa la colonna 5 e poi la colonna 2 nella stessa riga, l'output risulterà malformato, poiché lo scrittore emette semplicemente ciò che gli si dà nella sequenza in cui glielo si dà

L'API della riga presenta una piccola comodità per il caso d'uso comune. AddRow accetta un indice di riga su base 1, ma passandogli 0 si intende "prendi la riga successiva a quella precedente", cosicché un riempimento sequenziale non deve tracciare e passare un contatore incrementale. Ogni AddRow chiude la riga che la precede e ogni AddSheet chiude il foglio precedente, perciò non si termina mai esplicitamente una riga o un foglio. Si inizia semplicemente quello successivo e lo scrittore finalizza per te la struttura aperta

L'escaping viene gestito laddove il testo entra nell'XML

Qualsiasi testo che si scrive diventa parte di un documento XML, pertanto le cinque entità XML predefinite devono subire l'escaping, altrimenti il pacchetto risulterà non valido non appena un valore conterrà una e commerciale (ampersand) o una parentesi angolare. Lo scrittore esegue l'escaping di &, <, >, " e ' sia sul testo delle stringhe inline sia sul testo delle formule, i due punti in cui i caratteri forniti dal chiamante finiscono all'interno del markup. Tu passi una WideString grezza e lo scrittore la rende sicura. Un nome di prodotto come Smith & Co <Ltd> o una formula che fa riferimento a un nome di foglio tra virgolette ne esce come XML ben formato senza alcun bisogno di escaping da parte tua

Ciclo di vita e perché Destroy chiude comunque

Completare il pacchetto è ciò che scrive la parte della cartella di lavoro, la parte degli stili, le parti dei tipi di contenuto (content-types) e delle relazioni e infine la directory centrale dello zip. Quel lavoro avviene in Close. Un pacchetto che non viene mai chiuso è uno zip incompleto che nessun programma per fogli di calcolo aprirà, quindi la chiusura non è una pulizia facoltativa, ma il passaggio che rende valido il file. Per proteggersi da una Close dimenticata in un percorso di errore, Destroy esegue una chiusura al meglio delle sue possibilità (best-effort) se il pacchetto è ancora aperto, cosicché liberando lo scrittore non si perde l'oggetto zip sottostante nemmeno quando un'eccezione ha saltato la chiamata esplicita. Il pattern affidabile rimane comunque quello ordinario di Delphi: scrivere all'interno di un try, chiamare Close e liberare in finally

Trasmettere in streaming un grande foglio da un capo all'altro

La forma del lavoro è: iniziare, aggiungere un foglio, versare le righe, chiudere. L'esempio seguente scrive una riga di intestazione e poi una lunga serie di righe di dati tipizzati, mescolando stringhe, numeri, una formula senza risultato memorizzato nella cache e una data. La memoria che utilizza per dieci righe o per dieci milioni di righe è la stessa, perché ogni cella parte verso il flusso zip non appena viene scritta

uses
  lxDirectWrite;

procedure StreamReport(const Path: string; RowCount: Integer);
var
  W: TXLSDirectWriter;
  I: Integer;
begin
  W := TXLSDirectWriter.Create;
  try
    W.BeginFile(Path);
    W.AddSheet('Sales');

    // Header row, written in ascending column order
    W.AddRow(1);
    W.WriteString(1, 'Item');
    W.WriteString(2, 'Qty');
    W.WriteString(3, 'Price');
    W.WriteString(4, 'Total');
    W.WriteString(5, 'Date');

    // Data rows; pass 0 to AddRow to take the next row automatically
    for I := 1 to RowCount do
    begin
      W.AddRow(0);
      W.WriteString(1, 'Item ' + IntToStr(I));
      W.WriteNumber(2, I);
      W.WriteNumber(3, 1.5 + (I mod 10));
      W.WriteFormula(4, Format('B%d*C%d', [I + 1, I + 1]));
      W.WriteDateTime(5, EncodeDate(2026, 1, 1) + I);
    end;

    W.Close;                       // finalises the package
  finally
    W.Free;
  end;
end;

Un secondo foglio si realizza semplicemente con un'altra chiamata AddSheet prima di continuare, e lo scrittore chiude il primo foglio mentre apre il secondo. I flag booleani usano WriteBoolean, che scrive una cella booleana tipizzata anziché il testo "True". Se si desidera confermare che il file sia integro e faccia andata e ritorno (round-trips) senza problemi, la proprietà CellCount riporta quante celle sono state scritte, e leggendo indietro il risultato con il lettore streaming si dovrebbe ottenere lo stesso totale

  // A second sheet of typed flags after the data sheet above
  W.AddSheet('Flags');
  W.AddRow(1);
  W.WriteString(1, 'Name');
  W.WriteString(2, 'Active');
  W.AddRow(0);
  W.WriteString(1, 'alpha');
  W.WriteBoolean(2, True);

  WriteLn(Format('wrote %d cells', [W.CellCount]));

Scrivere in un flusso (stream) invece che in un file utilizza lo stesso codice con BeginStream al posto di BeginFile, il che consente a un server di inviare la cartella di lavoro a una risposta HTTP o a un flusso di memoria senza un file temporaneo su disco. Lo scrittore non è proprietario del flusso che si passa, quindi se ne mantiene il controllo del ciclo di vita

Quando il lavoro è un endpoint del server che costruisce cartelle di lavoro su richiesta, i modelli in scritture in streaming per server e lavori batch mostrano come collegare tutto ciò a un gestore di richieste e a un'esportazione pianificata. Quando la domanda riguarda il costo più ampio di cartelle di lavoro molto grandi, sia in lettura che in scrittura, l'articolo prestazioni di cartelle di lavoro di grandi dimensioni in Delphi copre dove vanno a finire effettivamente il tempo e la memoria. Lo scrittore diretto in streaming viene fornito come parte di HotXLS Component per Delphi e C++Builder, insieme alle API complete di lettura, modifica e salvataggio trattate altrove su questo blog