💡 完整的工作示例可在 GitHub 上获取:
sign-documents-in-docker-fonts-java
运行了九个月的合同签署服务
容器字体供应是决定 Java 签署服务是能在生产环境正常工作,还是只能在你偶然编写的测试中运行的关键步骤。它很重要,因为失败是可预见的:JRE 镜像提供了足够的字体覆盖以显示正确,但会在特定文档出现时才提供其余字体。
先来看一下整体情况。一个文档工作流在 eclipse-temurin:17-jre 上签署合同,并且能够正常工作。九个月后,公司在日本签下了第一位客户,客户姓名出现在签名文本中,作业因 Specified font file was not found 而失败。服务本身没有任何改动。该镜像从未包含 CJK(中日韩)覆盖;之前没有文档需要它。
技术原因很简单。eclipse-temurin:17-jre 为 AWT 捆绑了 8 种 DejaVu 字体文件,覆盖了拉丁文、希腊文和西里尔文。GroupDocs.Signature 不会为缺失的字体族进行替代,因此对日文可用字体的请求会直接失败,而不是降级;而且如果不设置字体,库会尝试使用 Times New Roman,但该字体同样不存在。
为什么这比无字体镜像更糟
.NET 和 Python 基础镜像不带任何字体。这是一种更好的失败方式:第一次签名就在首轮测试中失败,某人会在服务发布前修复它。
JVM 镜像则是部分失败,这种情况代价更高。bug 出现在已经上线的代码中,由客户数据触发,而不是部署过程中的任何因素;值班人员看到的是一个已经几个月没人碰过的服务的字体错误。事故成本不在于修复——修复只需要在 Dockerfile 中添加一层——而在于在任何人相信与字体无关之前的那一个小时。
这种不对称性正是主张在启动时断言字体覆盖,而不是等到运行时才发现的理由。
它也改变了谁来承担成本。无字体镜像只会让开发者在设置阶段多花二十分钟。部分覆盖的镜像会让值班工程师在不合时宜的时间里花费一个小时,加上因延迟合同产生的损失以及事故后的审查。两者的技术差异仅是 Dockerfile 中的四个软件包。
供应实际需要的成本
运行阶段需要的四个 Debian 软件包:
RUN apt-get update && apt-get install -y --no-install-recommends \
fontconfig \
fonts-dejavu-core \
fonts-liberation \
fonts-noto-cjk \
&& fc-cache -f \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
镜像体积通常是反对的理由,这里需要具体说明:CJK 包体积最大,另外三个体积很小,而且如果你的文档可能包含非拉丁字符,这些包都不是可选的。请根据实际文档需求安装,并通过读取回检验,而不是凭直觉裁剪。
fontconfig 是解析器,同时提供 fc-list 用于调试。fonts-dejavu-core 与 JRE 已经捆绑的内容重复,这么做是有意为之:如果基础镜像发生变化,仍能保持镜像的完整性。fonts-liberation 很重要,因为在 Windows 上创建的文档会按名称引用 Arial 和 Times New Roman,并期望得到度量兼容的渲染。fonts-noto-cjk 正是上文事故所需的那一个。
通过解析族而不是直接命名
仅仅供应字体还不够,代码仍需指定一个实际存在的族。可移植的做法是让库自行尝试:对每个候选族进行一次临时签名,返回第一个不抛异常的族。
for (String candidate : candidates) {
if (tryFamily(sourcePath, candidate) == null) {
return candidate;
}
}
return null;
探测本身就是一次普通的签名调用,写入临时目录,并把失败转换为返回值而不是异常:
SignatureFont font = new SignatureFont();
font.setFamilyName(familyName);
font.setSize(10);
options.setFont(font);
signature.sign(scratch.getAbsolutePath(), options);
return null;
文件名匹配看似简便,却并不可靠。Debian 的 fonts-noto-cjk 安装了 NotoSansCJK-Regular.ttc,其族名为 Noto Sans CJK JP,因此仅匹配文件名既会漏掉字体,也会报告无法解析的族。
诚实降级
有了解析后,两类失败可以清晰分离。没有拉丁族意味着镜像根本无法签名,容器应当停止运行。没有 CJK 族则可以跳过该签名并继续执行,同时给出警告:
List<SignOptions> options = new ArrayList<>();
options.add(buildTextOptions(LATIN_TEXT, latinFamily, 50));
if (cjkFamily != null) {
options.add(buildTextOptions(CJK_TEXT, cjkFamily, 120));
}
SignResult result = signature.sign(outputPath, options);
这一区别在运营层面很关键。容器在启动时因 “没有可用的字体族” 而退出属于部署问题,由部署者捕获。若文档交付后缺少某个签名,则属于合规问题,由收件人发现。将致命情况映射为非零退出码,可将这类失败归入第一类。
随后读取签名结果,因为在没有 CJK 覆盖的情况下写入的 CJK 签名可能只会显示为空白方块而不抛异常:
TextSearchOptions options = new TextSearchOptions();
options.setAllPages(true);
List<TextSignature> found = signature.search(TextSignature.class, options);
已经发布的团队该怎么做?
添加字体层,启动时进行族解析,并在服务的第一行日志中记录两者结果,供后续工程师查看。改动仅是 Dockerfile 的一次编辑以及大约三十行代码,它将客户触发的事故转变为要么在已知覆盖下启动,要么拒绝启动的容器。已有的已签署文档不受影响,只有新文档会走上 CJK 路径。
检查已有镜像
在做任何修改之前,先了解当前镜像中到底有哪些字体。下面两条命令可以从外部快速查看:
docker run --rm your-image sh -c "ls -R /usr/share/fonts | head"
docker run --rm your-image sh -c "fc-list : family | sort -u | head -20"
第一条列出字体文件,第二条列出解析器会返回的族名,二者之间的差距就是文件名匹配失效的原因。如果 fc-list 不存在,那说明 fontconfig 未安装,任何族查询都只能盲目进行。
在服务内部,等价的检查应放在启动日志中,紧随解析得到的族信息。例如:
fonts on disk: 8, latin: DejaVu Sans, cjk: (none)
这行日志让后续人员一目了然容器能签署哪些内容,远比凌晨三点才看到的异常更有价值。
JVM 细节:没人预料到的坑
还有一点专门针对 Java 的陷阱,且与字体无关。GroupDocs 的 Maven 构件是一个已签名的胖 JAR。将其重新打包为 shaded JAR 时会出现 NoClassDefFoundError: com/groupdocs/signature/options/search/SearchOptions,常见的做法是删除 META-INF/*.SF|RSA|DSA,但这仍不足够:MANIFEST.MF 中携带了约 19 MB 的每条目摘要,也必须截断到仅保留主段。示例通过在普通类路径下使用 dependency/ 目录而非 shading 来规避此问题。
之所以提及,是因为这两件事——部分字体覆盖和已签名的 JAR——都有一个共同特征:JVM 路径会以看似代码本身的问题出现,却并非如此。只要明确这两点,防御成本都很低:固定已知可用的类路径布局,在启动时断言字体覆盖,而不是盲目信任基础镜像。既不需要重新设计,也能消除一类原本难以与应用 bug 区分的事故。
结论
在容器中运行的 Java 签署服务,只需一个 Dockerfile 层和一次启动检查,就能实现可预期。安装 fontconfig、DejaVu、Liberation 和 Noto CJK;通过探测而非假设解析字体族;对无法嵌入的签名跳过;通过读取回检验。示例仓库同时提供了两种镜像,因而可以在两次构建后看到覆盖与否的差异,而不是等到一次事故后才发现。