Teknisk artikkel

Strøm enorme XLSX-filer i Delphi uten å laste dem inn

Et regneark med en million rader og et dusin kolonner er en helt vanlig eksport fra en databaserapporteringsjobb. Åpne det på vanlig måte, ved å laste hele arbeidsboken inn i en TXLSWorkbook, og prosessen må materialisere hver og en av disse tolv millioner cellene som et levende objekt før din første linje med forretningslogikk kjører. Filen på disken kan være seksti megabyte med komprimert XML. Objekttreet den utvides til er flere ganger det, og alt må være til stede i minnet samtidig fordi modellen er designet for vilkårlig tilgang (random access). For en rapport du har tenkt å lese fra topp til bunn og kaste, er det veldig mye minne brukt på en struktur du aldri trengte

Det finnes en annen vei gjennom den samme filen. I stedet for å bygge en modell, skanner du regneark-XML-en kun fremover, én celle om gangen, og lar hver celle flyte forbi etter at du har sett på den. Ingenting akkumuleres. Minnet forblir nesten konstant enten arket har tusen rader eller ti millioner, fordi leseren aldri beholder mer enn den delen den for øyeblikket tolker, pluss et par små oppslagstabeller. Dette er hva HotXLS direkte-leser gjør, og resten av denne artikkelen handler om hvorfor den forblir liten og hva den gir deg i bytte

Hvorfor minnemodellen ikke skalerer

En XLSX-fil is a ZIP package of XML parts described by ECMA-376. Each worksheet is its own part, xl/worksheets/sheetN.xml, and inside it every row is a <row> element holding <c> cell elements. Den vanlige innlastingsbanen leser den delen og konstruerer et adresserbart objekt for hver celle, slik at du senere kan be om Cells[12345, 7] og få et svar i konstant tid. Vilkårlig tilgang er hele poenget med en arbeidsbokmodell, og det er akkurat det som gjør redigering, formelevaluering og formatering praktisk

Kostnaden er at vilkårlig tilgang krever at alt er til stede samtidig. Du kan ikke indeksere inn i en struktur du bare delvis har bygd. Så minnetoppen ved full innlasting er en funksjon av celleantallet, og på et ark med millioner av utfylte celler lander den funksjonen et sted tjenesten din ikke ønsker å være, spesielt hvis flere slike jobber kjører samtidig på en delt maskin. Når tilgangsmønsteret du faktisk trenger er sekvensielt, er det å betale for vilkårlig tilgang det samme som å betale for en funksjon du ikke vil bruke

En kun-fremover SAX-skanning som ikke bygger noe tre

Direkte-leseren åpner ZIP-pakken og går gjennom hver regnearkdel med en SAX-lignende pull-parser. SAX betyr her at parseren rapporterer tolkehendelser (parse events) etter hvert som den møter dem, et startelement, en tekstkjøring, et sluttelement, og deretter går videre. Den etterlater seg ikke noe nodetre. Leseren sporer gjeldende rad og kolonne fra r-attributtene, samler cellens type, stilindeks, verdi og formeltekst etter hvert som hendelsene ankommer, og når den lukkende </c>-taggen sees, sender den ut én celle og glemmer den. Neste celle gjenbruker de samme få lokale variablene

Fordi ingenting beholdes mellom cellene, vokser ikke minneavtrykket med antall celler. Det er egenskapen det er verdt å holde fast ved. Et ark på to hundre rader og et ark på tjue millioner rader koster leseren det samme minnet, og forskjellen mellom dem er bare hvor lenge skanningen kjører. Du gir opp vilkårlig tilgang, modellens hovedfunksjon, og til gjengjeld får du et tak på minnebruken som celleantallet ikke kan presse seg igjennom

Hva forblir i minnet, og hvorfor akkurat disse to delene

Skanningen er ikke helt tilstandsløs, og unntakene er lærerike. To små tabeller må holdes i minnet underveis, fordi en celle alene ikke bærer nok informasjon til å tolkes uten dem

Den første er den delte strengtabellen. I SpreadsheetML lagrer ikke en tekstcelle sin egen tekst. Den bærer t="s" og en numerisk nyttelast som er en indeks inn i xl/sharedStrings.xml, en enkelt unik liste over alle distinkte strenger i arbeidsboken. Dette er et godt plass-bytte for filer der de samme etikettene gjentas over tusenvis av rader, som betyr at leseren må laste inn den strengtabellen på forhånd og holde den i minnet, fordi en hvilken som helst celle hvor som helst i et hvilket som helst ark kan referere til en hvilken som helst oppføring i den. Tabellen dimensjoneres etter antall distinkte strenger, ikke etter celleantall, så den forblir beskjeden selv på enorme ark

Den andre er tallformat-avbildningen (mappingen) fra stildelen. En numerisk celle og en datocelle er byte-for-byte like på ledningen: begge er et vanlig tall, fordi en dato i SpreadsheetML bare er et sekvensielt dagantall. Det eneste som skiller dem er cellens stil, som peker gjennom cellXfs i xl/styles.xml til en tallformat-id. For å rapportere en dato som en dato i stedet for som det rå sekvensielle tallet, laster leseren inn den stil-til-format-tabellen og beholder den i minnet. Alt annet i filen, de faktiske celledataene som utgjør størstedelen av bytene, strømmer forbi uten å bli lagret

