Μεταφορά ενός παιχνιδιού για πολλούς παίκτες από την C++ στον ιστό με Cheerp, WebRTC και Firebase

Εισαγωγή

η ΕΤΑΙΡΕΙΑ μας Leaning Technologies παρέχει λύσεις για τη μεταφορά παραδοσιακών εφαρμογών επιφάνειας εργασίας στον Ιστό. Ο μεταγλωττιστής μας C++ ευθυμία δημιουργεί έναν συνδυασμό WebAssembly και JavaScript, που παρέχει και τα δύο απλή αλληλεπίδραση με το πρόγραμμα περιήγησηςκαι υψηλή απόδοση.

Ως παράδειγμα της εφαρμογής του, αποφασίσαμε να μεταφέρουμε ένα παιχνίδι για πολλούς παίκτες στο διαδίκτυο και επιλέξαμε Teeworlds. Το Teeworlds είναι ένα XNUMXD ρετρό παιχνίδι για πολλούς παίκτες με μια μικρή αλλά ενεργή κοινότητα παικτών (συμπεριλαμβανομένου και εμένα!). Είναι μικρό τόσο όσον αφορά τους ληφθέντες πόρους όσο και τις απαιτήσεις CPU και GPU - ιδανικός υποψήφιος.

Μεταφορά ενός παιχνιδιού για πολλούς παίκτες από την C++ στον ιστό με Cheerp, WebRTC και Firebase
Εκτελείται στο πρόγραμμα περιήγησης Teeworlds

Αποφασίσαμε να χρησιμοποιήσουμε αυτό το έργο για να πειραματιστούμε γενικές λύσεις για τη μεταφορά κώδικα δικτύου στον Ιστό. Αυτό γίνεται συνήθως με τους εξής τρόπους:

  • XMLHttpΑίτηση/ανάκτηση, εάν το τμήμα δικτύου αποτελείται μόνο από αιτήματα HTTP ή
  • WebSockets.

Και οι δύο λύσεις απαιτούν τη φιλοξενία ενός στοιχείου διακομιστή από την πλευρά του διακομιστή και καμία δεν επιτρέπει τη χρήση ως πρωτόκολλο μεταφοράς UDP. Αυτό είναι σημαντικό για εφαρμογές σε πραγματικό χρόνο, όπως λογισμικό τηλεδιάσκεψης και παιχνίδια, επειδή εγγυάται την παράδοση και την παραγγελία των πακέτων πρωτοκόλλου TCP μπορεί να αποτελέσει εμπόδιο στη χαμηλή καθυστέρηση.

Υπάρχει ένας τρίτος τρόπος - χρησιμοποιήστε το δίκτυο από το πρόγραμμα περιήγησης: WebRTC.

RTCDataChannel Υποστηρίζει αξιόπιστη και αναξιόπιστη μετάδοση (στην τελευταία περίπτωση προσπαθεί να χρησιμοποιήσει το UDP ως πρωτόκολλο μεταφοράς όποτε είναι δυνατόν) και μπορεί να χρησιμοποιηθεί τόσο με απομακρυσμένο διακομιστή όσο και μεταξύ προγραμμάτων περιήγησης. Αυτό σημαίνει ότι μπορούμε να μεταφέρουμε ολόκληρη την εφαρμογή στο πρόγραμμα περιήγησης, συμπεριλαμβανομένου του στοιχείου διακομιστή!

Ωστόσο, αυτό συνοδεύεται από μια πρόσθετη δυσκολία: προτού μπορέσουν να επικοινωνήσουν δύο ομότιμοι WebRTC, πρέπει να εκτελέσουν μια σχετικά σύνθετη χειραψία για να συνδεθούν, η οποία απαιτεί πολλές οντότητες τρίτων (ένας διακομιστής σηματοδότησης και ένας ή περισσότεροι διακομιστές ΚΑΤΑΠΛΗΣΣΩ/ΣΤΡΟΦΗ).

