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

البرمجة غير المتزامنة في Node.js


Ola Abbas

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

01_Callbacks.png

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

تكون لغات البرمجة متزامنةً عادةً، وتوفِّر بعضها طريقةً لإدارة عدم التزامن في اللغة نفسها أو من خلال المكتبات، فاللغات C و Java و C#‎ و PHP و Go و Ruby و Swift و Python متزامنة افتراضيًا، كما تعالجِ بعضها عدم التزامن باستخدام الخيوط threads، مما ينتج عنه عملية جديدة، فلغة جافاسكربت متزامنة افتراضيًا وتعمل على خيط وحيد، وهذا يعني أنّ الشيفرة لا يمكنها إنشاء خيوط جديدة وتشغيلها على التوازي، إذ تُنفَّذ سطور الشيفرة تسلسليًا سطرًا تلو الآخر كما في المثال التالي:

const a = 1
const b = 2
const c = a * b
console.log(c)
doSomething()

نشأت جافاسكربت داخل المتصفح، وكانت وظيفتها الرئيسية في البداية الاستجابة لإجراءات المستخدِم مثل onClick وonMouseOver وonChange وonSubmit وما إلى ذلك، ولكن بيئتها ساعدتها في التعامل مع نمط البرمجة المتزامن من خلال المتصفح الذي يوفّر مجموعةً من واجهات برمجة التطبيقات APIs التي يمكنها التعامل مع هذا النوع من العمليات، كما قدّم Node.js في الآونة الأخيرة بيئة إدخال/إخراج دون توقف لتوسيع هذا المفهوم ليشمل الوصول إلى الملفات واستدعاءات الشبكة وغير ذلك.

دوال رد النداء Callbacks

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

document.getElementById('button').addEventListener('click', () => {
  //نُقِر العنصر
})

وهذا ما يسمى دالة رد النداء، وهي دالة بسيطة تُمرَّر على أساس قيمة إلى دالة أخرى وستُنفَّذ عند وقوع الحدث فقط، إذ يمكن ذلك لأن للغة جافاسكربت دوالًا من الصنف الأول، والتي يمكن إسنادها للمتغيرات وتمريرها إلى دوال أخرى تسّمى دوال الترتيب الأعلى higher-order functions، كما تُغلَّف شيفرة العميل في مستمع حدث load على الكائن window الذي يشغِّل دالة رد النداء عندما تكون الصفحة جاهزة فقط مثل المثال التالي:

window.addEventListener('load', () => {
  //حُمِّلت الصفحة
  //افعل ما تريده
})

تُستخدَم دوال رد النداء في كل مكان، ولا تقتصر على أحداث DOM فقط، فأحد الأمثلة الشائعة هو استخدام المؤقتات:

setTimeout(() => {
  // تشغيل بعد 2 ثانية
}, 2000)

تقبَل طلبات XHR دالة رد نداء عن طريق إسناد دالة لخاصية في المثال التالي، إذ ستُستدعَى هذه الدالة عند وقوع حدث معيّن -أي حدث تغيّرات حالة الطلب في مثالنا-:

const xhr = new XMLHttpRequest()
xhr.onreadystatechange = () => {
  if (xhr.readyState === 4) {
    xhr.status === 200 ? console.log(xhr.responseText) : console.error('error')
  }
}
xhr.open('GET', 'https://yoursite.com')
xhr.send()

معالجة الأخطاء في دوال رد النداء

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

fs.readFile('/file.json', (err, data) => {
  if (err !== null) {
    //عالِج الخطأ
    console.log(err)
    return
  }
  //لا يوجد خطأ، إذَا عالِج البيانات
  console.log(data)
})

مشكلة دوال رد النداء

تُعَدّ دوال رد النداء رائعةً في الحالات البسيطة، ولكن تضيف كل دالة رد نداء مستوىً من التداخل nesting، وبالتالي تتعقَّد الشيفرة بسرعة كبيرة عند وجود كثير من دوال رد النداء كما يلي:

window.addEventListener('load', () => {
  document.getElementById('button').addEventListener('click', () => {
    setTimeout(() => {
      items.forEach(item => {
        //أضِف شيفرتك هنا
      })
    }, 2000)
  })
})

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

بدائل دوال رد النداء

قدّمت جافاسكربت بدءًا من الإصدار ES6 ميزات متعددةً تساعدنا في التعامل مع الشيفرة غير المتزامنة التي لا تتضمن استخدام دوال رد النداء مثل:

