Введение


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

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


Работающая в браузере Teeworlds

Мы решили использовать этот проект, чтобы поэкспериментировать с общими решениями по портированию сетевого кода под веб. Обычно это выполняется следующими способами:

  • XMLHttpRequest/fetch, если сетевая часть состоит только из HTTP-запросов, или
  • WebSockets.

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

Существует и третий путь — использовать сеть из браузера: WebRTC.

RTCDataChannel поддерживает и надёжную, и ненадёжную передачу (в последнем случае он по возможности пытается использовать в качестве транспортного протокола UDP), и может применяться и с удалённым сервером, и между браузерами. Это значит, что мы можем портировать в браузер всё приложение, в том числе и серверный компонент!

Однако с этим связана дополнительная трудность: прежде чем два пира WebRTC смогут обмениваться данными, им нужно выполнить относительно сложную процедуру «рукопожатия» (handshake) для подключения, для чего требуется несколько сторонних сущностей (сигнальный сервер и один или несколько серверов STUN/TURN).

В идеале мы бы хотели создать сетевой API, внутри использующий WebRTC, но как можно более близкий к интерфейсу UDP Sockets, которому не нужно устанавливать соединение.

Это позволит нам использовать преимущества WebRTC без необходимости раскрытия сложных подробностей коду приложения (который в своём проекте мы хотели изменять как можно меньше).

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


WebRTC — это имеющийся в браузерах набор API, обеспечивающий передачу peer-to-peer звука, видео и произвольных данных.

Соединение между пирами устанавливается (даже в случае наличия NAT с одной или обеих сторон) при помощи серверов STUN и/или TURN через механизм под названием ICE. Пиры обмениваются информацией ICE и параметрами каналов через offer и answer протокола SDP.

Ого! Как много аббревиатур за один раз. Давайте вкратце объясним, что значат эти понятия:

  • Session Traversal Utilities for NAT (STUN) — протокол для обхода NAT и получения пары (IP, порт) для обмена данными непосредственно с хостом. Если ему удаётся выполнить свою задачу, то пиры могут самостоятельно обмениваться данными друг с другом.
  • Traversal Using Relays around NAT (TURN) тоже используется для обхода NAT, но он реали��ует это, перенаправляя данные через прокси, видимый обоим пирам. Он добавляет задержку и более затратен в выполнении, чем STUN (потому что применяется на протяжении всего сеанса связи), но иногда это единственный возможный вариант.
  • Interactive Connectivity Establishment (ICE) используется для выбора наилучшего возможного способа соединения двух пиров на основании информации, полученной при непосредственном соединении пиров, а также информации, полученной любым количеством серверов STUN и TURN.
  • Session Description Protocol (SDP) — это формат описания параметров канала подключения, например, кандидатов ICE, кодеков мультимедиа (в случае звукового/видеоканала), и т.п… Один из пиров отправляет SDP Offer («предложение»), а второй отвечает SDP Answer («откликом»). После этого создаётся канал.

Чтобы создать такое соединение, пирам нужно собрать информацию, полученную ими от серверов STUN и TURN, и обменяться ею друг с другом.

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

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


Упрощённая схема последовательности «рукопожатия» WebRTC

Обзор сетевой модели Teeworlds


Сетевая архитектура Teeworlds очень проста:

  • Компоненты клиента и сервера — это две разные программы.
  • Клиенты вступают в игру, подключаясь к одному из нескольких серверов, каждый из которых за раз хостит только одну игру.
  • Вся передача данных в игре ведётся через сервер.
  • Особый мастер-сервер используется для сбора списка всех публичных серверов, которые отображаются в игровом клиенте.

Благодаря использованию для обмена данными WebRTC мы можем перенести серверный компонент игры в браузер, где находится клиент. Это даёт нам прекрасную возможность…

Избавиться от серверов


Отсутствие серверной логики имеет приятное преимущество: мы можем развернуть всё приложение как статичный контент на Github Pages или на собственном оборудовании за Cloudflare, таким образом бесплатно обеспечив себе быстрые загрузки и высокий аптайм. По сути, можно будет о них забыть, и если нам повезёт и игра станет популярной, то инфраструктуру модернизировать не придётся.

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

  • Один или несколько серверов STUN: у нас есть выбор из нескольких бесплатных вариантов.
  • По крайней мере один сервер TURN: здесь бесплатных вариантов нет, поэтому мы можем или настроить свой, или платить за сервис. К счастью, бОльшую часть времени подключение можно будет устанавливать через серверы STUN (и обеспечить истинный p2p), но TURN необходим как запасной вариант.
  • Сигнальный сервер: в отличие от двух других аспектов, сигнализирование не стандартизировано. То, за что на самом деле будет отвечать сигнальный сервер, в чём-то зависит от приложения. В нашем случае перед установкой соединения необходимо обменяться небольшим объёмом данных.
  • Мастер-сервер Teeworlds: он используется другими серверами для оповещения о своём существовании и клиентами для поиска публичных серверов. Хотя он и не обязателен (клиенты всегда могут подключиться к известному им серверу вручную), было бы хорошо его иметь, чтобы игроки могли участвовать в играх со случайными людьми.

Мы решили использовать бесплатные серверы STUN компании Google, а один сервер TURN развернули самостоятельно.

Для двух последних пунктов мы использовали Firebase:

  • Мастер-сервер Teeworlds реализован очень просто: как список объектов, содержащих информацию (имя, IP, карта, режим, …) каждого активного сервера. Серверы публикуют и обновляют свой собственный объект, а клиенты берут весь список и отображают его игроку. Также мы отображаем список на домашней странице как HTML, чтобы игроки могли просто нажать на сервер и прямиком попасть в игру.
  • Сигнализирование тесно связано с нашей реализацией сокетов, описанной в следующем разделе.


Список серверов внутри игры и на домашней странице

Реализация сокетов


Мы хотим создать 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, и сервер там же отвечает своим откликом.



На показанной ниже схеме показан пример движения сообщений для схемы сокетов и передача от клиента серверу первого сообщения:


Полная схема этапа подключения между клиентом и сервером

Заключение


Если вы дочитали до конца, то вам наверно интересно посмотреть на теорию в действии. В игру можно сыграть на teeworlds.leaningtech.com, попробуйте!


Дружеский матч между коллегами

Код сетевой библиотеки свободно доступен на Github. Присоединяйтесь к общению на нашем канале в Gitter!