Wprowadzenie
Nasza firma oferuje rozwiązania do portowania tradycyjnych aplikacji desktopowych do sieci. Nasz kompilator C++ generuje połączenie WebAssembly i JavaScript, co zapewnia zarówno , jak i wysoką wydajność.
Jako przykład jego zastosowania postanowiliśmy przenieść do sieci grę wieloosobową i wybraliśmy do tego . Teeworlds to wieloosobowa, dwuwymiarowa gra retro z niewielką, ale aktywną społecznością graczy (w tym mnie!). Jest mała zarówno pod względem pobieranych zasobów, jak i wymagań dotyczących CPU i GPU — idealny kandydat.

Działająca w przeglądarce Teeworlds
Zdecydowaliśmy się wykorzystać ten projekt do eksperymentowania z ogólnymi rozwiązaniami do portowania kodu sieciowego do sieci. Zazwyczaj można to osiągnąć na kilka sposobów:
- XMLHttpRequest/fetch, jeśli część sieciowa składa się wyłącznie z zapytań HTTP, lub
- WebSockets.
Oba rozwiązania wymagają hostowania komponentu serwerowego po stronie serwera, i żadne z nich nie pozwala na użycie jako protokołu transportowego . Jest to istotne dla aplikacji czasu rzeczywistego, takich jak oprogramowanie do wideokonferencji i gry, ponieważ gwarancje dostarczania i porządku pakietów protokołu mogą zakłócać niskie opóźnienia.
Jest też trzeci sposób — użycie sieci z przeglądarki: .
obsługuje zarówno niezawodne, jak i niezawodne przesyłanie (w drugim przypadku stara się wykorzystać jako protokół transportowy UDP), i może być stosowane zarówno z serwerem zdalnym, jak i między przeglądarkami. To oznacza, że możemy portować całe aplikację do przeglądarki, w tym komponent serwerowy!
Jednak wiąże się to z dodatkową trudnością: zanim dwa węzły WebRTC będą mogły wymieniać dane, muszą przejść przez stosunkowo skomplikowaną procedurę „uścisku dłoni” (handshake) w celu połączenia, co wymaga kilku zewnętrznych podmiotów (serwer sygnalizacyjny i jeden lub więcej serwerów /).
W idealnym przypadku chcielibyśmy stworzyć sieciowe API, które wewnętrznie wykorzystuje WebRTC, ale było jak najbardziej zbliżone do interfejsu gniazd UDP, które nie wymaga ustanawiania połączenia.
To pozwoli nam wykorzystać zalety WebRTC bez konieczności ujawniania skomplikowanych szczegółów kodu aplikacji (który w naszym projekcie chcieliśmy zmieniać tak mało, jak to możliwe).
Minimalny WebRTC
WebRTC to zestaw interfejsów API dostępnych w przeglądarkach, umożliwiający wymianę dźwięku, wideo i dowolnych danych w trybie peer-to-peer.
Połączenie między parami jest ustanawiane (nawet w przypadku istnienia NAT z jednej lub obu stron) przy użyciu serwerów STUN i/lub TURN przez mechanizm zwany ICE. Parzy wymieniają informacje ICE oraz parametry kanałów za pomocą oferty i odpowiedzi z protokołu SDP.
Wow! Ile akronimów na raz. Podsumujmy, co te pojęcia oznaczają:
- to protokół do omijania NAT i uzyskiwania pary (adres IP, port) do bezpośredniej wymiany danych z hostem. Jeśli uda mu się to zrobić, pary mogą samodzielnie wymieniać się danymi.
- również jest używany do omijania NAT, ale realizuje to przez przekierowywanie danych przez proxy, widoczne dla obu par. Wprowadza to opóźnienie i jest bardziej kosztowne w wykonaniu niż STUN (ponieważ jest stosowane przez cały czas trwania sesji), ale czasami jest to jedyna możliwa opcja.
- jest używany do wyboru najlepszego możliwego sposobu połączenia dwóch par w oparciu o informacje uzyskane podczas bezpośredniego połączenia par, jak również informacje uzyskane od dowolnej liczby serwerów STUN i TURN.
- to format opisu parametrów kanału połączenia, takich jak kandydaci ICE, kodeki multimedialne (w przypadku kanału audio/wideo) itd. Jeden z par wysyła ofertę SDP („ofertę”), a drugi odpowiada odpowiedzią SDP („odpowiedzią”). Po tym tworzy się kanał.
Aby stworzyć takie połączenie, pary muszą zebrać informacje uzyskane od serwerów STUN i TURN i wymienić się nimi.
Problem polega na tym, że wciąż nie mają możliwości wymiany danych bezpośrednio, dlatego musi istnieć zewnętrzny mechanizm do wymiany tych danych: serwer sygnalizacyjny.
Serwer sygnalizacyjny może być bardzo prosty, ponieważ jego jedynym zadaniem jest przekazywanie danych między parami na etapie „uściski dłoni” (jak pokazano na schemacie poniżej).

