Teknik Makale

Delphi'de Sabit Bellekte Milyon Satırlık XLSX Yazma

Bir raporlama işi bir yıl boyunca gayet iyi çalışır. Bir çalışma kitabı (workbook) oluşturur, sorgunun (query) döndürdüğü her neyse onunla bir sayfayı doldurur ve kaydeder. Sonra beş yıllık geçmişi olan bir müşteri tam bir dışa aktarım (export) ister, satır sayısı bir milyonu aşar ve işlem, dosya diske ulaşamadan çok önce yetersiz bellek (out-of-memory) hatasıyla ölür. Kodda hiçbir yanlışlık yoktu. Tüm çalışma kitabını sonunda serileştirebilmek için RAM'de tutuyordu ve ihtiyaç duyduğu bellek, yazması istenen satır sayısıyla eşzamanlı (lockstep) olarak artıyordu

Düzeltme (fix) daha büyük bir makine değildir. Farklı bir yazma modelidir. HotXLS'teki akışlı doğrudan yazıcı (streaming direct writer), satırlar geldikçe OOXML paketini aşamalı olarak (incrementally) yayar (emits), bu nedenle kullandığı bellek kaç satır yazdığınıza bağlı değildir. Akışlı okuyucunun (streaming reader) yazma tarafındaki muadilidir (counterpart): okuyucunun hücre ağacı (cell tree) oluşturmadan devasa bir sayfayı taradığı (walks) yerde, yazıcı da yine bir hücre ağacı oluşturmadan bir tane üretir

Normal kaydetme yolunun verilerle birlikte büyümesinin nedeni

Düzenli (regular) TXLSXWorkbook yolu ilk önce tam bir nesne modeli oluşturur. Değeri, türü ve stil başvurusuyla birlikte her hücre, siz kaydet'i çağırana kadar bellekte bir nesne olarak yaşar, o noktada tüm ağaç (tree) paketin içine serileştirilir. Bir sayfayı okumak, düzenlemek, yeniden hesaplamak ve geri yazmak istediğinizde bu model doğru olandır, çünkü herhangi bir hücreye rastgele erişim (random access) tam olarak düzenlemenin ihtiyaç duyduğu şeydir. Satırları tek bir yöne doğru döktüğünüzde (pouring) ve asla geriye bakmadığınızda ise yanlış olandır, çünkü her bir satırı hiçbir faydası (benefit) olmadan yerleşik (resident) tutmak için ödeme yaparsınız. Nesnelerden oluşan bir milyon satır, onları bir daha ziyaret etseniz de etmeseniz de nesnelerden oluşan bir milyon satırdır

Akışlı yazıcı ağacı kaldırır (removes). Bir hücre yazılır yazılmaz çalışma sayfası bölümünde baytlar haline gelir ve bu baytlar zip çıktısına teslim edilir (handed). Çalışma sayfası akışı büyüyen tek arabellektir (buffer) ve yığında (heap) canlı Delphi nesneleri olarak değil, çıktı tarafında büyür. Yerleşik (resident) kalan şey sabit miktarda bir defter tutmadır (bookkeeping): sayfa adları, birkaç bayrak, geçerli satır numarası, bir hücre sayacı. Bu küme (set) birinci satır ile on milyonuncu satır arasında değişmez

Paylaşılan dize tablosu bir tuzaktır ve satır içi dizeler (inline strings) çıkış yoludur

