Technický článek

Proudový zápis HotXLS pro dávkové úlohy na serveru v Delphi

Řekněme, že noční služba v Delphi generuje jeden XLSX na zákazníka, pár set souborů, některé z nich 400 000 řádků široké. Profilujte to a překvapením zřídkakdy bývá smyčka naplňující buňky. Je jím volání SaveAs. U výchozího zapisovače se každý list serializuje do jediného řetězce XML v paměti dřív, než se tento řetězec zkomprimuje do zipu OOXML, a u širokého listu může tento dočasný řetězec zastínit velikostí model buněk, ze kterého vznikl. Takže úloha, která pohodlně sestaví svá data a drží se na 800 MB, během ukládání vystřelí přes limit kontejneru 2 GB, a OOM killer zapíše hlášení o chybě ve 3 ráno, kdy se nikdo nedívá. HotXLS, nativní knihovna losLab pro tabulkové procesory v Delphi a C++Builderu, má vlastnost mířící přesně na tuto špičku: StreamingWrite. Kolem ní stojí dvě další páky, které rozhodují o tom, jestli dávkový worker zůstane ve svém paměťovém a časovém rozpočtu, konkrétně callbacky pro zápis na úrovni řádků a chování poolu stylů uvnitř těsné smyčky

Co výchozí cesta ukládání ukládá do vyrovnávací paměti a co StreamingWrite mění

Výchozí zapisovač XLSX upřednostňuje jednoduchost. Vykreslí XML listu kompletně a pak předá hotový řetězec kompresoru zipu. To je správný kompromis pro naprostou většinu sešitů, kde se celé XML listu vejde do pár megabajtů. Přestává být správný ve chvíli, kdy serializovaná podoba jednoho listu naroste na stovky megabajtů. XML tabulkového procesoru je upovídané: každá číselná buňka stojí desítky znaků markupu a řetězec, který to všechno drží, musí být souvislý. Na grafu paměti je tento podpis těžké přehlédnout. Dlouhá plochá plošina, zatímco se plní řádky, pak ostrá trojúhelníková špička během SaveAs, a pak pád, jakmile se zip vyprázdní

Nastavení Book.StreamingWrite := True přepne SaveAs na zapisovač listu, který vydává XML listu přímo do streamu zip tak, jak se generuje. Mezilehlý řetězec se nikdy nealokuje a trojúhelníková špička se zploští do šumu

Buďte přesní v tom, co vám to skutečně přináší, protože přeceňování vede ke špatným plánům kapacity. Příznak mění jen cestu ukládání. Sestavení sešitu pořád alokuje plný model buněk v paměti, takže plošina během fáze plnění je přesně tak vysoká jako předtím. Co zmizí, je serializační špička, která se dřív skládala navrch této plošiny v čase ukládání, a u úlohy plnící 400 tisíc řádků je tato špička běžně celý rozdíl mezi vejitím se do paměťového rozpočtu a jeho prolomením. Vlastnost má výchozí hodnotu False, aby zachovala historické chování, takže zapnutí je jeden explicitní řádek, který napíšete záměrně

Paměť dávky Delphi v čase s HotXLS: výchozí SaveAs naskládá přechodnou špičku XML řetězce worksheetu na plato plnění, zatímco Book.StreamingWrite := True drží profil plochý skrze uložení
Plošina plnění je identická oběma způsoby, protože model buněk se stejně staví v paměti; StreamingWrite odstraňuje jen špičku serializace při ukládání

Hromadný export se zapnutým příznakem

Book := TXLSXWorkbook.Create;
try
  BoldIdx := Book.Fonts.Add('Calibri', 11, True, False); // index poolu, 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 na straně buňky
  end;
  Book.StreamingWrite := True;   // streamuje XML listu přímo do zipu
  Book.SaveAs('bulk.xlsx');
finally
  Book.Free;
end;

Cells[R, C] vytváří buňky na vyžádání, což udržuje tělo smyčky čisté. Dva stropy mřížky se vyplatí zapamatovat: 1 048 576 řádků a 16 384 sloupců, zpřístupněné jako XlsxMaxRow a XlsxMaxCol. Datový kanál, který přeteče strop řádků, musíte ve vlastním kódu rozdělit napříč listy. Nic po proudu přetečení nezaznamená ani jej za vás neopraví, a soubor prostě skončí useknutý na limitu

