Introduction
Notre entreprise fournit des solutions pour porter des applications de bureau traditionnelles vers le web. Notre compilateur C++ génère une combinaison de WebAssembly et de JavaScript, ce qui permet à la fois et de hautes performances.
À titre d'exemple, nous avons décidé de porter un jeu multijoueur pour le web et avons choisi pour cela . Teeworlds est un jeu rétro multijoueur en 2D avec une petite mais active communauté de joueurs (dont je fais partie !). Il est léger tant en ressources à télécharger qu'en exigences CPU et GPU – un candidat idéal.

Teeworlds fonctionne dans le navigateur
Nous avons décidé d'utiliser ce projet pour expérimenter avec des solutions générales pour porter le code réseau vers le web.Cela se fait généralement de deux manières :
- XMLHttpRequest/fetch, si la partie réseau ne se compose que de requêtes HTTP, ou
- WebSockets.
Les deux solutions nécessitent d'héberger un composant serveur côté serveur, et aucune d'elles ne permet d'utiliser comme protocole de transport . Cela est important pour les applications en temps réel, telles que les logiciels de vidéoconférence et les jeux, car les garanties de livraison et d'ordre des paquets avec le protocole peuvent nuire à la latence faible.
Il existe une troisième façon - utiliser le réseau depuis le navigateur : .
prend en charge à la fois des transmissions fiables et non fiables (dans ce dernier cas, il essaie, si possible, d'utiliser le protocole UDP), et peut être utilisé aussi bien avec un serveur distant qu'entre navigateurs. Cela signifie que nous pouvons porter toute l'application dans le navigateur, y compris le composant serveur !
Cependant, cela présente une difficulté supplémentaire : avant que deux pairs WebRTC puissent échanger des données, ils doivent effectuer une procédure de « poignée de main » relativement complexe pour se connecter, ce qui nécessite plusieurs entités externes (serveur de signalisation et un ou plusieurs serveurs /).
Idéalement, nous aimerions créer une API réseau utilisant WebRTC, mais aussi proche que possible de l'interface des Sockets UDP, qui n'a pas besoin d'établir de connexion.
Cela nous permettra de tirer parti de WebRTC sans avoir à divulguer les détails complexes du code de l'application (que nous souhaitions changer le moins possible dans notre projet).
WebRTC Minimal
WebRTC est un ensemble d'API présents dans les navigateurs, permettant la transmission de son, de vidéo et de données arbitraires en peer-to-peer.
La connexion entre les pairs est établie (même en cas de NAT d'un ou des deux côtés) à l'aide des serveurs STUN et/ou TURN via un mécanisme appelé ICE. Les pairs échangent des informations ICE et des paramètres de canaux via l'offre et la réponse du protocole SDP.
Wow ! Beaucoup d'acronymes en même temps. Expliquons brièvement la signification de ces termes :
- — un protocole pour contourner le NAT et obtenir une paire (IP, port) pour échanger des données directement avec l'hôte. S'il réussit, les pairs peuvent échanger des données entre eux.
- est également utilisé pour contourner le NAT, mais il le fait en redirigeant les données via un proxy, visible pour les deux pairs. Cela ajoute de la latence et est plus coûteux à exécuter que STUN (car il est appliqué pendant toute la session de communication), mais parfois c'est la seule option possible.
- est utilisé pour choisir le meilleur moyen possible de connecter deux pairs sur la base des informations obtenues lors de la connexion directe des pairs, ainsi que des informations reçues de plusieurs serveurs STUN et TURN.
- est un format décrivant les paramètres de la connexion, tels que les candidats ICE, les codecs multimédias (dans le cas d'un canal audio/vidéo), etc. L'un des pairs envoie une offre SDP (« proposition »), et l'autre répond par une réponse SDP (« réponse »). Après cela, un canal est créé.
Pour établir une telle connexion, les pairs doivent rassembler les informations qu'ils ont reçues des serveurs STUN et TURN et les échanger entre eux.
Le problème est qu'ils n'ont pas encore la possibilité d'échanger des données directement, donc il doit exister un mécanisme hors bande pour échanger ces données : un serveur de signalisation.
Le serveur de signalisation peut être très simple, car sa seule tâche est de rediriger les données entre les pairs lors de l'étape de « poignée de main » (comme indiqué dans le schéma ci-dessous).

Schéma simplifié de la séquence de « poignée de main » WebRTC
Aperçu du modèle réseau Teeworlds
L'architecture réseau de Teeworlds est très simple :
- Les composants client et serveur sont deux programmes différents.
- Les clients rejoignent le jeu en se connectant à un des plusieurs serveurs, chacun hôte d'un seul jeu à la fois.
- Toute la transmission de données dans le jeu passe par le serveur.
- Un serveur maître spécial est utilisé pour rassembler la liste de tous les serveurs publics, qui sont affichés dans le client de jeu.
Grâce à l'utilisation de WebRTC pour l'échange de données, nous pouvons déplacer le composant serveur du jeu dans le navigateur, où se trouve le client. Cela nous offre une excellente opportunité…
De se passer des serveurs
L'absence de logique serveur présente un avantage intéressant : nous pouvons déployer l'ensemble de l'application en tant que contenu statique sur Github Pages ou sur notre propre équipement derrière Cloudflare, assurant ainsi des chargements rapides et un temps de disponibilité élevé gratuitement. En gros, on pourra les oublier, et si nous avons de la chance et que le jeu devient populaire, nous n'aurons pas besoin de moderniser l'infrastructure.
Cependant, pour que le système fonctionne, nous devrons néanmoins utiliser une architecture externe :
- Un ou plusieurs serveurs STUN : nous avons le choix parmi plusieurs options gratuites.
- Au moins un serveur TURN : il n'y a pas d'options gratuites ici, donc nous pouvons soit configurer le nôtre, soit payer pour un service. Heureusement, la plupart du temps, la connexion pourra être établie via des serveurs STUN (et assurer un véritable p2p), mais TURN est nécessaire en tant que solution de secours.
- Serveur de signalisation : à la différence des deux autres aspects, la signalisation n'est pas normalisée. Ce que le serveur de signalisation devra réellement faire dépend en partie de l'application. Dans notre cas, un petit volume de données doit être échangé avant d'établir la connexion.
- Serveur maître de Teeworlds : il est utilisé par les autres serveurs pour signaler son existence et par les clients pour rechercher des serveurs publics. Bien qu'il ne soit pas obligatoire (les clients peuvent toujours se connecter à un serveur qu'ils connaissent manuellement), il serait bon de l'avoir pour que les joueurs puissent participer à des jeux avec des inconnus.
Nous avons décidé d'utiliser les serveurs STUN gratuits de Google, et nous avons déployé un serveur TURN nous-mêmes.
Pour les deux derniers points, nous avons utilisé :
- Le serveur maître de Teeworlds est réalisé très simplement : sous la forme d'une liste d'objets contenant des informations (nom, IP, carte, mode, …) de chaque serveur actif. Les serveurs publient et mettent à jour leur propre objet, et les clients récupèrent toute la liste et l'affichent au joueur. Nous affichons également la liste sur la page d'accueil sous forme de HTML, afin que les joueurs puissent simplement cliquer sur le serveur et entrer directement dans le jeu.
- La signalisation est étroitement liée à notre implémentation des sockets, décrite dans la section suivante.

Liste des serveurs dans le jeu et sur la page d'accueil
Implémentation des sockets
Nous souhaitons créer une API aussi proche que possible des sockets UDP Posix, afin de minimiser le nombre de modifications nécessaires.
De plus, nous voulons implémenter le strict minimum requis pour un échange de données basique sur le réseau.
Par exemple, nous n'avons pas besoin de véritable routage : tous les pairs se trouvent dans un "LAN virtuel" connecté à une instance spécifique de la base de données Firebase.
Par conséquent, nous n'avons pas besoin d'adresses IP uniques : pour identifier les pairs de manière unique, il suffit d'utiliser des valeurs clés uniques de Firebase (similaire aux noms de domaine), et chaque pair attribue localement des adresses IP "fausses" à chaque clé devant être convertie. Cela nous libère complètement de la nécessité d'allouer des adresses IP globales, ce qui constitue une tâche non triviale.
Voici l'API minimale que nous devons implémenter :
// 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);L'API est simple et ressemble à l'API des sockets Posix, mais avec quelques différences importantes : enregistrement des rappels, attribution d'IP locales et connexion "paresseuse".
Enregistrement des rappels
Même si le programme d'origine utilise des entrées/sorties non bloquantes, le code doit être réfactorisé pour fonctionner dans un navigateur web.
La raison en est que la boucle d'événements dans le navigateur est cachée du programme (qu'il s'agisse de JavaScript ou de WebAssembly).
Dans un environnement natif, nous pouvons écrire du code de cette manière
while(running) {
select(...); // attendre les événements I/O
while(true) {
int r = readfrom(...); // essayer de lire
if (r < 0 && errno == EWOULDBLOCK) // plus de données disponibles
break;
...
}
...
}Si la boucle d'événements est cachée pour nous, nous devons la transformer en quelque chose comme :
auto cb = []() { // ceci sera appelé lorsque de nouvelles données sont disponibles
while(true) {
int r = readfrom(...); // essayer de lire
if (r < 0 && errno == EWOULDBLOCK) // plus de données disponibles
break;
...
}
...
};
srecvCallback(cb); // enregistrer le rappelDestinataires des IP locaux
Les identifiants des nœuds dans notre « réseau » ne sont pas des adresses IP, mais des clés Firebase (ce sont des chaînes qui ressemblent à ça : -LmEC50PYZLCiCP-vqde ).
C'est pratique, car nous n'avons pas besoin d'un mécanisme pour attribuer des IP et vérifier leur unicité (ni de les récupérer après la déconnexion du client), mais il est souvent nécessaire d'identifier les pairs par une valeur numérique.
C'est précisément pour cela que nous utilisons les fonctions resolve et reverseResolve: l'application obtient d'une manière ou d'une autre la valeur de chaîne de la clé (soit par saisie utilisateur, soit via un serveur maître), et peut la convertir en adresse IP pour une utilisation interne. Le reste de l'API reçoit également cette valeur au lieu de la chaîne pour simplifier les choses.
C'est similaire à une recherche DNS, mais cela se fait localement sur le client.
C'est-à-dire que les adresses IP ne peuvent pas être partagées entre différents clients, et si un identifiant global est nécessaire, il devra être généré d'une autre manière.
Connexion paresseuse
Le UDP n'a pas besoin de connexion, mais, comme nous l'avons vu, avant de commencer à transférer des données entre deux pairs, WebRTC nécessite un processus de connexion long.
Si nous voulons garantir le même niveau d'abstraction, (sendto/recvfrom avec des pairs arbitraires sans connexion préalable), nous devons effectuer une connexion « paresseuse » (différée) au sein de l'API.
Voici ce qui se passe lors de l'échange normal de données entre un « serveur » et un « client » dans le cas de l'utilisation du UDP, et ce que notre bibliothèque doit faire :
- Le serveur appelle
bind(), pour indiquer au système d'exploitation qu'il souhaite recevoir des paquets sur le port spécifié.
Au lieu de cela, nous publions un port ouvert dans Firebase sous la clé du serveur et nous écoutons les événements dans son sous-arbre.
- Le serveur appelle
recvfrom(), acceptant sur ce port des paquets provenant de n'importe quel hôte.
Dans notre cas, nous devons vérifier la file d'attente des paquets entrants envoyés à ce port.
Chaque port a sa propre file d'attente, et nous ajoutons au début des datagrammes WebRTC les ports source et destination, afin de savoir dans quelle file d'attente rediriger lors de l'arrivée d'un nouveau paquet.
L'appel est non-bloquant, donc si aucun paquet n'est présent, nous retournons simplement -1 et définissons errno=EWOULDBLOCK.
- Le client obtient par des moyens externes l'IP et le port du serveur, et appelle
sendto(). Cette action entraîne également un appel interne, donc le suivantbind().recvfrom()obtiendra une réponse sans exécution explicite du bind.
Dans notre cas, le client obtient une clé de chaîne de manière externe et utilise la fonction resolve() pour obtenir l'adresse IP.
À ce stade, nous commençons le « handshake » WebRTC, si deux pairs ne sont pas encore connectés entre eux. Les connexions à différents ports d'un même pair utilisent le même DataChannel WebRTC.
Nous effectuons également une opération indirecte bind(), pour que le serveur puisse restaurer la connexion lors du prochain sendto() au cas où elle se serait fermée pour une raison quelconque.
Le serveur est informé de la connexion du client lorsque le client enregistre son offre SDP sous les informations du port du serveur dans Firebase, et le serveur répond également avec sa réponse.
Le schéma ci-dessous montre un exemple de transmission des messages pour le schéma de sockets et l'envoi du premier message du client au serveur :

Le schéma complet de l'étape de connexion entre le client et le serveur
Conclusion
Si vous êtes arrivé jusqu'ici, vous devez probablement être intéressé à voir la théorie en action. Vous pouvez jouer à , essayez-le !
Match amical entre collègues
Le code de la bibliothèque réseau est librement disponible sur . Rejoignez la discussion sur notre canal dans !
Source : habr.com
