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

قبل التاريخ
ذات مرة في Tinkoff Business ، كانت هناك محادثة عملت على
Websocket .
بعد مرور بعض الوقت ، توقف عن تصميم حسابه الشخصي ، وكان يطلب بشكل عام كتابة طويلة من الزاوي 1.6 إلى الزاوي 2+. قررت أن الوقت قد حان للبدء في تحديثه. اكتشف أحد زملاء الخلفية أن واجهة الدردشة ستتغير ، واقترح في الوقت نفسه إعادة واجهة برمجة التطبيقات ، على وجه الخصوص - تغيير النقل من طبقة الويب إلى طبقة المقابس الآمنة
(الأحداث التي يرسلها الخادم) . اقترح هذا لأنه عند تحديث التكوين NGINX تم قطع جميع الاتصالات ، والتي كانت مؤلمة لاستعادتها.
ناقشنا بنية الحل الجديد وتوصلنا إلى استنتاج مفاده أننا سوف نستقبل ونرسل البيانات باستخدام طلبات HTTP المعتادة. على سبيل المثال ، أرسل رسالة
POST: / api / send-message ، واحصل على قائمة
بمربعات حوار
GET: / api / conversations-list ، وما إلى ذلك. وسيتم إرسال الأحداث غير المتزامنة مثل "رسالة جديدة من المحاور" عبر SSE. وبالتالي ، سنزيد من التسامح مع الخطأ في التطبيق: إذا تعطل اتصال SSE ، ستظل المحادثة تعمل ، ولن تتلقى إعلامات في الوقت الفعلي.
بالإضافة إلى الدردشة في websocket ، كنا نطارد الأحداث لمكون "الإعلامات الرفيعة". يتيح لك هذا المكون إرسال إشعارات متعددة إلى الحساب الشخصي للمستخدم ، على سبيل المثال ، أن عملية استيراد الحسابات ، والتي قد تستغرق عدة دقائق ، قد تم إكمالها بنجاح. للتخلي عن websocket بالكامل ، نقلنا هذا المكون إلى اتصال SSE منفصل.
المشكلة
عند فتح علامة تبويب متصفح واحدة ، يتم إنشاء اتصالين SSE: واحد للدردشة والآخر للإعلامات الدقيقة. حسنًا ، دعهم يبدعون. اسف ام ماذا لا نشعر بالأسف ، ولكن المتصفحات تشعر بالأسف! لديهم
حد لعدد الاتصالات المستمرة المتزامنة للمجال . تخمين كم هو في كروم؟ صحيح ، ستة! لقد فتحت ثلاث علامات تبويب - لقد سجلت مجموعة الاتصال بالكامل ولم يعد بإمكانك تقديم طلبات HTTP. هذا صحيح بالنسبة لبروتوكول HTTP / 1.x. في HTTP / 2 لا توجد مشكلة من هذا القبيل بسبب تعدد الإرسال.
هناك طريقتان لحل هذه المشكلة على مستوى البنية التحتية:
- تقاسم المجال.
- HTTP / 2.
كلتا الطريقتين بدت باهظة الثمن ، لأن الكثير من البنية التحتية يجب أن تتأثر.
لذلك ، بالنسبة للمبتدئين ، حاولنا حل المشكلة من جانب المتصفح. كانت الفكرة الأولى هي إجراء نوع من النقل بين علامات التبويب ، على سبيل المثال ، من خلال
واجهة برمجة تطبيقات LocalStorage أو
Broadcast Channel .
المعنى هو: فتح اتصالات SSE في علامة تبويب واحدة فقط وإرسال البيانات إلى الباقي. لم يكن هذا الحل مثاليًا أيضًا ، حيث سيتطلب إصدار 50 SPA ، والتي تشكل حساب Tinkoff Business الشخصي. يعد إصدار 50 تطبيقًا مكلفًا أيضًا ، لذلك تابعت البحث عن طرق أخرى.
قرار
لقد عملت مؤخرًا مع عمال الخدمة وفكرت: هل من الممكن تطبيقها في هذه الحالة؟
للإجابة على هذا السؤال ، عليك أولاً أن تفهم ما الذي يقوم به عمال الخدمة عمومًا؟ يمكنهم تقديم طلبات بالوكالة ، يبدو كما يلي:
self.addEventListener('fetch', event => { const response = self.caches.open('example') .then(caches => caches.match(event.request)) .then(response => response || fetch(event.request)); event.respondWith(response); });
نستمع إلى الأحداث لطلبات HTTP والرد كما نريد. في هذه الحالة ، نحاول الاستجابة من ذاكرة التخزين المؤقت ، وإذا لم ينجح الأمر ، فنحن نقدم طلبًا إلى الخادم.
حسنًا ، دعنا نحاول اعتراض اتصال SSE والإجابة عليه:
self.addEventListener('fetch', event => { const {headers} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream'; if (!isSSERequest) { return; } event.respondWith(new Response('Hello!')); });
في طلبات الشبكة ، نرى الصورة التالية:

وفي وحدة التحكم ، هذا:

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

! ممتاز تأخذ فئة
الاستجابة كهيئة
ReadableStream . بعد قراءة
الوثائق ، يمكنك معرفة أن
ReadableStream لديها وحدة تحكم بها طريقة
enqueue () - بمساعدتها ، يمكنك دفق البيانات. مناسبة ، خذها!
self.addEventListener('fetch', event => { const {headers} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream'; if (!isSSERequest) { return; } const responseText = 'Hello!'; const responseData = Uint8Array.from(responseText, x => x.charCodeAt(0)); const stream = new ReadableStream({start: controller => controller.enqueue(responseData)}); const response = new Response(stream); event.respondWith(response); });

لا يوجد خطأ ، الاتصال معلقة في حالة معلقة ولا توجد بيانات تصل من جانب العميل. بمقارنة طلبي مع طلب خادم حقيقي ، أدركت أن الإجابة كانت في الرؤوس. لطلبات SSE ، يجب تحديد الرؤوس التالية:
const sseHeaders = { 'content-type': 'text/event-stream', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', };
عند إضافة هذه الرؤوس ، سيتم فتح الاتصال بنجاح ، ولكن لن يتم استلام البيانات من جانب العميل. هذا واضح ، لأنه لا يمكنك فقط إرسال نص عشوائي - يجب أن يكون هناك بعض التنسيق.
في javascript.info ،
يتم وصف تنسيق البيانات الذي تريد إرسال البيانات به من الخادم بشكل
جيد . يمكن وصفها بسهولة بوظيفة واحدة:
const sseChunkData = (data: string, event?: string, retry?: number, id?: number): string => Object.entries({event, id, data, retry}) .filter(([, value]) => ![undefined, null].includes(value)) .map(([key, value]) => `${key}: ${value}`) .join('\n') + '\n\n';
للامتثال لتنسيق SSE ، يجب أن يرسل الخادم رسائل مفصولة بفواصل الأسطر المزدوجة
\ n \ n .
تتكون الرسالة من الحقول التالية:
- البيانات - نص الرسالة ، يتم تفسير العديد من البيانات في صف واحد كرسالة واحدة ، مفصولة بفواصل الأسطر \ n ؛
- id - بتحديث خاصية lastEventId المرسلة في رأس Last-Event-ID عند إعادة الاتصال ؛
- إعادة المحاولة - التأخير الموصى به قبل إعادة الاتصال بالمللي ثانية ، لا يمكن تعيينه باستخدام JavaScript ؛
- الحدث - اسم حدث المستخدم ، المشار إليه قبل البيانات.
أضف الرؤوس اللازمة ، وقم بتغيير الإجابة إلى التنسيق المطلوب وشاهد ما يحدث:
self.addEventListener('fetch', event => { const {headers} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream'; if (!isSSERequest) { return; } const sseChunkData = (data, event, retry, id) => Object.entries({event, id, data, retry}) .filter(([, value]) => ![undefined, null].includes(value)) .map(([key, value]) => `${key}: ${value}`) .join('\n') + '\n\n'; const sseHeaders = { 'content-type': 'text/event-stream', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', }; const responseText = sseChunkData('Hello!'); const responseData = Uint8Array.from(responseText, x => x.charCodeAt(0)); const stream = new ReadableStream({start: controller => controller.enqueue(responseData)}); const response = new Response(stream, {headers: sseHeaders}); event.respondWith(response); });

يا عالمي نعم ، لقد أجريت اتصال SSE بدون خادم!
يؤدي
الآن يمكننا اعتراض طلب SSE والرد عليه دون تجاوز المستعرض بنجاح.
في البداية ، كانت الفكرة هي إقامة اتصال مع الخادم ، ولكن هناك شيئًا واحدًا - ومنه لإرسال البيانات إلى علامات التبويب. لنقم بذلك!
self.addEventListener('fetch', event => { const {headers, url} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream';
نفس الرمز على جيثب.حصلت على حل بسيط جدًا لمهمة غير تافهة. ولكن ، بالطبع ، لا يزال هناك الكثير من الفروق الدقيقة. على سبيل المثال ، تحتاج إلى إغلاق الاتصال بالخادم عند إغلاق جميع علامات التبويب ، ودعم بروتوكول SSE بشكل كامل ، وما إلى ذلك.
لقد قررنا كل هذا بنجاح - أنا متأكد من أنه لن يكون من الصعب عليك!