Teknik Makale

Delphi'de Devasa XLSX Dosyalarını Yüklemeden Akışla Okuyun (Stream)

Bir milyon satırlık ve bir düzine (dozen) sütunluk bir elektronik tablo, bir veritabanı raporlama işinden gelen gayet sıradan bir dışa aktarımdır. Tüm çalışma kitabını (workbook) bir TXLSWorkbook içine yükleyerek her zamanki yolla açtığınızda işlem, ilk iş mantığı satırınız çalışmadan önce o on iki milyon hücrenin her birini canlı bir nesne olarak somutlaştırmak (materialise) zorundadır. Diskteki dosya altmış megabaytlık sıkıştırılmış bir XML olabilir. Genişlediği nesne ağacı bunun birkaç katıdır ve hepsinin aynı anda yerleşik (resident) olması gerekir çünkü model tasarımı gereği rastgele erişimlidir (random-access). Yukarıdan aşağıya okumak ve çöpe atmak niyetinde olduğunuz bir rapor için, bu hiç ihtiyaç duymadığınız bir yapıya harcanan çok büyük bir bellektir

Aynı dosyadan geçen ikinci bir yol daha vardır. Bir model inşa etmek yerine, çalışma sayfası XML'ini hücre hücre yalnızca ileriye doğru (forward only) tararsınız ve baktıktan sonra her hücrenin akıp gitmesine izin verirsiniz. Hiçbir şey birikmez. İster bin, ister on milyon satır olsun bellek neredeyse sabit kalır, çünkü okuyucu hiçbir zaman şu anda ayrıştırdığı (parsing) parça ve birkaç küçük arama tablosundan (lookup tables) fazlasını tutmaz. HotXLS doğrudan okuyucusu (direct reader) bunu yapar ve bu makalenin geri kalanı neden küçük kaldığı ve buna karşılık size ne verdiği hakkındadır

Bellek içi model neden ölçeklenmez

Bir XLSX dosyası, ECMA-376 tarafından tanımlanan XML bölümlerinden (parts) oluşan bir ZIP paketidir. Her çalışma sayfası kendi bölümüdür (xl/worksheets/sheetN.xml) ve içinde her satır, <c> hücre öğelerini (elements) tutan bir <row> öğesidir. Düzenli (regular) yükleme yolu o bölümü okur ve her bir hücre için adreslenebilir (addressable) bir nesne oluşturur, böylece daha sonra Cells[12345, 7]'yi isteyebilir ve sabit (constant) zamanda bir yanıt alabilirsiniz. Rastgele erişim (Random access), bir çalışma kitabı modelinin tüm amacıdır ve düzenlemeyi (editing), formül değerlendirmesini (formula evaluation) ve stillendirmeyi (styling) kolay kılan da tam olarak budur

Maliyeti ise rastgele erişimin her şeyin aynı anda mevcut olmasını gerektirmesidir. Sadece kısmen oluşturduğunuz bir yapı içerisine dizin (index) koyamazsınız. Dolayısıyla tam bir yüklemenin (full load) en yüksek belleği (peak memory) hücre sayısının bir işlevidir ve milyonlarca dolu hücreye (populated cells) sahip bir sayfada bu işlev (function), özellikle de paylaşılan bir makinede (shared machine) aynı anda birkaç iş yürütülüyorsa hizmetinizin (service) olmak istemeyeceği bir yere iner. Gerçekte ihtiyaç duyduğunuz erişim düzeni ardışıksa (sequential), rastgele erişim için ödeme yapmak kullanmayacağınız bir yetenek (capability) için ödeme yapmaktır

Ağaç oluşturmayan yalnızca ileri (forward-only) SAX taraması

Doğrudan okuyucu ZIP paketini açar ve SAX tarzı bir çekme ayrıştırıcısı (pull parser) ile her çalışma sayfası bölümünü yürütür (walks). Buradaki SAX, ayrıştırıcının ayrıştırma olaylarını karşılaştıkça (bir başlangıç öğesi, bir metin akışı, bir bitiş öğesi) raporladığı ve ardından devam ettiği anlamına gelir. Arkasında hiçbir düğüm (node) ağacı bırakmaz. Okuyucu geçerli satırı ve sütunu r özniteliklerinden (attributes) izler, olaylar geldiğinde hücrenin türünü, stil dizinini (style index), değerini ve formül metnini toplar ve kapanış </c> etiketi görüldüğünde bir hücre yayar (emits) ve onu unutur. Sonraki hücre aynı bir avuç (handful) yerel değişkeni (variables) yeniden kullanır

