Introduzione
La nostra azienda offre soluzioni per la portabilità delle applicazioni desktop tradizionali sul web. Il nostro compilatore C++ genera una combinazione di WebAssembly e JavaScript, che assicura sia , sia alte prestazioni.
Come esempio del suo utilizzo, abbiamo deciso di portare sul web un gioco multiplayer e abbiamo scelto per questo . Teeworlds è un gioco retro 2D multiplayer con una comunità di giocatori piccola ma attiva (tra cui io!). È leggero sia in termini di risorse scaricabili che di requisiti per la CPU e la GPU, rendendolo un candidato ideale.

Teeworlds funzionante nel browser
Abbiamo deciso di utilizzare questo progetto per sperimentare con soluzioni generali per la portabilità del codice di rete sul web. Di solito, ciò avviene nei seguenti modi:
- XMLHttpRequest/fetch, se la parte di rete consiste unicamente in richieste HTTP, oppure
- WebSockets.
Entrambe le soluzioni richiedono di ospitare un componente server sul lato server, e nessuna delle due consente di utilizzare come protocollo di trasporto . Questo è importante per le applicazioni in tempo reale, come il software per videoconferenze e i giochi, perché le garanzie di consegna e ordine dei pacchetti del protocollo possono interferire con le basse latenze.
Esiste anche una terza via: utilizzare la rete dal browser: .
supporta sia la trasmissione affidabile che quella non affidabile (in quest'ultimo caso tenta di utilizzare come protocollo di trasporto l'UDP), e può essere utilizzato sia con un server remoto che tra browser. Questo significa che possiamo portare nel browser l'intera applicazione, inclusi il componente server!
Tuttavia, ciò comporta una difficoltà aggiuntiva: prima che due peer WebRTC possano scambiarsi dati, devono eseguire una procedura relativamente complessa di "handshake" per connettersi, per la quale è necessario coinvolgere diverse entità esterne (server di segnalazione e uno o più server /).
In ideale, vorremmo creare un'API di rete che utilizzi WebRTC internamente, ma quanto più possibile simile all'interfaccia dei socket UDP, che non richiede l'instaurazione di una connessione.
Questo ci permetterà di sfruttare i vantaggi di WebRTC senza dover rivelare dettagli complessi nel codice dell'applicazione (che nel nostro progetto volevamo modificare il meno possibile).
WebRTC Minimo
WebRTC è un insieme di API disponibili nei browser che consente il trasferimento di audio, video e dati arbitrari peer-to-peer.
La connessione tra i peer viene stabilita (anche nel caso 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 volta. Spieghiamo brevemente cosa significano questi concetti:
- è un protocollo per bypassare il NAT e ottenere una coppia (IP, porta) per scambiare dati direttamente con l'host. Se riesce a fare il suo lavoro, i peer possono scambiarsi dati tra di loro.
- viene utilizzato anch'esso per bypassare il NAT, ma lo fa reindirizzando i dati attraverso un proxy visibile a entrambi i peer. Aggiunge latenza e richiede più risorse rispetto a STUN (perché viene applicato durante l'intera sessione di comunicazione), ma a volte è l'unica opzione possibile.
- viene utilizzato per scegliere il modo migliore per connettere i due peer sulla base delle informazioni ottenute durante la connessione diretta tra i peer e delle informazioni ricevute da un numero qualsiasi 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), ecc... Uno dei peer invia un SDP Offer («offerta»), e l'altro risponde con un SDP Answer («risposta»). Dopo di che viene creato il canale.
Per creare una tale connessione, i peer devono raccogliere le informazioni ricevute dai server STUN e TURN e scambiarsi queste informazioni.
Il problema è che al momento non hanno la possibilità di scambiarsi dati direttamente, quindi deve esistere un meccanismo di signaling esterno: il server di signaling.
Il server di signaling può essere molto semplice, perché il suo unico compito è reindirizzare i dati tra i peer nella fase di «handshake» (come mostrato nello schema sottostante).

Schema semplificato della sequenza di «handshake» WebRTC
Panoramica del modello di rete Teeworlds
L'architettura di rete di Teeworlds è molto semplice:
- I componenti client e server sono due programmi distinti.
- I clienti entrano nel gioco connettendosi a uno dei diversi server, ognuno dei quali ospita solo una partita alla volta.
- Tutta la trasmissione dei 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 spostare 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 piacevole vantaggio: possiamo distribuire l'intera applicazione come contenuto statico su Github Pages o su hardware nostro dietro Cloudflare, garantendo così caricamenti veloci e un elevato uptime gratuitamente. In sostanza, sarà possibile dimenticarsene e, se saremo fortunati e il gioco diventerà popolare, non dovremo modernizzare l'infrastruttura.
Tuttavia, affinché il sistema funzioni, dovremo comunque utilizzare un'architettura esterna:
- Uno o più server STUN: abbiamo a disposizione diverse opzioni gratuite.
- Almeno un server TURN: qui non ci sono opzioni gratuite, quindi possiamo impostare il nostro server o pagare un servizio. Fortunatamente, la maggior parte delle volte la connessione può essere stabilita tramite server STUN (e garantire un vero p2p), ma TURN è necessario come opzione di riserva.
- Server di segnalazione: a differenza degli altri due aspetti, la segnalazione non è standardizzata. Ciò di cui si occuperà effettivamente il server di segnalazione dipende in parte dall'applicazione. Nel nostro caso, prima di stabilire una connessione è necessario scambiare una piccola quantità di dati.
- Master server di Teeworlds: 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 a un server che conoscono), sarebbe utile averlo così i giocatori possono partecipare a partite con persone casuali.
Abbiamo deciso di utilizzare i server STUN gratuiti di Google, mentre un server TURN l'abbiamo installato noi stessi.
Per i due ultimi 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 mostrano al giocatore. Inoltre, mostriamo l'elenco sulla homepage come HTML, in modo che i giocatori possano semplicemente cliccare sul server e accedere subito al gioco.
- La segnalazione è strettamente correlata alla nostra implementazione dei socket, descritta nella sezione successiva.

