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

البرمجة غير المتزامنة في جافاسكريبت


أسامة دمراني
اقتباس

قد يدرك المتأني بعض حاجته، وقد يكون مع المستعجل الزلل.

chapter_picture_11.jpg

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

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

عدم التزامن Asynchronicity

تحدث الأشياء في نموذج البرمجة المتزامنة synchronous واحدةً تلو الأخرى، فلا تعيد دالة تنفِّذ إجراءً يعمل على المدى الطويل إلا إذا وقع الإجراء وصار بإمكانه إعادة النتيجة، وبالتالي سيُوقِف هذا برنامجك إلى حين تنفيذ ذلك الإجراء؛ أما النموذج غير المتزامن asynchronous فسيسمح بحدوث عدة أشياء في الوقت نفسه، حيث سيستمر برنامجك بالعمل إذا بدأت بإجراء ما، كما سيُبلَّغ البرنامج حين ينتهي الإجراء ويحصل على وصول access إلى النتيجة مثل قراءة البيانات من القرص.

نستطيع موازنة البرمجة المتزامنة وغير المتزامنة باستخدام مثال بسيط، وهو برنامج يجلب مَوردَين من الشبكة ثم يوازن النتيجة، بحيث تُعيد دالة الطلب في البيئة المتزامنة بعد إنهاء عملها. وأسهل طريقة لتنفيذ ذلك الإجراء هي جعل الطلبات واحدًا تلو الآخر، وهذا له تبعته، إذ لن يبدأ الطلب الثاني إلا حين ينتهي الطلب الأول، كما سيكون الوقت الكلي المستغرق مساويًا وقت رد الطلبَين معًا.

يكون حل تلك المشكلة في النظام المتزامن ببدء خيوط تحكم control threads إضافية، وقد يكون الخيط هنا برنامجًا آخرًا عاملًا قد يتزامن تنفيذه مع برامج أخرى بواسطة نظام التشغيل، وبما أنّ أغلب الحواسيب الحديثة تحتوي على عِدة معالجات، فقد تعمل الخيوط المتعددة في الوقت نفسه، بل قد تعمل على معالجات مختلفة، فيبدأ خيط جديد الطلب الثاني، ثم ينتظر الخَيطان عودة نتائجهما، وبعد ذلك يُعيدا المزامنة من أجل دمج النتائج.

تمثِّل الخطوط السميكة thick lines في المخطط التالي الوقت الذي ينفقه البرنامج في العمل العادي، في حين تمثِّل الخطوط الرفيعة thin lines وقت انتظار الشبكة، كما يكون الوقت الذي تأخذه الشبكة في النموذج المتزامن جزءًا من الخط الزمني timeline لخط تحكم ما؛ أما في النموذج غير المتزامن، فيتسبب بدء إجراء شبكة في حدوث انقسام split في الخط الزمني، كما يستمر البرنامج الذي ابتدأ الحدث بالعمل، ويقع الحدث معه أيضًا، ثم ينبه البرنامج حين ينتهي.

control-io.png

هناك طريقة أخرى تصف الفرق بأنّ انتظار انتهاء الأحداث في النموذج المتزامن أمر ضمني implicit، بينما يكون صريحًا explicit وتحت تحكمنا في النموذج غير المتزامن.

تتقاطع اللاتزامنية مع كلا الطريقتين، فهي تسهل التعبير عن البرامج التي لا تتوافق مع النموذج الخطي للتحكم، في حين قد تصعِّب التعبير عن البرامج التي تتبع النموذج الخطي، وسنرى بعض الطرق للنظر في تلك الغرابة في هذا المقال لاحقًا.

تجعل المنصَّتان الهامتان لجافاسكربت -أي المتصفحات وNode.js- العمليات التي تستغرق وقتًا غير متزامنة، بدلًا من الاعتماد على الخيوط، وذلك جيد بما أنّ البرمجة بالخيوط صعبة جدًا بسبب صعوبة فهم مهمة البرنامج حين ينفِّذ أمورًا كثيرةً في وقت واحد.

تقنية الغراب

تستطيع الغربان استخدام الأدوات والتخطيط وتذكر الأشياء، وإيصال هذه الأشياء فيما بينهم، إذ أن شأنها في ذلك شأن غيرها من المخلوقات.

لكن ما لا يعلمه الكثير عنها أنها قادرة على أمور أكثر من ذلك، إذ تُنشئ العديد من أسراب الغربان أجهزةً حاسوبيةً حيويةً مثلًا، فهي ليست إلكترونية مثل التي نصنعها، لكنها تتكون من حشرات دقيقة أشبه بالنمل الأبيض، إذ تُطوِّر معها علاقةً تكافليةً بحيث تزودها الغربان بالطعام مقابل أن تبني الحشرات تلك المستعمرات المعقدة وتشغلها، لتُجري حسابات لاحقًا بمساعدة كائنات حية داخلها.

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

رسم أحد الخبراء خارطةً لشبكة أعشاش الغربان في قرية إييغ-سوغ-امبي Hières-sur-Amby الفرنسية، بحيث توضِّح أماكن الأعشاش واتصالاتها، كما في الشكل التالي:

Hieres-sur-Amby.png

سنكتب في هذا المقال بعض الدوال الشبكية البسيطة لمساعدة الغربان على التواصل.

ردود النداء Callbacks

يمكن النظر إلى البرمجة غير المتزامنة على أنها تجعل الدوال التي تنفِّذ إجراءً بطيئًا تأخذ وسيطًا إضافيًا يكون دالة رد نداء callback function، حيث يبدأ الإجراء، ثم تُستدعى دالة رد النداء بالنتيجة حين ينتهي.

لدينا مثلًا دالة setTimeout المتاحة في المتصفحات وNode.js على سواء، إذ تنتظر زمنًا بعينه يقاس بالميلي ثانية، ثم تَستدعي الدالة.

setTimeout(() => console.log("Tick"), 500);

لا يُعَدالانتظار عملًا مهمًا، لكن قد يكون مفيدًا حين نفعل أمرًا مثل تحديث رسم تحريكي أو التحقق من استغراق شيء وقتًا أطول من الوقت المسموح به، ويعني تنفيذ عدة أحداث غير متزامنة متتالية باستخدام ردود النداء؛ أنه علينا الاستمرار بتمرير دوال جديدة لمعالجة استمرارية الحوسبة بعد الحدث.

