Introduktion
VÄrt företag tillhandahÄller lösningar för portering av traditionella desktopapplikationer till webben. VÄr C++-kompilator genererar en kombination av WebAssembly och JavaScript, vilket möjliggör bÄde , och hög prestanda.
Som exempel pĂ„ dess tillĂ€mpning har vi beslutat att portera ett flerspelarspel för webben och valde . Teeworlds Ă€r ett flerspelars 2D-retrospel med ett litet men aktivt spelargemenskap (bland dem jag!). Det Ă€r litet bĂ„de nĂ€r det gĂ€ller nedladdningsresurser och krav pĂ„ CPU och GPU â en idealisk kandidat.

Teeworlds som fungerar i webblÀsaren
Vi beslutade att anvÀnda detta projekt för att experimentera med gemensamma lösningar för portering av nÀtverkskod till webben. Detta utförs vanligtvis pÄ följande sÀtt:
- XMLHttpRequest/fetch, om nÀtverksdelen endast bestÄr av HTTP-förfrÄgningar, eller
- WebSockets.
BÄda lösningarna krÀver att hysa serverkomponenten pÄ serversidan, och ingen av dem tillÄter anvÀndning av . Detta Àr viktigt för realtidsapplikationer, sÄsom programvara för videokonferenser och spel, eftersom garantier för leverans och paketordning för protokollet kan störa lÄga latenser.
Det finns ocksĂ„ ett tredje sĂ€tt â att anvĂ€nda nĂ€tverket frĂ„n webblĂ€saren: .
stödjer bÄde pÄlitlig och opÄlitlig överföring (i det senare fallet försöker det i möjligaste mÄn anvÀnda UDP som transportprotokoll), och kan anvÀndas bÄde med en fjÀrrserver och mellan webblÀsare. Det betyder att vi kan portera hela applikationen till webblÀsaren, inklusive serverkomponenten!
Men det finns en ytterligare svÄrighet: innan tvÄ WebRTC-peerar kan utbyta data mÄste de genomföra en relativt komplex "handskakning"-procedur för att ansluta, vilket krÀver flera externa enheter (signalingserver och en eller flera /).
I idealfallet skulle vi vilja skapa ett nÀtverks-API, som inuti anvÀnder WebRTC, men som sÄ nÀra som möjligt liknar grÀnssnittet för UDP-sockets, vilket inte behöver etablera en anslutning.
Det hÀr gör att vi kan utnyttja fördelarna med WebRTC utan att behöva avslöja de komplexa detaljerna i applikationens kod (som vi ville Àndra sÄ lite som möjligt i vÄrt projekt).
Minimal WebRTC
WebRTC Àr en uppsÀttning API:er som finns i webblÀsare och möjliggör peer-to-peer överföring av ljud, video och godtyckliga data.
Anslutningen mellan peeringarna upprÀttas (Àven om det finns NAT pÄ ena eller bÄda sidor) med hjÀlp av STUN- och/eller TURN-servrar genom en mekanism som kallas ICE. Peeringar byter information om ICE och kanalparametrar via offer och answer-protokollet SDP.
Oj! SÄ mÄnga förkortningar pÄ en gÄng. LÄt oss kort förklara vad dessa termer betyder:
- Àr ett protokoll för att kringgÄ NAT och fÄ ett par (IP, port) för att utbyta data direkt med vÀrden. Om det lyckas med sin uppgift kan peeringar utbyta data sinsemellan.
- anvÀnds ocksÄ för att kringgÄ NAT, men det gör detta genom att omdirigera data via en proxy som Àr synlig för bÄda peeringarna. Det tillför fördröjning och Àr mer resurskrÀvande Àn STUN (eftersom det tillÀmpas under hela kommunikationssessionen), men ibland Àr det den enda möjliga lösningen.
- anvÀnds för att vÀlja det bÀsta möjliga sÀttet att ansluta tvÄ peeringar baserat pÄ information som erhÄllits vid den direkta anslutningen av peeringarna samt information frÄn ett antal STUN- och TURN-servrar.
- Àr ett format för beskrivning av anslutningskanalens parametrar, till exempel kandidater för ICE, multimedia-codecs (i fallet med ljud/videokanal), osv. En av peeringarna skickar en SDP Offer ("erbjudande"), och den andra svarar med en SDP Answer ("svar"). Efter detta skapas en kanal.
För att skapa en sÄdan anslutning behöver peeringarna samla informationen de fÄtt frÄn STUN- och TURN-servrarna och utbyta den sinsemellan.
Problemet Àr att de för nÀrvarande inte kan utbyta data direkt, dÀrför mÄste det finnas en utanför-band mekanism för att utbyta dessa data: en signalserver.
Signalservern kan vara mycket enkel, eftersom dess enda uppgift Àr att omdirigera data mellan peeringarna under "handtryckssteget" (som visas i diagrammet nedan).

