کمربند سیاهِ Async Await در Node.js

حمیدرضا مهدوی پناه · خواندن 10 دقیقه · 2117 کلمه · ۱۴۰۰/۲/۱۲

در این نوشته یاد میگیری که چجوری اپلیکیشن‌های Node.jsای که با callback یا Promise نوشتی رو با توابع async ساده‌ترشون کنی.

اگه قبلا یه نگاهی به الگوی async/await و promiseها در جاوااسکریپت انداختی ولی هنوز کامل بهشون مسلط نیستی و یا این که فقط نیاز داری تا مرورشون کنی، هدف این نوشته کمک به توئه.

تابع‌های async چی هستن؟ 🔗

توابع async به طور پیش‌فرض در Node در دسترسند و با کلمه‌ی کلیدی async علامت‌گذاری میشن. این توابع حتی اگه به طور صریح در بدنشون مشخص نکنی، همیشه یه promise برمیگردونن. در ضمن، فعلا کلمه‌ی کلیدی await فقط در داخل توابع async قابل استفاده‌اس و نمیشه در دامنه سراسری (global scope) ازش استفاده کرد.

داخل یه تابع async میتونی منتظر یه Promise بمونی و یا این که در صورت مردود شدنش (rejected) میتونی خطاش رو دستگیر کنی (catch).

پس اگه یه کدی داری که با promiseها پیاده‌سازی شده:

 1function handler(req, res) {
 2  return request("https://user-handler-service")
 3    .catch((err) => {
 4      logger.error("Http error", err)
 5      error.logged = true
 6      throw err
 7    })
 8    .then((response) => Mongo.findOne({ user: response.body.user }))
 9    .catch((err) => {
10      !error.logged && logger.error("Mongo error", err)
11      error.logged = true
12      throw err
13    })
14    .then((document) => executeLogic(req, res, document))
15    .catch((err) => {
16      !error.logged && console.error(err)
17      res.status(500).send()
18    })
19}

میتونی با ‍‍async/await شبیه به یه کد همگام (synchronous) بنویسیش:

 1async function handler(req, res) {
 2  let response
 3  try {
 4    response = await request("https://user-handler-service")
 5  } catch (err) {
 6    logger.error("Http error", err)
 7    return res.status(500).send()
 8  }
 9
10  let document
11  try {
12    document = await Mongo.findOne({ user: response.body.user })
13  } catch (err) {
14    logger.error("Mongo error", err)
15    return res.status(500).send()
16  }
17
18  executeLogic(document, req, res)
19}

در حال حاضر در Node اگه در یه promise خطایی رخ بده که بهش رسیدگی نشده، Node فقط بهت هشدار میده، پس بنابراین نیازی نیست تا خودتو توی دردسر ساختن یه listener بندازی. هرچند چون این اتفاق نشون‌دهنده‌ی یه حالت نامشخصه، توصیه میشه تا اپلیکیشن رو ببندی و کرش کنی؛ دقیقا شبیه به حالتی که یه خطای catch نشده در جایی از کد رخ میده. این کارو میتونی یا با استفاده از پرچم unhandled-rejections=strict-- در کامند‌لاین انجام بدی یا با پیاده‌سازی چیزی شبیه به این:

1process.on("unhandledRejection", (err) => {
2  console.error(err)
3  process.exit(1)
4})

قراره تا قابلیت خارج شدن اتوماتیک از پروسه، در نسخه‌های آینده‌ی Node اضافه بشه. این که کدت رو از قبل برای اینکار آماده کنی زحمت زیادی نداره ولی خوبیش اینه که وقتی خواستی نسخه‌ها رو آپدیت کنی دیگه نگران این موضوع نیستی.

الگوها با توابع async 🔗

از اونجایی که رسیدگی به عملیات ناهمگام (asynchronous) با استفاده از Promiseها و یا callbackها نیاز به الگوهای پیچیده‌ای داره، وقتی که بتونی این عملیات رو جوری پیاده‌سازی کنی که انگار همگام هستن، کار خیلی برات ساده‌تره.

از نسخه‌ی 10.0.0 نود جی‌اس، پشتیبانی از async iterators و حلقه‌ی مرتبطش یعنی for-await اضافه شده. این امکانات زمانی بدردت میخورن که میخوای روی مقداری که یه iterator method برمیگردونه و از قبل مشخص نیستن، حرکت کنی (حلقه بزنی) و کاری رو انجام بدی و حالت نهایی این تکرار (iteration) هم مشخص نیست - معمولا این حالت حین کار با streamها پیش میاد.

