מאמר טכני

שמירות בטוחות-לקריסה ב-HotXLS: קובצי זמניים מבוימים ב-Delphi

שמירה שמתה באמצע, בין אם מאתחול כפוי, תהליך שנהרג, או דיסק שמתמלא באמצע-כתיבה, באופן מסורתי אמרה דבר אחד עבור פורמט הבנוי סביב כתיבות-במקום: מה שבייטים הגיעו לדיסק לפני ההפרעה זה מה שאתה מקבל בחזרה, וחוברת עבודה קטומה לא נפתחת שוב. HotXLS סוגרת את מצב-הכשל הזה עם נתיב שמירה בטוח-לקריסה שמשמש לכל קובץ XLSX, ‏ODS, ו-XLS קלאסי שהיא כותבת. כל קריאת SaveAs כותבת את הקובץ החדש השלם לקובץ זמני שנוצר לצד היעד, ואז מבצעת אותו עם שינוי-שם אטומי בודד MoveFileExW מ-API של Windows, כך ששמירה שהופרעה יכולה רק להיכשל בייצור הקובץ החדש, היא לעולם לא פוגעת בזה שכבר היה לך. אותה משמעת בַּמֵה-ואז-החלף רצה באופן אחיד על פני שני מנועי השמירה של HotXLS, כותב ה-BIFF8 שמאחורי XLS קלאסי וכותב ה-OOXML שמאחורי XLSX ו-ODS, וזו תבנית ששווה לשאול עבור כל קובץ שהקוד שלך ב-Delphi כותב מחדש ישירות, גיליונות אלקטרוניים או לא

מה קורה אם שמירת חוברת עבודה מופרעת באמצע?

התשובה הישירה היא שזה תלוי לגמרי איך הכותב נוגע בקובץ היעד, והמימוש הנפוץ, פתיחת קובץ היעד והזרמת תוכן חדש ישירות לתוכו, בסדר כל עוד שום דבר אף פעם לא משתבש. ברגע שמשהו כן משתבש, קריסה, הריגת תהליך כפויה, שיתוף רשת שנופל באמצע-כתיבה, הקובץ על הדיסק נשאר בכל מצב ביניים שהכותב הגיע אליו: מדריך מרכזי (central directory) של ZIP שאף פעם לא נוסף עבור XLSX או ODS, או זרם BIFF חסר רשומות שקורא מצפה להן עבור XLS קלאסי. Excel לא מתקן את זה בחן, וגם לא צרכן אחר שמצפה לקובץ שלם, כך שהתוצאה המעשית היא חוברת עבודה שנפתחה בסדר אתמול ומסרבת להיפתח היום

איך HotXLS מבימה כל שמירה מאחורי החלפה אטומית אחת

HotXLS אף פעם לא פותחת את קובץ היעד לכתיבה ישירות, עבור אף אחד משלושת הפורמטים שהיא שומרת. הרצף הוא אותה צורה בכל פעם: בנה את הפלט השלם במקום שאינו הקובץ שכבר יש למשתמש על הדיסק, ורק העבר אותו למקום ברגע שהבנייה הזו הצליחה במלואה. באופן קונקרטי, SaveAs יוצרת קובץ זמני ריק באותה תיקייה כמו נתיב היעד, כותבת את חוברת העבודה החדשה השלמה לתוך הקובץ הזמני הזה, ורק אחרי שהכתיבה הזו חוזרת ללא שגיאה היא מבצעת את הקובץ הזמני מעל היעד עם שינוי-שם בודד. שום דבר מזה לא דורש מאפיין להצטרף אליו; זה פשוט מה ש-SaveAs עושה עבור נתיב קובץ פשוט, בכל קריאה

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Sheet := Book.Sheets.Add('Report');
    Sheet.Cells[1, 1].Value := 'Nothing special to enable here';
    // If this call is interrupted, monthly-report.xlsx on disk stays
    // either the old version, complete, or the new version, complete
    if Book.SaveAs('monthly-report.xlsx', xlsxOpenXMLWorkbook) <> 1 then
      raise Exception.Create('Save failed, see Book.LastDiagnostic');
  finally
    Book.Free;
  end;
end;

אותה משמעת חלה על כותב ה-XLS הקלאסי, לא רק זה של OOXML, ושני הקבצים הזמניים אפילו חולקים מוסכמת שמות: שניהם קוראים ל-API של Windows בשם GetTempFileNameW עם הקידומת hxl, כך ששמירה שהופרעה לפני ניקוי יכולה להשאיר מאחוריה קובץ תועה עם שם כמו hxl4C2A.tmp שיושב ליד חוברת העבודה שלך. הקובץ הזה אינו השחתה, הוא ראייה שהמנגנון עבד בדיוק כמתוכנן: הכתיבה הבלתי-שלמה נעצרה שם, וחוברת העבודה שלך בפועל אף פעם לא נפתחה לכתיבה מלכתחילה. ראיית אחד כזה אחרי קריסה בטוחה למחיקה ואין מה לחקור