الوعود Promises

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

مدخل إلى الوعود

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

يبدأ الوعد عند استدعائه في حالة انتظار pending state، أي أن الدالة المستدعِية تواصل التنفيذ في الوقت الذي تنتظر به الوعد لينفّذ معالجته الخاصة ويستجيب لها، حيث تنتظر الدالة المستدعِية إما إعادة الوعد في حالة التأكيد أو الحل resolved state أو في حالة الرفض rejected state، ولكن لغة جافاسكربت غير متزامنة، لذلك تتابع الدالة تنفيذها ريثما ينتهي الوعد من عمله، كما تستخدِم واجهات برمجة تطبيقات الويب المعيارية الحديثة الوعود بالإضافة إلى شيفرتك ومكتباتها، ومن هذه الواجهات البرمجية:

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

إنشاء وعد

تُظهِر واجهة برمجة الوعد Promise API باني وعد Promise constructor يمكن تهيئته باستخدام الدالة new Promise()‎:

let done = true
const isItDoneYet = new Promise(
  (resolve, reject) => {
    if (done) {
      const workDone = 'Here is the thing I built'
      resolve(workDone)
    } else {
      const why = 'Still working on something else'
      reject(why)
    }
  }
)

يتحقّق الوعد من الثابت العام done، فإذا كانت قيمته صحيحة true، فإننا نعيد قيمة وعد مؤكَّد، وإلا فسنعيد وعدًا مرفوضًا، كما يمكننا إعادة قيمة باستخدام القيم resolve وreject، حيث أعدنا سلسلةً نصيةً فقط في المثال السابق، لكنها يمكن أن تكون كائنًا أيضًا.

استهلاك وعد

لنرى الآن كيفية استهلاك أو استخدام وعد.

const isItDoneYet = new Promise(
  //...
)
const checkIfItsDone = () => {
  isItDoneYet
    .then((ok) => {
      console.log(ok)
    })
    .catch((err) => {
      console.error(err)
    })
}

سيؤدي تشغيل الدالة checkIfItsDone()‎ إلى تنفيذ الوعد isItDoneYet()‎ وستنتظر إلى أن يُؤكَّد الوعد باستخدام دالة رد النداء then، وإذا كان هناك خطأ، فستعالجه في دالة رد النداء catch.

إذا أردت مزامنة وعود مختلفة، فسيساعدك التابع Promise.all()‎ على تحديد قائمة وعود، وتنفيذ شيء ما عند تأكيد هذه الوعود جميعها، وإليك المثال التالي:

const f1 = fetch('/something.json')
const f2 = fetch('/something2.json')
Promise.all([f1, f2]).then((res) => {
  console.log('Array of results', res)
})
.catch((err) => {
  console.error(err)
})

تتيح لك صيغة إسناد الهدم destructuring assignment syntax الخاصة بالإصدار ES2015 تنفيذ ما يلي:

Promise.all([f1, f2]).then(([res1, res2]) => {
  console.log('Results', res1, res2)
})

ليس الأمر مقتصرًا على استخدام fetch بالطبع، إذ يمكنك استخدام أيّ وعد، كما يُشغَّل التابع Promise.race()‎ عند تأكيد أول وعد من الوعود التي تمرّرها إليه، ويشغِّل دالةَ رد النداء المصاحبة للوعد مرةً واحدةً فقط مع نتيجة الوعد الأول المُؤكَّد resolved، وإليك المثال التالي:

const first = new Promise((resolve, reject) => {
  setTimeout(resolve, 500, 'first')
})
const second = new Promise((resolve, reject) => {
  setTimeout(resolve, 100, 'second')
})
Promise.race([first, second]).then((result) => {
  console.log(result) // second
})

سلسلة الوعود Chaining promises

يمكن أن يُعاد وعدٌ إلى وعد آخر، وبالتالي ستنشأ سلسلة من الوعود، إذ تقدِّم واجهة Fetch API -وهي طبقة فوق واجهة برمجة تطبيقات XMLHttpRequest- مثالًا جيدًا عن سلسلة وعود، إذ يمكننا استخدام هذه الواجهة للحصول على مورد ووضع سلسلة من الوعود في طابور لتنفيذها عند جلب المورد، كما تُعَدّ واجهة Fetch API آليةً قائمةً على الوعود، حيث يكافئ استدعاءُ الدالة fetch()‎ تعريف وعد باستخدام new Promise()‎، وإليك المثال التالي عن كيفية سلسلة الوعود:

