💡 完整的工作範例可在 GitHub 上取得:
nodejs-docker-signing-with-fonts
介紹
字型解析是容器簽署的一個環節,決定您的 Node 服務是產生文件還是拋出例外。GroupDocs.Signature 不會自動替代缺失的字族:如果指定的字族在映像檔中不存在,呼叫會拋出例外,且不會產生任何輸出。清除字型也不是解決方案,因為庫會改為使用自己的預設字型,結果同樣失敗。
有三種方式可以決定要傳遞哪個字族,其中只有一種能在容器中存活。本文將比較這三種方式,並說明影響它們的佈署與綁定行為,因為 Node.js 透過 Java 的方式在所有支援平台中最為複雜。
為何在 Node.js 上更重要
此套件是一座橋樑:node-java 會在同一個程序中載入 JVM。因此,Node 簽署映像必須具備 JDK、node-gyp 工具鏈以編譯橋樑,並且 LD_LIBRARY_PATH 必須指向 libjvm.so,這些需求必須在字型相關設定之前完成。node:18-bookworm 只會提供 6 個 DejaVu 字型檔案供 AWT 使用——足以支援拉丁字元,卻沒有任何 CJK 字型。
這樣的組合會產生看似應用程式錯誤的失敗情況。缺少 JVM 路徑、缺少字型或是封送不匹配,都會以 Error running instance method 顯示,因為 node-java 會把 Java 端拋出的任何例外都報告為此訊息。
前置條件
Node 18 – 橋樑會對 NAN 進行編譯,NAN 無法在 Node 20 或 22 上編譯('AccessorSignature' is not a member of 'v8')。JDK 8 至 17:在 JDK 25 上影像層會因 Cannot open an image. The image size can not be 0! 而失敗。
安裝
npm install @groupdocs/groupdocs.signature
在映像中,這個安裝需要 build-essential 與 python3,以及 openjdk-17-jdk-headless 與載入路徑:
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
ENV PATH="${JAVA_HOME}/bin:${PATH}"
# node-java dlopens libjvm.so at run time; it is not on the default loader path.
ENV LD_LIBRARY_PATH="${JAVA_HOME}/lib/server:${LD_LIBRARY_PATH}"
方法 1 – 硬編碼字族名稱
大家最先寫的版本:直接選 Arial,打包後上線。它在開發機上能正常運作,但在第一次容器執行時失敗,因為 Debian 映像不會安裝 Arial——它會安裝 Liberation Sans,該字型在度量上相容,但字族名稱不同。
此處沒有值得展示的程式碼,這正是重點。此方法的全部內容只是一個在某個環境下恰好成立的字串常量。
方法 2 – 從檔案系統偵測字型
自然的修正方式:掃描字型目錄,看看有哪些字型,然後挑選。這其中有一半是實用的——清單會告訴您映像中是 0 個字型還是 6 個:
const roots = [
'/usr/share/fonts',
'/usr/local/share/fonts',
path.join(home, '.fonts'),
path.join(home, '.local', 'share', 'fonts'),
'/System/Library/Fonts',
'/Library/Fonts',
];
另一半則無法正常運作。字型檔案很少會攜帶呼叫端必須傳遞的字族字串:Debian 的 fonts-noto-cjk 會安裝 NotoSansCJK-Regular.ttc,其字族為 Noto Sans CJK JP。從檔名推導字族會得到 NotoSansCJK-Regular,這根本找不到對應的字族。檔名偵測既會錯過實際存在的字型,也會自信地回報會失敗的字族。
將此清單保留作為診斷工具,切勿用來做選擇。計數可以回答映像是否已經佈署字型,這是一個不同且同樣有用的問題。
方法 3 – 向庫詢問
對每個候選字族嘗試一次拋棄性的簽章,保留第一個不拋例外的字族。每個候選字族會產生一次 PDF 寫入,且這是唯一能給出權威答案的方法,因為它使用的正是實際簽章時會呼叫的同一個介面。
for (const candidate of candidates) {
if (tryFamily(sourcePath, candidate) === null) {
return candidate;
}
}
return null;
在 Node 中,偵測需要額外的兩行程式碼。node-java 會把所有 Java 例外壓縮成 Error running instance method,因此必須從包裹的堆疊追蹤中還原真實訊息:
const stack = err.stack || '';
const match = stack.match(/com\.groupdocs\.signature\.exception\.[^\n]*/);
return match ? match[0].trim() : (err.message || String(err));
若沒有這兩行,沒有字型的容器與 JVM 路徑錯誤會產生完全相同的日誌。我花了比想像中更長的時間比較兩個容器,兩者因完全不同的原因印出相同錯誤,最後才加入正則表達式解決問題。
偵測成本
對偵測的反對意見是它會寫檔,而事實確實如此:每個候選字族會寫入一個小 PDF,寫入後立即刪除。範例中的拉丁字型清單有四筆,CJK 清單有八筆,因此在冷啟動時最多會在暫存目錄寫入十二個單頁文件,然後服務才算準備就緒。
這是啟動成本,而非每次請求的成本,且它會在日誌中留下兩個已解析字族的名稱。相較於一個乾淨啟動卻在第一個客戶文件上因橋接錯誤失敗的容器,十二個暫存檔案並不是難以接受的權衡。
方法比較:何時使用哪一種
| 方法 | 最適用情境 | 主要優勢 | 限制 |
|---|---|---|---|
| 硬編碼字族 | 單一受控環境 | 簡單,無啟動成本 | 任何缺少該字族的映像都會失效 |
| 檔名偵測 | 診斷映像內含什麼 | 快速,無簽章呼叫 | 檔名不是字族名稱,衍生出的選擇會失敗 |
| 庫偵測 | 任何容器化或可移植的情況 | 權威,於本機與映像皆可運作 | 每個候選字族寫入一次 PDF,建議在啟動時解析並快取 |
兩個值得了解的綁定怪癖
一旦字族解析完成,簽章呼叫本身會呈現 Node 特有的形態。Java API 接受一個選項列表,但 JavaScript 陣列不會自動封送為 java.util.List,直接傳遞會得到 Could not find method "sign(java.lang.String, [Ljava.lang.Object;)"。解法是使用單選項的 overload,先寫入暫存檔再進行第二次簽章:
new signatureLib.Signature(sourcePath)
.sign(firstOutput, buildTextOptions(LATIN_TEXT, latinFamily, 50));
if (stageTwo) {
new signatureLib.Signature(firstOutput)
.sign(outputPath, buildTextOptions(CJK_TEXT, cjkFamily, 120));
}
第二個怪癖是讀回驗證。TextVerifyOptions 透過此綁定無法回傳:verify 會拋出相同的通用橋接錯誤,因此範例會回傳哨兵值並印出 unavailable,而不是假裝簽章失敗。npm 套件的版本為 24.12.0(2024 年 12 月發佈),內含 23.6.1 引擎;而 .NET 版本已到 26.6,Java 版本為 26.5。簽章功能不受影響,只有驗證路徑缺失。
在正式環境仍建議使用 Node.js 綁定嗎?
對於僅需拉丁字元的簽章,答案是肯定的:它能正確簽署,且缺少字型時會直接拋錯,而不是悄悄降級,失敗模式相當明顯。對於混合腳本的工作,則需要衡量缺少讀回驗證的風險,因為此時流程無法確認 CJK 字形是否已正確嵌入而非顯示為方框。可在同一流水線中加入 .NET 或 Java 的小型驗證器以彌補此缺口。
最佳實踐與小技巧
- 依序佈署:JDK 與工具鏈 → 載入路徑 → 字型 → 應用程式。每一層失敗的表現不同,混合佈署會讓診斷變慢。
- 在啟動時解析一次所有字族,並將結果與字型數量一起寫入日誌。
- 鎖定 Node 18 以及 JDK 8~17,將兩者視為固定基礎設施,而非例行升級的對象。
- 將無字型的 Dockerfile 保留在版本庫中,讓失敗永遠只距離一次建置。
結論
挑選字型的方式有三種,只有一種能在部署後存活。先以庫偵測取得權威答案,快取結果,並將字型清單僅作為診斷工具而非決策依據。然後依照綁定的實際行為進行:一次只簽署一個選項,從堆疊追蹤中抽取 Java 例外訊息,並誠實回報缺少驗證的情況,而不是隱藏它。範例倉庫同時建構兩個映像,所有說法皆可透過兩條指令驗證。