تخطي إلى المحتوى

العمليات

هذه الصفحة مترجمة من الإنجليزية. اقرأ الصفحة الإنجليزية

تشغيل تطبيق Zeyn في بيئة الإنتاج: بيانات المناطق الزمنية، والتراخيص الموقّعة، والحالة المشتركة بين عدة خوادم، والسجلات والتتبعات، ومستندات PDF، والبريد الإلكتروني.

المناطق الزمنية

يقبل TimeZone::named كل منطقة من IANA (Europe/Istanbul وAmerica/New_York وAustralia/Sydney، بأي حالة أحرف) والإزاحات الثابتة (UTC+03:00 و+05:30).

  • بيانات المناطق هي قاعدة بيانات المناطق الزمنية IANA المضمنة في الملف التنفيذي عبر jiff (نحو 200 كيلوبايت). تكون النتائج واحدة على كل المنصات، ومنها Windows، ولا تُقرأ أي zoneinfo من النظام.
  • التاريخ مُنمذَج: التوقيت الصيفي في تركيا حتى 2016 و+03 الدائم منذ ذلك الحين، وقواعد الولايات المتحدة قبل 2007، وكل تغيير مسجل آخر.
  • وقت الساعة الجدارية الذي لا وجود له (عند تقديم الساعات) يُرفض بـ LocalTimeError::Skipped. والوقت الذي يحدث مرتين (عند تأخير الساعات) يعني اللحظة الأبكر.
  • تُقرأ المنطقة من الحزمة المضمنة أول مرة يُستخدم اسمها، وتبقى طوال عمر العملية، بضعة كيلوبايتات لكل منطقة. لا تُحفظ الأسماء غير المعروفة أبداً، فلا يستطيع العملاء تضخيم الذاكرة المؤقتة.
  • TimeZone::names() قائمة قصيرة بالمناطق الشائعة لمنتقي المناطق في الغلاف (shell). منطقة المستخدم غير الموجودة في القائمة تُضاف إلى المنتقي لذلك المستخدم.
  • تصل قواعد المناطق الجديدة مع إصدار أحدث من jiff (cargo update -p jiff-tzdb). أعد البناء والنشر عندما يغيّر بلد قواعده.

التراخيص الموقّعة

يُوقَّع ملف الترخيص بمفتاح ed25519 لا يملكه إلا المورّد. يضمّن التطبيق المفتاح العام، فيستطيع العميل قراءة الترخيص لكنه لا يستطيع تغييره ولا إنشاء ترخيص جديد. فعّله بميزة Cargo المسماة signed-licenses.

zeyn license keygen                      # writes zeyn-license.key, prints the public key
zeyn license sign acme.json --out license.json

يحتوي acme.json على حقول الترخيص:

{ "holder": "Acme", "valid_until": "2027-12-31", "modules": ["pos", "stock"], "max_users": 50 }
const LICENSE_KEY: &str = "f414a89e9dae57598d60141df96dd1c9fb655fdcf49f5eaf24e093fda7d332e3";

App::new("Shop")
    .license(SignedLicenseFile::new("license.json", LICENSE_KEY), Duration::from_secs(3600))
    .feature(Feature::new("pos").guard(Licensed::module("pos")).screen(pos))
  • الملف JSON يحمل الترخيص نصاً في payload والتوقيع في signature. ما يُوقَّع هو zeyn-license-v1\n متبوعاً بنص الحمولة، فتكون البايتات الموقّعة هي بالضبط البايتات المخزنة.
  • يبقى المفتاح الخاص على جهاز التوقيع. لا يكتب keygen فوق ملف مفتاح موجود أبداً، و*.key موجود في .gitignore للمشاريع الجديدة. يقرأ sign المفتاح من --key أو من المتغير ZEYN_LICENSE_KEY أو من zeyn-license.key، ويرفض ترخيصاً بلا holder أو بقيمة valid_until ليست بصيغة YYYY-MM-DD.
  • الملف المعدَّل أو الموقَّع بمفتاح آخر أو الذي ليس ترخيصاً يُرفض: تعيد License::from_signed الخطأ LicenseError (BadSignature وInvalidPublicKey وMalformed)، ويسجل الخادم السبب، وكل حارس Licensed يرفض برسالة "Your license does not cover this." الملف الذي لا يمكن قراءته يُبقي الترخيص الأخير، مثل أي مصدر ترخيص يفشل.
  • يفحص الحارس انتهاء الصلاحية عند كل طلب بالنسبة إلى تاريخ اليوم عند المستخدم، فالترخيص المنتهي يقفل التطبيق برسالة "Your license has expired." دون إعادة تشغيل. ويُقرأ الملف المجدَّد عند التحديث التالي.
  • لا يستطيع ed25519 التمييز بين ملف معدَّل وملف موقَّع بمفتاح آخر؛ كلاهما توقيع لا يطابق، والرسالة تقول ذلك.
  • max_users محمول وموقَّع لكن المجموعة لا تفرضه؛ التطبيق هو من يقرر ما يعدّه مستخدماً.

