Vi porterar ett flerspelarskap frÄn C++ till webben med Cheerp, WebRTC och Firebase

Introduktion

VÄrt företag Leaning Technologies tillhandahÄller lösningar för portering av traditionella desktopapplikationer till webben. VÄr C++-kompilator Cheerp genererar en kombination av WebAssembly och JavaScript, vilket möjliggör bÄde enkelt samspel med webblÀsaren, och hög prestanda.

Som exempel pĂ„ dess tillĂ€mpning har vi beslutat att portera ett flerspelarspel för webben och valde Teeworlds. 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.

Vi porterar ett flerspelarskap frÄn C++ till webben med Cheerp, WebRTC och Firebase
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 UDP. Detta Àr viktigt för realtidsapplikationer, sÄsom programvara för videokonferenser och spel, eftersom garantier för leverans och paketordning för protokollet TCP kan störa lÄga latenser.

Det finns ocksĂ„ ett tredje sĂ€tt – att anvĂ€nda nĂ€tverket frĂ„n webblĂ€saren: WebRTC.

RTCDataChannel 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 STUN/TURN).

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:

  • Session Traversal Utilities for NAT (STUN) Ă€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.
  • Traversal Using Relays around NAT (TURN) 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.
  • Interactive Connectivity Establishment (ICE) 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.
  • Session Description Protocol (SDP) Ă€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).

Vi porterar ett flerspelarskap frÄn C++ till webben med Cheerp, WebRTC och Firebase
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 Firebase:

  • 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.

Vi porterar ett flerspelarskap frÄn C++ till webben med Cheerp, WebRTC och Firebase
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-funktionen

Tilldelning 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 anrop bind(), sĂ„ efterföljande recvfrom() 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:

Vi porterar ett flerspelarskap frÄn C++ till webben med Cheerp, WebRTC och Firebase
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Ä teeworlds.leaningtech.com, ge det ett försök!


En vÀnskapsmatch mellan kollegor

Koden för det nÀtverksbiblioteket Àr fritt tillgÀnglig pÄ Github. GÄ med i diskussionen pÄ vÄr kanal i Gitter!

KĂ€lla: habr.com

Köp pĂ„litlig webbhotell med DDoS-skydd, VPS VDS-servrar đŸ”„ Köp pĂ„litlig webbhotell med DDoS-skydd, VPS VDS-servrar | ProHoster