技術文章

免 Office 自動化,用 Delphi 產生 Excel 檔案

如果一台伺服器唯一的工作就是發出 Excel 檔案,它就沒有道理去執行 Excel。在建置代理或報表服務上安裝 Office,再透過 COM 自動化驅動它,是錯誤的設計,而且從這個做法存在以來,它就一直是錯誤的設計。微軟自己也這麼說,那份二十年來不曾軟化的指引寫著:Office 既不是為了從無人值守的伺服器端程序自動化而建置,也未為此授權。正確的答案是直接寫出 BIFF 與 OOXML 的位元組,畫面中完全沒有 Excel。這正是 HotXLS 的整套前提——一個原生的 Object Pascal 程式庫,自己讀寫試算表格式,所以沒有會 hang、會漏、或需要按席位付費的桌面應用程式

為什麼從服務驅動 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 記錄串流引擎(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,所以活頁簿可以直接進到回應:

HotXLS 兩種 Delphi 門面的比較圖:TXLSWorkbook 經 IXLSWorkbook 介面參考計數自動釋放,TXLSXWorkbook 則是普通物件,需要在 try..finally 區塊中明確 Free
XLS 門面靠介面參考計數釋放,XLSX 門面需要明確 Free;工作表集合也不同,一邊是 1 起算的 Entries,一邊是 0 起算的 Items
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 類別,以及一個片段或完整文件的開關,讓入口網站的情境不必再用正規表示式去編輯匯出的標記

Delphi 要求處理器圖解:把 HotXLS 活頁簿存進 TMemoryStream、把 Mem.Position 倒回 0、再把串流交給 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 增益集的原生替代品,而 UDF 增益集往往就藏在某套 COM 自動化系統賴以長大的那些試算表裡

只在伺服器上才浮現的部署邊緣

幾個細節決定了上線是乾淨還是令人困惑,而第一個是單元圖。拖放式資料集匯出器 TDataToXLS 會拉進 VCL 的 FormsControlsDialogs。在桌面工具中無傷大雅;在主控台服務中,它卻把整個 VCL 拖在後面。核心單元 lxHandlelxHandleX 只伸手拿 WindowsClassesSysUtilsVariants,所以一個純服務最好自己針對核心 API 寫一個資料集迴圈,而不是為了方便而匯入這個元件

再來是執行緒。活頁簿執行個體不是執行緒安全的,但它們也不共用任何全域狀態,所以能擴充的模式是最簡單的那一個:每個工作、或每個工作者執行緒一個活頁簿物件。這買到了並行的報表產生,而這是單一共用 Excel 執行個體永遠做不到的。一個自己建立、填寫、儲存並釋放自己活頁簿的請求處理器,完全不需要鎖,而一次失敗的爆炸半徑也從「那個共用的 Excel 執行個體對所有人都卡住了」塌縮成「這一個請求拋了例外」,而你既有的錯誤處理早就知道該拿後者怎麼辦

格式目標是最後一項。TXLSWorkbook.SaveAs 預設寫出 BIFF(xlExcel97),而把 XLS 內容推進 .xlsx 會以降低的保真度走過 SaveXLSWorkbookAsXLSX 橋接。請在設計階段就依照你打算出貨的格式來挑選外觀層,而不是在其中一個裡建置、再在管線尾端轉換

對於一次典型替換專案中載入資料的那一半,資料庫到活頁簿的匯出模式同時涵蓋了元件與手寫迴圈,而一旦列數到達六位數,大型活頁簿效能技巧就成為分鐘與秒之間的差別。從設計者維護的版面建置的報表,則涵蓋在範本報表產生演練

HotXLS 以 Object Pascal 原始碼形式出貨,供 Delphi 與 C++Builder 使用;版本、授權與完整 API 參考位於HotXLS Delphi Component 產品頁