تحتوي أغلب حواسيب أعشاش الغربان على حوصلة bulb تخزين بيانات طويل الأمد، حيث تُحفَر أجزاء المعلومات في الأغصان كي تُستخدَم لاحقًا، ويستغرق ذلك الحفر أو إيجاد جزء من البيانات وقتًا، لذا فإن واجهة التخزين طويل الأمد غير متزامنة، كما تَستخدِم دوال رد نداء.

تخزِّن حواصل التخزين أجزاءً من بيانات JSON القابلة للتشفير تحت أسماء، وقد يخزِّن الغراب معلومات عن المكان الذي فيه الطعام المخبأ تحت اسم "food caches"، والذي قد يحمل مصفوفةً من الأسماء التي تشير إلى أجزاء أخرى من البيانات التي تصف الذاكرة المؤقتة الحقيقية، كما سيشغل الغراب مثل الشيفرة التالية للبحث عن مخزون طعام في حواصل التخزين storage bulbs في عش "Big Oak":

import {bigOak} from "./crow-tech";

bigOak.readStorage("food caches", caches => {
  let firstCache = caches[0];
  bigOak.readStorage(firstCache, info => {
    console.log(info);
  });
});

يُعَدّ هذا النسق من البرمجة قابلًا للتطبيق، لكنه يزيد من مستوى الإزاحة مع كل حدث غير متزامن، وذلك لأننا سنصل إلى دالة أخرى تفعل أمورًا أعقد من التي بين أيدينا لا محالة، مثل تنفيذ أحداث متعددة في الوقت نفسه، إذ سيكون الأمر غير مألوف.

تُبنى حواسيب الغربان العشيَّة لتتواصل فيما بينها باستخدام أزواج من طلب-رد request-response، وهذا يعني أنّ أحد الأعشاش سيرسل رسالةً إلى عش آخر، ثم يرد الثاني مباشرةً مؤكدًا استلام الرسالة، وربما يرد أيضًا على الرسالة في الوقت نفسه، كما تُوسَم كل رسالة بنوع يحدد كيفية معالجتها، إذ تستطيع شيفرتنا تعريف المعالِجات handlers لكل نوع من أنواع الطلبات، ويُستدعى المعالج متى ما دخل ذلك الطلب لإنتاج رد.

توفِّر الواجهة التي صدَّرتها وحدة ‎"./crow-tech"‎ دوالًا مبنيةً على ردود النداء من أجل التواصل، كما تحتوي الأعشاش على التابع send الذي يرسل الطلب، إذ يتوقع اسم العش الهدف ونوع الطلب ومحتواه على أساس أول ثلاثة وسائط، ثم يتوقع دالةً لتُستدعى حين يأتي الرد على أساس وسيط رابع وأخير.

bigOak.send("Cow Pasture", "note", "Let's caw loudly at 7PM",
            () => console.log("Note delivered."));

لكن يجب تعريف نوع طلب أولًا باسم "note" لتمكين الأعشاش من استقبال ذلك الطلب، كما يجب أن تعمل الشيفرة التي تعالج الطلبات على جميع الأعشاش التي تستطيع استقبال رسالة من هذا النوع وليس على حاسوب العش فقط، حيث سنفترض أنّ الغراب سيطير هنا ليثبِّت شيفرة المعالج تلك على جميع الأعشاش.

import {defineRequestType} from "./crow-tech";

defineRequestType("note", (nest, content, source, done) => {
  console.log(`${nest.name} received note: ${content}`);
  done();
});

تُعرِّف الدالة defineRequestType نوعًا جديدًا من الطلبات، كما يضيف المثال الدعم لطلبات "note" التي ترسِل تذكرة note إلى العش المعطى، ويَستدعي تطبيقنا console.log كي نستطيع توكيد وصول الطلب، كما تحتوي الأعشاش على الخاصية name التي تحمل أسماءها؛ أما الوسيط الرابع المعطى للمعالج فهو done، وهي دالة رد نداء يجب أن تستدعي عند انتهاء الطلب، فإذا استخدمنا قيمة الإعادة للمعالج على أساس قيمة رد، فهذا يعني عدم استطاعة معالج الطلب تنفيذ إجراءات غير متزامنة بنفسه، فالدالة التي تنفذ مهامًا غير متزامنة تُعيد قبل انتهاء العمل، كما تكون قد جهزت رد نداء عند انتهاءها، لذا فنحن نحتاج إلى آلية غير متزامنة، وهي دالة رد نداء أخرى في حالتنا لترسل إشعارًا حين يتاح ردًا.

قد يكون اللاتزامن معديًا contagious نوعًا ما، فأيّ دالة تستدعي دالةً أخرى تعمل بأسلوب غير متزامن يجب أن تكون هي نفسها غير متزامنة، ومستخدمةً لرد نداء أو آليةً مشابهةً لتسليم نتيجتها؛ أما استدعاء رد النداء ففيه تفصيل أكثر من مجرد إعادة قيمة ما، كما يكون عرضةً للأخطاء، لذا فإنّ الحاجة إلى هيكلة أجزاء كبيرة من برنامجنا بهذه الطريقة ليست الأسلوب الأمثل.

الوعود

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

يُعَدّ استدعاء Promise.resolve أسهل طريقة لإنشاء وعد، وهي دالة تضمن أنّ القيمة التي تعطيها إياها ستغلَّف داخل وعد، فإذا كان هي نفسها وعدًا، فستعاد ببساطة، وإلا فستحصل على وعد ينتهي مباشرةً بقيمتك على أساس نتيجة.

let fifteen = Promise.resolve(15);
fifteen.then(value => console.log(`Got ${value}`));
// → Got 15

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

يُعَدّ النظر للوعود على أنها جهاز لنقل القيم إلى بيئة غير متزامنة مفيدًا، فالقيمة العادية موجودة والقيمة الموعودة promised value قد تكون موجودة، كما قد تظهر في نقطة ما في المستقبل، كما تتصرف الحسابات المعرَّفة بالوعود على مثل هذه القيم المغلَّفة wrapped values وتنفَّذ تنفيذًا غير متزامن عندما تكون القيم متاحة.

نستطيع استخدام Promise على أساس باني لكي ننشئ وعدًا، لكن هذا سيكون له واجهة غريبة نوعًا ما، إذ يتوقع الباني دالةً على أساس وسيط ويستدعيها مباشرةً، ثم يمرر إليها دالةً تستطيع استخدامها لحل الوعد وإنهائه، وسبب عملها بهذا الأسلوب بدلًا من استخدام التابع resolve مثلًا، هو اقتصار حل الوعد على الشيفرة التي أنشأته.

