Centrifugo v2 - مستقبل خادم الرسائل والمكتبة لـ Go

ربما سمع بعض القراء عن Centrifugo من قبل. ستركز هذه المقالة على تطوير الإصدار الثاني من الخادم والمكتبة الجديدة في الوقت الفعلي للغة Go التي تكمن وراءها.


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



باختصار - هذا هو الخادم الذي يقوم بمهمة الحفاظ على الاتصالات المستمرة من مستخدمي التطبيق الخاص بك. تُستخدم Websocket أو SockJS polyfill كوسيلة نقل ؛ يمكنها ، إذا لم يكن من الممكن إنشاء اتصال Websocket ، العمل من خلال Eventsource ، وتدفق XHR ، والاستقصاء الطويل ، ووسائل النقل الأخرى المستندة إلى HTTP. يشترك العملاء في القنوات التي تنشر عليها الواجهة الخلفية من خلال Centrifuge API رسائل جديدة عند ظهورها - وبعد ذلك يتم تسليم الرسائل إلى المستخدمين المشتركين في القناة. بمعنى آخر ، هو خادم PUB / SUB.



حاليا ، يتم استخدام الخادم في عدد كبير من المشاريع. من بينها ، على سبيل المثال ، بعض مشاريع Mail.Ru (إنترانت ومنصات تدريب Technopark / Technosphere ومركز الشهادات وما إلى ذلك) ، مع Centrifugo ، لوحة تحكم جميلة تعمل في مكتب الاستقبال في مكتب Badoo موسكو ، و 350 ألف مستخدم متصلين في نفس الوقت بخدمة spot.im إلى جهاز الطرد المركزي.


بعض الروابط للمقالات السابقة على الخادم وتطبيقاته لأولئك الذين يسمعون عن المشروع لأول مرة:



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


مكتبة في الوقت الحقيقي لـ Go


في مجتمع Go ، يطرح السؤال من وقت لآخر - هل هناك أي بدائل لـ socket.io على Go؟ في بعض الأحيان لاحظت كيف ينصح المطورون استجابة لذلك بالنظر إلى Centrifugo. ومع ذلك ، فإن Centrifugo هو خادم تتم استضافته ذاتيًا وليس مكتبة - المقارنة ليست عادلة. لقد طُلب مني عدة مرات ما إذا كان يمكن إعادة استخدام رمز Centrifugo لكتابة تطبيقات في الوقت الفعلي في Go. وكان الجواب: ممكنًا نظريًا ، لكنني لم أستطع ضمان التوافق العكسي لواجهة برمجة التطبيقات للحزم الداخلية على مسؤوليتي الخاصة. من الواضح أنه لا يوجد سبب لأي شخص للمخاطرة به ، والتشكيل هو خيار كذلك. بالإضافة إلى ذلك ، لن أقول أن واجهة برمجة التطبيقات للحزم الداخلية تم إعدادها بشكل عام لهذا الاستخدام.


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


سأحاول تبرير واحد زائد من وجود مثل هذه المكتبة. معظم مستخدمي Centrifugo هم مطورون يكتبون الواجهة الخلفية باللغات / أطر العمل مع دعم التزامن الضعيف (مثل Django / Flask / Laravel / ...): العمل مع الكثير من الاتصالات المستمرة إن أمكن ، بطريقة غير واضحة أو غير فعالة. وفقًا لذلك ، لا يمكن لجميع المستخدمين المساعدة في تطوير خادم مكتوب بلغة Go (ممتلئ بسبب نقص المعرفة باللغة). لذلك ، حتى مجتمع صغير جدًا من مطوري Go حول المكتبة سيكون قادرًا على المساعدة في تطوير خادم Centrifugo باستخدامه.


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


أفهم أنه من خلال تسمية المكتبة بنفس طريقة الخادم ، سوف أتعامل مع الارتباك إلى الأبد. ولكن أعتقد أن هذا هو الخيار الصحيح ، لأن العملاء (مثل الطرد المركزي- js ، الطرد المركزي- go) يعملون مع كل من مكتبة الطرد المركزي وخادم Centrifugo. بالإضافة إلى ذلك ، فإن الاسم راسخ بالفعل في أذهان المستخدمين ، ولا أريد أن أفقد هذه الارتباطات. ومع ذلك ، لمزيد من الوضوح ، سأوضح مرة أخرى:


  • الطرد المركزي - مكتبة للغة Go ،
  • Centrifugo هو حل جاهز ، خدمة منفصلة ، والتي سيتم بناؤها في الإصدار 2 في مكتبة الطرد المركزي.

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



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


يمكنك تشغيل كالمعتاد:


git clone https://gist.github.com/2f1a38ae2dcb21e2c5937328253c29bf.git cd 2f1a38ae2dcb21e2c5937328253c29bf go get -u github.com/centrifugal/centrifuge go run main.go 