Plnění řádků bez režie Variant na úrovni jednotlivých buněk

Každé přiřazení Cells[R, C].Value platí za vyhledání buňky a konverzi Variant. U deseti tisíc řádků si toho nikdo nevšimne. U milionu řádků po dvaceti sloupcích se tato režie na volání stane dominantní cenou fáze plnění a profiler na ni ukáže rovnou. Dávková rozhraní vám místo toho umožňují předávat zapisovači celý řádek najednou. WriteRows pohání callback, který dodá jeden řádek na volání:

Tok callbacku HotXLS WriteRows v Delphi: kurzor dotazu podá jednu řádku na volání callbacku FillRow, který naplní variant pole hodnot nebo vyvolá Skip a Cancel a worksheet se plní řádek po řádku
WriteRows podá smyčku HotXLS, zatímco callback dodává jedno řádkové pole variant na volání, se Skip jako odhlášením po řádcích a Cancel jako čistým zastavením celého běhu
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;              // zdroj dat vyčerpán: čistě zastav
    Exit;
  end;
  Values := VarArrayCreate([FirstCol, LastCol], varVariant);
  Values[FirstCol]     := FReader.RecordId;
  Values[FirstCol + 1] := FReader.CustomerName;
  Values[FirstCol + 2] := FReader.Amount;
end;

// naplň řádky 2..100001, sloupce A..C, čerpej z readeru
Sheet.WriteRows(2, 1, 100001, 3, FillRow);

Příznak Cancel je to, co promění pevný rozsah řádků na „až N řádků", což je přirozený tvar, když počet řádků pochází z dotazu, jehož provádění jste ještě nedokončili. Skip je jemnější dotek: ponechá jednotlivý řádek prázdný, aniž by zastavil běh. Kromě plnění buněk se callback ukazuje jako dobrý domov pro provozní starosti, které se jinak k smyčce plnění přišroubovávají neohrabaně. Počítadlo průběhu, které tiká každý tisíc řádků, token zrušení dotazovaný z plánovače úloh, omezovač rychlosti čtení ze zdrojové databáze: to vše žije na jednom místě místo aby to bylo provlečeno kódem pro zápis buněk. Na straně čtení zrcadlí stejný vzor ForEachRow a ForEachCell, což hraje roli, když dávková úloha zároveň konzumuje i produkuje velké soubory

Pooly stylů odměňují vytažení ze smyčky

Model stylování XLSX je sada sdílených poolů. Fonts.Add, Fills.AddSolid a Borders.Add všechny vrací 0-based index poolu, a buňka odkazuje na písmo tak, že tento index plus jedna uloží do FontIndex, kde je nula rezervovaná pro výchozí hodnotu sešitu. To +1 je přímo tam ve výše uvedeném hromadném příkladu. Zapomeňte na něj a buňka si tiše osvojí špatný styl, protože chyba o jedna v indexu poolu stylů je pořád platný index a nic nevyvolá chybu

Disciplína, která z toho plyne, je vytvořit každý objekt stylu před smyčkou přes řádky a uvnitř smyčky se odkazovat na jeho index. Fonts.Add deduplikuje identické definice, takže volání jednou na řádek jen plýtvá CPU. Past je Alignments.Add, protože vrací nový záznam při každém volání. Uvnitř smyčky se 100 tisíci řádky to pohřbí styles.xml pod sto tisíci duplicitními záznamy zarovnání, což nafoukne soubor na disku a zpomalí každé pozdější otevření v Excelu, jak se duplicity znovu parsují. Postavte každý styl jednou mimo smyčku a pak se na jeho index odkazujte tolikrát, kolikrát potřebujete

Streamy, dočasné adresáře a dávková smyčka kolem toho všeho