Förenklad sekvensdiagram för WebRTC-handtryckning
Ăversikt av nĂ€tverksmodellen Teeworlds
NÀtverksarkitekturen i Teeworlds Àr mycket enkel:
- Klient- och serverkomponenterna Àr tvÄ olika program.
- Kunder gÄr med i spelet genom att ansluta till en av flera servrar, var och en av vilka Àr vÀrd för endast ett spel Ät gÄngen.
- All dataöverföring i spelet sker genom servern.
- En sÀrskild mÀsterserver anvÀnds för att samla en lista över alla offentliga servrar som visas i spelklienten.
Genom att anvĂ€nda WebRTC för datakommunikation kan vi flytta serverkomponenten av spelet till webblĂ€saren dĂ€r klienten finns. Detta ger oss en fantastisk möjlighetâŠ
Att slippa servrar
Bristen pÄ serverlogik har en trevlig fördel: vi kan implementera hela applikationen som statiskt innehÄll pÄ Github Pages eller pÄ vÄr egen hÄrdvara bakom Cloudflare, vilket pÄ sÄ sÀtt gratis ger oss snabba laddningstider och hög drifttid. I huvudsak kan vi glömma dem, och om vi har tur och spelet blir populÀrt, behöver infrastrukturen inte uppgraderas.
Men för att systemet ska fungera mÄste vi ÀndÄ anvÀnda extern arkitektur:
- En eller flera STUN-servrar: vi har flera gratis alternativ att vÀlja pÄ.
- Minst en TURN-server: hÀr finns det inga gratis alternativ, sÄ vi kan antingen stÀlla in vÄr egen eller betala för en tjÀnst. Lyckligtvis kan anslutning i de flesta fall upprÀttas via STUN-servrar (och möjliggöra Àkta p2p), men TURN Àr nödvÀndig som en reserv.
- Signaleringsserver: till skillnad frÄn de tvÄ andra aspekterna Àr signalering inte standardiserad. Vad som faktiskt kommer att ansvara för signaleringsservern beror delvis pÄ applikationen. I vÄrt fall Àr det nödvÀndigt att byta en liten mÀngd data innan anslutningen kan upprÀttas.
- Teeworlds mĂ€sterserver: den anvĂ€nds av andra servrar för att meddela sin existens och av kunder för att söka efter offentliga servrar. Ăven om den inte Ă€r obligatorisk (kunder kan alltid manuellt ansluta till en server de kĂ€nner till), skulle det vara bra att ha den sĂ„ att spelare kan delta i spel med slumpmĂ€ssiga mĂ€nniskor.
Vi har valt att anvÀnda Googles gratis STUN-servrar, medan vi har implementerat en TURN-server sjÀlva.
För de tvÄ senaste punkterna anvÀnde vi :
- MÀstaren för Teeworlds-servern Àr vÀldigt enkelt genomfört: som en lista med objekt som innehÄller information (namn, IP, karta, lÀge, ...) om varje aktiv server. Servrar publicerar och uppdaterar sitt eget objekt, och klienterna hÀmtar hela listan och visar den för spelaren. Vi visar ocksÄ listan pÄ startsidan som HTML, sÄ att spelarna enkelt kan klicka pÄ servern och gÄ direkt in i spelet.
- Signalering Àr nÀra kopplad till vÄr implementering av socketar, som beskrivs i nÀsta avsnitt.

