Tehnički članak

Streaming ogromnih XLSX datoteka u Delphi-ju bez učitavanja

Tabela s milion redova i desetak kolona sasvim je običan izvoz iz posla baze podataka za izvještavanje. Otvorite je uobičajenim načinom, učitavanjem cijele radne knjige u TXLSWorkbook, i proces mora materijalizirati svaku od tih dvanaest miliona ćelija kao živi objekt prije nego što vaša prva linija poslovne logike pokrene. Datoteka na disku može biti šezdeset megabajta kompresovanog XML-a. Stablo objekata u koje se to razvija je nekoliko puta to, i sve mora biti u memoriji odjednom jer je model dizajniran za nasumičan pristup. Za izvještaj koji namjeravate čitati od vrha do dna i odbaciti, to je velika količina memorije potrošene na strukturu koja vam nije bila potrebna

Postoji drugi prolaz kroz istu datoteku. Umjesto izgradnje modela, skenirate XML radnog lista samo unaprijed, jednu ćeliju u isto vrijeme, i svaka ćelija prolazi dalje nakon što ste je pogledali. Ništa se ne akumulira. Memorija ostaje gotovo konstantna bilo da list ima hiljadu redova ili deset miliona, jer čitač nikad ne drži više od dijela koji trenutno parsira plus par malih pretraživačkih tablica. To je ono što HotXLS direktni čitač radi, a ostatak ovog članka govori o tome zašto ostaje mali i što vam daje zauzvrat

Zašto in-memory model ne skalira

XLSX datoteka je ZIP paket XML dijelova opisan ECMA-376. Svaki radni list je vlastiti dio, xl/worksheets/sheetN.xml, a unutar njega svaki red je element <row> koji drži elemente ćelija <c>. Regularna putanja učitavanja čita taj dio i konstruiše adresabilni objekt za svaku ćeliju tako da možete kasnije zatražiti Cells[12345, 7] i dobiti odgovor u konstantnom vremenu. Nasumičan pristup je cijela poanta modela radne knjige, i tačno je ono što uređivanje, evaluaciju formula i stilizovanje čini praktičnim

Cijena je da nasumičan pristup zahtijeva da sve bude prisutno istovremeno. Ne možete indeksirati u strukturu koju ste samo djelomično izgradili. Pa je vršna memorija punog učitavanja funkcija broja ćelija, i na listu s milijunima popunjenih ćelija ta funkcija dospijeva negdje gdje vaš servis ne želi biti, naročito ako nekoliko takvih poslova istovremeno radi na dijeljenom računaru. Kada je vaš stvarni uzorak pristupa sekvencijalan, plaćanje za nasumičan pristup je plaćanje za mogućnost koju nećete koristiti

SAX skeniranje samo unaprijed koje ne gradi stablo

Direktni čitač otvara ZIP paket i prolazi svaki dio radnog lista SAX-stil pull parserom. SAX ovdje znači da parser prijavljuje parse događaje dok ih susreće, početni element, tekst, završni element, i zatim nastavlja. Iza sebe ne drži nikakvo stablo čvorova. Čitač prati trenutni red i kolonu iz atributa r, prikuplja tip ćelije, indeks stila, vrijednost i tekst formule dok eventi stižu, a kada se ugleda završni tag </c> emituje jednu ćeliju i zaboravlja je. Sljedeća ćelija ponovo koristi isti pregršt lokalnih varijabli

Jer ništa nije zadržano između ćelija, memorijsko zauzeće ne raste s brojem ćelija. To je svojstvo koje vrijedi zadržati. List s dvjesta redova i list s dvadeset miliona redova koštaju čitača isti rezidentni memoriju, a razlika između njih je samo koliko dugo skeniranje traje. Odričete se nasumičnog pristupa, istaknutog obilježja modela, a zauzvrat dobivate gornju granicu memorije kroz koju broj ćelija ne može probiti

Šta ostaje rezidentno i zašto ta dva dijela

Skeniranje nije potpuno bez stanja, a iznimke su poučne. Dvije male tablice moraju biti držane u memoriji za cijelo trajanje, jer ćelija sama za sebe ne nosi dovoljno informacija za interpretaciju bez njih

Prva je tablica dijeljenih niski. U SpreadsheetML, tekstualna ćelija ne pohranjuje vlastiti tekst. Nosi t="s" i numerički teret koji je indeks u xl/sharedStrings.xml, jedinstven deduplicirani popis svakog različitog niza u radnoj knjizi. Ovo je dobar kompromis prostora za datoteke gdje se iste oznake ponavljaju kroz hiljade redova, ali to znači da čitač mora unaprijed učitati tu tablicu niski i držati je rezidentnom, jer bilo koja ćelija bilo gdje na bilo kom listu može referencirati bilo koji unos u njoj. Tablica je veličinom određena brojem različitih niski, ne brojem ćelija, pa ostaje skromna čak i na ogromnim listovima

Druga je mapiranje formata broja iz dijela stilova. Numerička ćelija i datumska ćelija su bajt-za-bajt iste po žici: obje su obični broj, jer je datum u SpreadsheetML samo serijski broj dana. Jedina stvar koja ih razlikuje je stil ćelije, koji pokazuje kroz cellXfs u xl/styles.xml na ID formata broja. Da bi prijavio datum kao datum a ne kao neobrađeni serijski broj, čitač učitava tu tablicu stil-na-format i drži je rezidentnom. Sve ostalo u datoteci, stvarni podaci ćelija koji čine najveći dio bajtova, prolazi dalje bez pohrane

