> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# كيف تبني تطبيق محادثة مثل WhatsApp لملايين المستخدمين
- URL: https://arabroot.io/articles/كيف-تبني-تطبيق-محادثة-مثل-whatsapp/
- Published: 2026-10-10T08:30:00.000Z
- Updated: 2026-10-10T05:23:31.000Z
- Description: كيف تصل الرسالة من هاتف إلى آخر عبر سيرفرات مختلفة، وكيف تحفظ لمن انقطع اتصاله وتظهر علامات التسليم والقراءة.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, هندسة البرمجيات وتصميم الأنظمة, الشبكات والوصول, الاستضافة الذاتية

عندما ترسل رسالة في تطبيق محادثة تظهر بجانبها علامة واحدة خلال جزء من الثانية، ثم علامتان عندما تصل إلى هاتف صديقك، ثم تتغير ألوانهما عندما يقرؤها، وإذا كان هاتفه مغلقاً في الطائرة وصلته الرسائل كلها بالترتيب عندما يفتحه بعد الهبوط. وقد يبدو هذا من الوهلة الأولى سيرفراً يستقبل الرسالة ويرسلها، لكن في الواقع فخلف هذه العلامات الصغيرة أسئلة صعبة: كيف تبقي ملايين الهواتف متصلة بسيرفراتك طوال اليوم، وكيف تعرف أي سيرفر يحمل اتصال صديقك الآن، وكيف تضمن ألا تضيع رسالة ولا تتكرر ولا يختلف ترتيبها، وكيف تحفظها لمن انقطع اتصاله، وكيف تشفرها بحيث لا يستطيع السيرفر نفسه قراءتها.

