مقاله فنی

جریان‌دهی (Stream) فایل‌های بزرگ XLSX در دلفی بدون بارگذاری آن‌ها

یک صفحه گسترده با یک میلیون سطر و ده‌ها ستون، یک خروجی کاملاً معمولی از یک کار گزارش‌گیری پایگاه داده است. اگر آن را به روش معمول باز کنید، یعنی با بارگذاری کل کتاب‌کار در یک TXLSWorkbook، فرآیند باید پیش از اجرای اولین خط از منطق تجاری (business logic) شما، تک‌تک آن دوازده میلیون سلول را به عنوان یک شیء زنده تجسم کند. حجم فایل روی دیسک ممکن است شصت مگابایت XML فشرده‌شده باشد. درخت شیئی (object tree) که این فایل به آن بسط می‌یابد چندین برابر آن است، و همه آن باید به یکباره در حافظه ساکن شود زیرا این مدل ذاتاً برای دسترسی تصادفی (random-access) طراحی شده است. برای گزارشی که قصد دارید از بالا به پایین بخوانید و سپس دور بیندازید، این مقدار زیادی از حافظه است که صرف ساختاری می‌شود که هرگز به آن نیاز نداشته‌اید

یک مسیر دوم نیز برای همین فایل وجود دارد. به جای ساختن یک مدل، شما XML کاربرگ را فقط به سمت جلو (forward-only) و هر بار یک سلول اسکن می‌کنید، و اجازه می‌دهید هر سلول پس از آنکه به آن نگاه کردید، عبور کند. هیچ چیزی انباشته نمی‌شود. چه شیت شما هزار سطر داشته باشد و چه ده میلیون سطر، حافظه تقریباً ثابت می‌ماند، زیرا خواننده هرگز چیزی بیش از آن بخشی که در حال حاضر تجزیه می‌کند به اضافه چند جدول جستجوی کوچک (lookup tables) را در خود نگه نمی‌دارد. این همان کاری است که خواننده مستقیم (direct reader) HotXLS انجام می‌دهد، و بقیه این مقاله درباره این است که چرا این خواننده کوچک می‌ماند و در ازای آن چه چیزی به شما می‌دهد

چرا مدل درون-حافظه (in-memory) مقیاس‌پذیر نیست

یک فایل XLSX در واقع یک بسته ZIP از بخش‌های XML است که توسط ECMA-376 توصیف شده است. هر کاربرگ بخش مخصوص به خود را دارد، یعنی xl/worksheets/sheetN.xml، و در داخل آن هر سطر یک عنصر <row> است که عناصر سلول <c> را در خود نگه می‌دارد. مسیر بارگذاری معمول، آن بخش را می‌خواند و یک شیء آدرس‌پذیر برای هر سلول می‌سازد تا بعداً بتوانید درخواست Cells[12345, 7] را داشته باشید و در یک زمان ثابت (constant time) پاسخ بگیرید. دسترسی تصادفی هدف اصلی مدل کتاب‌کار است، و این دقیقاً همان چیزی است که ویرایش، ارزیابی فرمول و استایل‌دهی را راحت می‌کند

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

یک اسکن SAX فقط-به-جلو (forward-only) که هیچ درختی نمی‌سازد

خواننده مستقیم (direct reader) بسته ZIP را باز می‌کند و هر بخش کاربرگ را با یک پارسر کششی (pull parser) به سبک SAX پیمایش می‌کند. SAX در اینجا به این معناست که پارسر رویدادهای تجزیه را به محض برخورد با آن‌ها گزارش می‌دهد: یک عنصر شروع، یک اجرای متن، یک عنصر پایان، و سپس به کار خود ادامه می‌دهد. این پارسر هیچ درخت گره‌ای (node tree) در پشت سر خود نگه نمی‌دارد. خواننده، سطر و ستون فعلی را از ویژگی‌های (attributes) r ردیابی می‌کند، با رسیدن رویدادها، نوع سلول، ایندکس استایل، مقدار و متن فرمول را جمع‌آوری می‌نماید، و با دیدن برچسب پایانی </c> یک سلول را منتشر (emit) کرده و آن را فراموش می‌کند. سلول بعدی از همان تعداد انگشت‌شمار متغیرهای محلی دوباره استفاده می‌نماید

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

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

این اسکن کاملاً بدون وضعیت (stateless) نیست، و استثناهای آن آموزنده هستند. دو جدول کوچک باید در طول مدت فرآیند در حافظه نگهداری شوند، زیرا یک سلول به تنهایی اطلاعات کافی برای تفسیر شدن بدون آن‌ها را در خود ندارد

