Introducere
Compania noastră oferă soluții pentru portarea aplicațiilor desktop tradiționale în web. Compilatorul nostru C++ generează o combinație de WebAssembly și JavaScript, ceea ce asigură atât , cât și o performanță ridicată.
Ca exemplu al aplicării sale, am decis să portăm pentru web un joc multiplayer și am ales pentru aceasta . Teeworlds este un joc retro 2D multiplayer cu o comunitate mică, dar activă de jucători (inclusiv eu!). Este mic atât în ceea ce privește resursele descărcabile, cât și cerințele pentru CPU și GPU - un candidat ideal.

Teeworlds care rulează în browser
Am decis să folosim acest proiect pentru a experimenta cu soluții generale pentru portarea codului de rețea pentru web. De obicei, acest lucru se realizează în următoarele moduri:
- XMLHttpRequest/fetch, dacă partea de rețea constă doar din cereri HTTP, sau
- WebSockets.
Ambele soluții necesită găzduirea componentei server pe partea serverului, iar niciuna dintre ele nu permite utilizarea ca protocol de transport . Acest lucru este important pentru aplicațiile în timp real, cum ar fi software-ul pentru videoconferințe și jocuri, deoarece garanțiile de livrare și ordinea pachetelor protocolului pot deveni un obstacol pentru latențe scăzute.
Există și o a treia cale - utilizarea rețelei din browser: .
suportă atât transmiterea fiabilă, cât și cea nefiabilă (în acest ultim caz încearcă, pe cât posibil, să folosească ca protocol de transport UDP) și poate fi utilizat atât cu servere externe, cât și între browsere. Aceasta înseamnă că putem porta în browser întreaga aplicație, inclusiv componenta server!
Cu toate acestea, există o dificultate suplimentară: înainte ca două părți WebRTC să poată schimba date, trebuie să execute o procedură relativ complexă de «strângere de mâini» (handshake) pentru conectare, ceea ce necesită câteva entități externe (server de semnalizare și unul sau mai multe servere /).
Ideal ar fi să creăm un API de rețea, care să utilizeze WebRTC în interior, dar cât mai aproape de interfața UDP Sockets, care nu necesită stabilirea unei conexiuni.
Aceasta ne va permite să beneficiem de avantajele WebRTC fără a fi necesar să dezvăluim detalii complexe despre codul aplicației (pe care în proiectul nostru am dorit să-l modificăm cât mai puțin).
WebRTC minim
WebRTC este un set de API-uri disponibile în browsere, care asigură transmiterea de sunet, video și date arbitrary de la peer la peer.
Conexiunea între perechi este stabilită (chiar și în cazul în care există NAT de o parte sau de ambele părți) prin intermediul serverelor STUN și/sau TURN printr-un mecanism numit ICE. Perechile schimbă informații ICE și parametrii canalelor prin intermediul ofertei și răspunsului protocolului SDP.
Wow! Atâtea acronime deodată. Să explicăm pe scurt ce înseamnă aceste concepte:
- — un protocol pentru a ocoli NAT și a obține o pereche (IP, port) pentru a schimba date direct cu gazda. Dacă reușește să își îndeplinească sarcina, perechile pot schimba date între ele.
- de asemenea, este folosit pentru a ocoli NAT, dar o face redirecționând datele printr-un proxy vizibil ambelor perechi. Adaugă întârziere și este mai costisitor de realizat decât STUN (deoarece este utilizat pe întreaga durată a sesiunii de comunicare), dar uneori este singura opțiune posibilă.
- este utilizat pentru a alege cea mai bună modalitate posibilă de conectare a două perechi pe baza informațiilor obținute în timpul conexiunii directe, precum și a informațiilor obținute de la un număr de servere STUN și TURN.
- — este un format pentru descrierea parametrilor canalului de conectare, cum ar fi candidații ICE, codec-urile media (în cazul canalelor audio/video) etc. O pereche trimite SDP Offer („ofertă”), iar cealaltă răspunde cu SDP Answer („răspuns”). După aceea, se creează canalul.
Pentru a crea o astfel de conexiune, perechile trebuie să adune informațiile obținute de la serverele STUN și TURN și să le schimbe între ele.
Problema este că nu au încă capacitatea de a schimba date direct, așa că trebuie să existe un mecanism offline pentru a schimba aceste date: un server de semnalizare.
Serverul de semnalizare poate fi foarte simplu, deoarece singura sa sarcină este de a redirecționa datele între perechi în etapa de 'strângere de mână' (așa cum este arătat în schema de mai jos).

