هذه الصفحة مترجمة من الإنجليزية. اقرأ الصفحة الإنجليزية
تشغيل تطبيق 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)ما يحتفظ به الخادم لكل مستخدم، وأين:
| الحالة | أين | مشتركة |
|---|---|---|
| عدادات تحديد المعدل | RateStore | RedisStore |
| عروض الشبكات المحفوظة وتخطيطات الإرساء | DataGridViewStore | RedisStore |
| مهلة الخمول | المتصفح (كل علامات تبويب التطبيق) | لا شيء على الخادم |
| تسجيل الدخول وإعادة المصادقة | وكيل تسجيل الدخول الموحد | لا شيء على الخادم |
| سبب فتح شاشة حساسة | ملف تعريف ارتباط 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)، لأنظمة التتبع الخلفية:
| الامتداد | المستوى | الحقول |
|---|---|---|
request | info | الطريقة، والمسار (دون سلسلة الاستعلام)، والحالة، وms، وعند معرفتها: kind وscreen وaction وuser وtenant وok وerror |
run | info | شيفرة التطبيق: إجراء، أو مصدر بيانات (جلب الشبكة، البحث عن صف، مخزن العروض)، أو بانٍ للشاشة |
interceptor | debug | الاسم (اسم النوع، أو 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.transport | smtp أو file (ملف .eml لكل رسالة، للتطوير والاختبارات) أو log (سطر سجل فيه عدد المستلمين والحجم، دون عناوين أو محتوى أبداً؛ وهو الافتراضي) |
mail.smtp.host, mail.smtp.port | خادم SMTP |
mail.smtp.tls | starttls (افتراضي) أو tls (TLS ضمني، وعادة المنفذ 465) أو none (للمرحّلات المحلية فقط) |
mail.smtp.user, mail.smtp.password | بيانات الاعتماد؛ اقرأ كلمة المرور من مصدر أسرار مثل البيئة |
mail.file.dir | مجلد وسيلة النقل file |
يستخدم TLS مكتبة rustls مع شهادات Mozilla الجذرية (webpki-roots).