Technical Article

Strömma enorma XLSX-filer i Delphi utan att läsa in dem

Ett kalkylblad med en miljon rader och ett dussin kolumner är en helt vanlig export från ett databasrapporteringsjobb. Öppna det på vanligt sätt, genom att läsa in hela arbetsboken i en TXLSWorkbook, och processen måste materialisera varenda en av de tolv miljoner cellerna som ett levande objekt innan din första rad affärslogik körs. Filen på disken kan vara sextio megabyte komprimerad XML. Det träd av objekt som den expanderar till är flera gånger så stort, och allt måste ligga i minnet samtidigt eftersom modellen från början är designad för slumpmässig åtkomst. För en rapport som du tänker läsa uppifrån och ner för att sedan kasta bort, är det en hel del minne spenderat på en struktur du aldrig behövde

Det finns en andra väg genom samma fil. I stället för att bygga en modell, skannar du kalkylbladets XML enbart framåt, en cell i taget, och låter varje cell passera förbi efter att du har tittat på den. Inget ackumuleras. Minnesanvändningen hålls nästan konstant oavsett om bladet har tusen rader eller tio miljoner, eftersom läsaren aldrig håller mer än den del den just nu tolkar plus ett par små uppslagstabeller. Detta är vad HotXLS direkta läsare gör, och resten av den här artikeln handlar om varför den förblir liten och vad den ger dig i utbyte

Varför minnesmodellen inte skalar

En XLSX-fil är ett ZIP-paket av XML-delar som beskrivs av ECMA-376. Varje kalkylblad är en egen del, xl/worksheets/sheetN.xml, och inuti det är varje rad ett <row>-element som innehåller <c>-cellelement. Den vanliga inläsningsvägen läser den delen och konstruerar ett adresserbart objekt för varje cell så att du senare kan fråga efter Cells[12345, 7] och få ett svar på konstant tid. Slumpmässig åtkomst är hela poängen med en arbetsboksmodell, och det är exakt det som gör redigering, formelutvärdering och formatering bekvämt

Priset är att slumpmässig åtkomst kräver att allt är närvarande samtidigt. Du kan inte indexera in i en struktur du bara delvis har byggt. Så minnestoppen för en fullständig inläsning är en funktion av antalet celler, och på ett blad med miljontals fyllda celler hamnar den funktionen någonstans där din tjänst inte vill vara, särskilt om flera sådana jobb körs samtidigt på en delad maskin. När det åtkomstmönster du faktiskt behöver är sekventiellt, innebär att betala för slumpmässig åtkomst att betala för en förmåga du inte kommer att använda

En framåtriktad SAX-skanning som inte bygger något träd

Den direkta läsaren öppnar ZIP-paketet och går igenom varje kalkylbladsdel med en SAX-liknande pull-parser. SAX betyder här att parsern rapporterar tolkningshändelser i samma stund som den stöter på dem, ett start-element, en textkörning, ett slut-element, och sedan går vidare. Den lämnar inget nodträd efter sig. Läsaren spårar aktuell rad och kolumn från r-attributen, samlar in cellens typ, stilindex, värde och formeltext allt eftersom händelserna anländer, och när den avslutande </c>-taggen ses avger den en cell och glömmer den. Nästa cell återanvänder samma lilla handfull lokala variabler

Eftersom inget sparas mellan celler, växer inte minnesavtrycket med antalet celler. Det är den egenskap som är värd att hålla fast vid. Ett blad på tvåhundra rader och ett blad på tjugo miljoner rader kostar läsaren samma mängd allokerat minne, och skillnaden mellan dem är bara hur länge skanningen körs. Du ger upp slumpmässig åtkomst, modellens huvudfunktion, och i gengäld får du ett tak för minnet som antalet celler inte kan trycka sig igenom

Vad som stannar i minnet, och varför dessa två delar

Skanningen är inte helt tillståndslös, och undantagen är lärorika. Två små tabeller måste hållas i minnet under hela tiden, eftersom en cell för sig själv inte bär på tillräckligt med information för att kunna tolkas utan dem

