مقاله فنی

نوشتن فایل‌های یک میلیون سطری XLSX در دلفی با حافظه ثابت

یک کار گزارش‌گیری به مدت یک سال به خوبی اجرا می‌شود. این کار یک کتاب‌کار (workbook) می‌سازد، یک شیت را با هر آنچه پرس‌وجو (query) برمی‌گرداند پر می‌کند، و آن را ذخیره می‌نماید. سپس مشتری‌ای با پنج سال تاریخچه درخواست یک خروجی کامل می‌کند، تعداد سطرها از یک میلیون عبور می‌کند، و فرآیند با خطای کمبود حافظه (out-of-memory) مدت‌ها قبل از اینکه فایل به دیسک برسد، از کار می‌افتد. هیچ مشکلی در کد وجود نداشت. کد، کل کتاب‌کار را در RAM نگه می‌داشت تا بتواند در پایان آن را سریال‌سازی (serialise) کند، و حافظه‌ای که نیاز داشت همگام با تعداد سطرهایی که از آن خواسته شده بود بنویسد رشد می‌کرد

راه‌حل این مشکل یک سیستم قوی‌تر نیست. بلکه یک مدل نوشتن متفاوت است. نویسنده مستقیم جریان‌دهی (streaming direct writer) در HotXLS بسته OOXML را به صورت افزایشی همزمان با رسیدن سطرها تولید می‌کند، بنابراین حافظه‌ای که استفاده می‌نماید به تعداد سطرهایی که می‌نویسید بستگی ندارد. این همتای سمتِ نوشتن (write-side) برای خواننده جریان‌دهی است: همان‌طور که خواننده، یک شیت بزرگ را بدون ساختن درخت سلول‌ها (cell tree) پیمایش می‌کند، این نویسنده نیز شیتی را بدون ساختن درخت سلول‌ها تولید می‌نماید

چرا روش ذخیره معمولی همراه با داده‌ها رشد می‌کند

مسیر معمولی TXLSXWorkbook ابتدا یک مدل شیئی کامل (full object model) می‌سازد. هر سلول، با مقدار، نوع و ارجاع به استایل خود، به عنوان یک شیء در حافظه زندگی می‌کند تا زمانی که متد ذخیره را فراخوانی کنید، که در آن نقطه کل درخت در بسته (package) سریال‌سازی می‌شود. این مدل زمانی درست است که بخواهید یک شیت را بخوانید، آن را ویرایش کنید، محاسبات مجدد انجام دهید، و آن را بازنویسی نمایید، زیرا دسترسی تصادفی به هر سلول دقیقاً همان چیزی است که ویرایش به آن نیاز دارد. اما زمانی که سطرها را در یک جهت می‌ریزید و هرگز به عقب نگاه نمی‌کنید، این مدل اشتباه است، زیرا هزینه نگه‌داشتن هر سطر در حافظه را بدون هیچ مزیتی می‌پردازید. یک میلیون سطر از اشیاء، یک میلیون سطر از اشیاء است، صرف نظر از اینکه آیا هرگز دوباره به آن‌ها مراجعه می‌کنید یا خیر

نویسنده جریان‌دهی، درخت را حذف می‌کند. به محض اینکه یک سلول نوشته می‌شود، تبدیل به بایت‌هایی در بخش کاربرگ می‌شود، و آن بایت‌ها به خروجی zip تحویل داده می‌شوند. جریان (stream) کاربرگ تنها بافری است که رشد می‌کند، و این رشد در سمت خروجی است، نه به عنوان اشیاء زنده دلفی در هیپ (heap). آنچه در حافظه ساکن می‌ماند مقدار ثابتی از ثبت و نگهداری (bookkeeping) است: نام شیت‌ها، چند پرچم (flag)، شماره سطر فعلی، و یک شمارنده سلول. این مجموعه بین سطر اول و سطر ده میلیون تغییری نمی‌کند

جدول رشته‌های مشترک (shared-string table) یک تله است، و رشته‌های درون‌خطی (inline) راه فرار هستند