عدة خوادم: حالة مشتركة في Redis

مع ميزة Cargo المسماة redis، يحفظ RedisStore الحالة التي يجب أن تكون نفسها على كل نسخة خلف موازن أحمال:

let redis = RedisStore::new(config.require("redis.url")?, "billing").map_err(Error::Internal)?;
App::new("Billing")
    .rate_limit(RateLimit::per_ip(300, Duration::from_secs(60)))
    .rate_store(redis.clone())
    .datagrid_views(redis)

ما يحتفظ به الخادم لكل مستخدم، وأين:

الحالةأينمشتركة
عدادات تحديد المعدلRateStoreRedisStore
عروض الشبكات المحفوظة وتخطيطات الإرساءDataGridViewStoreRedisStore
مهلة الخمولالمتصفح (كل علامات تبويب التطبيق)لا شيء على الخادم
تسجيل الدخول وإعادة المصادقةوكيل تسجيل الدخول الموحدلا شيء على الخادم
سبب فتح شاشة حساسةملف تعريف ارتباط HttpOnlyلا شيء على الخادم
مسودات النماذجsessionStorageلا شيء على الخادم
ذاكرة الترخيص المؤقتةلكل عملية، وتُعاد قراءتها من المصدركل خادم يحمّل ترخيصه
المهام الخلفيةلكل عمليةغير مشتركة؛ انظر قسم المهام في enterprise.md
  • حدود المعدل هي دلو رموز (token bucket) في سكربت Lua واحد، فلا تتجاوز الطلبات المتزامنة على خوادم مختلفة الحد أبداً. يأتي الوقت من Redis لا من خوادم التطبيق. تنتهي صلاحية العدادات بعد نافذة واحدة من آخر استخدام.
  • العروض JSON تحت zeyn:<prefix>:view:<user length>:<user>:<grid> ولا تنتهي صلاحيتها أبداً.
  • يُنشأ الاتصال عند أول استخدام ويعود من تلقاء نفسه. تنتهي مهلة الاستدعاءات بعد 500 مللي ثانية. عند تعطل Redis، تمرر حدود المعدل الطلبات، ولا تُحمَّل العروض ولا تُحفظ (تعود الشبكة إلى تخطيطها الخاص، والإرساء إلى localStorage)، ويُسجَّل الانقطاع والتعافي مرة واحدة لكل منهما، ويُترك Redis وشأنه خمس ثوانٍ بعد كل فشل حتى لا ينتظر كل طلب مهلة منتهية.
  • RateStore::take تعيد future حتى تتمكن المخازن من التحدث عبر الشبكة. وتبقى MemoryRateStore::take متزامنة للاستخدام المباشر.
  • TLS (rediss://) غير مدمج.
  • تعمل الاختبارات على خادم حقيقي عند ضبط ZEYN_TEST_REDIS:
  docker run -d --rm --name zeyn-redis -p 6379:6379 redis:7-alpine
  ZEYN_TEST_REDIS=redis://127.0.0.1:6379 cargo test -p zeyn --features redis --test redis
  docker stop zeyn-redis

السجلات والتتبعات

تبلّغ المجموعة عبر tracing. ولا تطبع شيئاً من تلقاء نفسها أبداً.

  • يثبّت App::serve وApp::serve_until مشتركاً (subscriber) ما لم يكن التطبيق قد ثبّت واحداً قبلهما: تذهب الأحداث إلى مجرى الخطأ القياسي أسطراً نصية، وتُرشَّح بحسب log.level (info افتراضياً؛ وأي مرشح هدف في tracing مثل warn,zeyn=debug). التطبيق الذي يريد أسطر JSON أو وجهة أخرى يثبّت مشتركه الخاص قبل التقديم، ويتركه Zeyn وشأنه.
  • عند info يكون الخرج كما كان دائماً: عنوان الاستماع، وتحذيرات بدء التشغيل (الترجمات الناقصة وDevAuth وdev_latency)، ومصادر الترخيص الفاشلة، والأخطاء الداخلية. وعند debug يضيف كل استدعاء سطر call finished فيه النوع والشاشة والإجراء ومعرّف المستخدم ومعرّف المستأجر والنتيجة ورمز الخطأ والمللي ثانية.
  • الامتدادات (spans)، لأنظمة التتبع الخلفية:
الامتدادالمستوىالحقول
requestinfoالطريقة، والمسار (دون سلسلة الاستعلام)، والحالة، وms، وعند معرفتها: kind وscreen وaction وuser وtenant وok وerror
runinfoشيفرة التطبيق: إجراء، أو مصدر بيانات (جلب الشبكة، البحث عن صف، مخزن العروض)، أو بانٍ للشاشة
interceptordebugالاسم (اسم النوع، أو Interceptor::name)، والمرحلة (before أو after)
  • مع ميزة Cargo المسماة otel، تُصدَّر الامتدادات عبر OTLP/HTTP عند ضبط otel.endpoint في Config (http://collector:4318؛ تذهب الامتدادات إلى /v1/traces). يسمّي otel.service_name الخدمة، واسم التطبيق افتراضياً. تُجمَّع الامتدادات في دفعات على خيط خلفي وتُفرَّغ عندما يتوقف serve_until.
  • بدون التصدير، يستقبل مجرى الخطأ القياسي الأحداث فقط، فلا يُبنى أي امتداد ويكلف كل امتداد فحصاً مخزناً واحداً. التطبيقات المركّبة بـ App::router في خادم آخر، والاختبارات، تدفع الكلفة نفسها.
  • ما لا يُسجَّل أبداً: قيم الحقول (المقنَّعة أو غير المقنَّعة)، ومدخلات النماذج وAPI، وسلاسل الاستعلام، وملفات تعريف الارتباط، والترويسات، والأسرار. يشغّل اختبار الشبكات وحفظ النماذج واستدعاءات API وعمليات الكشف تحت مشترك بمستوى trace ويتحقق من عدم ظهور أي قيمة مقنَّعة. يبقى أمران على عاتق التطبيق: المسارات تُسجَّل، فلا تضع بيانات شخصية في المسارات، ورسائل Error::Internal تُسجَّل كما كُتبت.

مستندات PDF

ميزة Cargo المسماة pdf تولّد مستندات PDF من قوالب Typst. وهي معطلة افتراضياً لأن Typst كبير (انظر الأرقام أدناه).

const INVOICE: PdfTemplate = PdfTemplate::new("invoice", include_str!("../templates/invoice.typ"));

#[action(permission = "sales.order.read")]
pub async fn invoice_data(p: OrderParams, store: Orders, ctx: Ctx) -> Result<InvoiceData> { ... }

Screen::window("Order").body(PdfLink::new("Print invoice", INVOICE, invoice_data, params))

App::new("Shop").service(Pdf::from_config(&config)?)
  • يضع PdfLink زر تنزيل على الشاشة. يفتح GET /_zeyn/pdf الشاشة مع فحوص الوصول الخاصة بها، ويشغّل الإجراء للمستخدم (تُطبَّق صلاحيته ومعترضاته، ونوع الاستدعاء Document) ويولّد القالب بما يعيده الإجراء. المستخدمون الذين لا يحق لهم تشغيل الإجراء لا يرون الزر.
  • الشبكات ذات exportable() تطبع أيضاً تقرير قائمة من Export::Pdf في شريط الأدوات (GET /_zeyn/grid/export?format=pdf): أعمدة المستخدم وتسمياته ومرشحاته وفرزه، والأرقام محاذاة لليمين، وصف رأس مكرر، وأرقام الصفحات. يحتوي التقرير على 2,000 صف على الأكثر (MAX_PDF_ROWS، أو حد تصدير الشبكة إن كان أصغر) ويذكر ذلك عند استبعاد صفوف.
  • البيانات لا تصبح شيفرة Typst أبداً. يُسلسَل خرج الإجراء إلى JSON ويقرؤه القالب بوصفه data (يربط المولّد #let data = json("/data.json") قبل القالب). قيم مثل #panic() أو ] أو $x$ تُطبع نصاً؛ ويتحقق اختبار من ذلك. لا تستطيع القوالب قراءة أي شيء آخر: لا ملفات ولا حزم.
  • يعمل التوليد على خيط حاجز (blocking). يُولَّد على الأكثر pdf.max_concurrent مستنداً (من 1 إلى 64؛ اثنان افتراضياً، وواحد على معالج واحد) في الوقت نفسه؛ وينتظر الباقي فتحة. تُفرَّغ ذاكرة Typst المؤقتة للحفظ (memoization) بعد كل مستند، فتعود الذاكرة إلى الانخفاض.
المفتاحالمعنى
pdf.fontsمجلد خطوط .ttf أو .otf أو .ttc أو .otc إضافية، يُقرأ مرة واحدة عند البدء
pdf.max_concurrentعدد المستندات المولَّدة في الوقت نفسه

الخطوط: خط Libertinus Serif (عادي وعريض ومائل وعريض مائل؛ ترخيص SIL Open Font License) مضمّن ويغطي التركية؛ وخط DejaVu Sans Mono مضمّن بديلاً للرموز التي ينقصها Libertinus، مثل ₺. تراخيصها في crates/zeyn/assets/fonts. لا يوجد خط رياضيات؛ أضف واحداً بـ pdf.fonts إن كانت القوالب تضع معادلات.

القياس على Windows (بناء release، x86_64):

النتيجة
حجم الملف التنفيذي5.5 ميغابايت بدون pdf، ونحو 46 ميغابايت معه (يضيف Typst نحو 39 ميغابايت، والخطوط 1.6 ميغابايت)
مستند من صفحة واحدةمن 4 إلى 7 مللي ثانية، وأقل من 1 ميغابايت من الكومة أثناء التوليد، وملف حجمه 10 كيلوبايت
تقرير قائمة من 1,000 صف (25 صفحة)من 0.54 إلى 0.74 ثانية، و51 ميغابايت ذروة الكومة أثناء التوليد، و0 محتفظ به بعده، وملف حجمه 0.87 ميغابايت
أول مستند بعد البدءيبقى نحو 42 ميغابايت من الكومة لجداول Typst التي تُبنى كسولاً
الخطوط0.25 ميغابايت من الكومة لفهرس الخطوط

بناء debug أبطأ بنحو عشر مرات (استغرق تقرير من 2,000 صف 71 ثانية في showcase).

البريد الإلكتروني

ميزة Cargo المسماة email ترسل الإشعارات عبر lettre.

let mailer = Mailer::from_config(&config)?;
App::new("People").mailer(mailer)

#[action(permission = "people.leave.request", audit)]
pub async fn move_leave(r: TransitionRequest<LeaveRequestId>, store: Leaves, mailer: Mailer, ctx: Ctx) -> Result<LeaveState> {
    let state = leave_flow().apply(&ctx, current, &r.transition, r.comment.as_deref())?;
    mailer.send(&ctx, Mail::transition(&ctx, &manager_email, &employee)?)?;
    Ok(state)
}
  • يولّد Mailer::send الرسالة ويضعها في طابور في الذاكرة (10,000 رسالة على الأكثر) ثم يعود. تُرسل المهمة الخلفية zeyn.mail الطابور فوراً، وتعيد محاولة الرسائل الفاشلة مع تأخير متزايد (خمس مرات ابتداءً من عشر ثوانٍ ما لم يحدد Mailer::retry غير ذلك)، وتنظر في الطابور كل دقيقة. تُسقَط الرسالة، مع خطأ في السجل، بعد ثماني محاولات فاشلة. وكما في كل مهمة، يضيع الطابور الذي لم يُرسل عند توقف العملية؛ والتسليم الدائم يحتاج إلى الطابور المدعوم بقاعدة البيانات في طبقة البيانات.
  • تأتي الرسائل من فهارس i18n: <template>.subject و<template>.text واختيارياً <template>.html، مع وسائط {name}. لغة المستلم هي لغة المرسل ما لم يضبطها Mail::locale. لكل رسالة جزء نص عادي وجزء HTML؛ وفي جزء HTML يُهرَّب كل وسيط، وبدون مدخل .html يكون الـ HTML هو النص المهرَّب. تُزال فواصل الأسطر من الموضوعات، وتُحلَّل العناوين، فلا تستطيع البيانات إضافة ترويسات أو مستلمين.
  • يكتب Mail::transition الانتقال الذي أجراه الاستدعاء للتو بـ Workflow::apply (السجل، من، إلى، من قام به والتعليق) بالقالب المدمج zeyn.mail.transition. يُخطر showcase المدير عندما ينتقل طلب إجازة.
المفتاحالمعنى
mail.fromالمرسل، مثل Northwind <no-reply@example.com> (مطلوب)
mail.transportsmtp أو file (ملف .eml لكل رسالة، للتطوير والاختبارات) أو log (سطر سجل فيه عدد المستلمين والحجم، دون عناوين أو محتوى أبداً؛ وهو الافتراضي)
mail.smtp.host, mail.smtp.portخادم SMTP
mail.smtp.tlsstarttls (افتراضي) أو tls (TLS ضمني، وعادة المنفذ 465) أو none (للمرحّلات المحلية فقط)
mail.smtp.user, mail.smtp.passwordبيانات الاعتماد؛ اقرأ كلمة المرور من مصدر أسرار مثل البيئة
mail.file.dirمجلد وسيلة النقل file

يستخدم TLS مكتبة rustls مع شهادات Mozilla الجذرية (webpki-roots).