تلاش مجدد با عقب‌نشینی نمایی (exponential backoff) 🔗

پیاده‌سازی الگوریتم تلاش مجدد با Promiseها خیلی بدترکیبه:

 1function request(url) {
 2  return new Promise((resolve, reject) => {
 3    setTimeout(() => {
 4      reject(`Network error when trying to reach ${url}`)
 5    }, 500)
 6  })
 7}
 8
 9function requestWithRetry(url, retryCount, currentTries = 1) {
10  return new Promise((resolve, reject) => {
11    if (currentTries <= retryCount) {
12      const timeout = (Math.pow(2, currentTries) - 1) * 100
13      request(url)
14        .then(resolve)
15        .catch((error) => {
16          setTimeout(() => {
17            console.log("Error: ", error)
18            console.log(`Waiting ${timeout} ms`)
19            requestWithRetry(url, retryCount, currentTries + 1)
20          }, timeout)
21        })
22    } else {
23      console.log("No retries left, giving up.")
24      reject("No retries left, giving up.")
25    }
26  })
27}
28
29requestWithRetry("http://localhost:3000")
30  .then((res) => {
31    console.log(res)
32  })
33  .catch((err) => {
34    console.error(err)
35  })

این پیاده‌سازی کاری که میخوایمو میکنه اما میتونیم بازنویسیش کنیم و با async/await خیلی راحت‌تر کارو انجام بدیم:

 1function wait(timeout) {
 2  return new Promise((resolve) => {
 3    setTimeout(() => {
 4      resolve()
 5    }, timeout)
 6  })
 7}
 8
 9async function requestWithRetry(url) {
10  const MAX_RETRIES = 10
11  for (let i = 0; i <= MAX_RETRIES; i++) {
12    try {
13      return await request(url)
14    } catch (err) {
15      const timeout = Math.pow(2, i)
16      console.log("Waiting", timeout, "ms")
17      await wait(timeout)
18      console.log("Retrying", err.message, i)
19    }
20  }
21}

خیلی بیشتر به دل میشینه، نه؟

مقادیر میانی (intermediate values) 🔗

با این که مثال پیش‌رو به اندازه‌ی قبلی ترسناک نیست، اما اگه حالتی داشته باشی که ۳ تابع ناهمگام مختلف به شکلی که در زیر توضیح میدم، بهم وابسته باشن، مجبوری تا از بین چندتا راه‌حل زشت و بدترکیب یکیشونو انتخاب کنی.

تابع functionA یه Promise برمیگردونه که سپس functionB به مقدارش نیاز داره و بعد از اون functionC به مقدار نهایی Promiseهای جفت تابع functionA و functionB نیاز داره.

راه حل ۱: درخت کریسمس then. 🔗

1function executeAsyncTask() {
2  return functionA().then((valueA) => {
3    return functionB(valueA).then((valueB) => {
4      return functionC(valueA, valueB)
5    })
6  })
7}

توی این راه حل، برای انجام functionC مقدار valueA رو از کلوژر (closure) سومین then میگیری و مقدار valueB رو از Promise قبلش که انجام شده. نمیتونی این درخت کریسمس رو مسطح کنی چون در اون صورت کلوژر رو گم میکنی و valueA در دسترس functionC نخواهد بود.

راه حل ۲: حرکت به یه اسکوپ (scope) بالاتر 🔗

 1function executeAsyncTask() {
 2  let valueA
 3  return functionA()
 4    .then((v) => {
 5      valueA = v
 6      return functionB(valueA)
 7    })
 8    .then((valueB) => {
 9      return functionC(valueA, valueB)
10    })
11}

توی مثال درخت کریسمس شبیه به همین مثال، از یه اسکوپ بالاتر برای دسترسی به valueA استفاده کردیم. اما تفاوت این مثال اینه که متغیر valueA رو خارج از اسکوپ then. تعریف کردیم تا بتونیم مقدار نهایی اولین Promise رو بهش انتساب بدیم.

این مثال قطعا کار میکنه و زنجیره‌ی then. هم مسطح شده و از نظر معنایی هم درسته. با این وجود اگه از valueA در جاهای دیگه از تابع استفاده کنی، راه برای پیدا شدن باگ‌های جدید باز میشه. علاوه بر این، مجبوری برای یه مقدار یکسان از دوتا اسم مختلف استفاده کنی - valueA و v.

راه حل ۳: آرایه‌ی غیر ضروری 🔗