Hücreler arasında hiçbir şey tutulmadığından, bellek ayak izi (memory footprint) hücre sayısıyla artmaz (grow). Tutunmaya değer olan mülk (property) budur. İki yüz satırlık bir sayfa ve yirmi milyon satırlık bir sayfa okuyucuya aynı yerleşik belleğe mal olur ve aralarındaki fark yalnızca taramanın ne kadar süreceğidir. Modelin başyazı (headline) özelliği olan rastgele erişimden vazgeçersiniz ve karşılığında hücre sayısının geçemeyeceği bir bellek tavanı (ceiling) alırsınız

Ne yerleşik (resident) kalır ve neden o iki parça

Tarama tamamen durumsuz (stateless) değildir ve istisnalar (exceptions) öğreticidir (instructive). Kendi başına bir hücre, onlar olmadan (without them) yorumlamaya (interpret) yetecek kadar bilgi taşımadığından, süre boyunca bellekte tutulması gereken iki küçük tablo (tables) vardır

Birincisi paylaşılan dize (shared string) tablosudur. SpreadsheetML'de, bir metin hücresi kendi metnini depolamaz. t="s" ve çalışma kitabındaki her farklı (distinct) dizenin (string) tekilleştirilmiş (deduplicated) tek bir listesi olan xl/sharedStrings.xml'in (sharedStrings.xml) içine bir indeks (index) olan sayısal bir yük (payload) taşır. Bu, aynı etiketlerin binlerce satırda tekrarlandığı dosyalar için iyi bir alan (space) takasıdır, ancak okuyucunun bu dize tablosunu en baştan (up front) yüklemesi ve yerleşik tutması gerektiği anlamına gelir çünkü herhangi bir sayfadaki herhangi bir hücre içindeki (in it) herhangi bir girdiye (entry) atıfta (reference) bulunabilir. Tablo, hücre sayısına göre değil farklı dize (distinct strings) sayısına göre boyutlandırılmıştır (sized), bu nedenle devasa sayfalarda (enormous sheets) bile mütevazı (modest) kalır

İkincisi, stiller (styles) bölümünden sayı biçimi (number-format) haritalamasıdır (mapping). Sayısal bir hücre (numeric cell) ve bir tarih hücresi (date cell) tel (wire) üzerinde baytı baytına (byte-for-byte) aynıdır: her ikisi de düz (plain) bir sayıdır, çünkü SpreadsheetML'de bir tarih yalnızca seri bir gün sayımıdır. Onları ayıran tek şey (The only thing that distinguishes them is), xl/styles.xml içindeki cellXfs üzerinden bir sayı biçimi kimliğine (number-format id) işaret eden (points) hücrenin stilidir. Bir tarihi ham seri numarası (raw serial number) yerine bir tarih olarak raporlamak için, okuyucu bu stilden formata (style-to-format) tablosunu yükler ve onu yerleşik (resident) tutar. Dosyadaki diğer her şey, yani baytların çoğunu (bulk of the bytes) oluşturan gerçek hücre verisi (actual cell data), depolanmadan (stored) akıp (streams) geçer

Her hücre bir tür (kind) ve bir değer raporlar

Yayılan (emitted) her hücre bir TXLSDirectCell kaydı (record) olarak gelir. Sayfa dizinini (sheet index) ve adını, 1 tabanlı (1-based) satır ve sütunu, anlamsal (semantic) bir Kind'ı, Variant olarak Value'yu, baştaki eşittir (equals) işareti olmadan Formula metnini ve ham (raw) StyleIndex'i taşır. Tür (kind), xdkNumber, xdkString, xdkBoolean, xdkDate veya xdkError'dan biridir, böylece hücrenin ne anlama geldiğini özniteliklerden (attributes) yeniden türetmek (re-deriving) yerine üzerinde dallanabilirsiniz (branch on). Formül hücresi, formül metni ile birlikte önbelleğe alınmış (cached) sonucunun türünü (kind) raporlar, böylece hesaplanan bir toplam (computed total) aynı zamanda size nasıl üretildiğini (produced) de anlatan bir sayı olarak gelir

