مقاله فنی

نوشتن استریم با HotXLS برای کارهای گروهی سرور در Delphi

فرض کنید یک سرویس شبانهٔ Delphi برای هر مشتری یک XLSX تولید می‌کند، چند صد فایل که بعضی‌شان 400,000 ردیف پهنا دارند. آن را پروفایل کنید؛ غافلگیری به‌ندرت حلقهٔ پرکردن سلول‌هاست. غافلگیری فراخوانی SaveAs است. با نویسندهٔ پیش‌فرض، هر کاربرگ پیش از آنکه در zip قالب OOXML فشرده شود به یک رشتهٔ XML یکپارچه در حافظه سریال می‌شود، و برای یک برگهٔ پهن، آن رشتهٔ گذرا می‌تواند از مدل سلولی‌ای که از رویش ساخته شده بسیار بزرگ‌تر باشد. پس کاری که داده‌اش را با آسودگی می‌سازد و روی 800 MB می‌نشیند، هنگام ذخیره از سقف 2 GB ظرف می‌گذرد، و OOM killer ساعت 03:00 که کسی حواسش نیست گزارش خطا را ثبت می‌کند. HotXLS، کتابخانهٔ بومی صفحه‌گسترده از losLab برای Delphi و C++Builder، ویژگی‌ای دارد که دقیقاً همان اوج مصرف را هدف می‌گیرد: StreamingWrite. کنار آن دو اهرم دیگر هستند که تعیین می‌کنند یک worker دسته‌ای درون بودجهٔ حافظه و زمانش می‌ماند یا نه، یعنی callbackهای نوشتن در سطح ردیف و رفتار استخر سبک‌ها داخل یک حلقهٔ فشرده

مسیر ذخیرهٔ پیش‌فرض چه چیزی را بافر می‌کند و StreamingWrite چه چیزی را تغییر می‌دهد

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

تنظیم Book.StreamingWrite := True باعث می‌شود SaveAs به نویسندهٔ کاربرگی سوئیچ کند که XML برگه را همان‌طور که تولید می‌شود مستقیماً در جریان zip منتشر می‌کند. رشتهٔ میانی هرگز تخصیص داده نمی‌شود و آن اوج مثلثی در نوفه صاف می‌شود

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

حافظهٔ دستهٔ Delphi در طول زمان با HotXLS؛ SaveAs پیش‌فرض جهش گذرای رشتهٔ XML کاربرگ را روی فلات پرکردن می‌نشاند، درحالی‌که Book.StreamingWrite := True پروفایل را در طول ذخیره تخت نگه می‌دارد
فلات پرکردن به هر دو شکل یکسان است چون مدل سلول همچنان در حافظه ساخته می‌شود؛ StreamingWrite فقط قله سریال‌سازیِ زمان ذخیره را برمی‌دارد

یک خروجی انبوه با پرچم روشن

Book := TXLSXWorkbook.Create;
try
  BoldIdx := Book.Fonts.Add('Calibri', 11, True, False); // اندیس استخر، مبنا-صفر
  Sheet := Book.Sheets.Add('Bulk');
  for R := 1 to 100000 do
  begin
    Sheet.Cells[R, 1].Value := R;
    Sheet.Cells[R, 2].Value := 'Row ' + IntToStr(R);
    Sheet.Cells[R, 3].Value := R * 1.5;
    if (R mod 1000) = 0 then
      Sheet.Cells[R, 2].FontIndex := BoldIdx + 1;        // در سلول مبنا-یک است
  end;
  Book.StreamingWrite := True;   // XML برگه مستقیم به درون zip جریان می‌یابد
  Book.SaveAs('bulk.xlsx');
finally
  Book.Free;
end;

