Introdução
Nossa empresa fornece soluções para a portabilidade de aplicativos desktop tradicionais para a web. Nosso compilador C++ gera uma combinação de WebAssembly e JavaScript, o que assegura tanto , quanto um alto desempenho.
Como exemplo de sua aplicação, decidimos portar um jogo multiplayer para a web e escolhemos . Teeworlds é um jogo retro 2D multiplayer com uma comunidade de jogadores pequena, mas ativa (incluindo eu!). É pequeno tanto em termos de recursos para download quanto em requisitos de CPU e GPU — um candidato ideal.

Teeworlds rodando no navegador
Decidimos usar este projeto para experimentar com soluções gerais para portar código de rede para a web. Normalmente, isso é realizado das seguintes maneiras:
- XMLHttpRequest/fetch, se a parte de rede consiste apenas em requisições HTTP, ou
- WebSockets.
Ambas as soluções exigem hospedar um componente de servidor do lado do servidor, e nenhuma delas permite usar como protocolo de transporte. Isso é importante para aplicações em tempo real, como softwares para videoconferências e jogos, pois as garantias de entrega e ordem dos pacotes do protocolo podem interferir em baixas latências.
Há também um terceiro caminho — usar a rede a partir do navegador: .
suporta tanto transmissão confiável quanto não confiável (neste último caso, ele tenta usar como protocolo de transporte o UDP sempre que possível), e pode ser utilizado tanto com um servidor remoto quanto entre navegadores. Isso significa que podemos portar toda a aplicação para o navegador, incluindo o componente do servidor!
No entanto, isso traz uma dificuldade adicional: antes que dois pares WebRTC possam trocar dados, eles precisam passar por um procedimento relativamente complexo de "aperto de mão" (handshake) para se conectar, o que requer várias entidades externas (servidor de sinalização e um ou mais servidores /).
Idealmente, gostaríamos de criar uma API de rede que dentro utilize WebRTC, mas o mais próximo possível da interface de Sockets UDP, que não requer uma conexão estabelecida.
Isso nos permitirá aproveitar as vantagens do WebRTC sem a necessidade de expor os detalhes complexos ao código da aplicação (que em nosso projeto gostaríamos de modificar o mínimo possível).
WebRTC mínimo
WebRTC é um conjunto de APIs disponível nos navegadores que permite a transmissão de áudio, vídeo e dados arbitrários de peer-to-peer.
A conexão entre os pares é estabelecida (mesmo com NAT em um ou ambos os lados) através de servidores STUN e/ou TURN por meio de um mecanismo chamado ICE. Os pares trocam informações de ICE e parâmetros de canais através do protocolo SDP com oferta e resposta.
Uau! Quantas siglas de uma só vez. Vamos explicar brevemente o que esses conceitos significam:
- é um protocolo para contornar o NAT e obter um par (IP, porta) para trocar dados diretamente com o host. Se conseguir realizar sua função, os pares poderão trocar dados entre si.
- também é usado para contornar o NAT, mas o faz redirecionando os dados através de um proxy, visível para ambos os pares. Isso adiciona latência e é mais custoso em termos de execução do que o STUN (porque é utilizado durante toda a sessão de comunicação), mas às vezes é a única opção possível.
- é utilizado para selecionar a melhor maneira possível de conectar dois pares com base nas informações obtidas através da conexão direta dos pares, bem como as informações adquiridas por qualquer número de servidores STUN e TURN.
- é um formato para descrever os parâmetros do canal de conexão, como os candidatos de ICE, codecs de mídia (no caso de um canal de áudio/vídeo), etc. Um dos pares envia uma oferta SDP ("offer"), e o outro responde com uma resposta SDP ("answer"). Após isso, o canal é criado.
Para criar tal conexão, os pares precisam coletar as informações que conseguiram dos servidores STUN e TURN e trocá-las entre si.
O problema é que eles ainda não têm a capacidade de trocar dados diretamente, então deve existir um mecanismo de sinalização off-band: o servidor de sinalização.
O servidor de sinalização pode ser muito simples, pois sua única tarefa é redirecionar dados entre os pares durante a etapa de "aperto de mãos" (como mostrado no diagrama abaixo).

