Technical Article

Egymillió soros XLSX írása Delphiben állandó memóriával

Egy jelentéskészítési feladat egy évig gond nélkül lefut. Felépít egy munkafüzetet, kitölt egy lapot azzal, amit a lekérdezés visszaad, majd elmenti. Aztán egy öt éves múlttal rendelkező ügyfél teljes exportálást kér, a sorszám meghaladja az egymilliót, és a folyamat memóriahiány miatti hibával leáll, jóval azelőtt, hogy a fájl a lemezre kerülne. A kóddal semmi baj nem volt. Az egész munkafüzetet a RAM-ban tartotta, hogy a végén szerializálni tudja, és a szükséges memória a megírandó sorok számával egyenes arányban nőtt

A megoldás nem egy nagyobb gép, hanem egy másfajta írási modell. A HotXLS streamelő közvetlen írója (direct writer) lépésenként bocsátja ki az OOXML-csomagot, amint a sorok megérkeznek, így a felhasznált memória nem attól függ, hogy hány sort ír. Ez a streamelő olvasó írási oldali megfelelője: míg az olvasó úgy halad végig egy hatalmas lapon, hogy nem épít cellafát, az író úgy hoz létre egyet, hogy szintén nem épít cellafát

Miért nő a normál mentési útvonal az adatokkal

A hagyományos TXLSXWorkbook útvonal először egy teljes objektummodellt épít fel. Minden cella, a hozzá tartozó értékkel, típussal és stílushivatkozással együtt, objektumként él a memóriában, amíg meg nem hívja a mentést, amikor is a teljes fa szerializálódik a csomagba. Ez a modell akkor megfelelő, ha be akar olvasni egy lapot, szerkeszteni, újraszámolni, és visszaírni, mert a véletlen hozzáférés bármely cellához pontosan az, amire a szerkesztésnek szüksége van. Akkor viszont helytelen, amikor csak egy irányba önti a sorokat, és soha nem tekint vissza, mert fizet azért, hogy minden sor a memóriában maradjon, anélkül, hogy ebből előnye származna. Egymillió sornyi objektum az egymillió sornyi objektum marad, akár visszatér rájuk, akár nem

A streamelő író eltávolítja a fát. Amint egy cella kiírásra kerül, bájtjaivá válik a munkalap részben, és ezeket a bájtokat a rendszer átadja a ZIP-kimenetnek. A munkalap adatfolyama az egyetlen puffer, amely növekszik, és a kimeneti oldalon nő, nem pedig élő Delphi objektumokként a heapen. Ami a memóriában marad, az egy fix mennyiségű könyvelés: a lapok nevei, néhány jelző, az aktuális sorszám, egy cellaszámláló. Ez a halmaz az első és a tízmilliomodik sor között sem változik

A megosztott karakterlánc-tábla a csapda, és a beágyazott karakterláncok jelentik a kiutat

A legtöbb streamelő XLSX író jól teljesít, amíg nem találkozik szöveggel. Az OOXML formátum a karakterláncokat általában egy megosztott karakterlánc-táblázatban tárolja: minden különálló karakterlánc egyszer van beírva egy külön részbe, és minden cella, amelyik tartalmazza az adott karakterláncot, a szöveg helyett egy, a táblázatra mutató indexet hordoz. Ez jó helyoptimalizáció a sok ismétlődő címkével teli fájloknál, és a szabványos mentési útvonal is ezt használja alapértelmezettként. A probléma egy streamelő író számára brutális. A deduplikációhoz a táblának a teljes munka során a memóriában kell maradnia, mert bármelyik még hátralévő sor megismételhet egy már megírt sorból származó karakterláncot, és csak a látott karakterláncok teljes, memórián belüli térképe tudja hozzárendelni a megfelelő indexet. Tehát az az egyetlen struktúra, amelyet egy streamelő író nem tud streamelni, pont az a struktúra, amelynek a fájlt kicsivé kellene tennie. A sok szöveget tartalmazó adatok meghiúsítják a streamelést, amiért jött

A közvetlen író (direct writer) teljesen megkerüli a táblát. A karakterláncok beágyazva kerülnek kiírásra, t="inlineStr" cellákként, amelyek szövege közvetlenül a cellán belül, egy <is><t> elemben helyezkedik el. Nincs felhalmozandó tábla, és nincs memóriában tartandó térkép a látott karakterláncokról, így a szöveges oszlopok nem igényelnek több memóriát, mint a numerikusak. A csere egyértelmű, és érdemes világosan megfogalmazni. A beágyazott karakterláncok ugyanazt a szöveget ismétlik meg, ahol csak előfordulnak, így a sok azonos címkével rendelkező fájl a lemezen nagyobb lesz, mint a megosztott karakterláncos megfelelője. A fájlméretet állandó memóriára váltja be. Egy egymentes exportálásnál ez a jó oldala a cserének, és a ZIP-tömörítés egyébként is elnyeli az ismétlődés nagy részét a kimenet során

