اذهب إلى المحتوى

آليات الاتصال المستمر مع الخادم في جافاسكربت


ابراهيم الخضور

سنشرح في هذا المقال آليات الاتصال الدائم المستمر مع الخادم وهو الاتصال الذي يُنشَأ فيه اتصال مستمر بين الخادم والعميل (غالبًا متصفح الويب) يجري فيه تبادل الطلبيات والردود آنيًا، وتنحصر آلية تنفيذ الاتصال الدائم تلك في ثلاثة أنواع: آلية الاستطلاع المفتوح Long polling وآلية مقابس الويب عبر البروتوكول WebSocket وآلية الأحداث المرسلة EventSource، سنناقش كل واحدة بالتفصيل على حدة.

آلية الاستطلاع

آلية الجس والاستطلاع polling هي الطريقة الأبسط للمحافظة على اتصال ثابت مع الخادم، ولا تتطلب أي بروتوكولات خاصة مثل WebSocket، أو أي أحداث من طرف الخادم، كما أنها سهلة الإنجاز وكافية في الكثير من الحالات.

الاستطلاع الدوري Regular polling

الطريقة الأبسط للحصول على معلومات من الخادم هي الاستطلاع الدوري، بإرسال طلبات إلى الخادم لسؤاله عن أي معلومات جديدة على فترات زمنية منتظمة (مثلًا: كل 10 ثوان)، وعند الاستجابة ينتبه الخادم إلى اتصال العميل به، ثم يرسل حزمةً من الرسائل التي تلقاها حتى اللحظة، سيعمل الأمر كما هو متوقع، لكن مع بعض الجوانب السلبية، منها:

  1. تمرَّر الرسائل بتأخير زمني قد يصل إلى 10 ثوان (الزمن الفاصل بين الطلبات).
  2. سيُغرَق الخادم بالطلبات كل 10 ثوان حتى لو غادر المستخدم الموقع أو استغرق في النوم، وهذا حمل كبير عليه من منظور الأداء.

لهذا قد تكون هذه المقاربة جيدةً عندما تكون الخدمة بسيطةً، لكنها وبشكل عام تحتاج إلى التحسين.

الاستطلاع المفتوح Long polling

وهي طريقة أفضل للاستطلاع أو الجس من حالة الخادم، وتتميز بسهولة تنفيذها، وقدرتها على تسليم الرسائل دون تأخير.

حيث:

  1. يُرسَل الطلب إلى الخادم.
  2. لا يغلق الخادم الاتصال حتى تكون لديه رسالة لتسليمها.
  3. عندما تظهر الرسالة، يستجيب الخادم للطلب بهذه الرسالة.
  4. يرسل المتصفح طلبًا جديدًا مباشرة.

request_recieve_01.png

إن أساس هذه الطريقة هو إرسال المتصفح للطلب وإبقاء الاتصال مع الخادم قيد الانتظار، ولا يهيّئ المتصفح الاتصال من جديد حتى يتسلم الاستجابة، فإن فُقد الاتصال نتيجة خطأ ما، فسيرسل المتصفح طلبًا آخر مباشرةً.

تمثل الشيفرة التالية الدالة 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 ثوان ويقطع الاتصال.

سترى إذًا تسلسل الأحداث openmessageclose، والأمر بهذه البساطة فعليًا، لأنك تستخدم الآن البروتوكول WebSocket، لنتحدث الآن في أمور أكثر عمقًا.

فتح اتصال مقبس ويب WebSocket

يُنشئ الأمر (new WebSocket(url كائنًا جديدًا ويبدأ عملية الاتصال مباشرةً، سيسأل المتصفح الخادم خلال تأسيس الاتصال -عبر الترويسات- إذا كان يدعم البروتوكول Websocket، فإن كان الجواب نعم فسيستمر العمل به، وهو يختلف عن HTTP كليًا.

Copy of websocket_02.png

وإليك مثالًا عن ترويسات المتصفح للطلب الذي يرسله الأمر (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>

كما سنستخدم في جافاسكربت:

  1. فتح الاتصال.
  2. إرسال رسالة عن طريق النموذج (socket.send(message.
  3. وضع الرسالة المستقبلة ضمن 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.

تمثل الخطوات التالية خوارزمية عمل الواجهة الخلفية:

  1. إنشاء مجموعة من مقابس الاتصال ()clients = new Set.
  2. إضافة كل مقبس مقبول إلى المجموعة clients.add(socket)، وتهيئة مستمع message للحدث للحصول على الرسائل المتعلقة بالمقبس.
  3. عندما استقبال رسالة كرر عملية إرسالها إلى كل عميل.
  4. احذف مقابس العملاء عند قطع الاتصال (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 والذي يُقدم طريقةً عصريةً لتأسيس اتصال ثابت بين المتصفح والخادم، له ميزات منها:

  • لا توجد تقييدات للأصول المختلطة.
  • مدعوم جيدًا من قبل المتصفحات.
  • يمكنه إرسال نصوص وبيانات ثنائية واستقبالها.
  • واجهته البرمجية بسيطة.

التوابع المستعملة في هذا البروتوكول:

  1. socket.send(data)‎
  2. socket.close([ code ], [reason])‎

أما الأحداث:

  1. open
  2. message
  3. error
  4. 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.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...