يُعد جدول البيانات الذي يحتوي على مليون صف وعشرات الأعمدة عملية تصدير عادية تماماً من مهمة إعداد تقارير قاعدة البيانات. افتحه بالطريقة المعتادة، عن طريق تحميل المصنف بالكامل في TXLSWorkbook، ويجب أن تجسد العملية كل خلية من تلك الخلايا الاثني عشر مليوناً ككائن حي (live object) قبل تشغيل سطر منطق الأعمال الأول الخاص بك. قد يكون الملف على القرص ستين ميغابايت من ملفات XML المضغوطة. تكون شجرة الكائنات التي يتوسع إليها أكبر بعدة مرات، ويجب أن يكون كل ذلك مقيماً في وقت واحد لأن النموذج عبارة عن وصول عشوائي حسب التصميم. بالنسبة لتقرير تنوي قراءته من أعلى إلى أسفل ورميه، فإن ذلك يمثل قدراً كبيراً من الذاكرة التي يتم إنفاقها على بنية لم تكن بحاجة إليها مطلقاً
هناك مسار ثانٍ عبر نفس الملف. بدلاً من بناء نموذج، تقوم بفحص ورقة العمل XML للأمام فقط، خلية واحدة في كل مرة، وتدع كل خلية تتدفق بعد أن تكون قد نظرت إليها. لا شيء يتراكم. تظل الذاكرة شبه ثابتة سواء كانت الورقة تحتوي على ألف صف أو عشرة ملايين، لأن القارئ لا يحتفظ أبداً بأكثر من الجزء الذي يقوم بتحليله حالياً بالإضافة إلى جدولي بحث صغيرين. هذا ما يفعله القارئ المباشر (direct reader) في HotXLS، وتدور بقية هذا المقال حول سبب بقائه صغيراً وما يمنحك إياه في المقابل
لماذا لا يتوسع النموذج داخل الذاكرة بشكل جيد
ملف XLSX عبارة عن حزمة ZIP لأجزاء XML الموصوفة بواسطة ECMA-376. كل ورقة عمل هي جزء خاص بها، xl/worksheets/sheetN.xml، وداخلها يمثل كل صف عنصر <row> يحتفظ بعناصر الخلية <c>. يقرأ مسار التحميل العادي هذا الجزء وينشئ كائناً قابلاً للعنونة لكل خلية بحيث يمكنك لاحقاً طلب Cells[12345, 7] والحصول على إجابة في وقت ثابت. الوصول العشوائي هو بيت القصيد من نموذج المصنف، وهو بالضبط ما يجعل التحرير وتقييم الصيغة والتنسيق أمراً مريحاً
التكلفة هي أن الوصول العشوائي يتطلب أن يكون كل شيء موجوداً في وقت واحد. لا يمكنك الفهرسة داخل بنية قمت ببنائها جزئياً فقط. لذلك فإن ذروة الذاكرة للتحميل الكامل هي دالة لعدد الخلايا، وعلى ورقة تحتوي على ملايين الخلايا المأهولة، تهبط هذه الدالة في مكان لا تريد خدمتك التواجد فيه، خاصة إذا كانت عدة مهام من هذا القبيل تعمل في وقت واحد على جهاز مشترك. عندما يكون نمط الوصول الذي تحتاجه بالفعل تسلسلياً، فإن الدفع مقابل الوصول العشوائي يعني الدفع مقابل إمكانية لن تستخدمها
فحص SAX للأمام فقط ولا يبني أية شجرة
يفتح القارئ المباشر حزمة ZIP ويمشي على كل جزء من أجزاء ورقة العمل باستخدام محلل سحب بنمط SAX (SAX-style pull parser). تعني SAX هنا أن المحلل يبلغ عن أحداث التحليل عندما يصادفها، عنصر بداية، أو تشغيل نص، أو عنصر نهاية، ثم يمضي قدماً. لا يحتفظ بشجرة عقدة (node tree) خلفه. يتتبع القارئ الصف والعمود الحاليين من سمات r، ويجمع نوع الخلية وفهرس النمط والقيمة ونص الصيغة فور وصول الأحداث، وعندما تُرى علامة الإغلاق </c> فإنه يصدر خلية واحدة وينساها. تعيد الخلية التالية استخدام نفس العدد القليل من المتغيرات المحلية
لأنه لا يتم الاحتفاظ بأي شيء بين الخلايا، فإن مساحة الذاكرة لا تنمو مع عدد الخلايا. هذه هي الخاصية التي تستحق التمسك بها. تكلف ورقة من مائتي صف وورقة من عشرين مليون صف القارئ نفس الذاكرة المقيمة، والفرق بينهما هو فقط مدة تشغيل الفحص. أنت تتخلى عن الوصول العشوائي، وهي الميزة الرئيسية للنموذج، وفي المقابل تحصل على سقف للذاكرة لا يمكن لعدد الخلايا اختراقه
ما يبقى مقيماً، ولماذا هذين الجزأين
لا يكون الفحص خالياً تماماً من الحالة، والاستثناءات مفيدة. يجب الاحتفاظ بجدولين صغيرين في الذاكرة طوال المدة، لأن الخلية بمفردها لا تحمل معلومات كافية لتفسيرها بدونهما
الأول هو جدول السلاسل المشتركة (shared string table). في SpreadsheetML، لا تقوم الخلية النصية بتخزين نصها الخاص. إنها تحمل t="s" وحمولة رقمية وهي عبارة عن فهرس داخل xl/sharedStrings.xml، وهي قائمة واحدة خالية من التكرارات لكل سلسلة مميزة في المصنف. هذه مقايضة مساحة جيدة للملفات حيث تتكرر نفس التسميات عبر آلاف الصفوف، ولكنها تعني أن القارئ يجب أن يحمل جدول السلاسل هذا مقدماً ويحافظ عليه مقيماً، لأن أي خلية في أي مكان في أي ورقة قد تشير إلى أي إدخال فيه. يتم تحديد حجم الجدول بعدد السلاسل المميزة، وليس بعدد الخلايا، لذلك يظل متواضعاً حتى في الأوراق الضخمة
والثاني هو تعيين تنسيق الأرقام من جزء الأنماط. تتطابق الخلية الرقمية وخلية التاريخ بايتاً ببايت على السلك: كلاهما رقم عادي، لأن التاريخ في SpreadsheetML هو مجرد عدد متسلسل للأيام. الشيء الوحيد الذي يميزهما هو نمط الخلية، الذي يشير عبر cellXfs في xl/styles.xml إلى معرّف تنسيق الأرقام. للإبلاغ عن التاريخ كتاريخ وليس كعدد متسلسل خام، يقوم القارئ بتحميل هذا الجدول الخاص بالنمط إلى التنسيق ويحتفظ به مقيماً. كل شيء آخر في الملف، وهو بيانات الخلية الفعلية التي تشكل الجزء الأكبر من البايتات، يتدفق دون تخزينه
كل خلية تبلغ عن نوع وقيمة
تصل كل خلية مُصدرة كسجل TXLSDirectCell. وهو يحمل فهرس الورقة واسمها، والصف والعمود القائمين على 1، والنوع الدلالي Kind، و Value كـ Variant، ونص Formula بدون علامة التساوي البادئة، و StyleIndex الخام. يكون النوع واحداً من 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 كمية أو سعراً أو 17 فبراير 2025، ويخبرك الملف بأي منها فقط من خلال معرف تنسيق الأرقام الذي يتم الوصول إليه عبر نمط الخلية. تحتفظ ECMA-376 بمجموعة من معرّفات التنسيق المضمنة التي يتم تثبيت معناها عبر كل منتج متوافق، وتوجد المعرفات الحاملة للتاريخ في نطاقين: من 14 إلى 22 لتنسيقات التاريخ والوقت القياسية، ومن 45 إلى 47 لتنسيقات الوقت المنقضي مثل [h]:mm:ss. عندما يكون DetectDates قيد التشغيل، وهو كذلك افتراضياً، يحلل القارئ نمط كل خلية رقمية لمعرف التنسيق الخاص بها، والخلية التي يقع معرفها في تلك النطاقات المحجوزة يتم الإبلاغ عنها كـ xdkDate مع تحويل Value الخاص بها بالفعل إلى TDateTime لـ Delphi. يتم فحص التنسيقات المخصصة أيضاً، عن طريق فحص رمز التنسيق لرموز التاريخ والوقت، ولكن النطاقات المحجوزة هي العمود الفقري الموثوق. قم بإيقاف تشغيل DetectDates ولن يتم تحميل جدول الأنماط حتى، وستأتي كل خلية رقمية كـ xdkNumber، وسيكون الفحص أصغر قليلاً
تخطي الأوراق والإحباط المبكر
يمتلك المسح المتسلسل ميزة هادئة لا يمكن أن يضاهيها الوصول العشوائي: يمكنك التوقف. ينطلق حدث OnSheet قبل فتح كل ورقة عمل، ويمنحك محولين. قم بضبط SkipSheet ولن يتم تحليل هذا الجزء بأكمله أبداً، وهي الطريقة التي تفحص بها فقط الأوراق التي تهتم بها في مصنف متعدد الأوراق دون الدفع مقابل قراءة الباقي. قم بضبط Abort وينتهي الفحص بأكمله على الفور. يحمل الحدث OnCell قيمة Abort الخاصة به، بحيث يمكنك التوقف في اللحظة التي تجد فيها ما كنت تبحث عنه، وهو صف معين، أو قيمة حارس (sentinel value)، أو نهاية كتلة رأسية، دون قراءة ملايين الخلايا المتبقية. في فحص للأمام فقط، يكون الإحباط مجانياً حقاً، لأن العمل الذي تتخطاه هو العمل الذي لم يحدث بعد
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 مرفقاً أم لا. في وقت سابق، مع عدم تعيين أي معالج، كان عدد الخلايا المأهولة يعود كصفر، لأن العد كان أثراً جانبياً للإصدار. الآن العد مستقل عن الإصدار. وهذا يعني أنه يمكنك طرح سؤال واحد، وهو كم عدد الخلايا المأهولة التي يحتوي عليها هذا المصنف بالفعل، والحصول على الإجابة بثمن فحص دون أي استدعاءات على الإطلاق. يقوم كلاً من 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 واحدة تلو الأخرى. لا تترك العمليات الحسابية التي تم إجراؤها لكل خلية أي شيء وراءها، لذلك يتم تحديد ذروة الذاكرة من خلال عدد السلاسل المميزة للمصنف، وليس من خلال عدد صفوفه
القارئ المباشر هو الأداة المناسبة عندما تكون المهمة هي قراءة مصنف كبير مرة واحدة واستخراجه أو تلخيصه. عندما تحتاج بدلاً من ذلك إلى الوصول العشوائي للنموذج الكامل ولكن تريده أن يتصرف على ملفات كبيرة، فإن الضبط في ملاحظاتنا حول أداء المصنف الكبير في Delphi يغطي هذا المسار. وعندما ينعكس الاتجاه، لإنتاج مخرجات كبيرة بدلاً من استهلاكها، فإن الكتابة التدفقي لمهام الخادم الدفعية يطبق نفس انضباط الذاكرة الثابتة على الكتابة. يتم شحن الثلاثة كجزء من مكون HotXLS Component لـ Delphi و C++Builder، إلى جانب واجهات برمجة تطبيقات القراءة والكتابة والصيغة والتنسيق التي تمت تغطيتها في مكان آخر في هذه المدونة