Uproszczony schemat sekwencji „uściski dłoni” WebRTC
Przegląd modelu sieciowego Teeworlds
Architektura sieciowa Teeworlds jest bardzo prosta:
- Składniki klienta i serwera to dwa różne programy.
- Klienci wchodzą w grę, łącząc się z jednym z kilku serwerów, z których każdy jednocześnie hostuje tylko jedną grę.
- Cała wymiana danych w grze odbywa się przez serwer.
- Specjalny serwer masterowy służy do zbierania listy wszystkich publicznych serwerów, które są wyświetlane w kliencie gry.
Dzięki wykorzystaniu WebRTC do wymiany danych możemy przenieść komponent serwera gry do przeglądarki, w której jest klient. To daje nam doskonałą możliwość…
Rezygnacja z serwerów
Brak logiki serwerowej ma przyjemną zaletę: możemy wdrożyć całą aplikację jako statyczną zawartość na Github Pages lub na własnym sprzęcie za Cloudflare, zapewniając sobie w ten sposób darmowe szybkie ładowanie i wysoki czas pracy. W zasadzie można o nich zapomnieć, a jeśli będziemy mieli szczęście i gra stanie się popularna, nie będzie potrzeby modernizacji infrastruktury.
Jednak aby system działał, będziemy musieli korzystać z zewnętrznej architektury:
- Jeden lub kilka serwerów STUN: mamy do wyboru kilka darmowych opcji.
- Co najmniej jeden serwer TURN: tutaj nie ma darmowych opcji, więc możemy albo skonfigurować własny, albo płacić za usługę. Na szczęście, przez większość czasu połączenie można nawiązać przez serwery STUN (zapewniając prawdziwy p2p), ale TURN jest potrzebny jako opcja zapasowa.
- Serwer sygnalizacyjny: w przeciwieństwie do dwóch innych aspektów, sygnalizacja nie jest standaryzowana. To, za co właściwie będzie odpowiedzialny serwer sygnalizacyjny, częściowo zależy od aplikacji. W naszym przypadku przed nawiązaniem połączenia konieczna jest wymiana niewielkiej ilości danych.
- Serwer masterowy Teeworlds: jest używany przez inne serwery do powiadamiania o swojej obecności i przez klientów do wyszukiwania publicznych serwerów. Chociaż nie jest obowiązkowy (klienci zawsze mogą ręcznie połączyć się z znanym im serwerem), byłoby dobrze go mieć, aby gracze mogli brać udział w grach z przypadkowymi osobami.
Postanowiliśmy skorzystać z darmowych serwerów STUN firmy Google, a jeden serwer TURN wdrożyliśmy samodzielnie.
Do dwóch ostatnich punktów wykorzystaliśmy :
- Serwer główny Teeworlds jest bardzo prosty w realizacji: to lista obiektów zawierających informacje (nazwa, IP, mapa, tryb, ...) każdego aktywnego serwera. Serwery publikują i aktualizują swoje własne obiekty, a klienci pobierają całą listę i wyświetlają ją graczowi. Dodatkowo wyświetlamy listę na stronie głównej jako HTML, aby gracze mogli po prostu kliknąć na serwer i bezpośrednio wejść do gry.
- Sygnalizacja jest ściśle związana z naszą implementacją gniazd, opisaną w następnym rozdziale.