Elenco dei server all'interno del gioco e sulla homepage
Implementazione dei socket
Vogliamo creare un'API il più simile possibile ai socket UDP Posix, per ridurre al minimo il numero di modifiche necessarie.
Vogliamo anche implementare il minimo indispensabile necessario per il più semplice scambio di dati in rete.
Ad esempio, non abbiamo bisogno di una vera e propria instradamento: tutti i peer sono su una 'LAN virtuale' collegata a un'istanza specifica del database Firebase.
Pertanto, non abbiamo bisogno di indirizzi IP unici: per l'identificazione univoca dei peer è sufficiente utilizzare valori di chiave Firebase univoci (simile ai nomi di dominio), e ogni peer assegna localmente indirizzi IP 'falsi' a ciascuna chiave che deve essere trasformata. Questo ci libera completamente dalla necessità di assegnare indirizzi IP globali, il 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 socket Posix, ma presenta alcune differenze importanti: registrazione dei callback, assegnazione di IP locali e connessione 'pigra'.
Registrazione dei callback
Anche se il programma originale utilizza I/O non bloccante, per l'esecuzione nel browser web il codice deve essere rifattorizzato.
La ragione è che il ciclo degli eventi nel browser è nascosto al programma (sia esso JavaScript o WebAssembly).
In 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, allora bisogna trasformarlo in qualcosa del genere:
auto cb = []() { // questo verrà 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 di IP locali
Gli identificatori dei nodi nella nostra «rete» non sono indirizzi IP, ma chiavi Firebase (queste sono stringhe che sembrano così: -LmEC50PYZLCiCP-vqde ).
È comodo perché non abbiamo bisogno di un meccanismo per assegnare IP e verificarne l'unicità (e anche il loro smaltimento dopo la disconnessione del client), ma spesso è necessario identificare i peer in base a un valore numerico.
Questo è esattamente ciò per cui vengono utilizzate le funzioni resolve e reverseResolve: l'applicazione in qualche modo ottiene il valore stringa della chiave (tramite l'input dell'utente o un server master), e può convertirlo in un indirizzo IP per utilizzo interno. Anche il resto dell'API per semplicità riceve invece questo valore al posto della stringa.
Questo è simile a una ricerca DNS, ma avviene localmente sul client.
Cioè, gli indirizzi IP non possono essere condivisi tra diversi client, e se è necessario un qualche identificatore globale, dovrà essere generato in un altro modo.
Connessione pigra
UDP non richiede una connessione, ma, come abbiamo visto, prima di iniziare a trasmettere dati tra due peer, WebRTC richiede un lungo processo di connessione.
Se vogliamo fornire lo stesso livello di astrazione, (sendto/recvfrom a peer qualsiasi senza una connessione preliminare), dobbiamo eseguire una connessione «pigra» (ritardata) all'interno dell'API.
Ecco cosa accade durante uno scambio normale di dati tra un «server» e un «client» nel caso di utilizzo di 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 sotto la chiave del server e ascolteremo gli eventi nel suo sottoalbero.
- Il server chiama
recvfrom(), accettando su questa porta i pacchetti in arrivo da qualsiasi host.
Nel nostro caso, è necessario controllare la coda in ingresso dei pacchetti inviati a questa porta.
Ogni porta ha la sua coda, e aggiungiamo all'inizio dei datagrammi WebRTC le porte sorgente e destinazione per sapere in quale coda reindirizzare quando arriva un nuovo pacchetto.
La chiamata è non bloccante, quindi se non ci sono pacchetti, restituiamo semplicemente -1 e impostiamo errno=EWOULDBLOCK.
- Il client ottiene tramite mezzi esterni l'IP e la porta del server e chiama
sendto(). Viene eseguita anche una chiamata interna, quindi il successivobind()riceverà una risposta senza necessità di eseguire esplicitamente il bind.recvfrom()riceverà una risposta senza un'esecuzione esplicita del bind.
Nel nostro caso, il cliente riceve esternamente una chiave in formato stringa e utilizza la funzione resolve() per ottenere l'indirizzo IP.
A questo punto iniziamo il "handshake" di WebRTC, se i due peer non sono già connessi tra loro. Le connessioni a porte diverse di un singolo peer utilizzano lo stesso DataChannel WebRTC.
Inoltre, eseguiamo un'indiretta bind(), in modo che il server possa ripristinare la connessione nel successivo sendto() nel caso in cui essa si chiuda per qualche motivo.
Il server viene informato della connessione del cliente quando il cliente registra la sua offerta SDP sotto le informazioni sulla porta del server in Firebase, e il server risponde anch'esso con la sua risposta.
Nello schema mostrato qui sotto è illustrato un esempio del flusso dei messaggi per lo schema dei socket e la trasmissione del primo messaggio dal cliente al server:

Schema completo della fase di connessione tra cliente e server
Conclusione
Se sei arrivato fino a qui, probabilmente ti interesserà vedere la teoria in azione. Puoi giocare a , prova!
Partita amichevole tra colleghi
Il codice della libreria di rete è liberamente disponibile su . Unisciti alla nostra comunità nel nostro canale di !
Fonte: habr.com