وهذا المقال من قسم [دراسات الحالة](https://arabroot.io/start/#stage-case-studies) في خارطة الطريق، ونأخذ فيه WhatsApp مثالاً لأنه نشر أجزاء من قراراته الهندسية في مدونته وفي [الورقة التقنية للتشفير](https://www.whatsapp.com/security/WhatsApp-Security-Whitepaper.pdf?ref=arabroot.io)، ولن نكرر المفاهيم العامة التي شرحناها في مقالات مستقلة، مثل الإشعارات الفورية والطوابير وطريقة عمل مكالمات الفيديو، وإنما نربطها بما يحتاجه تطبيق المحادثة.

[![](https://arabroot.io/content/images/2026/10/push-basics-cover-cover.webp)دليلكيف تعمل الإشعارات الفورية Push Notifications؟كيف يصل الإشعار إلى تطبيق مغلق عبر خدمات Apple وGoogle، وما الذي تراه هذه الخدمات من بيانات مستخدميك.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%B9%D9%85%D9%84-%D8%A7%D9%84%D8%A7%D8%B4%D8%B9%D8%A7%D8%B1%D8%A7%D8%AA-%D8%A7%D9%84%D9%81%D9%88%D8%B1%D9%8A%D8%A9-push-notifications/)[![](https://arabroot.io/content/images/2026/10/queue-concepts-cover-cover.webp)دليلالطابور Queue للمبتدئين: لماذا تحتاجه وكيف يعمللماذا تضع العمل البطيء في طابور بدل أن ينتظره المستخدم، وكيف تضمن ألا تضيع الرسالة إذا توقف ال Worker فجأة.](https://arabroot.io/articles/%D9%85%D9%81%D8%A7%D9%87%D9%8A%D9%85-%D8%A7%D9%84%D8%B7%D9%88%D8%A7%D8%A8%D9%8A%D8%B1-queue-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/webrtc-basics-cover-cover.webp)دليلWebRTC من الداخل: كيف تعمل مكالمات الفيديو في المتصفحكيف تبدأ مكالمة الفيديو بين متصفحين وكيف تشفر وتتكيف مع الشبكة، مع مكالمة حقيقية نقرأ رسائلها وأرقامها سطراً بسطر.](https://arabroot.io/articles/webrtc-%D9%85%D9%86-%D8%A7%D9%84%D8%AF%D8%A7%D8%AE%D9%84-%D9%83%D9%8A%D9%81-%D8%AA%D8%B9%D9%85%D9%84-%D9%85%D9%83%D8%A7%D9%84%D9%85%D8%A7%D8%AA-%D8%A7%D9%84%D9%81%D9%8A%D8%AF%D9%8A%D9%88-%D9%81%D9%8A-%D8%A7%D9%84%D9%85%D8%AA%D8%B5%D9%81%D8%AD/)

وسوف نناقش في هذا المقال ما يلي:

- ما الذي تبنيه بالضبط، وما تقوله أرقام WhatsApp المنشورة عن الحجم.
- المعمارية الكاملة في مخطط واحد، مع الأدوات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو وما يحدث عندما يتعطل كل جزء.
- الاتصال الدائم عبر WebSocket وسيرفرات الاتصال Gateways، وكيف تعرف أين يتصل المستخدم الآن.
- معرف الرسالة وترتيبها داخل المحادثة، ومنع تكرارها عند إعادة الإرسال.
- علامات الإرسال والتسليم والقراءة، والحفظ لمن انقطع اتصاله Store-and-Forward مع الإشعارات.
- رسائل المجموعات: النسخ عند الكتابة Fan-out on Write أم القراءة من سجل مشترك Fan-out on Read.
- الصور والملفات عبر تخزين الكائنات، وحالة الاتصال Presence.
- التشفير بين الطرفين End-to-End Encryption بلمحة سريعة عن بروتوكول Signal.
- تجربة عملية: سيرفرا اتصال خلف Valkey Pub/Sub، ورسالة تعبر بينهما، وصندوق للمستخدم غير المتصل يسلم عند عودته، وعلامات التسليم والقراءة، ثم نوقف أحد السيرفرين فجأة.

## ما الذي تبنيه بالضبط؟

الوظائف الأساسية لتطبيق المحادثة معروفة: محادثة بين شخصين، ومجموعات، وإرسال صور وملفات وتسجيلات صوتية، وعلامات التسليم والقراءة، وحالة الاتصال وآخر ظهور، وإشعار عندما يكون التطبيق مغلقاً، ثم المكالمات الصوتية والمرئية. أما المتطلبات غير الوظيفية فهي التي تجعل التطبيق صعباً:

- زمن وصول قصير Low Latency: الرسالة يجب أن تصل في أقل من ثانية عندما يكون الطرفان متصلين، وهذا يعني أن السيرفر يدفع الرسالة إلى الهاتف ولا ينتظر أن يسأله الهاتف كل عدة ثوان.
- الوثوقية Reliability: الرسالة التي ظهرت عليها علامة واحدة لا تضيع أبداً، حتى لو توقف السيرفر في نفس اللحظة.
- الترتيب Ordering: الرسائل داخل المحادثة الواحدة تظهر لكل الأطراف بنفس الترتيب.
- العمل مع اتصال ضعيف: الهاتف ينتقل بين الشبكات ويفقد الاتصال ويعود، والتطبيق يجب أن يكمل من حيث توقف.
- الخصوصية: المحتوى مشفر بين الطرفين، فالسيرفر يوصل ما لا يستطيع قراءته.

أما الحجم فيكفي رقمان من مصادر WhatsApp نفسها: في 6 يناير 2012 نشر فريقه في [مدونة WhatsApp](https://blog.whatsapp.com/1-million-is-so-2011?ref=arabroot.io) أنه وصل إلى أكثر من مليوني اتصال TCP على سيرفر واحد، يعمل بنظام FreeBSD 8.2 وبرنامج مكتوب بلغة Erlang على معالج فيه 24 نواة وذاكرة 103 جيجابايت، وفي 12 فبراير 2020 أعلن في [تدوينة أخرى](https://blog.whatsapp.com/two-billion-users-connecting-the-world-privately?ref=arabroot.io) أن التطبيق يخدم أكثر من ملياري مستخدم. والرقم الأول هو المهم لنا هنا، لأنه يبين أن المشكلة الأولى في تطبيق المحادثة ليست قوة المعالجة وإنما عدد الاتصالات المفتوحة في نفس الوقت.

## المعمارية الكاملة: كيف تتصل الأجزاء ببعضها؟

قبل أن ندخل في كل جزء بالتفصيل، من المفيد أن ترى النظام كاملاً مرة واحدة، فلديك خمسة أجزاء رئيسية: سيرفرات اتصال Gateways لا تحمل منطقاً وتتوسع أفقياً بعدد الاتصالات، وخدمة رسائل تحفظ وتعطي الأرقام وتوزع، ومخزن للرسائل والصناديق مقسم حسب المحادثة والمستخدم، وسجل حضور سريع بمهلات قصيرة، وطابور للإشعارات يتحدث مع خدمات Apple وGoogle، ثم تخزين الكائنات للملفات. والصورة التالية تبين كيف تتصل هذه الأجزاء، والخط المتصل فيها طلب ينتظر رداً Sync، والخط المتقطع حدث يرسل ولا ينتظر Async:

![هاتفا Alice وBob متصلان باتصال WebSocket عبر Load balancer من نوع HAProxy إلى Gateway gw1 وGateway gw2. ال Gateway يرسل send وack وread إلى Chat service الذي يحفظ الرسالة أولاً في Message store على Cassandra، ويسأل Valkey أين المستلم عبر مفاتيح presence بمهلة، وينشر على قناة Pub/Sub باسم gw، فتصل إلى gw2 الذي يدفعها إلى هاتف Bob عبر اتصاله المفتوح. وإذا كان المستلم غير متصل تدخل مهمة في Push queue على NATS JetStream يقرؤها Push worker ويرسل إلى APNs وFCM فيستيقظ الهاتف. وفي الأعلى يطلب الهاتف رابط رفع من Media API ثم يرفع الملف المشفر مباشرة إلى Object storage على SeaweedFS](https://arabroot.io/content/images/2026/10/chat-app-06-architecture.webp)

الرسالة تحفظ أولاً، ثم تذهب إلى ال Gateway الذي يحمل اتصال المستلم أو إلى طابور الإشعارات، والملفات ترفع مباشرة إلى تخزين الكائنات

وفي [التجربة العملية](#lab) جمعنا ال Gateway وخدمة الرسائل في برنامج واحد حتى يبقى الكود قصيراً، ووضعنا الرسائل والصناديق في Valkey، أما في الإنتاج ففصلهما مفيد، والسبب أن ال Gateway يتوسع بعدد الاتصالات المفتوحة، وخدمة الرسائل تتوسع بعدد الرسائل في الثانية، وتحديث منطق الرسائل لا يجب أن يقطع ملايين الاتصالات.

### المكونات والأدوات المقترحة

| المكون                       | مسؤوليته                                                               | أداة مفتوحة المصدر مقترحة                                                                                                                                       | لماذا هي                                                                                                                                                        |
| ---------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| موزع الحمل Load Balancer     | إنهاء TLS وتوزيع اتصالات WebSocket الطويلة على ال Gateways             | [HAProxy](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-haproxy-load-balancer/) (GPLv2)                                                           | يتحمل عدداً كبيراً من الاتصالات الطويلة، وله مهلة منفصلة للاتصال بعد ترقيته إلى WebSocket، ويسمح بإخراج سيرفر من التوزيع بالتدريج                               |
| سيرفر الاتصال Gateway        | يحمل الاتصالات المفتوحة ويجدد سجل الحضور، ولا يحمل منطق التطبيق        | Node.js مع [ws](https://github.com/websockets/ws?ref=arabroot.io) (MIT)، كما في التجربة                                                                         | الحلقة غير المتزامنة Event Loop تحمل آلاف الاتصالات الخاملة بذاكرة قليلة، والمكتبة صغيرة ومستقرة                                                                |
| خدمة الرسائل Chat Service    | التحقق من العضوية ومنع التكرار والرقم المتسلسل والحفظ والتوزيع Fan-out | كودك أنت بنفس اللغة                                                                                                                                             | هنا منطق منتجك، ولا توجد أداة جاهزة تغني عنه                                                                                                                    |
| سجل الحضور وPub/Sub          | «أين يتصل bob الآن؟» وقناة لكل Gateway                                 | [Valkey](https://github.com/valkey-io/valkey?ref=arabroot.io) (BSD-3-Clause)                                                                                    | مفاتيح في الذاكرة بمهلة TTL وPub/Sub في نفس الأداة، وهو ما استخدمناه في التجربة                                                                                 |
| مخزن الرسائل Message Store   | سجل كل محادثة بالترتيب وصناديق المستخدمين                              | [Apache Cassandra](https://github.com/apache/cassandra?ref=arabroot.io) (Apache-2.0)                                                                            | المفتاح الطبيعي هو رقم المحادثة، وCassandra توزع البيانات على السيرفرات حسب هذا المفتاح، وتزيد سعة الكتابة كلما أضفت سيرفراً، وتنسخ البيانات بين مراكز البيانات |
| طابور الإشعارات Push Queue   | مهام «أيقظ هذا الجهاز» للمستخدمين غير المتصلين                         | [NATS JetStream](https://github.com/nats-io/nats-server?ref=arabroot.io) (Apache-2.0)                                                                           | طابور دائم خفيف يعيد المهمة إذا توقف ال Worker قبل تأكيدها                                                                                                      |
| Push Worker                  | الإرسال إلى APNs وFCM وحذف التوكنات الملغاة                            | [Gorush](https://github.com/appleboy/gorush?ref=arabroot.io) (MIT)                                                                                              | سيرفر جاهز يتحدث مع خدمتي Apple وGoogle، فلا تكتب بروتوكوليهما بنفسك                                                                                            |
| تخزين الملفات Object Storage | الصور والفيديو مشفرة، برفع مباشر عبر رابط موقع                         | [SeaweedFS](https://github.com/seaweedfs/seaweedfs?ref=arabroot.io) (Apache-2.0)                                                                                | واجهة متوافقة مع S3، ومصمم لعدد كبير جداً من الملفات الصغيرة                                                                                                    |
| الهوية والتوكنات             | تسجيل الجهاز وإصدار التوكن الذي يتحقق منه ال Gateway عند الاتصال       | [Keycloak](https://github.com/keycloak/keycloak?ref=arabroot.io) (Apache-2.0)                                                                                   | توكنات OIDC قياسية يتحقق منها ال Gateway محلياً بالمفتاح العام دون أن يسأل سيرفراً آخر مع كل اتصال                                                              |
| المراقبة                     | عدد الاتصالات لكل Gateway وزمن التوصيل وطول الصناديق والطوابير         | [Prometheus](https://github.com/prometheus/prometheus?ref=arabroot.io) (Apache-2.0) مع [Grafana](https://github.com/grafana/grafana?ref=arabroot.io) (AGPL-3.0) | المعيار المفتوح لجمع المقاييس ورسمها، مع تنبيه عندما يكبر صندوق أو طابور                                                                                        |

### نموذج البيانات

في التجربة كل شيء مفاتيح في Valkey: `msg:<id>` و`log:<conv>` و`inbox:<user>` و`conv:<conv>:members`، وفي الإنتاج تنتقل البيانات الدائمة إلى Cassandra بنفس الأسماء تقريباً، ويبقى في Valkey ما يتغير كل ثوان. والجداول التالية بلغة CQL الخاصة ب Cassandra:

```sql
-- the conversation history (lab: log:<conv> and msg:<id>), one partition per conversation, ordered by seq
CREATE TABLE messages (
    conv_id       text,
    seq           bigint,          -- the number inside the conversation (lab: INCR seq:<conv>)
    sender        text,
    client_msg_id text,            -- the id the phone created, kept for deduplication
    body          blob,            -- end-to-end encrypted: the server cannot read it
    sent_at       timestamp,
    PRIMARY KEY ((conv_id), seq)
) WITH CLUSTERING ORDER BY (seq DESC);

-- what a user's devices have not confirmed yet (lab: inbox:<user>), deleted on ack, 30 days at most
CREATE TABLE inbox (
    user_id  text,
    added_at timestamp,
    item_id  text,                 -- 'alice-bob:42' or a receipt id
    frame    blob,
    PRIMARY KEY ((user_id), added_at, item_id)
) WITH default_time_to_live = 2592000;

CREATE TABLE conversation_members (   -- lab: conv:<conv>:members
    conv_id text, user_id text, role text, joined_at timestamp,
    PRIMARY KEY ((conv_id), user_id)
);

CREATE TABLE devices (                -- where to send the push, and which public keys belong to which device
    user_id text, device_id text, platform text, push_token text, identity_key blob,
    PRIMARY KEY ((user_id), device_id)
);

-- Valkey (short-lived, rebuilt by itself):
--   presence:<user>              = gw2         TTL 30 s, renewed every 10 s
--   dedup:<user>:<clientMsgId>   = alice-bob:1 TTL 24 h
--   channel gw:<gateway>                       Pub/Sub, one per gateway
```

ولاحظ نقطتين في هذا النموذج: الأولى أن العداد `seq` في التجربة أمر `INCR` في Valkey، وهو في الإنتاج يجب أن يكون دائماً، فإما أن تملك كل محادثة خدمة واحدة تعطي أرقامها، أو تزيده في جدول المحادثة بتحديث مشروط Lightweight Transaction. والثانية أن الحذف المتكرر في Cassandra يترك علامات حذف Tombstones تبطئ القراءة، لذلك تحفظ أنظمة كثيرة لكل جهاز آخر رقم سلمه في كل محادثة بدل أن تحذف كل عنصر من الصندوق.

### الواجهات الرئيسية

| النوع     | المسار أو الإطار                                | ماذا يفعل                                                                                                                                                             |
| --------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| WSS       | /v1/connect مع التوكن في الترويسة               | يفتح الاتصال الدائم، فيجدد ال Gateway سجل الحضور ويرسل ما في الصندوق بالترتيب. وفي التجربة يرسل الاسم في الرابط دون تحقق، أما في الإنتاج فلا يقبل اتصال دون توكن صالح |
| إطار send | {conv, clientMsgId, text}                       | يرسل رسالة، ويرد السيرفر sent بالمعرف والرقم، وclientMsgId هو مفتاح منع التكرار Idempotency Key، فنفس المعرف يعيد نفس الرد                                            |
| إطار ack  | {id}                                            | الجهاز حفظ العنصر، فيحذف من الصندوق ويرسل إيصال التسليم delivered إلى المرسل                                                                                          |
| إطار read | {conv, upTo}                                    | إيصال قراءة واحد حتى رقم معين بدل إيصال لكل رسالة                                                                                                                     |
| GET       | /v1/conversations/{conv}/messages?after\_seq=41 | يملأ الفجوة عندما يرى الجهاز الرقم 43 قبل 42، أو عند إضافة جهاز جديد                                                                                                  |
| POST      | /v1/media/uploads                               | يعطي رابط رفع موقعاً Presigned URL لمدة قصيرة، والملف يرفع مشفراً إلى تخزين الكائنات مباشرة                                                                           |
| PUT       | /v1/devices/{deviceId}/push-token               | يسجل توكن APNs أو FCM للجهاز، ويحدثه عندما يتغير                                                                                                                      |

### تقدير السعة على ظهر ورقة

الأرقام التالية تقدير لنظام افتراضي، وطريقة الحساب هي المهمة وليست الأرقام نفسها، وقد شرحنا هذا النوع من التقدير في [مدخل إلى تصميم الأنظمة](https://arabroot.io/articles/%D9%85%D8%AF%D8%AE%D9%84-%D8%A7%D9%84%D9%89-%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D8%A7%D9%84%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D9%85%D9%86-%D8%B3%D9%8A%D8%B1%D9%81%D8%B1-%D9%88%D8%A7%D8%AD%D8%AF-%D8%A7%D9%84%D9%89-%D9%85%D9%84%D8%A7%D9%8A%D9%8A%D9%86/#estimation). نفترض ما يلي، وكلها افتراضات وليست أرقام WhatsApp:

- مليون مستخدم نشط في اليوم، يرسل كل منهم 40 رسالة في اليوم.
- ساعة الذروة أعلى من المتوسط بخمس مرات، و20% من المستخدمين متصلون في نفس اللحظة وقت الذروة.
- الرسالة مع بياناتها الوصفية 300 بايت، و10% من الرسائل تحمل ملفاً متوسط حجمه 200 كيلوبايت، ونصف الرسائل تصل إلى مستلم غير متصل فتحتاج إشعاراً.

```text
messages per day    = 1,000,000 users x 40          = 40,000,000
average per second  = 40,000,000 / 86,400           ~ 463 messages/s
peak per second     = 463 x 5                       ~ 2,300 messages/s

writes per message  = store 1 + inbox 1 + delete on ack 1 + delivered receipt 2 = 5
peak writes         = 2,300 x 5                     ~ 11,500 writes/s

open connections    = 1,000,000 x 20%               = 200,000 WebSockets
presence renewals   = 200,000 / 10 s                = 20,000 SET/s on Valkey

text storage        = 40,000,000 x 300 B            = 12 GB/day   ~ 4.4 TB/year
with 3 replicas     = 4.4 TB x 3                    ~ 13 TB/year

media               = 4,000,000 files x 200 KB      = 800 GB/day  ~ 292 TB/year
media upload        = 800 GB x 8 / 86,400 s         ~ 74 Mbit/s average, ~ 370 Mbit/s at peak

push notifications  = 20,000,000 / 86,400 x 5       ~ 1,160 pushes/s at peak
```

وفي الحساب أعلاه لاحظ التالي:

- أكبر مصدر للكتابة ليس الرسائل وإنما تجديد سجل الحضور: 20 ألف أمر في الثانية لمئتي ألف اتصال، وهذا خفيف على Valkey لأنه في الذاكرة، لكنه يعني أن تختار مدة التجديد بعناية، فالتجديد كل دقيقة بمهلة ثلاث دقائق يقسم الرقم على ستة، والثمن أن اكتشاف ال Gateway الذي توقف يتأخر.
- مئتا ألف اتصال تكفيها أربعة Gateways إذا افترضت 50 ألف اتصال لكل سيرفر، وهذا افتراض متحفظ بعيد عن رقم WhatsApp في 2012، فقس الرقم الحقيقي على سيرفرك باختبار أحمال، وأضف سيرفرين احتياطاً حتى تستطيع تحديث أحدها دون ضغط على البقية.
- الملفات أكبر من النص بأكثر من ستين ضعفاً في التخزين، لذلك فقرار مدة حفظ الملفات على السيرفر يؤثر في التكلفة أكثر من أي قرار آخر، وإذا حذفت الرسالة بعد تسليمها كما يفعل WhatsApp، فلن يبقى من ال 13 تيرابايت إلا ما لم يسلم بعد.

### مراحل النمو

| الجزء           | البداية MVP (سيرفر أو اثنان)                          | النمو (آلاف المتصلين في نفس الوقت)                            | الحجم الكبير (ملايين)                                         |
| --------------- | ----------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------- |
| الاتصالات       | برنامج واحد فيه ال Gateway والمنطق، خلف Reverse Proxy | عدة Gateways خلف HAProxy، وخدمة رسائل منفصلة                  | مئات ال Gateways في عدة مناطق، والهاتف يتصل بأقربها           |
| البيانات        | PostgreSQL واحدة للرسائل والأعضاء، وValkey واحد       | نسخة قراءة Read Replica، وجدول رسائل مقسم بالزمن Partitioning | Cassandra بعدة مراكز بيانات، والمحادثة هي مفتاح التقسيم       |
| الحضور وPub/Sub | Valkey واحد                                           | Valkey مع نسخة احتياطية Replica ومراقبة                       | عنقود Valkey Cluster، أو توجيه مباشر بين ال Gateways دون وسيط |
| الإشعارات       | استدعاء APNs وFCM من نفس البرنامج                     | طابور وWorker منفصل                                           | Workers لكل منطقة وحدود إرسال لكل تطبيق                       |
| الملفات         | مجلد على القرص أو تخزين S3 جاهز                       | تخزين كائنات مستقل بروابط موقعة                               | تخزين في عدة مناطق وCDN للتحميل                               |

ولا تقم ببناء Cassandra وعدة مناطق والتوجيه المباشر بين ال Gateways في اليوم الأول، والسبب أن سيرفراً واحداً يحمل عشرات الآلاف من اتصالات WebSocket، وما تحتاجه من اليوم الأول هو الأفكار التي لا تتغير مع الحجم: الحفظ قبل التوصيل، ومعرف من الكلاينت ضد التكرار، ورقم متسلسل داخل المحادثة، وتأكيد من الجهاز قبل الحذف. وقد يتساءل البعض: هل أحتاج هذا لتطبيق دعم فني أو محادثة داخلية لشركة؟ والإجابة لا، فمنصات جاهزة مثل [Mattermost](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-mattermost-%D9%88%D8%AA%D8%B1%D9%82%D9%8A%D8%AA%D9%87/) تكفي محادثة الشركة.

### ماذا لو تعطل جزء؟

| ماذا لو تعطل…؟        | الأثر                                                                                        | الحل                                                                                                                                                              |
| --------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Gateway               | تنقطع اتصالاته، ويبقى سجل الحضور يشير إليه حتى تنتهي مهلته، فتضيع النسخة المرسلة عبر Pub/Sub | الحفظ قبل التوصيل، فالرسالة في الصندوق، والتطبيق يعيد الاتصال بأي Gateway آخر فيستلمها، وهذا ما سوف تراه في [التجربة العملية](#lab) عندما نوقف أحد السيرفرين فجأة |
| Valkey                | لا يعرف أحد أين المستخدمون، ولا تصل الرسائل لحظياً                                           | نسخة احتياطية مع Sentinel للانتقال التلقائي، والرسائل محفوظة في المخزن فتسلم عند إعادة الاتصال، وسجل الحضور يعاد بناؤه وحده من تجديد ال Gateways                  |
| سيرفر من مخزن الرسائل | المحادثات التي على ذلك السيرفر تفقد نسخة من بياناتها                                         | ثلاث نسخ لكل بيانات، والكتابة والقراءة بمستوى اتساق QUORUM، فيتحمل النظام غياب سيرفر واحد دون توقف                                                                |
| APNs أو FCM           | المستخدمون غير المتصلين لا يعرفون أن لديهم رسائل                                             | المهام تبقى في الطابور وتعاد بانتظار متزايد، والرسائل نفسها في الصندوق فلا يضيع شيء، وتظهر عند فتح التطبيق                                                        |
| موزع الحمل            | لا يتصل أحد                                                                                  | اثنان بعنوان IP عائم Floating IP عبر Keepalived، أو موزع في كل منطقة                                                                                              |
| تخزين الملفات         | الصور لا ترفع ولا تحمل، والنص يعمل                                                           | الرسالة النصية لا تعتمد على الملف، والتطبيق يعيد الرفع لاحقاً، وفصل الملفات عن المحادثة هو ما يجعل هذا العطل محدوداً                                              |

## الاتصال الدائم وسيرفرات الاتصال Gateways

الحل البسيط أن يسأل التطبيق السيرفر كل ثانيتين «هل لدي رسائل جديدة؟»، وهذا ما يسمى الاستطلاع Polling، وهو يعمل مع عشرة مستخدمين، لكنه يعني مع مليون مستخدم نصف مليون طلب في الثانية أغلبها يعود فارغاً، ويستهلك بطارية الهاتف، ويؤخر الرسالة حتى السؤال التالي. والطريقة الصحيحة أن يفتح التطبيق اتصالاً دائماً واحداً مع السيرفر ويبقيه مفتوحاً، فيدفع السيرفر الرسالة فور وصولها، والمعيار المستخدم في الويب والتطبيقات هو WebSocket الموصوف في [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455?ref=arabroot.io)، وهو يبدأ كطلب HTTP عادي ثم يتحول إلى قناة في الاتجاهين، وقد شرحنا ما يحتاجه خلف ال Reverse Proxy من مهلات وترويسات في [تطبيقك خلف Reverse Proxy](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%83-%D8%AE%D9%84%D9%81-reverse-proxy/#websocket-sse).

والسيرفر الذي يحمل هذه الاتصالات يسمى سيرفر الاتصال أو ال Gateway، وهو الذي رأيته في قسم المعمارية الكاملة أعلاه، ووظيفته بسيطة: يحفظ الاتصالات المفتوحة، ويستقبل منها، ويرسل إليها، ولا يحمل منطق التطبيق. ولأن السيرفر الواحد له حد من الاتصالات فسوف تحتاج عدداً منها خلف موزع حمل، وهنا تظهر المشكلة الأساسية في التصميم: Alice متصلة بال Gateway الأول، وBob متصل بالثاني، والسيرفر الأول لا يعرف شيئاً عن اتصالات الثاني، فكيف تصل الرسالة؟ والحل أن يكون هناك سجل للحضور Presence يقول أين يتصل كل مستخدم الآن، وقناة لكل Gateway ينشر عليها غيره ما يريد إيصاله إليه، والصورة التالية تبين الخطوات:

![هاتفا Alice وBob متصلان عبر موزع حمل باتصال WebSocket دائم، Alice بال Gateway gw1 وBob بال Gateway gw2. الخطوة 1 الاتصال، والخطوة 2 حفظ الرسالة في Message store برقم داخل المحادثة وصندوق للمستلم، والخطوة 3 سؤال Presence الذي يقول bob على gw2 بمهلة 30 ثانية، والخطوة 4 النشر على Pub/Sub في القناة gw:gw2، والخطوة 5 وصولها إلى gw2، والخطوة 6 إرسالها إلى هاتف Bob عبر اتصاله المفتوح. وعلى اليمين مربع يشرح أن كل هاتف يبقي اتصالاً مفتوحاً طوال اليوم وأن كل Gateway يعرف اتصالاته فقط](https://arabroot.io/content/images/2026/10/chat-app-01-gateways.webp)

كل Gateway يعرف اتصالاته فقط، وسجل الحضور يقول أين المستخدم الآن

وفي الإعداد أعلاه لاحظ التالي:

- سجل الحضور مفتاح بمهلة قصيرة مثل 30 ثانية، يجدده ال Gateway كل عشر ثوان ما دام الاتصال مفتوحاً، والسبب أن ال Gateway قد يتوقف فجأة دون أن يحذف مفاتيحه، فتنتهي المفاتيح وحدها بعد نصف دقيقة.
- القناة لكل Gateway وليس لكل مستخدم، فالسيرفر يشترك في قناة واحدة باسمه بدل مليون قناة، وهذا ما سوف تراه في [التجربة العملية](#lab) بقناتي `gw:gw1` و`gw:gw2`.
- الرسالة تحفظ قبل أي محاولة توصيل، والسبب أن ال Pub/Sub في Valkey وRedis يرسل لمن يستمع الآن فقط ولا يحفظ شيئاً، كما توضح [وثائق Valkey](https://valkey.io/topics/pubsub/?ref=arabroot.io)، فإذا ضاعت الرسالة في الطريق تبقى نسختها المحفوظة.
- موزع الحمل أمام اتصالات طويلة يختلف عن موزع الطلبات العادية، فعندما تضيف Gateway جديداً لن تنتقل إليه الاتصالات القديمة وحدها، وعندما تريد تحديث Gateway عليك أن تغلق اتصالاته بالتدريج حتى لا يعيد مليون هاتف الاتصال في نفس الثانية.

## معرف الرسالة وترتيبها، ومنع تكرارها

كل رسالة تحتاج معرفين: الأول ينشئه الهاتف قبل الإرسال Client Message ID، والثاني يعطيه السيرفر بعد الحفظ. والسبب في الأول أن الهاتف قد يرسل الرسالة ثم ينقطع الاتصال قبل أن يصله التأكيد، فلا يعرف هل وصلت أم لا، فيعيد إرسالها، ولو لم يكن هناك معرف ثابت من الكلاينت لحفظ السيرفر نسختين وظهرت الرسالة مرتين عند صديقك. والحل أن يحفظ السيرفر لكل مرسل المعرفات التي استلمها مدة معقولة، فإذا جاء نفس المعرف مرة ثانية أعاد نفس الرد دون أن يحفظ شيئاً جديداً، وهذه فكرة ال Idempotency التي شرحناها في [تصميم واجهات API](https://arabroot.io/articles/%D8%AA%D8%B5%D9%85%D9%8A%D9%85-%D9%88%D8%A7%D8%AC%D9%87%D8%A7%D8%AA-api-rest-%D9%88-grpc-%D9%88-graphql/#idempotency).

أما الترتيب فقد يتساءل البعض: لماذا لا نرتب الرسائل بوقت الإرسال؟ والإجابة أن ساعة كل هاتف مختلفة، وساعات السيرفرات نفسها لا تتطابق تماماً كما شرحنا في [الأنظمة الموزعة](https://arabroot.io/articles/%D8%A7%D9%84%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D9%84%D9%85%D9%88%D8%B2%D8%B9%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86-cap-%D9%88%D8%A7%D9%84%D8%A7%D8%AA%D8%B3%D8%A7%D9%82-%D9%88%D8%A7%D9%84%D8%A7%D8%AC%D9%85%D8%A7%D8%B9/#time)، والحل العملي أن يعطي السيرفر كل رسالة رقماً متسلسلاً داخل المحادثة نفسها Sequence Number، فالرسالة 41 قبل 42 دائماً في كل الأجهزة، والجهاز يعرف أن لديه فجوة إذا وصلته 43 قبل 42 فيطلب ما فاته. ولاحظ أن الترتيب مطلوب داخل المحادثة الواحدة فقط، وليس بين كل رسائل النظام، وهذا يجعل المشكلة أسهل كثيراً، لأن كل محادثة يمكن أن تكون على سيرفر مختلف بعداد مستقل. وإذا احتجت معرفاً عاماً فريداً لكل رسالة في النظام كله فهناك طرق مثل Snowflake شرحناها في [المعرفات الفريدة](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D8%B1%D9%81%D8%A7%D8%AA-%D8%A7%D9%84%D9%81%D8%B1%D9%8A%D8%AF%D8%A9-uuid-%D9%88-ulid-%D9%88-snowflake/#snowflake).

## علامات الإرسال والتسليم والقراءة

العلامات الثلاث ليست ألواناً لنفس الحدث، وإنما ثلاثة أحداث مختلفة يرسل كل منها في وقت مختلف، والصورة التالية تبين الترتيب:

![مخطط تسلسلي بين Alice والسيرفر وBob. Alice ترسل send مع clientMsgId c-001، والسيرفر يرد sent مع id alice-bob:41 فتظهر علامة واحدة. السيرفر يرسل الرسالة إلى Bob، وجهاز Bob يرد ack بعد حفظها، فيرسل السيرفر delivered إلى Alice فتظهر علامتان. ثم يفتح Bob المحادثة فيرسل read حتى الرقم 41، فيصل إلى Alice وتظهر علامتان باللون الأزرق](https://arabroot.io/content/images/2026/10/chat-app-02-receipts.webp)

علامة واحدة من السيرفر، والثانية من جهاز المستقبل، والقراءة حدث مستقل برقم آخر رسالة

- علامة واحدة Sent: السيرفر حفظ الرسالة وأعطاها رقمها، ومن هذه اللحظة أصبحت مسؤولية السيرفر.
- علامتان Delivered: جهاز المستقبل نفسه أكد أنه حفظ الرسالة، وليس فقط أن السيرفر أرسلها، والسبب أن الإرسال قد يضيع في اتصال ينقطع، أما تأكيد الجهاز Ack فيعني أنها وصلت فعلاً.
- القراءة Read: يرسلها المستقبل عندما يفتح المحادثة، وترسل مرة واحدة برقم آخر رسالة قرأها بدل إيصال لكل رسالة، فإذا فتح محادثة فيها خمسون رسالة جديدة أرسل إيصالاً واحداً.

ولاحظ أن الإيصالات نفسها رسائل صغيرة تمر بنفس الطريق، فإذا كان المرسل غير متصل عندما قرأ صديقه الرسالة فإن الإيصال يحفظ له حتى يعود، تماماً مثل أي رسالة.

## المستقبل غير متصل: الحفظ ثم التوصيل Store-and-Forward

الهاتف في الجيب غالباً لا يحمل اتصالاً مفتوحاً، فنظاما iOS وAndroid يوقفان التطبيقات في الخلفية لتوفير البطارية، لذلك لا تعتمد على اتصال WebSocket لتوصيل الرسائل للتطبيق المغلق. والطريقة المستخدمة أن يكون لكل مستخدم صندوق وارد Inbox على السيرفر، تدخله كل رسالة موجهة إليه، ولا تحذف منه إلا عندما يؤكد جهازه استلامها، فإذا لم يكن متصلاً يرسل السيرفر إشعاراً عبر خدمة Apple أو Google من خلال طابور الإشعارات وال Push Worker الذي رأيته في مخطط المعمارية أعلاه، فيستيقظ التطبيق ويتصل ويسحب ما في صندوقه بالترتيب، والصورة التالية تبين ذلك:

![Alice ترسل إلى Gateway، والرسالة تحفظ في صندوق INBOX OF BOB الذي فيه alice-bob:42 وalice-bob:43 ويبقى فيه كل شيء حتى يؤكد الجهاز. ال Gateway يسأل Presence فيجد أن bob غير متصل، فيضع مهمة في Push queue تذهب إلى APNs وFCM برسالة رسالة جديدة، فيستيقظ هاتف Bob ويتصل من جديد ويسحب الصندوق بالترتيب ويؤكد كل عنصر](https://arabroot.io/content/images/2026/10/chat-app-03-offline.webp)

الصندوق يحفظ والإشعار يوقظ، ولا يحذف شيء قبل أن يؤكد الجهاز

وهنا ثلاث ملاحظات مهمة:

- الإشعار ليس مكان تخزين، فبحسب [وثائق Apple عن APNs](https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns?ref=arabroot.io) إذا كان الجهاز غير متصل تحفظ الخدمة إشعاراً واحداً فقط لكل تطبيق، وغالباً الأخير، وحجم الإشعار لا يتجاوز 4 كيلوبايت، لذلك الإشعار يقول «لديك رسائل» والرسائل نفسها تأتي من صندوقك عندما يتصل التطبيق. وقد شرحنا خدمات الإشعارات وما يمر عبرها في [كيف تعمل الإشعارات الفورية](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%B9%D9%85%D9%84-%D8%A7%D9%84%D8%A7%D8%B4%D8%B9%D8%A7%D8%B1%D8%A7%D8%AA-%D8%A7%D9%84%D9%81%D9%88%D8%B1%D9%8A%D8%A9-push-notifications/).
- الرسائل في الصندوق لا تبقى للأبد، وتذكر [سياسة خصوصية WhatsApp](https://www.whatsapp.com/legal/privacy-policy?ref=arabroot.io) أن الرسالة التي لا يمكن توصيلها تحفظ مشفرة على سيرفراتها حتى 30 يوماً أثناء محاولة التوصيل.
- التوصيل هنا مرة واحدة على الأقل At-least-once، أي أن الجهاز قد يستلم نفس الرسالة مرتين إذا انقطع الاتصال بعد الاستلام وقبل التأكيد، لذلك يتجاهل الجهاز أي رسالة يعرف معرفها، وقد شرحنا لماذا لا يوجد توصيل مرة واحدة بالضبط في [مفاهيم الطوابير](https://arabroot.io/articles/%D9%85%D9%81%D8%A7%D9%87%D9%8A%D9%85-%D8%A7%D9%84%D8%B7%D9%88%D8%A7%D8%A8%D9%8A%D8%B1-queue-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#delivery).

## رسائل المجموعات: Fan-out on Write أم Fan-out on Read؟

رسالة في مجموعة فيها خمسون عضواً يجب أن تصل إلى خمسين صندوقاً، وهناك طريقتان لذلك، والصورة التالية تقارن بينهما:

![لوحتان. اليسرى FAN-OUT ON WRITE: المرسل يرسل إلى السيرفر الذي يكتب نسخة في inbox:bob وinbox:carol وinbox:dana وحتى N عضو، والقراءة رخيصة لأن كل جهاز يقرأ صندوقه، لكن رسالة إلى ألف عضو تعني ألف عملية كتابة، وتناسب المحادثات الخاصة والمجموعات الصغيرة. اليمنى FAN-OUT ON READ: السيرفر يكتب مرة واحدة في group log فيه الرسائل 9812 و9813 و9814، والأجهزة تطلب ما بعد 9813، وتناسب القنوات الكبيرة والبث](https://arabroot.io/content/images/2026/10/chat-app-04-fanout.webp)

النسخ عند الكتابة للمجموعات الصغيرة، والسجل المشترك للقنوات الكبيرة

النسخ عند الكتابة Fan-out on Write يعني أن السيرفر يكتب نسخة في صندوق كل عضو لحظة الإرسال، فتبقى القراءة بسيطة ونفس طريق المحادثة الخاصة، والثمن أن الرسالة الواحدة تتحول إلى عدد من الكتابات بعدد الأعضاء. أما القراءة من سجل مشترك Fan-out on Read فتكتب الرسالة مرة واحدة في سجل المجموعة، وكل جهاز يطلب ما بعد آخر رقم رآه، وهذا يناسب القنوات التي فيها آلاف أو ملايين المتابعين، والثمن أن السجل يصبح ساخناً عندما يقرؤه الجميع، وأن معرفة من استلم ومن لم يستلم تصبح أصعب. وأغلب التطبيقات تجمع الطريقتين: نسخ للمجموعات الصغيرة، وسجل للقنوات الكبيرة، وهذه نفس المشكلة التي واجهناها في دردشة البث المباشر في [تطبيق البث المباشر](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D8%A8%D8%AB-%D9%85%D8%A8%D8%A7%D8%B4%D8%B1-%D9%85%D8%AB%D9%84-jaco-%D9%88-twitch/#chat) حيث تنسخ رسالة واحدة لآلاف المشاهدين.

ويضيف التشفير بين الطرفين بعداً آخر لهذه المسألة، فبحسب [الورقة التقنية لWhatsApp](https://www.whatsapp.com/security/WhatsApp-Security-Whitepaper.pdf?ref=arabroot.io) يولد المرسل في المجموعة مفتاحاً اسمه Sender Key ويوزعه على أجهزة الأعضاء عبر الجلسات المشفرة بين كل جهازين، ثم يشفر كل رسالة جماعية مرة واحدة بهذا المفتاح، فيقوم السيرفر بالنسخ إلى الأعضاء Server-side Fan-out دون أن يقرأ المحتوى، وعندما يغادر عضو المجموعة يمسح كل الأعضاء مفاتيحهم ويبدؤون من جديد.

## الصور والملفات

الصورة أو الفيديو لا يمر عبر اتصال المحادثة، والسبب أن ملفاً حجمه عشرون ميجابايت يحجز الاتصال ويؤخر كل الرسائل خلفه، لذلك يرفع الكلاينت الملف إلى تخزين الكائنات Object Storage مباشرة عبر رابط موقع Presigned URL كما شرحنا في [أنواع التخزين](https://arabroot.io/articles/%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D8%A7%D9%84%D8%AA%D8%AE%D8%B2%D9%8A%D9%86-block-%D9%88-file-%D9%88-object-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#presigned)، ثم يرسل في المحادثة رسالة صغيرة فيها مؤشر إلى الملف. ومع التشفير بين الطرفين تصف [الورقة التقنية](https://www.whatsapp.com/security/WhatsApp-Security-Whitepaper.pdf?ref=arabroot.io) الخطوات كما يلي: يولد جهاز المرسل مفتاح AES256 مؤقتاً ومفتاح HMAC-SHA256، ويشفر الملف قبل رفعه، ثم يرسل في الرسالة المشفرة العادية المفتاحين وهاش SHA256 للملف المشفر ومكانه في التخزين، فيحمله المستقبل ويتحقق منه ويفك تشفيره، والتخزين نفسه لا يرى إلا بايتات مشفرة. ولاحظ أن الصورة المصغرة Thumbnail ترسل داخل الرسالة نفسها حتى تظهر فوراً قبل تحميل الملف الكامل.

## حالة الاتصال Presence وآخر ظهور

عبارة «متصل الآن» تبدو بسيطة، لكن الحل البسيط أن تبلغ كل جهات اتصالك كلما فتحت التطبيق أو أغلقته، ومستخدم لديه 500 جهة اتصال يفتح التطبيق ويغلقه عشرات المرات في اليوم ينتج آلاف الرسائل التي لا يقرؤها أحد. والطريقة الأخف أن يشترك الجهاز في حالة شخص معين فقط عندما تكون محادثته مفتوحة على الشاشة، ويلغي الاشتراك عندما يغلقها، فلا ترسل حالة الاتصال إلا لمن ينظر إليها الآن. وسجل الحضور الذي يستخدمه السيرفر للتوجيه يختلف عن «آخر ظهور» الذي يراه الناس، فالأول داخلي سريع التغير بمهلة ثوان، والثاني إعداد خصوصية يتحكم فيه المستخدم.

## التشفير بين الطرفين بلمحة سريعة

في التشفير العادي بين الهاتف والسيرفر عبر TLS يفك السيرفر التشفير ويقرأ الرسالة ثم يشفرها من جديد للمستقبل، أما في التشفير بين الطرفين End-to-End Encryption فالمفاتيح الخاصة لا تخرج من الأجهزة، والسيرفر ينقل نصاً مشفراً لا يستطيع فتحه. وتقول [الورقة التقنية لWhatsApp](https://www.whatsapp.com/security/WhatsApp-Security-Whitepaper.pdf?ref=arabroot.io) إن تشفيره مبني على بروتوكول Signal، والصورة التالية تبين الفكرة دون تفاصيل الرياضيات:

![جهاز Alice وجهاز Bob ولكل منهما مفتاح خاص لا يخرج منه، وفي الوسط Chat server. الخطوة 1 جهاز Bob يرفع مفاتيحه العامة المسبقة identity وsigned وone-time إلى السيرفر، والخطوة 2 جهاز Alice يأخذها وينشئ جلسة بطريقة X3DH أو PQXDH، والخطوة 3 الرسائل تمر عبر السيرفر نصاً مشفراً فقط. وفي الأسفل مربع DOUBLE RATCHET يقول مفتاح جديد لكل رسالة وسرقة مفتاح لا تفتح الرسائل الأقدم، ومربع برتقالي بما يراه السيرفر رغم ذلك: من يتحدث مع من ومتى، وحجم كل رسالة، وأرقام الهواتف والمجموعات](https://arabroot.io/content/images/2026/10/chat-app-05-e2ee.webp)

المفاتيح على الأجهزة، والسيرفر ينقل نصاً مشفراً، لكنه ما زال يرى البيانات الوصفية

والبروتوكول موصوف في [وثائق Signal](https://signal.org/docs/?ref=arabroot.io) في عدة مواصفات، أهمها لهذا المقال ثلاث:

- إنشاء الجلسة عبر [X3DH](https://signal.org/docs/specifications/x3dh/?ref=arabroot.io)، ونسخته الأحدث [PQXDH](https://signal.org/docs/specifications/pqxdh/?ref=arabroot.io) التي تضيف حماية من الحواسيب الكمية المستقبلية للسرية الأمامية، والفكرة أن جهاز Bob يرفع مسبقاً إلى السيرفر حزمة من مفاتيحه العامة، فتستطيع Alice أن تبدأ جلسة مشفرة وترسل أول رسالة وBob غير متصل أصلاً، وهذا مهم في تطبيق محادثة حيث الطرف الآخر نائم غالباً.
- تشفير الرسائل عبر [Double Ratchet](https://signal.org/docs/specifications/doubleratchet/?ref=arabroot.io)، حيث يشتق مفتاح جديد لكل رسالة، فسرقة مفتاح اليوم لا تفتح رسائل الأمس، وهذا ما يسمى السرية الأمامية Forward Secrecy.
- إدارة الجلسات بين عدة أجهزة عبر Sesame، فلكل جهاز مفاتيحه، والرسالة تشفر لكل جهاز من أجهزة المستقبل. وقد ذكرت Meta في [تدوينة دعم الأجهزة المتعددة](https://engineering.fb.com/2021/07/14/security/whatsapp-multi-device/?ref=arabroot.io) في 14 يوليو 2021 أن المرسل يشفر الرسالة ويرسلها N مرة إلى N جهاز، وأن كل جهاز صار له مفتاح هوية خاص به بدل أن يكون نسخة من الهاتف.

والفرق مع GPG الذي شرحناه في [شرح GPG وOpenPGP](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-gpg-%D9%88-openpgp-%D8%A7%D9%84%D8%AA%D8%B4%D9%81%D9%8A%D8%B1-%D9%88%D8%A7%D9%84%D8%AA%D9%88%D9%82%D9%8A%D8%B9-%D8%A8%D8%A7%D9%84%D9%85%D9%81%D8%AA%D8%A7%D8%AD-%D8%A7%D9%84%D8%B9%D8%A7%D9%85/#alternatives) أن GPG يشفر بمفتاح طويل العمر، فمن يسرق المفتاح الخاص بعد سنة يفتح كل الرسائل القديمة التي احتفظ بنسخها، أما Signal فيغير المفاتيح مع كل رسالة، لذلك يناسب المحادثة الفورية، ولا يناسب توقيع الحزم والملفات الذي صمم له GPG. ولاحظ حدود التشفير بين الطرفين: السيرفر ما زال يعرف من يتحدث مع من ومتى وكم مرة، وهذه البيانات الوصفية Metadata قد تكشف الكثير، والنسخ الاحتياطية للمحادثات في السحابة تحتاج تشفيراً خاصاً بها، وإلا كانت الطريق الأسهل إلى المحتوى.

⚠️

لا تقم بكتابة بروتوكول تشفير خاص بتطبيقك، والسبب أن الأخطاء في هذا المجال لا تظهر في الاختبارات وإنما بعد سنوات في ورقة بحثية، فإذا احتجت تشفيراً بين الطرفين فاستخدم مكتبة Signal الرسمية [libsignal](https://github.com/signalapp/libsignal?ref=arabroot.io) أو معياراً مفتوحاً مثل MLS الموصوف في [RFC 9420](https://www.rfc-editor.org/rfc/rfc9420?ref=arabroot.io) للمجموعات الكبيرة.

## والمكالمات؟

المكالمات الصوتية والمرئية لا تمر عبر نفس طريق الرسائل، فاتصال المحادثة يستخدم فقط لتنسيق المكالمة Signaling، أي إرسال العرض والجواب بين الطرفين، ثم تتدفق الوسائط مباشرة بين الجهازين أو عبر سيرفر وسيط، وقد شرحنا ذلك كاملاً في [WebRTC من الداخل](https://arabroot.io/articles/webrtc-%D9%85%D9%86-%D8%A7%D9%84%D8%AF%D8%A7%D8%AE%D9%84-%D9%83%D9%8A%D9%81-%D8%AA%D8%B9%D9%85%D9%84-%D9%85%D9%83%D8%A7%D9%84%D9%85%D8%A7%D8%AA-%D8%A7%D9%84%D9%81%D9%8A%D8%AF%D9%8A%D9%88-%D9%81%D9%8A-%D8%A7%D9%84%D9%85%D8%AA%D8%B5%D9%81%D8%AD/) و[مكالمات WebRTC خلف NAT](https://arabroot.io/articles/%D9%85%D9%83%D8%A7%D9%84%D9%85%D8%A7%D8%AA-webrtc-%D8%AE%D9%84%D9%81-nat-%D8%B4%D8%B1%D8%AD-ice-%D9%88-stun-%D9%88-turn/).

## تجربة عملية: سيرفرا اتصال وصندوق لمن غاب

في هذه التجربة نشغل Valkey وسيرفري اتصال `gw1` و`gw2` مكتوبين بلغة Node.js، ثم سكربتاً يمثل ثلاثة مستخدمين: Alice على `gw1`، وBob وCarol على `gw2`، ويمر بالسيناريو كاملاً ويطبع ما يستلمه كل مستخدم مع الوقت منذ البداية. والكود مختصر عمداً ليبقى مقروءاً، فلا توجد مصادقة ولا تشفير بين الطرفين، والاسم يرسل في رابط الاتصال.

المتطلبات: جهاز عليه Docker Engine وDocker Compose، والمنفذان `38081` و`38082` منشوران على `127.0.0.1` فقط إذا أردت أن تتصل بنفسك بأداة مثل wscat.

### ملفات التجربة

الملف `compose.yaml`:

```yaml
name: lab-case2-chat
services:
  valkey:
    image: valkey/valkey:9.1.2-alpine
    container_name: lab-case2-chat-valkey
  gw1:
    build: .
    image: lab-case2-chat-gw:1
    container_name: lab-case2-chat-gw1
    environment: { GATEWAY_ID: gw1, VALKEY_URL: "redis://valkey:6379" }
    ports: ["127.0.0.1:38081:8080"]
    depends_on: [valkey]
  gw2:
    image: lab-case2-chat-gw:1
    container_name: lab-case2-chat-gw2
    environment: { GATEWAY_ID: gw2, VALKEY_URL: "redis://valkey:6379" }
    ports: ["127.0.0.1:38082:8080"]
    depends_on: [valkey, gw1]
  client:
    image: lab-case2-chat-gw:1
    container_name: lab-case2-chat-client
    entrypoint: ["node", "client.mjs"]
    profiles: ["run"]
    depends_on: [gw1, gw2]
```

والملفان `Dockerfile` و`package.json`:

```dockerfile
FROM node:24.21.0-alpine3.24
WORKDIR /app
COPY package.json .
RUN npm install --omit=dev --no-audit --no-fund
COPY gateway.mjs client.mjs ./
CMD ["node", "gateway.mjs"]
```

```json
{
  "name": "lab-chat",
  "private": true,
  "type": "module",
  "dependencies": { "ioredis": "6.0.0", "ws": "8.22.0" }
}
```

وسيرفر الاتصال `gateway.mjs`، وفيه كل ما شرحناه: القناة لكل Gateway، والحفظ قبل التوصيل، ومنع التكرار، والرقم داخل المحادثة، والإيصالات، وتسليم الصندوق عند الاتصال:

```javascript
// A WebSocket gateway for the chat lab. Run several of them; they share state through Valkey.
//   GATEWAY_ID=gw1 VALKEY_URL=redis://valkey:6379 node gateway.mjs
import { WebSocketServer } from 'ws';
import Redis from 'ioredis';

const GW = process.env.GATEWAY_ID;
const db = new Redis(process.env.VALKEY_URL);      // commands
const sub = new Redis(process.env.VALKEY_URL);     // a connection in subscribe mode cannot run other commands
const sockets = new Map();                          // user -> WebSocket, only for users connected to THIS gateway
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), GW, ...a);

// 1) Routing: every gateway listens on its own channel. To reach a user, look up his gateway and publish there.
await sub.subscribe(`gw:${GW}`);
sub.on('message', (_ch, raw) => {
    const { to, frame } = JSON.parse(raw);
    const ws = sockets.get(to);
    if (ws) ws.send(JSON.stringify(frame));         // if he left a moment ago, the copy in his inbox is still there
});

// 2) Every message and receipt goes to the recipient's inbox first, then to his gateway if he is online.
async function deliver(to, frame) {
    const id = frame.id;
    await db.multi()
        .hset(`msg:${id}`, 'frame', JSON.stringify(frame))
        .expire(`msg:${id}`, 30 * 24 * 3600)                     // undelivered items do not live forever
        .zadd(`inbox:${to}`, Date.now(), id)
        .exec();
    const gw = await db.get(`presence:${to}`);
    if (gw) {
        await db.publish(`gw:${gw}`, JSON.stringify({ to, frame }));
        log(`route ${frame.type} ${id} -> ${to}@${gw}`);
    } else if (frame.type === 'message') {
        await db.rpush('push:outbox', JSON.stringify({ to, conv: frame.conv, from: frame.from }));
        log(`${to} is offline: stored ${id}, queued a push notification`);
    } else {
        log(`${to} is offline: stored ${frame.type} ${id}`);
    }
}

async function onSend(user, ws, m) {
    // Idempotency: the same clientMsgId from the same sender always maps to the same server id
    const dedupKey = `dedup:${user}:${m.clientMsgId}`;
    const seen = await db.get(dedupKey);
    if (seen) {
        log(`duplicate ${m.clientMsgId} from ${user}, answering with ${seen}`);
        return ws.send(JSON.stringify({ type: 'sent', clientMsgId: m.clientMsgId, id: seen, duplicate: true }));
    }
    const members = await db.smembers(`conv:${m.conv}:members`);
    if (!members.includes(user)) return ws.send(JSON.stringify({ type: 'error', error: 'not a member' }));
    const seq = await db.incr(`seq:${m.conv}`);                 // order inside one conversation
    const id = `${m.conv}:${seq}`;
    await db.set(dedupKey, id, 'EX', 24 * 3600);
    await db.rpush(`log:${m.conv}`, JSON.stringify({ id, seq, from: user, text: m.text }));   // the conversation history
    ws.send(JSON.stringify({ type: 'sent', clientMsgId: m.clientMsgId, id, seq }));          // one tick: the server has it
    const frame = { type: 'message', id, conv: m.conv, seq, from: user, text: m.text };
    for (const to of members) if (to !== user) await deliver(to, frame);                      // fan-out on write
}

async function onAck(user, m) {
    // The device confirms it stored the item: remove it from the inbox, and tell the sender
    const raw = await db.hget(`msg:${m.id}`, 'frame');
    await db.zrem(`inbox:${user}`, m.id);
    if (!raw) return;
    const frame = JSON.parse(raw);
    if (frame.type === 'message') {
        const receipt = { type: 'delivered', id: `rcpt:${m.id}:${user}`, of: m.id, by: user };
        await deliver(frame.from, receipt);                                                     // two ticks
    }
}

async function onRead(user, m) {
    const receipt = { type: 'read', id: `read:${m.conv}:${m.upTo}:${user}`, conv: m.conv, upTo: m.upTo, by: user };
    for (const to of await db.smembers(`conv:${m.conv}:members`)) if (to !== user) await deliver(to, receipt);
}

const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', async (ws, req) => {
    const user = new URL(req.url, 'http://x').searchParams.get('user');   // the lab trusts the name; a real app checks a token
    sockets.set(user, ws);
    await db.set(`presence:${user}`, GW, 'EX', 30);
    const beat = setInterval(() => db.set(`presence:${user}`, GW, 'EX', 30), 10_000);
    log(`${user} connected`);

    // 3) Store-and-forward: whatever arrived while he was away, in order
    const pending = await db.zrange(`inbox:${user}`, 0, -1);
    if (pending.length) log(`${user} has ${pending.length} waiting item(s), sending them now`);
    for (const id of pending) {
        const raw = await db.hget(`msg:${id}`, 'frame');
        if (raw) ws.send(raw);
    }

    ws.on('message', async (data) => {
        const m = JSON.parse(data);
        if (m.type === 'send') await onSend(user, ws, m);
        else if (m.type === 'ack') await onAck(user, m);
        else if (m.type === 'read') await onRead(user, m);
    });
    ws.on('close', async () => {
        clearInterval(beat);
        if (sockets.get(user) === ws) {
            sockets.delete(user);
            // only remove presence if it still points to us (he may already be connected elsewhere)
            if (await db.get(`presence:${user}`) === GW) await db.del(`presence:${user}`);
        }
        log(`${user} disconnected`);
    });
});
log('listening on :8080');
```

أما سكربت المستخدمين `client.mjs` فهو في البطاقة التالية:

`client.mjs` كاملاً 

```javascript
// Scripted users for the chat lab: three people on two gateways, one goes offline and comes back.
import WebSocket from 'ws';
import Redis from 'ioredis';

const db = new Redis('redis://valkey:6379');
const t0 = Date.now();
const say = (...a) => console.log(`+${String(Date.now() - t0).padStart(5)}ms`, ...a);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));

// fresh lab data: two conversations
if (process.argv[2] !== 'crash') await db.flushall();
await db.sadd('conv:alice-bob:members', 'alice', 'bob');
await db.sadd('conv:trip:members', 'alice', 'bob', 'carol');

function connect(user, gw) {
    const ws = new WebSocket(`ws://${gw}:8080/?user=${user}`);
    ws.on('message', (data) => {
        const f = JSON.parse(data);
        const what = f.type === 'message' ? `message ${f.id} from ${f.from}: "${f.text}"`
            : f.type === 'sent' ? `sent ✓ ${f.clientMsgId} -> ${f.id}${f.duplicate ? ' (duplicate, same id)' : ''}`
            : f.type === 'delivered' ? `delivered ✓✓ ${f.of} to ${f.by}`
            : f.type === 'read' ? `read ✓✓ ${f.conv} up to seq ${f.upTo} by ${f.by}` : JSON.stringify(f);
        say(`${user}@${gw} <-`, what);
        if (f.id && f.type !== 'sent') ws.send(JSON.stringify({ type: 'ack', id: f.id }));   // stored on the device
    });
    return new Promise(r => ws.on('open', () => { say(`${user} connected to ${gw}`); r(ws); }));
}
const send = (ws, conv, clientMsgId, text) => ws.send(JSON.stringify({ type: 'send', conv, clientMsgId, text }));

if (process.argv[2] === 'crash') {
    // gw2 is killed from the host about 2 s after start (docker kill), while carol is connected to it
    const alice = await connect('alice', 'gw1');
    const carol = await connect('carol', 'gw2');
    carol.on('close', () => say('carol: connection to gw2 lost'));
    await sleep(3000);
    say(`valkey: presence carol=${await db.get('presence:carol')} ttl=${await db.ttl('presence:carol')}s (gw2 is dead, the key does not know yet)`);
    send(alice, 'trip', 'c-101', 'did everyone get the tickets?');
    await sleep(300);
    say(`valkey: inbox:carol has ${await db.zcard('inbox:carol')} item(s)`);
    const carol2 = await connect('carol', 'gw1');       // the app reconnects to any healthy gateway
    await sleep(500);
    say(`valkey: inbox:carol has ${await db.zcard('inbox:carol')} item(s)`);
    for (const ws of [alice, carol2]) ws.close();
    await sleep(200);
    process.exit(0);
}

say('--- 1) alice on gw1, bob and carol on gw2');
const alice = await connect('alice', 'gw1');
let bob = await connect('bob', 'gw2');
const carol = await connect('carol', 'gw2');
await sleep(200);

say('--- 2) alice -> bob across gateways, then bob reads it');
send(alice, 'alice-bob', 'c-001', 'hello bob');
await sleep(300);
bob.send(JSON.stringify({ type: 'read', conv: 'alice-bob', upTo: 1 }));
await sleep(300);

say('--- 3) the ack was lost, alice\'s phone sends c-001 again');
send(alice, 'alice-bob', 'c-001', 'hello bob');
await sleep(300);

say('--- 4) bob loses his connection, alice sends two more');
bob.close();
await sleep(300);
send(alice, 'alice-bob', 'c-002', 'are you there?');
send(alice, 'alice-bob', 'c-003', 'call me when you land');
await sleep(300);
say(`valkey: inbox:bob has ${await db.zcard('inbox:bob')} item(s), push:outbox has ${await db.llen('push:outbox')} item(s)`);

say('--- 5) bob comes back, this time through gw1');
bob = await connect('bob', 'gw1');
await sleep(500);

say('--- 6) group "trip": one send, fan-out to bob (gw1) and carol (gw2)');
send(alice, 'trip', 'c-004', 'bus leaves at 7');
await sleep(500);

say(`valkey: inbox sizes alice=${await db.zcard('inbox:alice')} bob=${await db.zcard('inbox:bob')} carol=${await db.zcard('inbox:carol')}`);
say(`valkey: log:alice-bob = ${(await db.lrange('log:alice-bob', 0, -1)).map(x => JSON.parse(x).seq).join(',')} | presence bob=${await db.get('presence:bob')} ttl=${await db.ttl('presence:bob')}s`);
for (const ws of [alice, bob, carol]) ws.close();
await sleep(200);
process.exit(0);
```

في الإعداد أعلاه لاحظ التالي:

- كل Gateway يفتح اتصالين بقاعدة Valkey، والسبب أن الاتصال الذي يدخل وضع الاشتراك `SUBSCRIBE` لا يستطيع تنفيذ أوامر أخرى.
- الدالة `deliver` تكتب الرسالة في `inbox:<user>` قبل أن تسأل عن الحضور، فالتوصيل عبر القناة طريق سريع فقط، والنسخة الحقيقية في الصندوق.
- الصندوق مجموعة مرتبة Sorted Set بوقت الإضافة، والرسالة نفسها في مفتاح `msg:<id>` بمهلة 30 يوماً.
- الكلاينت يرسل `ack` لكل ما يستلمه، والسيرفر يحذف العنصر من الصندوق ثم يرسل إيصال التسليم إلى المرسل، والإيصال نفسه يمر بنفس الدالة `deliver`.

شغل الخدمات ثم السيناريو:

```bash
docker compose up -d --build
docker compose run --rm client
```

### ماذا استلم كل مستخدم؟

والمخرج سوف يكون كما يلي:

```text
+   26ms --- 1) alice on gw1, bob and carol on gw2
+   40ms alice connected to gw1
+   47ms bob connected to gw2
+   51ms carol connected to gw2
+  252ms --- 2) alice -> bob across gateways, then bob reads it
+  258ms alice@gw1 <- sent ✓ c-001 -> alice-bob:1
+  262ms bob@gw2 <- message alice-bob:1 from alice: "hello bob"
+  269ms alice@gw1 <- delivered ✓✓ alice-bob:1 to bob
+  557ms alice@gw1 <- read ✓✓ alice-bob up to seq 1 by bob
+  853ms --- 3) the ack was lost, alice's phone sends c-001 again
+  855ms alice@gw1 <- sent ✓ c-001 -> alice-bob:1 (duplicate, same id)
+ 1154ms --- 4) bob loses his connection, alice sends two more
+ 1461ms alice@gw1 <- sent ✓ c-002 -> alice-bob:2
+ 1462ms alice@gw1 <- sent ✓ c-003 -> alice-bob:3
+ 1759ms valkey: inbox:bob has 2 item(s), push:outbox has 2 item(s)
+ 1759ms --- 5) bob comes back, this time through gw1
+ 1762ms bob connected to gw1
+ 1764ms bob@gw1 <- message alice-bob:2 from alice: "are you there?"
+ 1765ms bob@gw1 <- message alice-bob:3 from alice: "call me when you land"
+ 1768ms alice@gw1 <- delivered ✓✓ alice-bob:2 to bob
+ 1768ms alice@gw1 <- delivered ✓✓ alice-bob:3 to bob
+ 2262ms --- 6) group "trip": one send, fan-out to bob (gw1) and carol (gw2)
+ 2264ms alice@gw1 <- sent ✓ c-004 -> trip:1
+ 2266ms bob@gw1 <- message trip:1 from alice: "bus leaves at 7"
+ 2268ms carol@gw2 <- message trip:1 from alice: "bus leaves at 7"
+ 2270ms alice@gw1 <- delivered ✓✓ trip:1 to bob
+ 2270ms alice@gw1 <- delivered ✓✓ trip:1 to carol
+ 2766ms valkey: inbox sizes alice=0 bob=0 carol=0
+ 2767ms valkey: log:alice-bob = 1,2,3 | presence bob=gw1 ttl=29s
```

في المخرج أعلاه لاحظ التالي:

- في الخطوة 2 خرجت الرسالة من `gw1` ووصلت إلى Bob على `gw2` في 4 ملي ثانية تقريباً على نفس الجهاز، ثم عاد إيصال التسليم بعد 7 ملي ثوان، ثم إيصال القراءة عندما أرسله Bob.
- في الخطوة 3 أعاد هاتف Alice إرسال `c-001` كأن التأكيد ضاع، فرد السيرفر بنفس المعرف `alice-bob:1` مع `duplicate`، ولم يستلم Bob شيئاً جديداً.
- في الخطوة 4 انقطع Bob، فحفظت الرسالتان في صندوقه ودخل طابور الإشعارات عنصران، وفي الخطوة 5 عاد من `gw1` وليس من `gw2`، ومع ذلك استلم الرسالتين بالترتيب 2 ثم 3، ووصلت إلى Alice علامتا التسليم.
- في الخطوة 6 أرسلت Alice رسالة واحدة إلى المجموعة، فنسخها السيرفر إلى Bob على `gw1` وCarol على `gw2`، وعاد إيصالان.
- في النهاية كل الصناديق فارغة لأن كل شيء تم تأكيده، وسجل المحادثة فيه الأرقام 1 و2 و3 دون تكرار، وسجل الحضور يقول إن Bob على `gw1` بمهلة 29 ثانية.

وسجلات سيرفري الاتصال تبين الطريق نفسه من الداخل:

```bash
docker compose logs --no-log-prefix gw1 gw2 | sort
```

```text
15:05:07.855 gw1 listening on :8080
15:05:08.124 gw2 listening on :8080
15:05:12.222 gw1 alice connected
15:05:12.235 gw2 bob connected
15:05:12.236 gw2 carol connected
15:05:12.446 gw1 route message alice-bob:1 -> bob@gw2
15:05:12.453 gw2 route delivered rcpt:alice-bob:1:bob -> alice@gw1
15:05:12.741 gw2 route read read:alice-bob:1:bob -> alice@gw1
15:05:13.039 gw1 duplicate c-001 from alice, answering with alice-bob:1
15:05:13.346 gw2 bob disconnected
15:05:13.648 gw1 bob is offline: stored alice-bob:2, queued a push notification
15:05:13.648 gw1 bob is offline: stored alice-bob:3, queued a push notification
15:05:13.947 gw1 bob connected
15:05:13.948 gw1 bob has 2 waiting item(s), sending them now
15:05:13.952 gw1 route delivered rcpt:alice-bob:2:bob -> alice@gw1
15:05:13.953 gw1 route delivered rcpt:alice-bob:3:bob -> alice@gw1
15:05:14.450 gw1 route message trip:1 -> bob@gw1
15:05:14.452 gw1 route message trip:1 -> carol@gw2
15:05:14.454 gw1 route delivered rcpt:trip:1:bob -> alice@gw1
15:05:14.455 gw2 route delivered rcpt:trip:1:carol -> alice@gw1
15:05:14.956 gw2 carol disconnected
15:05:14.957 gw1 alice disconnected
15:05:14.958 gw1 bob disconnected
```

### نكسر التجربة: سيرفر الاتصال يتوقف فجأة

الآن نشغل سيناريو آخر تتصل فيه Carol ب `gw2`، ثم نوقف `gw2` بالقوة من جهازك بعد ثانيتين تقريباً كأن السيرفر انطفأ، ثم ترسل Alice رسالة إلى المجموعة:

```bash
docker compose run --rm client crash &
sleep 2; docker kill lab-case2-chat-gw2; wait
```

```text
+   37ms alice connected to gw1
+   41ms carol connected to gw2
+  986ms carol: connection to gw2 lost
+ 3044ms valkey: presence carol=gw2 ttl=27s (gw2 is dead, the key does not know yet)
+ 3048ms alice@gw1 <- sent ✓ c-101 -> trip:2
+ 3347ms valkey: inbox:carol has 1 item(s)
+ 3350ms carol connected to gw1
+ 3351ms carol@gw1 <- message trip:2 from alice: "did everyone get the tickets?"
+ 3354ms alice@gw1 <- delivered ✓✓ trip:2 to carol
+ 3852ms valkey: inbox:carol has 0 item(s)
```

```text
15:05:17.694 gw1 alice connected
15:05:20.708 gw1 bob is offline: stored trip:2, queued a push notification
15:05:20.710 gw1 route message trip:2 -> carol@gw2
15:05:21.010 gw1 carol connected
15:05:21.010 gw1 carol has 1 waiting item(s), sending them now
15:05:21.013 gw1 route delivered rcpt:trip:2:carol -> alice@gw1
15:05:21.515 gw1 alice disconnected
15:05:21.515 gw1 carol disconnected
```

لاحظ أن سجل الحضور ما زال يقول إن Carol على `gw2` بعد موت السيرفر، والسبب أن `gw2` لم يجد وقتاً ليحذف المفتاح، فنشر `gw1` الرسالة على قناة `gw:gw2` التي لا يستمع لها أحد، وضاعت هذه النسخة بصمت. ومع ذلك لم تضع الرسالة، لأن نسختها في الصندوق، فعندما أعاد تطبيق Carol الاتصال إلى `gw1` سلمها فوراً ووصل إيصال التسليم إلى Alice. وهذا هو السبب في القاعدة «الحفظ قبل التوصيل»: لو اعتمد التصميم على Pub/Sub وحده لضاعت الرسالة دون أي خطأ في السجلات.

### حذف التجربة

```bash
docker compose down
docker image rm lab-case2-chat-gw:1
```

## أخطاء شائعة

- الاعتماد على Pub/Sub أو على الاتصال المفتوح وحده لتوصيل الرسائل دون نسخة محفوظة.
- ترتيب الرسائل بساعة الهاتف أو ساعة السيرفر بدل رقم متسلسل داخل المحادثة.
- غياب معرف من الكلاينت، فتظهر الرسالة مرتين كلما ضعف الاتصال.
- حذف الرسالة من الصندوق عند إرسالها لا عند تأكيد الجهاز.
- وضع محتوى الرسالة كاملاً في الإشعار، فيمر عبر خدمات Apple وGoogle ويتجاوز حجمها.
- سجل حضور دون مهلة، فيبقى المستخدم «متصلاً» على سيرفر مات منذ ساعة.
- تحديث كل سيرفرات الاتصال في نفس اللحظة، فيعيد ملايين الهواتف الاتصال معاً.
- تمرير الصور والفيديو عبر اتصال المحادثة بدل تخزين الكائنات.
- كتابة بروتوكول تشفير خاص بدل مكتبة مراجعة.

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- المشكلة الأولى في تطبيق المحادثة عدد الاتصالات المفتوحة، وWhatsApp نشر في 2012 أنه وصل إلى أكثر من مليوني اتصال على سيرفر واحد.
- سيرفرات الاتصال Gateways تحمل الاتصالات فقط، وسجل الحضور بمهلة قصيرة يقول أين المستخدم، وقناة لكل Gateway توصل إليه الرسائل.
- الرسالة تحفظ قبل أي توصيل، وتحذف من صندوق المستقبل فقط بعد تأكيد جهازه، والتوصيل مرة واحدة على الأقل مع تجاهل المكرر.
- معرف من الكلاينت يمنع التكرار، ورقم متسلسل داخل المحادثة يضمن الترتيب.
- علامة واحدة من السيرفر، والثانية من جهاز المستقبل، والقراءة حدث مستقل برقم آخر رسالة.
- النسخ عند الكتابة للمجموعات الصغيرة، والسجل المشترك للقنوات الكبيرة.
- الإشعار يوقظ التطبيق ولا يحمل الرسائل، والملفات تذهب إلى تخزين الكائنات مشفرة.
- التشفير بين الطرفين مبني على بروتوكول Signal بمفاتيح على الأجهزة ومفتاح جديد لكل رسالة، والسيرفر ما زال يرى البيانات الوصفية.

وفي مقالات هذا القسم نكمل بأنظمة أخرى تعرفها:

[![](https://arabroot.io/content/images/2026/10/notification-system-cover-cover.webp)دليلكيف تبني نظام إشعارات يصل إلى ملايين المستخدمينكيف تصل الرسالة الصحيحة إلى المستخدم مرة واحدة وفي وقتها عبر الإشعار والرسائل والبريد، حتى عندما يتعطل المزود.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D9%86%D8%B8%D8%A7%D9%85-%D8%A7%D8%B4%D8%B9%D8%A7%D8%B1%D8%A7%D8%AA-%D9%8A%D8%B5%D9%84-%D8%A7%D9%84%D9%89-%D9%85%D9%84%D8%A7%D9%8A%D9%8A%D9%86-%D8%A7%D9%84%D9%85%D8%B3%D8%AA%D8%AE%D8%AF%D9%85%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/ticket-booking-cover-cover.webp)دليلكيف تبني نظام حجز تذاكر يتحمل لحظة فتح البيعكيف تمنع بيع المقعد الواحد مرتين، وكيف يصمد نظام الحجز عندما يدخل الجميع في الدقيقة نفسها لشراء التذاكر.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D9%86%D8%B8%D8%A7%D9%85-%D8%AD%D8%AC%D8%B2-%D8%AA%D8%B0%D8%A7%D9%83%D8%B1-%D9%8A%D8%AA%D8%AD%D9%85%D9%84-%D9%84%D8%AD%D8%B8%D8%A9-%D9%81%D8%AA%D8%AD-%D8%A7%D9%84%D8%A8%D9%8A%D8%B9/)[![](https://arabroot.io/content/images/2026/10/crowd-events-cover-cover.webp)دليلأنظمة الحشود الكبرى: كيف تصمد في مواسم الحج والعمرةكيف تصمم نظام حجز وتصاريح يتحمل ملايين الطلبات في مواعيد معروفة مسبقاً، وماذا تطفئ عندما يتجاوز الحمل خطتك.](https://arabroot.io/articles/%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D9%84%D8%AD%D8%B4%D9%88%D8%AF-%D8%A7%D9%84%D9%83%D8%A8%D8%B1%D9%89-%D8%A7%D9%84%D8%AD%D8%AC-%D9%88%D8%A7%D9%84%D8%B9%D9%85%D8%B1%D8%A9-%D9%88%D9%85%D9%88%D8%A7%D8%B3%D9%85-%D8%A7%D9%84%D8%AD%D8%AC%D8%B2/)[![](https://arabroot.io/content/images/2026/10/build-live-streaming-cover-cover.webp)دليلكيف تبني تطبيق بث مباشر مثل Jaco وTwitchكيف يصل بث المذيع إلى آلاف المشاهدين بتأخير ثانية أو ثانيتين، ومعه دردشة وهدايا لا تتوقف عند الذروة.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D8%A8%D8%AB-%D9%85%D8%A8%D8%A7%D8%B4%D8%B1-%D9%85%D8%AB%D9%84-jaco-%D9%88-twitch/)[![](https://arabroot.io/content/images/2026/10/ntfy-cover-cover.webp)دليلإشعارات فورية من سيرفرك إلى هاتفك مع ntfyكيف تشغل سيرفر ntfy خاصاً، وتصلك على هاتفك رسالة عندما يمتلئ القرص أو تفشل النسخة الاحتياطية أو ينتهي النشر.](https://arabroot.io/articles/%D8%A7%D8%B4%D8%B9%D8%A7%D8%B1%D8%A7%D8%AA-%D9%81%D9%88%D8%B1%D9%8A%D8%A9-%D8%A7%D9%84%D9%89-%D9%87%D8%A7%D8%AA%D9%81%D9%83-%D9%85%D8%B9-ntfy/)[![](https://arabroot.io/content/images/2026/10/gpg-basics-cover-cover.webp)دليلGPG وOpenPGP: شرح التشفير والتوقيع بالمفتاح العامكيف يعمل GPG بالمفتاح العام والخاص، ولماذا تقارن البصمة دائماً، وأين يحميك اليوم وأين لا يحميك.](https://arabroot.io/articles/%D8%B4%D8%B1%D8%AD-gpg-%D9%88-openpgp-%D8%A7%D9%84%D8%AA%D8%B4%D9%81%D9%8A%D8%B1-%D9%88%D8%A7%D9%84%D8%AA%D9%88%D9%82%D9%8A%D8%B9-%D8%A8%D8%A7%D9%84%D9%85%D9%81%D8%AA%D8%A7%D8%AD-%D8%A7%D9%84%D8%B9%D8%A7%D9%85/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة العملية على Docker Engine 29.8.0 وDocker Compose 5.5.1 مع Node.js 24.21.0 وws 8.22.0 وioredis 6.0.0 وValkey 9.1.2.
- أكتوبر 2026: إضافة قسم المعمارية الكاملة: المخطط والمكونات ونموذج البيانات والواجهات وتقدير السعة ومراحل النمو.