Introducción
Nuestra empresa ofrece soluciones para portar aplicaciones desktop tradicionales a la web. Nuestro compilador C++ genera una combinación de WebAssembly y JavaScript, lo que proporciona tanto , como un alto rendimiento.
Como ejemplo de su aplicación, decidimos portar un juego multijugador a la web y elegimos para ello . Teeworlds es un juego retro multijugador en 2D con una comunidad de jugadores pequeña pero activa (¡incluyéndome a mí!). Es pequeño tanto en términos de recursos descargables como de requisitos de CPU y GPU: un candidato ideal.

Teeworlds, que funciona en el navegador
Decidimos utilizar este proyecto para experimentar con soluciones generales para portar código de red a la web. Generalmente, esto se realiza de las siguientes maneras:
- XMLHttpRequest/fetch, si la parte de red consiste solo en solicitudes HTTP, o
- WebSockets.
Ambas soluciones requieren alojar un componente del servidor del lado del servidor, y ninguna de ellas permite usar como protocolo de transporte . Esto es importante para aplicaciones en tiempo real, como software de videoconferencias y juegos, porque las garantías de entrega y orden de paquetes del protocolo pueden interferir con las bajas latencias.
También existe un tercer camino: usar la red desde el navegador: .
soporta tanto la transmisión confiable como la no confiable (en el último caso, intenta usar UDP como protocolo de transporte cuando sea posible), y puede usarse tanto con un servidor remoto como entre navegadores. Esto significa que podemos portar toda la aplicación al navegador, incluido el componente del servidor.
Sin embargo, esto presenta una dificultad adicional: antes de que dos pares de WebRTC puedan intercambiar datos, necesitan completar un procedimiento de «apretón de manos» relativamente complejo para conectarse, lo que requiere varias entidades externas (servidor de señalización y uno o varios servidores /).
En un mundo ideal, nos gustaría crear una API de red que use WebRTC internamente, pero que sea lo más cercana posible a la interfaz de UDP Sockets, que no requiere establecer una conexión.
Esto nos permitirá aprovechar las ventajas de WebRTC sin necesidad de revelar los complejos detalles en el código de la aplicación (que en nuestro proyecto queremos modificar lo menos posible).
WebRTC Mínimo
WebRTC es un conjunto de API disponibles en los navegadores que permite la transmisión de audio, video y datos arbitrarios entre pares.
La conexión entre pares se establece (incluso en presencia de NAT de uno o ambos lados) mediante servidores STUN y/o TURN a través de un mecanismo llamado ICE. Los pares intercambian información ICE y parámetros de canal a través de la oferta y respuesta del protocolo SDP.
¡Vaya! Cuántas abreviaturas hay en una sola vez. Vamos a explicar brevemente qué significan estos conceptos:
- - es un protocolo para sortear NAT y obtener un par (IP, puerto) para intercambiar datos directamente con el host. Si logra cumplir su tarea, los pares pueden intercambiar datos entre sí de forma independiente.
- también se utiliza para evitar NAT, pero lo implementa redirigiendo datos a través de un proxy, visible para ambos pares. Añade latencia y es más costoso en ejecución que STUN (porque se aplica durante toda la sesión de comunicación), pero a veces es la única opción posible.
- se utiliza para elegir el mejor posible método de conexión entre dos pares basado en la información obtenida durante la conexión directa de pares, así como en la información obtenida de cualquier número de servidores STUN y TURN.
- - es un formato para describir los parámetros del canal de conexión, como los candidatos ICE, códecs multimedia (en el caso de un canal de audio/video), etc... Uno de los pares envía una Oferta SDP y el otro responde con una Respuesta SDP. Después de esto, se crea el canal.
Para crear tal conexión, los pares necesitan reunir la información que han obtenido de los servidores STUN y TURN e intercambiarla entre sí.
El problema es que aún no tienen la capacidad de intercambiar datos directamente, por lo que debe existir un mecanismo de señalización: un servidor de señalización.
Un servidor de señalización puede ser muy simple, porque su única tarea es redirigir datos entre los pares en la etapa de "apretón de manos" (como se muestra en el esquema a continuación).