Çoğu akışlı XLSX yazıcısı metinle (text) karşılaşana kadar iyi iş çıkarır. OOXML biçimi normalde dizeleri paylaşılan bir dize tablosunda (shared-string table) depolar: her farklı (distinct) dize, ayrı bir bölüme bir kez yazılır ve o dizeyi tutan her hücre, metin yerine tabloya bir dizin (index) taşır. Tekrar eden etiketlerle (labels) dolu dosyalar için iyi bir alan optimizasyonudur ve standart kaydetme yolunun kullandığı varsayılandır. Akışlı bir yazıcı içinse sorun acımasızdır (brutal). Tekilleştirme (deduplicate) yapmak için, tablonun tüm iş (job) boyunca yerleşik (resident) kalması gerekir, çünkü henüz gelmemiş (still to come) herhangi bir satır, zaten yazılmış (already written) bir satırdan bir dizeyi tekrarlayabilir ve yalnızca görülen (seen) dizelerin bellekteki tam bir haritası doğru dizini atayabilir. Dolayısıyla, akışlı bir yazıcının akışla aktaramadığı (cannot stream) tek yapı (structure), tam da dosyayı küçük tutması (make the file small) beklenen yapının (structure) ta kendisidir. Metin ağırlıklı (Text-heavy) veriler, uğruna geldiğiniz (you came for) akış işlemini alt eder (defeats)

Doğrudan yazıcı tablodan tamamen kaçınır (sidesteps). Dizeler, metni <is><t> öğesiyle (element) doğrudan hücrenin içinde oturan (sits) t="inlineStr" hücreleri olarak satır içi (inline) yazılır. Biriktirilecek (accumulate) bir tablo ve tutulacak (hold) görülen dizelerin bir haritası (map of seen strings) yoktur, dolayısıyla metin sütunları (text columns) sayısal sütunlardan (numeric ones) daha fazla belleğe mal olmaz. Takas (trade) açıktır (explicit) ve açıkça belirtilmeye değerdir. Satır içi dizeler, aynı metni nerede meydana gelirse (occurs) gelsin tekrar eder, bu nedenle birbirinin aynı (identical) pek çok etiketi olan bir dosya, diskte paylaşılan dizeli (shared-string) eşdeğerinden daha büyüktür. Sabit bellek (constant memory) satın almak için dosya boyutu (file size) harcarsınız (spend). Tek geçişli (one-pass) bir dışa aktarım (export) için bu, takasın doğru tarafıdır (right side) ve zip sıkıştırması zaten çıkış yolundaki tekrarların çoğunu emer (absorbs)

Stil tablosu sonunda (at the end), bir tarih formatıyla gelir

Stiller (Styles), dizelerle (strings) aynı gerilimi (tension) sunar. Bir çalışma kitabı biçimlendirmesine (formatting) bir stiller (styles) bölümü (part) aracılığıyla başvurur (references) ve akışlı bir yazıcı (streaming writer) büyüyen bir stil paletini halihazırda temizlediği (flushed) hücrelerle adım adım (in step) tutamaz. Doğrudan yazıcı bunu stil tablosunu küçük ve sabit tutarak ve en baştan (up front) ziyade kapatırken (on close) yayarak (emitting) yanıtlar. Bir varsayılan (default) hücre biçimi sıradan hücreleri kapsar. Bir tarih sayı biçimi (date number format), hücre formatları listesinde bilinen bir konumda (known position) yyyy-mm-dd biçim koduyla (format code) kaydedilen tarihleri (dates) kapsar

Bu tarih formatı WriteDateTime'ın kendi başına bir çağrı olarak var olmasının nedenidir (reason). Excel'in yerel bir tarih türü (native date type) yoktur; tarih, tarih biçimi (date format) giymiş bir sayıdır. WriteDateTime değeri düz (plain) bir seri numarası (serial number) olarak yazar ve hücreyi tek tarih stiliyle etiketler, böylece elektronik tablo onu beş basamaklı bir tam sayı (five-digit integer) yerine bir tarih olarak işler (renders). Yazdığı seri (serial), gidiş dönüş (round-tripping) için önemlidir (matters). TDateTime değerini doğrudan düzenli (regular) TXLSXWorkbook kaydetme yolunun kullandığı aynı kural (convention) olan 1900 tarih sistemi (date system) altında depolar. Her iki yol da seri (serial) üzerinde uyuştuğundan, akışlı yazıcının ürettiği bir dosya HotXLS okuyucu üzerinden geri okunur ve yazıcı (writer) ile okuyucu (reader) arasında eksi-bir (off-by-one) veya devir sürprizi (epoch surprise) olmadan, tam olarak amaçladığınız şeyle (what you intended) eşleşen (match) tarihlerle Excel'de açılır

