كيفية إنشاء خادم WebSocket بيثون بسيط باستخدام تورنادو
نشرت: 2022-03-11مع زيادة شعبية تطبيقات الويب في الوقت الفعلي ، أصبحت WebSockets تقنية رئيسية في تنفيذها. لقد ولت الأيام التي كان عليك فيها الضغط باستمرار على زر إعادة التحميل لتلقي التحديثات من الخادم. لم تعد تطبيقات الويب التي ترغب في تقديم تحديثات في الوقت الفعلي مضطرة لاستقصاء التغييرات على الخادم - بدلاً من ذلك ، تدفع الخوادم التغييرات إلى أسفل التدفق فور حدوثها. بدأت أطر الويب القوية في دعم WebSockets خارج الصندوق. Ruby on Rails 5 ، على سبيل المثال ، أخذها إلى أبعد من ذلك وأضفت دعمًا لكابلات العمل.
في عالم Python ، توجد العديد من أطر عمل الويب الشائعة. توفر الأطر مثل Django تقريبًا كل ما هو ضروري لإنشاء تطبيقات الويب ، وأي شيء يفتقر إليه يمكن تعويضه بواحد من آلاف المكونات الإضافية المتاحة لـ Django. ومع ذلك ، نظرًا للطريقة التي تعمل بها Python أو معظم أطر الويب الخاصة بها ، فإن التعامل مع الاتصالات طويلة الأمد يمكن أن يصبح كابوسًا سريعًا. غالبًا ما يُعتبر النموذج الملولب وقفل المترجم الشامل بمثابة كعب أخيل لبايثون.
لكن كل ذلك بدأ يتغير. مع بعض الميزات الجديدة في Python 3 والأطر الموجودة بالفعل في Python ، مثل Tornado ، لم يعد التعامل مع الاتصالات طويلة الأمد تحديًا بعد الآن. يوفر Tornado إمكانات خادم الويب في Python والتي تكون مفيدة بشكل خاص في التعامل مع الاتصالات طويلة العمر.
في هذه المقالة ، سوف نلقي نظرة على كيفية إنشاء خادم WebSocket بسيط في Python باستخدام Tornado. سيسمح لنا التطبيق التجريبي بتحميل ملف قيم مفصولة بعلامات جدولة (TSV) وتحليله وإتاحة محتوياته على عنوان URL فريد.
تورنادو ومآخذ الويب
Tornado هي مكتبة شبكة غير متزامنة ومتخصصة في التعامل مع الشبكات القائمة على الأحداث. نظرًا لأنه يمكن أن يحتوي بشكل طبيعي على عشرات الآلاف من الاتصالات المفتوحة بشكل متزامن ، يمكن للخادم الاستفادة من ذلك والتعامل مع الكثير من اتصالات WebSocket داخل عقدة واحدة. WebSocket هو بروتوكول يوفر قنوات اتصال ثنائية الاتجاه عبر اتصال TCP واحد. نظرًا لأنه مقبس مفتوح ، فإن هذه التقنية تجعل اتصال الويب ذا حالة ويسهل نقل البيانات في الوقت الفعلي من وإلى الخادم. يجعل الخادم ، الذي يحافظ على حالات العملاء ، من السهل تنفيذ تطبيقات الدردشة في الوقت الفعلي أو ألعاب الويب القائمة على WebSockets.
تم تصميم WebSockets ليتم تنفيذها في متصفحات الويب والخوادم ، وهي مدعومة حاليًا في جميع متصفحات الويب الرئيسية. يتم فتح الاتصال مرة واحدة ويمكن أن تنتقل الرسائل ذهابًا وإيابًا عدة مرات قبل إغلاق الاتصال.
تثبيت Tornado بسيط نوعًا ما. إنه مدرج في PyPI ويمكن تثبيته باستخدام pip أو easy_install:
pip install tornadoيأتي Tornado مع تطبيقه الخاص لـ WebSockets. لأغراض هذه المقالة ، هذا إلى حد كبير كل ما سنحتاجه.
WebSockets في العمل
إحدى مزايا استخدام WebSocket هي الخاصية ذات الحالة الخاصة بها. هذا يغير الطريقة التي نفكر بها عادة في الاتصال بين العميل والخادم. إحدى حالات الاستخدام الخاصة لهذا هو المكان الذي يكون فيه الخادم مطلوبًا لإجراء عمليات بطيئة طويلة وتدفق النتائج تدريجيًا إلى العميل.
في تطبيقنا كمثال ، سيتمكن المستخدم من تحميل ملف من خلال WebSocket. طوال عمر الاتصال بالكامل ، سيحتفظ الخادم بالملف الذي تم تحليله في الذاكرة. بناءً على الطلبات ، يمكن للخادم إرسال أجزاء من الملف إلى الواجهة الأمامية. علاوة على ذلك ، سيتم توفير الملف على عنوان URL يمكن بعد ذلك عرضه من قبل عدة مستخدمين. إذا تم تحميل ملف آخر على نفس عنوان URL ، فسيتمكن كل شخص ينظر إليه من رؤية الملف الجديد على الفور.
للواجهة الأمامية ، سنستخدم AngularJS. سيسمح لنا إطار العمل والمكتبات هذا بالتعامل بسهولة مع عمليات تحميل الملفات وترقيم الصفحات. ومع ذلك ، بالنسبة لكل ما يتعلق بـ WebSockets ، سنستخدم وظائف JavaScript القياسية.
سيتم تقسيم هذا التطبيق البسيط إلى ثلاثة ملفات منفصلة:
- parser.py: حيث يتم تنفيذ خادم Tornado مع معالجات الطلبات
- قوالب / index.html: قالب HTML للواجهة الأمامية
- static / parser.js: للواجهة الأمامية لجافا سكريبت
فتح WebSocket
من الواجهة الأمامية ، يمكن إنشاء اتصال WebSocket عن طريق إنشاء مثيل لكائن WebSocket:
new WebSocket(WEBSOCKET_URL);هذا شيء يتعين علينا القيام به عند تحميل الصفحة. بمجرد إنشاء كائن WebSocket ، يجب إرفاق المعالجات للتعامل مع ثلاثة أحداث مهمة:
- مفتوح: يتم إطلاقه عند إنشاء اتصال
- message: يتم إطلاقه عند تلقي رسالة من الخادم
- إغلاق: يتم إطلاقه عند إغلاق الاتصال
$scope.init = function() { $scope.ws = new WebSocket('ws://' + location.host + '/parser/ws'); $scope.ws.binaryType = 'arraybuffer'; $scope.ws.onopen = function() { console.log('Connected.') }; $scope.ws.onmessage = function(evt) { $scope.$apply(function () { message = JSON.parse(evt.data); $scope.currentPage = parseInt(message['page_no']); $scope.totalRows = parseInt(message['total_number']); $scope.rows = message['data']; }); }; $scope.ws.onclose = function() { console.log('Connection is closed...'); }; } $scope.init();نظرًا لأن معالجات الأحداث هذه لن تقوم تلقائيًا بتشغيل دورة حياة النطاق $ AngularJS ، يجب تغليف محتويات وظيفة المعالج بـ $ apply. في حال كنت مهتمًا ، توجد حزم معينة من AngularJS تسهل دمج WebSocket في تطبيقات AngularJS.
من الجدير بالذكر أن اتصالات WebSocket التي تم إسقاطها لا يتم إعادة تأسيسها تلقائيًا ، وسوف تتطلب من التطبيق محاولة إعادة الاتصال عند تشغيل معالج حدث الإغلاق. هذا قليلا خارج نطاق هذه المقالة.
اختيار ملف للتحميل
نظرًا لأننا نبني تطبيقًا من صفحة واحدة باستخدام AngularJS ، فلن تعمل محاولة إرسال النماذج مع الملفات بالطريقة القديمة. لتسهيل الأمور ، سنستخدم مكتبة تحميل الملفات الخاصة بـ Danial Farid. باستخدام هذا ، كل ما نحتاج إلى القيام به للسماح للمستخدم بتحميل ملف هو إضافة زر إلى قالب الواجهة الأمامية الخاص بنا مع توجيهات AngularJS المحددة:
<button class="btn btn-default" type="file" ngf-select="uploadFile($file, $invalidFiles)" accept=".tsv" ngf-max-size="10MB">Select File</button> تتيح لنا المكتبة ، من بين أشياء كثيرة ، تعيين امتداد وحجم مقبول للملف. النقر على هذا الزر ، تمامًا مثل أي عنصر <input type=”file”> ، سيفتح منتقي الملفات القياسي.
تحميل الملف
عندما تريد نقل البيانات الثنائية ، يمكنك الاختيار من بين المخزن المؤقت للصفيف والنقطة الثنائية الكبيرة. إذا كانت مجرد بيانات أولية مثل ملف صورة ، فاختر blob وتعامل معها بشكل صحيح في الخادم. المخزن المؤقت للصفيف مخصص للمخزن المؤقت الثنائي ذي الطول الثابت ويمكن نقل ملف نصي مثل TSV بتنسيق سلسلة بايت. يوضح مقتطف الشفرة هذا كيفية تحميل ملف بتنسيق المخزن المؤقت للصفيف.

