SSEGWSW: passerelle d'événements envoyés par le serveur par les techniciens de maintenance

Salut

Je m'appelle Sasha et je travaille en tant qu'architecte chez Tinkoff Business.

Dans cet article, je veux parler de la façon de surmonter la restriction des navigateurs sur le nombre de connexions HTTP ouvertes à long terme dans le même domaine à l'aide de Service Worker.

Si vous le souhaitez, n'hésitez pas à ignorer l'arrière-plan, la description du problème, à rechercher une solution et à passer immédiatement au résultat.

SSEGWSW

Contexte


Il était une fois à Tinkoff Business une discussion qui fonctionnait sur Websocket .

Après un certain temps, il a cessé de s'intégrer dans la conception de son compte personnel, et en général, il a longtemps demandé une réécriture de angulaire 1.6 à angulaire 2+. J'ai décidé qu'il était temps de commencer à le mettre à jour. Un collègue backender a découvert que l'interface de discussion allait changer et a suggéré en même temps de refaire l'API, en particulier - de changer le transport de websocket vers SSE (événements envoyés par le serveur) . Il a suggéré cela parce que lors de la mise à jour de la configuration NGINX, toutes les connexions ont été rompues, ce qui a été difficile à restaurer.

Nous avons discuté de l'architecture de la nouvelle solution et sommes arrivés à la conclusion que nous recevrons et enverrons des données à l'aide de requêtes HTTP régulières. Par exemple, envoyez un message POST: / api / send-message , obtenez une liste des boîtes de dialogue GET: / api / conversations-list, etc. Et les événements asynchrones comme "un nouveau message de l'interlocuteur" seront envoyés via SSE. Nous allons donc augmenter la tolérance aux pannes de l'application: si la connexion SSE tombe, le chat fonctionnera toujours, seulement il ne recevra pas de notifications en temps réel.

En plus du chat dans websocket, nous recherchions des événements pour le composant «notifications minces». Ce composant vous permet d'envoyer diverses notifications au compte personnel de l'utilisateur, par exemple, que l'importation de comptes, qui peut prendre plusieurs minutes, s'est terminée avec succès. Pour abandonner complètement websocket, nous avons déplacé ce composant vers une connexion SSE distincte.

Le problème


Lorsque vous ouvrez un onglet de navigateur, deux connexions SSE sont créées: une pour le chat et une pour les notifications subtiles. Eh bien, laissez-les être créés. Désolé ou quoi? Nous ne nous sentons pas désolés, mais les navigateurs sont désolés! Ils ont une limite sur le nombre de connexions persistantes simultanées pour un domaine . Devinez combien est dans Chrome? Bon, six! J'ai ouvert trois onglets - j'ai marqué l'ensemble du pool de connexions et vous ne pouvez plus faire de requêtes HTTP. Cela est vrai pour le protocole HTTP / 1.x. Dans HTTP / 2, il n'y a pas un tel problème en raison du multiplexage.

Il existe plusieurs façons de résoudre ce problème au niveau de l'infrastructure:

  1. Partage de domaine.
  2. HTTP / 2.

Ces deux méthodes semblaient coûteuses, car de nombreuses infrastructures devraient être affectées.

Par conséquent, pour commencer, nous avons essayé de résoudre le problème côté navigateur. La première idée était de faire une sorte de transport entre les onglets, par exemple, via l' API LocalStorage ou Broadcast Channel .

La signification est la suivante: nous ouvrons les connexions SSE dans un seul onglet et envoyons les données aux autres. Cette solution n'avait pas non plus l'air optimale, car elle nécessiterait la libération de tous les 50 SPA, qui composent le compte personnel Tinkoff Business. La publication de 50 applications est également coûteuse, j'ai donc continué à chercher d'autres moyens.

Solution


J'ai récemment travaillé avec des travailleurs des services et j'ai pensé: est-il possible de les appliquer dans cette situation?

Pour répondre à cette question, vous devez d'abord comprendre ce que font généralement les travailleurs des services? Ils peuvent envoyer des requêtes par procuration, cela ressemble à ceci:

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); }); 

Nous écoutons les événements pour les requêtes HTTP et répondons comme nous le souhaitons. Dans ce cas, nous essayons de répondre à partir du cache, et si cela ne fonctionne pas, nous faisons une demande au serveur.

