Tekninen artikkeli

Kirjoita miljoonan rivin XLSX-tiedosto Delphissä vakiomuistilla

Raportointityö (reporting job) pyörii hienosti vuoden ajan. Se rakentaa työkirjan (workbook), täyttää taulukon (sheet) sillä, mitä kysely (query) palauttaa, ja tallentaa sen. Sitten asiakas, jolla on viiden vuoden historia, pyytää täyden viennin (full export), rivimäärä ylittää miljoonan, ja prosessi kuolee muistin loppumisesta (out-of-memory) johtuvaan virheeseen kauan ennen kuin tiedosto saavuttaa levyn. Koodissa ei ollut mitään vikaa. Se piti koko työkirjaa RAM-muistissa (RAM), jotta se voisi serialisoida sen lopussa, ja sen tarvitsema muisti kasvoi samassa tahdissa sen rivimäärän kanssa, jota sen pyydettiin kirjoittamaan

Korjaus ei ole isompi kone. Se on erilainen kirjoitusmalli (writing model). HotXLS:n suoratoistava suorakirjoittaja (streaming direct writer) lähettää (emits) OOXML-paketin vähitellen (incrementally) rivien saapuessa, joten sen käyttämä muisti ei riipu siitä, kuinka monta riviä kirjoitat. Se on kirjoituspuolen vastine suoratoistolukijalle (streaming reader): siinä missä lukija kävelee valtavan taulukon läpi rakentamatta solupuuta (cell tree), kirjoittaja tuottaa sellaisen rakentamatta solupuuta myöskään

Miksi normaali tallennuspolku kasvaa datan mukana

Tavallinen TXLSXWorkbook-polku rakentaa ensin täyden objektimallin (object model). Jokainen solu – arvoineen, tyyppeineen ja tyyliviittauksineen (style reference) – elää objektina muistissa siihen asti, kunnes kutsut tallennusta (save), jolloin koko puu serialisoidaan pakettiin. Tuo malli on oikea silloin, kun haluat lukea taulukon, muokata sitä, laskea sen uudelleen (recalculate) ja kirjoittaa sen takaisin, koska satunnaissaanti (random access) mihin tahansa soluun on juuri sitä, mitä muokkaaminen tarvitsee. Se on väärä malli silloin, kun kaadat rivejä yhteen suuntaan etkä koskaan katso taaksepäin, koska maksat jokaisen rivin pitämisestä muistissa ilman mitään hyötyä. Miljoona riviä objekteja on miljoona riviä objekteja riippumatta siitä, palaatko koskaan niihin vai et

Suoratoistokirjoittaja (streaming writer) poistaa puun. Heti kun solu on kirjoitettu, siitä tulee tavuja (bytes) laskentataulukko-osassa (worksheet part), ja nuo tavut luovutetaan (handed to) zip-tulosteelle (zip output). Laskentataulukon virta (worksheet stream) on ainoa puskuri (buffer), joka kasvaa, ja se kasvaa tulostepuolella (output side), ei elävinä Delphi-objekteina keossa (heap). Se, mikä pysyy muistissa, on kiinteä määrä kirjanpitoa (bookkeeping): taulukoiden (sheet) nimet, muutama lippu (flag), nykyinen rivinumero, solulaskuri (cell counter). Tuo joukko ei muutu rivin yksi ja rivin kymmenen miljoonan välillä

Jaettu merkkijonotaulukko on ansa, ja sisäiset merkkijonot (inline strings) ovat tie ulos