يوضِّح المثال التالي كيفية إنشاء واجهة مبنية على وعد لدالة readStorage:

function storage(nest, name) {
  return new Promise(resolve => {
    nest.readStorage(name, result => resolve(result));
  });
}

storage(bigOak, "enemies")
  .then(value => console.log("Got", value));

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

الفشل Failure

يمكن أن تفشل حسابات جافاسكربت العادية برفع استثناء exception، إذ تحتاج الحوسبة غير المتزامنة إلى ذلك عادةً، فقد يفشل طلب الشبكة أو شيفرة ترفع استثناءً وتكون جزءًا من الحوسبة غير المتزامنة.

إحدى المشاكل المؤرقة في نمط رد النداء للبرمجة غير المتزامنة أنها تصعِّب ضمان الإبلاغ الصحيح لردود النداء بشأن حالات الفشل، وأحد الحلول المستخدَمة على نطاق واسع هو استخدام وسيط رد النداء الأول لتوضيح فشل الإجراء، بعدها سيحتوي الوسيط الثاني على القيمة التي أنتجها الإجراء حين كان ناجحًا.

يجب أن تتحقق دوال ردود النداء هذه إذا كانت قد استقبلت استثناءً أم لا، كما عليها التأكد من أنّ أيّ مشكلة تسببها بما فيها الاستثناءات المرفوعة من قِبَل الدوال التي استدعتها، قد عُلم بها وسُلِّمت إلى الدالة المناسبة؛ أما الوعود فتيسِّر من هذه العملية كثيرًا، فهي إما محلولة -أي انتهى الإجراء بنجاح-، أو مرفوضة -أي فشلت-، إذ لا تُستدعى معالِجات الإنهاء resolve handlers التي سُجلت في then إلا عند نجاح الإجراء؛ أما عمليات الرفض فتُنقَل إلى الوعد الجديد الذي تُعيده then. وحين يرفع معالج استثناءً، فسيجعل هذا الوعد الذي أنتجه الاستدعاء إلى then مرفوضًا.

لهذا إذا فشل أيّ عنصر في سلسلة إجراءات غير متزامنة، فسيحدَّد الناتج الكلي للسلسلة على أنه مرفوض، ولا تُستدعى معالِجات نجاح أبعد من النقطة التي فشلت فيها.

كذلك فإنّ رَفْض وعد ما ينتج قيمةً تمامًا مثل التي ينتجها حل الوعد، كما يُطلق على تلك القيمة سبب الرفض، فإذا تسبب استثناء في دالة معالِجة بالرفض، فستُستخدِم قيمة الاستثناء على أساس سبب، وبالمثل، إذا أعاد معالج وعدًا مرفوضًا، فسينتقل ذلك الرفض إلى الوعد التالي، حيث لدينا دالة Promise.reject التي تنشئ وعدًا جديدًا مرفوضًا فورًا.

تحتوي الوعود على التابع catch لمعالجة عمليات الرفض تلك صراحةً، إذ يسجِّل معالجًا ليُستدعى حين يُرفض الوعد مثلما تعالج معالجات then الحل العادي للوعود، كما تشبه then في أنها تُعيد وعدًا جديدًا، إذ يحل إلى قيمة الوعد الأصلية إذا حل حلًا عاديًا، وإلى نتيجة معالج catch في غير ذلك.

يُرفَض الوعد الجديد كذلك إذا رفع معالج catch خطأً ما، وللاختصار، تقبل then معالج الرفض على أساس وسيط ثاني، لذا نستطيع تثبيت كلا النوعين من المعالجات في استدعاء تابع وحيد، كما تستقبل الدالة الممرَّرة إلى الباني Promise وسيطًا ثانيًا مع دالة حل resolve function لتستخدمها في رفض الوعد الجديد.

يمكن النظر إلى سلاسل قيم الوعود المنشأة باستدعاءات إلى then وcatch على أساس خط أنابيب تتحرك فيه عمليات الفشل أو القيم غير المتزامنة، وبما أنّ تلك السلاسل قد أُنشئت بتسجيل معالجات، فسيكون لكل رابط معالج نجاح أو معالج فشل أو كليهما؛ حيث تُهمَل المعالِجات التي لا تطابق نوع الخرج -بنجاح أو فشل-؛ أما المعالجات التي تطابق فستُستدعى ويحدِّد خرجها نوع القيمة التي ستأتي تاليًا، بحيث تكوّن نجاحًا حين تُعيد قيمةً ليست وعدًا، وفشلًا حين ترفع استثناءً، وخرْج الوعد حين تُعيد أحد هذين.

new Promise((_, reject) => reject(new Error("Fail")))
  .then(value => console.log("Handler 1"))
  .catch(reason => {
    console.log("Caught failure " + reason);
    return "nothing";
  })
  .then(value => console.log("Handler 2", value));
// → Caught failure Error: Fail
// → Handler 2 nothing

تُعالَج الاستثناءات غير الملتقَطة بواسطة البيئة، إذ تستطيع بيئات جافاسكربت استشعار إذا لم يعالَج رفض الوعد، وستُبلِّغ هذا في صورة خطأ.

صعوبة الشبكات

قد تأتي أيام لا يكون فيها ضوء كافي لنظام المرايا الذي يستخدمه الغربان من أجل نقل إشارة، أو قد يحجب شيء ما مسار الإشارة، كذلك قد تُرسَل إشارة ولا تُستقبَل من الطرف الآخر، حيث سيتسبب ذلك في عدم استدعاء رد النداء المعطى إلى send، وهو الأمر الذي سيجعل البرنامج يتوقف دون أي ملاحظة حتى لوجود مشكلة، فلو بلَّغ الطلب بالفشل إذا مر زمن محدد دون رد، لكان أفضل.

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

وبما أننا عرفنا الآن أن الوعود أمر جيد، فسنجعل دالة الطلب تُعيد وعدًا، إذ يتشابه رد النداء والوعد من حيث ما يستطيعان التعبير عنه، كما يمكن تغليف الدوال المبنية على ردود النداء لكشف واجهة مبنية على وعد، والعكس صحيح.

