Artykuł techniczny

Zapis miliona wierszy XLSX w Delphi przy stałej pamięci

Zadanie raportowania działa dobrze przez rok. Buduje skoroszyt, wypełnia arkusz tym, co zwróci zapytanie, i zapisuje. Potem klient z pięcioletnim historią prosi o pełny eksport, liczba wierszy przekracza milion i proces umiera z błędem braku pamięci na długo przed tym, zanim plik trafi na dysk. Z kodem nie było nic złego. Trzymał cały skoroszyt w RAM, żeby go zserializować na końcu, a potrzebna mu pamięć rosła w lockstepie z liczbą wierszy, jakie miał zapisać

Rozwiązaniem nie jest większa maszyna. To inny model zapisu. Bezpośredni zapis strumieniowy w HotXLS emituje pakiet OOXML przyrostowo w miarę napływania wierszy, więc używana przez niego pamięć nie zależy od tego, ile wierszy zapiszesz. To odpowiednik zapisu bezpośredniego czytnika: tak jak czytnik przechodzi przez duży arkusz bez budowania drzewa komórek, tak zapisywacz produkuje go bez budowania drzewa komórek

Dlaczego normalna ścieżka zapisu rośnie wraz z danymi

Standardowa ścieżka TXLSXWorkbook najpierw buduje pełny model obiektowy. Każda komórka - ze swoją wartością, typem i odwołaniem do stylu - żyje jako obiekt w pamięci, aż wywołasz zapis, w którym momencie całe drzewo jest serializowane do pakietu. Ten model jest właściwy, gdy chcesz odczytać arkusz, edytować go, przeliczać i zapisywać z powrotem, bo właśnie losowy dostęp do dowolnej komórki jest tym, czego edytowanie potrzebuje. Jest zły, gdy wlewasz wiersze w jednym kierunku i nigdy nie patrzysz wstecz, bo płacisz za trzymanie każdego wiersza w pamięci bez żadnego zysku. Milion wierszy obiektów to milion wierszy obiektów niezależnie od tego, czy kiedykolwiek do nich wrócisz

Zapisywacz strumieniowy usuwa drzewo. Gdy tylko komórka zostaje zapisana, staje się bajtami w części arkusza, a te bajty są przekazywane do wyjścia ZIP. Strumień arkusza to jedyny bufor, który rośnie - i rośnie po stronie wyjścia, nie jako żywe obiekty Delphi na stercie. W pamięci pozostaje stała ilość bookkeepingu: nazwy arkuszy, kilka flag, bieżący numer wiersza, licznik komórek. Ten zestaw nie zmienia się między wierszem pierwszym a wierszem dziesięciomilionowym

Tabela ciągów udostępnionych to pułapka, a ciągi inline są wyjściem

Większość strumieniowych zapisywaczy XLSX radzi sobie dobrze, dopóki nie natrafi na tekst. Format OOXML normalnie przechowuje ciągi w tabeli ciągów udostępnionych: każdy odrębny ciąg jest zapisywany raz do osobnej części, a każda komórka zawierająca ten ciąg nosi indeks do tabeli zamiast tekstu. To dobra optymalizacja przestrzeni dla plików pełnych powtarzających się etykiet i jest to domyślna ścieżka standardowego zapisu. Problem dla zapisywacza strumieniowego jest brutalny. Aby deduplikować, tabela musi pozostawać w pamięci przez cały czas zadania, bo dowolny wiersz, który jeszcze przyjdzie, może powtórzyć ciąg z wiersza już zapisanego - a tylko kompletna mapa widzianych ciągów w pamięci może przypisać właściwy indeks. Więc jedyna struktura, której zapisywacz strumieniowy nie może strumieniować, to właśnie ta, która ma zmniejszać plik. Dane bogate w tekst anulują strumieniowanie, po które przyszedłeś