1function executeAsyncTask() {
2  return functionA()
3    .then((valueA) => {
4      return Promise.all([valueA, functionB(valueA)])
5    })
6    .then(([valueA, valueB]) => {
7      return functionC(valueA, valueB)
8    })
9}

جز این که میخوای درخت رو مسطح کنی، هیچ دلیلی نداره که valueA رو همراه با Promiseای که functionB برمیگردونه داخل یه آرایه پاس بدیم. مقدار این دو عنصر آرایه ممکنه از دو جنس کاملا مختلف باشن و بنابراین جالب نیست که توی یه آرایه‌ی واحد قرار بگیرن.

راه حل ۴: یه تابع کمکی بنویس 🔗

 1const converge =
 2  (...promises) =>
 3  (...args) => {
 4    let [head, ...tail] = promises
 5    if (tail.length) {
 6      return head(...args).then((value) =>
 7        converge(...tail)(...args.concat([value]))
 8      )
 9    } else {
10      return head(...args)
11    }
12  }
13
14functionA(2).then((valueA) => converge(functionB, functionC)(valueA))

البته که میتونی یه تابع کمکی بنویسی تا این آش شله قلمکار رو درست کنی. اما از نظر خوانایی خیلی ضعیفه و بنابراین ممکنه درکش برای کسایی که توی برنامه‌نویسی functional موهاشون سفید نشده، سخت باشه.

با استفاده از async/await به طور معجزه‌آسایی مشکلاتمون ناپدید میشه: 🔗

1async function executeAsyncTask() {
2  const valueA = await functionA()
3  const valueB = await functionB(valueA)
4  return function3(valueA, valueB)
5}

چندین درخواست موازی با async/await 🔗

این مثال شبیه قبلیه. فرض کن میخوای چند کار ناهمگام مختلف رو در یک لحظه شروع کنی و از مقادیر برگشتیشون تو جاهای مختلف استفاده کنی:

 1async function executeParallelAsyncTasks() {
 2  const [valueA, valueB, valueC] = await Promise.all([
 3    functionA(),
 4    functionB(),
 5    functionC(),
 6  ])
 7  doSomethingWith(valueA)
 8  doSomethingElseWith(valueB)
 9  doAnotherThingWith(valueC)
10}

متدهای iteration آرایه 🔗

اگرچه رفتارشون خیلی غیرمنتظرست ولی میتونی map ،filter و reduce رو با توابع async استفاده کنی. تلاش کن حدس بزنی که خروجی اسکریپت‌های زیر چیه:

‍‍۱. map 🔗

 1function asyncThing(value) {
 2  return new Promise((resolve) => {
 3    setTimeout(() => resolve(value), 100)
 4  })
 5}
 6
 7async function main() {
 8  return [1, 2, 3, 4].map(async (value) => {
 9    const v = await asyncThing(value)
10    return v * 2
11  })
12}
13
14main()
15  .then((v) => console.log(v))
16  .catch((err) => console.error(err))

‍‍۲. filter 🔗

 1function asyncThing(value) {
 2  return new Promise((resolve) => {
 3    setTimeout(() => resolve(value), 100)
 4  })
 5}
 6
 7async function main() {
 8  return [1, 2, 3, 4].filter(async (value) => {
 9    const v = await asyncThing(value)
10    return v % 2 === 0
11  })
12}
13
14main()
15  .then((v) => console.log(v))
16  .catch((err) => console.error(err))

‍‍۳. reduce 🔗

 1function asyncThing(value) {
 2  return new Promise((resolve) => {
 3    setTimeout(() => resolve(value), 100)
 4  })
 5}
 6
 7async function main() {
 8  return [1, 2, 3, 4].reduce(async (acc, value) => {
 9    return (await acc) + (await asyncThing(value))
10  }, Promise.resolve(0))
11}
12
13main()
14  .then((v) => console.log(v))
15  .catch((err) => console.error(err))

راه حل‌ها:
۱.

1[ Promise { <pending> }, Promise { <pending> }, Promise { <pending> }, Promise { <pending> } ]

۲.[ 4 ,3 ,2 ,1 ]
۳. 10

اگه خروجی هرکدوم از promiseهایی که map برمیگردونه رو چاپ کنی، میبینی که نتیجه‌ای که مورد انتظاره میده: [ 8 ,6 ,4 ,2 ]. تنها مشکل اینه که هرکدوم از این مقادیر توسط AsyncFunction توی یه Promise بسته‌بندی شدن.

پس اگه میخوای مقادیرتو بگیری، باید آرایه‌ی برگشتی رو به Promise.all پاس بدی:

