技術記事

HotXLS Delphi Component: Delphi での Office-free workbook automation

サーバーの唯一の仕事が Excel ファイルを出力することであれば、そのサーバーが Excel を実行する理由はありません。ビルドエージェントやレポートサービスに Office をインストールして COM 自動化で操作するのは誤った設計であり、この手法が存在してきた期間ずっと誤った設計であり続けています。Microsoft 自身も、20年間揺らいでいないガイダンスの中でそう述べています。Office は、無人のサーバー側プロセスから自動化されることを前提に作られても、ライセンスされてもいません。正しい答えは、Excel をまったく介在させずに BIFF と OOXML のバイトを直接書き込むことです。それこそが、スプレッドシートフォーマットそのものを読み書きするネイティブの Object Pascal ライブラリである HotXLS の前提すべてであり、ハングしたり、リークしたり、席数課金の対象になったりするデスクトップアプリケーションは一切存在しません

サービスから EXCEL.EXE を操作すると失敗する理由

COM 自動化はデスクトッププログラムをリモート制御するものであり、デスクトッププログラムは Windows サービスが与えられない 3 つのことを暗黙のうちに前提としています。ロードされたユーザープロファイル、対話型のウィンドウステーション、そして画面を見ている人間です。それらを取り除くと、開発者のマシンでは決して再現できない形で失敗が現れます。ファイル復旧のプロンプト、アドインのエラー、ライセンス有効化のダイアログが、誰も見ることのできないデスクトップ上で開き、それを引き起こした自動化呼び出しは二度と戻ってきません。呼び出し元はやがてタイムアウトして死にますが、Excel のインスタンスはしばしばそうならず、ファイルロックを保持したまま孤児として生き延び、次回の実行を汚染します。サービスアカウントの下に 11 個もの野良の EXCEL.EXE プロセスが積み上がるのを見たことがある人なら、その先の話は知っているはずです

COM オートメーションで EXCEL.EXE を駆動する Delphi サービス(隠しダイアログと孤立プロセスが呼び出しをブロック)と、BIFF8 と OOXML ブックバイトをインプロセスで直接書く HotXLS の対比図
COM オートメーションはデスクトッププログラムの満たされない前提を引き継ぎます。一方 HotXLS は BIFF8 と OOXML のバイトを直接書き、サーバーにインストールすべきものは何もありません

何もクラッシュしなくても、スケーリングの話はましにはなりません。Excel のインスタンスは 1 ワークブックしか扱えないパイプラインであり、プロパティへのアクセスはすべてプロセス間 COM マーシャリングのコストを払い、コードを実行するマシンには、まさにこの用途を除外する条項を持つ Office ライセンスが乗っています。ほとんどのチームはこれらの限界に、障害が起きるたびに 1 つずつぶつかり、そうやって「COM 層を廃止する」がロードマップに載っていく、というのがだいたいの経緯です

その書き直しを始める前に、まずスコープの問題を 1 つ決着させてください。それが実際の作業量を左右するからです。COM コードがセルの値を設定するだけということはほとんどありません。それはフォーマット定数を伴って Workbook.SaveAs を呼び出し、再計算を強制し、印刷設定を押し込み、時にはクリップボードにも手を伸ばします。古いコードを歩いて、それらの振る舞いのうち実際に出力に反映されるものはどれかを書き出してください。それぞれがネイティブライブラリの異なる部分に着地するからであり、その中のいくつか(クリップボード連携が最も分かりやすい例です)にはサーバー側での意味がまったくないため、移植するのではなく捨てるべきです

2 つのネイティブエンジン、2 つの所有モデル

HotXLS は Excel プロセスを 2 つの直接的なフォーマット実装に置き換えます。BIFF8 レコードストリームエンジン(TXLSWorkbook、ユニット lxHandle)が .xls を扱います。OOXML パッケージライター(TXLSXWorkbook、ユニット lxHandleX)は ECMA-376/ISO/IEC 29500 に準拠した .xlsx を生成します。サーバー上に登録するものも、インストールするものも何もなく、メモリが許す限りいくつでもワークブックを同時に開いたままにできます

早い段階で人がつまずくのは、この 2 つのファサードがメモリを異なる方法で所有していることであり、その違いはクラッシュするまで静かなままです

var
  Book: IXLSWorkbook;          // インターフェース参照: 自動的に解放される
  Sheet: IXLSWorksheet;
  BookX: TXLSXWorkbook;        // 通常のオブジェクト: 自分で解放する
  SheetX: TXLSXWorksheet;
