در این نوشته یاد میگیری که چجوری اپلیکیشنهای 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--
در کامندلاین انجام بدی یا با پیادهسازی چیزی شبیه به این:
قراره تا قابلیت خارج شدن اتوماتیک از پروسه، در نسخههای آیندهی 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