Cells[R, C] سلول‌ها را در صورت نیاز می‌سازد و همین بدنهٔ حلقه را تمیز نگه می‌دارد. دو سقف شبکه ارزش سپردن به حافظه را دارند: 1,048,576 ردیف و 16,384 ستون، که با نام‌های XlsxMaxRow و XlsxMaxCol در دسترس‌اند. خوراک داده‌ای که از سقف ردیف بگذرد باید در کد خودتان میان چند برگه تقسیم شود. هیچ چیز در ادامهٔ مسیر متوجه این سرریز نمی‌شود و آن را برای شما درست نمی‌کند، و فایل صرفاً در همان حد بریده تمام می‌شود

پرکردن ردیف‌ها بدون سربار Variant به ازای هر سلول

هر انتساب Cells[R, C].Value هزینهٔ یک جست‌وجوی سلول و یک تبدیل Variant را می‌پردازد. در ده هزار ردیف کسی متوجه نمی‌شود. در یک میلیون ردیف با بیست ستون، همان سربار هر فراخوانی به هزینهٔ غالب مرحلهٔ پرکردن تبدیل می‌شود و پروفایلر مستقیم به آن اشاره می‌کند. رابط‌های دسته‌ای به شما امکان می‌دهند به‌جای آن، هر بار یک ردیف کامل را به نویسنده بسپارید. WriteRows یک callback را می‌راند که در هر فراخوانی یک ردیف تأمین می‌کند:

جریان کال‌بک WriteRows در HotXLS در Delphi؛ نشانگر پرس‌وجو در هر فراخوان یک ردیف به کال‌بک FillRow می‌دهد که آرایهٔ variant مقادیر را پر می‌کند یا Skip و Cancel برمی‌انگیزد و کاربرگ ردیف‌به‌ردیف پر می‌شود
WriteRows حلقه را به HotXLS می‌سپارد در حالی که callback یک سطر variant-array به‌ازای هر فراخوانی می‌دهد، با Skip به‌عنوان انصراف هر-سطر و Cancel به‌عنوان توقف تمیز کل-اجرا
procedure TBulkExporter.FillRow(Sender: TObject; SheetIndex, Row, FirstCol,
  LastCol: Integer; var Values: Variant; var Skip: Boolean;
  var Cancel: Boolean);
begin
  if not FReader.Next then
  begin
    Cancel := True;              // منبع داده تمام شد: تمیز متوقف شو
    Exit;
  end;
  Values := VarArrayCreate([FirstCol, LastCol], varVariant);
  Values[FirstCol]     := FReader.RecordId;
  Values[FirstCol + 1] := FReader.CustomerName;
  Values[FirstCol + 2] := FReader.Amount;
end;

// ردیف‌های 2 تا 100001 و ستون‌های A تا C را از reader پر کن
Sheet.WriteRows(2, 1, 100001, 3, FillRow);

پرچم Cancel همان چیزی است که یک بازهٔ ردیف ثابت را به «حداکثر N ردیف» تبدیل می‌کند، و این شکل طبیعی کار است وقتی تعداد ردیف‌ها از پرس‌وجویی می‌آید که هنوز اجرایش تمام نشده. Skip لمس ملایم‌تری است: یک ردیف را بدون توقف اجرا خالی رها می‌کند. فراتر از پرکردن سلول‌ها، این callback خانهٔ خوبی برای دغدغه‌های عملیاتی از آب درمی‌آید که وگرنه به شکل‌های ناجور به حلقهٔ پرکردن پیچ می‌شوند. یک شمارندهٔ پیشرفت که هر هزار ردیف تیک می‌زند، یک توکن لغو که از زمان‌بند کارها خوانده می‌شود، یک محدودکنندهٔ نرخ روی خواندن از پایگاه دادهٔ منبع: همه‌شان در یک جا زندگی می‌کنند به‌جای آنکه لابه‌لای کد نوشتن سلول‌ها بافته شوند. در سمت خواندن، ForEachRow و ForEachCell همین الگو را آینه می‌کنند، که وقتی یک کار دسته‌ای هم فایل‌های بزرگ مصرف می‌کند و هم تولید، اهمیت پیدا می‌کند

استخر سبک‌ها به بیرون‌کشیدن پاداش می‌دهد

