Technical Article

Hatalmas XLSX fájlok streamelése Delphiben betöltés nélkül

Egy egymillió sort és egy tucat oszlopot tartalmazó táblázat egy teljesen átlagos exportálás egy adatbázis-jelentési feladatból. Ha a szokásos módon nyitja meg, a teljes munkafüzet TXLSWorkbook-ba történő betöltésével, akkor a folyamatnak a tizenkétmillió cella mindegyikét élő objektumként kell materializálnia, még mielőtt az első sor üzleti logika lefutna. A lemezen lévő fájl lehet, hogy hatvan megabájtos tömörített XML. Az objektumfa, amellyé kibomlik, ennek többszöröse, és az egésznek egyszerre a memóriában kell lennie, mert a modell tervezésénél fogva véletlen elérésű. Egy olyan jelentés esetében, amelyet fentről lefelé kíván elolvasni, majd eldobni, ez rengeteg elpazarolt memória egy olyan struktúrára, amelyre soha nem volt szüksége

Van egy második út is ugyanazon a fájlon keresztül. Modell építése helyett a munkalap XML-jét csak előrefelé olvassa be, cellánként, és miután megvizsgálta, hagyja, hogy minden cella elfolyjon. Semmi sem halmozódik fel. A memória szinte állandó marad, függetlenül attól, hogy a lap ezer vagy tízmillió sort tartalmaz, mert az olvasó soha nem tárol többet, mint amennyit éppen elemez, plusz néhány apró keresőtáblát. Ezt teszi a HotXLS közvetlen olvasó (direct reader), és a cikk hátralévő része arról szól, hogy miért marad kicsi, és mit ad cserébe

Miért nem skálázódik a memóriában lévő modell

Az XLSX fájl egy ECMA-376 által leírt XML-részekből álló ZIP-csomag. Minden munkalap egy külön rész, az xl/worksheets/sheetN.xml, és ezen belül minden sor egy <row> elem, amely <c> cella elemeket tartalmaz. A hagyományos betöltési útvonal beolvassa ezt a részt, és minden cellához egy címezhető objektumot hoz létre, hogy később lekérhesse a Cells[12345, 7] értéket, és állandó időben válaszoljon. A véletlen hozzáférés a munkafüzet-modell lényege, és pontosan ez teszi kényelmessé a szerkesztést, a képletek kiértékelését és a stílusozást

Ennek az az ára, hogy a véletlen hozzáférés megköveteli, hogy minden egyszerre legyen jelen. Nem indexelhet be egy olyan struktúrába, amelyet csak részben épített fel. A teljes betöltés memóriacsúcsa tehát a cellaszám függvénye, és egy több millió kitöltött cellát tartalmazó lapon ez a függvény olyan helyen landol, ahol a szolgáltatás nem akar lenni, különösen akkor, ha egy megosztott gépen több ilyen feladat fut egyszerre. Ha a ténylegesen szükséges hozzáférési minta szekvenciális, akkor a véletlen hozzáférésért fizetni olyan képességért fizetést jelent, amelyet nem fog használni

Előrefelé haladó SAX-vizsgálat, amely nem épít fát

A közvetlen olvasó megnyitja a ZIP-csomagot, és végigjárja az összes munkalap részt egy SAX-stílusú "pull parser" segítségével. A SAX itt azt jelenti, hogy az elemző jelenti az elemzési eseményeket, amint találkozik velük, egy kezdő elemet, egy szövegfutást, egy befejező elemet, majd továbblép. Nem tart meg maga mögött csomópontfát. Az olvasó nyomon követi az aktuális sort és oszlopot az r attribútumokból, összegyűjti a cella típusát, stílusindexét, értékét és képletszövegét az események érkezésekor, és amikor a záró </c> címkét látja, kibocsát egy cellát, majd elfelejti. A következő cella ugyanazt a maroknyi helyi változót használja újra

Mivel a cellák között semmi sem marad meg, a memórialábnyom nem nő a cellák számával. Ebbe a tulajdonságba érdemes belekapaszkodni. Egy kétszáz soros és egy húszmillió soros lap ugyanannyi rezidens memóriába kerül az olvasónak, és a különbség köztük csak annyi, hogy mennyi ideig tart a vizsgálat. Feladja a véletlen hozzáférést, a modell fő jellemzőjét, cserébe pedig kap egy memória plafont, amelyet a cellaszám nem tud áttörni

