يعد جدول البيانات الذي يحتوي على مليون صف وعشرات الأعمدة تصديراً عادياً تماماً من وظيفة إعداد تقارير قاعدة البيانات. إذا فتحته بالطريقة المعتادة، عن طريق تحميل المصنف بأكمله في TXLSWorkbook، فستضطر العملية إلى تجسيد كل خلية من تلك الخلايا الاثني عشر مليوناً ككائن حي قبل تشغيل السطر الأول من منطق عملك. قد يكون الملف الموجود على القرص ستين ميغابايت من XML المضغوط. شجرة الكائنات التي يتوسع إليها تبلغ عدة أضعاف ذلك، ويجب أن تكون جميعها مقيمة في نفس الوقت لأن النموذج هو وصول عشوائي حسب التصميم. بالنسبة لتقرير تنوي قراءته من أعلى إلى أسفل ثم التخلص منه، فهذا قدر كبير من الذاكرة يتم إنفاقه على هيكل لم تكن بحاجة إليه أبداً
هناك مسار ثانٍ عبر نفس الملف. بدلاً من بناء نموذج، تقوم بمسح XML لورقة العمل إلى الأمام فقط، خلية تلو الأخرى، وتدع كل خلية تتدفق بعد النظر إليها. لا يتراكم أي شيء. تظل الذاكرة شبه ثابتة سواء كانت الورقة تحتوي على ألف صف أو عشرة ملايين، لأن القارئ لا يحتفظ أبداً بأكثر من الجزء الذي يقوم بتحليله حالياً بالإضافة إلى بضعة جداول بحث صغيرة. هذا ما يفعله القارئ المباشر في HotXLS، وبقية هذا المقال تدور حول سبب بقائه صغيراً وما يقدمه لك في المقابل
لماذا لا يتوسع النموذج الموجود في الذاكرة
ملف XLSX هو حزمة ZIP لأجزاء XML موصوفة بواسطة ECMA-376. كل ورقة عمل هي جزء خاص بها، xl/worksheets/sheetN.xml، وبداخلها كل صف هو عنصر <row> يحمل عناصر الخلايا <c>. يقرأ مسار التحميل المنتظم هذا الجزء ويبني كائناً قابلاً للعنونة لكل خلية حتى تتمكن لاحقاً من طلب Cells[12345, 7] والحصول على إجابة في وقت ثابت. الوصول العشوائي هو الهدف الأساسي من نموذج المصنف، وهو بالضبط ما يجعل التحرير وتقييم الصيغ والتنسيق أمراً مريحاً
التكلفة هي أن الوصول العشوائي يتطلب وجود كل شيء في وقت واحد. لا يمكنك الفهرسة في بنية قمت ببنائها جزئياً فقط. لذا فإن ذروة ذاكرة التحميل الكامل هي دالة لعدد الخلايا، وفي ورقة تحتوي على ملايين الخلايا المأهولة، تهبط هذه الدالة في مكان لا ترغب خدمتك في التواجد فيه، خاصة إذا تم تشغيل العديد من هذه الوظائف في وقت واحد على جهاز مشترك. عندما يكون نمط الوصول الذي تحتاجه بالفعل تسلسلياً، فإن الدفع مقابل الوصول العشوائي يعني الدفع مقابل إمكانية لن تستخدمها
مسح SAX للأمام فقط لا يبني أي شجرة
يفتح القارئ المباشر حزمة ZIP ويمر عبر كل جزء من أجزاء ورقة العمل باستخدام محلل سحب بأسلوب SAX. يعني SAX هنا أن المحلل يبلغ عن أحداث التحليل فور مواجهتها، عنصر البداية، مسار نصي، عنصر النهاية، ثم يمضي قدماً. ولا يحتفظ بأي شجرة عقد وراءه. يتتبع القارئ الصف والعمود الحاليين من سمات r، ويجمع نوع الخلية، ومؤشر النمط، والقيمة، ونص الصيغة عند وصول الأحداث، وعندما يرى وسم الإغلاق </c> فإنه يصدر خلية واحدة وينساها. تعيد الخلية التالية استخدام نفس العدد القليل من المتغيرات المحلية
نظراً لأنه لا يتم الاحتفاظ بأي شيء بين الخلايا، فإن مساحة الذاكرة لا تنمو مع عدد الخلايا. هذه هي الخاصية التي تستحق التمسك بها. تكلف الورقة المكونة من مائتي صف والورقة المكونة من عشرين مليون صف القارئ نفس الذاكرة المقيمة، والفرق بينهما يكمن فقط في مدة تشغيل المسح. أنت تتخلى عن الوصول العشوائي، الميزة الرئيسية للنموذج، وفي المقابل تحصل على سقف للذاكرة لا يمكن لعدد الخلايا تجاوزه
ما يبقى مقيماً، ولماذا هذان الجزءان
المسح ليس عديم الحالة تماماً، والاستثناءات مفيدة. يجب الاحتفاظ بجدولين صغيرين في الذاكرة طوال المدة، لأن الخلية بمفردها لا تحمل معلومات كافية لتفسيرها بدونهما
الأول هو جدول السلاسل المشتركة. في 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 عبارة عن كمية أو سعر أو السابع عشر من فبراير 2025، ويخبرك الملف بأيها فقط من خلال معرف تنسيق الرقم الذي يتم الوصول إليه عبر نمط الخلية. يحجز ECMA-376 كتلة من معرفات التنسيق المضمنة التي يكون معناها ثابتاً عبر كل منتج مطابق، وتقع المعرفات التي تحمل التواريخ في نطاقين: 14 حتى 22 لتنسيقات التاريخ والوقت القياسية، و 45 حتى 47 لتنسيقات الوقت المنقضي مثل [h]:mm:ss. عندما يكون DetectDates قيد التشغيل، وهو كذلك افتراضياً، يحل القارئ نمط كل خلية رقمية إلى معرف التنسيق الخاص بها، ويتم الإبلاغ عن خلية يقع معرفها في تلك النطاقات المحجوزة كـ xdkDate مع تحويل Value الخاص بها بالفعل إلى TDateTime في Delphi. يتم التحقق من التنسيقات المخصصة أيضاً، عن طريق فحص كود التنسيق لرموز التاريخ والوقت، لكن النطاقات المحجوزة هي العمود الفقري الذي يمكن الاعتماد عليه. أوقف تشغيل 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 مرفقاً أم لا. في السابق، مع عدم تعيين أي معالج، كان عدد الخلايا المأهولة يعود كصفر، لأن العد كان أثراً جانبياً للإصدار. الآن أصبح العد مستقلاً عن الإصدار. هذا يعني أنه يمكنك طرح سؤال واحد، كم عدد الخلايا المأهولة التي يحتوي عليها هذا المصنف بالفعل، والحصول على الإجابة بسعر مسح بدون أي استدعاءات على الإطلاق. يعيد كل من 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 لكل من Delphi و C++Builder، جنباً إلى جنب مع واجهات برمجة تطبيقات القراءة والكتابة والصيغ والتنسيق التي تمت تغطيتها في مكان آخر في هذه المدونة