Uso de rutas Express.js para el manejo de errores basado en promesas

Publicado: 2022-03-11

El eslogan de Express.js suena a verdad: es un "marco web minimalista, rápido y sin opiniones para Node.js". Es tan desinteresado que, a pesar de las prácticas recomendadas actuales de JavaScript que prescriben el uso de promesas, Express.js no admite controladores de ruta basados ​​en promesas de forma predeterminada.

Dado que muchos tutoriales de Express.js omiten ese detalle, los desarrolladores a menudo adquieren el hábito de copiar y pegar el código de envío de resultados y manejo de errores para cada ruta, creando una deuda técnica a medida que avanzan. Podemos evitar este antipatrón (y sus consecuencias) con la técnica que cubriremos hoy, una que he usado con éxito en aplicaciones con cientos de rutas.

Arquitectura típica para rutas Express.js

Comencemos con una aplicación de tutorial de Express.js con algunas rutas para un modelo de usuario.

En proyectos reales, almacenaríamos los datos relacionados en alguna base de datos como MongoDB. Pero para nuestros propósitos, las especificaciones de almacenamiento de datos no son importantes, por lo que las burlaremos en aras de la simplicidad. Lo que no simplificaremos es una buena estructura de proyecto, la clave para la mitad del éxito de cualquier proyecto.

Yeoman puede producir esqueletos de proyectos mucho mejores en general, pero para lo que necesitamos, simplemente crearemos un esqueleto de proyecto con express-generator y eliminaremos las partes innecesarias, hasta que tengamos esto:

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

Hemos reducido las líneas de los archivos restantes que no están relacionados con nuestros objetivos.

Aquí está el archivo principal de la aplicación 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;

Aquí creamos una aplicación Express.js y agregamos un middleware básico para admitir el uso de JSON, la codificación de URL y el análisis de cookies. Luego agregamos un usersRouter de usuarios para /users . Finalmente, especificamos qué hacer si no se encuentra ninguna ruta y cómo manejar los errores, que cambiaremos más adelante.

El script para iniciar el servidor en sí es /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);

Nuestro /package.json también es básico:

 { "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" } }

Usemos una implementación de enrutador de usuario típica en /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;

Tiene dos rutas: / para obtener todos los usuarios y /:id para obtener un solo usuario por ID. También usa /services/userService.js , que tiene métodos basados ​​en promesas para obtener estos datos:

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

Aquí hemos evitado el uso de un conector DB real u ORM (por ejemplo, Mongoose o Sequelize), simplemente imitando la obtención de datos con Promise.resolve(...) .

Problemas de enrutamiento de Express.js

Al observar nuestros controladores de ruta, vemos que cada llamada de servicio utiliza devoluciones de llamada .then(...) y .catch(...) duplicadas para enviar datos o errores al cliente.

A primera vista, esto puede no parecer serio. Agreguemos algunos requisitos básicos del mundo real: necesitaremos mostrar solo ciertos errores y omitir errores genéricos de 500 niveles; además, si aplicamos esta lógica o no debe basarse en el medio ambiente. Con eso, ¿cómo se verá cuando nuestro proyecto de ejemplo crezca de sus dos rutas a un proyecto real con 200 rutas?

Enfoque 1: funciones de utilidad

Tal vez crearíamos funciones de utilidad separadas para manejar resolve y reject , y aplicarlas en todas partes en nuestras rutas 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)); });

Se ve mejor: no estamos repitiendo nuestra implementación de envío de datos y errores. Pero aún necesitaremos importar estos controladores en cada ruta y agregarlos a cada promesa pasada a then() y catch() .

Enfoque 2: Middleware

Otra solución podría ser usar las mejores prácticas de Express.js en torno a las promesas: mueva la lógica de envío de errores al middleware de errores de Express.js (agregado en app.js ) y pase los errores asíncronos usando la next devolución de llamada. Nuestra configuración básica de middleware de error usaría una función anónima simple:

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

Express.js entiende que esto es para errores porque la firma de la función tiene cuatro argumentos de entrada. (Aprovecha el hecho de que cada objeto de función tiene una propiedad .length que describe cuántos parámetros espera la función).

Pasar errores a través de next se vería así:

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

Incluso usando la guía oficial de mejores prácticas, todavía necesitamos nuestras promesas JS en cada controlador de ruta para resolver usando una función handleResponse() y rechazar pasando la next función.

Tratemos de simplificar eso con un mejor enfoque.

Enfoque 3: Middleware basado en promesas

Una de las mejores características de JavaScript es su naturaleza dinámica. Podemos agregar cualquier campo a cualquier objeto en tiempo de ejecución. Lo usaremos para extender los objetos de resultado de Express.js; Las funciones de middleware de Express.js son un lugar conveniente para hacerlo.

Nuestra promiseMiddleware()

Creemos nuestro middleware prometido, que nos brindará la flexibilidad para estructurar nuestras rutas Express.js de manera más elegante. Necesitaremos un nuevo archivo, /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(); }; }

En app.js , apliquemos nuestro middleware al objeto general de la app Express.js y actualicemos el comportamiento de error predeterminado:

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

