技术文章

在 Delphi 中免 Office 自动化生成 Excel 文件

如果一台服务器的唯一职责是产出 Excel 文件,它就不该去运行 Excel。在构建代理或报表服务上装 Office,再通过 COM 自动化驱动它,是错误的设计,而且从这种做法存在之日起就一直是错误的。微软自己就是这么说的,这条指引二十年来没有松口:Office 既不是为无人值守的服务端进程自动化而构建的,授权上也不允许。正确的答案是直接写出 BIFF 与 OOXML 字节,画面里根本不出现 Excel。这正是 HotXLS 的全部前提 —— 一个自己读写电子表格格式的原生 Object Pascal 库,因此没有哪个桌面应用会挂起、泄漏,或者按席位收费

为什么从服务里驱动 EXCEL.EXE 会失败

COM 自动化是在远程操控一个桌面程序,而桌面程序悄悄假定了三件 Windows 服务给不了它的东西:一份已加载的用户配置、一个交互式窗口站,以及一个盯着屏幕的人。把这三样拿掉,故障就以任何开发机都复现不出的形态到来。一个文件恢复提示、一个加载项错误,或者一个授权激活对话框,在没人看得见的桌面上弹了出来,而触发它的那次自动化调用永远不返回。调用方最终超时死掉;Excel 实例却常常不死,作为孤儿进程活着,攥着文件锁,毒害下一次运行。凡是见过十一个游离的 EXCEL.EXE 进程堆在一个服务账号下的人,都知道后面的故事

示意图:对比一个通过 COM 自动化驱动 EXCEL.EXE 的 Delphi 服务(隐藏对话框与孤儿进程会卡住调用)与 HotXLS 在进程内直接写出 BIFF8 与 OOXML 工作簿字节
COM 自动化继承了桌面程序那些缺失的假定,而 HotXLS 直接写出 BIFF8 与 OOXML 字节,服务器上什么都不用装

即便什么都没崩,扩展性的故事也好不到哪里去。一个 Excel 实例是一条单工作簿流水线,每一次属性访问都要付跨进程 COM 编组的代价,而跑这份代码的机器还背着一份条款明确排除这种用途的 Office 授权。多数团队是一次事故接一次事故地撞上这些限制,而“下线 COM 层”大致就是这样进了路线图

在那次重写开始之前,先定下一个范围问题,因为它决定了真正的工作量有多大。COM 代码几乎从不只是设置单元格的值。它带着格式常量调用 Workbook.SaveAs,强制重算,推送打印设置,有时还去够剪贴板。把老代码走一遍,记下这些行为里究竟哪些真的体现在了输出中,因为每一项都落在原生库的不同角落,而其中有几项(剪贴板互操作是最明显的一个)在服务端根本没有意义,应该弃掉而不是移植

两个原生引擎,两种所有权模型

HotXLS 用两份直接的格式实现换掉了 Excel 进程。一个 BIFF8 记录流引擎(TXLSWorkbooklxHandle 单元)负责 .xls。一个 OOXML 包写入器(TXLSXWorkbooklxHandleX 单元)产出符合 ECMA-376 / ISO/IEC 29500 的 .xlsx。服务器上没有什么要注册,也没有什么要安装,而且内存允许的话你想同时开多少个工作簿都行

示意图:对比 HotXLS 的两套 Delphi 门面,TXLSWorkbook 通过 IXLSWorkbook 接口引用计数自动释放,TXLSXWorkbook 则是需要在 try..finally 中显式 Free 的普通对象
XLS 门面由接口引用计数释放,XLSX 门面需要显式 Free,而两者的工作表集合还分别是从 1 起的 Entries 和从 0 起的 Items

早期最容易把人绊倒的,是这两套门面对内存的所有权方式不同,而这个差别在崩溃之前都是无声的:

var
  Book: IXLSWorkbook;          // 接口引用:自动释放
  Sheet: IXLSWorksheet;
  BookX: TXLSXWorkbook;        // 普通对象:由你释放
  SheetX: TXLSXWorksheet;
begin
  // BIFF8 .xls 输出 - 不要 Free;接口引用计数拥有它
  Book := TXLSWorkbook.Create;
  Sheet := Book.Sheets.Add;
  Sheet.Name := 'Report';
  Sheet.Cells.Item[1, 1].Value := 'Generated without Excel';
  Book.SaveAs('report.xls');

  // OOXML .xlsx 输出 - 显式生命周期
  BookX := TXLSXWorkbook.Create;
  try
    SheetX := BookX.Sheets.Add('Report');
    SheetX.Cells[1, 1].Value := 'Generated without Excel';
    BookX.SaveAs('report.xlsx');
  finally
    BookX.Free;
  end;
end;

XLS 门面通过 IXLSWorkbook 接口做引用计数。把变量声明为接口类型,永远别对它调 Free;若把同一个对象再拿一个普通对象变量持有并自己释放,引用计数还会再释放一次。XLSX 门面则是一个普通对象,它要的是普通的 try..finally。单元格寻址两边都从 1 起,这是两者唯一一致的地方。工作表集合就不一致了:XLS 一侧的 Entries 从 1 起,XLSX 的 Items 索引器从 0 起,而这处差一无论你错在哪一边都能干净编译,只在运行时才现形

把工作簿直接写进 HTTP 响应

服务端导出通常没有理由碰磁盘。临时文件需要清理策略,在并发请求下会撞车,还会把客户数据留在没人想起要审计的卷上。两套门面的 SaveAs 重载都接受 TStream,因此工作簿可以直接进响应:

Mem := TMemoryStream.Create;
Book := TXLSXWorkbook.Create;
try
  Sheet := Book.Sheets.Add('Data');
  Sheet.Cells[1, 1].Value := 'Generated ' + DateTimeToStr(Now);
  Book.SaveAs(Mem);          // 从流的当前位置开始写
  Mem.Position := 0;         // 交出流之前先回绕
  Response.ContentType :=
    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
  Response.ContentStream := Mem;   // 现在由框架拥有 Mem
finally
  Book.Free;
end;

那次回绕正是配得上它那句注释的一行。SaveAs(Stream) 从流的当前位置开始写,写完之后绝不回退到零。忘掉 Mem.Position := 0,客户端拿到的就是一份零字节下载,或者 Excel 说文件已损坏。这是面向 Web 的工作簿代码里最常见的 bug,也是最残忍的一个,因为它能顺利溜过任何只断言流长度非零的单元测试

一个构建工作簿的例程,不用重构就能通向其他所有投递格式。SaveAsCSV 回应“把原始数据给我就行”,SaveAsHTML 应付“把它塞进门户页面”,SaveAsRTF 喂给文档流水线,而 SaveAsODS 满足 OpenDocument 的强制要求,四者都同时有文件和流的重载。一个导出例程加一个格式参数,取代了过去往往是四段独立的 COM 宏。HTML 导出器的 TXLSXHtmlExportOptions 带有标题、CSS 类,以及“片段还是完整文档”的开关,这让门户那种场景不必再干用正则去改导出标记的活

示意图:一个 Delphi 请求处理器把 HotXLS 工作簿保存进 TMemoryStream,把 Mem.Position 回绕到零,再把流交给 HTTP 响应,旁边还有 CSV、HTML、RTF 和 ODS 导出器
先存进 TMemoryStream 再在交接前回绕,就能把工作簿字节直送客户端,而一个导出例程覆盖了 CSV、HTML、RTF 与 ODS 四个写入器

没有 Excel 进程来计算,公式值怎么办

在 COM 自动化下,Excel 免费替你重算了一切,而丢掉 COM 也就悄悄收回了这项福利。SaveAs 把公式作为文本存下,并不求值;数字要等 Excel 打开文件并重算之后才出现,XLS 门面允许你通过 RecalcOnSaveCalculationMode 调整这一行为。对一份要交给人看的文件,这完全正确。对一个必须在发出前确认合计的服务就不对了,对 CSV 导出也不对 —— 后者写出的是公式文本而不是它的结果。这两种情形都必须用内置引擎在服务器上求值:

SheetX.Cells[1, 1].Value := 1200;
SheetX.Cells[2, 1].Value := 950;
SheetX.Cells[3, 1].Formula := 'SUM(A1:A2)';   // XLSX 门面:不带 '=' 前缀
Total := BookX.Calculate('SUM(A1:A2)');       // 就地在服务器上求值
if Total <> 2150 then
  raise Exception.Create('reconciliation failed before delivery');

门面的约定在这里又咬了一口。XLSX 一侧通过 Cell.Formula 赋表达式,不带等号;XLS 一侧则通过 Cell.Value 写,并带一个前导的 '='。把代码原封不动地从一边搬到另一边,错误的约定就会存下一个仅仅长得像公式的文本字符串,且没有任何错误来标记它。当一个工作簿的公式需要伸进你自己的业务逻辑时,OnUserFunction 回调能让引擎在求值时把未知的函数名交给 Delphi 代码。这正是那些 UDF 加载项的原生替身,而这类加载项往往就藏在 COM 自动化系统赖以成长的那些电子表格里

只在服务器上才现形的部署边角

有几个细节决定了这次上线是干净利落还是一头雾水,头一个是单元依赖图。拖放式的数据集导出器 TDataToXLS 会引入 VCL 的 FormsControlsDialogs。在桌面工具里无伤大雅;在控制台服务里,它会把整个 VCL 拖在身后一起带进来。核心单元 lxHandlelxHandleX 只够到 WindowsClassesSysUtilsVariants,因此一个纯服务与其为图省事引入那个组件,不如自己照着核心 API 写数据集循环

接下来是线程。工作簿实例不是线程安全的,但它们也不共享任何全局状态,所以能扩展的模式就是最简单的那个:一个任务一个工作簿对象,或者一个工作线程一个。这换来的是并行报表生成,而单个共享的 Excel 实例永远做不到。一个自己创建、填充、保存并释放工作簿的请求处理器根本不需要锁,而一次失败的波及半径也从“共享的 Excel 实例把所有人都卡住了”缩小到“这一个请求抛了个异常”,后者你现有的错误处理已经知道该怎么办

最后一项是格式目标。TXLSWorkbook.SaveAs 默认写 BIFF(xlExcel97),而把 XLS 内容推成 .xlsx 要经过 SaveXLSWorkbookAsXLSX 这座桥,保真度会打折。在设计阶段就按你打算发布的格式挑门面,而不是先用一边搭好、到流水线末尾再转换

对一个典型替换项目中数据装载的那一半,数据库到工作簿的导出模式把组件和手写循环都讲了,而一旦行数达到六位数,大型工作簿性能技巧就是几分钟与几秒钟之间的分野。基于设计师维护的版式所构建的报表,见模板报表生成实操

HotXLS 以面向 Delphi 与 C++Builder 的 Object Pascal 源码形式发布;版本、授权和完整 API 参考见 HotXLS Delphi Component 产品页