قد يشير الرد إلى فشل عند تسليم الطلب ورده بنجاح إذا حاول الطلب استخدام نوع طلب غير معرَّف أو رفع المعالج خطأ مثلًا.

لدعم هذا يتَّبع كل من send وdefineRequestType الأسلوب المذكور أعلاه، حيث يكون الوسيط الأول الممرَّر إلى ردود النداء هو سبب الفشل إذا وُجد، في حين يكون الثاني هو النتيجة الحقيقية، ويمكن ترجمة هذا إلى حل الوعد ورفضه باستخدام المغلِّف الخاص بنا.

class Timeout extends Error {}

function request(nest, target, type, content) {
  return new Promise((resolve, reject) => {
    let done = false;
    function attempt(n) {
      nest.send(target, type, content, (failed, value) => {
        done = true;
        if (failed) reject(failed);
        else resolve(value);
      });
      setTimeout(() => {
        if (done) return;
        else if (n < 3) attempt(n + 1);
        else reject(new Timeout("Timed out"));
      }, 250);
    }
    attempt(1);
  });
}

سينجح ما فعلناه أعلاه لأنّ الوعود لا يمكن حلها أو رفضها إلا مرةً واحدة، إذ تُحدِّد المرة الأولى التي يُستدعى فيها resolve أو reject خرج الوعد، كما تُتجاهل الاستدعاءات التالية التي سببها طلب عائد بعد انتهاء طلب آخر.

سنحتاج إلى استخدام دالة تعاودية لبناء حلقة غير متزامنة من أجل إعادة المحاولة التي ذكرناها، حيث لا تسمح الحلقة العادية بالتوقف وانتظار الإجراء غير المتزامن، وتنفِّذ الدالة attempt محاولةً واحدةً لإرسال الطلب وتضبط زمن مهلة محدد، فإذا لم تحصل على استجابة أو رد بعد ربع ثانية، فستبدأ المحاولة الثانية أو ترفض الوعد إذا كانت هذه هي المحاولة الثالثة، ويكون السبب هنا هو نسخة من Timeout.

تُعَدّ المحاولة كل ربع ثانية وتركها إذا لم نتلق ردًا بعد ثلاثة أرباع ثانية نظامًا عشوائيًا نوعًا ما، فمن المحتمل إذا جاء الطلب لكن المعالج استغرق وقتًا أطول قليلًا أن تُسلَّم الطلبات أكثر من مرة، وسنكتب معالجاتنا واضعين تلك المشكلة في حساباتنا، فلا يجب أن تمثل الرسائل المكررة مشكلة.

لن نبني شبكة عالية المستوى هنا، فسقف توقعات الغربان ليس عاليًا على أي حال حين يتعلق الأمر بالحوسبة، ولكي نعزل أنفسنا من ردود النداء كلها، فسنعرِّف مغلِّفًا لـ defineRequestType يسمح لدالة المعالِج أن تُعيد وعدًا أو قيمةً مجردةً plain value ويوصل ذلك لرد النداء من أجلنا.

function requestType(name, handler) {
  defineRequestType(name, (nest, content, source,
                           callback) => {
    try {
      Promise.resolve(handler(nest, content, source))
        .then(response => callback(null, response),
              failure => callback(failure));
    } catch (exception) {
      callback(exception);
    }
  });
}

نستخدِم Promise.resolve لتحويل القيمة التي يُعيدها handler إلى وعد إذا لم تُحوَّل فعليًا.

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

تجميعات الوعود

يحتفظ حاسوب العش بمصفوفة من الأعشاش الأخرى التي في نطاق الاتصال في الخاصية neighbors.

سنكتب دالةً تحاول إرسال طلب "ping" إلى كل حاسوب لنرى أيها ترد علينا، وهو طلب يسأل ردًا ببساطة، وذلك لنرى إن كان مكن الوصول إلى أيّ من تلك الأعشاش.

تُعَدّ الدالة Promise.all مفيدةً عند العمل مع تجميعات من الوعود التي تعمل في نفس الوقت، إذ تُعيد وعدًا ينتظر حل جميع الوعود التي في المصفوفة، ثم يُحل هو إلى مصفوفة من القيم التي أنتجتها تلك الوعود بترتيب المصفوفة الأصلية نفسها، وإذا رُفض أي وعد فستُرفَض نتيجة Promise.all كذلك.

requestType("ping", () => "pong");

function availableNeighbors(nest) {
  let requests = nest.neighbors.map(neighbor => {
    return request(nest, neighbor, "ping")
      .then(() => true, () => false);
  });
  return Promise.all(requests).then(result => {
    return nest.neighbors.filter((_, i) => result[i]);
  });
}

لا نريد فشل الوعد المجمَّع إذا كان أحد الجيران غير متاح ونحن ما زلنا لا نعرف باقي البيانات، لذا تُلحِق الدالة التي رُبطت بمجموعة من الجيران لتحولهم إلى وعود طلبات، حيث أن المعالجات تصنع طلبات ناجحة تنتج true وطلبات فاشلة تنتج false.

يُستخدَم filter في معالج الوعد المجمَّع لحذف العناصر من مصفوفة neighbors التي تكون قيمتها الموافقة هي false، إذ يستفيد هذا من كون filter يمرر فهرس مصفوفة العنصر الحالي على أساس وسيط ثاني إلى دالة الترشيح، كما يفعل map وsome وتوابع المصفوفات العليا المشابهة.

إغراق الشبكة Network flooding

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

import {everywhere} from "./crow-tech";

everywhere(nest => {
  nest.state.gossip = [];
});

function sendGossip(nest, message, exceptFor = null) {
  nest.state.gossip.push(message);
  for (let neighbor of nest.neighbors) {
    if (neighbor == exceptFor) continue;
    request(nest, neighbor, "gossip", message);
  }
}

requestType("gossip", (nest, message, source) => {
  if (nest.state.gossip.includes(message)) return;
  console.log(`${nest.name} received gossip '${
               message}' from ${source}`);
  sendGossip(nest, message, source);
});

يحتفظ كل عش بمصفوفة من سلاسل gossip النصية التي رآها فعليُا، وذلك لتجنب تكرار إرسال الرسالة عبر الشبكة للأبد، كما نستخدِم دالة everywhere لتعريف تلك المصفوفة، إذ تُشغِّل هذه الدالة الشيفرة على كل عش من أجل إضافة خاصية إلى كائن state الخاص بالعش، وهو المكان الذي سنحفظ فيه حالة العش المحلية.

