برنامه هایی که می نویسیم، فارغ از اینکه با چه زبان و تکنولوژی نوشته شده باشند، همیشه طبق خواست ما پیش نمی روند و تقریبا در تمام موارد به خطا برخورد می کنیم. در برخی از این موارد لازم است خطا باعث متوقف شدن برنامه شود یا شاید بخواهیم بروز خطا را به کاربر اطلاع بدهیم. این خطاها چیزهایی مانند مانند باز کردن فایلی که وجود ندارد یا عدم وجود اتصال شبکه و اینترنت یا ورود اطلاعات اشتباه توسط کاربر هستند. در تمام این حالت ها یا خودمان به عنوان توسعه دهنده باید خطایی را تولید کنیم یا اینکه اجازه بدهیم موتور زبان برنامه نویسی ما خطا را بسازد. در هر دو صورت مدیریت کردن این خطا بر عهده ما توسعه دهندگان است در غیر این صورت ممکن است ضرر جبران ناپذیری به سیستم ما وارد شود (نشت اطلاعات حساس، کاهش شدید رضایت و اعتماد کاربران و غیره). ما در این مقاله می خواهیم نگاهی به تکنیک های مدیریت خطا در جاوا اسکریپت داشته باشیم.
سطح مقاله: این مقاله برای افرادی در نظر گرفته شده است که آشنایی متوسطی با زبان جاوا اسکریپت دارند و مباحثی مانند کدهای همگام یا غیر همگام در آن توضیح داده نمی شود.
خطاها (Error) در جاوا اسکریپت به شکل یک شیء (Object) هستند و احتمالا شنیده باشید که پرتاب می شوند (throw) و باعث توقف برنامه می شوند. پرتاب شدن یک خطا به سادگی به معنای اتفاق افتادن آن خطا است بنابراین نگذارید اصطلاحات فنی مانند throw شدن شما را سر در گم کنند. اگر بخواهیم خودمان خطایی را بسازیم باید از constructor خطاها در جاوا اسکریپت استفاده کنیم:
const err = new Error("Something bad happened!");
البته باید بدانید که می توانید کلیدواژه new را حذف کنید:
const err = Error("Something bad happened!");
در هر حال زمانی که شیء خطا را ایجاد کردید باید بدانید که حاوی ۳ خصوصیت است:
به مثال زیر توجه کنید:
const wrongType = TypeError("Wrong type given, expected number"); console.log("Here is the message", wrongType.message); console.log("Here is the name", wrongType.name); console.log("Here is the stack", wrongType.stack);
TypeError یکی از چندین نوع خطا در جاوا اسکریپت است (بعدا با انواع خطاها آشنا می شویم). من یک شیء خطا از نوع TypeError را در کد بالا ساخته ام و سپس سه خصوصیت آن را چاپ کرده ام. با اجرای این کد نتیجه زیر را می گیرید:
// پیام خطا Here is the message --> Wrong type given, expected number // نوع خطا Here is the name --> TypeError // پشته خطا Here is the stack --> TypeError: Wrong type given, expected number at Object.<anonymous> (/media/amir/Development/Roxo Academy/Independent/18- JavaScript Error Handling Guide/code/data.js:1:19) at Module._compile (node:internal/modules/cjs/loader:1109:14) at Object.Module._extensions..js (node:internal/modules/cjs/loader:1138:10) at Module.load (node:internal/modules/cjs/loader:989:32) at Function.Module._load (node:internal/modules/cjs/loader:829:14) at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:76:12) at node:internal/main/run_main_module:17:47
البته مرورگرهایی مانند فایرفاکس خصوصیات دیگری را به شیء خطا اضافه می کنند که columnNumber (شماره ستون) و filename (نام فایل) و lineNumber (شماره خط) می باشد. از آنجایی که این موارد مخصوص فایرفاکس است من روی آن ها تمرکز نمی کنم، هدف اصلی این مقاله بررسی خطاهای استاندارد جاوا اسکریپت در تمام فضاهای مرورگرها و سرور (Node.js) است.
کلمه Type به معنی «نوع» است بنابراین Error Type به معنی «نوع خطا» است. در زبان جاوا اسکریپت انواع مختلفی از خطاها را داریم که مهم ترین آن ها عبارت اند از:
توجه داشته باشید که تمام این خطاها constructor function هایی هستند که یک شیء خطا را ساخته و برمی گردانند. زمانی که صحبت از ساخت خطا توسط توسعه دهنده باشد، ما در اکثر موارد فقط با انواع Error و TypeError مواجه هستیم و در بیشتر پروژه ها اصلا از نوع خطاهای دیگر استفاده نمی کنیم اما اگر صحبت از خطاهای پرتاب شده توسط موتور جاوا اسکریپت باشد آنگاه بیشتر خطاها از نوع InternalError و SyntaxError خواهند بود.
یک مثال ساده از TypeError تغییر const است:
const name = "Jules"; name = "Caty";
نتیجه اجرای این کد یک خطا به شکل زیر خواهد بود:
TypeError: Assignment to constant variable.
همچنین یک مثال ساده از SyntaxError اشتباهات تایپی است:
cost name = "Jules";
نوشتن cost به جای const باعث تولید این خطا می شود:
SyntaxError: Unexpected identifier
بسیاری از توسعه دهندگان تصور می کنند که خطا (error) و استثناء (exception) یکی هستند اما اینطور نیست. از نظر فنی شیء خطا تنها زمانی تبدیل به exception می شود که پرتاب (throw) شود. به مثال زیر توجه کنید:
const wrongType = TypeError("Wrong type given, expected number"); throw wrongType;
متغیر wrongType در خط اول فقط یک شیء خطا (error) است که هیچ کاری نمی کند اما در خط دوم که پرتاب می شود به یک exception تبدیل شده است. البته معمولا رایج است که به جای نوشتن کد بالا در دو خط، از حالت خلاصه آن استفاده می شود:
throw TypeError("Wrong type given, expected number");
یا بدین شکل:
throw new TypeError("Wrong type given, expected number");
هر دو کد بالا یکی هستند و هر دو باعث پرتاب یک exception می شوند. البته در نظر داشته باشید که در ۹۰ درصد مواقع exception ها از درون یک تابع یا یک شرط (مانند if) پرتاب می شوند. به مثال زیر توجه کنید:
function toUppercase(string) { if (typeof string !== "string") { throw TypeError("Wrong type given, expected a string"); } return string.toUpperCase(); }
این مثال یک مثال واقع گرایانه از استفاده از خطاها در جاوا اسکریپت است. همانطور که می بینید خطا از درون یک تابع پرتاب شده است.
آخرین نکته این بخش این است که شما می توانید علاوه بر خطاها، داده های دیگری را نیز در جاوا اسکریپت پرتاب کنید:
throw Symbol(); throw 33; throw "Error!"; throw null;
اما از نظر استاندارد این کار اشتباه است و شما باید همیشه اشیاء خطای واقعی و سالم را پرتاب کنید.
در این بخش باید از خودمان بپرسیم که زمانی که exception پرتاب می شود چه می شود؟ آیا روند اجرای برنامه تغییری می کند؟ exception ها مانند آسانسوری هستند که به سمت بالا حرکت می کند! یعنی چه؟ یعنی در stack یا همان پُشته برنامه حرکت کرده و به سمت بالا می آیند مگر آنکه در بخشی از برنامه گرفته (catch) شوند. بیایید این مسئله را با ذکر مثال بررسی کنیم:
function toUppercase(string) { if (typeof string !== "string") { throw TypeError("Wrong type given, expected a string"); } return string.toUpperCase(); } toUppercase(4);
این تابع یک رشته را می خواهد اما ما از عمد یک عدد را پاس داده ایم. اگر این کد را در مرورگر یا Node.js اجرا کنید، اجرای برنامه متوقف شده و نتیجه به شکل یک خطا به شما نمایش داده می شود:
TypeError: Wrong type given, expected a string at toUppercase (file:///media/amir/Development/Roxo%20Academy/code/data.js:3:15) at file:///media/amir/Development/Roxo%20Academy/code/data.js:9:1
به این گزارش یک stack trace می گویند چرا که نشان می دهد روند اجرای کدها در پشته به چه صورت است و روند خواندن آن از پایین به بالا است. مثلا من بر اساس گزارش بالا متوجه می شوم که چیزی به نام toUppercase در فایل data.js و در خط ۹ و کارکتر ۱ عملیات را شروع کرده است (data.js:9:1 یعنی فایل data.js خط ۹ و کارکتر ۱). سپس همان toUppercase در همان فایل در خط ۳ و کاراکتر ۱۵ باعث توقف کامل برنامه و بروز خطا شده است. همانطور که مشخص شد من از پایین به بالا شروع به خواندن کردم و با همین روش ساده متوجه روند کلی بروز خطا در برنامه خودم شده ام.
این exception یک uncaught exception است یعنی exception ای که توسط برنامه نویس catch (گرفته) نشده است. این exception ها برنامه شما را crash می کنند یا به زبان ساده اجرای آن را به طور کامل متوقف می کنند. اینکه چطور باید exception ها را دریافت کرد یا اینکه در چه بخشی آن ها را دریافت کرد کاملا بستگی به شما و برنامه شما و وضعیت خاص آن کدها دارد. من در ادامه این مقاله چندین مثال مختلف از گرفتن (catch کردن) exception ها برایتان آورده ام که آن ها را به دو بخش جاوا اسکریپت async (ناهمگام) و sync (همگام) تقسیم خواهم کرد. در نهایت بخش خاصی را نیز به مدیریت خطا در Node.js اختصاص داده ام.
ما در این بخش به بررسی خطاها در کدهای همگام می پردازیم.
اولین دسته از کدهای همگام، توابع ساده ای هستند که هر روزه از آن ها استفاده می کنیم. ما می توانیم از همان مثال ابتدای مقاله استفاده کنیم:
function toUppercase(string) { if (typeof string !== "string") { throw TypeError("Wrong type given, expected a string"); } return string.toUpperCase(); } toUppercase(4);
همانطور که می بینید اگر به جای رشته یک عدد به ما پاس داده شود این تابع یک exception را پرتاب می کند و همه می دانیم که پرتاب شدن exception باعث توقف کامل اسکریپت ما می شود. برای جلوگیری از بروز چنین مشکلی باید از بلوک try & catch یا try & catch & finally استفاده کنیم:
function toUppercase(string) { if (typeof string !== "string") { throw TypeError("Wrong type given, expected a string"); } return string.toUpperCase(); } try { toUppercase(4); } catch (error) { console.error(error.message); // شما می توانید به جای چاپ کردن خطا هر کار دیگری را نیز انجام بدهید } finally { // کدهای این بخش فارغ از تمام موارد دیگر اجرا خواهند شد }
ساختار بلوک های try & catch دقیقا به شکلی است که در کد بالا می بینید:
نکته بسیار مهم در این بخش این است که try/catch/finally یک ساختار همگام است بنابراین به هیچ عنوان نمی تواند خطاهای تولید شده از کدهای ناهمگام مانند promise ها را دریافت کند.
دسته بعدی از کدهای همگام generator function ها یا توابع ژنراتور هستند. در صورتی که نمی دانید توابع ژنراتور نوع خاصی از توابع در جاوا اسکریپت است که قابلیت مکث کردن در اجرایشان را دارند و همچنین یک کانال ارتباطی دو طرفه بین scope داخل تابع و صدا زننده تابع ایجاد می کنند. برای تولید این توابع در جاوا اسکریپت باید از علامت * پس از کلمه function استفاده کنیم:
function* generate() { // }
زمانی که درون این توابع هستیم می توانیم با استفاده از کلیدواژه yield مقادیر را برگردانیم:
function* generate() { yield 33; yield 99; }
در واقع زمانی که این تابع صدا زده می شود از خط اول آن شروع می کنیم و هر جایی به yield رسیدیم متوقف می شویم تا زمانی که next روی آن صدا زده شود و سپس روند اجرای تابع ادامه پیدا می کند (در ادامه خواهید دید). مقادیری که با yield برگردانده شوند یک شیء گردش کننده هستند بنابراین مستقیما به خود مقدار دسترسی نداریم. برای دسترسی به این مقادیر باید یکی از دو راه زیر را انتخاب کنیم:
به طور مثال:
function* generate() { yield 33; yield 99; } const go = generate();
من در این بخش تابع generate را صدا زده ام و نتیجه اش را در متغیری به نام go قرار داده ام بنابراین go همان شیء گردش کننده ما است. با این حساب می توانیم next را روی آن صدا بزنیم تا از یک yield به yield دیگر برویم:
function* generate() { yield 33; yield 99; } const go = generate(); const firstStep = go.next().value; // 33 const secondStep = go.next().value; // 99
اولین باری که next را صدا بزنیم به اولین yield می رسیم بنابراین می توانیم با خصوصیت value به مقدار آن یعنی ۳۳ دسترسی داشته باشیم. دومین بار نیز به دومین yield می رسیم و باز هم با value مقدارش (۹۹) را گرفته ایم.
نکته جالب اینجاست که توابع ژنراتور به طور برعکس نیز کار می کنند؛ یعنی می توانند مقادیر یا exception ای را از صدا زننده تابع نیز دریافت کنند. ژنراتورها علاوه بر next یک تابع دیگر به نام throw دارند که کار دریافت exception را انجام می دهد. اگر یک exception را به throw بدهید، روند اجرای تابع ژنراتور به طور کامل متوقف می شود:
function* generate() { yield 33; yield 99; } const go = generate(); const firstStep = go.next().value; // 33 go.throw(Error("Tired of iterating!")); const secondStep = go.next().value; // این کد هیچگاه اجرا نخواهد شد
همانطور که می بینید من پس از دریافت ۳۳ یک شیء خطا را به throw پاس داده ام بنابراین اجرای generate به طور کامل متوقف شده و هیچگاه به بخش بعدی (secondStep) نمی رسیم. مشکل اینجاست که اگر به همین سادگی این کار را انجام بدهید، یک exception را پرتاب کرده اید اما آن را مدیریت نکرده اید. برای حل این مشکل باید درون تابع ژنراتور یک بلوک try & catch داشته باشید:
function* generate() { try { yield 33; yield 99; } catch (error) { console.error(error.message); } }
با انجام این کار خطا مدیریت خواهد شد. اگر یادتان باشد گفتم که توابع ژنراتور یک کانال ارتباطی دو طرفه دارند و منظورم همین بود. یعنی علاوه بر اینکه می توانیم exception را درون این توابع پرتاب کنیم، می توانیم یک exception را نیز از درون به بیرون تابع پرتاب کنیم. در این حالت برای مدیریت خطا در جاوا اسکریپت باید از همان try & catch استفاده کرد:
function* generate() { yield 33; yield 99; throw Error("Tired of iterating!"); } try { for (const value of generate()) { console.log(value); } } catch (error) { console.error(error.message); } /* خروجی کد بالا: 33 99 Tired of iterating! */
ما در این بخش به بررسی خطاها در کدهای ناهمگام می پردازیم. زبان جاوا اسکریپت در حالت عادی single-threaded است بنابراین در اکثر اوقات به صورت همگام کار می کند اما در برخی از موارد نیز اینطور نبوده و به صورت ناهمگام عمل می کند. در این بخش با انواع مهم کدهای ناهمگام آشنا خواهید شد.
من در ابتدا چند روش اشتباه و دسته ای از اطلاعات غلط را به شما نشان می دهم و پس از آن راه حل های اصلی و استاندارد را معرفی می کنم.
اولین دسته از کدهای ناهمگام تایمرها هستند. معمولا زمانی که افراد به تازگی با try/catch/finally آشنا می شوند سعی می کنند آن را دور تمام بلوک های کدشان قرار بدهند اما همانطور که گفتم این کار درست نیست. به مثال زیر توجه کنید:
function failAfterOneSecond() { setTimeout(() => { throw Error("Something went wrong!"); }, 1000); }
این تابع یک تابع بسیار ساده است که پس از ۱ ثانیه (۱۰۰۰ میلی ثانیه) یک exception را پرتاب می کند. استفاده از بلوک های try & catch در چنین حالتی کار نخواهند کرد:
function failAfterOneSecond() { setTimeout(() => { throw Error("Something went wrong!"); }, 1000); } try { failAfterOneSecond(); } catch (error) { console.error(error.message); }
از آنجایی که ماهیت try & catch همگام است، ترکیب آن با کدهای ناهمگام مانند setTimeout هیچ نتیجه ای نخواهد داشت و انگار اصلا کدهای try & catch وجود ندارند. چرا؟ توجه داشته باشید که اجرای کد در هر زبان برنامه نویسی بسیار سریع است. در جاوا اسکریپت کدها از بالا به پایین اجرا می شوند و تمام این کدها در کسری از ثانیه اتفاق می افتد (در حد میلی ثانیه) اما تایمر ما بعد از ۱ ثانیه اجرا می شود که یعنی دیگر try & catch ای وجود ندارد. وقتی از کلمه «ناهمگام» استفاده می کنیم منظورمان این است که کدهای ما دو لاین یا دو مسیر جداگانه دارند و این دو مسیر با یکدیگر و همگام نیستند:
مسیر اول : --> try/catch مسیر دوم : --> setTimeout --> callback --> throw
شاید بگویید برای حل این مشکل باید بلوک try & catch را به درون تابع پاس داده شده به setTimeout قرار بدهیم. این کار از توقف کامل برنامه جلوگیری می کند اما عجیب و غریب است و در بسیاری از مواقع باعث چالش های دیگری می شود. بهترین روش استفاده از promise ها است که در بخش های بعدی مشاهده خواهید کرد.
هر node در HTML به EventTarget متصل شده است. EventTarget جد تمام پخش کننده های رویداد در مرورگر است. با این حساب ما می توانیم به هر node ای گوش بدهیم تا رویدادهای آن را تحت نظر داشته باشیم. زمانی که صحبت از مدیریت خطا برای این رویدادها می شود باید مثل هر Web API ناهمگام دیگری با آن ها برخورد کنیم. به طور مثال این کد را در نظر بگیرید:
const button = document.querySelector("button"); button.addEventListener("click", function() { throw Error("Can't touch this button!"); });
در اینجا یک دکمه ساده را داریم که به محض کلیک روی آن، یک exception پرتاب می شود. شاید تصور کنید این کد همگام است بنابراین می توانید از یک try & catch استفاده کنید:
const button = document.querySelector("button"); try { button.addEventListener("click", function() { throw Error("Can't touch this button!"); }); } catch (error) { console.error(error.message); }
مشکل اینجاست که کد بالا کار نمی کند. چرا؟ به دلیل اینکه هر callback پاس داده شده به addEventListener به صورت ناهمگام اجرا می شود:
مسیر اول: --> try/catch مسیر دوم: --> addEventListener --> callback --> throw
اگر می خواهید از توقف کامل برنامه خود جلوگیری کنید باید try/catch را به درون addEventListener منتقل کنید اما باز هم می گویم که این روش اصلا استاندارد نیست و استفاده از promise ها یا async/await بسیار بهتر است. من در بخش های آینده روش این کار را برایتان توضیح می دهم اما فعلا باید خصوصیت onerror را نیز بررسی کنیم.
همانطور که می دانید عناصر HTML چندین دسته event handler مختلف مانند onclick و onmouseenter و onchange دارند. اگر با جاوا اسکریپت و طراحی front-end کار کرده باشید حتما با این موارد آشنا هستید. یکی از این خصوصیات onerror است اما به اشتباه تصور می شود که کار آن با throw مرتبط است. خصوصیت onerror زمانی اجرا می شود که فایلِ یک عنصر HTML مانند تگ <img> یا تگ <script> یا همان src آن پیدا نشود. به مثال زیر توجه کنید:
// بقیه کدها <body> <img src="nowhere-to-be-found.png" alt="So empty!"> </body> // بقیه کدها
در اینجا تصویری به نام nowhere-to-be-found.png نداریم بنابراین به خطا برخورد می کنیم. پاسخ سرور نیز به چنین خطایی به شکل زیر است:
GET http://localhost:5000/nowhere-to-be-found.png [HTTP/1.1 404 Not Found 3ms]
خصوصیت onerror در این حالت است که به کمک ما می آید و به ما اجازه می دهد خطای ایجاد شده را مهار کنیم:
const image = document.querySelector("img"); image.onerror = function(event) { console.log(event); };
یا حتی می توانیم به شکلی بهتر و کلی تر عمل کنیم:
const image = document.querySelector("img"); image.addEventListener("error", function(event) { console.log(event); });
این روش معمولا به شکل بالا و فقط با log کردن خطا استفاده نمی شود بلکه استفاده اصلی آن برای بارگذاری منابع دیگر به جای منابع پیدا نشده است. بنابراین در نظر داشته باشید که onerror هیچ ربطی به throw کردن خطا یا catch کردن آن ندارد.
promise ها روشی استاندارد و عالی برای مدیریت خطای کدهای ناهمگام هستند که البته روی کدهای همگام نیز کار می کنند. بیایید به تابع اول همگام خودمان برگردیم. من ابتدا با توابع همگام شروع می کنم تا درک مطلب ساده تر باشد.
function toUppercase(string) { if (typeof string !== "string") { throw TypeError("Wrong type given, expected a string"); } return string.toUpperCase(); } toUppercase(4);
اگر یادتان باشد این تابع همگام است اما هنوز می توانیم از promise ها برای مدیریت خطای آن استفاده کنیم:
function toUppercase(string) { if (typeof string !== "string") { return Promise.reject(TypeError("Wrong type given, expected a string")); } const result = string.toUpperCase(); return Promise.resolve(result); }
Promise.reject به معنی رد کردن یا همان پرتاب exception است و promise.resolve به معنی تایید کردن و ادامه مسیر است. ما نتیجه را به resolve پاس می دهیم تا به گیرنده اش برسد و نیازی نیست نتیجه را مستقیما return کنید. این بخش مربوط به تعریف این تابع بود و به کاری که انجام دادیم promisify کردن تابع می گویند. توجه کنید که promise.resolve یا promise.reject هر دو باید return شوند و صدا زدن خالی آن ها کاری از پیش نمی برد.
در مرحله بعدی و در هنگام استفاده از این تابع باید از دو بخش then و catch استفاده کنیم:
toUppercase(99) .then(result => result) .catch(error => console.error(error.message));
اگر صدا زدن تابع با موفقیت همراه باشد وارد بخش then می شویم که همان result پاس داده شده به promise.resolve را به صورت خودکار دریافت می کنیم. شما می توانید در این بخش هر کاری را که خواستید انجام بدهید. از طرف دیگر اگر اجرای تابع دچار مشکل شود promise.reject به ما پاس داده شده و مستقیما وارد بخش catch می شویم. همانطور که می بینید من در این بخش فقط پیام خطا را log کرده ام. با این حساب نتیجه اجرای کد بالا بدین شکل خواهد بود:
Wrong type given, expected a string
البته شما در این حالت finally را هم دارید اما کمتر از آن استفاده می شود:
toUppercase(99) .then(result => result) .catch(error => console.error(error.message)) .finally(() => console.log("Run baby, run"));
به عنوان یک استاندارد کلی همیشه بهتر است هنگام reject کردن یک promise از یک شیء خطا استفاده کنید و رشته های ساده را به آن پاس ندهید:
Promise.reject(TypeError("Wrong type given, expected a string"));
با انجام این کار یکپارچگی خطا در تمام برنامه خود را حفظ می کنید و دیگر اعضای تیم توسعه می دانند که همیشه به error.message دسترسی خواهند داشت.
سوال بعدی اینجاست که اگر بخواهیم از یک زنجیره promise خارج شویم چه کار باید کرد؟ مثلا وارد then شده ایم اما در آنجا اتفاقی افتاده است که باید به خطا برخورد کنیم. در این حالت باید یک exception ساده را پرتاب کنید:
Promise.resolve("A string").then(value => { if (typeof value === "string") { throw TypeError("Expected a number!"); } });
ما در این حالت متوجه می شویم که نتیجه پاس داده شده به ما از نوع رشته نیست بنابراین با یک exception از then خارج شده ایم. در این حالت برای دریافت این exception پرتاب شده از یک catch استفاده می کنیم:
Promise.resolve("A string") .then(value => { if (typeof value === "string") { throw TypeError("Expected a number!"); } }) .catch(reason => console.log(reason.message));
شاید بپرسید آیا اصلا چنین سناریویی واقع بینانه است؟ باید بگویم بله! یکی از مواقعی که از این حالت استفاده می کنیم، هنگام تعامل با یک API با استفاده از fetch یا دستورات مشابه است:
fetch("https://example-dev/api/") .then(response => { if (!response.ok) { throw Error(response.statusText); } return response.json(); }) .then(json => console.log(json));
همانطور که می بینید در کد بالا با یک API تعامل داشته ایم و نتیجه را بررسی کرده ایم. اگر response.ok برابر true نباشد یک exception را پرتاب می کنیم بنابراین احتمال پرتاب شدن یک خطا وجود دارد اما قسمت catch را نداریم. به چنین حالتی uncaught exception گفته می شود یعنی یک exception پرتاب شده است اما آن را catch نکرده ایم. بسته به اینکه در چه محیطی کد جاوا اسکریپتی می نویسید این مسئله می تواند به ضررتان تمام شود. مثلا در آینده نزدیک در برنامه های Node.js هر uncaught exception باعث crash شدن کل سرور می شود و خطای زیر را می گیرید:
DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
بنابراین بهتر است همیشه از catch استفاده کنید.
حالا بیایید به سراغ کد تایمر برویم. اگر یادتان باشد کد زیر را برایتان نوشته بودم و گفتم این کد غلط است و جلوی خطا را نمی گیرد چرا که نمی توانیم در عملیات های ناهمگام از try & catch استفاده کنیم:
function failAfterOneSecond() { setTimeout(() => { throw Error("Something went wrong!"); }, 1000); } // این کد کار نمی کند try { failAfterOneSecond(); } catch (error) { console.error(error.message); }
برای حل این مشکل می توانیم از promise ها استفاده کنیم:
function failAfterOneSecond() { return new Promise((_, reject) => { setTimeout(() => { reject(Error("Something went wrong!")); }, 1000); }); }
ما در این کد تابعی را داریم که پس از یک ثانیه خطایی را پرتاب می کند. اگر دقت کرده باشید من تمام تایمر را درون یک promise قرار داده ام. آرگومان اول تابع پاس داده شده به این promise به شکل _ است. آیا می دانید چرا؟ هر گاه در زبان جاوا اسکریپت آرگومانی را به شکل _ دیدید، یعنی آرگومان اصلی برایمان اهمیت نداشته است. در اصل باید به جای _ یک resolve را دریافت کنیم اما تابع ما اصلا resolve نمی شود بلکه فقط یک خطا را پرتاب می کند و به همین دلیل نیازی به resolve ندارد و به جایش _ را گذاشته ایم. این مسئله یک نکته فنی نیست بلکه به صورت یک قرارداد رایج بین توسعه دهندگان جاوا اسکریپت مشهور شده است اما شما می توانید از آن پیروی نکنید چرا که نام آرگومان ها در اجرای آن ها تاثیری ندارد.
درون این promise جدید تابع setTimeout خودمان را صدا زده ایم که پس از یک ثانیه یک خطا را با استفاده از تابع reject برمی گرداند. توجه داشته باشید که کل promise برگردانده شده است. حالا زمانی که بخواهیم از آن استفاده کنیم، می گوییم:
failAfterOneSecond().catch(reason => console.error(reason.message));
باز هم می گویم که نام گذاری پارامترها اهمیتی ندارد اما برای آشنایی شما توضیح می دهم که یکی از قرارداد های رایج در هنگام استفاده از promise ها انتخاب value به عنوان مقدار پاس داده شده در then و انتخاب reason به عنوان خطای پاس داده شده به catch است. شما می توانید هر نام دیگری را نیز انتخاب کنید.
اگر از کاربران Node.js هستید احتمالا می دانید که Node.js یک ماژول کمکی به نام promisify دارد. این ماژول به شما کمک می کند کدهای قدیمی که از callback استفاده می کنند را به promise تبدیل کنید. مثال:
const util = require('util'); const fs = require('fs'); const stat = util.promisify(fs.stat); stat('.').then((stats) => { // Do something with `stats` }).catch((error) => { // Handle the error. });
متدی استاتیک به نام Promise.all آرایه ای از promise های مختلف را دریافت می کند و آرایه ای از نتایج را برمی گرداند. به مثال زیر توجه کنید:
const promise1 = Promise.resolve("All good!"); const promise2 = Promise.resolve("All good here too!"); Promise.all([promise1, promise2]).then((results) => console.log(results)); // [ 'All good!', 'All good here too!' ]
ما در کد بالا دو promise را داریم و هر دو را در قالب یک آرایه به promise.all پاس داده ایم. به همین دلیل در قسمت then و هنگام چاپ، یک آرایه با نتیجه هر دو چاپ خواهد شد! من مقدار چاپ شده را به صورت کامنت در کد بالا قرار داده ام (آخرین خط). تعداد این promise ها مهم نیست اما همانطور که گفتم رها کردن خطاهای احتمالی در promise ها اصلا توصیه نمی شود بنابراین بهتر است catch را نیز در این دسته از کدها داشته باشید:
const promise1 = Promise.resolve("All good!"); const promise2 = Promise.reject(Error("No good, sorry!")); const promise3 = Promise.reject(Error("Bad day ...")); Promise.all([promise1, promise2, promise3]) .then(results => console.log(results)) .catch(error => console.error(error.message));
با قراردادن بخش catch احتمال بروز خطای uncaught exception را حذف می کنیم. اگر یکی از promise های پاس داده شده به Promise.all رد شود (reject شود) نتیجه کلی «شکست» خواهد بود. برای اینکه نتیجه Promise.all صحیح باشد باید تمام promise های پاس داده شده نیز صحیح باشند. به عبارت فنی تر اولین promise که reject شود به عنوان promise اصلی به مرحله بعد رفته و وارد catch می شویم.
Promise.any دقیقا برعکس Promise.all است و در نسخه های 79 به بعد فایرفاکس و 85 به بعد گوگل کروم پشتیبانی می شود. اگر یکی از promise های موجود در آرایه پاس داده شده به Promise.all رد یا reject شود نتیجه کلی نیز همان رد شدن خواهد بود اما در promise.any اگر یکی از promise ها reject شود اهمیتی ندارد. یعنی چه؟ یعنی promise.any همیشه اولین promise حل شده یا resolve شده را برمی گرداند. البته اگر تمام promise ها reject شوند آنگاه یک شیء AggregateError را دریافت خواهیم کرد:
const promise1 = Promise.reject(Error("No good, sorry!")); const promise2 = Promise.reject(Error("Bad day ...")); Promise.any([promise1, promise2]) .then(result => console.log(result)) .catch(error => console.error(error)) .finally(() => console.log("Always runs!"));
هر دو promise ما در کد بالا reject می شوند بنابراین مستقیما وارد catch می شویم. من جهت یادآوری بلوک finally را نیز گذاشته ام تا یادتان بیندازم که این بلوک همیشه فارغ از تمام مسائل اجرا خواهد شد. با اجرای کد بالا چنین نتیجه ای را می گیرید:
AggregateError: No Promise in Promise.any was resolved Always runs!
هر شیء AggregateError همان خصوصیت شیء Error ساده را دارد اما یک خصوصیت متفاوت نیز دارد که errors نام دارد (به s آخر آن دقت کنید) و می توانیم به شکل زیر به آن دسترسی داشته باشیم:
.catch(error => console.error(error.errors))
این خصوصیت تمام خطاهای موجود را در قالب یک آرایه برایتان برمی گرداند بنابراین با اجرای کد بالا نتیجه زیر را خواهیم داشت:
[Error: "No good, sorry!, Error: "Bad day ..."]
تا این بخش متوجه شدیم که نتیجه کلی Promise.all تنها زمانی صحیح است که تمام promise های پاس داده شده به آن صحیح باشند در صورتی که نتیجه Promise.any تنها زمانی صحیح است که یکی از promise ها صحیح باشد (اولین promise صحیح). از طرف دیگر promise.race را داریم که یک مسابقه بین promise ها برگذار می کند (کلمه race به معنی مسابقه است!). در این دستور هر promise ای که سریع تر کامل شود برنده است و نتیجه کلی را مشخص می کند، چه resolve شود و چه reject شده باشد:
const promise1 = Promise.resolve("The first!"); const promise2 = Promise.resolve("The second!"); Promise.race([promise1, promise2]).then(result => console.log(result)); // The first!
همانطور که می بینید خروجی The First است که متعلق به اولین promise ما می باشد. باز هم می گویم، مسئله مهم اینجاست که resolve شدن یا reject شدن اصلا مهم نیست بلکه تکمیل شدن قبل از دیگر promise ها مهم است. به کد زیر توجه کنید:
const promise1 = Promise.resolve("The first!"); const rejection = Promise.reject(Error("Ouch!")); const promise2 = Promise.resolve("The second!"); Promise.race([promise1, rejection, promise2]).then(result => console.log(result) ); // The first!
در کد بالا یک reject را نیز داریم اما نتیجه باز هم همان است. حالا اگر این کد را طوری بنویسیم که reject در ابتدا باشد چطور؟
const promise1 = Promise.resolve("The first!"); const rejection = Promise.reject(Error("Ouch!")); const promise2 = Promise.resolve("The second!"); Promise.race([rejection, promise1, promise2]) .then(result => console.log(result)) .catch(error => console.error(error.message)); // Ouch!
همانطور که می بینید نتیجه کلی صحیح نیست و مستقیما وارد catch می شویم چرا که متغیر rejection اول در آرایه پاس داده شده قرار گرفته است بنابراین برنده مسابقه است.
ECMAScript 2020 قابلیت جدیدی را به جاوا اسکریپت اضافه کرده است که Promise.allSettled نام دارد. نتیجه این متد استاتیک همیشه یک promise حل شده یا resolve شده است حتی اگر یک یا چند promise دیگر reject شوند. به مثال زیر توجه کنید:
const promise1 = Promise.resolve("Good!"); const promise2 = Promise.reject(Error("No good, sorry!")); Promise.allSettled([promise1, promise2]) .then(results => console.log(results)) .catch(error => console.error(error)) .finally(() => console.log("Always runs!"));
ما در این مثال دو promise را داریم که یکی resolve و دیگری reject شده است. طبق معمول هر دو را در قالب یک آرایه به این متد پاس داده ایم. در این کد ابتدا then و سپس finally اجرا می شوند و هیچ گاه به catch وارد نمی شویم حتی با اینکه یک reject را نیز داشته ایم. در واقع نتیجه اجرای بخش then از کد بالا بدین شکل خواهد بود:
[ { status: 'fulfilled', value: 'Good!' }, { status: 'rejected', reason: Error: No good, sorry! } ]
همانطور که می بینید همه چیز در then حل می شود، حتی خطاها!
کدهای ناهمگام مزایای بسیاری دارند اما یکی از معایب آن ها کاهش خوانایی است؛ یعنی خواندن آن ها برای انسان کار سختی است چرا که ترتیب اجرایی خطی ندارند بلکه از یک محل به محل دیگری می پرند. استفاده از async/await باعث می شود تمام مزایای کدهای ناهمگام را حفظ کنیم اما خوانایی آن ها را افزایش بدهیم! برای درک ساده تر از همان تابع همیشگی خودمان، یعنی toUppercase، استفاده می کنیم. برای استفاده از async/await ابتدا نیاز به یک تابع async داریم. برای این کار باید کلیدواژه async را در ابتدای تابع خود قرار بدهید:
async function toUppercase(string) { if (typeof string !== "string") { throw TypeError("Wrong type given, expected a string"); } return string.toUpperCase(); }
تابع بالا یک تابع async است! حالا در هنگام فراخوانی این تابع باید از await استفاده کنیم اما قبل از آن باید بدانید که توابع async خاص نیستند بلکه در اصل یک promise ساده را برمی گردانند. برای اثبات این موضوع می توانیم بدین شکل عمل کنیم:
async function toUppercase(string) { if (typeof string !== "string") { throw TypeError("Wrong type given, expected a string"); } return string.toUpperCase(); } toUppercase("abc") .then(result => console.log(result)) .catch(error => console.error(error.message)) .finally(() => console.log("Always runs!"));
همانطور که می بینید من فقط یک async را به ابتدای تعریف تابع اضافه کرده ام و حالا می توانیم then و catch و finally را در هنگام فراخوانی به آن زنجیر کنیم! زمانی که از درون یک تابع async یک خطا را پرتاب کنید (مانند کد بالا) نتیجه در پشت صحنه یک promise.reject خواهد بود.
البته موضوع از این هم جالب تر می شود چرا که ما می توانیم از ساختار try/catch/finally نیز برای توابع async استفاده کنیم! چطور؟ من به شما گفتم که ساختار try & catch فقط برای کدهای همگام است اما توابع async یک استثناء هستند. این توابع در اصل ناهمگام هستند اما از نظر ظاهری همگام جلوه می کنند بنابراین می توانید از try & catch در آن ها استفاده کنید. البته یک شرط مهم داریم و آن هم استفاده از await است. به کد زیر توجه کنید:
async function toUppercase(string) { if (typeof string !== "string") { throw TypeError("Wrong type given, expected a string"); } return string.toUpperCase(); } async function consumer() { try { await toUppercase(98); } catch (error) { console.error(error.message); } finally { console.log("Always runs!"); } } consumer();
من تابع دیگری را تعریف کرده ام که تابع toUppercase را در خودش صدا می زند و از try & catch استفاده می کند. نکته مهم اینجاست که در کنار فراخوانی تابع toUppercase از دستور await استفاده کرده باشید در غیر این صورت نمی توانید هیچ کاری را انجام بدهید. نتیجه اجرای کد بالا به شکل زیر است:
Wrong type given, expected a string Always runs!
قبلا با توابع ژنراتور آشنا شدید و می دانید که این توابع می توانند مقادیر ساده مانند رشته یا عدد را yield کنند اما ژنراتور های ناهمگام می توانند علاوه بر مقادیر ساده promise ها را نیز yield کنند. به مثال زیر توجه کنید:
async function* asyncGenerator() { yield 33; yield 99; throw Error("Something went wrong!"); // Promise.reject }
همانطور که می دانید اگر از درون یک تابع async خطایی را پرتاب کنیم در پشت صحنه promise.reject را انجام داده ایم. قاعده کلی برای بیرون کشیدن promise ها از شیء گردش کننده در توابع ژنراتور این است که یا مانند promise های ساده از then و catch استفاده کنید یا از گردش ناهمگام استفاده کنید. مثلا با نگاه کردن به ژنراتور بالا می دانیم که نتیجه آن پس از دو گردش قطعا یک promise.reject است. بیایید از روش اول (then و catch ساده) استفاده کنیم:
const go = asyncGenerator(); go.next().then(value => console.log(value)); go.next().then(value => console.log(value)); go.next().catch(reason => console.error(reason.message));
خروجی این کد به شکل زیر خواهد بود:
{ value: 33, done: false } { value: 99, done: false } Something went wrong!
حالا بیایید از روش دوم (گردش ناهمگام) استفاده کنیم. در این روش از ساختار خاصی به نام for await...of کمک می گیریم و همچنین باید تابع ژنراتور را درون یک تابع async دیگر قرار بدهیم:
async function* asyncGenerator() { yield 33; yield 99; throw Error("Something went wrong!"); // Promise.reject } async function consumer() { for await (const value of asyncGenerator()) { console.log(value); } } consumer();
با این کار بین مقادیر گردش می کنیم و تک تک آن ها را چاپ می کنیم. البته بهتر است که با try & catch مدیریت خطا را نیز انجام بدهیم:
async function* asyncGenerator() { yield 33; yield 99; throw Error("Something went wrong!"); // Promise.reject } async function consumer() { try { for await (const value of asyncGenerator()) { console.log(value); } } catch (error) { console.error(error.message); } } consumer();
خروجی کد بالا به شکل زیر خواهد بود:
33 99 Something went wrong!
در ضمن یادتان نرود که صدا زدن متد throw روی شیء گردش کننده باعث پرتاب exception نمی شود بلکه یک promise.reject را برمی گرداند. مثال:
async function* asyncGenerator() { yield 33; yield 99; yield 11; } const go = asyncGenerator(); go.next().then(value => console.log(value)); go.next().then(value => console.log(value)); go.throw(Error("Let's reject!")); go.next().then(value => console.log(value)); // value تعریف نشده است
حالا یک promise.reject را به داخل ژنراتور پاس داده ایم. برای مدیریت چنین حالتی از خارج از ژنراتور می توانید به شکل زیر نیز عمل کنید:
go.throw(Error("Let's reject!")).catch(reason => console.error(reason.message));
اما اگر بخواهید از درون خود ژنراتور کار را مدیریت کنید به شکل زیر عمل خواهید کرد:
async function* asyncGenerator() { try { yield 33; yield 99; yield 11; } catch (error) { console.error(error.message); } } const go = asyncGenerator(); go.next().then(value => console.log(value)); go.next().then(value => console.log(value)); go.throw(Error("Let's reject!")); go.next().then(value => console.log(value)); // value تعریف نشده است
در نهایت این مسئله به خود شما برمی گردد که از کدام روش استفاده کنید.
همانطور که می دانید Node.js یک runtime برای زبان جاوا اسکریپت است و معمولا در هنگام اجرای سرور های جاوا اسکریپتی از آن استفاده می شود. این مسئله باعث ایجاد تفاوت بسیار بزرگی بین جاوا اسکریپت مرورگر و جاوا اسکریپت سرور (Node.js) می شود و ماهیت مدیریت خطا در آن را کمی متفاوت می کند. یادتان باشد که مفاهیم اصلی مدیریت خطا در هر دو یکی است اما جزئیات کوچکی در مورد Node.js وجود دارد که می خواهم آن ها را در این بخش بررسی کنیم.
زمانی که صحبت از کدهای هنگام می کنیم مشکل خاصی وجود ندارد و می توانیم دقیقا مانند مرورگرها از try/catch/finally استفاده کنیم اما کدهای ناهمگام مسئله دیگری هستند. Node.js برای مدیریت خطای کدهای ناهمگام به دو بخش اصلی تکیه می کند:
در الگوی callback معمولا یک تابع در نظر گرفته می شود که در حلقه رویدادِ node.js قرار می گیرد و به محض خالی شدن call stack فراخوانی می شود. به کد زیر توجه نمایید:
const { readFile } = require("fs"); function readDataset(path) { readFile(path, { encoding: "utf8" }, function(error, data) { if (error) console.error(error); // انجام عملیات دلخواه روی داده }); }
ما در اینجا از ماژول fs استفاده کرده ایم تا یک فایل متنی ساده را بخوانیم. آرگومان اول پاس داده شده به readFile آدرس فایل متنی است، آرگومان دوم encoding یا رمزنگاری فایل است و آرگومان سوم callback ما است. زمانی که فایل به طور کامل خوانده شود آرگومان سوم که یک تابع است اجرا می شود و به صورت خودکار یک شیء error و یک شیء data را می گیرد. در صورتی که خطایی وجود نداشته باشد شیء error خالی خواهد بود و در غیر این صورت همان شیء همیشگی خطا را خواهیم داشت. در این حالت معمولا یکی از سه روش زیر انتخاب می شود:
حالت اول را در کد بالا دیدید اما برای پرتاب exception چطور باید عمل کرد؟ این کار نیز بسیار آسان است. به مثال زیر دقت کنید:
const { readFile } = require("fs"); function readDataset(path) { readFile(path, { encoding: "utf8" }, function(error, data) { if (error) throw Error(error.message); // انجام عملیات دلخواه روی داده }); }
من به سادگی exception خود را پرتاب کرده ام اما اگر آن را به همین شکل رها کنیم باعث توقف کامل برنامه می شویم چرا که هیچ مدیریت خطایی انجام نداده ایم. از آنجایی که این کد ناهمگام است، استفاده از try & catch به شکل زیر کارساز نیست و باز هم برنامه متوقف می شود:
const { readFile } = require("fs"); function readDataset(path) { readFile(path, { encoding: "utf8" }, function(error, data) { if (error) throw Error(error.message); // انجام عملیات دلخواه روی داده }); } try { readDataset("not-here.txt"); } catch (error) { console.error(error.message); }
اما راه های مختلف و جایگزینی برای روش بالا وجود دارد. روش صحیح اول پاس دادن خطا به یک callback دیگر است. به طور مثال ما یک تابع دیگر به نام ErrorHandler تعریف می کنیم که وظیفه مدیریت خطاها را داشته باشد. حالا می توان گفت:
const { readFile } = require("fs"); function readDataset(path) { readFile(path, { encoding: "utf8" }, function(error, data) { if (error) return errorHandler(error); // انجام عملیات دلخواه روی داده }); }
اما event emitter ها چه می شوند؟ همانطور که می دانید node.js رویداد محور است و بخش بزرگی از نوشتن کدهای node شامل کار کردن با رویدادها است. به طور مثال ماژول net در node.js کلاس پایه ای به نام EventEmitter را extend می کند که دو متد اصلی on و emit را دارد. از خود ماژول net برای ساخت سرور و دیگر عملیات های شبکه استفاده می شود. به مثال زیر توجه کنید:
const net = require("net"); const server = net.createServer().listen(8081, "127.0.0.1"); server.on("listening", function () { console.log("Server listening!"); }); server.on("connection", function (socket) { console.log("Client connected!"); socket.end("Hello client!"); });
این کد یک سرور HTTP ساده را نشان می دهد که به دو رویداد listening و connection گوش می کند و نسبت به آن ها واکنش خاصی نشان می دهد. خصوصیت اصلی این کد این است که event emitter ها در هنگام بروز خطا یک شیء خطا را پرتاب می کنند. به طور مثال بیایید سرور بالا را به جای پورت ۸۰۸۱ روی پورت ۸۰۸۰ اجرا کنیم:
const net = require("net"); const server = net.createServer().listen(80, "127.0.0.1"); server.on("listening", function () { console.log("Server listening!"); }); server.on("connection", function (socket) { console.log("Client connected!"); socket.end("Hello client!"); });
با انجام این کار با یک خطا روبرو می شویم و خروجی زیر به ما نمایش داده می شود:
events.js:291 throw er; // Unhandled 'error' event ^ Error: listen EACCES: permission denied 127.0.0.1:80 Emitted 'error' event on Server instance at: ...
طبیعتا چنین کدی باعث توقف کامل برنامه ما می شود. برای مدیریت این مشکل می توانیم یک event handler را برای خطاهای سیستم تعریف کنیم:
server.on("error", function(error) { console.error(error.message); });
از این به بعد به جای توقف برنامه، خطای زیر برایمان log می شود:
listen EACCES: permission denied 127.0.0.1:80
اما همانطور که گفتم بهترین و رایج ترین روش برای مدیریت خطای کدهای ناهمگام هنوز هم استفاده از async/await است، البته در صورتی که با حالت های خاصی مانند event emitter سر و کار نداشته باشیم. به عنوان یک قانون کلی پیشنهاد می دهم که همیشه از async/await استفاده کنید اما در برخی از موارد promise ها (then و catch) یا روش های دیگری که در این بخش ذکر شد برایتان مفید تر خواهند بود. به مثال ساده زیر توجه کنید:
async function thisThrows() { throw new Error("Thrown from thisThrows()"); } async function run() { try { await thisThrows(); } catch (e) { console.error(e); } finally { console.log("We do cleanup here"); } } run();
همانطور که می بییند خوانایی این کد به مراتب بهتر از استفاده از روش های دیگر است اما در نهایت به شرایط شما بستگی خواهد داشت. امیدوارم این مقاله به شما در درک مدیریت خطا در جاوا اسکریپت کمک کرده باشد. یادتان باشد شرایط کد و نحوه استفاده از آن در نحوه مدیریت خطا نقش بسیار مهمی دارد.
منابع: وب سایت های itnext و valentinog
در این قسمت، به پرسشهای تخصصی شما دربارهی محتوای مقاله پاسخ داده نمیشود. سوالات خود را اینجا بپرسید.