Technical Article

Stream enorme XLSX-filer i Delphi uden at indlæse dem

Et regneark med en million rækker og et dusin kolonner er et fuldt ud normalt eksport fra et database-rapporteringsjob. Åbn det på den sædvanlige måde ved at indlæse hele projektmappen i en TXLSWorkbook, og processen skal materialisere alle de tolv millioner celler som levende objekter, inden din første linje forretningslogik kører. Filen på disk er måske tres megabyte komprimeret XML. Det objekttræ, den udfoldes til, er adskillige gange det, og det hele skal være resident på én gang, fordi modellen er tilældig adgang af design. For en rapport, du agter at læse fra top til bund og kassere, er det en hel del hukommelse brugt på en struktur, du aldrig havde brug for

Der er en anden vej igennem den samme fil. I stedet for at bygge en model scanner du regnearkets XML fremad kun, én celle ad gangen, og lader hver celle flyde forbi, efter du har kigget på den. Intet akkumuleres. Hukommelsen forbliver nær konstant, om arket har et tusinde rækker eller ti millioner, fordi læseren aldrig holder mere end den del, den aktuelt parser, plus et par små opslagstabeller. Dette er, hvad HotXLS-direkt-læseren gør, og resten af denne artikel handler om, hvorfor den forbliver lille, og hvad den giver dig til gengæld

Hvorfor den hukommelsesbaserede model ikke skalerer

En XLSX-fil er en ZIP-pakke med XML-parts beskrevet af ECMA-376. Hvert regneark er sin egen part, xl/worksheets/sheetN.xml, og indeni er hver række et <row>-element, der indeholder <c>-celleelementer. Den regulære indlæsningssti læser den part og konstruerer et adresserbart objekt for hver celle, så du senere kan anmode om Cells[12345, 7] og få et svar i konstant tid. Tilfældig adgang er hele pointen med en projektmappe-model, og det er præcis det, der gør redigering, formulaevaluering og formatering bekvem

Prisen er, at tilfældig adgang kræver, at alt er til stede samtidigt. Du kan ikke indeksere i en struktur, du kun delvist har bygget. Så topp-hukommelsen af en fuld indlæsning er en funktion af celleantal, og på et ark med millioner af befolkede celler lander den funktion et sted, din tjeneste ikke vil være, særligt hvis flere sådanne jobs kører samtidigt på en delt maskine. Når det adgangsmuster, du faktisk har brug for, er sekventielt, betaler du for tilfældig adgang for en evne, du ikke bruger

En fremad-kun SAX-scanning, der ikke bygger noget træ

Direkt-læseren åbner ZIP-pakken og gennemløber hver regneark-part med en SAX-stil-pull-parser. SAX betyder her, at parseren rapporterer parse-hændelser, når den støder på dem, et start-element, en tekstkørsel, et slut-element, og derefter går videre. Den beholder intet nodetræ bag sig. Læseren sporer den aktuelle række og kolonne fra r-attributterne, samler cellens type, stilindeks, værdi og formeltekst, når hændelserne ankommer, og når det afsluttende </c>-tag ses, udsender den én celle og glemmer den. Den næste celle genbruger de samme håndfuld lokale variable

Fordi intet bevares mellem celler, vokser hukommelsesaftrykket ikke med antallet af celler. Det er den egenskab, det er værd at holde fast ved. Et trehundreders-rækkesark og et tyve-millioners-rækkesark koster læseren den samme residente hukommelse, og forskellen mellem dem er kun, hvor længe scanningen kører. Du giver afkald på tilfældig adgang, modellens vigtigste egenskab, og til gengspår får du et loft på hukommelse, som celleantallet ikke kan presse igennem

Hvad der forbliver resident, og hvorfor de to dele

Scanningen er ikke fuldstændig tilstandsløs, og undtagelserne er instruktive. To små tabeller skal holdes i hukommelsen i løbet af hele scanningen, fordi en celle i sig selv ikke bærer tilstrækkelig information til at fortolke uden dem

Den første er den delte strengtabel. I SpreadsheetML gemmer en tekstcelle ikke sin egen tekst. Den bærer t="s" og en numerisk nyttelast, der er et indeks i xl/sharedStrings.xml, en enkelt deduplikeret liste over alle distinkte strenge i projektmappen. Dette er en god pladsafvejning for filer, hvor de samme etiketter gentages på tværs af tusinder af rækker, men det betyder, at læseren skal indlæse den strengtabel på forhånd og holde den resident, fordi enhver celle, uanset hvor i et ark, måske refererer til en post i den. Tabellen er størrelsesbestemt af antallet af distinkte strenge, ikke af celleantal, så den forbliver beskeden selv på enorme ark

Den anden er talkormateringen fra stildelen. En numerisk celle og en datocelle er byte-for-byte identiske på leddet: begge er et simpelt tal, fordi en dato i SpreadsheetML blot er et serielt dagantal. Det eneste, der adskiller dem, er cellens stil, der peger via cellXfs i xl/styles.xml til et talkformat-id. For at rapportere en dato som en dato i stedet for som det rå serielle tal, indlæser læseren den stil-til-format-tabel og holder den resident. Alt andet i filen, de faktiske celledata, der udgstør bulken af bytes, streamer forbi uden at blive gemt