A stílustábla a végén érkezik, egy dátumformátummal

A stílusok ugyanolyan feszültséget keltenek, mint a karakterláncok. Egy munkafüzet a formázására egy stílus részen keresztül hivatkozik, és egy streamelő író nem tudja a stílusok növekvő palettáját lépésben tartani a már kiürített cellákkal. A közvetlen író erre azzal válaszol, hogy a stílustáblázatot kicsiben és rögzítve tartja, és nem előre, hanem lezáráskor bocsátja ki. Egy alapértelmezett cellaformátum fedi le a hétköznapi cellákat. Egy dátum-szám formátum fedi le a dátumokat, regisztrálva yyyy-mm-dd formátumkóddal a cellaformátumok listájának egy ismert helyén

Ez a dátumformátum az oka annak, hogy a WriteDateTime önálló hívásként létezik. Az Excelnek nincs natív dátumtípusa; a dátum egy dátumformátumot viselő szám. A WriteDateTime az értéket egyszerű sorozatszámként írja ki, és a cellát azzal az egyetlen dátumstílussal látja el, így a táblázatkezelő egy ötjegyű egész szám helyett dátumként jeleníti meg. A sorozatszám, amelyet kiír, számít a visszaút szempontjából (round-tripping). A TDateTime értéket közvetlenül az 1900-as dátumrendszer alatt tárolja, ami ugyanaz a konvenció, amit a hagyományos TXLSXWorkbook mentési útvonal használ. Mivel mindkét útvonal megegyezik a sorozatszámban, a streamelő író által előállított fájl visszaolvasható a HotXLS olvasón keresztül, és az Excelben az Ön által szándékozott dátumokkal nyílik meg, anélkül, hogy egy-gyel elcsúszna, vagy korszakos meglepetés érné az író és az olvasó között

A sorrend kötelező, mert a bájtok már eltűntek

A streamelés a memóriaprofilját egy olyan szabállyal vásárolja meg, amelyet be kell tartania. A kimenet kibocsátása folyamatosan történik, és nem lehet visszatérni rá, ezért mindent abban a sorrendben kell megírni, ahogyan az a fájlban megjelenik. Egy soron belül a cellák növekvő oszlopsorrendben következnek. Egy lapon belül a sorok növekvő sorrendben következnek. Nincs puffer, amely lehetővé tenné az író számára, hogy utólag rendezze a cellákat, mert az egy pillanattal ezelőtt lezárt sor már bájtok a ZIP-folyamban, és többé nem elérhető. Ha ugyanabban a sorban az 5., majd a 2. oszlopot adja át neki, akkor a kimenet hibás lesz, mivel az író egyszerűen azt bocsátja ki, amit átad neki, abban a sorrendben, ahogyan átadja

A sor API (row API) rendelkezik egy kis kényelmi funkcióval az általános esethez. Az AddRow egy 1-alapú sorindexet vesz fel, de a 0 átadása azt jelenti, hogy az előző sor után a következőt veszi fel, így a szekvenciális kitöltésnek nem kell egy növekvő számlálót nyomon követnie és átadnia. Minden AddRow lezárja az előtte lévő sort, és minden AddSheet lezárja az előtte lévő lapot, így soha nem kell explicit módon befejeznie egy sort vagy egy lapot. Ön elkezdi a következőt, és az író véglegesíti a nyitott struktúrát az Ön számára

Az escape-elés ott van kezelve, ahol a szöveg belép az XML-be

Bármilyen szöveg, amelyet ír, egy XML-dokumentum részévé válik, ezért az öt előre definiált XML-entitást (karakterkódolást) escape-elni kell, különben a csomag abban a pillanatban érvénytelenné válik, amint egy érték és-jelet (&) vagy csúcsos zárójelet (< vagy >) tartalmaz. Az író elvégzi az Ön számára az &, <, >, ", és ' escape-elését mind a beágyazott karakterlánc-szövegben, mind a képlet szövegében, azon a két helyen, ahol a hívó által megadott karakterek a markupban landolnak. Ön egy nyers WideString-et ad át, és az író biztonságossá teszi azt. Egy olyan terméknév, mint a Smith & Co <Ltd>, vagy egy idézőjeles lapnévre hivatkozó képlet jól formázott XML-ként jön ki, mindenféle escape-elés nélkül az Ön részéről

Életciklus, és miért zár be a Destroy mégis

