Technical Article

Streamovaný zápis HotXLS pre dávkové úlohy na serveri v Delphi

Predstavte si, že nočná služba v Delphi generuje jeden súbor XLSX pre každého zákazníka. Ide o niekoľko stoviek súborov, pričom niektoré z nich majú až 400 000 riadkov. Spustite analýzu výkonu a prekvapením bude málokedy cyklus plnenia buniek. Zvyčajne je to volanie metódy SaveAs. Pri predvolenom zapisovači sa každý hárok serializuje do jedného XML reťazca v pamäti predtým, ako sa tento skomprimuje do OOXML zip archívu, a pri širokom hárku môže dočasný reťazec zatieniť model buniek, z ktorého bol postavený. Úloha, ktorá pohodlne zostaví svoje dáta a usadí sa na hodnote 800 MB, tak pri ukladaní vyskočí nad 2 GB limit kontajnera a proces na čistenie pamäte (OOM killer) o tretej ráno, keď nikto nesleduje prevádzku, nahlási chybu. HotXLS, natívna knižnica pre tabuľky v Delphi od losLab, má vlastnosť zameranú priamo na túto špičku: StreamingWrite. Okolo nej sú k dispozícii ďalšie dva nástroje, ktoré rozhodujú o tom, či sa dávková úloha zmestí do svojho pamäťového a časového rozpočtu: spätné volania na úrovni riadkov a správanie fondu štýlov vnútri cyklu.

Čo vyrovnáva predvolená cesta a čo mení StreamingWrite

Predvolený zapisovač XLSX uprednostňuje jednoduchosť. Kompletne vykreslí XML hárka a potom odovzdá hotový reťazec zip kompresoru. Ide o správny kompromis pre veľkú väčšinu zošitov, kde sa XML celého hárka zmestí do niekoľkých megabajtov. Prestáva to však platiť, keď serializovaná podoba jedného hárka dosahuje stovky megabajtov. XML tabuliek je podrobné: každá číselná bunka stojí desiatky znakov značiek a reťazec, ktorý to všetko uchováva, musí byť súvislý. V grafe spotreby pamäte je tento priebeh jasne viditeľný: dlhá rovná plocha počas plnenia riadkov, potom prudký trojuholníkový nárast počas volania SaveAs a následný pokles po zapísaní zip archívu.

Nastavenie vlastnosti Book.StreamingWrite := True prepne zapisovač XLSX na taký, ktorý zapisuje XML kód hárka priamo do zip prúdu počas jeho generovania. Dočasný reťazec sa nikdy nealokuje a trojuholníkový nárast spotreby pamäte sa stratí v šume.

Buďte presní v tom, čo presne tým získate, pretože nadhodnotenie prínosu vedie k nesprávnym plánom kapacity. Tento príznak mení iba cestu ukladania. Zostavovanie zošita stále alokuje kompletný pamäťový model buniek, takže úroveň spotreby počas fázy plnenia je rovnaká ako predtým. Čo však zmizne, je serializačná špička, ktorá sa predtým pri uložení pripočítala k tejto úrovni, pričom pri úlohe plniacej 400 000 riadkov je táto špička bežne rozdielom medzi dodržaním pamäťového limitu a jeho prekročením. Táto vlastnosť je predvolene nastavená na False z dôvodu zachovania pôvodného správania, takže jej zapnutie je záležitosťou jedného explicitného zápisu v kóde.

Hromadný export so zapnutým príznakom

Book := TXLSXWorkbook.Create;
try
  BoldIdx := Book.Fonts.Add('Calibri', 11, True, False); // pool index, 0-based
  Sheet := Book.Sheets.Add('Bulk');
  for R := 1 to 100000 do
  begin
    Sheet.Cells[R, 1].Value := R;
    Sheet.Cells[R, 2].Value := 'Row ' + IntToStr(R);
    Sheet.Cells[R, 3].Value := R * 1.5;
    if (R mod 1000) = 0 then
      Sheet.Cells[R, 2].FontIndex := BoldIdx + 1;        // 1-based at the cell
  end;
  Book.StreamingWrite := True;   // stream sheet XML straight into the zip
  Book.SaveAs('bulk.xlsx');
finally
  Book.Free;
end;