Tenga en cuenta que no omitimos nuestro middleware de error . Sigue siendo un controlador de errores importante para todos los errores sincrónicos que puedan existir en nuestro código. Pero en lugar de repetir la lógica de envío de errores, el middleware de errores ahora pasa cualquier error síncrono a la misma función central handleError() a través de una llamada Promise.reject() enviada a res.promise() .

Esto nos ayuda a manejar errores sincrónicos como este:

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

Finalmente, usemos nuestro nuevo res.promise() en /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;

Tenga en cuenta los diferentes usos de .promise() : podemos pasarle una función o una promesa. Pasar funciones puede ayudarlo con métodos que no tienen promesas; .promise() ve que es una función y la envuelve en una promesa.

¿Dónde es mejor enviar errores al cliente? Es una buena pregunta de organización del código. Podríamos hacer eso en nuestro middleware de error (porque se supone que funciona con errores) o en nuestro middleware de promesa (porque ya tiene interacciones con nuestro objeto de respuesta). Decidí mantener todas las operaciones de respuesta en un solo lugar en nuestro middleware prometido, pero depende de cada desarrollador organizar su propio código.

Técnicamente, res.promise() es opcional

Hemos agregado res.promise() , pero no estamos obligados a usarlo: somos libres de operar con el objeto de respuesta directamente cuando lo necesitamos. Veamos dos casos en los que esto sería útil: redireccionamiento y canalización de transmisión.

Caso especial 1: Redirección

Supongamos que queremos redirigir a los usuarios a otra URL. Agreguemos una función getUserProfilePicUrl() en userService.js :

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

Y ahora usémoslo en el enrutador de nuestros usuarios en estilo async / en await con manipulación de respuesta directa:

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

Tenga en cuenta cómo usamos async / await , realizamos la redirección y (lo más importante) todavía tenemos un lugar central para pasar cualquier error porque usamos res.promise() para el manejo de errores.

Caso especial 2: tubería de flujo

Al igual que nuestra ruta de imagen de perfil, canalizar una secuencia es otra situación en la que necesitamos manipular el objeto de respuesta directamente.

Para manejar las solicitudes a la URL a la que ahora estamos redirigiendo, agreguemos una ruta que devuelva una imagen genérica.

Primero debemos agregar profilePic.jpg en una nueva subcarpeta /assets/img . (En un proyecto real, usaríamos almacenamiento en la nube como AWS S3, pero el mecanismo de canalización sería el mismo).

Canalicemos esta imagen en respuesta a /img/profilePic/:id . Necesitamos crear un nuevo enrutador para eso en /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;

Luego agregamos nuestro nuevo enrutador /img en app.js :

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

Es probable que se destaque una diferencia en comparación con el caso de redirección: ¡No hemos usado res.promise() en el enrutador /img ! Esto se debe a que el comportamiento de un objeto de respuesta ya canalizado al que se pasa un error será diferente que si el error se produjera en medio de la secuencia.

Los desarrolladores de Express.js deben prestar atención cuando trabajan con secuencias en aplicaciones de Express.js, manejando los errores de manera diferente según cuándo ocurran. Necesitamos manejar los errores antes de la canalización ( res.promise() puede ayudarnos allí), así como a mitad de camino (basado en el .on('error') ), pero más detalles están más allá del alcance de este artículo.

Mejora res.promise()

Al igual que con la llamada a res.promise() , tampoco estamos obligados a implementarlo de la forma en que lo hicimos. promiseMiddleware.js se puede aumentar para aceptar algunas opciones en res.promise() para permitir que las personas que llaman especifiquen códigos de estado de respuesta, tipo de contenido o cualquier otra cosa que pueda requerir un proyecto. Depende de los desarrolladores dar forma a sus herramientas y organizar su código para que se adapte mejor a sus necesidades.

El manejo de errores de Express.js cumple con la codificación moderna basada en promesas

El enfoque que se presenta aquí permite controladores de ruta más elegantes que con los que comenzamos y un único punto de procesamiento de resultados y errores , incluso aquellos disparados fuera de res.promise(...) gracias al manejo de errores en app.js Aún así, no estamos obligados a usarlo y podemos procesar casos extremos como queramos.

El código completo de estos ejemplos está disponible en GitHub. A partir de ahí, los desarrolladores pueden agregar lógica personalizada según sea necesario a la función handleResponse() , como cambiar el estado de respuesta a 204 en lugar de 200 si no hay datos disponibles.

Sin embargo, el control adicional sobre los errores es mucho más útil. Este enfoque me ayudó a implementar de manera concisa estas características en producción:

  • Formatee todos los errores consistentemente como {error: {message}}
  • Envíe un mensaje genérico si no se proporciona ningún estado o transmita un mensaje dado de lo contrario
  • Si el entorno es de desarrollo (o test , etc.), error.stack el campo dev
  • Manejar los errores de índice de la base de datos (es decir, ya existe alguna entidad con un campo indexado único) y responder correctamente con errores de usuario significativos

Esta lógica de ruta de Express.js estaba en un solo lugar, sin tocar ningún servicio, un desacoplamiento que hizo que el código fuera mucho más fácil de mantener y ampliar. Así es como las soluciones simples, pero elegantes, pueden mejorar drásticamente la estructura del proyecto.


Lecturas adicionales en el blog de ingeniería de Toptal:

  • Cómo construir un sistema de manejo de errores de Node.js