Proračunska tablica s milijun redaka i desetak stupaca savršeno je uobičajen izvoz iz nekog zadatka izvještavanja baze podataka. Otvorite je na uobičajen način, učitavanjem cijele radne knjige u TXLSWorkbook, a proces tada mora materijalizirati svaku od tih dvanaest milijuna ćelija kao živi objekt prije no što se uopće pokrene prva linija vaše poslovne logike. Datoteka na disku može iznositi šezdesetak megabajta sažetog XML-a. Stablo objekata u koje se on širi iznosi nekoliko puta više, a sve to mora biti prisutno odjednom jer je sam model po dizajnu zasnovan na nasumičnom pristupu (random-access). Za izvještaj koji namjeravate samo pročitati od vrha do dna i zatim ga baciti, to je zbilja velika količina memorije potrošene na strukturu koja vam nikada i nije trebala
Postoji i drugi put kroz istu datoteku. Umjesto da gradite model, skenirate XML radnog lista isključivo unaprijed, jednu po jednu ćeliju, i puštate da svaka ćelija prođe nakon što ste je pogledali. Ništa se ne nakuplja. Memorija ostaje gotovo konstantna bez obzira na to ima li list tisuću ili deset milijuna redaka, jer čitač nikada ne drži više od dijela koji upravo parsira plus nekoliko malih tablica za pretraživanje (lookup tables). Upravo to radi izravni čitač (direct reader) HotXLS-a, a ostatak ovog članka govori o tome zašto on ostaje malen i što vam daje zauzvrat
Zašto model unutar memorije ne skalira
XLSX datoteka je ZIP paket XML dijelova koje opisuje ECMA-376. Svaki radni list je vlastiti dio, xl/worksheets/sheetN.xml, i unutar njega svaki redak je <row> element koji drži elemente ćelija <c>. Redoviti put učitavanja čita taj dio i konstruira adresabilni objekt za svaku ćeliju tako da kasnije možete tražiti Cells[12345, 7] i dobiti odgovor u konstantnom vremenu. Nasumični pristup poanta je cijelog modela radne knjige i upravo je to ono što uređivanje, evaluaciju formula i stiliziranje čini praktičnim
Cijena se očituje u tome što nasumični pristup zahtijeva da apsolutno sve bude prisutno istovremeno. Ne možete indeksirati strukturu koju ste tek samo djelomično izgradili. Vršna memorija pri punom učitavanju tako postaje funkcija broja ćelija, a na listu s milijunima popunjenih ćelija ta funkcija odlazi točno tamo gdje vaša usluga uopće ne želi biti, osobito ako se nekoliko ovakvih poslova pokreće i isprepliće odjednom na istom dijeljenom stroju. Kada je obrazac pristupa koji vama zapravo treba sekvencijalan, plaćanje za nasumični pristup zapravo znači plaćanje za tu jednu sasvim ogromnu sposobnost koju jednostavno, objektivno ni sasvim uobičajeno nećete uopće nikada iskoristiti
SAX skeniranje isključivo unaprijed koje ne gradi stablo
Izravni čitač otvara ZIP paket i prolazi kroz svaki dio radnog lista sa SAX stilom 'pull' parsera. SAX ovdje znači da parser izvještava o događajima parsiranja kako naiđe na njih: početni element, tekstualni niz, završni element, i zatim nastavlja dalje. Iza sebe ne ostavlja stablo čvorova (node tree). Čitač prati trenutni redak i stupac iz atributa r, prikuplja vrstu ćelije, indeks stila, vrijednost i tekst formule onako kako događaji pristižu, a kada se ugleda završna </c> oznaka, on ispušta jednu ćeliju i potom je zaboravlja. Sljedeća ćelija ponovno koristi istu šačicu lokalnih varijabli
Budući da se između ćelija ništa ne zadržava, memorijski otisak ne raste s brojem ćelija. To je svojstvo kojeg se vrijedi držati. Radni list s dvjesto redaka i radni list s dvadeset milijuna redaka stoje čitač iste prisutne memorije, a razlika između njih jedino je u tome koliko će dugo skeniranje trajati. Odričete se nasumičnog pristupa, koji je inače glavna značajka modela, i zauzvrat dobivate maksimalnu granicu za memoriju koju ukupan broj ćelija ne može probiti
Što ostaje prisutno i zašto baš ta dva dijela
Skeniranje nije u potpunosti bez stanja (stateless), a iznimke su poučne. Dvije male tablice moraju se za to vrijeme držati u memoriji jer ćelija sama po sebi ne nosi dovoljno informacija za interpretaciju bez njih
Prva je tablica dijeljenih nizova znakova (shared string table). U SpreadsheetML-u tekstualna ćelija ne pohranjuje vlastiti tekst. Ona nosi t="s" i numerički podatak koji je zapravo indeks za xl/sharedStrings.xml, jednu dedupliciranu listu svakog zasebnog stringa u radnoj knjizi. Ovo je dobra ušteda prostora kod onih datoteka gdje se jedne te iste oznake ponavljaju kroz tisuće redaka, ali to znači da čitač mora učitati tu tablicu unaprijed i držati je prisutnom, jer bilo koja ćelija bilo gdje na bilo kojem listu može referencirati bilo koji unos u njoj. Veličina tablice ovisi o broju zasebnih nizova znakova (distinct strings), a ne o broju ćelija, pa tako ostaje skromna čak i na ogromnim listovima
Druga je mapiranje formata brojeva iz dijela za stilove. Numerička ćelija i datumska ćelija bit-po-bit su iste pri prijenosu: obje su samo običan broj jer datum unutar SpreadsheetML-a predstavlja tek serijski iznos dana. Jedina stvar koja ih razlikuje upravo je stil ćelije koji preko cellXfs u xl/styles.xml ukazuje na identifikator (id) brojevnog formata. Kako bi datum iskazao kao datum, a ne samo kao sirovi serijski broj, čitač učitava takvu tablicu odnosa stila prema formatu i drži je prisutnom. Sve ostalo u datoteci, stvarni podaci ćelija koji i čine glavninu njezinih bajtova, teče (streams past) potpuno bez pohranjivanja
Svaka ćelija iskazuje vrstu i vrijednost
Svaka emitirana ćelija dolazi kao TXLSDirectCell zapis. Nosi indeks i ime radnog lista, redak i stupac koji započinju od broja 1, semantičku vrstu Kind, Value kao Variant, tekst Formula bez njezinog vodećeg znaka jednakosti te sirovi StyleIndex. Vrsta (kind) je jedno od xdkNumber, xdkString, xdkBoolean, xdkDate ili xdkError, pa tako na temelju vrste možete granati svoje uvjete s obzirom na to što ona znači, umjesto da je ponovno izvodite iz atributa. Ćelija s formulom iskazuje vrstu njezina predmemoriranog (cached) rezultata s pratećim tekstom takve formule sa strane, tako da vam izračunati iznos dolazi kao broj koji vam ujedno i kazuje kako je proizveden
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;
Razlikovanje datuma od broja
Pitanje datuma zaslužuje pobliži pogled jer tu griješi većina naivnih skenera. Na numeričkoj ćeliji ne postoji vrsta datuma. Ćelija koja čuva serijsku vrijednost 46000 mogla bi biti količina, cijena ili pak 17. veljače 2025. godine, a datoteka vam to otkriva isključivo kroz id brojevnog formata, kojeg ste dobili preko stila ćelije. ECMA-376 rezervira blok ugrađenih id-ova formata čije je značenje fiksno kod svakog konformnog proizvođača, a datumski id-ovi nalaze se u dva raspona: 14 do 22 za standardne formate datuma i vremena, te 45 do 47 za formate proteklog vremena poput [h]:mm:ss. Kada je uključena opcija DetectDates, a to i jest zadana (default) postavka, čitač rješava stil svake numeričke ćelije do njezinog id-ja formata, te se ona ćelija čiji id pada u te rezervirane raspone prijavljuje kao xdkDate s Value podatkom već pretvorenim u Delphi format naziva TDateTime. Prilagođeni (custom) formati se također provjeravaju inspekcijom koda formata zbog postojanja tokena za datum i vrijeme, ali baš ovi rezervirani rasponi čine pouzdanu okosnicu (dependable backbone). Isključite li funkciju DetectDates, tablica stilova uopće se neće ni učitati, a svaka numerička ćelija proći će kao xdkNumber, tako da samo skeniranje bude u djeliću sekunde još neopterećenije i jednostavnije (leaner)
Preskočite radne listove i otkažite rano
Sekvencijalno skeniranje ima tu tihu prednost kojoj nasumični pristup (random access) ne može parirati: možete prestati. Događaj OnSheet pokreće se prije otvaranja svakog radnog lista te vam nudi dvije sklopke. Postavite SkipSheet i taj cijeli dio uopće neće biti parsiran, što je zapravo način na koji skenirate isključivo one listove do kojih vam je doista stalo unutar radne knjige s više radnih listova, i to bez plaćanja čitanja ostatka dokumenta. Postavite Abort i cijelo skeniranje smjesta prestaje. Događaj OnCell nosi vlastiti Abort, tako da se možete zaustaviti onog trenutka kada napokon nađete ono što ste cijelo to vrijeme tražili, poput npr. posebnog retka, stražarske (sentinel) vrijednosti ili samog kraja bloka zaglavlja (header block), i to posve bez ikakvog dodatnog isčitavanja na milijune preostalih dotičnih osiguranih ćelija. Na skeniranju rezerviranom isključivo za putanju prema naprijed (forward-only scan), odustajanje (abort) vas istinski ne košta apsolutno ništa jer trud koji njime preskočite u suštini predstavlja onaj posao koji se ionako još zapravo nije ni dogodio
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;
Prebrojavanje ćelija bez rukovatelja (handler)
Jedno nedavno poboljšanje svakako vrijedi posebno istaknuti jer od uobičajenog pitanja stvara jedan jednostavan i jeftin poziv. Čitač broji svaku popunjenu ćeliju pored koje prođe i on to radi bez obzira na to je li mu pritom priključen rukovatelj OnCell ili ne. Ranije bi se, bez postavljenog rukovatelja (handler), broj popunjenih ćelija vraćao kao nula jer je obično prebrojavanje predstavljalo zapravo popratnu nuspojavu ispisa tih ćelija. Sada takvo prebrojavanje postaje potpuno neovisno od odašiljanja. To ukratko znači da sasvim mirno možete upitati tek jedno pitanje o tome koliko zbilja točno popunjenih ćelija doista uopće takva jedna radna knjiga doista sadrži, te nakon toga zaprimiti jasan odgovor po cijeni skeniranja s isključivo i apsolutno nula posve bilo kakvih povratnih poziva (callbacks). ReadFile kao i sam ReadStream vraćaju oba navedena rezultata u obliku vrlo poznatog pojma Int64, te isti taj broj postaje naknadno slobodan pa se nudi uz prisutno svojstvo pod imenom CellCount. Povratni broj u obliku -1 obično sugerira kako se prepoznata datoteka vjerojatno uopće ni ne može posve otvoriti, ili kako ipak u konačnici i nije riječ o nekakvom standardnom OOXML paketu
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;
Za potpuno skeniranje priključite rukovatelja (handler) i pozovete ReadFile na potpuno isti način. Kontrast s potpunim učitavanjem (full load) i jest cijela poanta: dok bi učitavanje datoteke quarterly_export.xlsx u model radne knjige proširilo svaku ćeliju u prisutni (resident) objekt i tako ih sve zadržalo, izravni čitač zadržava samo dijeljene nizove znakova (shared strings) i tablicu stilova, dok onih dvanaest milijuna ćelija protječe kroz vaš OnCell jedna po jedna. Aritmetika koja se izvršavala po ćeliji ne ostavlja ništa iza sebe, tako da je vršna (peak) memorija određena brojem zasebnih nizova znakova (distinct-string count) radne knjige, a nipošto brojem njezinih redaka
Izravni čitač (direct reader) pravi je alat kada je posao jednom pročitati veliku radnu knjigu i iz nje izvući podatke ili ih sažeti. Kada umjesto toga trebate nasumični pristup (random access) cijelog modela, ali pritom želite da se on dobro ponaša s velikim datotekama, podešavanje iz naših zapisa o performansama velike radne knjige u Delphiju pokriva tu putanju. A kada je smjer obrnut, te se veliki ispis radije proizvodi umjesto što se samo konzumira, tada vodič za strujanje (streaming-write) kod serverskih grupnih zadataka primjenjuje posve jednaku disciplinu konstantne memorije prilikom upisivanja zapisa. Sve se navedeno redovito isporučuje izravno kao dio paketa HotXLS komponente za Delphi i C++Builder, kao i dodatni API-ji za čitanje, pisanje, formate (formatting) te evaluaciju raznih odabranih formula osmišljenih opsežno redovno jako jedinstveno obrađenih na ovom blogu