So erstellen Sie ein Node.js-Fehlerbehandlungssystem
Veröffentlicht: 2022-03-11Es ist nicht schwer zu erkennen, dass manche Leute Schwierigkeiten haben, mit Fehlern umzugehen, und manche es sogar völlig vermissen. Der richtige Umgang mit Fehlern bedeutet nicht nur, die Entwicklungszeit durch einfaches Auffinden von Bugs und Fehlern zu verkürzen, sondern auch eine robuste Codebasis für umfangreiche Anwendungen zu entwickeln.
Insbesondere Node.js-Entwickler arbeiten manchmal mit nicht so sauberem Code, während sie verschiedene Arten von Fehlern behandeln und fälschlicherweise überall dieselbe Logik anwenden, um sie zu behandeln. Sie fragen sich nur immer wieder: „Ist Node.js schlecht im Umgang mit Fehlern?“ oder wenn nicht, wie geht man damit um?“ Meine Antwort darauf lautet : „Nein, Node.js ist überhaupt nicht schlecht. Das hängt von uns Entwicklern ab.“
Hier ist eine meiner Lieblingslösungen dafür.
Arten von Fehlern in Node.js
Zunächst einmal ist es notwendig, ein klares Verständnis von Fehlern in Node.js zu haben. Im Allgemeinen werden Node.js-Fehler in zwei verschiedene Kategorien unterteilt: Betriebsfehler und Programmierfehler .
- Betriebsfehler stellen Laufzeitprobleme dar, deren Folgen erwartet und angemessen behandelt werden sollten. Betriebsfehler bedeuten nicht, dass die Anwendung selbst Fehler aufweist, aber Entwickler müssen sorgfältig damit umgehen. Beispiele für Betriebsfehler sind „nicht genügend Arbeitsspeicher“, „eine ungültige Eingabe für einen API-Endpunkt“ und so weiter.
- Programmierfehler stellen unerwartete Fehler in schlecht geschriebenem Code dar. Sie bedeuten, dass der Code selbst einige Probleme zu lösen hat und falsch codiert wurde. Ein gutes Beispiel ist der Versuch, eine Eigenschaft von „undefiniert“ zu lesen. Um das Problem zu beheben, muss der Code geändert werden. Das ist ein Fehler, den ein Entwickler gemacht hat, kein Betriebsfehler.
Vor diesem Hintergrund sollten Sie keine Probleme haben, zwischen diesen beiden Fehlerkategorien zu unterscheiden: Betriebsfehler sind ein natürlicher Bestandteil einer Anwendung, und Programmierfehler sind Fehler, die von Entwicklern verursacht werden. Eine logische Frage, die sich daran anschließt, lautet: „Warum ist es sinnvoll, sie in zwei Kategorien zu unterteilen und sich mit ihnen zu befassen?“
Ohne ein klares Verständnis von Fehlern möchten Sie möglicherweise eine Anwendung neu starten, wenn ein Fehler auftritt. Ist es sinnvoll, eine Anwendung aufgrund von „Datei nicht gefunden“-Fehlern neu zu starten, wenn Tausende von Benutzern die Anwendung genießen? Absolut nicht.
Aber was ist mit Programmierfehlern? Ist es sinnvoll, eine Anwendung am Laufen zu halten, wenn ein unbekannter Fehler auftritt, der zu einem unerwarteten Schneeballeffekt in der Anwendung führen könnte? Nochmals: definitiv nicht!
Es ist an der Zeit, mit Fehlern richtig umzugehen
Vorausgesetzt, Sie haben etwas Erfahrung mit asynchronem JavaScript und Node.js, sind Ihnen möglicherweise Nachteile bei der Verwendung von Rückrufen zur Behandlung von Fehlern aufgefallen. Sie zwingen Sie dazu, Fehler bis hin zu verschachtelten Fehlern zu überprüfen, was zu berüchtigten „Callback Hell“-Problemen führt, die es schwierig machen, dem Codefluss zu folgen.
Die Verwendung von Promises oder async/await ist ein guter Ersatz für Callbacks. Der typische Codefluss von async/await sieht wie folgt aus:
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(); } }
Die Verwendung des integrierten Error-Objekts von Node.j ist eine bewährte Methode, da es intuitive und klare Informationen zu Fehlern wie StackTrace enthält, auf die sich die meisten Entwickler verlassen, um die Ursache eines Fehlers zu verfolgen. Und zusätzliche aussagekräftige Eigenschaften wie HTTP-Statuscode und eine Beschreibung durch Erweiterung der Error-Klasse machen es informativer.
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); } }
Ich habe nur der Einfachheit halber einige HTTP-Statuscodes implementiert, aber Sie können später gerne weitere hinzufügen.
export enum HttpStatusCode { OK = 200, BAD_REQUEST = 400, NOT_FOUND = 404, INTERNAL_SERVER = 500, }
Es besteht keine Notwendigkeit, BaseError oder APIError zu erweitern, aber es ist in Ordnung, es für häufige Fehler entsprechend Ihren Bedürfnissen und persönlichen Vorlieben zu erweitern.
class HTTP400Error extends BaseError { constructor(description = 'bad request') { super('NOT FOUND', HttpStatusCode.BAD_REQUEST, true, description); } }
Wie benutzt man es? Einfach das hier einwerfen:
... const user = await User.getUserById(1); if (user === null) throw new APIError( 'NOT FOUND', HttpStatusCode.NOT_FOUND, true, 'detailed explanation' );
Zentralisierte Node.js-Fehlerbehandlung
Jetzt sind wir bereit, die Hauptkomponente unseres Node.js-Fehlerbehandlungssystems zu erstellen: die zentralisierte Fehlerbehandlungskomponente.
Es ist normalerweise eine gute Idee, eine zentralisierte Fehlerbehandlungskomponente zu erstellen, um mögliche Codeduplizierungen bei der Fehlerbehandlung zu vermeiden. Die Fehlerbehandlungskomponente ist dafür zuständig, die abgefangenen Fehler verständlich zu machen, indem sie beispielsweise Benachrichtigungen an Systemadministratoren sendet (falls erforderlich), Ereignisse an einen Überwachungsdienst wie Sentry.io überträgt und sie protokolliert.
Hier ist ein grundlegender Arbeitsablauf für den Umgang mit Fehlern:
In einigen Teilen des Codes werden Fehler abgefangen, um sie an eine Fehlerbehandlungs-Middleware zu übertragen.
... try { userService.addNewUser(req.body).then((newUser: User) => { res.status(200).json(newUser); }).catch((error: Error) => { next(error) }); } catch (error) { next(error); } ...
Die Fehlerbehandlungs-Middleware ist ein guter Ort, um zwischen Fehlertypen zu unterscheiden und sie an die zentralisierte Fehlerbehandlungskomponente zu senden. Die Kenntnis der Grundlagen zur Behandlung von Fehlern in Express.js-Middleware wäre sicherlich hilfreich.

