レポートジョブは1年間は問題なく実行されます。ワークブックを構築し、クエリが返すものをシートに入力して保存します。その後、5年分の履歴を持つ顧客が完全なエクスポートを求め、行数が100万を超えると、ファイルがディスクに到達するずっと前に、プロセスはメモリ不足エラーで強制終了します。コードに何も間違っている点はありませんでした。最後にシリアル化できるようにワークブック全体をRAMに保持していたため、必要となるメモリは、書き込むように要求された行数と歩調を合わせて増加しただけなのです
解決策はより大きなマシンにすることではありません。異なる書き込みモデルを採用することです。HotXLSのストリーミングダイレクトライターは、行が到着するたびにOOXMLパッケージをインクリメンタルに出力するため、使用するメモリは書き込む行数に依存しません。これはストリーミングリーダーの書き込み側の対応物です。リーダーがセルツリーを構築することなく巨大なシートを辿るのに対し、ライターもセルツリーを構築することなくシートを生成します
通常の保存パスがデータとともに増加する理由
通常のTXLSXWorkbookパスは、まず完全なオブジェクトモデルを構築します。値、型、スタイルの参照を持つ各セルは、保存を呼び出すまでメモリ内でオブジェクトとして生き続け、保存の時点ですべてのツリーがパッケージにシリアル化されます。シートを読み取り、編集し、再計算して、書き戻す場合、任意のセルへのランダムアクセスこそがまさに編集で必要とされるため、そのモデルは正しいと言えます。一方向にのみ行を流し込み、二度と振り返らない場合は間違っています。なぜなら、何の利益もなしにすべての行を常駐させるための代償を払うからです。100万行のオブジェクトは、再訪するかどうかにかかわらず100万行のオブジェクトなのです
ストリーミングライターはツリーを排除します。セルが書き込まれるとすぐにワークシート部分のバイトになり、それらのバイトはzip出力に渡されます。ワークシートのストリームは増加する唯一のバッファであり、それはヒープ上のライブなDelphiオブジェクトとしてではなく、出力側で増加します。常駐するのは固定量のアカウンティング(ブックキーピング)です。シート名、いくつかのフラグ、現在の行番号、セルカウンターです。このセットは、1行目から1,000万行目まで変わりません
共有文字列テーブルは罠であり、インライン文字列が抜け道となる
ほとんどのストリーミングXLSXライターは、テキストに出会うまではうまく機能します。OOXMLフォーマットは通常、文字列を共有文字列(Shared String)テーブルに保存します。異なる各文字列は別々のパーツに一度だけ書き込まれ、その文字列を保持するすべてのセルは、テキストの代わりにテーブルへのインデックスを持ちます。これは、繰り返されるラベルでいっぱいのファイルにとっては優れたスペース最適化であり、標準の保存パスが使用するデフォルトです。ストリーミングライターにとって、この問題は残酷です。重複を排除するには、テーブルはジョブ全体にわたって常駐していなければなりません。なぜなら、これから来るどの行もすでに書き込まれた行の文字列を繰り返す可能性があり、確認された文字列の完全なインメモリマップだけが正しいインデックスを割り当てることができるからです。したがって、ストリーミングライターがストリーミングできない唯一の構造は、ファイルを小さくするためのまさにその構造なのです。テキストが多いデータは、あなたが求めていたストリーミングを打ち負かしてしまいます
ダイレクトライターはこのテーブルを完全に回避します。文字列は、テキストが<is><t>要素とともにセルの内側に直接配置されるt="inlineStr"セルとして、インラインで書き込まれます。蓄積するテーブルや保持する確認済み文字列のマップがないため、テキストの列は数値の列と同じくらいメモリを消費しません。このトレードオフは明確であり、はっきりと述べておく価値があります。インライン文字列は、同じテキストがどこで発生してもそれを繰り返すため、多くの同一ラベルを持つファイルは、共有文字列の同等品よりもディスク上で大きくなります。ファイルサイズを費やして定数メモリを購入するのです。ワンパスのエクスポートにとっては、これはトレードオフの正しい側面であり、いずれにせよzip圧縮が出力時の繰り返しの大部分を吸収してくれます
スタイルテーブルは1つの日付フォーマットとともに最後に到着する
スタイルも文字列と同じ緊張感をもたらします。ワークブックはスタイル部分を通してそのフォーマットを参照しますが、ストリーミングライターは、すでにフラッシュされたセルと歩調を合わせて、増え続けるスタイルのパレットを維持することはできません。ダイレクトライターはこれに対し、スタイルテーブルを小さく固定し、最初ではなくクローズ時にそれを出力することで答えます。1つのデフォルトのセルフォーマットが通常のセルをカバーします。1つの日付数値フォーマットが日付をカバーし、セルフォーマットリストの既知の位置にyyyy-mm-ddのフォーマットコードで登録されます
その日付フォーマットこそが、WriteDateTimeが独自の呼び出しとして存在する理由です。Excelにはネイティブな日付型はありません。日付は日付フォーマットをまとった数値なのです。WriteDateTimeは値を単なるシリアル番号として書き込み、1つの日付スタイルでセルにタグ付けするため、スプレッドシートはそれを5桁の整数ではなく日付としてレンダリングします。それが書き込むシリアルは、ラウンドトリップにとって重要です。これはTDateTime値を1900年の日付システムの下に直接保存しますが、これは通常のTXLSXWorkbook保存パスが使用するのと同じ規約です。両方のパスがシリアルで合意しているため、ストリーミングライターが生成するファイルは、HotXLSリーダーを通して読み戻され、ライターとリーダーの間で「1ずれる」やエポックの驚き(epoch surprise)なしに、意図した通りの日付でExcelで開かれます
バイトはすでに失われているため、順序は必須である
ストリーミングは、尊重しなければならない1つのルールとともにそのメモリプロファイルを購入します。出力は進行するにつれて出力され、再訪することはできないため、すべてはファイルに表示される順序で書き込まれなければなりません。行内では、セルは列の昇順になります。シート内では、行は昇順になります。少し前に閉じた行はすでにzipストリーム内のバイトであり、もう到達できないため、事後的にライターにセルを並べ替えさせるバッファはありません。同じ行で列5を渡してから列2を渡すと出力は不正な形式になります。なぜなら、ライターは与えられたものを与えられた順番に単に出力するだけだからです
行APIには、一般的なケースのための小さな利便性があります。AddRowは1始まりの行インデックスを受け取りますが、0を渡すことは前の行の次の行を取得することを意味するため、シーケンシャルな塗りつぶしは増分カウンターを追跡して渡す必要はありません。各AddRowはその前の行を閉じ、各AddSheetはその前のシートを閉じるため、行やシートを明示的に終了することはありません。次を開始すると、ライターが開いている構造体を最終決定します
エスケープ処理はテキストがXMLに入る場所で処理される
書き込むテキストはすべてXMLドキュメントの一部になるため、5つの定義済みXMLエンティティをエスケープしなければ、値にアンパサンドや山括弧が含まれた瞬間にパッケージが無効になります。ライターは、呼び出し元が提供した文字がマークアップの内部に着地する2つの場所であるインライン文字列のテキストと数式のテキストの両方で、&、<、>、"、および'を自動的にエスケープします。生のWideStringを渡すと、ライターがそれを安全にします。Smith & Co <Ltd>のような製品名や、引用符で囲まれたシート名を参照する数式は、こちら側で何もエスケープすることなく、整形式のXMLとして出力されます
ライフサイクル、そしてなぜDestroyが依然としてクローズするのか
パッケージの仕上げとは、ワークブック部分、スタイル部分、コンテンツタイプと関係の部分、そして最後にzipの中央ディレクトリを書き込むことです。その作業はCloseで行われます。クローズされないパッケージは、どのスプレッドシートプログラムも開かない不完全なzipであるため、クローズはオプションのクリーンアップではなく、ファイルを有効にするためのステップです。エラーパスで忘れられたCloseを防ぐため、パッケージがまだ開いている場合、Destroyはベストエフォートのクローズを実行します。これにより、例外が明示的な呼び出しをスキップした場合でも、ライターを解放することで基礎となるzipオブジェクトがリークすることはありません。信頼できるパターンは依然として通常のDelphiのものです。つまり、tryの内部で書き込み、Closeを呼び出し、finallyで解放します
大きなシートを最初から最後までストリーミングする
ジョブの形は、開始、シートの追加、行の流し込み、終了です。以下の例では、ヘッダー行を書き込み、次に文字列、数値、キャッシュされた結果のない数式、および日付を混在させた、型付きデータ行の長い連続を書き込みます。各セルは書き込まれるとすぐにzipストリームに送られるため、10行と1,000万行で使用するメモリは同じです
uses
lxDirectWrite;
procedure StreamReport(const Path: string; RowCount: Integer);
var
W: TXLSDirectWriter;
I: Integer;
begin
W := TXLSDirectWriter.Create;
try
W.BeginFile(Path);
W.AddSheet('Sales');
// ヘッダー行、列の昇順で書き込まれる
W.AddRow(1);
W.WriteString(1, 'Item');
W.WriteString(2, 'Qty');
W.WriteString(3, 'Price');
W.WriteString(4, 'Total');
W.WriteString(5, 'Date');
// データ行。自動的に次の行に進むにはAddRowに0を渡す
for I := 1 to RowCount do
begin
W.AddRow(0);
W.WriteString(1, 'Item ' + IntToStr(I));
W.WriteNumber(2, I);
W.WriteNumber(3, 1.5 + (I mod 10));
W.WriteFormula(4, Format('B%d*C%d', [I + 1, I + 1]));
W.WriteDateTime(5, EncodeDate(2026, 1, 1) + I);
end;
W.Close; // パッケージを最終決定する
finally
W.Free;
end;
end;
2番目のシートは、続行する前に単にもう一度AddSheetを実行するだけであり、ライターは2番目を開く際に最初のシートを閉じます。ブール値のフラグはWriteBooleanを使用し、これは「True」というテキストではなく、型付けされたブール値セルを書き込みます。ファイルが健全でラウンドトリップすることを確認したい場合、CellCountプロパティは書き込まれたセルの数を報告し、ストリーミングリーダーで結果を読み戻せば同じ合計が報告されるはずです
// 上記のデータシートに続く、型付きフラグの2番目のシート
W.AddSheet('Flags');
W.AddRow(1);
W.WriteString(1, 'Name');
W.WriteString(2, 'Active');
W.AddRow(0);
W.WriteString(1, 'alpha');
W.WriteBoolean(2, True);
WriteLn(Format('wrote %d cells', [W.CellCount]));
ファイルの代わりにストリームに書き込む場合、BeginFileの代わりにBeginStreamを使用する同じコードになります。これにより、サーバーはディスク上のテンポラリファイルなしで、ワークブックをHTTP応答やメモリストリームに送信できます。ライターは渡されたストリームを所有しないため、そのライフサイクルはあなた自身が制御できます
仕事がオンデマンドでワークブックを構築するサーバーのエンドポイントである場合、サーバーおよびバッチジョブのストリーミング書き込みのパターンは、これをリクエストハンドラとスケジュールされたエクスポートに配線する方法を示しています。問題が非常に大きなワークブックの読み取りと書き込みの両方の広範なコストである場合、Delphiでの大規模ワークブックのパフォーマンスは、時間とメモリが実際にどこに使われるかをカバーしています。ストリーミングダイレクトライターは、このブログの他の場所で取り上げている完全な読み取り、編集、および保存のAPIとともに、DelphiおよびC++Builder向けのHotXLS Componentの一部として提供されています