مقاله فنی

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

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

راه‌حل در استفاده از یک ماشین بزرگتر نیست. بلکه به کارگیری یک مدل نوشتاری متفاوت است. نویسنده مستقیم استریم در HotXLS بسته OOXML را با رسیدن سطرها به تدریج ساطع می‌کند، بنابراین حافظه‌ای که استفاده می‌کند به تعداد سطرهایی که می‌نویسید وابسته نیست. این، معادل بخشِ نوشتن در خواننده استریم است: در جایی که خواننده، یک برگه بزرگ را بدون ساختن درخت سلول پیمایش می‌کند، نویسنده نیز یکی را بدون ساختن درخت سلول تولید می‌نماید

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

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

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

جدول رشته مشترک یک تله است، و رشته‌های درون‌خطی (inline strings) راه خروج هستند

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

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

جدول استایل‌ها در انتها، با یک فرمت تاریخ می‌آید

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

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

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

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

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

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

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

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

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

استریم یک برگه بزرگ از ابتدا تا انتها

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

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;

برگه دوم تنها با فراخوانی دوباره AddSheet قبل از ادامه کار ایجاد می‌شود، و نویسنده همزمان با باز کردن برگه دوم، برگه اول را می‌بندد. پرچم‌های بولین (Boolean flags) از WriteBoolean استفاده می‌کنند که یک سلول بولینِ نوع‌دار را به جای عبارت متنی "True" می‌نویسد. اگر می‌خواهید مطمئن شوید که فایل سالم و قابل رفت و برگشت است، ویژگی CellCount گزارش می‌دهد که چند سلول نوشته شده است و خواندن نتیجه بازگشتی با خواننده استریم باید دقیقاً همان مجموع را گزارش دهد

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

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

زمانی که کار در واقع یک اندپوینتِ (endpoint) سرور است که بر اساس درخواست کتاب‌های کاری را می‌سازد، الگوهای ارائه‌شده در نوشتن به‌صورت استریم برای کار سرور و وظایف دسته‌ای به شما نشان می‌دهند که چگونه این روند را به یک هندلر درخواست و یک استخراج زمان‌بندی‌شده متصل کنید. و هنگامی که بحث پیرامون هزینه گسترده‌تر کتاب‌های کار بسیار حجیم – چه در خواندن و چه در نوشتن – باشد، عملکرد کتاب کار بزرگ در دلفی بررسی می‌کند که زمان و حافظه دقیقاً به کجا می‌رود. نویسنده مستقیمِ استریم به عنوان بخشی از کامپوننت HotXLS برای دلفی و C++Builder، در کنار تمام APIهای مربوط به خواندن، ویرایش و ذخیره که در جاهای دیگر این وبلاگ پوشش داده شده‌اند، عرضه می‌شود