Ներածություն
Մեր ընկերությունը առաջարկում է ավանդական desktop-հավելվածների վեբ տեղափոխման լուծումներ։ Մեր C++ կոմպիլատորը արտադրում է WebAssembly և JavaScript-ին ընդհանրացնող կոմպոզիցիա, որը ապահովում է ինչպես , այնպես էլ բարձր կատարողականություն։
Երբեք դրվագեցրել ենք մեր փորձը որպես անդրադարձ, մենք որոշեցինք տեղափոխել բազմաբնույթ խաղը վեբի վրա և ընտրեցինք ։ Teeworlds-ը բազմապպատակի երկչափ ռետրո խաղ է՝ փոքր, բայց ակտիվ խաղացողների համայնքով (այդ թվում՝ ես!)։ Այն փոքր է՝ ինչպես ներբեռնվող ռեսուրսների, այնպես էլ CPU և GPU պահանջների տեսանկյունից՝ իդեալական թեկնածու։

Բրաուզերում աշխատող Teeworlds
Մենք որոշեցինք օգտագործել այս նախագիծն՝ փորձարկելու վեբի վրա ցանցային կոդի տեղափոխման ընդհանուր լուծումները։Համարյա միշտ այս գործընթացը կատարվում է այս կերպ.
- XMLHttpRequest/fetch, եթե ցանցային մասը միայն HTTP հարցումներով է կազմված, կամ
- WebSockets.
Երկու լուծումներ էլ պահանջում են հոստինգային սերվայն կազմավորել սերվերի կողմից, և ոչ մեկը թույլ չի տալիս օգտագործել որպես փոխադրման պրոտոկոլ. Դա կարևոր է իրական ժամանակի հավելվածների համար, ինչպես տեսանյութի կոնֆերանսների ծրագիրն ու խաղերը, քանի որ տվյալների առաքման և կարգի պոտենցիալները կարող են միանգամից խանգարել ցածր ուշացումների համար. Կա նաև երրորդ ճանապարհ՝ օգտագործել բրաուզերից ցանցը:
RTCDataChannel .
Այսինքն՝ մենք կարող ենք տեղափոխել բրաուզեր՝ ողջ հավելվածը, այդ թվում նաև սերվերային կոմպոնենտը! Սակայն այս համակարգի հետ մեկ այլ դժվարություն է կապված. Արդեն առաջ, երբ երկու WebRTC կատակասերները կարող են փոխանակել տվյալներ, սովորաբար նրանց պետք է կատարել հարաբերականորեն բարդ "գրկախառնությունից" (handshake) անցնելու գործընթաց, որի համար պահանջվում է մի քանի արտաքին մարմիներ (հաղորդագրական սերվեր և մի կամ մի քանի սերվեր
STUN /).
Սա կհնարավորի օգտագործել WebRTC-ի առավելությունները՝ առանց դիմելու համալիր մանրամասներին հավելվածի կոդում (որը նախագծում մենք ցանկանում էինք նվազագույնի հասցնել):
Минимальный WebRTC
WebRTC-ը առաջարկվող API-ծ набор браузерах обеспечивают передачу peer-to-peer звука, видео и произвольных данных.
Պիրսերի միջև կապը կտեղակայվի (նույն ժամին, եթե NAT կա մեկ կամ երկու կողմերում) STUN և/կամ TURN սերվերի միջոցով՝ ICE կոչվող մեխանիզմի միջոցով: Պիրսերը փոխանակվում են ICE տեղեկություններով և ալիքների պարամետրերով через предложение и ответ протокола SDP.
WebRTC-ը ունենում է արդեն գոյություն ունեցող API-ների հավաքածու, որը ապահովում է peer-to-peer բոեր, տեսակի և առաջարկվող տվյալների փոխանցումը: Պիրսերի միջև կապը ուղարկվում է (այդ մասին NAТ-ի առկայության դեպքում, մի կողմում կամ երկուսն էլ) STUN և/կամ TURN սերվերների միջոցով ICE կոչվող մեխանիզմի միջոցով: Պիրսերը փոխանակվում են ICE տեղեկություններով և ալիքների պարամետրերով SDPքի առաջարկության և պատասխանների միջոցով.
Վայ! Ի՞նչքան շատ հապավումներ մեկ անգամում։ Եկեք կարճ բացատրենք, թե ինչ են նշանակում այս հասկացությունները:
- — ՆԱՏ շրջանցելու պրոտոկոլ, որն ապահովում է (IP, պորտ) զույգ ստանալ, որպեսզի տվյալներ ուղարկել ուղղակիորեն հյուրընկալողին։ Եթե հաջողվի իր աշխատանքը կատարել, կողմերը կարող են ինքնուրույն տվյալներ փոխանակել միմյանց հետ:
- այն նույնպես օգտագործվում է ՆԱՏ շրջանցելու համար, սակայն իրականացնում է դա՝ տվյալները հղելով պրոքսիի միջոցով, որը տեսանելի է երկու կողմերին։ Այն ավելացնում է ուշացում և ավելի ծախսատար է կատարման մեջ, քան STUN (քանի որ կիրառվում է կապի ողջ ընթացքում), սակայն Sometimes this is the only possible option.
- կօգտագործվի երկու կողմերի կապի լավագույն հնարավորության ընտրության համար՝ հիմնված անմիջական կապի ժամանակ ստացված տեղեկատվության և STUN և TURN սերվերներից ստացված տեղեկատվության վրա:
- — դա կապի պարամետրերի նկարագրության ֆորմատ է, օրինակ, ICE թեկնածուների, մուլտիմեդիական կոդեկների (ձայնային/արճիճային գծի դեպքում) և այլն։ Դրանից մեկը կողմերից ուղարկում է SDP Offer («առաջարկ»), իսկ երկրորդը պատասխանում է SDP Answer («պատասխան»): Դրանից հետո ստեղծվում է կապ:
Այդպիսի մի կապ ստեղծելու համար կողմերը պետք է հավաքեն այն տեղեկատվությունը, որը ստացվել է STUN և TURN սերվերներից, և փոխանակվեն այն միմյանց հետ:
Մ problémն այն է, որ նրանք դեռ չունեն անմիջական տվյալ փոխանակելու հնարավորություն, ուստի այդ տվյալները փոխանակելու համար պետք է լինի արտանետային մեխանիզմ՝ ազդակային սերվեր:
Ամսաոր կողային սերվեր կարող է լինել շատ պարզ, քանի որ նրա միակ առաքելությունն է՝ տվյալները հղել կողմերի միջև «մանկապախաղի» փուլում (նմանեցված նորմանկով):

