یکی از موضوعاتی که بسیاری از توسعهدهندگان و برنامهنویسان با آن سروکله میزنند مدیریت خطاهای برنامهنویسی است، این در حالی است که برخی از این افراد حتی متوجه خطاهای موجود هم نمیشوند. مدیریت درست خطاها یا error-handling به این معناست که علاوه بر کاهش زمان توسعهی نرمافزار با پیدا کردن اشکالات و خطاها، بتوان یک کد پایه (codebase) قوی برای برنامههایی با مقیاس بزرگتر نیز ایجاد کرد.
ما در این مقاله بهطور خاص توسعهدهندگان Node.js را در نظر میگیریم؛ این افراد بعضی اوقات میتوانند ضمن دستیابی و مدیریت انواع مختلف خطاها با کدهای نهچندان تمیز کار کنند و با همان منطق نادرست همهجا خطاها را مدیریت کنند.
توسعهدهندگان همچنین گاهی از خود میپرسند که آیا بهطورکلی Node.js در مدیریت خطاها عملکرد بدی دارد یا خیر و اگر عملکرد بدی ندارد پس چگونه باید در node.js به کنترل و مدیریت خطاها پرداخت؟ در پاسخ به این موضوع باید گفت که Node.js اصلاً بد نیست و عملکرد بدی ندارد و این موضوع به توسعهدهندگان بستگی دارد.
در ابتدا لازم است که درک روشنی از خطاها در Node.js. داشته باشید. بهطورکلی خطاهای Node.js به دودسته مجزا تقسیم میشوند: خطاهای عملیاتی(Operational errors) و خطاهای برنامهنویس(Programmer errors).
یکی از راههای مدیریت خطا راهاندازی مجدد برنامه یا restart کردن میباشد، اما در این مقاله یکی از راهحلهای مناسبتر برای این موضوع را نیز شرح میدهیم.
پیش از شروع موضوع اصلی مقاله بد نیست یکبار به معرفی اجمالی Node.js بپردازیم:
Node.js چیست؟
همانطور که میدانید Node.js یک پلتفرم بر اساس زبان جاوا اسکریپت است که امروزه توانسته با استفاده از تکنولوژی رویداد محوری که درون خود پایهگذاری کرده است، بسیاری از برنامهنویسان را جذب خود کند.
نکتهای که در همین ابتدا باید به آن اشارهکنیم این است که Node.js یک کتابخانه یا یک فریم ورک جدید مربوط به زبانهای برنامهنویسی که تازه کشف شده باشد نیست بلکه یک پلتفرم است که ما با استفاده از آن میتوانیم کدهای جاوا اسکریپتی را روی سرور اجرا کنیم. درواقع Node.js، جاوا اسکریپتی است که سمت سرور اجرا خواهد شد.
انواع خطاها در Node.js
خطاهای عملیاتی نمایانگر مشکلات زمان اجرا(runtime) هستند که منتظر نتایج آنها هستیم و باید با روشی مناسب به آنها رسیدگی شود. خطاهای عملیاتی به این معنا نیست که برنامه دارای باگ است بلکه به این معناست که برنامهنویسان یا توسعهدهندگان باید با فکر خودشان آنها را مدیریت کنند. نمونههایی از خطاهای عملیاتی عبارتند از "out of memory" ، "an invalid input for an API endpoint" و غیره.
خطاهای برنامهنویس باگهایی که در کدهای برنامه نوشتهشده را نشان میدهد. منظور از این خطاها این است که خودِ کد مشکلاتی دارد که باید حل شود و از طرف دیگر نیز کدنویسی برنامه نیز اشتباه انجام شده است. برای مثال اگر با خطای "undefined" روبرو شوید، برای رفع مشکل آن باید کد برنامه تغییر کند. بنابراین خطای ایجادشده یک خطای عملیاتی نیست، بلکه باگی است که برنامهنویس ایجاد کرده است.
با توجه به این نکته، دیگر نباید مشکلی در تفکیک این دو دسته از خطاها داشته باشید: خطاهای عملیاتی بخش طبیعی یک برنامه هستند و خطاهای برنامهنویس درواقع باگهای ایجادشده توسط برنامهنویسان هستند.
حال سؤالی که ممکن است برای شما پیش بیاید این است که چرا باید این خطاها را به دو دسته تقسیم کنیم؟ در جواب باید گفته که بدون داشتن درک واضحی از خطاها ممکن است هر زمان که خطایی رخ داد، تنها راهی که برای مدیریت خطا به ذهنتان خطور میکند فقط راهاندازی مجدد برنامه (restart) باشد(همانطور که در ابتدای مقاله به آن اشاره کردیم)! اما مسلماً این راهحل منطقی و درستی نیست؛ زیرا ممکن است با راهاندازی مجدد برنامه، هزاران کاربر در حال استفاده با خطای یک کاربر از اپلیکیشن خارج شوند. بنابراین باید خطاهایی که با آن روبرو میشویم را بهطور جزئی بررسی و به روش درست مدیریت کنیم.
اگر شما تجربهی کار با async JavaScript و Node.js را داشته باشید ممکن است هنگام استفاده از callback ها برای مدیریت خطاها، با موانعی روبرو شده باشید. callback ها شما را مجبور میكنند که خطاها را تا آخرین سطح سلسلهمراتب تودرتو بررسی كنید و باعث میشود که بهاصطلاح جهنم Callback (یا Callback Hell) ایجاد شود كه مدیریت كد را دشوار میكند. جاوا اسکریپت چند قابلیت معرفی کرده است که به کدنویسی async بدون استفاده از Callbackها کمک میکند؛ در اینجا استفاده از موارد زیر جایگزین مناسبی برای callbackها است:
· استفاده از Promises (ES6)
· استفاده از Async/Await (ES8)
جریان کد (code flow) معمولی async / await به شرح زیر است:
const doAsyncJobs = async () => {
try {
const result1 = await job1();
const result2 = await job2(result1);
const result3 = await job3(result2);
return await job4(result3);
} catch (error) {
console.error(error);
} finally {
await anywayDoThisJob();
}
}
استفاده از شیء خطای داخلی Node.js (یا Node.js built-in Error object) روش مناسبی برای خطایابی است زیرا شامل اطلاعات بصری و واضح در مورد خطاهایی مانند StackTrace است که اکثر توسعهدهندگان برای پیگیری و ریشهیابی خطاها به آن نیاز دارند. همچنین خصوصیات معنیدار اضافی مانند کد وضعیت HTTP و توضیحی دربارهی کلاس Error کمک بیشتری به رفع خطاها میکند. این خصوصیات بااینکه بهصورت built-in در کلاس Error جاوا اسکریپت وجود ندارند اما میتوان آنها را بهسادگی با ساختن یک کلاس مجزا و با ارثبری از کلاس Error (که یک کلاس built-in است) بهصورت زیر به کد برنامه اضافه نمود (درواقع در کد زیر مواردی مثل httpCode و isOperational و ... در کلاس BaseError تعریف شده هستند و این کد مثالی از استفاده از یک کلاس شخصیسازیشده با ارثبری از کلاس Error میباشد.):
class BaseError extends Error {
public readonly name: string;
public readonly httpCode: HttpStatusCode;
public readonly isOperational: boolean;
constructor(name: string, httpCode: HttpStatusCode, description: string, isOperational: boolean) {
super(description);
Object.setPrototypeOf(this, new.target.prototype);
this.name = name;
this.httpCode = httpCode;
this.isOperational = isOperational;
Error.captureStackTrace(this);
}
}
//free to extend the BaseError
class APIError extends BaseError {
constructor(name, httpCode = HttpStatusCode.INTERNAL_SERVER, isOperational = true, description = 'internal server error') {
super(name, httpCode, isOperational, description);
}
}
برای مثال برخی از کدهای وضعیت HTTP به شکل زیر است که در کد بالا نیز از این enum استفاده شده است:
export enum HttpStatusCode {
OK = 200,
BAD_REQUEST = 400,
NOT_FOUND = 404,
INTERNAL_SERVER = 500,
}
توجه کنید که در کد بالا تعریف کلاس BaseError و APIError در همین حد کافی است و نیازهای ما را برطرف میکند اما شما میتوانید با بسط دادن یا افزودن جزییات بیشتر به این کلاسها، آنها را بر اساس نیاز خود شخصیسازی کنید.:
class HTTP400Error extends BaseError {
constructor(description = 'bad request') {
super('NOT FOUND', HttpStatusCode.BAD_REQUEST, true, description);
}
}
درنهایت بهصورت زیر میتوان از این سیستم استفاده کرد:
...
const user = await User.getUserById(1);
if (user === null)
throw new APIError(
'NOT FOUND',
HttpStatusCode.NOT_FOUND,
true,
'detailed explanation'
);
برای نتیجه گرفتن از این قطعه کد کافی است از throw کردن کلاس APIError استفاده شود. برای مثال این کد یک کاربر را از دیتابیس دریافت میکند و درصورتیکه کاربر وجود نداشته باشد خطای APIError را throw خواهد کرد.
نکته:
مفهوم throw چیست؟
ایجاد یک شیء خطا (error object) فقط ازنظر مجازی خطای ما را برای ارسال ارجاع میدهد اما بهطور واقعی آن را ارسال نمیکند. در اینجا ما از طریق throw آن را ارسال میکنیم. اما مفهوم throw چیست و چهکاری برای برنامهی ما انجام میدهد؟
throw درواقع دو کار انجام میدهد: برنامه را متوقف میکند و دنبال یک متد catch برای اجرا کردن میگردد. یعنی هنگامیکه جاوااسکریپت یک throw در کد پیدا میکند، برنامه را متوقف و از اجرای دیگر functionهای درون کد جلوگیری میکند و یک پیغام خطا ظاهر میکند. با این کار احتمال بروز هرگونه خطای دیگری را در آینده کاهش میدهد و به ما کمک میکند تا وضعیت برنامه خود را پیچیدهتر نکنیم.
دستور throw امکان ایجاد خطاهای سفارشی (custom error) را در اختیار برنامهنویس قرار میدهد. اصطلاح تخصصی که برای این منظور (ایجاد خطای سفارشی) در نظر گرفته شده throwing an exception میباشد.
با متوقف شدن برنامه، JavaScript شروع به جستجوی زنجیرهای از functionهای درون کد میکند که برای دستیابی به متد catch به ترتیب فراخوانی شدهاند. نزدیکترین Catchای که جاوا اسکریپت پیدا میکند جایی است که exception یا استثناءِ throw شده ظاهر میشود. نوع دادهی Exception (استثنا) میتواند Number، String ، Boolean و یا Object باشد. در صورت عدم یافتن متد try/catch ، استثنا throw میشود و پردازش Node.js از این مرحله خارج شده و باعث میشود سرور مجدداً راهاندازی شود.
سیستم متمرکز کنترل خطای Node.js (Centralized Node.js Error-handling)
بهمنظور جلوگیری از تکرار کد (code duplication) در هنگام کار با خطاها، معمولاً بهتر است یک مؤلفه کنترل خطای متمرکز ساخته شود. مؤلفه کنترل خطا وظیفه دارد خطاهای گرفتهشده را از راههایی مثل: ارسال اعلانها به ادمین سیستم (در صورت لزوم)، ارسال اتفاقات رخداده به یک سرویس نظارتی مانند Sentry.io و ورود به سیستم، مدیریت کند.
مفهوم مدیریت خطای متمرکز
سیستم مدیریت خطای متمرکز نوعی database مرکزی یا حافظهی ذخیرهسازی (storage) خطاها (که معمولاً یک ماژول یا یک header file میباشد) است که در آن همه پیامهای مربوط به خطاها ذخیره میشوند و زمانی که شما در کد به خطایی برخورد میکنید، کافی است کدِ دارای خطا را به برخی function های مربوط به سیستم مدیریت خطای مرکزی ارسال کنید و سپس در ادامه این سیستم خودش باقی کار را برای شما انجام میدهد.
نکته مهم این رویکرد این است که شما همهچیز را در یک مکان دارید. برای مثال اگر بخواهید چیزی را در گزارش خطا تغییر دهید میدانید دقیقاً باید به کجا بروید. اما در مقابل، همهچیز در نرمافزار شما به این component بستگی دارد؛ مثلاً وقتی تصمیم به استفاده مجدد از برخی از کدها را داشته باشید باید کد مدیریت خطای (error handling code) مربوط به آن را نیز همراه آن در نظر بگیرید. همچنین همزمان رشد و توسعهی برنامهی شما، تعداد خطاهای آن نیز رشد خواهد کرد، که این موضوع میتواند منجر به ایجاد تعداد زیادی کد در یک مکان شود که در برابر خطاها بسیار آسیبپذیر هستند(زیرا هر کس میخواهد آن را ویرایش کند تا خطاهای خود را به آن اضافه کند).
اکنون آمادهی ساختن مؤلفه اصلی سیستم کنترل خطای Node.js یعنی مؤلفهی کنترل خطای متمرکز (centralized error-handling component) هستیم.
در اینجا workflow اصلی برای مقابله با خطاها را مشاهده میکنید:
در برخی از قسمتهای کد، خطاهای گرفتهشده به یک میانافزار مدیریت خطا (error-handling middleware) انتقال داده میشوند. میانافزار درواقع یک اصطلاح برای کدی است که بهعنوان واسط بین دو چیز نوشته میشود تا در هنگام اجرای این کد، کاری انجام شود. وظیفهی میانافزار معمولاً انجام فرآیندهای تکراری است تا در هر بار و هر جا که نیاز بود، کد مربوط به این فرآیندها مجدداً نوشته نشود. مثلاً کاربر وارد صفحاتی از سایت میشود که نیاز است login بودن او بررسی شود؛ در اینجا باید یک میانافزار نوشته شود تا در صفحات مختلف، فرایند بررسی login بودن کاربر را انجام دهد.
...
try {
userService.addNewUser(req.body).then((newUser: User) => {
res.status(200).json(newUser);
}).catch((error: Error) => {
next(error)
});
} catch (error) {
next(error);
}
...
میانافزار مدیریت خطا مکان مناسبی برای مشخص و جدا کردن انواع خطاها و ارسال آنها به مؤلفه کنترل خطای متمرکز است.
app.use(async (err: Error, req: Request, res: Response, next: NextFunction) => {
if (!errorHandler.isTrustedError(err)) {
next(err);
}
await errorHandler.handleError(err);
});
تا اینجا میتوان تصور کرد که مؤلفهی متمرکز چگونه باید به نظر برسد زیرا در قطعه کد بالا از برخی توابع آن (مثل isTrustedError و handleError) استفاده کردهایم. توجه داشته باشید که چگونگی اجرای آن کاملاً بستگی به شما دارد اما بهعنوان نمونه میتواند به شرح زیر باشد:
class ErrorHandler {
public async handleError(err: Error): Promise<void> {
await logger.error(
'Error message from the centralized error-handling component',
err,
);
await sendMailToAdminIfCritical();
await sendEventsToSentry();
}
public isTrustedError(error: Error) {
if (error instanceof BaseError) {
return error.isOperational;
}
return false;
}
}
export const errorHandler = new ErrorHandler();
گاهی اوقات خروجی پیشفرض "console.log" مدیریت خطاها را دشوار میکند، بنابراین بهتر است خطاها را به روشی فرمت شده پرینت کنید تا توسعهدهندگان بتوانند بهسرعت مشکلات به وجود آمده را درک کرده و از رفع آن مطمئن شوند. برای مثال میتوان به مواردی مثل: تخصیص رنگهای مختلف به هر نوع خطا، تغییر استایل نمایش خطاها در کنسول، دستهبندی خطاها در قسمتهای مختلف و مواردی از این قبیل اشاره کرد.
بهطورکلی این کار با بالا بردن میزان دیده شدن و شناسایی خطاها در زمانِ توسعهدهندگان صرفهجویی میکند و باعث میشود کنترل خطاها و رسیدگی به آنها آسانتر شود. تصمیم مناسبی که در اینجا میشود گرفت بهکارگیری یک logger قابل تنظیم مانند وینستون یا مورگان است.
در اینجا یک نمونه logger سفارشی وینستون را مشاهده میکنید:
const customLevels = {
levels: {
trace: 5,
debug: 4,
info: 3,
warn: 2,
error: 1,
fatal: 0,
},
colors: {
trace: 'white',
debug: 'green',
info: 'green',
warn: 'yellow',
error: 'red',
fatal: 'red',
},
};
const formatter = winston.format.combine(
winston.format.colorize(),
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.splat(),
winston.format.printf((info) => {
const { timestamp, level, message, ...meta } = info;
return `${timestamp} [${level}]: ${message} ${
Object.keys(meta).length ? JSON.stringify(meta, null, 2) : ''
}`;
}),
);
class Logger {
private logger: winston.Logger;
constructor() {
const prodTransport = new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
});
const transport = new winston.transports.Console({
format: formatter,
});
this.logger = winston.createLogger({
level: isDevEnvironment() ? 'trace' : 'error',
levels: customLevels.levels,
transports: [isDevEnvironment() ? transport : prodTransport],
});
winston.addColors(customLevels.colors);
}
trace(msg: any, meta?: any) {
this.logger.log('trace', msg, meta);
}
debug(msg: any, meta?: any) {
this.logger.debug(msg, meta);
}
info(msg: any, meta?: any) {
this.logger.info(msg, meta);
}
warn(msg: any, meta?: any) {
this.logger.warn(msg, meta);
}
error(msg: any, meta?: any) {
this.logger.error(msg, meta);
}
fatal(msg: any, meta?: any) {
this.logger.log('fatal', msg, meta);
}
}
export const logger = new Logger();
چیزی که در اصل این سیستم فراهم میکند نمایش انواع خطاها در دستهبندیهای مختلف است و همچنین با توجه به environment ای که پروژه در آن اجرا شده است (مثلاً زمان development یا production)، نوع نمایش خطاها نیز متفاوت خواهد بود.. نکته خوب آن، این است که با استفاده از API های داخلی Winston میتوانید logهای سیستم را بهصورت دستهبندیشده مشاهده و استعلام کنید. علاوه بر این ، میتوانید از یک ابزار آنالیز log برای تجزیهوتحلیل فایلهای log فرمت شده استفاده کنید تا اطلاعات مفیدی در مورد برنامه دریافت کنید.
تا این مرحله ما بیشتر در مورد خطاهای عملیاتی بحث کردیم. اما در مورد خطاهای برنامهنویس چه باید کرد؟ بهترین راه برای مقابله با این خطاها، راهاندازی مجدد (restart) برنامه با یک restarter خودکار مانند PM2 است؛ زیرا خطاهای برنامهنویس غیرمنتظره هستند و باگهای واقعیای هستند که ممکن است باعث شوند برنامه در یک حالت نادرست قرار بگیرد و رفتاری غیرمنتظره کند.
process.on('uncaughtException', (error: Error) => {
errorHandler.handleError(error);
if (!errorHandler.isTrustedError(error)) {
process.exit(1);
}
});
در آخر به نحوهی برخورد با rejection و exceptionهای مربوط به promiseها میپردازیم: شما ممکن است هنگام کار روی برنامههای Node.js یا Express وقت زیادی برای برخورد با promise ها صرف کنید. اما هنگامیکه فراموش میکنید به rejectionهای مربوط به promiseها رسیدگی و آنها را مدیریت کنید، دیدن پیامهای هشداردهنده(warning) در مورد این خطاها کار را برای شما ساده میکند. پیامهای هشدار بهجز log کردن، کار دیگری انجام نمیدهند اما بهتر است در مدیریت خطاها از یک fallback مناسب استفاده کنید و آن را در دو eventیا رویداد (unhandledRejection’, callback’) و (uncaughtException’, callback’) که در شئ process تعریف شده است بکار ببرید.
Process یک شئ عمومی (global object) است که در node.js قرار دارد و در هر جای برنامه قابلدسترسی است. هر اسکریپت Node.js بر روی یک process اجرا میشود و شما میتوانید از شیء process برای گرفتن اطلاعات در مورد node.js و برنامهی در حال اجرا و کنترل فرآیندهای فعلی آن استفاده کنید.
یک مثال ساده از مدیریت خطاهای promiseها میتواند به شرح زیر باشد:
// somewhere in the code
...
User.getUserById(1).then((firstUser) => {
if (firstUser.isSleeping === false) throw new Error('He is not sleeping!');
});
...
// get the unhandled rejection and throw it to another fallback handler we already have.
process.on('unhandledRejection', (reason: Error, promise: Promise<any>) => {
throw reason;
});
process.on('uncaughtException', (error: Error) => {
errorHandler.handleError(error);
if (!errorHandler.isTrustedError(error)) {
process.exit(1);
}
});
نتیجهگیری
درنهایت باید بدانید که مدیریت خطا یک بخش اضافی و اختیاری در برنامهنویسی نیست بلکه بخشی اساسی چه در مرحله توسعه(development) و چه در مرحله تولید(production) برنامه میباشد.
استراتژی مدیریت خطاها در یک کامپوننت واحد در Node.js باعث صرفهجویی در وقت توسعهدهندگان میشود و با جلوگیری از تکرار کد (بهصورت نامطلوب و اضافی) یا code duplication و جا افتادن متن خطاها، باعث نوشته شدن کد تمیز و صحیح و قابل نگهداری نیز میشود.