Schema simplificată a secvenței de „strângere de mâini” WebRTC
Prezentare a modelului de rețea Teeworlds
Arhitectura rețelei Teeworlds este foarte simplă:
- Componentele clientului și serverului sunt două programe diferite.
- Clienții se alătură jocului conectându-se la unul dintre mai multe servere, fiecare dintre ele găzduind doar un singur joc în același timp.
- Toată transmiterea de date în joc se face prin intermediul serverului.
- Un server master special este utilizat pentru a colecta lista tuturor serverelor publice, care sunt afișate în clientul de joc.
Prin utilizarea WebRTC pentru schimbul de date, putem muta componenta de server a jocului în browser, unde se află clientul. Aceasta ne oferă o oportunitate minunată…
De a scăpa de servere
Lipsa logicii serverului are un avantaj plăcut: putem desfășura întreaga aplicație ca un conținut static pe Github Pages sau pe echipamentul nostru în spatele Cloudflare, asigurându-ne astfel încărcări rapide și o disponibilitate ridicată, complet gratuit. Practic, le putem uita, iar dacă avem noroc și jocul devine popular, nu va trebui să modernizăm infrastructura.
Cu toate acestea, pentru ca sistemul să funcționeze, va trebui totuși să folosim o arhitectură externă:
- Unul sau mai multe servere STUN: avem de ales dintre mai multe opțiuni gratuite.
- Cel puțin un server TURN: aici nu există opțiuni gratuite, așa că putem fie să ne configurăm propriul server, fie să plătim pentru un serviciu. Din fericire, majoritatea timpului conexiunea se va putea stabili prin intermediul serverelor STUN (asigurând astfel un p2p real), dar TURN este necesar ca variantă de rezervă.
- Server de semnalizare: spre deosebire de celelalte două aspecte, semnalizarea nu este standardizată. Ceea ce va face efectiv serverul de semnalizare depinde, în oarecare măsură, de aplicație. În cazul nostru, înainte de a stabili o conexiune, trebuie să schimbăm un volum mic de date.
- Server master Teeworlds: este utilizat de celelalte servere pentru a-și anunța existența și de clienți pentru a găsi servere publice. Deși nu este obligatoriu (clienții se pot conecta întotdeauna manual la serverul pe care îl cunosc), ar fi bine să existe pentru a permite jucătorilor să participe la jocuri cu oameni aleatori.
Am decis să folosim serverele STUN gratuite ale Google, iar un server TURN l-am desfășurat noi înșine.
Pentru ultimele două puncte, am folosit :
- Serverul master Teeworlds este realizat foarte simplu: ca o listă de obiecte, care conțin informații (nume, IP, hartă, mod, …) despre fiecare server activ. Serverele publică și își actualizează propriul obiect, iar clienții iau întreaga listă și o afișează jucătorului. De asemenea, afișăm lista pe pagina principală ca HTML, astfel încât jucătorii să poată pur și simplu să dea clic pe server și să intre direct în joc.
- Semnalizarea este strâns legată de implementarea noastră a socket-urilor, descrisă în secțiunea următoare.

