Портуємо розраховану на багато користувачів гру з С++ на веб c Cheerp, WebRTC і Firebase

Запровадження

Наша компанія Leaning Technologies надає рішення щодо портування традиційних desktop-додатків до Інтернету. Наш компілятор C++ веселитися генерує поєднання WebAssembly та JavaScript, що забезпечує і проста взаємодія з браузером, та високу продуктивність.

Як приклад його застосування ми вирішили портувати для Інтернету розраховану на багато користувачів гру і вибрали для цього Teeworlds. Teeworlds — це розрахована на багато користувачів двомірна ретро-гра з невеликою, але активною спільнотою гравців (у тому числі і я!). Вона мала як з точки зору ресурсів, що скачуються, так і вимог до ЦП і GPU — ідеальний кандидат.

Портуємо розраховану на багато користувачів гру з С++ на веб c Cheerp, WebRTC і Firebase
Teeworlds, що працює в браузері

Ми вирішили використати цей проект, щоб поекспериментувати з спільними рішеннями щодо портування мережного коду під веб. Зазвичай це виконується такими способами:

  • XMLHttpRequest/fetch, якщо мережева частина складається тільки з HTTP-запитів, або
  • WebSockets.

Обидва рішення вимагають хостити серверний компонент на стороні сервера, і жоден з них не дозволяє використовувати як транспортний протокол UDP. Це важливо для додатків реального часу, таких як софт для відеоконференцій та ігри, тому що гарантії доставки та порядку пакетів протоколу TCP можуть стати на заваді низьких затримок.

Існує і третій шлях – використовувати мережу з браузера: WebRTC.

RTCDataChannel підтримує і надійну, і ненадійну передачу (в останньому випадку він по можливості намагається використовувати як транспортний протокол UDP), і може застосовуватися і з віддаленим сервером, і між браузерами. Це означає, що ми можемо портувати в браузер весь додаток, у тому числі і серверний компонент!

Однак з цим пов'язана додаткова труднощі: перш ніж два бенкети WebRTC зможуть обмінюватися даними, їм потрібно виконати відносно складну процедуру «рукостискання» (handshake) для підключення, для чого потрібно кілька сторонніх сутностей (сигнальний сервер і один або кілька серверів ЗАГРУЗИТИ/ПЕРЕГЛЯД).

В ідеалі ми хотіли б створити мережевий API, що всередині використовує WebRTC, але якомога ближчий до інтерфейсу UDP Sockets, якому не потрібно встановлювати з'єднання.

Це дозволить нам використовувати переваги WebRTC без необхідності розкриття складних подробиць коду програми (який у своєму проекті ми хотіли змінювати якнайменше).

Мінімальний WebRTC

WebRTC - це наявний у браузерах набір API, що забезпечує передачу peer-to-peer звуку, відео та довільних даних.

З'єднання між бенкетами встановлюється (навіть у разі наявності NAT з одного або обох сторін) за допомогою серверів STUN та/або TURN через механізм під назвою ICE. Піри обмінюються інформацією ICE та параметрами каналів через offer і answer протоколу SDP.

