技術記事

HotXLSにおけるブール結果の代わりの構造化診断

一晩かけて1万枚のスプレッドシートに対して一括変換を実行すると、朝になってそのうち3枚がFalseを返してくる。それがブール型の保存結果があなたに与える検死結果のすべてである:失敗件数だけで、どのファイルか、どのシートか、あるいは十数個ありうる原因のうちどれが責任だったかについては何もない。losLab のExcelファイル向けネイティブDelphi・C++Builderコンポーネントである HotXLS は、その単一のビットを構造化診断に置き換える。IXLSWorkbookProgressインターフェースはDiagnosticsリストとOnDiagnosticイベントを公開しており、これらはすべてのOpenSaveAsRecalculate呼び出しについて、安定した数値コード、重大度レベル、失敗した操作、そしてそれが起きたシートを報告する

なぜブール型の保存結果は規模が大きくなると破綻するのか

ブール型の結果が生む問題は、失敗した1枚のファイルではなく、その1000枚である。SaveAsが1万枚中3枚のファイルに対して成功以外の何かを返すとき、次の疑問は常に同じである:この3枚はリトライ可能なのか、それとも人間が必要なのか?ネットワーク共有上の権限エラーは、計算エンジンが評価できない数式とは別のインシデントであり、それはまたフォーマットの上限を静かに超えたワークシートとも別物だ。合否だけの結果しか手元になければ、そのすべてが同一のサポートチケットになってしまい、誰かが各ファイルを手作業でExcelで開き、原因が明白になるまで睨めっこしなければならない。その手作業のトリアージこそがブール型APIの本当のコストであり、それはバッチのサイズに比例して線形に増加する——これはエラーハンドリングに望ましくない性質そのものである

IXLSWorkbookProgressの内部:TXLSDiagnosticが運ぶもの

IXLSWorkbookProgressは、HotXLSが操作の進行状況とその内部で何が誤ったかの両方を報告するために使うインターフェースであり、この2つが1つの契約を共有しているのには理由がある:どちらも、長時間実行されるOpenSaveAsRecalculate呼び出しが、操作途中で例外を発生させることなく伝える必要があるものだからだ。進行状況の半分はOnProgressOnProgressExであり、フェーズ、状態、現在値・合計値のペアとともに発火する。診断の半分は本稿が扱うものである:TXLSDiagnosticsリストを返すDiagnosticsプロパティ、直近のエントリへのショートカットであるLastDiagnostic、そして各TXLSDiagnosticレコードが作成された瞬間に発火するOnDiagnosticイベントだ。各レコードは、数値のCodeTXLSDiagnosticSeverity、それを生成したTXLSDiagnosticOperation、人間が読めるMessageSheetIndexSheetName、そしてそのエントリを引き起こした下位レベルの戻り値を保持する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を読むだけで、すでにブール型の結果を単独で上回る。なぜならCodeSheetNameが謎を具体的でフィルタ可能な事実に変えるからだ。TXLSDiagnosticレコードは、この例が出力するものよりさらに深くまで届く:RecordIdStreamOffsetはBIFFストリーム内部のバイトレベルの鑑識のために存在し、PartNameは問題の出所であるOOXMLのzipエントリ、たとえばxl/worksheets/sheet3.xmlを保持する。それらの周りにツールを構築する前に知っておく価値がある:現行リリースでは、組み込みの診断呼び出しサイトのどれもRecordIdStreamOffsetを埋めていない。そのため両方ともコンストラクタの既定値である-1のままであり、これは「ゼロ」ではなく「該当なし」を意味する。それらの不在は正常なものとして扱ってほしい、あなたのハンドラのバグとしてではなく

2つのエンジン、1つの形、1つの静かな違い

HotXLSはこの同じ報告モデルの背後に2つのエンジンを出荷している——レガシーな.xlsファイル向けのBIFF8ファサードと.xlsx向けのOOXMLファサードであり、両者はIXLSWorkbookProgressを同一には公開していない。.xlsエンジンであるTXLSWorkbookIXLSWorkbookProgressを正式に実装しているため、そのインターフェース型が期待されるどこにでも渡すことができる。.xlsxエンジンであるTXLSXWorkbookは同じDiagnosticsLastDiagnosticOnDiagnosticOnProgressOnProgressExのメンバーを同一の名前と型で公開しているが、そのインターフェースの正式な実装としてではなく単純なクラスとしてであるため、それだけではIXLSWorkbookProgressパラメータを単独で満たすことはない。実際にはこれはめったに問題にならない。ほとんどのコードは一度に1つの具体的なワークブッククラスに対して動作するからだが、これはIXLSWorkbookProgress型として書かれた単一のヘルパーを作り、どちらのエンジンのワークブックオブジェクトも互換的に渡すことはできないことを意味する。形式の分裂に直接由来する唯一のフィールドの違いはPartNameである:XLSXエンジンだけがそれを埋める、なぜならOOXMLだけが名前を付けるべきzipパーツを持っているからだ

何が診断コードを安全に分岐できるものにするのか