Mi marad a memóriában, és miért pont az a két rész

A vizsgálat nem teljesen állapotmentes, és a kivételek tanulságosak. Két apró táblázatot kell az időtartam alatt a memóriában tartani, mert egy cella önmagában nem hordoz elég információt ahhoz, hogy nélkülük értelmezni lehessen

Az első a megosztott karakterlánc-tábla. A SpreadsheetML-ben a szöveges cella nem tárolja a saját szövegét. Ehelyett egy t="s" jelzést és egy numerikus adatot hordoz, amely az xl/sharedStrings.xml-re mutató index, vagyis a munkafüzetben szereplő minden különálló karakterlánc egyetlen deduplikált listája. Ez egy jó helymegtakarítás olyan fájlok esetében, ahol ugyanazok a címkék ezerszer ismétlődnek a sorokban, de ez azt jelenti, hogy az olvasónak ezt a karakterlánc-táblázatot előre be kell töltenie, és a memóriában kell tartania, mert bármelyik lap bármelyik cellája hivatkozhat annak bármelyik bejegyzésére. A táblázat a különálló karakterláncok száma, nem pedig a cellaszám alapján van méretezve, így még hatalmas lapokon is szerény méretű marad

A második a számformátum-leképezés a stílusok részben. Egy numerikus cella és egy dátumcella bájtról bájtra megegyezik a hálózaton: mindkettő egyszerű szám, mert a SpreadsheetML-ben a dátum csak egy sorozatos napszám. Az egyetlen dolog, ami megkülönbözteti őket, a cella stílusa, amely a cellXfs-en keresztül az xl/styles.xml fájlban egy számformátum azonosítóra mutat. Annak érdekében, hogy a dátumot dátumként, ne pedig nyers sorozatszámként jelenítse meg, az olvasó betölti ezt a stílus-formátum táblázatot, és a memóriában tartja. Minden más a fájlban, a tényleges cellaadatok, amelyek a bájtok zömét teszik ki, tárolás nélkül átfolynak

Minden cella jelent egy típust és egy értéket

Minden kibocsátott cella egy TXLSDirectCell rekordként érkezik. Tartalmazza a lap indexét és nevét, az 1-alapú sort és oszlopot, egy szemantikai típust (Kind), az értéket (Value) mint Variant, a képlet (Formula) szövegét a kezdő egyenlőségjel nélkül, és a nyers stílusindexet (StyleIndex). A típus az xdkNumber, xdkString, xdkBoolean, xdkDate vagy xdkError egyike, így Ön aszerint ágathat szét, hogy mit jelent a cella, ahelyett, hogy újra levezetné az attribútumokból. Egy képletcella a gyorsítótárazott eredmény típusát jelenti, mellette a képlet szövegével, így egy számított végösszeg olyan számként jelenik meg, amely azt is elárulja, hogyan állították elő

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;

A dátum megkülönböztetése a számtól

A dátum kérdése közelebbi vizsgálatot érdemel, mert itt rontja el a legtöbb naiv vizsgálóeszköz. Nincs dátum típus egy numerikus cellában. A 46000-es sorozatszámot tartalmazó cella lehet egy mennyiség, egy ár, vagy 2025. február 17-e, és a fájl csak a cella stílusán keresztül elért számformátum-azonosító révén mondja meg, melyikről van szó. Az ECMA-376 fenntart egy beépített formátumazonosítókból álló blokkot, amelynek jelentése minden megfelelő gyártónál rögzített, és a dátumot tartalmazó azonosítók két tartományban helyezkednek el: 14-től 22-ig a szabványos dátum- és időformátumokhoz, és 45-től 47-ig az eltelt idő formátumokhoz, például [h]:mm:ss. Amikor a DetectDates (Dátumok észlelése) be van kapcsolva, ami alapértelmezett, az olvasó minden numerikus cella stílusát feloldja a formátumazonosítójára, és az a cella, amelynek azonosítója ezekbe a fenntartott tartományokba esik, xdkDate-ként jelenik meg, az Value értéke pedig már Delphi TDateTime-ra van konvertálva. Az egyéni formátumokat is ellenőrzi a rendszer, a formátumkód dátum- és idő tokenjeinek megvizsgálásával, de a fenntartott tartományok jelentik a megbízható gerincet. Ha kikapcsolja a DetectDates funkciót, a stílustáblázat be sem töltődik, minden numerikus cella xdkNumber-ként jön át, és a vizsgálat egy töredékkel soványabb lesz

