MENU

Express 实战(四):中间件

原生 Node 的单一请求处理函数,随着功能的扩张势必会变的越来越难以维护。而 Express 框架则可以通过中间件的方式按照模块和功能对处理函数进行切割处理。这样拆分后的模块不仅逻辑清晰,更重要的是对后期维护和开发非常有利。

本文将会详细介绍 Express 的使用,其中主要内容包括:

  • 中间件是什么?

  • 中间件栈以及请求处理的工作流。

  • 中间件的使用。

  • 如何实现自己的中间件。

  • Express 中常用的第三方中间件。

希望在读完本文后,你能对这个 Express 最主要的构成有更加清晰的认知。

中间件和中间件栈

对所有的 Web 应用来说它的处理流程可以简单描述为:监听请求、解析请求、做出响应。当然,Node 也遵循这一套流程,只不过将那些请求都转化为了 JavaScript 对象。

04_01

与原生 Node 代码不同的是,Express 会将上图中的最后一部分拆分为一组中间件函数(中间件栈)。所以Express 的工作流大致如下:

04_02

与纯 Node 不同的是,Express 中的中间件栈函数中除了表示请求和响应的参数外,还添加了第三个参数。该参数是一个函数对象,按照惯例我们称之为 next 。它用于传递中间件栈对某个请求的处理流。

04_03

在整个中间件栈的处理流中,最少有一个函数需要调用 res.end 方法结束响应处理。下面我们就通过搭建静态文件服务来加深对中间件栈的理解。

示例:一个静态文件服务器

创建一个文件夹并为此提供静态文件服务。你可以在文件夹中存放任何文件,例如:HTML 文件、图片。最终所有的这些文件都能通过示例程序进行网络访问。

该示例程序的功能大致包括:能够正确返回存在的文件;文件不存在时返回 404 错误;打印所有的访问请求。所以,该示例的中间件栈如下:

  1. 日志记录中间件。该函数会在终端打印所有的网络请求,并在打印介绍后继续下一个中间件函数。

  2. 静态文件发送中间件。如果访问的文件存在则返回给客户端。如果文件不存在则会跳到错误处理中间件。

  3. 404 处理中间件。如果文件不存在的话,该中间件将会给客户端发送 404 错误信息。

流程图如下:

04_04

明确示例的目标和需求后,下面我们就进行代码实现。

准备工作

与之前一样,新建工程目录并将下面内容复制到 package.json 中:

{
    "name": "static-file-fun", 
    "private": true, 
    "scripts": {
        "start": "node app.js" 
    }
}

接下来,我们执行 npm install express --save 安装最新版 Express 。确保安装完成后,我们在工程目录里新建文件夹 static 并在其中存放一些文件。最后,我们新建工程主入口文件 app.js 。一切就绪后,工程的大致目录如下:

04_05

另外,值的一提的是之所以配置 npm start 命令,既是因为开发约定更重要的是让其他人开箱即用无需自己手动查找程序入口。

第一个中间件:日志记录

按照前面制订的处理流程,首先需要实现的就是日志中间件。复制下面代码到入口文件 app.js 中:

var express = require("express");
var path = require("path");
var fs = require("fs");

var app = express();

app.use(function(req, res, next) {
    console.log("Request IP: " + req.url);
    console.log("Request date: " + new Date());
});

app.listen(3000, function() {
    console.log("App started on port 3000");
});

通过上面的 app.use 函数,我们成功实现了应用中的第一个功能,即记录每次网络请求。当然这里还有一个问题,当前应用并不会对请求做出响应。这意味这:如果你用 npm start 拉起服务并访问 loaclhost:3000 浏览器会一直挂起等待响应直到出现超时错误。不过不要担心,等补全后面功能后我们就可以在该中间件调用 next() 将响应的任务交给后续中间件。

这里我们只需要明白:理论上一个中间件函数处理结束后,它必须执行以下两个步骤中的一个。

  1. 所有处理结束,发送 red.end 或者 Express 中的 red.sendFile 等函数结束响应。

  2. 调用 next 函数执行下一个中间件函数。

所以这里我们先把 next() 调用补全将日志中间件的逻辑理顺:

// ...
app.use(function(req, res, next) {
    console.log("Request IP: " + req.url);
    console.log("Request date: " + new Date());
    next(); // 新的这行很重要
});
// ...

此时重启服务并访问 http://localhost:3000 的话访问请求会被完整记录下来。但是因为程序没有做出响应 ,Express 任会给客户端发送一个错误信息。所以,接下来我们就补全后续流程。

静态文件服务中间件

静态文件服务中间件应该有以下几个功能:

  1. 检查目录中是否存在该文件

  2. 如果文件存在则调用 res.sendFile 结束响应处理。

  3. 如果文件不存在则继续调用下一个中间件从代码角度来说就是调用 next

其中我们需要使用内置的 path 模块指定路径,然后使用内置的 fs 模块判断文件释放存在。将下面代码添加到日志中间件后面:


// 日志中间件
app.use(function(req, res, next) {
  // …
});
 
app.use(function(req, res, next) {
  var filePath = path.join(__dirname, "static", req.url);  
  fs.exists(filePath, function(exists) {                      
    if (exists) {                                             
      res.sendFile(filePath);                                 
    } else {                                                  
      next();                                                
    }
  });
});
 
app.listen(3000, function() {
    ...
}

在中间件中我们首先使用 path.join 拼接文件完整路径。例如,如果用户访问 http://localhost:3000/celine.mp3 文件的话 req.url 的值就是 /celine.mp3 拼接后的完整路径就是 "/path/to/your/project/static/celine.mp3" 了。

然后,该中间件调用 fs.exists 函数检查文件是否存在。如果文件存在则发生文件,否则调用 next() 继续执行下一个中间件。而如果访问的 URL 没有对应的文件的话就会出现之前一样的错误。所以下面需要实现最后一个中间件:404 处理中间件。

404 处理中间件

404 中间件的任务就是发送 404 错误信息,复制下面的实现代码并添加到静态服务中间件后面:



app.use(function(req, res) {
    // 设置状态码为404
    res.status(404);
    // 发送错误提示
    res.send("File not found!");
});

// ...

这样整个工程就算完成了。如果你再次启动服务的话,之前的错误就会被一个 404 错误取代。另外,如果你将该中间件函数移动到中间件栈的第一个,那么你会发现所有的请求都会得到 404 的错误信息。这意味着中间件栈中的函数顺序是非常重要的。

到这里,app.js 中的完整代码如下:


var express = require("express");
var path = require("path");
var fs = require("fs");
var app = express();
app.use(function(req, res, next) {
    console.log("Request IP: " + req.url);
    console.log("Request date: " + new Date());
    next();
});
app.use(function(req, res, next) {
    var filePath = path.join(__dirname, "static", req.url);
    fs.stat(filePath, function(err, fileInfo) {
        if (err) {
            next();
            return;
        }
        if (fileInfo.isFile()) {
            res.sendFile(filePath);
        } else {
            next();
        }
    });
});
app.use(function(req, res) {
    res.status(404);
    res.send("File not found!");
});
app.listen(3000, function() {
    console.log("App started on port 3000");
});

当然,这只是初步的代码,还有很多地方可以进行优化。

将日志中间件替换为:Morgan

在软件开发中如果你的问题已经存在比较好的解决方案,那么理想的做法是直接使用该方案而不应该“重复造轮子”。所以,下面我们使用功能强大的 Morgan 替换掉上面自己实现的日志中间件。虽然,该中间件不是 Express 内置模块,但是它却是由 Express 团队维护并久经考验。

运行 npm install morgan --save 安装最新版本的 Morgan 模块。然后使用 Morgan 替换掉之前的日志中间件:

var express = require("express");
var morgan = require("morgan");
...

var app = express();
app.use(morgan("short"));

...

当你再次启动服务并访问资源时,终端将会打印包括 IP 地址在内的有用信息:

04_06

代码中 morgan 其是一个函数并且它的返回值是一个中间件函数。当你调用它的时候,它会返回一个类似之间实现的日志中间件。为了代码更加清晰,你也可以将代码改写为:

var morganMiddleware = morgan("short");
app.use(morganMiddleware);

另外,这里在调用函数是使用的是 short 作为输出选项。其实该模块还提供另两个输出选项:combined 打印最多信息;tiny 打印最少的信息。

除了使用 Morgan 替换原有日志中间件之外,我们还可以使用内置的静态中间件替换之前的代码实现。

使用 Express 内置静态文件中间件

接下来,我们使用 Express 内置的 express.static 模块来替换之前的静态文件中间件。它的工作原理与之前的中间件代码类似,但是它具有更好的安全性和性能。例如,它在内部实现了资源的缓存功能。

与 Morgan 一样,express.static 函数的返回值也是一个中间件函数。我们只需为 express.static 函数指定路径参数即可。代码如下:

var staticPath = path.join(__dirname, "static"); // 设置静态文件的路径
app.use(express.static(staticPath)); // 使用express.static从静态路径提供服务
// ...

完成替换后你会发现代码相较之前明显变的简练了,与此同时功能反而比之前更强。另外,这些久经考验的中间件模块远比自己的代码实现功能更多也更可靠。此时 app.js 中的完整代码:

var express = require("express");
var morgan = require("morgan");
var path = require("path");
var app = express();
app.use(morgan("short"));
var staticPath = path.join(__dirname, "static");
app.use(express.static(staticPath));
app.use(function(req, res) {
    res.status(404);
    res.send("File not found!");
});
app.listen(3000, function() {
    console.log("App started on port 3000");
});

错误处理中间件

之前我说过调用 next() 会按序执行下一个中间件。其实,真实情况并不是这么简单。事实上,Express 中间件有两种类型。

到目前为止,你已经接触了第一种类型:包含三个参数的常规中间件函数(有时 next 会被忽略而只保留两个参数),而绝大多数时候程序中都是使用这种常规模式。

第二种类型非常少见:错误处理中间件。当你的 app 处于错误模式时,所有的常规中间件都会被跳过而直接执行 Express 错误处理中间件。想要进入错误模式,只需在调用 next 时附带一个参数。这是调用错误对象的一种惯例,例如:next(new Error("Something bad happened!"))

错误处理中间件中需要四个参数,其中后面三个和常规形式的一致而第一个参数则是 next(new Error("Something bad happened!")) 中传递过来的 Error 对象。你可以像使用常规中间件一样来使用错误处理中间件,例如:调用 res.end 或者 next 。如果调用含参数的 next 中间件会继续下一个错误处理中间件否则将会退出错误模式并调用下一个常规中间件。

假设,现在有四个中间件依次排开,其中第三个为错误处理中间件而其他的都是常规中间件。如果没有出现错误的话,流程应该是:

04_07

如上所示,当没有错误发生时错误处理中间件就像不存在一样。但是,一旦出现错误所有的常规中间件都被跳过,那么处理流程就会是这样:

04_08

虽然 Express 没有做出强制规定,但是一般错误处理中间件都会放在中间件栈的最下面。这样所有之前的常规中间件发生错误时都会被该错误处理中间件所捕获。

Express 的错误处理中间件只会捕获由 next 触发的错误,对于 throw 关键字触发的异常则不在处理范围内。对于这些异常 Express 有自己的保护机制,当请求失败时 app 会返回一个 500 错误并且整个服务依旧在持续运行。然而,对于语法错误这类异常将会直接导致服务奔溃。

现在通过一个简单示例来看看 Express 中的错误处理中间件。假设该应用对于用户的任何请求都是通过 res.sendFile 发生图片给用户。代码如下:

var express = require("express");
var path = require("path");
var app = express();

var filePath = path.join(__dirname, "celine.jpg");
app.use(function(req, res) {
  res.sendFile(filePath);
});
app.listen(3000, function() {
  console.log("App started on port 3000");
});

可以看到这是之前静态文件服务的简化版,对于任意请求都会发生 celine.jpg 图片。但是如果该文件不存在,或者是文件读取过程发生了错误该怎么办呢?这就需要一些机制来处理这种异常错误了,而这正是错误处理中间件存在的理由。

为了触发异常处理,我们在 res.sendFile 将异常回调函数补充完整。这个回调函数将会在文件发送之后得到执行并且该回调函数中有一个参数标记文件发送成功与否。代码示例如下:

res.sendFile(filePath, function(err) {
  if (err) {
    console.error("File failed to send.");
  } else {
    console.log("File sent!");
  }
});

当然,除了打印错误信息之外,我们还可以通过触发异常进入错误处理中间件函数,而该部分代码实现如下:

// ...
app.use(function(req, res, next) {
  res.sendFile(filePath, function(err) {
    if (err) {
      next(new Error("Error sending file!"));
    }
  });
});
// ...

异常触发后接下来就是错误处理中间件的实现了。

通常情况下我们都会首先将错误信息记录下来,而这些信息一般也不会展示给用户。毕竟将一长段的 JavaScript 栈调用信息展示给不懂技术的用户会给他们造成不必要的困惑。尤其是这些信息一旦暴露给了黑客,他们有可能就能逆向分析出网站是如何工作的从而造成信息风险。

下面,我们仅仅在处理处理中间件中打印错误信息而不做任何进一步的处理。它与之前的中间件类似只不过这里打印错误信息而不是请求信息。将下面代码复制到所有常规中间件的后面:

// ...

app.use(function(err, req, res, next) {
    // 记录错误
    console.error(err);
    // 继续到下一个错误处理中间件
    next(err);
});
// ... 

现在,当程序出现异常之后这些错误信息都将会被记录在控制台以便后面的进一步分析。当然,这里还有一些事情需要处理,例如:对请求作出响应。将下面代码放在上一个中间件之后:

// ...

app.use(function(err, req, res, next) {
  // 设置状态码为500
  res.status(500);
  // 发送错误信息
  res.send("Internal server error.");
});
// ...

请记住,这些错误处理中间件不管所在位置如何它都只能通过带参 next 进行触发。对于这个简单应用来说可能没有那么多异常和错误会触发错误处理中间件。但是随着应用的扩张,你就需要对错误行为进行仔细测试。如果发生了异常,那么你应该对妥善的处理好这些异常而不是让程序崩溃。

总结

在本文中我们仔细探讨了 Express 的核心模块:中间件。其中的内容包括:

  • Express 中间件栈的概念以及工作流。

  • 如何编写自定义的中间件函数。

  • 如何编写错误处理中间件。

  • 常见中间件模块的使用。