Svaka ćelija prijavljuje vrstu i vrijednost

Svaka emitovana ćelija stiže kao zapis TXLSDirectCell. Nosi indeks i naziv lista, 1-baziran red i kolonu, semantičku Kind, Value kao Variant, tekst Formula bez vodećeg znaka jednakosti, i sirovi StyleIndex. Vrsta je jedna od xdkNumber, xdkString, xdkBoolean, xdkDate, ili xdkError, pa možete granati prema tome što ćelija znači umjesto da to ponovo izvodite iz atributa. Ćelija s formulom prijavljuje vrstu svog keširovanog rezultata, s tekstom formule uz njega, pa se izračunata suma pojavljuje kao broj koji vam također govori kako je bila proizvedena

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 bliži pogled jer je tu gdje većina naivnih skenera griješi. Na numeričkoj ćeliji ne postoji tip datuma. Ćelija koja drži serijsku vrijednost 46000 može biti količina, cijena, ili 17. februar 2025., a datoteka vam govori koje jedino kroz ID formata broja dostignut kroz stil ćelije. ECMA-376 rezerviše blok ugrađenih ID-jeva formata čije je značenje fiksno kroz svakog konformnog proizvođača, a ID-jevi koji nose datum sjede u dva raspona: 14 do 22 za standardne formate datuma i vremena, i 45 do 47 za formate proteklog vremena kao što je [h]:mm:ss. Kada je DetectDates uključen, što je default, čitač razrješava stil svake numeričke ćelije na njen ID formata, a ćelija čiji ID pada u te rezervisane raspone se prijavljuje kao xdkDate s njenom Value već konvertovanom u Delphi TDateTime. Prilagođeni formati se također provjeravaju, inspiciranjem koda formata na datumske i vremenske tokene, ali rezervisani rasponi su pouzdana osnova. Isključite DetectDates i tablica stilova se uopće ne učitava, svaka numerička ćelija prolazi kao xdkNumber, i skeniranje je neznatno vitkije

Preskočite listove i prekinite rano

Sekvencijalno skeniranje ima tihu prednost koju nasumičan pristup ne može dosegnuti: možete stati. Događaj OnSheet se pokreće prije otvaranja svakog radnog lista, i daje vam dva prekidača. Postavite SkipSheet i cijeli taj dio se nikad ne parsira, što je način na koji skenirate samo listove koji vas zanimaju u radnoj knjizi s više listova bez plaćanja čitanja ostatka. Postavite Abort i cijelo skeniranje odmah završava. Događaj OnCell nosi vlastiti Abort, pa možete stati čim pronađete ono što ste tražili, određeni red, sentinel vrijednost, kraj bloka zaglavlja, bez čitanja preostalih miliona ćelija. Na skeniranju samo unaprijed, prekid je zaista besplatan, jer je posao koji preskačete posao koji se još nije 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;

Brojanje ćelija bez handlera

Jedno nedavno poboljšanje vrijedi istaknuti jer pretvara uobičajeno pitanje u jedan jeftini poziv. Čitač broji svaku popunjenu ćeliju kroz koju prolazi, i to radi bilo da je OnCell handler priključen ili nije. Ranije, bez postavljenog handlera, broj popunjenih ćelija bi se vratio kao nula, jer je brojanje bilo nusproizvod emitovanja. Sada je broj neovisan o emitovanju. To znači da možete postaviti jedno pitanje, koliko popunjenih ćelija ova radna knjiga zapravo sadrži, i dobiti odgovor za cijenu skeniranja bez ikakvog callback-a. ReadFile i ReadStream oba vraćaju taj ukupan broj kao Int64, i isti broj je naknadno dostupan kao svojstvo CellCount. Povrat od -1 signalizira da datoteka nije mogla biti otvorena ili nije 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;

Za puno skeniranje, priključite handler i pozovite ReadFile na isti način. Kontrast s punim učitavanjem je cijela poanta: dok bi učitavanje quarterly_export.xlsx u radnu knjigu proširilo svaku ćeliju u rezidentni objekt i sve to držalo, direktni čitač drži samo dijeljene nizove i tablicu stilova dok dvanaest miliona ćelija prolazi kroz vaš OnCell jednu po jednu. Aritmetika koja se odvijala po ćeliji ne ostavlja ništa za sobom, pa je vršna memorija određena brojem različitih niski radne knjige, ne brojem redova

Direktni čitač je pravi alat kada je posao pročitati veliku radnu knjigu jedanput i izvući ili sažeti je. Kada vam je umjesto toga potreban nasumičan pristup punog modela ali želite da se ponaša razumno s velikim datotekama, podešavanja u našim napomenama o performansama velikih radnih knjiga u Delphi-ju pokrivaju tu putanju. A kada je smjer obrnut, proizvođnja velikog izlaza umjesto potrošnje, streaming direktni pisač primjenjuje istu disciplinu konstantne memorije na pisanje. Sva tri se isporučuju kao dio HotXLS Component za Delphi i C++Builder, zajedno s API-jevima za čitanje, pisanje, formule i formatiranje obrađenim drugdje na ovom blogu