Lapok kihagyása és korai megszakítás

A szekvenciális vizsgálatnak van egy csendes előnye, amivel a véletlen hozzáférés nem veheti fel a versenyt: meg tud állni. Az OnSheet esemény minden munkalap megnyitása előtt lefut, és két kapcsolót ad Önnek. Állítsa be a SkipSheet (Lap kihagyása) értéket, és az a teljes rész soha nem kerül elemzésre; így vizsgálhatja meg csak a több munkalapos munkafüzetből Önt érdeklő lapokat anélkül, hogy fizetne a többi beolvasásáért. Állítsa be az Abort (Megszakítás) értéket, és az egész vizsgálat azonnal befejeződik. Az OnCell esemény saját Abort-ot is tartalmaz, így abban a pillanatban leállhat, amikor megtalálta, amit keresett, legyen az egy adott sor, egy őrérték vagy a fejlécblokk vége, anélkül, hogy elolvasná a fennmaradó több millió cellát. Egy csak előrefelé haladó vizsgálatnál a megszakítás valóban ingyenes, mert az a munka, amit kihagy, olyan munka, ami még meg sem történt

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;

Cellák számlálása kezelő nélkül

Egy friss finomítást érdemes kiemelni, mert egy gyakori kérdést egyetlen olcsó hívássá változtat. Az olvasó minden egyes kitöltött cellát megszámol, amelyen áthalad, és ezt attól függetlenül teszi, hogy van-e csatolva OnCell kezelő. Korábban beállított kezelő nélkül a kitöltött cellák száma nullaként tért vissza, mivel a számlálás a kibocsátás mellékhatása volt. Most a számlálás független a kibocsátástól. Ez azt jelenti, hogy feltehet egy kérdést, miszerint hány kitöltött cellát is tartalmaz ez a munkafüzet valójában, és a választ megkaphatja egyetlen vizsgálat áráért, mindenféle visszahívás nélkül. A ReadFile és a ReadStream is Int64-ként adja vissza ezt az összeget, és ugyanaz a szám utólag is elérhető a CellCount tulajdonságként. A -1 visszatérési érték azt jelzi, hogy a fájlt nem sikerült megnyitni, vagy nem egy OOXML-csomag

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;

A teljes vizsgálathoz csatolja a kezelőt, és pontosan ugyanúgy hívja meg a ReadFile-t. A különbség a teljes betöltéshez képest a lényeg: míg a quarterly_export.xlsx munkafüzetbe történő betöltése minden cellát egy memóriában lévő objektummá bővítene, és az egészet megtartaná, a közvetlen olvasó csak a megosztott karakterláncokat és a stílustáblázatot tartja meg, míg a tizenkétmillió cella egyesével áramlik át az Ön OnCell-jén. A cellánként lefutott aritmetika nem hagy maga után semmit, így a memóriacsúcsot a munkafüzet különálló karakterláncainak száma határozza meg, nem pedig a sorszáma

A közvetlen olvasó a megfelelő eszköz akkor, ha az a feladat, hogy egy nagy munkafüzetet egyszer beolvasson, és kinyerje vagy összefoglalja belőle az adatokat. Ha ehelyett a teljes modell véletlen hozzáférésére van szüksége, de azt szeretné, hogy a nagy fájlokon is jól viselkedjen, akkor a nagy munkafüzetek Delphiben történő teljesítményéről szóló jegyzeteinkben található finomhangolás fedi le ezt az utat. Amikor pedig megfordul az irány, és nem feldolgoz, hanem nagy kimenetet állít elő, a kiszolgálói kötegelt feladatok streamelő írásának útmutatója ugyanezt az állandó memória-fegyelmet alkalmazza az írásra. Mindhárom a Delphi és a C++Builder rendszerekhez készült HotXLS Component részeként érkezik, a blogon máshol tárgyalt olvasási, írásási, képlet- és formázási API-k mellett