Porti plurludantan ludon de C++ al la reto kun Cheerp, WebRTC kaj Firebase

Enkonduko

nia kompanio Leaning Technologies provizas solvojn por porti tradiciajn labortablajn aplikojn al la reto. Nia C++-kompililo hurai generas kombinaĵon de WebAssembly kaj JavaScript, kiu provizas ambaŭ simpla retumila interago, kaj alta rendimento.

Kiel ekzemplo de ĝia apliko, ni decidis porti plurludantan ludon al la reto kaj elektis Teeworlds. Teeworlds estas plurludanta XNUMXD retroa ludo kun malgranda sed aktiva komunumo de ludantoj (inkluzive de mi!). Ĝi estas malgranda kaj laŭ elŝutitaj rimedoj kaj postuloj pri CPU kaj GPU - ideala kandidato.

Porti plurludantan ludon de C++ al la reto kun Cheerp, WebRTC kaj Firebase
Kurante en la retumilo Teeworlds

Ni decidis uzi ĉi tiun projekton por eksperimenti ĝeneralaj solvoj por porti retkodon al la reto. Ĉi tio estas kutime farita laŭ la sekvaj manieroj:

  • XMLHttpRequest/fetch, se la reta parto konsistas nur el HTTP-petoj, aŭ
  • Retejoj.

Ambaŭ solvoj postulas gastigi servilan komponanton ĉe la servilo, kaj neniu ebligas uzi kiel transportprotokolo UDP. Ĉi tio gravas por realtempaj aplikoj kiel videokonferencaj programoj kaj ludoj, ĉar ĝi garantias la liveron kaj ordon de protokolpakaĵoj. TCP povas fariĝi malhelpo al malalta latenco.

Estas tria maniero - uzu la reton de la retumilo: WebRTC.

RTCDataChannel Ĝi subtenas kaj fidindan kaj nefidindan dissendon (en ĉi-lasta kazo ĝi provas uzi UDP kiel transportprotokolon kiam ajn eblas), kaj povas esti uzata kaj kun fora servilo kaj inter retumiloj. Ĉi tio signifas, ke ni povas porti la tutan aplikaĵon al la retumilo, inkluzive de la servila komponanto!

Tamen, ĉi tio venas kun plia malfacilaĵo: antaŭ ol du WebRTC-kunuloj povas komuniki, ili devas fari relative kompleksan manpremon por konekti, kiu postulas plurajn triajn entojn (signalservilo kaj unu aŭ pluraj serviloj. STUN/TURNU).

Ideale, ni ŝatus krei retan API kiu uzas WebRTC interne, sed estas kiel eble plej proksima al interfaco de UDP Sockets, kiu ne bezonas establi konekton.

Ĉi tio permesos al ni utiligi WebRTC sen devi elmontri kompleksajn detalojn al la aplika kodo (kiun ni volis ŝanĝi kiel eble plej malmulte en nia projekto).

Minimuma WebRTC

WebRTC estas aro de APIoj disponeblaj en retumiloj, kiuj disponigas samul-al-kunulan dissendon de audio, vidbendo kaj arbitraj datumoj.

La ligo inter samuloj estas establita (eĉ se ekzistas NAT sur unu aŭ ambaŭ flankoj) uzante STUN kaj/aŭ TURN-servilojn tra mekanismo nomita ICE. Samuloj interŝanĝas ICE-informojn kaj kanalparametrojn per oferto kaj respondo de la SDP-protokolo.

