3. Express 基础
如之前看到的那样,Node.js 已经内置了很多的模块,其中最重要的就是 http 。Node 构建 web 应用都是构建在这个以此为基石进行的。
竟然通过 HTTP 模块也能构建一个完整的 web 服务,但是该模块的功能还是有点不够多,而且开发效率也不够高,代码也不易维护。
这也是 Express 出现的背景,当你深入研究后就会发现,Express 其实是在 Node 内置的 HTTP 模块上构建了一层抽象。理论上,纯 Node 不使用 Express 也能实现功能。但是,Express 效率明显更高对开发者也更友好,毕竟它已经将一些坑填好了开发者无需自己去爬。
在这一章中,我们将基于前面介绍的 Node 内容去探究 Express 和 Node 之间的关系,其中包括:中间件和路由等概念。后面的章节会对这些内容进行更深入的讲解,所以本章只会进行一些综述。
总的来说,Express 提供了 4 个主要特性:
- 与纯 Node 中使用一个函数处理所有请求的代码不同, Express 则使用“中间件栈” 处理流。
- 路由与中间件类似,只有当你通过特定 HTTP 方法访问特定 URL 时才会触发处理函数的调用。例如, 当你访问 yourwebsite.com/about 只会触发一个请求处理函数。
- Express 通过一系列函数拓展了 request 和 response,提高了开发友好度。
- 视图模块允许你动态渲染和改变 HTML 内容,并且使用其他语言编写 HTML 。
中间件
中间件时 Express 中最大的特性之一。中间件与原生的 Node 处理函数非常类似(接受一个请求并做出响应),但是与原生不同的是,中间件将处理过程进行划分,并且使用多个函数构成一个完整的处理流程。
我们将会看到中间件在代码中的各种应用。例如,首先使用一个中间件记录所有的请求,接着在其他的中间件中设置 HTTP 头部信息,然后继续处理流程。虽然在一个“大函数”中也可以完成请求处理,但是将任务进行拆分为多个功能明确独立的中间件明显更符合软件开发中的 SRP 规则。如果你现在还是很困惑的话,不要紧,后面有具体的示例。
中间件并不是 Express 特有,Python 的 Django 或者 PHP 的 Laravel 也有同样的概念存在。同样的 Ruby 的 web 框架中也有被称为 Rack 中间件概念。虽然,你对这个概念并不陌生,但是 Express 还有有一些自己的特点。
现在我们就用 Express 中间件来重新实现 Hello World 应用。你将会发现只需几行代码就能完成开发,在提高效率的同时也消除了一些隐藏 bug。
Express 版 Hello World
首先新建一个Express工程:新建一个文件夹并在其中新建 package.json 文件。回想一下 package.json 的工作原理,其中完整的列出了该工程的依赖、项目名称、作者等信息。我们新工程中的 package.json 大致如下:
{
"name": "hello-world",
"author": "Your Name Here!",
"private": true,
"dependencies": {}
}
接下来执行命令,安装最新的 Express 并且将其保存到 package.json 中:
npm install express -sava
命令执行完成后,Express 会自动安装到 node_modules 的文件下,并且会在 package.json 明确列出改依赖。此时 package.json 中的内容如下:
{
"name": "hello-world",
"author": "Your Name Here!",
"private": true,
"dependencies": {
"express": "^5.0.0"
}
}
接下来将下列代码复制到 app.js 中:
var express = require("express");
var http = require("http");
var app = express();
app.use(function(request, response) {
response.writeHead(200, { "Content-Type": "text/plain" });
response.end("Hello, World!");
});
http.createServer(app).listen(3000);
在代码中,我们首先引入了 Express 。然后和之前一样引入了 Node 的 HTTP 模块。
然后,使用 express() 方法创建变量 app ,该方法会返回一个请求处理函数闭包。这一点非常重要,因为它意味着我可以像之前一样将其传递给 http.createServer 方法。
还记得前一章提到的原生 Node 请求处理吗?它大致如下:
var app = http.createServer(function(request, response) {
response.writeHead(200, { "Content-Type": "text/plain" });
response.end("Hello, world!");
});
两段代码非常相似(实际上,这里是复制粘贴),回调闭包里面包含两个参数并且做出了相同的响应。
最后,我们创建了一个服务并且启动了它。http.createServer 接受的参数是一个函数,所以合理猜测 app 也只是一个函数,只不过该函数表示的是 Express 中一个完整的中间件处理流程。
与人们将 request、response 缩写为 req、res 一样,http.createServer 也可以使用 app.listen(3000) 进行简写。
中间件如何在高层级工作
在原生的 Node 代码中,所有的 HTTP 请求处理代码如下:
function requestHandler(request, response) {
console.log("In comes a request to: " + request.url);
response.end("Hello, world!");
}
在没有中间件的世界中,你会发现所有的请求集中在一个函数中处理。如果抽象成流程图的话,它看起来就像:
这并不是说在处理过程中不能调用其它函数,而是所有的请求响应都由该函数发送。
而中间件则使用一组中间件栈函数来处理这些请求,处理过程如下图:
那么,接下来我们就有必要了解 Express 使用一组中间件函数的缘由,以及这些函数作用。
现在我们回顾一下第 1 章中的用户验证的例子。如果验证通过,将会展示用户的私密信息。另外,不管验证通过与否,我们都需要记录每次访问请求。
在这个应用中存在三个中间件函数:请求记录、用户验证、信息展示。中间件工作流为:先记录每个请求,然后进行用户验证,验证通过进行信息展示,最后对请求做出响应。
整个工作流有两种可能情形,如下图:
在每个中间价函数里都可以对 request、response 进行修改,虽然大多数时候不需要这么做。这些中间件函数中部分函数需要对响应做出响应。它可能是第一个,也可能是最后一个。如果没有做出任何响应的话,那么服务器会挂起请求而浏览器也会干等。
这样做的好处就是,我们可以将应用进行拆分。而拆分后的组件也利于后期维护,并且组件之间还可以进行不同组合,最后拆分后的小块也容易引入第三方中间件。
后面的例子将会让你对此有更清晰的认知。
不做任何修改的中间件
中间件函数可以对 request、response 进行修改,但它并不是必要操作。例如,前面的日志记录中间件代码:他只需要进行记录操作。
一个不做任何修改,纯功能性的中间函数代码大致如下:
function myFunMiddleware(request, response, next) {
...
nest();
}
因为中间件函数的执行是从上到下的。所以,加入纯功能性的请求记录中间件后,代码如下:
var express = require("express");
var http = require("http");
var app = express();
// 日志记录中间件
app.use(function(request, response, next) {
console.log("In comes a " + request.method + " to " + request.url);
next();
});
// 发送实际响应
app.use(function(request, response) {
response.writeHead(200, { "Content-Type": "text/plain" });
response.end("Hello, world!");
});
http.createServer(app).listen(3000);
修改 request、response 的中间件
并不是所有的中间件都和上面一样,大部分的中间件函数都会对 request、response 进行处理,尤其是后者。
下面我们来实现前面提到的验证中间件函数。为了简单起见,这里只允许当前分钟数为偶数的情况通过验证。那么,该中间件函数代码大致如下:
app.use(function(request, response, next) {
console.log("In comes a " + request.method + " to " + request.url);
next();
});
app.use(function(request, response, next) {
var minute = (new Date()).getMinutes();
// 如果在这个小时的第一分钟访问,那么调用next()继续
if ((minute % 2) === 0) {
next();
} else {
// 如果没有通过验证,发送一个403的状态码并进行响应
response.statusCode = 403;
response.end("Not authorized.");
}
});
app.use(function(request, response) {
response.end('Secret info: the password is "swordfish"!'); // 发送密码信息
});
当一个请求到来,这些中间件函数将会依次之前示例图中的顺序执行。
第三方中间件类库
与其他程序设计一样,也许你正在尝试的工作别人早你之前就做过了。虽然,你可以自己动手实现所有中间件,但是通常你会发现针对该功能社区里已经有轮子了。
下面,介绍一些既常见又非常有用的第三方中间件类库。
MORGAN:日志记录中间件
我们将会使用 Morgan 替换上面自己的日志中间件,该日志记录中间件的功能非常丰富。使用它的理由有很多。首先,它能对用户行为进行记录。虽然还没到达可以用于市场分析的地步,但是对于分析用户行为导致的莫名崩溃非常有用。另外,我发现在开发的时候它同样十分实用:你能知道每个请求发起的时间。如果出现了错误,你可以通过 Morgan 的日志中查找可能的原因。
使用命令 npm install morgan --save 安装该中间件,并修改 app.js 中的代码:
var express = require("express");
var logger = require("morgan");
var http = require("http");
var app = express();
app.use(logger("short"));
app.use(function(request, response){
response.writeHead(200, {"Content-Type": "text/plain"});
response.end("Hello, world!");
});
http.createServer(app).listen(3000);
再次访问 http://localhost:3000 你就会看到 Morgan 记录的日志了。
Express 的静态文件中间件
除了 Morgan 之外,还有更多的中间件值得一试。
通过网络发送静态文件对 Web 应用来说是一个常见的需求场景,包括图片资源、CSS 文件以及静态 HTML 文件。
一个简单的文件发送行为其实代码量很大,因为需要检查大量的边界情况以及性能问题的考量。而 Express 内置的 express.static 模块能最大程度简化工作。
假设现在需要对 public 文件夹提供文件服务,我们看看 Express 的静态文件中间件是如何应对的:
var express = require("express");
var path = require("path");
var http = require("http");
var app = express();
var publicPath = path.resolve(__dirname, "public");
app.use(express.static(publicPath));
app.use(function(request, response) {
response.writeHead(200, { "Content-Type": "text/plain" });
response.end("Looks like you didn't find a static file.");
});
http.createServer(app).listen(3000);
现在,任何在 public 目录下的静态文件都能直接请求了,所以你可以将所有需要的文件的放在该目录下。如果 public 文件夹中没有任何匹配的文件存在,它将继续执行下一个中间件并响应一段没有匹配的文件信息。
为什么使用 path.resolve ?
之所以不直接使用 /public 是因为 Mac 和 Linux 中目录为 /public 而 万恶的 Windows 使用反斜杠 \public 。path.resolve 就是用来解决多平台目录路径问题。
更多中间件
前面已经介绍了 Morgan 中间件和 Express 静态中间件。除此之外,还有很多其他功能强大的中间件,例如:
connect-ratelimit:可以让你控制每小时的连接数。如果某人向服务发起大量请求,那么可以直接返回错误停止处理这些请求。
helmet:可以添加 HTTP 头部信息来应对一些网络攻击。具体内容会在后面关于安全的章节讲到。
cookie-parses:用于解析浏览器中的 cookie 信息。
response-time:通过发送 X-Response-Time 信息,让你能够更好的调试应用的性能。
这些中间件大部分都会在下一章中用到。
如果你想查找更多中间件的话,你可以去试试去搜索 Express middleware 或者 Connect middleware 关键词。
路由
路由是一种将 URL 和 HTTP 方法映射到特定处理回调函数的技术。假设工程里有一个主页,一个关于页面以及一个 404 页面,接下来看看路由是如何进行映射的:
var express = require("express");
var path = require("path");
var http = require("http");
var app = express();
// 像之前一样设置静态文件中间件。
// 所有的请求通过这个中间件,如果没有文件被找到的话会继续前进
var publicPath = path.resolve(__dirname, "public");
app.use(express.static(publicPath));
// 当请求根目录的时候被调用
app.get("/", function(request, response) {
response.end("Welcome to my homepage!");
});
// 当请求/about的时候被调用
app.get("/about", function(request, response) {
response.end("Welcome to the about page!");
});
// 当请求/weather的时候被调用
app.get("/weather", function(request, response) {
response.end("The current weather is NICE.");
});
// 前面都不匹配,则路由错误。返回 404 页面
app.use(function(request, response) {
response.statusCode = 404;
response.end("404");
});
http.createServer(app).listen(3000);
代码中除了那几个特定路由之外,还和之前一样添加了静态文件的中间件。
后面三个 app.get 函数就是 Express 中强大的路由系统了。当然,你也可以使用
这三个调用app.get的就是Express神奇的路由系统。他们同样可以app.post,用于响应一个 POST 或者 PUT 等所有网络请求。函数中第一个参数是一个路径,例如 /about 或者 /weather 或者简单的根目录 / ,第二个参数是一个请求处理函数,类似于你之前看到的中间件部分。
该处理函数与之前的中间件工作方式一样,问题是它会在什么时候被调用。
除了固定路由形式外,它还可以匹配更复杂的路由(使用正则等方式):
// 指定“hello”为路由的固定部分
app.get("/hello/:who", function(request, response) {
// :who 并不是固定住,它表示 URL 中传递过来的名字
response.end\("Hello, " + request.params.who + "."\);
});
重启服务并访问 localhost:3000/hello/earth 等到的响应信息为:
Hello, earth
注意到如果你在 URL 后面插入多个 / 的话,例如:localhost:3000/hello/entire/earth 将会返回一个 404 错误。
你应该在日常生活中见过这种 URL 链接,特定的用户能够访问特定的 URL 。例如,有一个用户为 ExpressSuperHero ,那么他的个人信息页面 URL 可能是:
在 Express 中你可以通过这种通配方式简化路由定义,而不必将所有用户的特定路由都一一列举出来。
官方文档中还展示了一个使用正则表达式来进行复杂匹配的例子,并且你可以通过路由做更多其它的事情。不过这章中只需要知道路由概念就行了,更多的内容将会在第五章中深入讲解。
扩展 request 和 response
Express 中间件函数中的 request、response 在原来基础上功能得到了更多的拓展和提升。虽然能在官方文档中找到所有细节内容,不过我们可以先来领略其中的一部分:
Express 提供的功能中 redirect 算一个非常棒的功能,使用方法如下:
response.redirect("/hello/world");
response.redirect("http://expressjs.com");
原生 Node 中并没有重定向 redirect 方法,虽然,能够使用原生代码实现该功能,但它明显代码量会更多。
Express 中发送了文件发送的函数 sendFile,只需一行代码就能实现:
response.sendFile("path/to/cool_song.mp3")
与前面一样,该函数在原生 Node 中是不存在的,而且原生实现代码也比较复杂。
除了对响应对象 response 进行了拓展之外,Express 也对请求对象 request 进行了拓展。例如:你可以通过 request.ip 获取发送请求的机器 IP 地址或者通过 request.get 获取 HTTP 头部。
下面我们使用这些方法实现 IP 黑名单功能,代码如下:
var express = require("express");
var app = express();
var EVIL_IP = "123.45.67.89";
app.use(function(request, response, next) {
if (request.ip === EVIL_IP) {
response.status(401).send("Not allowed!");
} else {
next();
}
});
...
这里使用到了 req.ip 以及 res.status() 和 res.send() ,而这些方法全都来自于 Express 的拓展。
理论上来说,我们只需要知道 Express 拓展了 request 和 response 并知道如何使用就行了,至于细节可以不去做了解。
上面的例子,只是 Express 所有拓展中的冰山一角,你可以在文档中看到更多的示例。
视图
所有的网站内容都是基于 HTML 进行展示的,而该技术诞生于很久之前。尽管,单页应用十分流行,但是大多时候 HTML 内容都是动态生成的。你可能需要为当前登录用户提供特定欢迎页或者需要在页面中动态生成数据表。
而动态 HTML 的生成多数情况下都需要使用到视图模版引擎。在 Express 中它们是:EJS、Handlebars、Pug 等等,甚至可能是从其他语言中移植过来的模版语言,如 Swig 和 HAML 。所有的这些引擎最终都会将内容动态的生成为 HTML 页面。
在后面的示例中,我们将会使用 EJS 模版引擎。之所以选择一方面是因为它在开发者中很受欢迎,另一方面它的作者也是 Express 的作者。
下面是使用视图模版引擎的示例代码:
var express = require("express");
var path = require("path");
var app = express();
// 告诉 Express 你的视图存在于一个名为 views 的文件夹中
app.set("views", path.resolve(__dirname, "views"));
// 告诉 Express 你将使用EJS模板引擎
app.set("view engine", "ejs");
后面我们还会在上诉代码中添加更多的内容。
在代码中,首先我们导入了必要的模块。然后设置了视图文件所在的路径。紧接着,我们将模版引擎设置为 EJS (文档)。EJS 是一种能将代码编译为 HTML 的模版语言。我们使用 npm install ejs --save 进行安装。
安装并设置好 EJS 引擎之后,接下里就是如何使用的问题了。
首先,我们在 views 文件夹下面创建一个 index.ejs 文件,并拷贝下面的内容:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Hello, world!</title>
</head>
<body>
<%= message %>
</body>
</html>
形式上看起来非常像 HTML ,但是 body 体内确有另一种语法标签。EJS 实质上是 HTML 的一个超集,所有 HTML 的语法都可以直接使用并且完全兼容。但是 EJS 也拓展了臆想自己的语法和特性,例如变量插入。当你在 Express 进行视图渲染的过程中,<%= message %> 语法会将传递过来的参数 message 插入其中。,例如:
app.get("/", function(request, response) {
response.render("index", {
message: "Hey everyone! This is my webpage."
});
});
Express 给 response 对象添加了一个名为 render 的方法。该方法在视图目录下查找第一个参数对应的模版视图文件并将第二个参数传递给该模版文件。
下面是经过引擎渲染动态生成后的 HTML 文件内容:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Hello, world!</title>
</head>
<body>
Hey everyone! This is my webpage.
</body>
</html>
虽然 EJS 是最流行的视图引擎方案,但是依旧还有很多其它的选择。而这些模版引擎的细节将会在后面章节中带来。现在,我们需要对前面所有这些内容进行一次实践来加深了解。
实例:一个留言板的实现
如果你像我一样,经历过早起的互联网。那么你对页面上不便浏览的 GIF 动画,杂乱的代码和新罗马字体一定不陌生。在这部分中,我们将会重新实现旧时代的一个组件:留言簿。留言簿的功能很简单:用户可以在线编辑新留言,并且可以浏览他人的留言。
我们将会使用到前面的技术来构建一个完整的留言板 web 程序。通过这个示例来加深对这些技术的了解,该应用中有两个网页:
- 一个主页:主要用于列出之前所有的留言
- 一个编辑页面:用于编辑新的留言
下面就开始进行正式开发。
准备工作
新建一个文件夹并新建项目,在文件中创建 package.json 文件,其中内容如下:
{
"name": "express-guestbook",
"private": true,
"scripts": {
"start": "node app"
}
}
你可以在文件中添加其他字段信息(例如作者或者版本),但是在本例中这并不是必要信息。接下来,我们安装依赖文件,输入命令:
npm install express morgan body-parser ejs --save
除了 body-parser 之外,其他模块应该对你来说不陌生。我们将会通过 HTTP POST 请求来实现流言的新建发布,所以需要 body-parser 对 POST 请求进行解析。
安装完成后,检查一下 package.json 文件,看看这些依赖是否都已经保存进去了。如果没有的话,请确保安装依赖项的时候使用了 --save 标记。
核心代码
确保准备工作完成后,接下来就创建 app.js 文件并复制下面的代码:
var http = require("http");
var path = require("path");
var express = require("express");
var logger = require('morgan');
var bodyParser = require("body-parser");
var app = express();
// 设置引擎
app.set("views", path.resolve(__dirname, "views"));
app.set("view engine", "ejs");
// 设置留言的全局变量
var entries = [];
app.locals.entries = entries;
// 使用 Morgan 进行日志记录
app.use(logger("dev"));
// 设置用户表单提交动作信息的中间件,所有信息会保存在 req.body 里
app.use(bodyParser.urlencoded({ extended: false }));
// 当访问了网站根目录,就渲染主页(位于views/index.ejs)
app.get("/", function(request, response) {
response.render("index");
});
// 渲染“新留言”页面(位于views/index.ejs)当get访问这个URL的时候
app.get("/new-entry", function(request, response) {
response.render("new-entry");
});
// POST 动作进行留言新建的路由处理
app.post("/new-entry", function(request, response) {
// 如果用户提交的表单没有标题或者内容,则返回一个 400 的错误
if (!request.body.title || !request.body.body) {
response.status(400).send("Entries must have a title and a body.");
return;
}
// 添加新留言到 entries 中
entries.push({
title: request.body.title,
content: request.body.body,
published: new Date()
});
// 重定向到主页来查看你的新条目
response.redirect("/");
});
// 渲染404页面,因为你请求了未知资源
app.use(function(request, response) {
response.status(404).render("404");
});
// 在3000端口启动服务器
http.createServer(app).listen(3000, function() {
console.log("Guestbook app started on port 3000.");
});
新建视图
上面的代码引入了一下视图,所有接下来我们需要将其补充完整。新建 views 文件夹,然后在该目录下新建 header.ejs 文件,代码如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Express Guestbook</title>
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
</head>
<body class="container">
<h1>
Express Guestbook
<a href="/new-entry" class="btn btn-primary pull-right">
Write in the guestbook
</a>
</h1>
这里使用了 Twitter 的 Bootstrap 框架,当然你也可以进行任意替换。最重要的一点是,该文件会做为所有页面的通用头部。
接下来,在相同目录下新建 footer.ejs 作为通用的 footer:
</body>
</html>
通用部分完成后,接下来就是 index、new-entry、404 页面文件了。复制下面代码到文件 views/index.ejs 中:
<% include header %>
<% if (entries.length) { %>
<% entries.forEach(function(entry) { %>
<div class="panel panel-default">
<div class="panel-heading">
<div class="text-muted pull-right">
<%= entry.published %>
</div>
<%= entry.title %>
</div>
<div class="panel-body">
<%= entry.body %>
</div>
</div>
<% }) %>
<% } else { %>
No entries! <a href="/new-entry">Add one!</a>
<% } %>
<% include footer %>
同时将下面的代码复制到 views/new-entry.ejs 中
<% include header %>
<h2>Write a new entry</h2>
<form method="post" role="form">
<div class="form-group">
<label for="title">Title</label>
<input type="text" class="form-control" id="title" name="title" placeholder="Entry title" required>
</div>
<div class="form-group">
<label for="content">Entry text</label>
<textarea class="form-control" id="body" name="body" placeholder="Love Express! It's a great tool for building websites." rows="3" required></textarea>
</div>
<div class="form-group">
<input type="submit" value="Post entry" class="btn btn-primary">
</div>
</form>
<% include footer %>
最后就是 views/404.ejs 文件了:
<% include header %>
<h2>404! Page not found.</h2>
<% include footer %>
所有的视图文件都创建完成了,接下来就是运行服务了。
运行服务
如果你现在就使用 npm start 拉起服务,然后访问对应的 URL ,你就能见到下图所示的场景了。
这些图不禁让我想起了上世纪 90 年代的互联网世界。
接下来,我们回顾一下这个小项目的几个关键点:
- 使用了一个中间件来记录所有的请求,并且对不匹配的 URL 链接进行了 404 页面响应。
- 在新建留言后,我们将页面重定向到了主页。
- 在该工程里使用了 EJS 作为 Express 的模版引擎。并使用它实现了 HTML 文件的动态渲染。
总结
- Express 基于 Node 进行了工程拓展,使得开发过程更为流畅高效。
- Express 主要有四个部分构成。
- Express 的请求处理流程可以由多个中间件进行构建。
- Express 中流行的模版引擎为 EJS ,它能实现对 HTML 的动态渲染并且语法也更为友好。