Useimmat suoratoistavat XLSX-kirjoittajat pärjäävät hyvin, kunnes ne kohtaavat tekstiä. OOXML-formaatti tallentaa merkkijonot (strings) yleensä jaettuun merkkijonotaulukkoon (shared-string table): jokainen erillinen merkkijono kirjoitetaan kerran erilliseen osaan, ja jokainen solu, joka sisältää tuon merkkijonon, kantaa indeksiä taulukkoon tekstin sijaan. Se on hyvä tilaoptimointi (space optimisation) tiedostoille, jotka ovat täynnä toistuvia otsikoita (labels), ja se on oletus (default), jota tavallinen tallennuspolku käyttää. Ongelma suoratoistokirjoittajalle (streaming writer) on raaka. Jotta kaksoiskappaleet voidaan poistaa (deduplicate), taulukon on pysyttävä muistissa koko työn ajan, koska mikä tahansa vielä tuleva rivi saattaa toistaa merkkijonon jo kirjoitetulta riviltä, ja vain täydellinen muistissa oleva kartta (in-memory map) nähdyistä merkkijonoista voi määrittää oikean indeksin. Joten se ainoa rakenne, jota suoratoistokirjoittaja ei voi suoratoistaa, on juuri se rakenne, jonka oletetaan tekevän tiedostosta pienen. Tekstipainotteinen data tuhoaa suoratoiston, jota varten tulit

Suorakirjoittaja (direct writer) sivuuttaa (sidesteps) taulukon kokonaan. Merkkijonot kirjoitetaan sisäisinä (inline), nimellä t="inlineStr" soluina, joiden teksti istuu suoraan solun sisällä <is><t> -elementin avulla. Ei ole taulukkoa kerrytettävänä (accumulate) eikä karttaa nähdyistä merkkijonoista säilytettävänä, joten tekstisarakkeet (text columns) eivät vie sen enempää muistia kuin numeerisetkaan sarakkeet. Kauppa (trade) on eksplisiittinen ja se kannattaa sanoa selvästi ääneen. Sisäiset merkkijonot (inline strings) toistavat saman tekstin missä tahansa se esiintyy, joten tiedosto, jossa on paljon identtisiä otsikoita (labels), on levyllä (on disk) suurempi kuin vastaava jaettujen merkkijonojen tiedosto. Käytät tiedostokokoa (file size) ostaaksesi vakiomuistia (constant memory). Yhden läpikäynnin vientiin (one-pass export) se on kaupan (trade) oikea puoli, ja zip-pakkaus vaimentaa (absorbs) suuren osan toistoista matkalla ulos joka tapauksessa

Tyylitaulukko (style table) saapuu lopussa, yhdellä päivämäärämuodolla

Tyylit (styles) esittävät saman jännitteen (tension) kuin merkkijonot. Työkirja (workbook) viittaa muotoiluunsa (formatting) tyyliosan (styles part) kautta, eikä suoratoistokirjoittaja voi pitää kasvavaa tyylipalettia synkassa niiden solujen kanssa, jotka se on jo tyhjentänyt (flushed). Suorakirjoittaja vastaa tähän pitämällä tyylitaulukon pienenä ja kiinteänä, ja lähettämällä sen sulkemisen (close) yhteydessä sen sijaan, että se tekisi sen etukäteen (up front). Yksi oletusarvoinen solumuoto kattaa tavalliset solut. Yksi päivämäärän numeromuoto (date number format) kattaa päivämäärät, ja se on rekisteröity muotokoodilla (format code) yyyy-mm-dd tunnetussa sijainnissa solumuotojen luettelossa

Tuo päivämäärämuoto on syy siihen, miksi WriteDateTime on olemassa omana kutsunaan. Excelissä ei ole natiivia päivämäärätyyppiä (date type); päivämäärä on luku, jolla on päivämäärämuoto (date format). WriteDateTime kirjoittaa arvon pelkkänä sarjanumerona (serial number) ja merkitsee solun tuolla yhdellä päivämäärätyylillä (date style), jotta laskentataulukko (spreadsheet) renderöi sen päivämääränä (date) viisinumeroisen kokonaisluvun sijaan. Sen kirjoittamalla sarjanumerolla (serial) on väliä edestakaisen matkan (round-tripping) kannalta. Se tallentaa TDateTime-arvon suoraan vuoden 1900 päivämääräjärjestelmän (1900 date system) alle, mikä on sama käytäntö (convention), jota tavallinen TXLSXWorkbook-tallennuspolku käyttää. Koska molemmat polut ovat yhtä mieltä sarjanumerosta (serial), suoratoistokirjoittajan (streaming writer) tuottama tiedosto luetaan takaisin HotXLS-lukijan läpi ja avautuu Excelissä päivämäärillä, jotka vastaavat sitä, mitä tarkoitit, ilman off-by-one- tai aikakausiyllätyksiä (epoch surprise) kirjoittajan ja lukijan välillä