Zápis Cells[R, C] vytvára bunky na požiadanie, čo udržiava telo cyklu čisté. Stojí za to zapamätať si dva limity mriežky: 1 048 576 riadkov a 16 384 stĺpcov, ktoré sú dostupné ako konštanty XlsxMaxRow a XlsxMaxCol. Dátový zdroj, ktorý prekročí limit riadkov, musí byť vo vašom kóde rozdelený na viacero hárkov. Nič v nadväzujúcom spracovaní toto prekročenie nezachytí ani neopraví za vás a súbor sa na limite jednoducho oreže.

Plnenie riadkov bez réžie typu Variant na každú bunku

Každé priradenie do Cells[R, C].Value platí za vyhľadanie bunky a konverziu typu Variant. Pri desiatich tisíckach riadkov si to nikto nevšimne. Pri milióne riadkov, z ktorých každý má dvadsať stĺpcov, sa táto réžia stáva dominantnou zložkou ceny fázy plnenia a analýza výkonu ukáže priamo na ňu. Hromadné rozhrania vám namiesto toho umožňujú odovzdať zapisovaču celý riadok naraz. Metóda WriteRows spúšťa spätné volanie, ktoré dodáva jeden riadok na jedno zavolanie:

procedure TBulkExporter.FillRow(Sender: TObject; SheetIndex, Row, FirstCol,
  LastCol: Integer; var Values: Variant; var Skip: Boolean;
  var Cancel: Boolean);
begin
  if not FReader.Next then
  begin
    Cancel := True;              // data source drained: stop cleanly
    Exit;
  end;
  Values := VarArrayCreate([FirstCol, LastCol], varVariant);
  Values[FirstCol]     := FReader.RecordId;
  Values[FirstCol + 1] := FReader.CustomerName;
  Values[FirstCol + 2] := FReader.Amount;
end;

// fill rows 2..100001, columns A..C, pulling from the reader
Sheet.WriteRows(2, 1, 100001, 3, FillRow);

Príznak Cancel je tým, čo mení pevný rozsah riadkov na „až do N riadkov“, čo je prirodzený stav, keď počet riadkov pochádza z dopytu, ktorého vykonávanie ste ešte nedokončili. Príznak Skip je jemnejší: ponechá konkrétny riadok prázdny bez prerušenia behu programu. Okrem plnenia buniek sa spätné volanie ukazuje ako dobré miesto pre prevádzkové záležitosti, ktoré sa inak zložito vkladajú do cyklu plnenia. Počítadlo priebehu, ktoré sa aktualizuje každých tisíc riadkov, kontrola požiadavky na zrušenie z plánovača úloh alebo obmedzovač rýchlosti čítania zo zdrojovej databázy: to všetko žije na jednom mieste namiesto toho, aby to bolo roztrúsené v kóde pre zápis buniek. Na strane čítania kopírujú rovnaký vzor metódy ForEachRow a ForEachCell, čo je dôležité, keď dávková úloha veľké súbory spotrebováva aj produkuje.

Fondy štýlov odmeňujú presun pred cyklus

Model štýlovania XLSX predstavuje sadu zdieľaných fondov. Metódy Fonts.Add, Fills.AddSolid a Borders.Add vracajú index vo fonde od nuly a bunka odkazuje na písmo uložením tohto indexu zvýšeného o jedna do vlastnosti FontIndex, kde nula je vyhradená pre predvolený štýl zošita. Zápis +1 je viditeľný aj v hromadnom príklade vyššie. Vynechajte ho a bunka ticho získa nesprávny štýl, pretože nesprávny index vo fonde štýlov je stále platným indexom a nevyvolá žiadnu výnimku.

Pravidlom, ktoré z toho vyplýva, je vytvoriť každý objekt štýlu pred cyklom riadkov a vnútri cyklu sa odkazovať už len na jeho index. Metóda Fonts.Add deduplikuje rovnaké definície, takže jej volanie pre každý riadok iba plytvá procesorovým časom. Pascou je Alignments.Add, pretože vracia nový záznam pri každom volaní. Vnútri cyklu so 100 000 riadkami to zaplaví súbor styles.xml stotisíc duplicitnými záznamami zarovnania, čo nafukuje veľkosť súboru na disku a spomaľuje každé neskoršie otvorenie v Exceli z dôvodu ich analýzy. Vytvorte každý štýl raz mimo cyklu a odkazujte sa na jeho index toľkokrát, koľkokrát potrebujete.

Prúdy, dočasné adresáre a dávkový cyklus okolo toho všetkého

