Comment créer un système de gestion des erreurs Node.js

Publié: 2022-03-11

Il n'est pas difficile de voir que certaines personnes ont du mal à gérer les erreurs, et certaines le manquent même totalement. Gérer correctement les erreurs signifie non seulement réduire le temps de développement en trouvant facilement les bogues et les erreurs, mais aussi développer une base de code robuste pour les applications à grande échelle.

En particulier, les développeurs Node.js se retrouvent parfois à travailler avec du code pas si propre tout en gérant divers types d'erreurs, appliquant de manière incorrecte la même logique partout pour les traiter. Ils ne cessent de se demander "Est-ce que Node.js est mauvais pour gérer les erreurs ?" ou sinon, comment les gérer ? » Ma réponse est "Non, Node.js n'est pas mal du tout. Cela dépend de nous, les développeurs.

Voici une de mes solutions préférées pour cela.

Types d'erreurs dans Node.js

Tout d'abord, il est nécessaire d'avoir une compréhension claire des erreurs dans Node.js. En général, les erreurs Node.js sont divisées en deux catégories distinctes : les erreurs opérationnelles et les erreurs de programmation .

  • Les erreurs opérationnelles représentent des problèmes d'exécution dont les résultats sont attendus et doivent être traités de manière appropriée. Les erreurs opérationnelles ne signifient pas que l'application elle-même a des bogues, mais les développeurs doivent les gérer de manière réfléchie. Des exemples d'erreurs opérationnelles incluent "mémoire insuffisante", "une entrée non valide pour un point de terminaison d'API", etc.
  • Les erreurs du programmeur représentent des bogues inattendus dans un code mal écrit. Ils signifient que le code lui-même a des problèmes à résoudre et a été mal codé. Un bon exemple est d'essayer de lire une propriété "undefined". Pour résoudre le problème, le code doit être modifié. C'est un bogue qu'un développeur a fait, pas une erreur opérationnelle.

Dans cet esprit, vous ne devriez avoir aucun problème à faire la distinction entre ces deux catégories d'erreurs : les erreurs opérationnelles font naturellement partie d'une application et les erreurs de programmation sont des bogues causés par les développeurs. Une question logique qui suit est : « Pourquoi est-il utile de les diviser en deux catégories et de les traiter ? »

Sans une compréhension claire des erreurs, vous pourriez avoir envie de redémarrer une application chaque fois qu'une erreur se produit. Est-il judicieux de redémarrer une application en raison d'erreurs "Fichier introuvable" lorsque des milliers d'utilisateurs profitent de l'application ? Absolument pas.

Mais qu'en est-il des erreurs de programmation ? Est-il judicieux de maintenir une application en cours d'exécution lorsqu'un bogue inconnu apparaît qui pourrait entraîner un effet boule de neige inattendu dans l'application ? Encore une fois, certainement pas !

Il est temps de gérer correctement les erreurs

En supposant que vous ayez une certaine expérience avec JavaScript asynchrone et Node.js, vous pourriez avoir rencontré des inconvénients lors de l'utilisation de rappels pour traiter les erreurs. Ils vous obligent à vérifier les erreurs jusqu'aux erreurs imbriquées, provoquant des problèmes notoires d'"enfer de rappel" qui rendent difficile le suivi du flux de code.

L'utilisation de promesses ou async/wait est un bon remplacement pour les rappels. Le flux de code typique de async/wait ressemble à ceci :

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

L'utilisation de l'objet Error intégré de Node.js est une bonne pratique car il inclut des informations intuitives et claires sur les erreurs telles que StackTrace, dont dépendent la plupart des développeurs pour suivre la racine d'une erreur. Et des propriétés significatives supplémentaires comme le code d'état HTTP et une description en étendant la classe Error le rendront plus informatif.

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

J'ai seulement implémenté quelques codes d'état HTTP par souci de simplicité, mais vous êtes libre d'en ajouter d'autres plus tard.

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

Il n'est pas nécessaire d'étendre BaseError ou APIError, mais vous pouvez l'étendre pour les erreurs courantes en fonction de vos besoins et de vos préférences personnelles.

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

Alors, comment l'utilisez-vous? Jetez simplement ceci :

 ... const user = await User.getUserById(1); if (user === null) throw new APIError( 'NOT FOUND', HttpStatusCode.NOT_FOUND, true, 'detailed explanation' );

