Hyrje
Kompania jonë ofron zgjidhje për portimin e aplikacioneve tradicionale desktop në web. Kompileri ynë C++ gjeneron një kombinim të WebAssembly dhe JavaScript, që siguron , dhe një performancë të lartë.
Si një shembull të aplikimit të tij, vendosëm të portojmë për web një lojë multiplayer dhe zgjodhëm . Teeworlds është një lojë retro dy-dimensionale multiplayer me një komunitet të vogël, por aktiv lojtarësh (përfshirë edhe mua!). Ajo është e vogël sa i përket burimeve që shkarkohet dhe kërkesave për CPU dhe GPU — një kandidat ideal.

Teeworlds që funksionon në shfletues
Vendosëm ta përdorim këtë projekt për të eksperimentuar me zgjidhje të përgjithshme për portimin e kodit rrjetor në web. Zakonisht, kjo kryhet 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ë të hospedosh komponentin server në anën e serverit, dhe asnjëra prej tyre nuk lejon përdorimin si protokoll transporti . Kjo është e rëndësishme për aplikacione në kohë reale, si softi për videokonferenca dhe lojra, për shkak se garancitë e dorëzimit dhe rendit të paketave të protokollit. mund të krijojnë pengesa për vonesa të ulëta.
Ka një rrugë të tretë — të përdorësh një rrjet nga shfletuesi: .
mbështet si transmetim të besueshëm ashtu edhe të pabesueshëm (në rastin e dytë, ai përpiqet të përdorë UDP si protokoll transporti kur është e mundur) dhe mund të përdoret si me një server të shfletuesit, ashtu edhe mes shfletuesve. Kjo do të thotë se mund të portojmë gjithë aplikacionin në shfletues, përfshirë edhe komponentin server!
Megjithatë, kjo ka një vështirësi shtesë: para se dy peer të WebRTC të mund të shkëmbejnë të dhëna, ata duhet të kryejnë një procedurë relativisht të ndërlikuar të "duarve" (handshake) për të lidhur, për të cilin kërkohen disa entitete të jashtme (serveri i sinjalizimit dhe një ose më shumë servera, /).
Idealisht do të donim të krijonim një API rrjeti, brenda të cilit të përdorej WebRTC, por sa më afër që të ishte me ndërfaqen e UDP Sockets, e cila nuk kërkon lidhje.
Kjo do t'u lejojë të shfrytëzojmë përfitimet e WebRTC pa nevojën për të zbuluar detaje të komplikuara në kodin e aplikacionit (të cilin në projektin tonë donim ta ndryshonim sa më pak).
WebRTC Minimal
WebRTC është një grup API-sh që ekziston në shfletues, i cili siguron transmetimin peer-to-peer të zërit, videos dhe të dhënave të përgjithshme.
Lidhja midis peer-ve vendoset (edhe nëse ka NAT nga njëra ose të dyja anët) përmes serverëve STUN dhe/ose TURN përmes një mekanizmi të quajtur ICE. Peer-t shkëmbejnë informacionin ICE dhe parametrat e kanaleve përmes ofertave dhe përgjigjeve të protokollit SDP.
Wow! Sa shumë akronime në një herë. Le të shpjegojmë shkurtimisht se çfarë do të thonë këto koncepte:
- — një protokoll për të anashkaluar NAT dhe për të marrë një çift (IP, port) për shkëmbimin e të dhënave direkt me host-in. Nëse ia del të kryejë detyrën e tij, atëherë peer-t mund të shkëmbejnë të dhëna me njëri-tjetrin në mënyrë të pavarur.
- përdoret gjithashtu për të anashkaluar NAT, por e realizon këtë duke drejtuar të dhënat përmes një proksi, i cili është i dukshëm për të dy palët. Kjo shton vonesë dhe është më e kushtueshme për t'u zbatuar se STUN (sepse aplikohet 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 lidhjen e dy palëve bazuar në informacionin e marrë gjatë lidhjes direkte të palëve, si dhe informacionin e marrë nga çdo numër serverash STUN dhe TURN.
- është një format përshkrimi i parametrave të kanalit të lidhjes, për shembull, kandidatët ICE, kodekët multimedia (në rastin e kanaleve audio/video), etj... Njëra palë dërgon një ofertë SDP («oferta»), dhe tjetra përgjigjet me një përgjigje SDP («përgjigja»). Pas kësaj krijohet kanali.
Për të krijuar një lidhje të tillë, palët duhet të grumbullojnë informacionin e marrë nga serverat STUN dhe TURN dhe ta ndajnë atë me njëra-tjetrën.
Problemi është se ato për momentin nuk kanë mundësinë për të ndarë të dhëna direkt, prandaj për ndarjen e këtyre të dhënave duhet të ekzistojë një mekanizëm jashtë-banda: serveri i sinjalizimit.
Serveri sinjalizimi mund të jetë shumë i thjeshtë, sepse detyra e tij e vetme është të drejtosh të dhënat midis pirat në fazën e "shtrëngimit të duarve" (siç është treguar në diagramin më poshtë).

