NodeJS-开发者高级教程-全-

NodeJS 开发者高级教程(全)

原文:Pro Node.js for Developers

协议:CC BY-NC-SA 4.0

零、简介

自 2009 年创建以来,Node.js 已经发展成为一个强大且日益流行的异步开发框架,用于创建高度可伸缩的 JavaScript 应用。像道琼斯、LinkedIn 和沃尔玛这样受人尊敬的公司是许多已经看到 Node 的潜力并在他们的业务中采用它的组织中的一员。

Pro Node.js for Developers 为这项激动人心的年轻技术提供了全面的指导。在深入探究支撑其操作的关键概念和 API 之前,将从较高的层次向您介绍 Node。基于您现有的 JavaScript 技能,将向您展示如何使用 Node.js 构建基于 web 和基于网络的应用,处理各种数据源,捕获和生成事件,生成和控制子进程,等等。

一旦你掌握了这些技能,你将学习更高级的软件工程技能,这将使你的代码具有专业优势。您将学习如何创建易于重用的代码模块,快速有效地调试和测试您的应用,以及随着应用需求的增加,将您的代码从单线程扩展到云。

一、入门指南

JavaScript 最初被命名为 Mocha,是由 Brendan Eich 于 1995 年在网景公司开发的。1995 年 9 月,Netscape Navigator 2.0 的测试版与 Mocha 一起发布,Mocha 已被重命名为 LiveScript。到了 1995 年 12 月,LiveScript 经过了另一次更名,变成了 JavaScript,也就是现在的名字。在那段时间,网景公司与负责开发 Java 编程语言的 Sun 公司密切合作。JavaScript 这个名字的选择引起了很多猜测。许多人认为网景公司试图借用当时的热门词汇 Java。不幸的是,命名选择引起了很多混乱,因为许多人自动认为这两种语言在某种程度上是相关的。事实上,他们几乎没有共同点。

尽管混乱,JavaScript 成为了一种非常成功的客户端脚本语言。作为对 JavaScript 成功的回应,微软创建了自己的实现,命名为 JScript,并于 1996 年 8 月与 Internet Explorer 3.0 一起发布。1996 年 11 月,Netscape 向国际标准组织 Ecma International 提交了 JavaScript 标准。1997 年 6 月,JavaScript 成为了标准的 ECMA-262。

多年来,JavaScript 一直是客户端开发事实上的标准。然而,服务器领域是一个完全不同的故事。在很大程度上,服务器领域属于 PHP 和 Java 等语言。许多项目已经将 JavaScript 实现为服务器语言,但是没有一个项目特别成功。两个主要障碍阻碍了 JavaScript 在服务器上的广泛应用。首先是它的声誉。JavaScript 一直被视为玩具语言,只适合业余爱好者。第二个障碍是 JavaScript 与其他语言相比表现不佳。

然而,JavaScript 有一大优势。网络正在经历前所未有的增长,浏览器大战正在激烈进行。作为所有主流浏览器都支持的唯一语言,JavaScript 引擎开始受到谷歌、苹果和其他公司的关注。所有这些关注导致了 JavaScript 性能的巨大提高。突然间 JavaScript 不再落后了。

开发社区注意到了 JavaScript 的新力量,并开始创建有趣的应用。2009 年,Ryan Dahl 创建了 Node.js,这是一个主要用于为 web 应用创建高度可伸缩的服务器的框架。Node.js,简称 Node,是用 C++和 JavaScript 编写的。为了驱动 Node,达尔利用了谷歌的 V8 JavaScript 引擎 (V8 是谷歌 Chrome 内部的引擎,是现存最流行的浏览器)。使用 V8,开发人员可以用 JavaScript 编写成熟的应用——通常用 C 或 Java 等语言编写的应用。因此,随着 Node 的发明,JavaScript 最终成为了真正的服务器端语言。

Node 执行模式

除了速度,Node 还带来了一个非常规的执行模型。为了理解 Node 有何不同,我们应该将它与 Apache 进行比较,Apache 是 Linux、Apache、MySQL 和 PHP (LAMP) 软件栈中流行的 web 服务器。首先,Apache 只处理 HTTP 请求,将应用逻辑留给 PHP 或 Java 之类的语言来实现。Node 通过将服务器和应用逻辑结合在一个地方,消除了一层复杂性。一些开发人员批评这种模型消除了 LAMP 堆栈中使用的传统的关注点分离。然而,这种方法也为 Node 作为服务器提供了前所未有的灵活性。

Node 在并发性的使用上也不同于许多其他服务器。像 Apache 这样的服务器维护一个线程池来处理客户端连接。这种方法缺乏可伸缩性,因为线程相当耗费资源。此外,繁忙的服务器会很快耗尽所有可用的线程;结果,产生了更多的线程,创建和拆除这些线程的成本很高。另一方面,Node 在单个线程中执行。虽然这看起来是个坏主意,但实际上它工作得很好,因为大多数服务器应用都是这样工作的。通常,服务器接收客户端请求,然后执行一些高延迟 I/O 操作,如文件读取或数据库查询。在此期间,服务器阻塞,等待 I/O 操作完成。服务器可以处理更多的请求或做其他有用的工作,而不是无所事事。

在传统服务器中,线程在阻塞 I/O 操作时什么都不做是可以接受的。然而,Node 只有一个线程,阻塞它会导致整个服务器挂起。为了缓解这个问题,Node 几乎只使用非阻塞 I/O。例如,如果 Node 需要执行数据库查询,它只需发出查询,然后处理其他事情。当查询最终返回时,它会触发一个负责处理查询结果的异步回调函数。该过程的伪代码示例如清单 1-1 中的所示。

清单 1-1 。非阻塞数据库查询的伪代码示例

var sql = "SELECT * FROM table";

database.query(sql, function(results) {
  // process the results
});
// do something else instead of waiting

Node 的非阻塞异步执行模型以最小的开销提供了高度可伸缩的服务器解决方案。许多高调的公司,包括微软,LinkedIn,雅虎!和零售巨头沃尔玛已经注意到了 Node,并开始与它一起实施项目。例如,LinkedIn 将其整个移动堆栈迁移到 Node,并“从在每台物理机上运行 15 台服务器和 15 个实例(虚拟服务器),减少到只有 4 个实例,可以处理两倍的流量。”Node 还获得了媒体的广泛认可,例如赢得了 2012 年 InfoWorld 年度技术奖。

安装 Node

开始使用 Node 的第一步是安装。本节将帮助您在 Ubuntu、OS X 或 Windows 机器上安装并运行 Node。安装 Node 最简单的方法是通过 Node 主页上的安装按钮http://nodejs.org,如图 1-1 中的所示。这将下载适用于您的操作系统的二进制文件或安装程序。

9781430258605_Fig01-01.jpg

图 1-1 。从项目主页安装 Node

您还可以在http://nodejs.org/download浏览所有平台的二进制文件、安装程序和源代码。Windows 用户最有可能想要下载 Windows 安装程序(.msi文件),而 Mac 用户应该选择 Mac OS X 安装程序(.pkg文件)。Linux 和 SunOS 用户可以下载二进制文件,但是使用包管理器安装可能更简单。

通过软件包管理器安装

有关通过操作系统的软件包管理器安装 Node 的说明,请转到https://github.com/joyent/node/wiki/Installing-Node.js-via-package-manager。本页包含 Windows、OS X 和 Linux 的说明。同样,Windows 和 Mac 用户应该使用前面讨论过的安装程序。就 Linux 而言,有针对 Gentoo、Debian、Linux Mint、Ubuntu、openSUSE、SLE、Red Hat、Fedora、Arch Linux、FreeBSD 和 OpenBSD 的指令。

Ubuntu 用户可以使用清单 1-2 所示的高级打包工具(APT)命令安装 Node 和所有必备软件。这些步骤还会安装npm,Node 的软件包管理软件(在第二章中介绍)。

清单 1-2 。使用 Ubuntu 的软件包管理器安装 Node

$ sudo apt-get install python-software-properties python g++ make
$ sudo add-apt-repository ppa:chris-lea/node.js
$ sudo apt-get update
$ sudo apt-get install nodejs npm

如果add-apt-repository命令失败,使用清单 1-3 中所示的命令安装software-properties-common包。

清单 1-3 。安装Software-Properties-Common

$ sudo apt-get install software-properties-common

从源头开始构建

如果您想为 Node 的 C++核心做出贡献,或者只是尝试它的功能,您将需要编译项目的源代码。您可以从下载页面获得源代码,或者从项目的 GitHub 资源库https://github.com/joyent/node获得。一旦下载了代码,如果适用的话,从存档中提取它。在构建 Node 之前,Ubuntu 用户需要安装 Python 等构建工具;使用清单 1-4 中的命令。安装 Python 时,一定要安装 2.7 版本,而不是更新的 Python 3。

清单 1-4 。在 Ubuntu 上安装必备软件包

$ sudo apt-get install python-software-properties python g++ make

Ubuntu 和 OS X 用户可以从源代码目录中发出清单 1-5 所示的命令来构建 Node。请注意,源代码目录的完整路径不应包含任何空格。

清单 1-5 。在 Ubuntu 和 OS X 上从源代码安装 Node

./configure
make
sudo make install

在 Windows 上,您需要安装 Visual C++和 Python 2.7 来构建 Node。Visual C++可以通过 Visual Studio Express 从微软免费下载。Python 也可以在www.python.org/免费获得。要编译 Node,发出清单 1-6 中的命令。

清单 1-6 。在 Windows 上从源安装 Node

> vcbuild.bat release

最终安装步骤

无论您决定采用哪种安装方式,此时 Node 都应该可以使用了。为了验证一切都设置正确,打开一个新的终端窗口,并运行node可执行文件(参见清单 1-7 )。-v标志使Node打印已安装的版本,然后退出。在此示例中,安装了 0.10.18 版的 Node。

清单 1-7 。从命令行检查Node的版本

$ node -v
v0.10.18

你还应该确认npm已经安装(见清单 1-8 )。

清单 1-8 。从命令行检查npm的版本

$ npm -v
1.3.8

最后一个安装注意事项:即使您没有从源代码安装 Node,也可能需要在您的机器上安装 Python 和 C++编译器。这样做可以确保用 C++编写的本机模块可以在您的 Node 安装中编译和运行。在 Windows 上,这涉及到安装微软的 Visual C++编译器(参见上一节“从源代码构建”)。对于任何其他操作系统,构建基础应该包括必要的编译器。

读取-评估-打印循环

Node 提供了一个交互式 shell,称为读取-评估-打印循环,或 REPL。REPL 从用户那里读取输入,将输入作为 JavaScript 代码进行评估,打印结果,然后等待更多的输入。REPL 对于调试和试验小的 JavaScript 代码片段非常有用。要启动 REPL,请不带命令行参数启动Node。然后你会看到 REPL 命令提示符,即>字符。在提示符下,开始输入任意 JavaScript 代码。

清单 1-9 显示了如何启动 REPL 并输入代码。在这个例子中,用字符串值"Hello World!"创建了一个名为foo的变量。在第三行,REPL 打印出"undefined",因为变量声明语句没有返回值。接下来,语句foo;使得foo的值被检查。不出所料,REPL 返回字符串"Hello World!"。最后,使用console.log()功能将foo的值打印到终端。打印完foo后,REPL 再次显示"undefined",因为console.log()不返回值。

清单 1-9 。启动 REPL 并输入 JavaScript 代码

$ node
> var foo = "Hello World!";
undefined
> foo;
'Hello World!'
> console.log(foo);
Hello World!
undefined

您也可以在 REPL 中输入多行表达式。例如,在清单 1-10 的中,一个for循环进入了 REPL。REPL 使用...来表示正在进行的多行表达式。注意,...是由 REPL 显示的,而不是用户输入的。

清单 1-10 。在 REPL 中执行多行表达式的例子

> for (var i = 0; i < 3; i++) {
... console.log(i);
... }
0
1
2
undefined

REPL 特色

REPL 有许多增加可用性的特性,其中最有用的是使用上下箭头键浏览先前发布的命令的能力。要终止任何命令并返回空白提示符,请键入Control+C。从空白行按下Control+C两次会导致 REPL 终止。你可以随时按下Control+D退出 REPL。您可以使用Tab键查看当前命令的可能完成列表。如果只有一个可能的选项,Node 会自动插入它。该列表包括关键字、函数和变量。例如,清单 1-11 显示了在提示符下输入t时的完成选项。

清单 1-11 。通过键入t后跟 Tab 显示自动完成选项

> t
this            throw           true            try
typeof          tls             tty             toLocaleString
toString

REPL 还提供了一个特殊的变量,_(下划线),它总是包含上一个表达式的结果。清单 1-12 显示了_的几个使用示例。首先,创建一个字符串数组,使_引用该数组。然后使用pop()方法移除数组的最后一个元素baz。最后,访问baz的长度,使_变成3

清单 1-12 。_ 变量的使用示例

> ["foo", "bar", "baz"]
[ 'foo', 'bar', 'baz' ]
> _.pop();
'baz'
> _.length
3
> _
3

REPL 命令

.help

.help命令显示所有可用的 REPL 命令。清单 1-13 显示了运行.help命令的输出。

清单 1-13.help REPL 命令的输出

> .help
.break Sometimes you get stuck, this gets you out
.clear Alias for .break
.exit Exit the repl
.help Show repl options
.load Load JS from a file into the REPL session
.save Save all evaluated commands in this REPL session to a file

.exit

命令终止了 REPL。该命令相当于按下Control+D

.break

用于退出多行表达式的.break命令在您犯了一个错误或者只是选择不完成表达式时非常有用。清单 1-14 显示了一个使用.break命令在完成前终止for循环的例子。注意正常的>提示符显示在.break命令之后。

清单 1-14 。使用.break命令终止多行表达式

> for (var i = 0; i < 10; i++) {
... .break
>

.save文件名

.save命令将当前 REPL 会话保存到filename中指定的文件中。如果文件不存在,则创建该文件。如果文件确实存在,现有文件将被覆盖。REPL 命令和输出不会被保存。清单 1-15 显示了一个使用.save命令的例子。在本例中,当前会话被保存到文件repl-test.jsrepl-test.js的结果内容如清单 1-16 所示。请注意,该文件不包含 REPL 提示符或输出或.save命令。

清单 1-15 。使用.save命令保存当前 REPL 会话

> var foo = [1, 2, 3];
undefined
> foo.forEach(function(value) {
... console.log(value);
... });
1
2
3
undefined
> .save repl-test.js
Session saved to:repl-test.js

清单 1-16 。由.save命令生成的repl-test.js的内容

var foo = [1, 2, 3];
foo.forEach(function(value) {
console.log(value);
});

.load文件名

.load命令执行filename中指定的 JavaScript 文件。文件被执行,就好像每一行都被直接输入到 REPL 中。清单 1-17 显示了从清单 1-16 加载文件repl-test.js的输出。

清单 1-17 。使用.load命令执行repl-test.js的结果

> .load repl-test.js
> var foo = [1, 2, 3];
undefined
> foo.forEach(function(value) {
... console.log(value);
... });
1
2
3
undefined

.clear

类似于.break.clear可以用来终止多行表达式。.clear也用于重置 REPL 的上下文对象。在这一点上,你不需要理解细节,但是清单 1-18 显示了一个嵌入了 REPL 的 Node 程序。换句话说,运行这个程序实际上调用了 REPL 的一个实例。此外,您可以为 REPL 定义自定义的执行环境。在这种情况下,嵌入式 REPL 有一个已定义的变量foo,它保存字符串"Hello REPL"。从嵌入式 REPL 内部调用.clear会重置上下文并删除foo

清单 1-18 。在另一个 Node 程序中嵌入 REPL

var repl = require("repl");

repl.start({}).context.foo = "Hello REPL";

执行 Node 程序

尽管 REPL 环境很有用,但它很少在生产系统中使用。相反,程序被写成一个或多个 JavaScript 文件,然后由 Node 解释。最简单的 Node 程序如清单 1-19 所示。该示例只是将字符串"Hello World!"打印到控制台。

清单 1-19 。Hello World Node 的源代码!程序

console.log("Hello World!");

将清单 1-19 中的代码复制到一个新文件中,并另存为hello.js。接下来,打开一个终端窗口,并执行hello.js(参见清单 1-20 )。注意,Node 不要求你指定.js文件扩展名。如果没有找到输入文件并且没有提供文件扩展名,Node 将尝试添加扩展名.js.json.node。Node 将.js文件解释为 JavaScript 源代码,将扩展名为.json的文件解释为 JavaScript 对象符号 (JSON)文件。扩展名为.node的文件被视为已编译的附加模块。

清单 1-20 。从命令行执行 Node 程序

$ node hello.js

image JSON 是数据交换的明文标准。本书假设读者已经熟悉 JSON。但是,如果您需要介绍或复习,JSON 包含在附录 a 中。

摘要

恭喜你!您已经正式向开发 Node 应用迈出了第一步。本章为您提供了 Node 的高级介绍,并指导您完成安装过程。您甚至已经使用 REPL 编写了一些 Node 代码。本书的其余部分建立在本章的基础上,涵盖了 Node 开发的最重要的方面。Node 最出名的是创建可伸缩的 web 服务器,所以当然会讨论这个特性。但是,您还将了解更多内容,包括文件系统编程、流数据、应用伸缩和 Node 的模块系统。

二、Node 模块系统

作为开发人员,您可以使用核心 Node 功能解决许多复杂的问题。然而,Node 真正的优势之一是它的开发者社区和丰富的第三方模块。Node 的包管理器npm负责跟踪所有这些模块。npm FAQ 页面开玩笑地说npm不是“Node 包管理器”的首字母缩写,而是一个递归的反义词。npm不是首字母缩写不管它的意思是什么,npm是一个命令行工具,从 Node 版本 0.6.3 开始,它与 Node 环境捆绑在一起。

npm所做的——而且做得非常好——是管理 Node 模块及其依赖关系。在编写本报告时,官方登记册中有 47 000 多个包裹。您可以在注册中心的网站https://npmjs.org/上浏览所有可用的软件包。除了每个单独的模块,该网站还显示了各种排名,包括哪些模块最受欢迎,哪些模块最受依赖。如果您更愿意亲自使用命令行,您可以使用npm search命令搜索注册表,该命令允许您基于一个或多个关键字搜索软件包。例如,npm search可以用来定位名称或描述中包含database一词的所有模块(参见清单 2-1 )。第一次运行这个命令时,预计会有短暂的延迟,因为npm会构建一个本地索引。

清单 2-1 。使用npm searchnpm注册表中定位模块

$ npm search database

安装软件包

为了使用一个模块,你必须在你的机器上安装它。这通常就像下载几个 JavaScript 源文件一样简单(有些模块还需要下载或编译二进制文件)。要安装软件包,请键入npm install,后跟软件包名称。例如,commander模块提供了实现命令行接口的方法。要安装最新版本的commander,发出清单 2-2 中的命令。

清单 2-2 。使用npm安装最新版本的commander

$ npm install commander

如果您对安装软件包的最新版本不感兴趣,您可以指定一个版本号。Node 模块跟随一个专业小调补丁版本控制方案。例如,要安装commander版本 1.0.0 ,使用清单 2-3 中所示的命令。@字符用于将包名和版本分开。

清单 2-3 。安装commander的 1.0.0 版本

$ npm install commander@1.0.0

对主要版本号的更改可以表明模块已经以非向后兼容的方式进行了更改(称为重大更改)。即使对次要版本的更改也可能会意外引入重大更改。因此,您通常会希望安装某个版本的最新补丁——npm支持使用x通配符。清单 2-4 中的命令安装了commander1.0 版本的最新补丁。(注意,x通配符也可以用来代替主要版本和次要版本。)

清单 2-4 。安装最新的commander 1.0 补丁

$ npm install commander@1.0.x

您还可以使用关系版本范围描述符来选择版本。关系版本范围描述符选择与一组给定标准相匹配的最新版本。npm支持的各种关系版本范围描述符在表 2-1 中列出。

表 2-1 。关系版本范围描述符

|

关系版本范围描述符

|

版本标准

|
| --- | --- |
| =版本 | 与版本完全匹配。 |
| >版本 | 大于版本。 |
| > =版本 | 大于或等于版本。 |
| | 小于版本。 |
| < =版本 | 低于或等于版本。 |
| *版本 | 大于或等于版本,但小于下一个主要版本。 |
| * | 最新版本。 |
| "" | 最新版本。 |
| 版本1–版本 2 | 大于等于版本 1 ,小于等于版本 2 。 |
| 范围 1 ||范围 2 | 匹配范围 1 和范围 2 指定的版本。 |

根据表 2-1 ,列表 2-5 中的所有命令都是有效的npm命令。

清单 2-5 。使用关系版本范围描述符的各种npm install命令

$ npm install commander@"=1.1.0"
$ npm install commander@">1.0.0"
$ npm install commander@"∼1.1.0"
$ npm install commander@"*"
$ npm install commander@""
$ npm install commander@">=1.0.0 <1.1.0"
$ npm install commander@"1.0.0 - 1.1.0"
$ npm install commander@"<=1.0.0 || >=1.1.0"

从 URL 安装

此外,npm允许直接从gitURL 安装软件包。这些 URL 必须采用清单 2-6 中所示的形式之一。在清单中,commit-ish表示一个标签、SHA 或分支,可以作为参数提供给git checkout。注意,例子中的链接并没有指向任何特定的git项目。

image 注意使用 Node 不需要了解git和 GitHub。然而,大多数 Node 模块使用 GitHub 生态系统进行源代码控制和错误跟踪。虽然 GitHub 及其使用已经超出了本书的范围,但是熟悉它是非常可取的。

清单 2-6git支持的 URL 格式npm

`git://github.com/user/project.git#commit-ish`
git+ssh://user@hostname:project.git#commit-ish
git+ssh://user@hostname/project.git#commit-ish
git+http://user@hostname/project/blah.git#commit-ish
git+https://user@hostname/project/blah.git#commit-ish

软件包也可以从 tarball URLs 安装。例如,要安装 GitHub 库的主分支,使用清单 2-7 中所示的语法。虽然这个 URL 没有指向实际的存储库,但是您可以通过下载commander模块:https://github.com/visionmedia/commander.js/tarball/master进行试验。

清单 2-7 。从 GitHub 库安装 Tarball

$ npm install https://github.com/user/project/tarball/master

包装位置

当软件包被安装时,它们被保存在本地机器的某个地方。通常,这个位置是当前目录中名为node_modules的子目录。要确定位置,使用命令npm root。您也可以使用npm ls命令查看所有已安装的模块。安装commander模块后,您可以使用npm ls验证它是否存在。出于此示例的目的,请安装版本 1.3.2。清单 2-8 显示commander版本 1.3.2 已安装。另外,请注意安装了一个名为keypress的模块。树形结构表明commander依赖于keypress模块。由于npm能够识别这种依赖性,它会自动安装任何需要的模块。

清单 2-8 。使用npm ls列出所有当前安装的软件包

$ npm ls
/home/colin/npm-test
└─┬ commander@1.3.2
     └── keypress@0.1.0

也可以通过浏览node_modules子目录来查看已安装的模块。在这个例子中,commander安装在node_modules/commander,而keypress安装在node_modules/commander/node_modules/keypress。如果keypress有任何依赖项,它们将被安装在keypress目录下的另一个node_modules子目录中。

全球软件包

如前所述,包是包含在程序中的库。这些被称为本地包,必须安装在使用它们的每个项目中。另一种类型的软件包,称为全局软件包,只需要安装在一个位置。尽管全局包通常不包含代码库,但它们可以。根据经验,全局包通常包含命令行工具,它们应该包含在PATH环境变量中。

要全局安装包,只需发出带有-g--global选项的npm install。事实上,您可以通过在大多数npm命令中添加-g选项来处理全局包。例如,您可以通过发出命令npm ls -g来查看已安装的全局包。您也可以使用npm root -g命令定位全局node_modules文件夹。

链接包

使用npm,您可以创建到本地包的链接。当您链接到一个包时,它可以像一个全局包一样被引用。如果您正在开发一个模块,并且希望另一个项目引用该模块的本地副本,这将非常有用。如果您想部署您的模块而不将它发布到公共的npm注册中心,链接也是有用的。

包链接是一个两步过程。第一步,创建链接,是通过切换到您想要使其可链接的项目的目录来完成的。清单 2-9 展示了如何创建一个到你的模块的链接,假设你的模块位于foo-module中。执行npm link命令后,验证该链接是使用npm ls -g创建的。

清单 2-9 。使用npm link创建链接

$ cd foo-module
$ npm link

模块链接的第二步,实际上是引用链接,非常类似于包安装。首先,切换到将导入链接模块的项目的目录。接下来,发出另一个npm link命令。但是,这一次您还必须指定链接模块的名称。该程序的一个例子如清单 2-10 所示。在这个例子中,清单 2-9 中的foo-module链接是从第二个模块bar-module引用的。

清单 2-10 。使用npm link引用现有链接

$ cd bar-module
$ npm link foo-module

解除包的链接

移除链接模块的过程与创建链接模块的过程非常相似。要从应用中删除链接的模块,使用npm unlink命令,后跟名称。清单 2-11 显示了从bar-module中移除链接的foo-module的命令。

清单 2-11 。使用npm unlink删除对链接的引用

$ cd bar-module
$ npm unlink foo-module

类似地,要从您的系统中删除一个链接,切换到链接模块的目录,并发出npm unlink命令。清单 2-12 展示了如何移除foo-module链接。

清单 2-12 。使用npm unlink移除链接的模块

$ cd foo-module
$ npm unlink

更新软件包

因为任何被积极开发的包最终都会发布一个新版本,所以你的拷贝会变得过时。要确定你的副本是否过期,在你的项目目录中运行npm outdated(见清单 2-13 )。在示例中,假设安装了commander的过时版本 1.0.0,npm表示最新版本是 2.0.0,但您的副本只有 1.0.0。清单 2-13 检查所有的本地包。您可以通过指定它们的名称来检查单个包,并且可以通过指定-g选项来处理全局包。

清单 2-13 。使用npm outdated显示过期的包

$ npm outdated
npm http GET https://registry.npmjs.org/commander
npm http 304 https://registry.npmjs.org/commander
commander@2.0.0 node_modules/commander current=1.0.0

要更新任何过期的本地包,使用npm update命令。与outdated非常相似,update在默认情况下适用于所有本地包。同样,您可以通过指定它们的名称来定位单个模块。您也可以使用-g选项更新全局包。在清单 2-14 的中,npm使用-g选项更新自己。

清单 2-14 。使用npm update更新npm

$ npm update npm -g

卸载软件包

要删除一个包,使用npm uninstallnpm rm命令(这两个命令可以互换使用),并指定一个或多个要删除的包。您也可以通过提供-g选项来删除全局包。清单 2-15 显示了如何使用npm rm移除commander模块。

清单 2-15 。使用npm rm卸载commander

$ npm rm commander

require()功能

如前一节所示,使用npm管理 Node 包。然而,要将模块导入到程序中,需要使用require()函数。require()接受单个参数,即指定要加载的模块的字符串。如果指定的模块路径存在,require()返回一个可用于与模块接口的对象。如果找不到该模块,就会引发异常。清单 2-16 显示了如何使用require()函数将commander模块导入到程序中。

清单 2-16 。使用require()功能

var commander = require("commander")

核心模块

核心模块是编译成 Node 二进制的模块。require()赋予它们最高的优先级,这意味着在模块命名冲突的情况下,加载核心模块。例如,Node 包含一个名为http的核心模块,顾名思义,它提供了使用超文本传输协议(HTTP) 的功能。无论如何,对require("http")的调用总是会加载核心http模块。顺便提一下,核心模块位于 Node 源代码的lib目录中。

文件模块

文件模块是从文件系统加载的非核心模块。可以使用绝对路径、相对路径或从node_modules目录指定它们。以斜杠(/)开头的模块名被视为绝对路径。例如,在清单 2-17 中,一个文件模块foo使用绝对路径加载。

清单 2-17 。使用绝对路径导入文件模块

require("/some/path/foo");

image 注意Windows 等一些操作系统使用不区分大小写的文件系统。这允许你写require("commander")require("COMMANDER")require("CoMmAnDeR")。然而,在像 Linux 这样区分大小写的文件系统上,最后两个调用会失败。因此,无论使用什么操作系统,都应该区分大小写。

Node 还支持 Windows 样式的文件路径。在 Windows 上,Node 允许交换使用斜杠和反斜杠字符(/\)。为了一致性,也为了避免转义反斜杠字符,本书主要使用 Unix 风格的路径。然而,请注意在清单 2-18 中显示的所有路径在 Windows 上都是有效的。

清单 2-18 。在 Windows 上有效的模块路径示例

require("/some/path/foo");
require("C:/some/path/foo");
require("C:\\some\\path\\foo");
require("\\some/path\\foo");

以一两个点(...)开头的模块路径被解释为相对路径——也就是说,它们被认为是相对于调用require()的文件的。清单 2-19 显示了相对模块路径的三个例子。在第一个示例中,foo从与调用脚本相同的目录中加载。在第二个中,foo位于调用脚本的父目录中。在第三个示例中,foo位于调用脚本目录的子目录sub中。

清单 2-19 。使用相对路径的模块导入示例

require("./foo");
require("../foo");
require("./sub/foo");

如果模块路径不对应于核心模块、绝对路径或相对路径,那么 Node 开始在node_modules文件夹中搜索。Node 从调用脚本的父目录开始,并追加/node_modules。如果没有找到该模块,Node 在目录树中向上移动一级,追加/node_modules,然后再次搜索。重复这种模式,直到找到模块或到达目录结构的根。清单 2-20 中的例子假设一个项目位于/some/path中,并按顺序显示了将被搜索的各种node_modules目录。

清单 2-20node_modules目录的搜索顺序示例

/some/path/node_modules
/some/node_modules
/node_modules

文件扩展名处理

如果require()没有找到完全匹配,它会尝试添加.js.json.node文件扩展名。如第一章所述,.js文件被解释为 JavaScript 源代码,.json文件被解析为 JSON 源代码,.node文件被视为编译后的附加模块。如果 Node 仍然找不到匹配,就会抛出一个错误。

还可以使用内置的require.extensions对象以编程方式添加对附加文件扩展名的支持。最初,这个对象包含三个键,.js.json.node。每个键映射到一个函数,该函数定义了require()如何导入该类型的文件。通过扩展require.extensions,可以自定义require()的行为。例如,清单 2-21 扩展了require.extensions,使得.javascript文件被视为.js文件。

清单 2-21 。扩展require.extensions对象以支持额外的文件类型

require.extensions[".javascript"] = require.extensions[".js"];

您甚至可以添加自定义处理程序。在清单 2-22 ,.javascript文件使require()将导入文件的数据打印到控制台。

清单 2-22 。向require.extensions对象添加自定义处理程序

require.extensions[".javascript"] = function() {
 console.log(arguments);
};

image 注意虽然这个特性最近被弃用,但是模块系统 API 被锁定,所以require.extensions不太可能完全消失。官方文档推荐将非 JavaScript 模块包装在另一个 Node 程序中,或者先验地编译成 JavaScript。

解析模块位置

如果您只对了解包的位置感兴趣,可以使用require.resolve()函数,它使用与require()相同的机制来定位模块。然而,resolve()并没有真正加载模块,而是只返回模块的路径。如果传递给resolve()的模块名是核心模块,则返回该模块的名称。如果模块是文件模块,resolve()返回模块的文件名。如果 Node 找不到指定的模块,则会引发错误。清单 2-23 中的例子显示了resolve()在 REPL 环境中的用法。

清单 2-23 。使用require.resolve()定位http模块

> require.resolve("http");
'http'

模块缓存

成功加载的文件模块缓存在require.cache对象中。同一模块的后续导入将返回缓存的对象。一个警告是,解析的模块路径必须完全相同。这是因为模块通过其解析的路径进行缓存。因此,缓存成为导入模块和调用脚本的功能。假设你的程序依赖于两个模块,foobar。第一个模块foo没有依赖关系,但是bar依赖foo。产生的依赖层次结构如清单 2-24 所示。假设foo驻留在node_modules目录中,它被加载两次。第一次加载发生在foo解析到your-project/node_modules/foo目录时。当从bar引用foo并解析为your-project/node_modules/foo/node_modules时,发生第二次加载。

清单 2-24 。一个依赖层次结构,其中foo被多次引用

your-project
├── foo@1.0.0
└─┬ bar@2.0.0
     └── foo@1.0.0

package.json文件

在前面的部分中,您看到了npm识别包之间的依赖关系并相应地安装模块。但是npm如何理解模块依赖 ies 的概念呢?事实证明,所有相关信息都存储在名为package.json的配置文件中,该文件必须位于项目的根目录下。正如文件扩展名所暗示的,文件必须包含有效的 JSON 数据。从技术上来说,你不需要提供一个package.json,但是如果没有的话,npm将无法访问你的代码。

package.json中的 JSON 数据应该符合特定的模式。最低限度,你必须为你的包指定一个名字和版本。没有这些字段,npm将无法处理您的包裹。最简单的package.json文件如清单 2-25 所示。包的名称由name字段指定。该名称应该在npm注册表中唯一地标识您的包。通过使用npm,该名称成为 URL、命令行参数和目录名的一部分。因此,名称不能以点或下划线开头,也不能包含空格或任何其他非 URL 安全字符。最佳实践还规定,名称应该简短且具有描述性,并且不包含“js”或“node”,因为它们是隐含的。此外,如果您计划向公众发布您的包,请验证该名称在npm注册表中是否可用。

清单 2-25 。最小的package.json文件

{
  "name": "package-name",
  "version": "0.0.0"
}

包的版本在version字段中指定。当与名称结合时,版本为包提供了真正唯一的标识符。版本号指定了主版本号、次版本号和补丁号,用点分隔(npm允许版本以v字符开头)。您还可以通过在修补程序编号后附加一个标记来指定内部版本号。有两种类型的标签,预发布和发布后。后发布标签增加版本号,而预发布标签减少版本号。发布后标签是一个连字符后跟一个数字。所有其他标签都是预发布标签。清单 2-26 中的例子展示了版本标记的作用。几个带标签的版本和一个不带标签的版本(0.1.2)按降序排列。

清单 2-26 。几个带标签的版本和一个不带标签的版本按降序排列

0.1.2-7
0.1.2-7-beta
0.1.2-6
0.1.2
0.1.2beta

描述和关键字

description字段用于提供您的包的文本描述。类似地,使用keywords字段提供一组关键字来进一步描述您的包。关键字和描述帮助人们发现你的包,因为它们是由npm search命令搜索的。清单 2-27 显示了包含descriptionkeywords字段的package.json摘录。

清单 2-27 。在package.json文件中指定描述和关键字

"description": "This is a description of the module",
"keywords": [
  "foo",
  "bar",
  "baz"
]

作者和撰稿人

项目的主要作者在author字段中指定。该字段只能包含一个条目。然而,第二个字段contributors可以包含对项目做出贡献的人员的数组。有两种方法可以指定一个人。第一个是包含nameemailurl字段的对象。清单 2-28 中显示了这种语法的一个例子。该示例指定了一个主要作者和两个额外的投稿人。

清单 2-28 。在package.json文件中指定作者和贡献者

"author": {
  "name": "Colin Ihrig",
  "email": "colin@domain.com",
  "url": "http://www.cjihrig.com"
},
"contributors": [
  {
    "name": "Jim Contributor",
    "email": "jim@domain.com",
    "url": "http://www.domain.com"
  },
  {
    "name": "Sue Contributor",
    "email": "sue@domain.com",
    "url": "http://www.domain.com"
  }
]

或者,表示人的对象可以写成字符串。在一个字符串中,一个人由名字指定,然后由尖括号内的电子邮件地址指定,后面是圆括号内的 URL。在清单 2-28 中显示的对象语法已经在清单 2-29 中使用字符串重写。

清单 2-29 。将作者和贡献者指定为字符串而不是对象

"author": "Colin Ihrig <colin@domain.com> (http://www.cjihrig.com)",
"contributors": [
  "Jim Contributor <jim@domain.com> (http://www.domain.com)",
  "Sue Contributor <sue@domain.com> (http://www.domain.com)"
]

主入口点

由于包可以由许多文件组成,Node 需要某种方法来标识它的主入口点。像大多数其他配置选项一样,这是在package.json文件中处理的。在main字段中,您可以告诉 Node 在使用require()导入您的模块时加载哪个文件。假设您的模块名为foo,但是它的主入口点位于一个名为bar.js的文件中,该文件位于src子目录中。您的package.json文件应该包含清单 2-30 中的main字段。

清单 2-30 。指定包的主入口点

"main": "./src/bar.js"

preferGlobal设置

有些包是打算全局安装的,但是没有办法实际执行这个意图。然而,如果用户通过包含preferGlobal字段并将其设置为true来本地安装您的模块,您至少可以生成一个警告。同样,这将而不是阻止用户执行本地安装。

依赖性

包依赖关系在package.json文件的dependencies字段中指定。这个字段是一个将包名映射到版本字符串的对象。版本字符串可以是npm理解的任何版本表达式,包括 git 和 tarball URLs。清单 2-31 显示了一个仅依赖于commander的包的dependencies字段的例子。

清单 2-31 。一个简单的dependencies字段

"dependencies": {
  "commander": "1.1.x"
}

注意commander的版本字符串使用了清单 2-31 中的x通配符。在指定模块依赖关系时,使用这种语法通常被认为是最佳实践,因为主版本和次版本更新可能表示不兼容的更改,而补丁更改通常仅表示错误修复。保持软件包更新是好的,但是只有在彻底测试之后才这样做。例如,如果在清单 2-31 中使用的版本字符串是>= 1.1.0,那么在更新到版本 1.2.0 后,程序中可能会神秘地出现 bug。为了在安装新的软件包时自动更新dependencies字段,在npm install命令后添加--save标志。因此,要在安装期间将commander添加到package.json文件中,发出命令npm install commander --save

发展依赖性

许多包都有仅用于测试和开发的依赖项。这些包不应包含在dependencies字段中。相反,将它们放在单独的devDependencies字段中。例如,mocha包是 Node 社区中常用的一个流行的测试框架。使用mocha进行测试的包应该在devDependencies字段中列出,如清单 2-32 所示。

清单 2-32 。将mocha列为发展依赖

"devDependencies": {
  "mocha": "∼1.8.1"
}

开发依赖性也可以自动添加到package.json文件中。为此,将--save-dev标志附加到npm install命令上。命令npm install mocha --save-dev就是一个例子。

可选依赖项

可选依赖项是您希望使用但不需要的包,例如,提高加密性能的模块。如果可以的话,一定要使用它。如果由于某种原因它不可用,您的应用可以依靠一个较慢的替代方案。通常,如果依赖项不可用,npm将会失败。对于可选的依赖项,npm将继续执行,尽管它们不存在。与devDependencies一样,可选的依赖项列在一个单独的optionalDependencies字段中。通过将--save-optional标志指定给npm install,可选的依赖项也可以在安装过程中自动添加到package.json文件中。

如果您选择使用可选的依赖项,您的程序仍然必须考虑到包不存在的情况。这是通过在try...catchif语句中包装对模块的引用来实现的。在清单 2-33 的例子中,commander被假定为一个可选的依赖项。由于require()函数在commander不存在时抛出异常,所以它被包装在try...catch语句中。在程序的后面,在使用之前检查commander是否有定义的值。

清单 2-33 。引用可选依赖项时使用防御性编程

var commander;

try {
  commander = require("commander");
} catch (exception) {
  commander = null;
}

if (commander) {
  // do something with commander
}

发动机

engines 字段用于指定模块使用的nodenpm的版本。引擎版本控制类似于用于依赖关系的方案。然而,最佳实践会有所不同,这取决于您是在开发独立的应用还是可重用的模块。应用应该使用保守的版本控制来确保新发布的依赖项不会引入错误。另一方面,可重用模块应该使用积极的版本控制,以确保尽可能地使用最新版本的 Node。清单 2-34 中的例子包括一个engines字段。在这个例子中,node字段使用积极的版本控制,总是选择最新的版本。同时,npm版本字符串比较保守,只允许补丁更新。

清单 2-34 。在package.json文件中定义支持的引擎版本

"engines": {
  "node": ">=0.10.12",
  "npm": "1.2.x"
}

剧本

当存在时,scripts字段包含npm命令到脚本命令的映射。脚本命令可以是任何可执行命令,在外部 shell 进程中运行。两个最常见的命令是startteststart命令启动您的应用,而test运行您的应用的一个或多个测试脚本。在清单 2-35 的例子中,start命令导致node执行文件server.jstest命令显示没有指定测试。在真实的应用中,test可能会调用mocha或其他一些测试框架。

清单 2-35 。在package.json文件中指定一个scripts字段

"scripts": {
  "start": "node server.js",
  "test": "echo \"Error: no test specified\" && exit 1"
}

image 注意尽可能避免使用特定于平台的命令。例如,使用 Makefile 是 Unix 系统上的常见做法,但是 Windows 没有make命令。

要执行starttest命令,只需将命令名传递给npm。清单 2-36 ,基于清单 2-35 中的scripts字段,显示了test命令的输出。您可以从输出中看到npm将非零退出代码视为错误并中止命令。

清单 2-36 。启动npm test命令

$ npm test

> example@0.0.0 test /home/node/example
> echo "Error: no test specified" && exit 1

\"Error: no test specified\"
npm ERR! Test failed.  See above for more details.
npm ERR! not ok code 0

请注意,您不能简单地添加任意命令并从npm调用它们。例如,发出命令npm foo将不起作用,即使您已经在scripts字段中定义了foo。还有一些命令充当钩子,在某些事件发生时执行。例如,installpostinstall命令是在使用npm install安装包之后执行的。scripts字段(见清单 2-37 )使用这些命令显示软件包安装后的消息。要获得可用脚本命令的完整列表,请发出命令npm help scripts

清单 2-37 。一些 npm 挂钩

"scripts": {
  "install": "echo \"Thank you for installing!\"",
  "postinstall": "echo \"You're welcome!\""
}

附加字段

package.json文件中通常可以找到许多其他字段。例如,您可以在homepage字段中列出项目的主页,在license字段中列出软件许可类型,在repository字段中列出项目源代码所在的存储库。如果您计划将您的模块发布到npm注册中心,那么repository字段尤其有用,因为您的模块的npm页面将包含到您的存储库的链接。此外,通过包含一个repository字段,用户可以使用命令npm repo module-name快速导航到存储库(其中module-name是您的模块的npm名称)。

只要没有命名冲突,您甚至可以添加自己的特定于应用的字段。有关package.json文件的更多信息,请发出命令npm help json

生成 package.json 文件

虽然一个package.json文件的语法并不复杂,但是它可能会很乏味并且容易出错。最困难的部分可能是记住你的包的依赖项和它们的版本。为了帮助缓解这个问题,Node 提供了npm init,这是一个命令行向导,提示您输入关键字段的值,并自动生成一个package.json文件。如果您已经有了一个package.json文件,npm init会维护它的所有信息,只添加新信息。

例如,假设您有一个名为foo-module的项目目录。在那个目录里面是foo.js,你的模块的主要入口点。您的模块只有一个依赖项,commander,它是在开发过程中安装的。此外,您还有一个测试脚本test.js,它测试您的模块。现在是创建package.json文件的时候了。发出命令npm init,逐步完成清单 2-38 中所示的向导。

清单 2-38 。使用npm init 生成一个package.json文件

$ npm init
This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sane defaults.

See `npm help json` for definitive documentation on these fields
and exactly what they do.

Use `npm install <pkg> --save` afterwards to install a package and
save it as a dependency in the package.json file.

Press ^C at any time to quit.
name: (foo-module)
version: (0.0.0) 1.0.0
description: An awesome new Node module.
entry point: (foo.js)
test command: test.js
git repository:
keywords: node, awesome, foo
author: Colin Ihrig <cjihrig@domain.com>
license: (BSD)
About to write to /home/colin/foo-module/package.json:

{
  "name": "foo-module",
  "version": "1.0.0",
  "description": "An awesome new Node module.",
  "main": "foo.js",
  "dependencies": {
    "commander": "∼1.1.1"
  },
  "devDependencies": {},
  "scripts": {
    "test": "test.js"
  },
  "repository": "",
  "keywords": [
    "node",
    "awesome",
    "foo"
  ],
  "author": "Colin Ihrig <cjihrig@domain.com>",
  "license": "BSD"
}

Is this ok? (yes)
npm WARN package.json foo-module@1.0.0 No README.md file found!

请注意,一些值,包括名称foo-module,都用括号括起来了。这些值都是npm的猜测。你可以按下Enter键来接受它们。如果您想使用自己的值,只需在按下Enter前输入即可。对于某些字段,如descriptionnpm就不提供猜测了。在这些情况下,您可以提供一个值或将该字段留空,如git repository字段所示。在向导的最后,npm显示生成的 JSON 数据。此时,要么接受建议的数据并生成package.json文件,要么中止整个过程。

最后,npm提供了一条警告消息,表明没有找到README.md文件。README.md是一个可选的推荐文件,它提供了关于你的模块的文档。.md文件扩展名表示该文件包含 降价数据。Markdown 是一种标记语言,很容易转换为 HTML,但比 HTML 更容易阅读,它是 Node 文档的天然选择,因为 GitHub 能够显示 Markdown,并且大多数 Node 项目都托管在 GitHub 上。在你的项目根目录中总是包含一个README.md文件是一个好的惯例。如果存在,文件名使用readmeFilename域在package.json文件中指定。清单 2-39 中的例子显示了一个降价文件。GitHub 上呈现的相同降价显示在图 2-1 中。关于 Markdown 语法的其他信息在网上随处可见。

清单 2-39 。使用降价语法

#Level One Heading
This test is *italicized*, while this text is **bold**.

##Level Two Heading
By combining the two, this text is ***bold and italicized***.

9781430258605_Fig02-01.jpg

图 2-1 。GitHub 上呈现的清单 2-39 的降价

一个完整的例子

这可能是查看包含依赖项的 Node 程序的完整示例的好时机。在这个例子中,我们将创建一个 Hello World 风格的程序,它将彩色文本打印到控制台。为了创建彩色文本,程序将导入一个名为colors的第三方模块。示例程序的源代码如清单 2-40 所示。将源代码添加到名为colors-test.js的文件中并保存。第一行代码使用require()函数导入colors模块。第二行将消息"Hello Node!"打印到控制台。附加到控制台消息的.rainbow使字符串中的字符以各种颜色打印出来。

清单 2-40 。使用colors模块打印彩虹文本

var colors = require("colors");

console.log("Hello Node!".rainbow);

由于colors不是核心模块,运行程序前需要安装。为此,发出命令npm install colors。安装完成后,发出命令node colors-test执行程序。您应该会在控制台上看到一条彩色的消息。如果你是团队的一员,其他人将需要运行你的代码。对于这么小的程序,只有一个依赖项,您的团队成员可以简单地将您的代码从源代码控制中签出并安装colors。然而,这种方法对于具有数十甚至数百个依赖项的大型程序来说并不真正可行。如果你想让其他人运行你的重要程序,你必须提供一个package.json文件。要生成package.json,运行npm init。逐步执行向导,根据需要输入值。(该项目的示例package.json文件如清单 2-41 所示。)您的程序现在可以只安装您的源代码、package.json文件和npm

清单 2-41 。彩虹文本程序的package.json文件

{
  "name": "colors-test",
  "version": "1.0.0",
  "description": "An example program using the colors module.",
  "main": "colors-test.js",
  "dependencies": {
    "colors": "∼0.6.0-1"
  },
  "devDependencies": {},
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": "",
  "keywords": [
    "colors",
    "example"
  ],
  "author": "Colin Ihrig <cjihrig@domain.com>",
  "license": "BSD"
}

image 注意很多开发者并不将node_modules文件夹检入源代码控制。因为这个文件夹可以使用npm重新生成,所以在源代码管理中排除它可以节省空间。然而,应用开发人员应该考虑提交他们的node_modules文件夹,以避免在依赖关系引入不兼容的变更时可能出现的神秘错误。不幸的是,当应用加载到不同的机器或操作系统上时,这会带来问题。另一种方法是使用npm shrinkwrap实用程序锁定已知有效的确切模块版本。shrinkwrap不仅锁定顶层依赖关系的版本,还锁定所有依赖关系的版本(这不能通过package.json文件完成)。不用将node_modules签入源代码控制,只需运行npm shrinkwrap,并签入结果npm-shrinkwrap.json文件(与package.json在同一个目录下)。另一方面,模块开发者不应该提交他们的依赖或者使用shrinkwrap。相反,他们应该努力确保他们的代码尽可能地跨版本兼容。

模块创作

到目前为止,这一章的重点是使用现有的模块。这一节解释了模块实际上是如何产生的。在 Node 中,模块和文件是一一对应的。这意味着一个文件是一个可以使用require()导入到其他文件中的模块。为了演示这个概念,在同一个目录中创建两个文件,foo.jsbar.jsfoo.js的内容如清单 2-42 所示。该文件导入第二个文件bar.js,其内容如清单 2-43 所示。在foo.js 内部,require()的返回值保存在变量bar中,打印到控制台。

清单 2-42foo.js的内容,导入文件bar.js

var bar = require("./bar");

console.log(bar);

bar.js内部,定义了一个名为bar()的函数。该模块包含两个打印语句,一个在模块级,另一个在bar()函数 中。

清单 2-43 。在清单 2-42 中导入的bar.js的内容

function bar() {
  console.log("Inside of bar() function");
}

console.log("Inside of bar module");

要运行这个示例,发出命令node foo.js。结果输出如清单 2-44 所示。对foo.js中的require()的调用导入了bar.js,这导致第一条消息被打印出来。接下来,打印bar变量,显示一个空对象。基于这个例子,有两个问题需要回答。第一,空的对象到底是什么?第二,如何从bar.js外部调用bar()函数。

清单 2-44 。运行清单 2-42 中的代码的输出

$ node foo.js
Inside of bar module
{}

module物体

Node 在每个代表当前模块的文件中提供了一个自由变量modulemodule是包含名为exports的属性的对象,默认为空对象。exports的值由require()函数返回,定义了一个模块的公共接口。由于exports在清单 2-43 中从未被修改,这解释了在清单 2-44 中看到的空对象。

为了使bar()函数在bar.js之外可用,我们有两种选择。首先,bar可以被分配给bar.js内部的module.exports(如清单 2-45 所示)。请注意,exports对象已经被一个函数覆盖。

清单 2-45 。重写bar.js以导出bar()

module.exports = function bar() {
  console.log("Inside of bar() function");
}

console.log("Inside of bar module");

foo.js然后可以访问bar()功能,如清单 2-46 所示。因为bar变量现在指向一个函数,所以可以直接调用它。

清单 2-46 。重写foo.js以从清单 2-45 中访问bar()

var bar = require("./bar");

console.log(bar);
bar();

这种方法的缺点是bar模块只能导出bar()函数。第二种选择是简单地将bar()函数附加到现有的exports对象上,如清单 2-47 所示。这种技术允许模块导出任意数量的方法和属性。为了适应这种变化,foo.js将访问bar()函数作为bar.bar()

清单 2-47 。通过扩充现有的exports对象导出bar()

module.exports.bar = function bar() {
  console.log("Inside of bar() function");
}

console.log("Inside of bar module");

module对象提供了其他几个不常用的属性。这些属性总结在表 2-2 中。

表 2-2 。模块对象的附加属性

|

财产

|

描述

|
| --- | --- |
| id | 模块的标识符。通常这是模块的完全解析文件名。 |
| filename | 模块的完全解析文件名。 |
| loaded | 表示模块状态的布尔值。如果模块已经完成加载,这将是true。否则就是false。 |
| parent | 一个对象,表示加载当前模块的模块。 |
| children | 表示由当前模块导入的模块的对象数组。 |

发布到npm

为了将您的模块发布到npm,您必须首先创建一个npm用户帐户。清单 2-48 展示了建立一个npm账户所需的命令。前三个命令用于关联您的个人信息。最后一个命令npm adduser,将提示您输入用户名并创建一个npm账户(假设用户名可用)。帐户创建后,用户发布的模块可以在https://npmjs.org/∼username查看。

清单 2-48 。创建 npm 用户帐户

npm set init.author.name "John Doe"
npm set init.author.email "john@domain.com"
npm set init.author.url "http://www.johnspage.com"
npm adduser

在设置了一个npm帐户之后,您必须为您的模块创建一个package.json文件。本章已经介绍了这样做的过程。最后,发出命令npm publish来基于package.json文件创建一个npm条目。

摘要

这一章已经涵盖了大量的材料——这是必须的。开发 Node 应用的很大一部分是使用npm和第三方包。从本章开始,你应该已经很好地掌握了npmrequire()函数、package.json文件和模块创作。虽然整个软件包系统不能在一章中全面介绍,但是你现在应该知道足够的知识来完成本书的其余部分。通过阅读在线文档来填补知识上的空白。

三、Node 编程模型

在尝试编写任何有意义的 Node 应用之前,了解幕后发生的事情很重要。可能需要理解的最重要的一点是 JavaScript——以及扩展 Node——是单线程的。这意味着 Node 应用一次只能做一件事。然而,JavaScript 可以通过使用事件循环给人一种多线程的错觉。事件循环用于在 Node 的事件驱动编程模型中调度任务。每次事件发生时,它都被放入 Node 的事件队列中。在事件循环的每次迭代中,单个事件会出队并被处理。如果在处理过程中,此事件创建了任何其他事件,它们将被简单地添加到队列的末尾。当事件被完全处理后,控制返回到事件循环,并处理另一个事件。

清单 3-1 中的例子说明了事件循环如何允许多个任务并行执行。在本例中,setInterval()用于创建两个周期性任务,每个任务每秒运行一次。第一个任务是显示字符串foo的函数,而第二个任务显示bar。当应用运行时,setInterval()使每个功能大约每 1000 毫秒运行一次。结果是foobar每秒打印一次。记住,要执行一个 Node 程序,只需键入"node",后跟程序的文件名。

清单 3-1 。一个给出多线程执行错觉的示例应用

setInterval(function() {
  console.log("foo");
}, 1000);

setInterval(function() {
  console.log("bar");
}, 1000);

基于清单 3-1 中的代码,JavaScript 似乎在同时做多件事。不幸的是,验证它真正的单线程本质太容易了。在清单 3-2 中,一个无限循环被引入到一个重复函数中。无限循环阻止第一个函数返回。因此,控制永远不会传递回事件循环,从而阻止其他任何事情的执行。如果代码是真正多线程的,那么bar将继续被打印到控制台,即使其他函数陷入了无限循环。

清单 3-2 。通过引入无限循环利用 Node 的单线程特性

setInterval(function() {
  console.log("foo");

  while (true) {
  }
}, 1000);

setInterval(function() {
  console.log("bar");
}, 1000);

异步编程

Node 编程模型的另一个重要方面是几乎所有事情都是异步完成的。异步是如此普遍,以至于许多同步函数在其名称中包含字符串sync以避免混淆。在 Node 的范式下,有时被称为延续传递风格 (CPS)编程,异步函数需要一个额外的参数,这个函数在异步代码完成执行后被调用。这个额外的参数被称为延续,或者更常见的是回调函数

清单 3-3 中显示了一个异步函数调用的例子。这段代码从文件系统中读取一个文件,并将内容打印到屏幕上。访问文件系统将在本书的后面重新讨论,但是现在,这个例子应该足够简单,容易理解。第一行中导入的核心模块fs用于处理文件系统。readFile()方法异步工作,使用 UTF-8 编码读入文件foo.txt。一旦文件被读取,匿名回调函数被调用。回调函数有两个参数,errordata,它们分别代表错误条件和文件内容。

清单 3-3 。异步文件读取的一个例子

var fs = require("fs");

fs.readFile("foo.txt", "utf8", function(error, data) {
  if (error) {
    throw error;
  }

  console.log(data);
});

console.log("Reading file...");

这个简短的例子说明了 Node 开发人员的两个重要约定。首先,如果一个方法将回调函数作为参数,那么它应该是最后一个参数。第二,如果一个方法将错误作为参数,它应该是第一个参数。这些不是语言的规则,而是 Node 开发人员社区中普遍认同的调用约定。

当这个程序被执行时,它展示了异步编程的另一个重要方面。为了测试示例程序,将源代码保存在名为file-reader.js的文件中。接下来,在与 Node 脚本相同的目录中创建第二个文件foo.txt。为简单起见,只需将单词"foo"添加到文件中,并保存它。清单 3-4 显示了运行示例程序的输出。注意,消息Reading file...显示在文件内容之前,尽管消息直到最后一行代码才打印出来。

清单 3-4 。文件读取器示例程序的控制台输出

$ node file-reader.js
Reading file...
foo

readFile()被调用时,它对文件系统进行一个非阻塞 I/O 调用。I/O 是非阻塞的这一事实意味着 Node 不等待文件系统返回数据。相反,Node 继续下一条语句,这恰好是一个console.log()调用。最终,文件系统返回foo.txt的内容。发生这种情况时,调用readFile()回调函数,显示文件内容。这种行为似乎与 Node 程序是单线程的事实相矛盾,但是您必须记住,文件系统不是 Node 的一部分。

回调地狱

Node 中使用的 CPS 语法很容易导致被称为回调地狱的情况。当回调嵌套在几个级别的其他回调中时,就会出现回调地狱。这可能导致代码混乱,难以阅读和维护。回调地狱有时被称为末日金字塔,它的名字来自于代码所呈现的金字塔结构。

举个例子,让我们重温一下清单 3-3 中的文件阅读器程序。如果我们要访问一个不存在的文件,就会抛出一个异常,程序就会崩溃。为了使程序更健壮,首先要检查文件是否存在,并且它确实是一个文件(不是目录或其他结构)。修改后的程序如清单 3-5 所示。注意,程序现在包含对fs.exists()fs.stat()的调用,以及对readFile()的原始调用。由于所有这些都利用了回调函数,代码缩进的级别增加了。将这一点与类似于if语句的结构中的缩进结合起来,您会看到回调地狱如何成为复杂 Node 应用中的一个问题。

清单 3-5 。一个带有回调地狱的文件阅读器程序开始悄悄进入

var fs = require("fs");
var fileName = "foo.txt";

fs.exists(fileName, function(exists) {
  if (exists) {
    fs.stat(fileName, function(error, stats) {
      if (error) {
        throw error;
      }

      if (stats.isFile()) {
        fs.readFile(fileName, "utf8", function(error, data) {
          if (error) {
            throw error;
          }

          console.log(data);
        });
      }
    });
  }
});

在本章的后面,你将了解到async,一个可以帮助防止回调地狱的模块。但是,您也可以通过使用小型命名函数作为回调,而不是嵌套的匿名函数来避免这个问题。例如,清单 3-6 重构了清单 3-5 来使用命名函数。注意,对命名函数cbExists()cbStat()cbReadFile()的引用已经取代了匿名回调函数。缺点是代码稍长,可能更难理解。对于这么小的应用来说,这可能有点过了,但是对于大型应用来说,这对于整个软件架构来说是必不可少的。

清单 3-6 。重构了文件读取器示例以防止回调崩溃

var fs = require("fs");
var fileName = "foo.txt";

function cbReadFile(error, data) {
  if (error) {
    throw error;
  }

  console.log(data);
}

function cbStat(error, stats) {
  if (error) {
    throw error;
  }

  if (stats.isFile()) {
    fs.readFile(fileName, "utf8", cbReadFile);
  }
}

function cbExists(exists) {
  if (exists) {
    fs.stat(fileName, cbStat);
  }
}

fs.exists(fileName, cbExists);

异常处理

异步代码对异常处理也有很大的影响。在同步 JavaScript 代码中,try ... catch ... finally语句用于处理错误。然而,Node 的回调驱动特性允许函数在定义它们的错误处理代码之外执行。例如,清单 3-7 将传统的错误处理添加到来自清单 3-3 的文件阅读器示例中。此外,要读取的文件名已被硬编码为空字符串。因此,当调用readFile()时,它无法读取文件并填充回调函数的error参数。然后回调函数抛出错误。直觉上,人们假设catch子句将处理抛出的错误。然而,当回调函数被执行时,try ... catch语句不再是调用堆栈的一部分,异常被置之不理。

清单 3-7 。异步错误处理的错误尝试

var fs = require("fs");

try {
  fs.readFile("", "utf8", function(error, data) {
    if (error) {
      throw error;
    }

    console.log(data);
  });
} catch (exception) {
  console.log("The exception was caught!")
}

同步异常仍然可以用try...catch...finally语句处理,但是你会发现它们在 Node 中相对无用。大多数 Node 异常都是异步的,可以用多种方式处理。首先,所有接受错误参数的函数都应该检查它——至少清单 3-7 中的例子做到了这一点。在本例中,异常已经被检测到,但随后立即再次被抛出。当然,在实际的应用中,您会希望处理错误,而不是抛出它。

处理异步异常的第二种方法是为流程的uncaughtException事件设置一个全局事件处理程序。Node 提供了一个名为process的全局对象,它与 Node 流程进行交互。当一个未处理的异常一路冒泡回到事件循环时,一个uncaughtException错误被创建。这个异常可以使用process对象的on()方法来处理。清单 3-8 显示了一个全局异常处理程序的例子。

清单 3-8 。全局异常处理程序的示例

var fs = require("fs");

fs.readFile("", "utf8", function(error, data) {
  if (error) {
    throw error;
  }

  console.log(data);
});

process.on("uncaughtException", function(error) {
  console.log("The exception was caught!")
});

虽然全局异常处理程序对于防止崩溃很有用,但是它们不应该用于从错误中恢复。如果处理不当,异常会使您的应用处于不确定的状态。试图摆脱这种状态会带来额外的错误。如果你的程序包含一个全局异常处理程序,那么只使用它来优雅地终止程序。

域是处理 Node 中异步错误的首选机制。域,一个相对较新的特性(在 0.8 版本中引入),允许将多个 I/O 操作分组到一个单元中。当一个定时器、事件发射器(在第四章的中介绍)或者在一个域中注册的回调函数产生一个错误时,该域会得到通知,这样错误就可以得到适当的处理。

清单 3-9 中的例子展示了域是如何被用来处理异常的。在示例的第二行,导入了domain模块,并创建了一个新的域。然后使用域的run()方法来执行提供的函数。在run()的上下文中,所有的定时器、事件发射器和回调方法都隐式地注册到域中。当抛出一个错误时,它触发域的错误处理程序。当然,如果没有定义处理函数,异常就会导致程序崩溃。最后,当不再需要该域时,调用它的dispose()方法。

清单 3-9 。使用域的异常处理

var fs = require("fs");
var domain = require("domain").create();

domain.run(function() {
  fs.readFile("", "utf8", function(error, data) {
    if (error) {
      throw error;
    }

    console.log(data);
    domain.dispose();
  });
});

domain.on("error", function(error) {
  console.log("The exception was caught!")
});

显式绑定

如前所述,在run()的上下文中创建的定时器、事件发射器和回调函数被隐式地注册到相应的域中。但是,如果您创建了多个域,那么您可以显式地绑定到另一个域,甚至是在run()的上下文中。例如,清单 3-10 创建了两个域,d1d2。在d1run()方法中,创建了一个抛出错误的异步定时器。因为异常发生在d1run()回调中,所以异常通常由d1处理。然而,定时器是使用add()方法向d2显式注册的。因此,当抛出异常时,d2的错误处理程序被触发。

清单 3-10 。使用域的绑定回调函数示例

var domain = require("domain");
var d1 = domain.create();
var d2 = domain.create();

d1.run(function() {
  d2.add(setTimeout(function() {
    throw new Error("test error");
  }, 1));
});

d2.on("error", function(error) {
  console.log("Caught by d2");
});

d1.on("error", function(error) {
  console.log("Caught by d1")
});

正如我们刚刚看到的,add()用于显式地将定时器绑定到一个域。这也适用于事件发射器。类似的方法remove()从域中删除一个计时器或事件发射器。清单 3-11 展示了如何使用remove()解除一个定时器的绑定。需要注意的非常重要的一点是,从d2中移除timer变量并不会自动将其绑定到d1。相反,由timer的回调函数抛出的异常没有被捕获,程序崩溃。

清单 3-11 。使用remove()解除定时器与域的绑定

var domain = require("domain");
var d1 = domain.create();
var d2 = domain.create();

d1.run(function() {
  var timer = setTimeout(function() {
    throw new Error("test error");
  }, 1);

  d2.add(timer);
  d2.remove(timer);
});

d2.on("error", function(error) {
  console.log("Caught by d2");
});

d1.on("error", function(error) {
  console.log("Caught by d1")
});

image 注意每个域都有一个数组属性members,它包含所有明确添加到域中的定时器和事件发射器。

域还提供了一个bind()方法,可以用来向域显式注册回调函数。这很有用,因为它允许将一个函数绑定到一个域,而不像run()那样立即执行该函数。bind()方法将回调函数作为唯一的参数。返回的函数是原始回调的注册包装。与run()方法一样,异常通过域的错误处理程序来处理。清单 3-12 回顾了使用域bind()方法处理与readFile()回调函数相关的错误的文件阅读器示例。

清单 3-12 。使用域的绑定回调函数示例

var fs = require("fs");
var domain = require("domain").create();

fs.readFile("", "utf8", domain.bind(function(error, data) {
  if (error) {
    throw error;
  }

  console.log(data);
  domain.dispose();
}));

domain.on("error", function(error) {
  console.log("The exception was caught!")
});

还有一种方法intercept(),与bind()几乎相同。除了捕捉任何抛出的异常,intercept()还检测任何作为回调函数的第一个参数传递的Error对象。这消除了检查传递给回调函数的任何错误的需要。例如,清单 3-13 使用intercept()方法重写了清单 3-12 。这两个例子行为相同,但是注意在 3-13 中回调不再有error参数。我们还删除了用于检测error参数的if语句。

清单 3-13 。使用域intercept()方法的错误处理

var fs = require("fs");
var domain = require("domain").create();

fs.readFile("", "utf8", domain.intercept(function(data) {
  console.log(data);
  domain.dispose();
}));

domain.on("error", function(error) {
  console.log("The exception was caught!")
});

async模块

async是第三方开源模块,对于管理异步控制流非常有用。在撰写本文时,asyncnpm注册表中第二个最依赖的模块。虽然最初是为 Node 应用开发的,async也可以在客户端使用,因为该模块受到许多流行浏览器的支持,包括 Chrome、Firefox 和 Internet Explorer。开发人员可以提供一个或多个函数,并使用async模块定义它们将如何执行——是串行执行还是以指定的并行度执行。鉴于该模块的受欢迎程度、灵活性和强大功能,async是本书中第一个全面探讨的第三方模块。

串行执行

异步开发最具挑战性的方面之一是在保持代码可读的同时,强制执行函数的顺序。然而,使用async,强制串行执行只是使用series()方法的问题。作为它的第一个参数,series()接受一个数组或对象,其中包含要按顺序执行的函数。每个函数都将回调作为参数。按照 Node 约定,每个回调函数的第一个参数是一个错误对象,如果没有错误,则为null,。回调函数还接受一个可选的第二个参数来表示返回值。调用回调函数导致series()移动到下一个函数。但是,如果有任何函数向它们的回调函数传递错误,那么其余的函数都不会被执行。

series()方法也接受可选的第二个参数,这是在所有函数完成后调用的回调。这个最后的回调接受两个参数,一个错误和一个包含函数结果的数组或对象。如果任何函数向它们的回调函数传递错误,控制会立即传递给最后一个回调函数。

清单 3-14 包含三个定时器任务,每个任务填充results数组的一个元素。在本例中,任务 1 用了 300 毫秒完成,任务 2 用了 200 毫秒,任务 3 用了 100 毫秒。假设我们希望任务按顺序运行,那么需要重新构造代码,以便从任务 2 调用任务 3,而任务 2 又从任务 1 调用任务 3。此外,我们无法知道所有任务何时完成,结果何时准备好。

清单 3-14 。在没有建立控制流的情况下执行定时器任务的示例

var results = [];

setTimeout(function() {
  console.log("Task 1");
  results[0] = 1;
}, 300);

setTimeout(function() {
  console.log("Task 2");
  results[1] = 2;
}, 200);

setTimeout(function() {
  console.log("Task 3");
  results[2] = 3;
}, 100);

清单 3-15 显示了运行前一个例子的结果。请注意,任务没有按照正确的顺序执行,也没有办法验证任务返回的结果。

清单 3-15 。验证任务执行顺序错误的控制台输出

$ node timer-tasks
Task 3
Task 2
Task 1

清单 3-16 展示了我们如何使用asyncseries()方法来解决所有与控制流相关的问题,而不会使代码变得复杂。第一行导入了async模块,正如您在第二章中了解到的,可以使用命令npm install async 安装该模块。接下来,调用series(),用一组包含原始定时器任务的函数封装在匿名函数中。在每个任务中,期望的返回值作为回调函数的第二个参数传递。对series()的调用还包括一个最终回调函数,它解决了不知道所有结果何时准备好的问题。

清单 3-16 。使用Async串行执行功能的示例

var async = require("async");

async.series([
  function(callback) {
    setTimeout(function() {
      console.log("Task 1");
      callback(null, 1);
    }, 300);
  },
  function(callback) {
    setTimeout(function() {
      console.log("Task 2");
      callback(null, 2);
    }, 200);
  },
  function(callback) {
    setTimeout(function() {
      console.log("Task 3");
      callback(null, 3);
    }, 100);
  }
], function(error, results) {
  console.log(results);
});

清单 3-17 显示了清单 3-16 的控制台输出,它验证了三个任务是按照指定的顺序执行的。此外,最后的回调提供了检查结果的机制。在这种情况下,结果被格式化为数组,因为任务函数是在数组中传递的。如果使用对象传递任务,结果也会被格式化为对象。

清单 3-17 。清单 3-16 中代码的控制台输出

$ node async-series
Task 1
Task 2
Task 3
[ 1, 2, 3 ]

处理错误

如前所述,如果任何函数向它们的回调函数传递一个错误,执行会立即短路到最后一个回调函数。在清单 3-18 中,第一个任务中故意引入了一个错误。此外,为了简洁起见,第三个任务已经被删除,最后一个回调现在检查错误。

清单 3-18 。系列示例已经过修改,包含了一个错误

var async = require("async");

async.series([
  function(callback) {
    setTimeout(function() {
      console.log("Task 1");
      callback(new Error("Problem in Task 1"), 1);
    }, 200);
  },
  function(callback) {
    setTimeout(function() {
      console.log("Task 2");
      callback(null, 2);
    }, 100);
  }
], function(error, results) {
  if (error) {
    console.log(error.toString());
  } else {
    console.log(results);
  }
});

引入错误后的结果输出如列表 3-19 所示。请注意,第一个任务中的错误阻止了第二个任务的执行。

清单 3-19 。出现错误时的控制台输出

$ node async-series-error
Task 1
Error: Problem in Task 1

并行执行

async模块也可以使用parallel()方法并行执行多个功能。当然,JavaScript 仍然是单线程的,所以您的代码实际上不会并行执行。除了async在调用下一个函数之前不等待一个函数返回之外,parallel()方法的行为与series()完全一样,给人一种并行的错觉。清单 3-20 显示了一个使用parallel()执行同样三个任务的例子。此示例还传递了使用对象中的任务,因为您已经在前面的示例中看到了数组语法。

清单 3-20 。使用Async并行执行三个任务

var async = require("async");

async.parallel({
  one: function(callback) {
    setTimeout(function() {
      console.log("Task 1");
      callback(null, 1);
    }, 300);
  },
  two: function(callback) {
    setTimeout(function() {
      console.log("Task 2");
      callback(null, 2);
    }, 200);
  },
  three: function(callback) {
    setTimeout(function() {
      console.log("Task 3");
      callback(null, 3);
    }, 100);
  }
}, function(error, results) {
  console.log(results);
});

清单 3-21 显示了来自清单 3-20 的输出。在这种情况下,任务不按程序顺序执行。另外,请注意,显示任务结果的最后一行输出是一个对象,而不是一个数组。

清单 3-21 。并行执行任务的控制台输出

$ node async-parallel
Task 3
Task 2
Task 1
{ three: 3, two: 2, one: 1 }

极限平行度

parallel()方法试图尽快执行传递给它的所有函数。一个类似的方法,parallelLimit(),的行为与parallel()完全一样,除了您可以为并行执行的任务数量设置一个上限。清单 3-22 显示了一个parallelLimit()方法的使用示例。在这种情况下,并行度限制设置为 2,在最终回调之前使用一个额外的参数。需要注意的是,parallelLimit()不会在 n 的离散批次中执行功能。相反,该函数只是确保永远不会有超过 n 个函数同时执行。

清单 3-22 。并行执行三个任务,最大并行度为 2

var async = require("async");

async.parallelLimit({
  one: function(callback) {
    setTimeout(function() {
      console.log("Task 1");
      callback(null, 1);
    }, 300);
  },
  two: function(callback) {
    setTimeout(function() {
      console.log("Task 2");
      callback(null, 2);
    }, 200);
  },
  three: function(callback) {
    setTimeout(function() {
      console.log("Task 3");
      callback(null, 3);
    }, 100);
  }
}, 2, function(error, results) {
  console.log(results);
});

清单 3-23 显示了来自清单 3-22 的结果输出。请注意,任务 1 和 2 在第三个任务之前完成,尽管它的计时器延迟最小。这表明任务 3 直到前两个任务中的一个完成后才开始执行。

清单 3-23 。运行清单 3-22 中的代码的输出

$ node parallel-limit.js
Task 2
Task 1
Task 3
{ two: 2, one: 1, three: 3 }

瀑布模型

瀑布模型 是一种串行模型,当任务依赖于先前完成的任务的结果时,这种模型很有用。瀑布也可以被认为是装配线,每个任务执行一个更大的任务的一部分。瀑布是使用async方法waterfall()创建的。设置瀑布与使用series()parallel()非常相似。然而,有几个关键的区别。首先,组成瀑布的函数列表只能存储在一个数组中(不支持对象符号)。第二个关键区别是,只有最后一个任务的结果被传递给最终的回调函数。第三个区别是任务函数可以接受前一个任务提供的附加参数。

清单 3-24 显示了一个瀑布的例子。它使用勾股定理来计算三角形斜边的长度。勾股定理指出,对于直角三角形,斜边的平方长度等于其他两条边的平方之和。定理一般写成a2+b2=c2,其中 c 为斜边的长度。在清单 3-24 中,使用waterfall()方法将问题分解为三个任务。第一个任务创建两个随机数作为值 ab 。这些值被传递给任务的回调函数,从而使它们成为第二个任务的前两个参数。第二个任务计算 ab 的平方和,并将该值传递给第三个任务。第三个任务计算传递给它的值的平方根。这个值,斜边的长度,被传递给最终的回调函数,在那里被打印到控制台。

清单 3-24 。计算直角三角形斜边长度的瀑布

var async = require("async");

async.waterfall([
  function(callback) {
    callback(null, Math.random(), Math.random());
  },
  function(a, b, callback) {
    callback(null, a * a + b * b);
  },
  function(cc, callback) {
    callback(null, Math.sqrt(cc));
  }
], function(error, c) {
  console.log(c);
});

排队模型

async也支持使用queue()方法的任务队列。与以前的执行模型不同,以前的执行模型执行许多作为参数传入的函数,队列模型允许您在执行过程中的任何时候动态添加任务。队列对于解决生产者-消费者类型的问题很有用。因为 JavaScript 是单线程的,所以您可以放心地忽略生产者-消费者问题中通常会出现的潜在并发问题。

清单 3-25 显示了一个async队列的基本初始化。队列对象是使用queue()方法创建的,该方法将任务处理函数作为输入参数。任务处理程序接受两个参数,一个用户定义的任务和一个回调函数,一旦任务被处理,就应该用一个错误参数调用该回调函数。在这个例子中,没有发生错误,所以调用回调函数,用null作为它的参数。与parallelLimit()方法类似,queue()方法也采用一个参数来指定队列的并行级别。清单 3-25 中所示的队列可以同时处理多达四个任务。

清单 3-25 。初始化一个async队列

var async = require("async");
var queue = async.queue(function(task, callback) {
  // process the task argument
  console.log(task);
  callback(null);
}, 4);

一旦建立了队列,就开始使用它的push()unshift()方法向它添加任务。与同名的数组方法一样,unshift()push()分别将任务添加到队列的开头和结尾。这两种方法都可以将单个任务添加到队列中,或者通过传入数组将多个任务添加到队列中。两种方法都接受可选的回调函数;如果存在,则在每个任务完成后,将使用错误参数调用它。

在清单 3-26 的中,每隔 200 毫秒,一个间隔被用来把一个新任务添加到前一个例子的队列的末尾。在这个例子中,每个任务只是一个带有数字id字段的对象。然而,任务实际上可以是任何数据,这取决于您的应用。这个例子中包含了可选的回调参数。在这种情况下,回调函数只是打印一条消息,说明任务已经完成。

清单 3-26 。向async队列添加任务的例子

var i = 0;

setInterval(function() {
  queue.push({
    id: i
  }, function(error) {
    console.log("Finished a task");
  });
  i++;
}, 200);

其他队列方法和属性

在任何时候,您都可以通过使用length()方法来确定队列中元素的数量。您还可以使用concurrency属性来控制队列的并行级别。例如,如果队列长度超过了一个阈值,您可以使用清单 3-27 中所示的代码来增加并发任务的数量。

清单 3-27 。根据负载更新队列的Concurrency

if (queue.length() > threshold) {
  queue.concurrency = 8;
}

队列还支持许多回调函数,这些函数在某些事件发生时被触发。这些回调函数是saturated()empty()drain()。每当队列的长度等于它的并发性时,就会触发saturated()函数,每当从队列中移除最后一个任务时,就会调用empty(),当最后一个任务处理完毕时,就会调用drain()。清单 3-28 中显示了每个函数的示例。

清单 3-28saturated()empty()drain()的使用示例

queue.saturated = function() {
  console.log("Queue is saturated");
};

queue.empty = function() {
  console.log("Queue is empty");
};

queue.drain = function() {
  console.log("Queue is drained");
};

重复方法

async模块还提供了其他方法,这些方法重复调用一个函数,直到满足某个条件。其中最基本的是whilst(),它的行为类似于一个while循环。清单 3-29 展示了如何使用whilst()来实现一个异步while循环。whilst()方法将三个函数作为参数。第一个是同步真值测试,它没有参数,在每次迭代之前被检查。传递给whilst()的第二个函数在每次真值测试返回true时执行。这个函数将回调作为它唯一的参数,并且可以被认为是循环体。循环体的回调函数将一个可选的错误作为其唯一的参数,在本例中该参数被设置为null。一旦真值测试返回false,就执行whilst()的第三个参数,并作为最终的回调函数。这个函数也将一个可选的错误作为它唯一的参数。

清单 3-29 。使用whilst() 实现简单循环

var async = require("async");
var i = 0;

async.whilst(function() {
  return i < 5;
}, function(callback) {
  setTimeout(function() {
    console.log("i = " + i);
    i++;
    callback(null);
  }, 1000);
}, function(error) {
  console.log("Done!");
});

重复变化

async模块提供了三种额外的方法来实现异步的类循环结构。这些方法是doWhilst()until()doUntil(),它们的行为几乎和whilst()一模一样。第一个doWhilst(),是一个do-while循环的异步等价物,until()whilst()的逆,一直执行到真值测试返回true。类似地,doUntil()doWhilst()的逆,只要真值测试返回false就执行。这些方法的签名如清单 3-30 所示。请注意,body参数出现在doWhilst()doUntil()test之前。

清单 3-30doWhilst()until()doUntil() 的方法签名

async.doWhilst(body, test, callback)
async.until(test, body, callback)
async.doUntil(body, test, callback)

附加async功能

async除了已经介绍的功能之外,还提供了许多其他实用功能。例如,async提供了实现记忆化的memoize()unmemoize()方法。该模块还提供了用于处理集合的许多常用方法的串行和并行版本。这些方法包括each()map()filter()reduce()some()every()。在模块的 GitHub 页面上可以找到async提供的方法以及参考代码的完整列表:https://github.com/caolan/async

image 记忆化是一种编程技术,它试图通过缓存函数先前计算的结果来提高性能。当调用记忆化函数时,它的输入参数被映射到软件缓存中的输出。下次使用相同的输入调用该函数时,将返回缓存的值,而不是再次执行该函数。

摘要

本章已经开始探索 Node 编程模型。阅读本章后,您应该对异步编程和非阻塞 I/O 的概念有了更好的理解。如果您仍然不确定,请返回并再次阅读该章。如果您计划进行任何严肃的 Node 开发,理解这些概念是绝对必要的。异常处理(也在这里讨论)可能会被推迟到以后,但是由于异步错误处理可能是一个棘手的问题,所以最好尽快将其提上日程。

本章还介绍了现有最流行的 Node 模块之一async。在任何 Node 开发者的工具箱中,async都是一个非常强大的工具,它也可以在浏览器中工作,这也使它成为前端开发者的资产。使用async提供的模型,几乎可以抽象出任何执行模式。此外,模型可以嵌套在其他模型中。例如,您可以创建一组并行执行的函数,每个函数包含一个嵌套的瀑布。

四、事件和计时器

前一章介绍了 Node 的事件驱动编程模型。本章对事件和事件处理进行了更深入的研究。对事件处理的深刻理解将允许您创建复杂的、事件驱动的应用,例如 web 服务器。本章介绍事件发射器,即用于创建新事件的对象。在学习了如何创建事件之后,本章将转向事件处理。最后,本章讨论了 Node 中的定时器和功能调度。

事件发射器

在 Node 中,生成事件的对象称为事件发射器。创建一个事件发射器就像导入events核心模块并实例化一个EventEmitter对象一样简单。然后,EventEmitter实例可以使用它的emit()方法创建新事件。清单 4-1 中显示了一个创建事件发射器的例子。在这个例子中,事件发射器创建了一个foo事件。

清单 4-1 。一个简单事件发射器的例子

var events = require("events");
var emitter = new events.EventEmitter();

emitter.emit("foo");

事件名称可以是任何有效的字符串,但是按照惯例使用 camelCase 命名。例如,创建一个事件来表明一个新用户被添加到系统中,这个事件可能被命名为userAdded或类似的名称。

通常,事件需要提供事件名称之外的附加信息。例如,当按下一个键时,该事件还指定键入哪个键。为了支持这一功能,emit()方法可以在事件名称后接受任意数量的可选参数。回到创建新用户的例子,清单 4-2 展示了额外的参数是如何传递给emit()的。这个例子假设执行了一些 I/O(可能是一个数据库事务)操作,这会创建一个新用户。一旦 I/O 操作完成,事件发射器emitter创建一个新的userAdded事件,并传入用户的用户名和密码。

清单 4-2 。向发出的事件传递参数的示例

var events = require("events");
var emitter = new events.EventEmitter();var username = "colin";var password = "password";

// add the user
// then emit an event
emitter.emit("userAdded", username, password);

监听事件

在清单 4-2 的例子中,一个事件发射器被用来创建一个事件。不幸的是,如果没有人在听,一个事件是毫无意义的。在 Node 中,事件监听器使用on()addListener()方法连接到事件发射器。这两种方法可以互换使用。这两种方法都将事件名称和处理函数作为参数。当发出指定类型的事件时,会调用相应的处理函数。例如,在清单 4-3 中,使用on()方法将一个userAdded事件处理程序附加到emitter上。接下来,emitter发出一个userAdded事件,导致处理程序被调用。这个例子的输出如清单 4-4 所示。

清单 4-3 。使用on()设置事件监听器

var events = require("events");
var emitter = new events.EventEmitter();

var username = "colin";
var password = "password";

// an event listener
emitter.on("userAdded", function(username, password) {
  console.log("Added user " + username);
});

// add the user
// then emit an event
emitter.emit("userAdded", username, password);

image 注意事件监听器只能检测那些在监听器被连接后发生的事件。也就是说,收听者不能检测过去的事件。因此,如清单 4-3 所示,确保在发出事件之前附加一个监听器。

清单 4-4 。运行清单 4-3 中代码的输出

$ node user-event-emitter.js
Added user colin

一次性事件侦听器

有时你可能只对事件第一次发生时的反应感兴趣。在这些情况下,您可以使用once()方法。once()的用法与on()addListener().完全一样,但是,使用 once()附加的监听器最多执行一次,然后被删除。清单 4-5 显示了一个once()方法的使用示例。在本例中,once()用于监听foo事件。然后使用emit()方法创建两个foo事件。但是,因为事件监听器是使用once()注册的,所以只处理第一个foo事件。如果事件监听器是使用on()addListener()注册的,那么两个 foo 事件都会得到处理。运行该示例的输出如清单 4-6 所示。

清单 4-5 。使用once()的一次性事件监听器的示例

var events = require("events");
var emitter = new events.EventEmitter();

emitter.once("foo", function() {
  console.log("In foo handler");
});

emitter.emit("foo");
emitter.emit("foo");

清单 4-6 。运行清单 4-5 中代码的输出

$ node once-test.js
In foo handler

检查事件侦听器

在事件发射器的生命周期中,它可以有零个或多个侦听器。每种事件类型的侦听器可以通过几种方式进行检查。如果您只对确定附加侦听器的数量感兴趣,那么只需看看EventEmitter.listenerCount()方法就可以了。该方法将一个EventEmitter实例和一个事件名作为参数,并返回附加侦听器的数量。例如,在清单 4-7 中,创建了一个事件发射器,并附加了两个无趣的foo事件处理程序。该示例的最后一行显示了通过调用EventEmitter.listenerCount()连接到emitterfoo处理程序的数量。在这种情况下,该示例输出数字 2。请注意,listenerCount()调用被附加到了EventEmitter类,而不是特定的实例。许多语言称之为静态方法。然而,Node 文档将listenerCount()标识为一个类方法,因此本书也是如此。

清单 4-7 。使用EventEmitter.listenerCount()确定听众人数

var events = require("events");
var EventEmitter = events.EventEmitter; // get the EventEmitter constructor from the events module
var emitter = new EventEmitter();

emitter.on("foo", function() {});
emitter.on("foo", function() {});
console.log(EventEmitter.listenerCount(emitter, "foo"));

如果获取附加到事件发射器的处理程序的数量还不够,那么可以使用listeners()方法来获取事件处理程序函数的数组。该数组通过length属性提供处理程序的数量,以及事件发生时调用的实际函数。也就是说,修改由listeners()返回的数组不会影响由事件发射器对象维护的处理程序。

清单 4-8 提供了一个使用listeners()方法的例子。在这个例子中,一个foo事件处理程序被添加到一个事件发射器中。然后使用listeners()来检索事件处理程序的数组。然后使用数组forEach()方法遍历事件处理程序,一路调用每个事件处理程序。因为本例中的事件处理程序不接受任何参数,也不改变程序状态,所以对forEach()的调用实质上复制了emitter.emit("foo")的功能。

清单 4-8 。一个通过listeners()方法迭代事件处理程序的例子

var events = require("events");
var EventEmitter = events.EventEmitter;
var emitter = new EventEmitter();

emitter.on("foo", function() { console.log("In foo handler"); });
emitter.listeners("foo").forEach(function(handler) {
  handler();
});

newListener事件

每次注册新的事件处理程序时,事件发射器都会发出一个newListener事件。此事件用于检测新的事件处理程序。当您需要为每个新的事件处理程序分配资源或执行某些操作时,通常会使用newListener。一个newListener事件的处理方式和其他事件一样。处理程序需要两个参数:字符串形式的事件名称和处理程序函数。例如,在清单 4-9 中,一个foo事件处理程序被附加到一个事件发射器上。在幕后,发射器发出一个newListener事件,导致newListener事件处理程序被调用。

清单 4-9 。添加一个newListener事件处理器

var events = require("events");
var emitter = new events.EventEmitter();

emitter.on("newListener", function(eventName, listener) {
  console.log("Added listener for " + eventName + " events");
});

emitter.on("foo", function() {});

重要的是要记住newListener事件是在创建自己的事件时存在的。清单 4-10 显示了如果你忘记了会发生什么。在这个例子中,开发人员创建了一个定制的newListener事件处理程序,该程序期望被传递一个Date对象。当发出一个newListener事件时,一切都按预期工作。然而,当创建一个看似不相关的foo事件处理程序时,会抛出一个异常,因为内置的newListener事件是以字符串foo作为第一个参数发出的。因为Date对象有一个getTime()方法,而字符串没有,所以抛出一个TypeError

清单 4-10newListener事件的无效处理程序

var events = require("events");
var emitter = new events.EventEmitter();

emitter.on("newListener", function(date) {
  console.log(date.getTime());
});

emitter.emit("newListener", new Date());
emitter.on("foo", function() {});

删除事件侦听器

事件侦听器可以在附加到事件发射器后被删除。例如,要将事件发射器重置到某个没有监听器的初始状态,最简单的方法是使用removeAllListeners()方法。可以不带任何参数调用此方法,在这种情况下,所有事件侦听器都会被移除。或者,传入事件名称会导致命名事件的处理程序被移除。removeAllListeners()的语法如清单 4-11 所示。

清单 4-11removeAllListeners()方法的语法

emitter.removeAllListeners([eventName])

如果removeAllListeners()对于您的需求来说过于粗糙,那么就求助于removeListener()方法。此方法用于移除单个事件侦听器,并接受两个参数—要移除的事件的名称和处理函数。清单 4-12 展示了一个removeListener()的使用示例。在这种情况下,一个foo事件监听器被添加到一个事件发射器,然后立即被移除。发出事件时,不会发生任何事情,因为没有附加的侦听器。注意,removeListener()的用法与on()addListener()方法的用法相同,尽管它们执行相反的操作。

清单 4-12 。使用removeListener()删除事件处理程序

var events = require("events");
var emitter = new events.EventEmitter();

function handler() {
  console.log("In foo handler");
}

emitter.on("foo", handler);
emitter.removeListener("foo", handler);
emitter.emit("foo");

如果你打算使用removeListener() ,避免匿名处理函数。就其本质而言,匿名函数不会绑定到命名引用。如果创建了匿名事件处理程序,第二个相同的匿名函数将无法成功移除该处理程序。这是因为两个不同的Function对象不被认为是等价的,除非它们指向内存中的同一个位置。因此,清单 4-13 中的例子将而不是删除一个事件监听器。

清单 4-13 。匿名函数对removeListener()的不正确使用

var events = require("events");
var emitter = new events.EventEmitter();

emitter.on("foo", function() {
  console.log("foo handler");
});
emitter.removeListener("foo", function() {
  console.log("foo handler");
});
emitter.emit("foo");

检测潜在的内存泄漏

通常,单个事件发射器只需要少量的事件侦听器。因此,如果应用以编程方式向事件发射器添加事件侦听器,而该发射器突然拥有了几百个事件侦听器,这可能表明出现了某种类型的逻辑错误,从而导致内存泄漏。这方面的一个例子是添加事件侦听器的循环。如果循环包含逻辑错误,可能会创建大量事件处理程序,消耗不必要的内存。默认情况下,如果为任何单个事件添加了十个以上的侦听器,Node 会打印一条警告消息。该阈值可以使用setMaxListeners()方法进行控制。这个方法将一个整数作为它唯一的参数。通过将该值设置为0,事件发射器将接受无限制的侦听器,而不会输出警告消息。请注意,程序语义不受setMaxListeners()的影响(它只会打印一条警告消息)。相反,它只是提供了一个有用的调试机制。setMaxListeners()的用法如清单 4-14 所示。

清单 4-14setMaxListeners()方法的语法

emitter.setMaxListeners(n)

从事件发射器继承

到目前为止,所有的例子都明确涉及到了对EventEmitter实例的管理。或者,您可以创建从EventEmitter继承的定制对象,并包含额外的特定于应用的逻辑。清单 4-15 显示了这是如何完成的。第一行导入熟悉的EventEmitter构造函数。第二行导入util核心模块。顾名思义,util提供了许多有用的实用函数。本例中特别有趣的inherits()方法有两个参数,都是构造函数。使第一个构造函数继承第二个构造函数的原型方法。在这个例子中,自定义的User构造函数继承自EventEmitter。在User构造函数内部,调用了EventEmitter构造函数。此外,定义了一个方法addUser(),它发出userAdded事件。

清单 4-15 。创建一个扩展EventEmitter的对象

var EventEmitter = require("events").EventEmitter;
var util = require("util");

function UserEventEmitter() {
  EventEmitter.call(this);

  this.addUser = function(username, password) {
    // add the user
    // then emit an event
    this.emit("userAdded", username, password);
  };
};

util.inherits(UserEventEmitter, EventEmitter);

image 注意 JavaScript 采用了一种被称为原型继承的继承类型,它不同于传统继承 Java 等语言中使用的那种继承。在原型继承中,没有类。相反,对象充当其他对象的原型。

清单 4-16 展示了如何使用定制的User事件发射器。出于这个例子的目的,假设在同一个文件中定义了User构造函数——尽管理论上它可以在其他地方定义并使用require()函数导入。在这个例子中,一个新的User被实例化。接下来,添加一个userAdded事件监听器。然后调用addUser()方法来模拟新用户的创建。由于addUser()发出一个userAdded事件,事件处理程序被调用。另外,请注意示例最后一行的 print 语句。该语句检查user变量是否是EventEmitter的实例。由于UserEventEmitter继承而来,这将计算为true

清单 4-16 。使用自定义事件发射器

var user = new UserEventEmitter();
var username = "colin";
var password = "password";

user.on("userAdded", function(username, password) {
  console.log("Added user " + username);
});

user.addUser(username, password)
console.log(user instanceof EventEmitter);

使用事件来避免回调地狱

第三章探讨了许多避免回调地狱的方法,其中之一就是使用async模块。事件发射器提供了另一种避免死亡金字塔的好方法。举个例子,让我们用清单 4-17 来重温一下清单 3-5 中的文件阅读器应用。

清单 4-17 。一个带有回调地狱的文件阅读器程序开始悄悄进入

var fs = require("fs");
var fileName = "foo.txt";

fs.exists(fileName, function(exists) {
 if (exists) {
   fs.stat(fileName, function(error, stats) {
     if (error) {
       throw error;
     }

     if (stats.isFile()) {
       fs.readFile(fileName, "utf8", function(error, data) {
         if (error) {
           throw error;
         }

         console.log(data);
       });
     }
   });
 }
});

清单 4-18 展示了如何使用事件发射器重写文件阅读器应用。在本例中,创建了一个封装了所有文件读取功能的FileReader对象。需要EventEmitter构造函数和util模块来设置事件发射器继承。此外,需要使用fs模块来访问文件系统。

FileReader构造函数中,你会注意到的第一件事是this是私有_self变量的别名。这样做是为了在异步文件系统回调函数中维护对FileReader对象的引用。在这些回调函数中,this变量并不指向FileReader。这意味着在这些回调中不能通过关键字this访问emit()方法。

除了_self变量,代码相当简单。exists()方法用于检查文件是否存在。如果是,就会发出一个stats事件。然后触发stats监听器,调用stat()方法。如果该文件是一个正常文件,并且没有错误发生,则发出一个read事件。read事件触发了read监听器,它试图读取并打印文件的内容。

清单 4-18 。使用事件发射器重构文件阅读器应用

var EventEmitter = require("events").EventEmitter;
var util = require("util");
var fs = require("fs");

function FileReader(fileName) {
  var _self = this;

  EventEmitter.call(_self);

  _self.on("stats", function() {
    fs.stat(fileName, function(error, stats) {
      if (!error && stats.isFile()) {
        _self.emit("read");
      }
    });
  });

  _self.on("read", function() {
    fs.readFile(fileName, "utf8", function(error, data) {
      if (!error && data) {
        console.log(data);
      }
    });
  });

  fs.exists(fileName, function(exists) {
    if (exists) {
      _self.emit("stats");
    }
  });
};

util.inherits(FileReader, EventEmitter);

var reader = new FileReader("foo.txt");

定时器和时间安排

由于所有熟悉的用于处理定时器和时间间隔的 JavaScript 函数都可以在 Node 中作为全局变量使用,所以您不需要使用require()来导入它们。setTimeout()函数用于调度一个一次性回调函数在未来某个时间执行。setTimeout()的参数是要执行的回调函数、执行前等待的时间(以毫秒为单位),以及传递给回调函数的零个或多个参数。清单 4-19 显示了如何使用setTimeout()来安排一个回调函数在一秒钟的延迟后执行。在这个例子中,回调函数接受两个参数,foobar,它们由setTimeout()的最后两个参数填充。

image 注意记住 JavaScript 时间(实际上是一般的计算机时间)并不是 100%准确的,所以回调函数很可能不在指定的时间执行。因为 JavaScript 是单线程的,所以长时间运行的任务会完全影响时间。

清单 4-19 。创建一个延迟一秒后执行的计时器

setTimeout(function(foo, bar) {
  console.log(foo + " " + bar);
}, 1000, "foo", "bar");

The setTimeout()函数还返回一个超时标识符,可以用来在回调函数执行前取消定时器。通过将超时标识符传递给clearTimeout()函数来取消定时器。清单 4-20 显示了一个定时器在执行前被取消。在本例中,定时器在创建后立即被取消。然而,在实际应用中,定时器通常基于一些事件的发生而被取消。

清单 4-20 。使用clearTimeout()功能取消定时器

var timeoutId = setTimeout(function() {
  console.log("In timeout function");
}, 1000);

clearTimeout(timeoutId);

间隔时间

本质上,时间间隔是一个周期性重复的计时器。创建和取消间隔的功能分别是setInterval()clearInterval()。像setTimeout()一样,setInterval()接受一个回调函数、delay和可选的回调参数。它还返回一个可以传递给clearInterval()的间隔标识符,以便取消间隔。清单 4-21 展示了如何使用setInterval()clearInterval()创建和取消间隔。

清单 4-21 。创建和取消间隔的示例

var intervalId = setInterval(function() {
  console.log("In interval function");
}, 1000);

clearInterval(intervalId);

ref()unref()方法

事件循环中剩下的唯一一项计时器或时间间隔将阻止程序终止。然而,这种行为可以通过使用定时器或间隔标识符的ref()unref()方法以编程方式改变。调用unref()方法允许程序在定时器/间隔是事件循环中唯一剩下的项目时退出。例如,在清单 4-22 的中,interval 是在调用setInterval()之后的事件循环中唯一安排的项目。然而,因为unref()在这个区间被调用,程序终止了。

清单 4-22 。不使程序保持活动状态的时间间隔示例

var intervalId = setInterval(function() {
  console.log("In interval function");
}, 1000);

intervalId.unref();

如果已经在计时器或时间间隔上调用了unref(),但是您希望恢复到默认行为,那么可以调用ref()方法。ref()的用法如清单 4-23 所示。

清单 4-23ref()方法的使用

timer.ref()

即时

Immediates 用于安排回调函数立即执行。这允许在当前执行的函数之后调度函数。使用setImmediate()函数创建即时消息,该函数将回调和可选的回调参数作为其参数。与setTimeout()setInterval()不同,setImmediate()不接受delay参数,因为延迟被假定为零。也可以使用clearImmediate()功能取消即时消息。清单 4-24 显示了一个创建和取消即时消息的例子。

清单 4-24 。创建和取消即时消息的示例

var immediateId = setImmediate(function() {
  console.log("In immediate function");
});

clearImmediate(immediateId);

拆分长时间运行的任务

任何熟悉浏览器中 JavaScript 开发的人无疑都遇到过这样的情况:一段长时间运行的代码使用户界面没有响应。这种行为是 JavaScript 单线程特性的产物。例如,清单 4-25 中的函数包含一个长时间运行的循环,模拟计算密集型代码,即使循环体为空,也会导致应用的响应时间明显滞后。

清单 4-25 。合成计算密集型函数

function compute() {
 for (var i = 0; i < 1000000000; i++) {
   // perform some computation
 }
}

compute();
console.log("Finished compute()");

在浏览器世界中,这个问题的一个常见解决方案是使用setTimeout().将计算量大的代码分割成更小的块。同样的技术也适用于 Node,但是,首选的解决方案是setImmediate()。清单 4-26 展示了如何使用setImmediate()将计算密集型代码分解成更小的部分。在本例中,每次调用compute()时都会处理一次迭代。这个过程允许其他代码运行,同时仍然向事件循环添加compute()的迭代。但是,请注意,执行速度将明显慢于原始代码,因为每个函数调用只处理一次循环迭代。通过对每个函数调用执行更多的工作,可以更好地平衡性能和响应。例如,setImmediate()可以在每 10,000 次迭代后被调用。最佳方法将取决于您的应用的需求。

清单 4-26 。使用setImmediate()分解计算密集型代码

var i = 0;

function compute() {
 if (i < 1000000000) {
   // perform some computation
   i++;
   setImmediate(compute);
 }
}

compute();
console.log("compute() still working…");

使用process.nextTick()进行调度

Node 的process对象包含一个名为nextTick()的方法,该方法提供了一种类似于 immediate 的高效调度机制。nextTick() 将回调函数作为其唯一的参数,并在事件循环的下一次迭代中调用回调函数,称为 tick 。因为回调函数被安排在下一个时钟周期,nextTick()不需要delay参数。根据官方的 Node 文档,nextTick()也比类似的调用setTimeout(fn, 0)更有效,因此更受青睐。清单 4-27 显示了一个使用nextTick()的函数调度的例子。

清单 4-27 。使用process.nextTick()安排功能

process.nextTick(function() {
  console.log("Executing tick n+1");
});

console.log("Executing nth tick");

image 注意在 Node 的旧版本中,process.nextTick()是分解计算密集型代码的首选工具。然而,现在不鼓励递归调用nextTick();应当用setImmediate()来代替。

不幸的是,没有办法将参数传递给回调函数。幸运的是,这个限制可以通过创建一个绑定任何所需参数的函数来轻松克服。例如,清单 4-28 中的代码不会像预期的那样工作,因为没有办法将参数传递给回调函数。然而,清单 4-29 中的代码将会工作,因为函数的参数在传递给nextTick()之前是绑定的。

清单 4-28 。向process.nextTick()传递参数的错误尝试

process.nextTick(function(f, b) {
  console.log(f + " " + b);
});
// prints "undefined undefined"

清单 4-29 。将带有绑定参数的函数传递给process.nextTick()

function getFunction(f, b) {
  return function myNextTick() {
    console.log(f + " " + b);
  };
}

process.nextTick(getFunction("foo", "bar"));
// prints "foo bar"

实现异步回调函数

process.nextTick()通常用于创建接受异步回调函数作为最终参数的函数。如果不使用nextTick(),回调函数就不是真正的异步,它的行为就像普通(同步)函数调用一样。同步回调函数会阻止事件循环中的其他任务执行,从而导致资源匮乏。如果使用您的代码的人期望异步行为,那么它们也会给他们带来困惑。

清单 4-30 展示了一个简单的函数,将两个数相加,然后将它们的和传递给一个回调函数。Node 的调用约定规定回调函数应该异步执行。因此,人们会期望代码打印出The sum is:,后跟实际的总和 5。但是,回调函数不是使用nextTick()异步调用的。因此,总和实际上是先打印后打印,如清单 4-31 所示。为了避免混淆,这个函数命名为addSync()可能更合适。

清单 4-30 。一个同步回调函数的例子

function add(x, y, cb) {
  cb(x + y);
}

add(2, 3, console.log);
console.log("The sum is:");

清单 4-31 。运行清单 4-30 中代码的输出

$ node sync-callback.js
5
The sum is:

幸运的是,将同步回调函数转换成异步回调函数相当简单,如清单 4-32 所示。在这个例子中,回调函数被传递给nextTick()。另外,请注意,将回调函数包装在匿名函数中允许xy的值通过nextTick()传递。这些简单的更改会导致程序按照最初的预期运行。清单 4-33 显示了正确的输出结果。

清单 4-32 。使用process.nextTick()的适当异步回调函数

function add(x, y, cb) {
  process.nextTick(function() {
    cb(x + y);
  });
}

add(2, 3, console.log);
console.log("The sum is:");

清单 4-33 。运行清单 4-32 中的异步代码的输出

$ node async-callback.js
The sum is:
5

保持一致的行为

任何非平凡函数都可能有多个控制流路径。重要的是,所有这些路径都是一致异步或一致同步的。换句话说,函数不应该对一组输入异步运行,而应该对另一组输入同步运行。此外,您必须确保回调函数只被调用一次。这是一个常见的问题来源,因为许多开发人员认为调用回调函数会导致当前函数返回。实际上,一旦回调函数返回,函数就会继续执行。解决这个问题的一个非常简单的方法是每次调用nextTick()时返回。

考虑清单 4-34 中的函数,它决定一个数是否为负。如果n参数小于 0,则将true传递给回调函数。否则,false就通过了。不幸的是,这个例子有两个主要问题。第一个是true回调是异步的,而false回调是同步的。第二种是当n为负时,回调函数执行两次,一次在isNegative()结束时,第二次在执行nextTick()回调时。

清单 4-34 。回调函数的不一致实现

function isNegative(n, cb) {
  if (n < 0) {
    process.nextTick(function() {
      cb(true);
    });
  }

  cb(false);
}

清单 4-35 显示了同一个函数的正确实现(注意回调函数的两次调用现在是异步的)。此外,对nextTick()的两次调用都会导致isNegative()返回,确保回调函数只能被调用一次。

清单 4-35 。清单 4-34 中回调函数的一致实现

function isNegative(n, cb) {
  if (n < 0) {
    return process.nextTick(function() {
      cb(true);
    });
  }

  return process.nextTick(function() {
    cb(false);
  });
}

当然,这是一个人为的例子。代码可以大大简化,如清单 4-36 所示。

清单 4-36 。清单 4-35 中代码的简化版本

function isNegative(n, cb) {
  process.nextTick(function() {
    cb(n < 0);
  });
}

摘要

本章探讨了 Node.js 世界中的事件、计时器和调度控制。这一章和前一章一起,应该给你一个坚实的 Node 基础的掌握。以这种理解为基础,本书的其余部分将重点探讨各种 Node API,并使用它们创建令人兴奋的应用。下一章将向您展示如何创建命令行界面——这是构建真实 Node 应用的第一步。

五、命令行界面

前四章向您展示了 Node 开发的基础。从这一章开始,这本书改变了方向,开始关注用于创建 Node 应用的各种 API 和模块。本章重点介绍如何创建命令行界面(CLI)来与用户进行交互。首先,您将学习 Node 内置 API 的命令行基础。从那里,你可以使用commander模块扩展基础,你可能记得在第二章中的几个npm例子。

命令行参数

命令行参数是向计算机程序提供输入的最基本的方式之一。在 Node 应用中,命令行参数可以通过全局process对象的argv数组属性 来访问。清单 5-1 展示了如何使用forEach()方法迭代argv,就像任何其他数组一样。

清单 5-1 。一个迭代argv数组的例子

process.argv.forEach(function(arg, index) {
  console.log("argv[" + index + "] = " + arg);
});

为了检查保存在argv中的实际值,将来自清单 5-1 的代码保存在一个名为argv-test.js 的新 JavaScript 源文件中。接下来,运行代码,观察输出(参见清单 5-2 )。注意,有四个参数被传递给了我们的 Node 程序:-foo3--bar=4-baz。然而,基于程序的输出,在argv中有六个元素。无论您提供什么样的命令行参数组合,argv总是在数组的开头包含额外的两个元素。这是因为argv的前两个元素总是node(可执行文件的名称)和 JavaScript 源文件的路径。argv数组的其余部分由实际的命令行参数组成。

清单 5-2 。运行清单 5-1 中代码的输出

$ node argv-test.js -foo 3 --bar=4 -baz
argv[0] = node
argv[1] = /home/colin/argv-test.js
argv[2] = -foo
argv[3] = 3
argv[4] = --bar=4
argv[5] = -baz

解析参数值

基于清单 5-2 中的命令行,我们似乎试图传入三个参数:foobarbaz。然而,这三个论点的作用各不相同。foo的值来自它后面的自变量(我们假设它是一个整数)。在这种情况下,foo的值是3。与foo不同的是,bar4的值被编码在同一个参数中,后面跟一个等号。同时,baz是一个布尔自变量。如果提供了参数,则其值为true,否则为false。不幸的是,通过简单地检查argv中的值,这些语义都没有被捕获。

为了提取正确的命令行参数值,我们可以开发一个定制的解析器(见清单 5-3 )。在示例中,parseArgs()函数 负责解析命令行、提取值并返回一个对象,该对象将每个参数映射到其正确的值。这个函数的工作方式是循环遍历argv中的每个元素,检查可识别的参数名。如果参数是foo,那么从下面的参数中解析出一个整数。循环变量i也被递增以节省时间,因为没有必要为foo的值执行循环体。如果自变量被确定为baz,我们简单的赋值true。为了提取bar的值,使用了一个正则表达式。如果字符串--bar=后跟一系列一个或多个数字,那么这些数字将被解析为一个整数值。最后,所有的参数都通过args对象返回,并打印到控制台。

清单 5-3 。清单 5-2 中示例的命令行解析器

function parseArgs() {
  var argv = process.argv;
  var args = {
    baz: false
  };

  for (var i = 0, len = argv.length; i < len; i++) {
    var arg = argv[i];
    var match;

    if (arg === "-foo") {
      args.foo = parseInt(argv[++i]);
    } else if (arg === "-baz") {
      args.baz = true;
    } else if (match = arg.match(/--bar=(\d+)/)) {
      args.bar = parseInt(match[1]);
    }
  }

  return args;
}

var args = parseArgs();

console.log(args);

清单 5-4 显示了运行清单 5-3 中代码的输出。如你所见,所有的论点都被恰当地提取出来了。但是当用户输入格式错误时会发生什么呢?清单 5-5 显示了使用不同参数运行相同程序的输出。在这种情况下,baz被拼错为az,用户忘记为foo提供一个值。

清单 5-4 。运行清单 5-3 中代码的结果

$ node argv-parser.js -foo 3 --bar=4 -baz
{ foo: 3, bar: 4, baz: true }

清单 5-5 。由畸形的用户输入产生的输出

$ node argv-parser.js -foo -az --bar=4
{ foo: NaN, bar: 4 }

在清单 5-5 的输出中,请注意baz完全缺失,而foo的值为NaN(非数字),因为解析器试图将-az转换为整数。由于baz没有从命令行传入,理想情况下它的值是false。类似地,foobar应该有一些默认值,以便处理这样的情况。在这种情况下,预填充parseArgs()中的args对象不会阻止foo被设置为NaN

相反,我们可以使用一个sanitize()函数对args进行后处理(参见清单 5-6 )。这个函数检查每个参数的值,如果它还没有值,就给它分配一个合适的值。在这个例子中,JavaScript 内置的isFinite()方法用于确保foobar是有效的整数。由于baz是一个布尔值,代码简单地检查它是否不等于true,如果是,就将其设置为false。这确保了baz实际上被设置为布尔值false——而不是保留为undefined,这是一个不同的 falsy 值。注意parseArgs()代码不包括在本例中,因为它没有改变。

清单 5-6 。为参数分配默认值的sanitize()函数

function sanitize(args) {
  if (!isFinite(args.foo)) {
    args.foo = 0;
  }

  if (!isFinite(args.bar)) {
    args.bar = 0;
  }

  if (args.baz !== true) {
    args.baz = false;
  }

  return args;
}

var args = sanitize(parseArgs());

console.log(args);

commander 中的命令行参数

如果实现简单的命令行解析所需的工作量对您来说似乎有点多,请放心,您并不孤单。幸运的是,像commander这样的模块使得命令行解析变得简单。第三方模块commander用于简化常见的 CLI 任务,如参数解析和读取用户输入。要安装commander,使用命令npm install commander。为了适应命令行参数解析,commander 提供了option()parse()方法。对option()的每次调用都向commander注册一个有效的命令行参数。一旦使用option()注册了所有可能的参数,就可以使用parse()方法从命令行提取参数值。

用一个例子来说明commander的命令行参数系统是如何工作的可能是最简单的。在清单 5-7 中,commander被配置为接受三个参数:--foo--bar--baz。也可以使用-f来指定--foo参数。这被认为是论点的简短版本。所有的commander参数必须有一个短名称和一个长名称。短名称应该是一个破折号后跟一个字母,长名称应该在名称前有两个破折号。

清单 5-7 。使用commander的命令行解析器示例

var commander = require("commander");

commander
  .option("-f, --foo <i>", "Integer value for foo", parseInt, 0)
  .option("-b, --bar [j]", "Integer value for bar", parseInt, 0)
  .option("-z, --baz", "Boolean argument baz")
  .parse(process.argv);

console.log(commander.foo);
console.log(commander.bar);
console.log(commander.baz);

注意--foo--bar后面的<i>[j]。这些是应该跟在参数后面的值。当使用尖括号时,就像使用--foo一样,必须指定附加值,否则会抛出一个错误。与--bar一起使用的方括号表示附加值是可选的。--baz被视为布尔参数,因为它不接受任何附加参数。参数字符串之后是描述字符串。这些字符串是人类可读的,用于显示帮助,这将被暂时覆盖。

接下来要指出的是,--foo--bar选项也指parseInt()和数字 0(零)。parseInt()作为可选参数传递,用于解析附加参数。在这种情况下,--foo--bar的值被评估为整数。最后,如果没有为--foo--bar提供值,它们将被设置为 0。

一旦注册了所有选项,就调用parse()来处理命令行。从技术上讲,任何数组都可以传递给parse(),但是传入process.argv最有意义。解析后,参数值根据它们的长名称可用,如三个 print 语句所示。

自动生成的帮助

commander根据选项配置自动生成一个--help(或-h)自变量。清单 5-8 显示了前一个例子中自动生成的帮助。

清单 5-8 。为清单 5-7 中的代码自动生成帮助

$ node commander-test.js --help

  Usage: commander-test.js [options]

  Options:

    -h, --help         output usage information
    -f, --foo <i>      Integer value for foo
    -b, --bar [j]      Integer value for bar
    -z, --baz          Boolean argument baz

还有两种方法可以用来显示帮助:help()outputHelp()。它们之间唯一的区别是help()会导致程序退出,而outputHelp()不会。通常,如果提供了无效的参数,您可以调用help(),然后退出。但是,如果你想显示帮助菜单并出于某种原因继续执行,你可以调用outputHelp()。这两种方法的使用如清单 5-9 所示。

清单 5-9 。使用commander帮助方法

commander.help()
commander.outputHelp()

标准流

默认情况下,Node 应用连接到提供输入和输出功能的三个数据流— stdinstdoutstderr。如果您熟悉 C/C++、Java 或任何一种其他语言,您肯定以前遇到过这些标准流。本节将详细探讨每一个问题。

标准输入

stdin流(标准输入的缩写)是一个可读的流,为程序提供输入。默认情况下,stdin从用于启动应用的终端窗口接收数据,and通常用于在运行时接受用户的输入。然而,stdin也可以从一个文件或另一个程序接收它的数据。

在 Node 应用中,stdin是全局process对象的属性。但是,当应用启动时,stdin处于暂停状态,也就是说,不能从中读取任何数据。对于要读取的数据,必须使用resume()方法对数据流进行解析(见清单 5-10 ),该方法不带参数,也不提供返回值。

清单 5-10stdin.resume() 的用法

process.stdin.resume()

除了解除对stdin流的暂停,resume()还防止应用终止,因为它将处于等待输入的状态。然而,stdin可以再次暂停,使用pause()方法,允许程序退出。清单 5-11 显示了pause()的用法。

清单 5-11stdin.pause() 的用法

process.stdin.pause()

调用resume()后,你的程序可以从stdin读取数据。但是,您需要设置一个data事件处理程序来自己读取数据。stdin上新数据的到达触发了一个data事件。data事件处理程序接受一个参数,即接收到的数据。在清单 5-12 中,显示了如何使用data事件从stdin读取数据,提示用户输入他/她的名字。然后调用resume()以激活stdin流。一旦输入了名字,用户按下Return,就会调用data事件处理程序——使用once()方法添加的(在第四章中介绍)。然后事件处理器确认用户并暂停stdin。注意,在事件处理程序中,data参数被转换成一个字符串。这样做是因为data是作为Buffer对象传入的。用于处理 Node 应用中的原始二进制数据。(该主题在第八章的中有更详细的介绍。)

清单 5-12 。从stdin读取数据的示例

process.stdin.once("data", function(data) {
  var response = data.toString();

  console.log("You said your name is " + response);
  process.stdin.pause();
});

console.log("What is your name?");
process.stdin.resume();

通过预先指定stdin流的字符编码,可以避免每次读取数据时都必须将数据转换成字符串。为此,请使用stdinsetEncoding()方法。如表 5-1 所示,Node 支持许多不同的字符编码。处理字符串数据时,建议将编码设置为utf8 (UTF-8)。清单 5-13 展示了如何使用setEncoding()重写清单 5-12 。

表 5-1 。Node 支持的各种字符串编码类型

|

编码类型

|

描述

|
| --- | --- |
| utf8 | 多字节编码的 Unicode 字符。UTF-8 编码被许多网页使用,并用于表示 Node 中的字符串数据。 |
| ascii | 七位美国信息交换标准码(ASCII)编码。 |
| utf16le | 小端编码的 Unicode 字符。每个字符是两个或四个字节。 |
| ucs2 | 这只是utf16le编码的别名。 |
| base64 | Base64 字符串编码。Base64 通常用于 URL 编码、电子邮件和类似的应用。 |
| binary | 允许仅使用每个字符的前八位将二进制数据编码为字符串。这种编码现在已被弃用,取而代之的是Buffer对象,并将在 Node 的未来版本中删除。 |
| hex | 将每个字节编码为两个十六进制字符。 |

清单 5-13 。设置字符编码类型后从stdin读取

process.stdin.once("data", function(data) {
  console.log("You said your name is " + data);
  process.stdin.pause();
});

console.log("What is your name?");
process.stdin.setEncoding("utf8");
process.stdin.resume();

使用commanderstdin读取

commander模块还提供了几种从stdin读取数据的有用方法。其中最基本的是prompt(),它向用户显示一些消息或问题,然后读入响应。然后将响应作为字符串传递给回调函数进行处理。清单 5-14 展示了如何使用prompt()重写来自清单 5-13 的例子。

清单 5-14 。使用commanderprompt()方法从stdin读取

var commander = require("commander");

commander.prompt("What is your name? ", function(name) {
  console.log("You said your name is " + name);
  process.stdin.pause();
});

confirm()

confirm()方法与prompt()相似,但用于解析布尔响应。如果用户输入yyestrue或 ok,回调将被调用,其参数设置为true。否则,回调将被调用,其参数设置为false。清单 5-15 中显示了confirm()方法的一个使用示例,清单 5-16 显示了该示例的示例输出。

清单 5-15 。使用commanderconfirm()方法解析布尔响应

var commander = require("commander");

commander.confirm("Continue? ", function(proceed) {
  console.log("Your response was " + proceed);
  process.stdin.pause();
});

清单 5-16 。运行清单 5-15 中代码的输出示例

$ node confirm-example.js
Continue? yes
Your response was true

password()

prompt()的另一个特例是password()方法,它用于获取敏感的用户输入,而不在终端窗口中显示。顾名思义,它最大的用例是提示用户输入密码。清单 5-17 中的显示了一个使用password()的例子。

清单 5-17 。使用password()方法提示输入密码

var commander = require("commander");

commander.password("Password: ", function(password) {
  console.log("I know your password!  It's " + password);
  process.stdin.pause();
});

默认情况下,password()不会将信息回显到终端。但是,可以提供一个可选的掩码字符串,它会为用户输入的每个字符回显。清单 5-18 显示了一个例子。其中,掩码字符串只是星号字符(*)。

清单 5-18 。使用掩码字符提示输入密码

var commander = require("commander");

commander.password("Password: ", "*", function(password) {
  console.log("I know your password!  It's " + password);
  process.stdin.pause();
});

choose()

choose()功能对于创建基于文本的菜单很有用。以一组选项作为第一个参数,choose()允许用户从列表中选择一个选项。第二个参数是用所选选项的数组索引调用的回调。清单 5-19 显示了一个使用choose()的例子。

清单 5-19 。使用choose()显示文本菜单

var commander = require("commander");
var list = ["foo", "bar", "baz"];

commander.choose(list, function(index) {
  console.log("You selected " + list[index]);
  process.stdin.pause();
});

清单 5-20 显示了运行前一个例子的样本输出。需要注意的一点是,菜单项计数从 1 开始,而数组从 0 开始索引。考虑到这一点,choose()将正确的从零开始的数组索引传递给回调函数。

清单 5-20 。清单 5-19 的输出示例

$ node choose-example.js
  1) foo
  2) bar
  3) baz
  : 2
You selected bar

标准输出

标准输出,或stdout ,是一个可写的流,程序应该将它们的输出指向这个流。默认情况下,Node 应用直接输出到启动应用的终端窗口。向stdout写入数据的最直接方式是通过process.stdout.write()方法。write()的用法如清单 5-21 所示。write()的第一个参数是要写入的数据字符串。第二个参数是可选的;用于指定数据的字符编码,默认为utf8 (UTF-8)编码。write()支持表 5-1 中指定的所有编码类型。write()的最后一个参数是可选的回调函数。一旦数据成功写入stdout,就会执行该命令。没有参数传递给回调函数。

清单 5-21stdout的使用。write()方法

process.stdout.write(data, [encoding], [callback])

image process.stdout.write()也可以接受一个Buffer作为它的第一个自变量。

console.log()

阅读完stdout.write()之后,你可能会好奇它与已经讨论过的console.log()方法有什么关系。实际上,console.log()只是一个在引擎盖下调用stdout.write()的包装器。清单 5-22 显示了console.log()的源代码。这段代码直接取自 Node 官方 GitHub repo 中的文件https://github.com/joyent/node/blob/master/lib/console.js。如您所见,log()调用了_stdout.write()。检查整个源文件会发现_stdout只是对stdout的引用。

清单 5-22console.log()的源代码

Console.prototype.log = function() { this._stdout.write(util.format.apply(this, arguments) + '\n');
};

另外,注意对write()的调用调用了util.format()方法。util对象是对核心util模块的引用。format()方法用于根据传递给它的参数创建格式化字符串。作为第一个参数,format()接受一个包含零个或多个占位符的格式字符串。占位符是格式字符串中的一个字符序列,预计将被返回的字符串中的不同值替换。在格式字符串之后,format()期望每个占位符都有一个额外的参数。format()支持四种占位符,如表 5-2 所述。

表 5-2 。util.format()支持的各种占位符。

|

占位符

|

更换

|
| --- | --- |
| %s | 字符串数据。一个参数被使用并传递给String()构造函数。 |
| %d | 整数或浮点数字数据。一个参数被使用并传递给Number()构造函数。 |
| %j | JSON 数据。一个参数被消费并传递给JSON.stringify()。 |
| %% | 一个百分号(%)字符。这不会消耗任何参数。 |

清单 5-23 中显示了util.format()的几个例子,清单 5-24 中显示了的结果输出。这些示例显示了如何使用各种占位符替换数据。前三个示例使用字符串、数字和 JSON 占位符来替换字符串。请注意,数字占位符被替换为NaN。这是因为保存在name变量中的字符串不能被转换成实际数字。在第四个例子中,使用了 JSON 占位符,但是没有相应的参数传递给format()。结果就是没有替换发生,并且%j包含在结果中。在第五个例子中,format()比它能处理的多传递了一个参数。format()通过将附加参数转换为字符串并附加到结果字符串中来处理附加参数,使用空格字符作为分隔符。在第六个示例中,按照预期使用了多个占位符。最后,在第七个示例中,根本没有提供任何格式字符串。在这种情况下,参数被转换为字符串,并用空格字符分隔符连接起来。

清单 5-23 。使用util.format()的几个例子

var util = require("util");
var name = "Colin";
var age = 100;
var format1 = util.format("Hi, my name is %s", name);
var format2 = util.format("Hi, my name is %d", name);
var format3 = util.format("Hi, my name is %j", name);
var format4 = util.format("Hi, my name is %j");
var format5 = util.format("Hi, my name is %j", name, name);
var format6 = util.format("I'm %s, and I'm %d years old", name, age);
var format7 = util.format(name, age);

console.log(format1);
console.log(format2);
console.log(format3);
console.log(format4);
console.log(format5);
console.log(format6);
console.log(format7);

清单 5-24 。运行清单 5-23 中的代码的输出

$ node format.js
Hi, my name is Colin
Hi, my name is NaN
Hi, my name is "Colin"
Hi, my name is %j
Hi, my name is "Colin" Colin
I'm Colin, and I'm 100 years old
Colin 100

image 注意任何熟悉 C/C++、PHP 或其他语言的人都会认识到util.format()的行为,因为它提供了类似于printf()函数的格式。

其他打印功能

Node 还提供了几个不太流行的函数来打印到stdout。例如,util模块定义了log()方法。log()方法接受一个单独的字符串作为参数,并把它和时间戳一起打印给stdout。清单 5-25 显示了log()的一个实例。结果输出如清单 5-26 所示。

清单 5-25util.log()的一个例子

var util = require("util");

util.log("baz");

清单 5-26 。运行清单 5-25 中的代码的输出

$ node util-log-method.js
17 Mar 15:08:29 - baz

console对象还提供了两种额外的打印方法,info()dir()info()方法只是console.log()的别名。console.dir()将一个对象作为其唯一参数。使用util.inspect()方法将对象字符串化,然后打印到stdoututil.inspect()是用于将多余的参数字符串化到没有相应占位符的util.format()的相同方法。inspect(),一个强大的字符串化数据的方法,将在下面介绍。

util.inspect()

util.inspect()用于将对象转换成格式良好的字符串。虽然它真正的强大之处在于它的定制能力,但我们首先来看看它的默认行为。清单 5-27 显示了一个使用inspect()字符串化一个对象obj的例子。结果字符串如清单 5-28 中的所示。

清单 5-27 。一个使用util.inspect()方法的例子

var util = require("util");
var obj = {
  foo: {
    bar: {
      baz: {
        baff: false,
        beff: "string value",
        biff: null
      },
      boff: []
    }
  }
};

console.log(util.inspect(obj));

清单 5-28 。清单 5-27 中的util.inspect()创建的字符串

{ foo: { bar: { baz: [Object], boff: [] } } }

注意foobar是完全字符串化的,但是baz只显示字符串[Object]。这是因为,默认情况下,inspect()在格式化对象时只通过两级递归。不过,这种行为可以通过使用可选的第二个参数inspect()来改变。该参数是一个指定inspect()配置选项的对象。如果你对增加递归的深度感兴趣,设置depth选项。它可以设置为null来强制inspect()在整个对象上递归。清单 5-29 和清单 5-30 中显示了这样的例子和结果字符串。

清单 5-29 。在启用完全递归的情况下调用util.inspect()

var util = require("util");
var obj = {
  foo: {
    bar: {
      baz: {
        baff: false,
        beff: "string value",
        biff: null
      },
      boff: []
    }
  }
};

console.log(util.inspect(obj, {
  depth: null
}));

清单 5-30 。运行清单 5-29 中的代码的输出

$ node inspect-recursion.js
{ foo:
   { bar:
      { baz: { baff: false, beff: 'string value', biff: null },
        boff: [] } } }

options参数支持其他几个选项— showHiddencolorscustomInspectshowHiddencolors默认为false,而customInspect默认为true。当showHidden设置为true时,inspect()打印对象的所有属性,包括不可枚举的属性。将colors设置为true会导致结果字符串采用 ANSI 颜色代码。当customInspect设置为true时,对象可以定义自己的inspect()方法,调用这些方法可以返回字符串化过程中使用的字符串。在这个例子中,如清单 5-31 所示,一个自定义的inspect()方法被添加到顶层对象中。此自定义方法返回隐藏所有子对象的字符串。结果输出如清单 5-32 所示。

image 注意并不是所有的方法属性都是相同的。在 JavaScript 中,可以创建不可枚举的属性,当一个对象在for...in循环中迭代时,这些属性不会显示出来。通过设置showHidden选项,inspect()将在其输出中包含不可枚举的属性。

清单 5-31 。使用自定义的inspect()方法调用util.inspect()

var util = require("util");
var obj = {
  foo: {
    bar: {
      baz: {
        baff: false,
        beff: "string value",
        biff: null
      },
      boff: []
    }
  },
  inspect: function() {
    return "{Where'd everything go?}";
  }
};

console.log(util.inspect(obj));

清单 5-32 。清单 5-31 中自定义inspect()方法的结果

$ node inspect-custom.js
{Where'd everything go?}

标准误差

标准误差流stderr是类似于stdout的输出流。然而,stderr用于显示错误和警告信息。虽然stderrstdout是相似的,stderr是一个独立的实体,所以你不能像console.log()一样使用stdout函数来访问它。幸运的是,Node 提供了许多专门用于访问stderr的函数。对stderr最直接的访问路径是通过它的write()方法。write()的用法如清单 5-33 所示,与stdoutwrite()方法相同。

清单 5-33 。使用stderr write()方法

process.stderr.write(data, [encoding], [callback])

console对象还提供了两个方法error()warn(),用于写入stderrconsole.warn()的行为与console.log()完全一样,只是充当了process.stderr.write()的包装器。error()方法只是warn()的别名。清单 5-34 显示了warn()error()的源代码。

清单 5-34console.warn()console.error() 的源代码

Console.prototype.warn = function() {
  this._stderr.write(util.format.apply(this, arguments) + '\n');
};

Console.prototype.error = Console.prototype.warn;

console.trace()

console对象还提供了一个有用的调试方法,名为trace(),它创建并打印一个堆栈跟踪到stderr,而不会使程序崩溃。如果您曾经遇到过错误(我相信您现在已经遇到过了),那么您就会看到程序崩溃时打印的堆栈跟踪。trace()完成同样的事情,没有错误和崩溃。清单 5-35 显示了一个使用trace()的例子,其输出显示在清单 5-36 中。在示例中,名为test-trace的堆栈跟踪是在函数baz()中创建的,该函数从bar()中调用,而后者又从foo()中调用。请注意,这些函数是堆栈跟踪中的前三项。堆栈跟踪中的其余函数是由 Node 框架进行的调用。

清单 5-35 。使用console.trace() 生成示例堆栈跟踪

(function foo() {
  (function bar() {
    (function baz() {
      console.trace("test-trace");
    })();
  })();
})();

清单 5-36 。运行清单 5-35 中的示例的输出

$ node stack-trace.js
Trace: test-trace
    at baz (/home/colin/stack-trace.js:4:15)
    at bar (/home/colin/stack-trace.js:5:7)
    at foo (/home/colin/stack-trace.js:6:5)
    at Object.<anonymous> (/home/colin/stack-trace.js:7:3)
    at Module._compile (module.js:456:26)
    at Object.Module._extensions..js (module.js:474:10)
    at Module.load (module.js:356:32)
    at Function.Module._load (module.js:312:12)
    at Function.Module.runMain (module.js:497:10)
    at startup (node.js:119:16)

image 传递给console.trace()的自变量被转发给util.format()。因此,可以使用格式字符串创建堆栈跟踪名称。

分离stderrstdout

stderr定向到与stdout相同的目的地是常见的,但不是必需的。默认情况下,Node 的stdoutstderr都指向运行流程的终端窗口。但是,可以重定向一个流或两个流。清单 5-37 中的代码可以用来简单地演示这个概念。示例代码使用console.log()stdout输出一条消息,使用console.error()stderr输出第二条消息。

清单 5-37 。打印到stdoutstderr的示例应用

console.log("foo");
console.error("bar");

当清单 5-37 中的代码正常运行时,两条消息都被打印到终端窗口。输出如清单 5-38 所示。

清单 5-38 。运行清单 5-37 中的代码时的控制台输出

$ node stdout-and-stderr.js
foo
bar

同样的代码在清单 5-39 中再次执行。然而,这次使用>操作符将stdout重定向到文件output.txt。注意重定向对stderr流没有影响。结果是发送到stderrbar打印在终端窗口,而foo没有。

清单 5-39 。当stdout被重定向时清单 5-39 中代码的控制台输出

$ node stdout-and-stderr.js > output.txt
bar

image 注意您可能已经注意到了,console方法是同步的。这种行为(当底层流的目的地是文件或终端窗口时的默认行为)避免了由于程序崩溃或退出而丢失消息。在第七章中有更多关于流和它们如何被管道化的内容,但是现在,只需要知道当底层流被管道化时console方法的行为是异步的。

TTY 界面

正如您已经看到的,默认情况下,标准流被配置为使用终端窗口。为了适应这种配置,Node 提供了一个 API 来检查终端窗口的状态。因为流可以被重定向,所以所有标准流都提供了一个isTTY属性,如果流与终端窗口相关联,那么这个属性就是true。清单 5-40 显示了如何为每个流访问这些属性。默认情况下,isTTYstdinstdoutstderrtrue,如清单 5-41 所示。

清单 5-40 。检查每个标准流是否连接到终端的示例

console.warn("stdin  = " + process.stdin.isTTY);
console.warn("stdout = " + process.stdout.isTTY);
console.warn("stderr = " + process.stderr.isTTY);

清单 5-41 。默认条件下清单 5-40 的输出

$ node is-tty.js
stdin  = true
stdout = true
stderr = true

清单 5-42 展示了当stdout被重定向到一个文件时,这些值是如何变化的。注意源代码使用了console.warn()而不是console.log()。这是有意这样做的,以便stdout可以被重定向,同时仍然提供控制台输出。如你所料,isTTY的值不再是stdouttrue。然而,请注意isTTY不是false,而是简单的undefined,这意味着isTTY不是所有流的属性,只是那些与终端相关的流的属性。

清单 5-42 。来自清单 5-40 的输出,带有重定向的stdout

$ node is-tty.js > output.txt
stdin  = true
stdout = undefined
stderr = true

确定终端尺寸

终端窗口的大小,尤其是列数,会极大地影响程序输出的可读性。因此,一些应用可能需要根据终端大小定制输出。假设stdoutstderr或两者都与终端窗口相关联,则可以确定终端中的行数和列数。这些信息可以分别通过流的rowscolumns属性获得。您还可以使用流的getWindowSize()方法以数组的形式检索终端维度。列表 5-43 显示了如何确定端子尺寸,而列表 5-44 显示了最终输出。

清单 5-43 。以编程方式确定终端窗口的大小

var columns = process.stdout.columns;
var rows = process.stdout.rows;

console.log("Size:  " + columns + "x" + rows);

清单 5-44 。运行清单 5-43 中的代码的输出

$ node tty-size.js
Size:  80x24

image 注意使用stdin无法确定终端大小,因为终端尺寸仅与可写 TTY 流相关。

如果你的程序的输出依赖于终端的大小,那么当用户在运行时调整窗口大小时会发生什么?幸运的是,可写 TTY 流提供了一个resize事件,该事件在终端窗口调整大小时触发。清单 5-45 中的例子定义了一个函数size(),它打印出当前的端子尺寸。启动时,程序首先检查stdout是否连接到终端窗口。如果不是,将显示一条错误消息,并且程序通过调用process.exit()方法以一个错误代码终止。如果程序在终端窗口中运行,它会通过调用size()来显示窗口的当前大小。相同的函数随后被用作resize事件处理程序。最后,调用process.stdin.resume()来防止程序在测试时终止。

清单 5-45 。监控终端大小的示例

function size() {
  var columns = process.stdout.columns;
  var rows = process.stdout.rows;

  console.log("Size:  " + columns + "x" + rows);
}

if (!process.stdout.isTTY) {
  console.error("Not using a terminal window!");
  process.exit(-1);
}

size();
process.stdout.on("resize", size);
process.stdin.resume();

信号事件

信号是发送给特定进程或线程的异步事件通知。它们用于在符合 POSIX 的操作系统上提供有限形式的进程间通信。(如果您正在为 Windows 开发,您可能希望跳过这一部分。)所有信号及其含义的完整列表超出了本书的范围,但这些信息在互联网上很容易找到。

例如,如果您在终端程序运行时按下Ctrl+C,一个中断信号SIGINT将被发送到该程序。在 Node 应用中,除非提供了自定义处理程序,否则信号由默认处理程序处理。当默认处理程序接收到一个SIGINT信号时,它会导致程序终止。要覆盖这种行为,向process对象添加一个SIGINT事件处理程序,如清单 5-46 中的所示。

清单 5-46 。添加一个SIGINT信号事件处理器

process.on("SIGINT", function() {
  console.log("Got a SIGINT signal");
});

image 注意如果你在你的应用中包含了来自清单 5-46 的事件处理程序,你将无法使用Ctrl+C终止程序。但是,您仍然可以使用Ctrl+D停止程序。

用户环境变量

环境变量是操作系统级别的变量,可由系统上执行的进程访问。例如,许多操作系统定义了一个TEMPTMP环境变量,它指定了用于保存临时文件的目录。在 Node 中访问环境变量非常简单。process对象有一个包含用户环境的对象属性envenv对象可以像任何其他对象一样进行交互。清单 5-47 显示了如何引用env对象。在本例中,显示了PATH变量。然后在PATH的开头添加一个额外的 Unix 风格的目录。最后显示刚更新的PATH。清单 5-48 显示了这个例子的输出。但是,请注意,根据您当前的系统配置,您自己的输出可能会有很大的不同。

清单 5-47 。使用用户环境变量的示例

console.log("Original: " + process.env.PATH);
process.env.PATH = "/some/path:" + process.env.PATH;
console.log("Updated:   " + process.env.PATH);

清单 5-48 。运行清单 5-47 中代码的输出示例

$ node env-example.js
Original:  /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
Updated:   /some/path:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

环境变量通常用于配置应用中不同的执行模式。例如,一个程序可能支持两种执行模式,开发和生产。在开发模式下,调试信息可能会打印到控制台,而在生产模式下,调试信息可能会记录到文件中或被完全禁用。要启用开发模式,只需设置一个环境变量,该变量可以从应用内部访问。清单 5-49 展示了这个概念是如何工作的。在这个例子中,DEVELOPMENT环境变量被用来定义布尔变量devMode,然后控制if语句的条件。注意,!! (bang bang)符号用于强制将任何值转换为布尔值。

清单 5-49 。使用环境变量实现开发模式的一个例子

var devMode = !!process.env.DEVELOPMENT;

if (devMode) {
  console.log("Some useful debugging information");
}

清单 5-50 显示了在开发模式下执行前面例子的一种方法。请注意,如何在启动 Node 的同一个命令提示符下定义环境变量,从而实现快速的一次性测试,避免了实际定义环境变量的麻烦。(不过,那也可以。)

清单 5-50 。在开发模式下运行清单 5-51 中的例子

$ DEVELOPMENT=1 node dev-mode.js
Some useful debugging information

摘要

本章介绍了 Node 中命令行界面编程的基础知识。一些例子甚至展示了来自 Node 核心的实际代码。现在,您应该已经掌握了命令行参数、标准流、信号处理程序和环境变量等基本概念。这些概念集合了一些已经介绍过的内容(比如事件处理程序)和一些将在本书后面介绍的内容(比如流)。

本章还向您展示了commander模块的基础知识。在撰写本文时,commandernpm注册表中第六大依赖模块。但是,您可能有兴趣探索其他类似的 CLI 模块。其中最突出的是optimist模块(optimist由 James Halliday——又名 substack——Node 社区的杰出成员创建)。我们鼓励您浏览npm存储库并尝试其他模块,以找到最适合您需求的模块。

六、文件系统

对于许多 JavaScript 开发人员来说,访问文件系统很难实现。理由一直是——正确的——让 Web 脚本访问文件系统存在太大的安全风险。然而,Node 通常不会从互联网的黑暗角落执行任意脚本。作为一种成熟的服务器端语言,Node 拥有与 PHP、Python 和 Java 等语言相同的权利和责任。因此,对于 JavaScript 开发人员来说,文件系统是一个不依赖于特定于供应商的实现或黑客的现实。本章展示了文件系统如何成为 Node 开发人员工具箱中的另一个工具。

相关路径

每个 Node 应用都包含许多变量,这些变量提供了关于 Node 在文件系统中的哪个位置工作的洞察力。这些变量中最简单的是__filename__dirname。第一个变量__filename,是当前执行文件的绝对路径。类似地,__dirname是包含当前执行文件的目录的绝对路径。清单 6-1 中的例子显示了__filename__dirname的用法。请注意,这两者都可以在不导入任何模块的情况下访问。当这个例子从目录/home/colin中执行时,结果输出显示在清单 6-2 中。

清单 6-1 。使用__filename__dirname变量

console.log("This file is " + __filename);
console.log("It's located in " + __dirname);

清单 6-2 。运行清单 6-1 中代码的输出

$ node file-paths.js
This file is /home/colin/file-paths.js
It's located in /home/colin

image 注意__filename__dirname的值取决于引用它们的文件。因此,即使在单个 Node 应用中,它们的值也可能不同——例如,当从应用中的两个不同模块引用__filename时,就可能发生这种情况。

当前工作目录

应用的当前工作目录是应用在创建相对路径时引用的文件系统目录。这方面的一个例子是pwd命令,它返回一个 shell 的当前工作目录。在 Node 应用中,当前工作目录可通过process对象的cwd()方法获得。使用cwd()方法的例子如清单 6-3 所示。结果输出如清单 6-4 所示。

清单 6-3 。使用process.cwd()方法

console.log("The current working directory is " + process.cwd());

清单 6-4 。运行清单 6-3 中代码的输出

$ node cwd-example.js
The current working directory is /home/colin

更改当前工作目录

在执行过程中,应用可以改变其当前的工作目录。在 shell 中,这是通过cd命令完成的。process对象提供了一个名为chdir()的方法,通过接受一个表示要更改的目录名的字符串参数来完成相同的任务。该方法同步执行,如果目录更改由于任何原因失败(比如,如果目标目录不存在),该方法将引发异常。

清单 6-5 中的显示了一个例子,它使用chdir()方法显示当前工作目录,然后试图切换到根目录/。如果出现错误,它会被捕获,然后打印到stderr。最后,显示更新的工作目录。

清单 6-5 。使用process.chdir()改变当前工作目录

console.log("The current working directory is " + process.cwd());

try {
  process.chdir("/");
} catch (exception) {
  console.error("chdir error:  " + exception.message);
}

console.log("The current working directory is now " + process.cwd());

清单 6-6 显示了成功执行清单 6-5 中的代码。接下来,尝试将chdir()中的路径更改为某个不存在的路径,并再次运行该示例。清单 6-7 显示了一个失败的例子,它试图将chdir()改为/foo。请注意当前工作目录在失败后是如何保持不变的。

清单 6-6 。清单 6-5 中流程的成功运行

$ node chdir-example.js
The current working directory is /home/colin
The current working directory is now /

清单 6-7 。清单 6-5 中的流程运行失败

$ node chdir-example.js
The current working directory is /home/colin
chdir error:  ENOENT, no such file or directory
The current working directory is now /home/colin

定位node可执行文件

node可执行文件的路径也可以通过process对象获得。具体来说,可执行路径在process.execPath属性中。清单 6-8 显示了一个显示node可执行路径的例子,相应的输出显示在清单 6-9 中。请注意,您自己的路径可能会因操作系统或 Node 安装路径的不同而不同。

清单 6-8 。显示process.execPath的值

console.log(process.execPath);

清单 6-9 。清单 6-8 中的输出

$ node exec-path-example.js
/usr/local/bin/node

path模块

path模块是一个核心模块,它提供了许多使用文件路径的实用方法。虽然path模块使用文件路径,但是它的许多方法只执行简单的字符串转换,而不实际访问文件系统。清单 6-10 展示了path模块是如何包含在一个 Node 应用中的。

清单 6-10 。将path模块导入到 Node 应用中

var path = require("path");

跨平台差异

处理跨多个操作系统的路径可能有点痛苦。这主要是因为 Windows 使用反斜杠(\)来分隔文件路径的各个部分,而其他操作系统使用正斜杠(/)。Node 的 Windows 版本可以有效处理正斜杠,但大多数原生 Windows 应用不能。幸运的是,这个细节可以使用path.sep属性抽象出来。该属性保存当前操作系统的文件分隔符。这在 Windows 中是\\(记住,反斜杠必须被转义),但在其他地方是/。清单 6-11 展示了如何将path.sep与数组join()方法结合使用,来创建特定于平台的文件路径。

清单 6-11 。使用path.sepjoin() 创建跨平台目录

var path = require("path");
var directories = ["foo", "bar", "baz"];
var directory = directories.join(path.sep);

console.log(directory);

image 注意 Windows 使用一个反斜杠作为它的路径分隔符。然而,反斜线必须在 JavaScript 字符串中转义。这就是为什么在 Windows 中path.sep返回\\

非 Windows 系统的结果输出如清单 6-12 中的所示。在本章的后面,我们将解释如何在目录上执行文件系统操作,但是现在我们只显示目录路径。

清单 6-12 。运行清单 6-11 中代码的输出

$ node sep-join-example.js
foo/bar/baz

Windows 和其他平台的另一个主要区别是在PATH环境变量中分隔目录的字符。Windows 使用分号(;),但其他所有系统都使用冒号(:)。path模块的delimiter属性用于将其抽象出来。清单 6-13 使用delimiter属性分割PATH环境变量并打印每个单独的目录。

清单 6-13 。拆分PATH环境变量的跨平台示例

var path = require("path");

process.env.PATH.split(path.delimiter).forEach(function(dir) {
  console.log(dir);
});

提取路径组件

path模块还提供了对几个关键路径组件的简单访问。具体来说,pathextname()basename()dirname()方法分别返回路径的文件扩展名、文件名和目录名。extname()方法 查找路径中的最后一个句点(.),并将其和所有后续字符作为扩展名返回。如果路径不包含句点,则返回空字符串。清单 6-14 显示了如何使用extname()

清单 6-14 。使用path.extname()方法

var path = require("path");
var fileName = "/foo/bar/baz.txt";
var extension = path.extname(fileName);

console.log(extension);
// extension is .txt

basename()方法 返回路径的最后一个非空部分。如果路径对应于一个文件,basename()返回完整的文件名,包括扩展名。清单 6-15 中显示了一个这样的例子。您还可以通过将extname()的结果作为第二个参数传递给basename()来检索不带扩展名的文件名。清单 6-16 显示了一个这样的例子。

清单 6-15 。使用path.basename()从路径中提取完整文件名

var path = require("path");
var fileName = "/foo/bar/baz.txt";
var file = path.basename(fileName);

console.log(file);
// file is baz.txt

清单 6-16 。使用path.basename()从路径中提取文件名减去扩展名

var path = require("path");
var fileName = "/foo/bar/baz.txt";
var extension = path.extname(fileName);
var file = path.basename(fileName, extension);

console.log(file);
// file is baz

dirname()方法 返回路径的目录部分。清单 6-17 展示了dirname()的用法。

清单 6-17 。使用path.dirname()从路径中提取目录名

var path = require("path");
var fileName = "/foo/bar/baz.txt";
var dirName = path.dirname(fileName);

console.log(dirName);
// dirName is /foo/bar

路径标准化

如果混合了"."".."部分,路径会变得过于复杂和混乱。如果用户将路径作为命令行参数传入,很可能会发生这种情况。例如,用户发出cd命令来改变目录,通常会提供相对路径。反过来,path模块提供了一个normalize()方法来简化这些路径。在清单 6-18 的例子中,一个相当复杂的路径被规范化了。在跟随几个父目录和当前目录引用之后,结果路径就是/baz

清单 6-18 。使用path.normalize()实现路径标准化

var path = require("path");
var dirName = "/foo/bar/.././bar/../../baz";
var normalized = path.normalize(dirName);

console.log(normalized);
// normalized is /baz

path模块还有一个join()方法。对任意数量的字符串进行操作,join()获取这些字符串并创建一个单一的规范化路径。在清单 6-19 的例子中,展示了如何使用join()来规范化来自清单 6-18 的路径,输入路径被分成几个字符串。注意,如果传入一个字符串,join()的工作方式与normalize()完全一样。

清单 6-19 。使用path.join()实现路径标准化

var path = require("path");
var normalized = path.join("/foo/bar", ".././bar", "../..", "/baz");

console.log(normalized);
// normalized is /baz

解析目录之间的相对路径

path.relative()方法可用于确定从一个目录到另一个目录的相对路径,它采用两个字符串作为参数。第一个参数表示计算的起点,而第二个参数对应于终点。在清单 6-20 的例子中,显示了relative()的用法,计算了从/foo/bar/baz/biff的相对路径。基于这个目录结构,在遍历/baz/biff之前,相对路径向上移动两级到根目录。

清单 6-20 。使用path.relative()确定相对路径

var path = require("path");
var from = "/foo/bar";
var to = "/baz/biff";
var relative = path.relative(from, to);

console.log(relative);
// relative is ../../baz/biff

fs模块

Node 应用通过fs模块执行文件 I/O,这个核心模块的方法提供了标准文件系统操作的包装器。清单 6-21 展示了文件系统模块是如何导入到一个 Node 应用中的。你可能还记得第三章中的这个模块,其中实现了一个文件阅读器程序。

清单 6-21 。将模块fs导入到 Node 应用中

var fs = require("fs");

关于fs模块特别值得注意的一点是它的同步方法的扩散。更具体地说,几乎所有的文件系统方法都有异步和同步版本。同步的可以通过使用Sync后缀来识别。每个方法的异步版本都将回调函数作为其最终参数。在 Node 的早期版本中,许多异步fs方法允许您省略回调函数。但是根据官方文档,从 Node 0.12 开始,省略回调函数会导致异常。

如您所见,异步方法是 Node 编程模型的核心。使用异步编程使 Node 看起来高度并行,而实际上它是单线程的。即使是一个同步方法的粗心使用也有可能使整个应用停止(如果你需要复习,请参见第三章)。那么为什么将近一半的文件系统方法是同步的呢?

碰巧的是,许多应用访问文件系统来获取配置数据。这通常在启动时的配置过程中完成。在这种情况下,同步读取配置文件通常要简单得多,无需担心性能的最大化。此外,Node 可用于创建简单的实用程序,类似于 shell 脚本。这些脚本可能会逃脱同步行为。一般来说,可以同时调用多次的代码应该是异步的。虽然作为开发人员,您可以随意使用同步方法,但是使用时要非常小心。

确定文件是否存在

exists()existsSync()方法用于确定给定路径是否存在。这两种方法都将路径字符串作为参数。如果使用同步版本,则返回一个表示路径存在的布尔值。如果使用异步版本,相同的布尔值将作为参数传递给回调函数。

清单 6-22 使用existsSync()exists()检查根目录是否存在。当调用exists()回调函数时,比较两种方法的结果。当然,这两种方法应该返回相同的值。假设等价,路径被打印出来,后面跟着表示它存在的布尔值。

清单 6-22 。使用exists()existsSync() 检查文件是否存在

var fs = require("fs");
var path = "/";
var existsSync = fs.existsSync(path);

fs.exists(path, function(exists) {
  if (exists !== existsSync) {
    console.error("Something is wrong!");
  } else {
    console.log(path + " exists:  " + exists);
  }
});

正在检索文件统计信息

fs模块提供了一组用于读取文件统计数据的函数。这些功能是stat()lstat()fstat()。当然,这些方法也有同步的对等物— statSync()lstatSync()fstatSync()。这些方法最基本的形式是stat(),它将路径字符串和回调函数作为参数。回调函数也是用两个参数调用的。第一个表示发生的任何错误。第二个是包含实际文件统计信息的fs.Stats对象。在探索fs.Stats对象之前,让我们看一个使用stat()方法的例子。在清单 6-23 中,stat()用于收集我们假设存在的文件foo.js的信息。如果出现异常(比如文件不存在),错误信息会打印到stderr。否则,打印Stats对象。

清单 6-23 。正在使用的fs.stat()方法

var fs = require("fs");
var path = "foo.js";

fs.stat(path, function(error, stats) {
  if (error) {
    console.error("stat error:  " + error.message);
  } else {
    console.log(stats);
  }
});

清单 6-24 显示了一次成功运行的输出样本。表 6-1 包含了列表中显示的各种fs.Stats对象属性的解释。请注意,您的输出可能会有所不同,尤其是在使用 Windows 的情况下。事实上,在 Windows 中,有些属性根本不会出现。

清单 6-24 。清单 6-23 中代码的输出示例

$ node stat-example.js
{ dev: 16777218,
  mode: 33188,
  nlink: 1,
  uid: 501,
  gid: 20,
  rdev: 0,
  blksize: 4096,
  ino: 2935040,
  size: 75,
  blocks: 8,
  atime: Sun Apr 28 2013 12:55:17 GMT-0400 (EDT),
  mtime: Sun Apr 28 2013 12:55:17 GMT-0400 (EDT),
  ctime: Sun Apr 28 2013 12:55:17 GMT-0400 (EDT) }

表 6-1 。各种 fs 的解释。统计对象属性

|

财产

|

描述

|
| --- | --- |
| dev | 包含文件的设备的 ID。 |
| mode | 文件的保护。 |
| nlink | 指向文件的硬链接的数量。 |
| uid | 文件所有者的用户 ID。 |
| gid | 文件所有者的组 ID。 |
| rdev | 如果文件是特殊文件,则为设备 ID。 |
| blksize | 文件系统 I/O 的块大小。 |
| ino | 文件的索引 Node 号。inode 是存储文件信息的文件系统数据结构。 |
| size | 文件的总大小,以字节为单位。 |
| blocks | 为文件分配的块数。 |
| atime | 代表文件上次访问时间的对象。 |
| mtime | Date代表文件最后修改时间的对象。 |
| ctime | Date表示文件的信息 Node 最后一次被更改的对象。 |

fs.Stats对象也有几个帮助识别文件类型的方法(见表 6-2 )。这些方法是同步的,它们没有参数,并且返回一个布尔值。例如,isFile()方法为普通文件返回true,但是isDirectory()为目录返回true

表 6-2 。各种 fs 的解释。统计方法

|

方法

|

描述

|
| --- | --- |
| isFile() | 指示文件是否是正常文件。 |
| isDirectory() | 指示文件是否是目录。 |
| isBlockDevice() | 指示文件是否是块设备文件。这包括硬盘、光盘和闪存驱动器等设备。 |
| isCharacterDevice() | 指示文件是否是字符设备文件。这包括像键盘这样的设备。 |
| isSymbolicLink() | 指示文件是否是符号链接。这仅在使用lstat()lstatSync()时有效。 |
| isFIFO() | 指示文件是否是 FIFO 特殊文件。 |
| isSocket() | 指示文件是否是套接字。 |

其他stats()变化

lstat()fstat()的变化几乎与stat()相同。与lstat()的唯一区别是,如果路径参数是一个符号链接,那么fs.Stats对象对应的是链接本身,而不是它所引用的文件。对于fstat(),唯一的区别是第一个参数是文件描述符而不是字符串。文件描述符用于与打开的文件进行通信(稍后会有更详细的描述)。当然,statSync()lstatSync()fstatSync()的行为就像它们的异步对应物一样。因为同步方法没有回调函数,所以直接返回fs.Stats对象。

打开文件

使用open()openSync()方法打开文件。这两个方法的第一个参数是一个字符串,表示要打开的文件名。第二个是一个flags字符串,表示文件应该如何打开(读、写等)。).表 6-3 总结了 Node 让你打开文件的各种方式。

表 6-3 。open()和 openSync()可用的各种标志的分类

|

旗帜

|

描述

|
| --- | --- |
| r | 打开阅读。如果文件不存在,则会发生异常。 |
| r+ | 为阅读和写作而打开。如果文件不存在,则会发生异常。 |
| rs | 以同步模式打开进行读取。这指示操作系统绕过系统缓存。这主要用于打开 NFS 挂载上的文件。这并没有使而不是成为同步方法。 |
| rs+ | 以同步模式打开进行读写。 |
| w | 打开写。如果文件不存在,则创建该文件。如果文件已经存在,它将被截断。 |
| wx | 类似于w标志,但是文件是以独占模式打开的。独占模式确保文件是新创建的。 |
| w+ | 为阅读和写作而打开。如果文件不存在,则创建该文件。如果文件已经存在,它将被截断。 |
| wx+ | 类似于w+标志,但是文件是以独占模式打开的。 |
| a | 打开以追加。如果文件不存在,则创建该文件。 |
| ax | 类似于a标志,但是文件是以独占模式打开的。 |
| a+ | 打开以供阅读和追加。如果文件不存在,则创建该文件。 |
| ax+ | 类似于a+标志,但是文件是以独占模式打开的。 |

第三个参数是可选的,给open()openSync()指定了modemode默认为"0666"。异步open()方法将回调函数作为第四个参数。作为一个参数,回调函数接受一个错误和打开文件的文件描述符。文件描述符是一种用于与打开的文件交互的结构。文件描述符,无论是传递给回调函数还是由openSync()返回,都可以传递给其他函数来执行诸如读写之类的文件操作。选择清单 6-25 中的例子,使用open()打开文件/dev/null,是因为对它的任何写入都会被简单地丢弃。请注意,该文件在 Windows 中不存在。但是,您可以更改第二行的path的值,以指向一个不同的文件。建议使用当前不存在的文件路径,因为现有文件的内容将被覆盖,如本例所示。

清单 6-25 。使用open()打开/dev/null

var fs = require("fs");
var path = "/dev/null";

fs.open(path, "w+", function(error, fd) {
  if (error) {
    console.error("open error:  " + error.message);
  } else {
    console.log("Successfully opened " + path);
  }
});

从文件中读取数据

read()readSync()方法用于从打开的文件中读取数据。这些方法有许多参数,所以使用一个例子可能会使研究它们变得更容易(见清单 6-26 )。该示例从应用目录中的文件foo.txt读取数据(为了简单起见,省略了错误处理代码),从调用stat()开始。它必须这样做,因为稍后将需要该文件的大小。接下来,使用open()打开文件。获取文件描述符需要这一步。文件打开后,初始化一个数据缓冲区,这个缓冲区足够容纳整个文件。

清单 6-26 。使用read()从文件中读取

var fs = require("fs");
var path = __dirname + "/foo.txt";

fs.stat(path, function(error, stats) {
  fs.open(path, "r", function(error, fd) {
    var buffer = new Buffer(stats.size);

    fs.read(fd, buffer, 0, buffer.length, null, function(error, bytesRead, buffer) {
      var data = buffer.toString("utf8");

      console.log(data);
    });
  });
});

接下来是对read() 的实际调用。第一个参数是由open()提供的文件描述符。第二个是用来保存从文件中读取的数据的缓冲区。第三个是缓冲区内放置数据的偏移量(在本例中,偏移量为零,对应于缓冲区的开始)。第四个参数是要读取的字节数(在本例中,读取了文件的全部内容)。第五个是一个整数,指定文件中开始读取的位置。如果该值为null,则从当前文件位置开始读取,该位置被设置为文件最初打开时的开头,并在每次读取时更新。

如果这是对readSync()、的调用,它将返回从文件中成功读取的字节数。异步read()函数将一个回调函数作为它的最终参数,这个回调函数又将一个错误对象、读取的字节数和缓冲区作为参数。在回调函数中,原始数据缓冲区被转换为 UTF-8 字符串,然后打印到控制台。

image 注意这个例子在对read()的一次调用中读取整个文件。如果文件非常大,内存消耗可能是个问题。在这种情况下,您的应用应该初始化一个较小的缓冲区,并使用循环以较小的块读取文件。

readFile()readFileSync()方法

readFile()readFileSync()方法 提供了一种更简洁的从文件中读取数据的方法。以文件名作为参数,它们自动读取文件的全部内容,不需要文件描述符、缓冲区或其他麻烦。清单 6-27 显示了使用readFile()重写的来自清单 6-26 的代码。注意,readFile()的第二个参数指定数据应该作为 UTF-8 字符串返回。如果省略该参数或null,则返回原始缓冲区。

清单 6-27 。使用readFile()读取整个文件

var fs = require("fs");
var path = __dirname + "/foo.txt";

fs.readFile(path, "utf8", function(error, data) {
  if (error) {
    console.error("read error:  " + error.message);
  } else {
    console.log(data);
  }
});

将数据写入文件

将数据写入文件类似于读取数据。用于写入文件的方法有write()writeSync()。在清单 6-28 的例子中,使用write()方法 ,打开一个名为foo.txt的文件进行写操作。还创建了一个缓冲区来保存要写入文件的数据。接下来,write()用于将数据实际写入文件。write()的第一个参数是由open()提供的文件描述符。第二个是包含要写入的数据的缓冲区。第三和第四个参数对应于开始写入的缓冲区偏移量和要写入的字节数。第五个是一个整数,表示文件中开始写入的位置。如果该参数为null,则数据被写入当前文件位置,writeFileSync()返回成功写入文件的字节数。另一方面,write()接受一个带有三个参数的回调函数:异常对象、写入的字节数和缓冲区对象。

清单 6-28 。使用write()将数据写入文件

var fs = require("fs");
var path = __dirname + "/foo.txt";
var data = "Lorem ipsum dolor sit amet";

fs.open(path, "w", function(error, fd) {
  var buffer = new Buffer(data);

  fs.write(fd, buffer, 0, buffer.length, null, function(error, written, buffer) {
    if (error) {
      console.error("write error:  " + error.message);
    } else {
      console.log("Successfully wrote " + written + " bytes.");
    }
  });
});

writeFile()writeFileSync()方法

方法writeFile()writeFileSync()write()writeSync()提供快捷方式。清单 6-29 中的例子显示了writeFile()的用法,它将文件路径和要写入的数据作为它的前两个参数。通过可选的第三个参数,您可以指定编码(默认为 UTF-8)和其他选项。对writeFile() 的回调函数将一个错误对象作为其唯一的参数。

清单 6-29 。使用writeFile()写入文件

var fs = require("fs");
var path = __dirname + "/foo.txt";
var data = "Lorem ipsum dolor sit amet";

fs.writeFile(path, data, function(error) {
  if (error) {
    console.error("write error:  " + error.message);
  } else {
    console.log("Successfully wrote " + path);
  }
});

另外两种方法,appendFile()appendFileSync(),用于在不覆盖现有数据的情况下向现有文件追加数据。如果该文件尚不存在,则创建该文件。这些方法的用法和writeFile()writeFileSync()一模一样。

关闭文件

作为一个通用的编程经验,总是关闭你打开的任何东西。在 Node 应用中,使用close()closeSync()方法关闭文件。两者都将文件描述符作为参数。在异步版本中,回调函数应该作为第二个参数。回调函数的唯一参数用于指示可能的错误。在清单 6-30 的例子中,使用open()打开一个文件,然后使用close()立即关闭。

清单 6-30 。用open()close()打开然后关闭文件

var fs = require("fs");
var path = "/dev/null";

fs.open(path, "w+", function(error, fd) {
  if (error) {
    console.error("open error:  " + error.message);
  } else {
    fs.close(fd, function(error) {
      if (error) {
        console.error("close error:  " + error.message);
      }
    });
  }
});

image 注意没有必要关闭使用readFile()writeFile()等方法打开的文件。这些方法在内部处理一切。此外,它们没有提供文件描述符来传递给close()

重命名文件

要重命名文件,使用rename()renameSync()方法。这些方法的第一个参数是要重命名的文件的当前名称。正如您可能猜到的,第二个是文件的新名称。rename() 的回调函数只有一个参数,代表一个可能的异常。清单 6-31 中的例子将一个名为foo.txt的文件重命名为bar.txt

清单 6-31 。使用rename() 重命名文件

var fs = require("fs");
var oldPath = __dirname + "/foo.txt";
var newPath = __dirname + "/bar.txt";

fs.rename(oldPath, newPath, function(error) {
  if (error) {
    console.error("rename error:  " + error.message);
  } else {
    console.log("Successfully renamed the file!");
  }
});

删除文件

使用unlink()unlinkSync()方法删除文件,这两种方法将文件路径作为参数。异步版本也接受回调函数作为参数。回调函数只接受一个表示可能异常的参数。在清单 6-32 的示例中,展示了unlink()方法的使用,应用试图删除位于同一目录中的一个名为foo.txt的文件。

清单 6-32 。使用fs.unlink()方法删除文件

var fs = require("fs");
var path = __dirname + "/foo.txt";

fs.unlink(path, function(error) {
  if (error) {
    console.error("unlink error:  " + error.message);
  }
});

创建目录

使用mkdir()mkdirSync()方法创建新目录。mkdir()的第一个参数是要创建的目录路径。由于mkdir()只创建最后一级目录,mkdir()不能用于在一次调用中构建整个目录层次结构。这个方法还带有一个可选的第二个参数,它指定了目录的权限,默认为"0777"。异步版本还采用回调函数,该函数的唯一参数是一个可能的异常。清单 6-33 提供了一个使用mkdir()在应用的目录中创建目录树foo/bar的例子。

清单 6-33 。使用mkdir()创建几个目录

var fs = require("fs");
var path = __dirname + "/foo";

fs.mkdir(path, function(error) {
  if (error) {
    console.error("mkdir error:  " + error.message);
  } else {
    path += "/bar";
    fs.mkdir(path, function(error) {
      if (error) {
        console.error("mkdir error:  " + error.message);
      } else {
        console.log("Successfully built " + path);
      }
    });
  }
});

读取目录的内容

readdir()readdirSync()方法用于获取给定目录的内容。要读取的目录路径作为参数传入。readdirSync()方法返回包含目录中的文件和子目录的字符串数组,而readdir()将错误和相同的文件数组传递给回调函数。清单 6-34 显示了使用readdir()来读取进程当前工作目录的内容。注意readdir()readdirSync()提供的数组不包含目录"."".."

清单 6-34 。使用readdir() 读取目录的内容

var fs = require("fs");
var path = process.cwd();

fs.readdir(path, function(error, files) {
  files.forEach(function(file) {
    console.log(file);
  });
});

删除目录

您也可以使用rmdir()rmdirSync()方法删除目录。要移除的目录路径作为第一个参数传递给每个方法。rmdir()的第二个参数是一个回调函数,它将一个潜在的异常作为唯一的参数。清单 6-35 中的例子使用了rmdir()

清单 6-35 。使用rmdir() 删除目录

var fs = require("fs");
var path = __dirname + "/foo";

fs.rmdir(path, function(error) {
  if (error) {
    console.error("rmdir error:  " + error.message);
  }
});

如果试图删除非空目录,将会出现错误。删除这样一个目录需要更多的工作。清单 6-36 中的代码展示了一种实现在非空目录下工作的rmdir()函数的方法。在删除一个非空目录之前,我们首先要清空它。为此,删除目录中的所有文件,并递归删除所有子目录。

清单 6-36 。实现递归rmdir()功能

var fs = require("fs");
var path = __dirname + "/foo";

function rmdir(path) {
  if (fs.existsSync(path)) {
    fs.readdirSync(path).forEach(function(file) {
      var f = path + "/" + file;
      var stats = fs.statSync(f);

      if (stats.isDirectory()) {
        rmdir(f);
      } else {
        fs.unlinkSync(f);
      }
    });

    fs.rmdirSync(path);
  }
}

// now call the recursive rmdir() function
rmdir(path);

清单 6-36 中所有的函数调用都是同步的,这极大地简化了代码,使算法更容易理解。然而,同步函数不是 Node 方式。清单 6-37 展示了使用异步调用实现的相同功能。关于这个例子,首先要注意的是已经包含了async模块。因此,我们可以专注于实际的算法,因为async负责驯服异步函数调用。

清单 6-37 。递归的异步实现rmdir()

var async = require("async");
var fs = require("fs");
var path = __dirname + "/foo";

function rmdir(path, callback) {
  // first check if the path exists
  fs.exists(path, function(exists) {
    if (!exists) {
      return callback(new Error(path + " does not exist"));
    }

    fs.readdir(path, function(error, files) {
      if (error) {
        return callback(error);
      }

      // loop over the files returned by readdir()
      async.each(files, function(file, cb) {
        var f = path + "/" + file;

        fs.stat(f, function(error, stats) {
          if (error) {
            return cb(error);
          }

          if (stats.isDirectory()) {
            // recursively call rmdir() on the directory
            rmdir(f, cb);
          } else {
            // delete the file
            fs.unlink(f, cb);
          }
        });
      }, function(error) {
        if (error) {
          return callback(error);
        }

        // the directory is now empty, so delete it
        fs.rmdir(path, callback);
      });
    });
  });
}

// now call the recursive rmdir() function
rmdir(path, function(error) {
  if (error) {
    console.error("rmdir error:  " + error.message);
  } else {
    console.log("Successfully removed " + path);
  }
});

观看文件

fs模块让您的应用监视特定文件的修改。这是使用watch()方法完成的。watch()的第一个参数是要查看的文件的路径。可选的第二个参数是一个对象。如果存在的话,这个对象应该包含一个名为persistent的布尔属性。如果persistenttrue(默认),只要至少有一个文件被查看,应用就会继续运行。watch()的第三个参数是一个可选的回调函数,每次修改目标文件时都会触发这个函数。

如果存在,回调函数接受两个参数。第一个,观察事件的类型,将是changerename。回调函数的第二个参数是被监视文件的名称。

在清单 6-38 的例子中,显示了watch()方法 的运行,一个名为foo.txt的文件被持久地监视。也就是说,除非程序被终止或被监视的文件被删除,否则应用不会终止。每当foo.txt被修改时,回调函数就会触发并处理一个事件。如果文件被删除,将触发并处理一个rename事件,然后程序退出。

清单 6-38 。使用watch()方法观看文件

var fs = require("fs");
var path = __dirname + "/foo.txt";

fs.watch(path, {
  persistent: true
}, function(event, filename) {
  if (event === "rename") {
    console.log("The file was renamed/deleted.");
  } else if (event === "change") {
    console.log("The file was changed.");
  }
});

watch()方法也返回一个类型为fs.FSWatcher的对象。如果省略可选的回调函数,FSWatcher可以用来处理事件(通过第四章中介绍的熟悉的事件处理语法)。清单 6-39 显示了一个使用FSWatcher来处理文件监视事件的例子。另外,请注意close()方法,它用于指示FSWatcher停止查看有问题的文件。因此,此示例只处理一个文件更改事件。

清单 6-39 。使用可选的watch()语法查看文件

var fs = require("fs");
var path = __dirname + "/foo.txt";
var watcher;

watcher = fs.watch(path);
watcher.on("change", function(event, filename) {
  if (event === "rename") {
    console.log("The file was renamed/deleted.");
  } else if (event === "change") {
    console.log("The file was changed.");
  }

  watcher.close();
});

image 注意 Node 的官方文档将watch()列为不稳定,因为它依赖于底层的文件系统,并且没有跨平台实现 100%的一致性。例如,watch()回调函数的filename参数并非在所有系统中都可用。

摘要

本章介绍了 Node 的文件系统 API。在任何合法的应用中,有效地使用文件系统是一个关键因素。如果不能访问文件系统,应用就无法完成读取配置文件、创建输出文件和写入错误日志等任务。Node 中的许多文件系统任务都是使用fs模块来处理的,因此本章涵盖了fs提供的最重要的方法。但是,本章还没有介绍许多其他方法,这些方法允许您完成诸如更改文件所有权和权限之类的任务。读者可以参考完整的文档(http://nodejs.org/api/fs.html)以获得所有可能方法的列表。

七、流

Node 广泛使用流作为数据传输机制,例如,用于读写文件和通过网络套接字传输数据。第五章已经向你展示了标准流——stdinstdoutstderr。本章更详细地探讨了 Node 的 streams API,介绍了不同类型的流,它们是如何工作的,以及它们的各种应用。但是在开始之前,您应该知道,streams API 虽然是 Node 核心的重要部分,但在官方文档中被列为不稳定的。

什么是流?

流是一种在两点之间传输数据的机制。在行为方面,一个简单的花园软管提供了一个很好的类比。当你需要给你的草坪浇水时,你用一根软管把水源连接到洒水器上。当你打开水时,水通过软管流到喷水器。然后由洒水器来分配水。

流在概念上非常相似。例如,把给草坪浇水比作呼唤console.log()。在这种情况下,Node 应用充当水源。当调用console.log()时,水被打开,信息流经标准输出流。此时,Node 不再关心数据会发生什么。stdout流将数据传送到目的地。在这种情况下,目的地(喷洒器)几乎可以是任何东西——终端窗口、文件、另一个程序。

使用流

Node 支持几种类型的流,它们都继承自EventEmitter。每种类型的流行为略有不同。为了处理各种类型的流,首先导入stream核心模块(参见清单 7-1 )。

清单 7-1 。正在导入到stream模块

var Stream = require("stream");

导入stream模块会返回对Stream构造函数的引用。然后构造函数可以用来实例化新的流,如清单 7-2 所示。

清单 7-2 。使用stream模块创建新流

var Stream = require("stream");
var stream = new Stream();

可读流

可读流是数据的来源。一个典型的可读流是一个已经打开进行读取的文件。创建可读流的最简单方法是将流的readable属性分配给true,然后发出dataendcloseerror事件。以下部分探讨了如何使用这些事件。

data事件

您使用一个data事件来表示一个新的流数据片段(称为块)是可用的。对于发出的每个data事件,处理程序都被传递实际的数据块。许多应用将数据块作为二进制文件发出Buffer。这是官方文档规定的,尽管从技术上讲,任何数据都可以被发出。为了保持一致,建议data事件使用一个Buffer。清单 7-3 中的例子发出一个data事件,块被指定为Buffer

清单 7-3 。创建一个可读的流并发出一个data事件

var Stream = require("stream");
var stream = new Stream();

stream.readable = true;
stream.emit("data", new Buffer("foo"));

end事件

一旦一个流发送了它所有的数据,它应该发出一个单独的end事件。一旦发出了end事件,就不会再发出data事件。end事件不包括任何伴随数据。清单 7-4 中的例子创建了一个可读的流,它使用一个时间间隔在五秒钟内每秒发送一次数据。Date比较用来确定五秒钟过去了。此时,会发出一个end事件,间隔被清除。

清单 7-4 。一个可读的流,发出几个data事件,后跟一个end事件

var Stream = require("stream");
var stream = new Stream();
var duration = 5 * 1000; // 5 seconds
var end = Date.now() + duration;
var interval;

stream.readable = true;
interval = setInterval(function() {
  var now = Date.now();

  console.log("Emitting a data event");
  stream.emit("data", new Buffer("foo"));

  if (now >= end) {
    console.log("Emitting an end event");
    stream.emit("end");
    clearInterval(interval);
  }
}, 1000);

image 注意Date.now()方法返回当前日期和时间,指定为自 1970 年 1 月 1 日 00:00:00 UTC 以来经过的毫秒数。

close事件

close事件用于指示流数据的底层源已经关闭。例如,当文件描述符关闭时,从文件中读取数据的流会发出一个close事件。并非所有可读的流都会发出一个close事件。因此,如果您实现自己的可读流,则不需要发出此事件。如果存在的话,close事件不包含额外的参数。清单 7-5 中的显示了一个close事件的例子。

清单 7-5 。发出一个close事件

var Stream = require("stream");
var stream = new Stream();

stream.readable = true;
stream.emit("close");

error事件

error事件用于指示数据流出现问题。例如,如果后备文件不存在,从文件中读取的流会发出一个error事件。向error事件处理程序传递一个Error对象,该对象详细解释了问题。清单 7-6 中的例子发出了一个error事件。

清单 7-6 。发出一个error事件

var Stream = require("stream");
var stream = new Stream();

stream.readable = true;
stream.emit("error", new Error("Something went wrong!"));

控制可读流

要暂停可读流,请使用pause()方法。当处于暂停状态时,可读流停止发出data事件(第五章在stdin)的上下文中涉及pause())。清单 7-7 中显示了pause()的一个使用示例。

清单 7-7 。在stdin上调用pause()

process.stdin.pause();

默认情况下,stdin处于暂停状态(参见第五章)。为了从stdin或任何其他暂停的流中读取数据,首先使用resume()方法解除暂停。清单 7-8 中的例子显示了resume()的用法。调用resume()后,通过stdin到达的数据将导致data事件被发出。

清单 7-8 。在stdin上调用resume()

process.stdin.resume();

可写流

正如可读流是数据源一样,可写流是数据的目的地。要创建一个可写的流,将流的writable属性设置为true,并定义名为write()end()的方法。以下部分描述了这些方法,以及可写流的其他特性。

write()

方法负责将一大块数据写入数据流。数据块作为一个Buffer或字符串传递给write()。如果块是一个字符串,可选的第二个参数可用于指定编码。如果没有指定编码,默认情况下将使用 UTF-8。作为可选的最后一个参数,write()也接受一个回调函数。如果存在回调函数,则在成功写入数据块后调用该函数。

write()方法还返回一个布尔值,指示块是否被刷新到底层资源。如果返回true,则数据已经被刷新,流可以接受更多。如果返回了false,数据仍然在队列中等待写入。返回false还通知数据源停止发送数据,直到可写流发出一个drain事件。

清单 7-9 中的例子显示了对stdoutwrite()方法的调用。对write()的调用以字符串形式传递。因为文本是 UTF-8,所以省略了编码参数。回调函数因此成为第二个参数。

清单 7-9 。对stdoutwrite()方法的调用

var success = process.stdout.write("foo\n", function() {
  console.log("Data was successfully written!");
});
  console.log("success = " + success); 

在结果输出中(见清单 7-10 ,注意打印语句的执行顺序。对write()的调用完成,导致回调函数在事件循环中被调度。然而,执行从write()返回,然后继续,打印出success的值。此时,由于回调函数是事件循环中唯一剩下的项,因此它被执行,导致最终的打印语句运行。

清单 7-10 。运行清单 7-9 中代码的结果输出

$ node write.js
foo
success = true
Data was successfully written!

end()

用于表示数据流结束的end()方法可以在没有任何参数的情况下调用。但是,也可以使用与write()相同的参数调用它。对于只需要调用一次write(),然后再调用end()的情况,这是一个方便的快捷方式。

drain事件

write()返回false时,流的数据源应该不再发送数据。drain事件用于提醒源,处理完所有数据的可写流可以再次开始接收数据。drain事件不包括任何伴随数据。

finish事件

end()被调用并且不再有数据被写入时,流发出一个finish事件。它也没有提供额外的数据。与可能被多次发射的drain不同,finish可用于检测流的结束。

closeerror事件

像可读流一样,可写流也有行为方式相同的closeerror事件。

一个可写流的例子

现在让我们看一个非常简单的自定义可写流。当您希望在 Node 不支持的情况下使用流 API 时,自定义流非常有用。在清单 7-11 中的代码,改编自 James Halliday 的例子(https://github.com/substack/stream-handbook),流计算它处理的字节数。每次调用write()方法,总字节数都会增加缓冲区中的字节数。当调用end()时,它检查是否有缓冲区被传入。如果有,缓冲区被传递到write()。然后通过将writable属性设置为false并发出一个finish事件来关闭该流。最后,显示流处理的总字节数。

清单 7-11 。一个自定义的可写流,计算它处理的字节数

var Stream = require("stream");
var stream = new Stream();
var bytes = 0;

stream.writable = true;

stream.write = function(buffer) {
  bytes += buffer.length;
};

stream.end = function(buffer) {
  if (buffer) {
    stream.write(buffer);
  }

  stream.writable = false;
  stream.emit("finish");
  console.log(bytes + " bytes written");
};

管道

让我们回到花园软管的比喻。如果你的水管不够长,无法从水源到达你的草坪,那该怎么办?你可以用多根软管把它们连接起来。以类似的方式,数据流也可以链接在一起,以完成更大的任务。例如,假设我们有两个程序,程序 A 和程序 b。程序 A 的代码如清单 7-12 中的所示,它每秒钟生成一个随机的一位数整数(0–9)并将其输出到stdout。如清单 7-13 中的所示,程序 B 从stdin中读取任意数量的整数,并向stdout输出一个运行总和。现在你只需要一根软管来连接这两个程序。

清单 7-12 。一个随机的一位数整数生成器

setInterval(function() {
  var random = Math.floor(Math.random() * 10);

  console.log(random);
}, 1000);

清单 7-13 。对从stdin中读取的数字求和的应用

var sum = 0;

process.stdin.on("data", function(data) {
  var number = parseInt(data.toString(), 10);

  if (isFinite(number)) {
    sum += number;
  }

  console.log(sum);
});

process.stdin.resume();

image Math.random()返回一个介于 0(含)和 1(不含)之间的伪随机浮点数。将这个值乘以 10,如清单 7-12 所示,得到一个介于 0(含)和 10(不含)之间的随机浮点数。Math.floor()返回小于传入参数的最大整数。因此,清单 7-12 生成一个介于 0(含)和 9(含)之间的随机整数。

这些隐喻的软管被称为管道。如果您做过任何 shell 编程,您无疑会遇到管道。它们允许一个流程的输出流直接进入另一个流程的输入流。在 shell 编程中,管道操作符|实现管道。清单 7-14 显示了如何使用管道从命令行连接两个示例程序。在示例中,程序 A 的输出通过管道传输到程序 B 的输入。当您运行该命令时,您将看到一串数字,代表程序 B 中的sum变量的值,以每秒一个的速度打印到控制台。

清单 7-14 。从一个程序到另一个程序的管道输出

$ node Program-A.js | node Program-B.js

pipe()

在 Node 应用中,可以使用pipe()方法将流连接在一起,该方法有两个参数:一个作为数据目的地的必需的可写流和一个用于传入选项的可选对象。在清单 7-15 中的简单例子中,从stdinstdout创建了一个管道。当这个程序运行时,它监听用户的输入。当按下Enter键时,用户输入的任何数据都会回显到stdout

清单 7-15 。使用pipe()方法将stdin连接到stdout

process.stdin.pipe(process.stdout);

pipe()可选的第二个参数是一个可以保存单个布尔属性的对象,end。如果endtrue(默认行为),当源流发出其end事件时,目标流关闭。然而,如果end被设置为false,则目标流保持打开,因此可以将额外的数据写入目标流,而无需重新打开它。

image 注意当与文件或终端窗口相关联时,标准流的行为是同步的。例如,对stdout的写操作会阻塞程序的其余部分。然而,当它们通过管道传输时,它们的行为是异步的,就像任何其他流一样。此外,可写的标准流stdoutstderr不能被关闭,直到进程终止,不管end选项的值是多少。

回到可写流的例子

当清单 7-11 引入一个定制的可写流时,你看不到它做任何事情。既然您已经了解了管道,那么可以向这个示例流提供一些数据。清单 7-16 展示了这是如何做到的。最后三行特别值得注意。首先,创建一个具有相同源和目的地的管道。接下来,流发出一个data事件,随后是一个end事件。

清单 7-16 。从清单 7-11 中的向自定义可写流传输数据

var Stream = require("stream");
var stream = new Stream();
var bytes = 0;

stream.writable = true;

stream.write = function(buffer) {
  bytes += buffer.length;
};

stream.end = function(buffer) {
  if (buffer) {
    stream.write(buffer);
  }

  stream.writable = false;
  stream.emit("finish");
  console.log(bytes + " bytes written");
};

stream.pipe(stream);
stream.emit("data", new Buffer("foo"));
stream.emit("end");

这些事件触发可写流的write()end()方法。结果输出如清单 7-17 所示。

清单 7-17 。运行清单 7-16 中的代码得到的输出

$ node custom-stream.js
3 bytes written

文件流

在第六章的中,你看到了如何使用fs模块的readFile()writeFile()方法,以及它们的同步对应物来读写文件。这些方法非常方便,但是有可能导致应用中的内存问题。作为复习,以清单 7-18 中的readFile()为例,其中一个名为foo.txt的文件被异步读取。一旦读取完成,回调函数被调用,文件的内容被打印到控制台。

清单 7-18 。使用fs.readFile() 读取文件

var fs = require("fs");

fs.readFile(__dirname + "/foo.txt", function(error, data) {
  console.log(data);
});

为了理解这个问题,假设您的应用是一个每秒接收成百上千个连接的 web 服务器。还假设所有被服务的文件,不管什么原因,都非常大,并且在将数据返回给客户机之前,每次请求都使用readFile()将文件从磁盘读入内存。当调用readFile()时,它在调用其回调函数之前缓冲文件的全部内容。由于繁忙的服务器正在同时缓冲许多大文件,内存消耗可能会激增。

那么,如何避免所有这些肮脏的事情呢?事实证明,文件系统模块提供了以流的形式读写文件的方法。然而,这些方法createReadStream()createWriteStream()与大多数其他的fs方法不同,它们没有同步等价物。因此,第六章有意跳过了它们,直到读者对 streams 有了更彻底的介绍。

createReadStream()

顾名思义,createReadStream()用于将文件作为可读流打开。最简单的形式是,createReadStream()接受一个文件名作为参数,并返回一个类型为ReadStream的可读流。因为在fs模块中定义的ReadStream类型继承自标准可读流,所以它可以以同样的方式使用。

清单 7-19 中的例子显示createReadStream()正在读取一个文件的内容。data事件处理程序用于在数据通过流时打印出数据块。由于一个文件可以包含多个块,process.stdout.write()用于显示这些块。如果使用了console.log(),并且文件比一个块大,那么输出将包含原始文件中没有的额外的换行符。当end事件被接收时,console.log() 被用来简单地打印一个尾随的新行到输出。

清单 7-19 。使用fs.createReadStream()读取文件

var fs = require("fs");
var stream;

stream = fs.createReadStream(__dirname + "/foo.txt");

stream.on("data", function(data) {
  var chunk = data.toString();

  process.stdout.write(chunk);
});

stream.on("end", function() {
  console.log();
});()

ReadStreamopen事件

如前所述,ReadStream类型继承自基本可读流。这意味着ReadStream可以增强基本流的行为。?? 事件是一个很好的例子。当传递给createReadStream()的文件名被成功打开时,流发出一个open事件。用单个参数调用open事件的处理函数,该参数是流使用的文件描述符。通过获得文件描述符的句柄,createReadStream()可以与其他文件系统方法结合使用,这些文件系统方法使用诸如fstat()read()write()close()这样的文件描述符。在清单 7-20 的例子中,当调用open事件处理程序时,文件描述符被传递给fstat()以显示文件的统计数据。

清单 7-20 。使用来自open事件处理程序的文件描述符调用fstat()

var fs = require("fs");
var stream;

stream = fs.createReadStream(__dirname + "/foo.txt");

stream.on("open", function(fd) {
  fs.fstat(fd, function(error, stats) {
    if (error) {
      console.error("fstat error:  " + error.message);
    } else {
      console.log(stats);
    }
  });
});

options论证

createReadStream()接受的可选的第二个参数被命名为options。如果存在,这个参数是一个对象,它的属性允许你修改createReadStream()的行为。options参数支持的各种属性在表 7-1 中描述。

表 7-1。选项参数支持的属性描述

|

属性名称

|

描述

|
| --- | --- |
| fd | 现有的文件描述符。这默认为null。如果提供了一个值,就没有必要指定一个文件名作为createReadStream()的第一个参数。 |
| encoding | 指定流的字符编码。默认为null。表 5-1 描述了支持的编码类型。 |
| autoClose | 如果为true,当发出errorend事件时,文件自动关闭。如果false,文件不关闭。默认为true。 |
| flags | flags参数传递给open()。可用值列表见表 6-3。默认为"r"。 |
| mode | mode参数传递给了open()。默认为"0666"。 |
| start | 文件中开始读取的字节索引。默认值为零(文件的开头)。 |
| end | 文件中要停止读取的字节索引。只有在同时指定了start的情况下才能使用。默认为Infinity(文件的结尾)。 |

在清单 7-21 的例子中,利用了createReadStream()options参数,由open()返回的文件描述符被传递给createReadStream()。因为使用了一个现有的文件描述符,所以将null而不是文件名作为第一个参数传递给createReadStream()。该示例还使用了startend选项来跳过文件的第一个和最后一个字节。fstat()方法用于确定文件大小,以便适当设置end。该示例还包括许多错误检查。例如,如果使用目录而不是普通文件,代码将无法正常工作。

清单 7-21 。利用createReadStream()options自变量

var fs = require("fs");

fs.open(__dirname + "/foo.txt", "r", function(error, fd) {
  if (error) {
    return console.error("open error:  " + error.message);
  }

  fs.fstat(fd, function(error, stats) {
    var stream;
    var size;

    if (error) {
      return console.error("fstat error:  " + error.message);
    } else if (!stats.isFile()) {
      return console.error("files only please");
    } else if ((size = stats.size) < 3) {
      return console.error("file must be at least three bytes long");
    }

    stream = fs.createReadStream(null, {
      fd: fd,
      start: 1,
      end: size - 2
    });

    stream.on("data", function(data) {
      var chunk = data.toString();

      process.stdout.write(chunk);
    });

    stream.on("end", function() {
      console.log();
    });
  });
});

createWriteStream()

要创建与文件相关联的可写流,请使用createWriteStream()。与createReadStream()非常相似,createWriteStream()将一个文件路径作为其第一个参数,将一个可选的options对象作为其第二个参数,并返回一个WriteStream的实例,这是在fs模块中定义的一种数据类型,从基本的可写流类型继承而来。

清单 7-22 中的例子展示了数据如何通过管道传输到用createWriteStream()创建的可写文件流。在本例中,创建了一个可读的文件流,它从foo.txt中提取数据。然后,数据通过可写流传输到一个名为bar.txt的文件中。

清单 7-22 。将可读文件流管道传输到可写文件流

var fs = require("fs");
var readStream = fs.createReadStream(__dirname + "/foo.txt");
var writeStream = fs.createWriteStream(__dirname + "/bar.txt");

readStream.pipe(writeStream);

createWriteStream()options参数与createReadStream()使用的略有不同。表 7-2 描述了传递给createWriteStream()options对象可以包含的各种属性。

表 7-2 。createWriteStream()的 options 参数支持的属性

|

属性名称

|

描述

|
| --- | --- |
| fd | 现有的文件描述符。这默认为null。如果提供了一个值,就没有必要指定一个文件名作为createWriteStream()的第一个参数。 |
| flags | flags参数传递给open()。可用值列表见表 6-3。默认为"w"。 |
| encoding | 指定流的字符编码。默认为null。 |
| mode | mode参数传递给了open()。默认为"0666"。 |
| start | 文件中开始写入的字节索引。默认值为零(文件的开头)。 |

WriteStreamopen事件

WriteStream类型也实现了它自己的open事件,当目标文件被成功打开时,该事件被发出。open事件的处理程序接受文件描述符作为唯一的参数。清单 7-23 中显示了一个可写文件流的示例open事件处理程序。这个例子只是打印出代表打开文件的文件描述符的整数。

清单 7-23 。可写文件流的open事件处理程序

var fs = require("fs");
var stream = fs.createWriteStream(__dirname + "/foo.txt");

stream.on("open", function(fd) {
  console.log("File descriptor:  " + fd);
});

bytesWritten属性

WriteStream类型跟踪写入底层流的字节数。这个计数可以通过流的bytesWritten属性获得。清单 7-24 显示了如何使用bytesWritten。回到清单 7-22 中的例子,一个文件的内容使用一个可读的流读取,然后使用一个可写的流传输到另一个文件。然而,清单 7-24 包含了一个可写流的finish事件的处理程序。当发出finish事件时,这个处理程序被调用,并显示已经写入文件的字节数。

清单 7-24 。使用WriteStreambytesWritten属性

var fs = require("fs");
var readStream = fs.createReadStream(__dirname + "/foo.txt");
var writeStream = fs.createWriteStream(__dirname + "/bar.txt");

readStream.pipe(writeStream);

writeStream.on("finish", function() {
  console.log(writeStream.bytesWritten);
});

使用 zlib模块压缩

压缩是使用比原始表示更少的比特对信息进行编码的过程。压缩很有用,因为它允许使用更少的字节来存储或传输数据。当需要检索数据时,只需将其解压缩到原始状态。压缩广泛用于 web 服务器,通过减少网络上发送的字节数来缩短响应时间。但是,应该注意,压缩不是免费的,并且会增加响应时间。在归档数据时,压缩也通常用于减小文件大小。

Node 的核心zlib模块提供了使用流实现的压缩和解压缩 API。因为zlib模块是基于流的,所以它允许使用管道轻松压缩和解压缩数据。具体来说,zlib提供了使用 Gzip、Deflate 和通缩箭头进行压缩的绑定,以及使用 Gunzip、Inflate 和 Inflate 箭头进行解压缩的绑定。由于所有这三种方案都提供了相同的接口,因此在它们之间切换只是改变方法名的问题。

清单 7-25 中的例子使用 Gzip 压缩一个文件,从导入fszlib模块开始。接下来,zlib.creatGzip()方法用于创建 Gzip 压缩流。数据源input.txt用于创建可读的文件流。同样,创建一个可写文件流,将压缩数据输出到input.txt.gz。清单的最后一行通过读取未压缩的数据并将其通过 Gzip 压缩器来执行实际的压缩。然后,压缩数据通过管道传输到输出文件。

清单 7-25 。使用 Gzip 压缩来压缩文件

var fs = require("fs");
var zlib = require("zlib");
var gzip = zlib.createGzip();
var input = fs.createReadStream("input.txt");
var output = fs.createWriteStream("input.txt.gz");

input.pipe(gzip).pipe(output);

要测试压缩应用,只需创建input.txt,并在其中存储 100 个A字符(文件大小应为 100 字节)。接下来,运行 Gzip 压缩器。文件input.txt.gz应该以 24 字节的文件大小创建。当然,压缩文件的大小取决于几个因素。第一个因素是未压缩数据的大小。然而,压缩的有效性还取决于原始数据中重复模式的数量。我们的示例实现了出色的压缩,因为文件中的所有字符都是相同的。通过用一个B替换一个A,压缩文件的大小从 24 字节跳到 28 字节,即使源数据的大小相同。

压缩后的数据可能更小,但不是特别有用。为了处理压缩的数据,我们需要对其进行解压缩。清单 7-26 中的显示了一个 Gzip 解压缩应用的例子。zlib.createGunzip()方法创建一个执行解压缩的流。来自清单 7-25 的input.txt.gz文件被用作可读流,它通过管道传输到 Gunzip 流。解压缩后的数据通过管道传输到一个新的输出文件output.txt

清单 7-26 。使用 Gunzip 解压缩 Gzip 压缩文件

var fs = require("fs");
var zlib = require("zlib");
var gunzip = zlib.createGunzip();
var input = fs.createReadStream("input.txt.gz");
var output = fs.createWriteStream("output.txt");

input.pipe(gunzip).pipe(output);

放气/充气和放气箭头/充气箭头

Deflate 压缩方案可以用作 Gzip 的替代方案。DeflateRaw 方案类似于 Deflate,但是省略了 Deflate 中的 zlib 头。如前所述,这些方案的用法与 Gzip 相同。用于创建 Deflate 和 DeflateRaw 流的方法是zlib.createDeflate()zlib.createDeflateRaw()。类似地,zlib.createInflate()zlib.createInflateRaw()用于创建相应的解压缩流。一个额外的方法,zlib.createUnzip(),以同样的方式使用,它可以通过自动检测压缩方案来解压缩 Gzip 和 Deflate 压缩数据。

便利方法

前面提到的所有流类型都有相应的一步压缩/解压缩字符串或Buffer的便利方法。这些方法是gzip()gunzip()deflate()inflate()deflateRaw()inflateRaw()unzip()。它们都将一个Buffer或字符串作为第一个参数,将一个回调函数作为第二个参数。回调函数将错误条件作为第一个参数,将压缩/解压缩的结果(作为Buffer)作为第二个参数。清单 7-27 显示了如何使用deflate()unzip()来压缩和解压缩一个字符串。压缩和解压缩后,数据被打印到控制台。如果一切正常,存储在data变量中的相同字符串会显示出来。

清单 7-27 。使用方便的方法进行压缩和解压缩

var zlib = require("zlib");
var data = "This is some data to compress!";

zlib.deflate(data, function(error, compressed) {
  if (error) {
    return console.error("Could not compress data!");
  }

  zlib.unzip(compressed, function(error, decompressed) {
    if (error) {
      return console.error("Could not decompress data!");
    }

    console.log(decompressed.toString());
  });
});

摘要

本章介绍了数据流的概念。您已经看到了如何创建自己的流,以及如何使用现有的流 API,比如文件流。接下来的章节将展示网络编程环境中的流。您还将学习如何生成和控制子进程,这些子进程公开它们自己的标准流。

八、二进制数据

到目前为止,我们只研究了处理文本数据的应用。然而,为了节省空间和时间,应用通常必须处理二进制数据而不是文本。此外,一些应用数据,如图像和音频,本来就是二进制的。随着 web 应用越来越复杂,二进制数据的使用变得越来越普遍,甚至在浏览器中也是如此。因此,本章的重点转移到处理纯二进制数据的应用。它研究了什么是二进制数据,如何在 JavaScript 标准中处理二进制数据,以及 Node 特有的特性。

二进制数据概述

那么到底什么是二进制数据呢?如果你在想,“在计算机上,所有的数据都是二进制数据”,那你就对了。在最基本的层面上,计算机上的几乎所有数据都是以二进制形式存储的——由一系列 1 和 0 组成,代表二进制数和布尔逻辑值。然而,当术语“二进制数据”在编程语言的上下文中使用时,它指的是不包含附加抽象或结构的数据。例如,考虑清单 8-1 中显示的简单 JSON 对象。这个对象被认为是 JSON,因为它遵循特定的语法。为了使它成为有效的 JSON 对象,大括号、引号和冒号都是必需的。

清单 8-1 。一个简单的 JSON 对象

{"foo": "bar"}

您也可以将该示例简单地视为一系列字符。在这种情况下,大括号突然失去了语义上的重要性。大括号只是字符串中的两个字符,而不是标记 JSON 对象的开始和结束。用任何其他字符替换它们都没有区别。最终,您得到了一个包含 14 个字符的字符串,恰好符合 JSON 语法。但是,这些数据仍然被解释为文本,而不是真正的二进制数据。

在处理文本时,数据是用字符来定义的。例如,清单 8-1 中的字符串长度为 14 个字符。在处理二进制数据时,我们称之为字节,或八位字节。要将字节解释为文本,必须使用某种类型的字符编码。根据编码类型的不同,字符到字节可能有也可能没有一对一的映射。

image 一个八位字节是一段 8 位的数据。术语字节也常用来描述 8 位数据。然而,从历史上看,字节并不总是 8 位的。本书假定了字节的常见 8 位定义,并与八位字节互换使用。

Node 支持许多字符编码,但通常默认为 UTF-8。UTF-8 是一种可变宽度编码,与 ASCII 向后兼容,但它也可以表示所有 Unicode 字符。由于 UTF-8 编码是可变宽度的,一些字符用一个字节表示,但许多字符不是。更具体地说,单个 UTF-8 字符可能需要 1 到 4 个字节。

清单 8-2 显示了来自清单 8-1 的字符串,表示为二进制数据。由于二进制数据由一(1)和零(0)的长字符串组成,因此通常使用十六进制表示法显示,其中每个数字代表 4 位。因此,每对十六进制数字代表一个八位字节。在本例中,每个文本字符都被 UTF-8 编码为一个字节。因此,清单 8-2 包含 14 个字节。通过检查每个字节的值,您可以开始看到字符映射的模式。例如,字节值22出现了四次——引号位于清单 8-1 中。与"foo"中的"oo"相对应的值6f也连续出现两次。

清单 8-2 。清单 8-1 中的字符串表示为以十六进制编写的二进制数据

7b 22 66 6f 6f 22 3a 20 22 62 61 72 22 7d

在最后一个例子中,每个文本字符方便地映射到一个字节。然而,这可能并不总是发生。例如,考虑雪人 Unicode 字符(见清单 8-3 ),虽然很少使用,但它在 JavaScript 中是完全有效的字符串数据。清单 8-4 显示了雪人的二进制表示。请注意,在 UTF-8 编码中,需要 3 个字节来表示这一个字符。

清单 8-3 。雪人 Unicode 字符

unFig08-01.jpg

清单 8-4 。以二进制数据表示的雪人角色

e2 98 83

字节序

处理二进制数据时有时会出现的另一个问题是字节序。字节序是指给定机器在内存中存储数据的方式,在存储多字节数据(如整数和浮点数)时发挥作用。两种最常见的字符顺序是大端小端。大端机器首先存储数据项的最高有效字节。在这种情况下,“第一个”是指最低的内存地址。另一方面,小端机器将最低有效字节存储在最低内存地址中。为了说明大端存储和小端存储之间的区别,让我们来看看数字 1 在每种方案中是如何存储的。图 8-1 显示了编码为 32 位无符号整数的数字 1。为了方便起见,标记了最高有效字节和最低有效字节。由于数据长度为 32 位,因此需要 4 个字节来将数据存储在内存中。

9781430258605_Fig08-01.jpg

图 8-1 。数字 1,编码为 32 位无符号整数,以十六进制显示

图 8-2 显示了数据如何存储在大端机器上,而图 8-3 显示了以小端格式表示的相同数据。注意,包含01的字节从一种表示交换到另一种表示。标签0x000000000xFFFFFFFF表示存储空间的升序地址。

9781430258605_Fig08-02.jpg

图 8-2 。数字 1,因为它存储在大端机器的内存中

在研究了图 8-2 和图 8-3 之后,你就能明白为什么理解字符顺序很重要了。如果存储在一种字节序中的数字在另一种字节序中被解释,结果将是完全错误的。为了说明这一点,让我们回到数字 1 的例子。假设数据已经被写入使用小端存储的机器上的文件中。如果将文件移动到另一台机器上,并作为大端数据读取,会怎么样呢?事实证明,数字00 00 00 01会被解释为01 00 00 00。如果你算一下,结果是 2 24 ,或者 16,777,216——相差将近 1700 万!

9781430258605_Fig08-03.jpg

图 8-3 。数字 1,因为它存储在小端机器的内存中

确定字节顺序

os核心模块提供了一个方法endianness(),顾名思义,用于确定当前机器的字节序。endianness()方法不带参数,返回一个表示机器字节顺序的字符串。如果机器使用大端存储,endianness()返回字符串"BE"。相反,如果使用 little-endian,则返回"LE"。清单 8-5 中的例子调用endianness()并将结果打印到控制台。

清单 8-5 。使用os.endianness()方法确定机器的字节顺序

var os = require("os");

console.log(os.endianness());

类型化数组规范

在查看处理二进制数据的特定于 Node 的方式之前,让我们先看看 JavaScript 的标准二进制数据处理程序,称为类型化数组规范。这个名字来源于这样一个事实:与普通的 JavaScript 变量不同,二进制数据数组有一个特定的数据类型,它不会改变。因为类型化数组规范是 JavaScript 语言的一部分,所以本节中的内容适用于浏览器(如果支持的话)和 Node。大多数现代浏览器至少部分支持二进制数据,但哪些浏览器支持哪些特性是细节,不在本书讨论范围之内。

ArrayBuffer年代

JavaScript 的二进制数据 API 由两部分组成,一个缓冲区和一个视图。使用ArrayBuffer数据类型实现的缓冲区是一个保存字节数组的通用容器。因为ArrayBuffer是固定长度的结构,一旦创建,它们就不能调整大小。建议不要直接处理ArrayBuffer的内容。相反,创建一个视图来操作ArrayBuffer的内容(稍后将再次讨论视图的主题)。

通过调用ArrayBuffer()构造函数创建一个ArrayBuffer。构造函数接受一个参数,一个代表ArrayBuffer中字节数的整数。清单 8-6 中的例子创建了一个新的ArrayBuffer,它总共可以容纳 1024 个字节。

清单 8-6 。创建一个 1024 字节的ArrayBuffer

var buffer = new ArrayBuffer(1024);

使用现有的ArrayBuffer与使用普通数组非常相似。使用数组下标符号读写单个字节。然而,由于不能调整ArrayBuffer的大小,写入不存在的索引不会改变底层数据结构。相反,写操作不会发生,会无声地失败。在清单 8-7 的例子中,显示了一个超过ArrayBuffer结尾的写尝试,一个空的 4 字节ArrayBuffer被初始化。接下来,向每个字节写入一个值,包括超过ArrayBuffer结尾的写入。最后,将ArrayBuffer打印到控制台。

清单 8-7 。将值写入ArrayBuffer并打印结果

var foo = new ArrayBuffer(4);

foo[0] = 0;
foo[1] = 1;
foo[2] = 2;
foo[3] = 3;
// this assignment will fail silently
foo[4] = 4;
console.log(foo);

清单 8-8 显示了来自清单 8-7 的输出。请注意,虽然代码已经超出了缓冲区的末尾,但是写入的值并没有出现在输出中。失败的写入也没有生成任何异常。

清单 8-8 。运行清单 8-7 中代码的结果

$ node array-buffer-write.js
{ '0': 0,
  '1': 1,
  '2': 2,
  '3': 3,
  slice: [Function: slice],
  byteLength: 4 }

在前面的输出中,您可能已经注意到了byteLength属性,它以字节表示ArrayBuffer的大小。该值在ArrayBuffer创建时分配,不能更改。像普通数组的length属性一样,byteLength对于循环ArrayBuffer的内容很有用。清单 8-9 显示了byteLength属性如何在for循环中显示ArrayBuffer的内容。

清单 8-9 。使用byteLength属性在ArrayBuffer上循环

var foo = new ArrayBuffer(4);

foo[0] = 0;
foo[1] = 1;
foo[2] = 2;
foo[3] = 3;

for (var i = 0, len = foo.byteLength; i < len; i++) {
  console.log(foo[i]);
}

slice()

您可以使用slice()方法从现有文件中提取一个新的ArrayBufferslice()方法有两个参数,它们指定了要复制的范围的起始位置(包含)和结束位置(不包含)。结尾索引可以省略。如果未指定,切片跨度从起始索引到ArrayBuffer的结尾。这两个指数也可以是负数。负指数用于计算从ArrayBuffer末端而非开始的位置。清单 8-10 显示了几个从ArrayBuffer中截取相同的两个字节的例子。前两个示例使用显式的开始和结束索引,而第三个示例省略了结束索引。最后,第四个示例使用负起始索引创建一个切片。

清单 8-10 。使用slice()方法创建新的ArrayBuffer

var foo = new ArrayBuffer(4);

foo[0] = 0;
foo[1] = 1;
foo[2] = 2;
foo[3] = 3;

console.log(foo.slice(2, 4));
console.log(foo.slice(2, foo.byteLength));
console.log(foo.slice(2));
console.log(foo.slice(-2));
// returns [2, 3]

需要注意的是,slice()返回的新的ArrayBuffer只是原始数据的副本。因此,如果slice()返回的缓冲区被修改,原始数据不会改变(见清单 8-11 中的例子)。

清单 8-11 。使用slice()方法创建新的ArrayBuffer

var foo = new ArrayBuffer(4);
var bar;

foo[0] = 0;
foo[1] = 1;
foo[2] = 2;
foo[3] = 3;

// Create a copy of foo and modify it
bar = foo.slice(0);
bar[0] = 0xc;

console.log(foo);
console.log(bar);

在清单 8-11 中,一个名为fooArrayBuffer被创建并填充了数据。接下来,使用slice()foo的全部内容复制到bar中。然后将十六进制值0xc(二进制 12)写入bar中的第一个位置。最后,foobar都打印到控制台。清单 8-12 显示了结果输出。注意,除了第一个字节,这两个ArrayBuffer是相同的。写入bar的值0xc没有传播到foo

清单 8-12 。运行清单 8-11 中代码的输出

$ node array-buffer-slice.js
{ '0': 0,
  '1': 1,
  '2': 2,
  '3': 3,
  slice: [Function: slice],
  byteLength: 4 }
{ '0': 12,
  '1': 1,
  '2': 2,
  '3': 3,
  slice: [Function: slice],
  byteLength: 4 }

ArrayBuffer观点

直接处理字节数组既乏味又容易出错。通过给一个ArrayBuffer增加一个抽象层,视图给人一种更传统的数据类型的错觉。例如,您可以使用一个视图将数据显示为两个 4 字节整数的数组,每个都是 32 位长,总共是 64 位或 8 个字节,而不是使用 8 字节的ArrayBuffer。表 8-1 列出了各种类型的视图以及每个数组元素的字节大小。因此,在我们的示例场景中,我们需要一个Int32ArrayUint32Array视图,这取决于我们的应用需要有符号还是无符号数字。

表 8-1 。对 JavaScript 的各种数组缓冲视图的描述

|

视图类型

|

元素大小(字节)

|

描述

|
| --- | --- | --- |
| Int8Array | one | 8 位有符号整数数组。 |
| Uint8Array | one | 8 位无符号整数数组。 |
| Uint8ClampedArray | one | 8 位无符号整数数组。值被限制在 0–255 的范围内。 |
| Int16Array | Two | 16 位有符号整数数组。 |
| Uint16Array | Two | 16 位无符号整数数组。 |
| Int32Array | four | 32 位有符号整数数组。 |
| Uint32Array | four | 32 位无符号整数数组。 |
| Float32Array | four | 32 位 IEEE 浮点数数组。 |
| Float64Array | eight | 64 位 IEEE 浮点数的数组。 |

image 注意虽然Uint8ArrayUint8ClampedArray非常相似,但是在 0-255 范围之外的值的处理方式上有一个关键的区别。Uint8Array在确定一个值时只查看最低有效的 8 位。因此,255、256 和 257 分别被解释为 255、0 和 1。另一方面,Uint8ClampedArray将任何大于 255 的值解释为 255,将任何小于 0 的值解释为 0。也就是说 255,256,257 都解释为 255。

清单 8-13 中的例子展示了视图在实践中是如何使用的。在这种情况下,由两个 32 位无符号整数组成的视图是基于一个 8 字节的ArrayBuffer创建的。接下来,将两个整数写入视图,并显示视图。

清单 8-13 。使用Uint32Array视图的示例

var buf = new ArrayBuffer(8);
var view = new Uint32Array(buf);

view[0] = 100;
view[1] = 256;

console.log(view);

清单 8-14 显示了结果输出。它的前两行显示了写入视图的两个值,100 和 256。跟随数组值的是BYTES_PER_ELEMENT属性。这个只读属性包含在每种类型的视图中,表示每个数组元素中的原始字节数。跟在BYTES_PER_ELEMENT属性后面的是一个方法集合,我们将很快再次访问它。

清单 8-14 。运行清单 8-13 中的代码得到的输出

$ node array-buffer-view.js
{ '0': 100,
  '1': 256,
  BYTES_PER_ELEMENT: 4,
  get: [Function: get],
  set: [Function: set],
  slice: [Function: slice],
  subarray: [Function: subarray],
  buffer:
   { '0': 100,
     '1': 0,
     '2': 0,
     '3': 0,
     '4': 0,
     '5': 1,
     '6': 0,
     '7': 0,
     slice: [Function: slice],
     byteLength: 8 },
  length: 2,
  byteOffset: 0,
  byteLength: 8 }

注意,底层的ArrayBuffer也显示为buffer属性。检查ArrayBuffer中每个字节的值,您将看到它与视图中存储的值的对应关系。在本例中,字节 0 至 3 对应于值 100,字节 4 至 7 表示值 256。

image 注意提醒一下,256 相当于 2 8 ,意思是不能用单个字节表示。单个无符号字节最多可以容纳 255。所以 256 的十六进制表示是01 00

这带来了视图的另一个重要方面。与返回数据新副本的ArrayBuffer slice()方法不同,视图直接操作原始数据。因此修改视图的值会改变ArrayBuffer的内容,反之亦然。同样,拥有相同ArrayBuffer的两种观点可能会意外地(或有意地)改变彼此的价值观。在清单 8-15 中所示的例子中,一个 4 字节的ArrayBuffer由一个Uint32Array视图和一个Uint8Array视图共享,首先向Uint32Array写入 100,然后打印该值。然后Uint8Array将值 1 写入其第二个字节(实际上写入值 256)。然后再次打印来自Uint32Array的数据。

清单 8-15 。相互影响的视图

var buf = new ArrayBuffer(4);
var view1 = new Uint32Array(buf);
var view2 = new Uint8Array(buf);

// write to view1 and print the value
view1[0] = 100;
console.log("Uint32 = " + view1[0]);

// write to view2 and print view1's value
view2[1] = 1;
console.log("Uint32 = " + view1[0]);

清单 8-16 显示了来自清单 8-15 的输出。正如所料,第一个 print 语句显示值 100。但是,到第二个 print 语句出现时,该值已经增加到 356。在示例中,这种行为是意料之中的。然而,在更复杂的应用中,当创建同一数据的多个视图时,您必须小心谨慎。

清单 8-16 。运行清单 8-15 中代码的输出

$ node view-overwrite.js
Uint32 = 100
Uint32 = 356

关于视图大小的注释

视图的大小必须保证每个元素可以完全由ArrayBuffer中的数据组成。也就是说,视图只能从以字节为单位的长度是视图的BYTES_PER_ELEMENT属性的倍数的数据中构造。例如,一个 4 字节的ArrayBuffer可以用来构建一个保存单个整数的Int32Array视图。然而,同样的 4 字节缓冲区不能用于构建元素长度为 8 字节的Float64Array视图。

构造者信息

每种类型的视图都有四个构造函数。您已经看到的一种形式将一个ArrayBuffer作为它的第一个参数。这个构造函数也可以选择指定ArrayBuffer中的起始字节偏移量和视图的长度。字节偏移量默认为 0, 必须BYTES_PER_ELEMENT的倍数,否则抛出RangeError异常。如果省略,长度将试图消耗整个ArrayBuffer,从字节偏移量开始。这些参数,如果指定的话,允许视图基于ArrayBuffer的一部分,而不是全部。如果ArrayBuffer的长度不是视图BYTES_PER_ELEMENT的整数倍,这就特别有用。

在清单 8-17 的例子中,展示了如何从一个大小不是BYTES_PER_ELEMENT的整数倍的缓冲区构建一个视图,一个Int32Array视图建立在一个 5 字节的ArrayBuffer上。字节偏移量 0 表示视图应该从ArrayBuffer的第一个字节开始。同时,length 参数指定视图应该包含一个整数。没有这些论点,就不可能从这个ArrayBuffer中构建出这个观点。另外,请注意,该示例包含对buf[4]处的字节的写操作。由于视图只使用前四个字节,因此写入第五个字节不会改变视图中的数据。

清单 8-17 。基于ArrayBuffer的一部分构建视图

var buf = new ArrayBuffer(5);
var view = new Int32Array(buf, 0, 1);

view[0] = 256;
buf[4] = 5;
console.log(view[0]);

创建空视图

第二个构造函数用于创建一个预定义长度的空视图n。这种形式的构造函数还创建了一个新的足够大的ArrayBuffer来容纳n视图元素。例如,清单 8-18 中的代码创建了一个空的Float32Array视图,其中包含两个浮点数。在幕后,构造函数还创建了一个 8 字节的ArrayBuffer来保存浮动。在构建期间,ArrayBuffer中的所有字节都被初始化为 0。

清单 8-18 。创建一个空的Float32Array视图

var view = new Float32Array(2);

从数据值创建视图

第三种形式的构造函数接受用于填充视图数据的值数组。数组中的值被转换为适当的数据类型,然后存储在视图中。构造函数还创建了一个新的ArrayBuffer来保存这些值。清单 8-19 显示了一个创建用值 1、2 和 3 填充的Uint16Array视图的例子。

清单 8-19 。从包含三个值的数组创建一个Uint16Array视图

var view = new Uint16Array([1, 2, 3]);

从另一个视图创建视图

构造函数的第四个版本与第三个非常相似。唯一的区别是,这个版本接受另一个视图作为唯一的参数,而不是传入一个标准数组。新创建的视图还实例化了自己的后台ArrayBuffer——也就是说,底层数据是不共享的。清单 8-20 显示了这个版本的构造函数在实践中是如何使用的。在这个例子中,一个 4 字节的ArrayBuffer被用来创建一个包含四个数字的Int8Array视图。然后使用Int8Array视图创建一个新的Uint32Array视图。Uint32Array也包含四个数字,对应于Int8Array视图中的数据。然而,它的底层ArrayBuffer是 16 字节长,而不是 4 字节。当然,因为两个视图有不同的ArrayBuffer s,更新一个视图并不影响另一个。

清单 8-20 。从Int8Array视图创建Uint32Array视图

var buf = new ArrayBuffer(4);
var view1 = new Int8Array(buf);
var view2 = new Uint32Array(view1);

console.log(buf.byteLength);  // 4
console.log(view1.byteLength);  // 4
console.log(view2.byteLength);  // 16

查看属性

您已经看到视图的ArrayBuffer可以通过buffer属性访问,并且BYTES_PER_ELEMENT属性表示每个视图元素的字节数。视图还有两个属性,byteLengthlength,与数据大小有关,还有一个byteOffset属性,表示视图使用的缓冲区的第一个字节。

byteLength

byteLength 属性表示视图的数据大小,以字节为单位。这个值不一定等于底层ArrayBufferbyteLength属性。在这个例子中,如清单 8-21 所示,一个Int16Array视图是从一个 10 字节的ArrayBuffer构建的。但是,因为Int16Array构造函数指定它只包含两个整数,所以它的byteLength属性是 4,而ArrayBufferbyteLength是 10。

清单 8-21 。视图的不同byteLength及其ArrayBuffer

var buf = new ArrayBuffer(10);
var view = new Int16Array(buf, 0, 2);

console.log(buf.byteLength);
console.log(view.byteLength);

length

length属性的工作方式类似于标准数组,它指示视图中数据元素的数量。这个属性对于视图数据的循环很有用,如清单 8-22 所示。

清单 8-22 。使用length属性遍历视图数据

var view = new Int32Array([5, 10]);

for (var i = 0, len = view.length; i < len; i++) {
  console.log(view[i]);
}

byteOffset

属性指定了与视图使用的第一个字节相对应的ArrayBuffer的偏移量。该值始终为 0,除非将偏移量作为第二个参数传递给构造函数(参见清单 8-17 )。byteOffset可以与byteLength属性结合使用,以遍历底层ArrayBuffer的字节。在清单 8-23 的例子中,展示了如何使用byteOffsetbyteLength循环仅由视图使用的字节,源ArrayBuffer是 10 字节长,但是视图仅使用字节 4 到 7。

清单 8-23 。在ArrayBuffer中循环使用的字节子集

var buf = new ArrayBuffer(10);
var view = new Int16Array(buf, 4, 2);
var len = view.byteOffset + view.byteLength;

view[0] = 100;
view[1] = 256;

for (var i = view.byteOffset; i < len; i++) {
  console.log(buf[i]);
}

get()

get()方法 用于检索视图中给定索引处的数据值。然而,正如您已经看到的,同样的任务可以使用数组索引符号来完成,这需要更少的字符。如果你出于某种原因选择使用get(),清单 8-24 显示了它的用法示例。

清单 8-24 。使用视图get()方法

var view = new Uint8ClampedArray([5]);

console.log(view.get(0));
// could also use view[0]

set()

set() 用于给视图中的一个或多个值赋值。要分配单个值,将索引传递给 write,然后将要写入的值作为参数传递给set()(也可以使用数组索引符号来完成)。清单 8-25 中显示了一个将值 3.14 赋给第四个视图元素的例子。

清单 8-25 。使用set()分配单个值

var view = new Float64Array(4);

view.set(3, 3.14);
// could also use view[3] = 3.14

为了分配多个值,set()还接受数组和视图作为它的第一个参数。可选地使用这种形式的set()来提供第二个参数,该参数指定开始写入值的偏移量。如果不包括这个偏移量,set()从第一个索引开始写值。在清单 8-26 的中,set()被用来填充一个Int32Array的所有四个元素。

清单 8-26 。使用set()分配多个值

var view = new Int32Array(4);

view.set([1, 2, 3, 4], 0);

关于这个版本的set(),有两件重要的事情需要了解。首先,如果您试图写入视图的末尾,就会抛出一个异常。在清单 8-26 的例子中,如果第二个参数大于 0,就会超出四元素边界,导致错误。其次,注意因为set()接受一个视图作为它的第一个参数,参数的ArrayBuffer可能与调用对象共享。如果源和目标相同,Node 必须智能地复制数据,以便字节在有机会被复制之前不会被覆盖。清单 8-27 是两个Int8Array视图具有相同ArrayBuffer的一个例子。第二个视图view2也较小,表示较大视图view1的前半部分。当调用set()时,0 被分配给view1[1],1 被分配给view1[2]。由于view1[1]是源的一部分(在这个操作中也是目的地的一部分),您需要确保原始值在被覆盖之前被复制。

清单 8-27 。显示单个ArrayBufferset()中的共享位置

var buf = new ArrayBuffer(4);
var view1 = new Int8Array(buf);
var view2 = new Int8Array(buf, 0, 2);

view1[0] = 0;
view1[1] = 1;
view1[2] = 2;
view1[3] = 3;
view1.set(view2, 1);
console.log(view1.buffer);

根据规范,“设置这些值时,就好像首先将所有数据复制到一个不与任何数组重叠的临时缓冲区中,然后将临时缓冲区中的数据复制到当前数组中。”本质上,这意味着 Node 会为您处理一切。为了验证这一点,前面例子的结果输出显示在清单 8-28 中。请注意,字节 1 和 2 包含正确的值 0 和 1。

清单 8-28 。运行清单 8-27 中代码的输出

$ node view-set-overlap.js
{ '0': 0,
  '1': 0,
  '2': 1,
  '3': 3,
  slice: [Function: slice],
  byteLength: 4 }

subarray()

subarray() 返回依赖于同一ArrayBuffer的数据类型的新视图,它有两个参数。第一个参数指定新视图中引用的第一个索引。第二个是可选的,表示新视图中引用的最后一个索引。如果省略结束索引,新视图的范围将从起始索引到原始视图的结尾。任何一个索引都可以是负数,这意味着偏移量是从数据数组的末尾开始计算的。请注意,subarray()返回的新视图与原始视图具有相同的ArrayBuffer。清单 8-29 展示了如何使用subarray()创建几个相同的Uint8ClampedArray视图,组成另一个视图的子集。

清单 8-29 。使用subarray()从现有视图创建新视图

var view1 = new Uint8ClampedArray([1, 2, 3, 4, 5]);
var view2 = view1.subarray(3, view1.length);
var view3 = view1.subarray(3);
var view4 = view1.subarray(-2);

NodeBuffers

Node 提供了自己的Buffer数据类型来处理二进制数据。这是在 Node 中处理二进制数据的首选方法,因为它比类型化数组稍有效率。到目前为止,您已经遇到了许多处理Buffer对象的方法——例如,fs模块的read()write()方法。这一节详细探讨了Buffer的工作原理,包括它们与类型化数组规范的兼容性。

Buffer建造师

Buffer使用三个Buffer()构造函数中的一个来创建对象。Buffer构造函数是全局的,这意味着它不需要任何模块就可以被调用。一旦Buffer被创建,它就不能被调整大小。第一种形式的Buffer()构造函数创建一个给定字节数的空Buffer。清单 8-30 中的例子创建了一个空的 4 字节Buffer,也展示了Buffer中的单个字节可以使用数组下标符号来访问。

清单 8-30 。创建一个 4 字节的缓冲区并访问各个字节

var buf = new Buffer(4);

buf[0] = 0;
buf[1] = 1;

console.log(buf);

清单 8-31 显示了Buffer的字符串版本。Buffer中的前两个字节保存值0001,它们分别在代码中分配。请注意,最后两个字节也有值,尽管它们从未被赋值。这些实际上是程序运行时已经在内存中的值(如果您运行这段代码,您看到的值可能会有所不同),表明Buffer()构造函数没有将其保留的内存初始化为 0。这样做是有意的——在请求大量内存时节省时间(回想一下ArrayBuffer构造函数将其缓冲区初始化为 0)。由于 web 浏览器中经常使用,不初始化内存可能会有安全隐患——您可能不希望任意网站读取您计算机内存中的内容。由于Buffer类型是特定于 Node 的,所以它不存在同样的安全风险。

清单 8-31 。运行清单 8-30 中代码的输出结果

$ node buffer-constructor-1.js
<Buffer 00 01 05 02>

第二种形式的Buffer()构造函数接受一个字节数组作为它唯一的参数。产生的Buffer用数组中存储的值填充。在清单 8-32 中显示了这种形式的构造函数的一个例子。

清单 8-32 。从八位字节数组创建一个Buffer

var buf = new Buffer([1, 2, 3, 4]);

构造函数的最终版本用于从字符串数据创建一个Buffer。清单 8-33 中的代码展示了如何从字符串"foo"创建一个Buffer

清单 8-33 。从字符串创建一个Buffer

var buf = new Buffer("foo");

在本章的前面,您已经了解到为了将二进制数据转换为文本,必须指定字符编码。当一个字符串作为第一个参数传递给Buffer()时,第二个可选参数可以用来指定编码类型。在清单 8-33 中,没有明确设置编码,所以默认使用 UTF-8。表 8-2 分解了 Node 支持的各种字符编码。(敏锐的读者可能会从第五章的中认出这个表格。然而,值得在书中重复这一点的信息。)

表 8-2 。Node 支持的各种字符串编码类型

|

编码类型

|

描述

|
| --- | --- |
| utf8 | 多字节编码的 Unicode 字符。许多网页使用 UTF-8 编码来表示 Node 中的字符串数据。 |
| ascii | 7 位美国信息交换标准码(ASCII)编码。 |
| utf16le | 小端编码的 Unicode 字符。每个字符是 2 或 4 个字节。 |
| ucs2 | 这只是utf16le编码的别名。 |
| base64 | Base64 字符串编码。Base64 通常用于 URL 编码、电子邮件和类似的应用。 |
| binary | 允许仅使用每个字符的前 8 位将二进制数据编码为字符串。由于不赞成使用此选项,而支持使用Buffer对象,因此在 Node 的未来版本中将会删除它。 |
| hex | 将每个字节编码为两个十六进制字符。 |

字符串化方法

s 可以通过两种方式进行字符串化。第一个使用了toString()方法,它试图将Buffer的内容解释为字符串数据。toString()方法接受三个参数,都是可选的。它们指定了字符编码和从Buffer到 stringify 的开始和结束索引。如果未指定,整个Buffer将使用 UTF-8 编码进行字符串化。清单 8-34 中的例子使用toString()给出了一个完整的Buffer

清单 8-34 。使用Buffer.toString()方法

var buf = new Buffer("foo");

console.log(buf.toString());

第二个字符串化方法toJSON()Buffer数据作为 JSON 字节数组返回。通过在Buffer对象上调用JSON.stringify()可以得到类似的结果。清单 8-35 显示了一个toJSON()方法的例子。

清单 8-35 。使用Buffer.toJSON()方法

var buf = new Buffer("foo");

console.log(buf.toJSON());
console.log(JSON.stringify(buf));

Buffer.isEncoding()

isEncoding()方法清单 8-36 显示了isEncoding()的两个例子。第一个测试字符串"utf8"并显示true。然而,第二个会打印出false,因为"foo"不是有效的字符编码。

清单 8-36Buffer.isEncoding()类方法的两个例子

console.log(Buffer.isEncoding("utf8"));
console.log(Buffer.isEncoding("foo"));

Buffer.isBuffer()

类方法isBuffer() 用于判断一条数据是否为Buffer对象。它的使用方式与Array.isArray()法相同。清单 8-37 显示了一个isBuffer()的使用示例。这个例子打印了true,因为buf变量实际上是一个Buffer

清单 8-37Buffer.isBuffer()类方法

var buf = new Buffer(1);

console.log(Buffer.isBuffer(buf));

Buffer.byteLength()length

byteLength()类方法 用于计算给定字符串中的字节数。此方法还接受可选的第二个参数来指定字符串的编码类型。这个方法对于计算字节长度很有用,不需要实例化一个Buffer实例。但是,如果您已经构建了一个Buffer,那么它的length属性也有同样的作用。在清单 8-38 的例子中,显示了byteLength()lengthbyteLength()用于计算 UTF-8 编码的字符串"foo"的字节长度。接下来,从同一个字符串中构造一个实际的Buffer。然后使用Bufferlength属性检查字节长度。

清单 8-38Buffer.byteLength()length属性

var byteLength = Buffer.byteLength("foo");
var length = (new Buffer("foo")).length;

console.log(byteLength);
console.log(length);

fill()

Buffer写入数据有多种方式。合适的方法取决于几个因素,包括数据类型及其字节顺序。最简单的方法是fill(),它将相同的值写入全部或部分Buffer,它有三个参数——要写入的值、开始填充的可选偏移量和停止填充的可选偏移量。与其他写入方法一样,起始偏移默认为 0,结束偏移默认为Buffer的结束。由于默认情况下Buffer没有设置为零,fill()对于将Buffer初始化为一个值是有用的。清单 8-39 中的例子显示了如何将Buffer中的所有内存清零。

清单 8-39 。使用fill()Buffer中的内存清零

var buf = new Buffer(1024);

buf.fill(0);

write()

要将一个字符串写入一个Buffer,使用write()方法 。它接受以下四个参数。

  • 要写入的字符串。
  • 开始写入的偏移量。这是可选的,默认为索引 0。
  • 要写入的字节数。如果未指定,则写入整个字符串。但是,如果Buffer缺少容纳整个字符串的空间,它就会被截断。
  • 字符串的字符编码。如果省略,则默认为 UTF-8。

清单 8-40 中的例子用字符串"foo"的三个副本填充一个 9 字节的Buffer。由于第一次写入在Buffer的开始处开始,因此不需要偏移。但是,第二次和第三次写入需要一个偏移值。在第三个示例中,包含了字符串长度,尽管这不是必需的。

清单 8-40 。使用write()对同一个Buffer进行多次写入

var buf = new Buffer(9);
var data = "foo";

buf.write(data);
buf.write(data, 3);
buf.write(data, 6, data.length);

写入数字数据

有一组方法用于将数字数据 写入Buffer,每种方法都用于写入特定类型的数字。这类似于各种类型化的数组视图,每个视图存储不同类型的数据。表 8-3 列出了用于书写数字的方法。

表 8-3 。用于将数字数据写入缓冲区的方法集合

|

方法名称

|

描述

|
| --- | --- |
| writeUInt8() | 写入一个无符号 8 位整数。 |
| writeInt8() | 写入一个有符号的 8 位整数。 |
| writeUInt16LE() | 使用 little-endian 格式写入一个无符号 16 位整数。 |
| writeUInt16BE() | 使用 big-endian 格式写入一个无符号的 16 位整数。 |
| writeInt16LE() | 使用 little-endian 格式写入有符号的 16 位整数。 |
| writeInt16BE() | 使用 big-endian 格式写入有符号的 16 位整数。 |
| writeUInt32LE() | 使用 little-endian 格式写入一个无符号 32 位整数。 |
| writeUInt32BE() | 使用 big-endian 格式写入一个无符号 32 位整数。 |
| writeInt32LE() | 使用 little-endian 格式写入有符号的 32 位整数。 |
| writeInt32BE() | 使用 big-endian 格式写入有符号的 32 位整数。 |
| writeFloatLE() | 使用 little-endian 格式写入一个 32 位浮点数。 |
| writeFloatBE() | 使用 big-endian 格式写入 32 位浮点数。 |
| writeDoubleLE() | 使用 little-endian 格式写入 64 位浮点数。 |
| writeDoubleBE() | 使用 big-endian 格式写入 64 位浮点数。 |

表 8-3 中的所有方法都有三个参数——要写入的数据,Buffer中写入数据的偏移量,以及一个关闭验证检查的可选标志。如果验证标志被设置为false(默认),如果值太大或者数据溢出Buffer,则抛出异常。如果该标志被设置为true,大值将被截断,溢出写操作会自动失败。在使用清单 8-41 中的writeDoubleLE()的例子中,值 3.14 被写入缓冲区的前 8 个字节,没有验证检查。

清单 8-41 。使用writeDoubleLE()

var buf = new Buffer(16);

buf.writeDoubleLE(3.14, 0, true);

读取数字数据

Buffer中读取数值数据 ,和写一样,也需要一组方法。表 8-4 列出了用于读取数据的各种方法。注意与表 8-3 中的写入方法一一对应。

表 8-4 。用于从缓冲区读取数字数据的方法集合

|

方法名称

|

描述

|
| --- | --- |
| readUInt8() | 读取一个无符号 8 位整数。 |
| readInt8() | 读取一个带符号的 8 位整数。 |
| readUInt16LE() | 使用 little-endian 格式读取无符号 16 位整数。 |
| readUInt16BE() | 使用 big-endian 格式读取无符号 16 位整数。 |
| readInt16LE() | 使用 little-endian 格式读取有符号的 16 位整数。 |
| readInt16BE() | 使用 big-endian 格式读取有符号的 16 位整数。 |
| readUInt32LE() | 使用 little-endian 格式读取一个无符号 32 位整数。 |
| readUInt32BE() | 使用 big-endian 格式读取一个无符号 32 位整数。 |
| readInt32LE() | 使用 little-endian 格式读取有符号的 32 位整数。 |
| readInt32BE() | 使用 big-endian 格式读取有符号的 32 位整数。 |
| readFloatLE() | 使用 little-endian 格式读取 32 位浮点数。 |
| readFloatBE() | 使用 big-endian 格式读取 32 位浮点数。 |
| readDoubleLE() | 使用 little-endian 格式读取 64 位浮点数。 |
| readDoubleBE() | 使用 big-endian 格式读取 64 位浮点数。 |

所有数字读取方法都有两个参数。第一个是从Buffer中读取数据的偏移量。可选的第二个参数用于禁用验证检查。如果是false(默认值),当偏移量超过Buffer大小时抛出异常。如果标志为true,则不进行验证,返回的数据可能无效。清单 8-42 展示了一个 64 位浮点数如何被写入一个缓冲区,然后使用readDoubleLE()读回。

清单 8-42 。写入和读取数字数据

var buf = new Buffer(8);
var value;

buf.writeDoubleLE(3.14, 0);
value = buf.readDoubleLE(0);

slice()

slice()方法 返回一个新的Buffer,它与原来的Buffer共享内存。换句话说,对新的Buffer的更新会影响原来的,反之亦然。slice()方法有两个可选参数,代表切片的开始和结束索引。索引也可以是负的,这意味着它们相对于Buffer的结束。清单 8-43 显示了如何使用slice()提取一个 4 字节Buffer的前半部分。

清单 8-43 。使用slice()创建一个新的Buffer

var buf1 = new Buffer(4);
var buf2 = buf1.slice(0, 2);

copy()

copy()方法 用于将数据从一个Buffer复制到另一个Buffercopy()的第一个参数是目的地Buffer。第二个(如果存在)表示要复制的目标中的起始索引。第三和第四个参数,如果存在的话,是要复制的源Buffer中的开始和结束索引。将一个Buffer的全部内容复制到另一个的例子如清单 8-44 所示。

清单 8-44 。使用copy()将一个Buffer的内容复制到另一个

var buf1 = new Buffer([1, 2, 3, 4]);
var buf2 = new Buffer(4);

buf1.copy(buf2, 0, 0, buf1.length);

Buffer.concat()

concat() 类方法允许将多个Buffer连接成一个更大的Bufferconcat()的第一个参数是要连接的Buffer对象的数组。如果没有提供Buffer,则concat()返回零长度Buffer。如果提供了单个Buffer,则返回对该Buffer的引用。如果提供了多个Buffer,则创建一个新的Buffer。清单 8-45 提供了一个连接两个Buffer对象的例子。

清单 8-45 。连接两个Buffer对象

var buf1 = new Buffer([1, 2]);
var buf2 = new Buffer([3, 4]);
var buf = Buffer.concat([buf1, buf2]);

console.log(buf);

类型化数组兼容性

与类型化数组视图兼容。当从一个Buffer构建一个视图时,Buffer的内容被克隆到一个新的ArrayBuffer中。克隆的ArrayBuffer不与原Buffer共享内存。在清单 8-46 的例子中,它从一个缓冲区创建一个视图,一个 4 字节的Buffer被克隆到一个 16 字节的ArrayBuffer中,后者支持一个Uint32Array视图。注意,在创建视图之前,Buffer被初始化为全 0。如果不这样做,视图将包含任意数据。

清单 8-46 。从Buffer创建视图

var buf = new Buffer(4);
var view;

buf.fill(0);
view = new Uint32Array(buf);
console.log(buf);
console.log(view);

同样值得指出的是,虽然视图可以从一个Buffer构造,但是ArrayBuffer s 不能。一艘Buffer也不能由一艘ArrayBuffer建造。可以从视图中构造一个Buffer,但是在这样做的时候要小心,因为视图很可能包含不能很好传输的数据。在说明这一点的清单 8-47 中的简单示例中,当从Uint32Array视图移动到Buffer视图时,整数 257 变成字节值 1。

*清单 8-47 。从视图构建Buffer时数据丢失

var view = new Uint32Array([257]);
var buf = new Buffer(view);

console.log(buf);

摘要

这一章涵盖了许多材料。从二进制数据的概述开始,您接触到了包括高级字符编码和字符顺序的主题。从那以后,本章进入了类型化数组规范。希望你觉得这份材料有用。毕竟,它是 JavaScript 语言的一部分,可以在浏览器和 Node 中使用。在介绍了ArrayBuffer和视图之后,本章继续介绍 Node 的Buffer数据类型,最后,介绍了Buffer类型如何处理类型化数组。*

九、执行代码

本章关注的是不可信代码的执行。在这种情况下,“不可信”指的是不属于您的应用或导入模块的一部分,但仍然可以执行的代码。本章特别关注运行不可信代码的两个主要用例。第一种涉及通过产生子进程来执行应用和脚本。这个用例允许 Node 应用表现得像一个 shell 脚本,编排多个实用程序来实现一个更大的目标。第二个用例涉及 JavaScript 源代码的执行。虽然这种场景不像流程派生那样常见,但它在 Node 核心中受到支持,应该理解为eval()的替代方案。

child_process模块

用于产生子进程并与其交互的child_process核心模块 提供了几种运行这些进程的方法,每种方法提供不同级别的控制和实现复杂性。本节解释了每种方法的工作原理,并指出了每种方法的优缺点。

exec()

exec()方法 可能是启动子进程最简单的方法。exec()方法将命令(例如,从命令行发出的命令)作为其第一个参数。当exec()被调用时,一个新的 shell——在 Windows 中为cmd.exe,否则为/bin/sh——被启动并用于执行命令字符串。额外的配置选项可以通过可选的第二个参数传递给exec()。该参数(如果存在)应该是包含表 9-1 中所示的一个或多个属性的对象。

表 9-1 。exec()支持的配置选项

|

财产

|

描述

|
| --- | --- |
| cwd | 用于设置子进程工作目录的值。 |
| env | env应该是一个对象,它的键值对指定子进程的环境。这个对象相当于子对象中的process.env。如果未指定,子进程将从父进程继承其环境。 |
| encoding | 子进程的stdoutstderr流使用的字符编码。默认为utf8 (UTF-8) |
| timeout | 用于在一定时间后终止子进程的属性。如果该值大于 0,进程将在timeout毫秒后终止。否则,该过程将无限期运行。该属性默认为 0。 |
| maxBuffer | 子进程的stdoutstderr流中可以缓冲的最大数据量。默认为 200 KB。如果任何一个流超过了这个值,子进程就会被终止。 |
| killSignal | 用于终止子进程的信号。例如,如果发生超时或者超过了最大缓冲区大小,它将被发送到子进程。默认为SIGTERM。 |

exec()的最后一个参数是子进程终止后调用的回调函数。这个函数通过三个参数调用。按照 Node 约定,第一个参数是任何错误条件。论成功,这个论点就是null。如果存在错误,参数就是Error的一个实例。第二个和第三个参数是来自子进程的缓冲的stdoutstderr数据。因为回调是在子进程终止后调用的,所以stdoutstderr参数不是流,而是包含子进程执行时通过流传递的数据的字符串。stdoutstderr各可保存总共maxBuffer字节。清单 9-1 显示了一个使用exec()的例子,它执行ls命令(Windows 用户可以替换为dir)来显示根目录的内容(注意这个例子没有使用配置选项参数)。清单 9-2 显示了一个等价的例子,一个传递配置选项的例子。在第二个示例中,要列出的目录不再在实际的命令字符串中指定。但是,cwd选项用于将工作目录设置为根目录。尽管清单 9-1 和 9-2 的输出应该是相同的,但是它们将取决于您本地机器的内容。

清单 9-1 。使用exec()显示过程的输出

var cp = require("child_process");

cp.exec("ls -l /", function(error, stdout, stderr) {
  if (error) {
    console.error(error.toString());
  } else if (stderr !== "") {
    console.error(stderr);
  } else {
    console.log(stdout);
  }
});

清单 9-2 。相当于清单 9-1 中的显示(带有配置选项)

var cp = require("child_process");

cp.exec("ls -l", {
  cwd: "/"
}, function(error, stdout, stderr) {
  if (error) {
    console.error(error.toString());
  } else if (stderr !== "") {
    console.error(stderr);
  } else {
    console.log(stdout);
  }
});

execFile()

execFile()方法 与exec()类似,有两点细微区别。第一个是execFile()不会产生新的壳。相反,execFile()直接执行传递给它的文件,使得execFile()exec()消耗的资源稍微少一些。第二个区别是execFile()的第一个参数是要执行的文件的名称,没有其他参数。清单 9-3 显示了如何调用ls命令来显示当前工作目录的内容。

清单 9-3 。使用execFile()执行没有附加参数的文件

var cp = require("child_process");

cp.execFile("ls", function(error, stdout, stderr) {
  if (error) {
    console.error(error.toString());
  } else if (stderr !== "") {
    console.error(stderr);
  } else {
    console.log(stdout);
  }
});

image 警告因为execFile()没有产生新的 shell,Windows 用户无法让它发出dir之类的命令。在 Windows 中,dir是 shell 的内置功能。另外,execFile()不能用于运行.cmd.bat文件,它们依赖于 shell。然而,您可以使用execFile()来运行.exe文件。

如果需要向命令传递额外的参数,可以指定一个参数数组作为execFile()的第二个参数。清单 9-4 展示了这是如何完成的。在本例中,再次执行ls命令。然而,这次还传入了-l标志和/来显示根目录的内容。

清单 9-4 。向由execFile()执行的文件传递参数

var cp = require("child_process");

cp.execFile("ls", ["-l", "/"], function(error, stdout, stderr) {
  if (error) {
    console.error(error.toString());
  } else if (stderr !== "") {
    console.error(stderr);
  } else {
    console.log(stdout);
  }
});

第三个参数——或者第二个,如果没有命令参数传入的话——是可选的配置对象。由于execFile()支持与exec()相同的选项,可以从表 9-1 中获得对所支持属性的解释。清单 9-5 中的例子使用了配置对象的cwd选项,在语义上等同于清单 9-4 中的代码。

清单 9-5 。相当于清单 9-4 中的,它利用了cwd选项

var cp = require("child_process");

cp.execFile("ls", ["-l"], {
  cwd: "/"
}, function(error, stdout, stderr) {
  if (error) {
    console.error(error.toString());
  } else if (stderr !== "") {
    console.error(stderr);
  } else {
    console.log(stdout);
  }
});

image 注意在幕后,exec()调用execFile(),用你操作系统的 shell 作为文件参数。然后,要执行的命令被传递给数组参数中的execFile()

spawn()

exec()execFile()方法很简单,当您只需要发出一个命令并捕获它的输出时,它们工作得很好。然而,一些应用需要更复杂的交互。这就是spawn()发挥作用的地方,它是 Node 为子进程提供的最强大、最灵活的抽象(从开发人员的角度来看,它也需要做最多的工作)。spawn()也被execFile()——引申为exec()——以及fork()(本章后面会讲到)。

spawn()最多接受三个参数。第一个是要执行的命令,它应该只是可执行文件的路径。它不应该包含命令的任何参数。若要向命令传递参数,请使用可选的第二个参数。如果存在,它应该是要传递给命令的值的数组。第三个也是最后一个参数,也是可选的,用于将选项传递给spawn()本身。表 9-2 列出了spawn()支持的选项。

表 9-2 。spawn()支持的选项列表

|

财产

|

描述

|
| --- | --- |
| cwd | 用于设置子进程工作目录的值。 |
| env | env应该是一个对象,它的键值对指定子进程的环境。这个对象相当于子对象中的process.env。如果未指定,子进程将从父进程继承其环境。 |
| stdio | 用于配置子进程的标准流的数组或字符串。这一论点将在下文阐述。 |
| detached | 一个布尔值,指定子进程是否将成为进程组领导。如果true,即使父终止,子也可以继续执行。这默认为false。 |
| uid | 这个数字代表运行进程的用户身份,允许程序作为另一个用户运行并临时提升特权。默认为null,使子进程作为当前用户运行。 |
| gid | 用于设置进程组标识的数字。默认为null,根据当前用户设置。 |

stdio选项

stdio选项 用于配置子进程的stdinstdoutstderr流。该选项可以是一个三项数组或以下字符串之一:"ignore""pipe""inherit"。在解释字符串参数之前,必须先理解数组形式。如果stdio是一个数组,第一个元素为子进程的stdin流设置文件描述符。类似地,第二个和第三个元素分别为孩子的stdoutstderr流设置文件描述符。表 9-3 列举了每个数组元素的可能值。

表 9-3 。stdio 数组条目的可能值

|

价值

|

描述

|
| --- | --- |
| "pipe" | 在子进程和父进程之间创建管道。spawn()返回一个ChildProcess对象(稍后将详细解释)。父对象可以通过ChildProcess对象的stdinstdoutstderr流访问子对象的标准流。 |
| "ipc" | 在子进程和父进程之间创建一个进程间通信(IPC)通道,用于传递消息和文件描述符。一个子进程最多可以有一个 IPC 文件描述符。(IPC 通道将在后面的章节中详细介绍。) |
| "ignore" | 导致子级的相应流被忽略。 |
| 流对象 | 可以与子进程共享的可读或可写的流。流的基础文件描述符在子进程中是重复的。例如,父进程可以建立一个子进程来从文件流中读取命令。 |
| 正整数 | 对应于与子进程共享的父进程中当前打开的文件描述符。 |
| nullundefined | 分别对stdinstdoutstderr使用默认值 0、1 和 2。 |

如果stdio是字符串,可以是"ignore""pipe""inherit"。这些值是某些阵列配置的简写。各值的含义如表 9-4 所示。

表 9-4 。每个 stdio 字符串值的翻译

|

线

|

价值

|
| --- | --- |
| "ignore" | ["ignore", "ignore", "ignore"] |
| "pipe" | ["pipe", "pipe", "pipe"] |
| "inherit" | [process.stdin, process.stdout, process.stderr][0, 1, 2] |

ChildProcess

spawn()不接受exec()``execFile()等回调函数。相反,它返回一个ChildProcess对象。ChildProcess类继承自EventEmitter,用于与衍生的子进程交互。ChildProcess对象提供了三个流对象stdinstdoutstderr,代表底层子流程的标准流。清单 9-6 中的例子使用spawn()来运行根目录中的ls命令。然后子进程被设置为从父进程继承它的标准流。因为子级的标准流被连接到父级的流,所以子级的输出被打印到控制台。因为我们唯一真正感兴趣的是ls命令的输出,所以stdio选项也可以使用数组["ignore", process.stdout, "ignore"]来设置。

清单 9-6 。使用spawn()执行命令

var cp = require("child_process");
var child = cp.spawn("ls", ["-l"], {
  cwd: "/",
  stdio: "inherit"
});

image 注意为了复习使用标准流,请重温第五章和第七章。这一章着重于前面没有提到的内容。

在最后一个例子中,子进程的stdout流基本上是通过使用stdio属性的"inherit"值来管理的。然而,该流也可以被显式控制。清单 9-7 中的例子直接接入子进程的stdout流及其data事件处理程序。

清单 9-7 。清单 9-6 中的替代实现

var cp = require("child_process");
var child = cp.spawn("ls", ["-l", "/"]);

child.stdout.on("data", function(data) {
  process.stdout.write(data.toString());
});

error事件

当不能产生或杀死子对象时,或者当向它发送 IPC 消息失败时,ChildProcess对象发出一个error事件。ChildProcess error事件处理程序的通用格式如清单 9-8 所示。

清单 9-8 。一个事件处理程序

var cp = require("child_process");
var child = cp.spawn("ls");

child.on("error", function(error) {
  // process error here
  console.error(error.toString());
});

exit事件

当子进程终止时,ChildProcess对象发出一个exit事件。向exit事件处理程序传递了两个参数。第一个是进程被父进程终止时的退出代码(如果进程没有被父进程终止,代码参数为null))。第二个是用来杀死进程的信号。如果子进程没有被来自父进程的信号终止,这也是null。清单 9-9 显示了一个通用的exit事件处理程序。

清单 9-9 。一个事件处理程序

var cp = require("child_process");
var child = cp.spawn("ls");

child.on("exit", function(code, signal) {
  console.log("exit code:  " + code);
  console.log("exit signal:  " + signal);
});

close事件

当子进程的标准流关闭时,发出close事件 。这不同于exit事件,因为多个进程可能共享相同的流。像exit事件一样,close也提供退出代码和信号作为事件处理程序的参数。清单 9-10 中显示了一个通用的close事件处理程序。

清单 9-10 。一个事件处理程序

var cp = require("child_process");
var child = cp.spawn("ls");

child.on("close", function(code, signal) {
  console.log("exit code:  " + code);
  console.log("exit signal:  " + signal);
});

pid属性

一个ChildProcesspid属性 用于获取子进程的标识符。清单 9-11 显示了如何访问pid属性。

清单 9-11 。访问子进程的pid属性

var cp = require("child_process");
var child = cp.spawn("ls");

console.log(child.pid);

kill()

kill() 用于向子进程发送信号。这个给孩子的信号是kill()的唯一论据。如果没有提供参数,kill()发送SIGTERM信号试图终止子进程。在清单 9-12 中调用kill()的例子中,还包含了一个exit事件处理程序来显示终止信号。

清单 9-12 。使用kill()向子进程发送信号

var cp = require("child_process");
var child = cp.spawn("cat");

child.on("exit", function(code, signal) {
  console.log("Killed using " + signal);
});

child.kill("SIGTERM");

fork()

fork()``spawn()的特例,用于创建 Node 流程(见清单 9-13 )。modulePath参数是运行在子进程中的 Node 模块的路径。可选的第二个参数是一个数组,用于将参数传递给子进程。最后一个参数是一个可选对象,用于将选项传递给fork()fork()支持的选项如表 9-5 所示。

清单 9-13 。使用child_process.fork()方法

child_process.fork(modulePath, [args], [options])

表 9-5 。fork()支持的选项

|

[计]选项

|

描述

|
| --- | --- |
| cwd | 用于设置子进程工作目录的值。 |
| env | env应该是一个对象,它的键值对指定子进程的环境。该对象相当于子对象中的process.env。如果未指定,子进程将从父进程继承其环境。 |
| encoding | 子进程使用的字符编码。默认为"utf8" (UTF-8)。 |

image fork()返回的流程是 Node 的新实例,包含 V8 的完整实例。注意不要创建太多这样的进程,因为它们会消耗大量资源。

fork()返回的ChildProcess对象配备了内置的 IPC 通道,允许不同的 Node 进程通过 JSON 消息进行通信。默认情况下,子流程的标准流也与父流程相关联。

为了演示fork()如何工作,需要两个测试应用。第一个应用(见清单 9-14 )代表要执行的子模块。该模块只是打印传递给它的参数、它的环境和它的工作目录。将这段代码保存在名为child.js的文件中。

清单 9-14 。子模块

console.log("argv:  " + process.argv);
console.log("env:  " + JSON.stringify(process.env, null, 2));
console.log("cwd:  " + process.cwd());

清单 9-15 显示了相应的父进程。这段代码派生出 Node 的一个新实例,它运行清单 9-14 中的child模块。对fork()的调用传递了一个-foo参数给孩子。它还将孩子的工作目录设置为/,并提供自定义环境。当应用运行时,子进程的打印语句显示在父进程的控制台上。

清单 9-15 。清单 9-14 中显示的子模块的父模块

var cp = require("child_process");
var child;

child = cp.fork(__dirname + "/child", ["-foo"], {
  cwd: "/",
  env: {
    bar: "baz"
  }
});

send()

send()方法 使用内置的 IPC 通道在 Node 进程间传递 JSON 消息。父进程可以通过调用ChildProcess对象的send()方法来发送数据。然后,通过在process对象上设置一个message事件处理程序,可以在子流程中处理数据。类似地,子 Node 可以通过调用process.send()方法向其父 Node 发送数据。在父流程中,数据通过ChildProcessmessage事件处理程序接收。

以下示例包含两个 Node 应用,它们无限期地来回传递消息。子模块(见清单 9-16 )应该存储在一个名为message-counter.js的文件中。整个模块就是process对象的message处理程序。每次收到消息时,处理程序都会显示消息计数器。接下来,我们通过检查process.connected的值来验证父进程仍然存在,并且 IPC 通道完好无损。如果信道被连接,计数器递增,并且消息被发送回父进程。

清单 9-16 。将消息传递回其父模块的子模块

process.on("message", function(message) {
  console.log("child received:  " + message.count);

  if (process.connected) {
    message.count++;
    process.send(message);
  }
});

清单 9-17 显示了相应的父进程。父进程首先派生一个子进程,然后设置两个事件处理程序。第一个处理来自子进程的message事件。处理器显示消息计数,并检查 IPC 通道是否通过child.connected值连接。如果是,处理程序递增计数器,然后将消息传递回子进程。

第二个处理器监听SIGINT信号。如果收到了SIGINT,子进程被杀死,父进程退出。添加这个处理程序是为了允许用户终止两个程序,这两个程序正在一个无限的消息传递循环中运行。在清单 9-17 的末尾,通过向孩子发送一个计数为 0 的消息来开始消息传递。要测试这个程序,只需运行父进程。要终止,只需按下Ctrl+C

清单 9-17 。与清单 9-16 中的子模块协同工作的父模块

var cp = require("child_process");
var child = cp.fork(__dirname + "/message-counter");

child.on("message", function(message) {
  console.log("parent received:  " + message.count);

  if (child.connected) {
    message.count++;
    child.send(message);
  }
});

child.on("SIGINT", function() {
  child.kill();
  process.exit();
});

child.send({
  count: 0
});

image 注意如果通过send()传输的对象有一个名为cmd的属性,其值是一个以"NODE_"开头的字符串,那么消息不会作为message事件发出。对象{cmd: "NODE_foo"}就是一个例子。这些是 Node 核心使用的特殊消息,并导致发出internalMessage事件。官方文档强烈反对使用此功能,因为它可能会在不通知的情况下更改。

disconnect()

要关闭父进程和子进程之间的 IPC 通道,使用disconnect()方法。从父流程中,调用ChildProcessdisconnect()方法。从子进程来看,disconnect()process对象的一个方法。

disconnect(),不接受任何参数,导致几件事情发生。首先,在父进程和子进程中将ChildProcess.connectedprocess.connected设置为false。第二,在两个进程中都发出了一个disconnect事件。一旦disconnect()被调用,试图发送更多消息将导致错误。

清单 9-18 显示了一个只包含一个disconnect事件处理程序的子模块。当父进程断开连接时,子进程会向控制台打印一条消息。将这段代码存储在一个名为disconnect.js的文件中。清单 9-19 显示了相应的父进程。父进程派生一个子进程,设置一个disconnect事件处理程序,然后立即与子进程断开连接。当disconnect事件由子进程发出时,父进程也会向控制台输出一条再见消息。

清单 9-18 。实现disconnect事件处理程序的子模块

process.on("disconnect", function() {
  console.log("Goodbye from the child process");
});

清单 9-19 。与清单 9-18 中的所示的子 Node 相对应的父 Node

var cp = require("child_process");
var child = cp.fork(__dirname + "/disconnect");

child.on("disconnect", function() {
  console.log("Goodbye from the parent process");
});

child.disconnect();

vm模块

vm(虚拟机)核心模块 用于执行 JavaScript 代码的原始字符串。乍一看,它似乎只是 JavaScript 内置eval()函数的另一种实现,但是vm要强大得多。对于初学者来说,vm允许你解析一段代码并在以后运行它——这是用eval()做不到的。vm还允许您定义代码执行的上下文,使其成为eval()的更安全的替代方案。关于vm,上下文是由一个全局对象和一组内置对象和函数组成的 V8 数据结构。代码执行的上下文可以被认为是 JavaScript 环境。本节的剩余部分描述了vm为使用上下文和执行代码提供的各种方法。

image 注意 eval(),一个不与任何对象关联的全局函数,以一个字符串作为唯一的参数。这个字符串可以包含任意的 JavaScript 代码,eval()将试图执行这些代码。由eval()执行的代码拥有与调用者相同的特权,以及对当前作用域内任何变量的访问权。eval()被认为是一个安全风险,因为它让任意代码对您的数据进行读/写访问,,通常应该避免。

runInThisContext()

runInThisContext()方法允许代码使用与应用其余部分相同的上下文来执行。这个方法有两个参数。第一个是要执行的代码字符串。可选的第二个参数表示所执行代码的“文件名”。如果存在,这可以是任何字符串,因为它只是一个虚拟文件名,用于提高堆栈跟踪的可读性。清单 9-20 是一个使用runInThisContext()打印到控制台的简单例子。结果输出如清单 9-21 中的所示。

清单 9-20 。使用vm.runInThisContext()

var vm = require("vm");
var code = "console.log(foo);";

foo = "Hello vm";
vm.runInThisContext(code);

清单 9-21 。清单 9-20 中的代码生成的输出

$ node runInThisContext-hello.js
Hello vm

runInThisContext()执行的代码可以访问与您的应用相同的上下文,这意味着它可以访问所有全局定义的数据。但是,执行代码不能访问非全局变量。这大概是runInThisContext()eval()最大的区别。为了说明这个概念,首先看看清单 9-22 中的例子,它从runInThisContext()内部访问全局变量foo。回想一下,没有使用var关键字声明的 JavaScript 变量会自动成为全局变量。

清单 9-22 。在vm.runInThisContext()内更新全局变量

var vm = require("vm");
var code = "console.log(foo); foo = 'Goodbye';";

foo = "Hello vm";
vm.runInThisContext(code);
console.log(foo);

清单 9-23 显示了运行清单 9-22 中代码的输出。在这个例子中,变量foo最初保存值"Hello vm"。当runInThisContext()被执行时,foo被打印到控制台,然后赋值"Goodbye"。最后,再次打印出foo的值。在runInThisContext()内发生的分配持续存在,并且Goodbye被打印。

清单 9-23 。清单 9-22 中的代码产生的输出

$ node runInThisContext-update.js
Hello vm
Goodbye

如前所述,runInThisContext()不能访问非全局变量。清单 9-22 在清单 9-24 中被重写,因此foo现在是一个局部变量(使用var关键字声明)。另外,请注意,指定可选文件名的附加参数现在已经被传递到了runInThisContext()中。

清单 9-24 。试图访问vm.runInThisContext()中的非全局变量

var vm = require("vm");
var code = "console.log(foo);";
var foo = "Hello vm";

vm.runInThisContext(code, "example.vm");

当执行清单 9-24 中的代码时,试图访问foo时会出现ReferenceError。异常和堆栈跟踪如列表 9-25 所示。注意堆栈跟踪引用了example.vm,与runInThisContext()相关的文件名。

清单 9-25 。清单 9-24 中代码的堆栈跟踪输出

$ node runInThisContext-var.js

/home/colin/runInThisContext-var.js:5
vm.runInThisContext(code, "example.vm");
   ^
ReferenceError: foo is not defined
    at example.vm:1:13
    at Object.<anonymous> (/home/colin/runInThisContext-var.js:5:4)
    at Module._compile (module.js:456:26)
    at Object.Module._extensions..js (module.js:474:10)
    at Module.load (module.js:356:32)
    at Function.Module._load (module.js:312:12)
    at Function.Module.runMain (module.js:497:10)
    at startup (node.js:119:16)
    at node.js:901:3

清单 9-26 用对eval()的调用替换了对runInThisContext()的调用。结果输出也显示在清单 9-27 中。根据观察到的输出,eval()显然能够在本地范围内访问foo

清单 9-26 。使用eval()成功访问局部变量

var vm = require("vm");
var code = "console.log(foo);";
var foo = "Hello eval";

eval(code);

清单 9-27 。清单 9-26 的输出结果

$ node runInThisContext-eval.js
Hello eval

runInNewContext()

在上一节中,您看到了如何通过使用runInThisContext()而不是eval()来保护局部变量。然而,因为runInThisContext()与当前的上下文一起工作,它仍然允许不可信的代码访问您的全局数据。如果你需要进一步限制访问,使用vmrunInNewContext()方法。顾名思义,runInNewContext()创建了一个全新的上下文,代码可以在其中执行。清单 9-28 显示了runInNewContext()的用法。第一个参数是要执行的 JavaScript 字符串。第二个可选参数用作新上下文中的全局对象。第三个参数也是可选的,是堆栈跟踪中显示的文件名。

清单 9-28 。使用vm.runInNewContext()

vm.runInNewContext(code, [sandbox], [filename])

sandbox参数用于设置上下文中的全局变量,以及在runInNewContext()完成后检索值。记住,使用runInThisContext(),我们能够直接修改全局变量,并且这些改变会持续下去。然而,因为runInNewContext()使用了一组不同的全局变量,所以同样的技巧并不适用。例如,人们可能期望清单 9-29 中的代码在运行时显示"Hello vm",但事实并非如此。

清单 9-29 。试图使用vm.runInNewContext()执行代码

var vm = require("vm");
var code = "console.log(foo);";

foo = "Hello vm";
vm.runInNewContext(code);

这段代码没有成功运行,而是崩溃了,错误如清单 9-30 中的所示。出现错误是因为新的上下文无权访问应用的console对象。值得指出的是,程序崩溃前只抛出一个错误。然而,即使console可用,也会抛出第二个异常,因为全局变量foo在新的上下文中不可用。

清单 9-30 。清单 9-29 中的代码抛出的ReferenceError

ReferenceError: console is not defined

幸运的是,我们可以使用sandbox参数显式地将fooconsole对象传递给新的上下文。清单 9-31 展示了如何完成这个任务。运行时,这段代码如预期的那样显示"Hello vm"

清单 9-31vm.runInNewContext()的成功运用

var vm = require("vm");
var code = "console.log(foo);";
var sandbox;

foo = "Hello vm";
sandbox = {
  console: console,
  foo: foo
};
vm.runInNewContext(code, sandbox);

沙盒数据

关于runInNewContext()的一件好事是,对沙盒数据所做的更改实际上不会改变应用的数据。在清单 9-32 所示的例子中,全局变量fooconsole通过沙箱传递给runInNewContext()。在runInNewContext()内部,定义了一个名为bar的新变量,foo被打印到控制台,然后foo被修改。在runInNewContext()完成之后,foo会被再次打印,同时还有几个沙箱值。

清单 9-32 。创建和修改沙盒数据

var vm = require("vm");
var code = "var bar = 1; console.log(foo); foo = 'Goodbye'";
var sandbox;

foo = "Hello vm";
sandbox = {
  console: console,
  foo: foo
};
vm.runInNewContext(code, sandbox);
console.log(foo);
console.log(sandbox.foo);
console.log(sandbox.bar);

清单 9-33 显示了结果输出。"Hello vm"的第一个实例来自于runInNewContext()内部的 print 语句。不出所料,这是通过沙箱传入的foo的值。接下来,foo被设置为"Goodbye"。但是,下一个打印语句显示的是foo的原始值。这是因为runInNewContext()内部的赋值语句更新了foo的沙盒副本。最后两条 print 语句反映了foo ( "Goodbye")和bar (1)在runInNewContext()末尾的沙箱值。

清单 9-33 。清单 9-32 的输出结果

$ node runInNewContext-sandbox.js
Hello vm
Hello vm
Goodbye
1

runInContext()

Node 允许您创建单独的 V8 上下文对象,并使用runInContext()方法在其中执行代码。使用vmcreateContext()方法创建单独的上下文。runInContext()可以不带参数调用,导致它返回一个空的上下文。或者,沙盒对象可以传递给createContext(),它被浅层复制到上下文的全局对象。createContext()的用法如清单 9-34 所示。

清单 9-34 。使用vm.createContext()

vm.createContext([initSandbox])

createContext()返回的上下文对象可以作为第二个参数传递给vmrunInContext()方法,这与runInNewContext()几乎相同。唯一的区别是runInContext()的第二个参数是一个上下文对象,而不是沙箱。清单 9-35 显示了如何使用runInContext()重写清单 9-32 。不同之处在于runInContext()取代了runInNewContext()和用createContext()创建的context,,取代了sandbox变量。运行这段代码的输出与清单 9-33 中显示的相同。

清单 9-35 。使用vm.createContext()重写清单 9-34

var vm = require("vm");
var code = "var bar = 1; console.log(foo); foo = 'Goodbye'";
var context;

foo = "Hello vm";
context = vm.createContext({
  console: console,
  foo: foo
});
vm.runInContext(code, context);
console.log(foo);
console.log(context.foo);
console.log(context.bar);

createScript()

createScript()方法 ,用于编译一个 JavaScript 字符串以备将来执行,当你想多次执行代码时,这个方法很有用。createScript()方法接受两个参数,该方法返回一个无需重新解释代码就可以重复执行的vm.Script对象。首先是要编译的代码。可选的第二个参数表示将在堆栈跟踪中显示的文件名。

createScript()返回的vm.Script对象有三种执行代码的方法。这些方法是runInThisContext()runInNewContext()runInContext()的修改版本。这三种方法的用法如清单 9-36 所示。它们的行为与同名的vm方法相同。不同之处在于,这些方法不接受 JavaScript 代码字符串或文件名参数,因为它们已经是脚本对象的一部分。

清单 9-36vm.Script类型的脚本执行方法

script.runInThisContext()
script.runInNewContext([sandbox])
script.runInContext(context)

清单 9-37 显示了一个在循环中多次运行脚本的例子。在这个例子中,使用createScript()编译了一个简单的脚本。接下来,使用设置为 0 的单个值i创建沙箱。然后使用runInNewContext()在一个for循环中执行该脚本十次。每次迭代都会增加i的沙箱值。当循环完成时,沙箱被打印出来。当显示沙箱时,增量操作的累积效果是明显的,因为i的值是 10。

清单 9-37 。多次执行已编译的脚本

var vm = require("vm");
var script = vm.createScript("i++;", "example.vm");
var sandbox = {
      i: 0
    }

for (var i = 0; i < 10; i++) {
  script.runInNewContext(sandbox);
}

console.log(sandbox);
// displays {i: 10}

摘要

本章向您展示了如何以各种方式执行代码。首先讨论的是程序需要执行另一个应用的常见情况。在这些情况下,使用child_process模块中的方法。详细检查了方法exec()execFile()spawn()fork(),以及每种方法提供的不同抽象级别。接下来将介绍 JavaScript 代码字符串的执行。探索了vm模块,并将其各种方法与 JavaScript 的原生eval()函数进行了比较。还涵盖了上下文的概念和由vm提供的各种类型的上下文。最后,您学习了如何编译脚本并在以后使用vm.Script类型执行它们。

十、网络编程

到目前为止,本书中提供的示例代码都集中在您的本地机器上。无论是访问文件系统、解析命令行参数还是执行不受信任的代码,所有示例都被隔离到一台计算机上。本章开始探索本地主机之外的世界。它涵盖了网络编程并介绍了许多重要的主题,包括套接字、客户机-服务器编程、传输控制协议(TCP)、用户数据报协议(UDP)和域名服务(DNS)。对所有这些概念的完整解释超出了本书的范围,但是对它们的基本理解是至关重要的,因为它们是接下来几章中涉及的 web 应用的基础。

Sockets

当两个应用通过网络进行通信时,它们使用套接字进行通信。套接字是互联网协议(IP)地址和端口号的组合。IP 地址用于唯一标识网络上的设备,网络可以是小型家庭网络或整个互联网。该设备可以是 PC、平板电脑、智能手机、打印机或任何其他支持互联网的设备。IP 地址是 32 位数字,格式为由点分隔的四个 8 位数字。IP 地址的例子有184.168.230.12874.125.226.193。这些对应于www.cjihrig.comwww.google.com的 Web 服务器。

image 注意这里描述的 IP 地址被称为 IPv4 地址,最常见的一种。这些地址基于互联网协议版本 4。由于互联网的发展,预计 IPv4 地址的数量将会耗尽。为了缓解这个问题,互联网协议版本 6 (IPv6)被开发出来。IPv6 地址的长度为 128 位,这意味着可以代表更多的地址。IPv6 地址字符串也更长,包括十六进制值,用冒号而不是点作为分隔符。

套接字的端口组件是一个 16 位数字,用于唯一标识计算机上的通信端点。端口允许一台计算机同时维护许多套接字连接。为了更好地理解端口的概念,想象自己给在大型公司办公楼工作的人打电话。当你打电话时,你需要知道办公室的电话号码。在这个类比中,办公室是一台远程计算机,它的电话号码就是它的 IP 地址。公司办公室提供联系个人的分机。电话分机类似于端口号,您试图联系的一方代表远程机器上的一个进程或线程。进入对方分机并接通后,您就可以继续通话了。类似地,一旦两个套接字建立了通信通道,它们就可以开始来回发送数据。

前面提到过,IP 地址74.125.226.193对应于位于www.google.com的 web 服务器。要验证这一点,在你浏览器的地址栏中输入http://74.125.226.193。显然这个请求包含了服务器的 IP 地址,但是端口号在哪里呢?事实证明,谷歌的 Web 服务器接受 80 端口的连接。URL 语法允许您通过在主机后包含冒号和端口号来明确标识要连接的端口。要验证这一点,请尝试在您的浏览器中连接到http://74.125.226.193:80(或www.google.com:80)。你应该再次看到谷歌主页。现在尝试连接到http://74.125.226.193:81 ( www.google.com:81)。突然,页面再也找不到了。当www.google.com被输入地址栏时,浏览器如何知道要连接到 80 端口?为了回答这个问题,让我们回到我们的电话类比。在美国,你如何知道在紧急情况下打 911 而不是 912?答案是:因为这个国家的每个孩子都被教导在紧急情况下拨打 911。这是社会公认的惯例。

在互联网上,公共服务遵循类似的惯例。从 0 到 1023 的端口号称为知名端口,或保留端口。例如,端口 80 保留用于服务 HTTP 流量。因此,当您导航到以http://开头的 URL 时,您的浏览器会假定端口号为 80,除非您明确声明不是这样。这就是为什么 Google 的 web 服务器在端口 80 上响应了我们的请求,而不是 81。可以用 HTTPS(安全 HTTP)协议进行类似的实验。端口 443 是为 HTTPS 流量保留的。如果您在浏览器的地址栏中输入 URL http://74.125.226.193:443,将会遇到错误。然而,如果你将网址改为https://74.125.226.193:443,你将通过安全连接登陆谷歌主页。请注意,在导航过程中,您可能会遇到浏览器警告。在这种情况下,可以安全地忽略此警告。

如果您计划实现一个公共服务,比如一个 web 服务器,使用它众所周知的端口号是明智的。但是,没有什么可以阻止您在非标准端口上运行 web 服务器。例如,您可以在端口 8080 上运行 web 服务器,只要每个试图连接到服务器的人都在 URL 中明确指定端口 8080。同样,如果您正在创建自定义应用,请避免使用通常用于其他目的的端口。在为您的应用选择一个端口之前,您可能希望在 Internet 上快速搜索可能与之冲突的其他常见服务。此外,避免使用保留的端口号之一。

客户端-服务器编程

客户机-服务器模型 是一种范例,其中计算任务在服务器(提供资源的机器)和客户机(请求并消耗这些资源的机器)之间进行划分。Web 是客户机-服务器模型的一个很好的例子。当您打开浏览器窗口并导航到网站时,您的计算机充当客户端。您的计算机请求和使用的资源是网页。该网页由服务器提供,您的计算机使用套接字通过互联网连接到该服务器。这个模型的高层次抽象如图图 10-1 所示。

9781430258605_Fig10-01.jpg

图 10-1 。在互联网上工作的客户机-服务器模型

image 提示【Tip 地址127.0.0.1用来标识本地机器,称为localhost。通过让客户机连接到运行在localhost上的服务器,许多客户机-服务器应用可以在一台机器上进行测试。

image 上一节讨论了知名港口。在客户机-服务器模型中,这个概念通常只适用于服务器应用。由于客户端发起到服务器的连接,因此客户端必须知道要连接到哪个端口。另一方面,服务器不需要担心连接的客户端使用的端口。

传输控制协议

传输控制协议,简称 TCP ,是一种用于在互联网上传输数据的通信协议 。互联网数据传输不可靠。当你的计算机将一条信息发送到网络上时,这条信息首先被分解成称为信息包的小块,然后被发送到网络上,并开始向目的地前进。因为您的计算机与世界上的其他计算机没有直接连接,所以每个数据包都必须经过许多中间机器,直到找到到达目的地的路由。每个数据包都有可能采用唯一的路径到达目的地,这意味着数据包到达的顺序可能不同于它们被发送的顺序。此外,互联网不可靠,个别数据包可能会在途中丢失或损坏。

TCP 有助于给混乱的互联网带来可靠性。TCP 是所谓的面向连接的协议 ,这个术语指的是机器之间建立的虚拟连接。两台机器通过以一种被称为握手的定义模式来回发送小块数据来进入 TCP 连接。在多步握手结束时,两台机器已经建立了连接。使用这种连接,TCP 强制执行数据包之间的排序,并确认数据包在目的地被成功接收。此外,TCP 提供的功能还包括错误检查和丢失数据包的重新传输。

在 Node 生态系统中,使用 TCP 的网络编程是使用net核心模块实现的。清单 10-1 展示了如何将net模块导入到一个 Node 应用中。本模块包括创建客户端和服务器应用的方法。本节的剩余部分将探索由net提供的使用 TCP 的各种方法。

清单 10-1 。将Net模块导入应用

var net = require("net");

Creating a TCP Server

使用createServer()方法可以很容易地创建 TCP 服务器(参见清单 10-2 )。该方法有两个可选参数。第一个是包含配置选项的对象。createServer()支持单个选项allowHalfOpen,默认为false。如果该选项被明确设置为true,服务器将保持客户端连接打开,即使客户端终止它们。在这种情况下,套接字变得不可读,但仍可由服务器写入。此外,如果allowHalfOpentrue,则无论客户端做什么,都必须在服务器端显式关闭连接。这个问题将在后面讲述end()方法时详细解释。

清单 10-2 。使用net.createServer() 创建 TCP 服务器

var net = require("net");
var server = net.createServer({
  allowHalfOpen: false
}, function(socket) {
  // handle connection
});

清单 10-2 中createServer()的第二个参数是一个事件处理器,用于处理来自客户端的连接。事件处理程序接受一个参数,一个代表客户端套接字连接的net.Socket对象。在这一章的后面还会更详细地讨论net.Socket类。最后,createServer()将新创建的 TCP 服务器作为一个net.Server实例返回。net.Server类继承自EventEmitter并发出与套接字相关的事件。

监听连接

客户端无法访问由createServer()返回的服务器,因为它没有与特定端口相关联。要使服务器可访问,它必须在端口上侦听传入的客户端连接。listen()方法,其使用如清单 10-3 所示,用于将服务器绑定到指定端口。listen()唯一需要的参数是要绑定到的端口号。要监听随机选择的端口,请将 0 作为port参数传递。(请注意,通常应该避免这样做,因为客户端不知道要连接到哪个端口。)

清单 10-3 。使用net.Server.listen()方法

server.listen(port, [host], [backlog], [callback])

如果省略了host参数,服务器将接受指向任何有效 IPv4 地址的连接。要限制服务器接受的连接,请指定服务器将作为其响应的主机。此功能在具有多个网络接口的服务器上非常有用,因为它允许应用的作用范围局限于一个单独的网络。如果您的机器只有一个 IP 地址,您可以尝试这个特性。例如,清单 10-4 中的代码只接受指向localhost ( 127.0.0.1)的连接。这允许您为您的应用创建一个 web 接口,同时不会将它暴露给远程的、潜在的恶意连接。

清单 10-4 。仅接受端口 8000 上的localhost连接的代码

var net = require("net");
var server = net.createServer(function(socket) {
  // handle connection
});

server.listen(8000, "127.0.0.1");

服务器的 backlog 是已经连接到服务器但尚未处理的客户端连接的队列。一旦积压满了,任何新的传入连接到该端口被删除。backlog参数用于指定该队列的最大长度。该值默认为 511。

listen()的最后一个参数是一个响应listening事件的事件处理程序。当服务器成功绑定到一个端口并监听连接时,它会发出一个listening事件。listening事件不为它的处理函数提供参数,但是它对于调试和日志记录非常有用。例如,清单 10-5 中的代码试图监听一个随机端口。包含了一个listening事件处理程序,显示随机选择的端口。

清单 10-5 。带有listening事件处理程序的服务器

var net = require("net");
var server = net.createServer(function(socket) {
  // handle connection
});

server.listen(0, function() {
  var address = server.address();

  console.log("Listening on port " + address.port);
});

image 注意listen()的事件处理程序是为了方便而提供的。也可以使用on()方法添加listening事件处理程序。

address()

在清单 10-5 中,服务器的address()方法 用于显示随机选择的端口。address()方法返回一个包含服务器绑定地址、地址族和端口的对象。如前所述,port属性表示绑定端口。如果未指定host,则绑定地址从host参数到listen()"0.0.0.0"获取其值。地址族代表地址的类型(IPv4、IPv6 等。).注意,由于address()返回的值依赖于传递给listen()的参数,所以在发出listening事件之前,不应该调用这个方法。在清单 10-6 的例子中,展示了address()的另一种用法,使用了一个随机端口和地址::1(IPv6 中的localhost)。结果输出如清单 10-7 中的所示。当然,因为是随机的,你的端口号很可能是不一样的。

清单 10-6 。使用net.Server.address()

var net = require("net");
var server = net.createServer(function(socket) {
  // handle connection
});

server.listen(0, "::1", function() {
  var address = server.address();

  console.log(address);
});

清单 10-7 。清单 10-6 中的代码产生的输出

$ node server-address.js
{ address: '::1', family: 'IPv6', port: 64269 }

听歌的变奏()

listen()方法有两个不常用的签名。第一种变化允许服务器监听已经绑定的现有服务器/套接字。新的服务器开始接受本来会被定向到现有服务器/套接字的连接。创建两个服务器server1server2的示例如清单 10-8 所示(示例输出如清单 10-9 所示)。接下来,在server2上设置一个listening事件处理程序,调用address()并显示结果。接下来,server1listen()方法被它自己的listening事件处理程序调用。这个处理程序也显示address()的结果,但是告诉server2监听server1的配置。

清单 10-8 。将服务器实例传递给listen()

var net = require("net");
var server1 = net.createServer();
var server2 = net.createServer(function(socket) {
  // handle connection
});

server2.on("listening", function() {
  console.log("server2:");
  console.log(server2.address());
});

server1.listen(0, "127.0.0.1", function() {
  console.log("server1:");
  console.log(server1.address());
  server2.listen(server1);
});

清单 10-9 。运行清单 10-8 中代码的输出结果

$ node server-listen-handle.js
server1:
{ address: '127.0.0.1', family: 'IPv4', port: 53091 }
server2:
{ address: '127.0.0.1', family: 'IPv4', port: 53091 }

请注意,address()(见清单 10-9 )的结果对于两台服务器是相同的。您还没有看到如何实际处理连接,但是值得指出的是,在这个例子中,到server1的连接被定向到server2。同样值得注意的是,listen()的这个实例接受一个listening事件处理程序作为可选的第二个参数。

listen()的最后一个变体接受一个 Unix 套接字文件名或 Windows 命名管道作为其第一个参数,接受一个listening事件处理程序作为其可选的第二个参数。在清单 10-10 中显示了一个使用 Unix 套接字的例子。

清单 10-10 。将 Unix 套接字文件传递给listen()

var net = require("net");
var server = net.createServer(function(socket) {
  // handle connection
});

server.listen("/tmp/foo.sock");

Handling Connections

一旦服务器被绑定并侦听,它就可以开始接受连接。每当服务器接收到一个新连接时,就会发出一个connection事件。为了处理传入的连接,必须将一个connection事件处理程序传递给createServer()或使用一种方法(如on())附加。连接处理程序将一个net.Socket对象作为它唯一的参数。然后,这个套接字用于向客户端发送数据和从客户端接收数据。相同的 socket 类用于实现 TCP 客户端,因此完整的 API 将在该部分中介绍。现在,清单 10-11 展示了一个监听端口 8000 并响应客户端请求的服务器。

清单 10-11 。用简单消息响应客户机的服务器

var net = require("net");
var server = net.createServer(function(socket) {
  socket.end("Hello and Goodbye!\n");
});

server.listen(8000);

要测试服务器,像运行任何其他 Node 应用一样运行清单 10-11 中的代码。接下来,使用telnet或网络浏览器连接到服务器(telnet是用于建立网络连接和发送/接收数据的命令行实用程序)。要使用telnet测试服务器,从终端窗口发出命令telnet localhost 8000。如果使用网络浏览器,只需导航至http://localhost:8000。如果一切正常,终端或浏览器应该显示消息"Hello and Goodbye!" 清单 10-12 显示了使用telnet的输出。注意,telnet应用打印了几行实际上与服务器无关的代码。

清单 10-12 。清单 10-11 中连接到服务器的telnet输出

$ telnet localhost 8000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
Hello and Goodbye!
Connection closed by foreign host.

关闭服务器

要终止服务器,请使用close()方法。调用close()阻止服务器接受新的连接。但是,允许任何现有的连接完成它们的工作。一旦没有连接,服务器就会发出一个close事件。close()方法可选地接受一个处理close事件的事件处理程序。清单 10-13 中的例子启动了一个新的服务器,然后,一旦它在监听,就立即关闭。使用on()定义了一个close事件处理程序,而不是作为close()的参数。

清单 10-13 。监听然后立即关闭的服务器

var net = require("net");
var server = net.createServer();

server.on("close", function() {
  console.log("And now it's closed.");
});

server.listen(function() {
  console.log("The server is listening.");
  server.close();
});

ref()unref()

第四章介绍了定时器和间隔上下文中的两种方法ref()unref() 。如果计时器/时间间隔是事件循环中唯一剩余的项目,这些方法用于防止或允许 Node 应用终止。TCP 服务器有相同名称的等价方法。如果一个绑定的服务器是事件循环队列中唯一剩下的项目,调用unref()允许程序终止。这个场景在清单 10-14 中演示。相反,如果服务器是事件循环中唯一剩下的项目,调用ref()会恢复阻止应用退出的默认行为。

清单 10-14 。调用unref()后立即关机的服务器

var net = require("net");
var server = net.createServer();

server.listen();
server.unref();

错误事件

当事情出错时,net.Server实例发出error事件。一个常见的异常是EADDRINUSE错误,当一个应用试图使用一个已经被另一个应用使用的端口时就会出现这个错误。清单 10-15 展示了如何检测和处理这种类型的错误。一旦检测到错误,您的应用可以尝试连接到另一个端口,在尝试再次连接到同一个端口之前等待,或者直接退出。

清单 10-15 。检测端口已被使用的错误的处理程序

server.on("error", function(error) {
  if (error.code === "EADDRINUSE") {
    console.error("Port is already in use");
  }
});

另一个常见的错误是EACCES,当您没有足够的权限绑定到一个端口时抛出的异常。在 Unix 风格的操作系统上,当您试图绑定到保留端口时,会出现这些错误。例如,web 服务器通常需要管理员权限才能绑定到端口 80。

创建 TCP 客户端

net模块提供了两种方法,connect()createConnection() ,它们可以互换使用来创建 TCP 客户端套接字。这些客户端套接字用于连接本章中创建的服务器应用。本书通篇使用connect()是因为它的名字更短。请注意,在任何情况下,createConnection()都可以代替connect()connect()有三个实例,第一个如清单 10-16 所示。

清单 10-16net.connect()方法的一个用途

net.connect(port, [host], [connectListener])

在清单 10-16 中,在port指定的端口上创建了一个到host指定的机器的 TCP 连接。如果未指定host,则连接到localhost。如果连接成功建立,客户端将发出一个没有参数的connect事件。可选的第三个参数connectListener是一个事件处理程序,它将处理连接event。清单 10-17 显示了一个客户端连接到localhost上的端口 8000。这个客户端可以用清单 10-11 中创建的服务器进行测试。首先打开终端窗口并运行服务器应用。接下来,打开一个单独的终端窗口并运行客户端应用。成功连接到服务器后,客户端会显示一条消息。服务器返回的实际数据并没有显示出来(稍后会详细介绍)。

清单 10-17 。连接到端口 8000 上的localhost的客户端

var net = require("net");
var client = net.connect(8000, "localhost", function() {
  console.log("Connection established");
});

第二个版本的connect()将一个 Unix 套接字文件名或 Windows 命名管道作为第一个参数,将一个可选的connect事件处理程序作为第二个参数。清单 10-17 已被重写,以使用清单 10-18 中的 Unix 套接字文件。为了测试这个客户机,使用清单 10-19 中所示的修改后的服务器,它绑定到同一个套接字文件。

清单 10-18 。连接到套接字文件/tmp/foo.sock的客户端

var net = require("net");
var client = net.connect("/tmp/foo.sock", function() {
  console.log("Connection established");
});

清单 10-19 。用于测试清单 10-18 中的客户端的服务器

var net = require("net");
var server = net.createServer(function(socket) {
  socket.end("Hello and Goodbye!\n");
});

server.listen("/tmp/foo.sock");

connect()的最终版本采用一个配置对象和一个可选的connect事件处理程序作为参数。表 10-1 显示了配置对象支持的属性。清单 10-20 重写了清单 10-17 来使用这种形式的connect()。类似地,清单 10-21 重写了清单 10-18 。

表 10-1 。connect()支持的配置选项列表

|

财产

|

描述

|
| --- | --- |
| port | 如果通过 TCP 套接字连接(相对于 Unix 套接字文件或 Windows 命名管道),这将指定客户端应该连接的端口号。这是必需的。 |
| host | 如果通过 TCP 套接字连接,这将指定要连接的主机。如果省略,默认为localhost。 |
| localAddress | 创建连接时使用的本地接口。当一台机器有多个网络接口时,此选项很有用。 |
| path | 如果连接到 Unix 套接字文件或 Windows 命名管道,这用于指定路径。 |
| allowHalfOpen | 如果true,客户端不会在服务器关闭连接时关闭连接。相反,必须手动关闭连接。这默认为false。 |

清单 10-20 。连接到端口 8000 上的localhost的客户端

var net = require("net");
var client = net.connect({
  port: 8000,
  host: "localhost"
}, function() {
  console.log("Connection established");
});

清单 10-21 。连接到套接字文件/tmp/foo.sock的客户端

var net = require("net");
var client = net.connect({
  path: "/tmp/foo.sock"
}, function() {
  console.log("Connection established");
});

net.Socket类类

理解net.Socket类对于客户机和服务器开发都是必不可少的。在服务器端,一个套接字被传递给connection事件处理程序。在客户端,connect()返回一个套接字。由于 socket 类使用流来移动数据,您已经知道了一些基础知识(如果您需要复习,请重新阅读第七章)。例如,从一个套接字读取数据使用所有你已经知道并喜欢的可读流基础,包括data事件和pause()resume()方法。清单 10-22 显示了使用流从套接字读取数据是多么简单。这个客户端与清单 10-11 中的服务器协同工作,使用一个data事件处理程序从套接字读取数据并将数据打印到控制台。

清单 10-22 。在清单 10-11 的中,客户端显示从服务器读取的数据

var net = require("net");
var clientSocket = net.connect({
  port: 8000,
  host: "localhost"
});

clientSocket.setEncoding("utf8");

clientSocket.on("data", function(data) {
  process.stdout.write(data);
});

向套接字写入数据也可以使用 stream write()方法来完成。套接字有一个额外的方法end(),它关闭连接。end()可以有选择地通过dataencoding类似于write()的论证。因此,可以使用单个函数调用编写和关闭一个套接字(在清单 10-11 中的服务器中以这种方式使用了end())。注意,必须至少调用一次end()来关闭连接。此外,在调用end()后试图写入套接字会导致错误。

socket 类有几个其他的事件和方法,您应该已经知道了。例如,套接字有ref()unref()方法,如果套接字是事件循环中唯一剩余的项,它们会影响应用终止的能力。套接字也有一个address()方法,它返回连接套接字的绑定地址、端口号和地址族。关于事件,当写缓冲区变空时发出一个drain事件,当异常发生时发出一个error事件。

本地和远程地址

如前所述,address()方法返回一个包含本地绑定地址、其家族类型和使用的端口的对象。还有四个属性——remoteAddressremotePortlocalAddresslocalPort——提供关于套接字的远程和本地端点的信息。清单 10-23 中显示了这些属性的一个例子。

清单 10-23 。一个显示本地和远程地址和端口的例子

var net = require("net");
var client = net.connect(8000, function() {
  console.log("Local endpoint " + client.localAddress + ":" +
               client.localPort);
  console.log("is connected to");
  console.log("Remote endpoint " + client.remoteAddress + ":" +
               client.remotePort);
});

关闭套接字

如前所述,使用end()方法关闭套接字。技术上来说,end()只是半开插座。连接的另一端仍有可能继续发送数据。如果您需要完全关闭套接字——例如,在出现错误的情况下——您可以使用destroy()方法,它可以确保套接字上不再发生 I/O。

当远程主机调用end()destroy()时,本地端发出一个end事件。如果创建套接字时将allowHalfOpen选项设置为false(缺省值),本地端将写出所有未决数据,并关闭其连接端。但如果allowHalfOpen为真,本地端必须显式调用end()destroy()。一旦连接的两端都关闭,就会发出一个close事件。如果有一个close事件处理程序,它将接受一个布尔参数,如果套接字有任何传输错误,则为true,否则为false

清单 10-24 包括一个将其allowHalfOpen选项设置为true的客户端。该示例还包括endclose事件处理程序。注意,end()方法在end处理程序中被显式调用。如果这一行不存在,连接就不会完全关闭,也不会发出close事件。

清单 10-24 。带有endclose事件处理程序的客户端

var net = require("net");
var client = net.connect({
  port: 8000,
  host: "localhost",
  allowHalfOpen: true
});

client.on("end", function() {
  console.log("end handler");
  client.end();
});

client.on("close", function(error) {
  console.log("close handler");
  console.log("had error:  " + error);
});

Timeouts

默认情况下,套接字没有超时。这可能很糟糕,因为如果网络或远程主机出现故障,连接将无限期地处于空闲状态。但是,您可以使用套接字的setTimeout()方法在套接字上定义超时(不要与用于创建计时器的核心 JavaScript 方法混淆)。这个版本的setTimeout()将一个以毫秒为单位的超时作为它的第一个参数。如果套接字空闲了这段时间,就会发出一个timeout事件。一次性的timeout事件处理程序可以作为第二个参数传递给setTimeout()。一个timeout事件不关闭套接字;您负责使用end()destroy()关闭它。此外,您可以通过将 0 传递给setTimeout()来移除现有的超时。清单 10-25 显示了如何在一个套接字上创建十秒钟的超时。在本例中,当超时发生时,打印一条错误消息并关闭套接字。

清单 10-25 。有十秒钟超时的客户端

var net = require("net");
var client = net.connect(8000, "localhost");

client.setTimeout(10000, function() {
  console.error("Ten second timeout elapsed");
  client.end();
});

套接字、服务器和子进程

第九章展示了如何使用fork()方法创建 Node 子流程。使用send()方法,可以在进程间通信通道上的这些进程之间传输数据。要传输的数据作为第一个参数传递给send()。第九章中没有提到的是send()方法采用可选的第二个参数,TCP 套接字或服务器,它允许多个进程共享一个网络连接。如您所知,Node 进程是单线程的。产生共享单个套接字的多个进程允许更好地利用现代多核硬件。当涉及到cluster模块时,将在第十六章中更详细地回顾这个用例。

清单 10-26 包含了创建一个新的 TCP 服务器,派生一个子进程,并将服务器作为server消息传递给子进程的代码。子进程的代码(见清单 10-27 )应该保存在一个名为child.js的文件中。子进程检测server消息并设置一个connection处理程序。要验证套接字是否由两个进程共享,请建立到端口 8000 的多个连接。您将看到一些连接用"Handled by parent process"响应,而另一些用"Handled by child process"响应。

清单 10-26 。将 TCP 服务器传递给分叉的子进程

var cp = require("child_process");
var net = require("net");
var server = net.createServer();
var child = cp.fork("child");

server.on("connection", function(socket) {
  socket.end("Handled by parent process");
});

server.listen(8000, function() {
  child.send("server", server);
});

清单 10-27 。与清单 10-26 中的一起工作的child.js代码

process.on("message", function(message, server) {
  if (message === "server") {
    server.on("connection", function(socket) {
      socket.end("Handled by child process");
    });
  }
});

用户数据报协议

用户数据报协议,或 UDP ,是 TCP 的替代方案。UDP 和 TCP 一样,运行在 IP 之上。然而,UDP 并不包括许多使 TCP 如此可靠的特性。例如,UDP 在通信期间不建立连接。它也缺乏消息排序、有保证的传递和丢失数据的重新传输。由于协议开销较少,UDP 通信通常比 TCP 更快、更简单。硬币的另一面是,UDP 与底层网络一样可靠,因此数据很容易丢失。UDP 通常适用于音频和视频流等应用,在这些应用中,性能至关重要,一些数据可能会丢失。在这些应用中,一些丢失的数据包可能会对播放质量产生最低程度的影响,但媒体仍然可用。另一方面,UDP 不适合查看网页,因为即使一个丢失的数据包也会破坏页面的呈现能力。

要在 Node 应用中包含 UDP 功能,请使用dgram核心模块。清单 10-28 展示了这个模块是如何导入的。本节的剩余部分将探索由dgram模块提供的各种方法。

清单 10-28 。导入dgram核心模块

var dgram = require("dgram");

创建 UDP 套接字

客户机和服务器套接字都是使用createSocket()方法 创建的。指定套接字类型的createSocket()的第一个参数应该是"udp4""udp6"(对应于 IPv4 和 IPv6)。第二个参数(可选)是一个回调函数,用于处理通过套接字接收数据时发出的message事件。清单 10-29 中显示了一个创建新 UDP 套接字的示例。这个例子包含了一个message事件处理程序,当涉及到接收数据时将会被重新访问。

清单 10-29 。创建 UDP 套接字和message事件处理程序

var dgram = require("dgram");
var socket = dgram.createSocket("udp4", function(msg, rinfo) {
  console.log("Received data");
});

绑定到端口

创建套接字时,它使用随机分配的端口号。然而,服务器应用通常需要监听预定义的端口。UDP 套接字可以使用bind()方法 监听指定的端口,其用法如清单 10-30 所示。port参数是要绑定到的端口号。可选的address参数指定监听的 IP 地址(如果服务器有多个网络接口,这很有用)。如果省略,套接字将监听所有地址。可选的回调函数是一次性的listening事件处理程序。

清单 10-30 。使用bind()方法

socket.bind(port, [address], [callback])

清单 10-31 中的显示了bind()的一个例子。这个例子创建了一个 UDP 套接字并将其绑定到端口 8000。为了验证一切工作正常,绑定的地址被打印到控制台。清单 10-32 显示了结果输出。

清单 10-31 。将 UDP 套接字绑定到端口 8000

var dgram = require("dgram");
var server = dgram.createSocket("udp4");

server.bind(8000, function() {
  console.log("bound to ");
  console.log(server.address());
});

清单 10-32 。运行清单 10-31 中代码的输出

$ node udp-bind.js
bound to
{ address: '0.0.0.0', family: 'IPv4', port: 8000 }

Receiving Data

当在 UDP 套接字上接收到数据时,会发出一个message事件来触发任何现有的message事件处理程序。一个message事件处理程序接受两个参数,一个Buffer代表数据,一个对象包含发送者的信息。在清单 10-33 中,创建了一个绑定到端口 8000 的 UDP 服务器。当收到消息时,服务器显示消息大小、远程主机的 IP 地址和端口以及消息有效负载。

清单 10-33 。接收和显示消息的服务器

var dgram = require("dgram");
var server = dgram.createSocket("udp4", function(msg, rinfo) {
  console.log("received " + rinfo.size + " bytes");
  console.log("from " + rinfo.address + ":" + rinfo.port);
  console.log("message is:  " + msg.toString());
});

server.bind(8000);

接下来,让我们看看如何发送数据来测试服务器。

发送数据

使用send()方法 通过 UDP 套接字发送数据。清单 10-34 显示了如何使用这种方法。send()传输的数据来自一个Buffer,用buffer自变量表示。offset参数指定相关数据在缓冲区中的起始位置,而length指定要发送的字节数,从偏移量开始。由于 UDP 是一种无连接协议,因此在发送前没有必要连接到远程机器。因此,远程端口和地址是send()的参数。send()的最后一个参数是一个可选的回调函数,在数据发送后调用。回调函数有两个参数,代表潜在的错误和发送的字节数。包含这个回调是验证数据是否被实际发送的唯一方法。但是,UDP 没有用于验证数据已收到的内置机制。

清单 10-34 。使用send()方法

socket.send(buffer, offset, length, port, address, [callback])

清单 10-35 中的客户端代码可以与清单 10-33 中的服务器结合使用。客户端向服务器发送消息,然后服务器显示该消息。请注意,客户端的回调函数检查错误并报告发送的字节数,然后关闭连接。一旦套接字关闭,就会发出一个close事件,而不会发出新的message事件。

清单 10-35 。从清单 10-33 向服务器发送数据的客户端

var dgram = require("dgram");
var client = dgram.createSocket("udp4");
var message = new Buffer("Hello UDP");

client.send(message, 0, message.length, 8000, "127.0.0.1", function(error, bytes) {
  if (error) {
    console.error("An error occurred while sending");
  } else {
    console.log("Successfully sent " + bytes + " bytes");
  }

  client.close();
});

域名系统

域名系统(DNS )是一个分布式网络,它将域名映射到 IP 地址。DNS 是需要的,因为人们更容易记住名字,而不是一长串数字。DNS 可以被认为是互联网的电话簿。当您想要访问某个网站时,您可以在导航栏中键入其域名。然后,您的浏览器对该域名发出 DNS 查找请求。然后,DNS 查找返回该域的相应 IP 地址,假设它存在。

在 Node 生态系统中,DNS 通常在幕后处理,这意味着开发人员提供一个 IP 地址或域名,一切正常。但是,如果需要,可以使用dns核心模块直接访问 DNS。本节探讨用于 DNS 查找和反向查找的最重要的方法,这些方法将 IP 地址映射到域名。

执行查找

最重要的 DNS 方法可能是lookup(),它将一个域名作为输入,并返回找到的第一个 IPv4 或 IPv6 DNS 记录。lookup()方法 接受可选的第二个参数,指定要搜索的地址族。此参数默认为null,但也可以是46,对应 IPv4 或 IPv6 地址族。如果族参数为null,则同时搜索 IPv4 和 IPv6 地址。

lookup()的最后一个参数是一个回调函数,一旦 DNS 查找完成就调用这个函数。回调函数有三个参数,erroraddressfamilyerror参数表示发生的任何异常。如果查找由于任何原因失败,error.code被设置为字符串"ENOENT"address参数是字符串形式的 IP 地址,而family参数是46

在清单 10-36 的中,执行google.com的 DNS 查找。其输出如清单 10-37 中的所示。在本例中,DNS 查找仅限于 IPv4 地址。请注意,由于 Google 使用多个 IP 地址,您观察到的 IP 地址可能会有所不同。

清单 10-36 。执行 DNS 查找

var dns = require("dns");
var domain = "google.com";

dns.lookup(domain, 4, function(error, address, family) {
  if (error) {
    console.error("DNS lookup failed with code " + error.code);
  } else {
    console.log(domain + " -> " + address);
  }
});

清单 10-37 。清单 10-36 中代码的结果输出

$ node dns-lookup.js
google.com-> 74.125.226.229

resolve()

lookup()方法返回找到的第一个 IPv4 或 IPv6 DNS 记录。然而,还有其他类型的记录,并且每种类型可以有多个记录。要以数组格式检索特定类型的多个 DNS 记录,请使用resolve()来代替。resolve()的用法如清单 10-38 所示。

清单 10-38 。使用resolve()方法

dns.resolve(domain, [recordType], callback)

domain参数是要解析的域名。可选的recordType参数指定要查找的 DNS 记录的类型。表 10-2 列出了resolve()支持的各种 DNS 记录类型。如果没有提供recordTyperesolve()查找A记录(IPv4 地址记录)。第三个参数是在 DNS 查找之后调用的回调函数。一个可能的Error对象和一组 DNS 响应被传递给回调函数。

image注还有许多方法(如表 10-2 的第三列所示)用于解析特定类型的记录。每种方法的行为类似于resolve(),但是只适用于单一类型的记录,因此不需要recordType参数。例如,如果您对检索CNAME记录感兴趣,只需调用dns.resolveCname()

表 10-2 。resolve()支持的各种 DNS 记录类型

|

留档活字

|

描述

|

方法

|
| --- | --- | --- |
| A | IPv4 地址记录。这是resolve()的默认行为。 | dns.resolve4() |
| AAAA | IPv6 地址记录。 | dns.resolve6() |
| MX | 邮件交换记录。这些记录将一个域映射到邮件传输代理。 | dns.resolveMx() |
| TXT | 文字记录。这些记录应该包括人类可读的文本。 | dns.resolveTxt() |
| SRV | 服务定位器记录。这些记录将服务映射到位置。这些用于映射新协议,而不是为每个协议创建新的 DNS 记录类型。 | dns.resolveSrv() |
| PTR | 指针记录。这些记录用于反向 DNS 查找。 | 没有人 |
| NS | 名称服务器记录。这些委派一个 DNS 区域来使用给定的服务器名称。 | dns.resolveNs() |
| CNAME | 规范的名称记录。这些用于将一个域作为另一个域的别名。 | dns.resolveCname() |

清单 10-39 显示了通过查找与域google.com相关的 IPv6 地址(AAAA DNS 记录)来使用resolve()的例子。如果没有错误发生,域和地址数组将被打印到控制台。

清单 10-39 。使用resolve()查找google.com的 IPv6 地址

var dns = require("dns");
var domain = "google.com";

dns.resolve(domain, "AAAA", function(error, addresses) {
  if (error) {
    console.error("DNS lookup failed with code " + error.code);
  } else {
    console.log(domain + " -> " + addresses);
  }
});

反向查找

反向 DNS 查找将 IP 地址解析为域。在 Node 中,这种类型的查找是使用dns模块的reverse()方法 实现的。这个方法有两个参数,一个 IP 地址和一个回调函数。回调函数的参数是代表潜在错误的error和域名数组domains。在使用reverse()的例子中,如清单 10-40 所示,对www.google.com执行 DNS 查找。产生的 IP 地址然后用于执行反向 DNS 查找。

清单 10-40 。执行 DNS 查找,然后反向查找

var dns = require("dns");
var domain = "www.google.com";

dns.lookup(domain, 4, function(error, address, family) {
  dns.reverse(address, function(error, domains) {
    console.log(domain + " -> " + address + " -> " + domains);
  });
});

image 注意根据网站的 DNS 配置,反向搜索的结果可能会让你大吃一惊。如果一个站点没有建立任何PTR记录,反向查找可能是不可能的。例如,当清单 10-40 中的代码为www.nodejs.org运行时,反向查找返回undefined

Detecting Valid IP Addresses

为了结束这一章,让我们回到net模块,研究一些有用的实用方法。net模块提供了三种识别有效 IP 地址的方法:isIP()isIPv4()isIPv6()。每个方法都接受一个要测试的参数作为输入。isIP()检查其输入是否是有效的 IPv4 或 IPv6 地址。如果输入是 IPv4、IPv6 或无效,则isIP()返回460isIPv4()isIPv6()更具体,返回truefalse表示输入是否在给定的地址族中。列出 10-41 展示了在各种输入字符串上调用的所有三种方法。清单 10-42 显示了结果。

清单 。IP 地址分类

var net = require("net");
var input1 = "127.0.0.1";
var input2 = "fe80::1610:9fff:fee4:d63d";
var input3 = "foo";

function classify(input) {
  console.log("isIP('" + input + "') = " + net.isIP(input));
  console.log("isIPv4('" + input + "') = " + net.isIPv4(input));
  console.log("isIPv6('" + input + "') = " + net.isIPv6(input));
  console.log();
}

classify(input1);
classify(input2);
classify(input3);

清单 10-42 。清单中代码的输出 10-41

$ node ip-address-classification.js
isIP('127.0.0.1') = 4
isIPv4('127.0.0.1') = true
isIPv6('127.0.0.1') = false

isIP('fe80::1610:9fff:fee4:d63d') = 6
isIPv4('fe80::1610:9fff:fee4:d63d') = false
isIPv6('fe80::1610:9fff:fee4:d63d') = true

isIP('foo') = 0
isIPv4('foo') = false
isIPv6('foo') = false

摘要

本章提供了大量关于网络编程的信息。它的很多内容在 Node 的世界之外都是适用的。无论您使用哪种语言进行开发,对 IP、TCP、UDP 和 DNS 等流行网络主题的一般知识都会派上用场。当然,本章的主要焦点是网络编程,因为它与 Node 有关。到目前为止,您应该对netdgramdns核心模块有了很好的理解。但是,由于这些模块中的所有内容无法在一章中涵盖,因此建议您浏览 Node 文档,看看还有哪些内容是可行的。

这本书接下来的几章集中在创建 web 应用上。大多数人将 Node 与 web 服务器/应用联系在一起(尽管您现在应该意识到 Node 可以做更多事情)。由于 Web 应用主要使用建立在本章讨论的协议之上的更高级别的协议(如 HTTP ),所以您需要理解这里所涉及的内容。

十一、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 请求时被调用。事件处理程序接受两个参数,requestresponserequest参数是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(现在还在用)只支持GETPOSTHEAD请求。

表 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类访问。具体来说,这个例子使用了methodurlhttpVersion属性来重新创建请求行。

清单 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/jsonapplication/xmltext/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报头。

9781430258605_Fig11-01.jpg

图 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);

image 注意setHeader()创建的响应头可以用response.removeHeader()方法删除。该方法采用一个参数,即要移除的头的名称。你可能会问为什么这很重要。假设您有一个设置为使用缓存头进行缓存的资源。但是,在发送响应之前,会遇到一个错误。因为您不想缓存错误响应,所以可以使用removeHeader()方法来移除缓存头。

现在,尝试使用浏览器连接到清单 11-8 中的服务器。这一次,单词 HTTP 应该显示为粗体文本。图 11-2 显示了使用 Chrome 的结果页面,以及记录的 HTTP 请求。注意,响应头现在包括了Content-Type头。

9781430258605_Fig11-02.jpg

图 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 在客户机上维护状态更具可伸缩性,但安全性较低。

image 提示尽管 cookies 比服务器存储的状态更具可伸缩性,但您仍然应该谨慎使用它们。每个 HTTP 请求,包括对图像、脚本、样式表等的请求,站点的 cookies 都会在Cookie头中发送回服务器。所有这些数据都会增加网络延迟。缓解这个问题的一种方法是将静态资产(如图像)存储在不使用任何 cookies 的单独的域或子域中。

最简单的形式是,cookie 只是一个名称/值对,用等号分隔。使用分号作为分隔符将多个 cookies 连接在一起。清单 11-10 中的显示了两个Set-Cookie响应头的例子。这些 cookies 的名字是namefoo,而值分别是Colinbar。一旦这些 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 所示。这些属性中的一些——比如DomainPath—是给定值,而其他的——比如SecureHttpOnly—是布尔属性,它们的值要么被设置,要么不被设置。

表 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 显示了一个不执行任何处理的中间件的例子。注意,中间件有三个参数,requestresponsenext. requestresponse是您已经熟悉的用于处理请求的完全相同的对象。next是一个被调用来调用下一个中间件的函数。请注意,在本例中,next()已包含在return语句中。这不是必需的,但是在调用next()时返回以确保下一个中间件完成时不会继续执行是一个很好的做法。

清单 11-13 。中间件功能示例

function middleware(request, response, next) {
  return next();
}

连接

现在我们已经看到了中间件的样子,让我们用它来构建一些东西。第一步是安装连接模块(npm install connect)。Connect 将自己标榜为“node.js 的高质量中间件”。Connect 不仅允许您使用自己的中间件构建应用,而且它还捆绑了一些非常有用的中间件。还有大量免费的第三方中间件使用 Connect 构建。

安装 Connect 之后,创建一个新的服务器,包含清单 11-14 中所示的代码。这个例子的前两行导入了httpconnect模块。在第三行,使用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);

image 注意查询字符串是 URL 的可选部分,用于传递特定于请求的参数。问号(?)用于将请求的资源与查询字符串分开。在查询字符串中,各个参数被格式化为parameter=value。&符号(&)用于分隔参数-值对。

来自清单 11-15 的控制台输出示例如清单 11-16 所示。要重新创建这个输出,只需将浏览器指向http://localhost:8000?foo=bar&fizz=buzz。注意,query()成功地提取了两个查询字符串参数foofizz,以及它们的值barbuzz

清单 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()有两个参数,optionscallback. 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 套接字。如果使用该选项,则应省略hostnameport,反之亦然。 |

清单 11-17 中显示了一个与我们的示例服务器一起工作的客户端。客户端向http://localhost:8000/发出GET请求。传递给request()的几个选项可以省略,因为它们是缺省值(即hostnamepathmethod),但为了示例起见,它们已经被包括在内。当收到响应时,回调函数通过打印出状态行、标题和正文来重新创建 HTTP 响应。使用流data事件处理程序将主体显示为 UTF-8 数据。需要注意的一件重要事情是在示例的最后一行调用了end()。如果这是一个POSTPUT请求,可能会有一个通过调用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-18http.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-19http.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-TypeContent-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的对象是用两个属性firstlast创建的。

清单 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 请求的过程。在撰写本文时,requestnpm注册表中第三大依赖模块。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 | 如果是truerequest将自动跟随 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.pemcert.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();

image 注意当我们谈到 HTTPS 客户的话题时,值得指出的是request模块与 HTTPS 完全兼容。

摘要

本章介绍了大量与 HTTP 相关的材料。虽然 HTTP 不是一个非常复杂的协议,但是为了正确使用 HTTP,必须理解许多相关的概念。这一章也提到了像 cookies 和通过 HTTPS 协议的安全性这样的主题。除了核心的httphttpsquerystring模块,本章还介绍了connectrequest,它们是npm注册表中最受欢迎的两个模块。下一章专门介绍 Express,这是一个基于httpconnect构建的用于创建 web 应用的框架。因此,在进入下一章之前,理解这里的内容是很重要的。

十二、Express 框架

在《??》第十章中,你学习了如何使用net模块创建低级 TCP 应用。然后,在第十一章中,使用http模块抽象出 TCP 的底层细节。向更高抽象层次的转移允许我们做更多的事情,同时编写更少的代码。第十一章还通过连接库介绍了中间件的概念。中间件促进代码重用,并使您能够以流水线的方式请求处理。然而,使用httpconnect模块创建复杂的应用仍然有点乏味。

TJ Holowaychuk 创建的 Express 框架,在httpconnect之上提供了另一个抽象层次。Express 基于 Ruby 的 Sinatra 框架,并标榜自己是“一个最小且灵活的 Node.js web 应用框架,为构建单页面、多页面和混合 web 应用提供了一组强大的功能。”Express 为许多常见的任务提供了方便的方法和语法糖,否则这些任务将是乏味的或多余的。本章详细分析了 Express 框架。而且记住,因为 Express 是建立在httpconnect之上的,所以你在第十一章中学到的一切都是适用的。

快速路线

在看 Express 提供了什么之前,让我们先确定一下httpconnect的一些缺点。清单 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 的默认行为。

image 注意 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);

image 注意可选的命名参数后面都是问号。例如,在前面的例子中,如果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 连接到/路线的结果。

9781430258605_Fig12-01.jpg

图 12-1 。骷髅 app 返回的索引页

检查骨架应用

app.js是快递 app 的心脏。在清单 12-7 中生成的app.js文件的内容如清单 12-9 所示。该文件首先导入expresshttppath模块,以及两个项目文件/routes/index.js/routes/user.js。从routes目录导入的两个文件包含框架应用的路由所使用的中间件。在require()语句之后,使用express()函数创建一个快速应用。

清单 12-9app.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'));
});

image 注意如果传递给require()的模块路径解析为一个目录,Node 将在该目录中寻找一个index文件。这就是为什么表达require("./routes")解析为/routes/index.js

接下来,您将看到对应用的set()方法的三次调用,该方法用于定义应用设置。第一个调用定义了一个名为port的设置,它定义了服务器将绑定到的端口号。端口号默认为 3000,但是这个值可以通过定义一个名为PORT的环境变量来覆盖。接下来的两个设置,viewsview 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 表单发出GETPOST请求。要发出其他类型的请求(PUTDELETE等等),表单可以包含一个名为X-HTTP-Method-Override的输入,其值是所需的请求类型。这个中间件检测到这种情况,并相应地设置request.method属性。 |
| app.router | 这是用于将传入请求映射到定义的路由的快速路由。如果没有明确使用它,Express 将在第一次遇到路由时装载它。然而,手动安装路由将确保它在中间件序列中的位置。 |
| static | 这个中间件接受一个目录路径作为输入。该目录被视为静态文件服务器的根目录。这对于提供图像、样式表和其他静态资源等内容非常有用。在骷髅 app 中,静态目录是public。 |
| errorHandler | 顾名思义,errorHandler是处理错误的中间件。与其他中间件不同,errorHandler接受四个参数— errorrequestresponsenext。在 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文件中定义两个设置一样简单。这些设置是viewsview engineviews设置指定了 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-13render()的使用示例

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-16express-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()。前三种方法验证输入是整数、电子邮件地址还是nullis()方法接受一个正则表达式参数,并验证该参数是否与之匹配。contains()也接受一个参数,并检查参数是否包含它。

express-validator还为req附加了一个sanitize()方法,用于清理输入。清单 12-17 显示了sanitize()的几个例子。前两个示例分别将参数值转换为布尔值和整数。第三个示例删除了参数开头和结尾多余的空白。最后一个例子用相应的字符(<>)替换字符实体(比如&lt;&gt;)。

清单 12-17express-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 的POSTGETPUTDELETE动词来实现。此外,服务器应该尽可能使用适当的状态代码进行响应。

RESTful API 示例

Express 使得 RESTful 应用的实现变得非常简单。在接下来的几个例子中,我们将创建一个 RESTful API 来操作服务器上的文件。API 更常用于操作数据库条目,但是我们还没有涉及数据库。我们的示例应用也被分成许多文件。这使得示例更具可读性,同时也使得应用更加模块化。

首先,我们从app.js 开始,如清单 12-18 所示。这其中的大部分应该看起来很熟悉。然而,增加了一个额外的中间件来定义req.store。这是包含应用将使用的文件的目录。路线声明也被删除了,取而代之的是对文件routes.js中定义的自定义函数routes.mount(). mount()的调用,该函数将 Express app 作为其唯一的参数。

清单 12-18app.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.jsread.jsupdate.jsdelete.js)。需要指出的一点是,delete既是 HTTP 动词又是 JavaScript 保留字,所以在某些地方将delete操作简称为del

清单 12-19routes.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参数组成。要写入文件的数据来自名为dataPOST主体参数。然后使用fs.writeFile()方法创建新文件。文件是使用wx标志创建的,如果文件已经存在,这会导致操作失败。在writeFile()回调中,我们返回一个400状态码来表明请求不能被满足,或者返回一个201来表明一个新文件被创建。

清单 12-20create.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-21read .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-22update.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-23delete.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 使用wswss协议。标准 WebSockets 使用ws协议,默认情况下使用端口 80。另一方面,安全 WebSockets 使用wss协议,默认端口为 443。

清单 13-1WebSocket()构造函数

WebSocket(url, [protocols])

构造函数的第二个参数protocols是可选的。如果指定了它,它应该是一个字符串或字符串数组。字符串是子协议名称。使用子协议允许单个服务器同时处理不同的协议。

关闭 WebSockets

要关闭 WebSocket 连接,使用close()方法,其语法如清单 13-2 所示。close()带两个参数,codereason,都是可选的。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
});

image 注意除了处理字符串数据,WebSockets 还支持两种类型的二进制数据——二进制大型对象( Blob s)和ArrayBuffers。然而,一个单独的 WebSocket 一次只能处理两种二进制格式中的一种。当一个 WebSocket 被创建时,它最初被设置为处理Blob数据。WebSocket 的binaryType属性用于在BlobArrayBuffer支持之间进行选择。为了处理Blob数据,WebSocket 的binaryType应该在读取数据之前设置为"blob"。类似地,在试图读取一个ArrayBuffer之前,应当将binaryType设置为"arraybuffer"

close事件

当 WebSocket 关闭时,会触发一个close事件。传递给close处理程序的事件对象有三个属性,名为codereasonwasCleancodereason字段对应于传递给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事件。传递给处理程序的事件是一个标准的错误对象,包括namemessage属性。一个 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 中的代码是一个使用wshttpconnect模块构建的 WebSocket echo 服务器。此服务器接受端口 8000 上的 HTTP 和 WebSocket 连接。Connect 的static中间件允许通过 HTTP 从public子目录提供任意静态内容,而ws处理 WebSocket 连接。

清单 13-10 。使用wshttpconnect模块构建的 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()构造函数,可以构建一个没有httpconnect的纯 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" />&nbsp;
  <input id="close" type="button" value="Disconnect" /><br />
  <input id="send" type="button" value="Send" />&nbsp;
  <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 协议。

9781430258605_Fig13-01.jpg

图 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.IOws一样,很容易和http模块结合。清单 13-13 显示了另一个组合了 HTTP 和 WebSockets(通过Socket.IO)的 echo 服务器。清单 13-13 的第三行导入了Socket.IO模块。Socket.IO listen()方法强制Socket.IO监听 HTTP 服务器server。然后由listen()io返回的值用于配置应用的 WebSockets 部分。

清单 13-13 。使用httpconnectSocket.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.IOhttp整合在一起,连接起来没多大区别。清单 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)以及他们居住的城市和州(为了节省空间,省略了地址等信息)。

9781430258605_Fig14-01.jpg

图 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 所示。

9781430258605_Fig14-02.jpg

图 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-13connection.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 }

image 注意当向具有自动增量主键的表中插入行时,结果对象的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");

image 注意在 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 的每个细节),但是通过理解核心概念,您应该能够将您在这里学到的知识应用到其他系统中。

十五、日志记录、调试和测试

任何语言的产品代码都必须具有某种玩具或学术程序所缺乏的光泽。本章探讨了日志、调试和测试的主题,这将提高代码质量,同时减少诊断和修复 bug 所需的时间。通过记录有用的信息和错误,您可以更容易地修复出现的错误。调试器是任何程序员工具箱中的一个关键工具,因为它允许用细齿梳子探索代码,检查变量并找到 bug。最后,测试是系统地识别计算机程序中的错误的过程。本章着眼于用于日志记录、调试和测试的几个突出的模块和框架。

记录日志

在第五章的中,您通过console.log()console.error()方法学习了最基础的日志记录。首先要注意的是,不同类型的消息有不同的日志记录方法。例如,在清单 15-1 中,fs模块用于打开一个名为foo.txt的文件。如果文件成功打开,则使用console.log()stdout打印一条消息。然而,如果出现错误,则使用console.error()将其记录到stderr中。

清单 15-1 。包括错误和成功日志的示例

var fs = require("fs");
var path = "foo.txt";

fs.open(path, "r", function(error, fd) {
  if (error) {
    console.error("open error:  " + error.message);
  } else {
    console.log("Successfully opened " + path);
  }
});

这种方法的缺点是必须有人监视控制台来检测错误。但是,通常生产应用被部署到一个或多个服务器上,这些服务器与最初开发应用的机器是分开的。这些生产服务器通常位于服务器机房、数据中心或云上,没有人监控终端窗口的错误。即使有人在监控控制台,错误也很容易从屏幕上消失,永远消失。由于这些原因,在生产环境中通常不鼓励打印到控制台。

在生产环境中,记录到文件比控制台记录更可取。不幸的是,fs模块并不适合日志记录。理想情况下,日志代码应该像console.log()调用一样与应用代码融合在一起。然而,文件操作的异步特性导致代码块包含回调函数和错误处理。回想一下,fs模块也为它的许多方法提供了同步等价物。应该避免这些,因为它们会成为应用中的主要瓶颈。

winston模块

Node 的核心模块没有提供理想的日志记录解决方案。幸运的是,开发人员社区已经创建了许多有用的第三方日志模块。其中最好的是winston,它是一个异步日志库,保持了console.log()的简单接口。清单 15-2 展示了winston是如何被导入并在一个简单的应用中使用的。当然,你必须首先npm install winston才能使用该模块。清单 15-2 展示了如何使用winston.log()方法。传递给log()的第一个参数是日志级别。默认情况下,winston提供日志级别infowarnerrorlog()的第二个参数是记录的消息。

清单 15-2 。使用winston记录不同级别的信息

var winston = require("winston");

winston.log("info", "Hello winston!");
winston.log("warn", "Something not so good happened");
winston.log("error", "Something really bad happened");

清单 15-2 的输出显示在清单 15-3 的中。请注意,winston在输出消息之前显示日志级别。

清单 15-3 。清单 15-2 中的输出

$ node winston-basics.js
info: Hello winston!
warn: Something not so good happened
error: Something really bad happened

winston还为各种日志级别提供了方便的方法。这些方法(info()warn()error())如清单 15-4 所示。这段代码的输出与清单 15-3 中的相同。

清单 15-4 。使用日志级方法重写清单 15-2

var winston = require("winston");

winston.info("Hello winston!");
winston.warn("Something not so good happened");
winston.error("Something really bad happened");

到目前为止描述的所有日志记录方法都支持使用util.format() 占位符的字符串格式化。关于util.format()的复习,请参见第五章中的。可以提供一个可选的回调函数作为日志记录方法的最终参数。此外,通过在任何格式占位符后提供参数,可以将元数据附加到日志消息中。清单 15-5 显示了这些功能的实际应用。在本例中,如果出现错误,winston会记录一条包含path变量的值的消息。此外,实际的错误会作为元数据传递给winston。文件foo.txt不存在时的输出示例如清单 15-6 所示。

清单 15-5 。包含格式和元数据的日志示例

var winston = require("winston");
var fs = require("fs");
var path = "foo.txt";

fs.open(path, "r", function(error, fd) {
  if (error) {
    winston.error("An error occurred while opening %s.", path, error);
  } else {
    winston.info("Successfully opened %s.", path);
  }
});

清单 15-6?? 清单 15-5 文件不存在时的结果输出

$ node winston-formatting.js
error: An error occurred while opening foo.txt. errno=34, code=ENOENT, path=foo.txt

Transports

winston广泛使用运输工具。传输本质上是日志的存储设备。winston支持的核心运输类型有ConsoleFileHttp。顾名思义,Console传输用于将信息记录到控制台。File传输用于记录输出文件或任何其他可写流。Http传输用于将数据记录到任意 HTTP(或 HTTPS)端点。默认情况下,winston记录器只使用Console传输,但这是可以改变的。一个记录器可以有多个传输,或者根本没有传输。

使用add()方法可以将附加传输附加到记录器上。add()接受两个参数,一个传输类型和一个选项对象。支持的选项在表 15-1 中列出。值得注意的是,支持的选项因传输类型而异。类似地,使用remove()方法移除现有的传输。remove()方法接受传输类型作为它唯一的参数。

表 15-1 。winston 核心传输支持的选项

|

[计]选项

|

描述

|
| --- | --- |
| level | 传输使用的日志级别。 |
| silent | 用于禁止输出的布尔值。默认为false。 |
| colorize | 用于使输出丰富多彩的布尔标志。默认为false。 |
| timestamp | 导致时间戳包含在输出中的布尔标志。默认为false。 |
| filename | 要记录输出的文件的名称。 |
| maxsize | 日志文件的最大大小(以字节为单位)。如果超过该大小,将创建一个新文件。 |
| maxFiles | 超过日志文件大小时,可创建的最大日志文件数。 |
| stream | 要记录输出的可写流。 |
| json | 一个布尔标志,启用时会导致数据被记录为 JSON。默认为true。 |
| host | 用于 HTTP 日志记录的远程主机。默认为localhost。 |
| port | 用于 HTTP 日志记录的远程端口。默认为80443,取决于使用的是 HTTP 还是 HTTPS。 |
| path | 用于 HTTP 日志记录的远程 URI。默认为/。 |
| auth | 一个对象,如果包含的话,应该包含一个usernamepassword字段。这用于 HTTP 基本身份验证。 |
| ssl | 一个布尔标志,如果启用,将导致使用 HTTPS。默认为false。 |

清单 15-7 显示了如何移除传输并将其添加到winston记录器中。在本例中,默认的Console传输被删除。然后添加一个新的Console传输,它只响应错误消息。新的传输还打开了彩色化和时间戳。注意,remove()add()方法可以链接在一起。配置完winston后,通过调用info()error()测试新设置。对于对error()的调用,输出将显示带有时间戳的彩色消息,但是对info()的调用将不会显示任何内容,因为没有信息级日志的传输。

清单 15-7 。使用winston添加和移除传输

var winston = require("winston");

winston
  .remove(winston.transports.Console)
  .add(winston.transports.Console, {
    level: "error",
    colorize: true,
    timestamp: true
  });

winston.info("test info");
winston.error("test error");

Creating New Loggers

默认的记录器使用winston对象,如前面的例子所示。也可以使用winston.Logger()构造函数创建新的日志对象。清单 15-8 中的例子创建了一个带有两个传输的新记录器。第一个传输将彩色输出打印到控制台。第二个传输将错误转储到文件output.log。为了测试新的记录器,对info()进行一次调用,对error()进行另一次调用。两个日志记录调用都将被打印到控制台;但是,只有错误会打印到输出文件中。

清单 15-8 。使用winston创建新的记录器

var winston = require("winston");
var logger = new winston.Logger({
  transports: [
    new winston.transports.Console({
      colorize: true
    }),
    new winston.transports.File({
      level: "error",
      filename: "output.log"
    })
  ]
});

logger.info("foo");
logger.error("bar");

调试

调试是定位和修复软件错误的过程。调试器是帮助加速这一过程的程序。除此之外,调试器允许开发人员一步一步地执行指令,一路上检查变量的值。调试器对于诊断程序崩溃和意外值非常有用。V8 带有一个内置的调试器,可以通过 TCP 访问。这允许通过网络调试 Node 应用。不幸的是,内置调试器的命令行界面并不友好。

要访问调试器,必须用debug参数调用 Node。因此,如果你的应用存储在app.js中,你需要执行清单 15-9 中所示的命令。

清单 15-9 。运行应用时启用 Node 的调试器

node debug app.js

image 注意提供debug参数会使 Node 启动一个交互式调试器。但是,您也可以提供一个--debug(注意连字符)选项,这将使调试器侦听端口 5858 上的连接。第三个选项--debug-brk,让调试器监听端口 5858,同时在第一行设置一个断点。

然后,您可以像在任何其他调试器中一样逐句通过代码。用于单步执行代码的命令如表 15-2 所示。

表 15-2 。Node 调试器支持的指令步进命令

|

命令

|

描述

|
| --- | --- |
| contc | 继续执行。 |
| nextn | 跳到下一条指令。 |
| steps | 单步执行函数调用。 |
| outo | 跳出函数调用。 |
| pause | 暂停正在运行的代码。 |

您可能不希望单步执行整个应用。因此,还应该设置断点。添加断点最简单的方法是在源代码中添加debugger语句。这些语句将导致调试器停止执行,但如果调试器不在使用中,这些语句将被忽略。清单 15-10 中所示的例子将导致调试器在第二次给foo赋值之前暂停。

清单 15-10 。包含一个debugger语句的示例应用

var foo = 2;
var bar = 3;

debugger;
foo = foo + bar;

附加调试器后,发出contc命令继续执行debugger语句。此时,foo的值为 2,bar的值为 3。您可以通过输入repl命令来确认这一点,这将调用第一章中的 REPL。在 REPL 内,键入foobar检查变量值。接下来,按 Control+C 退出 REPL。发出两次next(或n)命令,跳过第二条赋值语句。通过再次启动 REPL,您可以验证该值是否已更新为 5。

前面的例子展示了使用 Node 调试器的一般流程。如前所述,调试器不完全是用户友好的。幸运的是,有一个名为node-inspector 的第三方模块,它允许 Node 的调试器以一种用户友好的方式与谷歌 Chrome 的开发者工具进行交互。在进入node-inspector之前,花点时间回顾一下 Node 调试器支持的其他一些命令,这些命令在表 15-3 中显示。

表 15-3 。Node 调试器支持的其他命令

|

命令

|

描述

|
| --- | --- |
| setBreakpoint()sb() | 在当前行设置断点。由于这些都是函数,您还可以传递一个参数来指定要设置断点的行号。可以使用语法sb("script.js", line)在特定文件的行号上设置断点。 |
| clearBreakpoint()cb() | 清除当前行上的断点。当使用sb()时,您可以传递参数来清除特定行上的断点。 |
| backtracebt | 打印当前执行帧的回溯。 |
| watch(expr) | 将由expr指定的表达式添加到观察列表。 |
| unwatch(expr) | 从观察列表中删除由expr指定的表达式。 |
| watchers | 列出所有观察者及其值。 |
| run | 运行脚本。 |
| restart | 重新启动脚本。 |
| kill | 扼杀了剧本。 |
| list(n) | 显示带有n行上下文的源代码(当前行之前的n行和当前行之后的n行)。 |
| scripts | 列出所有加载的脚本。 |
| version | 显示 v8 的版本。 |

node-inspector模块

本节不提供使用 Chrome 开发工具的教程。幸运的是,它们相当简单明了,而且网上有丰富的内容。本节将引导您完成在机器上设置和运行node-inspector的过程。你需要在你的机器上安装最新版本的 Chrome。您还需要使用清单 15-11 中的命令来全局安装node-inspector

清单 15-11 。全局安装node-inspector模块

npm install node-inspector -g

接下来,使用清单 15-12 中显示的命令启动清单 15-10 中的应用(保存在app.js)。注意已经使用了--debug-brk标志。这是因为我们不想使用交互式调试器的命令行界面。

清单 15-12 。使用 - debug-brk 标志启动应用

$ node --debug-brk app.js

接下来,在一个单独的终端窗口中,使用清单 15-13 中的命令启动node-inspector

清单 15-13 。启动node-inspector应用

$ node-inspector

启动node-inspector后,应该会看到一些终端输出。该输出将包括访问 URL 的方向。这个 URL 很可能是清单 15-14 中显示的那个。在 Chrome 中访问该 URL。页面应该看起来像图 15-1 。

清单 15-14 。运行node-inspector时要访问的 URL

http://127.0.0.1:8080/debug?port=5858

9781430258605_Fig15-01.jpg

图 15-1 。连接到清单 15-14 中的链接时的 Chrome 视图

打开 Chrome 时,执行会在一个断点处暂停。按下窗口右侧面板上的小播放按钮,恢复执行。这将导致应用执行,直到到达下一个断点,此时 Chrome 将看起来像图 15-2 。请注意图像右侧的范围变量部分。此部分允许您查看当前范围内的变量及其值。在图 15-2 中,可以看到foo等于 2,bar等于 3。

9781430258605_Fig15-02.jpg

图 15-2 。Chrome 的视图在调试器语句处停止

然后,在观察变量更新的同时,您可以使用控件单步执行、遍历和跳出指令和函数。此外,您可以单击 Console 选项卡来打开一个交互式控制台,用于检查值和执行代码。

测试

测试是软件开发过程中至关重要的部分。软件公司有专门的测试部门是非常重要的。本节的目标不是提供软件测试的全面覆盖。有许多书籍致力于各种软件测试方法。相反,这一节教你如何使用核心assert模块以及灵活的 JavaScript 测试框架 Mocha 编写单元测试。

assert模块

是一个核心模块,用于编写简单的单元测试。assert提供了将计算值(称为实际值)与预期值进行比较的便利方法,如果结果不是预期的,则抛出异常。清单 15-15 中显示了一个断言示例。在这个例子中,一个值被计算并存储在变量actual中。期望值也存储在expected变量中。然后将实际值和期望值作为第一个和第二个参数传递给assert.strictEqual()方法。正如方法名所暗示的,这两个值使用严格的等式进行比较(===操作符)。在这种情况下,断言测试通过,所以什么都不会发生。

清单 15-15 。使用严格等于断言的示例测试

var assert = require("assert");
var actual = 2 + 3;
var expected = 5;

assert.strictEqual(actual, expected);

清单 15-16 检查了断言失败的情况。在本例中,实际值是浮点数 0.1 和 0.2 的和,而预期值是 0.3。基础数学会让你相信断言会被通过。然而,由于浮点数学的工作方式,总和并不正好是 0.3。这会导致断言失败,并抛出如清单 15-17 所示的异常。

清单 15-16 。一个失败断言的例子

var assert = require("assert");
var actual = 0.1 + 0.2;
var expected = 0.3;

assert.strictEqual(actual, expected);

通过检查清单 15-17 中的错误信息,您可以看到实际值包含极少量的误差。这是在 JavaScript 中执行数学运算时必须考虑的事情。

清单 15-17 。清单 15-16 中的代码导致的异常

AssertionError: 0.30000000000000004 === 0.3

基本断言方法还带有一个可选的第三个参数,用于指定自定义错误消息。清单 15-16 在清单 15-18 中被重写,以包含一条自定义消息。当这段代码运行时,您会看到错误消息"AssertionError: JavaScript math is quirky"

清单 15-18 。创建带有自定义错误消息的断言

var assert = require("assert");
var actual = 0.1 + 0.2;
var expected = 0.3;

assert.strictEqual(actual, expected, "JavaScript math is quirky");

除了strictEqual()之外,assert模块还拥有许多其他方法,用于创建各种类型的断言。这些像strictEqual()一样使用的方法在表 15-4 中进行了总结。

表 15-4 。附加断言方法

|

方法

|

描述

|
| --- | --- |
| equal() | 使用==比较运算符执行简单的相等检查。使用浅层检查,两个对象不会被评估为相等,除非它们实际上是同一个对象。 |
| notEqual() | 使用!=比较运算符执行不相等的浅层检查。 |
| deepEqual() | 对相等性执行深度检查。通过使用深度检查,通过比较对象中存储的键和值来确定是否相等。 |
| notDeepEqual() | 对不平等执行深度检查。 |
| notStrictEqual() | 使用!==比较运算符检查严格不等式。 |
| ok() | ok()只接受两个参数— value和一个可选的message。这个方法是assert.equal(true, !!value, message)的简写。换句话说,这个方法测试提供的值是否是truthy。 |
| assert() | 这个功能的用法和ok()完全一样。然而,这不是assert模块的方法,而是assert模块本身的方法。这个函数是require("assert")返回的值。 |

The``throws()``Method

assert模块还提供了throws()方法来验证给定的函数是否像预期的那样抛出异常。清单 15-19 中显示了一个throws()的例子。block参数是测试中的函数,预计会抛出异常。如果block没有抛出异常,断言将会失败。稍后将再次讨论error的论点。可选的message参数的行为方式与之前讨论的断言方法相同。

清单 15-19 。使用assert.throws()

assert.throws(block, [error], [message])

可选的error参数用于验证是否抛出了正确的异常。该参数可以是构造函数、正则表达式对象或用户定义的验证函数。如果error是一个构造函数,那么使用instanceof操作符来验证异常对象。如果error是一个正则表达式,那么通过测试匹配来执行验证。如果error是一个非构造函数,那么如果error被验证,该函数应该返回true

举个例子,假设你正在测试一个执行除法的函数。如果出现被零除的情况,那么被测试的函数应该抛出一个异常。否则,该函数应该返回除法运算的商。清单 15-20 显示了这个除法函数的定义,以及几个使用throws()的成功断言测试。bind()方法创建了divide()方法的副本,其numeratordenominator参数被绑定到特定的值。在每个示例测试用例中,denominator被绑定为零,以确保抛出异常。

清单 15-20 。使用 assert.throws() 测试除法函数

var assert = require("assert");

function divide(numerator, denominator) {
  if (!denominator) {
    throw new RangeError("Division by zero");
  }

  return numerator / denominator;
}

assert.throws(divide.bind(null, 1, 0));
assert.throws(divide.bind(null, 2, 0), RangeError);
assert.throws(divide.bind(null, 3, 0), Error);
assert.throws(divide.bind(null, 4, 0), /Division by zero/);
assert.throws(divide.bind(null, 5, 0), function(error) {
  return error instanceof Error && /zero/.test(error.message);
});

在清单 15-20 中,所有的断言都是成功的。清单 15-21 包括许多会抛出异常的示例断言。第一个断言失败是因为denominator不为零,所以没有抛出异常。第二个断言失败,因为抛出了一个RangeError,但是提供了TypeError构造函数。第三个断言失败,因为正则表达式/foo/与抛出的异常不匹配。第四个断言失败,因为验证函数返回了false

清单 15-21 。使用 assert.throws() 方法的断言无效

var assert = require("assert");

function divide(numerator, denominator) {
  if (!denominator) {
    throw new RangeError("Division by zero");
  }

  return numerator / denominator;
}

assert.throws(divide.bind(null, 1, 1));
assert.throws(divide.bind(null, 2, 0), TypeError);
assert.throws(divide.bind(null, 3, 0), /foo/);
assert.throws(divide.bind(null, 4, 0), function(error) {
  return false;
});

The``doesNotThrow()``Method

throws()的反函数是doesNotThrow(),期望一个函数不抛出异常。doesNotThrow()功能如清单 15-22 中的所示。block参数是被测函数。如果block抛出一个异常,那么断言失败。可选的message参数的行为与之前讨论的断言方法一样。

清单 15-22 。使用assert.doesNotThrow()

assert.doesNotThrow(block, [message])

The``ifError()``Method

ifError()方法对于测试回调函数的第一个参数很有用,它通常用于传递错误条件。因为错误参数通常是nullundefined,所以ifError()方法检查falsy值。如果检测到truthy值,则断言失败。例如,清单 15-23 中显示的断言通过,而清单 15-24 中显示的断言失败。

清单 15-23 。使用assert.ifError()成功断言

var assert = require("assert");

assert.ifError(null);

清单 15-24 。使用 assert.ifError() 断言失败

var assert = require("assert");

assert.ifError(new Error("error"));

Mocha 测试框架

模块对于编写小而简单的单元测试很有用。然而,非常复杂的程序通常有大型的测试套件来验证应用的每个特性。运行全面的测试套件也有助于回归测试——现有功能的测试,以确保新代码的添加不会破坏现有代码。此外,当发现新的错误时,可以为它创建一个单元测试,并将其添加到测试套件中。为了管理和运行大型测试套件,您应该求助于测试框架。有许多可用的测试框架,但是这一节主要讨论 Mocha。Mocha 由 Express 的创始人 TJ Holowaychuk 创建,并标榜自己是“一个简单、灵活、有趣的 Node.js 和浏览器 JavaScript 测试框架。”

Running Mocha

摩卡必须安装后才能使用。尽管 Mocha 可以逐个项目地安装,但使用清单 15-25 中的命令全局安装更简单。

清单 15-25 。全球安装 Mocha 框架

$ npm install -g mocha

通过全局安装 Mocha,您可以使用mocha命令直接从命令行启动它。默认情况下,mocha会尝试执行test子目录中的 JavaScript 源文件。如果test子目录不存在,它将在当前目录中查找名为test.js的文件。或者,您可以通过简单地在命令行上提供文件名来指定一个测试文件。清单 15-26 显示了在一个空目录中运行mocha的示例输出。输出显示了成功运行的测试数量,以及它们所花费的时间。在这种情况下,没有运行测试,运行mocha有 1 毫秒的开销。

清单 15-26 。在没有测试的情况下运行mocha的示例输出

$ mocha

  0 passing (1ms)

Creating Tests

Mocha 允许在一个 JavaScript 源文件中定义多个测试。理论上,一个项目的整个测试套件可以包含在一个文件中。然而,为了清晰和简单起见,只有相关的测试应该放在同一个文件中。使用it()功能创建单独的测试。it()接受两个参数,一个描述测试内容的字符串和一个实现测试逻辑的函数。清单 15-27 显示了可能的最简单的测试。该测试实际上不做任何事情,但是当使用mocha运行时,它将被报告为通过测试。这个测试通过的原因是因为它没有抛出异常。在 Mocha 中,如果一个测试抛出一个异常,它就被认为是失败的。

清单 15-27 。微不足道的摩卡测试

it("An example test", function() {
});

关于清单 15-27 中的测试用例,另一件值得注意的事情是 Mocha 从未被导入,然而it()函数是可用的。如果您要在 Node 中直接执行这个测试,您会看到一个错误,因为没有定义it()。然而,通过mocha运行测试,it()和其他摩卡功能被纳入范围。

Creating Test Suites

Mocha 使用describe()方法将测试组合成套件。describe()需要两个参数。第一个是提供测试套件描述的字符串。第二个参数是包含零个或多个测试的函数。包含两个测试的测试套件的例子如清单 15-28 所示。

清单 15-28 。包含两个测试的简单测试套件

describe("Test Suite 1", function() {
  it("Test 1", function() {
  });

  it("Test 2", function() {
  });
});

image 注意尽管测试套件对于将相关的测试组合在一起很有用,但它们并不是必需的。如果没有指定测试套件,所有的测试都将被放置在 Mocha 预先存在的、未命名的全局测试套件中。

Mocha 还支持测试套件的嵌套。例如,假设您正在为一个框架中的多个类创建测试。每个类都值得拥有自己的测试套件。然而,如果一个类足够复杂,那么您可能想要为单个功能创建测试套件,比如方法。清单 15-29 提供了一个如何构建测试套件的例子。请注意,该示例使用了嵌套套件。

清单 15-29 。嵌套测试套件的一个例子

describe("Class Test Suite", function() {
  describe("Method Test Suite", function() {
    it("Method Test 1", function() {
    });

    it("Method Test 2", function() {
    });
  });
});

Testing Asynchronous Code

Mocha 还使得测试异步代码变得极其容易,这对于使用 Node 是绝对必要的。要创建一个异步测试,只需将一个回调函数传递给it()。按照惯例,这个回调函数被命名为done(),并作为参数传递给传递给it()的函数。当测试完成时,只需调用done(),如清单 15-30 所示。

清单 15-30 。清单 15-27 中的 Mocha 测试被重写为异步的

it("An example asynchronous test", function(done) {
  done();
});

定义失败

如果测试没有产生预期的结果,它被认为是失败的。Mocha 将失败定义为任何抛出异常的测试。这使得 Mocha 与本章前面讨论的assert模块兼容。清单 15-31 显示了一个练习字符串indexOf()方法的示例测试。这个简单的测试验证了当没有找到搜索的字符串时,indexOf()返回-1。由于在字符串"Hello Mocha!"中没有找到字符串"World""Goodbye",两个断言都将通过。然而,如果str的值被更改为"Hello World!",那么第一个断言将抛出一个异常,导致测试失败。

清单 15-31 。带有断言的示例测试

var assert = require("assert");

it("Should return -1 if not found", function() {
  var str = "Hello Mocha!";

  assert.strictEqual(str.indexOf("World"), -1);
  assert.strictEqual(str.indexOf("Goodbye"), -1);
});

清单 15-32 中显示了一个包含断言的异步测试的例子。在这个例子中,fs.exists()方法确定文件是否存在。在这种情况下,我们假设文件确实存在,因此测试将通过。

清单 15-32 。包含断言的异步测试

var assert = require("assert");
var fs = require("fs");

it("Should return true if file exists", function(done) {
  var filename = "foo.txt";

  fs.exists(filename, function(exists) {
    assert(exists);
    done();
  });
});

image 注意 Error对象可以在异步测试中直接传递给done()。这样做会导致测试失败,就像抛出了异常一样。

Test Hooks

Mocha 支持在测试执行前后调用的可选钩子。这些挂钩用于在测试运行前设置测试数据,并在测试完成后清理数据。这些前/后挂钩有两种风格。第一个在整个测试套件运行之前执行,第二个在整个测试套件运行之后执行。这些钩子是使用before()after()函数实现的。第二种挂钩在每次单独测试之前和之后运行。要实现这种类型的挂钩,使用beforeEach()afterEach()功能。这四个函数都将一个钩子函数作为唯一的参数。如果钩子执行异步代码,那么应该以与it()函数相同的方式提供一个done()回调。

清单 15-33 展示了如何在 Mocha 测试套件中使用钩子。这个例子包括了所有四种类型的钩子。为了说明执行流程,运行这个测试套件的输出如清单 15-34 所示。注意,首先和最后要执行的是通过before()after()提供的钩子。还要注意,after()钩子已经用异步方式实现了,尽管钩子函数是同步的。接下来,注意每个单独的测试都是在调用beforeEach()afterEach()钩子之间运行的。

清单 15-33 。包含测试挂钩和两个测试的测试套件

describe("Test Suite", function() {
  before(function() {
    console.log("Setting up the test suite");
  });

  beforeEach(function() {
    console.log("Setting up an individual test");
  });

  afterEach(function() {
    console.log("Tearing down an individual test");
  });

  after(function(done) {
    console.log("Tearing down the test suite");
    done();
  });

  it("Test 1", function() {
    console.log("Running Test 1");
  });

  it("Test 2", function() {
    console.log("Running Test 2");
  });
});

清单 15-34 。运行清单 15-33 中测试套件的控制台输出

$ mocha

  Setting up the test suite
Setting up an individual test
Running Test 1
․Tearing down an individual test
Setting up an individual test
Running Test 2
․Tearing down an individual test
Tearing down the test suite

  2 passing (5ms)

Disabling Tests

使用skip()方法可以禁用单个测试或测试套件。清单 15-35 显示了单个测试是如何被禁用的。注意skip()已经应用于第二个测试。如果使用mocha来执行这个测试集合,那么只有第一个测试会运行。类似地,可以使用describe.skip()跳过整个测试套件。

清单 15-35 。使用skip()方法禁用测试

it("Test 1", function() {
  console.log("Test 1");
});

it.skip("Test 2", function() {
  console.log("Test 2");
});

Running a Single Test Suite

only()方法用于运行单个套件或测试。当您只想运行一个测试时,这消除了注释掉大组测试的需要。使用only()和使用skip()是一样的,尽管语义不同。当运行清单 15-36 所示的例子时,只执行第二个测试。

清单 15-36 。使用only()运行单一测试

it("Test 1", function() {
  console.log("Test 1");
});

it.only("Test 2", function() {
  console.log("Test 2");
});

摘要

本章介绍了与 Node.js 相关的日志记录、调试和测试主题。这三个主题对于诊断和解决 bug 至关重要。调试和测试是开发过程的重要部分,因为它们有助于防止 bug 进入生产代码。另一方面,日志记录有助于跟踪漏洞,并将其投入生产。通过实现日志记录、调试和测试,您可以确保您的代码具有进入生产所需的润色。下一章将探讨如何部署和扩展生产代码。

十六、应用扩展

扩展 Node.js 应用可能是一个挑战。JavaScript 的单线程特性使 Node 无法利用现代多核硬件。为了有效地伸缩,Node 应用必须找到一种方法来利用它们所能支配的所有资源。核心模块服务于这个目的,允许单个应用启动一组共享资源的 Node 进程,同时分配负载。

扩展 Node 应用的另一种方法是减少应用必须完成的工作量。一个很好的例子是同时提供静态和动态内容的 web 服务器。因为静态内容不会改变(或很少改变),所以可以使用单独的服务器,甚至是一个内容交付网络 (CDN)来处理静态请求,让 Node 只处理动态内容。这种方法的好处是双重的。首先,Node 单线程的负载明显减轻。第二,静态内容可以通过专为静态数据优化的 CDN 或服务器传输。在多个服务器之间分配负载的一种常见方式是使用反向代理服务器。

也许现代计算中应用扩展的最好例子是。云计算提供按需应用扩展,同时将应用分发到世界各地的多个位置。两个比较流行的 Node.js 云计算平台是 Heroku 和 Nodejitsu。这两个平台都允许您将 Node 应用部署到云中,同时指定用于处理流量的进程数量。

本章探讨了扩展 Node 应用的各种技术。本章首先检查了在单台机器上进行扩容的cluster模块。从这里开始,本章继续讨论通过使用反向代理服务器进行扩展。最后,本章最后展示了如何使用 Heroku 和 Nodejitsu 将应用部署到云中。

cluster模块

核心cluster模块允许单个应用被分成多个进程。这些进程彼此独立运行,但可以共享端口,以平衡传入连接的负载。为了演示cluster是如何工作的,让我们从一个简单的 HTTP 服务器开始,如清单 16-1 所示。对于任何请求,服务器在返回一个200状态代码和消息"Hello World!"之前显示其进程 ID 和请求的 URL。

清单 16-1 。一个非常简单的 Hello World HTTP 服务器

var http = require("http");

http.createServer(function(request, response) {
  console.log(process.pid + ":  request for " + request.url);
  response.writeHead(200);
  response.end("Hello World!");
}).listen(8000);

清单 16-1 中的服务器将总是在单个处理器内核上的单个进程中运行,无论如何。鉴于大多数现代机器至少有两个处理器,如果服务器的一个实例可以在每个可用的内核上运行就好了。请注意,我们不希望在一个内核上运行多个实例,因为这样做会因为需要不断的上下文切换而对性能产生负面影响。清单 16-2 展示了如何使用cluster模块来实现这一点。

清单 16-2 。清单 16-1 中的服务器使用cluster模块实现了

var http = require("http");
var cluster = require("cluster");
var numCPUs = require("os").cpus().length;

if (cluster.isMaster) {
  for (var i = 0; i < numCPUs; i++) {
    console.log("Forking child");
    cluster.fork();
  }
} else {
  http.createServer(function(request, response) {
    console.log(process.pid + ":  request for " + request.url);
    response.writeHead(200);
    response.end("Hello World!");
  }).listen(8000);
}

清单 16-2 导入了clusteros核心模块,以及原始服务器中使用的http模块。os模块的cpus()方法 返回一个数组,包含当前机器上每个内核的详细信息。该数组的length属性决定了应用可用的内核数量。

后续的if语句检查cluster.isMaster的值,这是使用cluster模块时需要理解的最重要的事情。主流程用于派生子流程,也称为工作者。然后,子进程用于实现应用的真正功能。但是,每个分支的子进程都执行与原始主进程相同的代码。如果没有这个if语句,子进程将试图派生其他进程。通过添加if语句,主进程可以为每个内核派生一个子进程,而派生的进程(执行else分支)在共享端口 8000 上实现 HTTP 服务器。

image 注意正如cluster.isMaster标识主进程一样,cluster.isWorker标识子进程。

The fork() Method

实际的流程分叉是使用cluster模块的fork()方法完成的。在引擎盖下,来自第九章的child_process.fork()方法被调用。这意味着主进程和工作进程可以通过内置的 IPC 通道进行通信。cluster.fork()方法只能从主进程中调用。虽然没有在清单 16-2 中显示,fork()将一个可选对象作为它唯一的参数;该对象代表子进程的环境。fork()也返回一个cluster.Worker对象,可以用来与子进程交互。

当主进程试图派生一个新的 worker 时,会发出一个fork事件。一旦 worker 被成功分叉,它就向主进程发送一个online消息。收到该消息后,主机发出一个online事件。清单 16-3 中的例子展示了forkonline事件是如何在cluster应用中处理的。请注意,事件处理程序仅被添加到主流程中。虽然也可以将处理程序添加到工作进程中,但是这是多余的,因为事件只在主进程中发出。在本章的后面,您将学习如何侦听工作进程中的类似事件。

清单 16-3 。包含一个fork事件处理程序的cluster示例

var http = require("http");
var cluster = require("cluster");
var numCPUs = require("os").cpus().length;

if (cluster.isMaster) {
  cluster.on("fork", function(worker) {
    console.log("Attempting to fork worker");
  });

  cluster.on("online", function(worker) {
    console.log("Successfully forked worker");
  });

  for (var i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
} else {
  // implement worker code
}

更改默认的fork()行为

默认情况下,调用fork()会导致当前应用被分叉。然而,这种行为可以使用cluster.setupMaster()方法 来改变。setupMaster()接受一个设置对象作为它的唯一参数。可能的设置在表 16-1 中描述。清单 16-4 中的显示了setupMaster()的一个例子。在这个例子中,传递给setupMaster()的值是默认值,因此仍然可以观察到默认行为。

表 16-1 。setupMaster()支持的各种设置

|

环境

|

描述

|
| --- | --- |
| exec | 表示要派生的工作文件的字符串。默认为__filename。 |
| args | 传递给工作线程的字符串参数数组。默认为当前的process.argv变量,减去前两个参数(Node 应用和脚本)。 |
| silent | 一个布尔值,默认为false。当false时,worker 的输出被发送到 master 的标准流。当true出现时,工人的输出被静音。 |

清单 16-4 。一个使用setupMaster()设置默认值的cluster示例

var http = require("http");
var cluster = require("cluster");
var numCPUs = require("os").cpus().length;

if (cluster.isMaster) {
  cluster.setupMaster({
    exec: __filename,
    args: process.argv.slice(2),
    silent: false
  });

  for (var i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
} else {
  // implement worker code
}

disconnect()

disconnect()方法导致所有工作进程优雅地终止它们自己。一旦所有工作线程都终止了,如果事件循环中没有其他事件,主进程也可以终止。disconnect()接受一个可选的回调函数作为它唯一的参数。它是在所有的工人都死了之后调用的。在清单 16-5 中显示了一个使用disconnect()分叉然后立即终止工人的例子。

清单 16-5 。使用disconnect()终止所有工人的cluster示例

var http = require("http");
var cluster = require("cluster");
var numCPUs = require("os").cpus().length;

if (cluster.isMaster) {
  for (var i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.disconnect(function() {
    console.log("All workers have disconnected");
  });
} else {
  // implement worker code
}

当子进程自行终止时,它将关闭其 IPC 通道。这导致在主进程中发出一个disconnect事件。一旦子进程完全终止,主进程中就会发出一个exit事件。清单 16-6 显示了这些事件在主进程中是如何处理的。两个事件处理程序都将有问题的工人作为参数。注意,exit处理程序也接受codesignal参数。这些是退出代码和终止进程的信号的名称。但是,如果工作线程异常退出,则可能不会设置这些值。因此,已经从worker对象本身获得了工人的退出代码。

清单 16-6 。处理disconnectexit事件的示例

var http = require("http");
var cluster = require("cluster");
var numCPUs = require("os").cpus().length;

if (cluster.isMaster) {
  cluster.on("disconnect", function(worker) {
    console.log("Worker " + worker.id + " disconnected");
  });

  cluster.on("exit", function(worker, code, signal) {
    var exitCode = worker.process.exitCode;

    console.log("Worker " + worker.id + " exited with code " + exitCode);
  });

  for (var i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.disconnect();
} else {
  // implement worker code
}

在崩溃后,exit事件对于重启一个工作器非常有用。例如,在清单 16-7 的中,当发出一个exit事件时,主机试图确定是否发生了崩溃。在这个例子中,我们假设所有工人退出都是崩溃。当检测到崩溃时,fork()被再次调用来替换崩溃的工人。

清单 16-7 。重启崩溃的工作进程的示例

var http = require("http");
var cluster = require("cluster");
var numCPUs = require("os").cpus().length;

if (cluster.isMaster) {
  cluster.on("exit", function(worker, code, signal) {
    // determine that a crash occurred
    var crash = true;

    if (crash) {
      console.log("Restarting worker");
      cluster.fork();
    }
  });

  for (var i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
} else {
  // implement worker code
}

The workers Object

主进程可以通过遍历workers对象(模块cluster的一个属性)来遍历它的所有工作进程。清单 16-8 展示了如何使用for...in循环和cluster.workers对象循环所有分叉的工人。在这个例子中,通过调用每个工人的kill()方法,分叉的工人被立即终止。

清单 16-8 。一个循环并杀死所有分叉工人的例子

var http = require("http");
var cluster = require("cluster");
var numCPUs = require("os").cpus().length;

if (cluster.isMaster) {
  for (var i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  for (var id in cluster.workers) {
    console.log("Killing " + id);
    cluster.workers[id].kill();
  }
}

image cluster.workers仅在主流程中可用。然而,每个工作进程可以通过cluster.worker属性引用自己的worker对象。

The Worker Class

Worker类用于与分叉的进程交互。在主流程中,可以通过cluster.workers访问单个工人。对于单个工人来说,可以通过cluster.worker引用Worker类。每个工作进程被分配一个惟一的 ID(不同于它的进程 ID),这个 ID 可以通过Workerid属性获得。由child_process.fork()创建的ChildProcess对象也可以通过Workerprocess属性获得。有关ChildProcess类的更多信息,参见第九章。Worker类还包含一个send()方法,用于进程间通信,与ChildProcess.send()相同(process.send()也可以在工作进程内部使用)。正如您已经在清单 16-8 中看到的,Worker类也包含一个kill()方法,用于向工作进程发送信号。默认情况下,信号名称被设置为字符串SIGTERM,但是任何其他信号名称都可以作为参数传入。

Worker类也包含一些与cluster模块相同的方法和事件。例如,disconnect()方法和几个事件如清单 16-9 所示。这个例子为每个工人附加了事件监听器,然后调用Workerdisconnect()方法。值得指出的是,在Worker级别与这些特性有一些细微的区别。例如,disconnect()方法只断开当前工作线程,而不是所有工作线程。此外,事件处理程序不像在cluster级别那样将Worker作为参数。

清单 16-9Worker-级事件和disconnect()方法

var http = require("http");
var cluster = require("cluster");
var numCPUs = require("os").cpus().length;
var worker;

if (cluster.isMaster) {
  for (var i = 0; i < numCPUs; i++) {
    worker = cluster.fork();

    worker.on("online", function() {
      console.log("Worker " + worker.id + " is online");
    });

    worker.on("disconnect", function() {
      console.log("Worker " + worker.id + " disconnected");
    });

    worker.on("exit", function(code, signal) {
      console.log("Worker " + worker.id + " exited");
    });

    worker.disconnect();
  }
} else {
  // implement worker code
}

跨机器扩展

使用cluster模块,您可以更有效地利用现代硬件。但是,你还是受限于单机的资源。如果您的应用接收到大量流量,最终您将需要扩展到多台机器。这可以使用一个反向代理服务器来完成,该服务器在多个服务器之间对传入的请求进行负载平衡。反向代理代表客户端从一个或多个服务器检索资源。通过使用反向代理和多个应用服务器,应用可以处理的流量增加了。有许多可用的反向代理,但是本节特别关注两个— http-proxynginx

http-proxy

我们将在后面讨论的 Nodejitsu 开发了http-proxy,这是一个用于在 Node 应用中实现代理服务器和反向代理服务器的开源模块。http-proxy支持 WebSockets 和 HTTPS 等,并通过在nodejitsu.com的生产部署进行了全面测试。选择http-proxy还可以让您保持用 JavaScript 编写整个服务器堆栈,如果您愿意的话。

为了演示一个包含负载平衡反向代理的解决方案,我们必须首先创建应用服务器,如清单 16-10 所示。应用服务器负责为反向代理请求的内容提供服务。这与清单 16-1 中的基本 HTTP 服务器相同,适用于从命令行读取端口号。

清单 16-10 。一个简单的 Hello World Web 服务器,它从命令行读取端口

var http = require("http");
var port = ∼∼process.argv[2];

http.createServer(function(request, response) {
  console.log(process.pid + ":  request for " + request.url);
  response.writeHead(200);
  response.end("Hello World!");
}).listen(port);

运行 HTTP 服务器的两个独立实例,一个监听端口 8001,另一个监听端口 8002。接下来,创建反向代理,如清单 16-11 所示。从安装http-proxy模块开始。清单 16-11 的第一行导入了http-proxy模块。第二行定义了请求可以代理到的服务器阵列。在实际的应用中,这些信息可能来自配置文件,而不是硬编码的。接下来,createServer()方法用于定义反向代理的行为,该方法应该熟悉 HTTP。示例服务器通过维护一组服务器以循环方式代理请求。当请求进来时,它们被代理到阵列中的第一个服务器。然后,该服务器被推到数组的末尾,以允许下一个服务器处理请求。

清单 16-11 。基于http-proxy模块的反向代理服务器

var proxyServer = require("http-proxy");
var servers = [
  {
    host: "localhost",
    port: 8001
  },
  {
    host: "localhost",
    port: 8002
  }
];

proxyServer.createServer(function (req, res, proxy) {
  var target = servers.shift();

  console.log("proxying to " + JSON.stringify(target));
  proxy.proxyRequest(req, res, target);
  servers.push(target);
}).listen(8000);

当然,前面的例子只使用了一台机器。但是,如果您可以访问多台机器,您可以在一台机器上运行反向代理,而一台或多台其他机器运行 HTTP 服务器。您可能还想在代理服务器中添加处理静态资源的代码,比如图像和样式表,或者甚至一起添加另一个服务器。

nginx

使用 Node 反向代理很好,因为它让你的软件栈保持相同的技术。然而,在生产系统中,更常见的是使用nginx来处理负载平衡和静态内容。nginx是一个开源的 HTTP 服务器和反向代理,非常擅长服务静态数据。因此,nginx可用于处理诸如缓存和服务静态文件等任务,同时将动态内容请求转发到 Node 服务器。

要实现负载平衡,只需安装nginx,然后在服务器配置文件中添加 Node 服务器作为上游资源。配置文件位于{nginx-root}/conf/nginx.conf,其中{nginx-root}nginx根安装目录。整个配置文件如清单 16-12 所示;然而,我们只对几个关键部分感兴趣。

清单 16-12 。一个将 Node 服务器列为上游资源的nginx配置文件

#user  nobody;
worker_processes  1;

#error_log  logs/error.log;
#error_log  logs/error.log  notice;
#error_log  logs/error.log  info;

#pid        logs/nginx.pid;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;

    #log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
    #                  '$status $body_bytes_sent "$http_referer" '
    #                  '"$http_user_agent" "$http_x_forwarded_for"';

    #access_log  logs/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    #keepalive_timeout  0;
    keepalive_timeout  65;

    #gzip  on;

    upstream node_app {
      server 127.0.0.1:8001;
      server 127.0.0.1:8002;
    }

    server {
        listen       80;
        server_name  localhost;

        #charset koi8-r;

        #access_log  logs/host.access.log  main;

        location / {
            root   html;
            index  index.html index.htm;
        }

        location /foo {
          proxy_redirect off;
          proxy_set_header   X-Real-IP            $remote_addr;
          proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;
          proxy_set_header   X-Forwarded-Proto $scheme;
          proxy_set_header   Host                   $http_host;
          proxy_set_header   X-NginX-Proxy    true;
          proxy_set_header   Connection "";
          proxy_http_version 1.1;
          proxy_pass         http://node_app;
        }

        #error_page  404              /404.html;

        # redirect server error pages to the static page /50x.html
        #
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }

        # proxy the PHP scripts to Apache listening on 127.0.0.1:80
        #
        #location ∼ \.php$ {
        #    proxy_pass   http://127.0.0.1;
        #}

        # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
        #
        #location ∼ \.php$ {
        #    root           html;
        #    fastcgi_pass   127.0.0.1:9000;
        #    fastcgi_index  index.php;
        #    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
        #    include        fastcgi_params;
        #}

        # deny access to .htaccess files, if Apache's document root
        # concurs with nginx's one
        #
        #location ∼ /\.ht {
        #    deny  all;
        #}
    }

    # another virtual host using mix of IP-, name-, and port-based configuration
    #
    #server {
    #    listen       8000;
    #    listen       somename:8080;
    #    server_name  somename  alias  another.alias;

    #    location / {
    #        root   html;
    #        index  index.html index.htm;
    #    }
    #}

    # HTTPS server
    #
    #server {
    #    listen       443;
    #    server_name  localhost;

    #    ssl                  on;
    #    ssl_certificate      cert.pem;
    #    ssl_certificate_key  cert.key;

    #    ssl_session_timeout  5m;

    #    ssl_protocols  SSLv2 SSLv3 TLSv1;
    #    ssl_ciphers  HIGH:!aNULL:!MD5;
    #    ssl_prefer_server_ciphers   on;

    #    location / {
    #        root   html;
    #        index  index.html index.htm;
    #    }
    #}

}

如前所述,我们只对配置文件的一小部分感兴趣。第一个有趣的部分,您必须添加到您的配置文件中,如清单 16-13 中的所示,它定义了一个名为node_app的上游服务器,它在两个 IP 地址之间保持平衡。当然,这些 IP 地址会根据服务器的位置而有所不同。

清单 16-13 。名为node_app的上游资源在两个服务器之间保持平衡

upstream node_app {
  server 127.0.0.1:8001;
  server 127.0.0.1:8002;
}

简单地定义上游服务器并不能告诉nginx如何使用资源。因此,我们必须使用清单 16-14 中所示的指令定义一条路线。使用这个路由,对/foo的任何请求都被代理到上游的一个 Node 服务器。

清单 16-14 。定义反向代理到上游服务器的路由

location /foo {
  proxy_redirect off;
  proxy_set_header   X-Real-IP            $remote_addr;
  proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;
  proxy_set_header   X-Forwarded-Proto $scheme;
  proxy_set_header   Host                   $http_host;
  proxy_set_header   X-NginX-Proxy    true;
  proxy_set_header   Connection "";
  proxy_http_version 1.1;
  proxy_pass         http://node_app;
}

安装和配置nginx已经超出了本书的范围。事实上,有整本书都是献给nginx的。这个非常简短的介绍只是为了给你指明正确的方向。你可以在www.nginx.org的项目主页上找到更多关于nginx的信息。

在云中扩展

计算资源越来越被视为商品。云计算提供商允许服务器在几秒钟内启动和关闭,以适应流量高峰。这些服务器可以在地理上分布在世界各地,最好的是,您通常只需为您实际使用的计算时间付费。有许多公共云提供商可供选择,但是本节特别关注 Nodejitsu 和 Heroku。本节介绍使用这些平台部署 Node 应用的基础知识。

Nodejitsu

Nodejitsu 成立于 2010 年 4 月,是一家总部位于纽约市的平台即服务(PaaS)公司。Nodejitsu 提供了一组命令行工具,用于将应用部署到他们的云中。要开始使用 Nodejitsu,您必须首先在www.nodejitsu.com注册一个帐户。尽管注册是免费的,但部署应用却不是。Nodejitsu 将为您提供 30 天的免费试用,但之后您必须每月支付至少 9 美元(在撰写本文时)来托管您的应用。

注册后,您需要安装 Nodejitsu 的命令行工具,可以使用命令npm install -g jitsu安装jitsu. jitsu。在帐户创建过程中,您将收到一封电子邮件,其中包含创建jitsu帐户的说明。这些指令包括一个类似于清单 16-15 中所示的命令。输入通过电子邮件发送给您的命令后,将创建您的帐户,并提示您创建帐户密码。

清单 16-15 。确认jitsu账户的通用命令

$ jitsu users confirm username confirmation_code

接下来,像平常一样创建一个 Node 应用。出于这个例子的目的,简单地使用来自清单 16-1 的 HTTP 服务器。要将项目部署到 Nodejitsu,它必须包含一个package.json文件。如果您需要复习package.json文件,请参见第二章。接下来,从你的应用目录中发出清单 16-16 所示的命令。

清单 16-16 。使用jitsu部署项目

$ jitsu deploy

如果您的项目不包含package.json文件,jitsu将通过一个简短的向导为您创建一个文件。package.json文件应该包括nameversionscriptsenginessubdomain字段。engines字段应该包含一个node字段来指定所需的 Node 版本。类似地,scripts字段应该包含一个start脚本,以便 Nodejitsu 知道如何初始化您的应用。subdomain将在您的应用的 URL 中使用,并且必须是唯一的。适用于jitsu部署的示例package.json文件如清单 16-17 所示。请注意,本例中显示的subdomain包括一个用户名(cjihrig)来帮助确保字符串是惟一的。

清单 16-17 。适合 Nodejitsu 部署的示例文件package.json

{
  "name": "simple-server",
  "subdomain": "simpleserver.cjihrig",
  "scripts": {
    "start": "simple-server.js"
  },
  "version": "0.0.1",
  "engines": {
    "node": "0.10.x"
  }
}

如果一切配置正确,并且您想要的子域可用,您的应用将被部署到 Nodejitsu 的云中。要访问您的应用,请访问http://subdomain.jit.su,其中subdomain是在package.json文件中找到的值。

Heroku

Heroku 是一家 PaaS 公司,成立于 2007 年,2010 年被Salesforce.com收购。与 Nodejitsu 不同,Heroku 并不严格地专用于 Node。它支持 Ruby、Java、Scala 和 Python 等语言。为了将 Node 应用部署到 Heroku,您需要一个 Heroku 用户帐户。注册 Heroku 是免费的,与 Nodejitsu 不同,Heroku 为小型单核应用提供免费托管。

首先在本地机器上安装 Heroku Toolbelt。你可以从 Heroku 的网站www.heroku.com下载工具箱。一旦安装好工具带,使用清单 16-18 中的命令登录 Heroku。输入登录命令后,系统会提示您输入 Heroku 凭证和 SSH 密钥。

清单 16-18 。从命令行登录 Heroku

$ heroku login

接下来,像平常一样编写应用。与 Nodejitsu 一样,您的应用将需要一个package.json文件,因为 Heroku 将使用它来安装您的应用。需要注意的一点是,Heroku 将为您的应用分配一个端口号,不管您在代码中指定了什么。端口号将从命令行传入,您必须考虑这一点。清单 16-19 展示了这是如何完成的。注意,如果环境中没有指定端口,那么使用||操作符来选择端口。这使得代码既可以在本地运行,也可以在 Heroku 上运行。

清单 16-19 。通过环境变量选择端口号

var port = process.env.PORT || 8000;

接下来,创建一个ProcfileProcfile是一个位于应用根目录下的文本文件,其中包含用于启动程序的命令。假设你的程序存储在一个名为app.js的文件中,清单 16-20 显示了一个例子ProcfileProcfileweb部分表示应用将连接到 Heroku 的 HTTP 路由堆栈并接收 web 流量。

清单 16-20 。一个 Heroku Procfile的例子

web: node app.js

接下来,将您的应用文件、package.jsonProcfile和任何其他需要的文件添加到git存储库中。这是必需的,因为 Heroku 使用git进行部署。使用清单 16-21 中的命令可以创建一个新的git库。这假设您已经在本地安装了git

清单 16-21 。为您的应用创建一个git库的命令

$ git init
$ git add .
$ git commit -m "init"

下一步是创建 Heroku 应用。这是使用清单 16-22 中的命令完成的。您可能想要用您想要的应用名称替换app_name

清单 16-22 。用于创建 Heroku 应用的命令

$ heroku apps:create app_name

最后一步是使用清单 16-23 中的命令部署您的应用。该命令将您的代码推送到 Heroku 进行部署。一旦你的代码被部署,你可以在http://app_name.herokuapp.com访问你的应用,这里app_name是你的应用的名字。

清单 16-23 。用于部署 Heroku 应用的命令

$ git push heroku master

摘要

本章介绍了扩展 Node.js 应用的各种技术。我们从探索cluster模块开始,尽管 JavaScript 是单线程的,它允许应用利用现代机器提供的所有内核。接下来,我们转向反向代理服务器,它允许应用跨多台机器伸缩。本章讨论的反向代理可以与cluster模块结合使用,以利用多个内核和多台机器。最后,本章最后探讨了云中的 Node.js。我们研究了两个流行的 PaaS 提供商——node jitsu 和 Heroku。

本章总结了我们对 Node.js 生态系统的探索。我们真诚地希望你通过阅读这本书学到了很多东西。我们知道通过写它我们学到了很多。不过,这本书还没有完全完成。请继续阅读关于 JavaScript 对象符号(JSON) 的入门/复习资料。

十七、附录 A:JSON

JavaScript Object Notation(JSON)是一种纯文本的数据交换格式,它基于第三版 ECMA 262 标准的子集。JSON 被用作将数据结构序列化为字符串的机制。这些字符串通常通过网络发送、写入输出文件或用于调试。JSON 经常被吹捧为“XML 的无脂肪替代品”,因为它提供了与 XML 相同的功能,但通常需要更少的字符。与 XML 相比,JSON 也更容易解析。由于 JSON 的简单性和低开销,许多开发人员放弃了 XML,转而使用 JSON。

从语法上来说,JSON 非常类似于 JavaScript 的对象字面语法。JSON 对象以左花括号{开始,以右花括号}结束。花括号之间是零个或多个键/值对,称为成员。成员由逗号分隔,而冒号用于将成员的键与其对应的值分隔开。密钥必须是用双引号括起来的字符串。这是与 object literal 语法的最大区别,object literal 语法允许双引号、单引号或根本没有引号。值的格式取决于其数据类型。清单 A-1 显示了一个通用的 JSON 字符串。

清单 。JSON 对象的一般示例

{"key1": value1, "key2": value2, ..., "keyN": valueN}

image 一段 JSON 的根几乎总是一个对象。然而,这不是绝对的要求。顶层也可以是数组。

支持的数据类型

JSON 支持许多 JavaScript 的原生数据类型。具体来说,JSON 支持数字、字符串、布尔、数组、对象和null。本节介绍了与每种受支持的数据类型相关的详细信息。

数字

JSON 数字不能有前导零,小数点后必须至少有一个数字(如果有一个的话)。由于前导零的限制,JSON 只支持十进制数字(八进制和十六进制都需要前导零)。如果您想包含其他基数的数字,必须先将它们转换为基数为 10 的数字。在清单 A-2 中,创建了四个不同的 JSON 字符串。所有 JSON 字符串都定义了一个名为foo的字段,保存十进制值100。在第一个字符串中,foo的值来自整数常量100。在第二个字符串中,foo的值来自以 10 为基数的变量decimal。第三个字符串json3的值来自基数为 8 的变量octal,而json4的值来自基数为 16 的变量hex。所有的字符串都产生相同的 JSON 字符串,尽管有些变量有不同的基数。这是可能的,因为变量octalhex在字符串连接过程中被隐式转换为基数为 10 的数字。

清单 A-2 。JSON 字符串中使用的数字示例

var decimal = 100;
var octal = 0144; // JavaScript octals have a leading zero
var hex = 0x64;   // JavaScript hex numbers begin with 0x
var json1 = "{\"foo\":100}";
var json2 = "{\"foo\":" + decimal + "}";
var json3 = "{\"foo\":" + octal + "}";
var json4 = "{\"foo\":" + hex + "}";

// all JSON strings are {"foo":100}

清单 A-3 中的所示的字符串不是有效的 JSON,因为非十进制数字被直接构建到字符串中。在这个例子中,八进制和十六进制文字没有机会被转换成它们的十进制等价物。

清单 A-3 。JSON 字符串中无效数值的示例

var json1 = "{\"foo\":0144}";
var json2 = "{\"foo\":0x64}";

字符串

JSON 字符串非常类似于普通的 JavaScript 字符串。但是,JSON 要求字符串用双引号括起来。尝试使用单引号会导致错误。在 A-4 的清单中,用一个名为foo的字段创建了一个 JSON 字符串,该字段的字符串值为bar

清单 。包含字符串数据的 JSON 字符串示例

var json = "{\"foo\":\"bar\"}";

// json is {"foo":"bar"}

布尔型

JSON 布尔值与普通的 JavaScript 布尔值相同,只能保存值truefalse。清单 A-5 中的示例创建了一个带有两个字段foobar的 JSON 字符串,它们分别保存布尔值truefalse

清单 A-5 。包含布尔数据的 JSON 字符串示例

var json = "{\"foo\":true, \"bar\":false}";

// json is {"foo":true, "bar":false}

数组

一个数组是一个有序的值序列。JSON 数组以左方括号[开始,以右方括号]结束。括号之间是零个或多个值,用逗号分隔。所有的值不必都是相同的数据类型。数组可以包含 JSON 支持的任何数据类型,包括嵌套数组。清单 A-6 中的显示了几个包含数组的 JSON 字符串。在json1中定义的foo数组为空,而在json2中定义的数组包含两个字符串。在json3中定义的foo数组更加复杂——它包含一个数字、一个布尔值、一个字符串嵌套数组和一个空对象。

清单 A-6 。JSON 字符串中的数组示例

var json1 = "{\"foo\":[]}";
var json2 = "{\"foo\":[\"bar\", \"baz\"]}";
var json3 = "{\"foo\":[100, true, [\"bar\", \"baz\"], {}]}";

// json1 is {"foo":[]}
// json2 is {"foo":["bar", "baz"]}
// json3 is {"foo":[100, true, ["bar", "baz"], {}]}

对象

一个对象是一个无序的键/值对集合。与数组一样,对象可以由 JSON 支持的任何数据类型组成。列出 A-7 的中的例子展示了 JSON 对象是如何相互嵌套的。

清单 。JSON 中嵌套对象的一个例子

var json = "{\"foo\":{\"bar\":{\"baz\":true}}}";

// json is {"foo":{"bar":{"baz":true}}}

null

JSON 中也支持 JavaScript 的null数据类型。清单 A-8 创建一个 JSON 字符串,带有一个名为foonull值字段。

清单 A-8 。在 JSON 字符串中使用null数据类型

var json = "{\"foo\":null}";

// json is {"foo":null}

不支持的数据类型

JSON 不支持许多 JavaScript 的内置数据类型。这些类型是undefined,内置对象FunctionDateRegExpErrorMath. undefined的值根本无法在 JSON 中表示,但是其他不受支持的类型可以表示,如果您稍微有点创造力的话。为了序列化不支持的数据类型,必须首先将其转换成 JSON 兼容的其他表示形式。尽管没有标准化的方法,但是这些数据类型中的许多都可以使用toString()方法简单地转换成字符串。

使用 JSON 的函数

考虑到必须考虑所有的大括号和中括号,处理原始 JSON 字符串可能是乏味且容易出错的。为了避免这种繁琐,JavaScript 提供了一个全局的JSON对象来处理 JSON 数据。JSON对象包含两个方法——stringify()parse()——用于将对象序列化为 JSON 字符串,并将 JSON 字符串反序列化为对象。本节详细解释了这些方法的工作原理。

JSON.stringify()

JSON.stringify()是将 JavaScript 对象序列化为 JSON 字符串的推荐方法。清单 A-9 中的显示了stringify()的语法。第一个参数value是被字符串化的 JavaScript 对象。另外两个参数replacerspace是可选的,可以用来定制字符串化过程。这些争论将很快被重新讨论。

清单 A-9JSON.stringify()方法的使用

JSON.stringify(value[, replacer[, space]])

toJSON()

有几种方法可以定制字符串化过程。这方面的一个例子是使用toJSON()方法。在序列化过程中,JSON 检查对象是否有名为toJSON()的方法。如果这个方法存在,那么它被stringify()调用。stringify()将序列化toJSON()返回的任何值,而不是处理原始对象。JavaScript 的Date对象就是这样被序列化的。由于 JSON 不支持Date类型,Date对象配备了toJSON()方法。

列出 A-10 显示toJSON()在行动。在这个例子中,一个名为obj的对象是用字段foobarbaz创建的。当obj被字符串化时,它的toJSON()方法被调用。在这个例子中,toJSON()返回一个obj的副本,减去foo字段。obj的副本被序列化,产生一个只包含barbaz字段的 JSON 字符串。

清单 。使用自定义toJSON()方法的示例

var obj = {foo: 0, bar: 1, baz: 2};

obj.toJSON = function() {
  var copy = {};

  for (var key in this) {
    if (key === "foo") {
      continue;
    } else {
      copy[key] = this[key];
    }
  }

  return copy;
};

var json = JSON.stringify(obj);
console.log(json);

//json is {"bar":1,"baz":2}

replacer论据

JSON.stringify()replacer参数可以用作一个函数,它接受两个表示键/值对的参数。首先,使用空键调用函数,对象被序列化为值。为了处理这种情况,replacer()函数必须检查空字符串是否是键。接下来,每个对象的属性和相应的值被一个接一个地传递给replacer()。由replacer()返回的值用于字符串化过程。清单 A-11 中显示了一个没有定制行为的示例replacer()函数。

清单 A-11 。没有自定义行为的示例replacer()函数

function(key, value) {
  // check for the top level object
  if (key === "") {
    return value;
  } else {
    return value;
  }
}

正确处理顶级对象很重要。通常,最好简单地返回对象的值。在清单 A-12 的示例中,顶级对象返回字符串foo。因此,无论如何处理对象的属性,stringify()总是返回foo

清单 A-12 。将任何对象序列化为字符串fooreplacer()函数

function(key, value) {
  if (key === "") {
    return "foo";
  } else {
    // this is now irrelevant
    return value;
  }
}

在清单 A-13 的中,使用名为filter()的自定义replacer()函数序列化一个对象。filter()函数的工作是只序列化数值。所有非数字字段都将返回一个undefined值。返回undefined的字段会自动从字符串对象中移除。在这个例子中,replacer()函数导致baz被删除,因为它保存了一个字符串。

清单 A-13 。一个仅序列化数字的示例函数replacer()

function filter(key, value) {
  // check for the top level object
  if (key === "") {
    return value;
  } else if (typeof value === "number") {
    return value;
  }
}

var obj = {foo: 0, bar: 1, baz: "x"};
var json = JSON.stringify(obj, filter);

console.log(json);
// json is {"foo":0,"bar":1}

replacer的数组形式

replacer参数也可以保存一个字符串数组。每个字符串表示应该序列化的字段的名称。任何不包含在replacer数组中的字段都不会包含在 JSON 字符串中。在清单 A-14 的示例中,一个对象被定义为带有名为foobar的字段。还定义了一个数组,包含字符串foobaz。在字符串化过程中,bar字段被删除,因为它不是replacer数组 的一部分。请注意,没有创建baz字段,因为尽管它在replacer数组中定义,但它没有在原始对象中定义。这使得foo成为 stringified 对象中唯一的字段。

清单 A-14 。将replacer参数作为数组的示例

var obj = {foo: 0, bar: 1};
var arr = ["foo", "baz"];
var json = JSON.stringify(obj, arr);

console.log(json);
// json is {"foo":0}

space论据

JSON 字符串通常用于日志记录和调试目的。为了提高可读性,stringify()函数支持名为space的第三个参数,它允许开发人员格式化生成的 JSON 字符串中的空白。该参数可以是数字或字符串。如果space是一个数字,那么最多 10 个空格字符可以用作空格。如果该值小于 1,则不使用空格。如果该值超过 10,则使用最大值 10。如果space是一个字符串,那么这个字符串被用作空白。如果字符串长度大于 10,则只使用前 10 个字符。如果省略spacenull,则不使用空白。清单 A-15 展示了如何使用space参数。

清单 A-15 。使用space参数的字符串化示例

var obj = {
  foo: 0,
  bar: [null, true, false],
  baz: {
    bizz: "boff"
  }
};
var json1 = JSON.stringify(obj, null, "  ");
var json2 = JSON.stringify(obj, null, 2);

console.log(json1);
console.log(json2);

在清单 A-15 中,json1json2中的 JSON 字符串最终是相同的。产生的 JSON 如清单 A-16 所示。请注意,该字符串现在跨越了多行,并且随着嵌套的增加,属性多缩进了两个空格。对于重要的对象,这种格式极大地提高了可读性。

清单 A-16 。在清单 A-15 的中生成的格式化的 JSON 字符串

{
  "foo": 0,
  "bar": [
    null,
    true,
    false
  ],
  "baz": {
    "bizz": "boff"
  }
}

JSON.parse()

要从 JSON 格式的字符串构建 JavaScript 对象,可以使用JSON.parse()方法。parse()提供与stringify()相反的功能。它被用作比eval()更安全的选择,因为eval()将执行任意的 JavaScript 代码,而parse()被设计为只处理有效的 JSON 字符串。

parse()方法的语法如清单 A-17 中的所示。第一个参数text是 JSON 格式的字符串。如果text不是一个有效的 JSON 字符串,将会抛出一个SyntaxError异常。这个异常将被同步抛出,这意味着try...catch...finally语句可以和parse()一起使用。如果没有遇到问题,parse()返回一个对应于 JSON 字符串的 JavaScript 对象。parse()还带有一个可选的名为reviver的第二个参数,稍后将会介绍。

清单 A-17JSON.parse()方法的使用

JSON.parse(text[, reviver])

在清单 A-18 中,parse()方法用于从 JSON 字符串构建一个对象。存储在obj中的结果对象有两个属性——foobar——分别保存数值 10 和 20。

清单 A-18 。使用JSON.parse()反序列化 JSON 字符串的例子

var string = "{\"foo\":10, \"bar\":20}";
var obj = JSON.parse(string);

console.log(obj.foo);
console.log(obj.bar);
// obj.foo is equal to 10
// obj.bar is equal to 20

reviver()论点

parse()reviver()的第二个参数是一个函数,允许在解析过程中转换对象。每个属性都是从 JSON 字符串中解析出来的,它通过reviver()函数运行。由reviver()返回的值在构造的对象中被用作属性值。如果reviver()返回一个undefined值,那么该属性将从对象中移除。

reviver()函数有两个参数,属性名(key)和它的解析值(value)。reviver()应该总是检查空字符串的key参数。原因是,在每个单独的属性上调用reviver()之后,在构造的对象上调用。在最后一次调用reviver()时,空字符串作为key参数传递,构造的对象作为value传递。考虑到这种情况,清单 A-19 中的显示了一个没有定制的示例reviver()功能。

清单 A-19reviver()功能示例

function(key, value) {
  // check for the top level object
  if (key === "") {
    // be sure to return the top level object
    // otherwise the constructed object will be undefined
    return value;
  } else {
    // return the original untransformed value
    return value;
  }
}

在清单 A-20 的中,使用名为square()的定制reviver()函数从 JSON 字符串中构造一个对象。顾名思义,square()对解析过程中遇到的每个属性的值求平方。这导致foobar属性的值在解析后变成 100 和 400。

清单 。使用JSON.parse()和自定义reviver()函数的示例

function square(key, value) {
  if (key === "") {
    return value;
  } else {
    return value * value;
  }
}

var string = "{\"foo\":10, \"bar\":20}";
var obj = JSON.parse(string, square);

console.log(obj.foo);
console.log(obj.bar);
// obj.foo is 100
// obj.bar is 400

image 注意JSON.parse()JSON.stringify()都是可以抛出异常的同步方法。因此,这些方法的任何使用都应该包装在一个try...catch语句中。

摘要

JSON 在 Node 生态系统中得到了广泛的应用,这一点您现在肯定已经看到了。例如,任何值得使用的包都会包含一个package.json文件。事实上,为了使模块与npm一起使用,需要一个package.json。几乎每个数据 API 都是使用 JSON 构建的,因为 Node 社区更倾向于 JSON,而不是 XML。因此,理解 JSON 对于有效使用 Node 至关重要。幸运的是,JSON 很容易阅读、编写和理解。阅读完本章后,您应该对 JSON 有足够的了解,可以在您自己的应用中使用它,或者与其他应用进行交互(例如,RESTful web 服务)。

posted @ 2024-10-01 20:57  绝不原创的飞龙  阅读(156)  评论(0)    收藏  举报