Diagrama simplificado da sequência de "aperto de mãos" do WebRTC
Visão geral do modelo de rede Teeworlds
A arquitetura de rede do Teeworlds é muito simples:
- Os componentes do cliente e do servidor são dois programas diferentes.
- Os clientes entram em jogo conectando-se a um dos vários servidores, cada um dos quais hospeda apenas um jogo de cada vez.
- Toda a transmissão de dados no jogo é feita através do servidor.
- Um servidor-mestre especial é utilizado para coletar a lista de todos os servidores públicos, que são exibidos no cliente de jogo.
Graças ao uso do WebRTC para troca de dados, podemos transferir o componente de servidor do jogo para o navegador, onde está o cliente. Isso nos oferece uma oportunidade maravilhosa…
Desfazer-se dos servidores
A ausência de lógica de servidor tem a vantagem agradável: podemos implantar toda a aplicação como conteúdo estático no Github Pages ou em nosso próprio hardware atrás do Cloudflare, garantindo assim downloads rápidos e alta disponibilidade de forma gratuita. Basicamente, podemos esquecer deles, e se tivermos sorte e o jogo se tornar popular, não será necessário atualizar a infraestrutura.
No entanto, para que o sistema funcione, ainda teremos que usar uma arquitetura externa:
- Um ou mais servidores STUN: temos várias opções gratuitas à escolha.
- Pelo menos um servidor TURN: aqui não há opções gratuitas, então podemos ou configurar o nosso ou pagar pelo serviço. Felizmente, a maior parte do tempo a conexão pode ser estabelecida através de servidores STUN (assegurando p2p verdadeiro), mas o TURN é necessário como uma alternativa de reserva.
- Servidor de sinalização: ao contrário dos outros dois aspectos, a sinalização não é padronizada. O que o servidor de sinalização realmente fará depende do aplicativo. No nosso caso, é necessário trocar um pequeno volume de dados antes de estabelecer a conexão.
- Servidor-mestre Teeworlds: ele é usado por outros servidores para notificar sua existência e pelos clientes para encontrar servidores públicos. Embora não seja obrigatório (os clientes sempre podem se conectar manualmente a um servidor que conhecem), seria bom tê-lo para que os jogadores possam participar de jogos com pessoas aleatórias.
Decidimos usar os servidores STUN gratuitos da Google, e implantamos um servidor TURN nós mesmos.
Para os dois últimos pontos, utilizamos :
- O mestre servidor Teeworlds é implementado de forma muito simples: como uma lista de objetos contendo informações (nome, IP, mapa, modo, ...) de cada servidor ativo. Os servidores publicam e atualizam seu próprio objeto, enquanto os clientes capturam toda a lista e a exibem para o jogador. Também exibimos a lista na página inicial como HTML, para que os jogadores possam simplesmente clicar no servidor e entrar no jogo diretamente.
- A sinalização está intimamente relacionada à nossa implementação de soquetes, descrita na próxima seção.