مدل سبک‌دهی XLSX مجموعه‌ای از استخرهای اشتراکی است. Fonts.Add، Fills.AddSolid و Borders.Add همگی یک اندیس استخر مبنا-صفر برمی‌گردانند، و یک سلول با ذخیرهٔ همان اندیس به‌علاوهٔ یک در FontIndex به یک فونت ارجاع می‌دهد، جایی که صفر برای پیش‌فرض workbook رزرو شده است. آن +1 همان‌جا در مثال انبوه بالا دیده می‌شود. فراموشش کنید و سلول بی‌سروصدا سبک نادرست را برمی‌دارد، چون خطای یکی‌کم‌یکی‌زیاد در اندیس استخر سبک همچنان یک اندیس معتبر است و هیچ استثنایی برانگیخته نمی‌شود

انضباطی که از این نکته می‌آید این است: هر شیء سبک را پیش از حلقهٔ ردیف بسازید و داخل حلقه فقط به اندیسش ارجاع دهید. Fonts.Add تعریف‌های یکسان را حذف تکرار می‌کند، پس فراخوانی‌اش به ازای هر ردیف فقط CPU هدر می‌دهد. Alignments.Add تله است، چون در هر فراخوانی یک ورودی تازه برمی‌گرداند. داخل یک حلقهٔ 100k ردیفی، همین styles.xml را زیر صد هزار رکورد ترازبندی تکراری دفن می‌کند، که فایل روی دیسک را باد می‌کند و هر بار باز کردن بعدی در Excel را کند می‌کند، چون تکراری‌ها دوباره تجزیه می‌شوند. هر سبک را یک‌بار بیرون از حلقه بسازید، سپس هر چند بار که لازم است به اندیسش ارجاع دهید

جریان‌ها، پوشه‌های موقت و حلقهٔ دسته‌ای پیرامون همهٔ اینها

هیچ‌یک از اینها به سامانهٔ فایل نیاز ندارد. هر دو نما در سراسر سطح ورودی/خروجی خود overloadهای TStream دارند، از جمله Open و SaveAs و SaveAsCSV و SaveAsHTML و SaveAsODS، پس یک worker دسته‌ای می‌تواند مستقیماً در یک TMemoryStream رندر کند که مقصدش ذخیره‌سازی blob یا یک پاسخ HTTP است، بی‌آنکه هرگز دیسک را لمس کند. یک لبهٔ تیز را باید به یاد داشت. SaveAs(Stream) از موقعیت جاری جریان می‌نویسد و بعد آن را به عقب برنمی‌گرداند، پس پیش از تحویل جریان به هر چیزی که آن را تحویل می‌دهد، خودتان Position := 0 را تنظیم کنید، وگرنه مصرف‌کننده صفر بایت می‌خواند. نمای XLS دو دستهٔ تنظیم مخصوص خود را اضافه می‌کند. SetTempDir فایل‌های موقت نویسندهٔ BIFF را به حجمی نشانه می‌رود که فضا و ظرفیت ورودی/خروجی لازم برای جذبشان را دارد، و این روی سرورهایی مهم است که مسیر موقت پیش‌فرضشان روی یک دیسک سیستمی تنگ نشسته است. UseSharedFormulas بدنه‌های فرمول تکراری را در گروه‌های اشتراکی تا می‌زند؛ کاهش اندازهٔ واقعی برای همان شکل کلاسیک گزارش که در آن یک فرمول در تمام یک ستون کپی شده است

خودِ حلقهٔ دسته‌ای عمداً کسل‌کننده می‌ماند:

for FileName in SourceFiles do
begin
  Book := TXLSXWorkbook.Create;        // نمونهٔ تازه: نشت وضعیت رخ نمی‌دهد
  try
    Book.StreamingWrite := True;
    if Book.Open(FileName) <> 1 then
      Continue;                        // یک ورودی خراب نباید کل دسته را بکشد
    Book.SaveAsCSV(ChangeFileExt(FileName, '.csv'), 0, ',');
  finally
    Book.Free;
  end;