Järjestys on pakollinen, koska tavut (bytes) ovat jo menneet

Suoratoisto ostaa muistiprofiilinsa yhdellä säännöllä, jota sinun on kunnioitettava. Tulostetta (output) lähetetään (emitted) sitä mukaa kun etenet, eikä siihen voi enää palata, joten kaikki on kirjoitettava siinä järjestyksessä, jossa se esiintyy tiedostossa. Rivin sisällä solut (cells) menevät nousevassa sarakejärjestyksessä (ascending column order). Taulukon (sheet) sisällä rivit (rows) menevät nousevassa järjestyksessä. Ei ole olemassa puskuria (buffer), joka antaisi kirjoittajan lajitella (sort) solujasi jälkikäteen (after the fact), koska hetki sitten sulkemasi rivi on jo tavuina zip-virrassa (zip stream) eikä ole enää saavutettavissa (reachable). Jos annat sille sarakkeen 5 ja sitten sarakkeen 2 samalla rivillä, tuloste (output) on väärin muotoiltu, koska kirjoittaja (writer) yksinkertaisesti lähettää sen, mitä annat sille, siinä järjestyksessä kuin annat sen

Rivi-API:ssa (row API) on pieni mukavuus (convenience) yleisintä tapausta varten. AddRow ottaa 1-pohjaisen rivi-indeksin (row index), mutta arvon 0 antaminen tarkoittaa, että se ottaa seuraavan rivin edellisen jälkeen, jolloin peräkkäisen täytön (sequential fill) ei tarvitse seurata ja siirtää kasvavaa laskuria (incrementing counter). Jokainen AddRow sulkee edellisen rivin, ja jokainen AddSheet sulkee edellisen taulukon, joten sinun ei koskaan tarvitse eksplisiittisesti (explicitly) päättää riviä tai taulukkoa. Aloitat seuraavan, ja kirjoittaja (writer) viimeistelee avoimen rakenteen puolestasi

Pakeneminen (escaping) käsitellään siellä, missä teksti menee XML:ään

Kaikki kirjoittamasi teksti tulee osaksi XML-asiakirjaa, joten viisi ennalta määriteltyä XML-entiteettiä on paettava (escaped), muuten paketti (package) on epäkelpo heti, kun arvo sisältää et-merkin (ampersand) tai kulmasulkeen (angle bracket). Kirjoittaja pakenee merkit &, <, >, " ja ' puolestasi sekä sisäisissä merkkijonoteksteissä (inline string text) että kaavateksteissä (formula text) – kahdessa paikassa, joissa kutsujan (caller) toimittamat merkit päätyvät merkintäkielen (markup) sisään. Välität raa'an (raw) WideString-merkkijonon, ja kirjoittaja (writer) tekee siitä turvallisen. Tuotenimi kuten Smith & Co <Ltd> tai kaava, joka viittaa lainattuun (quoted) taulukon (sheet) nimeen, tulee ulos hyvin muotoiltuna XML:nä ilman, että sinun tarvitsee paeta (escaping) mitään omalla puolellasi

Elinkaari (lifecycle) ja miksi Destroy sulkee (closes) silti

