Úvod
Naše společnost poskytuje řešení pro portování tradičních desktopových aplikací na web. Náš C++ kompilátor generuje kombinaci WebAssembly a JavaScript, což zajišťuje jak , tak vysoký výkon.
Jako příklad jeho použití jsme se rozhodli portovat pro web multiplayerovou hru a vybrali jsme pro to . Teeworlds je multiplayerová 2D retro hra s malou, ale aktivní komunitou hráčů (mezi nimi jsem i já!). Je malá jak z hlediska stahovaných dat, tak požadavků na CPU a GPU – ideální kandidát.

Teeworlds běžící v prohlížeči
Rozhodli jsme se využít tento projekt k experimentování s obecnými řešeními pro portování síťového kódu na web. Obvykle se to provádí následujícími způsoby:
- XMLHttpRequest/fetch, pokud se síťová část skládá pouze z HTTP požadavků, nebo
- WebSockets.
Obě řešení vyžadují hostovat serverovou komponentu na straně serveru, a žádné z nich neumožňuje používat jako transportní protokol . To je důležité pro aplikace v reálném čase, jako jsou software pro videokonference a hry, protože záruky doručení a pořadí paketů protokolu mohou být překážkou pro nízké latence.
Existuje i třetí způsob – používat síť z prohlížeče: .
podporuje jak spolehlivý, tak nespolehlivý přenos (v druhém případě se snaží použít jako transportní protokol UDP), a může být použit jak s vzdáleným serverem, tak mezi prohlížeči. To znamená, že můžeme portovat do prohlížeče celou aplikaci, včetně serverové komponenty!
Nicméně s tím je spojena další obtíž: než dva peerové WebRTC mohou vyměňovat data, musí provést relativně složitý postup „podání ruky“ (handshake) k navázání spojení, k čemuž je nezbytné několik externích entit (signální server a jeden nebo více serverů /).
Ideálně bychom chtěli vytvořit síťové API, které uvnitř používá WebRTC, ale co nejblíže rozhraní UDP Sockets, které nepotřebuje navazovat spojení.
To nám umožní využít výhod WebRTC, aniž bychom museli odhalovat složité detaily kódu aplikace (který jsme v našem projektu chtěli co nejméně měnit).
Minimální WebRTC
WebRTC je sada existující sada API v prohlížečích, která umožňuje přenos peer-to-peer zvuku, videa a libovolných dat.
Spojení mezi peer se vytváří (i v případě existence NAT na jedné nebo obou stranách) pomocí serverů STUN a/nebo TURN prostřednictvím mechanismu zvaného ICE. Peery si vyměňují informace ICE a parametry kanálů prostřednictvím nabídky a odpovědi protokolu SDP.
Wow! Kolik zkratek najednou. Pojďme stručně vysvětlit, co tyto pojmy znamenají:
- — protokol pro obcházení NAT a získání páru (IP, port) pro přenos dat přímo s hostitelem. Pokud se mu podaří splnit jeho úkol, mohou peery autonomně vyměňovat data mezi sebou.
- také se používá pro obcházení NAT, ale realizuje to tím, že přesměrovává data přes proxy, která je viditelná oběma peerům. Přidává latenci a je nákladnější na výkon než STUN (protože se používá po celou dobu komunikace), ale někdy je to jediná možná varianta.
- se používá pro výběr nejlepší možné metody spojení dvou peerů na základě informací získaných při přímém spojení peerů a také informací získaných od jakéhokoli počtu serverů STUN a TURN.
- — to je formát popisu parametrů připojovacího kanálu, například kandidátů ICE, multimedia kodeků (v případě audio/video kanálu) atd. Jeden z peerů posílá SDP nabídku („nabídku“), a druhý odpovídá SDP odpovědí („odpovědí“). Poté se vytváří kanál.
Aby bylo možné vytvořit takové spojení, potřebují peery shromáždit informace, které od serverů STUN a TURN získaly, a vyměnit si je navzájem.
Problém je v tom, že zatím nemají možnost vyměňovat si data přímo, proto musí existovat offline mechanismus: signální server.
Signální server může být velmi jednoduchý, protože jeho jediným úkolem je přesměrování dat mezi peery během fáze „podání ruky“ (jak je znázorněno na níže uvedeném diagramu).

