Reportovacia úloha beží rok bez problémov. Zostaví zošit, naplní hárok tým, čo dotaz vráti, a uloží ho. Potom zákazník s päťročnou históriou požiada o úplný export, počet riadkov prekročí milión a proces zlyhá s chybou nedostatku pamäti ešte predtým, ako sa súbor dostane na disk. Na kóde nebolo nič zlé. Držal celý zošit v pamäti RAM, aby ho mohol na konci serializovať, a pamäť, ktorú potreboval, rástla v súlade s počtom riadkov, ktoré mal zapísať
Riešením nie je väčší stroj. Je to iný model zápisu. Streamovací priamy zapisovač v HotXLS vysiela balík OOXML postupne, ako prichádzajú riadky, takže pamäť, ktorú používa, nezávisí od počtu zapísaných riadkov. Je to zápis-strana náprotivku k streamovaciemu čítaču: kým čítač prechádza obrovským hárkom bez zostavenia stromu buniek, zapisovač ho produkuje taktiež bez zostavenia stromu buniek
Prečo bežná cesta ukladania rastie s dátami
Bežná cesta TXLSXWorkbook najprv zostaví úplný objektový model. Každá bunka so svojou hodnotou, typom a odkazom na štýl žije ako objekt v pamäti, kým nezavoláte uloženie, v tom okamihu sa celý strom serializuje do balíka. Tento model je správny, keď chcete prečítať hárok, upraviť ho, prepočítať a zapísať späť, pretože náhodný prístup k ľubovolnej bunke je presne to, čo úpravy vyžadujú. Je nesprávny, keď vkladáte riadky jedným smerom a nikdy sa neobzriete späť, pretože platíte za udržiavanie každého riadku v pamäti bez akéhokoľvek úžitku. Milión riadkov objektov je milión riadkov objektov, či ich navštívite znova alebo nie
Streamovací zapisovač strom odstráni. Hneď ako je bunka zapísaná, stane sa bajtmi v časti pracovného hárka a tieto bajty sú odovzdané do zip výstupu. Stream pracovného hárka je jediný buffer, ktorý rastie, a rastie na výstupnej strane, nie ako živé objekty Delphi na halde. To, čo zostáva v pamäti, je pevné množstvo administratívnych údajov: názvy hárkov, niekoľko príznakov, aktuálne číslo riadku, počítadlo buniek. Táto sada sa nemení medzi prvým a desaťmilióntym riadkom
Tabuľka zdieľaných reťazcov je pasca a vložené reťazce sú cesta von
Väčšina streamovacích zapisovačov XLSX funguje dobre, kým nenarazí na text. Formát OOXML bežne ukladá reťazce do tabuľky zdieľaných reťazcov: každý jedinečný reťazec sa zapíše raz do samostatnej časti a každá bunka, ktorá obsahuje daný reťazec, nesie index do tabuľky namiesto samotného textu. Je to dobrá optimalizácia priestoru pre súbory plné opakujúcich sa popisov a je to predvolená možnosť, ktorú používa štandardná cesta ukladania. Problém pre streamovací zapisovač je závažný. Na deduplikáciu musí tabuľka zostať v pamäti počas celej úlohy, pretože každý nasledujúci riadok môže opakovať reťazec z už zapísaného riadku a iba úplná mapa videných reťazcov v pamäti môže priradiť správny index. Takže jedinou štruktúrou, ktorú streamovací zapisovač nemôže streamovať, je práve tá, ktorá má súbor zmenšiť. Dátovo náročné texty porázia streamovanie, ktoré ste hľadali
Priamy zapisovač tabuľku úplne obchádza. Reťazce sa zapisujú vložene, ako bunky t="inlineStr", ktorých text sedí priamo vo vnútri bunky s elementom <is><t>. Nie je žiadna tabuľka na akumuláciu ani mapa videných reťazcov na udržiavanie, takže textové stĺpce nestoja viac pamäti ako číselné. Kompromis je explicitný a stojí za to ho jasne uviesť. Vložené reťazce opakujú rovnaký text všade, kde sa vyskytne, takže súbor s mnohými identickými popismi je na disku väčší ako ekvivalent so zdieľanými reťazcami. Vymeníte veľkosť súboru za konštantnú pamäť. Pre jednopriechodový export je to správna strana kompromisu a zip kompresia aj tak absorbuje veľkú časť opakovania pri výstupe
Tabuľka štýlov prichádza na konci s jedným formátom dátumu
Štýly predstavujú rovnaké napätie ako reťazce. Zošit odkazuje na svoje formátovanie prostredníctvom časti štýlov a streamovací zapisovač nemôže udržiavať rastúcu paletu štýlov v kroku s bunkami, ktoré už odoslal. Priamy zapisovač to rieši tým, že udržuje tabuľku štýlov malú a pevnú, a emituje ju pri zatvorení, nie vopred. Jeden predvolený formát bunky pokrýva bežné bunky. Jeden formát čísla pre dátum pokrýva dátumy, zaregistrovaný s formátovým kódom yyyy-mm-dd na známej pozícii v zozname formátov buniek
Tento formát dátumu je dôvodom, prečo WriteDateTime existuje ako samostatné volanie. Excel nemá natívny typ dátumu; dátum je číslo s formátom dátumu. WriteDateTime zapíše hodnotu ako jednoduché poradové číslo a označí bunku jedným štýlom dátumu, takže tabuľka ho vykreslí ako dátum namiesto päťmiestneho celého čísla. Poradové číslo, ktoré zapíše, je dôležité pre spätné prevody. Ukladá hodnotu TDateTime priamo v systéme dátumu 1900, čo je rovnaká konvencia, akú používa bežná cesta ukladania TXLSXWorkbook. Keďže obe cesty súhlasia s poradovým číslom, súbor, ktorý produkuje streamovací zapisovač, sa načíta späť cez čítač HotXLS a otvorí sa v Exceli s dátumami, ktoré zodpovedajú vašemu zámeru, bez odchýlky o jeden alebo prekvapenia epochy medzi zapisovačom a čítačom
Poradie je povinné, pretože bajty sú už preč
Streamovanie kupuje svoj pamäťový profil jedným pravidlom, ktoré musíte dodržiavať. Výstup sa emituje priebežne a nemožno ho navštíviť znova, takže všetko musí byť zapísané v poradí, v akom sa objavuje v súbore. V rámci riadku bunky idú v rastúcom poradí stĺpcov. V rámci hárka riadky idú v rastúcom poradí. Nie je žiadny buffer, ktorý by zapisovačovi umožnil triediť vaše bunky spätne, pretože riadok, ktorý ste uzavreli pred chvíľou, je už bajtmi v zip streame a nie je viac dosiahnuteľný. Dáte mu stĺpec 5 a potom stĺpec 2 v tom istom riadku a výstup je chybný, pretože zapisovač jednoducho emituje to, čo mu dáte, v poradí, v akom mu to dávate
API riadku má malé pohodlie pre bežný prípad. AddRow berie index riadku so základom 1, ale odovzdanie 0 znamená vziať nasledujúci riadok po predchádzajúcom, takže sekvenčné plnenie nemusí sledovať a odovzdávať narastajúci počítadlo. Každé AddRow uzavrie predchádzajúci riadok a každé AddSheet uzavrie predchádzajúci hárok, takže nikdy explicitne neukončujete riadok ani hárok. Spustíte ďalší a zapisovač za vás dokončí otvorenú štruktúru
Escapovanie sa vykonáva tam, kde text vstupuje do XML
Akýkoľvek text, ktorý zapíšete, sa stane súčasťou dokumentu XML, takže päť preddefinovaných XML entít musí byť escapovaných, inak je balík neplatný v okamihu, keď hodnota obsahuje ampersand alebo ostrú zátvorku. Zapisovač escapuje &, <, >, " a ' za vás, a to v texte vloženého reťazca aj v texte vzorca, čo sú dve miesta, kde znaky poskytnuté volajúcim pristánu vo vnútri značky. Odovzdáte surový WideString a zapisovač ho zabezpečí. Názov produktu ako Smith & Co <Ltd> alebo vzorec odkazujúci na citovaný názov hárka vychádza ako dobre formovaný XML bez akéhokoľvek escapovania na vašej strane
Životný cyklus a prečo Destroy stále zatvára
Dokončenie balíka je to, čo zapíše časť zošita, časť štýlov, časti typov obsahu a vzťahov a nakoniec centrálny adresár zipu. Táto práca sa vykonáva v Close. Balík, ktorý nikdy nie je uzavretý, je neúplný zip, ktorý žiadny tabuľkový program neotvorí, takže zatvorenie nie je voliteľné čistenie, ale krok, ktorý robí súbor platným. Na ochranu pred zabudnutým Close v chybovej ceste, Destroy vykoná pokus o najlepšie možné zatvorenie, ak je balík stále otvorený, takže uvoľnenie zapisovača neunikne základnému zip objektu, aj keď výnimka preskočila explicitné volanie. Spoľahlivý vzor je stále bežný vzor Delphi: zapisujte vo vnútri try, zavolajte Close a uvoľnite v finally
Streamovanie veľkého hárka od začiatku do konca
Tvar úlohy je: začnite, pridajte hárok, vkladajte riadky, zatvorte. Nasledujúci príklad zapíše riadok hlavičky a potom dlhú sériu typizovaných dátových riadkov, kde sa miešajú reťazce, čísla, vzorec bez uloženého výsledku a dátum. Pamäť, ktorú používa pre desať riadkov a pre desať miliónov riadkov, je rovnaká, pretože každá bunka odchádza do zip streamu hneď, ako je zapísaná
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;
Druhý hárok je jednoducho ďalšie AddSheet predtým, než budete pokračovať, a zapisovač uzavrie prvý hárok pri otváraní druhého. Logické príznaky používajú WriteBoolean, ktorý zapíše typizovanú logickú bunku namiesto textu „True". Ak chcete potvrdiť, že súbor je správny a podporuje spätné prevody, vlastnosť CellCount hlási, koľko buniek bolo zapísaných, a čítanie výsledku späť pomocou streamovacieho čítača by malo hlásiť rovnaký celkový počet
// 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]));
Zápis do streamu namiesto súboru je rovnaký kód s BeginStream namiesto BeginFile, čo umožňuje serveru odoslať zošit do HTTP odpovede alebo pamäťového streamu bez dočasného súboru na disku. Zapisovač nevlastní stream, ktorý odovzdáte, takže si udržíte kontrolu nad jeho životnosťou
Keď je práca serverový endpoint, ktorý zostavuje zošity na požiadanie, vzory v streamovacích zápisoch pre server a dávkové úlohy ukazujú, ako to zapojiť do obslužnej rutiny požiadavky a plánovaného exportu. Keď ide o širšie náklady veľmi veľkých zošitov, čítanie aj zápis, výkon veľkých zošitov v Delphi pokrýva, kde skutočne idú čas a pamäť. Streamovací priamy zapisovač sa dodáva ako súčasť HotXLS Component pre Delphi a C++Builder, spolu s úplnými API na čítanie, úpravu a ukladanie, ktoré sú pokryté inde na tomto blogu