مورد اول جدول رشته‌های مشترک (shared string table) است. در SpreadsheetML، یک سلول متنی متن خود را ذخیره نمی‌کند. بلکه t="s" و یک بارِ عددی (numeric payload) را به همراه دارد که یک ایندکس به xl/sharedStrings.xml است؛ فهرستی واحد و بدون تکرار از هر رشته متمایز در کتاب‌کار. این یک معامله فضایی خوب برای فایل‌هایی است که در آن‌ها همان برچسب‌ها در هزاران سطر تکرار می‌شوند، اما به این معناست که خواننده باید از همان ابتدا جدول رشته‌ها را بارگذاری کرده و در حافظه ساکن نگه دارد، زیرا هر سلولی در هر جای هر کاربرگی ممکن است به هر ورودی در آن ارجاع دهد. اندازه این جدول بر اساس تعداد رشته‌های متمایز تعیین می‌شود، نه تعداد سلول‌ها، بنابراین حتی در شیت‌های عظیم نیز در اندازه متعادلی باقی می‌ماند

مورد دوم نگاشت قالب اعداد (number-format mapping) از بخش استایل‌ها است. یک سلول عددی و یک سلول تاریخ از نظر بایت به بایت روی شبکه (on the wire) یکسان هستند: هر دو یک عدد ساده‌اند، زیرا تاریخ در SpreadsheetML فقط یک شمارش روز متوالی (serial day count) است. تنها چیزی که آن‌ها را متمایز می‌کند استایل سلول است، که از طریق cellXfs در xl/styles.xml به یک شناسه قالب اعداد (number-format id) اشاره می‌کند. برای گزارش یک تاریخ به عنوان تاریخ و نه به عنوان شماره سریال خام، خواننده جدول استایل-به-قالب (style-to-format) را بارگذاری کرده و آن را در حافظه ساکن نگه می‌دارد. هر چیز دیگری در فایل، یعنی داده‌های واقعی سلول که بخش عمده‌ای از بایت‌ها را تشکیل می‌دهند، جریان می‌یابد و بدون ذخیره شدن از بین می‌رود

هر سلول یک نوع (kind) و یک مقدار (value) را گزارش می‌دهد

هر سلول منتشرشده (emitted) به عنوان یک رکورد TXLSDirectCell می‌رسد. این رکورد شامل ایندکس و نام شیت، سطر و ستون با پایه 1، یک معنای Kind، یک Value به عنوان Variant، متن Formula بدون علامت تساوی در ابتدای آن، و StyleIndex خام است. نوع (kind) سلول یکی از مقادیر xdkNumber، xdkString، xdkBoolean، xdkDate یا xdkError می‌باشد، بنابراین به جای اینکه معنی سلول را دوباره از ویژگی‌های آن استخراج کنید، می‌توانید مستقیماً بر اساس آن منشعب شوید (branch). یک سلول دارای فرمول، نوع نتیجه کش‌شده (cached) خود را به همراه متن فرمول گزارش می‌دهد، بنابراین یک مجموع محاسبه‌شده به عنوان یک عدد دریافت می‌شود که همچنین به شما می‌گوید چگونه تولید شده است

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;

تمایز تاریخ از عدد

مسئله تاریخ سزاوار بررسی دقیق‌تری است، زیرا اینجا همان جایی است که اکثر اسکنرهای ساده‌لوح (naive scanners) دچار اشتباه می‌شوند. هیچ نوع تاریخی روی یک سلول عددی وجود ندارد. سلولی که دارای مقدار سریال 46000 است می‌تواند یک مقدار کمی، یک قیمت، یا هفدهم فوریه 2025 باشد، و فایل تنها از طریق شناسه قالب اعداد (number-format id) که از طریق استایل سلول به آن دسترسی پیدا می‌کنید، به شما می‌گوید کدام یک از این‌هاست. استاندارد ECMA-376 یک بلوک از شناسه‌های قالب تعبیه‌شده را رزرو می‌کند که معنای آن‌ها در بین تمام تولیدکنندگان منطبق با این استاندارد ثابت است، و شناسه‌های حامل تاریخ در دو محدوده قرار دارند: 14 تا 22 برای قالب‌های استاندارد تاریخ و زمان، و 45 تا 47 برای قالب‌های زمان سپری‌شده مانند [h]:mm:ss. وقتی DetectDates روشن است (که به طور پیش‌فرض این‌طور است)، خواننده استایل هر سلول عددی را به شناسه قالب آن تبدیل می‌نماید، و سلولی که شناسه آن در آن محدوده‌های رزروشده قرار گیرد به عنوان xdkDate گزارش می‌شود در حالی که Value آن از قبل به یک TDateTime در دلفی تبدیل شده است. قالب‌های سفارشی نیز با بازرسی کد قالب برای یافتن توکن‌های تاریخ و زمان بررسی می‌شوند، اما محدوده‌های رزروشده ستون فقرات قابل‌اعتماد هستند. اگر DetectDates را خاموش کنید، جدول استایل‌ها اصلاً بارگذاری نمی‌شود، هر سلول عددی به عنوان xdkNumber دریافت می‌گردد، و اسکن به میزان کمی سبک‌تر می‌شود

