Utilizarea rutelor Express.js pentru gestionarea erorilor bazată pe promisiuni

Publicat: 2022-03-11

Sloganul Express.js sună adevărat: este un „cadru web rapid, fără păreri, minimalist pentru Node.js”. Este atât de lipsit de părere încât, în ciuda celor mai bune practici JavaScript actuale care prescriu utilizarea promisiunilor, Express.js nu acceptă în mod implicit gestionarea rutelor bazate pe promisiuni.

Cu multe tutoriale Express.js care omit acest detaliu, dezvoltatorii se obișnuiesc adesea să copieze și să lipească codul de trimitere a rezultatelor și de tratare a erorilor pentru fiecare rută, creând datorii tehnice pe măsură ce merg. Putem evita acest antimodel (și consecințele sale) cu tehnica pe care o vom acoperi astăzi - una pe care am folosit-o cu succes în aplicații cu sute de rute.

Arhitectură tipică pentru rutele Express.js

Să începem cu o aplicație tutorial Express.js cu câteva rute pentru un model de utilizator.

În proiectele reale, am stoca datele aferente într-o bază de date precum MongoDB. Dar, pentru scopurile noastre, specificul stocării datelor nu este important, așa că le vom bate joc de dragul simplității. Ceea ce nu vom simplifica este o structură bună a proiectului, cheia pentru jumătate din succesul oricărui proiect.

Yeoman poate produce schelete de proiect mult mai bune în general, dar pentru ceea ce avem nevoie, vom crea pur și simplu un schelet de proiect cu generator expres și vom elimina părțile inutile, până când vom avea asta:

 bin start.js node_modules routes users.js services userService.js app.js package-lock.json package.json

Am redus liniile fișierelor rămase care nu au legătură cu obiectivele noastre.

Iată fișierul principal al aplicației 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;

Aici creăm o aplicație Express.js și adăugăm niște middleware de bază pentru a sprijini utilizarea JSON, codificarea URL și analiza cookie-urilor. Apoi adăugăm un usersRouter pentru /users . În cele din urmă, specificăm ce să facem dacă nu este găsită nicio rută și cum să gestionăm erorile, pe care le vom modifica ulterior.

Scriptul pentru a porni serverul în sine este /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);

/package.json nostru este, de asemenea, 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" } }

Să folosim o implementare tipică a unui router de utilizator în /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;

Are două rute: / pentru a obține toți utilizatorii și /:id pentru a obține un singur utilizator prin ID. De asemenea, folosește /services/userService.js , care are metode bazate pe promisiuni pentru a obține aceste date:

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

Aici am evitat să folosim un conector DB real sau ORM (de exemplu, Mongoose sau Sequelize), pur și simplu imitând preluarea datelor cu Promise.resolve(...) .

Probleme de rutare Express.js

Privind la gestionatorii noștri de rută, vedem că fiecare apel de serviciu folosește apeluri duplicate .then(...) și .catch(...) pentru a trimite date sau erori înapoi către client.

La prima vedere, acest lucru poate să nu pară grav. Să adăugăm câteva cerințe de bază din lumea reală: va trebui să afișăm doar anumite erori și să omitem erorile generice de nivel 500; de asemenea, dacă aplicăm sau nu această logică trebuie să se bazeze pe mediu. Cu asta, cum va arăta când proiectul nostru exemplu va crește de la cele două rute ale sale într-un proiect real cu 200 de rute?

Abordarea 1: Funcții utilitare

Poate că am crea funcții de utilitate separate pentru a gestiona resolve și reject și le-am aplica peste tot în rutele noastre 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)); });

Arată mai bine: nu repetăm ​​implementarea noastră de trimitere a datelor și a erorilor. Dar va trebui totuși să importam acești handlere în fiecare rută și să le adăugăm la fiecare promisiunea transmisă la then() și catch() .

Abordarea 2: Middleware