Bezpośredni zapisywacz całkowicie omija tabelę. Ciągi są zapisywane inline, jako komórki t="inlineStr", których tekst siedzi bezpośrednio wewnątrz komórki w elemencie <is><t>. Nie ma tabeli do akumulowania i żadnej mapy widzianych ciągów do trzymania - więc kolumny tekstowe nie kosztują więcej pamięci niż numeryczne. Kompromis jest jawny i warto go powiedzieć wprost. Ciągi inline powtarzają ten sam tekst wszędzie, gdzie się pojawia - więc plik z wieloma identycznymi etykietami jest większy na dysku niż odpowiednik z ciągami udostępnionymi. Poświęcasz rozmiar pliku, żeby kupić stałą pamięć. Dla jednorazowego eksportu to właściwa strona kompromisu, a kompresja ZIP pochłania wiele powtórzeń w drodze na wyjście i tak

Tabela stylów przybywa na końcu, z jednym formatem daty

Style stwarzają takie samo napięcie co ciągi. Skoroszyt odwołuje się do formatowania przez część stylów, a zapisywacz strumieniowy nie może trzymać rosnącej palety stylów w kroku z komórkami, które już wyforsował. Bezpośredni zapisywacz odpowiada na to, trzymając tabelę stylów małą i ustaloną, i emitując ją przy zamknięciu, a nie z góry. Jeden domyślny format komórki obejmuje zwykłe komórki. Jeden format liczb dla daty obejmuje daty, zarejestrowany z kodem formatu yyyy-mm-dd na znanej pozycji na liście formatów komórek

Ten format daty jest powodem istnienia WriteDateTime jako osobnego wywołania. Excel nie ma natywnego typu daty; data to liczba ubrana w format daty. WriteDateTime zapisuje wartość jako zwykły numer seryjny i oznacza komórkę jedynym stylem daty, żeby arkusz renderował ją jako datę zamiast pięciocyfrowej liczby całkowitej. Numer seryjny, który zapisuje, ma znaczenie dla round-trippingu. Przechowuje wartość TDateTime bezpośrednio w systemie dat 1900, co jest tą samą konwencją, jakiej używa standardowa ścieżka zapisu TXLSXWorkbook. Ponieważ obie ścieżki zgadzają się co do numeru seryjnego, plik wyprodukowany przez zapisywacz strumieniowy odczytuje się przez czytnik HotXLS i otwiera w Excelu z datami zgodnymi z intencją - bez błędu o jeden czy niespodzianki epoki między zapisywaczem a czytnikiem

Kolejność jest obowiązkowa, bo bajty już poszły

Strumieniowanie kupuje swój profil pamięci jedną regułą, którą musisz honorować. Wyjście jest emitowane w miarę postępowania i nie można do niego wracać, więc wszystko musi być zapisane w kolejności, w jakiej pojawia się w pliku. W wierszu komórki idą w rosnącej kolejności kolumn. W arkuszu wiersze idą w rosnącej kolejności. Nie ma bufora, który pozwoliłby zapisywaczowi posortować komórki po fakcie, bo wiersz zamknięty chwilę temu jest już bajtami w strumieniu ZIP i nie jest już osiągalny. Daj mu kolumnę 5 a potem kolumnę 2 w tym samym wierszu, a wyjście będzie zniekształcone - zapisywacz po prostu emituje to, co dajesz, w sekwencji, w jakiej dajesz

API wierszy ma małą wygodę dla często spotykanego przypadku. AddRow przyjmuje indeks wiersza od 1, ale podanie 0 oznacza wziąć następny wiersz po poprzednim - więc sekwencyjne wypełnianie nie musi śledzić i przekazywać rosnącego licznika. Każde AddRow zamyka poprzedni wiersz, a każde AddSheet zamyka poprzedni arkusz - więc nigdy jawnie nie kończysz wiersza ani arkusza. Otwierasz następny, a zapisywacz finalizuje za Ciebie otwartą strukturę

Znaki ucieczki są obsługiwane tam, gdzie tekst wchodzi do XML

Każdy tekst, który zapisujesz, staje się częścią dokumentu XML - więc pięć predefiniowanych encji XML musi być poprzedzone znakami ucieczki, bo inaczej pakiet jest nieprawidłowy w chwili, gdy wartość zawiera ampersand lub nawias ostry. Zapisywacz obsługuje za Ciebie znaki ucieczki &, <, >, " i ' zarówno w tekście ciągu inline, jak i w tekście formuły - w dwóch miejscach, gdzie znaki dostarczone przez wywołującego lądują wewnątrz znaczników. Przekazujesz surowy WideString, a zapisywacz czyni go bezpiecznym. Nazwa produktu taka jak Smith & Co <Ltd> lub formuła odwołująca się do cytowanej nazwy arkusza wychodzi jako poprawny XML bez żadnych znaków ucieczki po Twojej stronie

