Einführung
Unser Unternehmen bietet Lösungen für die Portierung traditioneller Desktop-Anwendungen ins Web an. Unser C++ Compiler generiert eine Kombination aus WebAssembly und JavaScript, was sowohl , als auch eine hohe Leistung gewährleistet.
Als Beispiel für seine Anwendung haben wir uns entschieden, ein Multiplayer-Spiel für das Web zu portieren und wählten dafür . Teeworlds ist ein Multiplayer-2D-Retro-Spiel mit einer kleinen, aber aktiven Community von Spielern (darunter auch ich!). Es ist sowohl in Bezug auf die herunterladbaren Ressourcen als auch auf die Anforderungen an CPU und GPU gering — der ideale Kandidat.

Teeworlds, das im Browser läuft
Wir haben uns entschieden, dieses Projekt zu nutzen, um mit allgemeinen Lösungen zur Portierung von Netzwerkkode ins Webexperimentieren. Normalerweise geschieht dies auf folgende Weise:
- XMLHttpRequest/fetch, wenn der Netzwerkteil nur aus HTTP-Anfragen besteht, oder
- WebSockets.
Beide Lösungen erfordern, dass die serverseitige Komponente auf der Serverseite gehostet wird, und keine von ihnen ermöglicht die Nutzung als Transportprotokoll. Das ist wichtig für Echtzeitanwendungen, wie Videokonferenzsoftware und Spiele, da die Garantien für die Lieferung und die Reihenfolge der Pakete des Protokolls niedrige Latenzen stören können.
Es gibt auch einen dritten Weg — das Nutzen des Netzwerks aus dem Browser: .
unterstützt sowohl die zuverlässige als auch die unreliable Übertragung (im letzteren Fall versucht es, wenn möglich, UDP als Transportprotokoll zu verwenden) und kann sowohl mit einem entfernten Server als auch zwischen Browsern verwendet werden. Das bedeutet, dass wir die gesamte Anwendung, einschließlich der serverseitigen Komponente, in den Browser portieren können!
Allerdings gibt es dabei eine zusätzliche Schwierigkeit: Bevor zwei WebRTC-Peers Daten austauschen können, müssen sie ein relativ komplexes Verfahren zum «Handshake» zur Verbindung durchlaufen, für das mehrere externe Entitäten erforderlich sind (Signalisierungsserver und einer oder mehrere /).
Idealerweise möchten wir eine Netzwerk-API schaffen, die intern WebRTC verwendet, aber so nah wie möglich am Interface von UDP-Sockets liegt, das keine Verbindung aufbauen muss.
Das würde es uns ermöglichen, die Vorteile von WebRTC zu nutzen, ohne komplizierte Details des Anwendungscodes offenlegen zu müssen (den wir in unserem Projekt so wenig wie möglich ändern wollten).
Minimales WebRTC
WebRTC ist eine in Browsern vorhandene API-Sammlung, die eine peer-to-peer Übertragung von Audio, Video und beliebigen Daten ermöglicht.
Die Verbindung zwischen Peers wird (auch bei NAT auf einer oder beiden Seiten) mit Hilfe von STUN- und/oder TURN-Servern über einen Mechanismus namens ICE hergestellt. Peers tauschen ICE-Informationen und Kanalparameter über das Angebot und die Antwort des SDP-Protokolls aus.
Wow! So viele Abkürzungen auf einmal. Lassen Sie uns kurz erklären, was diese Begriffe bedeuten:
- — ein Protokoll zur Umgehung von NAT und zur Ermittlung eines Paares (IP, Port) für den direkten Datenaustausch mit dem Host. Wenn es ihm gelingt, seine Aufgabe zu erfüllen, können Peers Daten direkt untereinander austauschen.
- wird ebenfalls verwendet, um NAT zu umgehen, aber es implementiert dies, indem es Daten über einen Proxy weiterleitet, der für beide Peers sichtbar ist. Es fügt Verzögerungen hinzu und ist in der Ausführung teurer als STUN (da es während der gesamten Kommunikationssitzung verwendet wird), aber manchmal ist es die einzige mögliche Option.
- wird verwendet, um die bestmögliche Methode zur Verbindung von zwei Peers basierend auf Informationen auszuwählen, die während der direkten Verbindung der Peers sowie von einer beliebigen Anzahl von STUN- und TURN-Servern gewonnen wurden.
- ist ein Format zur Beschreibung von Verbindungsparametern, wie z.B. ICE-Kandidaten, Multimedia-Codecs (im Fall von Audio-/Videokanälen) usw. Einer der Peers sendet ein SDP-Angebot („Angebot“), und der andere antwortet mit einer SDP-Antwort („Antwort“). Danach wird ein Kanal hergestellt.
Um eine solche Verbindung herzustellen, müssen Peers die Informationen, die sie von den STUN- und TURN-Servern erhalten haben, sammeln und untereinander austauschen.
Das Problem ist, dass es ihnen derzeit nicht möglich ist, Daten direkt auszutauschen, daher muss es einen Offline-Mechanismus für den Austausch dieser Daten geben: einen Signalisierungsserver.
Ein Signalisierungsserver kann sehr einfach sein, da seine einzige Aufgabe darin besteht, Daten zwischen den Peers während der „Handshake“-Phase weiterzuleiten (wie in dem untenstehenden Diagramm dargestellt).