type
  TReportScan = class
    procedure OnCell(Sender: TObject; const Cell: TXLSDirectCell;
      var Abort: Boolean);
  end;

procedure TReportScan.OnCell(Sender: TObject; const Cell: TXLSDirectCell;
  var Abort: Boolean);
begin
  case Cell.Kind of
    xdkString:  AccumulateLabel(Cell.Row, Cell.Col, VarToStr(Cell.Value));
    xdkNumber:  AddToTotals(Cell.Col, Double(Cell.Value));
    xdkDate:    NoteWhen(Cell.Row, VarToDateTime(Cell.Value));
    xdkBoolean: FlagRow(Cell.Row, Boolean(Cell.Value));
    xdkError:   LogBadCell(Cell.Row, Cell.Col, VarToStr(Cell.Value));
  end;
end;

Bir tarihi sayıdan ayırt etmek

Tarih sorusu daha yakından (closer) bir bakışı (look) hak ediyor çünkü saf tarayıcıların (naive scanners) çoğu (most) burada hata yapıyor. Sayısal bir hücrenin tarih türü (date type) yoktur. 46000 seri değerini (serial value) taşıyan bir hücre bir nicelik (quantity), bir fiyat (price) veya 17 Şubat 2025 olabilir ve dosya size hangisi olduğunu ancak (only through) hücrenin stili (style) aracılığıyla erişilen sayı-biçimi kimliği (number-format id) ile söyler. ECMA-376, anlamı uyan (conforming) her üretici (producer) genelinde sabitlenmiş yerleşik (built-in) biçim kimliklerinden oluşan bir bloğu saklı tutar ve tarih taşıyan (date-bearing) kimlikler iki aralıkta (ranges) bulunur: standart tarih ve saat biçimleri (standard date and time formats) için 14 ila 22; ve [h]:mm:ss gibi geçen zaman (elapsed-time) biçimleri için 45 ila 47. Varsayılan olarak açık olan DetectDates açık olduğunda, okuyucu her sayısal hücrenin stilini biçim kimliğine çözümler ve kimliği bu ayrılmış (reserved) aralıklara giren (falls) bir hücre, Value değeri (Value) halihazırda bir Delphi TDateTime'a dönüştürülmüş olarak xdkDate olarak rapor edilir. Özel biçimler (Custom formats) de, tarih ve saat belirteçleri (tokens) için biçim kodunu inceleyerek (inspecting) denetlenir, ancak ayrılmış aralıklar (reserved ranges) güvenilir (dependable) bir omurgadır (backbone). DetectDates özelliğini kapattığınızda stiller tablosu hiç yüklenmez, her sayısal hücre xdkNumber olarak gelir ve tarama (scan) kısmen (fractionally) daha zayıf (leaner) olur

Sayfaları atla ve erken iptal et

Sıralı tarama (Sequential scanning), rastgele erişimin eşleşemeyeceği sessiz bir avantaja sahiptir: durabilirsiniz. OnSheet olayı her çalışma sayfasının (worksheet) açılmasından önce tetiklenir (fires) ve size iki anahtar (switches) verir. SkipSheet ayarladığınızda bu bölümün (part) tamamı hiçbir zaman ayrıştırılmaz (parsed) ki bu, çok sayfalı (multi-sheet) bir çalışma kitabında geri kalanını okumak için ödeme yapmadan (paying to read) yalnızca ilgilendiğiniz sayfaları tarama şeklinizdir (how you scan). Abort ayarladığınızda tüm tarama hemen (immediately) sona erer (ends). OnCell olayının (event) kendine ait bir Abort'u vardır, bu nedenle milyonlarca kalan (remaining) hücreyi okumadan, aradığınızı -belirli bir satır, nöbetçi bir değer (sentinel value), bir başlık bloğunun (header block) sonu- bulduğunuz an durdurabilirsiniz (halt). Sadece ileri yönlü (forward-only) bir taramada iptal etme (abort) gerçekten (genuinely) ücretsizdir, çünkü atladığınız iş henüz gerçekleşmemiş (had not happened yet) olandır