למה לבים את הקובץ הזמני ליד חוברת העבודה במקום ב-%TEMP%?

התשובה הקצרה היא ששינוי-השם של MoveFileExW אטומי רק כאשר המקור והיעד יושבים על אותו כרך (volume), והדרך הבטוחה ביותר להבטיח את זה בלי לבקש מהקוד הקורא להגדיר משהו היא לגזור את מיקום הקובץ הזמני מנתיב היעד עצמו. HotXLS מחשבת את התיקייה של היעד עצמו ומוסרת את הספרייה הזו ישירות ל-GetTempFileNameW, כך שהקובץ הזמני תמיד נוצר על אותו כונן, אותו כרך, כמו הקובץ שהוא עומד להחליף, אוטומטית, בכל שמירה. אילו הספרייה הייתה במקום זאת מבימה כתיבות בתיקיית ה-temp של המערכת, נתיב יעד על כונן אחר או כרך רשת ממופה היה הופך את הצעד הסופי לפעולה חוצת-כרכים, שה-API של Windows או מסרב לחלוטין או, אם קוד קורא בוחר במפורש עם דגל נוסף ש-HotXLS לא מגדירה כאן, יורד בשקט לעותק לא-אטומי אחריו מחיקה, פותח מחדש בדיוק את חלון-ההפרעה שהמנגנון כולו הזה קיים כדי לסגור

שלב הביצוע: MoveFileExW, ‏write-through, ומה קורה בכשלון

הצעד הסופי בכל שמירה הוא בדיוק קריאת API אחת של Windows, ‏MoveFileExW, נושאת שני דגלים שכל אחד עושה עבודה נבדלת. ‏MOVEFILE_REPLACE_EXISTING הוא מה שמאפשר לשינוי-השם לנחות על קובץ שכבר קיים; בלעדיו, שינוי-שם שמכוון לנתיב קיים פשוט נכשל, מה שהיה מסכל את כל המטרה של שמירה שנועדה להחליף חוברת עבודה שכבר יש לך. ‏MOVEFILE_WRITE_THROUGH מכסה עמידות: הוא אומר לפונקציה לא לחזור עד שההעברה בפועל הושלמה על הדיסק, במקום לחזור ברגע ששינוי-השם רק תוזמן בתור, סוגר תחרות (race) צרה יותר אך אמיתית שבה קריסה מיד אחרי ש-SaveAs חוזרת עדיין הייתה יכולה לתפוס את ההחלפה בטיסה. אם הקובץ הזמני לא יכול להיווצר, או שינוי-השם הסופי נכשל מכל סיבה (בעיית הרשאה, יעד נעול, אי-התאמת כרך), HotXLS מוחקת את הקובץ הזמני בעצמה במקום להשאיר בלגן מאחור, וקובץ היעד נשאר בדיוק כפי שהיה לפני הקריאה

Result := Book.SaveAs(TargetPath, xlsxOpenXMLWorkbook);
if Result <> 1 then
begin
  // TargetPath on disk is unchanged; safe to retry, alert, or
  // fall back to a different path without touching prior output
  LogWriter.Write(Format('SaveAs failed (%d): %s',
    [Book.LastDiagnostic.Code, Book.LastDiagnostic.Message]));
  Exit(False);
end;

‏SaveAs עצמה שומרת על מוסכמת ההחזרה המשותפת ברחבי HotXLS, אחת בהצלחה, מספר שלילי בכישלון, אבל מספר שלם ערום לא אומר למה שמירה נכשלה, והתייחסות לכל תוצאה שלילית באותו אופן זורקת מידע שמדיניות ניסיון-חוזר יכולה בפועל להשתמש בו. מאפיין ה-LastDiagnostic, ואוסף ה-Diagnostics המלא יותר שמאחוריו, נושא את ההודעה ש-HotXLS ייצרה פנימית, מבחין בין קובץ זמני שלא הצליח להיווצר לבין שינוי-שם ש-Windows סירב לו. עבודת אצווה שרושמת Code ו-Message בכל SaveAs שנכשל בונה בדיוק את הראייה שאתה רוצה בפעם שלקוח מדווח על שמירה שבשקט לא עשתה כלום

XLS קלאסי משלם בזיכרון, XLSX ו-ODS משלמים בדיסק