Στην ιδανική περίπτωση, θα θέλαμε να δημιουργήσουμε ένα API δικτύου που χρησιμοποιεί WebRTC εσωτερικά, αλλά είναι όσο το δυνατόν πιο κοντά σε μια διεπαφή UDP Sockets που δεν χρειάζεται να δημιουργήσει σύνδεση.

Αυτό θα μας επιτρέψει να εκμεταλλευτούμε το WebRTC χωρίς να χρειάζεται να εκθέσουμε σύνθετες λεπτομέρειες στον κώδικα της εφαρμογής (τον οποίο θέλαμε να αλλάξουμε όσο το δυνατόν λιγότερο στο έργο μας).

Ελάχιστο WebRTC

Το WebRTC είναι ένα σύνολο API που διατίθενται σε προγράμματα περιήγησης που παρέχει peer-to-peer μετάδοση ήχου, βίντεο και αυθαίρετων δεδομένων.

Η σύνδεση μεταξύ των ομοτίμων δημιουργείται (ακόμα και αν υπάρχει NAT στη μία ή και στις δύο πλευρές) χρησιμοποιώντας διακομιστές STUN ή/και TURN μέσω ενός μηχανισμού που ονομάζεται ICE. Οι ομότιμοι ανταλλάσσουν πληροφορίες ICE και παραμέτρους καναλιού μέσω προσφοράς και απάντησης του πρωτοκόλλου SDP.

Ουάου! Πόσες συντομογραφίες ταυτόχρονα; Ας εξηγήσουμε εν συντομία τι σημαίνουν αυτοί οι όροι:

  • Session Traversal Utilities για NAT (ΚΑΤΑΠΛΗΣΣΩ) — ένα πρωτόκολλο για την παράκαμψη του NAT και τη λήψη ενός ζεύγους (IP, θύρα) για την απευθείας ανταλλαγή δεδομένων με τον κεντρικό υπολογιστή. Εάν καταφέρει να ολοκληρώσει την εργασία του, τότε οι συνομήλικοι μπορούν να ανταλλάξουν ανεξάρτητα δεδομένα μεταξύ τους.
  • Διέλευση με χρήση ρελέ γύρω από το NAT (ΣΤΡΟΦΗ) χρησιμοποιείται επίσης για διέλευση NAT, αλλά το υλοποιεί προωθώντας δεδομένα μέσω ενός διακομιστή μεσολάβησης που είναι ορατό και στους δύο ομότιμους. Προσθέτει λανθάνοντα χρόνο και είναι πιο ακριβό στην εφαρμογή του από το STUN (επειδή εφαρμόζεται σε όλη τη διάρκεια της επικοινωνίας), αλλά μερικές φορές είναι η μόνη επιλογή.
  • Δημιουργία διαδραστικής συνδεσιμότητας (ICE) χρησιμοποιείται για την επιλογή της καλύτερης δυνατής μεθόδου σύνδεσης δύο ομότιμων με βάση πληροφορίες που λαμβάνονται από απευθείας σύνδεση ομότιμων, καθώς και πληροφορίες που λαμβάνονται από οποιονδήποτε αριθμό διακομιστών STUN και TURN.
  • Περιγραφή συνεδρίας Πρωτόκολλο (SDP) είναι μια μορφή για την περιγραφή των παραμέτρων καναλιού σύνδεσης, για παράδειγμα, υποψηφίους ICE, κωδικοποιητές πολυμέσων (στην περίπτωση καναλιού ήχου/βίντεο) κ.λπ... Ένας από τους ομότιμους στέλνει μια προσφορά SDP και ο δεύτερος απαντά με μια απάντηση SDP . Μετά από αυτό, δημιουργείται ένα κανάλι.

Για να δημιουργήσουν μια τέτοια σύνδεση, οι ομότιμοι πρέπει να συλλέγουν τις πληροφορίες που λαμβάνουν από τους διακομιστές STUN και TURN και να τις ανταλλάσσουν μεταξύ τους.

