Hallo!
Mein Name ist Sasha und ich arbeite als Architekt bei Tinkoff Business.
In diesem Artikel möchte ich darüber sprechen, wie Sie das Browser-Limit für die Anzahl offener, langlebiger HTTP-Verbindungen innerhalb derselben Domäne mithilfe von Service Worker überwinden können.
Wenn Sie möchten, können Sie den Hintergrund und die Beschreibung des Problems überspringen, nach einer Lösung suchen und sofort mit dem Ergebnis fortfahren.

Hintergrund
Es war einmal bei Tinkoff Business ein Chat, der auf
Websocket funktionierte .
Nach einiger Zeit passte er nicht mehr in die Gestaltung seines persönlichen Kontos und bat im Allgemeinen lange um eine Neufassung von Winkel 1,6 auf Winkel 2+. Ich entschied, dass es Zeit war, mit der Aktualisierung zu beginnen. Ein Kollege-Backender fand heraus, dass sich das Chat-Frontend ändern wird, und schlug gleichzeitig vor, insbesondere die API zu wiederholen - den Transport von Websocket zu
SSE zu ändern
(vom Server gesendete Ereignisse) . Er schlug dies vor, da beim Aktualisieren der NGINX-Konfiguration alle Verbindungen unterbrochen wurden, was dann schmerzhaft wiederherzustellen war.
Wir haben die Architektur der neuen Lösung besprochen und sind zu dem Schluss gekommen, dass wir Daten über reguläre HTTP-Anforderungen empfangen und senden werden. Senden Sie beispielsweise eine
POST: / api / send-message-Nachricht , rufen Sie eine Liste der
GET- Dialoge ab
: / api / gesprächsliste usw. Und asynchrone Ereignisse wie "eine neue Nachricht vom Gesprächspartner" werden über SSE gesendet. Daher erhöhen wir die Fehlertoleranz der Anwendung: Wenn die SSE-Verbindung unterbrochen wird, funktioniert der Chat weiterhin, nur werden keine Echtzeitbenachrichtigungen empfangen.
Zusätzlich zum Chat im Websocket haben wir Ereignisse für die Komponente "Thin Notifications" verfolgt. Mit dieser Komponente können Sie verschiedene Benachrichtigungen an das persönliche Konto des Benutzers senden, z. B. dass der Import von Konten, der einige Minuten dauern kann, erfolgreich abgeschlossen wurde. Um den Websocket vollständig aufzugeben, haben wir diese Komponente in eine separate SSE-Verbindung verschoben.
Das Problem
Wenn Sie eine Browser-Registerkarte öffnen, werden zwei SSE-Verbindungen erstellt: eine für den Chat und eine für subtile Benachrichtigungen. Nun, lass sie erschaffen werden. Entschuldigung oder was? Es tut uns nicht leid, aber Browser tun uns leid! Sie haben eine
Begrenzung für die Anzahl gleichzeitiger persistenter Verbindungen für eine Domäne . Ratet mal, wie viel in Chrome ist? Richtig, sechs! Ich habe drei Registerkarten geöffnet. Ich habe den gesamten Verbindungspool bewertet und Sie können keine HTTP-Anforderungen mehr stellen. Dies gilt für das HTTP / 1.x-Protokoll. In HTTP / 2 gibt es kein solches Problem aufgrund von Multiplexing.
Es gibt verschiedene Möglichkeiten, um dieses Problem auf Infrastrukturebene zu lösen:
- Domain-Sharding.
- HTTP / 2.
Beide Methoden schienen teuer zu sein, da viele Infrastrukturen betroffen sein müssten.
Daher haben wir zunächst versucht, das Problem auf der Browserseite zu lösen. Die erste Idee war, eine Art Transport zwischen den Registerkarten
durchzuführen , beispielsweise über die
LocalStorage- oder
Broadcast Channel-API .
Die Bedeutung ist folgende: Wir öffnen SSE-Verbindungen in nur einer Registerkarte und senden die Daten an den Rest. Diese Lösung sah auch nicht optimal aus, da alle 50 SPA, aus denen das persönliche Konto von Tinkoff Business besteht, freigegeben werden müssten. Die Veröffentlichung von 50 Anwendungen ist ebenfalls teuer, daher habe ich weiter nach anderen Möglichkeiten gesucht.
Lösung
Ich habe kürzlich mit Servicemitarbeitern gearbeitet und dachte: Ist es möglich, sie in dieser Situation anzuwenden?
Um diese Frage zu beantworten, müssen Sie zunächst verstehen, was Servicemitarbeiter im Allgemeinen tun. Sie können Anfragen per Proxy stellen, es sieht ungefähr so aus:
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); });
Wir hören Ereignisse für HTTP-Anfragen ab und antworten, wie wir möchten. In diesem Fall versuchen wir, aus dem Cache zu antworten. Wenn dies nicht funktioniert, senden wir eine Anfrage an den Server.
Ok, versuchen wir, die SSE-Verbindung abzufangen und zu beantworten:
self.addEventListener('fetch', event => { const {headers} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream'; if (!isSSERequest) { return; } event.respondWith(new Response('Hello!')); });
In Netzwerkanfragen sehen wir das folgende Bild:

Und in der Konsole:

Schon nicht schlecht. Die Anfrage wurde abgefangen, aber die SSE möchte keine Antwort in Form von
Text / Plain , sondern
Text / Event-Stream . Wie erstelle ich jetzt einen Stream? Aber kann ich überhaupt mit einem Stream von einem Servicemitarbeiter antworten? Na mal sehen:

Großartig! Die
Response- Klasse nimmt als
Text ReadableStream . Nachdem Sie die
Dokumentation gelesen haben, können Sie feststellen, dass
ReadableStream über einen Controller mit einer
enqueue () -Methode verfügt. Mithilfe dieser Funktion können Sie Daten streamen. Passend, nimm es!
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); });