Vereinfachtes Diagramm des Handshake-Prozesses von WebRTC
Überblick über das Netzwerkmodell von Teeworlds
Die Netzwerkarchitektur von Teeworlds ist sehr einfach:
- Die Komponenten von Client und Server sind zwei unterschiedliche Programme.
- Kunden treten in das Spiel ein, indem sie sich mit einem der mehreren Server verbinden, wobei jeder Server jeweils nur ein Spiel hostet.
- Die gesamte Datenübertragung im Spiel erfolgt über den Server.
- Ein spezieller Master-Server wird verwendet, um eine Liste aller öffentlichen Server zu sammeln, die im Spiel-Client angezeigt werden.
Durch die Nutzung von WebRTC für den Datenaustausch können wir die serverseitige Komponente des Spiels in den Browser verlagern, wo sich der Client befindet. Das eröffnet uns großartige Möglichkeiten…
Server loswerden
Das Fehlen von Serverlogik hat den angenehmen Vorteil, dass wir die gesamte Anwendung als statischen Inhalt auf Github Pages oder auf unserer eigenen Hardware über Cloudflare bereitstellen können, wodurch wir uns schnelle Ladezeiten und hohe Verfügbarkeit kostenlos sichern. Im Grunde können wir sie vergessen, und wenn wir Glück haben und das Spiel populär wird, wird es nicht nötig sein, die Infrastruktur aufzurüsten.
Damit das System jedoch funktioniert, müssen wir dennoch eine externe Architektur nutzen:
- Einen oder mehrere STUN-Server: Wir haben die Wahl zwischen mehreren kostenlosen Optionen.
- Mindestens einen TURN-Server: Hier gibt es keine kostenlosen Optionen, weshalb wir entweder unseren eigenen einrichten oder für einen Service bezahlen müssen. Glücklicherweise kann die Verbindung die meiste Zeit über STUN-Server hergestellt werden (und echtes P2P ermöglichen), aber TURN ist als Backup erforderlich.
- Signalisierungsserver: Im Gegensatz zu den beiden anderen Aspekten ist die Signalisierung nicht standardisiert. Wofür der Signalisierungsserver tatsächlich verantwortlich ist, hängt teilweise von der Anwendung ab. In unserem Fall muss vor der Verbindung ein kleiner Datenvolumen Austausch stattfinden.
- Teeworlds Master-Server: Er wird von anderen Servern genutzt, um auf seine Existenz aufmerksam zu machen und von Clients zur Suche nach öffentlichen Servern. Auch wenn er nicht zwingend erforderlich ist (Clients können sich immer manuell mit einem bekannten Server verbinden), wäre es vorteilhaft, ihn zu haben, damit Spieler an Spielen mit zufälligen Personen teilnehmen können.
Wir haben uns entschieden, die kostenlosen STUN-Server von Google zu nutzen und einen TURN-Server selbst zu betreiben.
Für die letzten beiden Punkte haben wir verwendet :
- Der Master-Server von Teeworlds ist sehr einfach implementiert: als Liste von Objekten, die Informationen (Name, IP, Karte, Modus, …) zu jedem aktiven Server enthalten. Die Server veröffentlichen und aktualisieren ihr eigenes Objekt, während die Clients die gesamte Liste abrufen und sie dem Spieler anzeigen. Außerdem zeigen wir die Liste auf der Startseite als HTML an, damit die Spieler einfach auf den Server klicken und direkt ins Spiel gelangen können.
- Die Signalgebung ist eng mit unserer Implementierung der Sockets verbunden, die im folgenden Abschnitt beschrieben wird.