Lista de servidores dentro do jogo e na página inicial
Implementação de soquetes
Queremos criar uma API o mais próximo possível dos Soquetes UDP Posix, para minimizar o número de alterações necessárias.
Além disso, queremos implementar o mínimo necessário para a troca simples de dados pela rede.
Por exemplo, não precisamos de roteamento real: todos os peers estão em uma 'LAN virtual' ligada a uma instância específica do banco de dados Firebase.
Portanto, não precisamos de endereços IP exclusivos: para a identificação única dos peers, basta usar valores de chave exclusivos do Firebase (semelhante a nomes de domínio), e cada peer localmente atribui endereços IP 'falsos' a cada chave que precisa ser convertida. Isso nos livra completamente da necessidade de atribuição global de endereços IP, o que é uma tarefa não trivial.
Aqui está a API mínima que precisamos implementar:
// Create and destroy a socket
int socket();
int close(int fd);
// Bind a socket to a port, and publish it on Firebase
int bind(int fd, AddrInfo* addr);
// Send a packet. This lazily create a WebRTC connection to the
// peer when necessary
int sendto(int fd, uint8_t* buf, int len, const AddrInfo* addr);
// Receive the packets destined to this socket
int recvfrom(int fd, uint8_t* buf, int len, AddrInfo* addr);
// Be notified when new packets arrived
int recvCallback(Callback cb);
// Obtain a local ip address for this peer key
uint32_t resolve(client::String* key);
// Get the peer key for this ip
String* reverseResolve(uint32_t addr);
// Get the local peer key
String* local_key();
// Initialize the library with the given Firebase database and
// WebRTc connection options
void init(client::FirebaseConfig* fb, client::RTCConfiguration* ice);A API é simples e semelhante à API Posix Sockets, mas possui algumas diferenças importantes: registro de callbacks, atribuição de IPs locais e conexão 'preguiçosa'.
Registro de callbacks
Mesmo que o programa original utilize E/S não bloqueante, para execução em um navegador, o código precisa ser refatorado.
A razão para isso é que o loop de eventos no navegador está oculto para o programa (seja JavaScript ou WebAssembly).
No ambiente nativo, podemos escrever o código desta forma
while(running) {
select(...); // aguardar eventos de E/S
while(true) {
int r = readfrom(...); // tentar ler
if (r < 0 && errno == EWOULDBLOCK) // não há mais dados disponíveis
break;
...
}
...
}Se o loop de eventos está oculto para nós, precisamos transformá-lo em algo como:
auto cb = []() { // isso será chamado quando novos dados estiverem disponíveis
while(true) {
int r = readfrom(...); // tentar ler
if (r < 0 && errno == EWOULDBLOCK) // não há mais dados disponíveis
break;
...
}
...
};
someCallback(cb); // registrar o callbackAtribuição de IPs locais
Os identificadores de nós em nossa "rede" não são endereços IP, mas chaves do Firebase (são strings que se parecem com: -LmEC50PYZLCiCP-vqde ).
Isso é conveniente, pois não precisamos de um mecanismo para atribuir IPs e verificar sua exclusividade (bem como descartá-los após a desconexão do cliente), mas muitas vezes é necessário identificar pares por um valor numérico.
É exatamente para isso que são utilizadas as funções resolve e reverseResolve: o aplicativo de alguma forma obtém o valor da chave em string (por meio da entrada do usuário ou por meio de um servidor mestre) e pode convertê-lo em um endereço IP para uso interno. O restante da API também recebe, por simplicidade, esse valor em vez da string.
Isso é semelhante a uma consulta DNS, exceto que é realizada localmente no cliente.
Ou seja, os endereços IP não podem ser compartilhados entre diferentes clientes, e se um identificador global for necessário, ele precisará ser gerado de outra maneira.
Conexão preguiçosa
UDP não requer conexão, mas, como vimos, antes de começar a transmitir dados entre dois pares, o WebRTC exige um longo processo de conexão.
Se quisermos manter o mesmo nível de abstração, (sendto/recvfrom com pares arbitrários sem conexão prévia), devemos realizar uma conexão "preguiçosa" (delayed) dentro da API.
Isso é o que acontece durante a troca típica de dados entre um "servidor" e um "cliente" ao usar UDP, e o que nossa biblioteca deve executar:
- O servidor chama
bind(), para informar ao sistema operacional que deseja receber pacotes na porta especificada.
Em vez disso, vamos publicar uma porta aberta no Firebase sob a chave do servidor e escutar os eventos em seu subárvore.
- O servidor chama
recvfrom(), recebendo nesse porta pacotes provenientes de qualquer host.
No nosso caso, precisamos verificar a fila de entrada de pacotes enviados para essa porta.
Cada porta tem sua própria fila, e adicionamos nos datagramas WebRTC as portas de origem e destino, para saber em qual fila redirecionar quando um novo pacote chegar.
A chamada é não bloqueante, portanto, se não houver pacotes, simplesmente retornamos -1 e definimos errno=EWOULDBLOCK.
- O cliente obtém IP e a porta do servidor por meios externos e chama
sendto(). Também ocorre uma chamada interna,bind(), portanto, o subsequenterecvfrom()receberá uma resposta sem a execução explícita de bind.
Neste caso, o cliente recebe externamente uma chave de string e utiliza a função resolve() para obter o endereço IP.
Neste estágio, começamos o 'handshake' do WebRTC, caso os dois pares ainda não estejam conectados entre si. Conexões em diferentes portas de um mesmo par utilizam o mesmo DataChannel do WebRTC.
Além disso, realizamos uma indireta bind(), para que o servidor possa restaurar a conexão na próxima sendto() caso ela tenha sido encerrada por algum motivo.
O servidor é notificado da conexão do cliente quando o cliente registra sua oferta SDP sob a informação da porta do servidor no Firebase, e o servidor responde lá mesmo com sua resposta.
No esquema mostrado abaixo, é apresentado um exemplo do fluxo de mensagens para o esquema de sockets e a transmissão da primeira mensagem do cliente para o servidor:

Esquema completo da etapa de conexão entre o cliente e o servidor.
Conclusão
Se você chegou até aqui, deve estar interessado em ver a teoria em ação. O jogo pode ser jogado em , experimente!
Partida amigável entre colegas.
O código da biblioteca de rede está disponível livremente em . Junte-se à conversa no nosso canal em !
Fonte: habr.com