const status = (response) => {
  if (response.status >= 200 && response.status < 300) {
    return Promise.resolve(response)
  }
  return Promise.reject(new Error(response.statusText))
}

const json = (response) => response.json()

fetch('/todos.json')
  .then(status)
  .then(json)
  .then((data) => { console.log('Request succeeded with JSON response', data) })
  .catch((error) => { console.log('Request failed', error) })

نستدعي في المثال السابق التابع fetch()‎ للحصول على قائمة من عناصر TODO من ملف todos.json الموجود في نطاق الجذر، وننشئ سلسلة من الوعود، كما يعيد تشغيل التابع fetch()‎ استجابةً لها خاصيات منها:

  • status وهي قيمة عددية تمثِّل رمز حالة HTTP.
  • statusText وهي رسالة حالة تكون قيمتها OK إذا نجح الطلب.

تحتوي الاستجابة response أيضًا على تابع json()‎ الذي يعيد وعدًا سيُؤكد ويُربَط مع محتوى الجسم المُعالَج والمُحوَّل إلى JSON.

الوعد الأول في السلسلة هو الدالة التي حدّدناها وهي status()‎ التي تتحقق من حالة الاستجابة، فإذا لم تكن استجابةً ناجحةً -أي قيمتها بين 200 و299، فسترفِض الوعد، إذ ستؤدي هذه العملية إلى تخطي جميع الوعود المتسلسلة المدرجَة في سلسلة الوعود وستنتقل مباشرةً إلى تعليمة catch()‎ في الأسفل، مما يؤدي إلى تسجيل نص فشل الطلب Request failed مع رسالة الخطأ؛ أما إذا نجحت الاستجابة، فستُستدعَى دالة json()‎ التي حدّدناها، وبما أنّ الوعد السابق يعيد كائن الاستجابة response عند النجاح، فسنحصل عليه على أساس دخل للوعد الثاني، وبالتالي نُعيد بيانات JSON المُعالَجة في هذه الحالة، لذا فإن الوعد الثالث يتلقى JSON مباشرةً مع تسجيله ببساطة في الطرفية كما يلي:

.then((data) => {
  console.log('Request succeeded with JSON response', data)
})

معالجة الأخطاء

ألحقنا في المثال السابق تعليمة catch بسلسلة وعود، فإذا فشل أيّ شيء في سلسلة الوعود مسببًا خطأً أو رفض وعد، فسينتقل التحكم إلى أقرب تعلمية catch()‎ أسفل السلسلة.

new Promise((resolve, reject) => {
  throw new Error('Error')
})
  .catch((err) => { console.error(err) })

// أو

new Promise((resolve, reject) => {
  reject('Error')
})
  .catch((err) => { console.error(err) })

إذا ظهر خطأ ضمن تعليمة catch()‎، فيمكنك إلحاق تعليمة catch()‎ ثانية لمعالجة الخطأ وهلم جرًا وهذا ما يسمى بعملية معالجة توريث الأخطاء Cascading errors.

new Promise((resolve, reject) => {
  throw new Error('Error')
})
  .catch((err) => { throw new Error('Error') })
  .catch((err) => { console.error(err) })

إذا ظهر الخطأ Uncaught TypeError: undefined is not a promise في الطرفية، فتأكد من استخدام new Promise()‎ بدلًا من استخدام Promise()‎.

صيغة عدم التزامن/الانتظار async/await

تطوّرت لغة جافاسكربت في وقت قصير جدًا من دوال رد النداء callbacks إلى الوعود promises في الإصدار ES2015، وأصبحت لغة جافاسكربت غير المتزامنة منذ الإصدار ES2017 أبسط مع صيغة عدم التزامن/الانتظار async/await، فالدوال غير المتزامنة هي مزيج من الوعود والمولِّدات generators، وهي في الأساس ذات مستوىً أعلى من الوعود من ناحية التجريد، فصيغة async/await مبنية على الوعود.

سبب ظهور صيغة async/await هو أنها تقلل من الشيفرة التكرارية أو المتداولة boilerplate الموجودة في الوعود، وتقلّل من محدودية قيود عدم كسر السلسلة في تسلسل الوعود، فقد كان الهدف من تقديم الوعود في الإصدار ES2015 حلَّ مشكلة التعامل مع الشيفرة غير المتزامنة، وقد حلّت هذه المشكلة حقًا، ولكن كان واضحًا على مدار العامين اللذين فصلا بين الإصدارين ES2015 وES2017 أن الوعود ليست الحل النهائي.

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

