في عام 2023 بنيت طبقة الفصول الافتراضية الفورية لتطبيق Testat في شركة IT-Plus — بـ Node.js و Socket.io على الخادم، و WebRTC داخل المتصفح، وواجهة Angular أمامها. الرقم الذي أستطيع الدفاع عنه هو هذا: جلسة بث واحدة حافظت على أكثر من 50 مستخدماً متزامناً، وهو المقياس الوحيد في هذه المقالة. وكل ما عداه هنا معمارية وحساب، والحساب هو ما يحدد إن كنت ستصل إلى خمسين أم ستتوقف عند ثمانية.
الفصل الدراسي ليس مكالمة بين شخصين
معظم الدروس التعليمية عن WebRTC تعلّمك الشكل نفسه: متصفحان، وRTCPeerConnection واحد لكل منهما، وقليل من كود الإشارات، وانتهى الأمر. هذا يعمل فعلاً، ويبدو رائعاً في العرض التوضيحي، لكنه يعلّمك بهدوء بنية طوبولوجية لا تحتمل فصلاً دراسياً.
السبب أن الفصل الدراسي غير متماثل. شخص واحد يشرح معظم الساعة، والبقية يستمعون ويفتحون الميكروفون أحياناً لطرح سؤال. أما المكالمة الندّية فمتماثلة بحكم تصميمها: كل مشارك يرسل إلى كل مشارك آخر. وإذا بنيت فصلاً دراسياً على بنية متماثلة، فأنت تدفع تكلفة مؤتمر بخمسين طرفاً لتقدّم ما هو عملياً بثّ في اتجاه واحد.
الحساب الذي يقتل بنية Mesh
في شبكة Mesh الكاملة يحتفظ كل مشارك باتصال ندّي مع كل مشارك آخر. ولعدد n من المشاركين يكون ذلك n(n−1)/2 اتصالاً داخل الغرفة، وn−1 اتصالاً لكل متصفح. هذه ليست دعوى عن مشروعي، بل خاصية رياضية للرسوم البيانية الكاملة.
- 4 مشاركين: 6 اتصالات في الغرفة، و3 لكل متصفح.
- 10 مشاركين: 45 اتصالاً، و9 لكل متصفح.
- 50 مشاركاً: 1,225 اتصالاً، و49 لكل متصفح.
الآن أضف عرض النطاق. لنفترض تدفق فيديو متواضعاً بسرعة 500 كيلوبت في الثانية. في بنية Mesh لا يرسل المشارك هذا التدفق مرة واحدة، بل يرسل نسخة مُرمَّزة منفصلة إلى كل نظير. وعند خمسين مشاركاً يرفع كل متصفح ما يقارب 24.5 ميجابت في الثانية بينما يفكّ ترميز تسعة وأربعين تدفقاً وارداً. وسرعات الرفع المنزلية في مصر والخليج غالباً ما تكون جزءاً بسيطاً من هذا الرقم، والحاسوب الذي يشغّل تسعة وأربعين مُفكِّك ترميز لن يتبقى فيه شيء للتبويب الذي يدوّن فيه الطالب ملاحظاته.
بنية Mesh لا تتدهور تدريجياً كلما أضفت أشخاصاً؛ بل تسقط دفعة واحدة، وتسقط عند أضعف وصلة رفع في الغرفة.
السقف العملي لبنية Mesh هو مجموعة صغيرة. وما بعد ذلك يجب أن يدخل الخادم في مسار الوسائط.
Mesh و SFU و MCU — ما الذي تقايضه فعلاً
Mesh
لا يوجد خادم وسائط، وبالتالي لا فاتورة خادم وسائط ولا شيء إضافي تتولى تشغيله. التشفير من الطرف إلى الطرف افتراضي، لأن لا شيء يفكّ التدفق في المنتصف. وأقل زمن استجابة ممكن، لأن الحزم تسلك أقصر طريق. في المقابل، منحنى التكلفة تربيعي بعدد المشاركين. مناسبة للمكالمات الثنائية والمجموعات الصغيرة، وغير مناسبة لأي شيء يشبه المحاضرة.
SFU — وحدة إعادة التوجيه الانتقائية
كل مشارك يرسل تدفقاً واحداً إلى الخادم. والخادم لا يفكّ ترميزه، بل يقرأ ترويسات RTP ويعيد توجيه الحزم إلى من اشترك فيها. هكذا يصبح رفع الناشر ثابتاً بغض النظر عن حجم الجمهور، وتنتقل التكلفة إلى صادر الخادم — حيث تكون خطية، والأهم أنها تحت سيطرتك. ففي فصل من خمسين شخصاً مع مُحاضر واحد بسرعة 500 كيلوبت في الثانية، يعني ذلك وارداً واحداً بسرعة 500 كيلوبت وصادراً بنحو 24.5 ميجابت في الثانية: كمية مرور عادية تماماً لخادم واحد مجهّز بشكل سليم. هذه هي البنية التي تجعل WebRTC ممكناً على مقياس الفصل الدراسي أصلاً.
MCU — وحدة التحكم متعددة النقاط
يفكّ الخادم ترميز كل تدفق وارد، ويدمج التدفقات في صورة واحدة، ثم يعيد ترميزها ويرسل تدفقاً واحداً لكل عميل. العملاء يحصلون على أرخص مهمة ممكنة: فكّ ترميز واحد. وأنت تحصل على أغلى خادم ممكن، لأن إعادة الترميز مقيدة بالمعالج ولا تستفيد من أي تخزين مؤقت. تستحق بنية MCU تكلفتها حين تكون أجهزة العملاء ضعيفة، أو حين تحتاج إلى تسجيل الجلسة أو إعادة بثها عبر RTMP/HLS، أو حين تحتاج تخطيطاً مركّباً ثابتاً. لكنها خيار افتراضي خاطئ: أنت تشتري معالجة على الخادم لتوفّر معالجة على العميل كانت متاحة لديك غالباً.
كما تمنحك بنية SFU شيئاً لا تستطيع MCU تقديمه بنيوياً: simulcast. يرمّز الناشر الكاميرا نفسها بدقتين أو ثلاث ويرسلها جميعاً، ثم يختار الخادم الطبقة المناسبة لكل مشترك بناءً على عرض النطاق المقاس لديه. الطالب على شبكة فندق يحصل على 180p، والطالب على ألياف بصرية يحصل على 720p، وترتفع تكلفة رفع الناشر بنحو الثلث بدلاً من أن تتضاعف خمسين مرة.
ما الذي يفعله Socket.io فعلاً — وما لا يفعله
هذه هي النقطة التي يخطئ فيها الناس أكثر من غيرها، ولذلك تستحق وضوحاً حاداً: Socket.io لا يلمس الوسائط إطلاقاً.
تنتقل الوسائط عبر SRTP، وغالباً فوق UDP، ويجري التفاوض عليها مباشرة بين الطرفين — أو بين الطرف وخادم SFU. أما Socket.io فيحمل مستوى الإشارات فوق WebSocket: أوصاف الجلسة، ومرشحات ICE، وعضوية الغرفة، ومن الموجود في هذا الفصل، وأن المدرّس كتم صوتك، وأن السبورة انتقلت إلى الشريحة 12. بضعة كيلوبايتات لكل مشارك في الجلسة. طبقة الإشارات وطبقة الوسائط تفشلان لأسباب مختلفة تماماً، ويُحسب حجم كل منهما بطريقة مختلفة تماماً.
هذا خادم إشارات قريب مما سأكتبه اليوم — Node مع وحدات ES، و Socket.io الإصدار الرابع، ومحوّل Redis كي تتوسع الطبقة أفقياً خلف موازن الأحمال:
import { createServer } from 'node:http';
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
const httpServer = createServer();
const io = new Server(httpServer, {
cors: { origin: process.env.CLASSROOM_ORIGIN, credentials: true },
// A student who closed the tab should leave the roster in seconds, not in a minute.
pingInterval: 20_000,
pingTimeout: 25_000,
});
const pub = createClient({ url: process.env.REDIS_URL });
const sub = pub.duplicate();
await Promise.all([pub.connect(), sub.connect()]);
io.adapter(createAdapter(pub, sub));
// Authenticate before the connection is accepted, not after.
io.use(async (socket, next) => {
const { token, roomId } = socket.handshake.auth;
const session = await verifyClassroomToken(token, roomId);
if (!session) {
return next(new Error('unauthorized'));
}
socket.data.userId = session.userId;
socket.data.roomId = roomId;
socket.data.role = session.role; // 'teacher' | 'student'
next();
});
io.on('connection', (socket) => {
const { roomId, userId, role } = socket.data;
// Two rooms per socket: the classroom for broadcasts, a private
// mailbox so directed signalling works across Node processes.
socket.join([roomId, `user:${userId}`]);
socket.to(roomId).emit('peer:joined', { userId, role });
socket.on('roster:get', async (ack) => {
const sockets = await io.in(roomId).fetchSockets();
ack(sockets.map((s) => ({ userId: s.data.userId, role: s.data.role })));
});
// The server is a relay, not a broker. It never parses SDP.
socket.on('signal', ({ to, description, candidate }) => {
io.to(`user:${to}`).emit('signal', { from: userId, description, candidate });
});
socket.on('disconnect', (reason) => {
socket.to(roomId).emit('peer:left', { userId, reason });
});
});
httpServer.listen(3000);
ثلاث تفاصيل في هذا الكود أهم مما تبدو عليه.
- التحقق من الهوية يجري في الوسيط، قبل قبول الاتصال. فأي اتصال إشارات لم يثبت إلى أي فصل ينتمي هو اتصال قادر على استكشاف غرف الآخرين.
- غرفتان لكل اتصال. معرّف الغرفة لأحداث البث العام، و
user:<id>كصندوق بريد خاص للإشارات الموجَّهة. ومع محوّل Redis يستمر هذا في العمل حتى لو كان الطرفان متصلين بعمليتَي Node مختلفتين. - الخادم لا يحلّل SDP أبداً. هو ينقل كتلاً معتمة فحسب. وفي اللحظة التي يبدأ فيها خادم الإشارات بإعادة كتابة SDP، يصبح مسؤولاً عن كل خلل في التفاوض على الترميز في كل إصدار متصفح يستخدمه طلابك.
جانب المتصفح: تفاوض مرة واحدة، وتفاوض بأدب
التسابق الكلاسيكي في WebRTC هو ما يُعرف بـ glare: يُنشئ الطرفان عرضاً في اللحظة نفسها فيرفض كل منهما عرض الآخر. ونمط perfect negotiation يزيل هذه الفئة من الأخطاء بالكامل بجعل أحد الطرفين «مؤدَّباً» — الطرف المؤدَّب يتراجع عن عرضه عند التصادم، والطرف غير المؤدَّب يتجاهل العرض الوارد. وفي الفصل الدراسي يوزّع الدور نفسه بنفسه: المحاضر غير مؤدَّب، والطلاب مؤدَّبون.
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: [
'turn:turn.example.com:3478?transport=udp',
'turns:turn.example.com:5349?transport=tcp',
],
username: turnCreds.username,
credential: turnCreds.credential,
},
],
iceCandidatePoolSize: 2,
bundlePolicy: 'max-bundle',
});
// Direction is declared up front so the SDP is stable from the first offer.
if (role === 'teacher') {
const media = await navigator.mediaDevices.getUserMedia({
video: { width: 1280, height: 720 },
audio: { echoCancellation: true },
});
media.getTracks().forEach((track) => pc.addTrack(track, media));
} else {
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
}
const polite = role === 'student';
let makingOffer = false;
let ignoreOffer = false;
pc.onnegotiationneeded = async () => {
try {
makingOffer = true;
await pc.setLocalDescription();
socket.emit('signal', { to: peerId, description: pc.localDescription });
} finally {
makingOffer = false;
}
};
pc.onicecandidate = ({ candidate }) => {
if (candidate) {
socket.emit('signal', { to: peerId, candidate });
}
};
socket.on('signal', async ({ from, description, candidate }) => {
try {
if (description) {
const collision =
description.type === 'offer' &&
(makingOffer || pc.signalingState !== 'stable');
ignoreOffer = !polite && collision;
if (ignoreOffer) return;
await pc.setRemoteDescription(description);
if (description.type === 'offer') {
await pc.setLocalDescription();
socket.emit('signal', { to: from, description: pc.localDescription });
}
} else if (candidate) {
try {
await pc.addIceCandidate(candidate);
} catch (err) {
if (!ignoreOffer) throw err;
}
}
} catch (err) {
console.error('negotiation failed', err);
}
});
لاحظ اتجاهات الـ transceivers: الطلاب يعلنون recvonly مسبقاً بدلاً من انتظار ظهور المسارات البعيدة. وثبات وصف SDP منذ العرض الأول يعني تفاوضاً أقل تكراراً، وفرصاً أقل لتعطّل آلة حالات التفاوض.
ICE و STUN وفاتورة TURN التي لا مفرّ منها
يعثر WebRTC على المسار عبر ICE. و STUN رخيص: يسأل العميل خادماً عاماً كيف يبدو عنوانه من الخارج، فيحصل على إجابة ويعرض ذلك المرشح. وفي معظم الشبكات المنزلية يكفي هذا وتتدفق الوسائط مباشرة.
ثم هناك الشبكات التي لا يكفي فيها ذلك — NAT المتماثل، وجدران الحماية المؤسسية التي لا تسمح إلا بالمنفذ 443 صادراً، و CGNAT على شبكات الهاتف، ومعامل الجامعات التي تحجب UDP بالكامل. لهؤلاء يتراجع ICE إلى TURN، و TURN مُرحِّل: كل بايت من الوسائط يمرّ عبر خادمك. STUN لا يكلّف شيئاً يُذكر، أما TURN فيكلّف عرض نطاق بالمعدل نفسه الذي تستهلكه الوسائط. وفي الفصل الدراسي نادراً ما يكون الأمر طالباً واحداً سيئ الحظ؛ بل هو النسبة من الدفعة التي تجلس خلف شبكة مقيّدة ذلك اليوم — وفي التدريب المؤسسي أو معمل المدرسة قد تكون هذه النسبة الجميع.
نتيجتان تستحقان التخطيط المسبق:
- وفّر
turns:فوق TCP/443 إلى جانب UDP/3478. فإن نشرت UDP وحده، خسرت بالضبط المستخدمين الأكثر حاجة إلى البديل. - لا تضع بيانات اعتماد TURN ثابتة داخل حزمة الواجهة الأمامية أبداً. وضع
use-auth-secretفي coturn يمنحك بيانات اعتماد قصيرة العمر مشتقة من HMAC، تُصدَر لكل جلسة من الخادم الذي تحقّق من هوية المستخدم أصلاً.
import { createHmac } from 'node:crypto';
/**
* coturn `use-auth-secret`: the username is an expiry timestamp,
* the password is its HMAC. No shared secret ever reaches the client.
*/
function mintTurnCredentials(userId, ttlSeconds = 3600) {
const username = `${Math.floor(Date.now() / 1000) + ttlSeconds}:${userId}`;
const credential = createHmac('sha1', process.env.TURN_STATIC_SECRET)
.update(username)
.digest('base64');
return { username, credential, ttl: ttlSeconds };
}
إعادة الاتصال ميزة أساسية، لا حالة استثنائية
أي حصة تمتد ساعة على شبكة هاتف محمول ستشهد أحداث انقطاع. طالب يخرج من نطاق الـ Wi-Fi، وحاسوب يدخل وضع السكون، ومشغّل يسلّم الجلسة إلى برج آخر. هناك شيئان مستقلان قد ينكسران، ولكل منهما معالجة خاصة.
اتصال الإشارات. يعيد Socket.io الاتصال تلقائياً، لكن إعادة الاتصال ليست استئنافاً — فحالة التطبيق قد تكون قديمة حين يعود الاتصال. لذلك أعد جلب قائمة الحاضرين عند إعادة الاتصال بدلاً من الوثوق بأحداث ربما فاتتك أثناء الانقطاع.
اتصال الوسائط. الاتصال الندّي الذي تصل حالة ICE فيه إلى failed ليس ميتاً بالضرورة؛ إذ يمكنه إعادة جمع المرشحات على الاتصال القائم عبر restartIce()، وهو أرخص وأسرع كثيراً من هدم كل شيء وإعادة بنائه.
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') {
// Re-gather on the existing connection instead of rebuilding it.
pc.restartIce();
}
};
socket.io.on('reconnect', async () => {
// The socket is back; the app's idea of the room may not be.
const roster = await socket.emitWithAck('roster:get');
reconcileRoster(roster);
if (pc.connectionState !== 'connected') {
pc.restartIce();
}
});
أين يقع عنق الزجاجة فعلاً
حين تتدهور الجلسة، تكون الاستجابة الغريزية إضافة معالجة إلى خادم الإشارات. ونادراً جداً ما يكون خادم الإشارات هو السبب. وهذا ترتيب تقريبي لتكرار الأسباب الحقيقية:
- سرعة رفع الناشر. محاضر واحد على اتصال سيئ يفسد التجربة على الجميع، لأن الجميع في اتجاه التدفق النازل من ذلك الرفع الواحد. هذا أعلى العناصر مردوداً في المراقبة بفارق كبير.
- صادر الخادم ومعدل الحزم. خادم SFU جهاز شبكي لا جهاز حوسبة. هو مقيّد بالبتات في الثانية، وبالقدر نفسه بالحزم في الثانية — فحزم الصوت الصغيرة بمعدل 50 حزمة في الثانية لكل مشترك تتراكم في معالجة المقاطعات قبل أن تتراكم في عرض النطاق بوقت طويل.
- حجم مرور TURN. كما سبق. هذا هو البند الذي يفاجئ الناس في الفاتورة.
- فكّ الترميز لدى العميل. مشكلة حقيقية في مؤتمر بعرض شبكي للوجوه، وغير ذات أثر تقريباً في فصل دراسي يفكّ فيه الطالب ترميز تدفق أو اثنين.
- طبقة الإشارات. في المرتبة الأخيرة، وعادةً فقط لأن أحدهم وضع كتابة إلى قاعدة البيانات في المسار الساخن لتمرير مرشح ICE. أبقِ معالجات الإشارات خالية من عمليات الإدخال والإخراج المتزامنة، وستتفوق هذه الطبقة على كل ما عداها بمرتبة كاملة.
راقب مسار الوسائط، لا العملية. تعطيك RTCPeerConnection.getStats() زمن الذهاب والإياب، والارتجاف، والحزم المفقودة، وزوج المرشحات المختار لكل عميل. وأخذ عيّنة من ذلك كل بضع ثوانٍ وإرسالها عبر اتصال الإشارات الموجود لديك أصلاً هو أرخص قدرة مراقبة ستضيفها في حياتك، وهي التي تخبرك أي الأسباب الخمسة أعلاه تنظر إليه فعلاً.
ماذا يعني «أكثر من 50» بالضبط
لأكون دقيقاً بشأن ما ورد في الفقرة الأولى: أكثر من 50 مستخدماً متزامناً هو ما حافظت عليه جلسة بث واحدة في Testat في بيئة الإنتاج عام 2023، مع البث المباشر، على طبقة إشارات بـ Node.js و Socket.io وعميل Angular. هو رقم لكل جلسة، صمد تحت استخدام حقيقي.
وهو ليس سقف اختبار حمل، ولا عدد غرف لكل خادم، ولا وعداً بشأن حِمل مختلف. فبمجرد أن يتغير شكل الجلسة — كل الطلاب على الكاميرا، أو تفعيل التسجيل، أو غرف فرعية — تتغير الحسابات معه، وتصبح الإجابة الأمينة: «قِسها من جديد».
وهذا هو الدرس الحقيقي. التوسع في الأنظمة الفورية ليس عملية ضبط تؤجّلها إلى النهاية؛ بل قرار طوبولوجي تتخذه في البداية، والحساب يخبرك بالإجابة قبل أن تكتب سطراً واحداً من الكود.
تسجيل الدخول للتعليق
لنشر تعليق، يجب تسجيل الدخول. يرجى تسجيل الدخول. تسجيل الدخول
تعليقات (0)