Zjednodušený diagram posloupnosti „podání ruky“ WebRTC
Přehled síťového modelu Teeworlds
Síťová architektura Teeworlds je velmi jednoduchá:
- Komponenty klienta a serveru jsou dvě různé programy.
- Klienti vstupují do hry připojením k jednomu z několika serverů, z nichž každý hostí pouze jednu hru najednou.
- Všechna data v hře se přenášejí prostřednictvím serveru.
- Speciální hlavní server se používá k shromažďování seznamu všech veřejných serverů, které jsou zobrazeny v herním klientu.
Díky použití WebRTC pro výměnu dat můžeme přesunout serverovou část hry do prohlížeče, kde je klient. To nám dává skvělou příležitost…
Zbavit se serverů
Absence serverové logiky má příjemnou výhodu: můžeme nasadit celou aplikaci jako statický obsah na Github Pages nebo na vlastní infrastruktuře za Cloudflare, čímž si zdarma zajistíme rychlé načítání a vysokou dostupnost. V podstatě na ně můžeme zapomenout, a pokud budeme mít štěstí a hra se stane populární, infrastrukturu nebude třeba modernizovat.
Aby však systém fungoval, budeme i tak muset využívat externí architekturu:
- Jeden nebo více serverů STUN: máme na výběr z několika bezplatných variant.
- Nejméně jeden server TURN: zde není žádná bezplatná varianta, takže si můžeme buď nastavit vlastní, nebo platit za službu. Naštěstí většinu času bude možné připojení navazovat přes servery STUN (a zajistit skutečné p2p), ale TURN je potřebný jako záložní možnost.
- Signální server: na rozdíl od dvou dalších aspektů není signalizace standardizována. To, za co bude signální server skutečně odpovědný, do jisté míry závisí na aplikaci. V našem případě je třeba při navazování spojení vyměnit malé množství dat.
- Hlavní server Teeworlds: používají ho jiné servery k oznámení své existence a klienti k hledání veřejných serverů. Ačkoli není povinný (klienti se vždy mohou ručně připojit k známému serveru), bylo by dobré ho mít, aby se hráči mohli zapojit do her s náhodnými lidmi.
Rozhodli jsme se použít bezplatné servery STUN od společnosti Google a jeden server TURN jsme nasadili sami.
Pro dva poslední body jsme použili :
- Hlavní server Teeworlds je realizován velmi jednoduše: jako seznam objektů, které obsahují informace (název, IP, mapa, režim, ...) každého aktivního serveru. Servery publikují a aktualizují své vlastní objekty, zatímco klienti berou celý seznam a zobrazují ho hráči. Tento seznam také zobrazujeme na domovské stránce jako HTML, aby hráči mohli jednoduše kliknout na server a přímo se dostat do hry.
- Signalizace je úzce spjata s naší implementací soketů, popsanou v následující části.

