مقاله فنی

استریم فایل‌های حجیم XLSX در دلفی بدون بارگذاری آن‌ها

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

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

چرا مدل درون‌حافظه‌ای مقیاس‌پذیر نیست

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

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

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

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

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

چه چیزی در حافظه باقی می‌ماند و چرا آن دو بخش

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

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

دومین مورد، نگاشت فرمت اعداد (number-format) از بخش استایل‌ها است. یک سلول عددی و یک سلول تاریخ از نظر بایت به بایت روی شبکه یکسان هستند: هر دو عدد ساده‌ای هستند، زیرا تاریخ در SpreadsheetML فقط یک شمارش سریال روزها است. تنها چیزی که آن‌ها را متمایز می‌کند، استایل سلول است که از طریق cellXfs در xl/styles.xml به یک شناسه فرمت اعداد اشاره می‌کند. برای گزارش تاریخ به عنوان یک تاریخ به جای یک شماره سریال خام، خواننده این جدولِ استایل به فرمت را بارگذاری کرده و در حافظه نگه می‌دارد. هر چیز دیگری در فایل، یعنی داده‌های واقعی سلول که بخش عمده‌ای از بایت‌ها را تشکیل می‌دهند، بدون ذخیره شدن از مسیر استریم عبور می‌کنند

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

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

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;

تشخیص یک تاریخ از یک عدد

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

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

اسکن ترتیبی یک مزیت خاموش دارد که دسترسی تصادفی نمی‌تواند با آن برابری کند: شما می‌توانید توقف کنید. رویداد OnSheet قبل از باز شدن هر کاربرگ فعال می‌شود و دو سوئیچ در اختیار شما قرار می‌دهد. مقدار SkipSheet را تنظیم کنید تا کل آن بخش هرگز تجزیه نشود؛ این روشی است که به کمک آن می‌توانید در یک کتاب کار با چندین برگه، تنها برگه‌هایی را که برایتان مهم است بدون پرداخت هزینه برای خواندن بقیه، اسکن کنید. تنظیم Abort باعث می‌شود کل فرآیند اسکن فوراً متوقف شود. رویداد OnCell نیز 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;

شمارش سلول‌ها بدون یک هندلر (کنترل‌کننده)

یک بهبود اخیر ارزش اشاره کردن را دارد زیرا یک سوال رایج را به یک فراخوانی ارزان قیمت تبدیل می‌کند. خواننده در حین عبور، هر سلول پر شده را می‌شمارد و این کار را مستقل از اینکه آیا یک هندلر OnCell پیوست شده باشد یا نه انجام می‌دهد. قبلاً، بدون تنظیم هندلر، تعداد سلول‌های پر شده صفر برمی‌گشت، زیرا شمارش یکی از اثرات جانبیِ ساطع کردن (emitting) بود. اما اکنون شمارش مستقل از ساطع کردن است. این بدان معناست که شما می‌توانید فقط یک سؤال بپرسید: این کتاب کار در واقع چه تعداد سلول پر شده دارد؟ و در ازای یک اسکن بدون هیچ فراخوانی بازگشتی (callback)، پاسخ را دریافت کنید. متدهای 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 برای دلفی و C++Builder در کنار APIهای خواندن، نوشتن، فرمول و قالب‌بندی که در جاهای دیگر این وبلاگ پوشش داده شده‌اند، عرضه می‌شوند