Como usar rotas Express.js para tratamento de erros baseado em promessa
Publicados: 2022-03-11O slogan do Express.js soa verdadeiro: é um “framework web rápido, sem opiniões e minimalista para Node.js”. É tão sem opinião que, apesar das práticas recomendadas atuais de JavaScript que prescrevem o uso de promessas, o Express.js não oferece suporte a manipuladores de rota baseados em promessas por padrão.
Com muitos tutoriais do Express.js deixando de fora esse detalhe, os desenvolvedores geralmente adquirem o hábito de copiar e colar o código de envio de resultados e tratamento de erros para cada rota, criando dívida técnica à medida que avançam. Podemos evitar esse antipadrão (e suas consequências) com a técnica que abordaremos hoje — uma que usei com sucesso em aplicativos com centenas de rotas.
Arquitetura típica para rotas Express.js
Vamos começar com um aplicativo de tutorial Express.js com algumas rotas para um modelo de usuário.
Em projetos reais, armazenaríamos os dados relacionados em algum banco de dados como o MongoDB. Mas para nossos propósitos, as especificidades do armazenamento de dados não são importantes, então vamos zombar delas por uma questão de simplicidade. O que não vamos simplificar é uma boa estrutura de projeto, a chave para metade do sucesso de qualquer projeto.
Yeoman pode produzir esqueletos de projeto muito melhores em geral, mas para o que precisamos, simplesmente criaremos um esqueleto de projeto com express-generator e removeremos as partes desnecessárias, até que tenhamos isso:
bin start.js node_modules routes users.js services userService.js app.js package-lock.json package.jsonReduzimos as linhas dos arquivos restantes que não estão relacionados aos nossos objetivos.
Este é o arquivo principal do aplicativo 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; Aqui, criamos um aplicativo Express.js e adicionamos alguns middlewares básicos para dar suporte ao uso de JSON, codificação de URL e análise de cookies. Em seguida, adicionamos um usersRouter para /users . Por fim, especificamos o que fazer se nenhuma rota for encontrada e como lidar com erros, que alteraremos posteriormente.
O script para iniciar o próprio servidor é /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); Nosso /package.json também é 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" } } Vamos usar uma implementação típica de roteador de usuário em /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; Possui duas rotas: / para obter todos os usuários e /:id para obter um único usuário por ID. Ele também usa /services/userService.js , que possui métodos baseados em promessas para obter esses dados:
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 }; Aqui evitamos usar um conector de banco de dados real ou ORM (por exemplo, Mongoose ou Sequelize), simplesmente imitando a busca de dados com Promise.resolve(...) .
Problemas de roteamento do Express.js
Observando nossos manipuladores de rota, vemos que cada chamada de serviço usa retornos de chamada duplicados .then(...) e .catch(...) para enviar dados ou erros de volta ao cliente.
À primeira vista, isso pode não parecer sério. Vamos adicionar alguns requisitos básicos do mundo real: precisaremos exibir apenas alguns erros e omitir erros genéricos de nível 500; também, se aplicamos essa lógica ou não, deve ser baseado no ambiente. Com isso, como será quando nosso projeto de exemplo crescer de suas duas rotas para um projeto real com 200 rotas?
Abordagem 1: Funções de Utilidade
Talvez pudéssemos criar funções de utilitário separadas para lidar com resolve e reject e aplicá-las em todos os lugares em nossas rotas 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)); }); Parece melhor: não estamos repetindo nossa implementação de envio de dados e erros. Mas ainda precisaremos importar esses manipuladores em cada rota e adicioná-los a cada promessa passada para then() e catch() .
Abordagem 2: Middleware
Outra solução poderia ser usar as práticas recomendadas do Express.js em relação às promessas: mover a lógica de envio de erros para o middleware de erro do Express.js (adicionado em app.js ) e passar erros assíncronos para ele usando o next retorno de chamada. Nossa configuração básica de middleware de erro usaria uma função anônima simples:
app.use(function(err, req, res, next) { res.status(err.status || 500); res.send(err); }); O Express.js entende que isso é para erros porque a assinatura da função tem quatro argumentos de entrada. (Ele aproveita o fato de que cada objeto de função tem uma propriedade .length que descreve quantos parâmetros a função espera.)
A passagem de erros via next ficaria assim:
// 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); }); Mesmo usando o guia oficial de melhores práticas, ainda precisamos de nossas promessas JS em cada manipulador de rota para resolver usando uma função handleResponse() e rejeitar passando a next função.
Vamos tentar simplificar isso com uma abordagem melhor.
Abordagem 3: Middleware baseado em promessa
Uma das maiores características do JavaScript é sua natureza dinâmica. Podemos adicionar qualquer campo a qualquer objeto em tempo de execução. Usaremos isso para estender os objetos de resultado do Express.js; As funções de middleware do Express.js são um local conveniente para isso.
Nossa função promiseMiddleware()
Vamos criar nosso middleware de promessa, que nos dará flexibilidade para estruturar nossas rotas Express.js de forma mais elegante. Precisaremos de um novo arquivo, /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(); }; } Em app.js , vamos aplicar nosso middleware ao objeto geral do app Express.js e atualizar o comportamento de erro padrão:

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)); }); Observe que não omitimos nosso middleware de erro . Ainda é um manipulador de erros importante para todos os erros síncronos que possam existir em nosso código. Mas em vez de repetir a lógica de envio de erro, o middleware de erro agora passa quaisquer erros síncronos para a mesma função handleError() central por meio de uma chamada Promise.reject() enviada para res.promise() .
Isso nos ajuda a lidar com erros síncronos como este:
router.get('/someRoute', function(req, res){ throw new Error('This is synchronous error!'); }); Finalmente, vamos usar nosso novo res.promise() em /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; Observe os diferentes usos de .promise() : Podemos passar uma função ou uma promessa. A passagem de funções pode ajudá-lo com métodos que não possuem promessas; .promise() vê que é uma função e a envolve em uma promessa.
Onde é melhor enviar erros para o cliente? É uma boa questão de organização de código. Poderíamos fazer isso em nosso middleware de erro (porque deve funcionar com erros) ou em nosso middleware de promessa (porque já possui interações com nosso objeto de resposta). Decidi manter todas as operações de resposta em um só lugar em nosso middleware de promessa, mas cabe a cada desenvolvedor organizar seu próprio código.
Tecnicamente, res.promise() é opcional
Adicionamos res.promise() , mas não estamos limitados a usá-lo: somos livres para operar diretamente com o objeto de resposta quando precisarmos. Vejamos dois casos em que isso seria útil: redirecionamento e canalização de fluxo.
Caso Especial 1: Redirecionamento
Suponha que queremos redirecionar os usuários para outro URL. Vamos adicionar uma função getUserProfilePicUrl() em userService.js :
const getUserProfilePicUrl = (id) => Promise.resolve(`/img/${id}`); E agora vamos usá-lo em nosso roteador de usuários no estilo async / await com manipulação de resposta direta:
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)); } }); Observe como usamos async / await , realizamos o redirecionamento e (o mais importante) ainda temos um local central para passar qualquer erro porque usamos res.promise() para tratamento de erros.
Caso Especial 2: Tubulação de Fluxo
Como nossa rota de imagem de perfil, canalizar um fluxo é outra situação em que precisamos manipular o objeto de resposta diretamente.
Para lidar com solicitações para a URL para a qual estamos redirecionando, vamos adicionar uma rota que retorne uma imagem genérica.
Primeiro devemos adicionar profilePic.jpg em uma nova subpasta /assets/img . (Em um projeto real, usaríamos armazenamento em nuvem como AWS S3, mas o mecanismo de tubulação seria o mesmo.)
Vamos canalizar esta imagem em resposta às solicitações /img/profilePic/:id . Precisamos criar um novo roteador para isso em /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; Em seguida, adicionamos nosso novo roteador /img em app.js :
app.use('/users', require('./routes/users')); app.use('/img', require('./routes/img')); Uma diferença provavelmente se destaca em comparação com o caso de redirecionamento: não usamos res.promise() no roteador /img ! Isso ocorre porque o comportamento de um objeto de resposta já canalizado recebendo um erro será diferente de se o erro ocorrer no meio do fluxo.
Os desenvolvedores do Express.js precisam prestar atenção ao trabalhar com fluxos em aplicativos Express.js, tratando os erros de forma diferente dependendo de quando eles ocorrem. Precisamos lidar com erros antes de canalizar ( res.promise() pode nos ajudar lá), bem como midstream (com base no manipulador .on('error') ), mas mais detalhes estão além do escopo deste artigo.
Aprimorando res.promise()
Assim como ao chamar res.promise() , também não estamos presos a implementá -lo da maneira que fizemos. promiseMiddleware.js pode ser aumentado para aceitar algumas opções em res.promise() para permitir que os chamadores especifiquem códigos de status de resposta, tipo de conteúdo ou qualquer outra coisa que um projeto possa exigir. Cabe aos desenvolvedores moldar suas ferramentas e organizar seu código para que melhor atenda às suas necessidades.
O tratamento de erros do Express.js atende à codificação moderna baseada em promessas
A abordagem apresentada aqui permite manipuladores de rotas mais elegantes do que começamos e um único ponto de processamento de resultados e erros — mesmo aqueles disparados fora de res.promise(...) graças ao tratamento de erros em app.js . Ainda assim, não somos obrigados a usá-lo e podemos processar casos extremos como quisermos.
O código completo desses exemplos está disponível no GitHub. A partir daí, os desenvolvedores podem adicionar lógica personalizada conforme necessário à função handleResponse() , como alterar o status da resposta para 204 em vez de 200 se não houver dados disponíveis.
No entanto, o controle adicional sobre erros é muito mais útil. Essa abordagem me ajudou a implementar esses recursos de forma concisa na produção:
- Formate todos os erros de forma consistente como
{error: {message}} - Envie uma mensagem genérica se nenhum status for fornecido ou repasse uma determinada mensagem caso contrário
- Se o ambiente for
dev(outest, etc.), preencha o campoerror.stack - Lidar com erros de índice de banco de dados (ou seja, alguma entidade com um campo indexado exclusivo já existe) e responder normalmente com erros significativos do usuário
Essa lógica de rota do Express.js estava toda em um só lugar, sem tocar em nenhum serviço — uma dissociação que deixou o código muito mais fácil de manter e estender. É assim que soluções simples, mas elegantes, podem melhorar drasticamente a estrutura do projeto.
Leitura adicional no Blog da Toptal Engineering:
- Como construir um sistema de tratamento de erros Node.js
