מאמר טכני

הזרמת קבצי XLSX ענקיים ב-Delphi ללא טעינתם

גיליון אלקטרוני עם מיליון שורות ותריסר עמודות הוא ייצוא רגיל לחלוטין ממשימת דיווח של מסד נתונים. פתח אותו בדרך הרגילה, על ידי טעינת חוברת העבודה כולה לתוך TXLSWorkbook, והתהליך חייב לממש כל אחד מתריסר מיליון התאים הללו כאובייקט חי לפני ששורת הלוגיקה העסקית הראשונה שלך רצה. הקובץ על הדיסק עשוי להיות שישים מגה-בייט של XML דחוס. עץ האובייקטים שהוא מתרחב אליו הוא פי כמה מזה, וכל זה חייב לשבת בזיכרון בבת אחת משום שהמודל תוכנן לגישה אקראית (random-access). עבור דוח שאתה מתכוון לקרוא מלמעלה למטה ולזרוק, זוהי כמות גדולה מאוד של זיכרון המבוזבזת על מבנה שמעולם לא היית צריך

ישנו נתיב שני דרך אותו קובץ. במקום לבנות מודל, אתה סורק את ה-XML של גיליון העבודה קדימה בלבד, תא אחד בכל פעם, ונותן לכל תא לזרום הלאה לאחר שהבטת בו. שום דבר לא מצטבר. הזיכרון נשאר כמעט קבוע בין אם לגיליון יש אלף שורות או עשרה מיליון, משום שהקורא לעולם לא מחזיק יותר מהחלק שהוא מנתח כרגע בתוספת שתיים-שלוש טבלאות חיפוש קטנות. זה מה שהקורא הישיר (direct reader) של HotXLS עושה, ושאר המאמר הזה עוסק בשאלה מדוע הוא נשאר קטן ומה הוא נותן לך בתמורה

מדוע המודל בזיכרון (in-memory) אינו סקיילבילי (scale)

קובץ XLSX הוא חבילת ZIP של חלקי XML המתוארים על ידי ECMA-376. כל גיליון עבודה הוא חלק משלו, xl/worksheets/sheetN.xml, ובתוכו כל שורה היא רכיב <row> המחזיק רכיבי תא <c>. נתיב הטעינה הרגיל קורא את החלק הזה ובונה אובייקט הניתן לפנייה (addressable) עבור כל תא כך שתוכל מאוחר יותר לבקש את Cells[12345, 7] ולקבל תשובה בזמן קבוע. גישה אקראית (Random access) היא כל המטרה של מודל חוברת עבודה, וזה בדיוק מה שהופך עריכה, הערכת נוסחאות ועיצוב לנוחים

העלות היא שגישה אקראית דורשת שהכל יהיה נוכח בו-זמנית. אינך יכול לאנדקס לתוך מבנה שבנית רק באופן חלקי. לכן זיכרון השיא (peak memory) של טעינה מלאה הוא פונקציה של ספירת התאים, ועל גיליון עם מיליוני תאים מאוכלסים הפונקציה הזו נוחתת במקום שבו השירות שלך לא רוצה להיות, במיוחד אם מספר עבודות כאלה רצות בבת אחת על מכונה משותפת. כאשר תבנית הגישה שאתה באמת צריך היא רציפה (sequential), תשלום עבור גישה אקראית הוא תשלום עבור יכולת שלא תשתמש בה

סריקת SAX קדימה-בלבד שאינה בונה שום עץ

הקורא הישיר פותח את חבילת ה-ZIP וסורק כל חלק של גיליון עבודה עם מנתח משיכה (pull parser) בסגנון SAX. הפירוש של SAX כאן הוא שהמנתח מדווח על אירועי ניתוח כשהוא נתקל בהם, רכיב התחלה, רצף טקסט, רכיב סיום, ואז ממשיך הלאה. הוא לא משאיר אחריו שום עץ צמתים (node tree). הקורא עוקב אחר השורה והעמודה הנוכחיות מהתכונות r, אוסף את סוג התא, אינדקס העיצוב (style index), הערך וטקסט הנוסחה כשהאירועים מגיעים, וכאשר התגית הסוגרת </c> מופיעה הוא פולט תא אחד ושוכח ממנו. התא הבא עושה שימוש חוזר באותו קומץ משתנים מקומיים

משום ששום דבר אינו נשמר בין תאים, טביעת הרגל של הזיכרון אינה גדלה עם מספר התאים. זוהי התכונה שכדאי להחזיק בה. גיליון של מאתיים שורות וגיליון של עשרים מיליון שורות עולים לקורא את אותו הזיכרון היושב (resident memory), וההבדל ביניהם הוא רק כמה זמן אורכת הסריקה. אתה מוותר על גישה אקראית, תכונת הדגל של המודל, ובתמורה אתה מקבל תקרה לזיכרון שספירת התאים לא יכולה לפרוץ

מה נשאר בזיכרון, ומדוע שני החלקים האלה

הסריקה אינה חסרת-מצב (stateless) לחלוטין, והחריגים מאלפים. שתי טבלאות קטנות חייבות להישמר בזיכרון לכל אורך התהליך, משום שתא בפני עצמו אינו נושא מספיק מידע כדי לפרש אותו מבלעדיהן