begin
  // BIFF8 .xls 出力 - Free は不要。インターフェースの参照カウントが所有する
  Book := TXLSWorkbook.Create;
  Sheet := Book.Sheets.Add;
  Sheet.Name := 'Report';
  Sheet.Cells.Item[1, 1].Value := 'Generated without Excel';
  Book.SaveAs('report.xls');

  // OOXML .xlsx 出力 - 明示的なライフタイム
  BookX := TXLSXWorkbook.Create;
  try
    SheetX := BookX.Sheets.Add('Report');
    SheetX.Cells[1, 1].Value := 'Generated without Excel';
    BookX.SaveAs('report.xlsx');
  finally
    BookX.Free;
  end;
end;

XLS ファサードは IXLSWorkbook インターフェースを通じて参照カウントされます。変数はインターフェース型として宣言し、それに対して Free を呼んではいけません。同じオブジェクトを通常のオブジェクト変数にも保持して自分で解放すると、参照カウントが 2 回目の解放を行ってしまいます。XLSX ファサードは通常のオブジェクトであり、通常の try..finally を必要とします。セルのアドレッシングはどちらの側も 1 始まりで、これがこの 2 つが一致する唯一の点です。シートコレクションはそうではありません。XLS 側の Entries は 1 始まりですが、XLSX の Items インデクサーは 0 始まりであり、このオフバイワンはどちらの方向に間違えてもきれいにコンパイルが通り、実行時になって初めて姿を現します

ワークブックを HTTP レスポンスへ直接書き込む

サーバー側のエクスポートには、たいていディスクに触れる理由がありません。一時ファイルはクリーンアップポリシーを要求し、同時リクエストの下で衝突し、誰も監査するつもりのなかったボリューム上に顧客データを残します。両方のファサードは SaveAs のオーバーロードを通じて TStream を受け取るため、ワークブックをそのままレスポンスに流し込めます

2 つの HotXLS Delphi ファサードの比較図。IXLSWorkbook インターフェース参照カウントで自動解放される TXLSWorkbook と、try..finally ブロック内の明示的な Free を要するプレーンオブジェクトの TXLSXWorkbook
XLS ファサードはインターフェースの参照カウントで解放されますが、XLSX ファサードは明示的な Free を必要とし、シートコレクションは 1 起点の Entries と 0 起点の Items で異なります
Mem := TMemoryStream.Create;
Book := TXLSXWorkbook.Create;
try
  Sheet := Book.Sheets.Add('Data');
  Sheet.Cells[1, 1].Value := 'Generated ' + DateTimeToStr(Now);
  Book.SaveAs(Mem);          // 現在のストリーム位置から書き込む
  Mem.Position := 0;         // ストリームを渡す前に巻き戻す
  Response.ContentType :=
    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
  Response.ContentStream := Mem;   // これ以降 Mem はフレームワークが所有する
finally
  Book.Free;
end;

この巻き戻しこそが、コメントに値する行です。SaveAs(Stream) はストリームの現在位置から書き込み、その後決して先頭にシークし直しません。Mem.Position := 0 を忘れると、クライアントはゼロバイトのダウンロードを受け取るか、Excel がそのファイルを壊れていると判断します。これは Web 向けワークブックコードで最もよくあるバグであり、最も残酷なバグでもあります。ストリームの長さがゼロでないことしか確認しないユニットテストをすり抜けてしまうからです

1 つのワークブック構築ルーチンが、構造を変えることなく他のすべての配信フォーマットに到達します。SaveAsCSV は「生データだけをください」というリクエストに応え、SaveAsHTML は「ポータルページに埋め込みたい」に対応し、SaveAsRTF は文書パイプラインに供給し、SaveAsODS は OpenDocument の要件をカバーします。すべてファイル版とストリーム版の両方のオーバーロードを持っています。1 つのエクスポートルーチンとフォーマットのパラメータが、かつて 4 つの別々の COM マクロだったものを置き換えます。HTML エクスポーターの TXLSXHtmlExportOptions はタイトル、CSS クラス、フラグメントか完全な文書かのスイッチを持っており、これによってポータル向けのケースでエクスポートされたマークアップを正規表現で編集するような作業から解放されます

Delphi リクエストハンドラーが HotXLS ブックを TMemoryStream へ保存し、Mem.Position をゼロへ巻き戻し、ストリームを HTTP レスポンスへ渡す様子の図。CSV、HTML、RTF、ODS エクスポーターを併記
TMemoryStream へ保存し、引き渡し前に巻き戻せば、ワークブックのバイトはクライアントへ直接送られます。1 つのエクスポートルーチンが CSV、HTML、RTF、ODS の各ライターをカバーします