Το πρόβλημα είναι ότι δεν έχουν ακόμη τη δυνατότητα να επικοινωνούν απευθείας, επομένως πρέπει να υπάρχει ένας μηχανισμός εκτός ζώνης για την ανταλλαγή αυτών των δεδομένων: ένας διακομιστής σηματοδότησης.

Ένας διακομιστής σηματοδότησης μπορεί να είναι πολύ απλός επειδή η μόνη του δουλειά είναι να προωθήσει δεδομένα μεταξύ ομοτίμων στη φάση της χειραψίας (όπως φαίνεται στο παρακάτω διάγραμμα).

Μεταφορά ενός παιχνιδιού για πολλούς παίκτες από την C++ στον ιστό με Cheerp, WebRTC και Firebase
Απλοποιημένο διάγραμμα ακολουθίας χειραψίας WebRTC

Επισκόπηση μοντέλου δικτύου Teeworlds

Η αρχιτεκτονική δικτύου Teeworlds είναι πολύ απλή:

  • Τα στοιχεία πελάτη και διακομιστή είναι δύο διαφορετικά προγράμματα.
  • Οι πελάτες εισέρχονται στο παιχνίδι συνδέοντας σε έναν από πολλούς διακομιστές, καθένας από τους οποίους φιλοξενεί μόνο ένα παιχνίδι τη φορά.
  • Όλη η μεταφορά δεδομένων στο παιχνίδι πραγματοποιείται μέσω του διακομιστή.
  • Ένας ειδικός κύριος διακομιστής χρησιμοποιείται για τη συλλογή μιας λίστας με όλους τους δημόσιους διακομιστές που εμφανίζονται στον πελάτη του παιχνιδιού.

Χάρη στη χρήση του WebRTC για ανταλλαγή δεδομένων, μπορούμε να μεταφέρουμε το στοιχείο διακομιστή του παιχνιδιού στο πρόγραμμα περιήγησης όπου βρίσκεται ο πελάτης. Αυτό μας δίνει μια μεγάλη ευκαιρία...

Ξεφορτωθείτε τους διακομιστές

Η έλλειψη λογικής διακομιστή έχει ένα ωραίο πλεονέκτημα: μπορούμε να αναπτύξουμε ολόκληρη την εφαρμογή ως στατικό περιεχόμενο στις Σελίδες Github ή στο δικό μας υλικό πίσω από το Cloudflare, εξασφαλίζοντας έτσι γρήγορες λήψεις και υψηλό χρόνο λειτουργίας δωρεάν. Στην πραγματικότητα, μπορούμε να τα ξεχάσουμε και αν είμαστε τυχεροί και το παιχνίδι γίνει δημοφιλές, τότε η υποδομή δεν θα χρειαστεί να εκσυγχρονιστεί.

Ωστόσο, για να λειτουργήσει το σύστημα, πρέπει να χρησιμοποιήσουμε μια εξωτερική αρχιτεκτονική:

  • Ένας ή περισσότεροι διακομιστές STUN: Έχουμε πολλές δωρεάν επιλογές για να διαλέξετε.
  • Τουλάχιστον ένας διακομιστής TURN: δεν υπάρχουν δωρεάν επιλογές εδώ, επομένως μπορούμε είτε να δημιουργήσουμε το δικό μας είτε να πληρώσουμε για την υπηρεσία. Ευτυχώς, τις περισσότερες φορές η σύνδεση μπορεί να δημιουργηθεί μέσω διακομιστών STUN (και να παρέχει αληθινό p2p), αλλά το TURN χρειάζεται ως εναλλακτική επιλογή.
  • Διακομιστής σηματοδότησης: Σε αντίθεση με τις άλλες δύο πτυχές, η σηματοδότηση δεν είναι τυποποιημένη. Το τι θα είναι πραγματικά υπεύθυνος ο διακομιστής σηματοδότησης εξαρτάται κάπως από την εφαρμογή. Στην περίπτωσή μας, πριν από τη δημιουργία μιας σύνδεσης, είναι απαραίτητο να ανταλλάξουμε μια μικρή ποσότητα δεδομένων.
  • Teeworlds Master Server: Χρησιμοποιείται από άλλους διακομιστές για να διαφημίσουν την ύπαρξή τους και από πελάτες για να βρουν δημόσιους διακομιστές. Αν και δεν απαιτείται (οι πελάτες μπορούν πάντα να συνδεθούν με έναν διακομιστή που γνωρίζουν με μη αυτόματο τρόπο), θα ήταν ωραίο να υπάρχει έτσι ώστε οι παίκτες να μπορούν να συμμετέχουν σε παιχνίδια με τυχαία άτομα.