بیشتر نویسنده‌های جریان‌دهیِ XLSX تا زمانی که با متن مواجه شوند خوب عمل می‌کنند. قالب OOXML به طور معمول رشته‌ها را در یک جدول رشته‌های مشترک ذخیره می‌کند: هر رشته متمایز یک بار در یک بخش جداگانه نوشته می‌شود، و هر سلولی که آن رشته را نگه می‌دارد، به جای متن، یک ایندکس به جدول را به همراه دارد. این یک بهینه‌سازی فضایی خوب برای فایل‌های پر از برچسب‌های تکراری است، و این همان پیش‌فرضی است که مسیر استاندارد ذخیره از آن استفاده می‌کند. اما مشکل برای یک نویسنده جریان‌دهی بسیار بی‌رحمانه است. برای حذف موارد تکراری (deduplicate)، جدول باید در تمام طول فرآیند در حافظه ساکن بماند، زیرا هر سطری که قرار است بیاید ممکن است رشته‌ای را از سطری که از قبل نوشته شده است تکرار کند، و تنها یک نقشه کاملِ درون‌حافظه‌ای از رشته‌های مشاهده‌شده می‌تواند ایندکس مناسب را اختصاص دهد. بنابراین، ساختاری که یک نویسنده جریان‌دهی نمی‌تواند آن را جریان دهد، دقیقاً همان ساختاری است که قرار است فایل را کوچک نگه دارد. داده‌های متنیِ سنگین، جریان‌دهی‌ای را که به خاطرش آمده بودید از بین می‌برند

نویسنده مستقیم این جدول را کاملاً دور می‌زند. رشته‌ها به صورت درون‌خطی، به عنوان سلول‌های t="inlineStr" نوشته می‌شوند که متن آن‌ها مستقیماً داخل سلول با یک عنصر <is><t> قرار می‌گیرد. هیچ جدولی برای انباشتن و هیچ نقشه‌ای از رشته‌های دیده‌شده برای نگهداری وجود ندارد، بنابراین ستون‌های متنی حافظه بیشتری از ستون‌های عددی مصرف نمی‌کنند. این معامله صریح است و ارزش بیان واضح را دارد. رشته‌های درون‌خطی همان متن را هر کجا که رخ دهد تکرار می‌کنند، بنابراین فایلی با بسیاری از برچسب‌های یکسان روی دیسک بزرگ‌تر از معادلِ رشته‌مشترک (shared-string) آن است. شما حجم فایل را برای خریدن حافظه ثابت خرج می‌کنید. برای یک خروجی یک‌مرحله‌ای (one-pass export)، این قسمتِ درستِ معامله است، و فشرده‌سازی زیپ در هر صورت بخش زیادی از این تکرارها را در مسیر خروجی جذب می‌کند

جدول استایل‌ها در انتها، با یک قالب تاریخ (date format) می‌رسد

استایل‌ها نیز همان تنش را مانند رشته‌ها ایجاد می‌کنند. یک کتاب‌کار از طریق بخش استایل‌ها به قالب‌بندی خود ارجاع می‌دهد، و یک نویسنده جریان‌دهی نمی‌تواند پالت روبه‌رشدی از استایل‌ها را هماهنگ با سلول‌هایی که قبلاً به خروجی ریخته است (flushed) نگه دارد. نویسنده مستقیم با کوچک و ثابت نگه‌داشتن جدول استایل، و انتشار آن هنگام بستن به جای ابتدا، به این موضوع پاسخ می‌دهد. یک قالب سلولِ پیش‌فرض سلول‌های معمولی را پوشش می‌دهد. یک قالبِ شماره تاریخ (date number format) تاریخ‌ها را پوشش می‌دهد که با کد قالب yyyy-mm-dd در یک موقعیت شناخته‌شده در لیست قالب‌های سلول ثبت شده است

آن قالب تاریخ دلیل وجود متدی مجزا به نام WriteDateTime است. اکسل هیچ نوع تاریخ بومی (native date type) ندارد؛ یک تاریخ، عددی است که لباسی از قالب تاریخ پوشیده است. متد WriteDateTime مقدار را به عنوان یک شماره سریال ساده می‌نویسد و سلول را با تنها استایل تاریخ موجود برچسب‌گذاری می‌کند، بنابراین صفحه گسترده آن را به جای یک عدد صحیح پنج رقمی به عنوان یک تاریخ رندر می‌نماید. سریالی که می‌نویسد برای حفظ دقت (round-tripping) اهمیت دارد. این متد، مقدار TDateTime را مستقیماً تحت سیستم تاریخ 1900 ذخیره می‌کند، که همان قاعده‌ای است که مسیر ذخیره معمولی TXLSXWorkbook استفاده می‌نماید. از آنجا که هر دو مسیر بر روی این سریال توافق دارند، فایلی که نویسنده جریان‌دهی تولید می‌کند با خواننده HotXLS بازخوانی می‌شود و در اکسل با تاریخ‌هایی باز می‌شود که دقیقاً مطابق با قصد شماست، بدون هیچ‌گونه جابه‌جاییِ یک‌روزه (off-by-one) یا غافلگیریِ دوره‌ای (epoch surprise) بین نویسنده و خواننده