計算する Excel プロセスなしで数式の値を得る

COM 自動化の下では、Excel がすべてを無料で再計算してくれていましたが、COM を捨てるとそれは静かに取り消されます。SaveAs は数式を評価せずにテキストとして保存し、その数値は Excel がファイルを開いて再計算したときにのみ現れます。この挙動は、XLS ファサードでは RecalcOnSaveCalculationMode を通じて調整できます。人間向けのファイルであれば、これはまさに正しい挙動です。しかし、出荷前に合計を確認しなければならないサービスにとっては誤りであり、結果ではなく数式のテキストを書き出す CSV エクスポートにとっても誤りです。どちらの場合も、組み込みのエンジンでサーバー側で評価しなければなりません

SheetX.Cells[1, 1].Value := 1200;
SheetX.Cells[2, 1].Value := 950;
SheetX.Cells[3, 1].Formula := 'SUM(A1:A2)';   // XLSX ファサード: 先頭に '=' は付けない
Total := BookX.Calculate('SUM(A1:A2)');       // ここでサーバー側で評価する
if Total <> 2150 then
  raise Exception.Create('reconciliation failed before delivery');

ファサードの慣習が、ここでも再び噛みついてきます。XLSX 側はイコール記号なしで Cell.Formula を通じて式を代入しますが、XLS 側は先頭に '=' を付けて Cell.Value を通じて書き込みます。片方から他方へコードをそのまま持ち込むと、誤った慣習が数式に似ているだけのテキスト文字列を保存してしまい、それを知らせるエラーは何もありません。ワークブックの数式が独自のビジネスロジックに手を伸ばす必要があるとき、OnUserFunction コールバックによって、評価時に未知の関数名をエンジンから Delphi コードへ渡すことができます。これは、COM 自動化システムが育ってきたまさにそのスプレッドシートの中に隠れがちな UDF アドインの、ネイティブな代替品です

サーバー上でだけ表面化するデプロイの落とし穴

いくつかの詳細が、展開作業がすっきり終わるか、それとも頭を悩ませるものになるかを左右します。最初の 1 つはユニットグラフです。ドラッグアンドドロップ式のデータセットエクスポーター TDataToXLS は VCL の FormsControlsDialogs を引き込みます。デスクトップツールでは無害ですが、コンソールサービスの中ではその背後に VCL 全体を引きずり込むことになります。コアユニットの lxHandlelxHandleXWindowsClassesSysUtilsVariants にしか手を伸ばさないため、純粋なサービスは、利便性のためにこのコンポーネントをインポートするよりも、コアの API に対して自前のデータセットループを書くほうがよい選択です

次はスレッディングです。ワークブックのインスタンスはスレッドセーフではありませんが、グローバルな状態も一切共有しないため、スケールするパターンは最もシンプルなもの、つまりジョブごと、あるいはワーカースレッドごとに 1 つのワークブックオブジェクトを持つというものです。これにより並列でのレポート生成が可能になり、これは共有された単一の Excel インスタンスでは決してできないことです。自分自身のワークブックを作成し、埋め、保存し、解放するリクエストハンドラは、いかなるロックも必要とせず、失敗の影響範囲は「共有された Excel インスタンスが全員にとって固まってしまう」から「この 1 件のリクエストが例外を送出した」まで縮小し、これは既存のエラーハンドリングがすでに対処法を知っているものです

最後はフォーマットのターゲティングです。TXLSWorkbook.SaveAs は既定で BIFF(xlExcel97)を書き込み、XLS のコンテンツを .xlsx に押し込む場合は SaveXLSWorkbookAsXLSX ブリッジを経由し、忠実度は下がります。いったんどちらか一方で構築してパイプラインの最後で変換するのではなく、設計時に、出荷しようとしているフォーマットに合わせてファサードを選んでください

典型的な置き換えプロジェクトのデータ読み込み側については、データベースからワークブックへのエクスポートパターンが、コンポーネントと手書きループの両方をカバーしています。行数が 6 桁に達したら、大規模ワークブックの性能に関するテクニックが、数分と数秒の違いを生みます。デザイナーが保守するレイアウトから構築されたレポートについては、テンプレートレポート生成の解説で扱っています

HotXLS は Delphi と C++Builder 向けの Object Pascal ソースとして提供されます。エディション、ライセンス、完全な API リファレンスは HotXLS Delphi Component の製品ページにあります