Tehnički članak

Pisanje XLSX s milion redova u Delphi-ju uz konstantnu memoriju

Posao izvještavanja radi dobro godinu dana. Gradi radnu knjigu, puni list s onim što upit vrati, i sprema je. Tada kupac s pet godina historije traži pun izvoz, broj redova prelazi milion, i proces umire s greškom nedostatka memorije mnogo prije nego što datoteka dosegne disk. U kodu nije ništa bilo pogrešno. Cijelu radnu knjigu je držao u RAM-u kako bi je serijalizovao na kraju, i memorija koja mu je bila potrebna rasla je u korak s brojem redova koje je traženo da napiše

Rješenje nije veći računar. To je drugačiji model pisanja. Streaming direktni pisač u HotXLS-u inkrementalno emituje OOXML paket kako redovi stižu, pa memorija koju koristi ne ovisi o tome koliko redova pišete. To je pisački pandan streaming čitaču: gdje čitač prolazi ogromnim listom bez izgradnje stabla ćelija, pisač ga proizvodi bez izgradnje stabla ćelija

Zašto normalna putanja čuvanja raste s podacima

Regularna putanja TXLSXWorkbook prvo gradi potpun model objekata. Svaka ćelija, s njenom vrijednošću, tipom i referencom na stil, živi kao objekt u memoriji dok ne pozovete čuvanje, tada se cijelo stablo serijalizuje u paket. Taj model je pravi kada želite pročitati list, urediti ga, ponovo izračunati i upisati ga, jer nasumičan pristup bilo kojoj ćeliji je tačno ono što uređivanje treba. Pogrešan je kada sipate redove u jednom smjeru i nikad ne gledate natrag, jer plaćate da svaki red bude rezidentan bez ikakve koristi. Milion redova objekata je milion redova objekata bez obzira hodate li po njima ili ne

Streaming pisač uklanja stablo. Čim je ćelija napisana, postaje bajtovi u dijelu radnog lista, a ti bajtovi se predaju zip izlazu. Stream radnog lista je jedini bafer koji raste, i raste na izlaznoj strani, ne kao živi Delphi objekti na heapu. Ono što ostaje rezidentno je fiksna količina book-keepinga: nazivi listova, par zastavica, trenutni broj reda, brojač ćelija. Taj skup se ne mijenja između reda jednog i reda deset miliona

Tablica dijeljenih niski je zamka, a inline nizovi su izlaz

Većina streaming XLSX pisača radi dobro dok ne naiđe na tekst. OOXML format normalno pohranjuje nizove u tablici dijeljenih niski: svaki različiti niz se jednom piše u zasebni dio, a svaka ćelija koja drži taj niz nosi indeks u tablicu umjesto teksta. To je dobra optimizacija prostora za datoteke pune ponovljenih oznaka, i to je default koji normalna putanja čuvanja koristi. Problem za streaming pisač je brutalan. Da bi deduplificirao, tablica mora ostati rezidentna za cijeli posao, jer bilo koji red koji još dolazi može ponoviti niz iz reda koji je već napisan, i samo potpuna in-memory mapa viđenih nizova može dodijeliti pravi indeks. Pa je jedina struktura koju streaming pisač ne može streamati upravo struktura koja bi trebala učiniti datoteku malenom. Podaci s puno teksta poništavaju streaming koji ste tražili

Direktni pisač potpuno zaobilazi tablicu. Nizovi se pišu inline, kao ćelije t="inlineStr" čiji tekst sjedi direktno unutar ćelije s elementom <is><t>. Nema tablice za akumulaciju i nema mape viđenih niski za držanje, pa tekstualne kolone ne koštaju više memorije od numeričkih. Kompromis je eksplicitan i vrijedi ga jasno navesti. Inline nizovi ponavljaju isti tekst gdje god se pojavi, pa je datoteka s mnogim identičnim oznakama veća na disku od ekvivalenta s dijeljenim nizovima. Trošite veličinu datoteke da kupite konstantnu memoriju. Za jednokratni izvoz to je prava strana kompromisa, a zip kompresija apsorbira velik dio ponavljanja pri izlazu

Tablica stilova stiže na kraju, s jednim formatom datuma

Stilovi predstavljaju istu napetost kao nizovi. Radna knjiga referencira svoje formatiranje kroz dio stilova, a streaming pisač ne može držati rastuću paletu stilova u korak s ćelijama koje je već isplakao. Direktni pisač na ovo odgovara držanjem tablice stilova malom i fiksnom, i emitovanjem je pri zatvaranju a ne unaprijed. Jedan default format ćelije pokriva obične ćelije. Jedan format broja za datum pokriva datume, registrovan s kodom formata yyyy-mm-dd na poznatoj poziciji u listi formata ćelija

Taj format datuma je razlog zašto WriteDateTime postoji kao vlastiti poziv. Excel nema nativni tip datuma; datum je broj koji nosi format datuma. WriteDateTime piše vrijednost kao obični serijski broj i označava ćeliju s jednim stilom datuma, pa tabela prikazuje datum umjesto petocifrenog broja. Serijski koji piše je važan za povratni put. Pohranjuje vrijednost TDateTime direktno pod datumskim sistemom 1900, isti dogovor koji normalna putanja čuvanja TXLSXWorkbook koristi. Jer obje putanje se slažu na serijskom, datoteka koju streaming pisač proizvodi čita se natrag kroz HotXLS čitač i otvara se u Excelu s datumima koji odgovaraju onome što ste namjeravali, bez razlike za jedan ili iznenađenja epohe između pisača i čitača