Αποφασίσαμε να χρησιμοποιήσουμε τους δωρεάν διακομιστές STUN της Google και αναπτύξαμε μόνοι μας έναν διακομιστή TURN.

Για τα δύο τελευταία σημεία χρησιμοποιήσαμε Firebase:

  • Ο κύριος διακομιστής Teeworlds υλοποιείται πολύ απλά: ως μια λίστα αντικειμένων που περιέχει πληροφορίες (όνομα, IP, χάρτης, λειτουργία, ...) κάθε ενεργού διακομιστή. Οι διακομιστές δημοσιεύουν και ενημερώνουν το δικό τους αντικείμενο και οι πελάτες λαμβάνουν ολόκληρη τη λίστα και την εμφανίζουν στη συσκευή αναπαραγωγής. Εμφανίζουμε επίσης τη λίστα στην αρχική σελίδα ως HTML, ώστε οι παίκτες να μπορούν απλά να κάνουν κλικ στον διακομιστή και να μεταφερθούν απευθείας στο παιχνίδι.
  • Η σηματοδότηση σχετίζεται στενά με την υλοποίηση των υποδοχών μας, που περιγράφεται στην επόμενη ενότητα.

Μεταφορά ενός παιχνιδιού για πολλούς παίκτες από την C++ στον ιστό με Cheerp, WebRTC και Firebase
Λίστα διακομιστών μέσα στο παιχνίδι και στην αρχική σελίδα

Υλοποίηση πριζών

Θέλουμε να δημιουργήσουμε ένα API που να είναι όσο το δυνατόν πιο κοντά στα Posix UDP Sockets για να ελαχιστοποιήσουμε τον αριθμό των αλλαγών που απαιτούνται.

Θέλουμε επίσης να εφαρμόσουμε το απαραίτητο ελάχιστο που απαιτείται για την απλούστερη ανταλλαγή δεδομένων μέσω του δικτύου.

Για παράδειγμα, δεν χρειαζόμαστε πραγματική δρομολόγηση: όλοι οι ομότιμοι βρίσκονται στο ίδιο "εικονικό LAN" που σχετίζεται με μια συγκεκριμένη παρουσία βάσης δεδομένων Firebase.

Επομένως, δεν χρειαζόμαστε μοναδικές διευθύνσεις IP: οι μοναδικές τιμές κλειδιού Firebase (παρόμοιες με τα ονόματα τομέα) επαρκούν για τον μοναδικό προσδιορισμό ομότιμων και κάθε ομότιμος εκχωρεί τοπικά "ψεύτικα" διευθύνσεις IP σε κάθε κλειδί που πρέπει να μεταφραστεί. Αυτό εξαλείφει εντελώς την ανάγκη για καθολική εκχώρηση διεύθυνσης IP, η οποία είναι μια μη τετριμμένη εργασία.

Εδώ είναι το ελάχιστο API που πρέπει να εφαρμόσουμε:

// 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 είναι απλό και παρόμοιο με το Posix Sockets API, αλλά έχει μερικές σημαντικές διαφορές: καταγραφή επιστροφών κλήσεων, εκχώρηση τοπικών IP και τεμπέλικες συνδέσεις.