ثم انتقل إلى http: // localhost: 8000 ، افتح العديد من علامات تبويب المتصفح.


كما ترى ، تحدث نقطة الدخول إلى منطق العمل الخاص بالتطبيق عند تعليق On().Connect() وظائف رد الاتصال:


 node.On().Connect(func(ctx context.Context, client *centrifuge.Client, e centrifuge.ConnectEvent) centrifuge.ConnectReply { client.On().Disconnect(func(e centrifuge.DisconnectEvent) centrifuge.DisconnectReply { log.Printf("client disconnected") return centrifuge.DisconnectReply{} }) log.Printf("client connected via %s", client.Transport().Name()) return centrifuge.ConnectReply{} }) 

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


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


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

يمكن لمكتبة Centrifuge مساعدتك في ذلك - في الواقع ، لقد ورثت جميع الميزات الأساسية التي كانت متوفرة سابقًا في Centrifugo. يمكن العثور على المزيد من الأمثلة التي توضح النقاط المذكورة أعلاه على Github .


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


هناك بعض التحسينات في المكتبة التي تسمح باستخدام أكثر كفاءة للموارد. هذا يجمع بين عدة رسائل في إطار Websocket واحد للحفظ على كتابة مكالمات النظام أو ، على سبيل المثال ، استخدام Gogoprotobuf لإجراء تسلسل لرسائل Protobuf وغيرها. بالحديث عن Protobuf.


بروتوكول Protobuf الثنائي


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


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


بشكل عام ، يجب أن تنخفض حركة المرور الناتجة عن بروتوكول Centrifugo عند استخدام Protobuf بدلاً من JSON مرتين تقريبًا (باستثناء بيانات التطبيق). انخفض استهلاك وحدة المعالجة المركزية في اختبارات الحمل الاصطناعية بنفس المعدل ~ مرتين مقارنة بـ JSON. لا تتحدث هذه الأرقام كثيرًا في الواقع عما سيعتمد عليه كل شيء عمليًا على ملف تعريف تحميل تطبيق معين.


من أجل الاهتمام ، أطلقت على جهاز يعمل بنظام Debian 9.4 و 32 Intel® Xeon® Platinum 8168 CPU @ 2.70GHz vCPU ، مما سمح لنا بمقارنة عرض النطاق الترددي للتفاعل بين العميل والخادم في حالة استخدام بروتوكول JSON وبروتوكول Protobuf. كان هناك 1000 مشترك في قناة واحدة. في هذه القناة ، تم نشر الرسائل في 4 تيارات وتسليمها لجميع المشتركين. كان حجم كل رسالة 128 بايت.


نتائج بحث JSON:


 $ go run main.go -s ws://localhost:8000/connection/websocket -n 1000 -ns 1000 -np 4 channel Starting benchmark [msgs=1000, msgsize=128, pubs=4, subs=1000] Centrifuge Pub/Sub stats: 265,900 msgs/sec ~ 32.46 MB/sec Pub stats: 278 msgs/sec ~ 34.85 KB/sec [1] 73 msgs/sec ~ 9.22 KB/sec (250 msgs) [2] 71 msgs/sec ~ 9.00 KB/sec (250 msgs) [3] 71 msgs/sec ~ 8.90 KB/sec (250 msgs) [4] 69 msgs/sec ~ 8.71 KB/sec (250 msgs) min 69 | avg 71 | max 73 | stddev 1 msgs Sub stats: 265,635 msgs/sec ~ 32.43 MB/sec [1] 273 msgs/sec ~ 34.16 KB/sec (1000 msgs) ... [1000] 277 msgs/sec ~ 34.67 KB/sec (1000 msgs) min 265 | avg 275 | max 278 | stddev 2 msgs 

النتائج ل قضية Protobuf:


 $ go run main.go -s ws://localhost:8000/connection/websocket?format=protobuf -n 100000 -ns 1000 -np 4 channel Starting benchmark [msgs=100000, msgsize=128, pubs=4, subs=1000] Centrifuge Pub/Sub stats: 681,212 msgs/sec ~ 83.16 MB/sec Pub stats: 685 msgs/sec ~ 85.69 KB/sec [1] 172 msgs/sec ~ 21.57 KB/sec (25000 msgs) [2] 171 msgs/sec ~ 21.47 KB/sec (25000 msgs) [3] 171 msgs/sec ~ 21.42 KB/sec (25000 msgs) [4] 171 msgs/sec ~ 21.42 KB/sec (25000 msgs) min 171 | avg 171 | max 172 | stddev 0 msgs Sub stats: 680,531 msgs/sec ~ 83.07 MB/sec [1] 681 msgs/sec ~ 85.14 KB/sec (100000 msgs) ... [1000] 681 msgs/sec ~ 85.13 KB/sec (100000 msgs) min 680 | avg 680 | max 685 | stddev 1 msgs 

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


