Hyrje
Kompania jonë ofron zgjidhje për portimin e aplikacioneve tradicionale desktop në web. Kompajleri ynë C++ gjen një kombinim të WebAssembly dhe JavaScript, që siguron dhe , dhe performancë të lartë.
Si një shembull të përdorimit të tij, ne vendosëm të portojmë për ueb një lojë shumëlojëshe dhe zgjodhëm për këtë . Teeworlds është një lojë shumëlojëshe dy-dimensionale retro me një komunitet të vogël, por aktiv lojtarësh (përfshirë dhe mua!). Ajo është e vogël si nga pikëpamja e burimeve të shkarkuara, ashtu dhe nga kërkesat për CPU dhe GPU — kandidati ideal.

Teeworlds që punon në shfletues
Ne vendosëm të përdorim këtë projekt për të eksperimentuar me zgjidhjet e përgjithshme për portimin e kodit rrjet në web. Zakonisht kjo realizohet nëpërmjet mënyrave të mëposhtme:
- XMLHttpRequest/fetch, nëse pjesa rrjetore përbëhet vetëm nga kërkesa HTTP, ose
- WebSockets.
Të dyja zgjidhjet kërkojnë që komponenti server të hostohet nga ana e serverit, dhe asnjëra nga ato nuk lejon përdorimin e protokollit të transportit . Kjo është e rëndësishme për aplikacionet e kohës reale, siç janë softuerët për videokonferenca dhe lojërat, sepse garancitë për dorëzim dhe renditjen e paketave të protokollit mund të bëhen pengesë për vonesa të ulëta.
Ka dhe një rrugë të tretë — të përdorim rrjetin nga shfletuesi: .
mbështet si transmetimin e sigurt ashtu edhe atë të pasigurt (në rastin e fundit përpiqet të përdorë si protokoll transporti UDP), dhe mund të përdoret si me një server të largët, ashtu dhe midis shfletuesve. Kjo do të thotë se mund të portojmë në shfletues të gjithë aplikacionin, duke përfshirë edhe komponentin server!
Megjithatë, kjo vjen me një vështirësi shtesë: para se dy pira WebRTC të mund të shkëmbejnë të dhëna, ata duhet të kryejnë një procedurë relativisht të komplikuar "ndërlidhjeje" (handshake) për t'u lidhur, për të cilën nevojiten disa entitete të jashtme (serveri i sinjalizimit dhe një ose më shumë servera /).
Idealisht, ne do të donim të krijonim një API rrjeti, brenda së cilës përdorim WebRTC, por sa më afër mundësive të ndërfaqes UDP Sockets, të cilat nuk kërkojnë krijimin e një lidhjeje.
Kjo do të na lejojë të shfrytëzojmë avantazhet e WebRTC pa pasur nevojë të zbulojmë detajet e komplikuara të kodit të aplikacionit (të cilin në projektin tonë dëshirojmë ta ndryshojmë sa më pak të jetë e mundur).
Minimumi WebRTC
WebRTC është një grup API-sh që ekziston në shfletues, që siguron transmetimin peer-to-peer të zërit, videos dhe të dhënave të rastësishme.
Konektoni midis peer-ëve vendoset (edhe nëse ka NAT në njërën ose të dy anët) duke përdorur serverët STUN dhe/ose TURN përmes një mekanizmi që quhet ICE. Peer-ët ndajnë informacionin ICE dhe parametrat e kanaleve përmes ofertës dhe përgjigjes së protokollit SDP.
Wow! Sa shumë akronime në një herë. Le të shpjegojmë shkurtimisht se çfarë do të thotë këto terma:
- — një protokoll për të kaluar NAT dhe për të marrë një çift (IP, port) për të ndarë të dhëna direkt me hostin. Nëse arrin ta realizojë këtë, atëherë peer-ët mund të ndajnë të dhëna midis tyre vetë.
- po ashtu përdoret për të kaluar NAT, por e realizon këtë duke drejtuar të dhënat përmes një proxy, që është e dukshme për të dy peer-ët. Ai shton vonesa dhe është më i shtrenjtë në ekzekutim se STUN (për shkak se përdoret gjatë gjithë sesionit të komunikimit), por ndonjëherë ky është opsioni i vetëm i mundshëm.
- përdoret për të zgjedhur mënyrën më të mirë të mundshme për të lidhur dy peer-e në bazë të informacionit të marrë nga lidhja direkte midis tyre, si dhe informacionit të marrë nga ndonjë numër serverësh STUN dhe TURN.
- — është një format për përshkrimin e parametrave të kanalit të lidhjes, për shembull, kandidatë ICE, kodekët multimedia (në rastin e një kanali audio/video), etj… Një nga peer-ët dërgon një SDP Offer („ofertë“), ndërsa tjetri përgjigjet me një SDP Answer („përgjigje“). Pas kësaj krijohet një kanal.
Për të krijuar një lidhje të tillë, peer-ët duhet të mblidhen informacionin që kanë marrë nga serverët STUN dhe TURN, dhe ta ndajnë atë me njëri-tjetrin.
Problemi është se ata akoma nuk kanë mundësi të ndajnë të dhëna direkt, kështu që për ndarjen e këtyre të dhënave duhet të ekzistojë një mekanizëm jashtëlini: serveri i sinjalizimit.
Serveri i sinjalizimit mund të jetë shumë i thjeshtë, sepse detyra e tij e vetme është të drejtojë të dhënat midis peer-ëve në fazën e 'dorëzimit' (siç tregohet në diagramin më poshtë).