$scope.uploadFile = function(file, errFiles) { ws = $scope.ws; $scope.f = file; $scope.errFile = errFiles && errFiles[0]; if (file) { reader = new FileReader(); rawData = new ArrayBuffer(); reader.onload = function(evt) { rawData = evt.target.result; ws.send(rawData); } reader.readAsArrayBuffer(file); } }يوفر التوجيه ng-file-upload وظيفة uploadFile. هنا يمكنك تحويل الملف إلى مخزن مؤقت للصفيف باستخدام FileReader ، وإرساله عبر WebSocket.
لاحظ أن إرسال ملفات كبيرة عبر WebSocket من خلال قراءتها في مصفوفات مؤقتة قد لا تكون الطريقة المثلى لتحميلها حيث يمكن أن تشغل مساحة كبيرة من الذاكرة مما يؤدي إلى تجربة سيئة.
استلام الملف على الخادم
يحدد Tornado نوع الرسالة باستخدام كود التشغيل 4 بت ، ويعيد str للبيانات الثنائية و unicode للنص.
if opcode == 0x1: # UTF-8 data self._message_bytes_in += len(data) try: decoded = data.decode("utf-8") except UnicodeDecodeError: self._abort() return self._run_callback(self.handler.on_message, decoded) elif opcode == 0x2: # Binary data self._message_bytes_in += len(data) self._run_callback(self.handler.on_message, data)في خادم الويب Tornado ، يتم تلقي المخزن المؤقت للمصفوفة في نوع str.
في هذا المثال ، نوع المحتوى الذي نتوقعه هو TSV ، لذلك يتم تحليل الملف وتحويله إلى قاموس. بالطبع ، في التطبيقات الحقيقية ، هناك طرق أكثر دقة للتعامل مع التحميلات التعسفية.
def make_message(self, page_no=1): page_size = 100 return { "page_no": page_no, "total_number": len(self.rows), "data": self.rows[page_size * (page_no - 1):page_size * page_no] } def on_message(self, message): if isinstance(message, str): self.rows = [csv.reader([line], delimiter="\t").next() for line in (x.strip() for x in message.splitlines()) if line] self.write_message(self.make_message())اطلب صفحة
نظرًا لأن هدفنا هو إظهار بيانات TSV التي تم تحميلها في أجزاء من الصفحات الصغيرة ، فنحن بحاجة إلى وسيلة لطلب صفحة معينة. لتبسيط الأمور ، سنستخدم ببساطة اتصال WebSocket نفسه لإرسال رقم الصفحة إلى خادمنا.
$scope.pageChanged = function() { ws = $scope.ws; ws.send($scope.currentPage); }سيتلقى الخادم هذه الرسالة على هيئة Unicode:
def on_message(self, message): if isinstance(message, unicode): page_no = int(message) self.write_message(self.make_message(page_no))ستؤدي محاولة الرد بإملاء من خادم Tornado WebSocket إلى تشفيره تلقائيًا بتنسيق JSON. لذا لا بأس تمامًا من إرسال أمر يحتوي على 100 صف من المحتوى.
تقاسم الوصول مع الآخرين
لتتمكن من مشاركة الوصول إلى نفس التحميل مع عدة مستخدمين ، نحتاج إلى أن نكون قادرين على تحديد التحميلات بشكل فريد. عندما يتصل المستخدم بالخادم عبر WebSocket ، سيتم إنشاء UUID عشوائي وتخصيصه لاتصاله.
def open(self, doc_uuid=None): if doc_uuid is None: self.uuid = str(uuid.uuid4()) uuid.uuid4() UUID عشوائي ويحول str () UUID إلى سلسلة من الأرقام السداسية العشرية في شكل قياسي.
إذا قام مستخدم آخر لديه UUID بالاتصال بالخادم ، فسيتم إضافة مثيل FileHandler المقابل إلى قاموس باستخدام UUID كمفتاح وتتم إزالته عند إغلاق الاتصال.
@classmethod @tornado.gen.coroutine def add_clients(cls, doc_uuid, client): with (yield lock.acquire()): if doc_uuid in cls.clients: clients_with_uuid = FileHandler.clients[doc_uuid] clients_with_uuid.append(client) else: FileHandler.clients[doc_uuid] = [client] @classmethod @tornado.gen.coroutine def remove_clients(cls, doc_uuid, client): with (yield lock.acquire()): if doc_uuid in cls.clients: clients_with_uuid = FileHandler.clients[doc_uuid] clients_with_uuid.remove(client) if len(clients_with_uuid) == 0: del cls.clients[doc_uuid]قد يعرض قاموس العملاء خطأ KeyError عند إضافة أو إزالة العملاء في وقت واحد. نظرًا لأن Tornado عبارة عن مكتبة شبكات غير متزامنة ، فإنها توفر آليات قفل للمزامنة. قفل بسيط مع coroutine يناسب حالة التعامل مع قاموس العملاء.
إذا قام أي مستخدم بتحميل ملف أو التنقل بين الصفحات ، فإن جميع المستخدمين الذين لديهم نفس المعرف الفريد العمومي (UUID) يعرضون نفس الصفحة.
@classmethod def send_messages(cls, doc_uuid): clients_with_uuid = cls.clients[doc_uuid] message = cls.make_message(doc_uuid) for client in clients_with_uuid: try: client.write_message(message) except: logging.error("Error sending message", exc_info=True)الركض خلف انجينكس
يعد تطبيق WebSockets بسيطًا جدًا ، ولكن هناك بعض الأشياء الصعبة التي يجب مراعاتها عند استخدامها في بيئات الإنتاج. Tornado هو خادم ويب ، لذا يمكنه تلقي طلبات المستخدمين مباشرةً ، ولكن قد يكون نشره خلف Nginx خيارًا أفضل لأسباب عديدة. ومع ذلك ، فإن الأمر يتطلب مجهودًا أكبر قليلاً حتى تتمكن من استخدام WebSockets من خلال Nginx:
http { upstream parser { server 127.0.0.1:8080; } server { location ^~ /parser/ws { proxy_pass http://parser; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } } } يعمل التوجيهان proxy_set_header على جعل Nginx يمرر الرؤوس الضرورية إلى خوادم النهاية الضرورية لترقية الاتصال إلى WebSocket.
ماذا بعد؟
في هذه المقالة ، قمنا بتطبيق تطبيق ويب Python بسيط يستخدم WebSockets للحفاظ على الاتصالات المستمرة بين الخادم وكل من العملاء. مع أطر عمل الشبكات غير المتزامنة الحديثة مثل Tornado ، فإن الاحتفاظ بعشرات الآلاف من الاتصالات المفتوحة بشكل متزامن في Python أمر ممكن تمامًا.
على الرغم من أنه كان من الممكن إجراء بعض جوانب التنفيذ لهذا التطبيق التجريبي بشكل مختلف ، إلا أنني آمل أنه لا يزال يساعد في توضيح استخدام WebSockets في https://www.toptal.com/tornado framework. كود المصدر للتطبيق التجريبي متاح على GitHub.