Lista serwerów w grze i na stronie głównej
Implementacja gniazd
Chcemy stworzyć API jak najbliższe do gniazd Posix UDP, aby zminimalizować ilość wymaganych zmian.
Chcemy również zaimplementować minimalny zestaw funkcji wymaganych do najprostszej wymiany danych przez sieć.
Na przykład, nie potrzebujemy prawdziwego routingu: wszystkie węzły znajdują się w jednej "wirtualnej LAN", połączonej z konkretnym egzemplarzem bazy danych Firebase.
W związku z tym nie potrzebujemy unikalnych adresów IP: do unikalnej identyfikacji węzłów wystarczą unikalne wartości kluczy Firebase (analogicznie do nazw domen), a każdy węzeł lokalnie przypisuje "fałszywe" adresy IP do każdego klucza, który wymaga konwersji. Całkowicie eliminuje to potrzebę globalnego przypisywania adresów IP, co jest zadaniem niełatwym.
Oto minimalne API, które musimy zaimplementować:
// 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 jest proste i podobne do API gniazd Posix, ale ma kilka istotnych różnic: rejestracja wywołań zwrotnych, przypisanie lokalnych adresów IP i „leniwe” łączenie.
Rejestracja wywołań zwrotnych
Nawet jeśli oryginalny program używa asynchronicznego wejścia-wyjścia, aby uruchomić go w przeglądarce, kod musi zostać zrefaktoryzowany.
Powodem tego jest to, że pętla zdarzeń w przeglądarce jest ukryta przed programem (czy to JavaScript, czy WebAssembly).
W środowisku natywnym możemy pisać kod w ten sposób
while(running) {
select(...); // czekaj na zdarzenia I/O
while(true) {
int r = readfrom(...); // próbuj odczytać
if (r < 0 && errno == EWOULDBLOCK) // nie ma więcej dostępnych danych
break;
...
}
...
}Jeśli pętla zdarzeń jest dla nas ukryta, to musimy przekształcić ją w coś podobnego:
auto cb = []() { // to zostanie wywołane, gdy nowe dane będą dostępne
while(true) {
int r = readfrom(...); // próbuj odczytać
if (r < 0 && errno == EWOULDBLOCK) // nie ma więcej dostępnych danych
break;
...
}
...
};
recvCallback(cb); // zarejestruj wywołanie zwrotnePrzypisanie lokalnych adresów IP
Identyfikatory węzłów w naszej "sieci" nie są adresami IP, lecz kluczami Firebase (są to ciągi, które wyglądają tak: -LmEC50PYZLCiCP-vqde ).
Jest to wygodne, ponieważ nie potrzebujemy mechanizmu do przydzielania adresów IP i sprawdzania ich unikalności (ani ich utylizacji po dezaktywacji klienta), ale często konieczne jest identyfikowanie peerów za pomocą wartości numerycznej.
Właśnie dlatego używa się funkcji resolve i reverseResolve: aplikacja w jakiś sposób uzyskuje ciągowy klucz (poprzez wprowadzenie przez użytkownika lub za pomocą serwera głównego) i może zamienić go na adres IP do użytku wewnętrznego. Pozostała część API również dla uproszczenia otrzymuje zamiast tego wartość jako ciąg.
Przypomina to zapytanie DNS, ale odbywa się lokalnie u klienta.
Oznacza to, że adresy IP nie mogą być współdzielone przez różnych klientów, a jeśli potrzebny jest jakiś globalny identyfikator, trzeba go wygenerować w inny sposób.
Leniwe połączenie
UDP nie wymaga połączenia, ale, jak widzieliśmy, przed rozpoczęciem przesyłania danych między dwoma peerami, WebRTC wymaga długiego procesu nawiązywania połączenia.
Jeśli chcemy zapewnić ten sam poziom abstrakcji, (sendto/recvfrom z dowolnymi peerami bez wcześniejszego połączenia), musimy wykonać „leniwe” (odłożone) połączenie wewnątrz API.
Oto, co się dzieje podczas zwykłej wymiany danych między „serwerem” a „klientem” w przypadku użycia UDP i co musi zrobić nasza biblioteka:
- Serwer wywołuje
bind(), aby poinformować system operacyjny, że chce otrzymywać pakiety na określonym porcie.
Zamiast tego opublikujemy otwarty port w Firebase pod kluczem serwera i będziemy nasłuchiwać zdarzeń w jego poddrzewie.
- Serwer wywołuje
recvfrom(), odbierając na ten port pakiety przychodzące z dowolnego hosta.
W naszym przypadku należy sprawdzać kolejkę przychodzących pakietów wysyłanych na ten port.
Każdy port ma swoją własną kolejkę, a my dodajemy na początku datagramów WebRTC porty źródłowy i docelowy, aby wiedzieć, do której kolejki przekierować nowy pakiet przy jego przybyciu.
Wywołanie jest nieblokujące, dlatego jeśli nie ma pakietów, po prostu zwracamy -1 i ustawiamy errno=EWOULDBLOCK.
- Klient uzyskuje w jakiś sposób IP i port serwera z zewnętrznych źródeł i wywołuje
sendto(). Równocześnie wykonywane jest wewnętrzne wywołaniebind(), dlatego późniejszyrecvfrom()otrzyma odpowiedź bez jawnego wykonywania bind.
W naszym przypadku klient otrzymuje zewnętrzny klucz tekstowy i korzysta z funkcji resolve() w celu uzyskania adresu IP.
Na tym etapie rozpoczynamy „uścisk dłoni” WebRTC, jeśli dwa peer-y jeszcze się ze sobą nie połączyły. Połączenia na różnych portach jednego peer-a korzystają z tego samego DataChannel WebRTC.
Wykonujemy również pośredni bind(), aby serwer mógł przywrócić połączenie w następnym sendto() na wypadek, gdyby z jakiegoś powodu się ono zamknęło.
Serwer jest powiadamiany o połączeniu klienta, gdy klient zapisuje swoją ofertę SDP pod informacją o porcie serwera w Firebase, a serwer tam również odpowiada swoją odpowiedzią.
Na poniższym schemacie pokazano przykład ruchu wiadomości dla schematu socketów i przesyłania od klienta do serwera pierwszej wiadomości:

Pełny schemat etapu połączenia między klientem a serwerem.
Podsumowanie
Jeśli dotarłeś do końca, zapewne jesteś ciekaw teorii w działaniu. Możesz zagrać w grę na , spróbuj!
Przyjacielski mecz między kolegami.
Kod biblioteki sieciowej jest swobodnie dostępny na . Dołącz do rozmowy na naszym kanale w !
Źródło: habr.com
