Technical Article

Skriv XLSX med en million rækker i Delphi med konstant hukommelse

Et rapporteringsjob kører fint i et år. Det bygger en projektmappe, fylder et ark med, hvad foresporgsel returnerer, og gemmer det. Så anmoder en kunde med fem års historik om et fuldt eksport, rækkeantallet krydser en million, og processen dør med en out-of-memory-fejl længe før filen når disken. Der var ikke noget galt med koden. Den holdt hele projektmappen i RAM, så den kunne serialisere den til sidst, og hukommelsen, den behøvede, voksede i takt med antallet af rækker, den blev bedt om at skrive

Løsningen er ikke en større maskine. Det er en anden skrivermodel. Den streaming direkte forfatter i HotXLS udsender OOXML-pakken trinvist, når rækker ankommer, så den hukommelse, den bruger, ikke afhænger af, hvor mange rækker du skriver. Det er skriv-sidens modstykke til streaming-læseren: hvor læseren gennemløber et kæmpe ark uden at bygge et celletræ, producerer forfatteren et uden at bygge et celletræ heller

Hvorfor den normale gem-sti vokser med dataene

Den regulære TXLSXWorkbook-sti bygger først en fuld objektmodel. Hver celle, med sin værdi, type og stilreference, lever som et objekt i hukommelsen, indtil du kalder gem, på hvilket tidspunkt hele træet serialiseres til pakken. Den model er den rette, når du vil læse et ark, redigere det, genberegne og skrive det tilbage, fordi tilfældig adgang til enhver celle er præcis, hvad redigering behøver. Det er den forkerte, når du hælder rækker i én retning og aldrig ser tilbage, for du betaler for at holde hver række resident uden fordel. En million rækker af objekter er en million rækker af objekter, uanset om du nogensinde genser dem eller ej

Streaming-forfatteren fjerner træet. Så snart en celle er skrevet, bliver den bytes i regnearks-parten, og disse bytes overdrages til zip-outputtet. Regnearks-streamen er den eneste buffer, der vokser, og den vokser på outputsiden, ikke som levende Delphi-objekter på heapen. Det, der forbliver resident, er en fast mængde bogføring: arknavnene, et par flag, det aktuelle rækkenummer, en celletæller. Det sæt ændrer sig ikke mellem række ét og række ti millioner

Den delte-strengtabel er fælden, og inline-strenge er vejen ud

De fleste streaming XLSX-forfattere klarer sig godt, indtil de møder tekst. OOXML-formatet gemmer normalt strenge i en delt-strengtabel: hver distinkt streng skrives én gang til en separat part, og hver celle, der indeholder den streng, bærer et indeks i tabellen i stedet for teksten. Det er en god pladsoptimering til filer fyldt med gentagne etiketter, og det er standarden, den regulære gem-sti bruger. Problemet for en streaming-forfatter er brutalt. For at deduplikere skal tabellen forblive resident i hele jobbet, fordi enhver række, der endnu ikke er kommet, måske gentager en streng fra en række allerede skrevet, og kun et komplet in-memory-kort over sete strenge kan tildele det rette indeks. Så den éne struktur, en streaming-forfatter ikke kan streame, er den struktur, der er beregnet til at gøre filen lille. Teksttunge data slarer den streaming, du kom for

Den direkte forfatter omgår tabellen fuldstændigt. Strenge skrives inline, som t="inlineStr"-celler, hvis tekst sidder direkte inde i cellen med et <is><t>-element. Der er ingen tabel at akkumulere og ingen kort over sete strenge at holde, så tekstkolonner koster ingen mere hukommelse end numeriske. Afvejningen er eksplicit og værd at sige klart. Inline-strenge gentager den samme tekst, uanset hvor den forekommer, så en fil med mange identiske etiketter er større på disk end det delte-strengs-ækvivalent. Du bruger filstrørrelse for at købe konstant hukommelse. For et enkelt-pas-eksport er det den rette side af afvejningen, og zip-komprimering absorberer meget af gentagelsen på vejen ud alligevel

Stile præsenterer den samme spænding som strenge. En projektmappe refererer sin formatering via en stile-part, og en streaming-forfatter kan ikke holde en voksende palet af stile i trit med celler, den allerede har flushes. Den direkte forfatter besvarer dette ved at holde stiltabellen lille og fast og udsende den ved lukning frem for på forhånd. ét standard celleformat dækker ordinære celler. ét dato-talformat dækker datoer, registreret med en formatkode på yyyy-mm-dd på en kendt position på listen over celleformater

Det datoformat er grunden til, at WriteDateTime eksisterer som sit eget kald. Excel har ingen native datotype; en dato er et tal i et datoformat. WriteDateTime skriver værdien som et simpelt serielt tal og mærker cellen med den éne datostil, så regnearket gengiver den som en dato i stedet for et femcifret heltal. Det serielle tal, det skriver, er vigtigt for rundturskontrol. Det gemmer TDateTime-værdien direkte under 1900-datosystemet, som er den samme konvention, den regulære TXLSXWorkbook-gem-sti bruger. Fordi begge stier er enige om det serielle tal, læses en fil, streaming-forfatteren producerer, tilbage via HotXLS-læseren og åbnes i Excel med datoer, der stemmer overens med, hvad du ønskede, uden off-by-one eller epokeoverraskelse mellem forfatteren og læseren