WebRTC պակասման «մանկապախաղի» համառոտ սքեմ
Teeworlds ցանցային մոդելի ակնարկ
Teeworlds-ի ցանցային ճարտարապետությունը շատ պարզ է:
- Հաճախորդի և սերվերի բաղադրիչները երկու տարբեր ծրագրեր են:
- Հաճախորդները խաղին են մտնում՝ միանալով քանիցս սերվերներին, յուրաքանչյուրին, որը միաժամանակ հյուրընկալում է միայն մեկ խաղ:
- Խաղի համընդհանուր տվյալների փոխանցումը կատարվում է սերվերի միջոցով:
- Հատուկ մի վերականգնում սերվեր օգտագործվում է բոլոր հանրային սերվերների ցանկը հավաքելու համար, որոնք ցուցադրվում են խաղի հաճախորդում:
Այսպիսով, WebRTC օգտագործելու շնորհիվ մենք կարող ենք տեղափոխել խաղի սերվերային բաղադրիչը զննում՝ որտեղ գտնվում է հաճախորդը։ Դա մեզ տալիս է հրաշալի հնարավորություն ...
Անհրաժեշտությունից ազատվելու համար սերվերներից
Սերվերային տրամաբանության բացակայությունը հաճելի առավելություն ունի. մենք կարող ենք ներդնել ամբողջ ծրագրարը որպես ստատիկական բովանդակություն Github Pages-ում կամ մեր սարքավորումները Cloudflare-ի տակ, այդպիսով անվճար ապահովելով արագ загрузка և բարձր uptime: Հաճախ է, կարելի է մոռանալ դրանց մասին, և եթե մեզ հաջողված է, և խաղը հայտնի դառնա, ապա ենթակառուցվածքը վերաքարևորել անհրաժեշտություն չի լինի.
Այդուհանդերձ, համակարգը աշխատելու համար մենք ստիպված կլինենք օգտագործել արտաքին ճարտարապետություն:
- Մեկ կամ մի քանի STUN սերվերներ. մենք ունենք մի քանի անվճար տարբերակների ընտրություն:
- Վրացական TURN ծառ: այստեղ անվճար տարբերակներ չկան, այնպես որ մենք կարող ենք կամ մեր սեփականը կազմաձևել, կամ վճարել ծառայության համար: Բախտի վրա, ամենից շատ ժամանակ կապը կարելի է ստեղծել STUN սերվերների միջոցով (և իրական p2p ապահովել), բայց TURN-ը անհրաժեշտ է որպես պահուստային տարբերակ:
- Նշանակման սերվեր. երեք այլ ասպեկտների համեմատ, նշումը չի ստանդարտացվել: Այն, ինչի համար նշման սերվերը իրականում պատասխանատվություն ունի, ինչ-որ չափով կախված է հավելվածից: Մեր դեպքում, կապը ստեղծելուց առաջ անհրաժեշտ է փոխանակվել փոքր քանակությամբ տվյալներով:
- Teeworlds մաստեր սերվեր. այն օգտագործվում է այլ սերվերների կողմից սեփական առկայության մասին հաղորդելու համար և հաճախորդների կողմից հանրային սերվերներ որոնելու համար: Սակայն այն պարտադիր չէ (հաճախորդները միշտ կարող են ծանոթ սերվերին միանալ ձեռքով), լավ կլիներ ունենալ այն, որպեսզի խաղացողները կարողանան մասնակցել խաղերին պատահական մարդկանց հետ:
Մենք որոշեցինք օգտագործել Google-ի անվճար STUN սերվերները, իսկ մեկ TURN սերվեր սահմանեցինք ինքնուրույն:
Վերջին երկու կետերի համար մենք օգտագործեցինք :
- Teeworlds մաստեր սերվերը շատ պարզ է իրականացված՝ որպես տվյալների օբյեկտների ցանկ, որոնք պարունակում են յուրաքանչյուր ակտիվ սերվերի մասին տեղեկություն (անուն, IP, քարտ, ռեժիմ և այլն): Սերվերները հրապարակում և թարմացնում են իրենց սեփական օբյեկտը, իսկ հաճախորդները վերցնում են ամբողջ ցանկը և ցուցադրում այն խաղացողին: Նաեւ մենք ցուցադրում ենք այդ ցանկը գլխավոր էջում որպես HTML, որպեսզի խաղացողները պարզապես սեղմեն սերվեր և անմիջապես մտնեն խաղ:
- Նշումը սերտորեն կապված է մեր ս็ուակայնների իրականացման հետ, որը նկարագրված է հաջորդ բաժնում:

