Inleiding
Ons bedrijf biedt oplossingen voor het porteren van traditionele desktopapplicaties naar het web. Onze C++ compiler genereert een combinatie van WebAssembly en JavaScript, wat zorgt voor , en hoge prestaties.
Als voorbeeld van zijn toepassing hebben we besloten om een multiplayer game voor het web te porteren en hebben we gekozen voor . Teeworlds is een multiplayer retro 2D-spel met een kleine, maar actieve gemeenschap van spelers (inclusief ik!). Het is klein in termen van downloadgrootte en CPU- en GPU-eisen ā een perfecte kandidaat.

Teeworlds dat draait in de browser
We hebben besloten om dit project te gebruiken om te experimenteren met algemene oplossingen voor het porteren van netwerkcode naar het web. Gewoonlijk gebeurt dit op de volgende manieren:
- XMLHttpRequest/fetch, als het netwerkonderdeel alleen uit HTTP-verzoeken bestaat, of
- WebSockets.
Beide oplossingen vereisen het hosten van een servercomponent aan de serverzijde, en geen van beiden laat gebruik maken van . Dit is belangrijk voor real-time applicaties, zoals videoconferentiesoftware en games, omdat de garanties voor levering en volgorde van pakketten van het protocol een belemmering kunnen vormen voor lage latentie.
Er is ook een derde manier ā gebruik maken van de netwerkfunctionaliteit van de browser: .
ondersteunt zowel betrouwbare als onbetrouwbare transmissie (in het laatste geval probeert het indien mogelijk UDP als transportprotocol te gebruiken), en kan zowel met een externe server als tussen browsers worden gebruikt. Dit betekent dat we de hele applicatie, inclusief de servercomponent, naar de browser kunnen porteren!
Echter, dit brengt een extra uitdaging met zich mee: voordat twee WebRTC peers gegevens kunnen uitwisselen, moeten ze een relatief complexe 'handshake'-procedure uitvoeren om te verbinden, waarvoor verschillende externe entiteiten nodig zijn (signaalserver en een of meer /).
Idealiter willen we een netwerklast API creƫren die WebRTC intern gebruikt, maar zo dicht mogelijk bij de interface van UDP Sockets staat, waarvoor geen verbinding hoeft te worden gemaakt.
Dit stelt ons in staat om de voordelen van WebRTC te benutten zonder de complexe details van de applicatiecode (die we in ons project zo min mogelijk wilden wijzigen) openbaar te maken.
Minimale WebRTC
WebRTC is een set API's die beschikbaar zijn in browsers en die peer-to-peer overdracht van audio, video en willekeurige gegevens mogelijk maakt.
De verbinding tussen de peers wordt tot stand gebracht (zelfs als er NAT aan ƩƩn of beide zijden aanwezig is) met behulp van STUN en/of TURN-servers via een mechanisme genaamd ICE. Peers wisselen ICE-informatie en kanaalparameters uit via het offer- en antwoordprotocol van SDP.
Wauw! Wat een hoop afkortingen in ƩƩn keer. Laten we kort uitleggen wat deze termen betekenen:
- is een protocol voor het omzeilen van NAT en het verkrijgen van een paar (IP, poort) voor directe gegevensuitwisseling met de host. Als het erin slaagt zijn doel te bereiken, kunnen peers direct gegevens met elkaar uitwisselen.
- wordt ook gebruikt om NAT te omzeilen, maar doet dit door gegevens via een proxy om te leiden die voor beide peers zichtbaar is. Dit voegt vertraging toe en is kostbaarder in uitvoering dan STUN (omdat het tijdens de hele communicatiesessie wordt toegepast), maar soms is dit de enige mogelijkheid.
- wordt gebruikt om de best mogelijke manier te kiezen om twee peers te verbinden op basis van de informatie die is verkregen via de directe verbinding tussen de peers, evenals informatie verkregen van een of meer STUN- en TURN-servers.
- is een formaat voor het beschrijven van de parameters van de verbindingskanaal, zoals ICE-kandidaten, multimedia codecs (in het geval van audio/video), enz. Een van de peers stuurt een SDP Offer ('aanbod'), en de andere reageert met een SDP Answer ('antwoord'). Hierna wordt het kanaal aangemaakt.
Om zo'n verbinding te maken, moeten de peers de informatie verzameld van de STUN- en TURN-servers en deze uitwisselen.
Het probleem is dat ze nog geen directe gegevensuitwisseling kunnen doen, dus er moet een off-band mechanisme zijn: een signaalserver.
Een signaalserver kan heel eenvoudig zijn, omdat zijn enige taak het doorsturen van gegevens tussen de peers in de 'handshake'-fase is (zoals weergegeven in het onderstaande schema).

