使用 Express.js 路由进行基于 Promise 的错误处理
已发表: 2022-03-11Express.js 的标语是正确的:它是“一个快速、独立、极简的 Node.js 网络框架”。 尽管当前 JavaScript 最佳实践规定了使用 Promise,但 Express.js 默认不支持基于 Promise 的路由处理程序,这太没有意见了。
由于许多 Express.js 教程都忽略了这些细节,因此开发人员经常养成为每条路由复制和粘贴结果发送和错误处理代码的习惯,从而在进行过程中产生技术债务。 我们可以使用我们今天将介绍的技术来避免这种反模式(及其后果)——我已经在具有数百条路由的应用程序中成功使用了这种技术。
Express.js 路由的典型架构
让我们从一个 Express.js 教程应用程序开始,其中包含一些用于用户模型的路由。
在实际项目中,我们会将相关数据存储在 MongoDB 等数据库中。 但就我们的目的而言,数据存储细节并不重要,因此为了简单起见,我们将对其进行模拟。 我们不会简化的是良好的项目结构,这是任何项目成功的关键。
Yeoman 通常可以生成更好的项目骨架,但是对于我们需要的,我们将简单地使用 express-generator 创建一个项目骨架并删除不必要的部分,直到我们拥有:
bin start.js node_modules routes users.js services userService.js app.js package-lock.json package.json我们已经减少了与我们的目标无关的剩余文件的行。
这是主要的 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; 这里我们创建一个 Express.js 应用程序并添加一些基本的中间件来支持 JSON 使用、URL 编码和 cookie 解析。 然后我们为/users添加一个usersRouter 。 最后,我们指定如果没有找到路由该怎么做,以及如何处理错误,我们稍后会更改。
启动服务器本身的脚本是/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也是准系统:
{ "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" } } 让我们在/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; 它有两条路线: /获取所有用户和/:id通过 ID 获取单个用户。 它还使用/services/userService.js ,它具有基于承诺的方法来获取此数据:
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 }; 在这里,我们避免使用实际的 DB 连接器或 ORM(例如,Mongoose 或 Sequelize),只是模拟使用Promise.resolve(...)获取数据。
Express.js 路由问题
查看我们的路由处理程序,我们看到每个服务调用都使用重复的 .then( .then(...)和.catch(...)回调将数据或错误发送回客户端。
乍一看,这似乎并不严重。 让我们添加一些基本的现实世界要求:我们只需要显示某些错误并省略一般的 500 级错误; 另外,我们是否应用这个逻辑必须基于环境。 有了这个,当我们的示例项目从它的两条路线发展成一个有 200 条路线的真实项目时,它会是什么样子?
方法 1:效用函数
也许我们会创建单独的实用程序函数来处理resolve和reject ,并将它们应用到我们的 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)); }); 看起来更好:我们不会重复我们发送数据和错误的实现。 但是我们仍然需要在每条路由中导入这些处理程序,并将它们添加到传递给then()和catch()的每个 Promise 中。
方法二:中间件
另一种解决方案可能是围绕 Promise 使用 Express.js 最佳实践:将错误发送逻辑移动到 Express.js 错误中间件(添加到app.js中)并使用next回调将异步错误传递给它。 我们的基本错误中间件设置将使用一个简单的匿名函数:
app.use(function(err, req, res, next) { res.status(err.status || 500); res.send(err); }); Express.js 知道这是为了错误,因为函数签名有四个输入参数。 (它利用了每个函数对象都有一个.length属性来描述函数需要多少参数的事实。)
通过next传递错误如下所示:
// 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); }); 即使使用官方的最佳实践指南,我们仍然需要在每个路由处理程序中使用我们的 JS 承诺来使用handleResponse()函数解析并通过传递next函数来拒绝。
让我们尝试用更好的方法来简化它。
方法 3:基于 Promise 的中间件
JavaScript 最大的特点之一是它的动态特性。 我们可以在运行时将任何字段添加到任何对象。 我们将使用它来扩展 Express.js 结果对象; Express.js 中间件函数是一个方便的地方。

