الدردشة الموزعة على Node.JS و Redis

والنتيجة هي صورة مزحة لغسل "حمامة البريد"


سؤال / إجابة صغيرة:


لمن هذا؟ الأشخاص الذين لديهم خبرة ضئيلة أو معدومة في الأنظمة الموزعة ، والذين يهتمون برؤية كيفية بنائها ، وما هي الأنماط والحلول الموجودة.


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


بيان المشكلة


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


تجدر الإشارة إلى أن استجابة سريعة مهمة للغاية ، في الوقت الحقيقي سيئة السمعة ، إنها دردشة ! لا تسليم البريد حمامة.


٪ نكتة عشوائية حول المشاركة الروسية ٪


سوف نستخدم Node.JS ، فهو مثالي للنماذج الأولية. للمآخذ ، خذ Socket.IO. اكتب على TypeScript.


وماذا نريد؟


  1. بحيث يمكن للمستخدمين إرسال رسائل إلى بعضهم البعض
  2. معرفة من هو على الانترنت / حاليا

كيف نريدها:


خادم واحد


لا يوجد شيء للقول خاصة ، الحق في الرمز. قم بتعريف واجهة الرسالة:


interface Message{ roomId: string,//    message: string,//    } 

على الخادم:


 io.on('connection', sock=>{ //    sock.on('join', (roomId:number)=> sock.join(roomId)) //    //         sock.on('message', (data:Message)=> io.to(data.roomId).emit('message', data)) }) 

على العميل ، شيء مثل:


 sock.on('connect', ()=> { const roomId = 'some room' //      sock.on('message', (data:Message)=> console.log(`Message ${data.message} from ${data.roomId}`)) //   sock.emit('join', roomId) //    sock.emit('message', <Message>{roomId: roomId, message: 'Halo!'}) }) 

يمكنك العمل مع حالة عبر الإنترنت مثل هذا:


 io.on('connection', sock=>{ //         // ,        - //      sock.on('auth', (uid:string)=> sock.join(uid)) //,     , //          //   sock.on('isOnline', (uid:string, resp)=> resp(io.sockets.clients(uid).length > 0)) }) 

وعلى العميل:


 sock.on('connect', ()=> { const uid = 'im uid, rly' //  sock.emit('auth', uid) //     sock.emit('isOnline', uid, (isOnline:boolean)=> console.log(`User online status is ${isOnline}`)) }) 

ملاحظة: لم يتم تشغيل الرمز ، أكتب من الذاكرة فقط على سبيل المثال

تمامًا مثل الحطب ، نقوم بتدوير التفويض الحقيقي للطلبة وإدارة الغرف (سجل الرسائل ، إضافة / إزالة المشاركين) والربح.


لكن! لكننا سنتولى السيطرة على السلام العالمي ، مما يعني أنه ليس وقت التوقف ، نحن نسير بسرعة:


Node.JS الكتلة


أمثلة على استخدام Socket.IO في العديد من العقد موجودة على الموقع الرسمي . بما في ذلك هناك أيضًا مجموعة Node.JS أصلية ، والتي بدا لي أنها غير قابلة للتطبيق لمهمتي: إنها تتيح لنا توسيع نطاق تطبيقنا عبر الجهاز ، ولكن ليس خارج نطاقه ، لذلك نحن نفتقده بالتأكيد. نحتاج أخيرًا إلى تجاوز حدود قطعة واحدة من الحديد!


توزيع والدراجة


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


1549140775997


عندما تريد العقدة إرسال رسالة إلى أخرى ، فإنها تقدم طلبًا إلى Bus ، وبالفعل ، تقوم بإعادة توجيهها إلى حيث تدعو الحاجة ، وكل شيء بسيط. شبكتنا جاهزة!


FIN.


... لكنها ليست بهذه البساطة؟)


مع هذا النهج ، نواجه أداء هذا الرابط الوسيط ، ونود في الواقع الاتصال مباشرة بالعقد اللازمة ، لأن ما يمكن أن يكون أسرع من الاتصال المباشر؟ لذلك دعونا نتحرك في هذا الاتجاه!


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


1549048945334


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


عظيم ، الآن لدينا سكاينيت الخاصة بنا! لكن التنفيذ الحالي للدردشة فيه لم يعد مناسبًا. دعنا بالفعل نتوصل إلى المتطلبات:


  1. عندما يرسل المستخدم رسالة ، نحتاج إلى معرفة منظمة الصحة العالمية التي يرسلها إليها ، أي أن يكون لها وصول إلى المشاركين في الغرفة.
  2. عندما استقبلنا المشاركين ، يجب أن نرسل لهم الرسائل.
  3. نحن بحاجة إلى معرفة أي مستخدم متصل الآن.
  4. للراحة - امنح المستخدمين الفرصة للاشتراك في حالة المستخدمين الآخرين عبر الإنترنت ، حتى يتعرفوا في الوقت الفعلي على التغيير

دعونا نتعامل مع المستخدمين. على سبيل المثال ، يمكنك جعل المعلم يعرف العقدة المتصلة بالعقدة. الوضع كالتالي:


1549237952673


اثنين من المستخدمين متصلين بعقد مختلفة. السيد يعرف هذا ، العقد تعرف ما يعرف السيد. عندما يقوم UserB بتسجيل الدخول ، يقوم Node2 بإعلام Master ، والذي "يتذكر" أن UserB متصل بـ Node2. عندما يريد UserA إرسال رسالة UserB ، تحصل على الصورة التالية:


1549140491881


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


1549139768940


1549139882747


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


وسطاء الرسائل


الكيان الذي ينفذ "Bus" نفسه ، "الرابط الوسيط" المذكور أعلاه. مهمتها هي لتلقي وتسليم الرسائل. يمكننا ، كمستخدمين ، الاشتراك معهم وإرسال رسائلنا الخاصة. كل شيء بسيط.


هناك RabbitMQ وثبت كافكا: إنهم يفعلون فقط ما يقومون بتسليم الرسائل - هذا هو غرضهم ، مكتظة بكل الوظائف اللازمة للعنق. في عالمهم ، يجب تسليم رسالة بغض النظر عن ماذا.


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


... وهذا هو ما تحتاجه!


حسنا ، حقا ، نحن فقط الدردشة. ليس هناك نوع من خدمة المال الحرجة أو مركز مراقبة الرحلات الفضائية ، ولكن ... مجرد دردشة. إن المخاطرة المتمثلة في عدم تلقي Pete الشرطية مرة واحدة في السنة رسالة واحدة من أصل ألف - يمكن إهمالها إذا حصلنا في المقابل على نمو في الإنتاجية وفي نفس المكان عدد المستخدمين في نفس اليوم ، تم استبدالهم بكل مجد. علاوة على ذلك ، في الوقت نفسه ، يمكنك الاحتفاظ بسجل للرسائل في نوع من مستودع التخزين الدائم ، مما يعني أن Petya سيظل يرى تلك الرسالة التي لم يتم الرد عليها عن طريق إعادة تحميل الصفحة / التطبيق. لهذا السبب سنركز على Redis pub / sub ، أو بالأحرى: انظر إلى المحول الموجود لـ SocketIO ، وهو مذكور في المقالة في المكتب. الموقع .


إذن ما هذا؟


محول Redis


https://github.com/socketio/socket.io-redis


بمساعدتها ، يتحول تطبيق عادي عبر بضعة أسطر وعدد أدنى من الإيماءات إلى دردشة موزعة حقيقية! لكن كيف؟ إذا نظرت من الداخل - اتضح أن هناك ملفًا واحدًا فقط لكل نصف مائة سطر.


في حالة عندما نصدر رسالة


 io.emit("everyone", "hello") 

يتم دفعه إلى الفجل ، ويتم نقله إلى جميع الحالات الأخرى من الدردشة ، والتي بدورها تصدرها محليًا بالفعل على مآخذ


1549232309776


سيتم توزيع الرسالة عبر جميع العقد حتى لو أصدرناها لمستخدم معين. أي أن كل عقدة تقبل جميع الرسائل وتفهم بالفعل ما إذا كانت تحتاجها.


أيضًا ، هناك تطبيق RPC بسيط (استدعاء الإجراءات عن بُعد) ، والذي لا يسمح فقط بإرسال ولكن أيضًا بتلقي الإجابات. على سبيل المثال ، يمكنك التحكم في المقابس عن بُعد ، مثل "من هو في الغرفة المحددة" ، أو "طلب مأخذ التوصيل للانضمام إلى الغرفة" ، إلخ.


ما الذي يمكن القيام به مع هذا؟ على سبيل المثال ، استخدم معرف المستخدم كاسم للغرفة (معرف المستخدم == معرف الغرفة). عند التفويض ، لتوصيل المقبس به ، وعندما نريد أن نرسل رسالة إلى المستخدم - مجرد خوذة في ذلك. أيضًا ، يمكننا معرفة ما إذا كان المستخدم متصلًا بالإنترنت ، ما عليك سوى النظر في وجود مآخذ في الغرفة المحددة.


من حيث المبدأ ، يمكننا التوقف هنا ، ولكن كما هو الحال دائمًا ، لا يكفي هذا بالنسبة لنا:


  1. عنق الزجاجة في حالة فجل واحدة
  2. التكرار ، أود أن تتلقى العقد فقط الرسائل التي يحتاجونها

على حساب الفقرة الأولى ، انظر إلى شيء مثل:


مجموعة Redis


يربط العديد من حالات الفجل ، وبعد ذلك يعملون ككل. ولكن كيف يفعل ذلك؟ نعم ، مثل هذا:


1549233023980


... ونرى أن الرسالة مكررة لجميع أعضاء المجموعة. وهذا يعني أنه ليس المقصود منه زيادة الإنتاجية ، ولكن لزيادة الموثوقية ، وهي بالتأكيد جيدة وضرورية ، ولكن بالنسبة لحالتنا ، لا قيمة لها ولا تنقذ الموقف من عنق الزجاجة ، بالإضافة إلى أنها مضيعة أكثر للموارد.


1549231953897


أنا مبتدئ ، لا أعرف الكثير ، وأحيانًا يجب أن أعود إلى الملعب ، وهو ما سنفعله. لا ، دعنا نترك الفجل حتى لا ينزلق على الإطلاق ، لكن عليك التفكير في شيء بهندسة معمارية لأن الهيكل الحالي ليس جيدًا.


اقلب الطريق الخطأ


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


1549239818663


فويلا! بشكل عام ، تم حل المشكلة ، ولم تعد الفجل عنق الزجاجة ، يمكنك تفرخ أي عدد من النسخ! لكنها أصبحت العقد. نعم ، نعم ، لا تزال مثيلات الدردشة تستوعب جميع الرسائل التي لم تكن موجهة إليها.


يمكنك العكس: الاشتراك في واحدة عشوائية ، مما يقلل من الحمل على العقد ، ودفع كل شيء:


1549239361416


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


من أجل ضخ نظامنا ، سنترك حزمة socket.io-redis بمفردها ، على الرغم من أنها باردة ، إلا أننا نحتاج إلى مزيد من الحرية. وهكذا ، نقوم بتوصيل الفجل:


 //  : const pub = new RedisClient({host: 'localhost', port: 6379})//  const sub = new RedisClient({host: 'localhost', port: 6379})//   //    interface Message{ roomId: string,//    message: string,//    } 

قم بإعداد نظام المراسلة لدينا:


 //     sub.on('message', (channel:string, dataRaw:string)=> { const data = <Message>JSON.parse(dataRaw) io.to(data.roomId).emit('message', data)) }) //   sub.subscribe("messagesChannel") //    sock.on('join', (roomId:number)=> sock.join(roomId)) //   sock.on('message', (data:Message)=> { //   pub.publish("messagesChannel", JSON.stringify(data)) }) 

في الوقت الحالي ، يبدو الأمر كما هو الحال في socket.io-redis: نستمع إلى جميع الرسائل. الآن سوف نصلحها.


ننظم الاشتراكات على النحو التالي: تذكر المفهوم مع "معرف المستخدم == معرف الغرفة" ، وعندما يظهر المستخدم ، فإننا نشارك في القناة التي تحمل الاسم نفسه في الفجل. وبالتالي ، فإن العقد لدينا سوف تتلقى فقط الرسائل الموجهة لهم ، وليس الاستماع إلى "البث الكامل".


 //     sub.on('message', (channel:string, message:string)=> { io.to(channel).emit('message', message)) }) let UID:string|null = null; sock.on('auth', (uid:string)=> { UID = uid //   -   //  UID  sub.subscribe(UID) //   sock.join(UID) }) sock.on('writeYourself', (message:string)=> { //  ,        UID if (UID) pub.publish(UID, message) }) 

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


1550174595491


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


1550174092066


1550174943313


وبالتالي ، نحصل على بنية تشغيل ذات إمكانية توسع لا نهائية في الوقت الفعلي ، لا يعتمد الحمل على عقدة فردية في أي وقت على حجم النظام ، لأنه:


  1. إجمالي النطاق الترددي مقسم بين مجموعات ، أي بزيادة عدد المستخدمين / النشاط ، فنحن ببساطة نقارن المجموعات الإضافية.
  2. يتم تقسيم إدارة المستخدم (الاشتراكات) داخل المجموعات نفسها ، أي عند زيادة المستخدمين / الاشتراكات ، فإننا ببساطة نزيد عدد الحالات داخل المجموعات.

... وكما هو الحال دائمًا ، هناك "BUT" واحد: كلما حصل كل شيء ، زادت الموارد اللازمة لتحقيق المكسب التالي ، يبدو لي أنه غالبًا في مقابلته.


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


1550233610561


شيء مماثل يروي هذا الرجل:


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


قوة التجزئة


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


 function hash(val:string):number{/**/}// -,   const clients:RedisClient[] = []//   const uid = "some uid"//  //,            //      const selectedClient = clients[hash(uid) % clients.length] 

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


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


غمرت مستودع على جيثب.


انها تنفذ النسخة النهائية التي وصلنا إليها. بالإضافة إلى ذلك ، هناك منطق إضافي للعمل مع الغرف (مربعات الحوار).


بشكل عام ، أنا مرتاح ويمكن تقريبه.


المجموع


يمكنك أن تفعل أي شيء ، ولكن هناك شيء اسمه الموارد ، وهي محدودة ، لذلك عليك أن تتلوى.


لقد بدأنا بجهل تام بكيفية عمل الأنظمة الموزعة على أنماط ملموسة أكثر أو أقل ، وهذا أمر جيد.

Source: https://habr.com/ru/post/ar440546/


All Articles