Խաղի ներսում և գլխավոր էջում սերվերների ցանկ
Սոկետների իրականացում
Մենք ցանկանում ենք ստեղծել API, որքան հնարավոր է մոտ Posix UDP Sockets-ին, որպեսզի նվազեցնենք անհրաժեշտ փոփոխությունների քանակը:
Նաեւ մենք ցանկանում ենք իրականացնել անհրաժեշտ նվազագույնը, որը պահանջվում է պարզ տվյալներ փոխանակելու համար:
Օրինակ, մեզ իրական երթուղավորում չի պետք: Բոլոր պիրերը գտնվում են մեկ «տարրալանջային LAN»-ում, որը կապված է կոնկրետ Firebase տվյալների բազայի օրինակին:
Այսպիսով, մեզ անհրաժեշտ չեն եզակի IP հասցեներ. պիրերի.unique ճանաչման համար բավական է օգտագործել եզակի 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-ն պարզ է և նման է Posix Sockets API-ին, սակայն ունի մի քանի կարևոր տարբերություններ: կանչերի գրանցում, տեղի IP-ների մակնշում և «յոթ երկրներից» միացում.
Կանչերի գրանցում
Ա même եթե նախնական ծրագիրը օգտագործում է ոչ ապ挡ող մուտք/ռուսա, ծանուցարանները կոդը պետք է վերև կույելվական լինի ծանոթ добиться в веб-բրաուզերի:
Այս հանգամանքը պայմանավորված է նրանով, որ բրաուզերի իրադարձությունների ցիկլը թաքնված է ծրագրից (որն է JavaScript կամ WebAssembly):
Տեղական միջավայրում մենք կարող ենք գրել կոդը նման կերպ
while(running) {
select(...); // սպասել I/O իրադարձությունների
while(true) {
int r = readfrom(...); // փորձել կարդալ
if (r < 0 && errno == EWOULDBLOCK) // լրիվ տվյալներ չունենք
break;
...
}
...
}Եթե մեր համար իրադարձությունների ցիկլը թաքնված է, մենք պետք է այն փոխենք նման բաների:
auto cb = []() { // սա կանչվի, երբ նոր տվյալներ հասանելի լինեն
while(true) {
int r = readfrom(...); // փորձել կարդալ
if (r < 0 && errno == EWOULDBLOCK) // լրիվ տվյալներ չունենք
break;
...
}
...
};
recvCallback(cb); // գրանցել կ callback-ըՏեղական IP-ների մակնշում
Մեր «ցանցի» հանգույցների նույնականացնողները չեն հանդիսանում IP-հասցեներ, այլ Firebase բանալիներ (այսինքն տողեր, որոնք նայում են այսպես: -LmEC50PYZLCiCP-vqde ).
Սա հարմար է, որովհետև մեզ պետք չէ IP-ների մակնշման մեխանիզմ և նրանց միանձնության ստուգում (և նրանց վերամշակումը կ cliente-երի անջատումից), բայց հաճախ անհրաժեշտ է նույնականացնել հզորները թվային արժեքով:
Իրենց համար այդ ֆունկցիաները դուրս էին 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-ի «ձեռքի սեղմումը», եթե երկու զույգերը դեռ չեն միավորված միմյանց հետ: Բազմաթիվ նավահանգիստներով մեկ զույգն օգտագործում է նույն WebRTC DataChannel-ը:
Այնպես էլ կատարում ենք անուղղակի bind(), որպեսզի սերվերը կարողանա վերականգնել կապը հաջորդ sendto() հնարավորության դեպքում, եթե այն ինչ-որ պատճառներով փակված լինի:
Սերվերը տեղեկացվում է հաճախորդի միացման մասին, երբ հաճախորդը իր SDP առաջարկը գրանցում է սերվերի նավահանգստի տեղեկանքի տակ Firebase-ում, եւ սերվեր հ respondsպատասխանում է այնտեղ:
Ներքեւում ներկայացված սխեմայում նայում է մեսիջների շարժը սերվերի եւ հաճախորդի միջեւ առաջին հաղորդագրության հանձնումի համար:

Ամբողջ սխեման կապի փուլը հաճախորդի եւ սերվերի միջեւ:
Ավարտ
Եթե դուք վերջացնում եք կարդալ, ապա ձեզ հավանաբար հետաքրքրում է տեսնել տեսությունը գործնականում: Խաղը կարելի է խաղալ , փորձեք:
Անհրաժեշտ հանդիպում աշխատակիցների միջեւ:
Ձեռնարկի ցանցային գրադարանը ազատ է հասանելի . Միացեք մեր ալիքով !
Ընտանիք: habr.com