Esquema simplificado de la secuencia de "apretón de manos" de WebRTC
Descripción del modelo de red de Teeworlds
La arquitectura de red de Teeworlds es muy simple:
- Los componentes del cliente y el servidor son dos programas distintos.
- Los clientes entran en el juego conectándose a uno de varios servidores, cada uno de los cuales aloja solo un juego a la vez.
- Toda la transmisión de datos en el juego se realiza a través del servidor.
- Un maestro de servidor especial se utiliza para recopilar la lista de todos los servidores públicos, que se muestran en el cliente del juego.
Gracias al uso de WebRTC para el intercambio de datos, podemos trasladar el componente del servidor del juego al navegador, donde se encuentra el cliente. Esto nos brinda una excelente oportunidad…
Deshacernos de los servidores
La falta de lógica del servidor tiene una agradable ventaja: podemos desplegar toda la aplicación como contenido estático en Github Pages o en nuestro propio hardware detrás de Cloudflare, asegurándonos de cargas rápidas y alta disponibilidad sin costo. En esencia, podríamos olvidarnos de ellos, y si tenemos suerte y el juego se convierte en popular, no será necesario actualizar la infraestructura.
Sin embargo, para que el sistema funcione, aún necesitaremos utilizar una arquitectura externa:
- Uno o varios servidores STUN: tenemos varias opciones gratuitas disponibles.
- Al menos un servidor TURN: aquí no hay opciones gratuitas, así que podemos configurar el nuestro o pagar por el servicio. Afortunadamente, la mayor parte del tiempo la conexión se podrá establecer a través de servidores STUN (y asegurar un verdadero p2p), pero TURN es necesario como opción de respaldo.
- Servidor de señalización: a diferencia de los otros dos aspectos, la señalización no está estandarizada. Lo que realmente hará el servidor de señalización depende en parte de la aplicación. En nuestro caso, es necesario intercambiar un pequeño volumen de datos antes de establecer la conexión.
- Maestro de servidor de Teeworlds: es utilizado por otros servidores para avisar de su existencia y por los clientes para buscar servidores públicos. Aunque no es obligatorio (los clientes siempre pueden conectarse manualmente a un servidor conocido), sería bueno tenerlo para que los jugadores puedan participar en juegos con personas al azar.
Hemos decidido utilizar los servidores STUN gratuitos de Google, y hemos desplegado uno TURN por nuestra cuenta.
Para los dos últimos puntos, utilizamos :
- El maestro servidor de Teeworlds está implementado de manera muy sencilla: como una lista de objetos que contienen información (nombre, IP, mapa, modo, …) de cada servidor activo. Los servidores publican y actualizan su propio objeto, mientras que los clientes toman toda la lista y la muestran al jugador. También exhibimos la lista en la página de inicio como HTML, para que los jugadores puedan simplemente hacer clic en el servidor y entrar directamente al juego.
- La señalización está estrechamente relacionada con nuestra implementación de sockets, que se describe en la siguiente sección.

