SSEGWSW: Gateway de eventos enviados pelo servidor por trabalhadores de serviço

Oi

Meu nome é Sasha e trabalho como arquiteto na Tinkoff Business.

Neste artigo, quero falar sobre como superar a restrição de navegadores no número de conexões HTTP de longa duração abertas no mesmo domínio usando o service worker.

Se desejar, pule o plano de fundo, a descrição do problema, procure uma solução e prossiga imediatamente para o resultado.

SSEGWSW

Antecedentes


Era uma vez no Tinkoff Business, houve um bate-papo que funcionava no Websocket .

Depois de algum tempo, ele deixou de se encaixar no design de sua conta pessoal e, em geral, estava pedindo uma reescrita de angular 1.6 para angular 2+. Decidi que era hora de começar a atualizá-lo. Um colega-colaborador descobriu que o front-end do bate-papo mudará e sugeriu ao mesmo tempo refazer a API, em particular - alterar o transporte de websocket para SSE (eventos enviados pelo servidor) . Ele sugeriu isso porque, ao atualizar a configuração do NGINX, todas as conexões foram interrompidas, o que foi difícil de restaurar.

Discutimos a arquitetura da nova solução e chegamos à conclusão de que receberemos e enviaremos dados usando solicitações HTTP regulares. Por exemplo, envie uma mensagem POST: / api / send-message , obtenha uma lista das caixas de diálogo GET: / api / conversations-list e assim por diante. E eventos assíncronos como "uma nova mensagem do interlocutor" serão enviados via SSE. Portanto, aumentaremos a tolerância a falhas do aplicativo: se a conexão SSE cair, o bate-papo continuará funcionando, mas não receberá notificações em tempo real.

Além do bate-papo no websocket, estávamos perseguindo eventos para o componente "notificações finas". Este componente permite enviar várias notificações para a conta pessoal do usuário, por exemplo, que a importação de contas, que pode levar alguns minutos, foi concluída com êxito. Para abandonar completamente o websocket, movemos esse componente para uma conexão SSE separada.

O problema


Quando você abre uma guia do navegador, duas conexões SSE são criadas: uma para bate-papo e outra para notificações sutis. Bem, que eles sejam criados. Desculpe ou o que? Não sentimos muito, mas os navegadores sentem muito! Eles têm um limite no número de conexões persistentes simultâneas para um domínio . Adivinha quanto está no Chrome? Certo, seis! Abri três guias - marquei todo o conjunto de conexões e você não pode mais fazer solicitações HTTP. Isso é verdade para o protocolo HTTP / 1.x. No HTTP / 2, não existe esse problema devido à multiplexação.

Existem algumas maneiras de resolver esse problema no nível da infraestrutura:

  1. Fragmento de domínio.
  2. HTTP / 2.

Ambos os métodos pareciam caros, já que muita infraestrutura teria que ser afetada.

Portanto, para iniciantes, tentamos resolver o problema no lado do navegador. A primeira idéia foi fazer algum tipo de transporte entre as guias, por exemplo, por meio da API LocalStorage ou Broadcast Channel .

O significado é o seguinte: abrimos as conexões SSE em apenas uma guia e enviamos os dados para o restante. Essa solução também não parecia ótima, pois exigiria o lançamento de todos os 50 SPA, que compõem a conta pessoal do Tinkoff Business. A liberação de 50 aplicativos também é cara, então continuei procurando outras maneiras.

Solução


Recentemente, trabalhei com trabalhadores de serviços e pensei: é possível aplicá-los nessa situação?

Para responder a essa pergunta, primeiro você precisa entender o que os funcionários de serviço geralmente fazem? Eles podem fazer pedidos de proxy, é algo como isto:

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

Ouvimos eventos para solicitações HTTP e respondemos como desejamos. Nesse caso, estamos tentando responder a partir do cache e, se não der certo, fazemos uma solicitação ao servidor.

Ok, vamos tentar interceptar a conexão SSE e responder:

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

Nas solicitações de rede, vemos a seguinte imagem:

imagem

E no console, isso:

imagem

Já não é ruim. A solicitação foi interceptada, mas o SSE não deseja uma resposta na forma de texto / sem formatação , mas deseja texto / fluxo de eventos . Como criar um fluxo agora? Mas posso responder com um fluxo de um trabalhador de serviço? Bem, vamos ver:

imagem

Ótimo! A classe Response leva como um corpo ReadableStream . Depois de ler a documentação , você pode descobrir que o ReadableStream possui um controlador que possui um método enqueue () - com sua ajuda, você pode transmitir dados. Adequado, pegue!

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

imagem

Não há erro, a conexão trava no status pendente e nenhum dado chega no lado do cliente. Comparando minha solicitação com uma solicitação real do servidor, percebi que a resposta estava nos cabeçalhos. Para solicitações SSE, os seguintes cabeçalhos devem ser especificados:

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

Quando você adiciona esses cabeçalhos, a conexão é aberta com êxito, mas os dados não serão recebidos no lado do cliente. Isso é óbvio, já que você não pode enviar texto aleatório - deve haver algum formato.

No javascript.info, o formato de dados no qual você deseja enviar dados do servidor está bem descrito . Pode ser facilmente descrito com uma função:

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

Para estar em conformidade com o formato SSE, o servidor deve enviar mensagens separadas por uma quebra de linha dupla \ n \ n .

A mensagem consiste nos seguintes campos:

  • data - corpo da mensagem, vários dados seguidos são interpretados como uma mensagem, separados por quebras de linha \ n;
  • id - atualiza a propriedade lastEventId enviada no cabeçalho Last-Event-ID ao reconectar;
  • tente novamente - o atraso recomendado antes de reconectar em milissegundos, não pode ser definido usando JavaScript;
  • event - o nome do evento do usuário, indicado antes dos dados.

Adicione os cabeçalhos necessários, altere a resposta para o formato desejado e veja o que acontece:

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

imagem

Oh meu globo! Sim, fiz uma conexão SSE sem um servidor!

Resultado


Agora podemos interceptar com êxito a solicitação SSE e responder a ela sem ir além do navegador.

Inicialmente, a idéia era estabelecer uma conexão com o servidor, mas apenas uma coisa - e dele enviar dados para as guias. Vamos fazer isso!

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

O mesmo código no github.
Eu tenho uma solução bastante simples para uma tarefa não tão trivial. Mas, é claro, ainda existem muitas nuances. Por exemplo, você precisa fechar a conexão com o servidor ao fechar todas as guias, oferecer suporte total ao protocolo SSE e assim por diante.

Decidimos com sucesso tudo isso. Tenho certeza de que não será difícil para você!

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


All Articles