- PVSM.RU - https://www.pvsm.ru -
Наша компания Leaning Technologies [1] предоставляет решения по портированию традиционных desktop-приложений в веб. Наш компилятор C++ Cheerp [2] генерирует сочетание WebAssembly и JavaScript, что обеспечивает и простое взаимодействие с браузером [3], и высокую производительность.
В качестве примера его применения мы решили портировать для веба многопользовательскую игру и выбрали для этого Teeworlds [4]. Teeworlds — это многопользовательская двухмерная ретро-игра с небольшим, но активным сообществом игроков (в их числе и я!). Она мала как с точки зрения скачиваемых ресурсов, так и требований к ЦП и GPU — идеальный кандидат.
Работающая в браузере Teeworlds
Мы решили использовать этот проект, чтобы поэкспериментировать с общими решениями по портированию сетевого кода под веб. Обычно это выполняется следующими способами:
Оба решения требуют хостить серверный компонент на стороне сервера, и ни один из них не позволяет использовать в качестве транспортного протокола UDP [5]. Это важно для приложений реального времени, таких как софт для видеоконференций и игры, потому что гарантии доставки и порядка пакетов протокола TCP [6] могут стать помехой для низких задержек.
Существует и третий путь — использовать сеть из браузера: WebRTC [7].
RTCDataChannel [8] поддерживает и надёжную, и ненадёжную передачу (в последнем случае он по возможности пытается использовать в качестве транспортного протокола UDP), и может применяться и с удалённым сервером, и между браузерами. Это значит, что мы можем портировать в браузер всё приложение, в том числе и серверный компонент!
Однако с этим связана дополнительная трудность: прежде чем два пира WebRTC смогут обмениваться данными, им нужно выполнить относительно сложную процедуру «рукопожатия» (handshake) для подключения, для чего требуется несколько сторонних сущностей (сигнальный сервер и один или несколько серверов STUN [9]/TURN [10]).
В идеале мы бы хотели создать сетевой API, внутри использующий WebRTC, но как можно более близкий к интерфейсу UDP Sockets, которому не нужно устанавливать соединение.
Это позволит нам использовать преимущества WebRTC без необходимости раскрытия сложных подробностей коду приложения (который в своём проекте мы хотели изменять как можно меньше).
WebRTC — это имеющийся в браузерах набор API, обеспечивающий передачу peer-to-peer звука, видео и произвольных данных.
Соединение между пирами устанавливается (даже в случае наличия NAT с одной или обеих сторон) при помощи серверов STUN и/или TURN через механизм под названием ICE. Пиры обмениваются информацией ICE и параметрами каналов через offer и answer протокола SDP.
Ого! Как много аббревиатур за один раз. Давайте вкратце объясним, что значат эти понятия:
Чтобы создать такое соединение, пирам нужно собрать информацию, полученную ими от серверов STUN и TURN, и обменяться ею друг с другом.
Проблема в том, что у них пока нет возможности обмениваться данными напрямую, поэтому для обмена этими данными должен существовать внеполосной механизм: сигнальный сервер.
Сигнальный сервер может быть очень простым, потому что его единственная задача — перенаправление данных между пирами на этапе «рукопожатия» (как показано на схеме ниже).
Упрощённая схема последовательности «рукопожатия» WebRTC
Сетевая архитектура Teeworlds очень проста:
Благодаря использованию для обмена данными WebRTC мы можем перенести серверный компонент игры в браузер, где находится клиент. Это даёт нам прекрасную возможность…
Отсутствие серверной логики имеет приятное преимущество: мы можем развернуть всё приложение как статичный контент на Github Pages или на собственном оборудовании за Cloudflare, таким образом бесплатно обеспечив себе быстрые загрузки и высокий аптайм. По сути, можно будет о них забыть, и если нам повезёт и игра станет популярной, то инфраструктуру модернизировать не придётся.
Однако чтобы система работала, нам всё равно придётся использовать внешнюю архитектуру:
Мы решили использовать бесплатные серверы STUN компании Google, а один сервер TURN развернули самостоятельно.
Для двух последних пунктов мы использовали Firebase [13]:
Список серверов внутри игры и на домашней странице
Мы хотим создать 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-адресами, а ключами Firebase (это строки, которые выглядят так: -LmEC50PYZLCiCP-vqde
).
Это удобно, потому что нам не нужен механизм для назначения IP и проверка их уникальности (а также их утилизация после отключения клиента), но часто бывает необходимо идентифицировать пиров по числовому значению.
Именно для этого и используются функции resolve
и reverseResolve
: приложение каким-то образом получает строковое значение ключа (через ввод пользователя или через мастер-сервер), и может преобразовать его в IP-адрес для внутреннего использования. Остальная часть API тоже для простоты получает вместо строки это значение.
Это похоже на DNS-поиск, только выполняется локально у клиента.
То есть IP-адреса не могут быть общими для разных клиентов, и если нужен какой-то глобальный идентификатор, то его придётся генерировать иным способом.
UDP не нужно подключение, но, как мы видели, прежде чем начать передачу данных между двумя пирами, WebRTC требует длительный процесс подключения.
Если мы хотим обеспечить тот же уровень абстракции, (sendto
/recvfrom
с произвольными пирами без предварительного подключения), то должны выполнять «ленивое» (отложенное) подключение внутри API.
Вот что происходит при обычном обмене данными между «сервером» и «клиентом» в случае использования UDP, и что должна выполнять наша библиотека:
bind()
, чтобы сообщить операционной системе, что хочет получать пакеты в указанный порт.Вместо этого мы опубликуем открытый порт в Firebase под ключом сервера и будем слушать события в его поддереве.
recvfrom()
, принимая в этот порт пакеты, поступающие от любого хоста.В нашем случае нужно проверять входящую очередь пакетов, отправленных в этот порт.
Каждый порт имеет собственную очередь, и мы добавляем в начало датаграмм WebRTC исходный и конечный порты, чтобы знать, в какую очередь перенаправить при поступлении новый пакет.
Вызов является неблокирующим, поэтому если пакетов нет, мы просто возвращаем -1 и задаём errno=EWOULDBLOCK
.
sendto()
. Также при этом выполняется внутренний вызов bind()
, поэтому последующий recvfrom()
получит ответ без явного выполнения bind.
В нашем случае клиент внешним образом получает строковый ключ и использует функцию resolve()
для получения IP-адреса.
На этом этапе мы начинаем «рукопожатие» WebRTC, если два пира ещё не соединены друг с другом. Подключения к разным портам одного пира используют одинаковый DataChannel WebRTC.
Также мы выполняем косвенный bind()
, чтобы сервер мог восстановить соединение в следующем sendto()
на случай, если оно по каким-то причинам закрылось.
Сервер уведомляется о подключении клиента, когда клиент записывает свой SDP offer под информацией порта сервера в Firebase, и сервер там же отвечает своим откликом.
Полная схема этапа подключения между клиентом и сервером
Если вы дочитали до конца, то вам наверно интересно посмотреть на теорию в действии. В игру можно сыграть на teeworlds.leaningtech.com [14], попробуйте!
Дружеский матч между коллегами
Код сетевой библиотеки свободно доступен на Github [15]. Присоединяйтесь к общению на нашем канале в Gitter [16]!
Автор: PatientZero
Источник [17]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/c-3/330692
Ссылки в тексте:
[1] Leaning Technologies: https://leaningtech.com
[2] Cheerp: https://github.com/leaningtech/cheerp-meta
[3] простое взаимодействие с браузером: https://github.com/leaningtech/cheerp-meta/wiki/Cheerp-Tutorial%3A-Mixed-mode-C++-to-WebAssembly-and-JavaScript
[4] Teeworlds: https://www.teeworlds.com/
[5] UDP: https://en.wikipedia.org/wiki/User_Datagram_Protocol
[6] TCP: https://en.wikipedia.org/wiki/Transmission_Control_Protocol
[7] WebRTC: https://developer.mozilla.org/en-US/docs/Glossary/WebRTC
[8] RTCDataChannel: https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel
[9] STUN: https://en.wikipedia.org/wiki/STUN
[10] TURN: https://en.wikipedia.org/wiki/Traversal_Using_Relays_around_NAT
[11] Interactive Connectivity Establishment (ICE): https://en.wikipedia.org/wiki/Interactive_Connectivity_Establishment
[12] Session Description Protocol (SDP): https://en.wikipedia.org/wiki/Session_Description_Protocol
[13] Firebase: https://en.wikipedia.org/wiki/Firebase
[14] teeworlds.leaningtech.com: https://teeworlds.leaningtech.com
[15] Github: https://github.com/leaningtech/cheerpnet
[16] Gitter: https://gitter.im/leaningtech/cheerp
[17] Источник: https://habr.com/ru/post/468031/?utm_source=habrahabr&utm_medium=rss&utm_campaign=468031
Нажмите здесь для печати.