שני מנועי השמירה מגיעים לאותה תוצאה בטוחה-לקריסה בדרכים שונות, וההבדל חשוב אם אתה כבר מכוונן אחד מהם לעבודת אצווה גדולה. כותב ה-XLS הקלאסי בונה את מסמך ה-OLE המורכב השלם בזיכרון קודם, באמצעות אחסון מובנה (structured storage) נתמך על ידי ידית זיכרון, ורק מעתיק את המאגר המוגמר הזה החוצה לקובץ הזמני האחיים בכתיבה אחת; ההיגיון בקוד המקור של HotXLS עצמה ישיר: בניית הקובץ השלם בזיכרון קודם היא מה שעוצר שמירה שנכשלה או בוטלה מלקטום אי-פעם את היעד. כותב ה-XLSX וה-ODS במקום זאת מזרים את רשומות ה-ZIP שלו לתוך הקובץ הזמני כשהן מיוצרות, אותה במה ברמת-קובץ עם פרופיל זיכרון שונה. אם אתה כבר נשען על StreamingWrite כדי לשמור ייצוא XLSX גדול בתוך מגבלת הזיכרון של מכולה, דע שהמנוף המקביל עבור ייצוא XLS קלאסי לא קיים באותה צורה: הערבות הבטוחה-לקריסה בלתי-מותנית בכל מקרה, אבל ייצוא .xls מדור-קודם גדול מאוד מחזיק את הפלט השלם שלו ב-RAM ללא קשר, פשרה המכוסה לעומק רב יותר בהמאמר שלנו על כתיבות streaming עבור עבודות אצווה בשרת

יישום אותה תבנית מחוץ ל-HotXLS, ואיפה הערבות מסתיימת

שאילת התבנית היא בעיקר עניין של חיווט אותן שתי קריאות API של Windows ש-HotXLS נשענת עליהן פנימית. ‏GetTempFileNameW מוסרת לך קובץ ריק בעל שם ייחודי בתיקייה שאתה בוחר, ו-MoveFileExW מבצעת את הכתיבה המוגמרת שלך מעל היעד האמיתי בצעד אחד; גרסה מינימלית של אותה שגרה ש-HotXLS מריצה לפני כל SaveAs נראית כך

function SaveFileAtomically(const Path: WideString; const Contents: TBytes): Boolean;
var
  Dir, TempName: WideString;
  Buffer: array[0..MAX_PATH] of WideChar;
  FS: TFileStream;
begin
  Result := False;
  Dir := ExtractFilePath(ExpandFileName(Path));
  FillChar(Buffer, SizeOf(Buffer), 0);
  if GetTempFileNameW(PWideChar(Dir), 'app', 0, @Buffer[0]) = 0 then
    Exit;
  TempName := PWideChar(@Buffer[0]);
  try
    FS := TFileStream.Create(TempName, fmCreate or fmShareExclusive);
    try
      FS.WriteBuffer(Contents[0], Length(Contents));
    finally
      FS.Free;
    end;
    Result := MoveFileExW(PWideChar(TempName), PWideChar(ExpandFileName(Path)),
      MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH);
  finally
    if not Result then
      DeleteFileW(PWideChar(TempName));
  end;
end;

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

שני גבולות נוספים שווים לדגל בבירור. יעד על שיתוף רשת או בתוך תיקייה מסונכרנת על ידי OneDrive או לקוח דומה יכול להתנהג שונה מ-NTFS מקומי למרות ש-Windows עדיין מדווח עליו ככרך יחיד, שכן מנהל ההתקן של מערכת הקבצים שמולו אולי לא מיישם שינוי-שם באותו אופן; אם יעד הפריסה שלך שומר על פני נתיב רשת, כדאי לבדוק שם ספציפית הפרעה כפויה במקום להניח שהתנהגות דיסק-מקומי נשאת אליו. וכל המנגנון מוגבל לשמירה לתוך קובץ בעל שם. קרא ל-SaveAs מול TStream במקום זאת, ו-HotXLS כותבת לתוך כל stream שמסרת לה ישירות, ללא קובץ יעד לבים או להגן עליו, משום שהעמידות של ה-stream הזה (מאגר זיכרון, העלאת רשת, blob של מסד נתונים) היא באחריות הקוד שלך לגמרי מאותה נקודה ואילך

מעבר אימות זוכה להישען בדיוק על הערבות הזו אחר כך, כולל הסוג המובנה בשולחן עבודה לביקורת והמרת חוברות עבודה: קובץ שנפתח מחדש וחוזר קצר או חסר הוא בעיית המרה אמיתית לרדוף אחריה, לעולם לא שמירה שהופרעה באמצע והשאירה משהו מעורפל על הדיסק. כתיבות מבוימות בטוחות-לקריסה בנויות לתוך SaveAs עבור כל חוברת עבודה XLSX, ‏ODS, ו-XLS קלאסית שמיוצרת על ידי רכיב HotXLS עבור Delphi ו-C++Builder, ללא הגדרה נדרשת כדי להפעיל אותה