Gestion centralisée des erreurs Node.js

Nous sommes maintenant prêts à créer le composant principal de notre système de gestion des erreurs Node.js : le composant centralisé de gestion des erreurs.

C'est généralement une bonne idée de construire un composant centralisé de gestion des erreurs afin d'éviter d'éventuelles duplications de code lors de la gestion des erreurs. Le composant de gestion des erreurs est chargé de rendre compréhensibles les erreurs détectées, par exemple en envoyant des notifications aux administrateurs système (si nécessaire), en transférant les événements à un service de surveillance tel que Sentry.io et en les enregistrant.

Voici un flux de travail de base pour traiter les erreurs :

Gestion des erreurs dans Node.js : workflow de base

Dans certaines parties du code, les erreurs sont interceptées pour être transférées vers un middleware de gestion des erreurs.

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

Le middleware de gestion des erreurs est un bon endroit pour distinguer les types d'erreurs et les envoyer au composant centralisé de gestion des erreurs. Connaître les bases de la gestion des erreurs dans le middleware Express.js serait certainement utile.

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

À présent, on peut imaginer à quoi devrait ressembler le composant centralisé car nous avons déjà utilisé certaines de ses fonctions. Gardez à l'esprit que c'est à vous de décider comment l'implémenter, mais cela peut ressembler à ceci :

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

Parfois, la sortie du "console.log" par défaut rend difficile le suivi des erreurs. Au contraire, il pourrait être bien préférable d'imprimer les erreurs de manière formatée afin que les développeurs puissent rapidement comprendre les problèmes et s'assurer qu'ils sont corrigés.

Dans l'ensemble, cela fera gagner du temps aux développeurs, ce qui leur permettra de suivre facilement les erreurs et de les gérer en augmentant leur visibilité. C'est une bonne décision d'employer un enregistreur personnalisable comme Winston ou Morgan.

Voici un enregistreur Winston personnalisé :

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

Ce qu'il fournit essentiellement, c'est la journalisation à plusieurs niveaux différents de manière formatée, avec des couleurs claires, et la connexion à différents supports de sortie en fonction de l'environnement d'exécution. La bonne chose avec cela est que vous pouvez regarder et interroger les journaux en utilisant les API intégrées de Winston. De plus, vous pouvez utiliser un outil d'analyse de journal pour analyser les fichiers journaux formatés afin d'obtenir des informations plus utiles sur l'application. C'est génial, n'est-ce pas ?

Jusqu'à présent, nous avons surtout discuté de la gestion des erreurs opérationnelles. Qu'en est-il des erreurs de programmation ? La meilleure façon de gérer ces erreurs est de planter immédiatement et de redémarrer correctement avec un redémarrage automatique comme PM2, la raison étant que les erreurs du programmeur sont inattendues, car ce sont de véritables bogues qui pourraient entraîner l'application dans un mauvais état et se comporter d'une manière inattendue.

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

Enfin et surtout, je vais mentionner le traitement des rejets et des exceptions de promesses non gérées.

Vous pourriez passer beaucoup de temps à traiter des promesses lorsque vous travaillez sur des applications Node.js/Express. Il n'est pas difficile de voir des messages d'avertissement sur les rejets de promesses non gérées lorsque vous oubliez de gérer les rejets.

Les messages d'avertissement ne font pas grand-chose à part la journalisation, mais il est recommandé d'utiliser une solution de secours décente et de s'abonner à process.on('unhandledRejection', callback) .

Le flux typique de gestion des erreurs peut ressembler à ceci :

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

Emballer

En fin de compte, vous devez réaliser que la gestion des erreurs n'est pas un supplément facultatif mais plutôt une partie essentielle d'une application, à la fois en phase de développement et en production.

La stratégie de gestion des erreurs dans un seul composant dans Node.js permettra aux développeurs de gagner un temps précieux et d'écrire du code propre et maintenable en évitant la duplication de code et le contexte d'erreur manquant.

J'espère que vous avez apprécié la lecture de cet article et que vous avez trouvé le flux de travail et l'implémentation de gestion des erreurs discutés utiles pour créer une base de code robuste dans Node.js.


Lectures complémentaires sur le blog Toptal Engineering :

  • Utilisation des routes Express.js pour la gestion des erreurs basée sur les promesses