Introduzione
La nostra azienda fornisce soluzioni per il porting delle tradizionali applicazioni desktop nel web. Il nostro compilatore C++ genera una combinazione di WebAssembly e JavaScript, garantendo sia , sia alte prestazioni.
Come esempio del suo utilizzo, abbiamo deciso di portare sul web un gioco multiplayer e abbiamo scelto . Teeworlds è un gioco retro 2D multiplayer con una comunità di giocatori piccola ma attiva (incluso me!). È leggero sia in termini di risorse scaricate che di requisiti per CPU e GPU: il candidato ideale.

Teeworlds in esecuzione nel browser
Abbiamo deciso di utilizzare questo progetto per sperimentare con soluzioni generali per il porting del codice di rete per il web. Di solito questo viene realizzato nei seguenti modi:
- XMLHttpRequest/fetch, se la parte di rete consiste solo in richieste HTTP, oppure
- WebSockets.
Entrambe le soluzioni richiedono di ospitare il componente server sul lato server, e nessuna di esse consente di utilizzare come protocollo di trasporto . Questo è fondamentale per le applicazioni in tempo reale, come software per videoconferenze e giochi, poiché le garanzie di consegna e l'ordine dei pacchetti possono interferire con la bassa latenza. possono ostacolare le basse latenze.
Esiste anche un terzo modo: utilizzare una rete tra browser: .
supporta sia la trasmissione affidabile che quella non affidabile (in quest'ultimo caso cerca di utilizzare UDP come protocollo di trasporto quando possibile) e può essere utilizzato sia con un server remoto che tra browser. Questo significa che possiamo portare l'intera applicazione nel browser, incluso il componente server!
Tuttavia, ciò comporta una difficoltà aggiuntiva: prima che due peer WebRTC possano scambiarsi dati, devono eseguire una procedura di "handshake" relativamente complessa per connettersi, che richiede diverse entità esterne (server di segnalazione e uno o più server /).
In ideale, vorremmo creare un'API di rete che utilizzi WebRTC internamente, ma che sia il più vicino possibile all'interfaccia di UDP Sockets, la quale non richiede l'instaurazione di una connessione.
Questo ci permetterà di sfruttare i vantaggi di WebRTC senza la necessità di rivelare dettagli complessi del codice dell'applicazione (che nel nostro progetto volevamo modificare il meno possibile).
WebRTC minimale
WebRTC è un insieme di API disponibili nei browser che consente la trasmissione di audio, video e dati arbitrari in peer-to-peer.
La connessione tra i peer è stabilita (anche in presenza di NAT da una o entrambe le parti) tramite server STUN e/o TURN attraverso un meccanismo chiamato ICE. I peer scambiano informazioni ICE e parametri dei canali tramite l'offerta e la risposta del protocollo SDP.
Wow! Quante abbreviazioni in una sola volta. Spieghiamo brevemente cosa significano questi concetti:
- — un protocollo per il bypass del NAT e l'ottenimento di una coppia (IP, porta) per scambiare dati direttamente con l'host. Se riesce nel suo compito, i peer possono scambiarsi dati tra di loro in autonomia.
- viene utilizzato anche per bypassare il NAT, ma lo fa inoltrando i dati tramite un proxy visibile a entrambi i peer. Aggiunge un ritardo ed è più costoso in termini di esecuzione rispetto a STUN (perché viene applicato per tutta la durata della sessione), ma a volte è l'unica opzione possibile.
- viene utilizzato per selezionare il miglior metodo possibile per connettere due peer sulla base delle informazioni ottenute durante la connessione diretta tra i peer, oltre alle informazioni ricevute da un numero qualunque di server STUN e TURN.
- è un formato per descrivere i parametri del canale di connessione, ad esempio i candidati ICE, i codec multimediali (nel caso di canali audio/video), e così via... Uno dei peer invia un SDP Offer ("offerta") e l'altro risponde con un SDP Answer ("risposta"). Dopo questo, viene creato un canale.
Per stabilire tale connessione, i peer devono raccogliere le informazioni ricevute dai server STUN e TURN e scambiarsi queste informazioni tra di loro.
Il problema è che al momento non hanno la possibilità di scambiarsi dati direttamente, quindi deve esistere un meccanismo out-of-band per scambiare questi dati: il server di segnalazione.
Un server di segnalazione può essere molto semplice, poiché il suo unico compito è quello di reindirizzare i dati tra i peer durante la fase di «handshake» (come mostrato nello schema qui sotto).

Schema semplificato della sequenza di «handshake» WebRTC
Panoramica del modello di rete di Teeworlds
L'architettura di rete di Teeworlds è molto semplice:
- I componenti client e server sono due programmi distinti.
- I client partecipano al gioco connettendosi a uno dei diversi server, ciascuno dei quali ospita solo una partita alla volta.
- Tutta la trasmissione dati nel gioco avviene tramite il server.
- Un master server speciale è utilizzato per raccogliere l'elenco di tutti i server pubblici, che vengono visualizzati nel client di gioco.
Grazie all'uso di WebRTC per lo scambio di dati, possiamo trasferire il componente server del gioco nel browser, dove si trova il client. Questo ci offre un'opportunità fantastica...
Eliminare i server.
L'assenza di logica server ha un vantaggio notevole: possiamo distribuire l'intera applicazione come contenuto statico su Github Pages o su hardware dedicato dietro Cloudflare, garantendo così caricamenti rapidi e alta disponibilità senza costi. In effetti, potremmo dimenticarcene, e se siamo fortunati e il gioco diventa popolare, non dovremo aggiornare l'infrastruttura.
Tuttavia, affinché il sistema funzioni, dovremo comunque utilizzare un'architettura esterna:
- Uno o più server STUN: abbiamo diverse opzioni gratuite tra cui scegliere.
- Almeno un server TURN: qui non ci sono opzioni gratuite, quindi possiamo o configurare il nostro server, o pagare un servizio. Fortunatamente, la maggior parte delle volte la connessione può essere stabilita tramite i server STUN (garantendo un vero p2p), ma il TURN è necessario come opzione di riserva.
- Server di segnalazione: a differenza degli altri due aspetti, la segnalazione non è standardizzata. Ciò di cui si occupa effettivamente il server di segnalazione dipende in parte dall'applicazione. Nel nostro caso, è necessario scambiare un piccolo volume di dati prima di stabilire la connessione.
- Server master di Teeworlds: esso viene utilizzato da altri server per notificare la propria esistenza e dai client per cercare server pubblici. Anche se non è obbligatorio (i client possono sempre connettersi manualmente ai server di cui sono a conoscenza), sarebbe utile averlo affinché i giocatori possano partecipare a giochi con persone casuali.
Abbiamo deciso di utilizzare i server STUN gratuiti di Google, mentre un server TURN è stato distribuito autonomamente.
Per gli ultimi due punti abbiamo utilizzato :
- Il master server di Teeworlds è implementato in modo molto semplice: come un elenco di oggetti che contengono informazioni (nome, IP, mappa, modalità, …) di ciascun server attivo. I server pubblicano e aggiornano il proprio oggetto, mentre i client prendono l'intero elenco e lo visualizzano per il giocatore. Mostriamo anche l'elenco nella homepage come HTML, consentendo ai giocatori di cliccare direttamente sul server e accedere al gioco.
- La segnalazione è strettamente legata alla nostra implementazione dei socket, descritta nel prossimo capitolo.

Elenco dei server all'interno del gioco e sulla homepage
Implementazione dei socket
Vogliamo creare un'API il più vicina possibile ai socket UDP Posix, per ridurre al minimo le modifiche necessarie.
Vogliamo anche implementare il minimo necessario richiesto per il più semplice scambio di dati in rete.
Ad esempio, non abbiamo bisogno di vera routing: tutti i peer si trovano all'interno di una 'LAN virtuale' collegata a una specifica istanza del database Firebase.
Pertanto, non abbiamo bisogno di indirizzi IP unici: per la identificazione unica dei peer è sufficiente utilizzare valori unici delle chiavi Firebase (simile ai nomi di dominio), e ogni peer assegna localmente degli indirizzi IP "falsi" a ciascuna chiave da convertire. Questo ci libera completamente dalla necessità di una assegnazione globale di indirizzi IP, che è un compito non banale.
Ecco l'API minima che dobbiamo implementare:
// 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);L'API è semplice e simile all'API dei Posix Sockets, ma presenta alcune differenze importanti: registrazione dei callback, assegnazione di IP locali e connessione "pigra".
Registrazione dei callback
Anche se il programma originale utilizza input/output non bloccante, il codice deve essere rifattorizzato per l'esecuzione nel browser.
La ragione è che il ciclo degli eventi nel browser è nascosto al programma (che sia JavaScript o WebAssembly).
Nell'ambiente nativo, possiamo scrivere il codice in questo modo
while(running) {
select(...); // aspetta eventi I/O
while(true) {
int r = readfrom(...); // prova a leggere
if (r < 0 && errno == EWOULDBLOCK) // non ci sono più dati disponibili
break;
...
}
...
}Se il ciclo degli eventi è nascosto per noi, dobbiamo trasformarlo in qualcosa di simile:
auto cb = []() { // questo sarà chiamato quando sono disponibili nuovi dati
while(true) {
int r = readfrom(...); // prova a leggere
if (r < 0 && errno == EWOULDBLOCK) // non ci sono più dati disponibili
break;
...
}
...
};
recvCallback(cb); // registra il callbackAssegnazione degli IP locali
Gli identificatori dei nodi nella nostra "rete" non sono indirizzi IP, ma chiavi Firebase (sono stringhe che appaiono così: -LmEC50PYZLCiCP-vqde ).
Questo è comodo, perché non abbiamo bisogno di un meccanismo per assegnare IP e controllarne l'unicità (così come per la loro dismissione dopo la disconnessione del cliente), ma spesso è necessario identificare i peer tramite un valore numerico.
È proprio per questo che vengono utilizzate le funzioni resolve e reverseResolve: l'applicazione ottiene in qualche modo un valore stringa della chiave (attraverso l'immissione dell'utente o tramite un server principale) e può convertirlo in un indirizzo IP per uso interno. Il resto dell'API riceve anche per semplicità questo valore al posto della stringa.
È simile a una ricerca DNS, solo che viene eseguita localmente sul client.
Cioè, gli indirizzi IP non possono essere condivisi tra diversi clienti, e se è necessario un identificatore globale, allora deve essere generato in un altro modo.
Connessione pigra
UDP non richiede una connessione, ma, come abbiamo visto, prima di iniziare a trasferire dati tra due peer, WebRTC richiede un lungo processo di connessione.
Se vogliamo garantire lo stesso livello di astrazione, (sendto/recvfrom con peer casuali senza una connessione preliminare), dobbiamo eseguire una connessione «pigra» (ritardata) all'interno dell'API.
Ecco cosa accade durante uno scambio di dati normale tra un «server» e un «client» quando si utilizza UDP, e cosa deve fare la nostra libreria:
- Il server chiama
bind(), per informare il sistema operativo che desidera ricevere pacchetti sulla porta specificata.
Invece, pubblicheremo una porta aperta in Firebase con la chiave del server e ascolteremo gli eventi nel suo sottoalbero.
- Il server chiama
recvfrom(), accettando in questa porta pacchetti provenienti da qualsiasi host.
Nel nostro caso, dobbiamo controllare la coda dei pacchetti in arrivo inviati a questa porta.
Ogni porta ha la sua coda, e noi aggiungiamo in testa ai datagrammi WebRTC le porte sorgente e destinazione, per sapere in quale coda deviare il nuovo pacchetto al suo arrivo.
La chiamata è non bloccante, quindi se non ci sono pacchetti, restituiamo semplicemente -1 e poniamo errno=EWOULDBLOCK.
- Il client ottiene l'IP e la porta del server tramite mezzi esterni e invoca
sendto(). Viene inoltre effettuata una chiamata internabind(), quindi la successivarecvfrom()riceverà una risposta senza un'esplicita esecuzione di bind.
Nel nostro caso, il client ottiene esternamente una chiave in formato stringa e utilizza la funzione resolve() per ottenere l'indirizzo IP.
A questo punto, iniziamo il «handshake» WebRTC, se i due peer non sono già connessi tra loro. Le connessioni a porte diverse dello stesso peer utilizzano lo stesso DataChannel WebRTC.
Inoltre, eseguiamo un collegamento indiretto bind(), affinché il server possa ripristinare la connessione nel caso in cui questa si fosse chiusa per qualche motivo. sendto() Il server viene informato della connessione del client quando il client registra la propria offerta SDP sotto le informazioni sulla porta del server in Firebase e il server risponde con la propria risposta.
Nello schema mostrato di seguito è illustrato un esempio del flusso dei messaggi per lo schema dei socket e la trasmissione del primo messaggio dal client al server:
Schema completo della fase di connessione tra client e server

Se sei arrivato fino a qui, probabilmente sei curioso di vedere la teoria in azione. Puoi giocare su
Conclusione
teeworlds.leaningtech.com , prova!
Partita amichevole tra colleghi
Il codice della libreria di rete è liberamente disponibile su . Unisciti alla conversazione sul nostro canale in !
Fonte: habr.com