Cykl życia i dlaczego Destroy wciąż zamyka

Finalizacja pakietu to to, co zapisuje część skoroszytu, część stylów, typy zawartości i części relacji, a na końcu centralny katalog ZIP. To zadanie dzieje się w Close. Pakiet, który nigdy nie zostaje zamknięty, to niekompletny ZIP, którego żaden program arkuszowy nie otworzy - więc zamknięcie to nie opcjonalne porządkowanie, to krok, który czyni plik ważnym. Aby zabezpieczyć się przed zapomnianym Close na ścieżce błędu, Destroy wykonuje best-effort zamknięcie, jeśli pakiet jest jeszcze otwarty - więc zwolnienie zapisywacza nie wycieka bazowego obiektu ZIP nawet gdy wyjątek pominął jawne wywołanie. Niezawodny wzorzec to nadal zwykły wzorzec Delphi: pisz wewnątrz try, wywołaj Close i zwolnij w finally

Strumieniowanie dużego arkusza od początku do końca

Kształt zadania to: zacznij, dodaj arkusz, wlewaj wiersze, zamknij. Poniższy przykład zapisuje wiersz nagłówkowy, a następnie długi ciąg typowanych wierszy danych, mieszając ciągi, liczby, formułę bez buforowanego wyniku i datę. Pamięć, jakiej używa dla dziesięciu wierszy i dla dziesięciu milionów wierszy, jest taka sama, bo każda komórka odpływa do strumienia ZIP, gdy tylko zostaje zapisana

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');

    // Wiersz nagłówkowy, zapisany w rosnącej kolejności kolumn
    W.AddRow(1);
    W.WriteString(1, 'Item');
    W.WriteString(2, 'Qty');
    W.WriteString(3, 'Price');
    W.WriteString(4, 'Total');
    W.WriteString(5, 'Date');

    // Wiersze danych; przekaż 0 do AddRow, aby automatycznie wziąć następny wiersz
    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;                       // finalizuje pakiet
  finally
    W.Free;
  end;
end;

Drugi arkusz to po prostu kolejne AddSheet zanim będziesz kontynuować, a zapisywacz zamyka pierwszy arkusz otwierając drugi. Flagi logiczne używają WriteBoolean, które zapisuje typowaną komórkę logiczną, a nie tekst "True". Jeśli chcesz potwierdzić, że plik jest poprawny i poprawnie przechodzi round-trip, właściwość CellCount raportuje, ile komórek zostało zapisanych, a odczytanie wyniku z powrotem przez czytnik strumieniowy powinno raportować tę samą sumę

  // Drugi arkusz z typowanymi flagami po arkuszu danych powyżej
  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]));

Zapis do strumienia zamiast do pliku to ten sam kod z BeginStream zamiast BeginFile - co pozwala serwerowi wysłać skoroszyt do odpowiedzi HTTP lub strumienia pamięci bez tymczasowego pliku na dysku. Zapisywacz nie jest właścicielem przekazanego strumienia, więc zachowujesz kontrolę nad jego cyklem życia

Gdy praca to punkt końcowy serwera budujący skoroszyty na żądanie, wzorce w artykule o strumieniowym zapisie dla zadań serwerowych i wsadowych pokazują, jak to wpiąć w procedurę obsługi żądania i zaplanowany eksport. Gdy pytanie dotyczy szerszego kosztu bardzo dużych skoroszytów - zarówno odczytu, jak i zapisu - artykuł o wydajności dużych skoroszytów w Delphi omawia, gdzie faktycznie idzie czas i pamięć. Bezpośredni zapisywacz strumieniowy wchodzi w skład komponentu HotXLS dla Delphi i C++Builder, wraz z pełnymi API odczytu, edycji i zapisu omówionymi w pozostałych artykułach tego bloga