هذه الصفحة مترجمة من الإنجليزية. اقرأ الصفحة الإنجليزية
طلبات Zeyn عديمة الحالة: لا يحتفظ الخادم بأي socket ولا بأي حالة شاشة خاصة بكل مستخدم. وبعض الشاشات تحتاج إلى أكثر من ذلك: شخصان يحرران مخططاً واحداً، أو قائمة يجب أن تتحدث عندما يحفظ شخص آخر تغييراً. تضيف ميزة Cargo المسماة realtime قناة WebSocket اختيارية لهذا الغرض. التطبيقات التي لا تفعّلها لا تُصرَّف منها أي شيفرة، والصفحات التي لا تستخدمها لا تحمّل شيئاً.
كثيراً ما يُحجب WebSocket في شبكات الشركات، لذا تعامل مع القناة على أنها تحسين: يجب أن تعمل كل شاشة من دونها (محرر المخططات يحفظ عبر action الخاص به، والقائمة تتحدث عندما يفتحها المستخدم من جديد).
1. تفعيلها
zeyn = { version = "...", features = ["realtime"] }let realtime = Realtime::new()
.documents("diagram", "diagram.read", "diagram.write", MyDocStore::new(pool))
.topic("orders", "sales.order.read")
.limits(Limits::default());
let hub = realtime.hub(); // clone it into stores that notify
App::new(..).realtime(realtime) // also registers `Hub` as a service2. نقاط النهاية (Endpoints)
| نقطة النهاية | الغرض |
|---|---|
GET /_zeyn/live/doc/{kind}/{doc}?csrf=TOKEN | WebSocket: مستند تعاوني |
GET /_zeyn/live/topics?t=a,b&csrf=TOKEN | WebSocket: إشعارات التغيير للمواضيع (topics) |
GET /_zeyn/live.js | سكربت المواضيع، لا تربطه إلا الصفحات التي تشترك |
يُفحص كل handshake قبل الترقية (upgrade) بهذا الترتيب:
- القناة مفعّلة (وإلا
404). - Origin: يجب أن يسمّي ترويس
Originالمضيف الذي وصله الطلب، أو origin أُضيف عبرRealtime::allow_origin. المتصفحات ترسله دائماً في handshake الخاص بـ WebSocket، ويُرفض الطلب إذا غاب. وهذا ما يمنع موقعاً آخر من فتح socket بملفات تعريف الارتباط الخاصة بالمستخدم. - رمز CSRF: لا تستطيع المتصفحات ضبط ترويسات على WebSocket، لذلك يوضع الرمز المأخوذ من
meta[name=zeyn-csrf]في معامل الاستعلامcsrfويُقارن في زمن ثابت مع ملف تعريف الارتباطzeyn_csrf، تماماً كما يُفعل معx-zeyn-csrfفي طلباتPOST. تُجاب الإخفاقات بـ403. - الهوية: المصادِق نفسه المستخدم في كل طلب؛ يجب أن يكون المستخدم مسجلاً للدخول (
403). - حد كل مستخدم (
429). - المستندات: نوع (kind) مُعلَن ومعرّف بسيط (من 1 إلى 64 حرفاً أو رقماً أو
-أو_أو.)، وإلا404؛ ثم صلاحية القراءة الخاصة بالنوع (403). المواضيع: كل موضوع مُعلَن وصلاحيته ممنوحة (403). - المعترضات (Interceptors): تعمل معترضات التطبيق باستدعاء من النوع
Live(قيمةscreenهي نوع المستند، وتكون فارغة للمواضيع)، لذا فإن أي قاعدة عامة للتطبيق مثلRequireMfaأو فحص حساب موقوف تشمل القنوات الحية أيضاً. ويُجاب الرفض بحالته. - حدود الغرف: الغرف في الذاكرة والعملاء في كل غرفة (
503معRetry-After).
تُفحص صلاحية الكتابة مرة واحدة لكل اتصال: العميل الذي لا يملكها يتلقى التحديثات لكنه يُغلق بالرمز 1008 لحظة يرسل تحديثاً.
3. المستندات و Y-CRDT
المستندات هي مستندات Y-CRDT تُدار بواسطة yrs. تُفهرس الغرف حسب المستأجر (tenant) والنوع ومعرّف المستند، فلا يشترك مستأجران في غرفة واحدة حتى لو تطابقت المعرّفات.
الإطارات (frames) ثنائية. البايت الأول وسم؛ والقيمة 1 (FRAME_UPDATE) تحمل تحديث Y بترميز v1.
- عند الاتصال يرسل الخادم المستند كاملاً في تحديث واحد.
- يرسل العميل تحديثات لتغييراته؛ ويطبق الخادم كل تحديث (الإطار الذي ليس تحديثاً صالحاً يغلق الـ socket بالرمز
1008) ويمرر البايتات نفسها إلى كل عميل آخر في الغرفة. - بعد كل اتصال (أو إعادة اتصال) ترسل الجزيرة (island) حالتها الكاملة، فتندمج التعديلات التي أُجريت دون اتصال. تحديثات Y متكررة الأثر (idempotent) وتبادلية (commutative)، فلا يهم الترتيب ولا التكرار.
يجري الحفظ الدائم عبر سمة (trait):
pub trait DocStore: Send + Sync + 'static {
fn load(&self, key: RoomKey) -> BoxFuture<Result<Option<Vec<u8>>>>;
fn save(&self, key: RoomKey, state: Vec<u8>) -> BoxFuture<Result<()>>;
}يعمل load عند فتح الغرفة (الخطأ يرفض الاتصال بـ 503، وهذه أيضاً طريقة المخزن في تحديد المستندات الموجودة). ويستقبل save المستند كاملاً في تحديث واحد، ويعمل كل persist_every ما دامت في المستند تغييرات، وقبل إسقاط غرفة خاملة. الحفظ الفاشل يُعاد في الجولة التالية وتبقى الغرفة. MemoryDocStore للاختبارات والعروض التجريبية.
على جانب الجزيرة، توفر zeyn-island مع الميزة realtime الكائن live::Channel:
let channel = Channel::document("diagram", "main", on_open, on_update, on_state)?;
channel.send_update(&update); // after a local change
channel.close(); // in unmountيعيد الاتصال بعد ثانية واحدة، مضاعفاً المهلة حتى 30 ثانية، ويتوقف بعد close أو بعد 1008.
4. المواضيع: شاشات تتحدث في مكانها
Live::new("orders").add(DataGrid::of(search_orders)...)
#[action(permission = "sales.order.write")]
pub async fn save_order(o: Valid<Order>, store: Orders, hub: Hub, ctx: Ctx) -> Result<Order> {
let saved = store.save(o.into_inner())?;
hub.notify(&ctx, "orders");
Ok(saved)
}يعرض Live العنصر div[data-zeyn-live="orders"]. الصفحة التي تحتوي شاشتها على واحد منها تربط /_zeyn/live.js (أما الشاشة التي تصل عبر nav.js فتحصل على x-zeyn-live: 1 ويُستورد السكربت حينها). يفتح السكربت socket واحداً لكل مواضيع الصفحة. عند وصول إشعار يعيد كل عنصر مشترك تحميل جداوله في مكانها (zeyn:reload، بالصفحة الحالية للجدول وترتيبه ومرشحاته)، أو الشاشة كلها في مكانها (zeyn:refresh) إذا لم تحتوِ جدولاً. الإشعارات الواصلة خلال 300 ms تُعالج مرة واحدة.
تحمل الإشعارات اسم الموضوع فقط، ولا تحمل بيانات أبداً: يمر التحديث عبر نقطة نهاية الجدول العادية مع كل فحوص الصلاحيات. يصل notify إلى مستأجر المستدعي فقط؛ وnotify_tenant(Some(id), topic) يسمّي مستأجراً بعينه، وNone يعني التطبيق بلا تعدد مستأجرين.
5. الذاكرة والضغط العكسي (Backpressure)
لكل جزء من الحالة حد (Limits، والقيم الافتراضية بين قوسين):
| الحد | القيمة الافتراضية | عند بلوغه |
|---|---|---|
per_user من الـ sockets | 8 | handshake يُجاب بـ 429 |
per_room من العملاء | 32 | handshake يُجاب بـ 503 |
rooms في الذاكرة | 256 | handshake يُجاب بـ 503 |
message_bytes من العميل | 64 KB | يُغلق الـ socket |
doc_bytes لكل مستند | 1 MB | يُرفض التحديث ويُغلق الـ socket (1009) |
queue لكل عميل | 32 رسالة | يُسقط العميل من الغرفة ويُغلق بالرمز 1013 |
idle غرفة بلا عملاء | 30 s | تُحفظ وتُسقط من الذاكرة |
heartbeat | 20 s | ping؛ والعميل الصامت لثلاث فترات يُغلق |
persist_every | 2 s | تُحفظ المستندات المتغيرة |
لا ينتظر البث أبداً: يُعرض التحديث على طابور كل عميل المحدود بـ try_send. والعميل الذي يمتلئ طابوره يُزال من الغرفة فوراً، فلا يستطيع قارئ بطيء واحد أن يحتجز الذاكرة أو يبطئ الآخرين؛ إنه يعيد الاتصال ويتلقى المستند كاملاً. أما مشترك الموضوع الذي يمتلئ طابوره فيفوته ذلك الإشعار فقط (الإشعار التالي سيحدّثه على أي حال).
لذلك فإن أسوأ استهلاك للذاكرة يقارب rooms * doc_bytes للمستندات زائد clients * queue * message_bytes للطوابير (التحديث الواحد يُشارَك بين الطوابير ولا يُنسخ). خادم العرض التوضيحي (showcase) مع تبويبين حيين على المخطط يستخدم نحو 19 MB في المجموع (بناء debug). ويعرض Hub::stats() الغرف والعملاء والمشتركين وبايتات المستندات للمراقبة.
6. ملاحظات أمنية
- لا تشغّل القناة أي action أبداً: المستندات بيانات يقرؤها التطبيق عبر
DocStoreالخاص به، وهناك يتحقق منها (يقيّد العرض التوضيحي كل مخطط محفوظ بـDiagram::clamped). - يمكن لأي شخص يملك صلاحية القراءة الخاصة بالنوع داخل المستأجر أن يقرأ المستند؛ استخدم أنواعاً منفصلة (أو معرّفات يرفضها المخزن) للمستندات ذات الجمهور المختلف.
- تسمح سياسة كل صفحة بـ WebSocket إلى origin التطبيق نفسه عبر
default-src 'self'؛ ولا يُخفَّف شيء في سياسة أمان المحتوى من أجل القناة.
7. مثال
examples/showcase: افتح Islands، ثم Diagram editor في تبويبين. تحريك شكل أو إضافته في أحد التبويبين يظهر في الآخر، والجدول "Shapes stored on the server" أسفله يتحدث في مكانه خلال نحو ثانيتين، عندما يُحفظ المستند عبر DocStore ويُرسل إشعار إلى الموضوع diagram.
حدود الإصدار الحالي: الغرف تعيش في عملية واحدة (تعدد نسخ الخادم يحتاج إلى جلسات ثابتة sticky sessions أو وسيط مشترك)، والحضور (من هنا أيضاً، والمؤشرات) غير مبني، والمستند يُرسل كاملاً في كل اتصال.