הראשונה היא טבלת המחרוזות המשותפות. ב-SpreadsheetML, תא טקסט אינו שומר את הטקסט שלו בעצמו. הוא נושא t="s" ומטען מספרי שהוא אינדקס אל תוך xl/sharedStrings.xml, רשימה בודדת ללא-כפילויות של כל מחרוזת ייחודית בחוברת העבודה. זוהי פשרה טובה של מקום עבור קבצים שבהם אותן תוויות חוזרות על פני אלפי שורות, אך זה אומר שהקורא חייב לטעון את טבלת המחרוזות מראש ולהשאיר אותה בזיכרון, מכיוון שכל תא בכל מקום בכל גיליון עשוי להפפנות לכל ערך בתוכה. גודלה של הטבלה נקבע לפי מספר המחרוזות הייחודיות, לא לפי ספירת התאים, ולכן היא נשארת צנועה אפילו בגיליונות עצומים

השנייה היא מיפוי פורמט-המספר מחלק העיצובים (styles). תא מספרי ותא תאריך זהים לחלוטין ברמת הבתים (byte-for-byte) בקובץ עצמו: שניהם הם מספר פשוט, משום שתאריך ב-SpreadsheetML הוא רק ספירת ימים סדרתית. הדבר היחיד שמבדיל ביניהם הוא העיצוב (style) של התא, שמצביע דרך cellXfs בתוך xl/styles.xml למזהה של פורמט מספר. כדי לדווח על תאריך כתאריך ולא כמספר הסדרתי הגולמי, הקורא טוען את טבלת העיצוב-לפורמט (style-to-format table) ומשאיר אותה בזיכרון. כל השאר בקובץ, נתוני התאים עצמם שמהווים את עיקר נפח הבתים, זורם על פניו מבלי להישמר

כל תא מדווח סוג וערך

כל תא שנפלט מגיע כרשומה מסוג TXLSDirectCell. הוא נושא את אינדקס ושם הגיליון, את השורה והעמודה (מבוסס 1), Kind סמנטי, את ה-Value כ-Variant, את הטקסט של ה-Formula ללא סימן השוויון המוביל שלו, ואת ה-StyleIndex הגולמי. הסוג (kind) הוא אחד מ-xdkNumber, xdkString, xdkBoolean, xdkDate, או xdkError, כך שאתה יכול להתפצל (branch) לפי משמעות התא במקום להסיק זאת מחדש מתכונות (attributes). תא נוסחה מדווח על הסוג של התוצאה השמורה (cached result) שלו, לצד טקסט הנוסחה, כך שסכום מחושב מגיע כמספר שגם אומר לך כיצד הוא נוצר

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, והסריקה הופכת לקלה (leaner) בשבריר קטן

דילוג על גיליונות וביטול מוקדם

לסריקה רציפה יש יתרון שקט שגישה אקראית אינה יכולה להתחרות בו: אתה יכול לעצור. אירוע ה-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;

ספירת תאים ללא מטפל (handler)

עדכון חכם אחד מן הזמן האחרון ראוי לציון משום שהוא הופך שאלה נפוצה לקריאה זולה בודדת. הקורא סופר כל תא מאוכלס שהוא עובר על פניו, והוא עושה זאת בין אם מחובר אליו מטפל ל-OnCell ובין אם לא. מוקדם יותר, ללא מטפל מוגדר, ספירת התאים המאוכלסים חזרה כאפס, משום שהספירה הייתה תופעת לוואי של פליטת התא. כעת הספירה אינה תלויה בפליטה. משמעות הדבר היא שאתה יכול לשאול שאלה אחת, כמה תאים מאוכלסים באמת מכילה חוברת העבודה הזו, ולקבל את התשובה במחיר של סריקה ללא שום קריאות חוזרות (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 שלך בזה אחר זה. האריתמטיקה שרצה לכל תא אינה משאירה מאחור דבר, כך ששיא הזיכרון נקבע על ידי ספירת המחרוזות הייחודיות בחוברת העבודה, ולא לפי ספירת השורות שבה

הקורא הישיר הוא הכלי הנכון כאשר המשימה היא לקרוא חוברת עבודה גדולה פעם אחת ולחלץ או לסכם אותה. כאשר לעומת זאת אתה צריך את הגישה האקראית של המודל המלא אך רוצה שהוא יתנהג היטב על קבצים גדולים, כוונון הביצועים המתואר בהערות שלנו על ביצועי חוברת עבודה גדולה ב-Delphi מכסה נתיב זה. וכאשר הכיוון הפוך, ייצור פלט גדול ולא צריכה שלו, המדריך לכתיבה זורמת בעבודות אצווה של שרת מיישם את אותה משמעת של זיכרון קבוע בכתיבה. כל השלושה מסופקים כחלק מ-HotXLS Component עבור Delphi ו-C++Builder, לצד ממשקי ה-API לקריאה, כתיבה, נוסחאות ועיצוב הנסקרים במקומות אחרים בבלוג זה