Hver celle rapporterer en type og en verdi

Hver utsendt celle ankommer som en TXLSDirectCell-post (record). Den bærer ark-indeksen og navnet, den 1-baserte raden og kolonnen, en semantisk Kind (type), Value som en Variant, Formula-teksten uten sitt ledende likhetstegn, og den rå StyleIndex. Typen er en av xdkNumber, xdkString, xdkBoolean, xdkDate eller xdkError, slik at du kan forgrene deg etter hva cellen betyr i stedet for å utlede det på nytt fra attributter. En formelcelle rapporterer typen av sitt bufrede resultat, sammen med formelteksten, slik at en beregnet totalsum kommer gjennom som et tall som også forteller deg hvordan det ble produsert

Å skille en dato fra et tall

Dato-spørsmålet fortjener et nærmere ettersyn fordi det er der de fleste enkle skannere gjør feil. Det finnes ingen datotype på en numerisk celle. En celle som inneholder den sekvensielle verdien 46000 kan være et antall, en pris eller 17. februar 2025, og filen forteller deg hvilken kun gjennom tallformat-id-en man når via cellens stil. ECMA-376 reserverer en blokk med innebygde format-id-er hvis betydning er fast på tvers av alle samsvarende produsenter, og id-ene som bærer datoer sitter i to områder: 14 til 22 for standard dato- og tidsformater, og 45 til 47 for formater for medgått tid som [h]:mm:ss. Når DetectDates er på, noe som er standard, løser leseren hver numerisk celles stil til dens format-id, og en celle hvis id falls in those reserved ranges is reported as xdkDate with its Value already converted to a Delphi TDateTime. Egendefinerte formater sjekkes også ved å inspisere formatkoden for dato- og tidstokener, men de reserverte områdene er den pålitelige ryggraden. Slå av DetectDates, og stiltabellen blir ikke engang lastet, hver numerisk celle kommer gjennom som xdkNumber, og skanningen er ørlite grann slankere

Hopp over ark og avbryt tidlig

Sekvensiell skanning har en stille fordel som vilkårlig tilgang ikke kan matche: du kan stoppe. Hendelsen OnSheet utløses før hvert regneark åpnes, og den gir deg to brytere. Sett SkipSheet, og den delen blir aldri tolket, noe som er måten du kun skanner arkene du bryr deg om i en arbeidsbok med flere ark uten å betale for å lese resten. Sett Abort, og hele skanningen avsluttes umiddelbart. Hendelsen OnCell bærer sin egen Abort, slik at du kan stoppe i det øyeblikket du har funnet det du lette etter (en bestemt rad, en vaktverdi (sentinel), slutten på en overskriftsblokk) uten å lese de resterende millionene av celler. På en skanning som bare går fremover er avbrudd genuint gratis, fordi arbeidet du hopper over er arbeid som ennå ikke hadde skjedd

Telle celler uten en behandler

Én nylig forbedring er verdt å trekke frem fordi den gjør et vanlig spørsmål til et enkelt, rimelig kall. Leseren teller hver utfylte celle den passerer, og den gjør dette enten en OnCell-behandler er tilkoblet eller ikke. Tidligere, uten noen behandler satt, antallet utfylte celler kom tilbake som null, siden telling var en bieffekt av utsendelsen. Nå er tellingen uavhengig av utsendelsen. Det betyr at du kan stille ett spørsmål: hvor mange utfylte celler inneholder egentlig denne arbeidsboken, og få svaret til prisen av en skanning uten noen tilbakeslag (callbacks) i det hele tatt. Både ReadFile og ReadStream returnerer den totalsummen som en Int64, og det samme tallet er tilgjengelig etterpå som egenskapen CellCount. En retur på -1 signaliserer at filen ikke kunne åpnes eller ikke er en OOXML-pakke

For full skanning kobler du til behandleren og kaller ReadFile på akkurat samme måte. Kontrasten til en full innlasting er hele poenget: der innlasting av quarterly_export.xlsx i en arbeidsbok ville utvidet hver celle til et fast objekt i minnet og beholdt alt, beholder direkte-leseren bare de delte strengene og stiltabellen mens de tolv millioner cellene flyter gjennom din OnCell én om gangen. Aritmetikken som kjørte per celle etterlater ingenting, så minnetoppen settes av arbeidsbokens antall unike strenger, ikke av radantallet

Direkte-leseren er det rette verktøyet når jobben er å lese en stor arbeidsbok én gang og trekke ut eller oppsummere den. Når du i stedet trenger vilkårlig tilgang (random access) til hele modellen, men vil at den skal fungere godt på store filer, tilpasningen i våre notater om ytelse for store arbeidsbøker i Delphi dekker den banen. Og når retningen er omvendt, å produsere store utdata i stedet for å konsumere dem, bruker den strømmende skriveveiledningen for server-batchjobber den samme disiplinen med konstant minne for skriving. Alle tre leveres som en del av HotXLS Component for Delphi og C++Builder, sammen med API-er for lesing, skriving, formler og formatering som dekkes andre steder på denne bloggen