Sıra (Order) zorunludur, çünkü baytlar (bytes) zaten gitmiştir

Akış (Streaming), bellek profilini (memory profile) uymanız gereken tek bir kuralla satın alır (buys). Çıktı devam ettikçe yayılır (emitted as you go) ve yeniden ziyaret edilemez (cannot be revisited), bu nedenle her şeyin dosyada göründüğü sırayla (order) yazılması gerekir. Bir satırın (row) içinde, hücreler (cells) artan sütun sırasında (ascending column order) gider. Bir sayfanın (sheet) içinde, satırlar artan sırada gider. Bir an önce kapattığınız satır zip akışında halihazırda (already) bayt halinde olduğundan ve artık ulaşılamaz (reachable) olduğundan, yazıcının hücrelerinizi iş işten geçtikten sonra (after the fact) sıralamasına (sort) izin veren hiçbir arabellek (buffer) yoktur. Ona aynı satırda sütun 5'i ve ardından sütun 2'yi verin (hand it), yazıcı basitçe ona verdiğiniz sırada verdiğiniz şeyi yaydığı için (emits) çıktı hatalı biçimlendirilmiş (malformed) olur

Satır API'si, yaygın durum (common case) için küçük bir kolaylığa (convenience) sahiptir. AddRow, 1 tabanlı (1-based) bir satır dizini alır, ancak 0 geçmek öncekinden sonraki satırı al anlamına gelir, böylece sıralı (sequential) bir doldurmanın artan bir sayacı (incrementing counter) izlemesi ve geçmesi gerekmez. Her AddRow kendinden önceki satırı kapatır ve her AddSheet kendinden önceki sayfayı kapatır, bu nedenle bir satırı veya sayfayı hiçbir zaman açıkça (explicitly) sonlandırmazsınız (end). Siz bir sonrakine başlarsınız ve yazıcı açık yapıyı (open structure) sizin için sonlandırır (finalises)

Kaçış (escaping), metnin XML'e girdiği yerde halledilir

Yazdığınız herhangi bir metin bir XML belgesinin parçası olur, bu nedenle önceden tanımlanmış (predefined) beş XML varlığından (entities) kaçılması (escaped) gerekir, aksi takdirde (or) bir değer bir ve işareti (ampersand) veya köşeli ayraç (angle bracket) içerdiği an paket geçersiz (invalid) olur. Yazıcı, çağıranın sağladığı karakterlerin biçimlendirme (markup) içine indiği iki yer olan (two places) hem satır içi (inline) dize (string) metninde hem de formül metninde (formula text) &, <, >, " ve ' karakterlerinden (characters) sizin için kaçar (escapes). Ham (raw) bir WideString geçersiniz ve yazıcı onu güvenli hale getirir (makes it safe). Smith & Co <Ltd> gibi bir ürün adı veya alıntılanmış (quoted) bir sayfa adına başvuran (referencing) bir formül, sizin tarafınızda (on your side) herhangi bir kaçış olmadan (without any escaping) iyi biçimlendirilmiş (well-formed) XML olarak ortaya çıkar

Yaşam döngüsü (Lifecycle) ve Destroy'un neden hala kapattığı

