技術記事

DelphiでXLS、XLSX、ODS、CSVを1つのプル行カーソルで読む

HotXLSは、.xls、.xlsx、.xlsm、.ods、CSV、TSVの各ソースを単一のプル行カーソルTXLSRowCursorを通じて読みます。そのFindFirstFindNextは1論理行ずつ前進し、その行だけがメモリに滞在します。6値の状態マシンが先頭前をEOF、キャンセル、故障から分離し、旧来のコールバックリーダは今や同じカーソル上のアダプタです

シナリオはインポート機能を出荷したことがある人には馴染みのものです。200 MBの.xlsxが届き、OnCellハンドラを配線し、「読め」の次の要件が「逆転仕分けの最初の100件で止まれ」になります。ここでコードの形が敵に回ります。ループはライブラリの中に住み、ハンドラはフラグを立てなければならず、パーサが気づくまでその後のコールバックは全部発火し続け、蓄積された状態——これまでのヒット数、どの列が一致したか、次に何をするか——は、コールバックの居場所を提供するためだけに存在するクラスのフィールドに住まなければなりません。そこにはパースの問題は何もありません。制御フローの問題であり、プルカーソルが取り除くのはまさにそれです

プッシュコールバックが200 MBで実際に払う代償

プッシュは制御を反転させ、反転こそフィルタや結合を行う呼び出し側が耐えられないものです。コールバックAPIではライブラリがループを所有するので、呼び出し側はBreakを使えず、2つのソースを交互に処理できず、駆動されることを期待するルーチンへリーダを渡せず、バッファリングなしに「決める前に次の行を覗く」を表現できません。代償はスループットではありません。よく書かれたSAXコールバックパスは十分にストリームします。問題は、自前のループを書くことを許されなかったループを模倣する小さな状態マシンを、自明でない消費者すべてが育ててしまうことです。それを4つのファイル形式——それぞれ歴史的に独自の走査エントリポイントを持つ——に掛け合わせると、フィルタ、数式、エラーの意味論が形式間で乖離し始めます。HotXLSが閉じようとしたのはまさにその乖離です

プルカーソルは呼び出しコードをどう変えるか

ループをあなたに返し、それとともに普通のPascal制御フローを返します。TXLSRowCursor.Openはファイル名かTStreamを受け取り、形式を検出し、共有文字列と日付スタイルのメタデータを一度にロードし、シート1を選びます。SelectSheet(1始まり)やSelectSheetByNameは別のワークシートへ再ターゲットし、カーソルを先頭前へリセットします。FindFirstFindNextは次の値が入った行に位置します。デコード可能なセルを持たない行は飛ばされるので、RowIndexは跳び得ます。現在行はCellCountCells[]ValueByCol[]として露出し、列軸ではすべて1始まりです。ループを抜けるのはBreakです

var
  Cursor: TXLSRowCursor;
  Hits: Integer;
begin
  Cursor := TXLSRowCursor.Create;
  try
    Cursor.FirstRow := 2;        // ヘッダ帯を飛ばす
    Cursor.IncludeColumn(1);     // この2列だけデコードする
    Cursor.IncludeColumn(7);
    if not Cursor.Open('postings-200mb.xlsx') then
      Exit;
    if not Cursor.SelectSheetByName('Ledger') then
      Exit;

    Hits := 0;
    if Cursor.FindFirst then
      repeat
        if VarToStr(Cursor.ValueByCol[7]) = 'REVERSED' then
        begin
          Inc(Hits);
          if Hits = 100 then
            Break;               // 普通の Break。中断フラグも番兵も不要
        end;
      until not Cursor.FindNext;
  finally
    Cursor.Free;                 // デストラクタがパスを終了させる
  end;
end;

射影と範囲はパスの前に設定し、後でフィルタするのではありません。FirstRowLastRowIncludeColumnClearColumnProjectionIncludeFormulaTextDetectDatesDetectTextTypesはすべてバックエンド内部で尊重され、選択されていない列はそもそも値、数式文字列、リッチテキストペイロードを割り当てません。回帰スイートは、16 KiBの数式とキャッシュ文字列が、列が射影されていなければ決して実体化しないことでこれを証明します。これらのオプションはパスがアクティブな間は意図的に凍結され、EOF時、SelectSheet時、Close後に再び書き込み可能になります。1つの走査が2つのデコード契約を混ぜることは決してありません。行ではなくシート一覧だけが必要なら、メタデータのみと選択的シートロードがより安い入口です

形式ごとに1つのバックエンド、それぞれ1つの走査ループ

