Bevezetés
Cégünk megoldásokat kínál a hagyományos asztali alkalmazások webre történő átültetésére. C++ fordítónk WebAssembly és JavaScript kombinációját generálja, ami biztosítja a , és a nagy teljesítményt.
Példaként arra, hogy mire használható, úgy döntöttünk, hogy átportálunk egy többjátékos játékot a webre, és ezt kiválasztottuk . A Teeworlds egy többjátékos, kétdimenziós retro játék, amelynek kicsi, de aktív játékos közössége van (köztük én is!). Letöltési forrásai és CPU-, GPU-igényei alacsonyak — ideális jelölt.

A böngészőben futó Teeworlds
E projektet úgy döntöttük, hogy arra használjuk, hogy kísérletezünk a webre történő hálózati kód portálására vonatkozó általános megoldásokkal. Ezt általában a következő módokon valósítják meg:
- XMLHttpRequest/fetch, ha a hálózati rész csak HTTP-kérésekből áll, vagy
- WebSockets.
Mindkét megoldás megköveteli egy szerveroldali komponens hosztolását, és egyik sem teszi lehetővé, hogy szállítási protokollként használjuk . Ez fontos az olyan valós idejű alkalmazásoknál, mint a videokonferenciák és játékok, mert a megbízhatóság és a csomagok sorrendjének garantálása zavarokat okozhat az alacsony késleltetésben.
Van egy harmadik út is — a böngészőn keresztüli hálózat használata: .
támogatja a megbízható és a megbízhatatlan adatátvitelt (az utóbbi esetben lehetőség szerint UDP-t próbál használni szállítási protokollként), és alkalmazható távoli szerverrel és böngészők között is. Ez azt jelenti, hogy a teljes alkalmazást átportálhatjuk a böngészőbe, beleértve a szerveroldali komponenst is!
Ez azonban egy további nehézséggel jár: mielőtt a két WebRTC peer adatokat tudna cserélni, viszonylag bonyolult "kézfogó" (handshake) eljárást kell végrehajtani a csatlakozáshoz, amihez több külső entitás (jelző szerver és egy vagy több STUN szerver) szükséges. /).
Ideális esetben szeretnénk egy hálózati API-t létrehozni, amely a WebRTC-t használja, de minél közelebb áll a UDP Sockets interfészhez, amelyhez nem szükséges a kapcsolat létrehozása.
Ez lehetővé teszi számunkra, hogy kihasználjuk a WebRTC előnyeit anélkül, hogy bonyolult részleteket kellene felfednünk az alkalmazás kódjáról (amelyet a projektünkben a lehető legkevesebbet akartunk módosítani).
Minimális WebRTC
A WebRTC egy böngészőkben elérhető API-készlet, amely lehetővé teszi a peer-to-peer hang, videó és tetszőleges adatok átvitelét.
A kapcsolatok a peer-ek között STUN és/vagy TURN szervereken keresztül, egy ICE néven ismert mechanizmus segítségével jönnek létre (még akkor is, ha NAT van jelen az egyik vagy mindkét oldalon). A peer-ek ICE információt és csatornaparamétereket cserélnek az SDP protokoll offer és answer mechanizmusán keresztül.
Hűha! Milyen sok rövidítés egy füst alatt. Röviden magyarázzuk el, mit jelentenek ezek a fogalmak:
- — egy protokoll NAT megkerülésére és egy (IP, port) pár beszerzésére a közvetlen adatcseréhez a gazdagéppel. Ha sikerül elérnie a célját, a peer-ek közvetlenül adatokat cserélhetnek egymással.
- szintén NAT megkerülésére szolgál, de úgy valósítja meg ezt, hogy az adatokat a közvetlenül látható proxy-n keresztül irányítja mindkét peer számára. Késleltetést és költséget ad hozzá a végrehajtás során, mint a STUN (mivel a kapcsolat során folyamatosan alkalmazzák), de néha ez az egyetlen lehetséges megoldás.
- azt használják, hogy a legjobb lehetséges módot válasszák a két peer összekapcsolására a peer-ek kölcsönös kapcsolati információi és a STUN és TURN szerverektől beszerzett információk alapján.
- — a kapcsolat csatornájának paramétereinek leírására szolgáló formátum, például az ICE jelöltek, multimédia kodekek (hang-/videocsatornák esetén), stb. Az egyik peer SDP Offer-t ('ajánlat') küld, a másik pedig SDP Answer-t ('válasz') ad. Ezt követően létrejön a csatorna.
Ahhoz, hogy ilyen kapcsolatot hozzanak létre, a peer-eknek össze kell gyűjteniük az általuk a STUN és TURN szerverektől kapott információt, és meg kell osztaniuk egymással.
A probléma az, hogy jelenleg nincs közvetlen lehetőségük az adatok cseréjére, ezért egy alternatív mechanizmusnak kell léteznie az adatok átvitelére: a jelző szerver.
A jelző szerver nagyon egyszerű lehet, mert egyetlen feladata az adatok átkapcsolása a peer-ek között az 'ütközés' szakaszban (ahogy az az alábbi ábrán is látható).

