十五、日志记录、调试和测试
任何语言的产品代码都必须具有某种玩具或学术程序所缺乏的光泽。本章探讨了日志、调试和测试的主题,这将提高代码质量,同时减少诊断和修复 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
提供日志级别info
、warn
和error
。log()
的第二个参数是记录的消息。
清单 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
支持的核心运输类型有Console
、File
、Http
。顾名思义,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 日志记录的远程端口。默认为80
或443
,取决于使用的是 HTTP 还是 HTTPS。 |
| path
| 用于 HTTP 日志记录的远程 URI。默认为/
。 |
| auth
| 一个对象,如果包含的话,应该包含一个username
和password
字段。这用于 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
注意提供
debug
参数会使 Node 启动一个交互式调试器。但是,您也可以提供一个--debug
(注意连字符)选项,这将使调试器侦听端口 5858 上的连接。第三个选项--debug-brk
,让调试器监听端口 5858,同时在第一行设置一个断点。
然后,您可以像在任何其他调试器中一样逐句通过代码。用于单步执行代码的命令如表 15-2 所示。
表 15-2 。Node 调试器支持的指令步进命令
|
命令
|
描述
|
| — | — |
| cont
或c
| 继续执行。 |
| next
或n
| 跳到下一条指令。 |
| step
或s
| 单步执行函数调用。 |
| out
或o
| 跳出函数调用。 |
| pause
| 暂停正在运行的代码。 |
您可能不希望单步执行整个应用。因此,还应该设置断点。添加断点最简单的方法是在源代码中添加debugger
语句。这些语句将导致调试器停止执行,但如果调试器不在使用中,这些语句将被忽略。清单 15-10 中所示的例子将导致调试器在第二次给foo
赋值之前暂停。
清单 15-10 。包含一个debugger
语句的示例应用
var foo = 2;
var bar = 3;
debugger;
foo = foo + bar;
附加调试器后,发出cont
或c
命令继续执行debugger
语句。此时,foo
的值为 2,bar
的值为 3。您可以通过输入repl
命令来确认这一点,这将调用第一章中的 REPL。在 REPL 内,键入foo
或bar
检查变量值。接下来,按 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()
时,您可以传递参数来清除特定行上的断点。 |
| backtrace
或bt
| 打印当前执行帧的回溯。 |
| 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
图 15-1 。连接到清单 15-14 中的链接时的 Chrome 视图
打开 Chrome 时,执行会在一个断点处暂停。按下窗口右侧面板上的小播放按钮,恢复执行。这将导致应用执行,直到到达下一个断点,此时 Chrome 将看起来像图 15-2 。请注意图像右侧的范围变量部分。此部分允许您查看当前范围内的变量及其值。在图 15-2 中,可以看到foo
等于 2,bar
等于 3。
图 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()
方法的副本,其numerator
和denominator
参数被绑定到特定的值。在每个示例测试用例中,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()
方法对于测试回调函数的第一个参数很有用,它通常用于传递错误条件。因为错误参数通常是null
或undefined
,所以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() {
});
});
注意尽管测试套件对于将相关的测试组合在一起很有用,但它们并不是必需的。如果没有指定测试套件,所有的测试都将被放置在 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();
});
});
注意
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 导入了cluster
和os
核心模块,以及原始服务器中使用的http
模块。os
模块的cpus()
方法 返回一个数组,包含当前机器上每个内核的详细信息。该数组的length
属性决定了应用可用的内核数量。
后续的if
语句检查cluster.isMaster
的值,这是使用cluster
模块时需要理解的最重要的事情。主流程用于派生子流程,也称为工作者。然后,子进程用于实现应用的真正功能。但是,每个分支的子进程都执行与原始主进程相同的代码。如果没有这个if
语句,子进程将试图派生其他进程。通过添加if
语句,主进程可以为每个内核派生一个子进程,而派生的进程(执行else
分支)在共享端口 8000 上实现 HTTP 服务器。
注意正如
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 中的例子展示了fork
和online
事件是如何在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
处理程序也接受code
和signal
参数。这些是退出代码和终止进程的信号的名称。但是,如果工作线程异常退出,则可能不会设置这些值。因此,已经从worker
对象本身获得了工人的退出代码。
清单 16-6 。处理disconnect
和exit
事件的示例
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();
}
}
注
cluster.workers
仅在主流程中可用。然而,每个工作进程可以通过cluster.worker
属性引用自己的worker
对象。
The Worker Class
Worker
类用于与分叉的进程交互。在主流程中,可以通过cluster.workers
访问单个工人。对于单个工人来说,可以通过cluster.worker
引用Worker
类。每个工作进程被分配一个惟一的 ID(不同于它的进程 ID),这个 ID 可以通过Worker
的id
属性获得。由child_process.fork()
创建的ChildProcess
对象也可以通过Worker
的process
属性获得。有关ChildProcess
类的更多信息,参见第九章。Worker
类还包含一个send()
方法,用于进程间通信,与ChildProcess.send()
相同(process.send()
也可以在工作进程内部使用)。正如您已经在清单 16-8 中看到的,Worker
类也包含一个kill()
方法,用于向工作进程发送信号。默认情况下,信号名称被设置为字符串SIGTERM
,但是任何其他信号名称都可以作为参数传入。
Worker
类也包含一些与cluster
模块相同的方法和事件。例如,disconnect()
方法和几个事件如清单 16-9 所示。这个例子为每个工人附加了事件监听器,然后调用Worker
的disconnect()
方法。值得指出的是,在Worker
级别与这些特性有一些细微的区别。例如,disconnect()
方法只断开当前工作线程,而不是所有工作线程。此外,事件处理程序不像在cluster
级别那样将Worker
作为参数。
清单 16-9 。Worker
-级事件和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-proxy
和nginx
。
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
文件应该包括name
、version
、scripts
、engines
和subdomain
字段。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;
接下来,创建一个Procfile
。Procfile
是一个位于应用根目录下的文本文件,其中包含用于启动程序的命令。假设你的程序存储在一个名为app.js
的文件中,清单 16-20 显示了一个例子Procfile
。Procfile
的web
部分表示应用将连接到 Heroku 的 HTTP 路由堆栈并接收 web 流量。
清单 16-20 。一个 Heroku Procfile
的例子
web: node app.js
接下来,将您的应用文件、package.json
、Procfile
和任何其他需要的文件添加到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}
注一段 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 字符串,尽管有些变量有不同的基数。这是可能的,因为变量octal
和hex
在字符串连接过程中被隐式转换为基数为 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 布尔值相同,只能保存值true
和false
。清单 A-5 中的示例创建了一个带有两个字段foo
和bar
的 JSON 字符串,它们分别保存布尔值true
和false
。
清单 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 字符串,带有一个名为foo
的null
值字段。
清单 A-8 。在 JSON 字符串中使用null
数据类型
var json = "{\"foo\":null}";
// json is {"foo":null}
不支持的数据类型
JSON 不支持许多 JavaScript 的内置数据类型。这些类型是undefined
,内置对象Function
、Date
、RegExp
、Error
和Math. 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 对象。另外两个参数replacer
和space
是可选的,可以用来定制字符串化过程。这些争论将很快被重新讨论。
清单 A-9 。JSON.stringify()
方法的使用
JSON.stringify(value[, replacer[, space]])
toJSON()
法
有几种方法可以定制字符串化过程。这方面的一个例子是使用toJSON()
方法。在序列化过程中,JSON 检查对象是否有名为toJSON()
的方法。如果这个方法存在,那么它被stringify()
调用。stringify()
将序列化toJSON()
返回的任何值,而不是处理原始对象。JavaScript 的Date
对象就是这样被序列化的。由于 JSON 不支持Date
类型,Date
对象配备了toJSON()
方法。
列出 A-10 显示toJSON()
在行动。在这个例子中,一个名为obj
的对象是用字段foo
、bar
和baz
创建的。当obj
被字符串化时,它的toJSON()
方法被调用。在这个例子中,toJSON()
返回一个obj
的副本,减去foo
字段。obj
的副本被序列化,产生一个只包含bar
和baz
字段的 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 。将任何对象序列化为字符串foo
的replacer()
函数
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 的示例中,一个对象被定义为带有名为foo
和bar
的字段。还定义了一个数组,包含字符串foo
和baz
。在字符串化过程中,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 个字符。如果省略space
或null
,则不使用空白。清单 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 中,json1
和json2
中的 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-17 。JSON.parse()
方法的使用
JSON.parse(text[, reviver])
在清单 A-18 中,parse()
方法用于从 JSON 字符串构建一个对象。存储在obj
中的结果对象有两个属性——foo
和bar
——分别保存数值 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-19 。reviver()
功能示例
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()
对解析过程中遇到的每个属性的值求平方。这导致foo
和bar
属性的值在解析后变成 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
注意
JSON.parse()
和JSON.stringify()
都是可以抛出异常的同步方法。因此,这些方法的任何使用都应该包装在一个try...catch
语句中。
摘要
JSON 在 Node 生态系统中得到了广泛的应用,这一点您现在肯定已经看到了。例如,任何值得使用的包都会包含一个package.json
文件。事实上,为了使模块与npm
一起使用,需要一个package.json
。几乎每个数据 API 都是使用 JSON 构建的,因为 Node 社区更倾向于 JSON,而不是 XML。因此,理解 JSON 对于有效使用 Node 至关重要。幸运的是,JSON 很容易阅读、编写和理解。阅读完本章后,您应该对 JSON 有足够的了解,可以在您自己的应用中使用它,或者与其他应用进行交互(例如,RESTful web 服务)。