💡 مثال کامل قابل اجرا در گیت‌هاب موجود است:
sign-documents-in-docker-fonts-java

سرویس امضای قرارداد که به مدت نه ماه کار می‌کرد

تأمین فونت در کانتینر گامی است که تصمیم می‌گیرد آیا سرویس امضای Java در محیط تولید کار می‌کند یا فقط در تست‌هایی که به‌طور اتفاقی نوشته‌اید. این مهم است چون شکست برنامه‌ریزی‌شده است: یک تصویر JRE پوشش کافی فونت را برای نمایش صحیح فراهم می‌کند، سپس بقیه را تا زمانی که سند خاصی برسد، نگه می‌دارد.

شکل کلی آن را در نظر بگیرید. یک جریان کاری سند قراردادها را امضا می‌کند، بر روی eclipse-temurin:17-jre مستقر شده و کار می‌کند. پس از نه ماه، شرکت اولین مشتری خود را در ژاپن امضا می‌کند، نام در متن امضا وارد می‌شود و کار با خطای Specified font file was not found شکست می‌خورد. هیچ تغییری در سرویس ایجاد نشده بود. تصویر هرگز پوشش CJK نداشت؛ هیچ سندی درخواست آن را نکرده بود.

دلیل فنی کوتاه است. eclipse-temurin:17-jre هشت فایل فونت DejaVu را برای AWT بسته‌بندی می‌کند که شامل لاتین، یونانی و سیریلیک می‌شود. GroupDocs.Signature جایگزینی برای خانواده‌ی گمشده انجام نمی‌دهد، بنابراین درخواست برای یک فونت سازگار با ژاپنی به جای کاهش کیفیت، با خطا مواجه می‌شود و عدم تنظیم فونت نیز کمکی نمی‌کند چون کتابخانه سپس به دنبال Times New Roman می‌گردد که آن هم موجود نیست.

چرا این وضعیت بدتر از یک تصویر بدون فونت است

تصاویر پایه .NET و Python هیچ فونتی را حمل نمی‌کنند. این یک شکست بهتر است: اولین امضا بلافاصله در اولین اجرای تست شکست می‌خورد و کسی قبل از انتشار سرویس آن را اصلاح می‌کند.

یک تصویر JVM به‌صورت جزئی شکست می‌خورد که نسخه‌ی پرهزینه‌تری است. باگ در کدی زندگی می‌کند که قبلاً در تولید بوده، توسط داده‌های مشتری به‌جای هر چیزی در استقرار فعال می‌شود و شخصی که در دسترس است، خطای فونت را از سرویسی می‌بیند که ماه‌ها به آن دست نخورده است. هزینه‌ی حادثه نه رفع آن است – رفع آن یک لایه 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 مهم است چون اسنادی که در ویندوز ایجاد می‌شوند، 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;

تشخیص نام فایل یک میان‌بر است که به‌نظر معادل می‌آید اما نیست. بسته fonts-noto-cjk در Debian فایل 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);

این تمایز از نظر عملیاتی مهم است. یک کانتینری که در زمان راه‌اندازی با پیام «no usable font family» خارج می‌شود، یک مشکل استقرار است که توسط کسی که آن را مستقر کرده کشف می‌شود. یک امضای به‌صورت ساکت از سند تحویل‌داده‌شده حذف می‌شود، که یک مشکل انطباق است و توسط گیرنده کشف می‌شود. اتصال حالت کشنده به خروجی غیر‑صفر، شکست‌ها را در دستهٔ اول نگه می‌دارد.

سپس نتیجه را بخوانید، چون یک امضای 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 است و مربوط به فونت‌ها نیست. بسته Maven GroupDocs یک JAR چاق امضا شده است. بازپکیج کردن آن به یک JAR سایه‌دار باعث می‌شود NoClassDefFoundError: com/groupdocs/signature/options/search/SearchOptions رخ دهد و راه‌حل معمول حذف META-INF/*.SF|RSA|DSA کافی نیست: MANIFEST.MF حدود ۱۹ مگابایت دیجست‌های هر ورودی را حمل می‌کند و باید به بخش اصلی‌اش نیز کوتاه شود. نمونه با اجرای برنامه در یک classpath ساده با یک پوشه dependency/ به‌جای سایه‌دار کردن هر چیزی، این مشکل را دور می‌زند.

این را ذکر می‌کنم چون هر دو مورد – پوشش جزئی فونت و JAR امضا شده – شکل مشابهی دارند: مسیر JVM به‌گونه‌ای شکست می‌خورد که شبیه کد شما به‌نظر می‌رسد اما نیست. هر دو نیز پس از شناسایی، به‌راحتی می‌توانند دفاع شوند: مسیر classpath که می‌دانید کار می‌کند را ثابت کنید و پوشش فونت را در زمان راه‌اندازی تأیید کنید به‌جای اعتماد به تصویر پایه. هیچ‌کدام بازطراحی هزینه ندارند و هر دو یک کلاس از حوادث را که در غیر این صورت با یک باگ برنامه‌ای قابل تشخیص نیست، حذف می‌کنند.

نتیجه‌گیری

یک سرویس امضای Java در یک کانتینر تنها یک لایه Dockerfile و یک بررسی راه‌اندازی برای پیش‌بینی‌پذیری فاصله دارد. fontconfig، DejaVu، Liberation و Noto CJK را نصب کنید؛ خانواده را با پروب کردن به‌جای فرض کردن حل کنید؛ آنچه قابل جاسازی نیست را رد کنید؛ با خواندن‑باز تأیید کنید. مخزن نمونه هر دو تصویر را ارائه می‌دهد، بنابراین تفاوت بین پوشش و عدم پوشش دو ساخت را می‌گیرد نه یک حادثه برای یادگیری.

منابع اضافی