Serverlista inom spelet och pÄ startsidan
Implementering av socketar
Vi vill skapa ett API som ligger sÄ nÀra Posix UDP Socketar som möjligt för att minimera antalet nödvÀndiga förÀndringar.
Vi vill ocksÄ implementera det minimala som krÀvs för den enklaste datakommunikationen över nÀtverket.
Till exempel behöver vi ingen riktig routing: alla peers Àr i ett enda "virtuellt LAN" kopplat till en specifik instans av Firebase-databasen.
DÀrför behöver vi inga unika IP-adresser: för unik identifiering av peers rÀcker det med att anvÀnda unika nyckelvÀrden frÄn Firebase (liknande domÀnnamn), och varje peer tilldelar lokalt "falska" IP-adresser till varje nyckel som behöver översÀttas. Detta befriar oss helt frÄn behovet av global IP-tilldelning, vilket Àr en icke-trivial uppgift.
HÀr Àr det minimala API:t som vi behöver implementera:
// 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);API:t Àr enkelt och liknar API:t för Posix Socketar, men har nÄgra viktiga skillnader: registrering av callbacks, tilldelning av lokala IP adresser och "lazy" anslutning.
Registrering av callbacks
Ăven om det ursprungliga programmet anvĂ€nder icke-blockerande I/O, behöver koden refaktoreras för att köras i webblĂ€saren.
Anledningen till detta Àr att hÀndelseloopen i webblÀsaren Àr dold för programmet (oavsett om det Àr JavaScript eller WebAssembly).
I en inhemsk miljö kan vi skriva kod pÄ följande sÀtt
while(running) {
select(...); // vÀnta pÄ I/O-hÀndelser
while(true) {
int r = readfrom(...); // försök att lÀsa
if (r < 0 && errno == EWOULDBLOCK) // ingen mer data tillgÀnglig
break;
...
}
...
}Om hÀndelseloopen Àr dold för oss, mÄste vi omvandla den till nÄgot som liknar detta:
auto cb = []() { // detta kommer att kallas nÀr ny data Àr tillgÀnglig
while(true) {
int r = readfrom(...); // försök att lÀsa
if (r < 0 && errno == EWOULDBLOCK) // ingen mer data tillgÀnglig
break;
...
}
...
};
recvCallback(cb); // registrera callback-funktionenTilldelning av lokala IP-adresser
Identifierarna för noder i vÄrt "nÀt" Àr inte IP-adresser, utan Firebase-nycklar (detta Àr strÀngar som ser ut sÄ hÀr: -LmEC50PYZLCiCP-vqde ).
Detta Àr praktiskt eftersom vi inte behöver en mekanism för att tilldela IP-adresser och kontrollera deras unika vÀrde (eller avveckla dem efter att kunden har kopplats bort), men det Àr ofta nödvÀndigt att identifiera peers med ett numeriskt vÀrde.
Det Àr just dÀrför vi anvÀnder funktioner resolve och reverseResolve: applikationen fÄr pÄ nÄgot sÀtt strÀngvÀrdet av nyckeln (genom anvÀndarinmatning eller genom en masterserver), och kan omvandla det till en IP-adress för internt bruk. Resterande del av API:et fÄr ocksÄ istÀllet för strÀngen detta vÀrde för enkelhetens skull.
Det liknar en DNS-sökning, men den görs lokalt hos klienten.
Det vill sÀga att IP-adresser inte kan vara gemensamma för olika klienter, och om nÄgon global identifierare behövs, mÄste den genereras pÄ annat sÀtt.
Lazykoppling
UDP krÀver ingen anslutning, men som vi har sett, innan vi börjar överföra data mellan tvÄ peers, krÀver WebRTC en lÄng anslutningsprocess.
Om vi vill upprÀtthÄlla samma abstraktionsnivÄ, (sendto/recvfrom med godtyckliga peers utan föregÄende anslutning), mÄste vi utföra en "lazy" (uppskjuten) anslutning inom API:et.
SÄ hÀr fungerar den vanliga datautbytet mellan "servern" och "klienten" nÀr man anvÀnder UDP, och vad vÄrt bibliotek mÄste utföra:
- Servern anropar
bind(), för att informera operativsystemet om att den vill ta emot paket pÄ den angivna porten.
IstÀllet publicerar vi en öppen port i Firebase under serverns nyckel och kommer att lyssna pÄ hÀndelser i dess undertrÀd.
- Servern anropar
recvfrom(), tar emot paket pÄ den hÀr porten frÄn vilken vÀrd som helst.
I vÄrt fall mÄste vi kontrollera ingÄngskön för paket som skickas till den hÀr porten.
Varje port har sin egen kö, och vi lÀgger till ursprungliga och slutgiltiga portar i WebRTC-datagrammen för att veta vilken kö vi ska dirigera ett nytt paket till nÀr det ankommer.
Anropet Àr icke-blockerande, sÄ om det inte finns nÄgra paket returnerar vi helt enkelt -1 och stÀller in errno=EWOULDBLOCK.
- Klienten fÄr IP och serverport genom nÄgra externa medel, och anropar
sendto(). Samtidigt görs ett internt anropbind(), sÄ efterföljanderecvfrom()fÄr svaret utan uttrycklig bindning.
I vÄrt fall fÄr kunden en strÀngnyckel externt och anvÀnder funktionen resolve() för att hÀmta IP-adressen.
I det hÀr steget börjar vi "handshake" för WebRTC, om tvÄ peers Ànnu inte Àr anslutna till varandra. Anslutningar till olika portar hos en peer anvÀnder samma DataChannel för WebRTC.
Vi utför ocksÄ en indirekt bind(), sÄ att servern kan ÄterupprÀtta anslutningen i nÀsta sendto() ifall den av nÄgon anledning har stÀngts.
Servern meddelas om klientens anslutning nÀr klienten skriver sitt SDP-erbjudande under serverns portinformation i Firebase, och servern svarar med sin respons dÀr.
I schemat nedan visas ett exempel pÄ meddelandeflödet för socket-schemat och överföringen av det första meddelandet frÄn klienten till servern:

Den fullstÀndiga schemat för anslutningssteget mellan klienten och servern
Slutsats
Om du har lÀst Ànda hit sÄ Àr du nog intresserad av att se teorin i praktiken. Du kan spela spelet pÄ , ge det ett försök!
En vÀnskapsmatch mellan kollegor
Koden för det nÀtverksbiblioteket Àr fritt tillgÀnglig pÄ . GÄ med i diskussionen pÄ vÄr kanal i !
KĂ€lla: habr.com