Diagrami i thjeshtuar i sekuencës së "shtrëngimit të duarve" WebRTC
Përmbledhje e modelit rrjetor 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ë nga disa serverë, secili prej të cilëve ndan një lojë në një kohë.
- Të gjitha transferimet e të dhënave në lojë bëhen përmes serverit.
- Një server i veçantë master përdoret për të mbledhur listën e të gjithë serverëve publikë, të cilët shfaqen në klientin e lojës.
Falë përdorimit të WebRTC për shkëmbimin e të dhënave, ne mund ta transferojmë komponentin server të lojës në shfletues, ku ndodhet klienti. Kjo na ofron një mundësi të shkëlqyer…
Të heqim qafe serverët
Mungesa e logjikës së serverëve ka një avantaj të këndshëm: mund të zhvillojmë tërë aplikacionin si përmbajtje statike në Github Pages ose në pajisjet tona pas Cloudflare, duke siguruar kështu ngarkime të shpejta dhe një kohëzurri të lartë falas. Në thelb, mund të harrohet për to, dhe nëse na fat, dhe loja bëhet popullore, atëherë infrastruktura nuk do të kërkojë modernizim.
Megjithatë, për të funksionuar sistemi, gjithsesi do të na duhen struktura e jashtme:
- Një ose disa serverë STUN: kemi mundësinë të zgjedhim nga disa mundësi falas.
- Të paktën një server TURN: këtu nuk ka mundësi falas, prandaj mund të konfiguroni një tonë ose të paguani për shërbimin. Fatmirësisht, pjesën më të madhe të kohës lidhja do të mund të vendoset përmes serverëve STUN (dhe të sigurojë vërtet p2p), por TURN është e nevojshme si një mundësi rezervë.
- Serveri i sinjalit: ndryshe nga dy aspektet e tjera, sinjalizimi nuk është standardizuar. Ajo për të cilën në të vërtetë do të përgjigjet serveri i sinjalit, në një farë mënyre varet nga aplikacioni. Në rastin tonë, para se të vendoset lidhja, është e nevojshme të shkëmbehen disa të dhëna të vogla.
- Serveri kryesor i 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ë. Ndërsa ai nuk është i detyrueshëm (klientët gjithmonë mund të lidhen manualisht me një server të njohur), do të ishte mirë të kishte një të tillë, në mënyrë që lojtarët të mund të merrnin pjesë në lojëra me njerëz të rastësishëm.
Ne vendosëm të përdorim serverët falas STUN nga Google, dhe një server TURN e ngritëm në vetvete.
Për dy pika të fundit, ne përdorëm :
- Serveri master Teeworlds është implementuar shumë lehtë: si një listë objektesh që përmbajnë informacion (emri, IP, harta, mode, …) për çdo server aktiv. Serverët publikojnë dhe përditësojnë objektin e tyre, ndërsa klientët e marrin të gjithë listën dhe e shfaqin atë për lojtarin. Ne gjithashtu shfaqim listën në faqen kryesore si HTML, në mënyrë që lojtarët të mund të klikojnë direkt në server dhe të hyjnë në lojë.
- Sinjalizimi është ngushtësisht i lidhur me realizimin tonë të socket-eve, të përshkruar në seksionin në vijim.

