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