Xử lý ERROR trong NodeJS sao cho đúng?

1

Bất kỳ dự án nào cũng đều phải có một phần dành riêng cho việc xử lý các lỗi. Rất nhiều bạn khi tham gia dự án, không biết cách “quy hoạch” mã nguồn cho phần xử lý các lỗi một cách khoa học, code được clean.

Nếu xử lý lỗi đúng cách, nó làm giảm thời gian phát triển ứng dụng, giúp code base dễ dàng mở rộng.

Với các bạn mới học Node.JS, chắc chắn sẽ cảm thấy Node.JS thật khó học, khó làm việc vì nó quá bừa bộn. Bạn thấy lỗi ở đâu thì xử lý luôn tại đó, không cần theo một quy tắc nào cả. Bạn thốt nên rằng “NodeJS thật là tồi tệ, dự án mà phức tạp hơn thì mọi chuyện sẽ đi về đâu với đống code này chứ?”.

Câu trả lời là “Không, NodeJS không tệ như bạn nghĩ đâu. NodeJS xấu hay tốt hoàn toàn phụ thuộc vào bạn.”

Bài viết này mình sẽ giới thiệu cách xử lý error trong NodeJS sao cho khoa học, clean code.

Những kiểu Error trong NodeJS

Trước khi đi vào phần chính, chúng ta cần phân biệt các kiểu ERROR trong NodeJS. Nói chung, error trong NodeJS được chia làm 2 loại:

  • Operational errors: là các vấn đề xảy ra trong quá trình ứng dụng hoạt động khi đầu vào không được bình thường. Thực ra đây không hẳn là lỗi của ứng dụng, chỉ là chúng ra phải xử lý chúng sao cho khéo, nếu không nó sẽ thành lỗi, gây ảnh hưởng trải nghiệm người dùng. Ví dụ: hết bộ nhớ lưu trữ, người dùng nhập dữ liệu không hợp lệ…
  • Programmer errors: đây là những lỗi không mong muốn xảy ra do chất lượng code không tốt. Một ví dụ điển hình là cố gắng access vào thuộc tính của đối tượng “undefined”. Đây là những lỗi do nhà phát triển gây ra, chứ không phải do người dùng hay môi trường.

Ứng xử với mỗi loại Error như thế nào?

Có thể bạn sẽ thắc mắc:”Tại sao chúng ta phải chia ra hai loại lỗi, trong khi lỗi nào cũng phải xử lý

Lý do cần phân biệt rõ hai loại lỗi này là để thay đổi tư duy khi xây dựng ứng dụng. Để việc xử lý lỗi được uyển chuyển, khéo léo hơn. Không phải lỗi nào xảy ra cũng yêu cầu khởi động lại ứng dụng.

Bạn thử nghĩ nếu lỗi “Không tìm thấy tệp” xảy ra và bạn yêu cầu khởi động lại ứng dụng? Liệu có hợp lý trong trường hợp này không? Câu trả lời đơn giản là: Không!

Vậy programmer errors thì sao? Bạn nên đối xử với nó như thế nào? Liệu có ổn khi bạn cố gắng tiếp tục chạy ứng dụng khi một unknow error xảy ra. Nếu cứ “cố đấm ăn xôi”, tiếp tục chạy ứng dụng sẽ dẫn tới hiệu ứng “quả cầu tuyết”, gây ra một loạt lỗi không mong muốn tiếp theo.

💦 Đọc thêm: Hướng dẫn tạo Secure REST API trong Node.js

Xử lý Error trong NodeJS đúng cách

Trong những bài viết trước của VNTALKING, mình có nhắc đến hạn chế của callback trong vấn đề xử lý error.

Callback bắt bạn phải kiểm tra và xử lý error trong từng lần gọi, nếu callback lồng nhau nhiều quá dẫn tới lỗi kinh điển “callback hell“.

Do vậy, sử dụng Promise hay Async/await là lựa chọn hoàn hảo.