Rækkefølge er obligatorisk, fordi bytes allerede er væk

Streaming køber sin hukommelsesprofil med én regel, du skal overholde. Output udsendes, når du går, og kan ikke ses igen, så alt skal skrives i den rækkefølge, det vises i filen. Inden for en række går celler i stigende kolonneordre. Inden for et ark går rækker i stigende rækkefølge. Der er ingen buffer, der lader forfatteren sortere dine celler efterfølgende, fordi den række, du lukkede for et øjeblik siden, allerede er bytes i zip-streamen og ikke længere er tilgængelig. Giv den kolonne 5 og derefter kolonne 2 i den samme række, og outputtet er malformet, da forfatteren blot udsender, hvad du giver den i den sekvens, du giver den

Række-API'en har en lille bekvemmelighed til det almindelige tilfælde. AddRow tager et 1-baseret rækkeindeks, men at sende 0 betyder at tage den næste række efter den forrige, så en sekventiel fyldning ikke skal spore og sende en stigende tæller. Hvert AddRow lukker rækken før det, og hvert AddSheet lukker arket før det, så du eksplicit aldrig afslutter en række eller et ark. Du starter den næste, og forfatteren færdigtør den åbne struktur for dig

Escaping håndteres, når tekst indsættes i XML'en

Enhver tekst, du skriver, bliver en del af et XML-dokument, så de fem foruddefinerede XML-enheder skal escapes, ellers er pakken ugyldig i det øjeblik en værdi indeholder et og-tegn eller en vinkelparentes. Forfatteren escaper &amp;, &lt;, &gt;, &quot; og &apos; for dig på både inline-strengtekst og formeltekst, de to steder, hvor kaldersupplerede tegn lander inde i mærkning. Du sender en rå WideString, og forfatteren gør den sikker. Et produktnavn som Smith &amp; Co &lt;Ltd&gt; eller en formel, der refererer til et citeret arknavn, fremkommer som velformet XML uden nogen escaping på din side

Livscyklus, og hvorfor Destroy stadig lukker

At færdiggøre pakken er det, der skriver projektmappe-parten, stile-parten, indholdstyper- og relationsparts, og endelig zip-centralbiblioteket. Det arbejde sker i Close. En pakke, der aldrig lukkes, er en ufuldstændig zip, som intet regnearks program åbner, så lukning er ikke valgfri oprydning, det er det trin, der gør filen gyldig. For at gardere mod en glemt Close i en fejlsti udfører Destroy en best-effort-lukning, hvis pakken stadig er åben, så frigivelse af forfatteren ikke lækker det underliggende zip-objekt, selv når en undtagelse sprang over det eksplicitte kald. Det pålidelige mgnster er stadig det ordinære Delphi-mønster: skriv inde i et try, kald Close, og frigiv i finally

Streaming af et stort ark fra ende til anden

Formens job er begynde, tilføj et ark, hæld rækker, luk. Eksemplet nedenfor skriver en header-række og derefter et langt løb af typede data-rækker, blanding af strenge, tal, en formel uden bufferlagret resultat og en dato. Den hukommelse, den bruger til ti rækker og til ti millioner rækker, er den samme, fordi enhver celle forlader til zip-streamen, så snart den er skrevet

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;

Et andet ark er blot endnu et AddSheet, inden du fortsætter, og forfatteren lukker det første ark, når det åbner det andet. Booleanske flag bruger WriteBoolean, som skriver en typed booleansk celle frem for teksten "True". Hvis du vil bekræfte, at filen er ok og runder rigtigt, rapporterer egenskaben CellCount, hvor mange celler der blev skrevet, og at læse resultatet tilbage med streaming-læseren bør rapportere det samme total

  // 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]));

At skrive til en stream i stedet for en fil er den samme kode med BeginStream i stedet for BeginFile, hvilket lader en server sende projektmappen til et HTTP-svar eller en hukommelsesstream uden en midlertidig fil på disk. Forfatteren ejer ikke den stream, du sender, så du beholder kontrollen over dens levetid

Når arbejdet er et server-slutpunkt, der bygger projektmapper efter behov, viser mgnstrene i streaming-skrivninger til server- og batchjobs, hvordan man tilslutter dette til en anmodningshandler og et planlagt eksport. Når spørgsmålet er den bredere omkostning ved meget store projektmapper, både læsning og skrivning, dækker stor-projektmappe-ydeevne i Delphi, hvor tids- og hukommelse faktisk går hen. Den streaming-direkte-forfatter leveres som del af HotXLS Component til Delphi og C++Builder, ved siden af de fulde læse-, redigere- og gem-API'er, der er dækket andetsteds på denne blog