Ого! Як багато абревіатур за один раз. Давайте коротко пояснимо, що означають ці поняття:

  • Утиліти обходу сеансів для NAT (ЗАГРУЗИТИ) - протокол для обходу NAT та отримання пари (IP, порт) для обміну даними безпосередньо з хостом. Якщо йому вдається виконати своє завдання, то бенкети можуть самостійно обмінюватися даними один з одним.
  • Traversal Using Relays around NAT (ПЕРЕГЛЯД) теж використовується для обходу NAT, але він реалізує це, перенаправляючи дані через проксі, видимий обох бенкетів. Він додає затримку і більш витратний у виконанні, ніж STUN (бо застосовується протягом усього сеансу зв'язку), але іноді це єдиний можливий варіант.
  • Встановлення інтерактивного підключення (ICE) використовується для вибору найкращого можливого способу з'єднання двох бенкетів на підставі інформації, отриманої при безпосередньому з'єднанні бенкетів, а також інформації, отриманої будь-якою кількістю серверів STUN та TURN.
  • Протокол опису сеансу (SDP) — це формат опису параметрів каналу підключення, наприклад, кандидатів ICE, кодеків мультимедіа (у разі звукового/відеоканалу), тощо… Один із бенкетів відправляє SDP Offer (пропозиція), а другий відповідає SDP Answer (відгуком) . Після цього створюється канал.

Щоб створити таке з'єднання, пірам потрібно зібрати інформацію, отриману від серверів STUN і TURN, і обмінятися нею один з одним.

Проблема в тому, що вони поки що не мають можливості обмінюватися даними безпосередньо, тому для обміну цими даними повинен існувати позасмуговий механізм: сигнальний сервер.

Сигнальний сервер може бути дуже простим, тому що його єдине завдання - перенаправлення даних між бенкетами на етапі «стискання рук» (як показано на схемі нижче).

Портуємо розраховану на багато користувачів гру з С++ на веб c Cheerp, WebRTC і Firebase
Спрощена схема послідовності рукостискання WebRTC

Огляд мережної моделі Teeworlds

Мережева архітектура Teeworlds дуже проста:

  • Компоненти клієнта та сервера – це дві різні програми.
  • Клієнти вступають у гру, підключаючись до одного з кількох серверів, кожен з яких за один раз хостить лише одну гру.
  • Вся передача даних у грі ведеться через сервер.
  • Спеціальний майстер-сервер використовується для збору списку всіх публічних серверів, які відображаються в ігровому клієнті.

Завдяки використанню для обміну даними WebRTC ми можемо перенести серверний компонент гри до браузера, де знаходиться клієнт. Це дає нам чудову нагоду…

Позбутися серверів

Відсутність серверної логіки має приємну перевагу: ми можемо розгорнути всю програму як статичний контент на Github Pages або на власному устаткуванні за Cloudflare, таким чином безкоштовно забезпечивши собі швидкі завантаження та високий аптайм. По суті, можна буде про них забути, і якщо нам пощастить і гра стане популярною, то модернізувати інфраструктуру не доведеться.

Однак, щоб система працювала, нам все одно доведеться використовувати зовнішню архітектуру:

  • Один або кілька серверів STUN: у нас є вибір кількох безкоштовних варіантів.
  • Принаймні один сервер TURN: тут безкоштовних варіантів немає, тому ми можемо налаштувати свій, або платити за сервіс. На щастя, більшу частину часу підключення можна буде встановлювати через сервери STUN (і забезпечити справжній p2p), але TURN необхідний як запасний варіант.
  • Сигнальний сервер: на відміну від двох інших аспектів сигналізація не стандартизовано. Те, за що насправді відповідатиме сигнальний сервер, у чомусь залежить від програми. У разі перед встановленням з'єднання необхідно обмінятися невеликим обсягом даних.
  • Майстер-сервер Teeworlds: він використовується іншими серверами для оповіщення про своє існування та клієнтами для пошуку публічних серверів. Хоча він і не обов'язковий (клієнти завжди можуть підключитися до відомого їм сервера вручну), було б добре мати його, щоб гравці могли брати участь в іграх з випадковими людьми.

Ми вирішили використовувати безкоштовні сервери 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 простий і схожий на API Posix Sockets, але має кілька важливих відмінностей: реєстрація зворотних дзвінків, призначення локальних IP та «ліниве» з'єднання.

Реєстрація зворотних викликів

Навіть якщо вихідна програма використовує неблокуючий введення-виведення, для запуску у веб-браузері код потрібно рефакторизувати.

Причина цього полягає в тому, що цикл подій у браузері прихований від програми (або 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 з довільними бенкетами без попереднього підключення), то повинні виконувати «ледаче» (відкладене) підключення всередині API.

Ось що відбувається при звичайному обміні даними між «сервером» та «клієнтом» у разі використання UDP, і що має виконувати наша бібліотека:

  • Сервер викликає bind(), щоб повідомити операційну систему, що хоче отримувати пакети у вказаний порт.

Натомість ми опублікуємо відкритий порт у Firebase під ключем сервера і слухатимемо події в його піддереві.

  • Сервер викликає recvfrom(), беручи в цей порт пакети, що надходять від будь-якого хоста.

У нашому випадку потрібно перевіряти вхідну чергу пакетів, надісланих у цей порт.

Кожен порт має власну чергу, і ми додаємо на початок датаграм WebRTC вихідний та кінцевий порти, щоб знати, в яку чергу перенаправити при надходженні новий пакет.

Виклик є неблокуючим, тому якщо пакетів немає, ми просто повертаємо -1 і задаємо errno=EWOULDBLOCK.

  • Клієнт отримує деякими зовнішніми засобами IP та порт сервера, і викликає sendto(). Також при цьому виконується внутрішній виклик bind()тому наступний recvfrom() отримає відповідь без явного виконання bind.

У нашому випадку клієнт зовнішнім чином отримує рядковий ключ та використовує функцію resolve() для отримання IP-адреси.

На цьому етапі ми починаємо «рукостискання» WebRTC, якщо два бенкети ще не з'єднані один з одним. Підключення до різних портів одного бенкету використовують однаковий DataChannel WebRTC.

Також ми виконуємо непрямий bind()щоб сервер міг відновити з'єднання в наступному sendto() на випадок, якщо воно з якихось причин закрилося.

Сервер повідомляється про підключення клієнта, коли клієнт записує свій SDP offer під інформацією порту сервера в Firebase, і сервер там відповідає своїм відгуком.

На схемі показано приклад руху повідомлень для схеми сокетів і передача від клієнта серверу першого повідомлення:

Портуємо розраховану на багато користувачів гру з С++ на веб c Cheerp, WebRTC і Firebase
Повна схема етапу підключення між клієнтом та сервером

Висновок

Якщо ви дочитали до кінця, то вам цікаво подивитися на теорію в дії. У гру можна зіграти на teeworlds.leaningtech.com, Спробуйте!


Дружній матч між колегами

Код мережевої бібліотеки вільно доступний на Github. Приєднуйтесь до спілкування на нашому каналі Сітка!

Джерело: habr.com

Додати коментар або відгук