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

112 阅读40分钟

NodeJS 开发者高级教程(五)

原文:Pro Node.js for Developers

协议:CC BY-NC-SA 4.0

十五、日志记录、调试和测试

任何语言的产品代码都必须具有某种玩具或学术程序所缺乏的光泽。本章探讨了日志、调试和测试的主题,这将提高代码质量,同时减少诊断和修复 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 1Tearing down an individual test
Setting up an individual test
Running Test 2Tearing 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 服务)。