Lista e serverëve brenda lojës dhe në faqen kryesore
Realizimi i socket-eve
Dëshirojmë të krijojmë një API sa më afër socket-eve UDP Posix, për të minimizuar numrin e ndryshimeve të nevojshme.
Po ashtu, duam të zbatojmë minimumin e nevojshëm që kërkohet për shkëmbimin më të thjeshtë të të dhënave në rrjet.
Për shembull, nuk na nevojitet një ruter i vërtetë: të gjithë peer-t janë në një 'LAN virtuale', e lidhur me një instancë specifike të bazës së të dhënave Firebase.
Prandaj, nuk na duhen adresa IP unike: për identifikimin unik të peer-eve mjafton të përdoren vlera unike çelash Firebase (si emrat e domain-eve), dhe çdo peer cakton lokalisht adresa IP "fiktive" për çdo çelës që duhet të konvertohet. Kjo na liron plotësisht nga nevoja për caktimin global të adresave IP, që është një detyrë e ndërlikuar.
Ja minimalja API 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ë e thjeshtë dhe e ngjashme me API-në Posix Sockets, por ka disa karakteristika të rëndësishme: regjistrimi i thirrjeve të prapme, caktimi i IP-ve lokale dhe lidhja "lenive".
Regjistrimi i thirrjeve të prapme
Madje edhe nëse programi fillestar përdor hyrje-dalje jo bllokuese, për ta ekzekutuar në një shfletues interneti, 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 mund të shkruajmë kodin në këtë mënyrë
while(running) {
select(...); // prit për ngjarje I/O
while(true) {
int r = readfrom(...); // provo të lexosh
if (r < 0 && errno == EWOULDBLOCK) // s’ka më të dhëna të disponueshme
break;
...
}
...
}Nëse cikli i ngjarjeve është i fshehur për ne, atëherë duhet ta shndërrojmë në diçka të ngjashme:
auto cb = []() { // kjo do të thërritet kur janë të dhëna të reja në dispozicion
while(true) {
int r = readfrom(...); // përpiquni të lexoni
if (r < 0 && errno == EWOULDBLOCK) // nuk ka më të dhëna të disponueshme
break;
...
}
...
};
recvCallback(cb); // regjistrojini thirrjen e prapambetjesCaktoni IP lokale
Identifikuesit e nyjave në 'rrjetin' tonë nuk janë adresa IP, por çelësa Firebase (këto janë vargje që duken si: -LmEC50PYZLCiCP-vqde ).
Kjo është e përshtatshme, sepse nuk kemi nevojë për një mekanizëm për caktimin e IP-ve dhe kontrollimin e unikësisë së tyre (si dhe të heqim ato pas çaktivizimit të klientit), por shpesh është e nevojshme të identifikoni peers me një vlerë numerike.
Pikërisht për këtë përdoren funksionet resolve dhe reverseResolve: aplikacioni në njëfarë mënyre merr vlerën e vargut 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-së gjithashtu për thjeshtësi merr këtë vlerë në vend të vargut.
Kjo është si një kërkesë DNS, vetëm se realizohet lokalisht tek klienti.
Pra, adresat IP nuk mund të jenë të zakonshme për klientë të ndryshëm, dhe nëse nevojitet një identifikues global, duhet ta gjeneroni ndryshe.
Lidhje e lenë
UDP nuk kërkon lidhje, por, siç e pamë, përpara se të fillojmë transferimin e të dhënave midis dy pirat, WebRTC kërkon një proces të gjatë lidhjeje.
Nëse duam të sigurojmë të njëjtin nivel abstraksioni, (sendto/recvfrom me pira të rastësishme pa lidhje paraprake), atëherë duhet të kryejmë lidhje "të ngadalshme" (të vonuar) brenda API-së.
Këtu është se çfarë ndodh gjatë shkëmbimit të zakonshëm të të dhënave midis "serverit" dhe "klientit" kur përdoret UDP, dhe çfarë duhet të bëjë biblioteka jonë:
- Serveri thërret
bind(), për t'i njoftuar sistemit operativ që dëshiron të marr paketa në portin e caktuar.
Në vend të kësaj, do të publikojmë një port të hapur në Firebase nën çelësin e serverit dhe do të dëgjojmë ngjarjet në nënstrukturë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 vet, dhe ne shtojmë në fillim të datagrameve WebRTC portet e origjinës dhe atyre përfundimtare, për të ditur se në cilën radhë të drejtojmë paketën e re kur arrin.
Thirrja është jo-bllokuese, prandaj nëse nuk ka paketa, ne thjesht kthejmë -1 dhe vendosim errno=EWOULDBLOCK.
- Klienti merr nëpërmjet mjeteve të jashtme IP-në dhe portin e serverit dhe bën thirrjen
sendto(). Po ashtu, këtë realizojmë me një thirrje të brendshmebind(), prandaj e mëposhtmjarecvfrom()do të marrë përgjigje pa realizimin e qartë të bind.
Në rastin tonë, klienti në mënyrë të jashtme merr një çelës në formë stringu dhe përdor funksionin resolve() për të marrë IP-në.
Në këtë fazë, fillojmë "dorëzimin" WebRTC, nëse dy peer-a ende nuk janë lidhur me njëri-tjetrin. Lidhjet në porte të ndryshme të një peer-i përdorin të njëjtin DataChannel WebRTC.
Po ashtu, ne realizojmë një lidhje të dytë bind(), në mënyrë që serveri të mund të rikthejë lidhjen në sendto() rast se ajo është mbyllur për arsye të caktuara.
Serveri njoftohet për lidhjen e klientit, kur klienti regjistron ofertën e tij SDP në informacionin e portit të serverit në Firebase, dhe serveri i përgjigjet atje po ashtu.
Në diagramin e mëposhtëm ilustrohet shembulli i lëvizjes së mesazheve për skemën e soketeve dhe dërgimin e mesazhit të parë nga klienti te serveri:

Skema e plotë e fazës së lidhjes ndërmjet klientit dhe serverit
Përfundimi
Nëse e keni lexuar deri në fund, ndoshta ju intereson të shihni teorinë në veprim. Mund të luani në , provoni!
Një ndeshje miqësore mes kolegësh
Kodi i bibliotekës në rrjet është i disponueshëm falas në . Bashkohuni në diskutimin tonë në kanalin tonë në !
Burimi: habr.com