Paketin viimeistely on se, mikä kirjoittaa työkirjaosan (workbook part), tyyliosan (styles part), sisältötyyppi- (content-types) ja suhdeosat (relationship parts), ja lopuksi zip-keskushakemiston (zip central directory). Tämä työ tapahtuu komennolla Close. Paketti, jota ei koskaan suljeta, on epätäydellinen (incomplete) zip, jota mikään taulukkolaskentaohjelma (spreadsheet program) ei avaa, joten sulkeminen ei ole valinnainen (optional) siivoustoimenpide (cleanup), se on askel, joka tekee tiedostosta pätevän. Jotta voidaan suojautua unohdetulta Close-kutsulta virhepolulla (error path), Destroy suorittaa parhaan kykynsä mukaisen sulkemisen (best-effort close), jos paketti on yhä auki, joten kirjoittajan (writer) vapauttaminen ei vuoda (leak) alla olevaa zip-objektia silloinkaan, kun poikkeus (exception) ohitti eksplisiittisen (explicit) kutsun. Luotettava kuvio (pattern) on silti tavallinen Delphi-kuvio: kirjoita try-lohkossa, kutsu Close ja vapauta (free) finally-lohkossa

Suuren taulukon (sheet) suoratoisto alusta loppuun

Työn muoto on: aloita, lisää taulukko, kaada rivejä, sulje. Alla oleva esimerkki kirjoittaa otsikkorivin ja sitten pitkän sarjan tyypitettyjä datarivejä (typed data rows), sekoittaen merkkijonoja (strings), lukuja (numbers), kaavan, jolla ei ole välimuistissa olevaa tulosta (cached result), ja päivämäärän. Muisti, jota se käyttää kymmenelle riville ja kymmenelle miljoonalle riville, on sama, koska jokainen solu lähtee zip-virtaan (zip stream) heti, kun se on kirjoitettu

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;

Toinen taulukko (sheet) on vain toinen AddSheet-kutsu ennen kuin jatkat, ja kirjoittaja sulkee ensimmäisen taulukon avatessaan toisen. Totuusarvoliput (boolean flags) käyttävät WriteBoolean-kutsua, joka kirjoittaa tyypitetyn (typed) totuusarvosolun tekstin "True" sijaan. Jos haluat varmistaa, että tiedosto on ehjä (sound) ja kestää edestakaisen matkan (round-trips), ominaisuus CellCount raportoi kuinka monta solua kirjoitettiin, ja tuloksen lukeminen takaisin suoratoistolukijalla (streaming reader) pitäisi raportoida sama loppusumma

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

Kirjoittaminen virtaan (stream) tiedoston sijaan on sama koodi, jossa on BeginStream komennon BeginFile sijaan, mikä antaa palvelimen (server) lähettää työkirjan HTTP-vastaukseen (HTTP response) tai muistivirtaan (memory stream) ilman väliaikaista (temporary) tiedostoa levyllä. Kirjoittaja (writer) ei omista virtaa (stream), jonka annat sille, joten sinä säilytät hallinnan sen elinkaaresta

Kun työ (work) on palvelimen päätepiste (server endpoint), joka rakentaa työkirjoja pyynnöstä (on demand), ohjeet artikkelissa hotxls-streaming-write-server-batch-jobs.html, suoratoistavat kirjoitukset palvelin- ja eräajoille (streaming writes for server and batch jobs), näyttävät, kuinka kytkeä (wire) tämä pyynnön käsittelijään (request handler) ja ajastettuun vientiin (scheduled export). Kun kysymys koskee erittäin suurten työkirjojen laajempia kustannuksia, sekä lukiessa että kirjoittaessa, hotxls-large-workbook-performance-delphi.html, suurten työkirjojen suorituskyky Delphissä, kattaa sen, mihin aika ja muisti todellisuudessa menevät. Suoratoistava suorakirjoittaja toimitetaan osana HotXLS Component -komponenttia Delphiä ja C++Builderia varten, niiden täysien luku-, muokkaus- ja tallennus-API:den ohella, joita käsitellään muualla tässä blogissa