HotXLS内部ではすべての形式がちょうど1つの前方スキャナを持ち、プルカーソルもコールバックリーダもその同じスキャナを駆動します。TXLSXForwardRowBackendはECMA-376 Part 1 §18.3のシートパートに対する唯一のワークシートSAX状態マシンで、XMLリーダ、共有数式テーブル、リッチテキストパーサを保持し、呼び出しごとにちょうど1つの物理<row>境界へ前進します。TXLSBiffForwardParserは[MS-XLS]レコードストリームのグローバル、シート選択、行前進を所有します。これを一時停止可能にしたことが設計全体で最も鋭い制約を生みました。キャッシュされた文字列数式はFormulaレコードの直後にStringレコードが続く形なので、行ごとの停止点は決して2つの間に着地してはならないのです。TXLSForwardTextBackendはBOM対応リーダ、アクティブな区切り文字、1つの論理レコードを保持します。CSVは引用文字を無視しながら最初のレコードからカンマ、セミコロン、タブ、パイプを判別し、複数行の引用フィールドは#10で結合されるので、行番号は物理改行ではなく論理レコードを追跡します。TXLSForwardOdsBackendはOpenDocument §9テーブル用の単一の物理行テンプレートを保持し、table:number-rows-repeatedを展開ではなく残りカウントとして扱い、値を出力せずにカバー済みセルを通過します。ストリーミング直接リーダは同じ共有文字列と日付スタイルのローダを共有します

形式ごとに1つの前方スキャナへディスパッチするHotXLSのプル行カーソル。XLSX用SAXバックエンド、BIFF用レコードパーサ、区切り文字を判別するテキストバックエンド、ODS行テンプレート、そしてアダプタとしてその上に構成されたコールバックリーダ
すべての形式にちょうど1つの前方スキャナがあり、プルカーソルもコールバックリーダもその同じスキャナを駆動します。だからフィルタとエラーの意味論は乖離できません

1つのEofフラグではなく6つの状態なのはなぜか

単一のbooleanは4つの異なる状況を区別できなくなり、呼び出し側はそのすべてについて間違って推測するからです。TXLSRowCursorStateはそれらを明示的に名指します

  • xrcsClosed — 開かれたソースがない
  • xrcsBeforeFirst — 開いたか再ターゲットした直後で、まだ行を読んでいない
  • xrcsActive — 有効な行の上に立っている
  • xrcsEof — シートを終端まで消費した
  • xrcsCancelled — 呼び出し側がパスを意図的に止めた
  • xrcsFaulted — パスが失敗し、元の例外が投げられた

本番で効いてくるのは最後の区別です。欠落したワークシートパートや失敗したパス開始は自分のEReadErrorを保持し、カーソルをxrcsFaultedへ動かします。「このシートは空だった」と読める素のFalseへ格下げされることは決してありません。Cancelは意図的にCloseより狭いです。現在のワークシートバックエンドとそのinflateサブストリームを閉じて現在行を無効化しますが、ZIPアーカイブやソースストリームは解放せず、2回呼んでもノーオペレーションです。キャンセル後はSelectSheetを明示的に呼んで再開します。カーソルがあなたの代わりに静かにパスを再開することはありません。ストリーム所有権も同じ防御規則に従います。xsoBorrowedがデフォルトで、クローズ時にストリーム位置を復元し、xsoOwnedOpenがすでに成功した後にのみ所有権を移します。失敗したオープンが呼び出し側の保持するストリームを解放することは決してありません

遷移付きのHotXLS行カーソルの6状態。アクティブなパスをCancelがcancelledへ動かし、失敗したパス開始がfaultedへ動かし、両者がシート終端と区別され続けるようす
6つの名前付き状態が、空のシート、意図的な停止、失敗したパスを区別可能に保ちます。単一のEof booleanにはできないことです
var
  Cursor: TXLSRowCursor;
  Src: TFileStream;
begin
  Src := TFileStream.Create('quarter.ods', fmOpenRead or fmShareDenyWrite);
  try
    Cursor := TXLSRowCursor.Create;
    try
      // xsoBorrowed: カーソルは Src を決して解放せず、Close は
      // Open 時点のストリーム位置を復元する
      if not Cursor.Open(Src, xffAuto, xsoBorrowed) then
        Exit;

      if Cursor.FindFirst then
        repeat
          if UserPressedStop then
          begin
            Cursor.Cancel;   // ワークシートバックエンドとその inflate
            Break;           // サブストリームだけを閉じる。冪等
          end;
        until not Cursor.FindNext;

      case Cursor.State of
        xrcsEof:       Log('sheet consumed to the end');
        xrcsCancelled: Log('stopped by the operator');
        xrcsFaulted:   Log('pass failed; the EReadError was already raised');
      end;
    finally
      Cursor.Free;
    end;
  finally
    Src.Free;                // 依然こちらのもの、依然有効、位置も復元済み
  end;