Schema e thjeshtuar e sekuencës "shtrëngimi i duarve" WebRTC
Përgjithësimi i modelit të rrjetit Teeworlds
Arkitektura e rrjetit Teeworlds është shumë e thjeshtë:
- Komponentët e klientit dhe serverit janë dy programe të ndryshme.
- Klientët hyjnë në lojë duke u lidhur me njërin nga disa serverë, secili nga të cilët hoston vetëm një lojë në një kohë.
- Të gjitha informacionet e transmetuara në lojë realizohen përmes serverit.
- Një server i veçantë master përdoret për të mbledhur listën e të gjithë serverëve publikë që shfaqen në klientin e lojës.
Falë përdorimit të WebRTC për shkëmbimin e të dhënave, ne mund të çojmë komponentin server të lojës në shfletues, ku ndodhet klienti. Kjo na jep një mundësi të shkëlqyer…
Të heqim dorë nga serverët
Mungesa e logjikës server ka një përparësi të këndshme: ne mund ta vendosim të gjithë aplikacionin si përmbajtje të statikës në Github Pages ose në pajisjet tona përmes Cloudflare, duke siguruar kështu ngarkime të shpejta dhe një uptime të lartë falas. Në thelb, do të mund të harrojmë për ta, dhe nëse jemi me fat dhe loja bëhet popullore, praktikisht nuk do të jetë e nevojshme të modernizojmë infrastrukturen.
Megjithatë, që sistemi të funksionojë, do të duhet akoma të përdorim një arkitekturë të jashtme:
- Një ose disa serverë STUN: ne kemi zgjedhjen nga disa variante falas.
- Të paktën një server TURN: këtu nuk ka variante falas, prandaj mund të vendosim të konfiguroni një tonë ose të paguajmë për një shërbim. Fatmirësisht, shumicën e kohës lidhja do të jetë e mundur përmes serverëve STUN (dhe të sigurojmë p2p të vërtetë), por TURN është e nevojshme si një mundësi rezervë.
- Serveri i sinjalizimit: ndryshe nga dy aspektet e tjera, sinjalizimi nuk është standardizuar. Ajo për të cilën do të përgjigjet realisht serveri i sinjalizimit, në disa mënyra varet nga aplikacioni. Në rastin tonë, para se të vendoset lidhja, është e nevojshme të shkëmbehet një sasi e vogël të dhënash.
- Serveri master Teeworlds: ai përdoret nga serverët e tjerë për të njoftuar ekzistencën e tij dhe nga klientët për të kërkuar serverë publikë. Megjithëse nuk është e detyrueshme (klientët gjithmonë mund të lidhen manualisht me serverin e njohur), do të ishte mirë që të kishte një të tillë në mënyrë që lojtarët të mund të merrnin pjesë në lojëra me njerëz të rastit.
Ne vendosëm të përdorim serverët STUN falas nga Google, ndërsa një server TURN e vendosëm vetë.
Për dy piketat e fundit, ne përdorëm :
- Master serveri i Teeworlds është realizuar shumë thjesht: si një listë objektesh që përmbajnë informacionin (emri, IP, harta, moda, ...) e çdo serveri aktiv. Serverët publikojnë dhe azhurnojnë objektin e tyre, ndërsa klientët marrin të gjithë listën dhe e shfaqin atë për lojtarin. Gjithashtu, ne e shfaqim listën në faqen kryesore si HTML, në mënyrë që lojtarët të mund të klikojnë thjesht në server dhe të hyjnë direkt në lojë.
- Sinjalizimi është ngushtësisht i lidhur me realizimin tonë të soketëve, të përshkruar në seksionin në vijim.

