💡 مثال كامل يعمل متاح على GitHub:
sign-documents-in-docker-fonts-java
خدمة توقيع العقود التي عملت لمدة تسعة أشهر
توفير الخطوط داخل الحاوية هو الخطوة التي تحدد ما إذا كانت خدمة توقيع Java تعمل في الإنتاج أو فقط في الاختبارات التي كتبتها بالصدفة. الأمر مهم لأن الفشل يكون مجدولًا: صورة JRE تعطيك تغطية خطوط كافية لتظهر صحيحة، ثم تحجب البقية حتى وصول مستند معين.
تخيل الشكل العام لها. تدفق عمل المستندات يوقع العقود، مُنَشَّر على eclipse-temurin:17-jre، وهو يعمل. بعد تسعة أشهر، توقّع الشركة أول عميل لها في اليابان، واسم العميل يدخل نص التوقيع، وتفشل العملية بـ Specified font file was not found. لم يتغير شيء في الخدمة. الصورة لم تكن تحتوي على تغطية CJK أبداً؛ ولا مستند طلب ذلك.
السبب التقني قصير. eclipse-temurin:17-jre يضم 8 ملفات خطوط DejaVu لـ AWT، تغطي اللاتينية واليونانية والسيريلية. لا تقوم GroupDocs.Signature باستبدال عائلة خطوط مفقودة، لذا طلب خط يدعم اليابانية يفشل بدلاً من التراجع، وترك الخط غير محدد لا يساعد لأن المكتبة تطلب بعدها Times New Roman، وهو أيضًا غير موجود.
لماذا هذا أسوأ من صورة بلا خطوط
صور .NET وPython الأساسية لا تحتوي على أي خطوط. هذا فشل أفضل: أول توقيع يفشل مباشرةً في أول تشغيل للاختبار، ويصلحه أحدهم قبل أن تُشحن الخدمة.
صورة JVM تفشل جزئيًا، وهذا هو النسخة المكلفة. الخطأ يكمن في كود موجود بالفعل في الإنتاج، يُtrigger بواسطة بيانات العميل بدلاً من أي شيء في النشر، والشخص المناوب يرى خطأ خط في خدمة لم يمسها أحد منذ شهور. تكلفة الحادث ليست الإصلاح – الإصلاح هو طبقة واحدة في 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، whose family name is 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 بالإضافة إلى حوالي ثلاثين سطرًا، ويحوّل حادثًا مُtrigger من قبل العميل إلى حاوية إما تبدأ بتغطية معروفة أو ترفض البدء. المستندات الموقعة الحالية لا تتأثر؛ فقط المستندات الجديدة تحصل على مسار 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 artifact هو jar سميٌّ سميك. إعادة تعبئته في jar مُظلّل ينتج NoClassDefFoundError: com/groupdocs/signature/options/search/SearchOptions، وال remedy المعتاد بحذف META-INF/*.SF|RSA|DSA غير كافٍ: MANIFEST.MF يحمل حوالي 19 ميغابايت من digests لكل إدخال ويجب أيضًا تقصيره إلى قسمه الرئيسي. العينة تتجنب المشكلة بتشغيلها على classpath عادي مع دليل dependency/ بدلاً من تظليل أي شيء.
أذكر ذلك لأن كلا الأمرين – تغطية الخطوط الجزئية وjar الموقّع – يشتركان في شكل: مسار JVM يفشل بطريقة تبدو كأنها كودك لكنها ليست كذلك. كلاهما أيضًا رخيص الدفاع عنه بمجرد تسميته: ثبّت تخطيط classpath الذي تعرف أنه يعمل، وأكد تغطية الخطوط عند بدء التشغيل بدلاً من الاعتماد على الصورة الأساسية. لا أحد منهما يتطلب إعادة تصميم، وكلاهما يزيل فئة من الحوادث التي لا يمكن تمييزها عن خطأ تطبيق.
الخلاصة
خدمة توقيع Java داخل حاوية تبعد خطوة Dockerfile واحدة وفحص بدء تشغيل واحد عن أن تكون متوقعة. ثبّت fontconfig، DejaVu، Liberation وNoto CJK؛ حلّ العائلة بالتحقق بدلاً من الافتراض؛ تخطّ ما لا يمكن تضمينه؛ تحقق بقراءة عودة. المستودع النموذجي يرسل كلتا الصورتين، لذا الفرق بين التغطية وعدم التغطية يستغرق بناءين لرؤيته بدلاً من حادث واحد لتعلمه.