Lista de servidores dentro del juego y en la página de inicio
Implementación de sockets
Queremos crear una API que se asemeje lo más posible a los Sockets UDP de Posix, para minimizar el número de cambios necesarios.
También queremos implementar el mínimo necesario requerido para el intercambio de datos más básico por la red.
Por ejemplo, no necesitamos un enrutamiento real: todos los pares están en una "LAN virtual" conectada a una instancia específica de la base de datos de Firebase.
Por lo tanto, no necesitamos direcciones IP únicas: para la identificación única de los pares, basta con usar valores únicos de las claves de Firebase (similar a los nombres de dominio), y cada par asigna localmente direcciones IP "falsas" a cada clave que necesita ser traducida. Esto nos libera completamente de la necesidad de asignaciones globales de direcciones IP, que es una tarea no trivial.
Esta es la API mínima que necesitamos 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);La API es simple y similar a la de los Sockets Posix, pero tiene algunas diferencias importantes: registro de callbacks, asignación de IP locales y conexión "perezosa".
Registro de callbacks
Incluso si el programa original utiliza entrada-salida no bloqueante, para ejecutarse en un navegador web, el código necesita ser reestructurado.
La razón de esto es que el ciclo de eventos en el navegador está oculto para el programa (ya sea JavaScript o WebAssembly).
En un entorno nativo, podemos escribir el código de esta manera
while(running) {
select(...); // espera eventos de I/O
while(true) {
int r = readfrom(...); // intenta leer
if (r < 0 && errno == EWOULDBLOCK) // no hay más datos disponibles
break;
...
}
...
}Si el ciclo de eventos está oculto para nosotros, entonces necesitamos convertirlo en algo así:
auto cb = []() { // esto se llamará cuando haya nuevos datos disponibles
while(true) {
int r = readfrom(...); // intenta leer
if (r < 0 && errno == EWOULDBLOCK) // no hay más datos disponibles
break;
...
}
...
};
recvCallback(cb); // registrar el callbackAsignación de IP locales
Los identificadores de los nodos en nuestra «red» no son direcciones IP, sino claves de Firebase (son cadenas que se ven así: -LmEC50PYZLCiCP-vqde ).
Esto es conveniente, ya que no necesitamos un mecanismo para asignar IP y verificar su unicidad (así como su eliminación tras desconectar al cliente), pero a menudo es necesario identificar pares por un valor numérico.
Para esto se utilizan las funciones resolve y reverseResolve: la aplicación de alguna manera obtiene el valor de la clave como una cadena (ya sea mediante la entrada del usuario o a través de un servidor maestro), y puede convertirlo en una dirección IP para uso interno. El resto de la API también, por simplicidad, recibe este valor en lugar de una cadena.
Esto es similar a una búsqueda DNS, solo que se realiza localmente en el cliente.
Es decir, las direcciones IP no pueden ser compartidas entre diferentes clientes, y si se necesita un identificador global, tendrá que generarse de otra manera.
Conexión perezosa
UDP no requiere conexión, pero, como hemos visto, antes de comenzar a transferir datos entre dos pares, WebRTC requiere un proceso de conexión prolongado.
Si queremos proporcionar el mismo nivel de abstracción, (sendto/recvfrom con pares arbitrarios sin conexión previa), debemos realizar una conexión «perezosa» (pospuesta) dentro de la API.
Esto es lo que ocurre durante el intercambio de datos entre el «servidor» y el «cliente» en el caso de usar UDP, y lo que debe hacer nuestra biblioteca:
- El servidor llama
bind(), para informar al sistema operativo que desea recibir paquetes en el puerto especificado.
En su lugar, publicamos un puerto abierto en Firebase bajo la clave del servidor y escuchamos eventos en su subárbol.
- El servidor llama
recvfrom(), recibiendo en este puerto paquetes que llegan desde cualquier host.
En nuestro caso, necesitamos verificar la cola de entrada de paquetes enviados a este puerto.
Cada puerto tiene su propia cola, y añadimos al principio de los datagramas WebRTC los puertos de origen y destino, para saber a qué cola redirigir cuando llega un nuevo paquete.
La llamada es no bloqueante, por lo que si no hay paquetes, simplemente devolvemos -1 y establecemos errno=EWOULDBLOCK.
- El cliente obtiene de alguna manera la IP y el puerto del servidor, y llama a
sendto(). También se realiza una llamada interna,bind(), por lo que el siguienterecvfrom()recibirá una respuesta sin la ejecución explícita de bind.
En nuestro caso, el cliente obtiene externamente una clave de cadena y utiliza la función resolve() para obtener la dirección IP.
En esta etapa, comenzamos el «apretón de manos» de WebRTC, si los dos pares aún no están conectados entre sí. Las conexiones a diferentes puertos de un mismo par utilizan el mismo DataChannel de WebRTC.
También realizamos una conexión indirecta bind(), para que el servidor pueda restablecer la conexión en el siguiente sendto() en caso de que se cierre por alguna razón.
El servidor es notificado de la conexión del cliente cuando este guarda su oferta SDP bajo la información del puerto del servidor en Firebase, y el servidor responde allí mismo con su respuesta.
En el diagrama mostrado a continuación se muestra un ejemplo del movimiento de mensajes para el esquema de sockets y la transmisión del primer mensaje del cliente al servidor:

Diagrama completo de la etapa de conexión entre el cliente y el servidor
Conclusión
Si has llegado hasta aquí, seguramente te interesa ver la teoría en acción. Puedes jugar en , ¡pruébalo!
Partido amistoso entre colegas
El código de la biblioteca de red está disponible de forma gratuita en . Únete a la conversación en nuestro canal en !
Fuente: habr.com