Den första är den gemensamma strängtabellen. I SpreadsheetML lagrar en textcell inte sin egen text. Den bär på t="s" och en numerisk nyttolast som är ett index in i xl/sharedStrings.xml, en enda avduplicerad lista över varje unik sträng i arbetsboken. Detta är en bra utrymmeskompromiss för filer där samma etiketter upprepas över tusentals rader, men det betyder att läsaren måste läsa in den strängtabellen i förväg och hålla den i minnet, eftersom vilken cell som helst var som helst i vilket blad som helst kan referera till vilken post som helst i den. Tabellen dimensioneras efter antalet unika strängar, inte efter antal celler, så den förblir blygsam även på enorma blad

Den andra är mappningen för sifferformat från stildelen. En numerisk cell och en datumcell är bit-för-bit likadana i dataflödet: båda är ett vanligt nummer, eftersom ett datum i SpreadsheetML bara är en seriell dagräkning. Det enda som skiljer dem åt är cellens stil, som pekar via cellXfs i xl/styles.xml till ett id för sifferformat. För att rapportera ett datum som ett datum och inte som det råa serienumret, läser läsaren in denna stil-till-format-tabell och håller den i minnet. Allt annat i filen, den faktiska celldatan som utgör merparten av byten, strömmar förbi utan att lagras

Varje cell rapporterar en sort och ett värde

Varje avgiven cell anländer som en TXLSDirectCell-post. Den bär på bladets index och namn, den 1-baserade raden och kolumnen, en semantisk Kind, Value som en Variant, Formula-texten utan sitt inledande likhetstecken och det råa StyleIndex. Sorten är en av xdkNumber, xdkString, xdkBoolean, xdkDate eller xdkError, så du kan förgrena dig utifrån vad cellen betyder snarare än att härleda det på nytt från attribut. En formelcell rapporterar sorten för sitt cachade resultat, med formeltexten vid sidan om, så en beräknad totalsumma kommer in som ett nummer som också berättar hur den producerades

type
  TReportScan = class
    procedure OnCell(Sender: TObject; const Cell: TXLSDirectCell;
      var Abort: Boolean);
  end;

procedure TReportScan.OnCell(Sender: TObject; const Cell: TXLSDirectCell;
  var Abort: Boolean);
begin
  case Cell.Kind of
    xdkString:  AccumulateLabel(Cell.Row, Cell.Col, VarToStr(Cell.Value));
    xdkNumber:  AddToTotals(Cell.Col, Double(Cell.Value));
    xdkDate:    NoteWhen(Cell.Row, VarToDateTime(Cell.Value));
    xdkBoolean: FlagRow(Cell.Row, Boolean(Cell.Value));
    xdkError:   LogBadCell(Cell.Row, Cell.Col, VarToStr(Cell.Value));
  end;
end;

Att skilja ett datum från ett nummer

Datumfrågan förtjänar en närmare titt eftersom det är där de flesta naiva skannrar gör fel. Det finns ingen datumtyp på en numerisk cell. En cell som håller serievärdet 46000 kan vara en kvantitet, ett pris eller den 17 februari 2025, och filen berättar vilket det är enbart genom sifferformat-id:t som nås via cellens stil. ECMA-376 reserverar ett block av inbyggda format-id:n vars betydelse är fastställd hos varje överensstämmande producent, och id:na som bär på datum sitter i två intervall: 14 till 22 för standardformat för datum och tid, och 45 till 47 för format för förfluten tid, såsom [h]:mm:ss. När DetectDates är på, vilket det är som standard, löser läsaren varje numerisk cells stil till sitt format-id, och en cell vars id faller inom dessa reserverade intervall rapporteras som xdkDate med dess Value redan konverterat till en TDateTime i Delphi. Anpassade format kontrolleras också, genom att inspektera formatkoden efter datum- och tidstoken, men de reserverade intervallen är ryggraden att lita på. Stäng av DetectDates och stiltabellen läses inte ens in, varje numerisk cell kommer in som xdkNumber, och skanningen blir aningen smalare

