Utilisation des routes Express.js pour la gestion des erreurs basée sur les promesses
Publié: 2022-03-11Le slogan d'Express.js sonne vrai : il s'agit d'un "framework Web rapide, sans opinion et minimaliste pour Node.js". C'est tellement sans opinion que, malgré les meilleures pratiques JavaScript actuelles prescrivant l'utilisation de promesses, Express.js ne prend pas en charge les gestionnaires de route basés sur des promesses par défaut.
Avec de nombreux didacticiels Express.js omettant ce détail, les développeurs ont souvent l'habitude de copier et coller le code d'envoi des résultats et de gestion des erreurs pour chaque itinéraire, créant ainsi une dette technique au fur et à mesure. Nous pouvons éviter cet anti-modèle (et ses retombées) avec la technique que nous allons couvrir aujourd'hui, celle que j'ai utilisée avec succès dans des applications avec des centaines de routes.
Architecture typique pour les routes Express.js
Commençons par une application de didacticiel Express.js avec quelques itinéraires pour un modèle utilisateur.
Dans de vrais projets, nous stockons les données associées dans une base de données comme MongoDB. Mais pour nos besoins, les spécificités du stockage des données ne sont pas importantes, nous allons donc les simuler par souci de simplicité. Ce que nous ne simplifierons pas, c'est une bonne structure de projet, la clé de la moitié du succès de tout projet.
Yeoman peut produire de bien meilleurs squelettes de projet en général, mais pour ce dont nous avons besoin, nous allons simplement créer un squelette de projet avec express-generator et supprimer les parties inutiles, jusqu'à ce que nous ayons ceci :
bin start.js node_modules routes users.js services userService.js app.js package-lock.json package.jsonNous avons réduit les lignes des fichiers restants qui ne sont pas liés à nos objectifs.
Voici le fichier principal de l'application 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; Ici, nous créons une application Express.js et ajoutons un middleware de base pour prendre en charge l'utilisation de JSON, l'encodage d'URL et l'analyse des cookies. Nous ajoutons ensuite un usersRouter pour /users . Enfin, nous spécifions ce qu'il faut faire si aucune route n'est trouvée, et comment gérer les erreurs, ce que nous modifierons plus tard.
Le script pour démarrer le serveur lui-même est /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); Notre /package.json est aussi barebone :
{ "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" } } Utilisons une implémentation de routeur utilisateur typique dans /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; Il a deux routes : / pour obtenir tous les utilisateurs et /:id pour obtenir un seul utilisateur par ID. Il utilise également /services/userService.js , qui a des méthodes basées sur des promesses pour obtenir ces données :
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 }; Ici, nous avons évité d'utiliser un véritable connecteur DB ou ORM (par exemple, Mongoose ou Sequelize), en imitant simplement la récupération de données avec Promise.resolve(...) .
Problèmes de routage Express.js
En regardant nos gestionnaires de routage, nous constatons que chaque appel de service utilise des .then(...) et .catch(...) en double pour renvoyer des données ou des erreurs au client.
À première vue, cela peut ne pas sembler sérieux. Ajoutons quelques exigences de base du monde réel : nous n'aurons besoin d'afficher que certaines erreurs et d'omettre les erreurs génériques de niveau 500 ; aussi, que nous appliquions ou non cette logique doit être basé sur l'environnement. Avec cela, à quoi cela ressemblera-t-il lorsque notre exemple de projet passera de ses deux itinéraires à un véritable projet avec 200 itinéraires ?
Approche 1 : Fonctions utilitaires
Peut-être pourrions-nous créer des fonctions utilitaires distinctes pour gérer resolve et reject , et les appliquer partout dans nos itinéraires Express.js :
// 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)); }); Ça a l'air mieux : nous ne répétons pas notre mise en œuvre de l'envoi de données et d'erreurs. Mais nous devrons toujours importer ces gestionnaires dans chaque route et les ajouter à chaque promesse transmise à then() et catch() .
Approche 2 : middleware
Une autre solution pourrait consister à utiliser les meilleures pratiques d'Express.js autour des promesses : déplacer la logique d'envoi d'erreurs dans le middleware d'erreur Express.js (ajouté dans app.js ) et lui transmettre les erreurs asynchrones à l'aide du rappel next . Notre configuration de base du middleware d'erreur utiliserait une simple fonction anonyme :
app.use(function(err, req, res, next) { res.status(err.status || 500); res.send(err); }); Express.js comprend qu'il s'agit d'erreurs car la signature de la fonction a quatre arguments d'entrée. (Il tire parti du fait que chaque objet fonction a une propriété .length décrivant le nombre de paramètres attendus par la fonction.)
Passer des erreurs via next ressemblerait à ceci :
// 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); }); Même en utilisant le guide officiel des meilleures pratiques, nous avons toujours besoin de nos promesses JS dans chaque gestionnaire de route pour résoudre à l'aide d'une fonction handleResponse() et rejeter en passant la fonction next .
Essayons de simplifier cela avec une meilleure approche.
Approche 3 : Intergiciel basé sur les promesses
L'une des plus grandes caractéristiques de JavaScript est sa nature dynamique. Nous pouvons ajouter n'importe quel champ à n'importe quel objet lors de l'exécution. Nous l'utiliserons pour étendre les objets de résultat Express.js ; Les fonctions du middleware Express.js sont un endroit pratique pour le faire.
Notre fonction promiseMiddleware()
Créons notre middleware de promesse, qui nous donnera la flexibilité de structurer nos itinéraires Express.js de manière plus élégante. Nous aurons besoin d'un nouveau fichier, /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(); }; } Dans app.js , appliquons notre middleware à l'ensemble de l'objet app Express.js et mettons à jour le comportement d'erreur par défaut :

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)); }); Notez que nous n'omettons pas notre middleware d'erreur . C'est toujours un gestionnaire d'erreurs important pour toutes les erreurs synchrones qui peuvent exister dans notre code. Mais au lieu de répéter la logique d'envoi d'erreurs, le middleware d'erreur transmet désormais toutes les erreurs synchrones à la même fonction centrale handleError() via un appel Promise.reject() envoyé à res.promise() .
Cela nous aide à gérer les erreurs synchrones telles que celle-ci :
router.get('/someRoute', function(req, res){ throw new Error('This is synchronous error!'); }); Enfin, utilisons notre nouveau res.promise() dans /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; Notez les différentes utilisations de .promise() : On peut lui passer une fonction ou une promesse. Passer des fonctions peut vous aider avec des méthodes qui n'ont pas de promesses ; .promise() voit qu'il s'agit d'une fonction et l'enveloppe dans une promesse.
Où vaut-il mieux envoyer les erreurs au client ? C'est une bonne question d'organisation du code. Nous pourrions le faire dans notre middleware d'erreur (car il est censé fonctionner avec des erreurs) ou dans notre middleware de promesse (car il a déjà des interactions avec notre objet de réponse). J'ai décidé de conserver toutes les opérations de réponse au même endroit dans notre middleware de promesse, mais c'est à chaque développeur d'organiser son propre code.
Techniquement, res.promise() est facultatif
Nous avons ajouté res.promise() , mais nous ne sommes pas obligés de l'utiliser : nous sommes libres d'utiliser l'objet de réponse directement lorsque nous en avons besoin. Examinons deux cas où cela serait utile : la redirection et la canalisation de flux.
Cas particulier 1 : redirection
Supposons que nous voulions rediriger les utilisateurs vers une autre URL. Ajoutons une fonction getUserProfilePicUrl() dans userService.js :
const getUserProfilePicUrl = (id) => Promise.resolve(`/img/${id}`); Et maintenant, utilisons-le dans notre routeur d'utilisateurs dans le style async / await avec manipulation de réponse directe :
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)); } }); Notez comment nous utilisons async / await , effectuons la redirection et (surtout) avons toujours un emplacement central pour transmettre toute erreur car nous avons utilisé res.promise() pour la gestion des erreurs.
Cas particulier 2 : canalisation de flux
Comme notre route d'image de profil, diriger un flux est une autre situation où nous devons manipuler directement l'objet de réponse.
Pour gérer les requêtes vers l'URL vers laquelle nous redirigeons maintenant, ajoutons une route qui renvoie une image générique.
Nous devons d'abord ajouter profilePic.jpg dans un nouveau sous-dossier /assets/img . (Dans un projet réel, nous utiliserions un stockage cloud comme AWS S3, mais le mécanisme de canalisation serait le même.)
Canalisons cette image en réponse aux requêtes /img/profilePic/:id . Nous devons créer un nouveau routeur pour cela dans /routes/img.js :
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; Ensuite, nous ajoutons notre nouveau routeur /img dans app.js :
app.use('/users', require('./routes/users')); app.use('/img', require('./routes/img')); Une différence ressort probablement par rapport au cas de redirection : nous n'avons pas utilisé res.promise() dans le routeur /img ! Cela est dû au fait que le comportement d'un objet de réponse déjà canalisé qui reçoit une erreur sera différent que si l'erreur se produit au milieu du flux.
Les développeurs Express.js doivent faire attention lorsqu'ils travaillent avec des flux dans les applications Express.js, en gérant les erreurs différemment selon le moment où elles se produisent. Nous devons gérer les erreurs avant la canalisation ( res.promise() peut nous y aider) ainsi qu'à mi-chemin (basé sur le gestionnaire .on('error') ), mais d'autres détails dépassent le cadre de cet article.
Amélioration res.promise()
Comme pour l'appel de res.promise() , nous ne sommes pas obligés de l' implémenter comme nous l'avons fait non plus. promiseMiddleware.js peut être augmenté pour accepter certaines options dans res.promise() pour permettre aux appelants de spécifier les codes d'état de réponse, le type de contenu ou toute autre chose qu'un projet pourrait exiger. C'est aux développeurs de façonner leurs outils et d'organiser leur code pour qu'il réponde au mieux à leurs besoins.
La gestion des erreurs Express.js rencontre le codage moderne basé sur les promesses
L'approche présentée ici permet des gestionnaires de route plus élégants que ceux avec lesquels nous avons commencé et un point unique de traitement des résultats et des erreurs - même ceux déclenchés en dehors de res.promise(...) - grâce à la gestion des erreurs dans app.js . Pourtant, nous ne sommes pas obligés de l'utiliser et pouvons traiter les cas extrêmes comme nous le souhaitons.
Le code complet de ces exemples est disponible sur GitHub. À partir de là, les développeurs peuvent ajouter une logique personnalisée selon les besoins à la fonction handleResponse() , comme changer le statut de la réponse en 204 au lieu de 200 si aucune donnée n'est disponible.
Cependant, le contrôle supplémentaire sur les erreurs est beaucoup plus utile. Cette approche m'a aidé à implémenter de manière concise ces fonctionnalités en production :
- Formater toutes les erreurs de manière cohérente comme
{error: {message}} - Envoyer un message générique si aucun statut n'est fourni ou transmettre un message donné dans le cas contraire
- Si l'environnement est
dev(outest, etc.), remplissez le champerror.stack - Gérer les erreurs d'index de base de données (c'est-à-dire qu'une entité avec un champ indexé unique existe déjà) et répondre avec élégance avec des erreurs utilisateur significatives
Cette logique de routage Express.js se trouvait au même endroit, sans toucher à aucun service, un découplage qui a rendu le code beaucoup plus facile à maintenir et à étendre. C'est ainsi que des solutions simples, mais élégantes, peuvent considérablement améliorer la structure du projet.
Lectures complémentaires sur le blog Toptal Engineering :
- Comment créer un système de gestion des erreurs Node.js