Hver celle rapporterer en type og en værdi

Hver udsendt celle ankommer som en TXLSDirectCell-record. Den bærer arkindekset og -navnz, den 1-baserede række og kolonne, en semantisk Kind, Value som en Variant, Formula-teksten uden sit ledende lighedstegn, og det rå StyleIndex. Typen er en af xdkNumber, xdkString, xdkBoolean, xdkDate eller xdkError, så du kan forgrene på, hvad cellen betyder frem for at aflede det fra attributter. En formelcelle rapporterer typen af sit bufferlagrede resultat, med formelteksten ved siden af, så et beregnet total fremkommer som et tal, der også fortller, hvordan det blev produceret

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;

At skelne en dato fra et tal

Datospmålet fortjener et nærmere kig, fordi det er der, de fleste naive scannere går galt. Der er ingen datotype på en numerisk celle. En celle, der indeholder den serielle værdi 46000, kan være en mængde, en pris eller den 17. februar 2025, og filen fortæller dig kun, hvilken det er, via talkformat-id'et nået via cellens stil. ECMA-376 reserverer en blok af indbyggede format-id'er, hvis betydning er fast på tværs af enhver overensstemmende producent, og dato-bærende id'er sidder i to intervaller: 14 til 22 for standard dato- og tidsformater og 45 til 47 for de forstødte tidsformater som [h]:mm:ss. Når DetectDates er slået til, hvilket er standard, opløser læseren hver numerisk celles stil til sit format-id, og en celle, hvis id falder i disse reserverede intervaller, rapporteres som xdkDate med sin Value allerede konverteret til en Delphi TDateTime. Brugerdefinerede formater tjekkes også ved at undersøge formatkoden for dato- og tidstokens, men de reserverede intervaller er den pålidelige rygrad. Slå DetectDates fra, og stiltabellen indlæses ikke engang, hver numerisk celle fremkommer som xdkNumber, og scanningen er marginalt letvandere

Spring ark over, og afbryd tidligt

Sekventiel scanning har en stille fordel, som tilfældig adgang ikke kan måle sig med: du kan stoppe. Hændelsen OnSheet udløses, inden hvert regneark åbnes, og den giver dig to kontakter. Sæt SkipSheet, og hele den part parses aldrig, så du kan scanne kun de ark, du er interesseret i, i en multi-arks projektmappe uden at betale for at læse resten. Sæt Abort, og hele scanningen slutter straks. Hændelsen OnCell bærer sin egen Abort, så du kan stoppe i det øjeblik, du har fundet det, du ledte efter, en bestemt række, en sentinel-værdi, slutningen af en headerblok, uden at læse de resterende millioner af celler. På en fremad-kun-scanning er afbryd virkelig gratis, fordi det arbejde, du springer over, er arbejde, der endnu ikke er sket

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;

Tælling af celler uden en handler

En nyere forbedring er værd at fremhæve, fordi den gør et almindeligt spørgsmål til et enkelt billigt kald. Læseren tæller hver befolket celle, den passerer, og det gør den, uanset om en OnCell-handler er tilknyttet. Tidligere kom antallet af befolkede celler tilbage som nul, når ingen handler var angivet, da tælling var en bivirkning af udsendelse. Nu er tællingen uafhængig af udsendelse. Det betyder, at du kan stille ét spørgsmål, hvor mange befolkede celler indeholder denne projektmappe faktisk, og få svaret for prisen af en scanning uden callbacks overhovedet. ReadFile og ReadStream returnerer begge det totale antal som en Int64, og det samme tal er efterfølgende tilgængeligt som egenskaben CellCount. En returværdi på -1 signalerer, at filen ikke kunne åbnes eller ikke er en OOXML-pakke

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;

For den fulde scanning tilknytter du handleren og kalder ReadFile på præcis samme måde. Kontrasten med en fuld indlæsning er hele pointen: hvor indlæsning af quarterly_export.xlsx i en projektmappe ville udvide hver celle til et resident objekt og holde det hele, holder direkt-læseren kun de delte strenge og stiltabellen, mens de tolv millioner celler strømmer igennem din OnCell én ad gangen. Den aritmetik, der kørte per celle, efterlader intet, så tophukommelsen er bestemt af projektmappens distinkt-strengeantal, ikke af dens rækketal

Direkt-læseren er det rette værktøj, når jobbet er at læse en stor projektmappe én gang og uddrage eller opsummere den. Når du i stedet har brug for den fulde models tilfældige adgang, men vil have den til at opføre sig fornuftigt på store filer, dækker justeringerne i vores noter om stor-projektmappe-ydeevne i Delphi den sti. Og når retningen vendes, at producere stort output frem for at bruge det, anvender gennemgangen af streaming-skrivning til server-batchjobs den samme konstant-hukommelse-disciplin på skrivning. Alle tre leveres som del af HotXLS Component til Delphi og C++Builder, ved siden af de læse-, skrive-, formel- og formaterings-API'er, der er dækket andetsteds på denne blog