Ŭaŭ! Kiom da mallongigoj samtempe? Ni mallonge klarigu, kion signifas ĉi tiuj terminoj:

  • Session Traversal Utilities por NAT (STUN) — protokolo por preterpasi NAT kaj akiri paron (IP, haveno) por interŝanĝi datumojn rekte kun la gastiganto. Se li sukcesas plenumi sian taskon, tiam kunuloj povas sendepende interŝanĝi datumojn inter si.
  • Traversal Using Relays ĉirkaŭ NAT (TURNU) ankaŭ estas uzata por NAT-trapaso, sed ĝi efektivigas tion per plusendado de datumoj per prokurilo, kiu estas videbla por ambaŭ kunuloj. Ĝi aldonas latentecon kaj estas pli multekoste efektivigi ol STUN (ĉar ĝi estas aplikata dum la tuta komunika sesio), sed foje ĝi estas la sola opcio.
  • Interaga Konekteca Establo (ICE) uzata por elekti la plej bonan eblan metodon por konekti du kunulojn surbaze de informoj akiritaj de konekti kunuloj rekte, same kiel informojn ricevitajn de ajna nombro da STUN kaj TURN-serviloj.
  • Sesia Priskribo Protokolo (RDS) estas formato por priskribi parametrojn de konektokanalo, ekzemple, ICE-kandidatoj, plurmediaj kodekoj (kaze de aŭd-/video-kanalo), ktp... Unu el la samuloj sendas SDP-Oferton, kaj la dua respondas per SDP-Respondo. . . Post tio, kanalo estas kreita.

Por krei tian konekton, kunuloj devas kolekti la informojn, kiujn ili ricevas de la STUN kaj TURN-serviloj kaj interŝanĝi ĝin inter si.

La problemo estas, ke ili ankoraŭ ne havas la kapablon rekte komuniki, do devas ekzisti ekstergrupa mekanismo por interŝanĝi ĉi tiujn datumojn: signalservilo.

Signalservilo povas esti tre simpla ĉar ĝia nura tasko estas plusendi datumojn inter samuloj en la manpremofazo (kiel montrite en la diagramo malsupre).

Porti plurludantan ludon de C++ al la reto kun Cheerp, WebRTC kaj Firebase
Simpligita WebRTC manprema sekvencodiagramo

Teeworlds Reta Modela Superrigardo

Teeworlds-reta arkitekturo estas tre simpla:

  • La kliento kaj servilkomponentoj estas du malsamaj programoj.
  • Klientoj eniras la ludon per konektado al unu el pluraj serviloj, ĉiu el kiuj gastigas nur unu ludon samtempe.
  • Ĉiuj transdono de datumoj en la ludo estas farita per la servilo.
  • Speciala majstra servilo estas uzata por kolekti liston de ĉiuj publikaj serviloj kiuj estas montritaj en la ludkliento.

Danke al la uzo de WebRTC por interŝanĝo de datumoj, ni povas transdoni la servilon de la ludo al la retumilo, kie troviĝas la kliento. Ĉi tio donas al ni bonegan ŝancon...

Forigu servilojn

La manko de servila logiko havas belan avantaĝon: ni povas disfaldi la tutan aplikaĵon kiel senmovan enhavon sur Github Pages aŭ sur nia propra aparataro malantaŭ Cloudflare, tiel certigante rapidajn elŝutojn kaj altan funkciadon senpage. Fakte, ni povas forgesi pri ili, kaj se ni estas bonŝancaj kaj la ludo fariĝas populara, tiam la infrastrukturo ne devos esti modernigita.

Tamen, por ke la sistemo funkciu, ni ankoraŭ devas uzi eksteran arkitekturon:

  • Unu aŭ pluraj STUN-serviloj: Ni havas plurajn senpagajn elektojn por elekti.
  • Almenaŭ unu TURN-servilo: ĉi tie ne estas senpagaj opcioj, do ni povas aŭ agordi nian aŭ pagi por la servo. Feliĉe, plejofte la konekto povas esti establita per STUN-serviloj (kaj provizi veran p2p), sed TURN estas necesa kiel rezerva opcio.
  • Signaling Server: Male al la aliaj du aspektoj, signalado ne estas normigita. Pri kio efektive respondecos la signalservilo dependas iom de la aplikaĵo. En nia kazo, antaŭ ol establi konekton, necesas interŝanĝi malgrandan kvanton da datumoj.
  • Teeworlds Master Server: Ĝi estas uzata de aliaj serviloj por reklami ilian ekziston kaj de klientoj por trovi publikajn servilojn. Kvankam ĝi ne estas postulata (klientoj ĉiam povas konektiĝi al servilo pri kiu ili konas permane), estus bone havi, por ke ludantoj povu partopreni ludojn kun hazardaj homoj.