Egyszerűsített WebRTC kézfogó folyamatábra
Teeworlds hálózati modell áttekintése
A Teeworlds hálózati architektúrája nagyon egyszerű:
- A kliens és a szerver komponensek két külön programot jelentenek.
- A kliensek úgy lépnek be a játékba, hogy csatlakoznak az egyik több szerverhez, amelyek egyszerre csak egy játékot hosztolnak.
- Minden adatátvitel a játék során a szerveren keresztül történik.
- Egy külön mester szerver használatával gyűjtjük össze az összes nyilvános szerver listáját, amely a játékkliensben megjelenik.
A WebRTC adatváltásának köszönhetően áthelyezhetjük a játék szerver komponensét a böngészőbe, ahol a kliens található. Ez nagyszerű lehetőséget ad nekünk…
Elhagyjuk a szervereket
A szerverlogika hiányának van egy kellemes előnye: az egész alkalmazást statikus tartalomként telepíthetjük a Github Pages-re vagy a saját berendezésünkre a Cloudflare-en, így ingyenesen biztosíthatjuk a gyors töltést és a magas rendelkezésre állást. Alapvetően elfelejthetjük a szervereket, és ha szerencsénk van, hogy a játék népszerű lesz, akkor az infrastruktúrát nem kell frissíteni.
Azonban ahhoz, hogy a rendszer működjön, még mindig szükségünk van külső architektúrára:
- Egy vagy több STUN szerver: több ingyenes lehetőség közül választhatunk.
- Legalább egy TURN szerver: itt nincsenek ingyenes lehetőségek, így sajátot üzemeltethetünk, vagy fizethetünk a szolgáltatásért. Szerencsére a legtöbb időben a kapcsolatot STUN szervereken keresztül lehet létrehozni (és biztosítani az igazi p2p-t), de a TURN mint tartalék lehetőség szükséges.
- Jelző szerver: a két másik aspektustól eltérően a jelzés nem standardizált. Az, hogy a jelző szerver valójában milyen feladatokat lát el, részben az alkalmazástól függ. A mi esetünkben a kapcsolat létrehozása előtt kis mennyiségű adatcserére van szükség.
- Teeworlds mester szerver: más szerverek használják arra, hogy értesítsenek a létezésükről, míg a kliensek a nyilvános szerverek keresésére használják. Bár nem kötelező (a kliensek mindig csatlakozhatnak a már ismert szerverhez manuálisan), jó lenne, ha rendelkezésre állna, hogy a játékosok véletlenszerű emberekkel játszhassanak.
Úgy döntöttünk, hogy a Google ingyenes STUN szervereit használjuk, míg egy TURN szervert saját magunk üzemeltetjük.
Az utolsó két ponthoz használtuk :
- A Teeworlds mester szervere nagyon egyszerűen van megvalósítva: egy objektumok listájaként, amelyek tartalmazzák az aktív szerverek információit (név, IP, térkép, mód, …). A szerverek közzéteszik és frissítik a saját objektumukat, míg az ügyfelek lekérik az egész listát, és megjelenítik azt a játékosnak. Ezen kívül HTML formájában is megjelenítjük a listát a főoldalon, hogy a játékosok egyszerűen rákattinthassanak a szerverre és közvetlenül belépjenek a játékba.
- A jelzés szorosan összefügg a következő részben bemutatott socket megvalósításunkkal.

