對於以就地寫入為核心的格式而言,無論是強制重新開機、終止處理程序,還是磁碟在寫入途中填滿,半途失敗的儲存傳統上都代表同一件事:中斷前已寫入磁碟的位元組就是之後能取回的全部內容,而遭截斷的活頁簿將無法再次開啟。HotXLS 以防當機儲存路徑關閉這種失效模式,這條路徑適用於它寫入的每個 XLSX、ODS 與傳統 XLS 檔案。每次 SaveAs 呼叫都會將完整的新檔案寫入目的地旁建立的暫存檔,接著透過 Windows API 的單次原子 MoveFileExW 重新命名來提交,因此中斷的儲存只會無法產生新檔案,不會損害你原本已有的檔案。同樣的分階段再交換流程會一致地套用在 HotXLS 的兩個儲存引擎上,也就是傳統 XLS 背後的 BIFF8 寫入器,以及 XLSX 與 ODS 背後的 OOXML 寫入器;無論是試算表還是其他檔案,這都是你自己的 Delphi 程式直接覆寫檔案時值得採用的模式
活頁簿儲存若在半途中斷會發生什麼事
直接答案完全取決於寫入器如何接觸目的地檔案,而常見的實作方式是開啟目標檔案並直接將新內容串流寫入,只要一切從未出錯就能正常運作。但一旦發生當機、強制終止處理程序,或網路共用在寫入途中中斷,磁碟上的檔案就會停留在寫入器當時抵達的任何中間狀態:XLSX 或 ODS 可能沒有附加完成 ZIP 中央目錄,傳統 XLS 則可能缺少讀取器預期的 BIFF 串流記錄。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 寫入器,兩者甚至共用命名慣例:都會以 hxl 前綴呼叫 Windows GetTempFileNameW API,因此在清理前中斷的儲存可能會在活頁簿旁留下名稱類似 hxl4C2A.tmp 的多餘檔案。那個檔案不是損毀,而是機制完全按照設計運作的證據:不完整的寫入停在那裡,而你的實際活頁簿從未被開啟來寫入。當機後看到這種檔案可以放心刪除,不需要進一步調查
為什麼要把暫存檔放在活頁簿旁,而不是放在 %TEMP%
簡單來說,MoveFileExW 的重新命名只有在來源與目的地位於同一個磁區時才具備原子性,而不要求呼叫端進行任何設定、又能保證這一點的最可靠方式,就是從目的地路徑本身推導暫存檔位置。HotXLS 會計算目標所在的資料夾,並將該目錄直接交給 GetTempFileNameW,因此每次儲存都會自動在即將被取代的檔案所在的同一磁碟、同一磁區建立暫存檔。如果函式庫改為在系統暫存資料夾中分階段寫入,位於不同磁碟或對應網路磁區上的目標路徑就會讓最後一步變成跨磁區操作;Windows API 可能直接拒絕,或者在呼叫端明確以額外旗標選擇、而 HotXLS 在此並未設定的情況下,默默降級為非原子複製後刪除,重新打開這套機制原本要關閉的中斷時間窗
提交步驟:MoveFileExW、寫入直通,以及失敗時會發生什麼事
每次儲存的最後一步正好是一次 Windows API 呼叫,也就是帶有兩個各自負責不同工作的旗標的 MoveFileExW。MOVEFILE_REPLACE_EXISTING 允許重新命名落到已存在的檔案上;沒有它,指向現有路徑的重新命名就會直接失敗,這會違背取代既有活頁簿的儲存操作本意。MOVEFILE_WRITE_THROUGH 負責持久性:它告訴函式在移動真正完成並寫入磁碟前不要返回,而不是在重新命名只被排入佇列時就返回,從而關閉另一個較窄但真實的競爭窗口,避免 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 共用的返回慣例,成功時為 1,失敗時為負數,但單純的整數不會說明儲存失敗的原因,而將所有負值一視同仁會丟失重試策略實際可以利用的資訊。LastDiagnostic 屬性及其背後更完整的 Diagnostics 集合會攜帶 HotXLS 內部產生的訊息,區分無法建立暫存檔與 Windows 拒絕重新命名。每次失敗的 SaveAs 都記錄 Code 和 Message 的批次工作,會累積出客戶報告某次儲存悄悄沒有作用時,你真正想要的完整證據
傳統 XLS 付出記憶體,XLSX 與 ODS 付出磁碟
兩個儲存引擎透過不同路徑達到相同的防當機結果,如果你已經為大型批次工作調校其中一個引擎,這項差異就很重要。傳統 XLS 寫入器會先在記憶體中建立完整的 OLE 複合文件,使用由記憶體控制代碼支援的結構化儲存,然後只用一次寫入將完成的緩衝區複製到旁邊的暫存檔;HotXLS 自身原始碼中的理由很直接:先在記憶體中建立完整檔案,才能阻止失敗或取消的儲存截斷目的地。XLSX 與 ODS 寫入器則會在 ZIP 項目產生時將它們串流寫入暫存檔,仍是檔案層級的分階段處理,只是記憶體配置不同。如果你已經依賴 StreamingWrite,讓大型 XLSX 匯出維持在容器的記憶體限制內,請注意傳統 XLS 匯出沒有同樣形式的對等控制項:無論哪種方式,防當機保證都是無條件的,但非常大型的舊式 .xls 匯出仍會在 RAM 中保留完整輸出,這項取捨在我們的伺服器批次工作串流寫入文章中有更深入的說明
在 HotXLS 之外套用相同模式,以及保證的界線
借用這個模式,主要就是接上 HotXLS 內部依賴的相同兩個 Windows API 呼叫。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;
在盲目依賴這項保證前,值得先了解它實際存在的界線。在取代原始檔案前先分階段建立完整副本,意味著儲存期間短暫需要同時容納舊檔案與新檔案的磁碟空間,寫入期間大約是活頁簿大小的兩倍;對報表而言通常沒有問題,但對在幾乎已滿的磁區上執行的數 GB 匯出就值得檢查。暫存檔也必須落在與目的地相同的資料夾中,因此 HotXLS 執行所使用的帳戶必須對該資料夾本身擁有建立檔案的權限,而不只是能覆寫它已知的那一個檔案;若部署環境將目的地資料夾鎖定為只能就地編輯特定既有檔名,而不是提供資料夾層級的寫入權限,即使相同的直接寫入原本能成功,SaveAs 仍會在暫存檔步驟失敗
還有兩個界線值得明確指出。網路共用上的目的地,或位於 OneDrive 等類似用戶端同步資料夾內的目的地,即使 Windows 仍將它回報為單一磁區,也可能與本機 NTFS 表現不同,因為前方的檔案系統驅動程式可能不會以相同方式實作重新命名;如果你的部署目標會跨網路路徑儲存,值得特別在該處測試強制中斷,不要假設本機磁碟行為能直接延續。而且整套機制只涵蓋儲存到具名檔案。改為對 TStream 呼叫 SaveAs 時,HotXLS 會直接寫入你交給它的串流,不存在可供分階段處理或保護的目的地檔案,因為從那一刻起,該串流的持久性,無論是記憶體緩衝區、網路上傳還是資料庫 Blob,完全由你的程式負責
之後的驗證流程可以確實依賴這項保證,包括活頁簿稽核與轉換工作台內建的那類流程:重新開啟後變短或缺少內容的檔案是真正需要追查的轉換問題,絕不會是儲存中途遭中斷並在磁碟上留下模糊狀態。由HotXLS 元件為 Delphi 與 C++Builder 產生的每個 XLSX、ODS 與傳統 XLS 活頁簿,其 SaveAs 都內建防當機分階段寫入,無需任何設定即可啟用