app.use(async (err: Error, req: Request, res: Response, next: NextFunction) => { if (!errorHandler.isTrustedError(err)) { next(err); } await errorHandler.handleError(err); });
Mittlerweile kann man sich vorstellen, wie die zentrale Komponente aussehen soll, da wir einige ihrer Funktionen bereits genutzt haben. Denken Sie daran, dass es ganz Ihnen überlassen ist, wie Sie es implementieren, aber es könnte wie folgt aussehen:
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();
Manchmal macht es die Ausgabe der Standarddatei „console.log“ schwierig, den Überblick über Fehler zu behalten. Vielmehr könnte es viel besser sein, Fehler formatiert zu drucken, damit Entwickler die Probleme schnell verstehen und sicherstellen können, dass sie behoben werden.
Insgesamt wird dies den Entwicklern Zeit sparen, da es einfacher wird, Fehler im Auge zu behalten und sie zu behandeln, indem ihre Sichtbarkeit erhöht wird. Es ist eine gute Entscheidung, einen anpassbaren Logger wie Winston oder Morgan zu verwenden.
Hier ist ein angepasster Winston-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();
Was es im Wesentlichen bietet, ist eine formatierte Protokollierung auf mehreren verschiedenen Ebenen mit klaren Farben und eine Protokollierung auf verschiedenen Ausgabemedien entsprechend der Laufzeitumgebung. Das Gute daran ist, dass Sie Protokolle mithilfe der integrierten APIs von winston überwachen und abfragen können. Darüber hinaus können Sie ein Protokollanalysetool verwenden, um die formatierten Protokolldateien zu analysieren, um nützlichere Informationen über die Anwendung zu erhalten. Es ist großartig, nicht wahr?
Bisher haben wir hauptsächlich über den Umgang mit Bedienungsfehlern gesprochen. Wie sieht es mit Programmierfehlern aus? Der beste Weg, mit diesen Fehlern umzugehen, besteht darin, sofort abzustürzen und mit einem automatischen Neustarter wie PM2 ordnungsgemäß neu zu starten. Der Grund dafür ist, dass Programmierfehler unerwartet sind, da es sich um tatsächliche Fehler handelt, die dazu führen können, dass die Anwendung in einen falschen Zustand gerät und sich verhält auf unerwartete Weise.
process.on('uncaughtException', (error: Error) => { errorHandler.handleError(error); if (!errorHandler.isTrustedError(error)) { process.exit(1); } });
Zu guter Letzt werde ich den Umgang mit unbehandelten Versprechensablehnungen und Ausnahmen erwähnen.
Möglicherweise verbringen Sie bei der Arbeit an Node.js/Express-Anwendungen viel Zeit mit dem Umgang mit Versprechungen. Es ist nicht schwer, Warnmeldungen über unbehandelte Ablehnungen von Promises zu sehen, wenn Sie vergessen, Ablehnungen zu behandeln.
Die Warnmeldungen bewirken außer der Protokollierung nicht viel, aber es empfiehlt sich, einen anständigen Fallback zu verwenden und process.on('unhandledRejection', callback)
zu abonnieren.
Der typische Fehlerbehandlungsablauf könnte wie folgt aussehen:
// 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); } });
Einpacken
Letztendlich sollten Sie sich darüber im Klaren sein, dass die Fehlerbehandlung kein optionales Extra ist, sondern ein wesentlicher Bestandteil einer Anwendung, sowohl in der Entwicklungsphase als auch in der Produktion.
Die Strategie, Fehler in einer einzigen Komponente in Node.js zu behandeln, stellt sicher, dass Entwickler wertvolle Zeit sparen und sauberen und wartbaren Code schreiben, indem Codeduplizierung und fehlender Fehlerkontext vermieden werden.
Ich hoffe, Ihnen hat das Lesen dieses Artikels gefallen und Sie fanden den besprochenen Fehlerbehandlungs-Workflow und die Implementierung hilfreich für den Aufbau einer robusten Codebasis in Node.js.
Weiterführende Literatur im Toptal Engineering Blog:
- Verwenden von Express.js-Routen für Promise-basierte Fehlerbehandlung