end;

現在行をコピーせずに借りる

IXLSRowCursorViewはセル配列を複製せずに行を別のルーチンへ渡します。ビューはカーソルポインタとUInt64世代カウンタを保持する共有ガードを持ちます。前進、シート選択、キャンセル、クローズ、カーソルの破棄はすべてその世代をインクリメントし、破棄はさらにガードのオーナーをクリアします。したがって古くなったビューは解放済みメモリを読めません。Validはいつでも呼べる例外なしのプローブであり、他のすべてのメンバーは先に検証してEXLSRowCursorViewInvalidatedを投げます。この契約が何であるかは正直に言ってください。ライフタイムのフェイルファストであって、スレッド安全性の保証ではありません。1番目のスレッドがカーソルを前進させている間に2番目のスレッドから行を読む許可にもなりません

var
  View: IXLSRowCursorView;
  Cell: TXLSRowCursorCell;
  I: Integer;
begin
  if Cursor.FindFirst then
    repeat
      View := Cursor.CurrentRowView;      // 借りる。セル配列はコピーされない
      for I := 0 to View.CellCount - 1 do
      begin
        Cell := View.Cells[I];
        if Cell.HasFormula and not Cell.FormulaTextAvailable then
          UseCachedResult(Cell.Value)     // BIFF フォワード読み取りはトークンではなく
        else if Cell.Kind = xdkEmpty then //   キャッシュ結果を保持する
          UseStyleOnly(Cell.StyleIndex)   // Blank / MulBlank も実セル
        else
          UseValue(Cell.Col, Cell.Value);
      end;
    until not Cursor.FindNext;

  // インターフェースはループより長生きするが、裏の行はしない
  if not View.Valid then    // Valid は決して投げない。今 Cells[] を呼べば
    View := nil;            // EXLSRowCursorViewInvalidated が投げられる
end;

PeakRowBufferedBytesと、それが証明することを許されたもの

PeakRowBufferedBytesは、メモリが行数ではなく行幅を追跡することを示すために存在します。現在の出力行のセルレコード、Variant、数式文字列、リッチテキストペイロードを累積し、形式固有のワーキングセット——CSVの論理レコード、ODSの物理行テンプレート、BIFFのレコードピーク、デコード中のXLSX生セル——を折り込みます。実際に開始されたワークシートパスの数を数えるSheetPassesStartedとともに読んでください。これを正直に保つ2つの留保があります。数値は正確なヒープ会計ではなく推定であること、そして最新のOpen以降で単調であること。したがってライブゲージではなくデバッグと回帰の計器です。非常に大きなブックで時間とバイトがどこへ向かうかのより広い展望は、Delphiでの巨大ブックのパフォーマンスを参照してください

全シートロードがすべての行を常駐させるのに対し、プルカーソルが現在行と1つの形式ワーキングセットだけを保持するHotXLSの比較。PeakRowBufferedBytesが累積して報告するもの
PeakRowBufferedBytesは現在の出力行と形式固有のワーキングセットを累積します。メモリはシートの行数ではなく行の幅を追跡するということです

プッシュリーダはアダプタになった。そしてカーソルがしないこと

TXLSForwardReaderはもはやXLSX、BIFF、テキストの個別の走査エントリポイントを持ちません。カーソルを構成し、それを歩き、現在行をOnSheetOnCellイベントへ翻訳します。だから2つのファサードがフィルタ、数式状態、エラー処理で乖離することはもうありません。アップグレード前に知っておくべき帰結が2つあります。コールバックのSheetIndexTXLSForwardReader上で均一に1始まりになりました(TXLSDirectReaderは既存の0始まりイベント契約を保ちます)。そしてOnSheetSelectSheetの前に発火するので、SkipSheetを設定するとワークシートパートはそもそも開かれず展開もされません。境界も同じく明示です。パスがアクティブな間にブックを変更してはならず、キャンセルからの再開は明示的な再起動を要求し、BIFFフォワードパスは数式トークンを決して逆コンパイルしないので、クラシックな数式セルはHasFormulaが真でFormulaTextAvailableが偽と報告し、空の数式文字列をでっち上げる代わりにキャッシュ結果を渡します。行カーソルとそのアダプタは、Delphi Win32とWin64に加えてC++Builder 37.0 Win64静的パッケージ上で1,298検査に通りました

今持っているローダとプルカーソルを比較しているなら、問うべき問いはどちらが速くパースするかではなく、どちらが実際に必要な終了条件を書かせるかです。コンポーネントの詳細、対応IDEバージョン、ライセンスはHotXLS Delphi spreadsheet component pageにあります