end;

یک نمونهٔ workbook تازه به ازای هر فایل چند میکروثانیه هزینه دارد و یک ردهٔ کامل از باگ‌های آلودگی میان‌فایلی را حذف می‌کند: سبک‌ها، نام‌های تعریف‌شده و ویژگی‌های سند از فایل 17 هیچ راهی برای نشت به فایل 18 ندارند. رد کردن و ادامه دادن پس از یک Open ناموفق هم به همان اندازه ارزشش را دارد، چون یک آپلود ناقص در دسته‌ای 600 فایلی باید فقط یک خط لاگ برایتان خرج بردارد، نه باقی اجرا. آنچه پایهٔ CSV عمداً انجام نمی‌دهد هم ارزش هشدار دارد. SaveAsCSV فرمول‌ها را به‌صورت متن عینی می‌نویسد و هرگز آنها را ارزیابی نمی‌کند، پس دسته‌ای تبدیلی که مصرف‌کنندگانش انتظار عددهای محاسبه‌شده دارند باید نخست Calculate را روی سلول‌های مربوط اجرا کند، یا از workbookهایی شروع کند که پیشاپیش نتیجه‌های کش‌شده از یک محاسبهٔ قبلی را همراه دارند

مدل همروندی: یک workbook برای هر thread

اشیای هیچ‌یک از دو نما thread-safe نیستند و طراحی هم هرگز خلاف این را وانمود نکرده است. چون هیچ وضعیت سراسری مشترکی میان نمونه‌ها وجود ندارد، قاعدهٔ مقیاس‌پذیری به‌سادگی یک workbook برای هر thread کارگر است، بدون اشتراک یک workbook میان threadها. استخری از N کارگر که هر کدام TXLSXWorkbook خودش را دارد، تقریباً خطی مقیاس می‌گیرد تا وقتی حافظه سقف شود، و برای آن سقف می‌توان عدد گذاشت: بزرگ‌ترین مدل سلولی همزمان ضرب در تعداد کارگرها، به‌علاوهٔ هر سربار زمان ذخیره‌ای که StreamingWrite صافش کرده است. وقتی صف عمیق می‌شود، فشار برگشتی را به‌جای درون نویسنده، روی صف کارها اعمال کنید. threadی که گرسنه مانده و نیمی از یک workbook را نوشته چیز به‌دردبخوری تولید نکرده، حال آنکه کاری که چند ثانیه منتظر یک کارگر آزاد مانده، سالم به پایان می‌رسد

مدل همزمانی HotXLS برای کارهای دسته‌ای سرور Delphi؛ صف کار رشته‌های کارگر را تغذیه می‌کند که هر یک نمونهٔ خصوصی TXLSXWorkbook خود را دارند، با اعمال پس‌فشار در صف و حافظه به‌عنوان سقف مقیاس‌پذیری
نمونه‌های کتاب کار هیچ وضعیت جهانی مشترک ندارند، پس یک کتاب کار به‌ازای هر نخ تا جایی مقیاس می‌یابد که مدل‌های سلولی هم‌زمان به سقف حافظه برسند

برای تصویر گسترده‌تر تنظیم کارایی، شامل فرمول‌های اشتراکی، رد کردن گرافیک در سمت خواندن و اهرم‌های ویژهٔ XLS، به راهنمای کارایی workbookهای بزرگ نگاه کنید. کارهای دسته‌ای که ردیف‌هایشان مستقیم از یک پرس‌وجو می‌آید جداگانه در الگوهای خروجی‌گرفتن از پایگاه داده برای گزارش‌های Delphi پوشش داده شده‌اند

HotXLS به‌صورت Object Pascal بومی و بدون وابستگی بیرونی درون سرویس Delphi یا C++Builder شما کامپایل می‌شود؛ نسخه‌ها و مجوزها در صفحهٔ محصول HotXLS Delphi Component آمده است