Một ví dụ sử dụng 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();
 }
}

Sử dụng ngay đối tượng Error built-in đi kèm trong NodeJS là ý tưởng hay. Bởi vì nó tích hợp sẵn các thông tin trực quan và rõ ràng của lỗi như: StackTrace.

Bạn có thể thêm các thông tin hữu ích khác như HTTP status code, miêu tả lỗi… bằng cách extend lại Error class:

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);
 }
}

Trong ví dụ trên, mình chỉ thêm một vài HTTP status code để minh họa, bạn hoàn toàn có thể bổ sung thêm.

export enum HttpStatusCode {
 OK = 200,
 BAD_REQUEST = 400,
 NOT_FOUND = 404,
 INTERNAL_SERVER = 500,
}

Bạn không bắt buộc phải extend các class BaseError hay APIError. Nhưng nếu bạn thấy cần thiết thì có thể extend nó cho các common error theo nhu cầu và sở thích cá nhân của bạn.

class HTTP400Error extends BaseError {
 constructor(description = 'bad request') {
   super('NOT FOUND', HttpStatusCode.BAD_REQUEST, true, description);
 }
}

Đọc đến đây, bạn sẽ thắc mắc: vậy cách sử dụng các Error class trên như thế nào?

Đơn giản lắm, bạn chỉ cần throw nó như này:

...
const user = await User.getUserById(1);
if (user === null)
 throw new APIError(
   'NOT FOUND',
   HttpStatusCode.NOT_FOUND,
   true,
   'Giải thích chi tiết error'
 );

Node.js Error-handling tập trung

Như bạn thấy, nếu cứ gặp lỗi ở đâu mà xử lý ngay tại đoạn code đó sẽ gây trùng lặp code. Trong dự án, sẽ có rất nhiều đoạn code gây ra lỗi giống nhau. Và nếu bạn không tập trung hết tất cả phần xử lý lỗi vào một chỗ sẽ gây duplicate code.

Thông thường, các dự án lớn sẽ xây dựng một module riêng để chuyên xử lý lỗi. Thành phần xử lý lỗi này sẽ chịu trách nhiệm làm cho các lỗi có thể hiểu được bởi con người.

Ví dụ cho dễ hiểu, khi có lỗi xảy ra, nó sẽ gửi thông báo tới quản trị viên, chuyển các sự kiện tới  một dịch vụ giám sát hệ thống, ghi log lại thành file.v.v…

Workflow của Error Handling trong NodeJS

Dưới đây là workflow cơ bản xử lý lỗi:

workflow handle an error

Từ workflow trên, khi gặp lỗi bạn sẽ chuyển nó tới error-handling middleware trước:

...
try {
 userService.addNewUser(req.body).then((newUser: User) => {
   res.status(200).json(newUser);
 }).catch((error: Error) => {
   next(error)
 });
} catch (error) {
 next(error);
}
...

Error-handling middleware là nơi tốt nhất để phân loại các Error. Sau đó gửi chúng tới phần xử lý lỗi tập trung. Ở phần này, nếu bạn đã có kiến thức nhất định về middleware trong NodeJS thì thật tuyệt vời.

💦 Có ích cho bạn: Tài liệu học lập trình Node.js tiếng việt tốt nhất

app.use(async (err: Error, req: Request, res: Response, next: NextFunction) => {
 if (!errorHandler.isTrustedError(err)) {
   next(err);
 }
 await errorHandler.handleError(err);
});

Đọc đến đây, chắc hẳn bạn đã hình dung phần nào về phần xử lý Error tập trung rồi đúng không?

Hãy nhớ rằng, việc triển khai cụ thể sẽ hoàn toàn phụ thuộc vào bạn, vào yêu cầu của dự án. Dưới đây chỉ là một ví dụ:

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();

Sử dụng thư viện logger

