การแนะนำ
บริษัท ของเรา
เพื่อเป็นตัวอย่างในการใช้งาน เราตัดสินใจย้ายเกมที่มีผู้เล่นหลายคนไปยังเว็บและเลือก
ทำงานในเบราว์เซอร์ Teeworlds
เราตัดสินใจใช้โปรเจ็กต์นี้เพื่อทดลอง โซลูชันทั่วไปสำหรับการย้ายรหัสเครือข่ายไปยังเว็บ. โดยปกติจะทำด้วยวิธีต่อไปนี้:
- XMLHttpRequest/ดึงข้อมูลหากส่วนของเครือข่ายประกอบด้วยคำขอ HTTP เท่านั้น หรือ
- WebSockets.
โซลูชันทั้งสองจำเป็นต้องโฮสต์ส่วนประกอบเซิร์ฟเวอร์บนฝั่งเซิร์ฟเวอร์ และไม่อนุญาตให้ใช้เป็นโปรโตคอลการขนส่ง
มีวิธีที่สาม - ใช้เครือข่ายจากเบราว์เซอร์:
อย่างไรก็ตาม สิ่งนี้มาพร้อมกับความยากลำบากเพิ่มเติม: ก่อนที่เพื่อน WebRTC สองคนจะสามารถสื่อสารได้ พวกเขาจำเป็นต้องทำการจับมือที่ค่อนข้างซับซ้อนเพื่อเชื่อมต่อ ซึ่งต้องใช้เอนทิตีบุคคลที่สามหลายรายการ (เซิร์ฟเวอร์การส่งสัญญาณและเซิร์ฟเวอร์หนึ่งเครื่องขึ้นไป
ตามหลักการแล้ว เราต้องการสร้าง API เครือข่ายที่ใช้ WebRTC ภายใน แต่มีความใกล้เคียงกับอินเทอร์เฟซ UDP Sockets ที่ไม่จำเป็นต้องสร้างการเชื่อมต่อมากที่สุด
สิ่งนี้จะทำให้เราสามารถใช้ประโยชน์จาก WebRTC ได้โดยไม่ต้องเปิดเผยรายละเอียดที่ซับซ้อนให้กับโค้ดของแอปพลิเคชัน (ซึ่งเราต้องการเปลี่ยนแปลงให้น้อยที่สุดในโครงการของเรา)
WebRTC ขั้นต่ำ
WebRTC คือชุดของ API ที่มีอยู่ในเบราว์เซอร์ที่ให้การส่งข้อมูลเสียง วิดีโอ และข้อมูลที่กำหนดเองแบบเพียร์ทูเพียร์
การเชื่อมต่อระหว่างเพียร์ถูกสร้างขึ้น (แม้ว่าจะมี NAT อยู่ที่ด้านใดด้านหนึ่งหรือทั้งสองด้านก็ตาม) โดยใช้เซิร์ฟเวอร์ STUN และ/หรือ TURN ผ่านกลไกที่เรียกว่า ICE เพื่อนร่วมงานแลกเปลี่ยนข้อมูล ICE และพารามิเตอร์ช่องสัญญาณผ่านข้อเสนอและคำตอบของโปรโตคอล SDP
ว้าว! มีตัวย่อกี่ตัวในคราวเดียว? เรามาอธิบายสั้นๆ ว่าคำเหล่านี้หมายถึงอะไร:
ยูทิลิตี้การข้ามเซสชันสำหรับ NAT (งัน) — โปรโตคอลสำหรับการเลี่ยงผ่าน NAT และรับคู่ (IP, พอร์ต) เพื่อแลกเปลี่ยนข้อมูลโดยตรงกับโฮสต์ หากเขาจัดการงานของเขาให้สำเร็จ เพื่อนร่วมงานก็สามารถแลกเปลี่ยนข้อมูลระหว่างกันได้อย่างอิสระการสำรวจโดยใช้รีเลย์รอบ NAT (กลับ) ยังใช้สำหรับการแวะผ่าน NAT ด้วย แต่จะใช้สิ่งนี้โดยการส่งต่อข้อมูลผ่านพร็อกซีที่มองเห็นได้สำหรับเพียร์ทั้งสอง มันเพิ่มเวลาแฝงและมีราคาแพงกว่าในการใช้งานมากกว่า STUN (เพราะว่ามันถูกใช้ตลอดเซสชันการสื่อสารทั้งหมด) แต่บางครั้งก็เป็นเพียงตัวเลือกเดียวการสร้างการเชื่อมต่อแบบโต้ตอบ (ICE) ใช้เพื่อเลือกวิธีที่ดีที่สุดในการเชื่อมต่อเพียร์สองตัวตามข้อมูลที่ได้รับจากการเชื่อมต่อเพียร์โดยตรง รวมถึงข้อมูลที่ได้รับจากเซิร์ฟเวอร์ STUN และ TURN จำนวนเท่าใดก็ได้โปรโตคอลคำอธิบายเซสชัน (SDP) เป็นรูปแบบสำหรับอธิบายพารามิเตอร์ช่องสัญญาณการเชื่อมต่อ เช่น ตัวเลือก ICE, ตัวแปลงสัญญาณมัลติมีเดีย (ในกรณีของช่องเสียง/วิดีโอ) ฯลฯ... เพื่อนคนหนึ่งส่งข้อเสนอ SDP และคนที่สองตอบกลับด้วยคำตอบ SDP . . หลังจากนี้จะมีการสร้างช่อง
ในการสร้างการเชื่อมต่อดังกล่าว เพื่อนร่วมงานจำเป็นต้องรวบรวมข้อมูลที่ได้รับจากเซิร์ฟเวอร์ STUN และ TURN และแลกเปลี่ยนข้อมูลระหว่างกัน
ปัญหาคือพวกเขายังไม่มีความสามารถในการสื่อสารโดยตรง ดังนั้นจึงต้องมีกลไกนอกวงเพื่อแลกเปลี่ยนข้อมูลนี้: เซิร์ฟเวอร์การส่งสัญญาณ
เซิร์ฟเวอร์การส่งสัญญาณสามารถทำได้ง่ายมาก เนื่องจากหน้าที่เดียวคือการส่งต่อข้อมูลระหว่างเพียร์ในระยะแฮนด์เชค (ดังแสดงในแผนภาพด้านล่าง)
แผนภาพลำดับการจับมือ WebRTC แบบง่าย
ภาพรวมโมเดลเครือข่าย Teeworlds
สถาปัตยกรรมเครือข่าย Teeworlds นั้นง่ายมาก:
- ส่วนประกอบไคลเอนต์และเซิร์ฟเวอร์เป็นสองโปรแกรมที่แตกต่างกัน
- ลูกค้าเข้าสู่เกมโดยการเชื่อมต่อกับเซิร์ฟเวอร์ใดเซิร์ฟเวอร์หนึ่งจากหลาย ๆ เซิร์ฟเวอร์ ซึ่งแต่ละเซิร์ฟเวอร์จะโฮสต์เกมเพียงครั้งละหนึ่งเกมเท่านั้น
- การถ่ายโอนข้อมูลทั้งหมดในเกมดำเนินการผ่านเซิร์ฟเวอร์
- เซิร์ฟเวอร์หลักพิเศษใช้เพื่อรวบรวมรายชื่อเซิร์ฟเวอร์สาธารณะทั้งหมดที่แสดงในไคลเอนต์เกม
ต้องขอบคุณการใช้ WebRTC สำหรับการแลกเปลี่ยนข้อมูล เราจึงสามารถถ่ายโอนส่วนประกอบเซิร์ฟเวอร์ของเกมไปยังเบราว์เซอร์ที่ไคลเอนต์ตั้งอยู่ได้ นี่ทำให้เราได้รับโอกาสอันดี...
กำจัดเซิร์ฟเวอร์
การไม่มีตรรกะของเซิร์ฟเวอร์มีข้อได้เปรียบที่ดี: เราสามารถปรับใช้แอปพลิเคชันทั้งหมดเป็นเนื้อหาคงที่บน Github Pages หรือบนฮาร์ดแวร์ของเราเองที่อยู่เบื้องหลัง Cloudflare ได้ จึงรับประกันการดาวน์โหลดที่รวดเร็วและมีเวลาให้บริการสูงฟรี ในความเป็นจริง เราสามารถลืมสิ่งเหล่านั้นได้ และหากเราโชคดีและเกมดังกล่าวได้รับความนิยม โครงสร้างพื้นฐานก็ไม่จำเป็นต้องได้รับการปรับปรุงให้ทันสมัย
อย่างไรก็ตาม เพื่อให้ระบบทำงานได้ เรายังต้องใช้สถาปัตยกรรมภายนอก:
- เซิร์ฟเวอร์ STUN หนึ่งเซิร์ฟเวอร์ขึ้นไป: เรามีตัวเลือกฟรีมากมายให้เลือก
- เซิร์ฟเวอร์ TURN อย่างน้อยหนึ่งเซิร์ฟเวอร์: ไม่มีตัวเลือกฟรีที่นี่ ดังนั้นเราจึงสามารถตั้งค่าเองหรือชำระค่าบริการก็ได้ โชคดีที่เวลาส่วนใหญ่สามารถสร้างการเชื่อมต่อผ่านเซิร์ฟเวอร์ STUN (และจัดเตรียม p2p ที่แท้จริง) แต่จำเป็นต้องมี TURN เป็นตัวเลือกสำรอง
- เซิร์ฟเวอร์การส่งสัญญาณ: การส่งสัญญาณไม่ได้มาตรฐานซึ่งต่างจากอีกสองแง่มุมอื่น ๆ สิ่งที่เซิร์ฟเวอร์การส่งสัญญาณจะต้องรับผิดชอบนั้นขึ้นอยู่กับแอปพลิเคชันบ้าง ในกรณีของเรา ก่อนที่จะสร้างการเชื่อมต่อ จำเป็นต้องแลกเปลี่ยนข้อมูลจำนวนเล็กน้อยก่อน
- Teeworlds Master Server: ถูกใช้โดยเซิร์ฟเวอร์อื่นเพื่อโฆษณาการมีอยู่ของพวกเขาและโดยไคลเอนต์เพื่อค้นหาเซิร์ฟเวอร์สาธารณะ แม้ว่าจะไม่จำเป็น (ไคลเอนต์สามารถเชื่อมต่อกับเซิร์ฟเวอร์ที่พวกเขารู้จักด้วยตนเองได้ตลอดเวลา) แต่ก็คงจะดีถ้ามีเพื่อให้ผู้เล่นสามารถเข้าร่วมในเกมกับผู้คนแบบสุ่มได้
เราตัดสินใจใช้เซิร์ฟเวอร์ STUN ฟรีของ Google และปรับใช้เซิร์ฟเวอร์ TURN หนึ่งเซิร์ฟเวอร์ด้วยตัวเราเอง
สำหรับสองจุดสุดท้ายที่เราใช้
- เซิร์ฟเวอร์หลักของ Teeworlds ได้รับการปรับใช้อย่างง่ายดาย: เป็นรายการออบเจ็กต์ที่มีข้อมูล (ชื่อ, IP, แผนที่, โหมด, ...) ของแต่ละเซิร์ฟเวอร์ที่ใช้งานอยู่ เซิร์ฟเวอร์เผยแพร่และอัปเดตออบเจ็กต์ของตนเอง และไคลเอนต์จะนำรายการทั้งหมดไปแสดงให้ผู้เล่นเห็น นอกจากนี้เรายังแสดงรายการบนหน้าแรกเป็น HTML เพื่อให้ผู้เล่นสามารถคลิกบนเซิร์ฟเวอร์และเข้าสู่เกมได้ทันที
- การส่งสัญญาณมีความเกี่ยวข้องอย่างใกล้ชิดกับการใช้งานซ็อกเก็ตของเรา ตามที่อธิบายไว้ในส่วนถัดไป
รายชื่อเซิร์ฟเวอร์ภายในเกมและในหน้าแรก
การติดตั้งซ็อกเก็ต
เราต้องการสร้าง 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 นั้นเรียบง่ายและคล้ายกับ Posix Sockets API แต่มีความแตกต่างที่สำคัญบางประการ: บันทึกการโทรกลับ การกำหนด IP ในเครื่อง และการเชื่อมต่อแบบ Lazy.
การลงทะเบียนการโทรกลับ
แม้ว่าโปรแกรมดั้งเดิมจะใช้ I/O ที่ไม่ปิดกั้น โค้ดจะต้องถูกปรับโครงสร้างใหม่เพื่อให้ทำงานในเว็บเบราว์เซอร์
เหตุผลก็คือเหตุการณ์วนซ้ำในเบราว์เซอร์ถูกซ่อนจากโปรแกรม (ไม่ว่าจะเป็น 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;
...
}
...
}
หากเราซ่อน Event Loop ไว้ เราต้องเปลี่ยนมันให้เป็นดังนี้:
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 ท้องถิ่น
ID โหนดใน "เครือข่าย" ของเราไม่ใช่ที่อยู่ IP แต่เป็นคีย์ Firebase (เป็นสตริงที่มีลักษณะดังนี้: -LmEC50PYZLCiCP-vqde
).
วิธีนี้สะดวกเนื่องจากเราไม่ต้องการกลไกในการกำหนด IP และตรวจสอบความเป็นเอกลักษณ์ (รวมถึงการกำจัด IP เหล่านั้นหลังจากที่ไคลเอ็นต์ยกเลิกการเชื่อมต่อ) แต่บ่อยครั้งจำเป็นต้องระบุเพียร์ด้วยค่าตัวเลข
นี่คือสิ่งที่ฟังก์ชั่นใช้สำหรับ resolve
и reverseResolve
: แอปพลิเคชันได้รับค่าสตริงของคีย์ (ผ่านการป้อนข้อมูลของผู้ใช้หรือผ่านเซิร์ฟเวอร์หลัก) และสามารถแปลงเป็นที่อยู่ IP สำหรับการใช้งานภายในได้ ส่วนที่เหลือของ API ยังได้รับค่านี้แทนสตริงเพื่อความเรียบง่าย
สิ่งนี้คล้ายกับการค้นหา DNS แต่ดำเนินการในเครื่องบนไคลเอนต์
นั่นคือไม่สามารถแชร์ที่อยู่ IP ระหว่างไคลเอนต์ที่แตกต่างกันได้ และหากจำเป็นต้องใช้ตัวระบุส่วนกลางบางประเภท ก็จะต้องสร้างด้วยวิธีอื่น
การเชื่อมต่อที่ขี้เกียจ
UDP ไม่ต้องการการเชื่อมต่อ แต่ดังที่เราได้เห็นแล้วว่า WebRTC ต้องใช้กระบวนการเชื่อมต่อที่ใช้เวลานานก่อนจึงจะสามารถเริ่มถ่ายโอนข้อมูลระหว่างเพียร์สองคนได้
หากเราต้องการให้นามธรรมในระดับเดียวกัน (sendto
/recvfrom
กับเพื่อนโดยพลการโดยไม่มีการเชื่อมต่อล่วงหน้า) จากนั้นพวกเขาต้องทำการเชื่อมต่อแบบ "ขี้เกียจ" (ล่าช้า) ภายใน API
นี่คือสิ่งที่เกิดขึ้นระหว่างการสื่อสารตามปกติระหว่าง “เซิร์ฟเวอร์” และ “ไคลเอนต์” เมื่อใช้ UDP และสิ่งที่ห้องสมุดของเราควรทำ:
- เซิร์ฟเวอร์เรียก
bind()
เพื่อบอกระบบปฏิบัติการว่าต้องการรับแพ็กเก็ตบนพอร์ตที่ระบุ
แต่เราจะเผยแพร่พอร์ตที่เปิดไปยัง Firebase ภายใต้คีย์เซิร์ฟเวอร์และรับฟังเหตุการณ์ในแผนผังย่อยแทน
- เซิร์ฟเวอร์เรียก
recvfrom()
โดยรับแพ็กเก็ตที่มาจากโฮสต์ใดๆ บนพอร์ตนี้
ในกรณีของเรา เราจำเป็นต้องตรวจสอบคิวขาเข้าของแพ็กเก็ตที่ส่งไปยังพอร์ตนี้
แต่ละพอร์ตมีคิวของตัวเอง และเราเพิ่มพอร์ตต้นทางและปลายทางที่จุดเริ่มต้นของดาตาแกรม WebRTC เพื่อให้เราทราบว่าคิวใดที่จะส่งต่อเมื่อมีแพ็กเก็ตใหม่มาถึง
การโทรไม่มีการปิดกั้น ดังนั้นหากไม่มีแพ็กเก็ต เราก็เพียงส่งคืน -1 และตั้งค่า errno=EWOULDBLOCK
.
- ไคลเอนต์ได้รับ IP และพอร์ตของเซิร์ฟเวอร์โดยวิธีภายนอกและการโทร
sendto()
. นอกจากนี้ยังทำการโทรภายในด้วยbind()
ดังนั้นภายหลังrecvfrom()
จะได้รับการตอบสนองโดยไม่ต้องดำเนินการผูกอย่างชัดเจน
ในกรณีของเรา ลูกค้าจะได้รับคีย์สตริงจากภายนอกและใช้ฟังก์ชัน resolve()
เพื่อรับที่อยู่ IP
ณ จุดนี้ เราจะเริ่มการจับมือ WebRTC หากทั้งสองเพียร์ยังไม่ได้เชื่อมต่อถึงกัน การเชื่อมต่อกับพอร์ตที่แตกต่างกันของเครื่องเพียร์เดียวกันจะใช้ WebRTC DataChannel เดียวกัน
เรายังดำเนินการทางอ้อม bind()
เพื่อให้เซิร์ฟเวอร์สามารถเชื่อมต่อใหม่ได้ครั้งต่อไป sendto()
ในกรณีที่ปิดด้วยเหตุผลบางประการ
เซิร์ฟเวอร์จะได้รับแจ้งการเชื่อมต่อของลูกค้าเมื่อไคลเอนต์เขียนข้อเสนอ SDP ภายใต้ข้อมูลพอร์ตเซิร์ฟเวอร์ใน Firebase และเซิร์ฟเวอร์ตอบสนองด้วยการตอบสนองที่นั่น
แผนภาพด้านล่างแสดงตัวอย่างการไหลของข้อความสำหรับรูปแบบซ็อกเก็ตและการส่งข้อความแรกจากไคลเอนต์ไปยังเซิร์ฟเวอร์:
แผนภาพที่สมบูรณ์ของขั้นตอนการเชื่อมต่อระหว่างไคลเอนต์และเซิร์ฟเวอร์
ข้อสรุป
หากคุณอ่านมาไกลขนาดนี้ คุณอาจสนใจที่จะเห็นทฤษฎีนี้นำไปใช้จริง เกมสามารถเล่นได้
การแข่งขันกระชับมิตรระหว่างเพื่อนร่วมงาน
รหัสห้องสมุดเครือข่ายมีอิสระที่
ที่มา: will.com