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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意提供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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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()方法的副本,其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() {
  });
});

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意尽管测试套件对于将相关的测试组合在一起很有用,但它们并不是必需的。如果没有指定测试套件,所有的测试都将被放置在 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 导入了clusteros核心模块,以及原始服务器中使用的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 中的例子展示了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();
  }
}

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 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}

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 一段 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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意JSON.parse()JSON.stringify()都是可以抛出异常的同步方法。因此,这些方法的任何使用都应该包装在一个try...catch语句中。

摘要

JSON 在 Node 生态系统中得到了广泛的应用,这一点您现在肯定已经看到了。例如,任何值得使用的包都会包含一个package.json文件。事实上,为了使模块与npm一起使用,需要一个package.json。几乎每个数据 API 都是使用 JSON 构建的,因为 Node 社区更倾向于 JSON,而不是 XML。因此,理解 JSON 对于有效使用 Node 至关重要。幸运的是,JSON 很容易阅读、编写和理解。阅读完本章后,您应该对 JSON 有足够的了解,可以在您自己的应用中使用它,或者与其他应用进行交互(例如,RESTful web 服务)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值