Καταχώρηση επανάκλησης

Ακόμα κι αν το αρχικό πρόγραμμα χρησιμοποιεί I/O χωρίς αποκλεισμό, ο κώδικας πρέπει να αναπαρασκευαστεί για να εκτελεστεί σε πρόγραμμα περιήγησης Ιστού.

Ο λόγος για αυτό είναι ότι ο βρόχος συμβάντος στο πρόγραμμα περιήγησης είναι κρυφός από το πρόγραμμα (είτε είναι JavaScript είτε WebAssembly).

Στο φυσικό περιβάλλον μπορούμε να γράψουμε κώδικα σαν αυτό

while(running) {
  select(...); // wait for I/O events
  while(true) {
    int r = readfrom(...); // try to read
    if (r < 0 && errno == EWOULDBLOCK) // no more data available
      break;
    ...
  }
  ...
}

Εάν ο βρόχος συμβάντος είναι κρυμμένος σε εμάς, τότε πρέπει να τον μετατρέψουμε σε κάτι σαν αυτό:

auto cb = []() { // this will be called when new data is available
  while(true) {
    int r = readfrom(...); // try to read
    if (r < 0 && errno == EWOULDBLOCK) // no more data available
      break;
    ...
  }
  ...
};
recvCallback(cb); // register the callback

Τοπική εκχώρηση IP

Τα αναγνωριστικά κόμβων στο "δίκτυό μας" δεν είναι διευθύνσεις IP, αλλά κλειδιά Firebase (είναι συμβολοσειρές που μοιάζουν με αυτό: -LmEC50PYZLCiCP-vqde ).

Αυτό είναι βολικό γιατί δεν χρειαζόμαστε μηχανισμό για την αντιστοίχιση IP και τον έλεγχο της μοναδικότητάς τους (καθώς και την απόρριψή τους μετά την αποσύνδεση του πελάτη), αλλά συχνά είναι απαραίτητο να προσδιορίσουμε τους ομοτίμους με μια αριθμητική τιμή.

Για αυτό ακριβώς χρησιμοποιούνται οι λειτουργίες. resolve и reverseResolve: Η εφαρμογή λαμβάνει με κάποιο τρόπο την τιμή συμβολοσειράς του κλειδιού (μέσω εισόδου χρήστη ή μέσω του κύριου διακομιστή) και μπορεί να τη μετατρέψει σε διεύθυνση IP για εσωτερική χρήση. Το υπόλοιπο API λαμβάνει επίσης αυτήν την τιμή αντί για μια συμβολοσειρά για απλότητα.

Αυτό είναι παρόμοιο με την αναζήτηση DNS, αλλά εκτελείται τοπικά στον υπολογιστή-πελάτη.

Δηλαδή, οι διευθύνσεις IP δεν μπορούν να κοινοποιηθούν μεταξύ διαφορετικών πελατών, και εάν χρειάζεται κάποιο είδος καθολικού αναγνωριστικού, θα πρέπει να δημιουργηθεί με διαφορετικό τρόπο.

Τεμπέλης σύνδεση

Το UDP δεν χρειάζεται σύνδεση, αλλά όπως είδαμε, το WebRTC απαιτεί μια μακρά διαδικασία σύνδεσης προτού μπορέσει να ξεκινήσει τη μεταφορά δεδομένων μεταξύ δύο ομότιμων.

Αν θέλουμε να παρέχουμε το ίδιο επίπεδο αφαίρεσης, (sendto/recvfrom με αυθαίρετα peers χωρίς προηγούμενη σύνδεση), τότε πρέπει να εκτελέσουν μια "τεμπέλη" (καθυστερημένη) σύνδεση μέσα στο API.