Hoppa över blad och avbryt i förtid

Sekventiell skanning har en tyst fördel som slumpmässig åtkomst inte kan mäta sig med: du kan stanna. Händelsen OnSheet avfyras innan varje kalkylblad öppnas, och den ger dig två brytare. Sätt SkipSheet och hela den delen tolkas aldrig, vilket är hur du bara skannar de blad du bryr dig om i en flerbarksbok utan att betala för att läsa resten. Sätt Abort och hela skanningen avslutas omedelbart. Händelsen OnCell bär på sin egen Abort, så du kan stanna i samma ögonblick som du har hittat vad du letade efter, en specifik rad, ett sentinelvärde, slutet på ett rubrikblock, utan att läsa de återstående miljonerna celler. På en skanning som bara går framåt är det genuint gratis att avbryta, eftersom det arbete du hoppar över är arbete som ännu inte hade hänt

procedure TReportScan.OnSheet(Sender: TObject; SheetIndex: Integer;
  const SheetName: WideString; var SkipSheet: Boolean; var Abort: Boolean);
begin
  // Scan only the "Data" sheet; leave the rest unread
  SkipSheet := SheetName <> 'Data';
end;

Att räkna celler utan en hanterare

En sentida förfining är värd att lyfta fram eftersom den gör en vanlig fråga till ett enda billigt anrop. Läsaren räknar varje ifylld cell den passerar, och den gör detta oavsett om en OnCell-hanterare är ansluten eller inte. Tidigare, när ingen hanterare var satt, kom antalet ifyllda celler tillbaka som noll, eftersom räkningen var en sidoeffekt av att de avgavs. Nu är räkningen oberoende av avgivningen. Det betyder att du kan ställa en fråga, hur många fyllda celler innehåller denna arbetsbok faktiskt, och få svaret till priset av en skanning helt utan callbacks. ReadFile och ReadStream returnerar båda den totalen som en Int64, och samma nummer är tillgängligt efteråt som egenskapen CellCount. En retur på -1 signalerar att filen inte kunde öppnas eller inte är ett OOXML-paket

var
  Reader: TXLSDirectReader;
  Populated: Int64;
begin
  Reader := TXLSDirectReader.Create;
  try
    // No OnCell handler: a pure populated-cell census, still near-constant memory
    Populated := Reader.ReadFile('quarterly_export.xlsx');
    if Populated < 0 then
      raise Exception.Create('Not a readable XLSX package')
    else
      Writeln(Format('%d populated cells (CellCount = %d)',
        [Populated, Reader.CellCount]));
  finally
    Reader.Free;
  end;
end;

För den fullständiga skanningen fäster du hanteraren och anropar ReadFile på exakt samma sätt. Kontrasten mot en full inläsning är hela poängen: där inläsning av quarterly_export.xlsx till en arbetsbok skulle expandera varje cell till ett minnesbelagt objekt och hålla kvar allihop, behåller den direkta läsaren bara de delade strängarna och stiltabellen medan de tolv miljoner cellerna flödar genom din OnCell en i taget. Aritmetiken som kördes per cell lämnar inget efter sig, så toppen för minnesanvändningen bestäms av arbetsbokens antal unika strängar, inte av dess radantal

Den direkta läsaren är rätt verktyg när jobbet är att läsa en stor arbetsbok en gång och extrahera eller sammanfatta den. När du i stället behöver den slumpmässiga åtkomsten från den fulla modellen men vill att den ska fungera bra på stora filer, täcker trimningen i våra anteckningar om prestanda för stora arbetsböcker i Delphi den vägen. Och när riktningen är omvänd, att producera stora utdata snarare än att konsumera dem, tillämpar genomgången om strömmande skrivning för server- och batchjobb samma disciplin för konstant minne på skrivningen. Alla tre levereras som en del av HotXLS Component för Delphi och C++Builder, vid sidan av API:erna för läsning, skrivning, formler och formatering som täcks på andra ställen på den här bloggen