如果你让批处理在夜间转换一万份电子表格,早晨发现其中三份返回 False,布尔保存结果能给出的事后信息就只有这些:失败数量,却没有文件、工作表或十几种可能原因中的具体归因。HotXLS 是 losLab 面向 Excel 文件的原生 Delphi 和 C++Builder 组件,它用结构化诊断替代这个单一布尔位。IXLSWorkbookProgress 接口提供 Diagnostics 列表和 OnDiagnostic 事件,为每次 Open、SaveAs 和 Recalculate 调用报告稳定的数值代码、严重级别、失败操作以及发生问题的工作表
为什么布尔保存结果在大规模批处理中会失效?
布尔结果造成的问题不是单个文件失败,而是一千个文件失败。当一万份文件中有三份的 SaveAs 返回非成功结果时,下一个问题总是相同的:这三份可以重试,还是需要人工处理?网络共享上的权限错误,与计算引擎无法求值的公式不是同一类事件,悄悄超出格式限制的工作表也不同。只有成功或失败结果时,它们都会变成完全相同的支持工单,工作人员不得不在 Excel 中逐个打开文件并反复查看,直到原因变得明显。人工分流才是布尔 API 的真正成本,而且会随批处理规模线性增长,这正是错误处理最不希望看到的特性
深入了解 IXLSWorkbookProgress:TXLSDiagnostic 携带哪些信息?
IXLSWorkbookProgress 是 HotXLS 用来报告操作进度和内部错误的接口,两部分共用一个契约是有原因的:长时间运行的 Open、SaveAs 或 Recalculate 调用都需要在不中途抛出异常的情况下传递这些信息。进度部分由 OnProgress 和 OnProgressEx 组成,触发时提供阶段、状态以及当前值/总值对。本文关注的是诊断部分:Diagnostics 属性返回 TXLSDiagnostics 列表,LastDiagnostic 快捷属性指向最近一条记录,OnDiagnostic 事件则在每条 TXLSDiagnostic 记录创建的瞬间触发。每条记录包含数值 Code、TXLSDiagnosticSeverity、产生它的 TXLSDiagnosticOperation、面向用户的 Message、SheetIndex 和 SheetName,以及保留底层返回值的 NativeCode
var
Book: TXLSXWorkbook;
Diag: TXLSDiagnostic;
I: Integer;
begin
Book := TXLSXWorkbook.Create;
try
if Book.SaveAs('quarterly-report.xlsx') <> 1 then
for I := 0 to Book.Diagnostics.Count - 1 do
begin
Diag := Book.Diagnostics[I];
Writeln(Format('[%d] severity=%d sheet="%s": %s',
[Diag.Code, Ord(Diag.Severity), Diag.SheetName, Diag.Message]));
end;
finally
Book.Free;
end;
end;
像这样读取 Diagnostics,本身就已经胜过布尔结果,因为 Code 和 SheetName 会把谜团变成明确且可筛选的事实。TXLSDiagnostic 记录包含的内容比示例打印的更多:RecordId 和 StreamOffset 可用于 BIFF 流中的字节级取证,PartName 保存产生问题的 OOXML ZIP 条目,例如 xl/worksheets/sheet3.xml。在围绕这些字段构建工具前需要注意:当前版本的内置诊断调用点不会填充 RecordId 或 StreamOffset,因此它们都会保持构造函数默认值 -1,表示“不适用”而不是“零”。在处理程序中应将它们缺失视为正常情况,而不是错误
两个引擎、同一套模型,以及一个不易察觉的差异
HotXLS 在同一报告模型后面提供两个引擎:面向旧版 .xls 文件的 BIFF8 外观,以及面向 .xlsx 的 OOXML 外观;两者对 IXLSWorkbookProgress 的暴露方式并不完全相同。TXLSWorkbook 是 .xls 引擎,它正式实现了 IXLSWorkbookProgress,因此可以传给任何需要该接口类型的位置。TXLSXWorkbook 是 .xlsx 引擎,它以相同名称和类型暴露 Diagnostics、LastDiagnostic、OnDiagnostic、OnProgress 和 OnProgressEx 成员,但它是普通类,而不是该接口的正式实现,因此不能单独满足 IXLSWorkbookProgress 参数。实际开发中这通常影响不大,因为大多数代码一次只针对一个具体工作簿类,但这意味着不能编写一个类型为 IXLSWorkbookProgress 的通用辅助方法,然后不加区分地传入任一引擎的工作簿对象。格式拆分直接带来的唯一字段差异是 PartName:只有 XLSX 引擎会填充它,因为只有 OOXML 存在可命名的 ZIP 部件
什么样的诊断代码才适合安全地进行分支判断?
Code 字段是诊断中唯一值得硬编码比较的部分;Message 不是,因为说明文字恰恰可能在后续版本中被改写、重新翻译或扩展,而不会被视为破坏性变更。HotXLS 的内置诊断代码已经体现了这种区分:保存相关代码为 1000 到 1005,打开相关代码为 1100 和 1101,计算相关代码为 1200 和 1201,不支持格式代码为 1300;每个区间内部留有间隔,而不是让所有代码连续排列。这样的间隔允许厂商在保存阶段增加例如 1006 的新失败模式,而不必重新编号现有 switch 语句依赖的代码。在生产环境决定按代码匹配前,任何诊断 API 都值得检查这一点,而不只是 HotXLS。无论编号看起来多稳定,都应在自己的派发逻辑中保留默认分支,因为不断演进的解析器或写入器总会发现新的失败模式。当需要进一步追查时,NativeCode 和 ExceptionClass 位于 Code 的下一层:NativeCode 保留底层返回值,其中可能包括 Structured Storage 调用返回的 HRESULT;ExceptionClass 记录相关 Delphi 异常类型,通常足以发起精确的支持请求,而无需附加完整堆栈跟踪
严重级别和操作决定代码的下一步行为
严重级别和操作会把诊断从日志行变成路由决策。TXLSDiagnosticSeverity 包含 Info、Warning、Error 和 Fatal,TXLSDiagnosticOperation 则为每条记录标记产生它的调用:Open、Save、Calculate 或 Export。这两个轴按设计相互独立:xlsDiagnosticUnhandledException 是一个固定代码,触发时 Operation 会设置为实际引发异常的调用,因此 Code 回答“出了什么问题”,而 Operation 单独回答“发生在哪里”,无需为打开期间的异常和保存期间的异常分别设计代码。这种可组合性也让路由变得机械化:记录警告后继续,例如通过 Aborted 标志取消保存;统计错误并继续批处理,例如工作表序列化失败;遇到致命级别则停止批处理,因为这表示未处理异常已经展开调用,继续执行可能基于半更新状态工作。需要诚实说明的是:Info 作为新建 TXLSDiagnostic 的默认枚举值存在,但当前 HotXLS 版本内置的诊断调用点只会产生 Warning、Error 或 Fatal;Info 是为未来保留的值,而不是引擎当前会发出的值
// same Diagnostics loop as above, routed by severity instead of printed flat:
for I := 0 to Book.Diagnostics.Count - 1 do
begin
Diag := Book.Diagnostics[I];
case Diag.Severity of
xlsDiagnosticWarning:
Writeln(Format('WARN [%d] %s', [Diag.Code, Diag.Message]));
xlsDiagnosticError:
begin
Writeln(Format('ERROR [%d] %s (sheet %s, native %d)',
[Diag.Code, Diag.Message, Diag.SheetName, Diag.NativeCode]));
Inc(FailedSheetCount);
end;
xlsDiagnosticFatal:
raise Exception.CreateFmt('Fatal HotXLS diagnostic %d: %s', [Diag.Code, Diag.Message]);
end;
end;
如何将 OnDiagnostic 接入批处理流水线?
对于单个文件,在每次调用后轮询 Diagnostics 可以工作;但回到一万份文件的夜间批处理后,这种方式就失效了,因为每次 Open、SaveAs 和 Recalculate 调用开始时都会清空 Diagnostics。循环处理到第三个文件后再读取,你只能看到第三个文件的诊断;前两个文件报告的内容已经消失。OnDiagnostic 通过把集合变成事件流解决了这个问题:在循环开始前订阅一次,同一个处理程序会按文件顺序接收每条记录,文件名则通过实例字段保持在作用域内
type
TBatchConverter = class
private
FCurrentFile: string;
FFailedFiles: TStringList;
procedure HandleDiagnostic(Sender: TObject; Diagnostic: TXLSDiagnostic);
end;
procedure TBatchConverter.HandleDiagnostic(Sender: TObject; Diagnostic: TXLSDiagnostic);
begin
if Diagnostic.Severity >= xlsDiagnosticError then
FFailedFiles.Add(Format('%s: [%d] %s (sheet %s)',
[FCurrentFile, Diagnostic.Code, Diagnostic.Message, Diagnostic.SheetName]));
end;
// inside the batch loop:
Book.OnDiagnostic := HandleDiagnostic;
for I := 0 to FileNames.Count - 1 do
begin
FCurrentFile := FileNames[I];
if Book.Open(FCurrentFile) = 1 then
Book.SaveAs(ChangeFileExt(FCurrentFile, '.xlsx'));
end;
回调实际会带来多少开销?
OnDiagnostic 从结构上看开销很小:它只在问题已经发生时触发,而问题数量相对于工作簿包含的单元格、行或工作表数量通常很少。相比之下,OnProgress 和 OnProgressEx 报告常规进度,因此从一开始就必须围绕调用频率设计。HotXLS 在 Open 和 SaveAs 期间每个工作表触发一次工作表级进度,而不是每个单元格或每行触发一次;即使工作簿包含数百万个单元格,也能保持单次调用开销较小。Recalculate 更进一步,会将自己的进度事件限制为依赖图大约每推进百分之四触发一次,因此完整重算会提供心跳,而不是用事件淹没 UI 线程。诊断不需要任何这种节流,因为事件数量受实际问题数量限制,而不是受文件大小限制
性能仍取决于你的一个地方,是处理程序内部。OnDiagnostic 会在执行 Open、SaveAs 或 Recalculate 的线程上同步触发,因此会阻塞的处理程序——例如向远程日志服务进行同步写入——会成为该调用墙钟时间的一部分。单个文件中这点通常察觉不到,但在一万份文件的批处理中累积起来,就可能决定任务是夜间完成还是午餐时仍在运行。因此应缓冲处理程序需要做的工作并异步刷新,而不是在回调内联执行慢操作
结构化诊断在布尔结果最薄弱的地方最有价值,也就是一次处理许多文件而不是一个文件的工作流。工作簿审计和转换流水线是最清晰的例子:不要只为每个文件记录成功或失败,而应将每个文件的 Diagnostics 列表附加到审计记录,报告就能告诉你失败的原因,而不只是告诉你失败了;这正是我们构建工作簿审计与转换工作台的文章试图解决的核心问题。同样,进度和诊断也适合已经需要进度报告的工作流,这正是HotXLS 大型工作簿性能指南覆盖的范围:长时间运行的 Open 或 SaveAs 调用足够常见,通常已经接入 OnProgress,此时旁边增加 OnDiagnostic 几乎没有额外成本
整个流水线无需安装 Excel,也无需捕获通用异常后猜测它代表什么。IXLSWorkbookProgress 及其 Diagnostics、LastDiagnostic 和 OnDiagnostic 成员是面向 Delphi 和 C++Builder 的标准 HotXLS 组件,同时提供本文介绍的完整诊断代码参考以及 Open、SaveAs 和 Recalculate API