Liste der Server im Spiel und auf der Startseite
Implementierung der Sockets
Wir möchten eine API erstellen, die so nah wie möglich an Posix UDP Sockets herankommt, um die Anzahl der erforderlichen Änderungen zu minimieren.
Außerdem möchten wir das notwendige Minimum implementieren, das für den einfachsten Datenaustausch über das Netzwerk erforderlich ist.
Zum Beispiel benötigen wir keine echte Routing-Funktion: Alle Peers befinden sich in einem "virtuellen LAN", das mit einer bestimmten Instanz der Firebase-Datenbank verbunden ist.
Folglich benötigen wir keine eindeutigen IP-Adressen: Zur eindeutigen Identifizierung der Peers genügt es, eindeutige Werte der Firebase-Schlüssel (ähnlich wie bei Domainnamen) zu verwenden, und jeder Peer weist lokal jedem Schlüssel, der umgewandelt werden muss, "falsche" IP-Adressen zu. Das befreit uns vollständig von der Notwendigkeit einer globalen IP-Adresszuweisung, was eine nicht triviale Aufgabe ist.
Hier ist die minimale API, die wir umsetzen müssen:
// 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);Die API ist einfach und ähnelt der API von Posix Sockets, hat jedoch einige wichtige Unterschiede: Callback-Registrierung, Zuordnung lokaler IPs und "lazy" Verbindung.
Callback-Registrierung
Selbst wenn das ursprüngliche Programm nicht blockierendes I/O verwendet, muss der Code zum Ausführen im Webbrowser umstrukturiert werden.
Der Grund dafür ist, dass die Ereignisschleife im Browser vor dem Programm verborgen ist (egal ob JavaScript oder WebAssembly).
In einer nativen Umgebung können wir den Code so schreiben
while(running) {
select(...); // auf I/O-Ereignisse warten
while(true) {
int r = readfrom(...); // versuchen zu lesen
if (r < 0 && errno == EWOULDBLOCK) // keine weiteren Daten verfügbar
break;
...
}
...
}Wenn die Ereignisschleife für uns verborgen ist, muss sie in etwas Ähnliches umgewandelt werden:
auto cb = []() { // dies wird aufgerufen, wenn neue Daten verfügbar sind
while(true) {
int r = readfrom(...); // versuchen zu lesen
if (r < 0 && errno == EWOULDBLOCK) // keine weiteren Daten verfügbar
break;
...
}
...
};
recvCallback(cb); // Callback registrierenZuordnung lokaler IPs
Die Identifikatoren der Knoten in unserem „Netz“ sind keine IP-Adressen, sondern Firebase-Schlüssel (das sind Zeichenfolgen, die so aussehen: -LmEC50PYZLCiCP-vqde ).
Das ist praktisch, da wir keinen Mechanismus zur Zuweisung von IPs und zur Überprüfung ihrer Einzigartigkeit benötigen (sowie deren Entsorgung nach der Abmeldung eines Clients), es jedoch oft notwendig ist, Peers durch numerische Werte zu identifizieren.
Genau dazu werden die Funktionen resolve und reverseResolve: Die Anwendung erhält auf irgendeine Weise den Schlüsselwert als Zeichenfolge (durch Benutzereingabe oder über einen Master-Server) und kann ihn in eine IP-Adresse für interne Zwecke umwandeln. Der Rest der API erhält auch aus Gründen der Vereinfachung anstelle der Zeichenfolge diesen Wert.
Das ähnelt einer DNS-Abfrage, wird jedoch lokal beim Client durchgeführt.
Das bedeutet, dass IP-Adressen nicht für verschiedene Clients gemeinsam sein können, und wenn ein globaler Identifikator benötigt wird, muss dieser auf andere Weise generiert werden.
Laid-Back-Verbindung
UDP benötigt keine Verbindung, jedoch erfordert WebRTC, wie wir gesehen haben, einen langen Verbindungsprozess, bevor Daten zwischen zwei Peers übertragen werden können.
Wenn wir das gleiche Abstraktionsniveau gewährleisten wollen, (sendto/recvfrom mit beliebigen Peers ohne vorherige Verbindung), müssen wir eine „laissez-faire“ (verzögerte) Verbindung innerhalb der API herstellen.
So funktioniert der normale Datenaustausch zwischen einem „Server“ und einem „Client“ im Fall von UDP, und das ist, was unsere Bibliothek tun sollte:
- Der Server ruft
bind(), um dem Betriebssystem mitzuteilen, dass er Pakete an den angegebenen Port empfangen möchte.
Stattdessen veröffentlichen wir einen offenen Port in Firebase unter dem Schlüssel des Servers und hören Ereignisse in seinem Unterbaum.
- Der Server ruft
recvfrom(), um Pakete an diesem Port von jedem Host zu empfangen.
In unserem Fall müssen wir die eingehende Warteschlange der Pakete überprüfen, die an diesen Port gesendet werden.
Jeder Port hat seine eigene Warteschlange, und wir fügen den WebRTC-Datagrammen am Anfang die Quell- und Zielports hinzu, um zu wissen, in welche Warteschlange ein neues Paket weitergeleitet werden soll, wenn es eintrifft.
Der Aufruf ist nicht blockierend, daher geben wir, wenn keine Pakete vorhanden sind, einfach -1 zurück und setzen errno=EWOULDBLOCK.
- Der Client erhält durch externe Mittel die IP und den Port des Servers und ruft
sendto(). Gleichzeitig erfolgt ein interner Aufrufbind(), sodass der folgenderecvfrom()eine Antwort erhält, ohne explizit bind auszuführen.
In unserem Fall erhält der Kunde extern einen String-Schlüssel und verwendet die Funktion resolve() um die IP-Adresse zu erhalten.
An diesem Punkt beginnen wir das „Handschlag“ von WebRTC, falls zwei Peers noch nicht miteinander verbunden sind. Verbindungen zu verschiedenen Ports eines Peers verwenden denselben WebRTC DataChannel.
Außerdem führen wir eine indirekte bind()aus, damit der Server die Verbindung im nächsten sendto() Weltfall wiederherstellen kann, falls sie aus irgendeinem Grund geschlossen wurde.
Der Server wird über die Verbindung des Clients informiert, wenn der Client sein SDP-Angebot unter der Portinformation des Servers in Firebase speichert, und der Server dort mit seiner Rückmeldung antwortet.
Das folgende Diagramm zeigt ein Beispiel für den Nachrichtenaustausch im Socket-Schema und die Übertragung der ersten Nachricht vom Client an den Server:

Das vollständige Schema des Verbindungsprozesses zwischen Client und Server
Fazit
Wenn Sie bis hierher gelesen haben, sind Sie wahrscheinlich daran interessiert, die Theorie in der Praxis zu sehen. Sie können das Spiel auf , versuchen Sie es!
Ein freundliches Match zwischen Kollegen
Der Code der Netzwerkbibliothek ist frei verfügbar unter . Treten Sie unserem Kanal im !
Quelle: habr.com
