Які партуецца шматкарыстальніцкую гульню з З++ на вэб 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 (таму што прымяняецца на працягу ўсяго сеансу сувязі), але часам гэта адзіны магчымы варыянт.
  • Interactive Connectivity Establishment (ICE) выкарыстоўваецца для выбару найлепшага магчымага спосабу злучэння двух баляў на падставе інфармацыі, атрыманай пры непасрэдным злучэнні баляў, а таксама інфармацыі, атрыманай любой колькасцю сервераў STUN і TURN.
  • Session Description Protocol (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. Далучайцеся да зносін на нашым канале ў Gitter!

Крыніца: habr.com

Дадаць каментар