如果一台服务器的唯一职责是产出 Excel 文件,它就不该去运行 Excel。在构建代理或报表服务上装 Office,再通过 COM 自动化驱动它,是错误的设计,而且从这种做法存在之日起就一直是错误的。微软自己就是这么说的,这条指引二十年来没有松口:Office 既不是为无人值守的服务端进程自动化而构建的,授权上也不允许。正确的答案是直接写出 BIFF 与 OOXML 字节,画面里根本不出现 Excel。这正是 HotXLS 的全部前提 —— 一个自己读写电子表格格式的原生 Object Pascal 库,因此没有哪个桌面应用会挂起、泄漏,或者按席位收费
为什么从服务里驱动 EXCEL.EXE 会失败
COM 自动化是在远程操控一个桌面程序,而桌面程序悄悄假定了三件 Windows 服务给不了它的东西:一份已加载的用户配置、一个交互式窗口站,以及一个盯着屏幕的人。把这三样拿掉,故障就以任何开发机都复现不出的形态到来。一个文件恢复提示、一个加载项错误,或者一个授权激活对话框,在没人看得见的桌面上弹了出来,而触发它的那次自动化调用永远不返回。调用方最终超时死掉;Excel 实例却常常不死,作为孤儿进程活着,攥着文件锁,毒害下一次运行。凡是见过十一个游离的 EXCEL.EXE 进程堆在一个服务账号下的人,都知道后面的故事
即便什么都没崩,扩展性的故事也好不到哪里去。一个 Excel 实例是一条单工作簿流水线,每一次属性访问都要付跨进程 COM 编组的代价,而跑这份代码的机器还背着一份条款明确排除这种用途的 Office 授权。多数团队是一次事故接一次事故地撞上这些限制,而“下线 COM 层”大致就是这样进了路线图
在那次重写开始之前,先定下一个范围问题,因为它决定了真正的工作量有多大。COM 代码几乎从不只是设置单元格的值。它带着格式常量调用 Workbook.SaveAs,强制重算,推送打印设置,有时还去够剪贴板。把老代码走一遍,记下这些行为里究竟哪些真的体现在了输出中,因为每一项都落在原生库的不同角落,而其中有几项(剪贴板互操作是最明显的一个)在服务端根本没有意义,应该弃掉而不是移植
两个原生引擎,两种所有权模型
HotXLS 用两份直接的格式实现换掉了 Excel 进程。一个 BIFF8 记录流引擎(TXLSWorkbook,lxHandle 单元)负责 .xls。一个 OOXML 包写入器(TXLSXWorkbook,lxHandleX 单元)产出符合 ECMA-376 / ISO/IEC 29500 的 .xlsx。服务器上没有什么要注册,也没有什么要安装,而且内存允许的话你想同时开多少个工作簿都行
早期最容易把人绊倒的,是这两套门面对内存的所有权方式不同,而这个差别在崩溃之前都是无声的:
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 类,以及“片段还是完整文档”的开关,这让门户那种场景不必再干用正则去改导出标记的活
没有 Excel 进程来计算,公式值怎么办
在 COM 自动化下,Excel 免费替你重算了一切,而丢掉 COM 也就悄悄收回了这项福利。SaveAs 把公式作为文本存下,并不求值;数字要等 Excel 打开文件并重算之后才出现,XLS 门面允许你通过 RecalcOnSave 和 CalculationMode 调整这一行为。对一份要交给人看的文件,这完全正确。对一个必须在发出前确认合计的服务就不对了,对 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 的 Forms、Controls 和 Dialogs。在桌面工具里无伤大雅;在控制台服务里,它会把整个 VCL 拖在身后一起带进来。核心单元 lxHandle 和 lxHandleX 只够到 Windows、Classes、SysUtils 和 Variants,因此一个纯服务与其为图省事引入那个组件,不如自己照着核心 API 写数据集循环
接下来是线程。工作簿实例不是线程安全的,但它们也不共享任何全局状态,所以能扩展的模式就是最简单的那个:一个任务一个工作簿对象,或者一个工作线程一个。这换来的是并行报表生成,而单个共享的 Excel 实例永远做不到。一个自己创建、填充、保存并释放工作簿的请求处理器根本不需要锁,而一次失败的波及半径也从“共享的 Excel 实例把所有人都卡住了”缩小到“这一个请求抛了个异常”,后者你现有的错误处理已经知道该怎么办
最后一项是格式目标。TXLSWorkbook.SaveAs 默认写 BIFF(xlExcel97),而把 XLS 内容推成 .xlsx 要经过 SaveXLSWorkbookAsXLSX 这座桥,保真度会打折。在设计阶段就按你打算发布的格式挑门面,而不是先用一边搭好、到流水线末尾再转换
对一个典型替换项目中数据装载的那一半,数据库到工作簿的导出模式把组件和手写循环都讲了,而一旦行数达到六位数,大型工作簿性能技巧就是几分钟与几秒钟之间的分野。基于设计师维护的版式所构建的报表,见模板报表生成实操
HotXLS 以面向 Delphi 与 C++Builder 的 Object Pascal 源码形式发布;版本、授权和完整 API 参考见 HotXLS Delphi Component 产品页