Verwenden von Express.js-Routen für Promise-basierte Fehlerbehandlung
Veröffentlicht: 2022-03-11Der Slogan von Express.js klingt wahr: Es ist ein „schnelles, unparteiisches, minimalistisches Web-Framework für Node.js“. Es ist so unüberlegt, dass Express.js trotz aktueller JavaScript-Best-Practices, die die Verwendung von Promises vorschreiben, standardmäßig keine Promise-basierten Route-Handler unterstützt.
Da viele Express.js-Tutorials dieses Detail auslassen, gewöhnen sich Entwickler oft daran, Code für das Senden von Ergebnissen und die Fehlerbehandlung für jede Route zu kopieren und einzufügen, wodurch technische Schulden entstehen. Wir können dieses Antimuster (und seine Auswirkungen) mit der Technik vermeiden, die wir heute behandeln werden – eine Technik, die ich erfolgreich in Apps mit Hunderten von Routen eingesetzt habe.
Typische Architektur für Express.js-Routen
Beginnen wir mit einer Express.js-Lernanwendung mit einigen Routen für ein Benutzermodell.
In echten Projekten würden wir die zugehörigen Daten in einer Datenbank wie MongoDB speichern. Aber für unsere Zwecke sind die Besonderheiten der Datenspeicherung unwichtig, also werden wir sie der Einfachheit halber verspotten. Was wir nicht vereinfachen werden, ist eine gute Projektstruktur, der Schlüssel zum halben Erfolg eines jeden Projekts.
Yeoman kann im Allgemeinen viel bessere Projektskelette liefern, aber für das, was wir brauchen, erstellen wir einfach ein Projektskelett mit Express-Generator und entfernen die unnötigen Teile, bis wir Folgendes haben:
bin start.js node_modules routes users.js services userService.js app.js package-lock.json package.jsonWir haben die Zeilen der verbleibenden Dateien, die nichts mit unseren Zielen zu tun haben, reduziert.
Hier ist die Hauptanwendungsdatei von Express.js, ./app.js :
const createError = require('http-errors'); const express = require('express'); const cookieParser = require('cookie-parser'); const usersRouter = require('./routes/users'); const app = express(); app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); app.use('/users', usersRouter); app.use(function(req, res, next) { next(createError(404)); }); app.use(function(err, req, res, next) { res.status(err.status || 500); res.send(err); }); module.exports = app; Hier erstellen wir eine Express.js-App und fügen einige grundlegende Middleware hinzu, um die Verwendung von JSON, die URL-Codierung und die Analyse von Cookies zu unterstützen. Wir fügen dann einen usersRouter für /users hinzu. Abschließend spezifizieren wir, was zu tun ist, wenn keine Route gefunden wird, und wie mit Fehlern umgegangen wird, was wir später ändern werden.
Das Skript zum Starten des Servers selbst ist /bin/start.js :
const app = require('../app'); const http = require('http'); const port = process.env.PORT || '3000'; const server = http.createServer(app); server.listen(port); Unser /package.json ist auch Barebones:
{ "name": "express-promises-example", "version": "0.0.0", "private": true, "scripts": { "start": "node ./bin/start.js" }, "dependencies": { "cookie-parser": "~1.4.4", "express": "~4.16.1", "http-errors": "~1.6.3" } } Verwenden wir eine typische Benutzer-Router-Implementierung in /routes/users.js :
const express = require('express'); const router = express.Router(); const userService = require('../services/userService'); router.get('/', function(req, res) { userService.getAll() .then(result => res.status(200).send(result)) .catch(err => res.status(500).send(err)); }); router.get('/:id', function(req, res) { userService.getById(req.params.id) .then(result => res.status(200).send(result)) .catch(err => res.status(500).send(err)); }); module.exports = router; Es hat zwei Routen: / zum Abrufen aller Benutzer und /:id zum Abrufen eines einzelnen Benutzers anhand der ID. Es verwendet auch /services/userService.js , das über Promise-basierte Methoden verfügt, um diese Daten abzurufen:
const users = [ {id: '1', fullName: 'User The First'}, {id: '2', fullName: 'User The Second'} ]; const getAll = () => Promise.resolve(users); const getById = (id) => Promise.resolve(users.find(u => u.id == id)); module.exports = { getById, getAll }; Hier haben wir die Verwendung eines tatsächlichen DB-Konnektors oder ORM (z. B. Mongoose oder Sequelize) vermieden und einfach das Abrufen von Daten mit Promise.resolve(...) .
Express.js-Routing-Probleme
Wenn wir uns unsere Route-Handler ansehen, sehen wir, dass jeder Dienstaufruf doppelte .then(...) und .catch(...) Callbacks verwendet, um Daten oder Fehler an den Client zurückzusenden.
Auf den ersten Blick mag das nicht seriös erscheinen. Lassen Sie uns einige grundlegende Anforderungen aus der realen Welt hinzufügen: Wir müssen nur bestimmte Fehler anzeigen und generische Fehler der Stufe 500 weglassen; Ob wir diese Logik anwenden oder nicht, muss auch von der Umgebung abhängen. Wie wird es also aussehen, wenn unser Beispielprojekt von seinen zwei Strecken zu einem realen Projekt mit 200 Strecken heranwächst?
Ansatz 1: Hilfsfunktionen
Vielleicht würden wir separate Hilfsfunktionen erstellen, um mit resolve und reject , und sie überall in unseren Express.js-Routen anwenden:
// some response handlers in /utils const handleResponse = (res, data) => res.status(200).send(data); const handleError = (res, err) => res.status(500).send(err); // routes/users.js router.get('/', function(req, res) { userService.getAll() .then(data => handleResponse(res, data)) .catch(err => handleError(res, err)); }); router.get('/:id', function(req, res) { userService.getById(req.params.id) .then(data => handleResponse(res, data)) .catch(err => handleError(res, err)); }); Sieht besser aus: Wir wiederholen unsere Implementierung des Sendens von Daten und Fehlern nicht. Aber wir müssen diese Handler trotzdem in jede Route importieren und sie jedem Promise hinzufügen, das an then() und catch() übergeben wird.
Ansatz 2: Middleware
Eine andere Lösung könnte darin bestehen, die Best Practices von Express.js rund um Promises zu verwenden: Verschieben Sie die Fehlersendelogik in die Fehler-Middleware von Express.js (hinzugefügt in app.js ) und übergeben Sie asynchrone Fehler mithilfe des next Callbacks an sie. Unser grundlegendes Fehler-Middleware-Setup würde eine einfache anonyme Funktion verwenden:
app.use(function(err, req, res, next) { res.status(err.status || 500); res.send(err); }); Express.js versteht, dass dies für Fehler gilt, da die Funktionssignatur vier Eingabeargumente hat. (Es nutzt die Tatsache, dass jedes Funktionsobjekt eine .length Eigenschaft hat, die beschreibt, wie viele Parameter die Funktion erwartet.)
Das Übergeben von Fehlern über next würde so aussehen:
// some response handlers in /utils const handleResponse = (res, data) => res.status(200).send(data); // routes/users.js router.get('/', function(req, res, next) { userService.getAll() .then(data => handleResponse(res, data)) .catch(next); }); router.get('/:id', function(req, res, next) { userService.getById(req.params.id) .then(data => handleResponse(res, data)) .catch(next); }); Selbst wenn wir den offiziellen Best-Practice-Leitfaden verwenden, benötigen wir immer noch unsere JS-Versprechen in jedem Route-Handler, um sie mit einer handleResponse() Funktion aufzulösen und abzulehnen, indem wir die next Funktion weitergeben.
Versuchen wir, das mit einem besseren Ansatz zu vereinfachen.
Ansatz 3: Promise-basierte Middleware
Eine der größten Eigenschaften von JavaScript ist seine dynamische Natur. Wir können jedem Objekt zur Laufzeit jedes Feld hinzufügen. Wir werden das verwenden, um Express.js-Ergebnisobjekte zu erweitern; Express.js-Middleware-Funktionen sind ein geeigneter Ort dafür.
Unsere promiseMiddleware() Funktion
Lassen Sie uns unsere vielversprechende Middleware erstellen, die uns die Flexibilität gibt, unsere Express.js-Routen eleganter zu strukturieren. Wir brauchen eine neue Datei, /middleware/promise.js :
const handleResponse = (res, data) => res.status(200).send(data); const handleError = (res, err = {}) => res.status(err.status || 500).send({error: err.message}); module.exports = function promiseMiddleware() { return (req,res,next) => { res.promise = (p) => { let promiseToResolve; if (p.then && p.catch) { promiseToResolve = p; } else if (typeof p === 'function') { promiseToResolve = Promise.resolve().then(() => p()); } else { promiseToResolve = Promise.resolve(p); } return promiseToResolve .then((data) => handleResponse(res, data)) .catch((e) => handleError(res, e)); }; return next(); }; } Wenden wir in app.js unsere Middleware auf das gesamte Express.js- app -Objekt an und aktualisieren das Standardfehlerverhalten:

const promiseMiddleware = require('./middlewares/promise'); //... app.use(promiseMiddleware()); //... app.use(function(req, res, next) { res.promise(Promise.reject(createError(404))); }); app.use(function(err, req, res, next) { res.promise(Promise.reject(err)); }); Beachten Sie, dass wir unsere Fehler-Middleware nicht auslassen . Es ist immer noch ein wichtiger Fehlerbehandler für alle synchronen Fehler, die in unserem Code vorkommen können. Aber anstatt die Fehler-Sendelogik zu wiederholen, übergibt die Fehler-Middleware jetzt alle synchronen Fehler an dieselbe zentrale handleError() Funktion über einen Promise.reject() Aufruf, der an res.promise() gesendet wird.
Dies hilft uns, synchrone Fehler wie diesen zu behandeln:
router.get('/someRoute', function(req, res){ throw new Error('This is synchronous error!'); }); Zum Schluss verwenden wir unser neues res.promise() in /routes/users.js :
const express = require('express'); const router = express.Router(); const userService = require('../services/userService'); router.get('/', function(req, res) { res.promise(userService.getAll()); }); router.get('/:id', function(req, res) { res.promise(() => userService.getById(req.params.id)); }); module.exports = router; Beachten Sie die verschiedenen Verwendungen von .promise() : Wir können ihm eine Funktion oder ein Promise übergeben. Das Übergeben von Funktionen kann Ihnen bei Methoden helfen, die keine Versprechungen haben; .promise() , dass es sich um eine Funktion handelt und verpackt sie in ein Promise.
Wo ist es besser, Fehler tatsächlich an den Client zu senden? Es ist eine gute Code-Organisationsfrage. Wir könnten das in unserer Error-Middleware (weil sie mit Fehlern arbeiten soll) oder in unserer Promise-Middleware (weil sie bereits mit unserem Response-Objekt interagiert) tun. Ich habe mich entschieden, alle Response-Operationen an einem Ort in unserer Promise-Middleware zu belassen, aber es liegt an jedem Entwickler, seinen eigenen Code zu organisieren.
Technisch gesehen ist res.promise() optional
Wir haben res.promise() hinzugefügt, aber wir sind nicht darauf festgelegt, es zu verwenden: Wir sind frei, mit dem Response-Objekt direkt zu arbeiten, wenn wir es brauchen. Schauen wir uns zwei Fälle an, in denen dies nützlich wäre: Umleiten und Stream-Piping.
Sonderfall 1: Umleitung
Angenommen, wir möchten Benutzer auf eine andere URL umleiten. Lassen Sie uns eine Funktion getUserProfilePicUrl() in userService.js :
const getUserProfilePicUrl = (id) => Promise.resolve(`/img/${id}`); Und jetzt verwenden wir es in unserem Benutzerrouter im async / await -Stil mit direkter Antwortmanipulation:
router.get('/:id/profilePic', async function (req, res) { try { const url = await userService.getUserProfilePicUrl(req.params.id); res.redirect(url); } catch (e) { res.promise(Promise.reject(e)); } }); Beachten Sie, wie wir async / await verwenden, die Umleitung durchführen und (am wichtigsten) immer noch einen zentralen Ort haben, um Fehler weiterzuleiten, da wir res.promise() für die Fehlerbehandlung verwendet haben.
Sonderfall 2: Stream Piping
Wie bei unserer Profilbildroute ist das Weiterleiten eines Streams eine weitere Situation, in der wir das Antwortobjekt direkt manipulieren müssen.
Um Anfragen an die URL zu verarbeiten, zu der wir jetzt umleiten, fügen wir eine Route hinzu, die ein generisches Bild zurückgibt.
Zuerst sollten wir profilePic.jpg in einem neuen Unterordner /assets/img hinzufügen. (In einem realen Projekt würden wir Cloud-Speicher wie AWS S3 verwenden, aber der Pipe-Mechanismus wäre derselbe.)
Lassen Sie uns dieses Bild als Antwort auf /img/profilePic/:id Anforderungen weiterleiten. Dafür müssen wir in /routes/img.js einen neuen Router erstellen:
const express = require('express'); const router = express.Router(); const fs = require('fs'); const path = require('path'); router.get('/:id', function(req, res) { /* Note that we create a path to the file based on the current working * directory, not the router file location. */ const fileStream = fs.createReadStream( path.join(process.cwd(), './assets/img/profilePic.png') ); fileStream.pipe(res); }); module.exports = router; Dann fügen wir unseren neuen /img -Router in app.js :
app.use('/users', require('./routes/users')); app.use('/img', require('./routes/img')); Ein Unterschied fällt wahrscheinlich im Vergleich zum Redirect-Fall auf: Wir haben res.promise() im /img -Router nicht verwendet! Dies liegt daran, dass das Verhalten eines bereits per Pipe geleiteten Antwortobjekts, bei dem ein Fehler übergeben wird, anders ist, als wenn der Fehler mitten im Stream auftritt.
Express.js-Entwickler müssen bei der Arbeit mit Streams in Express.js-Anwendungen darauf achten, Fehler je nach Auftreten unterschiedlich zu behandeln. Wir müssen Fehler vor dem Piping ( res.promise() kann uns dabei helfen) sowie während des Streams (basierend auf dem .on('error') Handler) behandeln, aber weitere Details würden den Rahmen dieses Artikels sprengen.
Verbesserung res.promise()
Wie beim Aufrufen res.promise() sind wir auch nicht daran gebunden, es so zu implementieren , wie wir es getan haben. promiseMiddleware.js kann erweitert werden, um einige Optionen in res.promise() zu akzeptieren, damit Aufrufer Antwortstatuscodes, Inhaltstypen oder alles andere angeben können, was ein Projekt erfordern könnte. Es liegt an den Entwicklern, ihre Tools zu gestalten und ihren Code so zu organisieren, dass er ihren Anforderungen am besten entspricht.
Express.js-Fehlerbehandlung trifft auf moderne Promise-basierte Codierung
Der hier vorgestellte Ansatz ermöglicht elegantere Route-Handler als wir begonnen haben, und dank der Fehlerbehandlung in app.js einen einzigen Punkt für die Verarbeitung von Ergebnissen und Fehlern – auch derjenigen, die außerhalb von res.promise(...) ausgelöst werden. Trotzdem sind wir nicht gezwungen , es zu verwenden, und können Grenzfälle nach Belieben verarbeiten.
Der vollständige Code dieser Beispiele ist auf GitHub verfügbar. Von dort aus können Entwickler der Funktion handleResponse() nach Bedarf benutzerdefinierte Logik hinzufügen, z. B. den Antwortstatus auf 204 statt auf 200 ändern, wenn keine Daten verfügbar sind.
Die zusätzliche Kontrolle über Fehler ist jedoch viel nützlicher. Dieser Ansatz hat mir geholfen, diese Funktionen präzise in der Produktion zu implementieren:
- Formatieren Sie alle Fehler konsistent als
{error: {message}} - Senden Sie eine allgemeine Nachricht, wenn kein Status bereitgestellt wird, oder geben Sie eine bestimmte Nachricht weiter
- Wenn die Umgebung
dev(odertestusw.) ist, füllen Sie das Felderror.stackaus - Behandeln Sie Datenbankindexfehler (dh eine Entität mit einem eindeutig indizierten Feld existiert bereits) und reagieren Sie elegant mit aussagekräftigen Benutzerfehlern
Diese Express.js-Routenlogik befand sich an einem Ort, ohne einen Dienst zu berühren – eine Entkopplung, durch die der Code viel einfacher zu warten und zu erweitern war. So können einfache – aber elegante – Lösungen die Projektstruktur drastisch verbessern.
Weiterführende Literatur im Toptal Engineering Blog:
- So erstellen Sie ein Node.js-Fehlerbehandlungssystem
