Введение
Наша компания 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!