Đôi khi, nếu bạn thích sự cầu toàn, muốn log được in ra có định dạng gọn gàng, thông tin chi tiết, màu sắc sặc sỡ… Lúc này console.log không đáp ứng được.

Lời khuyên là bạn nên sử dụng một thư viện hỗ trợ in log, cụ thể là winston hoặc morgan.

Đây là một ví dụ sử dụng winston logger và chỉnh sửa theo ý muốn:

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();

Về cơ bản, những thư viện logger như winston hay morgan sẽ hỗ trợ bạn ghi log ở nhiều cấp độ khác nhau (Info, Debug, warning, Error) theo cách được định dạng trước, với màu sắc rõ ràng.

Ưu điểm mà mình tâm đắc nhất đó là có thể dùng công cụ phân tích log để phân tích các tệp log được định dạng. Nhờ đó, bạn có thêm các thông tin hữu ích về ứng dụng. Ví dụ: tần suất sử dụng ứng dụng, tỉ lệ lỗi, lỗi nào xảy ra nhiều nhất…

Xử lý programmer errors

Cho đến thời điểm này, chúng ta mới chỉ nói tới cách xử lý các operational errors. Vậy còn programmer errors thì sao?

Cách xử lý tốt nhất là khi gặp programmer errors thì xử lý ngay lập tức, ngay tại đoạn code bị lỗi và khởi động lại ứng dụng một cách khéo léo bằng trình quản lý process như PM2.

Còn lý tại sao lại phải khởi động lại ứng dụng khi gặp programmer errors!? Mình đã đề cập ở trên rồi nhé.

Đây là một ví dụ:

process.on('uncaughtException', (error: Error) => {
 errorHandler.handleError(error);
 if (!errorHandler.isTrustedError(error)) {
   process.exit(1);
 }
});

Phần tiếp theo, cũng không kém phần quan trọng. Đó là chúng ta sẽ xử lý các trường hợp unhandled promise rejections.

Xử lý unhandled promise rejections

Khi làm việc với Promise trong NodeJS, có lẽ không ít lần bạn bắt gặp các lỗi liên quan tới unhandled promise rejections. Nguyên nhân là do bạn quên xử lý các rejections của Promise.

Như đã biết, mỗi Promise sẽ có 2 tham số: ResolveReject. Thường thì bạn code vội nên chỉ handle mỗi phần resolve thôi. Nhưng đến khi gặp lỗi thì Reject mới lòi ra, mà bạn lại không có code handle nó, thế là gặp lỗi thôi.

Có một ý tưởng khá hay để xử lý trường hợp này. Đó là sử dụng một callback hợp lệ và đăng ký process.on(‘unhandledRejection’, callback)

Dưới đây là một ví dụ:

// 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);
 }
});

Tóm lại

Sau tất cả, qua bài viết này mình chỉ muốn nhắn nhủ với bạn rằng: Xử lý error trong NodeJS không phải là một khâu tùy chọn. Mà nó là bắt buộc trong việc phát triển ứng dụng.

Chiến lược xử lý tập trung tất cả các lỗi ở một nơi duy nhất trong NodeJS sẽ đảm bảo nhà phát triển tiết kiệm thời gian, code clean, dễ bảo trì, tránh trùng lặp code và ít bị ăn “chửi” từ các reviewer 😰

Mình hi vọng, bài viết xử lý error trong NodeJS này có ích với bạn. Đừng nỡ lòng đọc xong mà không để lại một bình luận động viên 😰.

🔥 Đọc thêm về NodeJS:

Dịch vụ phát triển ứng dụng mobile giá rẻ - chất lượng

1
Bình luận. Cùng nhau thảo luận nhé!

avatar
  Theo dõi bình luận  
Mới nhất Cũ nhất Nhiều voted nhất
Thông báo
Tuấn
Guest
Tuấn

Cám ơn tác giả đã chia sẻ kinh nghiệm hay. trước mình cứ try catch tùm lum cả