Seznam serverů uvnitř hry a na domovské stránce
Implementace soketů
Chceme vytvořit API, které bude co nejblíže Posix UDP soketům, abychom minimalizovali potřebné změny.
Chceme také implementovat nezbytné minimum, které je nutné pro základní výměnu dat přes síť.
Například nepotřebujeme skutečné směrování: všechny peer jsou umístěny v jedné 'virtuální LAN', která je spojena s konkrétním instance databáze Firebase.
Proto nepotřebujeme unikátní IP adresy: pro unikátní identifikaci peer stačí použít unikátní hodnoty klíčů Firebase (podobně jako doménová jména), a každý peer místně přiřazuje 'falešné' IP adresy každému klíči, který je třeba převést. To nás zcela osvobozuje od potřeby globálního přidělování IP adres, což je netriviální úkol.
Zde je minimální API, které musíme implementovat:
// 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 je jednoduché a podobá se API Posix soketů, ale má několik důležitých rozdílů: registrace zpětných volání, přiřazení místních IP a 'lenivé' připojení.
Registrace zpětných volání
I když původní program používá neblokující vstup/výstup, pro spuštění v webovém prohlížeči je třeba kód refaktorovat.
Důvodem je, že cyklus událostí v prohlížeči je skryt před programem (ať už se jedná o JavaScript nebo WebAssembly).
V nativním prostředí můžeme psát kód tímto způsobem
while(running) {
select(...); // čekejte na I/O události
while(true) {
int r = readfrom(...); // pokus o čtení
if (r < 0 && errno == EWOULDBLOCK) // žádná další data k dispozici
break;
...
}
...
}Pokud je cyklus událostí pro nás skrytý, musíme ho převést na něco podobného:
auto cb = []() { // toto bude voláno, když jsou nová data k dispozici
while(true) {
int r = readfrom(...); // pokus o čtení
if (r < 0 && errno == EWOULDBLOCK) // žádná další data k dispozici
break;
...
}
...
};
recvCallback(cb); // registrace zpětného voláníPřiřazení místních IP
Identifikátory uzlů v naší "síti" nejsou IP adresy, ale klíče Firebase (jsou to řetězce, které vypadají takto: -LmEC50PYZLCiCP-vqde ).
Je to pohodlné, protože nepotřebujeme mechanismus pro přiřazení IP a kontrolu jejich jedinečnosti (ani jejich likvidaci po odpojení klienta), ale často je nutné identifikovat peer pomocí číselné hodnoty.
Právě k tomu se používají funkce resolve a reverseResolve: aplikace nějakým způsobem získává řetězcovou hodnotu klíče (prostřednictvím vstupu uživatele nebo přes master server) a může ji převést na IP adresu pro interní použití. Zbytek API také pro jednoduchost dostává místo řetězce tuto hodnotu.
Je to podobné jako DNS dotaz, jen se provádí lokálně u klienta.
To znamená, že IP adresy nemohou být sdíleny mezi různými klienty, a pokud je potřeba nějaký globální identifikátor, musí být generován jiným způsobem.
Lazy connection
UDP nepotřebuje připojení, ale, jak jsme viděli, před zahájením přenosu dat mezi dvěma peer, WebRTC vyžaduje dlouhý proces připojení.
Pokud chceme zajistit stejnou úroveň abstrakce, (sendto/recvfrom s libovolnými peery bez předchozího připojení), musíme provést "líné" (odložené) připojení uvnitř API.
To, co se děje při běžné výměně dat mezi "serverem" a "klientem" v případě použití UDP, a co by měla naše knihovna vykonávat:
- Server zavolá
bind(), aby oznámil operačnímu systému, že chce přijímat pakety na určeném portu.
Místo toho zveřejníme otevřený port v Firebase pod klíčem serveru a budeme poslouchat události v jeho podstromu.
- Server zavolá
recvfrom(), přijímat na tento port pakety přicházející od jakéhokoli hostitele.
V našem případě je třeba kontrolovat příchozí frontu paketů odeslaných do tohoto portu.
Každý port má svou vlastní frontu a na začátek datagramů WebRTC přidáváme zdrojové a cílové porty, abychom věděli, do které fronty přesměrovat nový paket při jeho příjmu.
Zavolání je neblokující, takže pokud nejsou žádné pakety, jednoduše vrátíme -1 a nastavíme errno=EWOULDBLOCK.
- Klient získává IP a port serveru nějakými externími prostředky a volá
sendto(). Také se přitom provádí interní voláníbind(), takže následnýrecvfrom()dostane odpověď bez explicitního provedení bind.
V našem případě klient externě získává řetězcový klíč a používá funkci resolve() k získání IP adresy.
V tomto kroku zahajujeme „ruce se podávající“ WebRTC, pokud dva peer ještě nejsou spojeni mezi sebou. Připojení k různým portům jednoho peer používá stejný DataChannel WebRTC.
Také provádíme nepřímé bind(), aby server mohl obnovit připojení v následujícím sendto() v případě, že bylo z nějakého důvodu uzavřeno.
Server je informován o připojení klienta, když klient zapisuje svůj SDP nabídku pod informací o portu serveru do Firebase, a server zde také odpovídá svou odpovědí.
Na níže uvedeném schématu je uveden příklad pohybu zpráv pro schéma socketů a předání první zprávy od klienta k serveru:

Celé schéma fáze připojení mezi klientem a serverem
Závěr
Pokud jste dočetli až do konce, asi vás zajímá podívat se na teorii v akci. Hru si můžete zahrát na , zkuste to!
Přátelské utkání mezi kolegy
Kód síťové knihovny je volně dostupný na . Připojte se k diskuzi na našem kanálu v !
Zdroj: habr.com