Lista e serverëve brenda lojës dhe në faqen kryesore
Realizimi i soketëve
Ne duam të krijojmë një API sa më të afërt me Posix UDP Sockets, për të minimizuar numrin e ndryshimeve të nevojshme.
Po ashtu, ne dëshirojmë të realizojmë minimumin e nevojshëm për shkëmbimin më të thjeshtë të të dhënave përmes rrjetit.
Për shembull, ne nuk kemi nevojë për rrugëzim të vërtetë: të gjithë peerët ndodhen në një ‘LAN virtuale’, të lidhur me një instancë specifike të bazës së të dhënave Firebase.
Prandaj, ne nuk kemi nevojë për adresa IP unike: për identifikimin unik të peerëve mjafton të përdorim vlerat unike të çelësave të Firebase (në analogji me emrat e domain-it), dhe çdo peer cakton lokal ‘adresa IP të rreme’ çdo çelësi që duhet të konvertohet. Kjo na çliron plotësisht nga nevoja për caktimin global të adresave IP, që është një detyrë jo triviale.
Ja API minimale që na nevojitet të realizojmë:
// 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 është i thjeshtë dhe ngjan me API-në e Soketëve Posix, por ka disa dallime të rëndësishme: regjistrimi i thirrjeve të prapave, caktimi i IP-ve lokale dhe ‘lidhja e ngadaltë’.
Regjistrimi i thirrjeve të prapave
Edhe nëse programi origjinal përdor hyrje-dalje jo bllokuese, për ta ekzekutuar në shfletuesin e internetit, kodi duhet të ristrukturohet.
Arsyeja për këtë është se cikli i ngjarjeve në shfletues është i fshehur nga programi (qoftë JavaScript ose WebAssembly).
Në mjedisin natyror, ne mund të shkruajmë kod në këtë mënyrë
while(running) {
select(...); // prisni për ngjarjet I/O
while(true) {
int r = readfrom(...); // provoni të lexoni
if (r < 0 && errno == EWOULDBLOCK) // nuk ka më të dhëna të disponueshme
break;
...
}
...
}Nëse cikli i ngjarjeve është i fshehur nga ne, duhet ta kthejmë atë në diçka të tillë:
auto cb = []() { // kjo do të thirret kur të dhënat e reja të jenë të disponueshme
while(true) {
int r = readfrom(...); // provoni të lexoni
if (r < 0 && errno == EWOULDBLOCK) // nuk ka më të dhëna të disponueshme
break;
...
}
...
};
recvCallback(cb); // regjistro thirrjen e prapaveQëllimi i IP-ve lokale
Identifikuesit e node-ve në "rrjetin" tonë nuk janë adresa IP, por çelësa Firebase (kjo janë vargje që duken kështu: -LmEC50PYZLCiCP-vqde ).
Kjo është e dobishme sepse nuk na nevojitet një mekanizëm për caktimin e IP-ve dhe verifikimin e unikalitetit të tyre (po ashtu dhe për riciklimin e tyre pas çaktivizimit të klientit), por shpesh është e nevojshme të identifikosh peer-et në bazë të një vlerës numerike.
Pikërisht për këtë përdoren funksionet resolve dhe reverseResolve: aplikacioni në një mënyrë merr vlerën string të çelësit (nëpërmjet hyrjes së përdoruesit ose përmes serverit kryesor), dhe mund ta konvertojë atë në një adresë IP për përdorim të brendshëm. Pjesa tjetër e API gjithashtu për thjeshtësi merr këtë vlerë në vend të vargut.
Kjo është e ngjashme me kërkimin DNS, vetëm se kryhet lokalisht te klienti.
Kështu që adresat IP nuk mund të jenë të zakonshme për klientë të ndryshëm, dhe nëse nevojitet një identifikues global, atëherë do të duhet të gjenerohet në një mënyrë tjetër.
Koneksioni lenvë
UDP nuk kërkon një lidhje, por, siç pamë, përpara se të fillojë kalimi i të dhënave mes dy peer-ëve, WebRTC kërkon një proces të gjatë lidhjeje.
Nëse duam të sigurojmë të njëjtin nivel abstrahimi, (sendto/recvfrom me peer të rastësishëm pa lidhje paraprake), atëherë duhet të kryejmë një lidhje "lenva" në brendësi të API.
Kjo është ajo që ndodh gjatë shkëmbimit të zakonshëm të të dhënave mes "serverit" dhe "klientit" në rastin e përdorimit të UDP, dhe çfarë duhet të realizojë biblioteka jonë:
- Serveri thërret
bind(), për të informuar sistemin operativ se dëshiron të marrë paketa në portin e caktuar.
Në vend të kësaj, ne do të publikojmë një port të hapur në Firebase nën çelësin e serverit dhe do të dëgjojmë ngjarjet në nënpjesën e tij.
- Serveri thërret
recvfrom(), duke pranuar në këtë port paketa që vijnë nga çdo host.
Në rastin tonë, duhet të kontrollojmë radhën hyrëse të paketimeve të dërguara në këtë port.
Çdo port ka radhën e tij të vetme, dhe ne shtojmë në fillim të datagramëve WebRTC portet burimore dhe të fundit, në mënyrë që të dimë se në cilën radhë të riorientojmë kur arrin një paketë të re.
Thirrja është jo-bllokuese, kështu që nëse nuk ka paketa, ne thjesht kthejmë -1 dhe caktojmë errno=EWOULDBLOCK.
- Klienti merr nëpërmjet mjeteve të jashtme IP-në dhe portin e serverit, dhe thërret
sendto(). Po ashtu, gjatë kësaj kryhet një thirrje e brendshmebind(), kështu që thirrja e mëpasshmerecvfrom()do të marrë përgjigje pa ekzekutimin e qartë të bind.
Në rastin tonë, klienti merr në mënyrë të jashtme një çelës string dhe përdor funksionin resolve() për të marrë adresën IP.
Në këtë fazë, ne fillojmë "dëshirat" WebRTC, nëse dy peer nuk janë ende të lidhur me njëri-tjetrin. Koneksionet në porte të ndryshme të një peer përdorin të njëjtin DataChannel WebRTC.
Po ashtu ne kryejmë një tërheqje bind(), për t'i mundësuar serverit të rikthejë lidhjen në sendto() rast se ajo u mbyll për ndonjë arsye të caktuar.
Serveri njoftohet për lidhjen e klientit kur klienti regjistron ofertën e tij SDP nën informacionin e portës së serverit në Firebase, dhe serveri përgjigjet me përgjigjen e tij atje.
Në diagramin e treguar më poshtë, një shembull i lëvizjes së mesazheve për skemën e soketeve dhe dërgimin nga klienti në server të mesazhit të parë është paraqitur:

Diagrami i plotë i fazës së lidhjes midis klientit dhe serverit
Përfundim
Nëse e keni lexuar deri në fund, ndoshta jeni të interesuar të shihni teorinë në veprim. Mund të luani në , provoni!
Një ndeshje miqësore midis kolegëve
Kodi i bibliotekës së rrjetit është i disponueshëm falas në . Bashkohuni në bisedë në kanalimizin tonë në !
Burimi: habr.com
