Technical Article

Rašykite milijono eilučių XLSX failus Delphi aplinkoje naudodami pastovią atmintį

Ataskaitų užduotis puikiai veikia metus. Ji sukuria darbaknygę, užpildo lapą tuo, ką grąžina užklausa, ir jį išsaugo. Tuomet klientas, turintis penkerių metų istoriją, paprašo pilno eksporto, eilučių skaičius perkopia milijoną, ir procesas miršta dėl atminties trūkumo (out-of-memory) klaidos dar gerokai prieš failui pasiekiant diską. Su kodu nebuvo nieko blogo. Jis laikė visą darbaknygę RAM atmintyje, kad pabaigoje galėtų ją serializuoti, o reikalinga atmintis augo kartu su eilučių, kurias buvo prašoma įrašyti, skaičiumi

Sprendimas nėra didesnis kompiuteris. Tai yra kitoks rašymo modelis. Srautinis tiesioginis rašytojas (streaming direct writer) HotXLS komponente išveda OOXML paketą palaipsniui, kai tik atkeliauja eilutės, todėl jo naudojama atmintis nepriklauso nuo to, kiek eilučių įrašote. Tai yra rašymo pusės atitikmuo srautiniam skaitytuvui: ten, kur skaitytuvas pereina per didžiulį lapą nekuriant langelių medžio, rašytojas jį sukuria taip pat nekuriant langelių medžio

Kodėl įprastas išsaugojimo kelias auga kartu su duomenimis

Įprastas TXLSXWorkbook kelias pirmiausia sukuria pilną objektų modelį. Kiekvienas langelis, su savo reikšme, tipu ir stiliaus nuoroda, gyvena kaip objektas atmintyje tol, kol iškviečiate išsaugojimą, po kurio visas medis serializuojamas į paketą. Šis modelis yra teisingas, kai norite perskaityti lapą, jį redaguoti, perskaičiuoti ir įrašyti atgal, nes atsitiktinė prieiga (random access) prie bet kurio langelio yra būtent tai, ko reikia redagavimui. Jis yra neteisingas, kai pilate eilutes viena kryptimi ir niekada neatsigręžiate atgal, nes mokate už tai, kad kiekviena eilutė išliktų atmintyje be jokios naudos. Milijonas eilučių objektų yra milijonas eilučių objektų, nesvarbu, ar jūs kada nors prie jų grįšite, ar ne

Srautinis rašytojas pašalina medį. Kai tik langelis yra įrašomas, jis tampa baitais darbalapio dalyje, ir tie baitai perduodami į zip išvestį. Darbalapio srautas yra vienintelis buferis, kuris auga, ir jis auga išvesties pusėje, o ne kaip gyvi Delphi objektai krūvoje (heap). Tai, kas išlieka atmintyje (resident), yra fiksuotas buhalterijos kiekis: lapų pavadinimai, kelios vėliavėlės (flags), dabartinis eilutės numeris, langelių skaitiklis. Šis rinkinys nesikeičia tarp pirmos ir dešimties milijoninės eilutės

Bendra eilučių lentelė yra spąstai, o įterptinės eilutės yra išeitis

Dauguma srautinių XLSX rašytojų veikia gerai, kol susiduria su tekstu. OOXML formatas paprastai saugo eilutes (strings) bendroje eilučių lentelėje (shared-string table): kiekviena skirtinga eilutė yra įrašoma vieną kartą į atskirą dalį, o kiekvienas langelis, kuriame yra ta eilutė, turi indeksą į lentelę, o ne patį tekstą. Tai gera vietos optimizacija failams, pilniems pasikartojančių etikečių, ir tai yra numatytasis nustatymas, kurį naudoja standartinis išsaugojimo kelias. Problema srautiniam rašytojui yra brutali. Norint pašalinti dublikatus (deduplicate), lentelė turi išlikti atmintyje visos užduoties metu, nes bet kuri dar tik busianti eilutė gali pakartoti tekstą iš jau įrašytos eilutės, ir tik pilnas atmintyje esantis matytų eilučių žemėlapis (map) gali priskirti teisingą indeksą. Taigi, vienintelė struktūra, kurios srautinis rašytojas negali perduoti srautu, yra būtent ta struktūra, kuri turėtų padaryti failą mažą. Tekstu perkrauti duomenys nugali srautinį perdavimą, kurio jūs ir atėjote