Es liegt kein Fehler vor, die Verbindung bleibt im Status "Ausstehend" und es kommen keine Daten auf der Clientseite an. Beim Vergleich meiner Anfrage mit einer echten Serveranfrage wurde mir klar, dass sich die Antwort in den Headern befand. Für SSE-Anforderungen müssen die folgenden Header angegeben werden:
const sseHeaders = { 'content-type': 'text/event-stream', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', };
Wenn Sie diese Header hinzufügen, wird die Verbindung erfolgreich geöffnet, die Daten werden jedoch nicht auf der Clientseite empfangen. Dies ist offensichtlich, da Sie nicht nur zufälligen Text senden können - es muss ein Format vorhanden sein.
In javascript.info ist
ein Datenformat gut beschrieben, in dem Daten vom Server gesendet werden müssen. Es kann leicht mit einer Funktion beschrieben werden:
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';
Um dem SSE-Format zu entsprechen, muss der Server Nachrichten senden, die durch einen doppelten Zeilenumbruch getrennt sind
\ n \ n .
Die Nachricht besteht aus folgenden Feldern:
- Daten - Nachrichtentext, mehrere Daten in einer Zeile werden als eine Nachricht interpretiert, getrennt durch Zeilenumbrüche \ n;
- id - Aktualisiert die lastEventId-Eigenschaft, die beim erneuten Herstellen der Verbindung im Last-Event-ID-Header gesendet wird.
- Wiederholen - Die empfohlene Verzögerung vor dem erneuten Herstellen der Verbindung in Millisekunden kann nicht mit JavaScript festgelegt werden.
- Ereignis - Der Name des Benutzerereignisses, der vor den Daten angegeben wird.
Fügen Sie die erforderlichen Überschriften hinzu, ändern Sie die Antwort in das gewünschte Format und sehen Sie, was passiert:
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); });

Oh mein Globus! Ja, ich habe eine SSE-Verbindung ohne Server hergestellt!
Ergebnis
Jetzt können wir die SSE-Anfrage erfolgreich abfangen und darauf antworten, ohne über den Browser hinauszugehen.
Ursprünglich bestand die Idee darin, eine Verbindung zum Server herzustellen, aber nur eines - und daraus Daten an Registerkarten zu senden. Lass es uns tun!
self.addEventListener('fetch', event => { const {headers, url} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream';
Der gleiche Code auf Github.Ich habe eine ziemlich einfache Lösung für eine nicht so triviale Aufgabe. Aber natürlich gibt es noch viele Nuancen. Beispielsweise müssen Sie die Verbindung zum Server schließen, wenn Sie alle Registerkarten schließen, das SSE-Protokoll vollständig unterstützen usw.
Wir haben das alles erfolgreich entschieden - ich bin sicher, es wird nicht schwierig für Sie!