Utilizzo dei percorsi Express.js per la gestione degli errori basata su promesse

Pubblicato: 2022-03-11

La tagline di Express.js suona vera: è un "framework web veloce, semplice e minimalista per Node.js". È così poco convinto che, nonostante le attuali best practice JavaScript prescrivano l'uso di promesse, Express.js non supporta i gestori di route basati su promesse per impostazione predefinita.

Con molti tutorial di Express.js che tralasciano questo dettaglio, gli sviluppatori spesso prendono l'abitudine di copiare e incollare il codice di invio dei risultati e di gestione degli errori per ogni percorso, creando debiti tecnici man mano che procedono. Possiamo evitare questo antipattern (e le sue ricadute) con la tecnica che tratteremo oggi, una che ho usato con successo in app con centinaia di percorsi.

Architettura tipica per i percorsi Express.js

Iniziamo con un'applicazione tutorial Express.js con alcuni percorsi per un modello utente.

Nei progetti reali, memorizzeremmo i dati correlati in alcuni database come MongoDB. Ma per i nostri scopi, le specifiche dell'archiviazione dei dati non sono importanti, quindi le prenderemo in giro per semplicità. Quello che non semplificheremo è una buona struttura del progetto, la chiave per metà del successo di qualsiasi progetto.

Yeoman può produrre scheletri di progetto molto migliori in generale, ma per ciò di cui abbiamo bisogno, creeremo semplicemente uno scheletro di progetto con express-generator e rimuoveremo le parti non necessarie, finché non avremo questo:

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

Abbiamo ridotto le righe dei file rimanenti che non sono correlati ai nostri obiettivi.

Ecco il file principale dell'applicazione 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;

Qui creiamo un'app Express.js e aggiungiamo alcuni middleware di base per supportare l'uso di JSON, la codifica degli URL e l'analisi dei cookie. Quindi aggiungiamo un usersRouter per /users . Infine, specifichiamo cosa fare se non viene trovato alcun percorso e come gestire gli errori, che cambieremo in seguito.

Lo script per avviare il server stesso è /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);

Anche il nostro /package.json è 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" } }

Usiamo una tipica implementazione del router utente 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;

Ha due percorsi: / per ottenere tutti gli utenti e /:id per ottenere un singolo utente tramite ID. Utilizza anche /services/userService.js , che ha metodi basati su promesse per ottenere questi dati:

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

Qui abbiamo evitato di utilizzare un vero connettore DB o ORM (ad esempio, Mongoose o Sequelize), imitando semplicemente il recupero dei dati con Promise.resolve(...) .

Problemi di instradamento di Express.js

Osservando i nostri gestori di route, vediamo che ogni chiamata di servizio utilizza .then(...) e .catch(...) duplicati per inviare dati o errori al client.

A prima vista, questo potrebbe non sembrare grave. Aggiungiamo alcuni requisiti di base del mondo reale: dovremo visualizzare solo determinati errori e omettere errori generici di livello 500; inoltre, l'applicazione o meno di questa logica deve essere basata sull'ambiente. Detto questo, come sarà quando il nostro progetto di esempio crescerà dai suoi due percorsi in un progetto reale con 200 percorsi?

Approccio 1: Funzioni di utilità

Forse creeremmo funzioni di utilità separate per gestire la resolve e il reject e le applicheremmo ovunque nei nostri percorsi 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)); });

Sembra migliore: non stiamo ripetendo la nostra implementazione di invio di dati ed errori. Ma dovremo comunque importare questi gestori in ogni percorso e aggiungerli a ogni promessa passata a then() e catch() .

Approccio 2: Middleware

Un'altra soluzione potrebbe essere quella di utilizzare le best practice di Express.js per le promesse: spostare la logica di invio degli errori nel middleware degli errori Express.js (aggiunto in app.js ) e passare gli errori asincroni usando il callback next . La nostra configurazione di base del middleware di errore utilizzerebbe una semplice funzione anonima:

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

Express.js comprende che si tratta di errori perché la firma della funzione ha quattro argomenti di input. (Sfrutta il fatto che ogni oggetto funzione ha una proprietà .length che descrive quanti parametri si aspetta la funzione.)

Il passaggio degli errori tramite next sarebbe simile a questo:

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

Anche utilizzando la guida ufficiale alle migliori pratiche, abbiamo ancora bisogno delle nostre promesse JS in ogni gestore di route per risolvere utilizzando una funzione handleResponse() e rifiutare passando la funzione next .

Proviamo a semplificarlo con un approccio migliore.

Approccio 3: Middleware basato sulla promessa

Una delle maggiori caratteristiche di JavaScript è la sua natura dinamica. Possiamo aggiungere qualsiasi campo a qualsiasi oggetto in fase di esecuzione. Lo useremo per estendere gli oggetti risultato di Express.js; Le funzioni del middleware di Express.js sono un posto conveniente per farlo.

La nostra funzione promiseMiddleware()

Creiamo il nostro middleware di promessa, che ci darà la flessibilità per strutturare i nostri percorsi Express.js in modo più elegante. Avremo bisogno di un nuovo file, /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(); }; }

In app.js , applichiamo il nostro middleware all'oggetto app Express.js generale e aggiorniamo il comportamento di errore predefinito:

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

Si noti che non omettiamo il nostro middleware di errore . È ancora un importante gestore di errori per tutti gli errori sincroni che possono esistere nel nostro codice. Ma invece di ripetere la logica di invio degli errori, il middleware degli errori ora passa tutti gli errori sincroni alla stessa funzione centrale handleError() tramite una chiamata Promise.reject() inviata a res.promise() .