我们的promiseMiddleware()函数
让我们创建我们的 Promise 中间件,这将使我们能够灵活地更优雅地构建 Express.js 路由。 我们需要一个新文件/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(); }; } 在app.js中,让我们将中间件应用到整个 Express.js app对象并更新默认错误行为:
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)); }); 请注意,我们没有省略错误中间件。 对于我们代码中可能存在的所有同步错误,它仍然是一个重要的错误处理程序。 但是,错误中间件现在不再重复错误发送逻辑,而是通过发送到res.promise()的Promise.reject()调用将任何同步错误传递给同一个中央handleError()函数。
这有助于我们处理像这样的同步错误:
router.get('/someRoute', function(req, res){ throw new Error('This is synchronous error!'); }); 最后,让我们在/routes/users.js中使用我们的新res.promise() :
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; 请注意.promise()的不同用途:我们可以将其传递给函数或承诺。 传递函数可以帮助您处理没有承诺的方法; .promise()看到它是一个函数并将其包装在一个承诺中。
实际向客户端发送错误在哪里更好? 这是一个很好的代码组织问题。 我们可以在我们的错误中间件(因为它应该与错误一起工作)或在我们的承诺中间件(因为它已经与我们的响应对象交互)中做到这一点。 我决定将所有响应操作都放在我们的 Promise 中间件中的一个位置,但是由每个开发人员来组织他们自己的代码。
从技术上讲, res.promise()是可选的
我们添加了res.promise() ,但我们并没有被限制在使用它:我们可以在需要时直接使用响应对象进行操作。 让我们看一下这将是有用的两种情况:重定向和流管道。
特例一:重定向
假设我们想将用户重定向到另一个 URL。 让我们在userService.js中添加一个函数getUserProfilePicUrl() :
const getUserProfilePicUrl = (id) => Promise.resolve(`/img/${id}`); 现在让我们在我们的用户路由器中以async / await风格使用它并直接响应操作:
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)); } }); 请注意我们如何使用async / await ,执行重定向,并且(最重要的是)仍然有一个中心位置来传递任何错误,因为我们使用res.promise()进行错误处理。
特例 2:流管道
就像我们的个人资料图片路由一样,管道流是我们需要直接操作响应对象的另一种情况。
为了处理对我们现在重定向到的 URL 的请求,让我们添加一个返回一些通用图片的路由。
首先,我们应该在新的/assets/img子文件夹中添加profilePic.jpg 。 (在实际项目中,我们会使用 AWS S3 之类的云存储,但管道机制是相同的。)
让我们通过管道传输此图像以响应/img/profilePic/:id请求。 我们需要在/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; 然后我们在app.js中添加我们的新/img路由器:
app.use('/users', require('./routes/users')); app.use('/img', require('./routes/img')); 与重定向案例相比,一个区别可能很突出:我们没有在/img路由器中使用res.promise() ! 这是因为传递错误的已管道响应对象的行为与错误发生在流中间的行为不同。
Express.js 开发人员在处理 Express.js 应用程序中的流时需要注意,根据错误发生的时间以不同的方式处理错误。 我们需要在管道之前处理错误( res.promise()可以帮助我们)以及中游(基于.on('error')处理程序),但更多细节超出了本文的范围。
增强res.promise()
与调用res.promise() ,我们也没有按照我们的方式来实现它。 promiseMiddleware.js可以扩展为接受res.promise()中的一些选项,以允许调用者指定响应状态代码、内容类型或项目可能需要的任何其他内容。 由开发人员决定他们的工具并组织他们的代码,以使其最适合他们的需求。
Express.js 错误处理符合现代基于 Promise 的编码
由于app.js中的错误处理,这里介绍的方法允许比我们开始时更优雅的路由处理程序以及单点处理结果和错误——即使是那些在res.promise(...)之外触发的错误。 尽管如此,我们并没有被迫使用它,并且可以根据需要处理边缘情况。
这些示例的完整代码可在 GitHub 上找到。 从那里,开发人员可以根据需要向handleResponse()函数添加自定义逻辑,例如如果没有可用数据,则将响应状态更改为 204 而不是 200。
但是,对错误的附加控制更有用。 这种方法帮助我在生产中简洁地实现了这些功能:
- 将所有错误统一格式化为
{error: {message}} - 如果未提供任何状态,则发送通用消息,否则传递给定消息
- 如果环境是
dev(或test等),填充error.stack字段 - 处理数据库索引错误(即,某些具有唯一索引字段的实体已经存在)并优雅地响应有意义的用户错误
这个 Express.js 路由逻辑都在一个地方,不涉及任何服务——这种解耦使代码更容易维护和扩展。 这就是简单但优雅的解决方案可以显着改善项目结构的方式。
进一步阅读 Toptal 工程博客:
- 如何构建 Node.js 错误处理系统