Vereenvoudigd schema van de WebRTC 'handdruk'-volgorde
Overzicht van het netwerkmodel van Teeworlds
De netwerkarchitectuur van Teeworlds is zeer eenvoudig:
- De client- en servercomponenten zijn twee verschillende programma's.
- Klanten nemen deel aan het spel door verbinding te maken met een van de verschillende servers, elk van die slechts ƩƩn spel tegelijk host.
- Alle gegevensoverdracht in het spel gebeurt via de server.
- Een speciale masterserver wordt gebruikt om een lijst met alle openbare servers te verzamelen, die in de spelclient worden weergegeven.
Door WebRTC te gebruiken voor gegevensuitwisseling, kunnen we de servercomponent van het spel naar de browser verplaatsen, waar de client zich bevindt. Dit biedt ons een prachtige mogelijkheid...
Af te zien van servers
Het ontbreken van serverlogica heeft het mooie voordeel dat we de gehele applicatie als statische inhoud op Github Pages of op onze eigen hardware achter Cloudflare kunnen implementeren, waardoor we ons gratis snelle laadtijden en hoge uptime kunnen verzekeren. In wezen kunnen we ze vergeten, en als we geluk hebben en het spel populair wordt, hoeven we de infrastructuur niet te upgraden.
Echter, om het systeem te laten werken, moeten we nog steeds externe architectuur gebruiken:
- Een of meerdere STUN-servers: we hebben de keuze uit verschillende gratis opties.
- Minimaal ƩƩn TURN-server: er zijn geen gratis opties, dus we kunnen ofwel onze eigen instellen of betalen voor een dienst. Gelukkig kan de verbinding meestal via STUN-servers worden ingesteld (en echte p2p bieden), maar TURN is nodig als back-up.
- Signaalserver: in tegenstelling tot de andere twee aspecten is signalering niet gestandaardiseerd. Wat de signaalserver daadwerkelijk zal doen, hangt in zekere zin af van de applicatie. In ons geval moet er voor het tot stand brengen van een verbinding een kleine hoeveelheid gegevens worden uitgewisseld.
- Masterserver van Teeworlds: deze wordt door andere servers gebruikt om hun bestaan aan te geven en door clients om openbare servers te vinden. Hoewel het niet verplicht is (clients kunnen altijd handmatig verbinding maken met een bekende server), is het goed om het te hebben zodat spelers kunnen deelnemen aan spellen met willekeurige mensen.
We hebben ervoor gekozen om de gratis STUN-servers van Google te gebruiken, en we hebben zelf ƩƩn TURN-server opgezet.
Voor de laatste twee punten hebben we gebruikt :
- De masterserver van Teeworlds is heel eenvoudig geĆÆmplementeerd: als een lijst met objecten die informatie (naam, IP, kaart, modus, ...) van elke actieve server bevatten. Servers publiceren en updaten hun eigen object, terwijl clients de hele lijst ophalen en deze aan de speler tonen. We tonen ook de lijst op de startpagina als HTML, zodat spelers gewoon op de server kunnen klikken en direct in het spel kunnen komen.
- Signalisatie is nauw verbonden met onze implementatie van sockets, die in het volgende gedeelte wordt beschreven.