ترتیب الزامی است، زیرا بایت‌ها پیش از این رفته‌اند

جریان‌دهی مشخصات حافظه‌اش را با یک قانون که باید آن را رعایت کنید می‌خرد. خروجی در حین حرکت منتشر می‌شود و نمی‌توان به آن بازگشت، بنابراین همه چیز باید به همان ترتیبی که در فایل ظاهر می‌شود نوشته شود. در یک سطر، سلول‌ها با ترتیب صعودیِ ستون می‌روند. در یک شیت، سطرها با ترتیب صعودی می‌روند. هیچ بافری وجود ندارد که به نویسنده اجازه دهد پس از انجام کار، سلول‌های شما را مرتب کند، زیرا سطری که لحظه‌ای پیش بسته‌اید هم‌اکنون به بایت‌هایی در جریان زیپ تبدیل شده است و دیگر در دسترس نیست. به آن ستون 5 بدهید و سپس ستون 2 در همان سطر، و خروجی بدشکل (malformed) خواهد شد، زیرا نویسنده به سادگی آنچه را که به آن می‌دهید به همان ترتیبی که می‌دهید منتشر می‌کند

متدِ مربوط به سطر دارای راحتیِ کوچکی برای موارد متداول است. AddRow یک ایندکس سطر با پایه 1 می‌گیرد، اما ارسال 0 به این معنی است که سطر بعدی پس از سطر قبلی گرفته شود، بنابراین پرکردن ترتیبی نیازی به ردیابی و ارسال یک شمارنده افزایشی ندارد. هر AddRow سطر پیش از خود را می‌بندد، و هر AddSheet شیت پیش از خود را می‌بندد، بنابراین شما هرگز صراحتاً به یک سطر یا شیت پایان نمی‌دهید. شما سطر یا شیت بعدی را شروع می‌کنید و نویسنده ساختار باز را برای شما نهایی می‌نماید

فرار (Escaping) در همان جایی که متن وارد XML می‌شود مدیریت می‌گردد

هر متنی که می‌نویسید به بخشی از یک سند XML تبدیل می‌شود، بنابراین در لحظه‌ای که مقداری حاوی آمپرسند (ampersand) یا براکت زاویه‌دار (angle bracket) باشد، پنج موجودیت از پیش‌تعریف‌شده XML باید فرار داده شوند (escaped) وگرنه بسته نامعتبر است. نویسنده کاراکترهای &، <، >، " و ' را در هر دو مورد، هم متن رشته‌های درون‌خطی و هم متن فرمول (که دو جایی هستند که کاراکترهای تهیه‌شده توسط فراخوان‌دهنده در داخل نشانه‌گذاری قرار می‌گیرند) برای شما فرار می‌دهد. شما یک WideString خام را عبور می‌دهید و نویسنده آن را ایمن می‌سازد. نام یک محصول مانند Smith & Co <Ltd> یا یک فرمول که به یک نام شیت داخل گیومه ارجاع می‌دهد، به عنوان XML ساختاریافته‌ی ایمن (well-formed XML) و بدون نیاز به فراردادن توسط شما، خارج می‌شود

چرخه حیات، و چرا Destroy همچنان می‌بندد

