Como o JavaScript funciona: Mergulho profundo nos WebSockets e HTTP/2 com SSE + como escolher o caminho certo
Parte 5 - Explorando as entranhas do JavaScript: uma série reveladora sobre o motor, tempo de execução e pilha de chamadas por debaixo dos panos.
Este é o post nº 5 da série dedicada a explorar o JavaScript e seus componentes de construção. No processo de identificar e descrever os elementos principais, também compartilhamos algumas regras práticas que usamos ao construir o SessionStack, uma ferramenta JavaScript para desenvolvedores identificarem, visualizarem e reproduzirem bugs de aplicativos da web por meio de reprodução de sessão perfeita em pixels.
Se você perdeu os capítulos anteriores, pode encontrá-los aqui:
- Uma visão geral do mecanismo, tempo de execução e pilha de chamadas
- Dentro do mecanismo V8 do Google + 5 dicas sobre como escrever código otimizado
- Gerenciamento de memória + como lidar com 4 vazamentos de memória comuns
- O loop de eventos e o surgimento da programação assíncrona + 5 maneiras de melhorar a codificação com async/await
Desta vez, mergulharemos no mundo dos protocolos de comunicação, mapeando e discutindo seus atributos e partes de construção ao longo do caminho. Faremos uma comparação rápida entre WebSockets e HTTP/2. No final, compartilharemos algumas ideias sobre como escolher qual caminho seguir quando se trata de protocolos de rede.
Introdução
Atualmente, aplicativos da web complexos que possuem interfaces de usuário ricas e dinâmicas são considerados como algo garantido. E não é surpreendente - a internet percorreu um longo caminho desde o seu início.
Inicialmente, a internet não foi construída para suportar aplicativos da web tão dinâmicos e complexos. Ela foi concebida para ser uma coleção de páginas HTML, interligadas umas às outras para formar o conceito de "web" que contém informações. Tudo foi construído em torno do paradigma chamado de solicitação/resposta do HTTP. Um cliente carrega uma página e depois nada acontece até que o usuário clique e navegue para a próxima página.
Por volta de 2005, o AJAX foi introduzido e muitas pessoas começaram a explorar as possibilidades de fazer conexões bidirecionais entre um cliente e um servidor. Ainda assim, toda a comunicação HTTP era controlada pelo cliente, o que exigia interação do usuário ou consultas periódicas para carregar novos dados do servidor.
Tornando o HTTP "bidirecional"
Tecnologias que permitem que o servidor envie dados para o cliente "proativamente" existem há bastante tempo. "Push" e "Comet" são alguns exemplos.
Uma das gambiarras mais comuns para criar a ilusão de que o servidor está enviando dados para o cliente é chamada de long polling. Com o long polling, o cliente abre uma conexão HTTP com o servidor, que a mantém aberta até que uma resposta seja enviada. Sempre que o servidor tiver novos dados para enviar, ele os transmite como uma resposta.
Vamos ver como um trecho de código muito simples de long polling pode ser:
Isso é basicamente uma função autoexecutável que é executada automaticamente na primeira vez. Ela configura o intervalo de dez (10) segundos e, após cada chamada Ajax assíncrona ao servidor, a função de retorno de chamada chama o ajax novamente.
Outras técnicas envolvem Flash ou solicitação multipartida XHR e os chamados htmlfiles.
Todos esses truques compartilham o mesmo problema: eles têm a sobrecarga do HTTP, o que não os torna adequados para aplicativos com baixa latência. Pense em jogos de tiro em primeira pessoa multiplayer no navegador ou qualquer outro jogo online com um componente em tempo real.
A introdução do WebSockets
A especificação do WebSockets define uma API que estabelece conexões "socket" entre um navegador da web e um servidor. Em palavras simples: há uma conexão persistente entre o cliente e o servidor, e ambas as partes podem começar a enviar dados a qualquer momento.
O cliente estabelece uma conexão WebSocket por meio de um processo conhecido como handshake do WebSocket. Esse processo começa com o cliente enviando uma solicitação HTTP regular ao servidor. Um cabeçalho de atualização é incluído nessa solicitação, informando ao servidor que o cliente deseja estabelecer uma conexão WebSocket.
Vamos ver como abrir uma conexão WebSocket se parece no lado do cliente:
URLs do WebSocket usam o esquema ws. Também existe o wss para conexões WebSocket seguras, que é o equivalente ao HTTPS.
Esse esquema inicia o processo de abertura de uma conexão WebSocket em direção a websocket.example.com.
Aqui está um exemplo simplificado dos cabeçalhos de solicitação inicial.
GET ws://websocket.example.com/ HTTP/1.1
Origin: http://example.com
Connection: Upgrade
Host: websocket.example.com
Upgrade: websocket
Se o servidor suportar o protocolo WebSocket, ele concordará com a atualização e comunicará isso por meio do cabeçalho de atualização na resposta.
Vamos ver como isso pode ser implementado no Node.JS:
Após a conexão ser estabelecida, o servidor responde fazendo a atualização:
HTTP/1.1 101 Switching Protocols
Date: Wed, 25 Oct 2017 10:07:34 GMT
Connection: Upgrade
Upgrade: WebSocket
Depois que a conexão é estabelecida, o evento de abertura será disparado na instância do WebSocket no lado do cliente:
Agora que o handshake está completo, a conexão HTTP inicial é substituída por uma conexão WebSocket que usa a mesma conexão TCP/IP subjacente. Neste ponto, qualquer uma das partes pode começar a enviar dados.
Com o WebSockets, você pode transferir quantos dados quiser sem incorrer na sobrecarga associada às solicitações HTTP tradicionais. Os dados são transferidos por meio de um WebSocket como mensagens, cada uma das quais consiste em um ou mais quadros contendo os dados que você está enviando (a carga útil). Para garantir que a mensagem possa ser reconstruída corretamente quando ela chegar ao cliente, cada quadro é prefixado com 4-12 bytes de dados sobre a carga útil. O uso desse sistema de mensagens baseado em quadros ajuda a reduzir a quantidade de dados não relacionados à carga útil que são transferidos, levando a reduções significativas na latência.
Observação: Vale ressaltar que o cliente só será notificado sobre uma nova mensagem depois que todos os quadros forem recebidos e a carga útil da mensagem original tiver sido reconstruída.
URL's do WebSocket
Mencionamos brevemente antes que o WebSockets introduz um novo esquema de URL. Na realidade, ele introduz dois novos esquemas: ws:// e wss://.
As URLs têm uma gramática específica do esquema. URLs do WebSocket são especiais porque não suportam âncoras (#sample_anchor).
As mesmas regras se aplicam às URLs no estilo WebSocket como às URLs no estilo HTTP. ws é não criptografado e tem a porta 80 como padrão, enquanto wss requer criptografia TLS e tem a porta 443 como padrão.
Protocolo de enquadramento
Vamos dar uma olhada mais profunda no protocolo de enquadramento. Isso é o que a RFC nos fornece:
De acordo com a versão do WebSocket especificada pela RFC, há apenas um cabeçalho na frente de cada pacote. No entanto, é um cabeçalho bastante complexo. Aqui estão seus elementos de construção explicados:
fin (1 bit)
: indica se este quadro é o quadro final que compõe a mensagem. Na maioria das vezes, a mensagem cabe em um único quadro e esse bit sempre será definido. Experimentos mostram que o Firefox faz um segundo quadro após 32K.rsv1, rsv2, rsv3 (1 bit cada)
: deve ser 0 a menos que uma extensão seja negociada que defina significados para valores diferentes de zero. Se um valor diferente de zero for recebido e nenhuma das extensões negociadas definir o significado desse valor diferente de zero, o ponto final receptor deve falhar na conexão.opcode (4 bits)
: indica o que o quadro representa. Os seguintes valores estão atualmente em uso:0x00
: este quadro continua a carga útil do anterior.0x01
: este quadro inclui dados de texto.0x02
: este quadro inclui dados binários.0x08
: este quadro encerra a conexão.0x09
: este quadro é um ping.0x0a
: este quadro é um pong. (Como você pode ver, há valores suficientes não utilizados; eles foram reservados para uso futuro).
mask (1 bit)
: indica se a conexão está mascarada. No estado atual, toda mensagem de um cliente para um servidor deve ser mascarada e a especificação gostaria de encerrar a conexão se ela não estiver mascarada.payload_len (7 bits)
: o comprimento da carga útil. Os quadros do WebSocket vêm nos seguintes intervalos de comprimento: 0-125 indica o comprimento da carga útil. 126 significa que os dois bytes seguintes indicam o comprimento, 127 significa que os próximos 8 bytes indicam o comprimento. Portanto, o comprimento da carga útil vem nos intervalos de ~7 bits, 16 bits e 64 bits.masking-key (32 bits)
: todos os quadros enviados do cliente para o servidor são mascarados por um valor de 32 bits que está contido no quadro.payload
: os dados reais que provavelmente estão mascarados. Seu comprimento é o comprimento de payload_len.
Por que os WebSockets são baseados em quadros e não em fluxos? Eu não sei e, assim como você, adoraria aprender mais, então se você tiver uma ideia, fique à vontade para adicionar comentários e recursos nas respostas abaixo. Além disso, uma boa discussão sobre o assunto está disponível no HackerNews.
Dados nos quadros
Como mencionado acima, os dados podem ser fragmentados em vários quadros. O primeiro quadro que transmite os dados tem um opcode que indica que tipo de dados está sendo transmitido. Isso é necessário porque o JavaScript praticamente não tem suporte para dados binários no momento em que a especificação foi iniciada. 0x01 indica dados de texto codificados em UTF-8, 0x02 são dados binários. A maioria das pessoas transmitirá JSON, caso em que você provavelmente desejará escolher o opcode de texto. Quando você emite dados binários, eles serão representados em um Blob específico do navegador.
A API para enviar dados por meio de um WebSocket é muito simples:
Quando o WebSocket está recebendo dados (no lado do cliente), é disparado um evento de mensagem. Este evento inclui uma propriedade chamada data que pode ser usada para acessar o conteúdo da mensagem.
Você pode explorar facilmente os dados em cada um dos quadros em sua conexão WebSocket usando a guia Rede dentro das Ferramentas de Desenvolvedor do Chrome:

Fragmentação
Os dados da carga útil podem ser divididos em vários quadros individuais. O receptor deve armazená-los até que o bit fin seja definido. Portanto, você pode transmitir a string "Olá Mundo" em 11 pacotes de 6 (comprimento do cabeçalho) + 1 byte cada um. A fragmentação não é permitida para pacotes de controle. No entanto, a especificação deseja que você seja capaz de lidar com quadros de controle intercalados. Isso ocorre no caso de pacotes TCP chegarem em ordem arbitrária.
A lógica para unir quadros é aproximadamente a seguinte:
- receba o primeiro quadro
- lembre-se do opcode
- concatene a carga útil do quadro até que o bit fin seja definido
- verifique se o opcode para cada pacote é zero
O objetivo principal da fragmentação é permitir o envio de uma mensagem de tamanho desconhecido quando a mensagem é iniciada. Com a fragmentação, um servidor pode escolher um buffer de tamanho razoável e, quando o buffer estiver cheio, gravar um fragmento na rede. Um caso de uso secundário para a fragmentação é a multiplexação, onde não é desejável que uma mensagem grande em um canal lógico assuma todo o canal de saída, portanto, a multiplexação precisa ser capaz de dividir a mensagem em fragmentos menores para compartilhar melhor o canal de saída.
O que é Heartbeating?
A qualquer momento após o handshake, o cliente ou o servidor podem optar por enviar um ping para a outra parte. Quando o ping é recebido, o destinatário deve enviar de volta um pong o mais rápido possível. Isso é um batimento cardíaco. Você pode usá-lo para garantir que o cliente ainda esteja conectado.
Um ping ou pong é apenas um quadro regular, mas é um quadro de controle. Pings têm um opcode de 0x9
, e pongs têm um opcode de 0xA
. Quando você recebe um ping, envie de volta um pong com os mesmos dados de carga útil do ping (para pings e pongs, o comprimento máximo da carga útil é 125). Você também pode receber um pong sem nunca enviar um ping. Ignore se isso acontecer.
Heartbeating pode ser muito útil. Existem serviços (como balanceadores de carga) que encerrarão conexões ociosas. Além disso, não é possível para o lado receptor ver se o lado remoto foi encerrado. Somente na próxima transmissão você perceberia que algo deu errado.
Lidando com erros
Você pode lidar com quaisquer erros que ocorram ouvindo o evento de erro.
Ele se parece com isso:
Fechando a conexão
Para fechar uma conexão, o cliente ou o servidor deve enviar um quadro de controle com dados contendo um opcode de 0x8
. Ao receber tal quadro, o outro peer envia um quadro de Fechamento em resposta. O primeiro peer então fecha a conexão. Qualquer dado adicional recebido após o fechamento da conexão é descartado.
Assim é como você inicia o fechamento de uma conexão WebSocket do cliente:
Além disso, para realizar qualquer limpeza após o fechamento ser concluído, você pode anexar um ouvinte de eventos ao evento de fechamento:
O servidor precisa ouvir o evento de fechamento para processá-lo, se necessário:
Como WebSockets e HTTP/2 se comparam?
Embora o HTTP/2 tenha muito a oferecer, ele não substitui completamente a necessidade das tecnologias de push/streaming existentes.
A primeira coisa importante a notar sobre o HTTP/2 é que ele não substitui todo o HTTP. Os verbos, códigos de status e a maioria dos cabeçalhos permanecerão os mesmos de hoje. O HTTP/2 trata de melhorar a eficiência da forma como os dados são transferidos na rede.
Agora, se compararmos o HTTP/2 com o WebSocket, podemos ver muitas semelhanças:

Como vimos acima, o HTTP/2 introduz o Server Push, que permite que o servidor envie recursos para o cache do cliente de forma proativa. No entanto, ele não permite o envio de dados para o aplicativo cliente em si. Os envios do servidor são processados apenas pelo navegador e não aparecem no código do aplicativo, o que significa que não há API para o aplicativo receber notificações desses eventos.
É aí que os Server-Sent Events (SSE) se tornam muito úteis. SSE é um mecanismo que permite que o servidor envie assincronamente os dados para o cliente assim que a conexão cliente-servidor for estabelecida. O servidor pode então decidir enviar dados sempre que um novo "chunk" de dados estiver disponível. Pode ser considerado como um modelo de publish-subscribe unidirecional. Ele também oferece uma API de cliente JavaScript padrão chamada EventSource, implementada na maioria dos navegadores modernos como parte do padrão HTML5 do W3C. Observe que navegadores que não suportam a EventSource API podem ser facilmente preenchidos com polyfills.
Como o SSE é baseado em HTTP, ele se encaixa naturalmente com o HTTP/2 e pode ser combinado para obter o melhor dos dois mundos: o HTTP/2 tratando de uma camada de transporte eficiente baseada em fluxos multiplexados e o SSE fornecendo a API para que as aplicações possam habilitar o push.
Para entender completamente o que são Streams e Multiplexação, vamos dar uma olhada na definição do IETF: um "stream" é uma sequência independente e bidirecional de quadros trocados entre o cliente e o servidor dentro de uma conexão HTTP/2. Uma de suas principais características é que uma única conexão HTTP/2 pode conter vários streams abertos simultaneamente, com qualquer uma das extremidades intercalando quadros de vários streams.

Devemos lembrar que o SSE é baseado em HTTP. Isso significa que, com o HTTP/2, não apenas vários fluxos SSE podem ser intercalados em uma única conexão TCP, mas o mesmo também pode ser feito com uma combinação de vários fluxos SSE (envio do servidor para o cliente) e várias solicitações do cliente (cliente para o servidor). Graças ao HTTP/2 e ao SSE, agora temos uma conexão HTTP bidirecional pura com uma API simples para permitir que o código da aplicação se registre para os envios do servidor. A falta de capacidades bidirecionais muitas vezes foi percebida como uma grande desvantagem ao comparar o SSE com o WebSocket. Graças ao HTTP/2, isso não é mais o caso. Isso abre a oportunidade de pular os WebSockets e ficar com um sinalização baseada em HTTP.
Como escolher entre WebSocket e HTTP/2?
Os WebSockets certamente sobreviverão à dominação do HTTP/2 + SSE, principalmente porque é uma tecnologia já bem adotada e, em casos de uso muito específicos, tem uma vantagem sobre o HTTP/2, pois foi construída para capacidades bidirecionais com menos sobrecarga (por exemplo, cabeçalhos).
Digamos que você queira construir um jogo online multiplayer massivo que precise de uma enorme quantidade de mensagens de ambos os lados da conexão. Nesse caso, os WebSockets terão um desempenho muito, muito melhor.
Em geral, use WebSockets sempre que precisar de uma conexão verdadeiramente de baixa latência e quase em tempo real entre o cliente e o servidor. Tenha em mente que isso pode exigir repensar como você constrói suas aplicações no lado do servidor, além de focar em tecnologias como filas de eventos.
Se o seu caso de uso exigir a exibição de notícias de mercado em tempo real, dados de mercado, aplicativos de chat, etc., contar com o HTTP/2 + SSE fornecerá um canal de comunicação bidirecional eficiente, aproveitando os benefícios de permanecer no mundo do HTTP:
- Os WebSockets podem muitas vezes ser uma fonte de dor ao considerar a compatibilidade com a infraestrutura web existente, pois atualizam uma conexão HTTP para um protocolo completamente diferente que não tem nada a ver com o HTTP.
- Escala e segurança: componentes web (Firewalls, Detecção de Intrusão, Balanceadores de Carga) são construídos, mantidos e configurados com o HTTP em mente, um ambiente que aplicações grandes/críticas preferirão em termos de resiliência, segurança e escalabilidade.
Também é necessário levar em consideração o suporte do navegador. Dê uma olhada no WebSocket:

Na verdade, é bastante bom, não é?
A situação com o HTTP/2, no entanto, não é a mesma:

- Apenas TLS (o que não é tão ruim)
- Suporte parcial no IE 11, mas apenas no Windows 10
- Apenas suportado no OSX 10.11+ no Safari
- Apenas suporta HTTP/2 se você puder negociá-lo via ALPN (algo que seu servidor precisa suportar explicitamente)
O suporte para SSE é melhor, no entanto:

Apenas o IE/Edge não fornece suporte. (Bem, o Opera Mini não suporta SSE nem WebSockets, então podemos excluí-lo completamente da equação). Existem alguns bons polyfills por aí para suporte SSE no IE/Edge.