سنشرح في هذا المقال آليات الاتصال الدائم المستمر مع الخادم وهو الاتصال الذي يُنشَأ فيه اتصال مستمر بين الخادم والعميل (غالبًا متصفح الويب) يجري فيه تبادل الطلبيات والردود آنيًا، وتنحصر آلية تنفيذ الاتصال الدائم تلك في ثلاثة أنواع: آلية الاستطلاع المفتوح Long polling وآلية مقابس الويب عبر البروتوكول WebSocket وآلية الأحداث المرسلة EventSource، سنناقش كل واحدة بالتفصيل على حدة.
آلية الاستطلاع
آلية الجس والاستطلاع polling هي الطريقة الأبسط للمحافظة على اتصال ثابت مع الخادم، ولا تتطلب أي بروتوكولات خاصة مثل WebSocket، أو أي أحداث من طرف الخادم، كما أنها سهلة الإنجاز وكافية في الكثير من الحالات.
الاستطلاع الدوري Regular polling
الطريقة الأبسط للحصول على معلومات من الخادم هي الاستطلاع الدوري، بإرسال طلبات إلى الخادم لسؤاله عن أي معلومات جديدة على فترات زمنية منتظمة (مثلًا: كل 10 ثوان)، وعند الاستجابة ينتبه الخادم إلى اتصال العميل به، ثم يرسل حزمةً من الرسائل التي تلقاها حتى اللحظة، سيعمل الأمر كما هو متوقع، لكن مع بعض الجوانب السلبية، منها:
- تمرَّر الرسائل بتأخير زمني قد يصل إلى 10 ثوان (الزمن الفاصل بين الطلبات).
- سيُغرَق الخادم بالطلبات كل 10 ثوان حتى لو غادر المستخدم الموقع أو استغرق في النوم، وهذا حمل كبير عليه من منظور الأداء.
لهذا قد تكون هذه المقاربة جيدةً عندما تكون الخدمة بسيطةً، لكنها وبشكل عام تحتاج إلى التحسين.
الاستطلاع المفتوح Long polling
وهي طريقة أفضل للاستطلاع أو الجس من حالة الخادم، وتتميز بسهولة تنفيذها، وقدرتها على تسليم الرسائل دون تأخير.
حيث:
- يُرسَل الطلب إلى الخادم.
- لا يغلق الخادم الاتصال حتى تكون لديه رسالة لتسليمها.
- عندما تظهر الرسالة، يستجيب الخادم للطلب بهذه الرسالة.
- يرسل المتصفح طلبًا جديدًا مباشرة.
إن أساس هذه الطريقة هو إرسال المتصفح للطلب وإبقاء الاتصال مع الخادم قيد الانتظار، ولا يهيّئ المتصفح الاتصال من جديد حتى يتسلم الاستجابة، فإن فُقد الاتصال نتيجة خطأ ما، فسيرسل المتصفح طلبًا آخر مباشرةً.
تمثل الشيفرة التالية الدالة subscribe
التي ترسل الطلبات المفتوحة من جهة العميل:
async function subscribe() { let response = await fetch("/subscribe"); if (response.status == 502) { // 502 خطأ تجاوز الوقت المسموح للاتصال // يحدث عند بقاء الاتصال مفتوحًا لفترة طويلة جدًا // ويغلقه الخادم لذا يجب إعادة الاتصال await subscribe(); } else if (response.status != 200) { // إظهار الخطأ showMessage(response.statusText); // إعادة الاتصال خلال ثانية await new Promise(resolve => setTimeout(resolve, 1000)); await subscribe(); } else { // الحصول على الرسالة وإظهارها let message = await response.text(); showMessage(message); // استدعاء التابع من جديد await subscribe(); } } subscribe();
يرسل التابع subscribe
طلب إحضار، وينتظر الاستجابة ليعالجها عندما تصل، ثم يستدعي نفسه مجددًا.
اقتباسيجب أن لا تمثل الاتصالات المفتوحة عبئًا على الخادم ينبغي أن تكون بنية الخادم قادرةً على العمل مع عدة اتصالات مفتوحة، حيث تنفذ بعض الخوادم عمليةً واحدةً لكل اتصال، مما ينتج عنه الكثير من العمليات المستهلكة للذاكرة عند تخديم عدد أكبر من الاتصالات، وتظهر هذه المشكلة عادةً عند كتابة شيفرة الواجهة الخلفية باستخدام PHP أو Ruby، لكنها لن تظهر عند استخدام Node.JS. مع ذلك لا تتعلق المشكلة بلغات البرمجة إطلاقًا، بل ينبغي التأكد من قدرة بنية الخادم على التعامل بكفاءة مع الاتصالات المفتوحة في نفس الوقت.
مثال اختباري لنظام محادثة بسيط
إليك تطبيق محادثة اختباريًا يمكنك تنزيله وتشغيله محليًا إذا كنت على دراية باستخدام Node.JS وتثبيت الوحدات البرمجية اللازمة:
ملف شيفرة العميل "browser.js":
// POSTإرسال رسالة، طلب function PublishForm(form, url) { function sendMessage(message) { fetch(url, { method: 'POST', body: message }); } form.onsubmit = function() { let message = form.message.value; if (message) { form.message.value = ''; sendMessage(message); } return false; }; } // تلقي الرسائل باستخدام الاستطلاع المفتوح function SubscribePane(elem, url) { function showMessage(message) { let messageElem = document.createElement('div'); messageElem.append(message); elem.append(messageElem); } async function subscribe() { let response = await fetch(url); if (response.status == 502) { // تجاوز الوقت المسموح // يحدث عندما يبقى الاتصال مفتوحًا لفترة طويلة // لا بد من إعادة الاتصال await subscribe(); } else if (response.status != 200) { // Show Error showMessage(response.statusText); // إعادة الاتصال خلال ثانية await new Promise(resolve => setTimeout(resolve, 1000)); await subscribe(); } else { // الحصول على الرسالة let message = await response.text(); showMessage(message); await subscribe(); } } subscribe(); }
ملف شيفرة الواجهة الخلفية "server.js":
let http = require('http'); let url = require('url'); let querystring = require('querystring'); let static = require('node-static'); let fileServer = new static.Server('.'); let subscribers = Object.create(null); function onSubscribe(req, res) { let id = Math.random(); res.setHeader('Content-Type', 'text/plain;charset=utf-8'); res.setHeader("Cache-Control", "no-cache, must-revalidate"); subscribers[id] = res; req.on('close', function() { delete subscribers[id]; }); } function publish(message) { for (let id in subscribers) { let res = subscribers[id]; res.end(message); } subscribers = Object.create(null); } function accept(req, res) { let urlParsed = url.parse(req.url, true); // مستخدم جديد يريد النتائج if (urlParsed.pathname == '/subscribe') { onSubscribe(req, res); return; } // إرسال رسالة if (urlParsed.pathname == '/publish' && req.method == 'POST') { // POSTقبول req.setEncoding('utf8'); let message = ''; req.on('data', function(chunk) { message += chunk; }).on('end', function() { publish(message); // النشر للجميع res.end("ok"); }); return; } // البقية ثابتة fileServer.serve(req, res); } function close() { for (let id in subscribers) { let res = subscribers[id]; res.end(); } } // ----------------------------------- if (!module.parent) { http.createServer(accept).listen(8080); console.log('Server running on port 8080'); } else { exports.accept = accept; if (process.send) { process.on('message', (msg) => { if (msg === 'shutdown') { close(); } }); } process.on('SIGINT', close); }
الملف الأساسي "index.html":
<!DOCTYPE html> <script src="browser.js"></script> All visitors of this page will see messages of each other. <form name="publish"> <input type="text" name="message" /> <input type="submit" value="Send" /> </form> <div id="subscribe"> </div> <script> new PublishForm(document.forms.publish, 'publish'); // random url parameter to avoid any caching issues new SubscribePane(document.getElementById('subscribe'), 'subscribe?random=' + Math.random()); </script>
وستظهر النتيجة بالشكل التالي:
مجال الاستخدام
تعمل طريقة الاستطلاع المفتوح بصورة ممتازة عندما تكون الرسائل نادرةً، لكن عندما تتكرر الرسائل بكثرة، فسيكون منحني (طلب-استجابة) المرسوم في الأعلى على هيئة أسنان المنشار، ولأن لكل رسالة طلبًا مستقلًا له ترويسات وآلية استيثاق خاصة، فمن الأفضل استخدام طرق أخرى مثل: Websocket أوServer Sent Events، التي سنشرحها في الفقرات التالية.
استخدام البروتوكول WebSocket
يزوّدنا هذا البروتوكول الموصوف في المعيار RFC 6455 بطريقة لتبادل البيانات بين الخادم والمتصفح عبر اتصال ثابت، حيث تمرَّر البيانات إلى كلا الطرفين على شكل حزم دون قطع الاتصال أو استخدام طلبات HTTP إضافية، وتظهر الفائدة الكبيرة لاستخدامه عند وجود خدمات تتطلب التبادل المستمر للبيانات، مثل ألعاب الشبكة وأنظمة التجارة العاملة في الزمن الحقيقي وغيرها.
مثال بسيط
سنحتاج إلى كائن مقبس اتصال جديد new WebSocket
لفتح اتصال websocket باستخدام بروتوكول نقل خاص هو ws
في العنوان:
let socket = new WebSocket("ws://javascript.info");
كما يمكن استخدام البروتوكول المشفّر //:wss
، فهو مشابه للبروتوكول HTTPS لكن مع websockets.
اقتباسيُفضّل دومًا استخدام البروتوكول
//:wss
، لأنه مشفّر وأكثر وثوقيةً، فالبيانات التي تنتقل عبر البروتوكول//:ws
غير مشفّرة ومرئية بالنسبة لأي وسيط، وقد لا تميزه الخوادم الوكيلة القديمة، كما ستصل إليها ترويسات ستعدها غريبةً وتقطع الاتصال. من ناحية أخرى فالبروتوكول//:wss
هو WebSocket يستخدم TLS (وهي اختصار للعبارة transport security layer وتعني طبقة أمن النقل)، وهي طبقة تشفّر البيانات عند إرسالها وتفك تشفيرها عند استقبالها، وبالتالي ستكون حزم البيانات مشفرةً أثناء انتقالها عبر الأوساط فلا يمكن معرفة محتواها واختراقه.
ينبغي الاستماع إلى الأحداث التي تطرأ على مقبس الاتصال Socket بعد تأسيسه مباشرةً، وهي أربعة أحداث:
-
open
: عند تأسيس الاتصال. -
message
: عند استقبال البيانات. -
error
: عند وقوع خطأ يتعلق بالبروتوكول websocket. -
close
: عند إغلاق الاتصال.
ولإرسال أي بيانات يكفي استخدام الصيغة (socket.send(data
، وإليك مثالًا:
let socket = new WebSocket("wss://javascript.info/article/websocket/demo/hello"); socket.onopen = function(e) { alert("[open] Connection established"); alert("Sending to server"); socket.send("My name is John"); }; socket.onmessage = function(event) { alert(`[message] Data received from server: ${event.data}`); }; socket.onclose = function(event) { if (event.wasClean) { alert(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`); } else { // كأن تلغى العملية أو فشل في الشبكة // 1006 قيمته في هذه الحالة عادة event.code alert('[close] Connection died'); } }; socket.onerror = function(error) { alert(`[error] ${error.message}`); };
لقد كتبنا شيفرة خادم تجريبي باستخدام Node.JS في الملف server.js لتشغيل الشيفرة في المثال السابق، وسيرد الخادم بالرسالة "Hello from server, John"، ثم ينتظر 5 ثوان ويقطع الاتصال.
سترى إذًا تسلسل الأحداث open
→ message
→ close
، والأمر بهذه البساطة فعليًا، لأنك تستخدم الآن البروتوكول WebSocket، لنتحدث الآن في أمور أكثر عمقًا.
فتح اتصال مقبس ويب WebSocket
يُنشئ الأمر (new WebSocket(url
كائنًا جديدًا ويبدأ عملية الاتصال مباشرةً، سيسأل المتصفح الخادم خلال تأسيس الاتصال -عبر الترويسات- إذا كان يدعم البروتوكول Websocket، فإن كان الجواب نعم فسيستمر العمل به، وهو يختلف عن HTTP كليًا.
وإليك مثالًا عن ترويسات المتصفح للطلب الذي يرسله الأمر (new WebSocket("wss://javascript.info/chat
.
GET /chat Host: javascript.info Origin: https://javascript.info Connection: Upgrade Upgrade: websocket Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q== Sec-WebSocket-Version: 13
-
Origin
: أصل الصفحة العميلة (https://javascript.info
مثلًا)، فالكائناتWebSocket
هي كائنات من أصول مختلطة بطبيعتها، حيث لا توجد أي قيود لاستخدامها، وطالما أنّ الخوادم القديمة لا تدعم هذا البروتوكول، فلن نواجه مشكلة توافق. تظهر أهمية الترويسةOrigin
في أنها تسمح للخادم بالاختيار إن كان سيتواصل مع الصفحة العميلة وفق هذا البروتوكول أم لا. -
Connection: Upgrade
: يشير إلى رغبة العميل في تغيير البروتوكول. -
Upgrade: websocket
: تدل أنّ البروتوكول المطلوب هو websocket. -
Sec-WebSocket-Key
: مفتاح أمان عشوائي يولده المتصفح. -
Sec-WebSocket-Version
: نسخة بروتوكول websocket، وهي حاليًا النسخة 13.
اقتباسلا يمكن تقليد المصافحة (أي تأسيس الاتصال) الخاصة بالبروتوكول WebSocket لا يمكن استخدام الكائنين
fetch
أوXMLHttpRequest
لتنفيذ هذا النوع من طلبات HTTP، لأنه لا يسمح لشيفرة JavaScript بضبط تلك الترويسات.
ينبغي على الخادم أن يعيد الرمز 101 عندما يوافق على التحوّل إلى البروتوكول WebSocket:
101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g=
ستكون الترويسة Sec-WebSocket-Accept
هي نفسها Sec-WebSocket-Key
مسجلةً باستخدام خوارزمية خاصة، وسيستخدمها المتصفح للتأكد من توافق الاستجابة مع الطلب. بعد ذلك ستُنقل البيانات باستخدام بروتوكول WebSocket، وسنطلع على بنيتها (إطاراتها) قريبًا.
الموسعات والبروتوكولات الفرعية
هنالك أيضًا الترويستان Sec-WebSocket-Extensions
التي تصف الموسّعات، وSec-WebSocket-Protocol
التي تصف البروتوكولات الفرعية.
فمثلًا:
-
Sec-WebSocket-Extensions: deflate-frame
: يعني أن المتصفح سيدعم ضغط البيانات، فالموسّع هو شيء يتعلق بنقل البيانات ووظيفة توسّع عمل البروتوكول، يرسل المتصفح الترويسةSec-WebSocket-Extensions
تلقائيًا مزودةً بقائمة الموسّعات المدعومة كلها. -
Sec-WebSocket-Protocol: soap, wamp
: يعني أننا سننقل البيانات باستخدام أحد البروتوكولين الفرعيين SOAP أو WAMP (وهو اختصار للعبارة " WebSocket Application Messaging Protocol" أي "بروتوكول نقل الرسائل لتطبيق websocket"). تُسجل البروتوكولات الفرعية في IANA catalogue، أي أن هذه الترويسة الاختيارية تحدد صيغ البيانات التي سنستخدمها، وتُضبَط باستخدام المعامل الثاني للدالة البانيةnew WebSocket
، فلو أردنا استخدام SOAP أو WAMP مثلًا، فسنضعهما في مصفوفة بالشكل التالي:
let socket = new WebSocket("wss://javascript.info/chat", ["soap", "wamp"]);
سيستجيب الخادم بقائمة من البروتوكولات والموسّعات التي يوافق على استخدامها، وإليك مثالًا:
GET /chat Host: javascript.info Upgrade: websocket Connection: Upgrade Origin: https://javascript.info Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q== Sec-WebSocket-Version: 13 Sec-WebSocket-Extensions: deflate-frame Sec-WebSocket-Protocol: soap, wamp
وستكون الاستجابة:
101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g= Sec-WebSocket-Extensions: deflate-frame Sec-WebSocket-Protocol: soap
أي أن الخادم سيدعم الموسّع "deflate-frame" والبروتوكول الفرعي SOAP فقط.
نقل البيانات
يتكون الاتصال من إطارات "frames"، وهي أجزاء من البيانات يمكن إرسالها من كلا الطرفين وقد تكون من الأنواع التالية:
- "text": يحتوي بيانات نصيةً ترسلها الأطراف إلى بعضها.
- "binary data": يحتوي بيانات ثنائيةً تُرسلها الأطراف إلى بعضها.
- "ping/pong": تستخدَم للتحقق من الاتصال، ويرسلها الخادم إلى المتصفح الذي يرد عليها تلقائيًا.
- ستجد أيضًا "connection close" وبعض الإطارات الأخرى.
ونعمل في بيئة المتصفح مع الإطارات النصية والثنائية.
يمكن للتابع ()send.
إرسال بيانات نصية أو ثنائية.، ويسمح استدعاء التابع (socket.send(body
بأن تكون قيمة جسم الطلبbody
بالتنسيق النصي أو الثنائي، بما في ذلك الكائن Blob
وArrayBuffer
وغيرهما، ولا حاجة لإجراء ضبط خاص بل تُرسَل البيانات كما هي.
تأتي البيانات النصية على شكل "string" عندما نستقبل البيانات، ومع ذلك يمكننا الاختيار بين Blob
وArrayBuffer
للبيانات الثنائية.** وكذا ضبط ذلك باستخدام الخاصية socket.binaryType
، التي تأخذ القيمة "blob"
افتراضيًا، لذلك تأتي البيانات الثنائية ضمن الكائن Blob، الذي يمثل كائن بيانات ثنائية عالي المستوى ويتكامل مباشرةً مع المعرّفين <a>
و<img>
وغيرهما، ويمكن الانتقال إلى استخدام الكائن ArrayBuffer
عند معالجة البيانات الثنائية والعمل مع البايتات بشكل منفصل.
socket.binaryType = "arraybuffer"; socket.onmessage = (event) => { //أما أن يكون نصًا أو بيانات ثنائيةً event.data };
محدودية معدل النقل Rate limiting
لنتخيل أن تطبيقنا قادر على توليد كمية كبيرة من البيانات، لكن المستخدم يعاني من اتصال بطيء بالانترنت. يمكن استدعاء التابع (socket.send(data
مرات عدةً، لكن البيانات ستُخزّن مؤقتًا في الذاكرة وستُرسَل بالسرعة التي تسمح بها الشبكة فقط، عندها يمكن استخدام الخاصية socket.bufferedAmount
التي تُخزّن حجم البيانات الموجودة في المخزن المؤقت في هذه اللحظة وتنتظر الظروف المناسبة لنقلها، ويمكن فحص الحجم المتبقي من البيانات للتأكد من أن المقبس Socket لا يزال متاحًا لمتابعة نقل البيانات.
// افحص المقبس كل 100 ثانية وحاول إرسال البيانات // عندما يكون حجم البيانات المخزّنة التي لم تصل صفرًا setInterval(() => { if (socket.bufferedAmount == 0) { socket.send(moreData()); } }, 100);
إغلاق الاتصال
عندما يحاول أحد الأطراف إغلاق الاتصال (للمخدم والمتصفح الحق نفسه في ذلك)، يرسَل عادةً إطار إغلاق الاتصال "connection close" الذي يحتوي قيمةً عدديةً وسببًا نصيًا للإغلاق.
إليك الصيغة اللازمة:
socket.close([code], [reason]);
-
code
: رمز خاص لإغلاق اتصال WebSocket، وهو اختياري. -
reason
: نص يصف سبب الإغلاق، وهو اختياري.
يستقبل معالج الحدث close
في الطرف الآخر الرمز والسبب:
// الطرف الذي سيغلق الاتصال: socket.close(1000, "Work complete"); // الطرف الآخر socket.onclose = event => { // event.code === 1000 // event.reason === "Work complete" // event.wasClean === true (clean close) };
إليك القيم الأكثر شيوعًا للرموز:
-
1000
: وهي القيمة الافتراضية، وتستخدم للإغلاق العادي (تستخدم إن لم يكن هناك رمزcode
). -
1006
: لا يمكن ضبط هذا الرمز يدويًا ويشير إلى فقد الاتصال. -
1001
: عند مغادرة أحد الأطراف، مثل: إغلاق الخادم أو مغادرة المتصفح للصفحة. -
1009
: الرسائل أضخم من أن تُعالج. -
1011
: خطأ في الخادم.
يمكن الاطلاع على القائمة الكاملة ضمن المعيار RFC6455, §7.4.1.
إنّ رموز WebSocket مشابهة نوعًا ما لرموز HTTP وتختلف في بعض النواحي، فأي رمز أقل من 1000 سيكون محجوزًا، وسيتولد خطأ عند استخدامه.
// في حال فشل الاتصال socket.onclose = event => { // event.code === 1006 // event.reason === "" // event.wasClean === false (no closing frame) };
حالة الاتصال
تتيح الخاصية socket.readyState
إمكانية معرفة الوضع الحالي للاتصال وفقًا للقيم التي تأخذها:
-
0
متصل "CONNECTING": أي أن الاتصال يجري، ولم يُؤسَّس بعد. -
1
مفتوح "OPEN": التواصل قائم. -
2
يُغلق "CLOSING": يجري إغلاق الاتصال. -
3
مُغلق "CLOSED": الاتصال مغلق.
مثال تطبيق محادثة آنية
لنراجع مثال تطبيق المحادثة باستخدام الواجهة البرمجية WebSocket API والوحدة البرمجية Node.js WebSocket module، سنركز على طرف العميل، وسيكون الخادم بسيطًا أيضًا.
نحتاج إلى نموذج <form>
لإرسال الرسائل ومعرِّف <div>
لاستقبالها.
<!-- message form --> <form name="publish"> <input type="text" name="message"> <input type="submit" value="Send"> </form> <!-- div with messages --> <div id="messages"></div>
كما سنستخدم في جافاسكربت:
- فتح الاتصال.
-
إرسال رسالة عن طريق النموذج
(socket.send(message
. -
وضع الرسالة المستقبلة ضمن
div#messages
.
وإليك الشيفرة اللازمة:
let socket = new WebSocket("wss://javascript.info/article/websocket/chat/ws"); // إرسال رسالة إلى النموذج document.forms.publish.onsubmit = function() { let outgoingMessage = this.message.value; socket.send(outgoingMessage); return false; }; // div#messagesإظهار الرسالة بعد وصولها في socket.onmessage = function(event) { let message = event.data; let messageElem = document.createElement('div'); messageElem.textContent = message; document.getElementById('messages').prepend(messageElem); }
لن نتعمق بشيفرة الواجهة الخلفية، فهي خارج نطاق مقالتنا، لكننا سنستخدم هنا Node.js، ومع ذلك فالأمر ليس إلزاميًا، إذ أن للعديد من المنصات طرقها للتعامل مع WebSocket.
تمثل الخطوات التالية خوارزمية عمل الواجهة الخلفية:
-
إنشاء مجموعة من مقابس الاتصال
()clients = new Set
. -
إضافة كل مقبس مقبول إلى المجموعة
clients.add(socket)
، وتهيئة مستمعmessage
للحدث للحصول على الرسائل المتعلقة بالمقبس. - عندما استقبال رسالة كرر عملية إرسالها إلى كل عميل.
-
احذف مقابس العملاء عند قطع الاتصال
(clients.delete(socket
.
const ws = new require('ws'); const wss = new ws.Server({noServer: true}); const clients = new Set(); http.createServer((req, res) => { // websocket سنعالج فقط اتصالات // websocket في مشروعنا الحقيقي ستوجد هنا شيفرة تعالج الطلبات بطرق أخرى غير wss.handleUpgrade(req, req.socket, Buffer.alloc(0), onSocketConnect); }); function onSocketConnect(ws) { clients.add(ws); ws.on('message', function(message) { message = message.slice(0, 50); // 50سيكون الحجم الأكبر للرسالة هو for(let client of clients) { client.send(message); } }); ws.on('close', function() { clients.delete(ws); }); }
إليك النتيجة:
يمكن تنزيل التطبيق وتجربته محليًا على جهازك، ولا تنس تثبيت Node.js، ثم تثبيت WebSocket باستخدام الأمر npm install ws
قبل تشغيل التطبيق.
الأحداث المرسلة من قبل الخادم
تشرح التوصيفات Server-Sent Events صنفًا مدمجًا هو EventSource
، حيث يحافظ على الاتصال مع الخادم ويسمح باستقبال أحداث منه، وهو اتصال ثابت مثل WebSocket
تمامًا لكن مع بعض الاختلافات بينهما:
WebSocket
|
EventSource
|
---|---|
ثنائي الاتجاه: إذ يمكن أن يتبادل الخادم والعميل الرسائل | وحيد الاتجاه: فالخادم هو من يرسل الرسائل إلى العميل |
نصوص وبيانات ثنائية | نصوص فقط |
بروتوكول websocket | بروتوكول HTTP النظامي. |
يمثل EventSource
طريقةً أقل قدرةً للاتصال مع الخادم بالموازنة مع WebSocket
، إذًا لماذا نستخدمها؟
يعود السبب الرئيسي لاستخدامه في كونه أبسط، إذ لا نحتاج إلى القدرة الكبيرة لبروتوكول WebSocket في بعض التطبيقات، فتلقي بيانات من الخادم مثل رسائل محادثة أو لوائح أسعار أو أي شيء مشابه هو مجال EventSource
، كما أنه يدعم إعادة الاتصال، وهو أمر لا بد من إنجازه يدويًا في WebSocket
، ويستخدم كذلك بروتوكول HTTP وليس أي بروتوكول جديد.
الحصول على الرسائل
لا بد من إنشاء كائن جديد من الصنف EventSource
للبدء باستقبال الرسائل، وذلك بتنفيذ الأمر (new EventSource(url
، حيث سيتصل المتصفح بالعنوان url
ويبقي الاتصال مفتوحًا ومنتظرًا الأحداث، وهنا ينبغي أن يستجيب الخادم برمز الحالة 200 وبالترويسة Content-Type: text/event-stream
، وسيبقي الخادم بعدها قناة الاتصال مفتوحةً، وسيرسل الرسائل ضمنها بتنسيق خاص بالشكل التالي:
data: Message 1 data: Message 2 data: Message 3 data: of two lines
-
تأتي الرسالة بعد الكلمة
:data
بينما يعَد الفراغ الموجود بعد النقطتين اختياريًا. -
يفصَل بين الرسائل برمز السطر الجديد مضاعفًا
n\n\
-
يمكننا عند الحاجة إلى إرسال رمز سطر جديد
n\
أن نرسل مباشرةً الكلمة:data
(كما في الرسالة الثالثة في المثال السابق)، لكن عمليًاP تًرسل الرسائل المركّبة مرمزةً بتنسيق JSON، وترمّز محارف الانتقال إلى سطر جديد بالشكلn\
ضمنها، فلا حاجة عندها للرسائل:data
متعددة السطر.
فمثلًا:
data: {"user":"John","message":"First line\n Second line"}
وهكذا يمكن أن نعد أن العبارة data:
ستليها رسالة واحدة تمامًا.
يولَّد الحدث message
لكل من تلك الرسائل بالشكل التالي:
let eventSource = new EventSource("/events/subscribe"); eventSource.onmessage = function(event) { console.log("New message", event.data); // سيطبع ثلاث رسائل في المثال السابق }; // or eventSource.addEventListener('message', ...)
الطلبات ذات الأصل المختلط
يدعم الصنف EventSource
الطلبات ذات الأصل المختلط cross-origin requests كما يفعل fetch
، إذ يمكننا استخدام أي عنوان URL:
let source = new EventSource("https://another-site.com/events");
سيستقبل الخادم البعيد الترويسة Origin
وينبغي عليه الإجابة بالترويسة Access-Control-Allow-Origin
للمتابعة، ولتمرير الثبوتيات credentials لا بد من ضبط خيار إضافي هو withCredentials
بالشكل التالي:
let source = new EventSource("https://another-site.com/events", { withCredentials: true });
راجع فصل استخدام Fetch مع الطلبات ذات الأصول المختلطة للاطلاع على تفاصيل أكثر حول ترويسات الأصول المختلطة.
إعادة الاتصال
بعد إنشاء الكائن EventSource
فسيتصل بالخادم مباشرةً ويعيد الاتصال في حال انقطاعه، وبالتالي لا حاجة لإجراء تدابير خاصة بهذا الأمر، وبطبيعة الحال يوجد تأخير زمني صغير لثوان معدودة افتراضيًا بين كل محاولتين لإعادة الاتصال، وهنا يمكن للخادم أن يضبط التأخير اللازم باستخدام الخاصية :retry
في الاستجابة (مقدرًا بالميلي ثانية).
retry: 15000 data: Hello, I set the reconnection delay to 15 seconds
يمكن أن تأتي :retry
مصحوبةً ببيانات أخرى أو برسالة وحيدة، وهكذا سيكون على الخادم انتظار الفترة المحددة قبل إعادة الاتصال، وقد ينتظر فترة أطول، فإذا علم المتصفح -عن طريق نظام التشغيل- أن الشبكة غير متصلة حاليًا مثلًا، فسينتظر حتى يعود الاتصال ثم يحاول ثانيةً.
- إذا أراد الخادم من المتصفح التوقف عن إعادة الاتصال، فعليه أن يرد برمز الحالة 204 (رمز HTTP).
-
إذا أراد المتصفح إغلاق الاتصال، فعليه استدعاء التابع
eventSource.close
.
let eventSource = new EventSource(...); eventSource.close();
إذا احتوت الاستجابة على ترويسة Content-Type
خاطئةً فلن يعاد الاتصال، وكذلك إذا اختلف رمز حالة HTTP عن القيم 301 أو 307 أو 200 أو 204، فعندها سيُولد الحدث "error"
ولن يعيد المتصفح الاتصال.
اقتباسلاحظ أنه عندما يُغلق الاتصال نهائيًا، فلا وسيلة لإعادته، فإن أردنا الاتصال ثانيةً فلا بد من إنشاء كائن
EventSource
جديد.
معرف الرسالة الفريد Message id
عندما ينقطع الاتصال جراء خلل في الشبكة، tلن يتأكد كلا الطرفين من كون الرسائل استلمَت أم لا، وحتى يُستأنَف الاتصال بشكل صحيح، فينبغي أن تحمل كل رسالة مُعرِّفًا مميزًا id
مثل التالي:
data: Message 1 id: 1 data: Message 2 id: 2 data: Message 3 data: of two lines id: 3
عندما يتلقى المتصفح الرسالة مع معرّفها :id
فإنه:
-
يضبط قيمة الخاصية
eventSource.lastEventId
على قيمة المعرِّف. -
يعيد الترويسة
Last-Event-ID
أثناء إعادة الاتصال مع قيمة المعرّفid
، ليتمكن الخادم من إرسال الرسائل التالية إن وجدت.
اقتباستأتي
:id
بعدdata
، حيث يضع الخادم المعرّفid
تحت العبارةdata
للتأكد من تحديث قيمة الخاصيةlastEventId
قبل تلقي الرسالة.
حالة الاتصال: الخاصية ReadyState
يمتلك الكائن EventSource
الخاصية readyState
التي تحمل إحدى القيم الثلاث التالية:
EventSource.CONNECTING = 0; // يتصل أو يعيد الاتصال EventSource.OPEN = 1; // متصل EventSource.CLOSED = 2; // الاتصال مغلق
تكون قيمة هذه الخاصية عند إنشاء كائن جديد أو انقطاع الاتصال هي EventSource.CONNECTING
دومًا (وهي تعادل 0)، ويمكن الاستعلام عن هذه الخاصية لمعرفة حالة الكائن EventSource
.
أنواع الأحداث
يوّلِّد الكائن EventSource
افتراضيًا ثلاثة أحداث:
-
message
: عند استلام رسالة، وتكون متاحةً باستخدامevent.data
. -
open
: عندما يكون الاتصال مفتوحًا. -
error
: عند عدم إمكانية تأسيس الاتصال، كأن يعيد الخادم رمز الحالة 500.
فمثلًا:
event: join data: Bob data: Hello event: leave data: Bob
لمعالجة الأحداث السابقة لابد من استخدام مستمع الحدث addEventListener
وليس onmessage
:
eventSource.addEventListener('join', event => { alert(`Joined ${event.data}`); }); eventSource.addEventListener('message', event => { alert(`Said: ${event.data}`); }); eventSource.addEventListener('leave', event => { alert(`Left ${event.data}`); });
مثال نموذجي كامل
يرسل الخادم في هذا المثال الرسائل 1
و2
و3
ثم bye
ويقطع الاتصال، بعدها يعيد المتصفح الاتصال تلقائيًا.
شيفرة الخادم "server.js":
let http = require('http'); let url = require('url'); let querystring = require('querystring'); let static = require('node-static'); let fileServer = new static.Server('.'); function onDigits(req, res) { res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache' }); let i = 0; let timer = setInterval(write, 1000); write(); function write() { i++; if (i == 4) { res.write('event: bye\ndata: bye-bye\n\n'); clearInterval(timer); res.end(); return; } res.write('data: ' + i + '\n\n'); } } function accept(req, res) { if (req.url == '/digits') { onDigits(req, res); return; } fileServer.serve(req, res); } if (!module.parent) { http.createServer(accept).listen(8080); } else { exports.accept = accept; }
شيفرة الملف "index.html":
<!DOCTYPE html> <script> let eventSource; function start() { // "Start" عند ضغط الزر if (!window.EventSource) { // متصفح إنترنت إكسبلورر أو أي متصفح قديم alert("The browser doesn't support EventSource."); return; } eventSource = new EventSource('digits'); eventSource.onopen = function(e) { log("Event: open"); }; eventSource.onerror = function(e) { log("Event: error"); if (this.readyState == EventSource.CONNECTING) { log(`Reconnecting (readyState=${this.readyState})...`); } else { log("Error has occurred."); } }; eventSource.addEventListener('bye', function(e) { log("Event: bye, data: " + e.data); }); eventSource.onmessage = function(e) { log("Event: message, data: " + e.data); }; } function stop() { // when "Stop" button pressed eventSource.close(); log("eventSource.close()"); } function log(msg) { logElem.innerHTML += msg + "<br>"; document.documentElement.scrollTop = 99999999; } </script> <button onclick="start()">Start</button> Press the "Start" to begin. <div id="logElem" style="margin: 6px 0"></div> <button onclick="stop()">Stop</button> "Stop" to finish.
وستظهر النتيجة بالشكل التالي:
خلاصة
تعرفنا في هذا الفصل على آليات إنشاء إتصال مستمر مع الخادم، فبدأ أولًا بعرض آلية الاستطلاع والجس وهي الطريقة الأبسط للمحافظة على اتصال ثابت مع الخادم، ولا تتطلب أي بروتوكولات أو أي أحداث خاصة من طرف الخادم، وهي طريقة سهلة الإنجاز وكافية في الكثير من الحالات.
انتقلنا بعدها إلى شرح بروتوكول WebSocket والذي يُقدم طريقةً عصريةً لتأسيس اتصال ثابت بين المتصفح والخادم، له ميزات منها:
- لا توجد تقييدات للأصول المختلطة.
- مدعوم جيدًا من قبل المتصفحات.
- يمكنه إرسال نصوص وبيانات ثنائية واستقبالها.
- واجهته البرمجية بسيطة.
التوابع المستعملة في هذا البروتوكول:
- socket.send(data)
- socket.close([ code ], [reason])
أما الأحداث:
-
open
-
message
-
error
-
close
لا تقدّم WebSocket بذاتها تقنيات لإعادة الاتصال أو الاستيثاق وغيرها من الآليات عالية المستوى، لذلك ستجد مكتبات للعميل والخادم تهتم بهذه النقاط، ويمكنك أيضًا تنفيذ شيفرتها يدويًا بنفسك. ولكي تتكامل WebSocket مع مشروع جاهز، جرت العادة أن يشغل المستخدم خادم WebSocket بالتوازي مع خادم HTTP الرئيسي ليتشاركا بقاعدة بيانات واحدة، حيث تُرسل طلبات WebSocket إلى النطاق الفرعي wss://ws.site.com
الذي يقود إلى خادم WebSocket، بينما يقود العنوان https://site.com
إلى خادم HTTP الرئيسي، كما توجد طرق أخرى للتكامل.
عرضنا أخيرًا الآلية الثالثة للاتصال وهي عبر الكائن EventSource الذي يؤسس اتصالًا ثابتًا مع الخادم ويسمح له بإرسال الرسائل عبر قناة الاتصال، ويقدم:
-
إعادة الاتصال التلقائي مع قيمة زمنية
retry
لإعادة الاتصال بعدها. -
معرّفات فريدة للرسائل "ids" لاستئناف الأحداث من آخر معرّف أرسِل مع الترويسة
Last-Event-ID
أثناء إعادة الاتصال. -
حالة الاتصال من خلال الخاصية
readyState
.
تجعل هذه المزايا الكائن EventSource
بديًلا قيمًا للكائن WebSocket
، الذي يعمل في مستوىً أدنى ويفتقر إلى الميزات السابقة، والتي يمكن إنجازها يدويًا بالطبع.
يعَد الكائن EventSource
في الكثير من التطبيقات الحقيقية قادرًا كفاية على إنجاز المطلوب، وتدعمه جميع المتصفحات الحديثة، بينما لا يدعمه IE، وإليك صيغة استخدامه:
let source = new EventSource(url, [credentials]);
للوسيط الثاني خيار وحيد ممكن هو: { withCredentials: true }
، ويسمح بإرسال ثبوتيات طلبات الأصول المختلطة.
إن عوامل الأمان المتعلقة باستخدام طلبات الأصول المختلطة للكائن EventSource
مشابهة للاتصال fetch
وغيره من طرق الاتصال عبر الشبكات.
خاصيات الكائن EventSource:
-
readyState
: حالة الاتصال القائم-
EventSource.CONNECTING (=0)
-
EventSource.OPEN (=1)
-
EventSource.CLOSED (=2)
-
-
lastEventId
: آخر معرّف استقبله المتصفح، وسيرسله أثناء إعادة الاتصال مع الترويسةLast-Event-ID
.
وتوابع EventSource فتتمثل بالتابع:
-
()close
: يغلق الاتصال.
أما أحداث EventSource فهي:
-
message
: عند استلام رسالة، وتكون متاحةً باستخدامevent.data
. -
open
: عندما يكون الاتصال مفتوحًا. -
error
: في حال وقع خطأ، بما في ذلك فقدان الاتصال أو الأخطاء القاتلة، يمكننا التحقق من الخاصيةreadyState
للتأكد من وجود محاولات لإعادة الاتصال.
يمكن للخادم اختيار اسم مخصص لحدث ما من خلال :event
، وينبغي معالجة هذه الأحداث من خلال مستمع حدث addEventListener
وليس باستخدام <on<event
.
رأينا من أجل تنسيق استجابة الخادم أن الخادم يرسل الرسائل مفصولةً عن بعضها باستخدامn\n\
، ويمكن أن تحتوي الرسالة الحقول التالية:
-
:data
ويمثل جسم الرسالة، وتُفسَّر سلسلة من الحقولdata
كرسالة واحدة يفصل بين أجزائها الكتلةn\
. -
:id
تُجدِّد قيمةlastEventId
وتُرسَل ضمن الترويسةLast-Event-ID
عند إعادة الاتصال. -
:retry
تحدد فترة التأخير الزمني بين كل محاولتين لإعادة الاتصال بالميلي ثانية، ولا يمكن ضبطها باستخدام JavaScript. -
:event
اسم الحدث وينبغي أن تسبق الحقلdata
.
يمكن أن تحتوي الرسالة على أحد الحقول السابقة أو أكثر وبأي ترتيب، لكن الحقل :id
يأتي في النهاية عادةً.
ترجمة -وبتصرف- للفصول Long polling وWebSocket وServer sent events من سلسلة The Modern JavaScript Tutorial.
اقرأ أيضًا
- المقال السابق: استئناف رفع الملفات في جافاسكريبت
- تطويع البيانات في جافاسكربت
أفضل التعليقات
لا توجد أية تعليقات بعد
انضم إلى النقاش
يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.