Ni decidis uzi la senpagajn STUN-servilojn de Guglo, kaj mem deplojis unu TURN-servilon.

Por la lastaj du punktoj ni uzis Firebase:

  • La ĉefservilo de Teeworlds estas efektivigita tre simple: kiel listo de objektoj enhavantaj informojn (nomo, IP, mapo, reĝimo, ...) de ĉiu aktiva servilo. Serviloj publikigas kaj ĝisdatigas sian propran objekton, kaj klientoj prenas la tutan liston kaj montras ĝin al la ludanto. Ni ankaŭ montras la liston sur la ĉefpaĝo kiel HTML, por ke ludantoj povu simple klaki sur la servilo kaj esti kondukitaj rekte al la ludo.
  • Signalado estas proksime rilata al niaj ingoj efektivigo, priskribita en la sekva sekcio.

Porti plurludantan ludon de C++ al la reto kun Cheerp, WebRTC kaj Firebase
Listo de serviloj ene de la ludo kaj sur la hejmpaĝo

Efektivigo de ingoj

Ni volas krei API kiel eble plej proksiman al Posix UDP Sockets por minimumigi la nombron da ŝanĝoj necesaj.

Ni ankaŭ volas efektivigi la necesan minimumon necesan por la plej simpla datumŝanĝo tra la reto.

Ekzemple, ni ne bezonas realan vojigon: ĉiuj kunuloj estas sur la sama "virtuala LAN" asociita kun specifa Firebase datumbaza kazo.

Tial ni ne bezonas unikajn IP-adresojn: unikaj ŝlosilaj valoroj de Firebase (similaj al domajnaj nomoj) sufiĉas por unike identigi kunulojn, kaj ĉiu samulo loke asignas "falsajn" IP-adresojn al ĉiu ŝlosilo, kiu devas esti tradukita. Ĉi tio tute forigas la bezonon de tutmonda IP-adreso, kio estas ne-triviala tasko.

Jen la minimuma API, kiun ni devas efektivigi:

// 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);

La API estas simpla kaj simila al la API de Posix Sockets, sed havas kelkajn gravajn diferencojn: registrante revokojn, asignante lokajn IP-ojn kaj maldiligentajn konektojn.

Registrante Revokojn

Eĉ se la origina programo uzas ne-blokan I/O, la kodo devas esti refaktorita por funkcii en tTT-legilo.

La kialo de tio estas, ke la okazaĵbuklo en la retumilo estas kaŝita de la programo (ĉu ĝi JavaScript aŭ WebAssembly).

En la denaska medio ni povas skribi kodon tiel

while(running) {
  select(...); // wait for I/O events
  while(true) {
    int r = readfrom(...); // try to read
    if (r < 0 && errno == EWOULDBLOCK) // no more data available
      break;
    ...
  }
  ...
}

Se la evento-buklo estas kaŝita al ni, tiam ni devas ŝanĝi ĝin en ion tian:

auto cb = []() { // this will be called when new data is available
  while(true) {
    int r = readfrom(...); // try to read
    if (r < 0 && errno == EWOULDBLOCK) // no more data available
      break;
    ...
  }
  ...
};
recvCallback(cb); // register the callback

Loka IP-asigno

La nodaj identigiloj en nia "reto" ne estas IP-adresoj, sed Firebase-ŝlosiloj (ili estas ĉenoj kiuj aspektas jene: -LmEC50PYZLCiCP-vqde ).

Ĉi tio estas oportuna ĉar ni ne bezonas mekanismon por atribui IP-ojn kaj kontroli ilian unikecon (same kiel forigi ilin post kiam la kliento malkonektas), sed ofte necesas identigi kunulojn per nombra valoro.