A játékbeli és a főoldalon lévő szerverek listája
Socket megvalósítás
Egy olyan API-t szeretnénk létrehozni, amely a lehető legközelebb áll a Posix UDP Sockets-hoz, hogy minimalizáljuk a szükséges változtatások számát.
A legszükségesebb minimális funkciókat szeretnénk megvalósítani a legegyszerűbb hálózati adatcseréhez.
Például nincs szükségünk valódi útválasztásra: minden peer egy virtuális "LAN"-ban található, amely egy adott Firebase adatbázishoz kapcsolódik.
Ennek megfelelően nincs szükségünk egyedi IP-címekre: a peer-ek egyedi azonosításához elegendő, ha a Firebase egyedi kulcsértékeit használjuk (használható domainnevek szerint), és minden peer helyben "ál" IP-címeket rendel a megfelelő kulcshoz, amelyet le kell fordítani. Ez teljesen megszünteti a globális IP-címek kiosztásának szükségességét, ami nem triviális feladat.
Itt van a minimális API, amelyet meg kell valósítanunk:
// 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);Az API egyszerű és hasonlít a Posix Sockets API-hoz, de van néhány fontos eltérése: visszahívások regisztrálása, helyi IP-k kiosztása és "lusta" csatlakoztatás.
Visszahívások regisztrálása
Még ha az alapprogram nem blokkoló bemeneti-kimenetet használ is, a webböngészőben való futtatáshoz a kódot refaktorálni kell.
Ennek az az oka, hogy az eseményciklus a böngészőben rejtve van a program elől (legyen szó JavaScript-ről vagy WebAssembly-ről).
Natív környezetben ilyen módon írhatunk kódot
while(running) {
select(...); // várakozás I/O eseményekre
while(true) {
int r = readfrom(...); // próbálkozás az olvasással
if (r < 0 && errno == EWOULDBLOCK) // nincs több elérhető adat
break;
...
}
...
}Ha az eseményciklus rejtve van előttünk, akkor azt ilyesmivé kell alakítanunk:
auto cb = []() { // ezt hívják meg, amikor új adatok érhetők el
while(true) {
int r = readfrom(...); // próbálkozás az olvasással
if (r < 0 && errno == EWOULDBLOCK) // nincs több elérhető adat
break;
...
}
...
};
recvCallback(cb); // a visszahívás regisztrálásaHelyi IP-k feladata
A csomópontok az «hálózatunkban» nem IP-címek, hanem Firebase kulcsok (ezek olyan karakterláncok, amelyek így néznek ki: -LmEC50PYZLCiCP-vqde ).
Ez kényelmes, mert nincs szükségünk IP-k kiosztásának mechanizmusára és azok egyediségének ellenőrzésére (valamint azok eltávolítására az ügyfél lekapcsolása után), de gyakran szükséges a peer-ek azonosítása numerikus érték alapján.
Ezért használjuk a következő funkciókat resolve és reverseResolve: az alkalmazás valamilyen módon megszerzi a kulcs string értékét (felhasználói bevitel vagy mesterszerver útján), és át tudja alakítani IP-címmé a belső használat érdekében. Az API többi része is egyszerűség kedvéért ezt az értéket kapja újra, nem string formájában.
Ez hasonlít a DNS keresésre, csak a kliensnél helyben történik.
Tehát az IP-címek nem lehetnek közösek különböző kliensek között, és ha valamilyen globális azonosítóra van szükség, azt más módon kell generálni.
Lusta kapcsolat
Az UDP-nek nincs szüksége kapcsolatra, de mint láttuk, mielőtt adatokat elkezdenénk átadni két peer között, a WebRTC hosszú kapcsolati folyamatot igényel.
Ha ugyanazt az absztrakciós szintet szeretnénk biztosítani, (sendto/recvfrom tetszőleges peerekkel előzetes kapcsolódás nélkül), akkor a belső API-ban «lusta» (késleltetett) kapcsolatot kell létrehoznunk.
Itt van, mi történik az adatcserénél a «szerver» és a «kliensek» között UDP használatakor, és mit kell megvalósítania a könyvtárunknak:
- A szerver meghívja
bind(), hogy értesítse az operációs rendszert, hogy csomagokat szeretne fogadni a megadott porton.
Ehelyett a Firebase-en közzéteszünk egy nyitott portot a szerver kulcsaként, és figyeljük az eseményeket annak a lefelé mutató ágain.
- A szerver meghívja
recvfrom(), fogadva a portjára érkező csomagokat bármely hostból.
A mi esetünkben ellenőriznünk kell a bejövő csomagok sorát, amelyeket erre a portra küldtek.
Minden port saját sorral rendelkezik, és a WebRTC datagramjaként hozzáadjuk a forrást és a célportot, hogy tudjuk, hová irányítsuk az érkező új csomagot.
A hívás nem blokkoló, így ha nincsenek csomagok, egyszerűen -1-et adunk vissza, és beállítjuk errno=EWOULDBLOCK.
- Az ügyfél valamilyen külső módon megszerzi a szerver IP-címét és portját, és meghívja
sendto(). Ezenkívül belső hívás is zajlik, így a következőbind()választ fog kapni a bind explicite végrehajtása nélkül.recvfrom()válasz fog jönni anélkül, hogy a bind kifejezett végrehajtása történne.
Ebben az esetben a kliens külsőleg egy karakterlánc kulcsot kap, és használja a funkciót resolve() az IP-cím megszerzéséhez.
Ebben a szakaszban elkezdjük a WebRTC „kézfogását”, ha a két peer még nem csatlakozott egymáshoz. Különböző portok csatlakozása egy peeren ugyanazt a WebRTC DataChannel-t használja.
Továbbá közvetett bind(), hogy a szerver a következő alkalommal helyre tudja állítani a kapcsolatot, sendto() ha valamilyen okból megszakad.
A szerver értesítést kap a kliens csatlakozásáról, amikor a kliens rögzíti SDP ajánlatát a szerver portinformációjával a Firebase-en, és a szerver ott is válaszol az ajánlatra.
Az alábbi ábrán a socket séma üzeneteinek áramlását és az első üzenet kliensről a szerverre való továbbítását láthatjuk:

A kliens és a szerver közötti csatlakozási szakasz teljes sémája
Zárás
Ha végigolvastad, valószínűleg érdekelne, hogy a gyakorlatban is lásd a teóriát. Játszhatsz a , próbáld ki!
Barátságos mérkőzés a kollégák között
A hálózati könyvtár kódja szabadon hozzáférhető a . Csatlakozz a közösséghez a csatornánkon a !
Forrás: habr.com