O altă soluție ar putea fi să folosiți cele mai bune practici Express.js în jurul promisiunilor: mutați logica de trimitere a erorilor în programul intermediar de eroare Express.js (adăugat în app.js ) și transmiteți-i erorile asincrone folosind next apel invers. Configurarea noastră de bază pentru middleware de eroare ar folosi o funcție anonimă simplă:

 app.use(function(err, req, res, next) { res.status(err.status || 500); res.send(err); });

Express.js înțelege că acest lucru este pentru erori, deoarece semnătura funcției are patru argumente de intrare. (Profită de faptul că fiecare obiect funcție are o proprietate .length care descrie câți parametri așteaptă funcția.)

Trecerea erorilor prin next ar arăta astfel:

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

Chiar și folosind ghidul oficial de bune practici, avem nevoie de promisiunile noastre JS în fiecare handler de rută pentru a le rezolva folosind o handleResponse() și a respinge prin transmiterea next funcții.

Să încercăm să simplificăm asta cu o abordare mai bună.

Abordarea 3: Middleware bazat pe promisiuni

Una dintre cele mai mari caracteristici ale JavaScript este natura sa dinamică. Putem adăuga orice câmp la orice obiect în timpul execuției. Vom folosi asta pentru a extinde obiectele rezultat Express.js; Funcțiile middleware Express.js sunt un loc convenabil pentru a face acest lucru.

Promisiunea noastră promiseMiddleware() .

Să creăm middleware-ul nostru promit, care ne va oferi flexibilitatea de a ne structura rutele Express.js mai elegant. Vom avea nevoie de un fișier nou, /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(); }; }

În app.js , să aplicăm middleware-ul nostru la obiectul general al app Express.js și să actualizăm comportamentul de eroare implicit:

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

Rețineți că nu omitem middleware-ul nostru de eroare . Este încă un instrument de gestionare a erorilor important pentru toate erorile sincrone care pot exista în codul nostru. Dar, în loc să repete logica de trimitere a erorilor, middleware-ul de eroare transmite acum orice erori sincrone aceleiași funcții centrale handleError() printr-un apel Promise.reject() trimis către res.promise() .

Acest lucru ne ajută să gestionăm erorile sincrone precum aceasta:

 router.get('/someRoute', function(req, res){ throw new Error('This is synchronous error!'); });

În cele din urmă, să folosim noul nostru res.promise() în /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;

Observați diferitele utilizări ale .promise() : Îi putem transmite o funcție sau o promisiune. Funcțiile de trecere vă pot ajuta cu metode care nu au promisiuni; .promise() vede că este o funcție și o înglobează într-o promisiune.

Unde este mai bine să trimiteți erorile către client? Este o întrebare bună de organizare a codului. Am putea face asta în middleware-ul nostru de eroare (pentru că ar trebui să funcționeze cu erori) sau în middleware-ul nostru de promisiune (pentru că are deja interacțiuni cu obiectul nostru de răspuns). Am decis să păstrez toate operațiunile de răspuns într-un singur loc în middleware-ul nostru promis, dar depinde de fiecare dezvoltator să își organizeze propriul cod.

Din punct de vedere tehnic, res.promise() este opțional

Am adăugat res.promise() , dar nu suntem blocați să-l folosim: suntem liberi să operam cu obiectul răspuns direct atunci când avem nevoie. Să ne uităm la două cazuri în care acest lucru ar fi util: redirecționarea și canalizarea fluxului.

Caz special 1: Redirecționare

Să presupunem că vrem să redirecționăm utilizatorii către o altă adresă URL. Să adăugăm o funcție getUserProfilePicUrl() în userService.js :

 const getUserProfilePicUrl = (id) => Promise.resolve(`/img/${id}`);

Și acum să-l folosim în routerul utilizatorilor noștri în stil async / await cu manipulare directă a răspunsului:

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

Rețineți cum folosim async / await , efectuăm redirecționarea și (cel mai important) avem încă un loc central pentru a transmite orice eroare, deoarece am folosit res.promise() pentru tratarea erorilor.