يتجاهل العش رسالة gossip مكرَّرة مرسَلة إليه، الأمر الذي يحدث عادةً حين يعيد الناس إرسالها دون وعي، لكن حين يستلم رسالةً جديدةً، فسيخبر جميع جيرانه عدا ذلك الذي أرسلها.

سينشر هذا جزءًا جديدًا من gossip في الشبكة كما تنتشر بقعة الحبر في الماء، فحتى إذا لم تكن بعض الاتصالات عاملةً وقتها، فستصل gossip العش المعطى إذا كان ثمة طريق بديلة إليه.

يسمى ذلك الأسلوب في التواصل الشبكي باسم الإغراق أو الغمر flooding، فهو يغرق الشبكة بجزء من المعلومات إلى أن تكون لدى جميع العقد.

نستطيع استدعاء sendGossip لنرى تدفق الرسائل خلال القرية.

sendGossip(bigOak, "Kids with airgun in the park");

توجيه الرسائل

إذا أرادت عقدة ما التواصل مع عقدة أخرى فلن يكون أسلوب الإغراق هنا هو الأمثل، خاصةً حين تكون الشبكة كبيرة، فقد يؤدي هذا إلى كثير من نقل البيانات غير الضروري، والحل هنا هو إعداد طريقة تنتقل بها الرسائل من عقدة إلى عقدة حتى تصل إلى وجهتها.

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

لا يملك العش المعلومات التي يحتاجها لحساب الطريق لأنه لا يعرف إلا جيرانه المباشَرِين، وعليه يجب نشر معلومات الاتصال لكل الأعشاش بطريقة تسمح لها بالتغير مع مرور الوقت كلما هُجِر عش أو بُني آخر.

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

requestType("connections", (nest, {name, neighbors},
                            source) => {
  let connections = nest.state.connections;
  if (JSON.stringify(connections.get(name)) ==
      JSON.stringify(neighbors)) return;
  connections.set(name, neighbors);
  broadcastConnections(nest, name, source);
});

function broadcastConnections(nest, name, exceptFor = null) {
  for (let neighbor of nest.neighbors) {
    if (neighbor == exceptFor) continue;
    request(nest, neighbor, "connections", {
      name,
      neighbors: nest.state.connections.get(name)
    });
  }
}

everywhere(nest => {
  nest.state.connections = new Map();
  nest.state.connections.set(nest.name, nest.neighbors);
  broadcastConnections(nest, nest.name);
});

تستخدِم الموازنة JSON.stringify لأنّ == لن يُعيد القيمة true إلا إذا كان الاثنان نفس القيمة بالضبط سواءً كان على كائنات أو مصفوفات، وليس هذا ما نريده هنا، وبالتالي فقد تكون موازنة سلاسل JSON النصية بدائيةً قليلًا لكنها فعالة لموازنة محتواها.

تبدأ العقد بنشر اتصالاتها مباشرةً، مما يعطي كل عش خريطةً بمخطط الشبكة الحالي، ما لم يوجد عش منها يستحيل الوصول إليه، كما نستطيع باستخدام ذلك المخطط إيجاد الطرق فيه كما رأينا في مقال مشروع تطبيقي لبناء رجل آلي (روبوت) عبر جافاسكريبت، فإذا كان لدينا طريق نحو وجهة رسالة ما، فسنستطيع معرفة أي اتجاه نرسلها فيه.

تبحث الدالة findRoute -التي تشابه findRoute من مقال مشروع تطبيقي لبناء رجل آلي (روبوت) عبر جافاسكريبت- عن طريقة للوصول إلى عقدة معطاة في الشبكة، لكن بدلًا من إعادة الطريق كله، فهي تعيد الخطوة التالية فقط، وسيقرِّر العش التالي مستخدِمًا المعلومات المتوفرة لديه عن الشبكة أين سيرسل الرسالة تاليًا.

function findRoute(from, to, connections) {
  let work = [{at: from, via: null}];
  for (let i = 0; i < work.length; i++) {
    let {at, via} = work[i];
    for (let next of connections.get(at) || []) {
      if (next == to) return via;
      if (!work.some(w => w.at == next)) {
        work.push({at: next, via: via || next});
      }
    }
  }
  return null;
}

نستطيع الآن بناء دالة يمكنها إرسال رسائل بعيدة المدى، فإذا كانت مرسَلةً إلى جار مباشر، فستسلَّم مثل العادة؛ أما إذا كان إلى جار بعيد، فستُحزَّم في كائن وترسَل إلى جار قريب من الهدف باستخدام نوع الطلب "route" الذي سيجعل ذلك الجار يكرِّر السلوك نفسه.

function routeRequest(nest, target, type, content) {
  if (nest.neighbors.includes(target)) {
    return request(nest, target, type, content);
  } else {
    let via = findRoute(nest.name, target,
                        nest.state.connections);
    if (!via) throw new Error(`No route to ${target}`);
    return request(nest, via, "route",
                   {target, type, content});
  }
}

requestType("route", (nest, {target, type, content}) => {
  return routeRequest(nest, target, type, content);
});

نستطيع الآن إرسال الرسالة إلى العش الذي في برج الكنيسة، حيث سنكون قد وفرنا أربع محطات من الطريق في الشبكة.

routeRequest(bigOak, "Church Tower", "note",
             "Incoming jackdaws!");

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

دوال async

تنسخ الغربان المعلومات المهمة في الأعشاش من أجل الحفاظ عليها إذا هجم صقر ما على أحدها ودمره، فإذا أرادت استرجاع معلومات ليست موجودة في حوصلة التخزين الخاصة بها، فسيسأل حاسوب العش بقية الأعشاش في الشبكة حتى يجد واحدًا منها لديه تلك المعلومات.

requestType("storage", (nest, name) => storage(nest, name));

function findInStorage(nest, name) {
  return storage(nest, name).then(found => {
    if (found != null) return found;
    else return findInRemoteStorage(nest, name);
  });
}

function network(nest) {
  return Array.from(nest.state.connections.keys());
}

function findInRemoteStorage(nest, name) {
  let sources = network(nest).filter(n => n != nest.name);
  function next() {
    if (sources.length == 0) {
      return Promise.reject(new Error("Not found"));
    } else {
      let source = sources[Math.floor(Math.random() *
                                      sources.length)];
      sources = sources.filter(n => n != source);
      return routeRequest(nest, source, "storage", name)
        .then(value => value != null ? value : next(),
              next);
    }
  }
  return next();
}

