Skaičiuoklė su milijonu eilučių ir tuzinu stulpelių yra visiškai įprastas eksportas iš duomenų bazės ataskaitų kūrimo užduoties. Atidarykite ją įprastu būdu, įkeldami visą darbaknygę į TXLSWorkbook, ir procesas turės materializuoti kiekvieną iš tų dvylikos milijonų langelių kaip gyvą objektą prieš įvykstant jūsų pirmajai verslo logikos eilutei. Failas diske gali būti šešiasdešimties megabaitų suspaustas XML. Objektų medis, į kurį jis išsiplečia, yra kelis kartus didesnis, ir visa tai turi būti atmintyje vienu metu, nes modelis pagal savo dizainą turi atsitiktinę prieigą (random-access). Ataskaitai, kurią ketinate perskaityti nuo viršaus iki apačios ir išmesti, tai yra didžiulis atminties kiekis, iššvaistytas struktūrai, kurios jums niekada nereikėjo
Yra ir antras kelias per tą patį failą. Užuot kūrę modelį, jūs skenuojate skaičiuoklės XML tik į priekį (forward only), po vieną langelį vienu metu, ir leidžiate kiekvienam langeliui praplaukti po to, kai į jį pažiūrėjote. Niekas nesikaupia. Atmintis išlieka beveik pastovi, nesvarbu, ar lape yra tūkstantis eilučių, ar dešimt milijonų, nes skaitytuvas niekada nelaiko daugiau nei dalies, kurią tuo metu analizuoja, plius poros mažų peržiūros lentelių (lookup tables). Būtent tai ir daro HotXLS tiesioginis skaitytuvas (direct reader), o likusi šio straipsnio dalis yra apie tai, kodėl jis išlieka mažas ir ką tai duoda mainais
Kodėl atmintyje esantis modelis neskalaujamas
XLSX failas yra XML dalių ZIP paketas, aprašytas ECMA-376 standarte. Kiekvienas darbalapis yra atskira dalis, xl/worksheets/sheetN.xml, ir jo viduje kiekviena eilutė yra <row> elementas, laikantis <c> langelio elementus. Įprastas įkėlimo kelias nuskaito tą dalį ir sukonstruoja adresuojamą objektą kiekvienam langeliui, kad vėliau galėtumėte paprašyti Cells[12345, 7] ir gauti atsakymą per pastovų laiką (constant time). Atsitiktinė prieiga yra visa darbaknygės modelio esmė, ir būtent tai padaro redagavimą, formulių skaičiavimą ir stiliaus kūrimą patogiu
Kaina yra ta, kad atsitiktinė prieiga reikalauja, jog viskas būtų pateikta vienu metu. Negalite indeksuoti struktūros, kurią sukūrėte tik iš dalies. Todėl didžiausia pilno įkėlimo atmintis yra langelių skaičiaus funkcija, ir lape su milijonais užpildytų langelių ta funkcija atsiduria ten, kur jūsų paslauga nenori būti, ypač jei kelios tokios užduotys veikia vienu metu bendrinamame kompiuteryje. Kai prieigos šablonas (access pattern), kurio jums iš tikrųjų reikia, yra nuoseklus (sequential), mokėjimas už atsitiktinę prieigą yra mokėjimas už galimybę, kurios nenaudosite
Tik į priekį einantis SAX nuskaitymas, kuris nekuria medžio
Tiesioginis skaitytuvas atidaro ZIP paketą ir pereina kiekvieno darbalapio dalį naudodamas SAX stiliaus traukimo analizatorių (pull parser). SAX čia reiškia, kad analizatorius praneša apie analizės įvykius juos aptikęs: pradžios elementas, teksto vykdymas, pabaigos elementas, ir tada juda toliau. Jis nepalieka už savęs jokio mazgų medžio. Skaitytuvas seka dabartinę eilutę ir stulpelį iš r atributų, renka langelio tipą, stiliaus indeksą, reikšmę ir formulės tekstą atvykstant įvykiams, o pamatęs uždarymo </c> žymą, išveda vieną langelį ir jį pamiršta. Kitas langelis pakartotinai naudoja tą patį nedidelį kiekį vietinių kintamųjų
Kadangi niekas nėra išsaugoma tarp langelių, atminties pėdsakas (memory footprint) neauga su langelių skaičiumi. Tai yra savybė, kurios verta laikytis. Dviejų šimtų eilučių lapas ir dvidešimties milijonų eilučių lapas kainuoja skaitytuvui tiek pat nuolatinės (resident) atminties, ir skirtumas tarp jų yra tik tas, kiek laiko trunka skenavimas. Jūs atsisakote atsitiktinės prieigos, pagrindinės modelio ypatybės, o mainais gaunate atminties ribą, kurios langelių skaičius negali peržengti
Kas išlieka nuolat, ir kodėl būtent tos dvi dalys
Skenavimas nėra visiškai be būsenos (stateless), ir išimtys yra pamokančios. Visam laikui atmintyje turi būti laikomos dvi nedidelės lentelės, nes vienas pats langelis neturi pakankamai informacijos, kad be jų būtų galima jį interpretuoti
Pirmoji yra bendra eilučių lentelė (shared string table). SpreadsheetML formate tekstinis langelis nesaugo savo teksto. Jame yra t="s" ir skaitmeninė reikšmė, kuri yra indeksas į xl/sharedStrings.xml – vieną, be dublikatų esantį sąrašą kiekvienos skirtingos eilutės (string) darbaknygėje. Tai yra geras vietos kompromisas failams, kuriuose tos pačios etiketės kartojasi tūkstančiuose eilučių, tačiau tai reiškia, kad skaitytuvas turi įkelti tą eilučių lentelę iš anksto ir laikyti ją nuolat, nes bet kuris langelis bet kuriame lape gali nurodyti į bet kurį jos įrašą. Lentelės dydį lemia skirtingų eilučių skaičius, o ne langelių skaičius, todėl ji išlieka nedidelė net didžiuliuose lapuose
Antroji yra skaičių formato susiejimas (mapping) iš stilių dalies. Skaitmeninis langelis ir datos langelis (date cell) tinkle yra visiškai vienodi baitas po baito: abu yra paprastas skaičius, nes data SpreadsheetML yra tik nuoseklus dienų skaičius. Vienintelis dalykas, kuris juos skiria, yra langelio stilius, kuris per cellXfs faile xl/styles.xml nurodo į skaičiaus formato ID. Norėdamas pranešti apie datą kaip apie datą, o ne kaip neapdorotą serijinį skaičių, skaitytuvas įkelia tą stiliaus į formatą lentelę ir laiko ją nuolat. Viskas, kas yra faile, t.y. tikri langelių duomenys, sudarantys didžiąją dalį baitų, praeina srautu, nebūdami saugomi
Kiekvienas langelis praneša rūšį ir reikšmę
Kiekvienas išvestas langelis gaunamas kaip TXLSDirectCell įrašas. Jame yra lapo indeksas ir pavadinimas, 1 pagrindu (1-based) paremta eilutė ir stulpelis, semantinė Kind (rūšis), Value kaip Variant, Formula tekstas be lygybės ženklo priekyje ir neapdorotas StyleIndex. Rūšis yra viena iš šių: xdkNumber, xdkString, xdkBoolean, xdkDate arba xdkError, todėl galite išsišakoti pagal tai, ką langelis reiškia, užuot iš naujo tai išvedę iš atributų. Formulės langelis praneša apie savo išsaugoto (cached) rezultato rūšį, kartu su formulės tekstu, todėl apskaičiuota suma gaunama kaip skaičius, kuris taip pat nurodo, kaip ji buvo gauta
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;
Datos atskyrimas nuo skaičiaus
Datos klausimas nusipelno atidesnio žvilgsnio, nes būtent čia klysta dauguma naivių skaitytuvų. Skaitmeniniame langelyje nėra datos tipo. Langelis, kurio serijinė reikšmė yra 46000, gali reikšti kiekį, kainą arba 2025 m. vasario 17 d., ir failas jums pasako, kas tai yra, tik per skaičiaus formato ID, pasiekiamą per langelio stilių. ECMA-376 rezervuoja integruotų formatų ID bloką, kurių reikšmė yra fiksuota visuose standarto besilaikančiuose gamintojuose, ir datas turintys ID yra dviejuose intervaluose: nuo 14 iki 22 standartiniams datos ir laiko formatams, ir nuo 45 iki 47 praėjusio laiko formatams, pvz., [h]:mm:ss. Kai DetectDates yra įjungtas, kas numatyta pagal nutylėjimą, skaitytuvas išsprendžia kiekvieno skaitmeninio langelio stilių į jo formato ID, ir langelis, kurio ID patenka į šiuos rezervuotus intervalus, yra pranešamas kaip xdkDate, o jo Value jau konvertuota į Delphi TDateTime. Individualūs formatai taip pat tikrinami, nagrinėjant formato kodą dėl datos ir laiko žetonų, tačiau rezervuoti intervalai yra patikimas pagrindas. Išjungus DetectDates, stilių lentelė net neįkeliama, kiekvienas skaitmeninis langelis pateikiamas kaip xdkNumber, ir skenavimas tampa šiek tiek paprastesnis
Lapų praleidimas ir ankstyvas nutraukimas
Nuoseklus skenavimas turi tylų pranašumą, kuriam atsitiktinė prieiga negali prilygti: jūs galite sustoti. OnSheet įvykis suveikia prieš atidarant kiekvieną darbalapį, ir jis suteikia jums du jungiklius. Nustatykite SkipSheet ir visa ta dalis niekada nebus analizuojama – taip skenuojate tik tuos lapus, kurie jums rūpi kelių lapų darbaknygėje, nemokėdami už likusiųjų skaitymą. Nustatykite Abort, ir visas skenavimas iškart baigsis. OnCell įvykis turi savo paties Abort, todėl galite sustoti tą akimirką, kai randate tai, ko ieškojote – konkrečią eilutę, kontrolinę reikšmę (sentinel value), antraštės bloko pabaigą – neskaitydami likusių milijonų langelių. Vykdant tik į priekį, nutraukimas yra tikrai nemokamas, nes darbas, kurį praleidžiate, yra darbas, kuris dar nebuvo atliktas
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;
Langelių skaičiavimas be apdoroklio
Vieną naujesnį patobulinimą verta paminėti, nes jis įprastą klausimą paverčia vienu pigiu iškvietimu. Skaitytuvas skaičiuoja kiekvieną praeitą užpildytą langelį ir daro tai neatsižvelgiant į tai, ar yra prijungtas OnCell apdoroklis (handler). Anksčiau, nenustačius apdoroklio, užpildytų langelių skaičius grįždavo lygus nuliui, nes skaičiavimas buvo išvedimo šalutinis poveikis. Dabar skaičiavimas nepriklauso nuo išvedimo. Tai reiškia, kad galite užduoti vieną klausimą: kiek iš tikrųjų užpildytų langelių yra šioje darbaknygėje, ir gauti atsakymą už skenavimo kainą visiškai be atgalinio ryšio (callbacks) iškvietimų. Tiek ReadFile, tiek ReadStream grąžina tą sumą kaip Int64, ir po to tą patį skaičių galima rasti CellCount savybėje. Grąžinamas -1 signalizuoja, kad failo nepavyko atidaryti arba jis nėra OOXML paketas
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;
Pilnam nuskaitymui prijungiate apdoroklį ir iškviečiate ReadFile lygiai taip pat. Kontrastas su pilnu įkėlimu ir yra visa esmė: ten, kur quarterly_export.xlsx įkėlimas į darbaknygę išplėstų kiekvieną langelį į nuolatinį (resident) objektą ir laikytų juos visus, tiesioginis skaitytuvas laiko tik bendras eilutes ir stilių lentelę, o dvylika milijonų langelių teka per jūsų OnCell po vieną. Aritmetika, kuri veikė kiekvienam langeliui, nepalieka nieko po savęs, todėl didžiausia atmintis nustatoma pagal darbaknygės skirtingų eilučių skaičių, o ne pagal jos eilučių skaičių
Tiesioginis skaitytuvas yra tinkamas įrankis, kai užduotis yra perskaityti didelę darbaknygę vieną kartą ir iš jos išgauti arba apibendrinti duomenis. Jei vietoj to jums reikia pilno modelio atsitiktinės prieigos, bet norite, kad jis gerai veiktų su dideliais failais, derinimas, aprašytas mūsų užrašuose apie didelių darbaknygių našumą Delphi aplinkoje, apima šį kelią. O kai kryptis yra atvirkštinė – kuriama didelė išvestis, o ne suvartojama, serverio paketinių užduočių srautinio rašymo instrukcija taiko tą pačią pastovios atminties discipliną rašymui. Visi trys pristatomi kaip HotXLS komponento, skirto Delphi ir C++Builder, dalis kartu su skaitymo, rašymo, formulių ir formatavimo API, aprašytais kitur šiame tinklaraštyje