كيفية عمل صيغة async/await

تعيد الدالة غير المتزامنة وعدًا كما في المثال التالي:

const doSomethingAsync = () => {
  return new Promise((resolve) => {
    setTimeout(() => resolve('I did something'), 3000)
  })
}

إذا أردت استدعاء هذه الدالة، فستضيف الكلمة await في البداية، وستتوقف شيفرة الاستدعاء حتى تأكيد أو رفض الوعد.

اقتباس

تحذير: يجب تعريف دالة العميل على أنها غير متزامنة async.

إليك المثال التالي:

const doSomething = async () => {
  console.log(await doSomethingAsync())
}

إليك المثال التالي أيضًا والذي يوضِّح استخدام صيغة async/await لتشغيل دالة تشغيلًا غير متزامن:

const doSomethingAsync = () => {
  return new Promise((resolve) => {
    setTimeout(() => resolve('I did something'), 3000)
  })
}

const doSomething = async () => {
  console.log(await doSomethingAsync())
}

console.log('Before')
doSomething()
console.log('After')

ستطبع الشيفرة السابقة ما يلي في طرفية المتصفح:

Before
After
I did something //after 3s

تطبيق الوعود على كل شيء

تعني إضافة الكلمة المفتاحية async في بداية أيّ دالة أنّ هذه الدالة ستعيد وعدًا، وإذا لم تفعل ذلك صراحةً، فستعيد وعدًا داخليًا، وهذا سبب كون الشيفرة التالية صالحةً valid:

const aFunction = async () => {
  return 'test'
}

aFunction().then(alert) // سيؤدي هذا إلى تنبيه‫ 'test'

وكذلك الشيفرة التالية:

const aFunction = async () => {
  return Promise.resolve('test')
}

aFunction().then(alert) // سيؤدي هذا إلى تنبيه‫ 'test'

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

const getFirstUserData = () => {
  return fetch('/users.json') // الحصول على قائمة المستخدِمين
    .then(response => response.json()) // تحليل‫ JSON
    .then(users => users[0]) // التقاط المستخدِم الأول
    .then(user => fetch(`/users/${user.name}`)) // الحصول على بيانات المستخدِم
    .then(userResponse => response.json()) // تحليل‫ JSON
}
getFirstUserData()

وإليك المثال التالي الذي ينفّذ ما يفعله المثال السابق ولكن باستخدام صيغة await/async:

const getFirstUserData = async () => {
  const response = await fetch('/users.json') // الحصول على قائمة المستخدِمين
  const users = await response.json() // تحليل‫ JSON
  const user = users[0] // التقاط المستخدِم الأول
  const userResponse = await fetch(`/users/${user.name}`) // الحصول على بيانات المستخدِم
  const userData = await user.json() // تحليل‫ JSON
  return userData
}

getFirstUserData()

استخدام دوال متعددة غير متزامنة ضمن سلسلة

يمكن وضع الدوال غير المتزامنة ضمن سلسلة بسهولة باستخدام صيغة أكثر قابلية للقراءة من الوعود الصرفة كما يلي:

const promiseToDoSomething = () => {
  return new Promise(resolve => {
    setTimeout(() => resolve('I did something'), 10000)
  })
}

const watchOverSomeoneDoingSomething = async () => {
  const something = await promiseToDoSomething()
  return something + ' and I watched'
}

const watchOverSomeoneWatchingSomeoneDoingSomething = async () => {
  const something = await watchOverSomeoneDoingSomething()
  return something + ' and I watched as well'
}

watchOverSomeoneWatchingSomeoneDoingSomething().then((res) => {
  console.log(res)
})

ستطبع الشيفرة السابقة ما يلي:

I did something and I watched and I watched as well

سهولة تنقيح الأخطاء

يُعَدّ تنقيح أخطاء Debugging الوعود أمرًا صعبًا لأن منقِّح الأخطاء لن يتخطى الشيفرة غير المتزامنة، بينما تجعل صيغة Async/await هذا الأمر سهلًا لأنها تُعَدّ مجرد شيفرة متزامنة بالنسبة للمصرِّف compiler.

ترجمة -وبتصرّف- للفصل Asynchronous programming من كتاب The Node.js handbook لصاحبه Flavio Copes.

اقرأ أيضًا


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

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

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



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

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

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

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


×
×
  • أضف...