NodeJS 开发者高级教程(四)
十一、HTTP
超文本传输协议,简称 HTTP,推动了网络的发展。HTTP 是一种基于文本的无状态协议,工作在 TCP 之上。在处理敏感数据时,也经常使用 HTTP 的加密版本,即 HTTP Secure 或 HTTPS。HTTP 是一种请求-响应协议,使用第十章中讨论的客户端-服务器编程模型来实现。传统上,浏览器被用作 HTTP 事务中的客户端,但是您会发现情况并非总是如此。当浏览器导航到给定的 URL 时,会向托管该 URL 的服务器发出 HTTP 请求。正如你在第十章中了解到的,这个请求通常是在 TCP 端口 80(或者 443,如果 HTTPS 正在使用的话)上发出的。服务器处理请求,然后响应客户端。这就是 HTTP 如何在非常高的层次上工作的。本章深入探讨了 Node.js 世界中的 HTTP。
基本服务器
在我们深入了解 HTTP 之前,让我们使用清单 11-1 中的代码创建一个简单的服务器应用。Node 的 HTTP API 在http核心模块中实现,在清单 11-1 的第一行导入。在下面一行中,http模块的createServer()方法用于创建一个 HTTP 服务器的新实例。与同名的等效 TCP 方法非常相似,createServer()返回的服务器是一个事件发射器,并不绑定到任何特定的端口。在清单 11-1 的最后一行,服务器使用listen()方法绑定到端口 8000。listen()的http版本也以与 TCP listen()方法相同的方式使用。
清单 11-1 。一个基本的 HTTP 服务器
var http = require("http");
var server = http.createServer(function(request, response) {
response.write("Hello <strong>HTTP</strong>!");
response.end();
});
server.listen(8000);
传递给createServer()的函数是一个可选的request事件处理程序,它在每次接收到新的 HTTP 请求时被调用。事件处理程序接受两个参数,request和response。request参数是http.IncomingMessage的一个实例,包含关于客户端请求的信息。另一方面,response参数是http.ServerResponse的一个实例,用于响应客户端。清单 11-1 中的处理程序使用write()和end()方法用一个简单的 HTML 字符串响应所有连接。您可能已经猜到,这些方法的行为类似于同名的 TCP 方法。
HTTP 请求的剖析
现在我们有了一个简单的 HTTP 服务器,我们可以开始向它发送请求。清单 11-2 中的显示了一个 HTTP 请求的例子。请求的第一行称为请求行,指定了请求方法、请求的 URL 和使用的协议。在这个例子中,请求方法是GET,URL 是/,协议是 HTTP 版。我们将很快解释其中每一个的含义,但是首先让我们检查一下示例 HTTP 请求的其余部分。请求行之后是一组请求头,用于参数化请求。在清单 11-2 中,仅包含了Host标题。这个头在 HTTP 1.1 中是强制性的,用于指定被请求的服务器的域名和端口。虽然没有包含在这个例子中,但是一个请求还可以包含一个主体,用于向服务器传递附加信息。
清单 11-2 。手工制作的 HTTP 请求
GET / HTTP/1.1
Host: localhost:8000
由于 HTTP 是基于文本的协议,我们可以很容易地使用telnet手工创建请求。清单 11-3 展示了来自清单 11-2 的请求是如何使用telnet 发送给示例服务器的。需要注意的是,HTTP 请求必须以一个空行结束。在清单 11-3 中,该空行显示在Host标题之后。
清单 11-3 。清单 11-1 中连接到服务器的telnet会话
$ telnet localhost 8000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
GET / HTTP/1.1
Host: localhost:8000
HTTP/1.1 200 OK
Date: Sun, 21 Jul 2013 22:14:26 GMT
Connection: keep-alive
Transfer-Encoding: chunked
1c
Hello <strong>HTTP</strong>!
0
请求的终止空行之后的所有内容都是服务器发送的响应的一部分。响应以指定协议的状态行、、状态代码和原因短语开始。同样,协议是 HTTP 1.1。200状态代码表示请求成功,原因短语用于提供状态代码的简短描述。状态行后面是一组响应头。服务器使用响应头的方式与客户端使用请求头的方式相同。响应头后面是另一个空行,然后是响应体。值1c是表示车身长度的十六进制值。在这种情况下,主体是服务器返回的 HTML 字符串。
请求方法
HTTP 请求的请求行以请求方法开始,后面是请求资源的 URL。请求方法也称为 HTTP 谓词,用于指定要在指定的 URL 上执行的操作。例如,在清单 11-2 中,对位于/的资源发出了一个GET请求。GET请求的目的是查看指定的资源(例如,GET要在浏览器中显示的网页)。另一个常见的 HTTP 动词是POST,它允许客户端向服务器提交数据。POST请求通常用于提交 HTML 表单。表 11-1 列出了 HTTP 1.1 支持的各种 HTTP 动词。以前 HTTP 1.0(现在还在用)只支持GET、POST和HEAD请求。
表 11-1 。各种 HTTP 请求方法
|
方法
|
描述
|
| --- | --- |
| GET | 检索指定资源的表示形式。一个GET请求不应该改变服务器的状态,本质上是一个读操作。 |
| HEAD | 检索与等效的GET请求相同的数据,只是应该省略响应体。这有助于快速检索资源的响应头,而不会产生传输整个正文的开销。HEAD请求的一个用例是简单地检查资源是否存在,而不下载其全部内容。 |
| POST | 用于在服务器上创建新资源。POST请求的典型用途是提交 HTML 表单和向数据库添加数据。 |
| PUT | PUT请求类似于POST请求;然而,PUT是用来更新服务器上的现有资源的。如果资源不存在,服务器可以创建它。 |
| DELETE | 用于从服务器中删除资源。 |
| TRACE | 回传给客户。这对于检测中间服务器所做的任何更改非常有用。 |
| OPTIONS | 返回给定 URL 支持的动词列表。 |
| CONNECT | 用于创建通过代理服务器的隧道。代理将代表客户端建立连接。连接建立后,代理只是在客户机和远程服务器之间转发 TCP 流量。该技术允许加密的 HTTPS 流量通过未加密的 HTTP 通道进行代理。 |
| PATCH | PATCH方法类似于PUT。然而,PATCH用于对现有资源进行部分更新。这与PUT不同,后者应该在更新期间重新提交整个资源。 |
清单 11-4 中的例子显示了每个连接的请求行。请求行中的所有信息都可以通过http.IncomingMessage类访问。具体来说,这个例子使用了method、url和httpVersion属性来重新创建请求行。
清单 11-4 。显示每个传入连接的请求行的服务器
var http = require("http");
var server = http.createServer(function(request, response) {
var requestLine = request.method + " " + request.url +
" HTTP/" + request.httpVersion;
console.log(requestLine);
response.end();
});
server.listen(8000);
请求标题
从客户端发送的请求头集合告诉服务器如何正确处理请求。你已经看到了一个包含Host头的例子;然而,还有许多其他常用的请求头。例如,Accept头用于请求某种格式的数据。当资源有多种格式(JSON、XML、HTML 等)时,这个头非常有用。在这个场景中,客户端可以通过将Accept头设置为适当的Content-Type ( application/json、application/xml、text/html等等)来请求特定的数据格式。当涉及到响应头时,我们会更详细地讨论。表 11-2 中显示了常见请求标题的非详尽列表。
表 11-2 。几种常见的 HTTP 请求头
|
页眉
|
描述
|
| --- | --- |
| Accept | 指定客户端愿意为此请求接受的Content-Types。 |
| Accept-Encoding | 提供可接受的编码列表。许多服务器可以压缩数据以加快网络传输速度。这个头告诉服务器客户机可以处理哪些压缩类型(gzip、deflate 等等)。 |
| Cookie | 服务器存储在客户机上的小块数据。Cookie头包含客户端当前为服务器存储的所有 cookies。 |
| Content-Length | 请求正文的长度,以八位字节为单位。 |
| Host | 服务器的域和端口。这个头在 HTTP 1.1 中是强制性的。当多台服务器托管在同一台机器上时,此标头非常有用。 |
| User-Agent | 标识客户端类型的字符串。这通常包含浏览器名称、版本和操作系统等信息。 |
请求头可以通过http.IncomingMessage类的headers属性访问。清单 11-5 提供了一个打印出每个请求标题的例子。
清单 11-5 。显示每个传入连接的请求标头的服务器
var http = require("http");
http.createServer(function(request, response) {
console.log(request.headers);
response.end();
}).listen(8000);
Response Codes
每个 HTTP 响应的状态行都包括一个数字状态代码,以及描述该代码的原因短语。原因短语只是装饰性的,而状态代码实际上是由客户端使用的,它与响应头一起决定了如何处理响应。表 11-3 包含几个常见(和一个不常见)HTTP 响应状态代码和原因短语的列表。
表 11-3 。几个常见的(和一个滑稽的)HTTP 响应代码和原因短语
|
状态代码和原因短语
|
描述
|
| --- | --- |
| 200 OK | 指示 HTTP 请求已成功处理。 |
| 201 Created | 指示请求已经完成,并且已经在服务器上创建了新的资源。 |
| 301 Moved Permanently | 请求的资源已永久移动到新的 URL。Location响应头应该包含重定向到的新 URL。 |
| 303 See Other | 可以通过对在Location响应头中指定的 URL 的GET请求来找到所请求的资源。 |
| 304 Not Modified | 指示缓存的资源尚未被修改。为了提高性能,304响应不应该包含主体。 |
| 400 Bad Request | 表示请求格式不正确,无法理解。这方面的一个例子是缺少必需参数的请求。 |
| 401 Unauthorized | 如果某个资源需要身份验证,而所提供的凭证被拒绝,那么服务器将使用此状态代码进行响应。 |
| 404 Not Found | 服务器找不到请求的 URL。 |
| 418 I'm a Teapot | 这个状态代码是作为愚人节玩笑引入的。实际的服务器不应该返回这个状态代码。 |
| 500 Internal Server Error | 服务器在尝试完成请求时遇到错误。 |
在http模块中,通过它的STATUS_CODES属性,可以获得 HTTP 状态代码的更详细的列表。STATUS_CODES是一个将数字状态代码映射到原因短语字符串的对象。清单 11-6 中的例子显示了与404状态代码相对应的原因短语。
清单 11-6 。使用http.STATUS_CODES的例子
var http = require("http");
console.log(http.STATUS_CODES[404]);
// displays "Not Found"
您可以使用响应对象的statusCode属性来设置其状态代码。如果没有明确提供状态代码,该值默认为200。设置statusCode属性的示例服务器如清单 11-7 所示。如果请求 URL /foo,服务器将用一个200状态代码和一个 HTML 响应体来响应。但是,如果请求任何其他 URL,服务器会以一个404错误做出响应。
清单 11-7 。该示例根据请求的 URL 提供不同的响应
var http = require("http");
http.createServer(function(request, response) {
if (request.url === "/foo") {
response.end("Hello <strong>HTTP</strong>");
} else {
response.statusCode = 404;
response.end();
}
}).listen(8000);
响应标题
响应头与响应状态代码一起用于解释从服务器发回的数据。在表 11-4 中显示了一些更常见的响应报头。
表 11-4 。几种常见的 HTTP 响应头
|
页眉
|
描述
|
| --- | --- |
| Cache-Control | 指定是否可以缓存资源。如果可以,这个头指定它可以在任何缓存中存储的时间长度,以秒为单位。 |
| Content-Encoding | 指定对数据使用的编码。这允许服务器压缩响应,以便通过网络更快地传输。 |
| Content-Length | 响应正文的长度,以字节为单位。 |
| Content-Type | 指定响应正文的 MIME 类型。本质上,这个头告诉客户端如何解释数据。 |
| Location | 当客户端被重定向时,目标 URL 存储在这个头中。 |
| Set-Cookie | 在客户端创建新的 cookie。这个 cookie 将包含在未来请求的Cookie头中。 |
| Vary | 用于规定哪些请求头影响缓存。例如,如果一个给定的资源有一个以上的表示,而Accept请求头用于区分它们,那么Accept应该包含在Vary头中。 |
| WWW-Authenticate | 如果为给定的资源实现了一个身份验证方案,这个头用于标识该方案。一个示例值是Basic,对应于 HTTP 基本认证。 |
表 11-4 中一个特别重要的表头是Content-Type 。这是因为Content-Type头告诉客户端它正在处理哪种数据。为了演示这一点,使用浏览器连接到清单 11-1 中的示例服务器。图 11-1 显示了使用谷歌 Chrome 浏览器的结果。此外,Chrome 的开发工具已经被用来记录 HTTP 请求。请注意,响应中的 HTML 标记显示在屏幕上,而不是标记文本。通过检查响应,您可以看到服务器没有发回任何Content-Type报头。
图 11-1 。使用谷歌的 Chrome 浏览器连接到清单 11-1 中的服务器
幸运的是,http模块提供了几种创建响应头的方法。最简单的方法是使用response参数的setHeader()方法。这个方法有两个参数,头名和值。标头名称始终是一个字符串。如果需要创建多个同名的头,该值应该是一个字符串或字符串数组。在清单 11-8 中,服务器被修改为返回一个Content-Type头。由于服务器发送回一个 HTML 字符串,Content-Type头应该告诉客户端将响应解释为 HTML。这是通过将头的值设置为text/html MIME 类型来实现的。
清单 11-8 。使用setHeader()方法设置Content-Type响应头
var http = require("http");
var server = http.createServer(function(request, response) {
response.setHeader("Content-Type", "text/html");
response.write("Hello <strong>HTTP</strong>!");
response.end();
});
server.listen(8000);
注意用
setHeader()创建的响应头可以用response.removeHeader()方法删除。该方法采用一个参数,即要移除的头的名称。你可能会问为什么这很重要。假设您有一个设置为使用缓存头进行缓存的资源。但是,在发送响应之前,会遇到一个错误。因为您不想缓存错误响应,所以可以使用removeHeader()方法来移除缓存头。
现在,尝试使用浏览器连接到清单 11-8 中的服务器。这一次,单词 HTTP 应该显示为粗体文本。图 11-2 显示了使用 Chrome 的结果页面,以及记录的 HTTP 请求。注意,响应头现在包括了Content-Type头。
图 11-2 。使用 Chrome 连接到清单 11-8 中的服务器
编写响应头的第二种方法是使用writeHead()方法。这个方法有三个参数。第一个是要返回的状态代码。第二个参数是可选的原因短语。最后一个参数是包含响应头的可选对象。清单 11-9 展示了来自清单 11-8 的服务器是如何使用writeHead()而不是setHeader()实现的。
清单 11-9 。使用writeHead()方法的示例
var http = require("http");
var server = http.createServer(function(request, response) {
response.writeHead(200, {
"Content-Type": "text/html"
});
response.write("Hello <strong>HTTP</strong>!");
response.end();
});
server.listen(8000);
请注意,在调用write()或end()之前必须设置头部信息。一旦write()或end()被调用,Node 将隐式调用writeHead(),如果你还没有显式这样做的话。如果您试图在此之后再次写入标题,您将会得到一个"Can't set headers after they are sent"错误。此外,每个请求只能调用一次writeHead()。如果您不确定头文件是否已经被写入,您可以使用response.headersSent属性来查找。headersSent保存一个布尔值,如果报头已经发送,则为true,否则为false。
Working with Cookies
因为 HTTP 是一种无状态协议,它不能直接记住客户机以前与服务器交互的细节。例如,如果您要访问同一个页面 1000 次,HTTP 会将每个请求视为第一次。显然,网页可以记住你的详细信息,比如你是否登录。那么,如何在无状态协议上维护状态呢?有几个选择。可以使用数据库或会话在服务器上维护状态。另一种方法是将客户机上的数据存储在 cookie 中。每种方法都有优点和缺点。将数据存储在服务器上的优点是不容易被篡改。缺点是所有的状态信息都会消耗服务器上的内存。对于负载很重的服务器,内存消耗很快就会成为一个问题。另一方面,使用 cookies 在客户机上维护状态更具可伸缩性,但安全性较低。
提示尽管 cookies 比服务器存储的状态更具可伸缩性,但您仍然应该谨慎使用它们。每个 HTTP 请求,包括对图像、脚本、样式表等的请求,站点的 cookies 都会在的
Cookie头中发送回服务器。所有这些数据都会增加网络延迟。缓解这个问题的一种方法是将静态资产(如图像)存储在不使用任何 cookies 的单独的域或子域中。
最简单的形式是,cookie 只是一个名称/值对,用等号分隔。使用分号作为分隔符将多个 cookies 连接在一起。清单 11-10 中的显示了两个Set-Cookie响应头的例子。这些 cookies 的名字是name和foo,而值分别是Colin和bar。一旦这些 cookies 被设置,它们将被包含在未来的Cookie请求头中,如清单 11-11 所示。
清单 11-10 。两个Set-Cookie标题的示例
Set-Cookie: name=Colin
Set-Cookie: foo=bar
清单 11-11 。清单 11-10 中标题的Cookie标题
Cookie: name=Colin; foo=bar
Cookies 也可以使用属性进行参数化。各种 cookie 属性如表 11-5 所示。这些属性中的一些——比如Domain和Path—是给定值,而其他的——比如Secure和HttpOnly—是布尔属性,它们的值要么被设置,要么不被设置。
表 11-5 。各种 Cookie 属性的描述
|
属性
|
描述
|
| --- | --- |
| Domain | 限制 cookie 的范围,使其仅在给定域请求时发送到服务器。如果省略,则默认为设置 cookie 的资源的域。 |
| Path | 将 cookie 的范围限制为所提供路径中包含的所有资源。如果省略,Path默认为/,适用于所有资源。 |
| Expires | 包括 cookie 应该被删除并且不再有效的日期。 |
| Max-Age | 还指定 cookie 应该何时过期。但是,Max-Age被指定为 cookie 从设置时起应该持续的秒数。 |
| Secure | 标有Secure标志的 Cookies 仅用于安全连接。浏览器应该只通过安全(HTTPS)连接发送这些 cookies,而服务器应该只在客户端建立安全连接时设置它们。 |
| HttpOnly | 标有HttpOnly的 Cookies 只能通过 HTTP 和 HTTPS 访问。这些 cookies 不能通过浏览器中的 JavaScript 访问,这有助于减少跨站点脚本攻击。 |
在清单 11-12 中显示了一个使用setHeader()创建两个带有属性的 cookies 的例子。在本例中,name cookie 将于 2015 年 1 月 10 日到期。这个 cookie 也是安全的,并且是仅 HTTP cookie。另一方面,foo cookie 使用Max-Age属性,一小时后过期。
清单 11-12 。设置两个包含属性的 Cookies
response.setHeader("Set-Cookie",
["name=Colin; Expires=Sat, 10 Jan 2015 20:00:00 GMT;\
Domain=foo.com; HttpOnly; Secure",
"foo=bar; Max-Age=3600"]);
中间件
即使有 Node 核心模块的帮助,实现一个普通 web 服务器的所有功能也是一项艰巨的任务。例子可能包括实现 HTTP 基本认证和 gzip 压缩。您可以自己编写所有这些代码,但是更流行的选择是使用中间件。中间件是以流水线方式处理请求的功能。这意味着一个中间件最初处理一个传入的请求。这个中间件既可以完全处理请求,也可以对请求执行操作,然后将其传递给另一个中间件进行额外的处理。
清单 11-13 显示了一个不执行任何处理的中间件的例子。注意,中间件有三个参数,request、response、next. request和response是您已经熟悉的用于处理请求的完全相同的对象。next是一个被调用来调用下一个中间件的函数。请注意,在本例中,next()已包含在return语句中。这不是必需的,但是在调用next()时返回以确保下一个中间件完成时不会继续执行是一个很好的做法。
清单 11-13 。中间件功能示例
function middleware(request, response, next) {
return next();
}
连接
现在我们已经看到了中间件的样子,让我们用它来构建一些东西。第一步是安装连接模块(npm install connect)。Connect 将自己标榜为“node.js 的高质量中间件”。Connect 不仅允许您使用自己的中间件构建应用,而且它还捆绑了一些非常有用的中间件。还有大量免费的第三方中间件使用 Connect 构建。
安装 Connect 之后,创建一个新的服务器,包含清单 11-14 中所示的代码。这个例子的前两行导入了http和connect模块。在第三行,使用connect模块初始化一个连接应用。接下来,通过它的use()方法将一个中间件添加到应用中。中间件的主体应该看起来很熟悉,因为我们在前面的例子中一直在使用它。这是连接中间件的美妙之处之一——它构建在http模块之上,因此与您已经了解的一切都兼容。最后,基于 Connect 应用构建一个 HTTP 服务器。您可能还记得,createServer()接受一个request事件处理程序(函数)作为参数。事实证明,connect()返回的app对象只是一个可以用来处理request事件的函数。
清单 11-14 。使用中间件构建的示例服务器
var http = require("http");
var connect = require("connect");
var app = connect();
app.use(function(request, response, next) {
response.setHeader("Content-Type", "text/html");
response.end("Hello <strong>HTTP</strong>!");
});
http.createServer(app).listen(8000);
我们刚刚展示了如何使用中间件重新创建简单的 HTTP 服务器。然而,为了真正理解中间件,让我们看另一个使用多个中间件的例子。清单 11-15 中的服务器使用了三个中间件功能。首先是 Connect 内置的query() 中间件。query()自动解析请求的 URL,并用包含所有查询字符串参数及其值的query对象来扩充request对象。中间件的第二部分是定制的,它遍历所有解析的查询字符串参数,并一路打印每个参数。在调用next()之后,控制权被传递给第三个也是最后一个中间件,它响应客户端。注意,中间件的执行顺序与它们被附加的顺序相同(通过调用use())。在这个例子中,query()必须在定制中间件之前被调用。如果顺序相反,将不会出现错误,但是在定制中间件中不会观察到控制台输出。
清单 11-15 。将多个连接中间件链接在一起
var http = require("http");
var connect = require("connect");
var app = connect();
app.use(connect.query());
app.use(function(request, response, next) {
var query = request.query;
for (q in query) {
console.log(q + ' = ' + query[q]);
}
next();
});
app.use(function(request, response, next) {
response.setHeader("Content-Type", "text/html");
response.end("Hello <strong>HTTP</strong>!");
});
http.createServer(app).listen(8000);
注意查询字符串是 URL 的可选部分,用于传递特定于请求的参数。问号(
?)用于将请求的资源与查询字符串分开。在查询字符串中,各个参数被格式化为parameter=value。&符号(&)用于分隔参数-值对。
来自清单 11-15 的控制台输出示例如清单 11-16 所示。要重新创建这个输出,只需将浏览器指向http://localhost:8000?foo=bar&fizz=buzz。注意,query()成功地提取了两个查询字符串参数foo和fizz,以及它们的值bar和buzz。
清单 11-16 。清单 11-15 中连接到服务器后的示例输出
$ node connect-query.js
foo = bar
fizz = buzz
query()只是 Connect 附带的 20 多种中间件方法中的一种。bodyParser()和cookieParser()中间件分别为处理请求体和 cookies 提供了类似的功能。关于 Connect 提供的所有中间件的列表,建议读者在https://github.com/senchalabs/connect查看该项目的 GitHub 页面。位于http://www.senchalabs.org/connect/的 Connect 主页也提供了到流行的第三方中间件的链接。
发出 HTTP 请求
除了创建服务器之外,http模块还允许您使用名副其实的request()方法发出请求。request()有两个参数,options和callback. options是用于参数化 HTTP 请求的对象。options支持的各种属性描述如表 11-6 所示。callback参数是一个在收到请求响应时调用的函数。http.IncomingMessage的实例是传递给回调函数的唯一参数。request()还返回一个http.ClientRequest的实例,这是一个可写的流。
表 11-6 。选项参数支持的各种属性来请求()
|
[计]选项
|
描述
|
| --- | --- |
| hostname | 要连接的域或 IP 地址。如果省略,默认为localhost。您也可以使用host属性来指定,但是最好使用hostname。 |
| port | 要连接的服务器端口。默认为 80。 |
| method | 请求的 HTTP 方法。这默认为GET。 |
| path | 被请求的资源的路径。如果请求包含查询字符串,则应该将其指定为路径的一部分。如果省略,默认为/。 |
| headers | 包含请求标头的对象。 |
| auth | 如果正在使用基本认证,则使用auth属性来生成Authorization报头。用于验证的用户名和密码应该采用username:password的格式。在headers字段设置Authorization标题将覆盖该选项。 |
| socketPath | 要使用的 UNIX 套接字。如果使用该选项,则应省略hostname和port,反之亦然。 |
清单 11-17 中显示了一个与我们的示例服务器一起工作的客户端。客户端向http://localhost:8000/发出GET请求。传递给request()的几个选项可以省略,因为它们是缺省值(即hostname、path和method),但为了示例起见,它们已经被包括在内。当收到响应时,回调函数通过打印出状态行、标题和正文来重新创建 HTTP 响应。使用流data事件处理程序将主体显示为 UTF-8 数据。需要注意的一件重要事情是在示例的最后一行调用了end()。如果这是一个POST或PUT请求,可能会有一个通过调用request.write()创建的请求体。为了标记请求体的结束,即使没有请求体,也要调用end()。如果没有调用end(),这个请求就永远不会被发出。
清单 11-17 。使用request()方法发出 HTTP 请求
var http = require("http");
var request = http.request({
hostname: "localhost",
port: 8000,
path: "/",
method: "GET",
headers: {
"Host": "localhost:8000"
}
}, function(response) {
var statusCode = response.statusCode;
var headers = response.headers;
var statusLine = "HTTP/" + response.httpVersion + " " +
statusCode + " " + http.STATUS_CODES[statusCode];
console.log(statusLine);
for (header in headers) {
console.log(header + ": " + headers[header]);
}
console.log();
response.setEncoding("utf8");
response.on("data", function(data) {
process.stdout.write(data);
});
response.on("end", function() {
console.log();
});
});
request.end();
request()有一个更简单但功能更弱的签名,它将 URL 字符串作为第一个参数。清单 11-17 在清单 11-18 中被重写,以使用这个版本的request()。这个版本的缺点是不能指定请求方法和头。因此,这个例子发出了一个没有报头的GET请求。还要注意,我们仍然必须调用end()。
清单 11-18 。http.request()的另一种用法
var http = require("http");
var request = http.request("http://localhost:8000/", function(response) {
response.setEncoding("utf8");
response.on("data", function(data) {
process.stdout.write(data);
});
response.on("end", function() {
console.log();
});
});
request.end();
为了方便起见,http模块还提供了一个get()方法来发出GET请求,而不调用end()。清单 11-19 中的显示了一个get()的例子。值得指出的是get()支持request()支持的两种参数签名。
清单 11-19 。http.get()的一个例子
var http = require("http");
http.get("http://localhost:8000/", function(response) {
response.setEncoding("utf8");
response.on("data", function(data) {
process.stdout.write(data);
});
response.on("end", function() {
console.log();
});
});
表格数据
到目前为止,我们只处理了GET请求,不包括请求体。现在我们来看看向服务器发送数据的请求。清单 11-20 中的例子向我们的示例服务器发出一个POST请求(它需要被更新以处理额外的数据)。首先要注意的是,querystring核心模块是在示例的第二行导入的。querystring模块的stringify()方法从一个对象创建一个查询字符串。在这个例子中,stringify()创建查询字符串foo=bar&baz=1&baz=2。值得指出的是,数组,比如baz,可以字符串化,但是嵌套对象不能。
清单 11-20 。示例POST请求
var http = require("http");
var qs = require("querystring");
var body = qs.stringify({
foo: "bar",
baz: [1, 2]
});
var request = http.request({
hostname: "localhost",
port: 8000,
path: "/",
method: "POST",
headers: {
"Host": "localhost:8000",
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(body)
}
}, function(response) {
response.setEncoding("utf8");
response.on("data", function(data) {
process.stdout.write(data);
});
response.on("end", function() {
console.log();
});
});
request.end(body);
传递给request()的选项是接下来要注意的。显然,请求方法被设置为POST,但是还要注意Content-Type和Content-Length头。Content-Type头向服务器表明请求体包含 URL 编码的表单数据(由querystring.stringify()生成)。Content-Length头告诉服务器请求体中包含多少字节(不是字符)。最后,使用end()将请求主体发送到服务器(也可以使用write()后跟end())。
我们当前的服务器可以很好地运行更新后的客户端,但是它无法处理表单数据。清单 11-21 展示了如何使用熟悉的流 API 和querystring模块解析请求体。request流的data处理程序用于收集bodyString变量中的整个请求体。当发出end事件时,使用querystring.parse()方法 将请求体解析成一个对象。接下来,遍历主体中的每个字段,并写回客户端。
清单 11-21 。处理POST请求的示例服务器
var http = require("http");
var qs = require("querystring");
var server = http.createServer(function(request, response) {
var bodyString = "";
request.setEncoding("utf8");
request.on("data", function(data) {
bodyString += data;
});
request.on("end", function() {
var body = qs.parse(bodyString);
for (var b in body) {
response.write(b + ' = ' + body[b] + "\n");
}
response.end();
});
});
server.listen(8000);
现在服务器已经配置好处理POST请求,我们可以测试我们的客户机了。如果一切正常,客户端应该生成如清单 11-22 所示的输出。
清单 11-22 。来自POST请求客户端和服务器的示例输出
$ node post-client.js
foo = bar
baz = 1,2
处理传入的请求体并不十分困难,但是比实际需要的要繁琐一些。为了缓解这个问题,我们可以求助于 Connect 的bodyParser()中间件。清单 11-23 展示了如何使用 Connect 重写服务器。bodyParser()中间件解析传入的请求体,并将结果存储在request.body中,以备将来处理。
清单 11-23 。使用 Connect 的bodyParser()中间件处理POST请求
var http = require("http");
var connect = require("connect");
var app = connect();
app.use(connect.bodyParser());
app.use(function(request, response, next) {
var body = request.body;
for (b in body) {
response.write(b + ' = ' + body[b] + "\n");
}
response.end();
});
http.createServer(app).listen(8000);
嵌套对象
前面提到过querystring.stringify()不处理嵌套对象。解决方法是使用方括号符号定义查询参数,如清单 11-24 所示。在这个例子中,一个名为name的对象是用两个属性first和last创建的。
清单 11-24 。将嵌套对象传递给querystring.stringify()
var body = qs.stringify({
"name[first]": "Colin",
"name[last]": "Ihrig"
});
Connect 的bodyParser()中间件将把这个请求解释为清单 11-25 中所示的对象。不幸的是,如果您使用querystring.parse()手工解析请求,这个技巧将不起作用,数据将被存储,如清单 11-26 所示。
清单 11-25 。Connect 对清单 11-24 中数据的解释
{
name: {
first: "Colin",
last: "Ihrig"
}
}
清单 11-26 。使用querystring.parse()解析的来自清单 11-24 的数据
{
"name[first]": "Colin",
"name[last]": "Ihrig"
}
request模块
是一个第三方模块,由 Mikeal Rogers 编写,它简化了 HTTP 请求的过程。在撰写本文时,request是npm注册表中第三大依赖模块。request的流行是因为它在 Node 的核心功能上提供了简单的抽象。为了展示request的简单性,清单 11-20 在清单 11-27 中被重写。您会立即注意到没有流和querystring模块——所有这些都发生在request中。请求的所有参数都在第一个参数中传递给request()。在这一点上,这些参数中的大多数是不言自明的,但是request支持的许多常见选项的概要在表 11-7 中提供。
清单 11-27 。模块request的使用示例
var request = require("request");
request({
uri: "http://localhost:8000/",
method: "POST",
headers: {
Host: "localhost:8000"
},
form: {
foo: "bar",
baz: [1, 2]
}
}, function(error, response, body) {
console.log(body);
});
表 11-7 。与请求模块一起使用的通用选项
|
[计]选项
|
描述
|
| --- | --- |
| uri(或url) | 被请求的 URL。这是唯一必需的选项。 |
| method | HTTP 请求方法。这默认为GET。 |
| headers | 要发送的请求标头。这默认为一个空对象。 |
| body | 字符串或Buffer形式的请求体。 |
| form | 请求正文的对象表示形式。在内部,这将把body选项设置为 URL 编码的等价字符串。此外,Content-Type标题将被设置为application/x-www-form-urlencoded; charset=utf-8。 |
| qs | 任何查询字符串参数的对象表示形式。在内部,这将被转换为 URL 编码的查询字符串,并附加到所请求的 URL。 |
| jar | 用于为请求定义 cookie 的 cookie jar 对象。这将在后面详细介绍。 |
| followRedirect | 如果true(默认),request将自动跟随 HTTP 3xx响应重定向。 |
| followAllRedirects | 如果是true,request将自动跟随 HTTP 3xx响应重定向,即使是非GET请求。这默认为false。 |
| maxRedirects | 跟随重定向的最大数量。默认为 10。 |
| timeout | 中止请求前等待响应的毫秒数。 |
request()的第二个参数是一个回调函数,一旦收到响应就调用这个函数。回调的第一个参数用于传递任何错误信息。第二个参数是完整的响应,是http.IncomingMessage的一个实例。第三个论点,body,是回应体。
request中的 Cookies
许多网站需要 cookies 才能正常运行。例如,大多数电子商务网站使用 cookies 将请求映射到购物车。如果您能够猜测(或窃取)另一个用户的 cookies,您就可以操纵他们的购物车。仅使用 Node 核心,您必须在每个请求上设置Cookie头,并检查每个响应上的Set-Cookie头。request通过饼干罐的概念抽象出这一点。cookie jar 是一个包含 cookie 表示的对象。然后这个 jar 被传递到request()而不是Cookie头。一旦收到响应,request就会用任何Set-Cookie头更新 cookie jar。
清单 11-28 中显示了一个使用 cookies 的示例request客户端。request.jar()方法用于创建一个新的空 cookie jar。接下来,使用request.cookie()方法创建一个名为count的新 cookie,值为 1。然后使用add()方法将 cookie 添加到 jar 中。当发出请求时,cookie jar 通过jar选项传入。最后,一旦收到响应,就会打印 cookie jar 的内容。
清单 11-28 。使用 Cookies 的请求示例
var request = require("request");
var jar = request.jar();
var cookie = request.cookie("count=1");
jar.add(cookie);
request({
url: "http://localhost:8000/",
jar: jar
}, function(error, response, body) {
console.log(jar);
});
为了验证request自动更新 cookie jar,我们将创建一个更新 cookie 值的服务器。清单 11-29 中的示例服务器使用 Connect 的cookieParser()中间件解析Cookie头并创建request.cookies对象。接下来,count cookie 的值被读取并转换成一个整数。最后,创建一个带有递增计数的Set-Cookie响应头。连接到该服务器产生的客户端输出如清单 11-30 所示。
清单 11-29 。更新 Cookie 值的示例服务器
var http = require("http");
var connect = require("connect");
var app = connect();
app.use(connect.cookieParser());
app.use(function(request, response, next) {
var cookies = request.cookies;
var count = parseInt(cookies.count, 10);
var setCookie = "count=" + (count + 1);
response.setHeader("Set-Cookie", setCookie);
response.end();
});
http.createServer(app).listen(8000);
清单 11-30 。Cookie 示例的输出
$ node cookie-update.js
{ cookies:
[ { str: 'count=2',
name: 'count',
value: '2',
expires: Infinity,
path: '/' } ] }
安全超文本传输协议
HTTP 以明文传输数据,这使得它本身就不安全。当传输敏感/私人数据,如社会安全号码、信用卡信息、电子邮件甚至即时消息时,应使用安全协议。幸运的是,HTTP 在 HTTPS 有一个安全的姐妹协议。HTTPS 只是在安全通道上执行的标准 HTTP。更具体地说,使用 SSL/TLS(安全套接字层/传输层安全) 协议来保护信道。
在 SSL/TLS 下,每个客户端和服务器都必须有一个私有密钥。因此,我们需要做的第一件事是创建一个私钥。这可以使用免费提供的 OpenSSL 实用程序来完成。有关获取 OpenSSL 的更多信息,请访问www.openssl.org。接下来,使用清单 11-31 中的命令创建一个名为key.pem的私钥。请务必记住保存密钥的位置,因为您稍后会需要它!
清单 11-31 。使用 OpenSSL 创建私钥
$ openssl genrsa -out key.pem 1024
除了私钥之外,每个服务器必须有一个证书,这是一个由认证机构(CA)签名的公钥。从本质上讲,证书是一种凭证,证明公钥的所有者就是他们所说的那个人。任何人都可以签署证书,因此其合法性确实取决于签署者的声誉。因此,ca 通常是受信任的第三方。要获得证书,您必须首先生成证书签名请求。使用 OpenSSL,这可以通过清单 11-32 中的命令来完成。
清单 11-32 。使用 OpenSSL 创建证书签名请求
$ openssl req -new -key key.pem -out request.csr
此时,您可以将您的request.csr发送到 CA 进行签名。然而,这通常是有费用的,在这里展示的例子中并不需要。出于我们的目的,我们可以使用清单 11-33 中的所示的 OpenSSL 命令 创建一个自签名证书。
清单 11-33 。使用 OpenSSL 创建自签名证书
$ openssl x509 -req -in request.csr -signkey key.pem -out cert.pem
使用我们刚刚创建的key.pem和cert.pem文件,我们可以构建一个简单的 HTTPS 服务器(如清单 11-34 所示)。Node 提供了一个核心的https模块,它为http模块中包含的许多特性提供了安全的替代方案。注意,createServer()的https版本在request事件监听器之前接受了一个额外的参数。该参数用于传入服务器的私钥和证书。如有必要,调整路径以指向您的密钥和证书的位置。服务器的其余部分与我们的旧 HTTP 服务器相同。
清单 11-34 。一个示例 HTTPS 服务器
var fs = require("fs");
var https = require("https");
var server = https.createServer({
key: fs.readFileSync(__dirname + "/key.pem"),
cert: fs.readFileSync(__dirname + "/cert.pem")
}, function(request, response) {
response.writeHead(200, {
"Content-Type": "text/html"
});
response.end("Hello <strong>HTTP</strong>!");
});
server.listen(8000);
为了测试我们闪亮的新 HTTPS 服务器,我们需要一个新的客户端。您可以在浏览器中导航到https://localhost:8000。忽略关于无效/不可信证书的任何警告,因为它们是由于使用自签名证书而引起的。https模块也提供了它自己的request()方法,如清单 11-35 所示。使用https request()不需要做任何特别的事情。事实上,清单 11-35 与我们的 HTTP 示例完全相同,除了第一行,以及使用了https模块而不是http。第一行用于抑制由于服务器的不可信证书而引发的错误。在生产代码中,您可能希望删除这一行,并根据需要在应用中处理错误。
清单 11-35 。一个 HTTPS 客户的例子
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
var https = require("https");
var request = https.request({
hostname: "localhost",
port: 8000
}, function(response) {
response.setEncoding("utf8");
response.on("data", function(data) {
process.stdout.write(data);
});
response.on("end", function() {
console.log();
});
});
request.end();
注意当我们谈到 HTTPS 客户的话题时,值得指出的是
request模块与 HTTPS 完全兼容。
摘要
本章介绍了大量与 HTTP 相关的材料。虽然 HTTP 不是一个非常复杂的协议,但是为了正确使用 HTTP,必须理解许多相关的概念。这一章也提到了像 cookies 和通过 HTTPS 协议的安全性这样的主题。除了核心的http、https和querystring模块,本章还介绍了connect和request,它们是npm注册表中最受欢迎的两个模块。下一章专门介绍 Express,这是一个基于http和connect构建的用于创建 web 应用的框架。因此,在进入下一章之前,理解这里的内容是很重要的。
十二、Express 框架
在《??》第十章中,你学习了如何使用net模块创建低级 TCP 应用。然后,在第十一章中,使用http模块抽象出 TCP 的底层细节。向更高抽象层次的转移允许我们做更多的事情,同时编写更少的代码。第十一章还通过连接库介绍了中间件的概念。中间件促进代码重用,并使您能够以流水线的方式请求处理。然而,使用http和connect模块创建复杂的应用仍然有点乏味。
TJ Holowaychuk 创建的 Express 框架,在http和connect之上提供了另一个抽象层次。Express 基于 Ruby 的 Sinatra 框架,并标榜自己是“一个最小且灵活的 Node.js web 应用框架,为构建单页面、多页面和混合 web 应用提供了一组强大的功能。”Express 为许多常见的任务提供了方便的方法和语法糖,否则这些任务将是乏味的或多余的。本章详细分析了 Express 框架。而且记住,因为 Express 是建立在http和connect之上的,所以你在第十一章中学到的一切都是适用的。
快速路线
在看 Express 提供了什么之前,让我们先确定一下http和connect的一些缺点。清单 12-1 包括了一个支持三个唯一的GETURL 的例子,并为其他的所有内容返回一个404。注意,每个新支持的动词/URL 组合在if语句中都需要一个额外的分支。还有相当数量的重复代码。通过更好地优化代码,可以消除一些重复,但这需要牺牲代码的可读性和一致性。
清单 12-1 。使用http模块支持多种资源
var http = require("http");
http.createServer(function(request, response) {
if (request.url === "/" && request.method === "GET") {
response.writeHead(200, {
"Content-Type": "text/html"
});
response.end("Hello <strong>home page</strong>");
} else if (request.url === "/foo" && request.method === "GET") {
response.writeHead(200, {
"Content-Type": "text/html"
});
response.end("Hello <strong>foo</strong>");
} else if (request.url === "/bar" && request.method === "GET") {
response.writeHead(200, {
"Content-Type": "text/html"
});
response.end("Hello <strong>bar</strong>");
} else {
response.writeHead(404, {
"Content-Type": "text/html"
});
response.end("404 Not Found");
}
}).listen(8000);
HTTP 动词和 URL 的组合被称为路由,Express 拥有处理它们的高效语法。清单 12-2 显示了来自清单 12-1 的路线是如何使用 Express 的语法编写的。首先,express模块必须安装(npm install express)并导入到应用中。http模块也必须导入。在清单 12-2 的第三行,通过调用express()函数创建了一个 Express app。这个应用的行为类似于一个连接应用,并被传递给清单 12-2 最后一行的http.createServer()方法。
清单 12-2 。使用 Express 重写清单 12-1 中的服务器
var express = require("express");
var http = require("http");
var app = express();
app.get("/", function(req, res, next) {
res.send("Hello <strong>home page</strong>");
});
app.get("/foo", function(req, res, next) {
res.send("Hello <strong>foo</strong>");
});
app.get("/bar", function(req, res, next) {
res.send("Hello <strong>bar</strong>");
});
http.createServer(app).listen(8000);
对应用的get()方法的三次调用用于定义路线。get()方法定义了处理GET请求的路径。Express 还为其他 HTTP 动词定义了类似的方法(put()、post()、delete()等等)。所有这些方法都将 URL 路径和一系列中间件作为参数。路径是表示路由响应的 URL 的字符串或正则表达式。请注意,查询字符串不被视为路径 URL 的一部分。还要注意,我们还没有定义一个404路由,因为这是当一个请求与任何已定义的路由都不匹配时 Express 的默认行为。
注意 Express 中间件遵循与 Connect 相同的
request - response - next签名。Express 还用其他方法增加了请求和响应对象。这方面的一个例子是response.send()方法,如清单 12-2 所示,因为res.send(). send()用于将响应状态代码和/或主体发送回客户端。如果send()的第一个参数是一个数字,那么它将被视为状态代码。如果没有提供状态代码,Express 将发回一个200。响应体可以在第一个或第二个参数中指定,可以是字符串、Buffer、数组或对象。send()也设置Content-Type标题,除非你明确这样做。如果响应体是一个Buffer,那么Content-Type头也被设置为application/octet-stream。如果主体是字符串,Express 会将Content-Type头设置为text/html。如果主体是数组或对象,那么 Express 会发回 JSON。最后,如果没有提供主体,则使用状态代码的原因短语。
Route Parameters
假设您正在创建一个销售数百或数千种不同产品的电子商务网站,每种产品都有自己唯一的产品 ID。您肯定不希望手动指定数百条唯一的路线。一种方法是创建一条路线,并将产品 ID 指定为查询字符串参数。尽管这是一个非常有效的选择,但它会导致不吸引人的 URL。如果毛衣的网址看起来像/products/sweater而不是/products?productId=sweater不是更好吗?
事实证明,可以定义为正则表达式的 Express routes 非常适合支持这种场景。清单 12-3 展示了如何使用正则表达式来参数化一条路线。在本例中,产品 ID 可以是除正斜杠以外的任何字符。在路由的中间件内部,任何匹配的参数都可以通过req.params对象访问。
清单 12-3 。使用正则表达式参数化快速路径
var express = require("express");
var http = require("http");
var app = express();
app.get(/\/products\/([^\/]+)\/?$/, function(req, res, next) {
res.send("Requested " + req.params[0]);
});
http.createServer(app).listen(8000);
为了更加方便,即使 URL 是用字符串描述的,路由也可以参数化。清单 12-4 展示了这是如何完成的。在本例中,使用冒号(:)字符创建了一个命名参数productId。在路由的中间件内部,使用req.params对象按名称访问这个参数。
清单 12-4 。带有命名参数的路线
var express = require("express");
var http = require("http");
var app = express();
app.get("/products/:productId", function(req, res, next) {
res.send("Requested " + req.params.productId);
});
http.createServer(app).listen(8000);
您甚至可以从字符串中为参数定义一个正则表达式。假设productId参数现在只能由数字组成,清单 12-5 展示了正则表达式是如何定义的。请注意\d字符类上的附加反斜杠。因为正则表达式是在字符串常量中定义的,所以需要一个额外的反斜杠作为转义字符。
清单 12-5 。在路由字符串中定义正则表达式
var express = require("express");
var http = require("http");
var app = express();
app.get("/products/:productId(\\d+)", function(req, res, next) {
res.send("Requested " + req.params.productId);
});
http.createServer(app).listen(8000);
注意可选的命名参数后面都是问号。例如,在前面的例子中,如果
productId是可选的,它将被写成:productId?。
创建快速应用
Express 包含一个名为express(1)的可执行脚本,用于生成 skeleton Express 应用。运行express(1)的首选方式是使用清单 12-6 中的命令全局安装express模块。要复习全局安装模块的含义,请参见第二章。
清单 12-6 。全局安装模块express
npm install -g express
在全局安装 Express 之后,你可以通过发出清单 12-7 所示的命令在你机器的任何地方创建一个框架应用。这个清单还包括命令的输出,其中详细列出了创建的文件以及配置和运行应用的指令。注意,在这个例子中你实际输入的唯一东西是express testapp。
清单 12-7 。使用express(1)创建应用框架
$ express testapp
create : testapp
create : testapp/package.json
create : testapp/app.js
create : testapp/public
create : testapp/public/stylesheets
create : testapp/public/stylesheets/style.css
create : testapp/routes
create : testapp/routes/index.js
create : testapp/routes/user.js
create : testapp/public/javascripts
create : testapp/views
create : testapp/views/layout.jade
create : testapp/views/index.jade
create : testapp/public/images
install dependencies:
$ cd testapp && npm install
run the app:
$ node app
将在新文件夹中创建 skeleton Express 应用。在这种情况下,文件夹将被命名为testapp。接下来,使用清单 12-8 中的命令安装应用的依赖项。
清单 12-8 。安装框架应用的依赖项
$ cd testapp && npm install
在npm安装完依赖项之后,我们就可以运行框架程序了。快速应用的入口点位于文件app.js中。因此,要运行testapp,从项目的根目录发出命令node app。你可以通过连接到localhost的 3000 端口进入测试程序。框架应用定义了两条路线——/和/users——它们都响应GET请求。图 12-1 显示了使用 Chrome 连接到/路线的结果。
图 12-1 。骷髅 app 返回的索引页
检查骨架应用
app.js是快递 app 的心脏。在清单 12-7 中生成的app.js文件的内容如清单 12-9 所示。该文件首先导入express、http和path模块,以及两个项目文件/routes/index.js和/routes/user.js。从routes目录导入的两个文件包含框架应用的路由所使用的中间件。在require()语句之后,使用express()函数创建一个快速应用。
清单 12-9 。app.js的生成内容
/**
* Module dependencies.
*/
var express = require('express');
var routes = require('./routes');
var user = require('./routes/user');
var http = require('http');
var path = require('path');
var app = express();
// all environments
app.set('port', process.env.PORT || 3000);
app.set('views', __dirname + '/views');
app.set('view engine', 'jade');
app.use(express.favicon());
app.use(express.logger('dev'));
app.use(express.bodyParser());
app.use(express.methodOverride());
app.use(app.router);
app.use(express.static(path.join(__dirname, 'public')));
// development only
if ('development' == app.get('env')) {
app.use(express.errorHandler());
}
app.get('/', routes.index);
app.get('/users', user.list);
http.createServer(app).listen(app.get('port'), function(){
console.log('Express server listening on port ' + app.get('port'));
});
注意如果传递给
require()的模块路径解析为一个目录,Node 将在该目录中寻找一个index文件。这就是为什么表达require("./routes")解析为/routes/index.js。
接下来,您将看到对应用的set()方法的三次调用,该方法用于定义应用设置。第一个调用定义了一个名为port的设置,它定义了服务器将绑定到的端口号。端口号默认为 3000,但是这个值可以通过定义一个名为PORT的环境变量来覆盖。接下来的两个设置,views和view engine,由快速模板系统使用。模板系统将在本章后面被重新讨论。现在,只需要知道这些设置使用 Jade 模板语言来呈现存储在views目录中的视图。
在设置定义之后是对use()的几个调用,这些调用定义了用于处理所有请求的中间件。表 12-1 包含了对 skeleton 应用中包含的各种中间件的简短描述。这些功能中有许多只是使用了同名的 Connect 中间件。
表 12-1 。app.js 中使用的中间件 ??
|
中间件
|
描述
|
| --- | --- |
| favicon | 如果您一直在使用浏览器测试您的 web 服务器,那么您可能已经注意到了对文件favicon.ico的请求。这个中间件通过为您的favicon.ico文件提供服务来处理这样的请求,或者如果您没有提供文件,则使用连接默认值。 |
| logger | 这个中间件记录它收到的每个请求的信息。在框架应用中使用的dev模式中,logger显示请求动词和 URL,以及响应代码、处理请求所用的时间和返回数据的大小。 |
| bodyParser | 这个中间件在第十一章中有解释。它将请求体字符串解析成一个对象,并将其作为request.body附加到请求对象上。 |
| methodOverride | 有些浏览器只允许 HTML 表单发出GET和POST请求。要发出其他类型的请求(PUT、DELETE等等),表单可以包含一个名为X-HTTP-Method-Override的输入,其值是所需的请求类型。这个中间件检测到这种情况,并相应地设置request.method属性。 |
| app.router | 这是用于将传入请求映射到定义的路由的快速路由。如果没有明确使用它,Express 将在第一次遇到路由时装载它。然而,手动安装路由将确保它在中间件序列中的位置。 |
| static | 这个中间件接受一个目录路径作为输入。该目录被视为静态文件服务器的根目录。这对于提供图像、样式表和其他静态资源等内容非常有用。在骷髅 app 中,静态目录是public。 |
| errorHandler | 顾名思义,errorHandler是处理错误的中间件。与其他中间件不同,errorHandler接受四个参数— error、request、response和next。在 skeleton app 中,这个中间件只在开发模式下使用(见development only注释)。 |
在set()和use()呼叫之后,使用get()方法定义两条GET路线。如前所述,这些路线的网址是/和/users。 /users路由使用存储在user.list变量中的一个中间件。回头看看require()语句,user变量来自文件/routes/user,其内容如清单 12-10 所示。正如您所看到的,这个路由只是返回字符串"respond with a resource"。
清单 12-10 。/routes/user.js生成的内容
/*
* GET users listing.
*/
exports.list = function(req, res){
res.send("respond with a resource");
};
/路线比较有意思。在/routes/index.js 中定义,如清单 12-11 所示。这里显示的代码看起来不像能创建如图 12-1 所示的页面。关键是render()方法,它与 Express 模板系统联系在一起。这可能是一个探索模板化,以及如何在 Express 中处理它的好时机。
清单 12-11 。/routes/index.js生成的内容
/*
* GET home page.
*/
exports.index = function(req, res){
res.render('index', { title: 'Express' });
};
模板
创建动态 web 内容通常涉及构建长的 HTML 字符串。手动完成这项工作既繁琐又容易出错。例如,很容易忘记对长字符串中的字符进行适当的转义。模板引擎是一种替代方法,它通过提供一个框架文档(模板)来大大简化这个过程,您可以在这个框架文档中嵌入动态数据。现在有许多兼容 JavaScript 的模板引擎,其中一些比较流行的选项是 Mustache、Handlebars、嵌入式 JavaScript (EJS)和 Jade。Express 支持所有这些模板引擎,但是默认情况下,Jade 是和 Express 一起打包的。这一节解释了如何使用玉。其他模板引擎可以很容易地安装和配置,以便与 Express 一起工作,但这里不讨论。
配置 Jade 就像在app.js文件中定义两个设置一样简单。这些设置是views和view engine。views设置指定了 Express 可以定位模板的目录,也称为视图。如果没有提供,那么view engine指定要使用的视图文件扩展名。清单 12-12 显示了如何应用这些设置。在本例中,模板位于名为views的子目录中。这个目录应该包括一些 Jade 模板文件,文件扩展名是.jade。
清单 12-12 。用于在 Express 中配置 Jade 的设置
app.set("views", __dirname + "/views");
app.set("view engine", "jade");
一旦 Express 被配置为使用您最喜欢的模板引擎,您就可以开始呈现视图了。这是通过response对象的render()方法完成的。render()的第一个参数是您的views目录中视图的名称。如果您的views目录包含子目录,该名称可以包含正斜杠。render()的下一个参数是传递数据的可选参数。这用于在静态模板中嵌入动态数据。render()的最后一个参数是一个可选的回调函数,一旦模板完成渲染,这个函数就会被调用。如果省略回调,Express 将自动用呈现的页面响应客户端。如果包含回调,Express 将不会自动响应,调用函数时会出现一个可能的错误,并以呈现的字符串作为参数。
假设您正在为一个用户的帐户页面创建一个视图。用户登录后,您需要称呼他们的名字。清单 12-13 显示了一个使用render()处理这种情况的例子。这个例子假设模板文件被命名为home.jade,并且位于views文件夹中的一个名为account的目录中。假设用户的名字是 Bob。在实际的应用中,这些信息可能来自某种类型的数据存储。这里还包含了可选的回调函数。在回调中,我们检查错误。如果出现错误,将返回一个500内部服务器错误。否则,返回呈现的 HTML。
清单 12-13 。render()的使用示例
res.render("account/home", {
name: "Bob"
}, function(error, html) {
if (error) {
return res.send(500);
}
res.send(200, html);
});
当然,为了呈现视图,我们需要实际创建视图。因此,在您的views目录中,创建一个名为account/home.jade 的文件,包含如清单 12-14 所示的代码。这是一个 Jade 模板,虽然对 Jade 语法的解释超出了本书的范围,但我们将介绍绝对的基础知识。第一行用于指定 HTML5 文档类型。第二行创建了开始的<html>标记。请注意,Jade 不包含任何尖括号或结束标记。相反,Jade 根据代码缩进来推断这些事情。
清单 12-14 。一个翡翠模板的例子
doctype 5
html
head
title Account Home
link(rel='stylesheet', href='/stylesheets/style.css')
body
h1 Welcome back #{name}
接下来是文档的<head>标签。标题包括页面标题和一个样式表链接。link旁边的括号用于指定标签属性。样式表链接到一个静态文件,Express 可以使用static中间件找到该文件。
清单 12-14 的最后两行定义了文档的<body>。在这种情况下,主体由欢迎用户的单个<h1>标记组成。#{name}的值取自传递给render()的 JSON 对象。在花括号内,可以使用 JavaScript 的标准点和下标符号访问嵌套的对象和数组。
产生的 HTML 字符串显示在清单 12-15 中。请注意,为了可读性,该字符串已被格式化。实际上,Express 呈现的模板没有额外的缩进和换行符。有关 Jade 语法的更多信息,请参见 Jade 主页上的http``:``//``www``.``jade``-``lang``.``com。
清单 12-15 。从清单 12-14 中的模板呈现的 HTML 示例
<!DOCTYPE html>
<html>
<head>
<title>Account Home</title>
<link rel="stylesheet" href="/stylesheets/style.css">
</head>
<body>
<h1>Welcome back Bob</h1>
</body>
</html>
express-validator
express-validator是一个有用的第三方模块,用于确保用户输入以预期的格式提供。express-validator创建中间件,将数据检查方法附加到request对象上。清单 12-16 中的显示了一个使用express-validator验证产品 ID 的例子。在示例的第二行导入了express-validator模块,然后用use()将其添加为中间件。中间件将assert()和validationErrors()方法附加到req上,在路由中使用。
assert()方法将参数名和错误消息作为参数。该参数可以是命名的 URL 参数、查询字符串参数或请求正文参数。由assert()返回的对象用于验证参数的数据类型和/或值。清单 12-16 展示了三种验证方法,notEmpty()、isAlpha()和len()。这些方法验证了productId参数存在,并且长度在 2 到 10 个字母之间。为了方便起见,这些方法可以链接在一起,如第二个assert()所示。当然,如果您完全省略了productId参数,路由将不会被匹配,验证器将永远不会运行。notEmpty()在验证查询字符串参数和表单体数据时更有用。
清单 12-16 。express-validator的一个例子
var express = require("express");
var validator = require("express-validator");
var http = require("http");
var app = express();
app.use(express.bodyParser());
app.use(validator());
app.get("/products/:productId", function(req, res, next) {
var errors;
req.assert("productId", "Missing product ID").notEmpty();
req.assert("productId", "Invalid product ID").isAlpha().len(2, 10);
errors = req.validationErrors();
if (errors) {
return res.send(errors);
}
res.send("Requested " + req.params.productId);
});
http.createServer(app).listen(8000);
在做出所有断言后,使用validationErrors()方法来检索任何错误。如果没有错误,将返回null。但是,如果检测到错误,将返回一组验证错误。在这个例子中,错误数组只是作为响应被发送回来。
还有许多其他有用的验证方法没有在清单 12-16 中显示。其中一些是isInt()、isEmail()、isNull()、is()和contains()。前三种方法验证输入是整数、电子邮件地址还是null。is()方法接受一个正则表达式参数,并验证该参数是否与之匹配。contains()也接受一个参数,并检查参数是否包含它。
express-validator还为req附加了一个sanitize()方法,用于清理输入。清单 12-17 显示了sanitize()的几个例子。前两个示例分别将参数值转换为布尔值和整数。第三个示例删除了参数开头和结尾多余的空白。最后一个例子用相应的字符(<和>)替换字符实体(比如<和>)。
清单 12-17 。express-validator sanitize()方法的例子
req.sanitize("parameter").toBoolean()
req.sanitize("parameter").toInt()
req.sanitize("parameter").trim()
req.sanitize("parameter").entityDecode()
REST
代表性状态转移 或 REST,是一种越来越常见的创建 API 的软件架构。由 Roy Fielding 在 2000 年提出的 REST 本身并不是一项技术,而是一套用于创建服务的原则。RESTful APIs 几乎总是使用 HTTP 实现,但这不是严格的要求。下面的列表列举了 RESTful 设计背后的一些原则。
- RESTful 设计应该有一个单一的基本 URL,和一个类似目录的 URL 结构。例如,一个博客 API 可以有一个基本 URL
/blog。某一天的个人博客条目可以使用类似于/blog/posts/2013/03/17/的 URL 结构进行访问。 - 作为应用状态引擎的超媒体(HATEOAS) 。客户端应该能够只使用服务器提供的超链接来导航整个 API 。例如,在访问一个 API 的入口点之后,服务器应该提供链接,客户端可以使用这些链接来导航 API。
- 服务器不应该维护任何客户端状态,例如会话。相反,每个客户端请求都应该包含定义状态所需的所有信息。这一原则通过简化服务器来提高可伸缩性。
- 服务器响应应该声明它们是否可以被缓存。这种声明可以是显式的,也可以是隐式的。如果可能,响应应该是可缓存的,因为它可以提高性能和可伸缩性。
- RESTful 设计应该尽可能地利用底层协议的词汇。例如,CRUD(创建、读取、更新和删除)操作分别使用 HTTP 的
POST、GET、PUT和DELETE动词来实现。此外,服务器应该尽可能使用适当的状态代码进行响应。
RESTful API 示例
Express 使得 RESTful 应用的实现变得非常简单。在接下来的几个例子中,我们将创建一个 RESTful API 来操作服务器上的文件。API 更常用于操作数据库条目,但是我们还没有涉及数据库。我们的示例应用也被分成许多文件。这使得示例更具可读性,同时也使得应用更加模块化。
首先,我们从app.js 开始,如清单 12-18 所示。这其中的大部分应该看起来很熟悉。然而,增加了一个额外的中间件来定义req.store。这是包含应用将使用的文件的目录。路线声明也被删除了,取而代之的是对文件routes.js中定义的自定义函数routes.mount(). mount()的调用,该函数将 Express app 作为其唯一的参数。
清单 12-18 。app.js的内容
var express = require("express");
var routes = require("./routes");
var http = require("http");
var path = require("path");
var app = express();
var port = process.env.PORT || 8000;
app.use(express.favicon());
app.use(express.logger("dev"));
app.use(express.bodyParser());
app.use(express.methodOverride());
// define the storage area
app.use(function(req, res, next) {
req.store = __dirname + "/store";
next();
});
app.use(app.router);
// development only
if ("development" === app.get("env")) {
app.use(express.errorHandler());
}
routes.mount(app);
http.createServer(app).listen(port, function() {
console.log("Express server listening on port " + port);
});
routes.js 的内容如清单 12-19 所示。测试应用接受四个路径,每个 CRUD 操作一个路径。每个路由的中间件都在自己的文件中定义(create.js、read.js、update.js和delete.js)。需要指出的一点是,delete既是 HTTP 动词又是 JavaScript 保留字,所以在某些地方将delete操作简称为del。
清单 12-19 。routes.js的内容
var create = require("./create");
var read = require("./read");
var update = require("./update");
var del = require("./delete");
module.exports.mount = function(app) {
app.post("/:fileName", create);
app.get("/:fileName", read);
app.put("/:fileName", update);
app.delete("/:fileName", del);
};
由POST进路处理的create操作在create.js中找到,如清单 12-20 所示。因为我们正在执行文件系统操作,所以我们从导入fs模块开始。在路由中间件内部,计算文件路径及其内容。该路径由req.store值和fileName参数组成。要写入文件的数据来自名为data的POST主体参数。然后使用fs.writeFile()方法创建新文件。文件是使用wx标志创建的,如果文件已经存在,这会导致操作失败。在writeFile()回调中,我们返回一个400状态码来表明请求不能被满足,或者返回一个201来表明一个新文件被创建。
清单 12-20 。create.js的内容
var fs = require("fs");
module.exports = function(req, res, next) {
var path = req.store + "/" + req.params.fileName;
var data = req.body.data || "";
fs.writeFile(path, data, {
flag: "wx"
}, function(error) {
if (error) {
return res.send(400);
}
res.send(201);
});
};
下一个 CRUD 操作是读取,由GET路径处理。read.js的内容如清单 12-21 所示。这一次,fs.readFile()方法用于检索在fileName参数中指定的文件内容。如果读取因任何原因失败,将返回一个404状态代码。否则,将返回一个200状态代码,以及包含文件数据的 JSON 主体。值得指出的是,在设置响应代码时,可以更彻底地检查error参数。例如,如果error.code等于"ENOENT",那么文件确实不存在,状态代码应该是404。所有其他错误都可以简单地返回一个400。
清单 12-21 。read .js的内容
var fs = require("fs");
module.exports = function(req, res, next) {
var path = req.store + "/" + req.params.fileName;
fs.readFile(path, {
encoding: "utf8"
}, function(error, data) {
if (error) {
return res.send(404);
}
res.send(200, {
data: data
});
});
};
接下来是PUT路线,它实现了update操作,如清单 12-22 所示。这非常类似于create操作,有两个小的不同。首先,在成功更新时返回一个200状态代码,而不是一个201。第二,用r+标志而不是wx打开文件。如果文件不存在,这会导致update操作失败。
清单 12-22 。update.js的内容
var fs = require("fs");
module.exports = function(req, res, next) {
var path = req.store + "/" + req.params.fileName;
var data = req.body.data || "";
fs.writeFile(path, data, {
flag: "r+"
}, function(error) {
if (error) {
return res.send(400);
}
res.send(200);
});
};
最终的 CRUD 操作是delete ,如清单 12-23 中的所示。方法删除由参数fileName指定的文件。这条路由失败时返回一个400,成功时返回一个200。
清单 12-23 。delete.js的内容
var fs = require("fs");
module.exports = function(req, res, next) {
var path = req.store + "/" + req.params.fileName;
fs.unlink(path, function(error) {
if (error) {
return res.send(400);
}
res.send(200);
});
};
测试 API
我们可以创建一个简单的测试脚本,如清单 12-24 所示,用于测试 API。该脚本使用request模块至少访问一次所有的 API 路径。async模块也用于避免回调地狱。通过查看对async.waterfall()的调用,您可以看到脚本是从创建一个文件并读回内容开始的。然后,文件被更新并再次被读取。最后,我们删除文件并尝试再次读取它。所有的请求都处理同一个文件,foo。每个请求完成后,将显示操作名称和响应代码。对于成功的GET请求,也会显示文件内容。
清单 12-24 。RESTful API 的测试脚本
var async = require("async");
var request = require("request");
var base = "http://localhost:8000";
var file = "foo";
function create(callback) {
request({
uri: base + "/" + file,
method: "POST",
form: {
data: "This is a test file!"
}
}, function(error, response, body) {
console.log("create: " + response.statusCode);
callback(error);
});
}
function read(callback) {
request({
uri: base + "/" + file,
json: true // get the response as a JSON object
}, function(error, response, body) {
console.log("read: " + response.statusCode);
if (response.statusCode === 200) {
console.log(response.body.data);
}
callback(error);
});
}
function update(callback) {
request({
uri: base + "/" + file,
method: "PUT",
form: {
data: "This file has been updated!"
}
}, function(error, response, body) {
console.log("update: " + response.statusCode);
callback(error);
});
}
function del(callback) {
request({
uri: base + "/" + file,
method: "DELETE"
}, function(error, response, body) {
console.log("delete: " + response.statusCode);
callback(error);
});
}
async.waterfall([
create,
read,
update,
read,
del,
read
]);
测试脚本的输出显示在清单 12-25 中。在运行脚本之前,请确保创建了store目录。创建操作返回一个201,表示在服务器上成功创建了foo。当文件被读取时,返回一个200,并显示文件的正确内容。接下来,文件被成功更新并再次读取。然后,文件被成功删除。随后的read操作返回一个404,因为文件不再存在。
清单 12-25 。清单 12-24 中测试脚本的输出
$ node rest-test.js
create: 201
read: 200
This is a test file!
update: 200
read: 200
This file has been updated!
delete: 200
read: 404
摘要
本章介绍了 Express 框架的基础知识。Express 在 Connect 和 HTTP 之上提供了一个层,这大大简化了 web 应用的设计。在撰写本文时,Express 是npm注册表中第五大依赖模块,已经被用于构建超过 26,000 个 web 应用。这使得 Express 对于全面发展的 Node 开发人员来说极其重要。尽管 Express 可能是一整本书的主题,但本章已经触及了框架和相关技术的最重要的方面。为了更好地理解这个框架,我们鼓励你浏览位于http://www.expressjs.com的 Express 文档,以及位于https://github.com/visionmedia/express的源代码。
十三、实时网络
正如你在第十一章中了解到的,HTTP 是围绕请求-响应模型设计的。所有 HTTP 通信都是由客户端向服务器发出请求而发起的。然后,服务器用请求的数据响应客户机。在网络的早期,这种模式是可行的,因为网站是链接到其他静态 HTML 页面的静态 HTML 页面。然而,网络已经进化,网站不再仅仅是静态页面。
像 Ajax 这样的技术使 web 变得动态和数据驱动,并使一类 web 应用能够与本地应用相媲美。Ajax 调用仍然发出 HTTP 请求,但是它们不是从服务器检索整个文档,而是只请求一小部分数据来更新现有页面。Ajax 调用更快,因为它们每个请求传输的字节更少。它们还通过平滑更新当前页面而不是强制刷新整个页面来改善用户体验。
对于 Ajax 带来的一切,它仍然有很大的改进空间。首先,每个 Ajax 请求都是一个完整的 HTTP 请求。这意味着,如果应用使用 Ajax 只是为了向服务器报告信息(例如,一个分析应用),服务器仍然会浪费时间发回一个空响应。
Ajax 的第二个主要限制是所有的通信仍然必须由客户端发起。客户端发起的通信,被称为拉技术,对于客户端总是想要服务器上可用的最新信息的应用来说是低效的。这些类型的应用更适合推送技术,在这种技术中,通信是由服务器发起的。很适合推动技术发展的应用的例子有体育行情、聊天程序、股票行情和社交媒体新闻。Ajax 请求可以通过多种方式欺骗推送技术,但这些都是不体面的攻击。例如,客户端可以定期向服务器发出请求,但这是非常低效的,因为许多服务器响应可能不包含任何更新。另一种技术,称为长轮询,涉及客户端向服务器发出请求。如果没有新数据,连接就保持打开状态。一旦数据变得可用,服务器将它发送回客户机并关闭连接。然后,客户端立即发出另一个请求,确保打开的连接始终可用于推送数据。由于与服务器的重复连接,长轮询也是低效的。
近年来,HTML5 引入了几种新的浏览器技术,更好地促进了推送技术。这些技术中最突出的是 WebSockets 。WebSockets 使浏览器能够通过全双工通信信道与服务器通信。这意味着客户端和服务器可以同时传输数据。此外,一旦建立了连接,WebSockets 允许客户端和服务器直接通信,而无需发送请求和响应头。基于浏览器的游戏和其他实时应用是 WebSockets 提供的性能提升的最大受益者。
本章介绍了 WebSockets API,并展示了如何使用 Node.js 构建 WebSockets 应用。Socket.IO在 WebSockets 之上提供了一个抽象层,就像 Connect 和 Express 构建在 Node 的http模块上一样。Socket.IO还依靠 Ajax 轮询等技术,为不支持 WebSockets 的旧浏览器提供实时功能。最后,本章最后展示了如何将Socket.IO与 Express 服务器集成。
WebSockets API
尽管客户端开发不是本书的重点,但在创建任何 Node 应用之前,有必要解释一下 WebSockets API。本节解释如何在浏览器中使用 WebSockets。值得注意的是,WebSockets 是 HTML5 相对较新的特性。旧的浏览器,甚至一些当前的浏览器,都不支持 WebSockets。要确定您的浏览器是否支持 WebSockets,请咨询www.caniuse.com。该网站提供了有关哪些浏览器支持特定功能的信息。本节中显示的示例假设您的浏览器支持 WebSockets。
打开 WebSocket
WebSockets 是通过清单 13-1 中的WebSocket()构造函数创建的。构造函数的第一个参数是 WebSocket 将连接到的 URL。当构建 WebSocket 时,它会立即尝试连接到所提供的 URL。没有办法阻止或推迟连接尝试。构造之后,WebSocket 的 URL 可以通过它的url属性访问。WebSocket URLs 看起来就像你习惯的 HTTP URLs 然而,WebSockets 使用ws或wss协议。标准 WebSockets 使用ws协议,默认情况下使用端口 80。另一方面,安全 WebSockets 使用wss协议,默认端口为 443。
清单 13-1 。 WebSocket()构造函数
WebSocket(url, [protocols])
构造函数的第二个参数protocols是可选的。如果指定了它,它应该是一个字符串或字符串数组。字符串是子协议名称。使用子协议允许单个服务器同时处理不同的协议。
关闭 WebSockets
要关闭 WebSocket 连接,使用close()方法,其语法如清单 13-2 所示。close()带两个参数,code和reason,都是可选的。code参数是一个数字状态代码,而reason是一个描述close事件环境的字符串。close的支持值如表 13-1 所示。通常,close()是不带参数调用的。
清单 13-2 。WebSocket close()方法
socket.close([code], [reason])
表 13-1 。close()支持的状态代码
|
状态代码
|
描述
| | --- | --- | | 0-999 | 保留。 | | 1000 | 正常关闭。在正常情况下,当 WebSocket 关闭时会使用此代码。 | | 第一千零一章 | 离开。可能是服务器出现故障,或者是浏览器离开了该页面。 | | 第一千零二章 | 由于协议错误,连接关闭。 | | 第一千零三章 | 由于收到端点不知道如何处理的数据,连接被终止。一个例子是在需要文本时接收二进制数据。 | | 第一千零四章 | 由于收到过大的数据帧,连接被关闭。 | | 第一千零五章 | 保留。此代码表示没有提供状态代码,尽管应该提供状态代码。 | | 第一千零六章 | 保留。此代码表示连接异常关闭。 | | 1007-1999 | 为 WebSocket 标准的未来版本保留。 | | 2000 年至 2999 年 | 为 WebSocket 扩展保留。 | | 3000-3999 | 这些代码应该由库和框架使用,而不是应用。 | | 4000-4999 | 这些代码可供应用使用。 |
检查 WebSocket 的状态
web socket 的状态可以通过它的readyState属性随时检查。在 WebSocket 的生命周期中,它可以处于表 13-2 中描述的四种可能状态之一。
表 13-2 。WebSocket 的 readyState 属性的可能值
|
状态
|
描述
|
| --- | --- |
| 连接 | 当构造 WebSocket 时,它会尝试连接到它的 URL。在此期间,它被视为处于connecting状态。处于connecting状态的 WebSocket 的readyState值为0。 |
| 打开 | WebSocket 成功连接到它的 URL 后,它进入open状态。WebSocket 必须处于open状态,以便通过网络发送和接收数据。处于open状态的 WebSocket 的readyState值为1。 |
| 关闭 | 当 WebSocket 关闭时,它必须首先与希望断开连接的远程主机通信。在此通信期间,WebSocket 被认为处于closing状态。处于closing状态的 WebSocket 的readyState值为2。 |
| 关闭 | WebSocket 一旦成功断开连接,就会进入closed状态。处于closed状态的 WebSocket 的readyState值为3。 |
因为硬编码常量值不是好的编程实践,所以 WebSocket 接口定义了表示可能的readyState值的静态常量。清单 13-3 展示了如何使用这些常量通过switch语句来评估连接的状态。
清单 13-3 。使用readyState属性确定 WebSocket 的状态
switch (socket.readyState) {
case WebSocket.CONNECTING:
// in connecting state
break;
case WebSocket.OPEN:
// in open state
break;
case WebSocket.CLOSING:
// in closing state
break;
case WebSocket.CLOSED:
// in closed state
break;
default:
// this never happens
break;
}
open事件
当 WebSocket 转换到open状态时,它的open事件被触发。清单 13-4 中显示了一个open事件处理程序的例子。事件对象是传递给事件处理程序的唯一参数。
清单 13-4 。一个示例open事件处理程序
socket.onopen = function(event) {
// handle open event
};
WebSocket 事件处理程序也可以使用addEventListener()方法来创建。清单 13-5 展示了如何使用addEventListener()来附加同一个open事件处理程序。这种替代语法比onopen更可取,因为它允许多个处理程序附加到同一个事件。
清单 13-5 。使用addEventListener()附加一个open事件处理程序
socket.addEventListener("open", function(event) {
// handle open event
});
message事件
当 WebSocket 接收到新数据时,会触发一个message事件。接收到的数据可以通过message事件的data属性获得。清单 13-6 中显示了一个message事件处理程序的例子。在本例中,addEventListener()用于附加事件,但也可以使用onmessage。如果正在接收二进制数据,则在调用事件处理程序之前,应该相应地设置 WebSocket 的binaryType属性。
清单 13-6 。一个示例message事件处理程序
socket.addEventListener("message", function(event) {
var data = event.data;
// process data as string, Blob, or ArrayBuffer
});
注意除了处理字符串数据,WebSockets 还支持两种类型的二进制数据——二进制大型对象( Blob s)和
ArrayBuffers。然而,一个单独的 WebSocket 一次只能处理两种二进制格式中的一种。当一个 WebSocket 被创建时,它最初被设置为处理Blob数据。WebSocket 的binaryType属性用于在Blob和ArrayBuffer支持之间进行选择。为了处理Blob数据,WebSocket 的binaryType应该在读取数据之前设置为"blob"。类似地,在试图读取一个ArrayBuffer之前,应当将binaryType设置为"arraybuffer"。
close事件
当 WebSocket 关闭时,会触发一个close事件。传递给close处理程序的事件对象有三个属性,名为code、reason和wasClean。code和reason字段对应于传递给close()的相同名称的自变量。wasClean字段是一个布尔值,它指示连接是否被干净地关闭。一般情况下,wasClean就是true。清单 13-7 中显示了一个close事件处理程序的例子。
清单 13-7 。一个示例close事件处理程序
socket.addEventListener("close", function(event) {
var code = event.code;
var reason = event.reason;
var wasClean = event.wasClean;
// handle close event
});
error事件
当 WebSocket 遇到问题时,会触发一个error事件。传递给处理程序的事件是一个标准的错误对象,包括name和message属性。一个 WebSocket error事件处理程序的例子如清单 13-8 所示。
清单 13-8 。一个示例error事件处理程序
socket.addEventListener("error", function(event) {
// handle error event
});
发送数据
WebSockets 通过send()方法传输数据,该方法有三种风格——一种用于发送 UTF-8 字符串数据,第二种用于发送ArrayBuffer,第三种用于发送Blob数据。所有三个版本的send()都有一个参数,它代表要传输的数据。send()的语法如清单 13-9 中的所示。
清单 13-9 。使用 WebSocket 的send()方法
socket.send(data)
Node 中的 WebSockets
Node 核心不支持 WebSocket,但幸运的是在npm注册表中有大量的第三方 web socket 模块。尽管您可以自由选择任何想要的模块,但本书中的示例使用了ws模块。这一决定背后的理由是,ws速度快、受欢迎、得到很好的支持,并且被用于本章后面将要讨论的Socket.IO库中。
为了演示ws模块是如何工作的,让我们先来看一个例子。清单 13-10 中的代码是一个使用ws、http和connect模块构建的 WebSocket echo 服务器。此服务器接受端口 8000 上的 HTTP 和 WebSocket 连接。Connect 的static中间件允许通过 HTTP 从public子目录提供任意静态内容,而ws处理 WebSocket 连接。
清单 13-10 。使用ws、http和connect模块构建的 WebSocket Echo 服务器
var http = require("http");
var connect = require("connect");
var app = connect();
var WebSocketServer = require("ws").Server;
var server;
var wsServer;
app.use(connect.static("public"));
server = http.createServer(app);
wsServer = new WebSocketServer({
server: server
});
wsServer.on("connection", function(ws) {
ws.on("message", function(message, flags) {
ws.send(message, flags);
});
});
server.listen(8000);
要创建服务器的 WebSocket 组件,我们必须首先导入ws模块的Server()构造函数。构造函数存储在清单 13-10 中的WebSocketServer变量中。接下来,通过调用构造函数创建 WebSocket 服务器的实例wsServer。HTTP 服务器server被传递给构造函数,允许 WebSockets 和 HTTP 在同一个端口上共存。从技术上讲,通过将{port: 8000}传递给WebSocketServer()构造函数,可以构建一个没有http和connect的纯 WebSocket 服务器。
当接收到 WebSocket 连接时,调用connection事件处理程序。该处理程序接受一个 WebSocket 实例ws作为它唯一的参数。WebSocket 附加了一个用于从客户端接收数据的message事件处理程序。当接收到数据时,使用 WebSocket 的send()方法将消息及其相关标志简单地回显到客户端。消息标志用于指示消息是否包含二进制数据等信息。
WebSocket 客户端
ws模块还允许创建 WebSockets 客户端。清单 13-10 中与 echo 服务器一起工作的客户端在清单 13-11 中显示。客户端首先导入ws模块作为变量WebSocket。在示例的第二行,构建了一个 WebSocket,它连接到本地机器的端口 8000。回想一下,WebSocket 客户端会立即尝试连接到传递给构造函数的 URL。因此,我们没有告诉 WebSocket 进行连接,而是简单地设置了一个open事件处理程序。一旦建立了连接,open事件处理程序就将字符串"Hello!"发送给服务器。
清单 13-11 。与清单 13-10 中的服务器协同工作的 WebSocket 客户端
var WebSocket = require("ws");
var ws = new WebSocket("ws://localhost:8000");
ws.on("open", function() {
ws.send("Hello!");
});
ws.on("message", function(data, flags) {
console.log("Server says:");
console.log(data);
ws.close();
});
一旦服务器接收到消息,它将把它回显给客户机。为了处理传入的数据,我们还必须设置一个message事件处理程序。在清单 13-11 中,message处理程序将数据显示到屏幕上,然后使用close()关闭 WebSocket。
一个 HTML 客户端
因为示例服务器支持 HTTP 和 WebSockets,所以我们可以提供嵌入了 WebSocket 功能的 HTML 页面。清单 13-12 中显示了一个使用 echo 服务器的示例页面。HTML5 页面包含用于连接和断开服务器的按钮,以及用于键入和发送消息的文本字段和按钮。最初,只有Connect按钮被激活。连接后,Connect按钮被禁用,其他控件被启用。然后你可以输入一些文本并按下Send按钮。然后,数据将被发送到服务器,回显并显示在页面上。为了测试这个页面,首先将它作为test.htm保存在 echo 服务器的public子目录中。服务器运行时,只需导航至http://localhost:8000/test.htm。
清单 13-12 。与清单 13-10 中的服务器协同工作的 HTML 客户端
<!DOCTYPE html>
<html lang="en">
<head>
<title>WebSocket Echo Client</title>
<meta charset="UTF-8" />
<script>
"use strict";
// Initialize everything when the window finishes loading
window.addEventListener("load", function(event) {
var status = document.getElementById("status");
var open = document.getElementById("open");
var close = document.getElementById("close");
var send = document.getElementById("send");
var text = document.getElementById("text");
var message = document.getElementById("message");
var socket;
status.textContent = "Not Connected";
close.disabled = true;
send.disabled = true;
// Create a new connection when the Connect button is clicked
open.addEventListener("click", function(event) {
open.disabled = true;
socket = new WebSocket("ws://localhost:8000");
socket.addEventListener("open", function(event) {
close.disabled = false;
send.disabled = false;
status.textContent = "Connected";
});
// Display messages received from the server
socket.addEventListener("message", function(event) {
message.textContent = "Server Says: " + event.data;
});
// Display any errors that occur
socket.addEventListener("error", function(event) {
message.textContent = "Error: " + event;
});
socket.addEventListener("close", function(event) {
open.disabled = false;
status.textContent = "Not Connected";
});
});
// Close the connection when the Disconnect button is clicked
close.addEventListener("click", function(event) {
close.disabled = true;
send.disabled = true;
message.textContent = "";
socket.close();
});
// Send text to the server when the Send button is clicked
send.addEventListener("click", function(event) {
socket.send(text.value);
text.value = "";
});
});
</script>
</head>
<body>
Status: <span id="status"></span><br />
<input id="open" type="button" value="Connect" />
<input id="close" type="button" value="Disconnect" /><br />
<input id="send" type="button" value="Send" />
<input id="text" /><br />
<span id="message"></span>
</body>
</html>
检查 WebSocket 连接
您可能想知道 HTTP 和 WebSockets 如何同时监听同一个端口。原因是初始 WebSocket 连接是通过 HTTP 进行的。图 13-1 展示了从 Chrome 开发者工具的角度来看 WebSocket 连接的样子。图像的顶部显示了来自清单 13-12 的实际测试页面。图的底部显示了 Chrome 开发者工具,并显示了两个记录的网络请求。第一个请求test.htm,只是下载测试页面。标记为localhost的第二个请求在网页上按下Connect按钮时发生。该请求发送 WebSocket 头和一个Upgrade头,这使得将来的通信能够通过 WebSocket 协议进行。通过检查响应状态代码和头,您可以看到连接成功地从 HTTP 切换到 WebSocket 协议。
图 13-1 。使用 Chrome 的开发工具检查 WebSocket 连接
插座。IO
本章前面已经解释了 WebSockets 的众多好处。然而,它们最大的缺点可能是缺乏浏览器支持,尤其是在传统浏览器中。进入Socket.IO,一个自称为“实时应用的跨浏览器 WebSocket”的 JavaScript 库Socket.IO通过提供心跳和超时等附加功能,在 WebSockets 之上增加了另一个抽象层。这些功能通常用于实时应用,可以使用 WebSockets 实现,但不是标准的一部分。
Socket.IO的真正优势在于它能够在完全不支持 WebSockets 的旧浏览器上维护相同的 API。当本地 WebSockets 不可用时,这可以通过依靠旧技术来实现,如 Adobe Flash Sockets、Ajax long polling 和 JSONP polling。通过提供回退机制,Socket.IO可以与 Internet Explorer 5.5 等传统浏览器一起工作。它的灵活性使它成为npm注册表中第五大明星模块,同时被超过 700 个npm模块所依赖。
创建套接字。IO 服务器
Socket.IO和ws一样,很容易和http模块结合。清单 13-13 显示了另一个组合了 HTTP 和 WebSockets(通过Socket.IO)的 echo 服务器。清单 13-13 的第三行导入了Socket.IO模块。Socket.IO listen()方法强制Socket.IO监听 HTTP 服务器server。然后由listen()、io返回的值用于配置应用的 WebSockets 部分。
清单 13-13 。使用http、connect和Socket.IO的 Echo 服务器
var http = require("http");
var connect = require("connect");
var socketio = require("socket.io");
var app = connect();
var server;
var io;
app.use(connect.static("public"));
server = http.createServer(app);
io = socketio.listen(server);
io.on("connection", function(socket) {
socket.on("message", function(data) {
socket.emit("echo", data);
});
});
server.listen(8000);
一个connection事件处理程序处理传入的 WebSocket 连接。与ws非常相似,连接处理程序将 WebSocket 作为其唯一的参数。接下来,注意message事件处理程序。当新数据通过 WebSocket 到达时,将调用该处理程序。然而,与标准的 WebSockets 不同,Socket.IO允许任意命名的事件。这意味着我们可以监听foo事件,而不是message事件。不管事件的名称是什么,收到的数据都会传递给事件处理程序。然后,通过发出一个echo事件,数据被回显到客户端。同样,事件名称是任意的。另外,注意数据是使用熟悉的EventEmitter语法的emit()方法发送的。
创建套接字。IO 客户端
Socket.IO还附带了可用于浏览器开发的客户端脚本。清单 13-14 提供了一个示例页面,它可以与清单 13-13 中的服务器对话。将该页面放在 echo 服务器的public子目录中。首先要注意的是文档头中包含的Socket.IO脚本。该脚本由服务器端模块自动处理,不需要添加到public目录中。
清单 13-14 。与清单 13-13 中的服务器协同工作的Socket.IO客户端
<!DOCTYPE html>
<html>
<head>
<script src="/socket.io/socket.io.js"></script>
</head>
<body>
<body>
<script>
var socket = io.connect("http://localhost");
socket.emit("message", "Hello!");
socket.on("echo", function(data) {
document.write(data);
});
</script>
</body>
</html>
接下来要检查的是内嵌的<script>标签。这就是Socket.IO应用逻辑。当页面被加载时,使用io.connect()方法来建立到服务器的连接。注意,这个连接是使用 HTTP URL 建立的,而不是使用ws协议。然后使用emit()方法向服务器发送一个message事件。同样,事件名称的选择是任意的,但是客户机和服务器必须在名称上达成一致。由于服务器将发回一个echo事件,我们做的最后一件事是创建一个echo事件处理程序,它将接收到的消息打印到文档中。
插座。IO 和 Express
集成Socket.IO和 Express 非常简单。其实和把Socket.IO和http整合在一起,连接起来没多大区别。清单 13-15 展示了这是如何完成的。唯一的主要区别是,Express 被导入并用于创建app变量和附加中间件,而不是 Connect。仅仅为了举例,一个快速路由也被添加到现有的 echo 服务器中。清单 13-14 中的客户端页面仍然可以在这个例子中使用,无需修改。
清单 13-15 。使用Socket.IO和 Express 构建的 Echo 服务器
var express = require("express");
var http = require("http");
var socketio = require("socket.io");
var app = express();
var server = http.createServer(app);
var io = socketio.listen(server);
app.use(express.static("public"));
app.get("/foo", function(req, res, next) {
res.send(200, {
body: "Hello from foo!"
});
});
io.on("connection", function(socket) {
socket.on("message", function(data) {
socket.emit("echo", data);
});
});
server.listen(8000);
摘要
本章讲述了实时网络的概念。这个领域最大的玩家无疑是 WebSockets。WebSockets 通过在客户机和服务器之间提供双向通信而无需发送 HTTP 头,提供了一流的性能。然而,虽然 WebSockets 提供了潜在的巨大性能提升,但它们是相对较新的标准,在传统浏览器中不受支持。因此,本章还介绍了Socket.IO,这是一个跨浏览器的 WebSocket 模块,它通过依靠其他效率较低的数据传输机制来支持旧浏览器。此外,本章还向您展示了如何将Socket.IO与第十一章和第十二章中涵盖的其他技术相集成。在下一章中,您将学习如何访问数据库,以及如何将它们与到目前为止您已经学习过的所有 Node 模块集成在一起。
十四、数据库
几乎所有的 web 应用都有某种类型的后备数据存储。通常,这种数据存储是某种数据库,用于存储从地址和信用卡号到传感器读数和处方信息的所有内容。数据库提供了一种快速访问大量数据的方法。通常有两种类型的数据库——关系数据库和 NoSQL 数据库。本章重点介绍数据库,以及如何从 Node 应用访问它们。更具体地说,探索了 MySQL 关系数据库和 MongoDB NoSQL 数据库。请注意,本章没有提供安装 MySQL 和 MongoDB 的说明。此外,它还假设您已经熟悉结构化查询语言(SQL ),该语言与关系数据库结合使用。
关系数据库
关系数据库由一组表组成。每个表保存一组由数据组成的记录。表中的单个记录被称为行或元组。存储在这些元组中的数据类型是使用模式预定义的。图 14-1 中显示了一个示例表。该表包含个人信息,包括姓名、性别、社会保险号(SSN)以及他们居住的城市和州(为了节省空间,省略了地址等信息)。
图 14-1 。关系数据库中的示例表
用于创建图 14-1 中表格的 SQL CREATE语句如清单 14-1 所示。这个 SQL 命令定义了表的模式,所有元组都必须遵守这个模式。在这种情况下,这个人的社会保险号必须是 11 个字符长(以适应破折号),他们的性别必须是一个字符,他们的居住州必须是两个字符。此人的姓、名和居住城市的长度都不超过 50 个字符。
清单 14-1 。用于创建图 14-1 中表格的 SQL
CREATE TABLE Person (
SSN CHAR(11) NOT NULL,
LastName VARCHAR(50) NOT NULL,
FirstName VARCHAR(50) NOT NULL,
Gender CHAR(1),
City VARCHAR(50) NOT NULL,
State CHAR(2) NOT NULL,
PRIMARY KEY(SSN)
);
还要注意,社会保险号被用作表的主键。主键是一个或多个字段,用于确保表中某个元组的唯一性。由于每个人都应该有一个唯一的社会安全号,这使得它成为主键的理想选择。
清单 14-2 中显示的 SQL INSERT语句用于填充人员表。请注意,每个语句中的所有值都符合预定义的模式。如果您要输入一个无效的数据,或者一个已经存在于表中的 SSN,那么数据库管理系统将拒绝插入。
清单 14-2 。用于填充图 14-1 中表格的 SQL
INSERT INTO Person (SSN, LastName, FirstName, Gender, City, State)
VALUES ('123-45-6789', 'Pluck', 'Peter', 'M', 'Pittsburgh', 'PA');
INSERT INTO Person (SSN, LastName, FirstName, Gender, City, State)
VALUES ('234-56-7890', 'Johnson', 'John', 'M', 'San Diego', 'CA');
INSERT INTO Person (SSN, LastName, FirstName, Gender, City, State)
VALUES ('345-67-8901', 'Doe', 'Jane', 'F', 'Las Vegas', 'NV');
INSERT INTO Person (SSN, LastName, FirstName, Gender, City, State)
VALUES ('456-78-9012', 'Doe', 'John', 'M', 'Las Vegas', 'NV');
关系数据库试图通过只在一个地方存储数据来消除冗余。如果只需要在一个位置更新和删除数据,则过程会简单得多。去除冗余的过程被称为规范化,导致多个表使用外键相互引用。外键是在不同的表中唯一标识一个元组的一个或多个字段。
对于一个具体的例子,让我们回到我们的示例数据库。它目前有一个表 Person,用于存储个人信息。如果我们也想追踪这些人的车呢?通过在模式中创建额外的列,可以将这些信息存储在 Person 表中。然而,如何处理一个人拥有多辆汽车的情况呢?您必须继续向表中添加额外的 car 字段(car1、car2 等),其中许多字段都是空的(大多数人只有一辆或没有汽车)。更好的替代方法是创建一个单独的车辆表,其中包含汽车信息和一个引用 Person 表的外键。车辆表示例如图 14-2 所示。
图 14-2 。一个简化的车辆表
用于定义车辆表的CREATE语句如清单 14-3 所示,而用于填充车辆表的插入语句如清单 14-4 所示。注意这辆车。SSN·菲尔德提到了这个人。SSN 场。这是一个外键关系,尽管在本例中两个表中的字段具有相同的名称,但这不是必需的。
清单 14-3 。用于创建车辆表的 SQL
CREATE TABLE Vehicle (
SSN CHAR(11) NOT NULL,
VIN INT UNSIGNED NOT NULL,
Type VARCHAR(50) NOT NULL,
Year INT UNSIGNED NOT NULL,
PRIMARY KEY(VIN),
FOREIGN KEY(SSN)
REFERENCES Person(SSN)
);
清单 14-4 。用于填充车辆表的 SQL
INSERT INTO Vehicle (SSN, VIN, Type, Year)
VALUES ('123-45-6789', 12345, 'Jeep', 2014);
INSERT INTO Vehicle (SSN, VIN, Type, Year)
VALUES ('234-56-7890', 67890, 'Van', 2010);
INSERT INTO Vehicle (SSN, VIN, Type, Year)
VALUES ('345-67-8901', 54327, 'Truck', 2009);
INSERT INTO Vehicle (SSN, VIN, Type, Year)
VALUES ('123-45-6789', 98032, 'Car', 2006);
关系数据库的真正优势之一是能够快速查询信息,即使信息分散在多个表中。这是使用JOIN操作完成的。清单 14-5 的中显示的 SQL SELECT语句使用了一个JOIN操作来选择在拉斯韦加斯拥有汽车的每个人的名字。在这个例子中,people 表中有两个来自拉斯维加斯的人,但是只有一个人拥有汽车。因此,该查询将返回姓名 Jane Doe。
清单 14-5 。涉及JOIN操作的 SQL 查询
SELECT FirstName, LastName FROM Person INNER JOIN Vehicle
WHERE Person.SSN = Vehicle.SSN AND City = 'Las Vegas';
MySQL〔??〕
MySQL 是一个非常流行的关系数据库管理系统。它也是开源的,可以免费获得。它被广泛使用,以至于 LAMP stack 中的 M 代表 MySQL。它已经被用于许多高知名度的项目和网站,如 WordPress、Wikipedia、Google 和 Twitter。本章中的 MySQL 示例使用第三方模块mysql 访问数据库,该模块必须使用清单 14-6 中所示的命令安装。
清单 14-6 。用于安装 mysql 模块的 npm 命令
$ npm install mysql
连接到 MySQL
为了访问数据库,您必须首先建立连接。本章中的例子假设 MySQL 运行在您的本地机器上。要建立连接,您应该首先使用createConnection( )方法创建一个连接对象。有两个实现相同最终结果的createConnection( )化身。第一个版本将一个对象作为唯一的参数。此参数包含用于建立连接的参数。创建连接的例子如清单 14-7 所示。该示例创建了一个到 MySQL 数据库 dbname 的连接,该数据库运行在 localhost:3306 上(MySQL 默认端口为 3306,因此通常可以省略该选项)。用户和密码选项通过防止数据库被任意访问来提供安全性。
清单 14-7 。 创建与 MySQL 数据库的连接
var mysql = require("mysql");
var connection = mysql.createConnection({
"host": "localhost",
"port": 3306,
"user": "username",
"password": "secret",
"database": "dbname"
});
另一个版本的createConnection( )将一个 MySQL URL 字符串作为唯一的参数。清单 14-8 展示了同样的createConnection( )例子如何被重写以使用一个 URL 字符串。虽然这个版本提供了更简洁的语法,但可读性不如使用对象文字。
清单 14-8 。使用 URL 字符串创建到 MySQL 数据库的连接
var mysql = require("mysql");
var connection =
mysql.createConnection("mysql://username:secret@localhost:3306/dbname");
创建连接对象后,下一步是调用它的connect( )方法。该方法采用单个参数,即在连接建立后调用的回调函数。如果连接时发生错误,它将作为回调函数的第一个也是唯一一个参数传递。清单 14-9 展示了建立连接的过程。
清单 14-9 。 使用 connect()方法建立连接
var mysql = require("mysql");
var connection = mysql.createConnection({
"host": "localhost",
"port": 3306,
"user": "username",
"password": "secret",
"database": "dbname"
});
connection.connect(function(error) {
if (error) {
return console.error(error);
}
// Connection successfully established
});
连接池
在前面的例子中,每次应用需要访问数据库时,都会建立一个新的连接。但是,如果您提前知道您的应用将需要许多到数据库的频繁连接,那么建立一个可重用的连接池可能会更有效。每次需要新的连接时,应用可以简单地从池中请求一个连接。一旦连接完成了它的目的,它就可以被返回到池中供将来的请求使用。使用createPool( )方法创建一个连接池,如清单 14-10 所示。注意createPool( )和createConnection( )非常相似。createPool( )还支持一些特定于池的附加选项。这些选项在表 14-1 中列出。
清单 14-10 。使用 createPool( )方法创建连接池
var mysql = require("mysql");
var pool = mysql.createPool({
"host": "localhost",
"user": "username",
"password": "secret",
"database": "dbname"
});
表 14-1 。createPool()支持的附加选项
|
[计]选项
|
描述
|
| --- | --- |
| createConnection | 创建池连接时使用的函数。这默认为createConnection( )。 |
| connectionLimit | 一次可以创建的最大连接数。如果省略,则默认为 10。 |
| queueLimit | 池可以排队的最大连接请求数。如果该值为零(默认值),则没有限制。如果存在一个极限并且超过了这个极限,那么从createConnection( )返回一个错误。 |
| waitForConnections | 如果这是真的(默认值),那么如果没有可用的连接,请求将被添加到队列中。如果这是假的,那么池将立即回调并返回一个错误。 |
池的getConnection( )方法用于请求连接。该方法将回调函数作为其唯一的参数。回调函数的参数是可能的错误条件和请求的连接对象。如果没有错误发生,那么连接对象将已经处于连接状态,这意味着不需要调用connect( )。清单 14-11 显示了如何从连接池中请求连接。
清单 14-11 。 使用getConnection( )方法从池中请求连接
var mysql = require("mysql");
var pool = mysql.createPool({
"host": "localhost",
"user": "username",
"password": "secret",
"database": "dbname"
});
pool.getConnection(function(error, connection) {
if (error) {
return console.error(error);
}
// Connection available for use
});
关闭连接
可以使用end( )和destroy( )方法关闭非池连接。end( )方法优雅地关闭连接,允许任何排队的查询执行。end( )将回调作为唯一参数。清单 14-12 展示了如何使用end( )来关闭一个打开的连接。
清单 14-12 。 打开一个连接,然后使用end( )关闭它
var mysql = require("mysql");
var connection =
mysql.createConnection("mysql://username:secret@localhost/dbname");
connection.connect(function(error) {
if (error) {
return console.error(error);
}
connection.end(function(error) {
if (error) {
return console.error(error);
}
});
});
另一方面,destroy( )方法会立即关闭底层套接字,而不管发生了什么。destroy( )的用法如清单 14-13 所示。
清单 14-13 。connection.destroy( )方法的用法
connection.destroy( );
使用release( )和destroy( )方法关闭池连接。release( )实际上并不终止连接,而是简单地将它返回到池中供另一个请求使用。或者,使用destroy( )方法来终止一个连接,并将其从池中删除。下次请求新连接时,池将创建一个新连接来替换被破坏的连接。清单 14-14 提供了一个使用release( )方法的例子。
清单 14-14 。 使用release( )方法释放池连接
var mysql = require("mysql");
var pool = mysql.createPool({
"host": "localhost",
"user": "username",
"password": "secret",
"database": "dbname"
});
pool.getConnection(function(error, connection) {
if (error) {
return console.error(error);
}
connection.release( );
});
执行查询
你学会了如何打开连接,也学会了如何关闭连接。现在是时候了解在开始和结束之间发生了什么。连接到数据库后,您的应用将执行一个或多个查询。这是使用连接的query( )方法完成的。query( )方法有两个参数——一个要执行的 SQL 字符串和一个回调函数。回调函数的参数是一个可能的错误对象和 SQL 命令的结果。
清单 14-15 显示了一个完整的例子,它创建一个连接池,请求一个连接,在 Person 表上执行一个 SQL 查询,显示结果,然后将连接释放回连接池。结果输出如清单 14-16 所示。
清单 14-15 。在 Person 表上执行查询
var mysql = require("mysql");
var pool = mysql.createPool({
"host": "localhost",
"user": "username",
"password": "secret",
"database": "dbname"
});
pool.getConnection(function(error, connection) {
if (error) {
return console.error(error);
}
var sql = "SELECT * FROM Person";
connection.query(sql, function(error, results) {
if (error) {
return console.error(error);
}
console.log(results);
connection.release( );
});
});
清单 14-16 。清单 14-15 中代码的输出
$ node sql-query.js
[ { SSN: '123-45-6789',
LastName: 'Pluck',
FirstName: 'Peter',
Gender: 'M',
City: 'Pittsburgh',
State: 'PA' },
{ SSN: '234-56-7890',
LastName: 'Johnson',
FirstName: 'John',
Gender: 'M',
City: 'San Diego',
State: 'CA' },
{ SSN: '345-67-8901',
LastName: 'Doe',
FirstName: 'Jane',
Gender: 'F',
City: 'Las Vegas',
State: 'NV' },
{ SSN: '456-78-9012',
LastName: 'Doe',
FirstName: 'John',
Gender: 'M',
City: 'Las Vegas',
State: 'NV' } ]
注意清单 14-16 中显示的结果被格式化为一个对象数组。这是因为执行的查询是一个SELECT操作。如果操作是不同的类型(UPDATE, INSERT, DELETE,等等),那么结果应该是包含操作信息的单个对象。例如,清单 14-17 中的命令删除了 People 表中的所有个人。产生的对象如清单 14-18 所示。请注意,affectedRows属性被设置为 4,以指示被删除的元组的数量。
清单 14-17 。用于清除人员表的 SQL DELETE命令
DELETE FROM People;
清单 14-18 。执行清单 14-17 中的语句时来自query( )的结果对象
{ fieldCount: 0,
affectedRows: 4,
insertId: 0,
serverStatus: 34,
warningCount: 0,
message: '',
protocol41: true,
changedRows: 0 }
注意当向具有自动增量主键的表中插入行时,结果对象的
insertId属性非常有用。
NoSQL 数据库
NoSQL 数据库代表了数据库的另一种主要风格。有许多类型的 NoSQL 数据库可用,例如键/值存储、对象存储和文档存储。常见的 NoSQL 特征是缺乏模式、简单的 API 和宽松的一致性模型。NoSQL 数据库的一个共同点是,为了追求更高的性能和可伸缩性,它们放弃了 MySQL 等系统使用的关系数据模型。
关系数据模型擅长使用被称为事务的原子操作来保持数据的一致性。然而,维护数据一致性是以额外开销为代价的。银行等一些应用要求数据绝对正确。毕竟,一家失去客户资金记录的银行不会存在太久。然而,许多应用可以摆脱 NoSQL 数据存储提供的宽松约束。例如,如果一个更新没有立即出现在社交媒体的新闻源上,这并不是世界末日。
蒙戈布〔??〕
与 Node.js 结合使用的最著名的 NoSQL 数据库之一是 MongoDB,有时简称为 Mongo。Mongo 是一个面向文档的数据库,它将数据存储在 BSON(二进制 JSON)格式的文档中。Mongo 在 Node 应用中的突出使用产生了术语均值堆栈。首字母缩写词 MEAN 指的是由 MongoDB、Express、 AngularJS(一种用于创建单页面应用的前端框架)和 Node.js 组成的流行软件堆栈。Mongo 已被用于许多流行的网络公司,包括易贝、Foursquare 和 Craigslist。
要从 Node 应用中访问 Mongo,需要一个驱动程序。有许多可用的 Mongo 驱动程序,但 Mongoose 是其中最受欢迎的。清单 14-19 显示了用于安装 mongoose 模块的npm命令。
清单 14-19 。 命令用来安装 mongoose 模块
$ npm install mongoose
正在连接到 MongoDB
createConnection( )方法用于创建一个新的 MongoDB 连接。这个方法接受一个 MongoDB URL 作为输入参数。清单 14-20 中显示了一个示例 URL,它使用了与前面的 MySQL 示例相同的连接参数。在本例中,username、secret、localhost 和 dbname 分别对应于用户名、密码、服务器主机和数据库名称。
清单 14-20 。 使用 Mongoose 连接到 MongoDB
var mongoose = require("mongoose");
var connection =
mongoose.createConnection("mongodb://username:secret@localhost/dbname");
注意在 MongoDB 中创建连接有多种方式。本书中展示的方法被认为是最灵活的,因为它可以处理任意数量的数据库连接。另一种技术并不简单,但它只适用于单个数据库连接。
一旦建立了连接,connection 对象就会发出一个 open 事件。open 事件处理程序不接受任何参数。清单 14-21 中显示了一个处理程序的例子。请注意,close( )方法也用于终止连接。
清单 14-21 。 一个示例连接打开事件处理程序
var mongoose = require("mongoose");
var connection = mongoose.createConnection("mongodb://localhost/test");
connection.on("open", function( ) {
console.log("Connection established");
connection.close( );
});
计划
MongoDB 没有预定义的模式。Mongoose 通过定义模式来帮助定义 Mongo 文档的结构。模式是定义要存储的数据结构的对象。为了说明模式是如何工作的,我们将重新访问 MySQL 部分中的 People 表。清单 14-22 显示了被重构为一个 Mongoose 模式对象的 People 表。在示例的第二行,导入了Schema( )构造函数。Schema( )构造函数接受一个参数,一个包含模式定义的对象。在本例中,所有模式字段都是字符串类型。Schema( )支持的其他数据类型包括Number, Date, Buffer, Boolean, Mixed, Objectid,和Array。
清单 14-22 。创建表示 Person 表的模式
var mongoose = require("mongoose");
var Schema = mongoose.Schema;
var PersonSchema = new Schema({
SSN: String,
LastName: String,
FirstName: String,
Gender: String,
City: String,
State: String
});
回想一下,最初的 Person 表被一个使用外键关系的 Vehicle 表引用。在关系数据库的世界里,这是一个好主意。但是,在 MongoDB 世界中,车辆信息可以作为数组直接添加到 Person 模式中。清单 14-23 显示了人车混合动力车的模式。注意,这种方法不需要连接操作。
清单 14-23 。在 MongoDB 模式中组合 Person 和 Vehicle 表
var mongoose = require("mongoose");
var Schema = mongoose.Schema;
var PersonSchema = new Schema({
SSN: String,
LastName: String,
FirstName: String,
Gender: String,
City: String,
State: String,
Vehicles: [{
VIN: Number,
Type: String,
Year: Number
}]
});
模型
要使用我们新创建的模式对象,我们必须将它与一个数据库连接相关联。在猫鼬术语中,这种联系被称为模型。要创建一个模型,使用连接对象的model( )方法。这个方法有两个参数,一个表示模型名称的字符串和一个Schema对象。清单 14-24 显示了如何创建一个人模型。该示例将人员模型定义为模块导出,以便于代码重用。
清单 14-24 。以可重用的方式定义人员模型
var mongoose = require("mongoose");
var Schema = mongoose.Schema;
var PersonSchema = new Schema({
SSN: String,
LastName: String,
FirstName: String,
Gender: String,
City: String,
State: String,
Vehicles: [{
VIN: Number,
Type: String,
Year: Number
}]
});
module.exports = {
getModel: function getModel(connection) {
return connection.model("Person", PersonSchema);
}
};
因为人员模型在设计时考虑了可重用性,所以它可以很容易地导入到其他文件中,如清单 14-25 所示。这个例子假设模型已经保存在一个名为PersonModel.js的文件中。
清单 14-25 。在另一个文件中导入人员模型
var mongoose = require("mongoose");
var connection = mongoose.createConnection("mongodb://localhost/test");
var Person = require(__dirname + "/PersonModel").getModel(connection);
插入数据
使用 Mongoose 模型将数据插入 MongoDB 是一个简单的两步过程。第一步是使用模型构造函数实例化一个对象。基于清单 14-25 中的,构造函数应该是Person( )。创建对象后,您可以像操作任何其他 JavaScript 对象一样操作它。要真正插入数据,调用模型的save( )方法。save( )接受一个可选参数,一个接受错误参数的回调函数。
清单 14-26 中的例子使用清单 14-24 中定义的模型创建了一个人对象。接下来,一个定制的 foo 字段被添加到模块中。最后,使用模型的save( )方法将数据插入数据库。需要注意的一点是,当保存数据时,foo 字段不会持久化。原因是foo不是模型模式的一部分。该模型将阻止向模型中添加额外的数据,但不会确保包含任何缺失的字段。例如,如果省略了LastName字段,插入仍然会顺利进行。
清单 14-26 。使用 Mongoose 将 Person 对象插入 MongoDB
var mongoose = require("mongoose");
var connection = mongoose.createConnection("mongodb://localhost/test");
var Person = require(__dirname + "/PersonModel").getModel(connection);
connection.on("open", function( ) {
var person = new Person({
SSN: "123-45-6789",
LastName: "Pluck",
FirstName: "Peter",
Gender: "M",
City: "Pittsburgh",
State: "PA",
Vehicles: [
{
VIN: 12345,
Type: "Jeep",
Year: 2014
},
{
VIN: 98032,
Type: "Car",
Year: 2006
}
]
});
person.foo = "bar";
person.save(function(error) {
connection.close( );
if (error) {
return console.error(error);
} else {
console.log("Successfully saved!");
}
});
});
查询数据
模型有几种执行查询的方法。要从 Mongo 中检索数据,请使用模型对象的find( )方法。传递给find( )的第一个参数是一个定义查询条件的对象。这个论点稍后将被重新讨论。find( )的第二个参数是可选的回调函数。如果存在,回调函数将可能的错误作为第一个参数,查询结果作为第二个参数。
清单 14-27 中的例子使用 Person 模型的find( )方法来选择所有居住在拉斯维加斯的车主。条件对象通过指定城市“拉斯维加斯”来选择所有拉斯维加斯市民。为了进一步细化搜索,我们寻找大小不等于零的车辆数组(意味着这个人至少拥有一辆汽车)。如果没有错误发生,结果将显示在回调函数中。示例输出如清单 14-28 中的所示。
清单 14-27 。在 MongoDB 中查询所有居住在拉斯维加斯的车主
var mongoose = require("mongoose");
var connection = mongoose.createConnection("mongodb://localhost/test");
var Person = require(__dirname + "/PersonModel").getModel(connection);
connection.on("open", function( ) {
Person.find({
City: "Las Vegas",
Vehicles: {
$not: {$size: 0}
}
}, function(error, results) {
connection.close( );
if (error) {
return console.error(error);
}
console.log(results);
});
});
清单 14-28 。运行清单 14-27 中代码的输出
$ node mongoose-query
[ { City: 'Las Vegas',
FirstName: 'Jane',
Gender: 'F',
LastName: 'Doe',
SSN: '345-67-8901',
State: 'NV',
__v: 0,
_id: 528190b19e13b00000000007,
Vehicles:
[ { VIN: 54327,
Type: 'Truck',
Year: 2009,
_id: 528190b19e13b00000000008 } ] } ]
查询构建器方法
如果没有向find( )提供回调函数,则返回查询对象。这个查询对象提供了一个查询构建器接口,允许通过使用助手方法将函数调用链接在一起来构建更复杂的查询。在表 14-2 中讨论了其中一些辅助功能。
表 14-2 。各种查询生成器助手方法
|
方法
|
描述
|
| --- | --- |
| where() | 创建附加的搜索细化。这类似于 SQL WHERE子句。 |
| limit() | 接受一个整数参数,该参数指定要返回的最大结果数。 |
| sort() | 根据某些标准对结果进行排序。这类似于 SQL ORDER BY子句。 | |
| select() | 返回已选定字段的子集。 | |
| exec() | 执行查询并调用回调函数。 |
清单 14-29 中显示了一个示例查询生成器。在这个示例中,find( )方法用于选择来自拉斯维加斯的所有个人。然后使用where( )和equals( )方法将搜索进一步细化到姓氏为 Doe 的个人。接下来,使用limit( )方法确保最多选择 10 个人。然后使用sort( )方法按姓氏对结果进行排序,然后按名字进行逆序排序。接下来,使用select( )方法从结果中只提取名字和姓氏字段。最后,执行查询并打印结果。这个特定的查询将从我们的示例数据库中返回 John 和 Jane Doe。
清单 14-29 。查询生成器的一个示例
var mongoose = require("mongoose");
var connection = mongoose.createConnection("mongodb://localhost/test");
var Person = require(__dirname + "/PersonModel").getModel(connection);
connection.on("open", function( ) {
Person.find({
City: "Las Vegas"
})
.where("LastName").equals("Doe")
.limit(10)
.sort("LastName -FirstName")
.select("FirstName LastName")
.exec(function(error, results) {
connection.close( );
if (error) {
return console.error(error);
}
console.log(results);
});
});
更新数据
在 Mongoose 中,使用模型的update( )方法更新数据。update( )接受两个必需的参数,后跟两个可选的参数。第一个参数是用于指定更新条件的对象。该对象的行为类似于传递给find( )的对象。update( )的第二个参数是执行实际更新操作的对象。可选的第三个参数是用于传入选项的另一个对象。update( )支持的选项汇总在表 14-3 中。最后一个参数是一个可选的回调函数,它有三个参数。这些参数是一个错误、更新的 Mongo 文档的数量以及 Mongo 返回的原始响应。
表 14-3 。update()支持的选项
|
[计]选项
|
描述
|
| --- | --- |
| safe | 这是一个设置安全模式值的布尔值。如果未指定,则默认为模式中设置的值(true)。如果这是真的,那么发生的任何错误都被传递给回调函数。 |
| upsert | 如果是true,则不存在的文档会被创建。这默认为false。 |
| multi | 如果true,一次操作可以更新多个文档。这默认为false。 |
| strict | 这是为更新设置严格选项的布尔值。如果 strict 为 true,则非模式数据不会写入文档。这默认为false,意味着无关数据将不会持续。 |
清单 14-30 中的例子对居住城市为拉斯维加斯的所有人执行更新操作。第二个参数将他们的居住城市更新为纽约。第三个参数将multi选项设置为true,这意味着可以使用一个操作更新多个文档。回调函数检查错误,然后显示受影响文档的数量和从 Mongo 收到的响应。
清单 14-30 。将所有拉斯维加斯市民转移到纽约的更新
var mongoose = require("mongoose");
var connection = mongoose.createConnection("mongodb://localhost/test");
var Person = require(__dirname + "/PersonModel").getModel(connection);
connection.on("open", function( ) {
Person.update({
City: "Las Vegas"
}, {
City: "New York"
}, {
multi: true
}, function(error, numberAffected, rawResponse) {
connection.close( );
if (error) {
return console.error(error);
}
console.log(numberAffected + " documents affected");
console.log(rawResponse);
});
});
删除数据
要使用模型删除数据,请使用模型的remove( )方法。remove( )需要两个参数。第一个参数是指定移除条件的对象。这个对象的工作方式类似于传递给find()的对象。第二个参数是一个可选的回调函数,在执行删除后调用。清单 14-31 中的显示了一个移除居住在圣地亚哥的人的例子。当执行此代码时,它将显示数字 1,对应于删除的项目数。
清单 14-31 。使用 MongoDB 模型删除数据
var mongoose = require("mongoose");
var connection = mongoose.createConnection("mongodb://localhost/test");
var Person = require(__dirname + "/PersonModel").getModel(connection);
connection.on("open", function( ) {
Person.remove({
City: "San Diego"
}, function(error, response) {
connection.close( );
if (error) {
return console.error(error);
}
console.log(response);
});
});
摘要
本章向您展示了如何在 Node.js 中使用数据库。在非常简要地概述了关系数据库之后,我们继续讨论 MySQL 数据库。通过介绍mysql模块,您学习了如何与现存的最流行的关系数据库之一进行交互。接下来,本章将重点转向 NoSQL 类的数据存储。近年来,这些数据库变得越来越流行,因为它们比关系数据库更简单,性能更好。在所有可用的 NoSQL 数据库中,本章选择关注 MongoDB,因为它是日益流行的 MEAN stack 的一部分。为了使用 Mongo,我们转向了 mongoose 模块。当然,我们不可能在一章中涵盖所有数据库(甚至 MySQL 和 Mongo 的每个细节),但是通过理解核心概念,您应该能够将您在这里学到的知识应用到其他系统中。