Nič z toho nevyžaduje súborový systém. Obe rozhrania obsahujú preťaženia TStream naprieč celým svojím IO rozhraním (vrátane metód Open, SaveAs, SaveAsCSV, SaveAsHTML a SaveAsODS), takže dávková úloha môže zapisovať dáta priamo do TMemoryStream určeného pre úložisko objektov alebo sieťovú odpoveď bez zápisu na disk. Treba si pamätať na jednu dôležitú vec. Metóda SaveAs(Stream) zapisuje od aktuálnej pozície v prúde a neposúva sa na začiatok, preto pred odovzdaním prúdu nastavte vlastnosť Position := 0, inak príjemca prečíta nula bajtov. Rozhranie XLS pridáva dve vlastné nastavenia. Metóda SetTempDir nasmeruje dočasné súbory zapisovača BIFF na zväzok, ktorý má dostatok miesta a výkonu na ich spracovanie, čo je dôležité na serveroch, kde je predvolená systémová dočasná cesta umiestnená na obmedzenom disku. Vlastnosť UseSharedFormulas spája opakujúce sa telá vzorcov do zdieľaných skupín, čo výrazne zmenšuje veľkosť pri klasickom reporte, kde sa jeden vzorec kopíruje naprieč celým stĺpcom.

Samotný dávkový cyklus zostáva zámerne jednoduchý

for FileName in SourceFiles do
begin
  Book := TXLSXWorkbook.Create;        // fresh instance: no state bleed
  try
    Book.StreamingWrite := True;
    if Book.Open(FileName) <> 1 then
      Continue;                        // one bad input must not kill the batch
    Book.SaveAsCSV(ChangeFileExt(FileName, '.csv'), 0, ',');
  finally
    Book.Free;
  end;
end;

Nová inštancia zošita pre každý súbor stojí mikrosekundy a odstraňuje celú kategóriu chýb s prenosom stavu medzi súbormi: štýly, definované názvy a vlastnosti dokumentu zo súboru 17 nemajú šancu uniknúť do súboru 18. Preskočenie a pokračovanie pri zlyhaní metódy Open je rovnako dôležité, pretože jeden poškodený nahraný súbor v dávke 600 súborov by mal stáť len jeden záznam v logu a nie pád celého behu programu. Za zmienku stojí aj to, čo export do CSV zámerne nerobí. Metóda SaveAsCSV zapisuje vzorce ako doslovný text a nevyhodnocuje ich, takže dávka konverzie, ktorej odberatelia očakávajú vypočítané čísla, musí najprv spustiť metódu Calculate na príslušných bunkách alebo začať zo zošitov, ktoré už obsahujú uložené výsledky z predchádzajúceho výpočtu.

Model konkurentnosti: jeden zošit na jedno vlákno

Objekty oboch rozhraní nie sú vláknovo bezpečné a návrh to nikdy nepredstieral. Keďže medzi inštanciami neexistuje žiadny zdieľaný globálny stav, pravidlo škálovania je jednoduché: jeden zošit na jedno pracovné vlákno bez zdieľania zošita medzi vláknami. Skupina N vlákien, z ktorých každé vlastní svoj objekt TXLSXWorkbook, škáluje takmer lineárne, kým sa nedosiahne pamäťový strop. Tento strop sa dá vyčísliť: najväčší súbežný model buniek vynásobený počtom vlákien plus úspora, ktorú prinieslo vyhladenie špičiek cez StreamingWrite. Keď je front požiadaviek plný, aplikujte reguláciu na úrovni úloh (job queue) a nie vnútri zapisovača. Vlákno, ktoré napoly zapísalo zošit a vyčerpalo prostriedky, neprodukuje nič užitočné, zatiaľ čo úloha, ktorá počkala pár sekúnd na voľné vlákno, sa dokončí úspešne.

Pre širší pohľad na ladenie výkonu vrátane zdieľaných vzorcov, preskakovania grafiky na strane čítania a špecifík rozhrania XLS navštívte sprievodcu výkonom veľkých zošitov. Dávkové úlohy, ktorých riadky pochádzajú priamo z databázového dopytu, sú popísané samostatne v článku o exporte databáz pre reporty v Delphi.

HotXLS sa do vašej služby v Delphi alebo C++Builderi kompiluje ako natívny Object Pascal bez externých závislostí; edície a licencovanie nájdete na produktovej stránke komponentu HotXLS.