Tiesioginis rašytojas visiškai išvengia šios lentelės. Eilutės (strings) rašomos įterptai (inline), kaip t="inlineStr" langeliai, kurių tekstas yra tiesiogiai langelio viduje su <is><t> elementu. Nėra jokios lentelės, kurią reikėtų kaupti, ir jokio matytų eilučių žemėlapio, kurį reikėtų laikyti, todėl teksto stulpeliai kainuoja ne daugiau atminties nei skaitmeniniai. Kompromisas yra aiškus ir verta jį pasakyti tiesiai. Įterptinės eilutės pakartoja tą patį tekstą visur, kur jis pasitaiko, todėl failas su daug vienodų etikečių diske yra didesnis nei jo atitikmuo su bendra eilučių lentele. Jūs išleidžiate failo dydį, kad nusipirktumėte pastovią atmintį. Vieno praėjimo (one-pass) eksportui tai yra teisinga kompromiso pusė, o zip suspaudimas bet kokiu atveju sugeria didžiąją dalį pasikartojimų išvesties metu

Stilių lentelė atvyksta pabaigoje, su vienu datos formatu

Stiliai sukelia tokią pačią įtampą kaip ir eilutės. Darbaknygė nurodo savo formatavimą per stilių dalį, o srautinis rašytojas negali išlaikyti augančios stilių paletės sinchronizuotos su langeliais, kuriuos jis jau išvedė (flushed). Tiesioginis rašytojas į tai atsako išlaikydamas stilių lentelę mažą ir fiksuotą, ir išvesdamas ją uždarant (on close), o ne iš anksto. Vienas numatytasis langelio formatas apima paprastus langelius. Vienas datos skaičiaus formatas apima datas, užregistruotas su formato kodu yyyy-mm-dd žinomoje pozicijoje langelių formatų sąraše

Šis datos formatas yra priežastis, dėl kurios WriteDateTime egzistuoja kaip atskiras iškvietimas. Excel neturi natūralaus datos tipo; data yra skaičius, dėvintis datos formatą. WriteDateTime įrašo reikšmę kaip paprastą serijinį skaičių ir pažymi langelį vienu datos stiliumi, todėl skaičiuoklė jį atvaizduoja kaip datą, o ne kaip penkiaženklį sveikąjį skaičių. Serijinis skaičius, kurį jis įrašo, yra svarbus duomenų perdavimui pirmyn ir atgal (round-tripping). Jis saugo TDateTime reikšmę tiesiogiai pagal 1900 m. datų sistemą – tą pačią konvenciją naudoja ir įprastas TXLSXWorkbook išsaugojimo kelias. Kadangi abu keliai sutampa dėl serijinio skaičiaus, srautinio rašytojo sukurtas failas yra sėkmingai nuskaitomas per HotXLS skaitytuvą ir atidaromas Excel programoje su datomis, kurios atitinka jūsų ketinimus, be jokių paklaidų vienetu (off-by-one) ar epochos staigmenų tarp rašytojo ir skaitytuvo

Tvarka yra privaloma, nes baitai jau yra dingę

Srautinis perdavimas perka savo atminties profilį viena taisykle, kurios privalote laikytis. Išvestis yra išvedama jums judant į priekį ir negali būti peržiūrėta iš naujo, todėl viskas turi būti įrašyta ta tvarka, kuria pasirodo faile. Eilutės viduje langeliai eina didėjančia stulpelių tvarka. Lapo viduje eilutės eina didėjančia tvarka. Nėra jokio buferio, kuris leistų rašytojui surūšiuoti jūsų langelius po fakto, nes eilutė, kurią uždarėte prieš akimirką, jau yra tapusi baitais zip sraute ir nebepasiekiama. Paduokite jam 5 stulpelį, o po to 2 stulpelį toje pačioje eilutėje, ir išvestis bus neteisingai suformuota (malformed), nes rašytojas tiesiog išveda tai, ką jam duodate, ta seka, kuria duodate

Eilučių API turi nedidelį patogumą įprastam atvejui. AddRow priima 1 pagrindu paremtą (1-based) eilutės indeksą, bet perdavus 0 reiškia paimti kitą eilutę po ankstesnės, todėl nuosekliam pildymui nereikia sekti ir perduoti didėjančio skaitiklio. Kiekvienas AddRow uždaro prieš jį esančią eilutę, o kiekvienas AddSheet uždaro prieš jį esantį lapą, todėl jums niekada nereikia aiškiai užbaigti eilutės ar lapo. Jūs pradedate kitą, o rašytojas užbaigia atvirą struktūrą už jus

Simbolių pabėgimas apdorojamas ten, kur tekstas patenka į XML