A csomag befejezése az, ami kiírja a munkafüzet részt, a stílusok részt, a tartalomtípusokat és a kapcsolati részeket, és végül a ZIP központi könyvtárat. Ez a munka a Close metódusban történik. Egy soha be nem zárt csomag egy befejezetlen ZIP, amelyet egyetlen táblázatkezelő program sem fog megnyitni, tehát a bezárás nem egy opcionális takarítás, ez az a lépés, amely érvényessé teszi a fájlt. Egy hibás útvonalon felejtett Close elleni védekezésként a Destroy végrehajt egy tőle telhető legjobb bezárást, ha a csomag még nyitva van, így az író felszabadítása nem szivárogtatja ki a mögöttes ZIP objektumot még akkor sem, ha egy kivétel kihagyta az explicit hívást. A megbízható minta még mindig a szokásos Delphi-minta: írjon egy try blokkban, hívja meg a Close metódust, és szabadítsa fel a finally részben

Nagy lap streamelése elejétől a végéig

A feladat formája: kezdés, lap hozzáadása, sorok öntése, bezárás. Az alábbi példa ír egy fejlécsort, majd egy hosszú, tipizált adatsorokból álló sorozatot, karakterláncokat, számokat, egy gyorsítótárazott eredmény nélküli képletet és egy dátumot keverve. A memória, amelyet tíz, illetve tízmillió sorra használ, ugyanaz, mert minden cella abban a pillanatban elindul a ZIP-folyam felé, amint kiírták

uses
  lxDirectWrite;

procedure StreamReport(const Path: string; RowCount: Integer);
var
  W: TXLSDirectWriter;
  I: Integer;
begin
  W := TXLSDirectWriter.Create;
  try
    W.BeginFile(Path);
    W.AddSheet('Sales');

    // Header row, written in ascending column order
    W.AddRow(1);
    W.WriteString(1, 'Item');
    W.WriteString(2, 'Qty');
    W.WriteString(3, 'Price');
    W.WriteString(4, 'Total');
    W.WriteString(5, 'Date');

    // Data rows; pass 0 to AddRow to take the next row automatically
    for I := 1 to RowCount do
    begin
      W.AddRow(0);
      W.WriteString(1, 'Item ' + IntToStr(I));
      W.WriteNumber(2, I);
      W.WriteNumber(3, 1.5 + (I mod 10));
      W.WriteFormula(4, Format('B%d*C%d', [I + 1, I + 1]));
      W.WriteDateTime(5, EncodeDate(2026, 1, 1) + I);
    end;

    W.Close;                       // finalises the package
  finally
    W.Free;
  end;
end;

Egy második lap egyszerűen egy újabb AddSheet hívás, mielőtt folytatná, és az író bezárja az első lapot, miközben megnyitja a másodikat. A logikai jelzők (boolean flags) a WriteBoolean-t használják, amely egy tipizált logikai cellát ír a "True" (Igaz) szöveg helyett. Ha meg akar bizonyosodni arról, hogy a fájl hibátlan és oda-vissza bejárható, a CellCount tulajdonság jelentést tesz a kiírt cellák számáról, és a streamelő olvasóval történő visszaolvasás során ugyanezt az összeget kell kapnia

  // A second sheet of typed flags after the data sheet above
  W.AddSheet('Flags');
  W.AddRow(1);
  W.WriteString(1, 'Name');
  W.WriteString(2, 'Active');
  W.AddRow(0);
  W.WriteString(1, 'alpha');
  W.WriteBoolean(2, True);

  WriteLn(Format('wrote %d cells', [W.CellCount]));

A folyamatba történő írás a fájl helyett ugyanaz a kód, azzal a különbséggel, hogy a BeginFile helyett a BeginStream szerepel, amely lehetővé teszi a kiszolgáló számára, hogy a munkafüzetet egy HTTP-válaszba vagy egy memóriafolyamba küldje, anélkül, hogy a lemezen egy ideiglenes fájl jönne létre. Az író nem birtokolja az Ön által átadott folyamot (stream), így Ön továbbra is irányíthatja annak élettartamát

Amikor a feladat egy olyan kiszolgálói végpont, amely igény szerint épít fel munkafüzeteket, a kiszolgálói kötegelt feladatok streamelő írásának útmutatójában szereplő minták mutatják be, hogyan lehet ezt bekötni egy kéréskezelőbe és egy ütemezett exportba. Ha pedig a kérdés a nagyon nagy munkafüzetek tágabb értelemben vett költsége, mind olvasás, mind írás esetén, a nagy munkafüzetek Delphiben történő teljesítményéről szóló cikkünk megmutatja, hová megy el valójában az idő és a memória. A streamelő közvetlen író a Delphi és a C++Builder rendszerekhez készült HotXLS Component részeként érkezik, a blogon máshol tárgyalt teljes olvasási, szerkesztési és mentési API-k mellett