Lista serverelor din joc și pe pagina principală
Implementarea socket-urilor
Dorim să creăm un API cât mai apropiat de socket-urile Posix UDP, pentru a minimiza numărul de modificări necesare.
De asemenea, dorim să implementăm minimul necesar pentru un schimb de date simplu prin rețea.
De exemplu, nu avem nevoie de rutare reală: toate nodurile se află într-o „LAN virtuală”, legată de o instanță specifică a bazei de date Firebase.
Prin urmare, nu avem nevoie de adrese IP unice: pentru identificarea unică a nodurilor, este suficient să folosim valori unice ale cheilor Firebase (similar cu numele de domenii), iar fiecare nod își alocă local „falsuri” IP pentru fiecare cheie care trebuie convertită. Acest lucru ne scutește complet de necesitatea alocării globale a adreselor IP, ceea ce este o sarcină non-trivială.
Iată API-ul minim pe care trebuie să-l implementăm:
// 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-ul este simplu și seamănă cu API-ul socket-urilor Posix, dar are câteva diferențe importante: înregistrarea callback-urilor, alocarea IP-urilor locale și conexiune „leneșă”.
Înregistrarea callback-urilor
Chiar dacă programul inițial folosește intrare/ieșire non-blocantă, pentru a rula în browserul web, codul trebuie refactorizat.
Motivul este că ciclul de evenimente din browser este ascuns de program (fie că este vorba de JavaScript sau WebAssembly).
Într-un mediu nativ, putem scrie cod în acest mod
while(running) {
select(...); // așteaptă evenimente I/O
while(true) {
int r = readfrom(...); // încearcă să citească
if (r < 0 && errno == EWOULDBLOCK) // nu mai sunt date disponibile
break;
...
}
...
}Dacă ciclul de evenimente este ascuns pentru noi, atunci trebuie să-l transformăm într-un mod similar:
auto cb = []() { // acest lucru va fi apelat când sunt disponibile date noi
while(true) {
int r = readfrom(...); // încearcă să citească
if (r < 0 && errno == EWOULDBLOCK) // nu mai sunt date disponibile
break;
...
}
...
};
recvCallback(cb); // înregistrează callback-ulScopul IP-urilor locale
Identificatorii nodurilor din „rețeaua” noastră nu sunt adrese IP, ci chei Firebase (acestea sunt șiruri care arată așa: -LmEC50PYZLCiCP-vqde ).
Acest lucru este convenabil, deoarece nu avem nevoie de un mecanism pentru a aloca IP-uri și a verifica unicitatea lor (precum și de gestionarea lor după deconectarea clientului), dar este adesea necesar să identificăm perechile printr-o valoare numerică.
Exact pentru aceasta sunt folosite funcțiile resolve și reverseResolve: aplicația obține în vreun fel valoarea șirului cheii (prin introducerea utilizatorului sau prin serverul maestru) și poate transforma aceasta în adresă IP pentru utilizare internă. Restul API-ului de asemenea, pentru simplificare, primește în locul șirului această valoare.
Aceasta este similar cu o căutare DNS, doar că se efectuează local pe client.
Adică, adresele IP nu pot fi comune pentru diferiți clienți, iar dacă este nevoie de un identificator global, acesta va fi generat printr-o altă metodă.
Conexiune leneșă
UDP nu necesită o conexiune, dar, așa cum am văzut, înainte de a începe transferul de date între două perechi, WebRTC necesită un proces lung de conectare.
Dacă dorim să asigurăm același nivel de abstractizare, (sendto/recvfrom cu perechi arbitrare fără conectare prealabilă), atunci trebuie să realizăm o conectare „leneșă” (întârziată) în cadrul API-ului.
Iată ce se întâmplă în mod obișnuit în timpul schimbului de date între „server” și „client” în cazul utilizării UDP, și ce ar trebui să facă biblioteca noastră:
- Serverul apelează
bind(), pentru a informa sistemul de operare că dorește să primească pachete pe portul specificat.
În schimb, vom publica un port deschis în Firebase sub cheia serverului și vom asculta evenimente în subarborele său.
- Serverul apelează
recvfrom(), primind în acel port pachete de la orice gazdă.
În cazul nostru, trebuie să verificăm coada de intrare a pachetelor trimise pe acel port.
Fiecare port are propria coadă, iar noi adăugăm la capătul datagramei WebRTC porturile sursă și destinație, pentru a ști în ce coadă să redirecționăm un pachet nou la sosire.
Apelul este non-blocant, așa că, dacă nu există pachete, doar returnăm -1 și setăm errno=EWOULDBLOCK.
- Clientul obține prin metode externe IP-ul și portul serverului și apelează
sendto(). De asemenea, se efectuează un apel intern, astfel încât următorulbind()va primi un răspuns fără a efectua explicit bind.recvfrom()va primi un răspuns fără a efectua explicit bind.
În cazul nostru, clientul primește extern un cheie de tip string și folosește funcția resolve() pentru a obține adresa IP.
În acest stadiu, începem «strângerea de mână» WebRTC, dacă cei doi peer-uri nu sunt încă conectați unul la celălalt. Conexiunile la porturi diferite ale aceluiași peer folosesc aceeași DataChannel WebRTC.
De asemenea, efectuam indirect bind(), pentru ca serverul să poată restabili conexiunea în următorul sendto() în cazul în care acesta s-a închis din diverse motive.
Serverul este notificat de conectarea clientului atunci când clientul își scrie oferta SDP sub informația portului serverului în Firebase, iar serverul răspunde tot acolo cu un răspuns.
Schema prezentată mai jos arată un exemplu de mișcare a mesajelor pentru schema de socket și transmiterea primei mesaje de la client la server:

Schema completă a etapei de conectare între client și server.
Concluzie
Dacă ați citit până la capăt, probabil că sunteți curios să vedeți teoria în acțiune. Puteți juca jocul pe , da, încercați!
Un meci prietenesc între colegi.
Codul bibliotecii de rețea este disponibil gratuit pe . Alăturați-vă discuțiilor pe canalul nostru din !
Sursa: habr.com
