技术文章

HotXLS 防崩溃保存:Delphi 中的临时文件暂存

对于采用原地写入的格式来说,一次在中途失败的保存,无论原因是强制重启、进程被终止,还是磁盘在写入过程中耗尽空间,传统上都意味着同一件事:中断前已经写入磁盘的字节就是最终能取回的内容,而被截断的工作簿无法再次打开。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 中内置了防崩溃暂存写入,无需任何配置即可启用