Come creare un sistema di gestione degli errori Node.js
Pubblicato: 2022-03-11Non è difficile vedere che alcune persone stanno lottando per gestire gli errori e alcuni addirittura lo mancano completamente. Gestire correttamente gli errori significa non solo ridurre i tempi di sviluppo trovando facilmente bug ed errori, ma anche sviluppare una solida base di codice per applicazioni su larga scala.
In particolare, gli sviluppatori di Node.js a volte si trovano a lavorare con codice non così pulito mentre gestiscono vari tipi di errori, applicando in modo errato la stessa logica ovunque per gestirli. Continuano a chiedersi "Node.js è pessimo nella gestione degli errori?" o se no, come gestirli?" La mia risposta è "No, Node.js non è affatto male. Dipende da noi sviluppatori".
Ecco una delle mie soluzioni preferite per questo.
Tipi di errori in Node.js
Prima di tutto, è necessario avere una chiara comprensione degli errori in Node.js. In generale, gli errori di Node.js sono divisi in due categorie distinte: errori operativi ed errori di programmazione .
- Gli errori operativi rappresentano problemi di runtime i cui risultati sono attesi e dovrebbero essere affrontati in modo corretto. Errori operativi non significano che l'applicazione stessa abbia dei bug, ma gli sviluppatori devono gestirli con attenzione. Esempi di errori operativi includono "memoria insufficiente", "un input non valido per un endpoint API" e così via.
- Gli errori del programmatore rappresentano bug imprevisti nel codice scritto male. Significano che il codice stesso ha alcuni problemi da risolvere ed è stato codificato in modo errato. Un buon esempio è provare a leggere una proprietà di "undefined". Per risolvere il problema, è necessario modificare il codice. Questo è un bug creato da uno sviluppatore, non un errore operativo.
Con questo in mente, non dovresti avere problemi a distinguere tra queste due categorie di errori: gli errori operativi sono una parte naturale di un'applicazione e gli errori del programmatore sono bug causati dagli sviluppatori. Una domanda logica che segue è: "Perché è utile dividerli in due categorie e affrontarli?"
Senza una chiara comprensione degli errori, potresti aver voglia di riavviare un'applicazione ogni volta che si verifica un errore. Ha senso riavviare un'applicazione a causa di errori "File non trovato" quando migliaia di utenti si stanno godendo l'applicazione? Assolutamente no.
Ma per quanto riguarda gli errori del programmatore? Ha senso mantenere un'applicazione in esecuzione quando viene visualizzato un bug sconosciuto che potrebbe causare un effetto valanga imprevisto nell'applicazione? Ancora una volta, decisamente no!
È tempo di gestire correttamente gli errori
Supponendo che tu abbia una certa esperienza con JavaScript asincrono e Node.js, potresti aver riscontrato degli svantaggi durante l'utilizzo dei callback per la gestione degli errori. Ti costringono a controllare gli errori fino a quelli annidati, causando famigerati problemi di "callback hell" che rendono difficile seguire il flusso del codice.
L'uso di promesse o async/await è un buon sostituto per i callback. Il flusso di codice tipico di async/await è simile al seguente:
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'uso dell'oggetto Error integrato in Node.js è una buona pratica perché include informazioni chiare e intuitive su errori come StackTrace, da cui la maggior parte degli sviluppatori fa affidamento per tenere traccia della radice di un errore. E ulteriori proprietà significative come il codice di stato HTTP e una descrizione estendendo la classe Error lo renderanno più informativo.
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); } }
Ho implementato alcuni codici di stato HTTP solo per motivi di semplicità, ma sei libero di aggiungerne altri in seguito.
export enum HttpStatusCode { OK = 200, BAD_REQUEST = 400, NOT_FOUND = 404, INTERNAL_SERVER = 500, }
Non è necessario estendere BaseError o APIError, ma va bene estenderlo per errori comuni in base alle proprie esigenze e preferenze personali.
class HTTP400Error extends BaseError { constructor(description = 'bad request') { super('NOT FOUND', HttpStatusCode.BAD_REQUEST, true, description); } }
Allora come lo usi? Basta inserire questo:
... const user = await User.getUserById(1); if (user === null) throw new APIError( 'NOT FOUND', HttpStatusCode.NOT_FOUND, true, 'detailed explanation' );
Gestione centralizzata degli errori di Node.js
Ora siamo pronti per creare il componente principale del nostro sistema di gestione degli errori Node.js: il componente centralizzato di gestione degli errori.
Di solito è una buona idea creare un componente centralizzato per la gestione degli errori per evitare possibili duplicazioni del codice durante la gestione degli errori. Il componente di gestione degli errori ha il compito di rendere comprensibili gli errori rilevati, ad esempio inviando notifiche agli amministratori di sistema (se necessario), trasferendo eventi a un servizio di monitoraggio come Sentry.io e registrandoli.
Ecco un flusso di lavoro di base per la gestione degli errori:
In alcune parti del codice, gli errori vengono rilevati per essere trasferiti a un middleware di gestione degli errori.
... try { userService.addNewUser(req.body).then((newUser: User) => { res.status(200).json(newUser); }).catch((error: Error) => { next(error) }); } catch (error) { next(error); } ...
Il middleware di gestione degli errori è un buon posto per distinguere tra i tipi di errore e inviarli al componente centralizzato di gestione degli errori. Conoscere le nozioni di base sulla gestione degli errori nel middleware di Express.js sarebbe sicuramente d'aiuto.