Paketi bitirmek (finishing) çalışma kitabı (workbook) bölümünü, stiller (styles) bölümünü, içerik türleri (content-types) ve ilişki (relationship) bölümlerini ve son olarak zip merkez dizinini (zip central directory) yazan şeydir. Bu iş (work) Close işlevinde (in Close) gerçekleşir. Hiçbir zaman kapatılmayan (closed) bir paket, hiçbir elektronik tablo (spreadsheet) programının açmayacağı eksik (incomplete) bir zip dosyasıdır, bu nedenle kapatmak isteğe bağlı (optional) bir temizlik (cleanup) değildir, dosyayı geçerli (valid) kılan (makes) adımdır. Bir hata yolunda (error path) unutulmuş (forgotten) bir Close'a karşı koruma (guard) sağlamak için, Destroy paket hala açıksa en iyi çabayla (best-effort) bir kapatma gerçekleştirir, böylece bir istisna (exception) açık (explicit) çağrıyı (call) atlamış (skipped) olsa bile (even when) yazıcıyı serbest bırakmak (freeing) temel (underlying) zip nesnesini sızdırmaz (does not leak). Güvenilir model (pattern) yine de sıradan (ordinary) Delphi modelidir: bir try bloğu (block) içinde yazın, Close işlevini çağırın ve finally bloğu içinde serbest bırakın (free)

Büyük bir sayfayı uçtan uca akışla aktarma (Streaming)

İşin (job) şekli şudur: başla, bir sayfa ekle, satırları dök, kapat. Aşağıdaki örnek bir başlık satırı (header row) ve ardından dizeleri, sayıları, önbelleğe alınmış sonucu olmayan (no cached result) bir formülü ve bir tarihi birbirine karıştıran (mixing) yazılı (typed) veri satırlarından oluşan uzun bir dizi (run) yazar. On satır ve on milyon satır için kullandığı bellek aynıdır, çünkü her hücre yazılır yazılmaz zip akışına doğru ayrılır (leaves for)

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;

İkinci bir sayfa, devam etmeden (continue) önce (before) yapacağınız bir başka AddSheet işlemidir (is simply another AddSheet) ve yazıcı (writer) ikinciyi açarken birinci sayfayı kapatır. Mantıksal (Boolean) bayraklar, "True" (Doğru) metni yerine yazılı (typed) bir mantıksal (boolean) hücre (cell) yazan WriteBoolean'ı (WriteBoolean) kullanır (use). Dosyanın (file) sağlam (sound) ve gidiş-dönüş (round-trips) yaptığını doğrulamak (confirm) isterseniz (If you want to confirm), CellCount (CellCount) özelliği (property) kaç hücre yazıldığını raporlar (reports) ve sonucu (result) akışlı okuyucuyla (streaming reader) geri okumak (reading back) aynı (same) toplamı (total) raporlamalıdır (should report)

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

Dosya yerine bir akışa (stream) yazmak (Writing to a stream), bir sunucunun (server) çalışma kitabını (workbook) diskte geçici bir dosya (temporary file) olmadan bir HTTP yanıtına (HTTP response) veya bir bellek akışına (memory stream) göndermesini (send) sağlayan (lets), BeginFile yerine (in place of) BeginStream içeren aynı (same) koddur. Yazıcı (writer) geçtiğiniz (pass) akışın (stream) sahibi değildir (does not own), dolayısıyla yaşam döngüsünün (lifetime) kontrolünü siz elinizde tutarsınız

İş (work), talep üzerine (on demand) çalışma kitapları (workbooks) oluşturan bir sunucu uç noktası (server endpoint) olduğunda, sunucu ve toplu işler (batch jobs) için akışlı yazmalar (streaming writes) bölümündeki kalıplar (patterns), bunun bir istek işleyiciye (request handler) ve zamanlanmış (scheduled) bir dışa aktarıma nasıl bağlanacağını (wire) gösterir. Soru (question), hem okuma hem de yazma olmak üzere çok büyük çalışma kitaplarının (very large workbooks) daha geniş (wider) maliyeti (cost) olduğunda, Delphi'de büyük çalışma kitabı performansı (large workbook performance in Delphi) zamanın ve belleğin gerçekte nereye gittiğini ele alır. Akışlı (streaming) doğrudan yazıcı (direct writer), bu blogda (blog) başka yerlerde (elsewhere) ele alınan tam okuma (read), düzenleme (edit) ve kaydetme (save) API'lerinin (API'lerinin) yanında Delphi ve C++Builder için HotXLS Bileşeninin (Component) bir parçası (part) olarak sevk edilir (ships)