中间件
在不使用框架的情况下,Node 提供的 API 就显得过于简单。所有的请求处理都是在一个函数里面,随着程序的扩张势必会导致该处理函数变的笨重不易维护。
Express 框架则可以很好的处理这些问题,其中最主要的技术就是使用中间件。中间件可以将 Node 中的单一处理函数进行拆分,而拆分后的小模块职责也更明确。有的中间件只负责日志记录,另一个对特殊请求进行解析,还有的可能负责用户认证。
在本章中,将会学到:
- 中间件是什么?
- 中间件栈以及请求处理的工作流。
- 中间件的使用。
- 如何实现自己的中间件。
- Express 中常用的第三方中间件。
理论上来说,中间件是 Express 框架中的最大构成部分了。在使用 Express 的大多数时候你都是编写中间件代码。希望在本章介绍后,你能对中间件有清晰的认知。
中间件和中间件栈
简单来说,所有的 web 应用的都处理流程不过是监听请求、解析请求、做出响应。
Node 在运行时会监听到所有的请求,然后将这些请求转化为 JavaScript 中的对象方便后续处理。其中 request 表示请求对象而 response 表示响应对象,有时候也简写为 req 和 res 。
这两个对象都将作为参数传入到处理函数,req 包含用户用户需要的内容,res 则是我们给用户的响应。
在响应信息处理完成后,你需要立刻调用 res.end 通知 Node 响应完成。Node 收到该信号后会将响应信息转为二进制流发送给客户端。
在原生 Node 代码中这两个参数之后传递给一个处理函数。而在 Express 则会传递给一组名为 中间件栈 的函数。Express 的工作流会沿着 中间件栈 进行传递:
中间件栈中的每个函数都有三个参数,前两个是来源于 Node 的 req 和 res 。另外, Express 还对这两个对象的方法进行了拓展。
第三个参数是一个函数对象,按照惯例我们称之为 next 。当 next 被调用后,Express 将会执行中间件栈的下一个函数,如图:
最后,该栈中最少有一个函数需要调用 res.end 方法结束响应处理。不过在 Express 中,你也可以调用 res.end、res.sendFile 等函数,因为这些函数内部都会调用原生的 res.end。虽然可以在工作流的任意函数中调用 res.end 方法,但是该调用只会调用一次,除非发生了错误。
纯概念讲解可能会比较抽象,下面就通过静态文件服务的示例来加深了解。
示例:一个静态文件服务器
创建一个文件夹并为此提供静态文件服务。你可以在文件夹中存放任何文件,例如:HTML 文件、图片、甚至是你翻唱的 MP3 歌曲。所有的这些文件都能通过示例程序进行网络访问。
该示例程序的功能大致包括:能够正确返回存在的文件;文件不存在时返回 404 错误;打印所有的访问请求。所以,该示例的中间件栈如下:
- 日志记录中间件。该函数会在终端打印所有的网络请求,并在打印介绍后继续下一个中间件函数。
- 静态文件发送中间件。如果访问的文件存在则返回给客户端。如果文件不存在则会跳到错误处理中间件。
- 404 处理中间件。如果文件不存在的话,该中间件将会给客户端发送 404 错误信息。
流程图如下:
明确示例的目标和需求后,接下来就该动手实现了。
准备工作
新建工程目录 static-file-fun,并在文件夹中新建 package.json,最后负责下面内容到 package.json 中:
{
"name": "static-file-fun", //定义你的包名
"private": true, // 告诉Node不将它发布到公开模块仓库
"scripts": {
"start": "node app.js" // 当你运行npm start,他将执行node app.js
}
}
接下来,我们执行 npm install express --save 安装最新版 Express 。安装完成后,package.json 的内容如下:
{
"name": "static-file-fun",
"private": true,
"scripts": {
"start": "node app.js"
},
"dependencies": {
"express": "^5.0.0" // 你的依赖版本可能会发生变化
}
}
接下来,在工程目录里新建文件夹 static ,并在其中放入一些图片、HTML 文件等资源。这些资源只是为了后面的示例演示所以文件内容是啥并不重要。
最后,我们新建 app.js 文件作为工程入口文件。所有这些都介绍后,工程目录结构大致如下:
如果想运行服务的话,你可以执行 npm start 命令。该命令会检查 package.json 中的 start 命令脚步并执。在本示例中,本质上就是执行 node app.js 命令。
因为 app.js 中还没有任何代码,所有上诉命令的执行并不会真正发生什么。
之所以使用 npm start 来替换 node app.js 命令,主要是基于以下原因: 首先,这是一个开发约定。因为,不同开发中的项目结构可能是不一样的,例如,有的人喜欢将入口文件命名为 application.js 而不是 app.js 。而使用 npm start 命令的话,你就不需要自己查找工程入口文件了。 其次,随着项目的扩张,除了需要启动入口文件之外,可能还需要拉起数据库服务,清除日志文件。而这些复杂任务全部可以封装到 start 脚本里并通过简单 npm start 命令得到执行。 最后就是,你可以通过 npm 脚本给项目添加新的命令,从而让所有依赖项安装在工程内部还不是全局,这让代码的测试和构建提供了极大的便利。
准备工作结束后,下面就是代码实现了。
第一个中间件:日志记录
先从请求日志中间件开始,一步步实现静态文件服务应用。
复制下面代码到 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 你就会发现该程序有严重的 bug。
虽然,程序成功打印了请求日志。但是浏览器会一直挂起等待响应知道出现超时错误。而该 bug 出现的原因是在日志中间件没有调用 next()。
理论上一个中间件函数处理结束后,它需要执行以下两种操作中的一个:
- 所有处理结束,发送 red.end 或者 Express 中的 red.sendFile 等函数结束响应。
- 调用 next 函数执行下一个中间件函数。
如果两者有一个成功的到执行那么上面的 bug 就会消失。如果像上面一样一个都没有执行的话,那么客户端将永远得不到响应,而且服务器的处理过程看起来会显得非常的慢。
找到原因之后,该 bug 的修复也会变的非常的简单。
// ...
app.use(function(req, res, next) {
console.log("Request IP: " + req.url);
console.log("Request date: " + new Date());
next(); // 新的这行很重要
});
// ...
如果此时重启服务并访问 http://localhost:3000 的话访问请求会被完整记录下来。另外,因为程序并没有中间件函数处理相关的 URL 请求,Express 会给客户端发送一个错误信息。
完成日志中间件后,接下来就该是静态文件服务中间件了。
静态文件服务中间件
静态文件服务中间件应该有以下几个功能:
- 检查目录中是否存在该文件
- 如果文件存在则调用 res.sendFile 结束响应处理。
- 如果文件不存在则继续调用下一个中间件从代码角度来说就是调用 next 。
这里先自己动手实现,然后再使用其他工具来压缩代码量。
其中我们需要使用内置的 path 模块指定路径,然后使用内置的 fs 模块判断文件释放存在。
将下面代码添加到 app.js 的日志中间件后面:
“// …
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() 继续执行下一个中间件。
如果工程的静态文件夹里包含一个名为 secret_plans.txt 文件,那么再次使用 npm start 启动服务并输入 localhost:3000/secret_plans.txt URL 就能访问该文件了。
如果访问的 URL 没有对应的文件的话就会出现之前一样的错误,这是因为程序调用了 next() 但是实际上后面并没有中间件了,所以下面需要实现最后一个中间件: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 地址在内的有用信息:
所以,到底发生了什么?
morgan 是一个函数并且它的返回值是一个中间件函数。当你调用它的时候,它会返回一个类似之间实现的日志中间件,只不过它有三个参数。这种通过调用函数返回中间件的工作方式也是很多第三方模块中最常用的一种。为了代码更加清晰,你也可以将代码改写为:
var morganMiddleware = morgan("short");
app.use(morganMiddleware);
这里使用了 Morgan 中的 short 作为输出选项。当然你可以使用其他的选项设置:combined 打印最多信息;tiny 打印最少的信息。而使用不同的输出选项得到的中间件是不一样的。
除了使用 Morgan 替换原有日志中间件之外,我们还可以使用内置的静态中间件替换之前的代码实现。
使用 Express 内置静态文件中间件
Express 中只内置了一个中间件模块,接下来我们将会使用它替换之前的静态文件中间件。
该内置中间件模块就是 express.static 。它的工作原理与之前的中间件代码类似,但是它具有更好的安全性和性能。例如,它在内部实现了资源的缓存功能。如果你想了解更多的细节内容,可以查看我的这篇博客。
与 Morgan 一样,express.static 函数的返回值也是一个中间件函数。我们只需为 express.static 函数指定路径参数即可。当然,这里的路径依然会使用 path.join 方法。
代码如下:
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() 会按序执行下一个中间件。其实,真实情况并不是这么简单。
其实,中间件类型有两种。
到目前为止,你已经接触了第一种类型:包含三个参数的常规中间件函数(有时 next 会被忽略而只保留两个参数),而绝大多数时候程序中都是使用这种常规模式。
第二种类型非常少见:错误处理中间件。当你的 app 处于错误模式时,所有的常规中间件都会被跳过而直接执行 Express 错误处理中间件。想要进入错误模式,只需在调用 next 时附带一个参数。这是调用错误对象的一种惯例,例如:next(new Error("Something bad happened!")) 。
错误处理中间件中需要四个参数,其中后面三个和常规形式的一致而第一个参数则是 next(new Error("Something bad happened!")) 中传递过来的 Error 对象。你可以像使用常规中间件一样来使用错误处理中间件,例如:调用 res.end 或者 next 。如果调用含参数的 next 中间件会继续下一个错误处理中间件否则将会退出错误模式并调用下一个常规中间件。
假设,现在有四个中间件依次排开,其中第三个为错误处理中间件而其他的都是常规中间件。如果没有出现错误的话,流程应该是:
如上所示,当没有错误发生时错误处理中间件就像不存在一样。但是,一旦出现错误所有的常规中间件都被跳过,那么处理流程就会是这样:
虽然 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 应用的中间件栈完全可以是不一样的。示例 app 的配置仅仅是常用组合中的一种,你完全可以使用不同的方案。
除了 Express 自带的 express.static 外,书中还会介绍很多其他的中间件。
Express 团队自己也维护着一些中间件模块,尽管它们并没有内置到 Express 中:
- body-parser:用于解析请求的 body 体。
- cookie-parser:用于解析用户的 cookies 实现用户行为追踪。它需要配合其他模块一起使用,例如 express-session 。
- compression:对响应数据进行压缩。
你可以在 Express 的资源页中找到所有的可用中间件列表。这里面有很多第三方的中间件模块值得去我们仔细研究:
- Helmet:用于提高应用的安全性。虽然它并不能完全保证应用的安全,但是通过少量的配置就能解决很多潜在的安全问题。
- connect-assets:对你的 CSS 和 JavaScript 文件进行编译压缩。它同样适用于如:SASS、SCSS、LESS 和 Stylus 这类 CSS 预处理器。
总结
在这章中我们仔细探讨了 Express 的核心模块:中间件。其中的内容包括:
- Express 中间件栈的概念以及工作流。
- 如何编写自定义的中间件函数。
- 如何编写错误处理中间件。
- 常见中间件模块的使用。