100万行と十数個の列を持つスプレッドシートは、データベースのレポートジョブからのごく普通のエクスポートです。通常の方法、つまりワークブック全体をTXLSWorkbookに読み込む方法で開くと、ビジネスロジックの最初の1行が実行される前に、プロセスはそれら1,200万個のセルのそれぞれをライブオブジェクトとして実体化しなければなりません。ディスク上のファイルは、60メガバイトの圧縮されたXMLかもしれません。それが展開されるオブジェクトツリーはその数倍になり、モデルが設計上ランダムアクセスであるため、それらがすべて同時にメモリに常駐している必要があります。上から下まで読んで捨てるつもりのレポートにとって、それは決して必要としない構造に費やされる膨大な量のメモリです
同じファイルを通る2番目の経路があります。モデルを構築する代わりに、ワークシートのXMLを前方へのみ、一度に1セルずつスキャンし、各セルを見た後は過去に流します。何も蓄積されません。シートが1,000行であっても1,000万行であってもメモリはほぼ一定に保たれます。なぜなら、リーダーは現在解析している部分といくつかの小さなルックアップテーブル以上のものを保持しないからです。これがHotXLSのダイレクトリーダーが行うことであり、この記事の残りの部分では、なぜそれが小さく保たれるのか、そしてその代わりに何が得られるのかについて説明します
インメモリモデルがスケールしない理由
XLSXファイルは、ECMA-376によって記述されるXMLパーツのZIPパッケージです。各ワークシートはそれ自身のパーツ(xl/worksheets/sheetN.xml)であり、その中で各行は<c>のセル要素を保持する<row>要素です。通常のロードパスでは、そのパーツを読み取り、後でCells[12345, 7]を要求したときに定数時間で答えを得られるように、各セルに対してアドレス可能なオブジェクトを構築します。ランダムアクセスはワークブックモデルの最大の目的であり、編集、数式評価、そしてスタイル設定を便利にするまさにその理由です
その代償として、ランダムアクセスにはすべてが同時に存在している必要があります。部分的にしか構築していない構造にインデックスを付けることはできません。したがって、完全なロードのピークメモリはセル数の関数となり、何百万ものデータが入力されたセルを持つシートでは、その関数は、特に共有マシン上でそのようなジョブが複数同時に実行される場合、あなたのサービスが存在したくない場所に着地します。実際に必要なアクセスパターンがシーケンシャルである場合、ランダムアクセスへの支払いは、使用しない機能への支払いとなります
ツリーを構築しない、前方向のみのSAXスキャン
ダイレクトリーダーはZIPパッケージを開き、SAXスタイルのプルパーサーで各ワークシートのパーツを辿ります。ここでのSAXとは、パーサーが解析イベントに遭遇したとき(開始要素、テキストの実行、終了要素など)にそれを報告し、そして先に進むことを意味します。背後にノードツリーを保持することはありません。リーダーはr属性から現在の行と列を追跡し、イベントが到着するにつれてセルの型、スタイルインデックス、値、数式テキストを収集し、閉じタグの</c>が確認されると、1つのセルを出力してそれを忘れます。次のセルは、同じ一握りのローカル変数を再利用します
セル間に何も保持されないため、メモリフットプリントはセル数に比例して増加しません。これは維持する価値のある特性です。200行のシートと2,000万行のシートはリーダーに同じ常駐メモリのコストをかけ、その違いはスキャンが実行される時間の長さだけです。モデルの主要機能であるランダムアクセスを諦める代わりに、セル数では突破できないメモリの上限を手に入れることができます
何が常駐するのか、そしてなぜそれら2つのパーツなのか
スキャンは完全にステートレスというわけではなく、その例外は教訓的です。2つの小さなテーブルは期間中メモリに保持されなければなりません。なぜなら、セル単体では、それらなしで解釈するのに十分な情報を持たないからです
1つ目は共有文字列(Shared String)テーブルです。SpreadsheetMLでは、テキストセルは自身のテキストを保存しません。t="s"と数値のペイロードを持ち、それはワークブック内のすべての異なる文字列の単一の重複排除されたリストであるxl/sharedStrings.xmlへのインデックスです。これは、何千行にもわたって同じラベルが繰り返されるファイルにとっては優れたスペースのトレードオフですが、どのシートのどのセルでもその中の任意のエントリを参照する可能性があるため、リーダーはその文字列テーブルを事前に読み込んで常駐させる必要があります。テーブルのサイズはセル数ではなく、異なる文字列の数によって決まるため、巨大なシートでも控えめなサイズのままです
2つ目は、スタイル部分からの数値フォーマットのマッピングです。数値セルと日付セルは、ワイヤー上ではバイト単位で同じです。SpreadsheetMLでの日付は単なるシリアル日数であるため、どちらも単なる数値なのです。それらを区別する唯一のものはセルのスタイルであり、これはxl/styles.xmlのcellXfsを介して数値フォーマットIDを指し示します。日付を生のシリアル番号としてではなく日付として報告するために、リーダーはそのスタイルからフォーマットへのテーブルを読み込み、常駐させ続けます。ファイル内の他のすべてのもの、つまりバイトの大半を占める実際のセルデータは、保存されることなく通り過ぎていきます
すべてのセルが種類と値を報告する
出力された各セルは、TXLSDirectCellレコードとして到着します。これにはシートのインデックスと名前、1始まりの行と列、セマンティックな種類であるKind、VariantとしてのValue、先頭の等号がないFormulaのテキスト、および生のStyleIndexが含まれます。種類はxdkNumber、xdkString、xdkBoolean、xdkDate、またはxdkErrorのいずれかであるため、属性から再導出するのではなく、セルの意味に基づいて分岐できます。数式セルはキャッシュされた結果の種類を報告し、数式テキストも並べて表示されるため、計算された合計は数値として入ってくると同時に、それがどのように生成されたかも教えてくれます
type
TReportScan = class
procedure OnCell(Sender: TObject; const Cell: TXLSDirectCell;
var Abort: Boolean);
end;
procedure TReportScan.OnCell(Sender: TObject; const Cell: TXLSDirectCell;
var Abort: Boolean);
begin
case Cell.Kind of
xdkString: AccumulateLabel(Cell.Row, Cell.Col, VarToStr(Cell.Value));
xdkNumber: AddToTotals(Cell.Col, Double(Cell.Value));
xdkDate: NoteWhen(Cell.Row, VarToDateTime(Cell.Value));
xdkBoolean: FlagRow(Cell.Row, Boolean(Cell.Value));
xdkError: LogBadCell(Cell.Row, Cell.Col, VarToStr(Cell.Value));
end;
end;
数値から日付を見分ける
日付の問題は、ほとんどの素朴なスキャナーが間違える部分であるため、より詳しく見る価値があります。数値セルには日付型は存在しません。シリアル値46000を保持するセルは、数量、価格、あるいは2025年2月17日である可能性があり、ファイルはセルのスタイルを介して到達する数値フォーマットIDを通してのみ、どれであるかを教えてくれます。ECMA-376は、すべての準拠するプロデューサー間で意味が固定されている組み込みのフォーマットIDのブロックを予約しており、日付を持つIDは2つの範囲に位置します。標準の日付と時間のフォーマット用の14から22、および[h]:mm:ssのような経過時間のフォーマット用の45から47です。デフォルトでオンになっているDetectDatesが有効な場合、リーダーは各数値セルのスタイルをそのフォーマットIDに解決し、IDがそれらの予約された範囲に該当するセルは、ValueがすでにDelphiのTDateTimeに変換された状態でxdkDateとして報告されます。カスタムフォーマットも日付と時間のトークンのフォーマットコードを検査することでチェックされますが、予約された範囲は信頼できるバックボーンです。DetectDatesをオフにすると、スタイルテーブルは読み込まれず、すべての数値セルはxdkNumberとして扱われ、スキャンはわずかに軽くなります
シートのスキップと早期の中止
シーケンシャルなスキャンには、ランダムアクセスにはない静かな利点があります。それは、途中で止められるということです。各ワークシートが開かれる前にOnSheetイベントが発生し、2つのスイッチが提供されます。SkipSheetを設定すると、そのパーツ全体がまったく解析されません。これは、残りの部分を読み取るコストを支払うことなく、複数シートのワークブックで関心のあるシートだけをスキャンする方法です。Abortを設定すると、スキャン全体が直ちに終了します。OnCellイベントは自身のAbortを伴っているため、特定の行、センチネル値(番兵値)、ヘッダーブロックの終わりなど、探していたものが見つかった瞬間に、残りの何百万ものセルを読み取ることなく停止できます。前方向のみのスキャンでは、スキップする作業はまだ発生していない作業であるため、中止は純粋に無料です
procedure TReportScan.OnSheet(Sender: TObject; SheetIndex: Integer;
const SheetName: WideString; var SkipSheet: Boolean; var Abort: Boolean);
begin
// Scan only the "Data" sheet; leave the rest unread
SkipSheet := SheetName <> 'Data';
end;
ハンドラなしでのセルのカウント
最近の1つの改良は、一般的な質問を1回の安価な呼び出しに変えるため、言及する価値があります。リーダーは通過するすべてのデータが入力されたセルをカウントし、これはOnCellハンドラがアタッチされているかどうかに関係なく行われます。以前は、カウントは出力の副作用であったため、ハンドラが設定されていない場合、データ入力されたセルのカウントはゼロとして返されていました。現在では、カウントは出力から独立しています。つまり、「このワークブックには実際にいくつデータ入力されたセルが含まれているか」という1つの質問をし、コールバックをまったく伴わないスキャンのコストでその答えを得ることができるということです。ReadFileとReadStreamはどちらもその合計をInt64として返し、その後は同じ数値がCellCountプロパティとして利用可能です。-1が返された場合は、ファイルを開けなかったか、OOXMLパッケージではないことを示します
var
Reader: TXLSDirectReader;
Populated: Int64;
begin
Reader := TXLSDirectReader.Create;
try
// No OnCell handler: a pure populated-cell census, still near-constant memory
Populated := Reader.ReadFile('quarterly_export.xlsx');
if Populated < 0 then
raise Exception.Create('Not a readable XLSX package')
else
Writeln(Format('%d populated cells (CellCount = %d)',
[Populated, Reader.CellCount]));
finally
Reader.Free;
end;
end;
フルスキャンの場合、ハンドラをアタッチし、まったく同じ方法でReadFileを呼び出します。フルロードとの対比が最も重要なポイントです。quarterly_export.xlsxをワークブックにロードすると、すべてのセルが常駐オブジェクトに展開されてすべて保持されますが、ダイレクトリーダーは共有文字列とスタイルテーブルのみを保持し、1,200万個のセルが一度に1つずつあなたのOnCellを流れていきます。セルごとに実行された計算は何も残さないため、ピークメモリは行数ではなく、ワークブックの異なる文字列の数によって決まります
ダイレクトリーダーは、大きなワークブックを一度読み取って抽出し、あるいは要約することが仕事である場合の適切なツールです。代わりに完全なモデルのランダムアクセスが必要で、しかも大きなファイルでも機能させたい場合は、Delphiでの大規模ワークブックのパフォーマンスに関する私たちのメモにあるチューニングがそのパスをカバーしています。また、方向が逆になり、消費するのではなく大きな出力を生成する場合は、サーバーバッチジョブ用のストリーミング書き込みのチュートリアルが、同じ定数メモリの規律を書き込みに適用します。これら3つはすべて、このブログの他の場所で取り上げている読み取り、書き込み、数式、およびフォーマットのAPIとともに、DelphiおよびC++Builder向けのHotXLS Componentの一部として提供されています