Questo ci aiuta a gestire errori sincroni come questo:

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

Infine, utilizziamo il nostro nuovo 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;

Nota i diversi usi di .promise() : possiamo passargli una funzione o una promessa. Il passaggio di funzioni può aiutarti con metodi che non hanno promesse; .promise() vede che è una funzione e la racchiude in una promessa.

Dove è meglio inviare effettivamente errori al client? È una buona domanda sull'organizzazione del codice. Potremmo farlo nel nostro middleware di errore (perché dovrebbe funzionare con gli errori) o nel nostro middleware di promessa (perché ha già interazioni con il nostro oggetto di risposta). Ho deciso di mantenere tutte le operazioni di risposta in un unico posto nel nostro middleware di promessa, ma spetta a ogni sviluppatore organizzare il proprio codice.

Tecnicamente, res.promise() è facoltativo

Abbiamo aggiunto res.promise() , ma non siamo vincolati a usarlo: siamo liberi di operare direttamente con l'oggetto response quando necessario. Diamo un'occhiata a due casi in cui ciò sarebbe utile: reindirizzamento e flusso di tubazioni.

Caso speciale 1: reindirizzamento

Supponiamo di voler reindirizzare gli utenti a un altro URL. Aggiungiamo una funzione getUserProfilePicUrl() in userService.js :

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

E ora usiamolo nel router dei nostri utenti in stile async / await con manipolazione della risposta diretta:

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

Nota come utilizziamo async / await , eseguiamo il reindirizzamento e (soprattutto) abbiamo ancora un posto centrale per passare qualsiasi errore perché abbiamo usato res.promise() per la gestione degli errori.

Caso speciale 2: tubazioni di flusso

Come il percorso dell'immagine del profilo, il piping di un flusso è un'altra situazione in cui è necessario manipolare direttamente l'oggetto risposta.

Per gestire le richieste all'URL a cui stiamo ora reindirizzando, aggiungiamo un percorso che restituisca un'immagine generica.

Per prima cosa dovremmo aggiungere profilePic.jpg in una nuova sottocartella /assets/img . (In un progetto reale utilizzeremmo lo storage cloud come AWS S3, ma il meccanismo di piping sarebbe lo stesso.)

Trasformiamo questa immagine in risposta alle richieste /img/profilePic/:id . Dobbiamo creare un nuovo router per quello in /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;

Quindi aggiungiamo il nostro nuovo router /img in app.js :

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

Una differenza probabilmente spicca rispetto al caso di reindirizzamento: non abbiamo usato res.promise() nel router /img ! Ciò è dovuto al fatto che il comportamento di un oggetto di risposta già reindirizzato a cui viene passato un errore sarà diverso rispetto a se l'errore si verifica nel mezzo del flusso.

Gli sviluppatori di Express.js devono prestare attenzione quando lavorano con i flussi nelle applicazioni Express.js, gestendo gli errori in modo diverso a seconda di quando si verificano. Dobbiamo gestire gli errori prima del piping ( res.promise() può aiutarci in questo caso) e del midstream (basato sul gestore .on('error') ), ma ulteriori dettagli esulano dallo scopo di questo articolo.

Miglioramento res.promise()

Come con la chiamata a res.promise() , non siamo nemmeno bloccati nell'implementazione nel modo in cui lo abbiamo fatto. promiseMiddleware.js può essere ampliato per accettare alcune opzioni in res.promise() per consentire ai chiamanti di specificare codici di stato della risposta, tipo di contenuto o qualsiasi altra cosa un progetto potrebbe richiedere. Spetta agli sviluppatori modellare i propri strumenti e organizzare il codice in modo che si adatti al meglio alle proprie esigenze.

La gestione degli errori di Express.js incontra la moderna codifica basata su promesse

L'approccio qui presentato consente gestori di percorsi più eleganti di quelli con cui abbiamo iniziato e un unico punto di elaborazione dei risultati e degli errori , anche quelli attivati ​​al di fuori di res.promise(...) grazie alla gestione degli errori in app.js . Tuttavia, non siamo obbligati a usarlo e possiamo elaborare i casi limite come vogliamo.

Il codice completo di questi esempi è disponibile su GitHub. Da lì, gli sviluppatori possono aggiungere la logica personalizzata secondo necessità alla funzione handleResponse() , ad esempio modificare lo stato della risposta su 204 anziché 200 se non sono disponibili dati.

Tuttavia, il controllo aggiunto sugli errori è molto più utile. Questo approccio mi ha aiutato a implementare in modo conciso queste funzionalità nella produzione:

  • Formatta tutti gli errori in modo coerente come {error: {message}}
  • Invia un messaggio generico se non viene fornito alcuno stato o trasferisci un determinato messaggio in caso contrario
  • Se l'ambiente è dev (o test e così via), compila il campo error.stack
  • Gestire gli errori di indice del database (ovvero, esiste già qualche entità con un campo indicizzato univoco) e rispondere con garbo con errori utente significativi

Questa logica di instradamento di Express.js era tutta in un unico posto, senza toccare alcun servizio, un disaccoppiamento che rendeva il codice molto più facile da mantenere ed estendere. Ecco come soluzioni semplici, ma eleganti, possono migliorare drasticamente la struttura del progetto.


Ulteriori letture sul blog di Toptal Engineering:

  • Come creare un sistema di gestione degli errori Node.js