技术文章

在 Delphi 中用 HotXLS GetSheetNames 快速列出工作表名

有时候,一个接收例程需要回答的唯一问题只是结构性的:这份工作簿里有没有一张叫 "Mapping" 的工作表,或者它一共带了多少个标签页。用 Open 来回答这个问题是代价最高的做法。一次完整打开会展开共享字符串表、解码每一条样式记录,并遍历每张工作表的单元格,因为它无从知道你只想要一份目录。在一个大文件上,这意味着数百兆字节的分配和好几秒的 CPU 时间,只为读出一份仅占几千字节的清单。HotXLS 是 losLab 出品的原生 Delphi 电子表格库,它能单独把这份清单交给你:GetSheetNames 按工作簿顺序返回工作表名,而不实体化任何一个单元格

为什么读目录如此便宜

两种电子表格格式都把自己的目录放在文件靠前的位置,正是这一点让列出工作表这件事快得理所当然,而不是靠什么技巧。OOXML 包把工作表目录放在 xl/workbook.xml 里,无论工作簿装的是十行还是一千万行,这个部件都一样小。BIFF8 的 .xls 则把 BoundSheet 记录存在工作簿全局流的开头,位于任何单元格数据之前。所以列出调用省下的工作并不是可以忽略的零头,而是文件的绝大部分。读目录的代价始终是那几千字节,与行数无关,而完整打开的代价随数据量增长;在一个数兆字节的工作簿上,两者在触碰的字节数和分配的内存上都能差出好几个数量级

Delphi 中 HotXLS GetSheetNames 只读取 XLSX 或 XLS 文件的工作表目录,而完整打开会遍历每一个单元格
目录位于 workbook.xml 或 BoundSheet 记录中,因此列出工作表只需几千字节,而完整打开的代价随数据量增长

这种恒定的代价才是值得围绕着做设计的性质。建立在 GetSheetNames 之上的接收关卡,对一个 200 行的文件和一个 200 MB 的文件表现完全一样,于是一批文件里最慢的那个,不再决定"这个文件值不值得处理"这一判断的节奏

一个调用覆盖 .xls、.xlsx 与模板格式

在 XLS 门面上,TXLSWorkbook.GetSheetNames 读的不止 .xls。它同样接受基于 zip 的 .xlsx、.xlsm、.xltx 和 .xltm,只从压缩包里取出 workbook.xml。对于真正的 .xls 输入,它扫描 BoundSheet 记录,并在全局子流的第一条 EOF 记录处停下,所以一个很大的二进制文件也只花掉开头那几千字节。XLSX 门面还带着一项保证,它对长期运行的服务代码的意义比乍看之下更大:TXLSXWorkbook.GetSheetNames 既不重置也不填充工作簿实例,因此一个已经打开着文档的实例可以去探查别的文件,而不打扰手上这一份。GetODSSheetNames 对 OpenDocument 包采用同样的做法,而且这些调用每一个都有流重载,让你可以检视一份从未落盘的上传文件

var
  Book: TXLSXWorkbook;
  Names: TStringList;
  I: Integer;
begin
  Names := TStringList.Create;
  Book := TXLSXWorkbook.Create;
  try
    if Book.GetSheetNames('upload-7f3a.xlsx', Names) <= 0 then
      raise Exception.Create('unreadable workbook package');
    if Names.IndexOf('Mapping') < 0 then
      raise Exception.Create('required Mapping sheet is missing');
    for I := 0 to Names.Count - 1 do
      Writeln(Format('sheet %d: %s', [I, Names[I]]));
  finally
    Book.Free;
    Names.Free;
  end;
end;

同一个调用也能做出一个体面的桌面导入对话框。先列出工作表,让用户选一张,等选择做出之后再为完整打开付出代价。在一个五十张表的工作簿上,差别肉眼可见:一个立刻出现的选择器,对比一个在整份文件加载完之前一直卡住的选择器

启用宏的 .xlsm 文件和模板格式,列出方式与普通 .xlsx 完全一样,因为无论包里有没有捎带一份 vbaProject.bin,目录都坐在同一个 workbook.xml 中。因此接收流水线可以为了路由而枚举一个宏工作簿的工作表,全程不碰宏负载,也不做任何会让它运行起来的事,把宏策略的判断留给真正打开文件的那一环

读懂返回值,不要自欺