ولأن connections هي خارطة Map، فلن تعمل Object.keys عليها، لكن لديها التابع keys الذي يعيد مكرِّرًا iterator وليس مصفوفةً، كما يمكن تحويله إلى مصفوفة باستخدام الدالة Array.from، وهي شيفرة غير مألوفة حتى مع الوعود، إذ تُسلسَل عدة إجراءات غير متزامنة معًا بطرق غير واضحة، وسنحتاج إلى الدالة التعاودية next لوضع نموذج المرور الحلَقي على الأعشاش؛ أما ما تفعله الشيفرة على الحقيقة فهو سلوك خطي، إذ تنتظر تمام الإجراء السابق قبل بدء الذي يليه، وبالتالي ستكون أسهل في التعبير عنها في نموذج برمجة متزامنة.

الجميل في الأمر هو سماح جافاسكربت بكتابة شيفرة وهمية متزامنة pseudo-synchronous لوصف الحوسبة غير المتزامنة، إذ تُعيد الدالة async -ضمنيًا- وعدًا وتنتظر await في متنها وعودًا أخرى بطريقة تبدو تزامنية.

نُعيد كتابة findInStorage كما يلي:

async function findInStorage(nest, name) {
  let local = await storage(nest, name);
  if (local != null) return local;

  let sources = network(nest).filter(n => n != nest.name);
  while (sources.length > 0) {
    let source = sources[Math.floor(Math.random() *
                                    sources.length)];
    sources = sources.filter(n => n != source);
    try {
      let found = await routeRequest(nest, source, "storage",
                                     name);
      if (found != null) return found;
    } catch (_) {}
  }
  throw new Error("Not found");
}

تُحدَّد الدالة async بكلمة async قبل كلمة function المفتاحية، كما يمكن جعل التوابع غير تزامنية بكتابة async قبل أسمائها. وإذا استدعيت دالةً مثل تلك؛ فستُعيد وعدًا، وكذلك الحال بالنسبة للتوابع، ويُحل الوعد عندما تُعيد الدالة شيئًا؛ أما إذا رفعت الدالة استثناءً فسيُرفض الوعد.

findInStorage(bigOak, "events on 2017-12-21")  
.then(console.log);

يمكن وضع كلمة await المفتاحية أمام تعبير ما داخل دالة async لجعله ينتظر حل وعد ما، ولا يتابع تنفيذ الدالة إلا بعد حل ذلك الوعد.

لم تَعُد مثل تلك الدوال تعمل من البداية للنهاية على مرة واحدة، شأنها شأن أيّ دالة جافاسكربت عادية، إذ يمكن تجميدها frozen في أي نقطة توجد فيها await، وبعدها ستستعيد العمل في وقت لاحق؛ أما بالنسبة للشيفرات غير المتزامنة المهمة، فتلك الصيغة أسهل من استخدام الوعود مباشرةً، حتى لو احتجنا إلى فعل شيء لا يناسب النموذج المتزامن مثل تنفيذ عدة إجراءات في الوقت نفسه، إذ من السهل دمج await مع الاستخدام المباشر للوعود.

المولدات Generators

إن قدرة الدالة على التوقف ثم المتابعة مرةً أخرى ليست حكرًا على دوال async وحدها، حيث تحتوي جافاسكربت على خاصية اسمها دوال المولِّد generator functions التي تشبهها لكن مع استثناء الوعود، فحين نعرِّف دالةً باستخدام ‎function*‎ -أي أننا سنضع محرف النجمة بعد الكلمة function لستصير مولِّدًا، ويُعيد مكررًا عند استدعائه كما رأينا في مقال الحياة السرية للكائنات في جافاسكريبت.

function* powers(n) {
  for (let current = n;; current *= n) {
    yield current;
  }
}

for (let power of powers(3)) {
  if (power > 50) break;
  console.log(power);
}
// → 3
// → 9
// → 27

تُجمَّد الدالة في بدايتها حين نستدعي powers، وتعمل في كل مرة تستدعي next على المكرِّر حتى تقابل تعبير yield الذي يوقفها ويجعل القيمة المحصَّلة هي القيمة التي ينتجها المكرِّر تاليًا، وحين تُعيد الدالة -علمًا أنّ دالة مثالنا لا تعيد- فسيكون المكرِّر قد انتهى.

تُعَدّ كتابة المكرِّرات أسهل كثيرًا من استخدام الدوال المولِّدة، حيث يمكن كتابة مكرِّر الصنف Group من التدريب في مقال الحياة السرية للكائنات في جافاسكريبت بهذا المولِّد:

Group.prototype[Symbol.iterator] = function*() {
  for (let i = 0; i < this.members.length; i++) {
    yield this.members[i];
  }
};

لم نعد في حاجة إلى إنشاء كائن ليحفظ حالة المكرِّر، إذ تحفظ المولِّدات حالتها المحلية تلقائيًا في كل مرة تُحصِّل فيها، وقد لا تقع تعبيرات مثل yield تلك إلا مباشرةً في دالة المولِّد نفسها وليس في دالة داخلية معرَّفة داخلها، والحالة التي يحفظها المولِّد عند التحصيل هي بيئته المحلية فقط والموضع الذي حَصَّل فيه؛ أما دالة async فهي نوع خاص من المولِّدات، فهي تُنتِج وعدًا عند استدعائها، كما يُحَل عند إعادتها -أي انتهائها-، في حين يُرفَض إذا رفعت استثناءً، ونحصل على ناتج الوعد سواء كان قيمة أو استثناءً مرفوعًا باستعمال التعبير awaits مع الوعد الذي تعيده الدالة.

حلقة الحدث التكرارية

تُنفَّذ البرامج غير المتزامنة جزءًا جزءًا، وقد يبدأ كل جزء ببعض الإجراءات ويجدول شيفرات لتنفيذها عند انتهاء الإجراء أو فشله؛ أما بين تلك الأجزاء فسيكون البرنامج ساكنًا منتظِرًا الإجراء التالي، وبالتالي لا تُستدعى ردود النداء من قِبَل الشيفرة التي جدولَتها، فإذا استدعينا setTimeout من داخل دالة، فستكون تلك الدالة قد أعادت بالوقت الذي تُستدعى فيه دالة رد النداء، وحين يُعيد رد النداء، فلن يعود التحكم إلى الدالة التي جدولته.

