使用Express.js Routes进行基于promise的错误处理

381 阅读10分钟

使用Express.js路由进行基于承诺的错误处理

Express.js的标语是真实的:它是一个 "快速、无偏见、简约的Node.js的网络框架"。尽管目前的JavaScript最佳实践规定要使用承诺,但Express.js默认不支持基于承诺的路由处理程序,所以它是如此的没有主见。

由于许多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(...).catch(...) 回调来发送数据或错误回给客户端。

乍一看,这似乎并不严重。让我们添加一些基本的现实世界的要求。我们将需要只显示某些错误,而省略通用的500级错误;另外,我们是否应用这种逻辑,必须基于环境。有了这些,当我们的例子项目从两条路线发展到有200条路线的真实项目时,会是什么样子?

方法一:实用函数

也许我们会创建单独的实用函数来处理resolvereject ,并在我们的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() 的每个承诺中。

方法2:中间件

另一个解决方案是使用围绕承诺的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:基于承诺的中间件

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.jsapp 对象,并更新默认的错误行为。

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() ,但我们并没有被锁定在使用它。当我们需要时,我们可以自由地直接对响应对象进行操作。让我们看看两种情况,这将是有用的:重定向和流管道。

特殊情况1:重定向

假设我们想把用户重定向到另一个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的错误处理与现代基于承诺的编码相结合

这里介绍的方法允许比我们开始时更优雅的路由处理程序,以及单点处理结果和错误--即使是res.promise(...)以外触发的错误--这要归功于app.js 中的错误处理。不过,我们并没有被迫使用它,可以按照我们的意愿处理边缘情况。

这些例子的完整代码可以在GitHub上找到。在那里,开发人员可以根据需要向handleResponse() 函数添加自定义逻辑,例如在没有数据的情况下将响应状态改为204而不是200。

然而,增加对错误的控制是更有用的。这种方法帮助我在生产中简洁地实现这些功能。

  • 将所有错误统一格式化为{error: {message}}
  • 如果没有提供状态,就发送一个通用消息,否则就传递一个给定的消息
  • 如果环境是dev (或test ,等等),则填入error.stack 字段
  • 处理数据库索引错误(即某个具有唯一索引字段的实体已经存在),并优雅地回应有意义的用户错误

这个Express.js路由逻辑都在一个地方,没有接触到任何服务--这种解耦使代码更容易维护和扩展。这就是简单而优雅的解决方案是如何极大地改善项目结构的。