پرش از شیت‌ها و توقف زودهنگام

اسکن ترتیبی (Sequential scanning) یک مزیت پنهان دارد که دسترسی تصادفی نمی‌تواند با آن برابری کند: شما می‌توانید توقف کنید. رویداد OnSheet قبل از باز شدن هر کاربرگ فعال می‌شود، و دو کلید به شما می‌دهد. اگر SkipSheet را تنظیم کنید، کل آن بخش هرگز تجزیه نمی‌شود، که این روشی است که شما فقط شیت‌هایی را که برایتان مهم هستند در یک کتاب‌کار چندشیتی اسکن می‌کنید بدون اینکه هزینه‌ای برای خواندن بقیه بپردازید. اگر Abort را تنظیم کنید، کل اسکن بلافاصله پایان می‌یابد. رویداد OnCell نیز Abort مخصوص به خود را دارد، بنابراین می‌توانید در لحظه‌ای که آنچه را به دنبالش بودید پیدا کردید —یک سطر خاص، یک مقدار نگهبان (sentinel value)، یا انتهای یک بلوک هدر— بدون خواندن میلیون‌ها سلول باقی‌مانده متوقف شوید. در یک اسکن فقط-به-جلو، لغو (abort) کاملاً بدون هزینه است، زیرا کاری که از آن صرف‌نظر می‌کنید، کاری است که هنوز انجام نشده است

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;

شمارش سلول‌ها بدون هندلر (handler)

اشاره به یکی از اصلاحات اخیر ارزشمند است زیرا یک سؤال رایج را به یک فراخوانی ارزان قیمت تبدیل می‌کند. خواننده هر سلول پرشده‌ای که از آن عبور می‌کند را می‌شمارد، و این کار را چه هندلر OnCell ضمیمه شده باشد و چه نشده باشد، انجام می‌دهد. پیش از این، اگر هیچ هندلری تنظیم نشده بود، شمارش سلول‌های پرشده عدد صفر را بازمی‌گرداند، زیرا شمارش یک اثر جانبی (side effect) از انتشار بود. اکنون شمارش از انتشار مستقل است. این بدان معناست که شما می‌توانید این سؤال واحد را بپرسید که، این کتاب‌کار در واقع حاوی چند سلول پرشده است، و پاسخ آن را تنها با هزینه یک اسکن بدون هیچ فراخوانی برگشتی (callbacks) دریافت کنید. ReadFile و ReadStream هر دو آن مجموع را به عنوان یک Int64 برمی‌گردانند، و همان عدد پس از آن به عنوان ویژگی CellCount در دسترس است. بازگشت -1 سیگنالی است مبنی بر اینکه فایل باز نشده یا یک بسته OOXML نیست

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;

برای اسکن کامل، شما هندلر را متصل کرده و ReadFile را دقیقاً به همان روش فراخوانی می‌کنید. تقابل این روش با یک بارگذاری کامل، در واقع همان اصل مطلب است: در حالی که بارگذاری quarterly_export.xlsx در یک کتاب‌کار، هر سلول را به یک شیء ساکن بسط داده و همه را در حافظه نگه می‌دارد، خواننده مستقیم فقط رشته‌های مشترک و جدول استایل‌ها را حفظ می‌کند و در همین حین، دوازده میلیون سلول یک به یک از طریق OnCell شما جریان می‌یابند. محاسباتی که برای هر سلول اجرا می‌شدند هیچ ردی از خود به جا می‌گذارند، بنابراین اوج مصرف حافظه با توجه به تعداد رشته‌های متمایز کتاب‌کار تنظیم می‌شود، نه تعداد سطرهای آن

زمانی که کار خواندن یک کتاب‌کار بزرگ برای یک بار و استخراج یا خلاصه‌سازی آن است، خواننده مستقیم ابزار درستی است. اگر در عوض به دسترسی تصادفی مدل کامل نیاز دارید اما می‌خواهید این مدل روی فایل‌های بزرگ هم خوب کار کند، تنظیمات مربوط به آن در یادداشت‌های ما در مورد عملکرد کتاب‌کار بزرگ در دلفی به این مسیر می‌پردازد. و زمانی که جهت معکوس است، یعنی تولید خروجی بزرگ به جای مصرف آن، راهنمای قدم به قدم جریان‌دهی-نوشتن برای کارهای دسته‌ای سرور همان انضباط حافظه ثابت را برای نوشتن اعمال می‌کند. هر سه این موارد به عنوان بخشی از کامپوننت صفحات گسترده HotXLS برای Delphi و C++Builder عرضه می‌شود، در کنار APIهای خواندن، نوشتن، قالب‌بندی و محاسبه که در جاهای دیگر این وبلاگ پوشش داده شده‌اند