HotXLS 各处的返回约定并不统一。有些调用成功时返回 1,另一些返回一个计数,所以对这些列出函数而言,唯一站得住脚的检查是把任何小于等于零的值都当作失败,同时字符串列表被清空。要抵住把空列表读成"一份没有工作表的工作簿"的诱惑。ECMA-376 和 BIFF8 规范都要求一份有效的工作簿至少有一张工作表,所以零个名字永远意味着读取失败,而绝不意味着这份文件合法地为空

列出失败本身也是一个值得留存的信号。一个让调用失败的 .xlsx 文件,只可能是这么几种情况之一:被截断、根本不是 OOXML 包(来自其他系统、被贴错扩展名的 CSV 导出在这里层出不穷),或者是一个加密容器。把它们区分开是下一道检查的任务。把被拒文件的头几个字节和失败一起记进日志,通常能把一整条支持工单缩成一条消息

在路由之前识别加密容器

一个加密的 .xlsx 不是 zip。它是一个 OLE 复合文件,里面包着 EncryptionInfoEncryptedPackage 流,所以 GetSheetNames 看不进去,会像面对任何其他不可读文件一样返回失败。CanReadEncrypted 检测的正是这种容器形状,它让接收环节能够有意识地路由一个加密文件,而不是从某个工作进程深处吞下一个笼统的读取错误:

Delphi 接收分流流程:HotXLS CanReadEncrypted 与 GetSheetNames 把上传文件路由到需要密码、不可读或正常三条路径
CanReadEncrypted 先跑,因为一个加密的 OOXML 文件是列出调用看不进去的 OLE 容器
type
  TIntakeRoute = (irNormal, irNeedsPassword, irUnreadable);

function ClassifyUpload(const FileName: string; Names: TStrings): TIntakeRoute;
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    // 加密的 OOXML 是 OLE 容器而不是 zip:先检查它,
    // 因为列出调用无法看到它的内部
    if Book.CanReadEncrypted(FileName) then
      Exit(irNeedsPassword);
    if SameText(ExtractFileExt(FileName), '.ods') then
    begin
      if Book.GetODSSheetNames(FileName, Names) <= 0 then
        Exit(irUnreadable);
    end
    else if Book.GetSheetNames(FileName, Names) <= 0 then
      Exit(irUnreadable);
    Result := irNormal;
  finally
    Book.Free;
  end;
end;

加密正是 HotXLS 刻意不对称的地方,所以路由必须尊重这一点。老式 .xls 加密(RC4、RC4 CryptoAPI、XOR)是可读的:TXLSWorkbook.Open(FileName, Password) 用一个存好的密码解密,这些文件可以留在自动化路径上。加密的 OOXML 包则相反。HotXLS 能用 SaveAsEncrypted 写出一个,却读不回来。OpenEncrypted 在拿到一个加密包时会抛出 EXlsxEncryptionNotImplemented,这就是为什么一个诚实的接收设计会把加密的 .xlsx 交给一个有 Excel 的人,而把带密码的 .xls 留在代码里处理

对批量作业来说,这个分类器的价值在于:它可以在任何工作进程开始真正处理之前,先跑遍整个来件目录,因为每一次探测的代价大约只是打开一次文件加上几千字节的读取。把它提前,改变的是运维真正在意的那种失败形态。你得到的不再是凌晨三点的作业死在 600 个文件中的第 412 个上,而是 412 个文件排进队列、5 个在接收环节被拒并各自附上原因。同样的库调用,运维故事好得多

列出调用回答不了的问题

名字和顺序就是你能拿到的全部。这些列出调用对可见性只字不提,所以隐藏和极深隐藏的工作表出现在列表里时,看上去和别的没有两样。它们不报告已用区域的尺寸、不报告单元格数量,也不报告文档属性。docProps/core.xml 这个部件同样很小,但今天还没有一个只读属性的探测接口,所以作者和标题元数据仍然要付出一次完整 Open 的代价。与之共处的干净办法,是让便宜的事实去路由每一个文件,把昂贵的留给通过路由的那些。对于确实要进入深度读取的文件,把 _DisableGraphics := True 打开后,对一个大 .xls 的只读扫描会明显更快,因为它跳过了 OfficeArt 解析。只是千万别从那个实例保存:它跳过的绘图层已经不在模型里了,保存会把它从文件中丢掉

通过分流的文件通常会进入更深的分析。工作簿审计与转换工作台介绍了在一次完整打开已被证明值得之后,值得采集的逐表计数器,而大型工作簿性能指南讲的是如何让那次完整打开保持快速

HotXLS 是面向 Delphi 和 C++Builder 的原生 Object Pascal 电子表格库;完整的 API 面,包括这里展示的检视调用,都记录在 HotXLS Delphi Component 产品页