Technical Article

Skriv miljonraders XLSX i Delphi med konstant minne

Ett rapporteringsjobb rullar på fint i ett år. Det bygger en arbetsbok, fyller ett blad med vad nu frågan returnerar, och sparar den. Sedan ber en kund med fem års historik om en fullständig export, radantalet passerar en miljon, och processen dör med ett felmeddelande om slut på minne långt innan filen når disken. Det var inget fel på koden. Den höll hela arbetsboken i RAM så att den kunde serialisera den på slutet, och det minne den behövde växte i takt med antalet rader den ombads skriva

Lösningen är inte en större maskin. Det är en annan skrivmodell. Den strömmande direkta skrivaren i HotXLS spottar ut OOXML-paketet inkrementellt allt eftersom raderna anländer, så det minne den använder beror inte på hur många rader du skriver. Den är motsvarigheten till den strömmande läsaren på skrivsidan: där läsaren går igenom ett enormt blad utan att bygga ett cellträd, producerar skrivaren ett utan att bygga ett cellträd heller

Varför den normala sparningsvägen växer med datan

Den vanliga TXLSXWorkbook-vägen bygger först en komplett objektmodell. Varje cell, med sitt värde, sin typ och sin stilreferens, lever som ett objekt i minnet tills du anropar save, varpå hela trädet serialiseras in i paketet. Den modellen är den rätta när du vill läsa ett blad, redigera det, räkna om och skriva tillbaka det, eftersom slumpmässig åtkomst till valfri cell är exakt vad redigering behöver. Den är den felaktiga när du öser in rader i en riktning och aldrig tittar bakåt, eftersom du betalar för att hålla varje rad i minnet till ingen nytta. En miljon rader av objekt är en miljon rader av objekt, oavsett om du någonsin återbesöker dem eller inte

Den strömmande skrivaren tar bort trädet. Så fort en cell har skrivits blir den till bytes i kalkylbladsdelen, och dessa bytes skickas över till zip-utmatningen. Kalkylbladets ström är den enda buffert som växer, och den växer på utmatningssidan, inte som levande Delphi-objekt på heapen. Det som stannar i minnet är en fast mängd bokföring: bladens namn, några flaggor, det aktuella radnumret, en cellräknare. Det setet förändras inte mellan rad ett och rad tio miljoner

Tabellen för delade strängar är fällan, och inline-strängar är vägen ut

De flesta strömmande XLSX-skrivare klarar sig bra tills de möter text. OOXML-formatet lagrar normalt strängar i en gemensam strängtabell: varje unik sträng skrivs in en gång i en separat del, och varje cell som innehåller den strängen bär på ett index till tabellen i stället för texten. Det är en bra utrymmesoptimering för filer fulla av upprepade etiketter, och det är standarden som den vanliga sparningsvägen använder. Problemet för en strömmande skrivare är brutalt. För att kunna avduplicera måste tabellen stanna i minnet under hela jobbet, eftersom vilken rad som helst som ännu ska komma kan upprepa en sträng från en rad som redan har skrivits, och endast en komplett minneskarta över sedda strängar kan tilldela rätt index. Så den enda struktur som en strömmande skrivare inte kan strömma är just den struktur som antas göra filen liten. Texttung data besegrar just den strömning du var ute efter

Den direkta skrivaren kringgår tabellen helt och hållet. Strängar skrivs inline, som t="inlineStr"-celler vars text sitter direkt inuti cellen med ett <is><t>-element. Det finns ingen tabell att bygga upp och ingen karta över sedda strängar att hålla fast vid, så textkolumner kostar inte mer minne än numeriska. Kompromissen är uttrycklig och värd att säga rent ut. Inline-strängar upprepar samma text var den än förekommer, så en fil med många identiska etiketter blir större på disken än motsvarigheten med delade strängar. Du offrar filstorlek för att köpa dig konstant minne. För en enpass-export är det den rätta änden av affären, och zip-komprimering absorberar mycket av upprepningen på vägen ut i alla fall

Stiltabellen kommer på slutet, med ett datumformat

Stilar skapar samma spänning som strängar. En arbetsbok refererar till sin formatering genom en stildel, och en strömmande skrivare kan inte hålla en växande färgpalett av stilar i synk med celler den redan har spolat ut. Den direkta skrivaren svarar på detta genom att hålla stiltabellen liten och fast, och ge ut den vid stängning i stället för i förväg. Ett standardformat för celler täcker vanliga celler. Ett format för datumnummer täcker datum, registrerat med en formatkod yyyy-mm-dd på en känd position i listan över cellformat