De lijst met servers binnen het spel en op de startpagina
Implementatie van sockets
We willen een API creƫren die zo dicht mogelijk bij Posix UDP Sockets ligt, om het aantal noodzakelijke wijzigingen te minimaliseren.
Daarnaast willen we het noodzakelijke minimum implementeren dat vereist is voor de eenvoudigste gegevensuitwisseling via het netwerk.
Bijvoorbeeld, we hebben geen echte routering nodig: alle peers bevinden zich in ƩƩn 'virtueel LAN', gekoppeld aan een specifieke instantie van de Firebase-database.
Daarom hebben we geen unieke IP-adressen nodig: voor unieke identificatie van peers is het voldoende om unieke waarden van de Firebase-sleutels te gebruiken (vergelijkbaar met domeinnamen), en elke peer kent lokaal 'nep' IP-adressen toe aan elke sleutel die moet worden omgezet. Dit elimineert volledig de noodzaak voor globale toewijzing van IP-adressen, wat geen triviale taak is.
Hier is de minimale API die we moeten implementeren:
// 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);De API is eenvoudig en lijkt op de API van Posix Sockets, maar heeft een aantal belangrijke verschillen: registratie van callback-functies, toewijzing van lokale IP's en 'lui' verbindingen.
Registratie van callback-functies
Zelfs als de oorspronkelijke programma gebruik maakt van niet-blokkerende invoer/uitvoer, moet de code worden gerefactoreerd voor gebruik in een webbrowser.
De reden hiervoor is dat de event loop in de browser verborgen is voor het programma (of het nu JavaScript of WebAssembly is).
In een native omgeving kunnen we de code op deze manier schrijven
while(running) {
select(...); // wacht op I/O gebeurtenissen
while(true) {
int r = readfrom(...); // probeer te lezen
if (r < 0 && errno == EWOULDBLOCK) // geen gegevens meer beschikbaar
break;
...
}
...
}Als de event loop voor ons verborgen is, moeten we deze omzetten in iets dat hierop lijkt:
auto cb = []() { // dit zal worden aangeroepen wanneer nieuwe gegevens beschikbaar zijn
while(true) {
int r = readfrom(...); // probeer te lezen
if (r < 0 && errno == EWOULDBLOCK) // geen gegevens meer beschikbaar
break;
...
}
...
};
recvCallback(cb); // registreer de callbackToepassing van lokale IP's
De knooppunten in ons 'netwerk' zijn geen IP-adressen, maar Firebase-sleutels (dit zijn strings die er als volgt uitzien: -LmEC50PYZLCiCP-vqde ).
Dit is handig omdat we geen mechanisme nodig hebben voor het toewijzen van IP's en de controle van hun uniciteit (en hun verwijdering na het uitschakelen van de klant), maar het is vaak nodig om peers te identificeren op basis van numerieke waarde.
Daarom worden de functies resolve en reverseResolve: de applicatie krijgt op de een of andere manier de stringwaarde van de sleutel (door invoer van de gebruiker of via de master-server) en kan deze omzetten in een IP-adres voor intern gebruik. De rest van de API ontvangt ook deze waarde in plaats van de string voor de eenvoud.
Dit lijkt op een DNS-zoekopdracht, alleen wordt deze lokaal bij de klant uitgevoerd.
Dat betekent dat IP-adressen niet gedeeld kunnen worden tussen verschillende klanten, en als er een globale identifier nodig is, deze op een andere manier gegenereerd moet worden.
Lui verbinding maken
UDP vereist geen verbinding, maar zoals we eerder hebben gezien, vereist WebRTC een lang verbindingsproces voordat het dataverkeer tussen twee peers kan beginnen.
Als we hetzelfde abstractieniveau willen garanderen, (sendto/recvfrom met willekeurige peers zonder voorafgaande verbinding), dan moeten we 'lui' (uitgestelde) verbinding maken binnen de API.
Dit is wat er gebeurt bij normale gegevensuitwisseling tussen een 'server' en 'client' bij gebruik van UDP, en wat onze bibliotheek zou moeten uitvoeren:
- De server roept
bind(), om het besturingssysteem te informeren dat het pakketten op de opgegeven poort wil ontvangen.
In plaats daarvan publiceren we een open poort in Firebase onder de serversleutel en luisteren we naar gebeurtenissen in zijn subtree.
- De server roept
recvfrom(), waarmee we pakketten vanaf elke host naar deze poort accepteren.
In ons geval moeten we de inkomende wachtrij van pakketten controleren die naar deze poort zijn gestuurd.
Elke poort heeft zijn eigen wachtrij, en we voegen de oorspronkelijke en eindpoorten van de WebRTC-datagrammen aan het begin toe, zodat we weten in welke wachtrij een nieuw pakket moet worden omgeleid wanneer het binnenkomt.
De aanroep is niet-blokkerend, dus als er geen pakketten zijn, retourneren we gewoon -1 en stellen we errno=EWOULDBLOCK.
- De klant verkrijgt op een of andere externe manier het IP en de poort van de server en roept
sendto(). Ook wordt bij dit proces een interne aanroep gedaan, zodat de daaropvolgendebind()een antwoord ontvangt zonder expliciet een bind uit te voeren.recvfrom()ontvangen een antwoord zonder expliciete uitvoering van bind.
In ons geval ontvangt de klant extern een string sleutel en gebruikt de functie resolve() om het IP-adres te verkrijgen.
In deze fase beginnen we met de "handshake" van WebRTC, als de twee peers nog niet met elkaar verbonden zijn. Verbindingen op verschillende poorten van ƩƩn peer gebruiken dezelfde WebRTC DataChannel.
Ook voeren we een indirecte bind(), zodat de server de verbinding in de volgende sendto() kan herstellen, indien deze om welke reden dan ook is verbroken.
De server wordt op de hoogte gesteld van de verbinding van de klant wanneer de klant zijn SDP-aanbod onder de serverpoortinformatie in Firebase opslaat, en de server daar ook op reageert met zijn antwoord.
In de onderstaande afbeelding wordt een voorbeeld van de berichtbewegingen voor de socketschema en de overdracht van het eerste bericht van de klant naar de server weergegeven:

Het volledige schema van de verbindingsfase tussen de klant en de server
Conclusie
Als je tot het einde hebt gelezen, ben je waarschijnlijk benieuwd om de theorie in actie te zien. Je kunt het spel spelen op , probeer het eens!
Een vriendschappelijke wedstrijd tussen collegaās
De code van de netwerkbibliotheek is vrij beschikbaar op . Neem deel aan het gesprek op ons kanaal op !
Bron: habr.com