Nic z tohoto nevyžaduje souborový systém. Obě fasády nesou přetížení TStream napříč svým povrchem IO, mezi nimi Open, SaveAs, SaveAsCSV, SaveAsHTML a SaveAsODS, takže dávkový worker může vykreslit přímo do TMemoryStream mířícího do blob úložiště nebo odpovědi HTTP, aniž by se kdy dotkl disku. Je tu jedna ostrá hrana, na kterou je třeba pamatovat. SaveAs(Stream) zapisuje od aktuální pozice streamu a poté se nepřevine zpět, takže si sami nastavte Position := 0 dřív, než stream předáte tomu, co jej doručí, jinak konzument přečte nula bajtů. Fasáda XLS přidává dva vlastní knoflíky. SetTempDir nasměruje dočasné soubory zapisovače BIFF na svazek, který má prostor a rezervu IO na jejich pohlcení, což hraje roli na serverech, kde výchozí dočasná cesta sedí na stísněném systémovém disku. UseSharedFormulas sbalí opakovaná těla vzorců do sdílených skupin, což je skutečné zmenšení velikosti pro klasický tvar reportu, kde je jeden vzorec zkopírovaný dolů přes celý sloupec

Samotná dávková smyčka zůstává záměrně nudná:

for FileName in SourceFiles do
begin
  Book := TXLSXWorkbook.Create;        // čerstvá instance: žádné prosakování stavu
  try
    Book.StreamingWrite := True;
    if Book.Open(FileName) <> 1 then
      Continue;                        // jeden špatný vstup nesmí zabít celou dávku
    Book.SaveAsCSV(ChangeFileExt(FileName, '.csv'), 0, ',');
  finally
    Book.Free;
  end;
end;

Čerstvá instance sešitu na soubor stojí mikrosekundy a odstraňuje celou kategorii chyb kontaminace napříč soubory: styly, definované názvy a vlastnosti dokumentu ze souboru 17 nemají žádnou cestu k proniknutí do souboru 18. Přeskoč-a-pokračuj při neúspěšném Open si zaslouží stejnou pozornost, protože jeden uříznutý nahraný soubor v dávce o 600 souborech by vás měl stát jediný řádek logu, ne zbytek běhu. Stojí za zmínku i to, co úsek CSV záměrně nedělá. SaveAsCSV zapisuje vzorce jako doslovný text a nikdy je nevyhodnocuje, takže konverzní dávka, jejíž konzumenti očekávají vypočítaná čísla, musí nejdřív spustit Calculate na relevantních buňkách, nebo začít od sešitů, které už nesou mezipaměťové výsledky z předchozího výpočtu

Model souběžnosti: jeden sešit na vlákno

Objekty ani jedné z fasád nejsou bezpečné pro vlákna, a návrh nikdy nepředstíral opak. Protože mezi instancemi neexistuje žádný sdílený globální stav, pravidlo škálování je prostě jeden sešit na pracovní vlákno, bez sdílení sešitu napříč vlákny. Pool N workerů, každý vlastnící svůj vlastní TXLSXWorkbook, škáluje téměř lineárně, dokud se stropem nestane paměť, a na tento strop dokážete nasadit číslo: největší souběžný model buněk vynásobený počtem workerů, plus jakákoli režie v čase ukládání, kterou StreamingWrite zploštil. Když fronta naroste do hloubky, uplatněte protitlak ve frontě úloh, ne uvnitř zapisovače. Vyhladovělé vlákno, které napůl zapsalo sešit, nevyprodukovalo nic užitečného, zatímco úloha, která pár sekund počkala na volného workera, doběhne netknutá

Model souběžnosti HotXLS pro dávkové úlohy serveru Delphi: fronta úloh živí pracovní vlákna, jež každé vlastní soukromou instanci TXLSXWorkbook, s back-pressure aplikovanou na frontě a pamětí jako stropem škálování
Instance sešitů nesdílejí žádný globální stav, takže jeden sešit na vlákno škáluje, dokud souběžné modely buněk nedosáhnou paměťového stropu

Pro širší obrázek ladění, včetně sdílených vzorců, přeskočení grafiky na straně čtení a pák specifických pro XLS, viz průvodce výkonem velkých sešitů. Dávkové úlohy, jejichž řádky přicházejí přímo z dotazu, pokrývají samostatně vzory exportu z databáze pro reporty v Delphi

HotXLS se do vaší služby v Delphi nebo C++Builderu kompiluje jako nativní Object Pascal bez externích závislostí; edice a licencování najdete na stránce produktu HotXLS Delphi Component