Det datumformatet är anledningen till att WriteDateTime existerar som ett eget anrop. Excel har ingen inbyggd datumtyp; ett datum är ett nummer klätt i ett datumformat. WriteDateTime skriver värdet som ett vanligt serienummer och märker cellen med den enda datumstilen, så att kalkylprogrammet renderar det som ett datum i stället för ett femsiffrigt heltal. Serienumret den skriver spelar roll för rundturer. Den lagrar värdet TDateTime direkt enligt datumsystemet 1900, vilket är samma konvention som den vanliga TXLSXWorkbook-sparningsvägen använder. Eftersom båda vägarna är överens om serienumret, kan en fil som den strömmande skrivaren producerar läsas tillbaka genom HotXLS-läsaren och öppnas i Excel med datum som matchar vad du menade, utan någon off-by-one eller epoköverraskning mellan skrivaren och läsaren

Ordning är obligatoriskt, eftersom byten redan är borta

Strömning köper sin minnesprofil med en enda regel du måste hedra. Utmatningen avges allt eftersom du går framåt och kan inte besökas igen, så allt måste skrivas i den ordning det uppträder i filen. Inom en rad går celler i stigande kolumnordning. Inom ett blad går rader i stigande ordning. Det finns ingen buffert som låter skrivaren sortera dina celler i efterhand, eftersom raden du stängde för ett ögonblick sedan redan är bytes i zip-strömmen och inte längre är nåbar. Ge den kolumn 5 och därefter kolumn 2 på samma rad och utmatningen är deformerad, eftersom skrivaren helt enkelt avger det du ger den i den sekvens du ger den

Rad-API:et har en liten bekvämlighet för det vanligaste fallet. AddRow tar ett 1-baserat radindex, men att skicka 0 betyder ta nästa rad efter den föregående, så att en sekventiell påfyllning inte behöver spåra och skicka in en ökande räknare. Varje AddRow stänger raden före den, och varje AddSheet stänger bladet före det, så du avslutar aldrig uttryckligen en rad eller ett blad. Du startar nästa och skrivaren färdigställer den öppna strukturen åt dig

Escaping hanteras där texten äntrar XML

All text du skriver blir en del av ett XML-dokument, så de fem fördefinierade XML-entiteterna måste escape:as annars blir paketet ogiltigt i samma ögonblick som ett värde innehåller ett et-tecken eller en vinkelparentes. Skrivaren escapare &, <, >, " och ' åt dig både på inline-strängtext och formeltext, de två platser där tecken från anroparen hamnar inuti uppmärkning. Du skickar en rå WideString och skrivaren gör den säker. Ett produktnamn som Smith & Co <Ltd> eller en formel som refererar till ett citerat bladnamn kommer ut som välformad XML utan någon escaping från din sida

Livscykel, och varför Destroy ändå stänger

Att slutföra paketet är det som skriver arbetsboksdelen, stildelen, innehållstyper och relationsdelar, och till sist zippens centrala katalog. Det arbetet sker i Close. Ett paket som aldrig stängs är en ofullständig zip som inget kalkylprogram kommer att öppna, så stängning är inte frivillig uppstädning, det är steget som gör filen giltig. För att skydda mot en glömd Close längs en felväg, utför Destroy en stängning efter bästa förmåga om paketet fortfarande är öppet, så att frigörandet av skrivaren inte läcker det underliggande zip-objektet även när ett undantag hoppade över det uttryckliga anropet. Det pålitliga mönstret är fortfarande det vanliga Delphi-mönstret: skriv inuti en try, anropa Close och frigör i finally

Att strömma ett stort blad från ände till ände

Jobbets form är börja, lägg till ett blad, ös in rader, stäng. Exemplet nedan skriver en rubrikrad och sedan en lång räcka av typade datarader, blandar strängar, nummer, en formel utan cachat resultat, och ett datum. Minnet det använder för tio rader och för tio miljoner rader är detsamma, eftersom varje cell beger sig till zip-strömmen så fort den har skrivits

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;

Ett andra blad är helt enkelt ytterligare en AddSheet innan du fortsätter, och skrivaren stänger det första bladet när den öppnar det andra. Booleska flaggor använder WriteBoolean, som skriver en typad boolesk cell i stället för texten "True". Om du vill bekräfta att filen är hel och kan göra en rundtur, rapporterar egenskapen CellCount hur många celler som skrevs, och om du läser tillbaka resultatet med den strömmande läsaren bör det rapportera samma summa

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

Att skriva till en ström i stället för en fil är samma kod med BeginStream i stället för BeginFile, vilket låter en server skicka arbetsboken till ett HTTP-svar eller en minnesström utan en temporär fil på disken. Skrivaren äger inte strömmen du skickar, så du behåller kontrollen över dess livstid

När arbetet är en serverändpunkt som bygger arbetsböcker på begäran, visar mönstren i strömmande skrivningar för server- och batchjobb hur man kopplar in detta i en begärandehanterare och en schemalagd export. När frågan gäller den bredare kostnaden för mycket stora arbetsböcker, både läsning och skrivning, täcker prestanda för stora arbetsböcker i Delphi vart tiden och minnet faktiskt tar vägen. Den strömmande direkta skrivaren levereras som en del av HotXLS Component för Delphi och C++Builder, vid sidan av de fullständiga API:erna för att läsa, redigera och spara som täcks på andra ställen på den här bloggen