Redoslijed je obavezan, jer bajtovi su već otišli

Streaming kupuje svoy profil memorije jednim pravilom koje morate poštovati. Izlaz se emituje dok idete i ne može biti ponovo posjećen, pa sve mora biti napisano u redoslijedu kojim se pojavljuje u datoteci. Unutar reda, ćelije idu uzlaznim redoslijedom kolona. Unutar lista, redovi idu uzlaznim redoslijedom. Nema bafera koji bi pisaču dozvolio da sortira vaše ćelije naknadno, jer red koji ste zatvorili trenutak prije je već bajtovi u zip streamu i više nije dosežan. Predajte mu kolonu 5 pa kolonu 2 u istom redu i izlaz je malformiran, jer pisač jednostavno emituje što mu date u sekvenci u kojoj mu to date

API reda ima malu pogodnost za uobičajeni slučaj. AddRow uzima 1-bazirani indeks reda, ali proslijeđivanje 0 znači uzmi sljedeći red iza prethodnog, pa sekvencijalno punjenje ne mora pratiti i proslijeđivati rastuće brojilo. Svaki AddRow zatvara red ispred njega, a svaki AddSheet zatvara list ispred njega, pa nikad eksplicitno ne završavate red ili list. Počinjete sljedeći i pisač finalizuje otvorenu strukturu za vas

Escaping se obrađuje gdje tekst ulazi u XML

Svaki tekst koji pišete postaje dio XML dokumenta, pa pet predefinisanih XML entiteta mora biti escapovano ili paket je nevažeći čim vrijednost sadrži ampersand ili ugaonu zagradu. Pisač escapuje &amp;, &lt;, &gt;, &quot; i &apos; za vas i na tekstu inline niza i na tekstu formule, na dva mjesta gdje znakovi koje daje pozivalac leže unutar markupa. Predajete sirov WideString i pisač ga čini sigurnim. Naziv proizvoda kao Smith & Co <Ltd> ili formula koja referencira naziv lista pod navodnicima izlazi kao dobro formirani XML bez ikakvog escapinga s vaše strane

Životni ciklus i zašto Destroy ipak zatvara

Završavanje paketa je ono što piše dio radne knjige, dio stilova, dijelove content-types i veza, i konačno zip centralni direktorij. Taj posao se odvija u Close. Paket koji se nikad ne zatvori je nepotpun zip koji nijedan program za tabele neće otvoriti, pa zatvaranje nije opcijalno čišćenje, to je korak koji datoteku čini validnom. Za zaštitu od zaboravljenog Close u putanji s greškom, Destroy izvodi zatvaranje po principu najvećih napora ako je paket još otvoren, pa oslobađanje pisača ne propušta temeljni zip objekt čak i kada je iznimka preskočila eksplicitan poziv. Pouzdani obrazac je i dalje obični Delphi: pišite unutar try, pozovite Close, i oslobodite u finally

Streaming velikog lista od početka do kraja

Oblik posla je početak, dodavanje lista, sipanje redova, zatvaranje. Primjer ispod piše red zaglavlja, a zatim dug niz tipiziranih podatkovnih redova, miješajući nizove, brojeve, formulu bez keširane vrijednosti i datum. Memorija koju koristi za deset redova i za deset miliona redova je ista, jer svaka ćelija odlazi u zip stream čim je napisana

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;

Drugi list je jednostavno još jedan AddSheet prije nego što nastavite, i pisač zatvara prvi list dok otvara drugi. Boolean zastavice koriste WriteBoolean, koji piše tipiziranu boolean ćeliju umjesto teksta "True". Ako želite potvrditi da je datoteka ispravna i vraća se, svojstvo CellCount prijavljuje koliko je ćelija napisano, a čitanje rezultata natrag sa streaming čitačem treba prijaviti isti ukupan broj

  // 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]));

Pisanje u stream umjesto datoteke je isti kod s BeginStream na mjestu BeginFile, što serveru omogućuje slanje radne knjige HTTP odgovoru ili memorijskom streamu bez privremene datoteke na disku. Pisač ne posjeduje stream koji mu predajete, pa zadržavate kontrolu nad njegovim životnim vijekom

Kada je posao serverski endpoint koji gradi radne knjige na zahtjev, obrasci u streaming pisanju za server i batch poslove pokazuju kako ovo spojiti u handler zahtjeva i zakazani izvoz. Kada je pitanje širi trošak vrlo velikih radnih knjiga, i čitanja i pisanja, performanse velikih radnih knjiga u Delphi-ju pokrivaju gdje zapravo idu vrijeme i memorija. Streaming direktni pisač isporučuje se kao dio HotXLS Component za Delphi i C++Builder, zajedno s punim API-jevima za čitanje, uređivanje i čuvanje obrađenim drugdje na ovom blogu