Ok, essayons d'intercepter la connexion SSE et d'y répondre:

 self.addEventListener('fetch', event => { const {headers} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream'; if (!isSSERequest) { return; } event.respondWith(new Response('Hello!')); }); 

Dans les demandes de réseau, nous voyons l'image suivante:

image

Et dans la console, ceci:

image

Déjà pas mal. La demande a été interceptée, mais le SSE ne veut pas de réponse sous forme de texte / simple , mais veut du texte / flux d'événements . Comment créer un stream maintenant? Mais puis-je même répondre avec un flux provenant d'un technicien de service? Voyons voir:

image

Super! La classe Response prend comme un corps ReadableStream . Après avoir lu la documentation , vous pouvez découvrir que ReadableStream a un contrôleur qui a une méthode enqueue () - avec son aide, vous pouvez diffuser des données. Convient, prends-le!

 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); }); 

image

Il n'y a aucune erreur, la connexion se bloque dans l'état en attente et aucune donnée n'arrive du côté client. En comparant ma demande avec une vraie demande de serveur, j'ai réalisé que la réponse était dans les en-têtes. Pour les demandes SSE, les en-têtes suivants doivent être spécifiés:

 const sseHeaders = { 'content-type': 'text/event-stream', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', }; 

Lorsque vous ajoutez ces en-têtes, la connexion s'ouvrira correctement, mais les données ne seront pas reçues côté client. Cela est évident, car vous ne pouvez pas simplement envoyer du texte au hasard - il doit y avoir un certain format.

Sur javascript.info, le format de données dans lequel vous souhaitez envoyer des données depuis le serveur est bien décrit . Il peut être facilement décrit avec une fonction:

 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'; 

Pour respecter le format SSE, le serveur doit envoyer des messages séparés par un saut de ligne double \ n \ n .

Le message se compose des champs suivants:

  • données - corps du message, plusieurs données consécutives sont interprétées comme un seul message, séparées par des sauts de ligne \ n;
  • id - met à jour la propriété lastEventId envoyée dans l'en-tête Last-Event-ID lors de la reconnexion;
  • réessayer - le délai recommandé avant de se reconnecter en millisecondes, ne peut pas être défini à l'aide de JavaScript;
  • événement - le nom de l'événement utilisateur, indiqué avant les données.

Ajoutez les en-têtes nécessaires, modifiez la réponse au format souhaité et voyez ce qui se passe:

 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); }); 

image

Oh mon glob! Oui, j'ai fait une connexion SSE sans serveur!

Résultat


Nous pouvons maintenant intercepter avec succès la demande SSE et y répondre sans aller au-delà du navigateur.

Initialement, l'idée était d'établir une connexion avec le serveur, mais une seule chose - et à partir de là, d'envoyer des données aux onglets. Faisons-le!

 self.addEventListener('fetch', event => { const {headers, url} = event.request; const isSSERequest = headers.get('Accept') === 'text/event-stream'; //   SSE- if (!isSSERequest) { return; } //    SSE const sseHeaders = { 'content-type': 'text/event-stream', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', }; // ,    SSE 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'; //    ,   — url,  — EventSource const serverConnections = {}; //   url             const getServerConnection = url => { if (!serverConnections[url]) serverConnections[url] = new EventSource(url); return serverConnections[url]; }; //          const onServerMessage = (controller, {data, type, retry, lastEventId}) => { const responseText = sseChunkData(data, type, retry, lastEventId); const responseData = Uint8Array.from(responseText, x => x.charCodeAt(0)); controller.enqueue(responseData); }; const stream = new ReadableStream({ start: controller => getServerConnection(url).onmessage = onServerMessage.bind(null, controller) }); const response = new Response(stream, {headers: sseHeaders}); event.respondWith(response); }); 

Le même code sur github.
J'ai obtenu une solution assez simple pour une tâche pas si banale. Mais, bien sûr, il y a encore beaucoup de nuances. Par exemple, vous devez fermer la connexion au serveur lors de la fermeture de tous les onglets, prendre entièrement en charge le protocole SSE, etc.

Nous avons décidé de tout cela avec succès - je suis sûr que ce ne sera pas difficile pour vous!

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


All Articles