Codeフィールドは、診断の中でハードコードして比較する価値のある唯一の部分である。Messageはそうではない、なぜなら文章はまさに、誰もそれを破壊的変更として扱うことなく後のリリースで言い回しが変わったり、再翻訳されたり、より詳しい説明に拡張されたりする類のものだからだ。HotXLSの組み込み診断コードは、すでにその区別を念頭に置いて設計されたかのように読める:保存関連のコードは1000から1005、開く関連のコードは1100と1101、計算関連のコードは1200と1201に位置し、サポート外形式のコードは1300であり、それぞれの帯の中に隙間が残されている——すべてを通して連番になっているわけではない。その間隔こそが、ベンダーがたとえば1006に新しい保存時の失敗モードを追加できるようにし、あなたのswitch文がすでに依存しているコードを振り直す必要をなくす。これはこの1つに限らず、本番でコードによるマッチングにコミットする前に、どんな診断APIでもチェックする価値がある。番号付けがどれだけ安定して見えても、自分のディスパッチロジックには既定の分岐を残しておくこと。なぜなら、新しい失敗モードこそが、進化を続けるパーサーやライターがまさに発見し続けるものだからだ。NativeCodeExceptionClassは、エスカレーションが必要なときのためにCodeの一段下に位置する:NativeCodeは基盤となる戻り値(その中には構造化ストレージ呼び出しからのHRESULTも含まれる)を保持し、ExceptionClassは関与していた場合のDelphi例外型を記録する。これは通常、完全なスタックトレースを添付しなくても正確なサポートリクエストを開くのに十分である

重大度と操作があなたのコードが次に何をすべきかを決める

重大度と操作こそが、診断をログの1行からルーティングの決定へと変えるものである。TXLSDiagnosticSeverityInfoWarningErrorFatalを持ち、TXLSDiagnosticOperationはそれを生成した呼び出し——OpenSaveCalculateExport——ですべてのエントリにタグを付ける。この2つの軸は設計上独立している:xlsDiagnosticUnhandledExceptionは、実際にそれを発生させた呼び出しに応じてOperationが設定されて発火する1つの固定コードである。そのためCodeは何が誤ったかに答え、Operationは別途どこでかに答える。openの間の例外とsaveの間の例外にそれぞれ別個のコードを必要とするのではない。この組み合わせ可能性はまた、ルーティングを機械的にする:警告をログして先に進む(Abortedフラグを通じてキャンセルされた保存が典型例)、エラーをカウントしてバッチを走らせ続ける(シリアライズに失敗したワークシートが典型例)、致命的な重大度でバッチを止める(そのレベルは未処理の例外がすでに呼び出しを巻き戻していることを意味し、続行すると半端に更新された状態から作業するリスクがある)。一つ正直に言っておくべき注意点がある:Infoは新しいTXLSDiagnosticが開始する既定値として列挙型に存在するが、現行のHotXLSリリースに組み込まれているすべての診断呼び出しサイトはWarningErrorFatalしか発生させない。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をポーリングすることは単一ファイルには有効だが、1万枚の一晩がかりのバッチに戻った途端に機能しなくなる。なぜならDiagnosticsはあらゆるOpenSaveAsRecalculate呼び出しの開始時にクリアされるからだ。ループの中で3枚目のファイルの後にそれを読めば、3枚目のファイルの診断だけが見える。最初の2枚が報告した内容はすでに消えている。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は構造的な理由で安価である:それは何かがすでに誤っているときにだけ発火し、誤りはワークブックが保持するセル・行・ワークシートの数に比べれば稀である。これをOnProgressOnProgressExと対比してみよう。これらは日常的な進行状況を報告し、最初から呼び出し頻度を中心に設計しなければならなかった。HotXLSはOpenSaveAsの間、セルや行ごとにではなく、シート単位で一度だけワークシートレベルの進捗を発火させる。これが数百万セルを持つワークブックでも呼び出しごとのオーバーヘッドを小さく保つ理由である。Recalculateはさらに進んで、自身の進捗イベントを依存グラフのおよそ4パーセントごとにスロットリングする。そのため完全な再計算は、UIスレッドをイベントで氾濫させるのではなく心拍のようなものを与えてくれる。診断にはそのようなスロットリングは一切必要なかった、なぜならイベント数はファイルのサイズではなく実際の問題の数によって制限されるからだ

パフォーマンスが依然としてあなた次第である唯一の場所は、ハンドラ自体の内部である。OnDiagnosticは、OpenSaveAsRecalculateを実行しているスレッド上で同期的に発火する。そのため、たとえばリモートのロギングサービスへの同期的な書き込みのようにブロックするハンドラは、その呼び出しの実時間の一部になってしまう。単一ファイルであればそれは見えない。1万ファイルのバッチにわたって乗じられれば、それはジョブが一晩で終わるか昼になってもまだ動いているかの違いになる。だからこそ、ハンドラがすべきことをバッファリングし、インラインで遅い部分を行うのではなく非同期でフラッシュすること

構造化診断は、ブール型の結果が最も弱いところ、すなわち1つではなく多くのファイルに触れるワークフローにおいて最も価値を発揮する。ワークブックの監査・変換パイプラインが最も分かりやすい例だ:ファイルごとに単なる合否を記録する代わりに、各ファイルのDiagnosticsリストをその監査記録に添付すれば、レポートは何が失敗したかだけでなくなぜかも教えてくれる。これはまさにワークブック監査・変換ワークベンチの構築に関する記事がそもそも達成しようとしていることの大部分である。進行状況と診断の同じ組み合わせは、それ自体のために進行状況の報告をすでに必要としているどんなワークフローにも当てはまる。これはまさにHotXLSにおける大規模ワークブックのパフォーマンスに関するガイドで扱われている領域であり、長いOpenSaveAs呼び出しが十分によくあるためOnProgressはすでに配線済みであり、OnDiagnosticはその隣に自然に、ほぼ無償で追加できる

これらのいずれも、パイプラインのどこかにExcelがインストールされていることを必要とせず、一般的な例外をキャッチしてそれが何を意味したかを推測することも必要としない。IXLSWorkbookProgressとそのDiagnosticsLastDiagnosticOnDiagnosticのメンバーは、本稿がこれまで見てきた完全な診断コードリファレンスとOpenSaveAsRecalculateの残りの面とともに、DelphiおよびC++Builder向け標準HotXLSコンポーネントの一部である