Bet koks jūsų rašomas tekstas tampa XML dokumento dalimi, todėl penki iš anksto nustatyti XML subjektai (entities) turi būti apsaugoti (escaped), kitaip paketas taps netinkamu tą akimirką, kai reikšmėje atsiras ampersandas ar kampinis skliaustas. Rašytojas apsaugo &, <, >, " ir ' už jus tiek įterptinės eilutės tekste, tiek formulės tekste – dvejose vietose, kur skambintojo pateikti simboliai atsiduria žymėjimo (markup) viduje. Jūs perduodate neapdorotą (raw) WideString, o rašytojas padaro jį saugų. Produkto pavadinimas, pvz., Smith & Co <Ltd>, arba formulė, nurodanti į cituojamą lapo pavadinimą, išeina kaip gerai suformuotas (well-formed) XML be jokio papildomo simbolių apsaugojimo iš jūsų pusės

Gyvavimo ciklas ir kodėl Destroy vis dar uždaro

Paketo užbaigimas yra tai, kas įrašo darbaknygės dalį, stilių dalį, turinio tipų (content-types) bei ryšių (relationship) dalis, ir galiausiai centrinį zip katalogą. Šis darbas atliekamas iškvietus Close. Niekada neuždarytas paketas yra nepilnas zip failas, kurio neatidarys jokia skaičiuoklių programa, todėl uždarymas nėra neprivalomas išvalymas – tai žingsnis, kuris padaro failą galiojančiu (valid). Siekiant apsisaugoti nuo pamiršto Close klaidos atveju, Destroy atlieka geriausių pastangų (best-effort) uždarymą, jei paketas vis dar atidarytas, todėl rašytojo atlaisvinimas nesukelia bazinio (underlying) zip objekto nutekėjimo net tada, kai išimtis (exception) praleido aiškų iškvietimą. Patikimas šablonas vis dar yra įprastas Delphi: rašykite try bloke, iškvieskite Close ir atlaisvinkite finally bloke

Didelio lapo srautinis perdavimas nuo pradžios iki pabaigos

Užduoties forma yra: pradėti, pridėti lapą, supilti eilutes, uždaryti. Žemiau pateiktame pavyzdyje rašoma antraštės eilutė, o po jos eina ilga tipizuotų duomenų eilučių seka, maišant eilutes (strings), skaičius, formulę be išsaugoto (cached) rezultato ir datą. Atmintis, kurią jis naudoja dešimčiai eilučių ir dešimčiai milijonų eilučių, yra tokia pati, nes kiekvienas langelis iškeliauja į zip srautą vos tik jį įrašius

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;

Antrasis lapas yra tiesiog dar vienas AddSheet prieš jums tęsiant, ir rašytojas uždaro pirmąjį lapą atidarydamas antrąjį. Būlio vėliavėlėms (boolean flags) naudojamas WriteBoolean, kuris įrašo tipizuotą loginį langelį, o ne tekstą „True“. Jei norite patvirtinti, kad failas yra tvarkingas ir teisingai perduodamas (round-trips), savybė CellCount praneša, kiek langelių buvo įrašyta, o rezultato nuskaitymas atgal su srautiniu skaitytuvu turėtų parodyti tą pačią sumą

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

Rašymas į srautą vietoje failo yra tas pats kodas, tik vietoj BeginFile naudojamas BeginStream, o tai leidžia serveriui išsiųsti darbaknygę į HTTP atsaką arba atminties srautą be laikino failo diske. Rašytojas nevaldo srauto, kurį perduodate, todėl jūs patys kontroliuojate jo gyvavimo ciklą (lifetime)

Kai darbas yra serverio galutinis taškas (endpoint), kuris kuria darbaknyges pagal pareikalavimą, šablonai, aprašyti srautinio rašymo serveriams ir paketinėms užduotims straipsnyje, parodo, kaip tai sujungti su užklausų apdorokliu (request handler) ir suplanuotu eksportu. Kai kyla klausimas dėl platesnių labai didelių darbaknygių sąnaudų – tiek skaitymo, tiek rašymo, straipsnis didelių darbaknygių našumas Delphi aplinkoje aptaria, kur iš tikrųjų dingsta laikas ir atmintis. Srautinis tiesioginis rašytojas pateikiamas kaip HotXLS komponento, skirto Delphi ir C++Builder, dalis kartu su pilnais skaitymo, redagavimo ir išsaugojimo API, aprašytais kitur šiame tinklaraštyje