app.use(async (err: Error, req: Request, res: Response, next: NextFunction) => { if (!errorHandler.isTrustedError(err)) { next(err); } await errorHandler.handleError(err); });
Ormai si può immaginare come dovrebbe essere il componente centralizzato perché abbiamo già utilizzato alcune delle sue funzioni. Tieni presente che dipende totalmente da te come implementarlo, ma potrebbe apparire come segue:
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();
A volte, l'output del "console.log" predefinito rende difficile tenere traccia degli errori. Piuttosto, potrebbe essere molto meglio stampare gli errori in modo formattato in modo che gli sviluppatori possano comprendere rapidamente i problemi e assicurarsi che siano risolti.
Nel complesso, ciò consentirà agli sviluppatori di risparmiare tempo rendendo più semplice tenere traccia degli errori e gestirli aumentando la loro visibilità. È una buona decisione utilizzare un logger personalizzabile come Winston o Morgan.
Ecco un registratore winston personalizzato:
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();
Ciò che fornisce fondamentalmente è la registrazione a più livelli diversi in modo formattato, con colori chiari e l'accesso a diversi supporti di output in base all'ambiente di runtime. La cosa buona è che puoi guardare e interrogare i log usando le API integrate di Winston. Inoltre, puoi utilizzare uno strumento di analisi del registro per analizzare i file di registro formattati per ottenere informazioni più utili sull'applicazione. È fantastico, vero?
Fino a questo punto, abbiamo principalmente discusso della gestione degli errori operativi. Che ne dici di errori del programmatore? Il modo migliore per gestire questi errori è arrestare immediatamente in modo anomalo e riavviare correttamente con un riavvio automatico come PM2, il motivo è che gli errori del programmatore sono imprevisti, poiché sono veri e propri bug che potrebbero causare l'applicazione di uno stato errato e comportarsi in modo inaspettato.
process.on('uncaughtException', (error: Error) => { errorHandler.handleError(error); if (!errorHandler.isTrustedError(error)) { process.exit(1); } });
Ultimo ma non meno importante, menzionerò la gestione del rifiuto e delle eccezioni delle promesse non gestite.
Potresti ritrovarti a passare molto tempo a gestire le promesse quando lavori su applicazioni Node.js/Express. Non è difficile vedere messaggi di avviso sui rifiuti delle promesse non gestiti quando ci si dimentica di gestire i rifiuti.
I messaggi di avviso non fanno molto tranne la registrazione, ma è una buona pratica usare un fallback decente e iscriversi a process.on('unhandledRejection', callback)
.
Il tipico flusso di gestione degli errori potrebbe essere simile al seguente:
// 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); } });
Avvolgendo
Quando tutto è stato detto e fatto, dovresti renderti conto che la gestione degli errori non è un extra opzionale ma piuttosto una parte essenziale di un'applicazione, sia nella fase di sviluppo che in produzione.
La strategia di gestione degli errori in un singolo componente in Node.js garantirà agli sviluppatori di risparmiare tempo prezioso e di scrivere codice pulito e gestibile evitando la duplicazione del codice e il contesto di errore mancante.
Spero che ti sia piaciuto leggere questo articolo e che il flusso di lavoro e l'implementazione discussi per la gestione degli errori siano utili per creare una solida base di codice in Node.js.
Ulteriori letture sul blog di Toptal Engineering:
- Utilizzo dei percorsi Express.js per la gestione degli errori basata su promesse