Ĝuste por tio estas uzataj la funkcioj. resolve и reverseResolve: La aplikaĵo iel ricevas la ĉenvaloron de la ŝlosilo (per uzanta enigo aŭ per la majstra servilo), kaj povas konverti ĝin al IP-adreso por interna uzo. La resto de la API ankaŭ ricevas ĉi tiun valoron anstataŭ ĉeno por simpleco.

Ĉi tio similas al DNS-serĉo, sed farita loke ĉe la kliento.

Tio estas, IP-adresoj ne povas esti dividitaj inter malsamaj klientoj, kaj se ia tutmonda identigilo estas necesa, ĝi devos esti generita alimaniere.

Maldiligenta konekto

UDP ne bezonas konekton, sed kiel ni vidis, WebRTC postulas longan konektan procezon antaŭ ol ĝi povas komenci transdoni datumojn inter du samuloj.

Se ni volas provizi la saman nivelon de abstraktado, (sendto/recvfrom kun arbitraj kunuloj sen antaŭa konekto), tiam ili devas plenumi "maldiligentan" (malfruan) konekton ene de la API.

Jen kio okazas dum normala komunikado inter la "servilo" kaj la "kliento" kiam oni uzas UDP, kaj kion devas fari nia biblioteko:

  • Servilaj vokoj bind()por diri al la operaciumo, ke ĝi volas ricevi pakaĵojn sur la specifita haveno.

Anstataŭe, ni publikigos malferman havenon al Firebase sub la servila ŝlosilo kaj aŭskultos eventojn en ĝia subarbo.

  • Servilaj vokoj recvfrom(), akceptante pakaĵetojn venantajn de iu ajn gastiganto sur ĉi tiu haveno.

En nia kazo, ni devas kontroli la envenantan vicon de pakoj senditaj al ĉi tiu haveno.

Ĉiu haveno havas sian propran atendovicon, kaj ni aldonas la fontajn kaj celajn havenojn al la komenco de la WebRTC-dagramoj por ke ni sciu al kiu atendovico plusendi kiam nova pako alvenos.

La alvoko estas nebloka, do se ne estas pakaĵoj, ni simple resendas -1 kaj agordas errno=EWOULDBLOCK.

  • La kliento ricevas la IP kaj havenon de la servilo per iuj eksteraj rimedoj, kaj vokas sendto(). Ĉi tio ankaŭ faras internan vokon. bind(), do posta recvfrom() ricevos la respondon sen eksplicite ekzekuti bind.

En nia kazo, la kliento ekstere ricevas la ĉenŝlosilon kaj uzas la funkcion resolve() por akiri IP-adreson.

Je ĉi tiu punkto, ni komencas WebRTC manpremon se la du samuloj ankoraŭ ne estas konektitaj unu al la alia. Konektoj al malsamaj havenoj de la sama samaĝulo uzas la saman WebRTC DataChannel.

Ni ankaŭ agas nerekte bind()por ke la servilo povu rekonekti en la sekva sendto() se ĝi ial fermiĝis.

La servilo estas sciigita pri la konekto de la kliento kiam la kliento skribas sian SDP-oferton sub la servilhaveninformoj en Firebase, kaj la servilo respondas tie.

La diagramo malsupre montras ekzemplon de mesaĝofluo por ingoskemo kaj la transdono de la unua mesaĝo de la kliento al la servilo:

Porti plurludantan ludon de C++ al la reto kun Cheerp, WebRTC kaj Firebase
Kompleta diagramo de la koneksa fazo inter kliento kaj servilo

konkludo

Se vi legis ĝis nun, vi verŝajne interesiĝas vidi la teorion en ago. La ludo povas esti ludita teeworlds.leaningtech.com, provu ĝin!


Amika matĉo inter kolegoj

La retbiblioteka kodo estas libere havebla ĉe GitHub. Aliĝu al la konversacio ĉe nia kanalo ĉe Gitter!

fonto: www.habr.com

Aldoni komenton