procedure TReportScan.OnSheet(Sender: TObject; SheetIndex: Integer;
  const SheetName: WideString; var SkipSheet: Boolean; var Abort: Boolean);
begin
  // Scan only the "Data" sheet; leave the rest unread
  SkipSheet := SheetName <> 'Data';
end;

Bir işleyici (handler) olmadan hücreleri sayma

Son zamanlarda yapılan bir iyileştirme (refinement), yaygın bir soruyu ucuz (cheap) bir tek çağrıya dönüştürdüğü (turns) için belirtmeye değer. Okuyucu, geçtiği (passes) her dolu (populated) hücreyi sayar (counts) ve bunu bir OnCell işleyicisi (handler) bağlı (attached) olsun veya olmasın yapar. Daha önce, bir işleyici ayarlanmamışken dolu hücre sayısı, sayım (counting) yaymanın (emitting) bir yan etkisi (side effect) olduğu için sıfır (zero) olarak geri dönüyordu. Artık sayım yaymadan bağımsızdır (independent of emission). Bu, tek bir soru sorabileceğiniz, bu çalışma kitabının (workbook) gerçekte (actually) kaç dolu hücre içerdiğini sorabileceğiniz ve yanıtı (answer) hiçbir geri çağrısı (callbacks) olmayan bir tarama (scan) fiyatına alabileceğiniz anlamına gelir (means). ReadFile ve ReadStream bu toplamı bir Int64 olarak döndürür ve aynı (same) numara (number) sonradan CellCount özelliği (property) olarak mevcuttur. -1 dönüşü (return), dosyanın açılamadığını veya bir OOXML paketi olmadığını belirtir

var
  Reader: TXLSDirectReader;
  Populated: Int64;
begin
  Reader := TXLSDirectReader.Create;
  try
    // No OnCell handler: a pure populated-cell census, still near-constant memory
    Populated := Reader.ReadFile('quarterly_export.xlsx');
    if Populated < 0 then
      raise Exception.Create('Not a readable XLSX package')
    else
      Writeln(Format('%d populated cells (CellCount = %d)',
        [Populated, Reader.CellCount]));
  finally
    Reader.Free;
  end;
end;

Tam (full) tarama için, işleyiciyi bağlar (attach the handler) ve ReadFile işlevini tıpkı (exactly) aynı yolla çağırırsınız. Tam bir yüklemeyle olan zıtlık meselenin ta kendisidir (the whole point): quarterly_export.xlsx (quarterly_export.xlsx) dosyasını bir çalışma kitabına yüklemek, her hücreyi yerleşik (resident) bir nesneye genişletecek (expand) ve tüm grubu (the lot) tutacakken, doğrudan (direct) okuyucu yalnızca paylaşılan (shared) dizeleri (strings) ve stil tablosunu tutar (keeps) ve on iki milyon hücre her defasında bir tane olmak üzere OnCell'inizden geçer. Hücre başına çalışan aritmetik geride hiçbir şey bırakmaz, dolayısıyla en yüksek (peak) bellek, satır sayısına göre değil çalışma kitabının farklı dize (distinct-string) sayısına (count) göre belirlenir (set)

Görev, büyük bir çalışma kitabını bir kez okumak ve onu ayıklamak veya özetlemek olduğunda doğrudan okuyucu doğru araçtır. Bunun yerine tam modelin (full model) rastgele erişimine ihtiyaç duyduğunuzda ancak büyük dosyalarda davranmasını (behave) istediğinizde, Delphi'deki büyük çalışma kitabı performansı hakkındaki notlarımızdaki ayarlama (tuning) bu yolu (path) kapsar. Ve büyük bir çıktı (output) üretmek üzere yön (direction) tersine çevrildiğinde, sunucu toplu işleri (server batch jobs) için akışlı yazma (streaming-write) kılavuzu, yazma işlemi için aynı sabit bellek (constant-memory) disiplinini uygular. Üçü de bu blogda (blog) başka yerlerde ele alınan okuma, yazma, formül (formula) ve format (formatting) API'lerinin yanında Delphi ve C++Builder için HotXLS Bileşeninin bir parçası (part) olarak sevk edilir (ship)