如果一台伺服器唯一的工作就是發出 Excel 檔案,它就沒有道理去執行 Excel。在建置代理或報表服務上安裝 Office,再透過 COM 自動化驅動它,是錯誤的設計,而且從這個做法存在以來,它就一直是錯誤的設計。微軟自己也這麼說,那份二十年來不曾軟化的指引寫著:Office 既不是為了從無人值守的伺服器端程序自動化而建置,也未為此授權。正確的答案是直接寫出 BIFF 與 OOXML 的位元組,畫面中完全沒有 Excel。這正是 HotXLS 的整套前提——一個原生的 Object Pascal 程式庫,自己讀寫試算表格式,所以沒有會 hang、會漏、或需要按席位付費的桌面應用程式
為什麼從服務驅動 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 的活頁簿程式碼中最常見的錯誤,也是最殘酷的一個,因為它會矇混過任何只斷言串流有非零長度的單元測試
同一套建置活頁簿的常式,不必重組就能抵達每一種其他交付格式。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 增益集的原生替代品,而 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 以 Object Pascal 原始碼形式出貨,供 Delphi 與 C++Builder 使用;版本、授權與完整 API 參考位於HotXLS Delphi Component 產品頁