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