من الجدير بالذكر أيضًا أن أداء تسلسل JSON على الخادم يمكن "تعزيزه" باستخدام نفس النهج المتبع في gogoprotobuf - تجمع المخزن المؤقت وإنشاء التعليمات البرمجية - يتم حاليًا تصنيف JSON بواسطة حزمة من مكتبة Go القياسية المبنية على انعكاس. على سبيل المثال ، في Centrifugo ، يتم إجراء تسلسل للإصدار الأول من JSON يدويًا باستخدام مكتبة توفر تجمعًا مؤقتًا . يمكن القيام بشيء مماثل في المستقبل كجزء من الإصدار الثاني.


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


JSON Web Token (JWT)


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


للقيام بذلك ، في الإصدار الأول من Centrifuge ، عند الاتصال ، تم استخدام توقيع SHA-256 HMAC ، بناءً على مفتاح سري معروف فقط للواجهة الخلفية وأجهزة الطرد المركزي. هذا يضمن أن هوية المستخدم التي يرسلها العميل تخصه حقًا.


ربما كان النقل الصحيح لمعلمات الاتصال وإنشاء رمز مميز أحد الصعوبات الرئيسية في دمج Centrifugo في المشروع.


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


على سبيل المثال ، في Python ، يمكن إنشاء رمز مميز للاتصال بـ Centrifugo على النحو التالي:


 import jwt import time token = jwt.encode({"user": "42", "exp": int(time.time()) + 10*60}, "secret").decode() print(token) 

من المهم ملاحظة أنه إذا كنت تستخدم مكتبة Centrifuge ، فيمكنك مصادقة المستخدم باستخدام طريقة Go الأصلية - داخل الوسيطة. الأمثلة موجودة في المستودع.


GRPC


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


فقدت GRPC أمام Websocket من جميع النواحي:


  • تزيد نسبة GRPC من الزيارات بنسبة 20٪ في سيناريوهات مماثلة ،
  • يستهلك GRPC من وحدة المعالجة المركزية 2-3 مرات (اعتمادًا على تكوين الاتصالات - جميعهم مشتركون في قنوات مختلفة أو جميعهم مشتركون في قناة واحدة) ،
  • يستهلك GRPC 4 أضعاف ذاكرة الوصول العشوائي لكل اتصال. على سبيل المثال ، في اتصالات 10 آلاف ، أكل خادم Websocket 500 ميجا بايت من الذاكرة ، و GRPC - 2 جيجا بايت.

وكانت النتائج ... متوقعة. بشكل عام ، في GRPC ، بصفتي وسيلة نقل للعميل ، لم أر الكثير من المعقول - وحذفت الشفرة بضمير مرتاح حتى أوقات أفضل ، ربما.


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


الصعوبات مع العملاء


التغييرات التي تم إجراؤها في الإصدار الثاني ، أزلت الدعم الإلزامي للمكتبات لواجهة برمجة تطبيقات الخادم - أصبح من الأسهل الاندماج على جانب الخادم ، ومع ذلك ، تم تغيير بروتوكول العميل في المشروع ولديه عدد كافٍ من الميزات. هذا يجعل تنفيذ العملاء صعبًا للغاية. بالنسبة إلى الإصدار الثاني ، لدينا الآن عميل لـ Javascript يعمل في المتصفحات ، يجب أن يعمل مع NodeJS و React-Native. هناك عميل على Go ومبني على أساسه وعلى أساس روابط مشروع gomobile لنظامي التشغيل iOS و Android .


لتحقيق السعادة الكاملة ، لا توجد مكتبات أصلية كافية لنظامي التشغيل iOS و Android. بالنسبة للنسخة الأولى من Centrifugo ، تم شراؤها من قبل رجال من مجتمع مفتوح المصدر. أريد أن أصدق أن شيئًا كهذا سيحدث الآن.


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



الخلاصة


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


من الصعب بالنسبة لي أن أحكم على مدى الطلب على مكتبة الطرد المركزي التي تكمن وراء Centrifugo v2. في الوقت الحالي ، أنا سعيد لأنني تمكنت من الوصول به إلى حالته الحالية. أهم مؤشر بالنسبة لي الآن هو إجابة السؤال "هل سأستخدم هذه المكتبة في مشروعي الشخصي؟" جوابي نعم. في العمل؟ نعم لذلك ، أعتقد أن المطورين الآخرين سيقدرون ذلك.


سكرتير خاص أود أن أشكر الرجال الذين ساعدوا في العمل والمشورة - ديمتري كورولكوف ، أرتيمي ريابينكوف ، أوليغ كوزمين. سيكون ضيق بدونك.

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


All Articles