Αυτό συμβαίνει κατά τη διάρκεια της κανονικής επικοινωνίας μεταξύ του «διακομιστή» και του «πελάτη» κατά τη χρήση του UDP και τι πρέπει να κάνει η βιβλιοθήκη μας:

  • Κλήσεις διακομιστή bind()για να πει στο λειτουργικό σύστημα ότι θέλει να λαμβάνει πακέτα στην καθορισμένη θύρα.

Αντίθετα, θα δημοσιεύσουμε μια ανοιχτή θύρα στο Firebase κάτω από το κλειδί διακομιστή και θα ακούσουμε για συμβάντα στο υποδέντρο του.

  • Κλήσεις διακομιστή recvfrom(), αποδέχεται πακέτα που προέρχονται από οποιονδήποτε κεντρικό υπολογιστή σε αυτήν τη θύρα.

Στην περίπτωσή μας, πρέπει να ελέγξουμε την εισερχόμενη ουρά των πακέτων που αποστέλλονται σε αυτήν τη θύρα.

Κάθε θύρα έχει τη δική της ουρά και προσθέτουμε τις θύρες προέλευσης και προορισμού στην αρχή των δεδομένων WebRTC, ώστε να γνωρίζουμε σε ποια ουρά να προωθήσουμε όταν φτάσει ένα νέο πακέτο.

Η κλήση δεν είναι αποκλειστική, οπότε αν δεν υπάρχουν πακέτα, απλώς επιστρέφουμε -1 και ρυθμίζουμε errno=EWOULDBLOCK.

  • Ο πελάτης λαμβάνει την IP και τη θύρα του διακομιστή με κάποιο εξωτερικό μέσο και καλεί sendto(). Αυτό κάνει επίσης μια εσωτερική κλήση. bind(), επομένως μεταγενέστερα recvfrom() θα λάβει την απάντηση χωρίς ρητή εκτέλεση δεσμεύσεων.

Στην περίπτωσή μας, ο πελάτης λαμβάνει εξωτερικά το κλειδί συμβολοσειράς και χρησιμοποιεί τη συνάρτηση resolve() για να αποκτήσετε μια διεύθυνση IP.

Σε αυτό το σημείο, ξεκινάμε μια χειραψία WebRTC εάν οι δύο ομότιμοι δεν είναι ακόμη συνδεδεμένοι μεταξύ τους. Οι συνδέσεις σε διαφορετικές θύρες του ίδιου peer χρησιμοποιούν το ίδιο WebRTC DataChannel.

Εκτελούμε και έμμεσα bind()ώστε ο διακομιστής να μπορεί να επανασυνδεθεί στο επόμενο sendto() σε περίπτωση που έκλεισε για κάποιο λόγο.

Ο διακομιστής ειδοποιείται για τη σύνδεση του πελάτη όταν ο πελάτης γράψει την προσφορά του SDP κάτω από τις πληροφορίες της θύρας διακομιστή στο Firebase και ο διακομιστής απαντά με την απάντησή του εκεί.

Το παρακάτω διάγραμμα δείχνει ένα παράδειγμα ροής μηνύματος για ένα σχήμα υποδοχής και τη μετάδοση του πρώτου μηνύματος από τον πελάτη στον διακομιστή:

Μεταφορά ενός παιχνιδιού για πολλούς παίκτες από την C++ στον ιστό με Cheerp, WebRTC και Firebase
Πλήρες διάγραμμα της φάσης σύνδεσης μεταξύ πελάτη και διακομιστή

Συμπέρασμα

Αν έχετε διαβάσει ως εδώ, πιθανότατα σας ενδιαφέρει να δείτε τη θεωρία σε δράση. Το παιχνίδι μπορεί να παιχτεί σε teeworlds.leaningtech.com, Δοκίμασέ το!


Φιλικός αγώνας μεταξύ συναδέλφων

Ο κώδικας της βιβλιοθήκης δικτύου διατίθεται δωρεάν στη διεύθυνση Github. Λάβετε μέρος στη συζήτηση στο κανάλι μας στο πλέγμα!

Πηγή: www.habr.com

Προσθέστε ένα σχόλιο