技术文章

Delphi 中 XLS、XLSX、ODS 与 CSV 的拉取式行游标

HotXLS 通过单个拉取式行游标 TXLSRowCursor 读取 .xls、.xlsx、.xlsm、.ods、CSV 与 TSV 源,其 FindFirstFindNext 每次推进一个逻辑行,且只有该行驻留内存。一个六值状态机把首行之前与 EOF、已取消、已故障区分开,旧的回调读取器如今只是同一游标之上的适配器

做过导入功能的人都熟悉这个场景。一个 200 MB 的 .xlsx 到了,你接上 OnCell 处理器,而“读出来”之后的第一个需求是“读到第一百条冲正过账就停”。此刻代码的形态开始跟你作对:循环住在库里,你的处理器得立个标志,之后的每次回调照样触发直到解析器注意到,而累积状态——目前命中几条、哪一列匹配、下一步做什么——只能放在一个类的字段里,这个类存在的唯一理由是给回调一个坐的地方。这里面没有一件事是解析问题。这是控制流问题,而拉取式游标正是把它拿掉的东西

200 MB 时推送回调的真实代价

推送反转控制,而过滤或联接型调用方恰恰承受不起这种反转。回调 API 下库拥有循环,于是调用方用不了 Break,无法交错两个源,无法把读取器交给一个等待被驱动的例程,不缓冲就表达不了“先偷看下一行再决定”。代价不在吞吐——写得好的 SAX 回调路径流得很顺——在于每个不平凡的消费者都自己长出一台小状态机,来模拟那台它不被允许写的循环。再乘以四种文件格式、每种历史上各有扫描入口,过滤、公式与错误语义就开始在它们之间漂移,而这正是 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);     // 只解码这两列
    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、SelectSheetClose 之后重新可写,于是一次扫描永远不可能混用两套解码契约。如果你只要清单而不要行,仅元数据与选择性工作表加载是更便宜的入口

每种格式一个后端,各自一条扫描循环

每种格式在 HotXLS 内恰好有一台前向扫描器,拉取式游标与回调读取器驱动的是同一台。TXLSXForwardRowBackend 是 ECMA-376 Part 1 §18.3 工作表部件唯一的工作表 SAX 状态机,持有 XML 读取器、共享公式表与富文本解析器,每次调用恰好推进到一个物理 <row> 边界。TXLSBiffForwardParser 掌管 [MS-XLS] 记录流的全局区、工作表选择与行推进;让它可暂停产生了整个设计中最尖锐的约束,因为一个缓存字符串公式是一条 Formula 记录紧跟一条 String 记录,按行挂起的挂点绝不能落在两条记录之间。TXLSForwardTextBackend 持有一个 BOM 感知的读取器、当前分隔符与一条逻辑记录——CSV 从首条记录嗅探逗号、分号、制表符或竖线且忽略引号内字符,多行带引号字段以 #10 拼接,于是行号跟踪逻辑记录而非物理换行。TXLSForwardOdsBackend 为 OpenDocument §9 表格持有单个物理行模板,把 table:number-rows-repeated 当作剩余计数而非展开,并越过被覆盖单元格而不产生值。流式直接读取器共享同一共享字符串与日期样式加载器

HotXLS 拉取式行游标分派到每种格式一台前向扫描器:XLSX 的 SAX 后端、BIFF 的记录解析器、嗅探分隔符的文本后端与 ODS 行模板,回调读取器作为适配器配置在其上
每种格式恰好有一台前向扫描器,拉取式游标与回调读取器驱动同一台扫描器,于是过滤与错误语义不可能漂移

为什么是六个状态而不是一个 Eof 标志?

因为单个布尔让四种不同情形不可区分,而调用方对它们全都猜错。TXLSRowCursorState 把它们显式命名

  • xrcsClosed——没有打开的源
  • xrcsBeforeFirst——已打开或已重定向,尚未读到任何行
  • xrcsActive——正站在一个有效行上
  • xrcsEof——工作表被消费到末尾
  • xrcsCancelled——调用方有意停止了本次扫描
  • xrcsFaulted——扫描失败,原始异常已被抛出

最后这个区分才是生产中要紧的。工作表部件缺失或扫描启动失败会保留其 EReadError 并把游标移入 xrcsFaulted;它绝不会被降级成调用方会读作“这张表是空的”的普通 FalseCancel 刻意比 Close 窄:它关闭当前工作表后端及其 inflate 子流并使当前行失效,但不释放 ZIP 包或源流,调用第二次是空操作。取消后想恢复须显式调用 SelectSheet——游标不会替你悄悄重启一次扫描。流所有权遵循同一条防御性规则:xsoBorrowed 是默认值,关闭时恢复流位置;xsoOwned 只在 Open 已成功后才转移所有权,于是打开失败永远不会释放调用方仍持有的流

HotXLS 行游标的六个状态及迁移:Cancel 把活动扫描移到已取消,扫描启动失败移到已故障,二者都与读到表尾保持可区分
六个命名状态让空表、有意停止与失败扫描可区分,单个 Eof 布尔做不到这一点
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;   // 只关闭工作表后端及其
            Break;           // inflate 子流;幂等
          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。这份契约要说实话——它是生命周期快速失败,不是线程安全保证,它不许可你在第一个线程推进游标时从第二个线程读行

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 一起读,后者统计实际开始了多少次工作表扫描。两条警告让这个数字保持诚实:它是估算而非精确堆核算,且自最近一次 Open 起单调不减,所以它是调试与回归仪表而非实时仪表。大簿上时间与字节去向的全景见Delphi 中的大工作簿性能

HotXLS 对比:整表加载让每一行常驻内存,拉取式游标只持有当前行加一份格式工作集,即 PeakRowBufferedBytes 累计并报告的内容
PeakRowBufferedBytes 累计当前输出行加格式相关工作集,于是内存跟踪一行有多宽,而不是表有多少行

推送读取器变成了适配器,以及游标不做什么

TXLSForwardReader 不再携带独立的 XLSX、BIFF 与文本扫描入口。它配置一个游标、走它、把当前行翻译成 OnSheetOnCell 事件,所以两个门面在过滤、公式状态或错误处理上再也不可能漂移。升级前有两条后果值得知道:回调 SheetIndex 现在在 TXLSForwardReader 上统一为 1 起(TXLSDirectReader 保留其既有的 0 起事件契约),且 OnSheet 先于 SelectSheet 触发,于是设置 SkipSheet 意味着该工作表部件根本不会被打开或解压。边界同样显式:扫描进行期间工作簿不可修改,取消后须显式重启,BIFF 前向路径绝不反编译公式记号,所以经典公式单元格报告 HasFormula 为 true 而 FormulaTextAvailable 为 false,递给你缓存结果而不是编造一个空公式字符串。该游标及其适配器通过了 Delphi Win32 与 Win64 外加 C++Builder 37.0 Win64 静态包上的 1,298 项检查

如果你在掂量拉取式游标与手头加载器的取舍,该问的不是谁解析得更快,而是谁让你写出你真正需要的退出条件。组件全部细节、支持的 IDE 版本与许可见 HotXLS Delphi 电子表格组件页