اتمامِ کار بسته چیزی است که بخش کتاب‌کار، بخش استایل‌ها، بخش‌های انواع محتوا (content-types) و روابط، و در نهایت پوشه مرکزی زیپ (zip central directory) را می‌نویسد. آن کار در Close رخ می‌دهد. بسته‌ای که هرگز بسته نشود یک زیپ ناقص است که هیچ برنامه‌ی صفحه گسترده‌ای آن را باز نخواهد کرد، بنابراین بستن یک پاکسازی دلخواه (optional cleanup) نیست، بلکه مرحله‌ای است که فایل را معتبر می‌سازد. برای محافظت در برابر یک Close فراموش‌شده در مسیرهای خطا، Destroy اگر بسته هنوز باز باشد، یک بسته‌شدن با حداکثر تلاش (best-effort close) را انجام می‌دهد، بنابراین آزادکردن (free) نویسنده باعث نشتی در شیء زیرینِ زیپ نمی‌شود، حتی زمانی که یک استثنا (exception) از فراخوانی صریح گذر کند. الگوی قابل‌اعتماد در اینجا همچنان همان الگوی رایج دلفی است: بنویسید درون یک بلوک try، سپس Close را فراخوانی کنید، و در قسمت finally آزاد نمایید

جریان‌دهی یک شیت بزرگ از ابتدا تا انتها

شکل کار این‌گونه است: شروع، افزودن یک شیت، ریختن سطرها، و بستن. مثال زیر یک سطر هدر (header) و سپس یک اجرای طولانی از سطرهای داده‌های نوع‌دار (typed data) می‌نویسد، که در آن رشته‌ها، اعداد، یک فرمول بدون نتیجه کش‌شده، و یک تاریخ ترکیب می‌شوند. حافظه‌ای که برای ده سطر و برای ده میلیون سطر استفاده می‌شود یکسان است، زیرا هر سلول به محض نوشتن، راهی جریان زیپ می‌گردد

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');

    // سطر هدر، به ترتیب صعودیِ ستون نوشته شده است
    W.AddRow(1);
    W.WriteString(1, 'Item');
    W.WriteString(2, 'Qty');
    W.WriteString(3, 'Price');
    W.WriteString(4, 'Total');
    W.WriteString(5, 'Date');

    // سطرهای داده؛ عدد 0 را به AddRow پاس دهید تا به طور خودکار سطر بعدی را بگیرد
    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;                       // بسته را نهایی می‌سازد
  finally
    W.Free;
  end;
end;

یک شیت دوم به سادگی یک AddSheet دیگر پیش از ادامه کار شماست، و نویسنده در حین بازکردن شیت دوم، شیت اول را می‌بندد. پرچم‌های بولی (Boolean flags) از WriteBoolean استفاده می‌کنند که یک سلول بولیِ نوع‌دار (typed boolean cell) به جای متن "True" می‌نویسد. اگر می‌خواهید تأیید کنید که فایل سالم است و تغییرات در بازگشت نیز دقیق هستند (round-trips)، ویژگی CellCount گزارش می‌دهد که چند سلول نوشته شده‌اند، و بازخوانی نتایج با خواننده جریان‌دهی باید دقیقاً همان مجموع را گزارش کند

  // یک شیت دوم از پرچم‌های نوع‌دار، پس از شیت داده‌های بالا
  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]));

نوشتن در یک جریان به جای فایل، همان کد اما با BeginStream به جای BeginFile است که به یک سرور اجازه می‌دهد تا کتاب‌کار را به یک پاسخ HTTP یا یک جریان حافظه (memory stream) بدون نیاز به فایل موقت روی دیسک ارسال کند. نویسنده مالکیت جریانی را که عبور می‌دهید در اختیار ندارد، بنابراین کنترل چرخه حیات آن در دست شما باقی می‌ماند

زمانی که کار، یک نقطه پایانی سرور (server endpoint) است که کتاب‌کارها را بر اساس تقاضا تولید می‌کند، الگوهای نوشتن‌های جریان‌دهی برای سرور و کارهای دسته‌ای نشان می‌دهند که چگونه این ویژگی را در یک کنترل‌کننده درخواست (request handler) و یک خروجی‌گیری برنامه‌ریزی‌شده قرار دهید. وقتی پرسش، هزینه وسیع‌تر در کتاب‌کارهای بسیار بزرگ، هم در خواندن و هم نوشتن باشد، عملکرد کتاب‌کارهای بزرگ در دلفی بررسی می‌کند که زمان و حافظه در واقع کجا صرف می‌شوند. نویسنده مستقیم جریان‌دهی به عنوان بخشی از کامپوننت صفحات گسترده HotXLS برای Delphi و C++Builder عرضه می‌شود، در کنار APIهای خواندن، نوشتن، قالب‌بندی و محاسبه که در جاهای دیگر این وبلاگ پوشش داده شده‌اند