Introduccion
Nuestra empresa
Leaning Technologies ofrece soluciones para portar aplicaciones de escritorio tradicionales a la web. Nuestro
compilador Cheerp de C ++ genera una combinación de WebAssembly y JavaScript, que proporciona
una interacción fácil con el navegador y un alto rendimiento.
Como ejemplo de su aplicación, decidimos portar un juego multijugador para la web y elegimos
Teeworlds para esto. Teeworlds es un juego retro bidimensional para varios jugadores con una pequeña pero activa comunidad de jugadores (¡incluido yo!). Es pequeño en términos de recursos descargables y requisitos de CPU y GPU, un candidato ideal.
Funciona en el navegador TeeworldsDecidimos utilizar este proyecto para experimentar con
soluciones generales para portar código de red a la web . Esto generalmente se hace de las siguientes maneras:
- XMLHttpRequest / fetch si la parte de la red consta solo de solicitudes HTTP, o
- WebSockets
Ambas soluciones requieren alojar el componente del servidor en el lado del servidor, y ninguna de ellas le permite utilizar
UDP como protocolo de transporte. Esto es importante para aplicaciones en tiempo real, como videoconferencias y software de juegos, porque las garantías de entrega y
el pedido de paquetes
TCP pueden interferir con bajas latencias.
Hay una tercera forma: usar la red desde un navegador:
WebRTC .
RTCDataChannel admite transmisiones confiables y poco confiables (en el último caso, si es posible, intenta usar UDP como protocolo de transporte), y puede usarse tanto con un servidor remoto como entre navegadores.
Esto significa que podemos transferir toda la aplicación al navegador, ¡incluido el componente del servidor!Sin embargo, esta es una dificultad adicional: antes de que dos pares de WebRTC puedan intercambiar datos, deben realizar un procedimiento de protocolo de enlace relativamente complicado para la conexión, que requiere varias entidades de terceros (un servidor de señal y uno o más servidores
STUN /
TURN ).
Idealmente, nos gustaría crear una API de red internamente utilizando WebRTC, pero lo más cerca posible de la interfaz UDP Sockets, que no necesita establecer una conexión.
Esto nos permitirá aprovechar WebRTC sin la necesidad de revelar detalles complejos al código de la aplicación (que queríamos cambiar lo menos posible en nuestro proyecto).
WebRTC mínimo
WebRTC es un conjunto de API disponible en los navegadores que proporciona audio, video y transferencia arbitraria de datos entre pares.
La conexión entre los pares se establece (incluso si hay NAT en uno o ambos lados) utilizando los 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 SDP y el protocolo de respuesta.
Wow! Cuántas abreviaturas a la vez. Expliquemos brevemente qué significan estos conceptos:
- Utilidades transversales de sesión para NAT ( STUN ) : un protocolo para omitir NAT y recibir un par (IP, puerto) para intercambiar datos directamente con el host. Si logra completar su tarea, los compañeros pueden intercambiar datos de forma independiente entre sí.
- El uso transversal de los relés alrededor de NAT ( TURN ) también se usa para omitir NAT, pero lo hace redirigiendo datos a través de un proxy que es visible para ambos pares. Agrega retraso y es más costoso de ejecutar que STUN (porque se usa durante toda la sesión de comunicación), pero a veces esta es la única opción posible.
- El establecimiento de conectividad interactiva ( ICE ) se utiliza para seleccionar la mejor manera posible de conectar dos pares en función de la información obtenida conectando directamente a pares, así como la información recibida por cualquier número de servidores STUN y TURN.
- El Protocolo de descripción de sesión ( SDP ) es un formato para describir los parámetros del canal de conexión, por ejemplo, candidatos ICE, códecs multimedia (en el caso de un canal de audio / video), etc. ... Uno de los pares envía una oferta SDP ("oferta"), y el segundo responde con SDP Respuesta ("respuesta"). Después de eso, se crea un canal.
Para crear dicha conexión, los pares deben recopilar la información que recibieron de los servidores STUN y TURN e intercambiarla entre sí.
El problema es que todavía no tienen la capacidad de intercambiar datos directamente, por lo que debe haber un mecanismo fuera de banda para intercambiar estos datos: un servidor de señales.
Un servidor de señales puede ser muy simple, porque su única tarea es redirigir los datos entre pares en la etapa de "apretón de manos" (como se muestra en el diagrama a continuación).
Secuencia de protocolo de enlace simplificado WebRTCDescripción del modelo de red de Teeworlds
La arquitectura de red de Teeworlds es muy simple:
- Los componentes de cliente y servidor son dos programas diferentes.
- Los clientes ingresan al juego conectándose a uno de varios servidores, cada uno de los cuales aloja un solo juego a la vez.
- Toda la transferencia de datos en el juego es a través del servidor.
- Se usa un servidor maestro especial para recopilar una lista de todos los servidores públicos que se muestran en el cliente del juego.
Debido al uso de WebRTC para el intercambio de datos, podemos transferir el componente del servidor del juego al navegador donde se encuentra el cliente. Nos da una gran oportunidad ...
Deshazte de los servidores
La falta de lógica del servidor tiene una buena ventaja: podemos implementar toda la aplicación como contenido estático en las páginas de Github o en nuestro propio equipo detrás de Cloudflare, lo que garantiza descargas rápidas y un alto tiempo de actividad de forma gratuita. De hecho, podemos olvidarnos de ellos, y si tenemos suerte y el juego se vuelve popular, entonces la infraestructura no tendrá que modernizarse.
Sin embargo, para que el sistema funcione, todavía tenemos que usar una arquitectura externa:
- Uno o más servidores STUN: tenemos la opción de varias opciones gratuitas.
- Al menos un servidor TURN: no hay opciones gratuitas aquí, por lo que podemos configurar el nuestro o pagar el servicio. Afortunadamente, la mayoría de las veces puede conectarse a través de los servidores STUN (y proporcionar un verdadero p2p), pero TURN es necesario como alternativa.
- Servidor de señal: a diferencia de los otros dos aspectos, la señalización no está estandarizada. El responsable del servidor de señales depende de alguna manera de la aplicación. En nuestro caso, antes de establecer una conexión, es necesario intercambiar una pequeña cantidad de datos.
- Servidor maestro de Teeworlds: otros servidores lo utilizan para notificar su existencia y los clientes buscan servidores públicos. Aunque no es obligatorio (los clientes siempre pueden conectarse a un servidor que conocen manualmente), sería bueno tenerlo para que los jugadores puedan participar en juegos con personas aleatorias.
Decidimos usar los servidores STUN gratuitos de Google e implementamos un servidor TURN por nuestra cuenta.
Para los últimos dos puntos usamos
Firebase :
- El servidor maestro de Teeworlds se implementa de manera muy simple: 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, y los clientes toman la lista completa y se la muestran al jugador. También mostramos la lista en la página de inicio como HTML, para que los jugadores puedan simplemente hacer clic en el servidor e ir directamente al juego.
- La señalización está estrechamente relacionada con nuestra implementación de socket, descrita en la siguiente sección.
Lista de servidores dentro del juego y en la página de inicioImplementación de socket
Queremos crear una API lo más cerca posible de los sockets Posix UDP para minimizar la cantidad de cambios necesarios.
También queremos obtener el mínimo necesario para el intercambio de datos más simple en la red.
Por ejemplo, no necesitamos enrutamiento real: todos los pares están en la misma "LAN virtual" asociada con una instancia específica de la base de datos Firebase.
Por lo tanto, no necesitamos direcciones IP únicas: para la identificación única de pares, es suficiente usar valores únicos de claves Firebase (similares a los nombres de dominio), y cada par asigna localmente direcciones IP "falsas" a cada clave que necesita ser convertida. Esto elimina por completo la necesidad de una asignación de dirección IP global, que es una tarea no trivial.
Aquí está la API mínima que necesitamos implementar:
La API es simple y similar a la API de Sockets Posix, pero tiene varias diferencias importantes:
registrar devoluciones de llamada, asignar direcciones IP locales y una conexión diferida .
Registro de devolución de llamada
Incluso si el programa fuente usa E / S sin bloqueo, el código debe ser refactorizado para ejecutarse en un navegador web.
La razón de esto es que el bucle de eventos en el navegador está oculto del programa (ya sea JavaScript o WebAssembly).
En un entorno nativo, podemos escribir código de esta manera
while(running) { select(...);
Si el bucle de eventos está oculto para nosotros, entonces debemos convertirlo en algo como esto:
auto cb = []() {
Asignación de IP local
Los identificadores de nodo en nuestra "red" no son direcciones IP, sino claves de Firebase (estas son líneas que se ven así:
-LmEC50PYZLCiCP-vqde
).
Esto es conveniente porque no necesitamos un mecanismo para asignar IP y verificar su singularidad (así como su eliminación después de desconectar al cliente), pero a menudo es necesario identificar a los pares por un valor numérico.
Para esto, se utilizan las funciones
resolve
e
reverseResolve
: la aplicación de alguna manera obtiene el valor de cadena de la clave (a través de la entrada del usuario o del servidor maestro) y puede convertirlo a una dirección IP para uso interno. El resto de la API también obtiene este valor en lugar de una cadena por simplicidad.
Esto es similar a una búsqueda de DNS, solo se realiza localmente en el cliente.
Es decir, las direcciones IP no se pueden compartir entre diferentes clientes, y si necesita algún tipo de identificador global, tendrá que generarlo de una manera diferente.
Mezcla perezosa
UDP no necesita una conexión, pero, como vimos, antes de comenzar la transferencia de datos entre dos pares, WebRTC requiere un largo proceso de conexión.
Si queremos proporcionar el mismo nivel de abstracción (
sendto
/
recvfrom
con pares arbitrarios sin conectar primero), entonces debemos hacer una conexión "perezosa" (retardada) dentro de la API.
Esto es lo que sucede durante el intercambio de datos normal entre el "servidor" y el "cliente" en caso de utilizar UDP, y lo que debe hacer nuestra biblioteca:
- El servidor llama a
bind()
para indicarle al sistema operativo que desea recibir paquetes en el puerto especificado.
En su lugar, publicaremos el puerto abierto en Firebase bajo la clave del servidor y escucharemos los eventos en su subárbol.
- El servidor llama a
recvfrom()
, aceptando paquetes de cualquier host a este puerto.
En nuestro caso, debemos verificar la cola entrante de los paquetes enviados a este puerto.
Cada puerto tiene su propia cola, y agregamos los puertos de origen y de destino al comienzo de los datagramas WebRTC para saber qué cola redirigir cuando llega un nuevo paquete.
La llamada no es de bloqueo, por lo que si no hay paquetes, simplemente devolvemos -1 y establecemos
errno=EWOULDBLOCK
.
- El cliente recibe, por algún medio externo, la IP y el puerto del servidor, y llama a
sendto()
. Además, se realiza una llamada interna a bind()
, por lo que recvfrom()
posterior recibirá una respuesta sin ejecutar explícitamente bind.
En nuestro caso, el cliente recibe externamente la clave de cadena y utiliza la función
resolve()
para obtener la dirección IP.
En este punto, 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 del mismo par utilizan el mismo DataRannel WebRTC.
También realizamos un
bind()
indirecto
bind()
para que el servidor pueda volver a conectarse en el próximo
sendto()
en caso de que se
sendto()
por algún motivo.
El servidor recibe una notificación cuando el cliente se conecta cuando el cliente escribe su oferta SDP bajo la información del puerto del servidor en Firebase, y el servidor responde con su propia respuesta.
El siguiente diagrama muestra un ejemplo del movimiento de mensajes para un esquema de socket y la transmisión del primer mensaje del cliente al servidor:
Diagrama completo de pasos de conexión entre el cliente y el servidorConclusión
Si has leído hasta el final, entonces probablemente estés interesado en mirar la teoría en acción. El juego se puede jugar en
teeworlds.leaningtech.com , ¡pruébalo!
Partido amistoso entre colegasEl código de la biblioteca de red está disponible gratuitamente en
Github . ¡Únete al
chat en nuestro canal en
Gitter !