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

قناة الوقت الفعلي (Realtime Channel)

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

طلبات 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 service

2. نقاط النهاية (Endpoints)

نقطة النهايةالغرض
GET /_zeyn/live/doc/{kind}/{doc}?csrf=TOKENWebSocket: مستند تعاوني
GET /_zeyn/live/topics?t=a,b&csrf=TOKENWebSocket: إشعارات التغيير للمواضيع (topics)
GET /_zeyn/live.jsسكربت المواضيع، لا تربطه إلا الصفحات التي تشترك

يُفحص كل handshake قبل الترقية (upgrade) بهذا الترتيب:

  1. القناة مفعّلة (وإلا 404).
  2. Origin: يجب أن يسمّي ترويس Origin المضيف الذي وصله الطلب، أو origin أُضيف عبر Realtime::allow_origin. المتصفحات ترسله دائماً في handshake الخاص بـ WebSocket، ويُرفض الطلب إذا غاب. وهذا ما يمنع موقعاً آخر من فتح socket بملفات تعريف الارتباط الخاصة بالمستخدم.
  3. رمز CSRF: لا تستطيع المتصفحات ضبط ترويسات على WebSocket، لذلك يوضع الرمز المأخوذ من meta[name=zeyn-csrf] في معامل الاستعلام csrf ويُقارن في زمن ثابت مع ملف تعريف الارتباط zeyn_csrf، تماماً كما يُفعل مع x-zeyn-csrf في طلبات POST. تُجاب الإخفاقات بـ 403.
  4. الهوية: المصادِق نفسه المستخدم في كل طلب؛ يجب أن يكون المستخدم مسجلاً للدخول (403).
  5. حد كل مستخدم (429).
  6. المستندات: نوع (kind) مُعلَن ومعرّف بسيط (من 1 إلى 64 حرفاً أو رقماً أو - أو _ أو .)، وإلا 404؛ ثم صلاحية القراءة الخاصة بالنوع (403). المواضيع: كل موضوع مُعلَن وصلاحيته ممنوحة (403).
  7. المعترضات (Interceptors): تعمل معترضات التطبيق باستدعاء من النوع Live (قيمة screen هي نوع المستند، وتكون فارغة للمواضيع)، لذا فإن أي قاعدة عامة للتطبيق مثل RequireMfa أو فحص حساب موقوف تشمل القنوات الحية أيضاً. ويُجاب الرفض بحالته.
  8. حدود الغرف: الغرف في الذاكرة والعملاء في كل غرفة (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 من الـ sockets8handshake يُجاب بـ 429
per_room من العملاء32handshake يُجاب بـ 503
rooms في الذاكرة256handshake يُجاب بـ 503
message_bytes من العميل64 KBيُغلق الـ socket
doc_bytes لكل مستند1 MBيُرفض التحديث ويُغلق الـ socket (1009)
queue لكل عميل32 رسالةيُسقط العميل من الغرفة ويُغلق بالرمز 1013
idle غرفة بلا عملاء30 sتُحفظ وتُسقط من الذاكرة
heartbeat20 sping؛ والعميل الصامت لثلاث فترات يُغلق
persist_every2 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 أو وسيط مشترك)، والحضور (من هنا أيضاً، والمؤشرات) غير مبني، والمستند يُرسل كاملاً في كل اتصال.