يحدث السلوك غير المتزامن في مكدس استدعاء الدوال الفارغ الخاص به، وهذا أحد الأسباب التي تصعب معها إدارة الاستثناءات في الشيفرات غير المتزامنة في غياب الوعود، إذ لن تكون معالجات catch الخاصة بنا في المكدس حين ترفع استثناءً بما أنّ كل رد نداء يبدأ بمكدس شبه فارغ.

try {
  setTimeout(() => {
    throw new Error("Woosh");
  }, 20);
} catch (_) {
  // لن يعمل هذا
  console.log("Caught!");
}

مهما كانت أوقات وقوع الأحداث -مثل طلبات timeouts أو incoming- متقاربة، فلن تشغّل بيئة جافاسكربت إلا برنامجًا واحدًا في كل مرة.

يمكن النظر إلى هذا السلوك على أنه يشغِّل حلقةً تكراريةً كبيرةً على برنامجنا يُطلق عليها حلقة الحدث event loop، فإذا لم يكن ثمة شيء لفعله فستتوقف الحلقة؛ أما إذا جاءت الأحداث، فستضاف إلى طابور queue وتنفَّذ شيفراتها واحدةً تلو الأخرى، ولأنه لا يمكن تنفيذ شيئين في الوقت نفسه، فستؤخر الشيفرة البطيئة التنفيذ معالجة الأحداث الأخرى.

يضبط المثال أدناه زمن إنهاء timeout، لكن يتلكأ بعد ذلك إلى ما بعد النقطة الزمنية المحدَّدة له مسببًا تأخر زمن الإنهاء.

let start = Date.now();
setTimeout(() => {
  console.log("Timeout ran at", Date.now() - start);
}, 20);
while (Date.now() < start + 50) {}
console.log("Wasted time until", Date.now() - start);
// → Wasted time until 50
// → Timeout ran at 55

تُحل الوعود دائمًا أو تُرفض على أساس حدث جديد، وحتى لو حُل الوعد، فسسيتسبب انتظاره في تشغيل رد النداء الخاص بك بعد انتهاء السكربت الحالية بدلًا من تشغيله مباشرةً.

Promise.resolve("Done").then(console.log);
console.log("Me first!");
// → Me first!
// → Done

سنرى في الفصول اللاحقة عدة أنواع من الأحداث التي تعمل على حلقة الحدث.

زلات البرامج غير المتزامنة

لا توجد تغيرات حادثة في الحالة إذا كان البرنامج يعمل بتزامن على مرة واحدة عدا تلك التي يصنعها البرنامج نفسه؛ أما بالنسبة للبرامج غير المتزامنة فالأمر مختلف، إذ قد تحتوي على فجوات gaps في تنفيذها يمكن لشيفرات أخرى العمل خلالها.

لننظر إلى المثال التالي، إذ يهوى أحد الغربان عدّ الفراخ التي تفقس في القرية كل عام، حيث تخزِّن الأعشاش هذا العدد في حواصل التخزين الخاصة بها، وتحاول الشيفرة التالية حساب العدد من جميع الأعشاش لأحد الأعوام.

function anyStorage(nest, source, name) {
  if (source == nest.name) return storage(nest, name);
  else return routeRequest(nest, source, "storage", name);
}

async function chicks(nest, year) {
  let list = "";
  await Promise.all(network(nest).map(async name => {
    list += `${name}: ${
      await anyStorage(nest, name, `chicks in ${year}`)
    }\n`;
  }));
  return list;
}

يُظهر الجزء async name‎ =>‎ أنّ الدوال السهمية يمكن أن تكون غير متزامنة إذا وضعنا كلمة async أمامها. لن يبدو أيّ شيء غريبًا على الشيفرة لأول وهلة، فهي توجه الدوال السهمية غير المتزامنة async خلال مجموعة من الأعشاش لتنشئ مصفوفةً من الوعود، ثم ستستخدِم Promise.all لتنتظر حل كل تلك الوعود قبل إعادتها القائمة التي جهزتها، غير أنّ هذا السلوك معيب، إذ يُعيد سطرًا واحدًا يحتوي دائمًا على العش الأبطأ -الذي يصل رده متأخرًا- في الإجابة.

chicks(bigOak, 2017).then(console.log);

تكمن المشكلة في العامِل ‎+=‎ الذي يأخذ قيمة list الحالية وقت بدء تنفيذ التعليمة، ثم يضبط رابطة list بعد انتهاء await لتكون تلك القيمة إضافةً إلى السلسلة النصية المضافة؛ أما في الوقت الذي يكون بين بدء تنفيذ التعليمة والوقت الذي تنتهي فيه، سيكون لدينا فجوة غير متزامنة.

يعمل التعبير map قبل أي شيء أضيف إلى القائمة، لذا يبدأ كل عامل من العوامل ‎+=‎ بسلسلة فارغة، وينتهي حين تنتهي عملية استعادة التخزين بضبط قائمة list من سطر واحد، حيث تكون نتيجة إضافة السطر إلى السلسلة الفارغة.

كان يمكن تجنب ذلك بسهولة بإعادة الأسطر من الوعود الموجَّهة واستدعاء join على نتيجة Promise.all بدلًا من بناء القائمة بتغيير رابطة ما، كما سيكون حساب قيمة جديدة أقل عرضةً للخطأ من تغيير القيم الموجودة سلفًا.

async function chicks(nest, year) {
  let lines = network(nest).map(async name => {
    return name + ": " +
      await anyStorage(nest, name, `chicks in ${year}`);
  });
  return (await Promise.all(lines)).join("\n");
}

تحدث أخطاء مثل هذه بسهولة خاصةً عند استخدام await، كما يجب أن نكون على علم بأماكن حدوث الفجوات في شيفراتنا، وإحدى مزايا اللاتزامن الصريح في جافاسكربت، هي أنّ العثور على تلك الفجوات سهل نسبيًا، إذ سيكون إما باستخدام ردود النداء أو الوعود أو await.

خاتمة

تمكننا البرمجة غير التزامنية من التعبير عن الانتظار للإجراءات actions التي تعمل لوقت طويل دون تجميد البرنامج أثناء تلك الإجراءات، وتنفّذ بيئات جافاسكربت ذلك النمط من البرمجة باستخدام ردود النداء، وهي الدوال التي تُستدعى عند تمام الإجراءات.