1main()
2  .then((v) => Promise.all(v))
3  .then((v) => console.log(v))
4  .catch((err) => console.error(err))

بدون async/await باید اول منتظر تموم شدن promiseها میموندی و بعد روی مقادیرشون map رو اجرا میکردی:

1function main() {
2  return Promise.all([1, 2, 3, 4].map((value) => asyncThing(value)))
3}
4
5main()
6  .then((values) => values.map((value) => value * 2))
7  .then((v) => console.log(v))
8  .catch((err) => console.error(err))

روش دوم یکم واضح‌تره، نه؟
روشی که از async/await استفاده میکنه زمانی که برای هر مقدار promise نیازه تا کارهای همگام طولانی بکنی و در عین حال کارهای ناهمگام هم طولانی‌اند، میتونه بدردت بخوره.

با این روش، به محض این که اولین مقدار رو بدست بیاری، میتونی محاسبتش رو شروع کنی و مجبور نیسی برای شروع محاسباتت منتظر بقیه‌ی Promiseها بمونی تا تکمیل بشن. اگرچه خروجی نهایی مقادیر توی Promiseها پیچیده شدن، اما این Promiseها خیلی سریع تکمیل میشن و در نهایت کل فرآیند سریع‌تر از زمانیه که بخوای به طور متوالی انجامش بدی.

داستان filter چیه؟ مطمئنا یه چیزی این وسط اشتباهه…
خب، درست حدس زدی: اگرچه مقادیر برگشتی از این قراره: [ false, true, false, true ] اما هرکدومشون توی یه Promise پیچیده شدن، و از اونجایی که هر Promise یه مقدار اصطلاحا truthy هست (یعنی زمانیه که به شکل بولین ببینیمش، مقدارش true میشه)، تمام مقادیر آرایه برمیگردن و هیچکدوم فیلتر نمیشن. متاسفانه تنها کاری که برای رفع این مشکل از دستت برمیاد اینه که اول منتظر بشی تا مقادیر Promiseها برگردن و بعد فیلترشون کنی.

متد reduce خیلی سرراسته. اگرچه یادت باشه که باید مقدار اولیه رو داخل Promise.resolve بپیچی، و همچنین مقدار متغیر تجمعی هم به صورت Promise برمیگرده و بنابراین نیازه تا براش از await استفاده کنی.

بازنویسی اپلیکیشن‌های برپایه‌ی callback 🔗

توابع async به طور پیشفرض یه Promise برمیگردونن، بنابراین میتونی هر تابعی که برپایه‌ی callback هست رو جوری بازنویسی کنی که از Promiseها استفاده کنه و سپس مقدار بازگشتیش رو با await بگیری. توی Node.js برای تبدیل یه تابع برپایه‌ی callback به یه تابع برپایه‌ی Promise میتونی از تابع util.promisify استفاده کنی.

بازنویسی اپلیکیشن‌های برپایه‌ی Promise 🔗

زنجیره‌های ساده‌ی then. رو خیلی سرراست میشه با استفاده از async/await بازنویسی کرد.

1function asyncTask() {
2  return functionA()
3    .then((valueA) => functionB(valueA))
4    .then((valueB) => functionC(valueB))
5    .then((valueC) => functionD(valueC))
6    .catch((err) => logger.error(err))
7}

تبدیل میشه به:

 1async function asyncTask() {
 2  try {
 3    const valueA = await functionA()
 4    const valueB = await functionB(valueA)
 5    const valueC = await functionC(valueB)
 6    return await functionD(valueC)
 7  } catch (err) {
 8    logger.error(err)
 9  }
10}

اپلیکیشن‌های Node.js رو با async/await بازنویسی کن اگه: 🔗

  • مفاهیم قدیمی و باحالی مثل شرط‌های if-else و حلقه‌های for/while رو دوس داری.

  • باور داری که بلوک‌های try-catch راه درست رسیدگی به خطاهاست.

همونطور که باهم دیدیم، استفاده از async/await هم خوانایی کدهارو بیشتر میکنه و هم نوشتنشون رو ساده‌تر میکنه و در بسیاری از کارها مناسب‌تر از زنجیره‌های ()Promise.then هست. اما اگه به شور و اشتیاقی که در چند سال اخیر برای برنامه‌نویسی فانکشنال بوجود اومده دچار شدی، شاید بهتر باشه که از خیر این قابلیت جاوااسکریپت بگذری.


منبع: Rewriting Node.js apps with async/await از وبلاگ RisingStack