Cazul special 2: Conducta fluxului

La fel ca ruta noastră a imaginii de profil, canalizarea unui flux este o altă situație în care trebuie să manipulăm direct obiectul de răspuns.

Pentru a gestiona solicitările către adresa URL la care redirecționăm acum, să adăugăm o rută care returnează o imagine generică.

Mai întâi ar trebui să adăugăm profilePic.jpg într-un nou subfolder /assets/img . (Într-un proiect real am folosi stocarea în cloud precum AWS S3, dar mecanismul de conducte ar fi același.)

Să transmitem această imagine ca răspuns la solicitările /img/profilePic/:id . Trebuie să creăm un nou router pentru asta în /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;

Apoi adăugăm noul nostru router /img în app.js :

 app.use('/users', require('./routes/users')); app.use('/img', require('./routes/img'));

O diferență probabil iese în evidență în comparație cu cazul de redirecționare: nu am folosit res.promise() în routerul /img ! Acest lucru se datorează faptului că comportamentul unui obiect de răspuns deja canalizat, căruia i se transmite o eroare, va fi diferit de cazul în care eroarea apare în mijlocul fluxului.

Dezvoltatorii Express.js trebuie să acorde atenție atunci când lucrează cu fluxuri în aplicațiile Express.js, gestionând erorile în mod diferit, în funcție de momentul în care apar. Trebuie să ne ocupăm de erori înainte ca canalizarea ( res.promise() să ne ajute acolo), precum și midstream (bazat pe handlerul .on .on('error') ), dar detalii suplimentare depășesc scopul acestui articol.

Îmbunătățirea res.promise()

Ca și în cazul apelării res.promise() , nici nu suntem blocați să-l implementăm așa cum am făcut-o. promiseMiddleware.js poate fi mărit pentru a accepta unele opțiuni în res.promise() pentru a permite apelanților să specifice codurile de stare de răspuns, tipul de conținut sau orice altceva ar putea necesita un proiect. Depinde de dezvoltatori să-și modeleze instrumentele și să-și organizeze codul astfel încât să se potrivească cel mai bine nevoilor lor.

Gestionarea erorilor Express.js îndeplinește codarea modernă bazată pe promisiuni

Abordarea prezentată aici permite gestionări de rute mai elegante decât am început și un singur punct de procesare a rezultatelor și erorilor — chiar și a celor declanșate în afara res.promise(...) datorită gestionării erorilor în app.js . Totuși, nu suntem obligați să-l folosim și putem procesa cazurile marginale după cum ne dorim.

Codul complet din aceste exemple este disponibil pe GitHub. De acolo, dezvoltatorii pot adăuga logică personalizată după cum este necesar la funcția handleResponse() , cum ar fi schimbarea stării răspunsului la 204 în loc de 200 dacă nu sunt disponibile date.

Cu toate acestea, controlul adăugat asupra erorilor este mult mai util. Această abordare m-a ajutat să implementez concis aceste caracteristici în producție:

  • Formatați toate erorile în mod consecvent ca {error: {message}}
  • Trimiteți un mesaj generic dacă nu este furnizat nicio stare sau transmiteți un anumit mesaj în caz contrar
  • Dacă mediul este dev (sau test , etc.), completați câmpul error.stack
  • Gestionați erorile de index al bazei de date (adică există deja o entitate cu un câmp indexat unic) și răspundeți cu grație cu erori semnificative ale utilizatorului

Această logică a rutei Express.js era totul într-un singur loc, fără a atinge niciun serviciu - o decuplare care a lăsat codul mult mai ușor de întreținut și extins. Acesta este modul în care soluțiile simple – dar elegante – pot îmbunătăți drastic structura proiectului.


Citiți suplimentare pe blogul Toptal Engineering:

  • Cum să construiți un sistem de gestionare a erorilor Node.js