تُجدوِل حلقة الحدث ردود النداء لتُستدعى في الوقت المناسب واحدًا تلو الآخر كي لا يتداخل تنفيذها، كما صارت البرمجة غير التزامنية سهلةً بفضل الوعود التي هي كائنات تمثّل إجراءات قد تكتمل في المستقبل، ودوال async التي تسمح لنا بكتابة برنامج غير متزامن كما لو كان تزامنيًا.

تدريبات

تتبع المشرط

تملك غربان القرية مشرطًا قديمًا تستخدِمه أحيانًا في المهام الخاصة مثل التحزيم أو قطع الأسلاك، كما تريد الحفاظ عليه لئلا يضيع، لذلك، فكلما نُقل المشرط إلى عش جديد أُضيف مُدخل جديد في ذاكرة العش القديم والجديد معًا تحت اسم مشرط "scalpel"، حيث تكون قيمته هي موقعه الجديد، ويعني هذا أنّ إيجاد المشرط مسألة اتباع أثر مدخلات الذاكرة حتى تجد عشًا يشير إلى نفسه.

اكتب دالة async اسمها locateScalpel تفعل ذلك، بحيث تبدأ من العش الذي تشغّله، كما يمكنك استخدام الدالة anyStorage التي عرَّفناها من قبل للوصول إلى الذاكرة في أعشاش عشوائية، وافترض أن المشرط قد دار لمدة تكفي لكي يحتوي كل عش على مُدخل "scalpel" في ذاكرة بياناته، ثم اكتب الدالة نفسها مرةً أخرى دون استخدام async وawait.

هل تظهر طلبات الفشل مثل رفض للوعود المعادة في كلا النسختين من الدالة؟ وكيف؟

تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen.

async function locateScalpel(nest) {
  // شيفرتك هنا
}

function locateScalpel2(nest) {
  // شيفرتك هنا
}

locateScalpel(bigOak).then(console.log);
// → Butcher Shop

إرشادات للحل

يمكن تنفيذ هذا التدريب بحلقة واحدة تبحث في الأعشاش وتنتقل من العش لغيره حين تجد قيمةً لا تطابق اسم العش الحالي، وتعيد اسم العش حين تجد قيمة مطابِقة، كما يمكن استخدام حلقة while أو for عادية في دالة async.

عليك بناء حلقتك باستخدام دالة تعاودية لتنفيذ الشيء نفسه في الدالة المجردة plain function، وأسهل طريقة لذلك هي جعل تلك الدالة تعيد وعدًا باستدعاء then على الوعد الذي يجلب قيمة الذاكرة.

سيُعيد المعالج تلك القيمة أو وعدًا مستقبليًا يُنشأ باستدعاء الدالة الحلقية مرةً أخرى بناءً على مطابقة القيمة لاسم العش الحالي.

لا تنسى بدء الحلقة باستدعاء الدالة التعاودية مرة واحدة من الدالة الأساسية.

تحوِّل await الوعود المرفوضة في دالة async إلى استثناءات، وحين ترفع دالة async استثناءً سيُرفض وعدها، وعليه تكون هذه الطريقة ناجحةً.

إذا استخدمت دالة ليست async كما وضحنا سابقًا، فستتسبب الطريقة التي تعمل بها then تلقائيًا في وجود الفشل في الوعد المعاد، وإذا فشل الطلب فلن يُستدعى المعالِج الممرَّر إلى then، ويُرفض الوعد الذي يعيده للسبب نفسه.

بناء Promise.all

إذا كانت لدينا مصفوفة من الوعود، فإن Promise.all تعيد وعدًا ينتظر جميع الوعود الأخرى في المصفوفة حتى انتهائها، حيث سينجح ذلك الوعد ويُحصِّل مصفوفةً من القيم الناتجة، فإذا فشل وعد في المصفوفة، فسيفشل الوعد المعاد من all أيضًا، ويكون سبب الفشل هو الوعد الفاشل.

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

تستطيع تعديل شيفرة التدريب لكتابة الحل وتشغيلها في طرفية المتصفح إن كنت تقرأ من متصفح، أو بنسخها إلى codepen.

function Promise_all(promises) {
  return new Promise((resolve, reject) => {
    // شيفرتك هنا.
  });
}

// شيفرة الاختبار.
Promise_all([]).then(array => {
  console.log("This should be []:", array);
});
function soon(val) {
  return new Promise(resolve => {
    setTimeout(() => resolve(val), Math.random() * 500);
  });
}
Promise_all([soon(1), soon(2), soon(3)]).then(array => {
  console.log("This should be [1, 2, 3]:", array);
});
Promise_all([soon(1), Promise.reject("X"), soon(3)])
  .then(array => {
    console.log("We should not get here");
  })
  .catch(error => {
    if (error != "X") {
      console.log("Unexpected failure:", error);
    }
  });

إرشادات للحل

ستُستدعى then من قِبَل الدالة التي تمرَّر إلى باني Promise وذلك على كل وعد في المصفوفة المعطاة، وعند نجاح أحد تلك الوعود، فيجب تخزين القيمة الناتجة في الموضع الصحيح في المصفوفة الناتجة، كما يجب التحقق هل كان ذلك هو الوعد الأخير المنتظِر أم لا، وننهي وعدنا الخاص إن كان. ويمكن تنفيذ ذلك التحقق الأخير بعدَّاد يبدأ بطول مصفوفة الدخل، ونطرح منه 1 في كل مرة ينجح فيها أحد الوعود، ونكون قد انتهينا عند وصوله إلى الصفر.

تأكد أن تضع في حسابك موقفًا تكون فيه مصفوفة الدخل فارغة من الوعود، فكيف تُحل الوعود وهي غير موجودة؟ تتطلب معالجة حالات الفشل بعض التأني، لكنها بسيطة في الوقت نفسه، إذ ما عليك سوى تمرير دالة reject للوعد المغلِّف إلى كل الوعود الموجودة في المصفوفة على أساس معالِج catch، أو على أساس وسيط ثاني إلى then، فإذا حدث فشل في أحدها، فسيطلق رفضًا للوعد المغلِّف كله.

ترجمة -بتصرف- للفصل الحادي عشر من كتاب Elequent Javascript لصاحبه Marijn Haverbeke.

اقرأ أيضًا


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

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

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



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

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

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

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   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.


×
×
  • أضف...