Node-实践指南-全-
Node 实践指南(全)
原文:Node.Js In Practice
译者:飞龙
第一部分:Node 基础
Node 拥有一个极小的标准库,旨在为模块开发者提供最低级别的 API 以构建。尽管相对容易找到第三方模块,但许多任务可以在没有它们的情况下完成。在接下来的章节中,我们将深入研究多个核心模块,并探讨如何将它们用于实际应用。
通过加强对这些模块的理解,你将反过来成为一个更加全面的 Node 程序员。你还将能够更有信心和更深入地分析第三方模块。
第一章:入门
本章涵盖
-
为什么选择 Node?
-
Node 的主要特性
-
构建 Node 应用程序
Node 已迅速成为了一个可行且确实高效的 Web 开发平台。在 Node 出现之前,JavaScript 在服务器端还只是一种新颖的技术,而对于其他脚本语言来说,非阻塞 I/O 则需要特殊的库。有了 Node,这一切都发生了改变。
非阻塞 I/O 和 JavaScript 的结合非常强大:我们可以在同一个进程中异步地处理文件的读写、网络套接字等,所有这些都具有 JavaScript 回调的自然和表达性特征。
本书面向中级 Node 开发者,因此本章是一个快速回顾。如果你想要对 Node 的基础知识进行彻底的了解,那么请参阅我们的配套书籍,Node.js in Action(由 Mike Cantelon、Marc Harter、TJ Holowaychuk 和 Nathan Rajlich 编写;Manning Publications,2013 年)。
在本章中,我们将介绍 Node,它是什么,它是如何工作的,以及为什么它是你无法离开的东西。在 第二章 中,你将通过查看 Node 的全局变量——每个 Node 进程可用的对象和方法——来尝试一些技术。
预飞检查
Node In Practice 是一本食谱风格的书籍,面向中级和高级 Node 开发者。尽管本章涵盖了一些入门材料,但后续章节会迅速深入。如果你想要对 Node 的基础知识进行彻底的了解,请参阅我们的配套书籍,Node.js in Action。
1.1. 了解 Node
Node 是一个用于开发网络应用的 平台。它基于 V8,即 Google 的 JavaScript 运行时引擎。Node 不仅仅是 V8,Node 平台的一个重要部分是其核心库。这包括从 TCP 服务器到异步和同步文件管理的一切。本书将教会你如何正确使用这些模块。
但首先:为什么使用 Node,何时应该使用它?让我们通过查看 Node 在哪些场景中表现出色来探讨这个问题。
1.1.1. 为什么选择 Node?
假设你正在构建一个广告服务器,每分钟分发数百万条广告。Node 的非阻塞 I/O 将是这种情况下极具成本效益的解决方案,因为服务器可以充分利用可用的 I/O,而无需你编写特殊的低级代码。此外,如果你已经有一个能够编写 JavaScript 的网络团队,那么他们应该能够为 Node 项目做出贡献。一个典型的、更重的网络平台不会有这些优势,这就是为什么像微软这样的公司尽管拥有像 .NET 这样的优秀技术栈,也会为 Node 做出贡献。Visual Studio 用户可以安装 Node 特定的工具^([1]),这些工具增加了对 Intelli-Sense、性能分析甚至 npm 的支持。微软还开发了 WebMatrix (www.microsoft.com/web/webmatrix/),它直接支持 Node,也可以用来部署 Node 项目。
Node 通过拥抱非阻塞 I/O 作为提高某些类型应用程序性能的一种方式。JavaScript 的传统事件驱动实现意味着它具有相对方便且易于理解的语法,适合异步编程。在典型的编程语言中,I/O 操作会阻塞执行,直到完成。Node 的异步文件和网络 API 意味着在相对较慢的 I/O 操作完成的同时,处理仍然可以发生。图 1.1 展示了如何使用异步网络和文件系统 API 执行不同的任务。
图 1.1. 使用 Node 构建的广告服务器

在 图 1.1 中,一个新的 HTTP 请求已被 Node 的 http 模块接收并解析
。然后广告服务器的应用程序代码使用异步 API(传递给数据库读取函数的回调)进行数据库查询
。当 Node 等待此操作完成时,广告服务器能够从磁盘读取模板文件
。这个模板将用于显示合适的网页。一旦数据库请求完成,模板和数据库结果将用于生成响应
。
当这一切发生时,其他请求也可能正在撞击广告服务器,并且它们将根据可用资源进行处理
。在开发广告服务器时无需考虑线程,你只需通过使用标准的 JavaScript 编程技术,就能使 Node 非常高效地使用服务器的 I/O 资源。
Node 在其他场景中也表现出色,例如网络 API 和网络爬虫。如果你正在下载和提取网页内容,那么 Node 是完美的选择,因为它可以被诱导模拟 DOM 并运行客户端 JavaScript。同样,Node 在这里也有性能优势,因为爬虫和网络蜘蛛在网络和文件 I/O 方面成本较高。
如果你正在生成或消费 JSON API,Node 是一个很好的选择,因为它使处理 JavaScript 对象变得容易。Node 的 Web 框架(如 Express,expressjs.com)使创建 JSON API 变得快速且友好。我们对此有详细的介绍在第九章。
Node 不仅限于 Web 开发。你可以创建任何你喜欢的 TCP/IP 服务器。例如,一个通过网络游戏服务器将游戏状态广播到多个玩家的 TCP/IP 套接字,可以在发送数据给玩家的同时执行后台任务,比如维护游戏世界。第七章探讨了 Node 的网络 API。
何时使用 Node
为了让你像真正的 Node 主义者一样思考,下表提供了 Node 适合的应用程序示例。
| Node 的优势 |
|---|
| 场景 |
| --- |
| 广告分发 |
-
高效地分发小块信息
-
处理可能缓慢的网络连接
-
易于扩展到多个处理器或服务器
|
| 游戏服务器 |
|---|
-
使用易于理解的 JavaScript 语言来模拟业务逻辑
-
不使用 C 语言编写满足特定网络需求的程序
|
| 内容管理系统,博客 |
|---|
-
适合有客户端 JavaScript 经验的团队
-
容易创建 RESTful JSON API
-
轻量级服务器,复杂的浏览器 JavaScript
|
1.1.2. Node 的主要特点
Node 的主要特点是它的标准库、模块系统和 npm。当然,还有更多,但在这本书中,我们将专注于教你如何使用 Node 的这些部分。我们会使用第三方库,但我们会看到 Node 的许多内置功能。
事实上,Node 最强大和最有力的特性是其标准库。这实际上是两部分:一组二进制库和核心模块。二进制库包括libuv,它为网络和文件系统提供快速的运行循环和非阻塞 I/O。它还有一个 HTTP 库,所以你可以确信你的 HTTP 客户端和服务器是快速的。
图 1.2 是 Node 内部结构的高级概述,展示了所有东西是如何各就各位的。
图 1.2. Node 在上下文中的关键部分

Node 的核心模块大多是用 JavaScript 编写的。这意味着如果你对任何东西不理解或者想更深入地了解,你可以阅读 Node 的源代码。这包括网络、高级文件系统操作、模块系统和流等特性。还包括 Node 特有的特性,如使用 cluster 模块同时运行多个 Node 进程,以及将代码段包裹在基于事件的错误处理器中,称为域。
下几节将更详细地介绍每个核心模块,从events API 开始。
EventEmitter:事件 API
每个 Node 开发者迟早会遇到EventEmitter。一开始它似乎只是库作者需要使用的东西,但实际上它是 Node 大多数核心模块的基础。流、网络和文件系统 API 都源自它。
你可以通过继承EventEmitter来创建自己的基于事件的 API。假设你正在开发一个 PayPal 支付处理模块,你可以使其基于事件,这样Payment对象的实例会发出paid和refund等事件。通过这种方式设计类,你可以将其与应用程序逻辑解耦,这样你就可以在多个项目中重用它。
我们有一个专门介绍事件的章节:更多内容请参阅第四章。EventEmitter的另一个有趣之处在于它被用作stream模块的基础。
stream: 可扩展 I/O 的基础
流继承自EventEmitter,可以用来模拟具有不可预测吞吐量的数据——例如,网络连接,其中数据速度可能会根据网络上其他用户的行为而变化。使用 Node 的stream API 允许你创建一个接收关于连接事件的对象:当有新数据到来时发出data事件,没有更多数据时发出end事件,以及发生错误时发出error事件。
与将大量回调函数传递给可读流构造函数相比,这样做会变得很混乱,你可以订阅你感兴趣的事件。流可以被管道连接起来,因此你可以有一个流类,它从网络读取数据,然后将数据通过管道传输到另一个流,将其转换成其他形式。这可能是从 XML API 转换成 JSON 的数据,使得在 JavaScript 中使用它变得更加容易。
我们非常喜欢流,因此我们专门为它们编写了一整章。跳转到第五章开始深入了解。你可能认为事件和流听起来很抽象,尽管这是真的,但值得注意的是,它们被用作 I/O 模块(如fs和net)的基础。
fs: 文件操作
Node 的文件系统模块能够使用非阻塞 I/O 读写文件,但它也提供了同步方法。你可以使用fs.stat来获取文件信息,其同步版本是fs.statSync。
如果你想要以超级高效的方式使用流来处理文件内容,那么可以使用fs.createReadStream来返回一个ReadableStream对象。关于这方面的更多信息,请参阅第六章。
net: 创建网络客户端和服务器
网络模块是http模块的基础,可以用来创建通用的网络客户端和服务器。尽管 Node 开发通常被认为是基于 Web 的,但第七章展示了如何创建 TCP 和 UDP 服务器,这意味着你不仅限于 HTTP。
全局对象和其他模块
如果你有一些使用 Node 制作 Web 应用程序的经验,也许是用 Express 框架,那么你可能已经在不知不觉中使用了http、net和fs核心模块。其他内置特性可能不是那么引人注目,但对于使用 Node 创建程序来说却至关重要。
一个例子是全球对象和方法的理念。例如,process对象允许你通过访问标准 I/O 流将数据管道输入和输出到 Node 程序中。就像 Unix 和 Windows 脚本一样,你可以将数据cat到 Node 程序中。无处不在的console对象,受到所有 JavaScript 开发者的喜爱,也被视为一个全局对象。
Node 的模块系统也是全球功能的一部分。第二章中包含了展示如何使用这些特性的技术。
现在你已经看到了一些核心模块,是时候看到它们在实际中的应用了。示例将使用stream模块生成文本流的统计数据,你将能够用它处理文件和 HTTP 连接。如果你想了解更多关于 Node 中流或 HTTP 的基础知识,请参考《Node.js 实战》。
1.2. 构建一个 Node 应用程序
我们不会深入更多的理论,而是会展示如何构建一个 Node 应用程序。但这不仅仅是一个应用程序:它使用了 Node 的一些关键特性,如模块和流。这将是一次快速而深入的 Node 之旅,所以请启动你最喜欢的文本编辑器和终端,准备就绪。
在接下来的 10 分钟内,你将学到以下内容:
-
如何创建一个新的 Node 项目
-
如何编写你自己的流类
-
如何编写一个简单的测试并运行它
流非常适合处理数据,无论是读取、写入还是转换。想象一下,你想将数据库中的数据转换为另一种格式,比如 CSV。你可以创建一个流类,它接受来自数据库的输入并将其作为 CSV 流输出。这个新的 CSV 流可以连接到 HTTP 请求,这样你就可以直接将 CSV 流到浏览器。同一个类甚至可以连接到可写文件流——你甚至可以将流分叉来创建文件并发送到网页浏览器。
在这个例子中,流类将接受文本输入,根据正则表达式计算单词匹配,然后在流发送完毕时通过事件发出结果。你可以用它来计算文本文件中的单词匹配,或者从网页中读取数据并计算段落标签的数量——这取决于你。首先我们需要创建一个新的项目。
1.2.1. 创建一个新的 Node 项目
你可能想知道专业 Node 开发者是如何创建新项目的。多亏了 npm,这个过程非常直接。虽然你可以创建一个 JavaScript 文件并运行node file.js,但我们将使用npm init来创建一个带有 package.json 文件的新项目。创建一个新的目录
,cd
进入它,然后运行npm init
:

习惯于输入这些命令:您会经常这样做!当 npm 提示时,您可以按 Return 接受默认值。在您写下第一行 JavaScript 代码之前,您已经看到了 Node 的一个主要特性——npm——是多么酷。它不仅用于安装模块,还用于管理项目。
何时使用 package.json 文件
您可能有一个小脚本的思路,并想知道是否真的需要 package.json 文件。这并不总是必要的,但通常您应该尽可能多地创建它们。
Node 开发者更喜欢小型模块,在 package.json 中表达依赖关系意味着无论项目多小,将来在您的机器上或在其他人的机器上安装都非常容易。
现在是时候编写一些 JavaScript 代码了。在下一节中,您将创建一个新的 JavaScript 文件,该文件实现了一个流。
1.2.2. 创建流类
创建一个名为 countstream.js 的新文件,并使用 util.inherits 从 stream.Writable 继承并实现所需 _write 方法。太快了吗?让我们放慢速度。完整的源代码在下面的列表中。
列表 1.1. 一个计数可写流

这个例子说明了本书中后续示例的工作方式。我们展示了一段代码片段,并对其底层代码进行了注释。例如,类的前一部分使用 util.inherits 方法从 Writable 基类继承!
。这个例子在这里不会完全展开——关于编写自己的流的更多信息,请参阅第五章技术 30。现在,只需关注正则表达式是如何传递给构造函数!
并用于在类实例中流动文本计数!
。Node 的 Writable 类会为我们调用 _write,所以我们目前不需要担心这一点。
流和事件
在列表 1.1 中有一个事件,total。这是我们编写的——您也可以自己编写。流继承自 EventEmitter,因此它们具有相同的 emit 和 on 方法。
当没有更多数据时,Node 的 Writable 基类也会调用 end!
。这个流可以按需实例化和管道连接。在下一节中,您将看到如何使用 pipe 连接它。
1.2.3. 使用流
现在您已经看到了如何创建流类,您可能迫不及待地想尝试一下。创建另一个文件,index.js,并添加下一列表中所示的代码。
列表 1.2. 使用 CountStream 类

您可以通过输入 node index.js 来运行此示例。它应该显示类似 Total matches: 24 的内容。您可以通过更改它获取的 URL 来进行实验。
这个例子从列表 1.1 加载模块,然后使用文本'book'实例化它
。它还使用 Node 的标准http模块从网站下载文本
,然后将结果通过我们的CountStream类进行管道传输
。
这里重要的是res.pipe(countStream)。当你进行管道传输时,数据的大小或网络是否缓慢并不重要:CountStream类将尽职尽责地计数匹配项,直到数据处理完毕。这个 Node 程序不会首先下载整个文件!它是逐块处理文件的。这就是这里的大事,也是 Node 开发的一个关键方面。
回顾一下,图 1.3 总结了您到目前为止创建新 Node 项目所做的工作。首先,您创建了一个新目录,并运行了npm init
,然后创建了 JavaScript 文件
,最后运行了代码
。
图 1.3. 创建新的 Node 项目的三个步骤

Node 开发的重要部分之一是测试。下一节将通过测试CountStream来总结这个例子。
1.2.4. 编写测试
我们可以不使用任何第三方模块为CountStream编写一个简短的测试。Node 自带了一个内置的assert模块,因此我们可以用它来进行快速测试。打开 test.js 并添加下面的代码。
列表 1.3. 使用CountStream类

这个测试可以用node test.js运行,你应该在控制台看到打印出Assertions passed: 1。实际上,这个测试读取当前文件并通过CountStream传递数据。它可能会调用 Ouroboros,但这是一个有用的例子,因为它给我们提供了我们了解的内容——我们可以始终确信有一个匹配的单词example。
断言
Node 自带了一个名为assert的断言库。可以通过直接调用模块来制作一个基本的测试 – assert(expression)。
测试首先监听total事件,该事件由CountStream的实例发出
。这是一个很好的地方来断言匹配的数量应该与预期相同
。打开了一个表示当前文件的可读流,并将其通过我们的类进行管道传输
。在程序结束前,我们打印出触发了多少断言
。
这很重要,因为如果total事件从未触发,那么assert.equal根本不会运行。我们没有方法知道回调中的测试是否运行,因此使用了一个简单的计数器来展示 Node 编程可能需要从您可能熟悉的其他编程语言和平台上获取的模式。
如果你感到疲倦,可以在这里休息一下,但我们的项目还有一些甜头。Node 开发者喜欢在命令行上使用 npm 运行测试和其他脚本。打开 package.json 并将"test"属性更改为如下所示:
"scripts": {
"test": "node test.js"
},
现在,你只需输入 npm test 就可以运行测试。当你有很多测试且运行它们更复杂时,这非常有用。测试、测试运行器和异步测试问题都在第十章(kindle_split_020.html#ch10)中有所涉及。
npm 脚本
可以通过编辑 package.json 来配置 npm test 和 npm start 命令。你还可以运行任意命令,这些命令通过 npm run command 调用。你所需要做的只是设置 scripts 下的一个属性,就像列表 1.4 一样。
这对于特定类型的测试或维护程序很有用——例如 npm run integration-tests,或者甚至 npm run seed-data。
根据你之前对 Node 的经验,这个例子可能有些复杂,但它捕捉了 Node 开发者的思考方式和利用 Node 带来的强大资源的方式。
现在,你已经看到了一个 Node 项目的构建方式,我们已经完成了 Node 的复习课程。下一章介绍了我们的第一套技术,这是本书格式的主要内容。它涵盖了所有 Node 程序都可以使用的全局功能的工作方式。
1.3. 摘要
在本章中,你学习了关于 Node.js in Practice 的内容——它涵盖了什么以及它如何专注于 Node 令人印象深刻的内置核心模块,如网络模块和文件系统模块。
你还了解到了使 Node 运行的原因以及如何使用它。我们涵盖的一些主要点是
-
何时使用 Node,以及 Node 如何构建在非阻塞 I/O 之上,允许你编写标准的 JavaScript 但获得出色的性能优势。
-
Node 的标准库被称为其 核心模块。
-
核心模块的功能——如网络协议的 I/O 任务,以及与文件和更通用的功能(如流)的工作。
-
如何快速启动一个新的 Node 项目,包括 package.json 文件,以便添加依赖项和脚本。
-
如何使用 Node 强大的
streamAPI 处理数据。 -
流继承自
EventEmitter,因此你可以发出并响应你想要在应用程序中使用的任何事件。 -
如何仅使用 npm 和
assert模块编写小型测试——你可以在不安装任何第三方库的情况下测试想法。
最后,我们希望你能从我们的入门级应用程序中学到一些东西。使用基于事件的 API、非阻塞 I/O 和流确实是 Node 的核心,但利用 Node 的独特工具(如 package.json 文件和 npm)也同样重要。
现在是时候介绍技术了。下一章介绍了你甚至不需要加载就可以使用的功能:全局对象。
第二章. 全局变量:Node 的环境
本章涵盖
-
使用模块
-
不需要任何模块就能做的事情
-
进程和控制台对象
-
定时器
全局对象在所有模块中都是可用的。它们是通用的。无论你是在编写网络程序、命令行脚本还是网络应用程序,你的程序都将能够访问这些对象。这意味着你可以始终依赖像 console.log 和 __dirname 这样的功能——这两个功能在本章中都有详细解释。
本章的目标是介绍 Node 的全局对象和方法,帮助你了解所有 Node 进程可用的功能。这将帮助你更好地理解 Node 以及它与操作系统的关系,以及它与浏览器等其他 JavaScript 环境的比较。
Node 提供了一些重要的内置功能,即使不加载任何模块也是如此。除了 ECMAScript 语言提供的功能外,Node 还包含几个 宿主对象——Node 提供的对象,以帮助程序执行。
一个关键的全球对象是 process,它用于与操作系统通信。Unix 程序员将熟悉标准 I/O 流,并且可以通过 Node 的流式 API 通过 process 对象访问这些流。
另一个重要的全局对象是 Buffer 类。这是因为它包含了 JavaScript 传统的二进制数据支持不足的问题。随着 ECMAScript 标准的发展,这个问题正在得到解决,但到目前为止,大多数 Node 开发者仍然依赖于 Buffer 类。有关缓冲区的更多信息,请参阅第三章 [kindle_split_012.html#ch03]。
一些全局变量是每个模块的独立实例。例如,module 在每个 Node 程序中都是可用的,但它是当前模块的局部变量。由于 Node 程序可能由多个模块组成,这意味着给定的程序有多个不同的 module 对象——它们的行为像全局变量,但位于 模块作用域 内。
在下一节中,你将学习如何加载模块。与模块相关的对象和方法是全局的,因此它们始终可用并准备好使用。
2.1. 模块
模块可以用来组织更大的程序和分发 Node 项目,因此熟悉安装和创建它们的基本技术很重要。
技巧 1 安装和加载模块
无论你是在使用 Node 提供的核心模块还是 npm 的第三方模块,模块的支持都内置在 Node 中,并且始终可用。
问题
你想从 npm 加载第三方模块。
解决方案
使用命令行工具 npm 安装模块,然后使用 require 加载模块。以下列表显示了一个安装 express 模块的示例。
列表 2.1. 使用 npm

讨论
npm 命令行工具与 Node 一起分发,可用于搜索、安装和管理包。网站 npmjs.org 提供了另一个搜索模块的界面,每个模块都有自己的页面,显示相关的说明文件和依赖项。
一旦知道了模块的名称,安装就变得简单:键入 npm install module-name
它将被安装到 ./node_modules 中。模块也可以“全局”安装——运行 npm install -g module_name 将其安装到全局文件夹中。在 Unix 系统上,这通常是 /usr/local/lib/node_modules。在 Windows 上,它应该在 node.exe 二进制文件所在的任何位置。
模块安装后,可以使用 require('module-name') 来加载它
。require 方法通常会返回一个对象或一个方法,具体取决于模块是如何设置的。
搜索 npm
默认情况下,npm 会搜索每个模块的 package.json 文件中的多个字段。这包括模块的名称、描述、维护者、URL 和关键词。这意味着简单的搜索如 npm search express 会产生数百个结果。
您可以通过使用正则表达式来减少匹配的数量。将搜索词用斜杠括起来以触发 npm 的正则表达式匹配:npm search /^express$/
然而,这仍然有限。幸运的是,有一些开源模块可以改进内置的搜索命令。例如,Gorgi Kosev 的 npmsearch 会使用它自己的相关性排名来排序结果。
是否全局安装模块的问题对于开发可维护的项目至关重要。如果其他人需要参与您的项目,那么您应该考虑将模块作为依赖项添加到项目 package.json 文件中。保持项目依赖项紧密管理将使得在依赖项的新版本发布时更容易维护它们。
技术二:创建和管理模块
除了安装和分发开源模块外,“本地”模块还可以用来组织项目。
问题
您希望将项目拆分成单独的文件。
解决方案
使用 exports 对象。
讨论
Node 的模块系统为在多个文件之间分割代码提供了一个解决方案。它与 C 中的 include 或 Ruby 和 Python 中的 require 非常不同。主要区别在于 Node 中的 require 返回一个对象,而不是将代码加载到当前命名空间中,就像 C 预处理器会发生的那样。
在 技术 1 中,您看到了如何使用 npm 安装模块,以及如何使用 require 来加载它们。尽管 npm 不是唯一管理模块的工具,但 Node 基于 CommonJS Modules/1.1 规范(wiki.commonjs.org/wiki/Modules/1.1)提供了一个内置的模块系统。
这允许对象、函数和变量从一个文件导出并在其他地方使用。exports 对象始终存在,尽管本章专门探讨了全局对象,但它实际上并不是全局的。更准确地说,exports 对象位于模块作用域内。
当一个模块围绕一个单一类构建时,模块的用户将更喜欢键入 var MyClass = require('myclass'); 而不是 var MyClass = require('myclass').MyClass,因此您应该使用 module.exports。列表 2.2 展示了这是如何工作的。这与使用 exports 对象不同,它要求您设置一个属性来导出某些内容。
列表 2.2. 导出模块

列表 2.3 展示了如何导出多个对象、方法或值,这是一种通常用于导出多个内容的实用库技术。
列表 2.3. 导出多个对象、方法和值
exports.method = function() {
return 'Hello';
};
exports.method2 = function() {
return 'Hello again';
};
最后,列表 2.4 展示了如何使用 require 加载这些模块以及如何使用它们提供的功能。
列表 2.4. 使用 require 加载模块

注意,加载本地模块始终需要一个路径名——在这些示例中,路径只是 ./. 如果没有它,Node 将尝试在 \(NODE_PATH 中找到匹配的模块,然后是 ./node_modules、\)HOME/.node_modules、$HOME/.node_libraries 或 $PREFIX/lib/node。
在 列表 2.4 中注意,./myclass 会自动展开为 ./myclass.js
,而 ./module-2 会展开为 ./module-2.js
。
该程序的输出如下:
Hello
Hello
Hello again
哪个模块?
要确定 Node 将加载的确切模块,请使用 require.resolve(id)。这将返回一个完全展开的文件名。
一旦加载了一个模块,它就会被缓存。这意味着多次加载它将返回缓存的副本。这通常很高效,并有助于在项目中大量重用模块,而无需担心使用 require 时产生开销。您不必集中加载所有依赖项,可以安全地对该模块调用 require。
卸载模块
虽然自动缓存模块适合 Node 开发中的许多用例,但可能存在一些罕见的情况,您可能想要卸载一个模块。require.cache 对象使这成为可能。
要从缓存中删除一个模块,请使用 delete 关键字。需要模块的完整路径,您可以使用 require.resolve 获取。例如:
delete require.cache[require.resolve('./myclass')];
这应该返回 true,这意味着模块已被卸载。
在下一个技巧中,您将学习如何将相关模块分组并一次性加载它们。
技巧 3 加载一组相关模块
Node 可以将目录视为模块,这为逻辑上分组相关模块提供了机会。
问题
您希望将相关文件分组在目录下,并且只需通过一次 require 调用即可加载。
解决方案
创建一个名为 index.js 的文件来加载每个模块并将它们作为一组导出,或者在该目录中添加一个 package.json 文件。
讨论
有时,一个模块在逻辑上可能是自包含的,但将其分成几个文件仍然是有意义的。你将在 npm 上找到的大多数模块都是这样编写的。Node 的模块系统通过允许目录作为模块来支持这一点。最简单的方法是创建一个名为 index.js 的文件,该文件包含一个 require 语句来加载每个文件。下面的列表演示了这是如何工作的。
列表 2.5. group/index.js 文件

group/one.js 和 group/two.js 文件可以随后按需导出值或方法
。下面的列表显示了此类文件的示例。
列表 2.6. group/one.js 文件
module.exports = function() {
console.log('one');
};
需要使用文件夹作为模块的代码可以使用单个 require 语句一次性加载所有内容。下面的列表演示了这一点。
列表 2.7. 加载模块组的文件

列表 2.7 的输出应如下所示:
one
two
这种方法通常用作架构技术来构建 Web 应用程序。相关项目,如控制器、模型和视图,可以保存在不同的文件夹中,以帮助在应用程序中分离关注点。图 2.1 展示了按照这种风格构建应用程序的方法。
图 2.1. 作为模块的文件夹

Node 还提供了一种支持此模式的替代技术。将一个 package.json 文件添加到目录中可以帮助模块系统一次性确定如何加载目录中的所有文件。该 JSON 文件应包含一个 main 属性,指向一个 JavaScript 文件。这实际上是 Node 在加载模块时默认查找的文件——如果没有 package.json,它将接着查找 index.js。下面的列表显示了 package.json 文件的示例。
列表 2.8. 包含模块的目录的 package.json 文件

文件扩展名
当加载文件时,Node 配置为搜索具有 .js、.json 和 .node 扩展名的文件。可以使用 require.extensions 数组来告诉 require 加载具有其他扩展名的文件。当 Node 的模块系统将目录视为模块时,也会考虑这一点。
此功能在 Node 的文档中被标记为已弃用,但模块系统也被标记为“锁定”,因此它不应该消失。如果你想使用它,你应该首先检查 Node 的文档。^([1]) 如果你只是尝试从具有不寻常扩展名的旧系统加载 JavaScript 文件,那么它可能适合实验。
¹ 查看
nodejs.org/api/globals.html#globals_require_extensions。
require API 提供了许多管理文件的方法。但当你想要加载相对于当前模块或模块保存的目录中的内容时怎么办?请继续阅读以了解 技巧 4 的解释。
技巧 4:处理路径
有时候你需要根据相对位置打开文件。Node 提供了确定当前文件、目录和模块路径的工具。
问题
你想访问一个不由模块系统处理的文件。
解决方案
使用 __dirname 或 __filename 确定文件的路径。
讨论
有时候你需要从文件中加载数据,这些文件显然不应该由 Node 的模块系统处理,但你需要考虑当前脚本的路径——例如,一个 Web 应用程序中的模板。在这种情况下,__dirname 和 __filename 变量非常有用。
运行以下列表将打印这些值的输出。
列表 2.9. 路径变量

大多数开发者使用简单的字符串连接将这两个变量与路径片段连接起来:var view = __dirname + '/views/view.html';。这在 Windows 和 Unix 上都适用——Windows API 足够智能,可以自动将反斜杠转换为本地格式,因此你不需要特殊处理来支持这两个操作系统。
如果你确实想确保路径正确连接,可以使用 Node 的 path 模块中的 path.join 方法:path.join(__dirname, 'views', 'view.html');。
除了模块管理之外,还有全局可用的对象用于写入标准 I/O 流。下一组技巧将探讨 process.stdout 和 console 对象。
2.2. 标准 I/O 和 console 对象
可以使用 Unix 或 Windows 的命令行工具将文本导入到 Node 进程中。本节包括处理这些标准 I/O 流的技术,以及如何正确使用 console 对象来完成各种与日志记录相关的任务。
技巧 5 读取和写入标准 I/O
每当你需要将数据输入和输出到程序中时,一个有用的技巧是使用 process 对象来读取和写入标准 I/O 流。
问题
你想将数据从 Node 程序中导入导出。
解决方案
使用 process.stdout 和 process.stdin。
讨论
process.stdout 对象是一个可写流,用于 stdout。我们将在第五章(kindle_split_014.html#ch05)中更详细地介绍流,但就目前而言,你需要知道它是每个 Node 程序都可以访问的 process 对象的一部分,并且对于显示和接收文本输入非常有用。
下一个列表显示了如何从另一个命令中读取文本,处理它,然后再输出。
列表 2.10. 路径变量

每次从输入流中读取一段文本时,它都会通过 toUpperCase() 转换,然后写入输出流。 显示了数据如何从一个操作系统进程流过你的 Node 程序,然后流出到另一个程序。在终端中,这些程序将通过管道(
|)符号连接起来。
图 2.2. 使用 stdio 的简单程序中的数据流。

这种基于 管道 的方法在处理 Unix 中的输入时效果很好,因为许多其他命令都是设计成这样工作的。这给 Node 程序带来了类似乐高积木的模块化,从而促进了重用。
如果你只想打印消息或错误,Node 通过 console 对象提供了一个专门为此目的而设计的更简单的 API。下一技术将解释如何使用它,以及一些不太明显的功能。
技巧 6 记录消息
从程序中记录信息和错误的最简单方法是通过使用 console 对象。
问题
你希望将不同类型的消息记录到控制台。
解决方案
使用 console.log、console.info、console.error 和 console.warn。务必利用这些方法提供的内置格式化功能。
讨论
console 对象有几种方法可以用来输出不同类型的消息。它们将被写入相关的输出流,这意味着你可以在 Unix 系统上相应地管道化它们。
尽管基本用法是 console.log('message'),但其中还包含了更多功能。变量可以被插入,或者简单地与字符串字面量一起追加。这使得记录显示原始值或对象内容的消息变得极其容易。以下列表演示了这些功能。
列表 2.11. 路径变量

列表 2.11 的输出如下所示:

当消息字符串被格式化时,使用 util.format。表 2.1 显示了支持的格式化占位符。
表 2.1. 格式化占位符
| 占位符 | 类型 | 示例 |
|---|---|---|
| %s | 字符串 | '%s', 'value' |
| %d | 数字 | '%f', 3.14 |
| %j | JSON | '%j', |
这些格式化占位符很方便,但仅仅能够简单地在 console.log 消息中包含对象,而不需要手动追加字符串,这也是记录消息的一个方便方法。
info 和 warn 方法是 log 和 error 的同义词。log 和 error 之间的区别在于使用的输出流。在 技巧 5 中,你看到了 Node 如何使标准输入和输出流对所有程序可用。它还通过 process.stderr 暴露标准错误流。console.error 方法将写入此流,而不是 process.stdout。这意味着你可以在终端或 shell 脚本中重定向 Node 进程的 error 消息。
如果你以前面的列表使用 2> error-file.log 运行,错误消息将被重定向到 error-file.log。其他消息将像往常一样打印到控制台:
node listings/globals/console-1.js 2> errors-file.log
2 处理器指向错误流;1 是标准输出。这意味着你可以将错误重定向到日志文件,而无需在 Node 程序中打开文件,或者使用特定的日志模块。传统的 shell 重定向对于许多项目来说已经足够好了。
标准流
标准流有三种类型:stdin、stdout 和 stderr。在 Unix 终端中,这些通过数字来引用。0 用于标准输入,1 用于标准输出,2 用于标准错误。
对于 Windows 系统也是如此:从命令提示符运行程序并添加 2> errors-file.log 将会将错误信息发送到 errors-file.log,就像 Unix 一样。
堆栈跟踪
console 对象的另一个特性是 console.trace()。此方法在当前执行点生成堆栈跟踪。生成的堆栈跟踪包括调用异步回调的代码的行号,这有助于报告那些否则难以追踪的错误。例如,在事件监听器内部生成的跟踪将显示事件是从哪里触发的。第五章 中的技巧 28 详细探讨了这一点。
console 的另一个稍微高级一些的用法是其基准测试功能。继续阅读以详细了解。
技巧 7:基准测试程序
Node 使得无需任何额外工具即可进行程序基准测试。
问题
您需要基准测试一个运行缓慢的操作。
解决方案
使用 console.time() 和 console.timeEnd()。
讨论
在你作为 Node 程序员职业生涯中,你将会有这样的时刻:试图确定某个特定操作为何运行缓慢。幸运的是,console 对象自带一些内置的基准测试功能。
调用 console.time('label') 记录当前时间(以毫秒为单位),然后稍后调用 console.timeEnd('label') 显示从该点开始的时间长度。时间(以毫秒为单位)将自动打印在标签旁边,因此您不需要单独调用 console.log 来打印标签。
列表 2.12 是一个接受命令行参数的简短程序(有关处理参数的更多信息,请参阅技巧 9),其中包含基准测试以查看文件输入读取的速度。
列表 2.12. 基准测试一个函数

使用多个带有不同标签的 console.time 交错调用,可以执行多个基准测试,这对于探索复杂、嵌套的异步程序的性能非常完美。
这些函数基于 Date.now() 计算持续时间,提供毫秒级的精度。为了获得更精确的基准测试,可以使用第三方 benchmark 模块(npmjs.org/package/benchmark)与 microtime 模块(npmjs.org/package/microtime)一起使用。
process 对象用于处理标准 I/O 流,并且使用得当,console 可以处理许多初学者可能会用第三方模块解决的问题。在下一节中,我们将进一步探讨 process 对象,看看它是如何帮助与更广泛的操作系统集成的。
2.3. 操作系统和命令行集成
可以使用 process 对象获取有关操作系统的信息,并使用退出代码和信号监听器与其他进程通信。本节包含一些更高级的技术,用于使用这些功能。
技巧 8 获取平台信息
节点提供了一些内置方法用于查询操作系统功能。
问题
您需要根据操作系统或处理器架构运行特定平台的代码。
解决方案
使用 process.arch 和 process.platform 属性。
讨论
Node JavaScript 通常具有可移植性,因此您不太可能需要根据操作系统或进程架构进行分支。但您可能希望针对特定操作系统功能定制项目,或者简单地收集有关脚本在哪些系统上执行的统计数据。某些包含对二进制库绑定绑定的基于 Windows 的模块可以在 32 位和 64 位版本的二进制之间切换。下一个列表显示了如何支持这一点。
列表 2.13. 基于架构的分支
switch (process.arch) {
case 'x64':
require('./lib.x64.node');
break;
case 'ia32':
require('./lib.Win32.node');
break;
default:
throw new Error('Unsupported process.arch:', process.arch);
}
通过 process 模块也可以从系统中获取其他信息。其中一个方法是 process.memoryUsage()—它返回一个对象,包含三个属性,描述了进程当前的内存使用情况:
-
rss—常驻集大小,即进程在 RAM 中持有的内存部分 -
heapTotal—动态分配可用内存 -
heapUsed—已使用的堆内存量
下一个技术将更详细地探讨处理命令行参数。
技巧 9 传递命令行参数
Node 提供了一个简单的 API 用于命令行参数,您可以使用它将选项传递给程序。
问题
您正在编写一个需要从命令行接收简单参数的程序。
解决方案
使用 process.argv。
讨论
process.argv 数组允许您检查是否向您的脚本传递了任何参数。因为它是一个数组,您可以使用它来查看传递了多少参数,如果有。前两个参数是 node 和脚本的名称。
列表 2.14 展示了使用 process.argv 的一种方法。此示例遍历 process.argv 并将其切片以“解析”带有选项的参数标志。您可以使用 node arguments.js -r arguments.js 运行此脚本,并且它会打印出其自身的源代码。
列表 2.14. 操作命令行参数

args 对象
包含脚本支持的每个开关。然后使用 createReadStream
将文件 pipe 到标准输出流。最后,使用 Function.prototype.apply
执行 args 中命令行开关引用的函数。
尽管这是一个玩具示例,但它说明了在不依赖第三方模块的情况下,process.argv 可以多么方便。由于它是一个 JavaScript Array,因此它非常容易处理:您可以使用 map、forEach 和 slice 等方法轻松处理参数。
复杂参数
对于更复杂的程序,请使用选项解析模块。最流行的两个是 optimist (npmjs.org/package/optimist) 和 commander (npmjs.org/package/commander)。optimist 将参数转换为 Object,这使得它们更容易操作。它还支持默认值、自动使用生成和简单的验证,以确保已提供某些参数。commander 略有不同:它使用一个抽象的 程序 概念,允许您使用链式 API 指定程序接受的参数。
良好的 Unix 程序在需要时处理参数,并且它们也会通过返回合适的状态码来退出。下一个技巧将展示如何以及何时使用 process.exit 来表示程序的完成成功或失败。
技巧 10 退出程序
Node 允许您在程序终止时指定退出码。
问题
您的 Node 程序需要以特定的状态码退出。
解决方案
使用 process.exit()。
讨论
退出状态码在 Windows 和 Unix 中都具有重要意义。其他程序会检查退出状态以确定程序是否正确运行。当编写参与更大系统的 Node 程序时,这一点尤为重要,并且有助于后续的监控和调试。
默认情况下,Node 程序返回 0 退出状态。这意味着程序已正确运行并终止。任何非零状态都被视为错误。在 Unix 中,通常使用 $? 在 shell 中访问此状态码。Windows 的等效项是 %errorlevel%。
列表 2.15 展示了对 列表 2.14 的修改,当未指定 -r 选项的文件名时,程序会以相关的状态码干净地退出。
列表 2.15. 返回有意义的退出状态码

运行 列表 2.15 后,在 Unix 终端中键入 echo $? 将显示 1。请注意,console.error
用于输出错误消息。这将导致消息被写入 process.stderr,这使得脚本的用户可以轻松地将错误消息管道到某个地方。
具有特殊含义的退出码
在《高级 Bash 脚本指南》(tldp.org/LDP/abs/html/index.html)中,有一个专门讨论带有特殊含义的退出状态码的页面,称为带有特殊含义的退出状态码(tldp.org/LDP/abs/html/exitcodes.html)。这试图概括错误代码,尽管在脚本语言之外没有标准的状态码列表,非零表示发生了错误。
由于许多 Node 程序是异步的,有时您可能需要显式调用process.exit()或关闭 I/O 连接,以使 Node 进程优雅地结束。例如,使用 Mongoose 数据库库(mongoosejs.com/)的脚本需要在 Node 进程能够退出之前调用mongoose.connection.close()。
您可能需要跟踪挂起的异步操作的数量,以确定何时调用mongoose.connection.close()或另一个数据库模块的等效操作是安全的。大多数人使用一个简单的计数器变量来做这件事,在异步操作开始之前增加它,然后在它们的回调触发后减少它。一旦它达到0,关闭连接就是安全的。
开发正确程序的一个重要方面是创建信号处理程序。继续阅读以了解 Node 如何实现信号处理程序以及何时使用它们。
技巧 11 响应信号
节点程序可以响应其他进程发送的信号。
问题
您需要响应其他进程发送的信号。
解决方案
使用发送到process对象的信号事件。
讨论
大多数现代操作系统使用信号作为向程序发送简单消息的一种方式。信号处理程序通常用于在后台运行的程序中,因为这可能是在它们之间通信的唯一方式。在其他情况下,它们也可以在您最可能编写的程序类型中很有用——考虑一个在接收到SIGTERM时干净地关闭其数据库连接的 Web 应用程序。
process对象是一个EventEmitter,这意味着您可以向其添加事件监听器。为 POSIX 信号名称添加监听器应该可以工作——在 Unix 系统上,您可以通过输入man sigaction来查看所有信号的名字。
信号监听器使您能够满足 Unix 程序的预期行为。例如,许多服务器和守护进程在接收到SIGHUP信号时会重新加载配置文件。下一个列表显示了如何将监听器附加到SIGHUP。
列表 2.16. 为 POSIX 信号添加监听器

在对标准输入进行任何操作之前,应该调用resume以防止 Node 立即退出。接下来,在process对象上添加对SIGHUP事件的监听器!。最后,显示当前进程的 PID!。
一旦代码清单 2.16 中的程序开始运行,它将显示进程的 PID。可以使用kill命令与 PID 一起发送进程信号。例如,kill-HUP 94962将向 PID 94962发送HUP信号。如果你发送另一个信号,或者只输入kill 94962,那么进程将退出。
重要的是要意识到,信号可以从任何进程发送到任何其他进程,无论权限如何。你的 Node 进程可以通过使用process.kill(pid, [signal])向另一个进程发送信号——在这种情况下,kill并不意味着进程将被“杀死”,而只是发送了一个特定的信号。这个方法是以 C 标准库中signal.h函数的名称命名的。
图 2.3 展示了信号如何在操作系统中从任何进程产生,并且可以被你的 Node 进程接收。
图 2.3. 信号从进程产生,并通过事件监听器处理。

你不必在 Node 程序中响应信号,但如果你正在编写一个长时间运行的网络服务器,那么信号监听器可以非常有用。支持像SIGHUP这样的信号将使你的程序更自然地融入现有系统。
Node 的吸引力很大一部分在于其异步 API 和非阻塞 I/O 特性。有时可能需要模拟这种行为——比如在自动化测试中——或者简单地强制代码稍后执行。在下一节中,我们将探讨 Node 如何实现 JavaScript 定时器,这些定时器支持此类功能。
2.4. 使用定时器延迟执行
Node 实现了 JavaScript 定时器函数setTimeout、setInterval、clearTimeout和clearInterval。这些函数是全局可用的。尽管它们是 Mozilla 定义的 JavaScript 的一部分,但它们并未在 ECMAScript 标准中定义。相反,定时器是 HTML DOM Level 0 规范的一部分。
技巧 12 使用setTimeout延迟执行函数
使用 Node 的setTimeout全局方法可以在延迟后运行代码一次。
问题
你想在延迟后执行一个函数。
解决方案
使用setTimeout,并在必要时使用Function.prototype.bind。
讨论
setTimeout最基本的使用很简单:传递一个要执行的函数和延迟(以毫秒为单位):
setTimeout(function() {
console.log('Hello from the past!');
}, 1000);
这看起来很简单且人为,但你会在测试中看到它被最常使用,在这些测试中,正在测试异步 API,并且需要一个小延迟来模拟现实世界的行为。Node 支持 JavaScript 定时器,正是为了应对这种情况。
可以通过使用Function.prototype.bind轻松地将方法传递给setTimeout。这可以用来绑定第一个参数到this,或者更常见的是,绑定方法所属的对象。以下列表展示了如何使用bind与一个简单的对象结合使用。
代码清单 2.17. 将setTimeout与Function.prototype.bind结合使用

绑定确保方法内部的代码可以访问对象的内部属性。否则,setTimeout 将导致方法以 this 绑定到全局对象的方式运行。绑定方法可能比创建新的匿名函数更易于阅读。
要取消计划中的函数,保留由 setTimeout 返回的 timeoutId 引用,然后调用 clearTimeout(timeoutId)![1.jpg]。下面的列表展示了 clearTimeout。
列表 2.18. 使用 clearTimeout 来防止计划中的函数
![033fig02_alt.jpg]
回调函数何时运行?
尽管你可以指定回调在毫秒级的时间运行,但 Node 并不完全 那么 精确。它可以保证回调将在指定时间后运行,但可能会稍微晚一些。
除了延迟执行外,你还可以定期调用函数。下一技术将讨论如何通过使用 setInterval 来实现这一点。
技术第 13 条:使用计时器定期运行回调
Node 也可以使用 setInterval 定期运行回调,其工作方式与 setTimeout 类似。
问题
你想要以固定的时间间隔运行一个回调函数。
解决方案
使用 setInterval 和 clearInterval 来停止计时器。
讨论
setInterval 方法在浏览器中已经存在多年,在 Node 中与客户端类似。回调将在指定的延迟后或稍后执行,并在 I/O 之后的事件循环中运行(以及任何对 setImmediate 的调用,如第 14 技术所述技术 14)。
下面的列表展示了如何将 setInterval 与 setTimeout 结合起来,以按顺序调度两个函数执行。
列表 2.19. 使用 setInterval 和 setTimeout 结合
![034fig01.jpg]
setInterval 方法本身返回计时器的引用,可以通过调用 clearInterval 并传递引用来停止计时器。列表 2.19 使用第二次调用 setTimeout ![1.jpg] 来触发一个在第一个计时器之后 500 毫秒运行的第二个间隔计时器。
由于 setInterval 阻止程序退出,在某些情况下,你可能希望在程序没有做其他事情时退出程序。例如,假设你正在运行一个程序,该程序应该在复杂操作完成后退出,并且你希望使用 setInterval 定期监控它。一旦复杂操作完成,你就不想再监控它了。
而不是调用 clearInterval,Node 0.10 允许你在复杂操作完成之前任何时候调用 timerRef.unref()。这意味着你可以使用 setTimeout 或 setInterval 来执行不发出完成信号的操作。
列表 2.20 使用 setTimeout 来模拟一个长时间运行的操作,该操作将在计时器显示进程内存使用情况时保持程序运行。一旦达到超时延迟,程序将退出 而不 调用 clearTimeout。
列表 2.20. 保持计时器活动直到程序干净退出

在没有合适的地方调用 clearInterval 的情况下,这非常有用。
一旦你掌握了计时器,你将遇到需要以尽可能短的时间延迟运行回调的情况。使用延迟为零的 setTimeout 不是一个最佳解决方案,尽管这似乎是明显的策略。在下一个技巧中,你将看到如何在 Node 中使用 process.nextTick 正确地做到这一点。
技巧 14 安全地管理异步 API
有时你只想稍微延迟一个操作。在传统的 JavaScript 中,使用带有小延迟值的 setTimeout 可能是可以接受的。Node 提供了一个更有效的解决方案:process.nextTick。
问题
你想编写一个返回 EventEmitter 实例的方法或接受一个回调,该回调有时会调用异步 API,但并非在所有情况下。
解决方案
使用 process.nextTick 包装同步操作。
讨论
process.nextTick 方法允许你将回调放置在运行循环的下一个周期的开头。这意味着这是一种轻微延迟某种方法的方式,因此它比仅仅使用延迟为零的 setTimeout 更有效率。
这可能很难想象为什么这很有用,但考虑以下示例。列表 2.21 展示了一个返回 EventEmitter 的函数。想法是提供一个以事件为中心的 API,允许 API 的用户根据需要订阅事件,同时能够内部运行异步调用。
列表 2.21. 使用事件错误地触发异步方法

运行此示例将无法在示例末尾触发 success 监听器
。为什么会出现这种情况?嗯,事件是在监听器订阅之前发出的。在大多数情况下,事件会在某些异步操作的回调内部发出,但有时提前发出事件是有意义的——例如,在验证参数并发现包含错误时,这样 error 可以非常快速地发出。
为了纠正这个微妙的缺陷,任何发出事件的代码部分都可以用 process.nextTick 包装。以下列表通过使用返回 EventEmitter 实例的函数,然后发出一个事件来演示这一点。
列表 2.22. 在 process.nextTick 内部触发事件

Node 的文档建议 API 应始终是 100% 异步或同步。这意味着如果你有一个接受回调并可能异步调用它的方法,那么你应该在 process.nextTick 中包装同步情况,这样用户就可以依赖执行顺序。
列表 2.23 使用异步调用来从磁盘读取文件。一旦读取了文件,它会在内存中保留一个缓存版本。后续调用将返回缓存版本。在返回缓存版本时,使用process.nextTick以确保 API 仍然表现出异步行为。这使得在终端中的输出按照预期的顺序读取。
列表 2.23. 创建始终异步 API 的错觉

在这个例子中,通过使用fs.readFile读取文件并将其缓存到内存中,然后为每个后续调用返回它的一个副本!,这是通过多次调用一个过程来实现的!,这样您就可以比较非阻塞文件系统操作与process.nextTick的行为。
可视化事件循环:setImmediate 和 process.maxTickDepth
setImmediate和clearImmediate全局函数接受一个回调和可选参数,并且会在任何即将到来的 I/O 事件之后、setTimeout和setInterval之前运行。
以这种方式添加的回调会被推送到队列中,并且每次运行循环只会执行一个回调。这与process.nextTick不同,后者会在运行循环的每次迭代中触发process.maxTickDepth个回调。
使用process.nextTick传递的回调通常在当前事件循环的末尾运行。可以安全运行的回调数量由process.maxTickDepth控制,默认值为 1000,以允许 I/O 操作继续处理。
图 2.4 说明了每个定时函数在事件循环的单次迭代中的位置。
图 2.4. 在事件循环上调度nextTick

当您创建自己的类和方法,并且它们的行为是异步的,请使用process.nextTick来保持行为的一致性和可预测性。
Node 对基于浏览器的 JavaScript 定时器的实现与它的事件循环和非阻塞 I/O 很好地结合在一起。尽管这些函数通常用于测试异步代码,但深入了解setTimeout、setImmediate和process.nextTick何时执行将提供对事件循环的掌握。
2.5. 摘要
在本章中,您已经看到了一些内置在 Node 程序中而无需加载模块的令人惊讶的强大功能。下次您想要将相关的模块组合在一起时,您可以创建一个 index.js 文件,如技术 3 所述。如果您需要读取标准输入,可以使用process对象的stdin属性(技术 5)。
除了process对象之外,还有一个经常被忽视的console对象,它将帮助您调试和维护程序(技术 6)。
在下一章中,你将学习有关缓冲区的内容。缓冲区非常适合处理二进制数据,这在传统上被视为 JavaScript 的弱点。缓冲区还支撑了 Node 的一些强大功能,如流。
第三章:缓冲区:处理位、字节和编码
本章涵盖
-
缓冲数据类型的介绍
-
改变数据编码
-
将二进制文件转换为 JSON
-
创建自己的二进制协议
JavaScript 在历史上对二进制支持不佳。通常,解析二进制数据会涉及各种字符串技巧来提取所需的数据。没有良好的机制在 JavaScript 中处理原始内存是 Node 核心开发者在项目开始获得动力时必须解决的问题之一。这主要是出于性能原因。所有原始内存都累积在Buffer数据类型中。
缓冲区是堆的原始分配,以类似数组的方式暴露给 JavaScript。它们是全局暴露的,因此不需要导入,可以将其视为另一种 JavaScript 类型(如String或Number):

如果你没有太多处理二进制数据的经验,不要担心;这一章旨在对新手友好,同时也为那些对概念更熟悉的人提供帮助。我们将涵盖简单和更高级的技术:
-
将
Buffer转换为不同的编码 -
使用
BufferAPI 将二进制文件转换为 JSON -
编码和解码自己的二进制协议
让我们先看看如何更改缓冲区的编码。
3.1. 改变数据编码
如果没有指定编码,文件操作和许多网络操作将返回Buffer数据。以下fs.readFile为例:

但很多时候你已经知道文件的编码,将数据作为编码字符串获取更有用。在本节中,我们将探讨在Buffer和其他格式之间进行转换。
技巧 15:将缓冲区转换为其他格式
默认情况下,Node 的核心 API 返回一个缓冲区,除非指定了编码。但缓冲区可以轻松转换为其他格式。在接下来的技术中,我们将探讨如何转换缓冲区。
问题
你想将一个Buffer转换为纯文本。
解决方案
Buffer API 允许你将Buffer转换为字符串值。
讨论
假设我们有一个我们知道是纯文本的文件。为了我们的目的,我们将称这个文件为 names.txt,并且它将在文件的每一行包含一个人的名字:
Janet
Wookie
Alex
Marc
如果我们使用文件系统(fs)API 中的方法来加载文件,我们默认会得到一个Buffer(buf)
var fs = require('fs');
fs.readFile('./names.txt', function (er, buf) {
console.log(buf);
});
当输出日志时,它显示为一系列八位字节(使用十六进制表示):
<Buffer 4a 61 6e 65 74 0a 57 6f 6f 6b 69 65 0a 41 6c 65 78 0a
4d 61 72 63 0a>
这并不很有用,因为我们知道文件是纯文本。Buffer类提供了一个名为toString的方法,可以将我们的数据转换为 UTF-8 编码的字符串:

这将产生与我们的原始文件相同的输出:
Janet
Wookie
Alex
Marc
但因为我们知道这些数据仅由 ASCII 字符组成,^([1)) 我们也可以通过将编码更改为 ASCII 而不是 UTF-8 来获得性能上的好处。为此,我们将编码类型作为 toString 的第一个参数提供:

Buffer API 提供了其他编码,如 utf16le、base64 和 hex,你可以通过查看 Buffer API 在线文档了解更多信息.^([2])
技巧 16 使用缓冲区更改字符串编码
除了转换缓冲区,你还可以利用缓冲区将一种字符串编码转换为另一种编码。
问题
你想要将一个字符串编码转换为另一个编码。
解决方案
Node Buffer API 提供了一种更改编码的机制。
讨论
示例 1:创建基本身份验证头
有时构建一个数据字符串并更改其编码是有帮助的。例如,如果你想要从使用基本身份验证的服务器请求数据,^([3)),你需要使用 Base64 编码发送用户名和密码:

在应用 Base64 编码之前,基本身份验证凭据将用户名和密码组合在一起,使用冒号 : 分隔。在我们的例子中,我们将使用 johnny 作为用户名,c-bad 作为密码:

现在我们必须将其转换为 Buffer,以便将其转换为另一种编码。缓冲区可以通过字节分配,正如我们之前通过简单地传递一个数字(例如,new Buffer(255))所看到的。它们也可以通过传递字符串数据来分配:

指定编码
当使用字符串分配 Buffer 时,它们被认为是 UTF-8 字符串,这通常是您想要的。但您可以使用第二个可选的编码参数指定传入数据的编码:
new Buffer('am9obm55OmMtYmFk', 'base64')
现在我们已经将数据作为 Buffer,我们可以通过使用 toString('base64') 将其转换回 Base64 编码的字符串:

这个过程也可以被压缩,因为可以直接在返回的 Buffer 实例上调用实例方法,并且可以省略 new 关键字:
var encoded = Buffer(user + ':' + pass).toString('base64');
示例 2:处理数据 URI
数据 URI^([4]) 是使用 Buffer API 有帮助的另一个例子。数据 URI 允许资源使用以下方案内联嵌入到网页中:
data:[MIME-type][;charset=<encoding>[;base64],<data>
例如,这张猴子的 PNG 图像可以表示为一个数据 URI:
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACsAAAAoCAYAAABny...
当在浏览器中读取时,数据 URI 将显示如图 3.1 所示的我们的灵长类动物。
图 3.1. 浏览器中读取的数据 URI 显示猴子图像

让我们看看如何使用Buffer API 创建数据 URI。在我们的灵长类例子中,我们使用了一个 MIME 类型为image/png的 PNG 图像:
var mime = 'image/png';
二进制文件可以使用 Base64 编码表示,所以让我们为这个设置一个变量:
var encoding = 'base64';
使用我们的 MIME 类型和编码,我们可以构建数据 URI 的开始部分:
var mime = 'image/png';
var encoding = 'base64';
var uri = 'data:' + mime + ';' + encoding + ',';
我们需要添加实际的数据。我们可以使用fs.readFileSync来同步读取我们的数据并返回内联数据。fs.readFileSync将返回一个Buffer,然后我们可以将其转换为 Base64 字符串:
var encoding = 'base64';
var data = fs.readFileSync('./monkey.png').toString(encoding);
让我们把所有这些放在一起,编写一个输出数据 URI 的程序:

这个程序的输出将是
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACsAAAAoCAYAAABny...
让我们改变一下场景。如果你有一个数据 URI 但想将其写入实际文件,我们会再次使用我们的猴子例子。首先,我们split数组以仅获取数据:^([5])
⁵ 这并不是对所有数据 URI 都有规定性的,因为逗号可能出现在其他地方。
var uri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACsAAAAo...';
var data = uri.split(',')[1];
然后,我们可以使用我们的data字符串并指定编码来创建一个Buffer:
var buf = Buffer(data, 'base64');
接下来,我们使用fs.writeFileSync将此同步写入磁盘,指定文件名和Buffer:
fs.writeFileSync('./secondmonkey.png', buf);
将这个例子全部放在一起看起来是这样的:

在我们的默认图像查看器中打开,这将给我们猴子,如图图 3.2 所示。
图 3.2. 从数据 URI 生成的 secondmonkey.png 文件

大多数时候,当你处理 Node 中的Buffer对象时,你会将其转换为其他格式,有时你会更改编码。但你也可能发现自己必须处理二进制文件格式,而Buffer API(我们将在下一节中探讨)提供了一套丰富的工具来处理这种格式。
3.2. 将二进制文件转换为 JSON
与二进制数据打交道有点像解谜。通过阅读数据含义的规范来获取线索,然后你必须外出将那些数据转换成你应用中可用的东西。
技巧 17 使用缓冲区转换原始数据
如果你能利用二进制格式在 Node 程序中做些有用的事情会怎样?在本技巧中,我们将深入探讨如何使用二进制数据将常见的文件格式转换为 JSON。
问题
你想将二进制文件转换为更易用的格式。
解决方案
Node API 通过Buffer类扩展了 JavaScript,提供了一个用于原始二进制数据访问的 API 以及处理二进制数据的工具。
讨论
为了我们示例的目的,即文件转换,你可以将这个过程视为图 3.3。
图 3.3. 将二进制数据转换为更易用/可编程的格式

使用二进制规范作为指南和二进制 API 作为实现转换的机制,以更可用的格式读取、处理和写入二进制数据。这并不是二进制数据的唯一用途。例如,你可以对二进制协议进行处理,以传递消息,图表将有所不同。
对于我们的技术,我们将要处理的二进制文件格式是 DBase 5.0 (.dbf)。这种格式可能听起来有些陌生,但(为了将其置于上下文中)它曾经是一种流行的数据库格式,现在仍然被广泛用于地理空间数据的归属。你可以将其视为一个简化的 Excel 电子表格。我们将要使用的样本位于 buffers/world.dbf。
该文件包含世界各国的地理空间信息。不幸的是,如果你在文本编辑器中打开它,它将不会很有用。
为什么我们要深入探讨一个我可能永远不会使用的二进制格式?
尽管我们可以选择许多二进制格式,但 DBase 5.0 是一种可以教会你许多不同方法来处理读取二进制文件的问题的格式,这些问题在许多其他格式中都很常见。此外,对于来自网络开发背景的人来说,二进制格式可能不太熟悉,所以我们花了一些时间来专注于读取二进制规范。如果你已经熟悉,请随意浏览。
由于我们想在 Node 应用程序中使用它,JSON 将是一个好的格式选择,因为它可以在 JavaScript 中原生解析,并且类似于原生 JavaScript 对象。这如图 3.4 所示。
图 3.4. 使用 FileSystem API 读取二进制数据到 Node.js,使用 Buffer API 转换为更易用的 JSON 格式。

图 3.5 展示了我们想要进行的转换示例:左侧是在文本编辑器中打开的原始二进制文件,右侧是转换后的 JSON 格式。
图 3.5. 我们转换的最终结果

文件头
在我们开始解决这个问题之前,我们需要进行一些研究,以找出我们想要处理的二进制格式的规范。在我们的案例中,从搜索引擎查询中找到了一些类似的规范。对于 DBase 5.0,我们将使用的主要规范可以在 mng.bz/i7K4 找到。
规范的第一部分被称为 头。许多二进制格式将使用头作为存储文件元数据的地方;表 3.1 显示了 DBase 5.0 的规范看起来是什么样子。
表 3.1. DBase 5.0 文件头规范
| 字节 | 内容 | 描述 |
|---|---|---|
| 0 | 1 字节 | 有效的 dBASE for Windows 表格文件;位 0-2 指示版本号... |
| 1-3 | 3 字节 | 最后更新的日期;采用 YYMMDD 格式 |
| 4-7 | 32 位数字 | 表中的记录数 |
| 8-9 | 16 位数字 | 文件头中的字节数 |
| 10-11 | 16 位数字 | 记录中的字节数 |
| ... | ... | ... |
| 32-n 每个字节 | 32 字节 | 字段描述符数组 |
| n+1 | 1 字节 | 0Dh 存储为字段终止符 |
让我们看一下第一行。
| 字节 | 内容 | 描述 |
|---|---|---|
| 0 | 1 字节 | 有效的 dBASE for Windows 表文件;位 0-2 表示版本号 ... |
这一行告诉我们位于位置 0 的字节包含描述中指定的信息。那么我们如何访问位置 0 的字节呢?幸运的是,使用缓冲区这非常简单。
在 Node 中,除非您为读取的数据指定特定的编码,否则您将得到一个 Node Buffer,如本例所示:
var fs = require('fs');
fs.readFile('./world.dbf', function (er, buf) {
Buffer.isBuffer(buf); // true
});
fs.readFile 不是获取缓冲区回显的唯一方法,但为了简单起见,我们将使用该方法,以便在读取后以对象的形式获取整个缓冲区。这种方法可能不适合大型二进制文件,您可能不希望一次性将整个缓冲区加载到内存中。在这种情况下,您可以使用 fs.createReadStream 流式传输数据,或者使用 fs.read 手动一次读取文件的一部分。还应注意的是,缓冲区不仅限于文件;它们几乎存在于您可以从数据流中获取数据的地方(例如,HTTP 请求上的请求数据)。
如果您想查看缓冲区的字符串表示形式,简单的 buf.toString() 调用就足够了(默认为 UTF-8 编码)。如果您知道要拉取的数据只是文本,这会很好:

在我们的情况下,buf.toString() 会和用文本编辑器打开 world.dbf 文件一样糟糕:不可用。我们首先需要理解二进制数据。
注意
从现在开始,每当您看到我们的变量 buf,它指的是一个 Buffer 实例,因此是 Node Buffer API 的一部分。
在我们讨论的表中,我们提到了字节位置 0。Node 中的缓冲区与 JavaScript 数组非常相似,但索引是内存中的字节位置。因此,字节位置 0 是 buf[0]。在 Buffer 语法中,buf[0] 与位置 0 的字节、八位字节、无符号 8 位整数或正有符号 8 位整数同义。
对于这个例子,我们并不真正关心存储关于这个特定字节的任何信息。让我们继续到下一个字节定义。
| 字节 | 内容 | 描述 |
|---|---|---|
| 1-3 | 3 字节 | 最后更新的日期;YYMMDD 格式 |
这里有一些有趣的东西:最后更新的日期。但这个规范并没有告诉我们更多,只是它有 3 个字节,并且是 YYMMDD 格式。所有这些都是说,您可能不会在一个地方找到您想要的所有东西。随后的网络搜索得到了以下信息:
每个字节都包含一个二进制数。YY 被加到一个 1900 十进制基数上,以确定实际年份。因此,YY 的可能值从 0x00-0xFF,这允许从 1900-2155 的范围。^([6])
这更有帮助。让我们看看如何在 Node 中解析它:

在这里,我们使用一个 JavaScript Date对象,并将其年份设置为 1900 加上从buf[1]中提取的整数。我们使用位置 2 和 3 的整数来设置月份和日期。由于 JSON 不存储 JavaScript Date类型,我们将它存储为一个 UTC Date字符串。
让我们暂停一下,回顾一下。正如这里所示,“Sat Aug 26 1995...”是解析 world.dbf 二进制数据的一部分到 JavaScript 字符串的结果。随着我们继续,我们将看到更多这样的例子。
| 字节 | 内容 | 描述 |
|---|---|---|
| 4-7 | 32 位数字 | 表中的记录数 |
下一个定义给我们提供了两个线索。我们知道字节从偏移量 4 开始,它是一个 32 位数字,最低有效字节在前。由于我们知道这个数字不应该是负数,我们可以假设它是一个正的有符号整数或无符号整数。在Buffer API 中,两者都以相同的方式访问:

buf.readUInt32LE将从偏移量 4 处读取一个无符号 32 位整数,采用小端格式,这与我们之前描述的相符。
下两个定义遵循类似的模式,但它们是 16 位整数。以下是它们的定义。
| 字节 | 内容 | 描述 |
|---|---|---|
| 8-9 | 16 位数字 | 头部中的字节数 |
| 10-11 | 16 位数字 | 记录中的字节数 |
这里是对应的代码:

本节头文件规范与代码之间的转换在图 3.6 中展示。
图 3.6。头部:使用 Node Buffer API 从规范到代码的转换

字段描述符数组
对于这个例子,在 world.dbf 文件的头文件中只剩下一个相关的信息。它是以下行中看到的字段定义,包括类型和名称信息。
| 字节 | 内容 | 描述 |
|---|---|---|
| 32-n each | 32 字节 | 字段描述符数组 |
| n+1 | 1 字节 | 以 0Dh 存储的字段终止符 |
从这里我们知道每个字段描述都是以 32 字节的信息存储的。由于这个数据库可能有一个或多个数据字段,我们将知道它何时结束,当我们遇到第二行中显示的 1 字节字段终止符(0Dh)时。让我们编写一个结构来处理这个问题:

在这里,我们以 32 字节为单位循环缓冲区,直到我们遇到表示为十六进制记法的fieldTerminator。
现在我们需要处理每个字段描述符的信息。规范中还有一个专门为此目的的表格;我们例子的相关信息在表 3.2 中展示。
表 3.2。DBase 5.0 字段描述符数组规范
| 字节 | 内容 | 描述 |
|---|---|---|
| 0-10 | 11 字节 | 字段名称(ASCII,零填充) |
| 11 1 | 字节 | 字段类型(ASCII,C,N 等) |
| ... | ... | ... |
| 16 | 1 字节 | 字段长度(二进制) |
注意,字节的索引从 0 开始,即使我们在读取文件时已经远远超过了字节位置 0。如果我们从每个记录开始重新开始,那将很棒,这样我们就可以更紧密地遵循规范。Buffer提供了一个slice方法,我们可以用它来做这件事:
var fields = [];
var fieldOffset = 32;
var fieldTerminator = 0x0D;
while (buf[fieldOffset] != fieldTerminator) {
var fieldBuf = buf.slice(fieldOffset, fieldOffset+32);
// here is where we parse each field
fieldOffset += 32;
}
buf.slice(start, end)与标准数组切片方法非常相似,因为它返回从start到end的缓冲区索引。但它不同之处在于它不返回数据的新副本。它只返回那些点的数据快照。所以如果你以任何方式在切片缓冲区中操作数据,它也会在原始缓冲区中被操作。
使用我们新的fieldBuf在每个迭代中从零开始索引,我们可以不进行额外的头脑数学运算来接近规范。让我们看看第一行。
| 字节 | 内容 | 描述 |
|---|---|---|
| 0-10 | 11 字节 | 字段名称(ASCII,零填充) |
这是提取字段名称的代码:

默认情况下,buf.toString()假设utf8,但 Node Buffers 也支持其他编码,包括ascii,这正是我们的规范所要求的。buf.toString()还允许你传入你想要转换的范围。我们还必须replace()掉如果字段小于 11 字节则填充的零字符,这样我们就不会在我们的名称中结束于零填充字符(\u0000)。
下一个相关的字段是字段数据类型。
| 字节 | 内容 | 描述 |
|---|---|---|
| 11 | 1 字节 | 字段类型(ASCII,C,N 等) |
但字符 C 和 N 对我们来说还没有实际意义。在规范的下文中,我们将得到这些类型的定义,如表 3.3 所示。
表 3.3. 字段类型规范
| 数据类型 | 数据输入 |
|---|---|
| C(字符) | 所有 OEM 代码页字符 |
| N(数字) | - . 0 1 2 3 4 5 6 7 8 9 |
将这些数据转换为我们的应用程序相关的类型会很好。JavaScript 不使用语言字符或数字,但它确实有String和Number;在我们解析实际记录时,让我们记住这一点。目前,我们可以将其存储在一个小的查找对象中,稍后进行转换:
var FIELD_TYPES = {
C: 'Character',
N: 'Numeric'
}
现在我们有了查找表,我们可以在继续转换二进制数据时提取相关信息:

buf.toString()将给我们一个 ASCII 字符,然后我们在哈希中查找以获取完整的类型名称。
解析剩余文件中每个字段描述所需解析的唯一其他信息是字段大小。
| 字节 | 内容 | 描述 |
|---|---|---|
| 16 | 1 字节 | 字段长度(二进制) |
我们现在写下这个熟悉的代码:

该字段描述符数组部分规范和代码之间的转换在图 3.7 中得到了说明。
图 3.7. 字段描述符数组:使用 Node Buffer API 从规范到代码的转换

记录
现在我们已经解析了头部,包括字段描述符,我们还有另一部分要处理:实际的记录数据。规范告诉我们这一点:
记录在表文件中跟随头部。如果记录未被删除,则数据记录之前有一个字节,即空格(20h);如果记录被删除,则有一个星号(2Ah)。字段没有字段分隔符或记录终止符地打包到记录中。文件的末尾由一个字节标记,即文件结束标记,OEM 代码页字符值为 26(1Ah)。
让我们将其分解以进行讨论:
记录在表文件中跟随头部。
虽然我们可以在fieldOffset之后跟踪字节位置,但头部有一个表示头部字节数的字段,我们将其存储为header.bytesInHeader。因此,我们知道我们需要从这里开始:
var startingRecordOffset = header.bytesInHeader;
从我们对头部的解析中,我们还学到了一些其他东西。第一点是数据中存在多少条记录,我们将其存储为header.totalRecords。第二点是每条记录分配了多少字节,我们将其存储为header.bytesPerRecord。知道从哪里开始,迭代多少次,以及每次迭代跳多少有助于我们设置一个处理每个记录的优雅的for循环:
for (var i = 0; i < header.totalRecords; i++) {
var recordOffset = startingRecordOffset +
(i * header.bytesPerRecord);
// here is where we parse each record
}
现在,在每次迭代的开始,我们知道我们想要开始的字节位置存储为recordOffset。让我们继续阅读规范:
如果记录未被删除,则数据记录之前有一个字节,即空格(20h);如果记录被删除,则有一个星号(2Ah)。
接下来,我们必须检查第一个字节以查看记录是否被删除:

与我们在头部文件中测试fieldTerminator类似,这里我们测试整数是否匹配0x2A或 ASCII 的“星号”字符。让我们继续阅读:
字段没有字段分隔符或记录终止符地打包到记录中。
最后,我们可以提取实际的记录数据。这提取了从解析字段描述符数组中学到的信息。我们为每个字段存储了field.type、field.name和field.length(以字节为单位)。我们希望将名称作为记录中的键,其值是转换到正确类型的该长度字节数据。让我们用简单的伪代码来看看:
record[name] = cast type for (characters from length)
e.g.
record['pop2005'] = Number("13119679")
我们还希望对每条记录的每个字段进行此类类型转换,因此我们使用另一个for循环:
for (var j = 0; j < fields.length; j++) {
var field = fields[j];
var Type = field.type == 'Numeric' ? Number : String;
record[field.name] = Type(buf.toString('ascii', recordOffset,
recordOffset+field.length).trim());
recordOffset += field.length;
}
我们遍历每个字段:
1. 首先,我们找出要将值转换为哪种 JavaScript 类型,并将其存储在变量
Type中。2. 然后,我们使用
buf.toString从recordOffset到下一个field.length提取字符。我们还需要trim()数据,因为我们不知道是否所有字节都用于存储相关数据,或者只是填充了空格。3. 最后,我们将
recordOffset增加field.length,以便在再次进入for循环时,我们保持下一个字段的起始位置。
本记录部分的规范与代码之间的转换在图 3.8 中得到了说明。
图 3.8. 记录:使用 Node Buffer API 将规范转换为代码

你还在吗?我希望如此。完整的代码示例在图 3.9 中展示。
图 3.9. 将 DBF 文件解析为 JSON 的完整代码

使用 Node 的Buffer API,我们能够将二进制文件转换为可用的 JSON 格式。运行此应用程序的输出如下所示:
{ header:
{ lastUpdated: 'Sat Aug 26 1995 21:55:03 GMT-0500 (CDT)',
totalRecords: 246,
bytesInHeader: 385,
bytesPerRecord: 424 },
fields:
[ { name: 'LON', type: 'Numeric', length: 24 },
{ name: 'NAME', type: 'Character', length: 80 },
{ name: 'ISO2', type: 'Character', length: 80 },
{ name: 'UN', type: 'Numeric', length: 11 },
{ name: 'ISO3', type: 'Character', length: 80 },
{ name: 'AREA', type: 'Numeric', length: 11 },
{ name: 'LAT', type: 'Numeric', length: 24 },
{ name: 'SUBREGION', type: 'Numeric', length: 11 },
{ name: 'REGION', type: 'Numeric', length: 11 },
{ name: 'POP2005', type: 'Numeric', length: 11 },
{ name: 'FIPS', type: 'Character', length: 80 } ],
records:
{ _isDel: false,
LON: -160.027,
NAME: 'United States Minor Outlying Islands',
ISO2: 'UM',
UN: 581,
ISO3: 'UMI',
AREA: 0,
LAT: -0.385,
SUBREGION: 0,
REGION: 0,
POP2005: 0,
FIPS: '' },
{ _isDel: false,
LON: 35.278,
NAME: 'Palestine',
ISO2: 'PS',
UN: 275,
ISO3: 'PSE',
AREA: 0,
LAT: 32.037,
SUBREGION: 145,
REGION: 142,
POP2005: 3762005,
FIPS: '' },
...
}
几乎神奇的是,一个不可读的二进制文件被转换成了一个不仅可读,而且可用的数据格式,可以用来进行更多转换。当然,这并不是魔法,而是投入时间学习二进制格式并使用可用的工具进行转换。Buffer API 提供了很好的工具来完成这项工作。
使用 fs 方法
我们也可以选择使用fs.writeFile及其相关方法将生成的代码写入文件。[^a 就像 Node 中的大多数 API 可以读取缓冲区对象一样,大多数也可以写入缓冲区对象。在我们的情况下,我们没有得到一个缓冲区,而是一个 JSON 对象,因此我们可以使用JSON.stringify结合fs.writeFile将数据写入:
^a 参见
nodejs.org/api/fs.html。
fs.writeFile('world.json', JSON.stringify(result), ...
二进制文件格式可以很有趣去破解。Buffer的另一个有趣且实用的用途是处理二进制协议,我们将在下一部分探讨。
3.3. 创建自己的二进制协议
当你阅读二进制文件并从中理解其含义时,感觉就像破解了一道代码。编写自己的谜题并解码它们同样有趣。当然,这不仅仅是为了乐趣。使用一个定义良好的二进制协议可以是一种紧凑且高效的数据传输方式。
技巧 18:创建自己的网络协议
在这个技术中,我们将涵盖一些处理二进制数据的额外方面,例如位掩码和协议设计。我们还将探讨压缩二进制数据。
问题
你想要创建一个高效的消息传输方式,无论是在网络中还是在进程中。
解决方案
JavaScript 和 Node 的Buffer API 为你提供了创建自己的二进制协议的工具。
讨论
要创建一个二进制协议,你首先必须定义你想要通过网络或进程发送的信息类型以及你将如何表示这些信息。就像你在上一技巧中学到的那样,一个规范为这一过程提供了一个很好的路线图。
对于这种技术,我们将开发一个简单且紧凑的数据库协议。我们的协议将包括
-
使用位掩码来确定将消息存储在哪个数据库中
-
将数据写入特定的键,该键将是一个介于 0-255(一个字节)的无符号整数
-
使用 zlib 压缩任何长度的消息
表 3.4 显示了我们可以如何编写规范。
表 3.4. 简单键值数据库协议
| 字节 | 内容 | 描述 |
|---|---|---|
| 0 | 1 字节 | 根据哪些位被激活,确定将数据写入哪个数据库。每个位位置代表一个从 1 到 8 的数据库。 |
| 1 | 1 字节 | 一个字节的未签名整数(0-255),用作数据库键,以存储数据。 |
| 2-n | 0-n 字节 | 要存储的数据,可以是使用 deflate(zlib)压缩的任意数量的字节。 |
通过操作位来选择数据库
我们的协议规定,第一个字节将用于表示哪些数据库应该记录传输的信息。在接收端,我们的主要数据库将是一个简单的多维数组,将包含八个数据库的位置(因为一个字节中有八个位)。这可以用 JavaScript 中的数组字面量简单地表示:
var database = [ [], [], [], [], [], [], [], [] ];
无论哪些位被激活,都将指示哪些数据库或数据库将存储接收到的消息。例如,数字8在二进制中表示为00001000。在这种情况下,我们将信息存储在数据库 4 中,因为第四位是开启的(位是从右到左读取的)。
零索引数组
在 JavaScript 中,数组是零索引的,所以数据库 4 在数组位置 3,但为了避免复杂化,我们故意将我们的数据库称为 1 到 8,而不是 0 到 7,以更接近我们的语言在讨论字节的位时。
如果你好奇一个数字在 JavaScript 中的二进制表示,你可以使用内置的toString方法,将其第一个参数设置为2:

数字可以有多个位被激活;例如,20 在二进制中表示为00010100,对于我们的应用程序来说,这意味着我们想要在数据库 3 和 5 中存储消息。
那么,我们如何测试任何给定数字的哪些位被激活了呢?为了解决这个问题,我们可以使用一个位掩码。位掩码代表我们感兴趣的测试的位模式。例如,如果我们想知道是否应该在数据库 5 中存储一些数据,我们可以创建一个第五位被激活的位掩码。在二进制中,这将看起来像00010000,这是数字 32(或十六进制表示为0x20)。
我们接下来必须测试我们的位掩码与一个值,JavaScript 包含各种位运算符^([8]) 来完成这个任务。其中一个是 &(位与)运算符。& 运算符的行为类似于 && 运算符,但它不是测试两个条件都为真,而是测试两个位都打开(有 1 而不是 0)并且在该情况下保持位打开(或 1):
⁸ 查看
developer.mozilla.org/en-US/docs/JavaScript/Reference/Operators/Bitwise_Operators。
000101000
& 000100000
-----------
000100000
两个值都打开了位位置 5,所以使用 & 时它仍然保持打开状态。有了这个知识,我们可以看到,如果一个值与位掩码进行比较,并且它有相同的位或位打开,那么它将是位掩码。有了这个信息,我们可以设置一个简单的条件来测试:
if ((value & bitmask) === bitmask) { .. }
很重要,& 表达式必须被括号包围;否则,由于操作符优先级,会首先检查位掩码的相等性.^([9])
⁹ 查看
developer.mozilla.org/en-US/docs/JavaScript/Reference/Operators/Operator_Precedence。
为了测试我们二进制协议中接收到的第一个字节,我们想要设置一个与我们的数据库索引相对应的位掩码列表。如果位掩码匹配,我们知道该索引的数据库将需要写入数据。每个位置的“打开”位是一个数组
var bitmasks = [ 1, 2, 4, 8, 16, 32, 64, 128 ]
这对应于:
1 2 4 8 16 32 64 128
-----------------------------------------------------------------------
00000001 00000010 00000100 00001000 00010000 00100000 01000000 10000000
现在我们知道,如果一个字节在我们的位掩码数组中匹配 1,它将匹配数据库 1 或数组位置 0。我们可以设置一个简单的循环来测试每个位掩码与第一个字节的值:

在一开始处理位可能很棘手,但一旦你更了解它们是如何工作的,它们就变得更容易管理。到目前为止,我们所涵盖的所有内容不仅可在 Node 中使用,也可在浏览器 JavaScript 中使用。我们已经足够远,可以确定我们应该将我们的传入数据放在哪个数据库中;我们仍然需要找出应该将数据存储在哪个键中。
查找存储数据的键
这是我们的例子中最简单的一部分,因为你已经从之前的技术中学到了这一点。在我们开始之前,让我们看看之前在 表 3.4 中定义的规范的相关部分。
| 字节 | 内容 | 描述 |
|---|---|---|
| 1 | 1 字节 | 一个字节的未签名整数(0–255),用作数据库键来存储数据。 |
我们知道我们将在字节位置 1 接收到一个字节的无符号整数(0-255),它将被用作数据库键来存储数据。我们故意将数据库设置为一个多维数组,其中第一个维度是数据库。现在我们可以使用第二个维度作为存储键和值的地方,因为键是数字,所以数组可以工作.^([10])让我们通过一个例子来说明这一点,以下是存储在第一个和第三个数据库中键0的值'foo'的情况:
^([10])尽管在 ECMAScript 6 中出现了更理想的替代方案。
[
['foo'],
[],
['foo'],
[],
[],
[],
[],
[]
]
要从位置 1 获取键值,我们可以使用现在可能已经熟悉的readUInt8方法:

让我们将其添加到我们之前构建的主要代码示例中:

现在我们能够解析数据库及其中的键,我们可以开始解析实际要存储的数据。
使用 zlib 解压缩数据
在通过网络发送字符串/ASCII/UTF-8 数据时进行压缩是一个明智的想法,因为压缩可以真正减少带宽的使用。在我们的简单数据库协议中,我们假设要存储的数据已经被压缩了;让我们查看规范以了解相关描述。
Node 包含一个内置的zlib模块,该模块公开了deflate(压缩)和inflate(解压缩)方法。它还包括 gzip 压缩。为了避免接收到格式不正确的消息,我们可以检查接收到的消息确实已经正确压缩,如果没有,我们将拒绝解压缩它。通常,zlib“deflated”数据的第一个字节是0x78^(11),因此我们可以相应地进行测试:
^([11])一个更健壮的实现应该进行更多的检查;请参阅
tools.ietf.org/html/rfc6713。

现在我们知道我们很可能会处理压缩数据,我们可以使用zlib.inflate来解压缩它。我们还需要使用buf.slice()来获取消息的数据部分(因为留下前两个字节会导致错误):

我们已经拥有了使用我们的简单数据库协议在数据库中存储数据的所有必要条件。让我们将所有组件组合起来:

现在我们已经有了存储一些数据的代码。我们可以通过以下方式生成消息:

我们可以编写一个示例,通过 TCP 发送消息并进行更多的错误处理。但让我们把这留给你在后面的章节中学习 Node 网络时作为练习来处理。
3.4. 摘要
在本章中,你学习了关于缓冲区的内容以及如何使用toString方法将缓冲区转换为不同的编码字符串。我们深入探讨了使用Buffer API 将二进制文件转换为更易用的事物这一复杂任务。最后,我们通过创建自己的协议并学习位掩码和压缩来享受了一些乐趣。
我们在 Node 中介绍了缓冲区的一些常见用法,难度各异,希望这能让你更舒适地使用它们,并充分利用它们。去尝试进行二进制格式转换,并在 NPM 上发布你的作品,或者可能有一个更适合你业务需求的协议正在等待被编写。
在下一章中,我们将探讨 Node 的另一个核心部分——事件。
第四章:事件:精通EventEmitter及其他
本章涵盖
-
使用 Node 的
EventEmitter模块 -
管理错误
-
第三方模块如何使用
EventEmitter -
如何使用事件与域
-
EventEmitter的替代方案
Node 的events模块目前只包含一个类:EventEmitter。这个类在 Node 的内置模块和第三方模块中都被使用。它对许多 Node 程序的整体架构做出了贡献。因此,理解EventEmitter及其使用方法非常重要。
这是一个简单的类,如果你熟悉 DOM 或 jQuery 事件,那么你理解起来应该不会有太多困难。在使用 Node 时,主要考虑的是错误处理,我们将在技术 21 中探讨这一点。
EventEmitter可以用各种方式使用——它通常用作解决各种问题的基类,从构建网络服务器到构建应用程序逻辑。鉴于它被用作 Express 等流行 Node 模块中关键类的基础,了解它的工作原理对于编写与现有模块良好协作的惯用代码非常有用。
在本章中,你将学习如何使用EventEmitter创建自定义类,以及它在 Node 和开源模块中的使用方式。你还将学习在使用EventEmitter时遇到的问题,并了解一些替代方案。
4.1. 基本用法
要使用EventEmitter,必须从基类继承。本节包括从EventEmitter继承的技术,以及将其混合到已经从另一个基类继承的其他类中的技术。
技术第十九章:从EventEmitter继承
这种技术演示了如何基于EventEmitter创建自定义类。通过理解这个技术中的原则,你将学会如何使用EventEmitter,以及如何更好地使用基于它的模块。
问题
你想使用基于事件的方法来解决一个问题。你有一个类,你希望它在异步事件发生时进行操作。
Web、桌面和移动用户界面有一个共同点:它们都是基于事件的。事件是处理本质上异步的输入(人类输入)的一个很好的范例。为了展示EventEmitter的工作原理,我们将以音乐播放器为例。它实际上不会播放音乐,但这个底层概念是学习如何使用事件的一个很好的方式。
解决方案
在 Node 中使用事件的一个典型例子是从 EventEmitter 继承。这可以通过使用一个简单的原型类来实现——只需记住在您的新的构造函数中调用 EventEmitter 的构造函数。
第一段代码展示了如何从 EventEmitter 继承。
列表 4.1. 从 EventEmitter 继承

讨论部分
简单构造函数和 util.inherits 的组合是创建定制事件类最简单、最常见的方式。下一段代码扩展了前面的列表,展示了如何使用 on 方法来发射和绑定监听器。
列表 4.2. 从 EventEmitter 继承


这可能看起来不多,但假设我们需要在 play 触发时做其他事情——也许用户界面需要更新。这可以通过简单地向 play 事件添加另一个监听器来支持。以下列表展示了如何添加更多监听器。
列表 4.3. 添加多个监听器

监听器也可以被移除。emitter.removeListener 用于移除特定事件的监听器,而 emitter.removeAllListeners 则移除所有监听器。您需要将监听器存储在一个变量中,以便在移除特定监听器时能够引用它,这与使用 clearTimeout 移除定时器类似。下一段代码展示了这一过程。
列表 4.4. 移除监听器

util.inherits 通过包装 ES5 方法 Object.create 来工作,它将一个原型中的属性继承到另一个原型中。Node 的实现还设置了 super_ 属性中的超构造函数。这使得访问原始构造函数变得容易得多——在 util.inherits 使用后,您的原型类将通过 YourClass.super_ 访问 EventEmitter。
您也可以只响应一次事件,而不是每次事件触发时都响应。为此,请使用 once 方法附加一个监听器。当事件可以多次触发,但您只关心它发生一次时,这很有用。例如,您可以将 列表 4.3 更新为跟踪播放事件是否已被触发:
musicPlayer.once('play', {
this.audioFirstStarted = new Date();
});
当从 EventEmitter 继承时,在构造函数中使用 events.EventEmitter.call(this) 是一个好主意,以运行 EventEmitter 的构造函数。这样做的原因是,如果正在使用域,它将实例附加到 domain 上。要了解更多关于域的信息,请参阅技巧 22。
我们在这里介绍的方法——on、emit 和 removeListener——是 Node 开发的基石。一旦你掌握了EventEmitter,你会发现它在各个地方都有出现:在 Node 的内置模块中,以及更远的地方。使用net.createServer创建 TCP/IP 服务器将返回一个基于EventEmitter的服务器,甚至全局的process对象也是一个EventEmitter的实例。此外,像Express这样的流行模块也是基于EventEmitter的——你实际上可以创建一个 Express 的app对象,并调用app.emit来在 Express 项目中发送消息。
技巧 20 混入 EventEmitter
有时继承并不是使用EventEmitter的正确方式。在这些情况下,混合EventEmitter可能有效。
问题
这是技巧 19 的另一种选择。而不是使用EventEmitter作为基类,你可以将其方法复制到另一个类中。当你有一个现有的类并且不能轻易地重新设计它以直接继承自EventEmitter时,这很有用。
解决方案
使用 for-in 循环足以从prototype的一个复制属性到另一个。这样,你可以从EventEmitter复制必要的属性。
讨论
这个例子可能看起来有些牵强,但有时确实有必要复制EventEmitter的属性,而不是以通常的方式从它继承。这种方法更类似于混入,或多重继承;如下一列表所示进行演示。
列表 4.5. 混入 EventEmitter

野外的多重继承的一个例子是 Connect 框架.^([1]) 核心的Server类从多个来源继承,在这种情况下,Connect 作者决定创建他们自己的属性复制方法,如下一列表所示。
列表 4.6. Connect 的 utils.merge
exports.merge = function(a, b){
if (a && b) {
for (var key in b) {
a[key] = b[key];
}
}
return a;
};
当你已经有了一个可以受益于事件但不容易成为EventEmitter直接后代的成熟类时,这种技术可能很有用。
一旦你从EventEmitter继承,你将需要处理错误。下一节将探讨处理由EventEmitter类生成的错误的技术。
4.2. 错误处理
尽管大多数事件都同等对待,但error事件是一个特殊情况,因此被不同对待。本节探讨了处理错误的两种方式:一种是将监听器附加到error事件上,另一种是使用域从一组EventEmitter实例中收集错误。
技巧 21 管理错误
使用EventEmitter的错误处理有其自己的特殊规则,必须遵守。这项技术解释了错误处理是如何工作的。
问题
你正在使用EventEmitter,并希望优雅地处理错误发生的情况,但它不断抛出异常。
解决方案
要防止 EventEmitter 在发出 error 事件时抛出异常,请向 error 事件添加监听器。这可以通过自定义类或任何继承自 EventEmitter 的标准类来完成。
讨论
要处理错误,将监听器绑定到 error 事件。以下列表通过构建音乐播放器示例来演示这一点。
列表 4.7. 基于事件的错误

这个例子可能很简单,但它很有用,因为它应该能帮助你意识到 EventEmitter 如何处理错误。它感觉像是一个特殊情况,确实如此。以下摘录来自 Node 文档:
当
EventEmitter实例遇到错误时,典型的操作是发出一个error事件。错误事件在 Node 中被视为特殊情况。如果没有监听器,则默认操作是打印堆栈跟踪并退出程序。
你可以通过从 列表 4.7 中移除 'error' 处理程序来尝试这个方法。应该在控制台显示堆栈跟踪。
从语义上看,这样做是有意义的——否则,如果没有错误处理程序,可能会出现潜在危险的活动而未被注意到。事件名称,或称为内部所提到的 type,必须恰好为 error——额外的空格、标点符号或大写字母都不会被视为 error 事件。
这种约定意味着基于事件的错误处理代码具有很高的一致性。它可能是一个特殊情况,但这是一个值得注意的情况。
技巧 22 使用域管理错误
处理多个 EventEmitter 实例的错误可能感觉像是一项艰巨的工作 ... 除非使用域!
问题
你正在处理多个非阻塞 API,但难以有效地处理错误。
解决方案
Node 的 domain 模块可用于集中处理一组异步操作的错误,包括发出未处理的 error 事件的 EventEmitter 实例。
讨论
Node 的 domain API 提供了一种将现有非阻塞 API 和异常包装在错误处理程序中的方法。这有助于集中处理错误,并在使用多个相互依赖的 I/O 操作的情况下特别有用。
列表 4.8 通过使用两个 EventEmitter 后代构建在音乐播放器示例之上,展示了如何使用单个错误处理程序来处理不同对象的错误。
列表 4.8. 使用 domain 管理错误


域可以与 EventEmitter 后代、网络代码以及异步文件系统方法一起使用。
为了可视化域的工作方式,想象一下 domain.run 回调围绕你的代码,即使回调内部的代码触发的事件发生在它之外。任何抛出的错误仍然会被域捕获。图 4.1 说明了这个过程。
图 4.1. 域帮助捕获错误并以 EventEmitter-style API 处理它们。

没有域名,使用 throw 抛出的任何错误都可能使解释器处于未知状态。域名避免了这种情况,并有助于你更优雅地处理错误。
现在你已经知道了如何从 EventEmitter 继承并处理错误,你应该开始看到它可以用到的各种有用的方式。下一节通过介绍一些高级使用模式和解决与事件相关的程序结构问题的更高级解决方案来扩展这些技术。
4.3. 高级模式
本节提供了一些最佳实践技术,用于解决在使用 EventEmitter 时发现的结构问题。
技巧 23 反射
有时你需要动态地响应 EventEmitter 实例的变化,或者查询其监听者。这项技术解释了如何做到这一点。
问题
你需要捕获监听者被添加到发射器时的时刻,或者查询现有的监听者。
解决方案
要跟踪监听者的添加,EventEmitter 会发出一个特殊事件,称为 new-Listener。添加到该事件的监听者将接收到事件名称和监听者函数。
讨论
在某些方面,编写好的 Node 代码和 优秀的 Node 代码之间的区别在于对 EventEmitter 的深入理解。能够正确地反射 EventEmitter 对象会带来一系列创建更灵活和直观 API 的机会。实现这一点的动态方式之一是通过 new-Listener 事件,当使用 on 方法添加监听者时发出。有趣的是,这个事件是通过 EventEmitter 本身发出的——它是通过使用 emit 实现的。
下一个列表展示了如何跟踪 newListener 事件。
列表 4.9. 跟踪新监听者

尽管在这个例子中 'a listener' 没有被明确发出,但 newListener 事件仍然会触发。由于监听者的回调函数以及事件名称都被传递,这是为需要访问原始监听函数的事物创建简化公共 API 的绝佳方式。列表 4.10 通过在添加 pulse 事件的监听者时自动启动计时器来演示这个概念。
列表 4.10. 基于新监听者自动触发事件

我们可以更进一步,通过调用 emitter.listeners(event) 来查询 EventEmitter 对象的监听者。一次无法返回所有监听者列表。整个列表在技术上在 this._events 对象中是可用的,但这个属性应被视为私有。监听者方法目前返回一个 Array 实例。如果给定的事件添加了多个监听者,这可以用来迭代多个监听者——可能是为了在异步过程结束时移除它们,或者简单地检查是否添加了任何监听者。
在有事件数组可用的情况下,listeners 方法将有效地返回 this._events[type].slice(0)。在数组上调用 slice 是创建数组 副本 的 JavaScript 快捷方式。文档中提到,这种行为可能会在未来改变,所以如果你真的想创建附加监听器的副本,那么请自己调用 slice 以确保你真的得到一个副本,而不是发射器实例中的数据结构的引用。
列表 4.11 为 Pulsar 类添加了一个 stop 方法。当调用 stop 时,它会检查是否有任何监听器;如果没有,则抛出错误。检查监听器是防止错误使用的好方法,但你不必在自己的代码中这样做。
列表 4.11. 查询监听器
Pulsar.prototype.stop = function() {
if (this.listeners('pulse').length === 0) {
throw new Error('No listeners have been added!');
}
};
var pulsar = new Pulsar(500, 5);
pulsar.stop();
技巧 24 检测和利用 EventEmitter
许多成功的开源 Node 模块都是基于 EventEmitter 构建的。了解 EventEmitter 被使用的地方以及如何利用它是有用的。
问题
你正在处理一个包含多个组件的大型项目,并希望在它们之间进行通信。
解决方案
当你使用 Node 的标准模块或开源库时,寻找 emit 和 on 方法。例如,Express 的 app 对象有这些方法,它们非常适合在应用程序内发送消息。
讨论
通常当你在一个大型项目中工作时,有一个主要组件是问题域的核心。如果你使用 Express 构建一个 Web 应用程序,那么 app 对象就是这样一种组件。快速检查源代码显示,该对象混合了 EventEmitter,因此你可以利用事件在项目中的不同组件之间进行通信。
列表 4.12 展示了一个基于 Express 的示例,其中监听器绑定到事件上,然后当访问特定路由时,事件被触发。
列表 4.12. 在 Express 中重用 EventEmitter

这可能看起来有些牵强,但如果路由定义在另一个文件中呢?在这种情况下,除非它被定义为全局变量,否则你无法访问 app 对象。
基于 EventEmitter 的另一个流行项目是 Node Redis 客户端 (npmjs.org/package/redis)。RedisClient 实例继承自 EventEmitter。这允许你挂钩到有用的事件,如 error 事件,如下一列表所示。
列表 4.13. 在 redis 模块中重用 EventEmitter

在使用路由分离技术将路由存储在几个文件中的情况下,你实际上可以通过调用 res.app.emit(event) 来发送事件。这允许路由处理程序将消息发送回 app 对象本身。
这可能看起来像一个非常具体的 Express 示例,但其他流行的开源模块也是基于 EventEmitter 构建的——只需寻找 emit 和 on 方法。记住,Node 的内部模块,如 process 对象和 net.create-Server 继承自 EventEmitter,而编写良好的开源模块通常也会从这些模块继承。这意味着基于事件解决架构问题的范围非常广泛。
这个例子还突出了围绕 EventEmitter 构建项目的另一个好处——异步过程可以尽快响应。如果 hello-alert 事件执行一个非常慢的操作,比如发送电子邮件,那么浏览页面的用户可能不想等待这个过程完成。在这种情况下,你可以在后台有效地执行较慢的操作的同时渲染请求的页面。
Node Redis 客户端对 EventEmitter 的使用非常出色,作者为每个方法编写了文档,说明了它们的功能。这是一个好主意——如果有人加入你的项目,他们可能会发现很难全面了解正在使用的事件。
技巧 25 对事件名称进行分类
一些项目的事件实在太多了。这个技巧展示了如何处理由输入错误的事件名称引起的错误。
问题
你正在失去对程序中事件的跟踪,并且担心可能太容易在某个地方写错事件名称,从而造成难以追踪的错误。
解决方案
解决这个问题的最简单方法就是使用一个对象作为所有事件名称的中心字典。这为项目中的每个事件创建了一个集中位置。
讨论
跟踪散布在整个项目中的事件名称很困难。一种管理方法是保持每个事件名称在一个地方。列表 4.14 展示了如何使用对象根据本章前面的示例对事件名称进行分类。
列表 4.14. 使用对象对事件名称进行分类

虽然 EventEmitter 是 Node 标准库的一个基本组成部分,并且是许多问题的优雅解决方案,但它可能是大型项目中许多错误的来源,因为人们可能会忘记特定事件的名称。一种解决方法是避免将事件作为字符串编写。相反,可以使用一个对象,其属性引用事件名称字符串。
如果你正在编写可重用的开源模块,你应该考虑将其作为公共 API 的一部分,这样人们就可以轻松地获得事件名称的集中列表。
有其他观察者模式实现避免了使用字符串事件名称来有效地进行事件类型检查。在下一个技巧中,我们将探讨一些通过 npm 可用的实现。
虽然 EventEmitter 在 Node 项目中提供了广泛的解决方案,但还有其他实现。下一节将介绍一些流行的替代方案。
4.4. 第三方模块和扩展
EventEmitter 实质上是一个 观察者模式 实现。这个模式还有其他解释,可以帮助将 Node 程序扩展到多个进程或网络中运行。接下来的技巧介绍了 Node 社区创建的一些更流行的替代方案。
技巧 26 替代 EventEmitter
EventEmitter 拥有一个优秀的 API,并且在 Node 程序中运行良好,但有时一个问题需要稍微不同的解决方案。这项技术探讨了 EventEmitter 的几种替代方案。
问题
你正在尝试解决的问题并不完全适合 EventEmitter。
解决方案
根据你试图解决的问题的确切性质,有几种 EventEmitter 的替代方案:发布/订阅、AMQP 和 js-signals 是一些在 Node 中有良好支持的流行替代方案。
讨论
EventEmitter 类是 观察者模式 的一个实现。一个相关的模式是发布/订阅,其中发布者发送消息,这些消息被分类到类中,发送给订阅者,而无需知道订阅者的详细信息。
发布/订阅模式在需要横向扩展的情况下通常很有用。如果你需要在多台服务器上运行多个 Node 进程,那么像 AMQP 和 ØMQ 这样的技术可以帮助实现这一点。它们都是专门设计来解决这个问题类的,但如果你已经在使用 Redis,那么可能不如使用 Redis 发布/订阅 API 方便。
如果你需要在分布式集群中进行横向扩展,那么像 RabbitMQ (www.rabbitmq.com/) 这样的 AMQP 实现将运行良好。rabbitmq-nodejs-client (github.com/adrai/rabbitmq-nodejs-client) 模块提供了一个发布/订阅 API。下面的列表展示了 Node 中 RabbitMQ 的一个简单示例。
列表 4.15. 在 Node 中使用 RabbitMQ

ØMQ (www.zeromq.org/) 在 Node 社区中更为流行。Justin Tulloss 和 TJ Holowaychuk 的 zeromq.node 模块 (github.com/JustinTulloss/zeromq.node) 是一个流行的绑定。下面的列表展示了这个 API 的简单性。
列表 4.16. 在 Node 中使用 ØMQ
var zmq = require('zmq');
var push = zmq.socket('push');
var pull = zmq.socket('pull');
push.bindSync('tcp://127.0.0.1:3000');
pull.connect('tcp://127.0.0.1:3000');
console.log('Producer bound to port 3000');
setInterval(function() {
console.log('sending work');
push.send('some work');
}, 500);
pull.on('message', function(msg) {
console.log('work: %s', msg.toString());
});
如果你已经在 Node 中使用 Redis,那么尝试一下 Pub/Sub API (redis.io/topics/pubsub) 是值得的。列表 4.17 展示了使用 Node Redis 客户端 (github.com/mranney/node_redis) 的一个示例。
列表 4.17. 在 Node 中使用 Redis Pub/Sub

最后,如果你不寻找发布/订阅,那么你可能想看看js-signals(github.com/millermedeiros/js-signals)。此模块是一个不使用字符串作为信号名的消息系统,对尚未存在的事件进行分发或监听将引发错误。
列表 4.18 显示了js-signals如何发送和接收消息。注意信号是对象的属性,而不是字符串,并且监听器可以接收任意数量的参数。
列表 4.18. 使用 Redis Pub/Sub 与 Node

如第 25 项技术 technique 25 中提到的,js-signals提供了一种使用属性作为信号名的方法,但在此情况下,如果未注册的监听器被分发或绑定,模块将引发错误。这种方法更像是“强类型”事件,与大多数发布/订阅和事件观察器实现非常不同。
4.5. 摘要
在本章中,你学习了如何通过继承和多继承使用EventEmitter,以及如何在有和无域的情况下管理错误。你还看到了如何集中管理事件名,开源模块如何建立在EventEmitter之上,以及一些替代方案。
你应该从本章中吸取的教训是,尽管EventEmitter通常用作继承的基类,但它也可以混合到现有类中。此外,尽管EventEmitter是许多问题的绝佳解决方案,并在 Node 的内部结构中得到广泛应用,但有时其他解决方案可能更优。例如,如果你使用 Redis,那么你可以利用其发布/订阅实现。最后,EventEmitter并非没有问题;管理大量事件名可能导致错误,而现在你知道如何通过使用作为事件名的属性的对象来避免这种情况。
在下一章中,我们将探讨一个相关主题:流。流是基于事件驱动的 API 构建的,因此你将能够在那里使用一些这些EventEmitter技术。
第五章. 流:节点最强大且最被误解的功能
本章涵盖
-
流是什么以及如何使用它们
-
如何使用 Node 的内置流 API
-
Node 0.8 及以下版本中使用的流 API
-
自 Node 0.10 以来捆绑的流原始类
-
测试流的策略
流是用于管理和建模数据的事件驱动 API,并且非常高效。通过利用EventEmitter和 Node 的非阻塞 I/O 库,stream模块允许在数据可用时动态处理数据,并在不再需要时释放它。
数据流的概念并不新鲜,但它是一个重要的概念,并且对 Node 至关重要。在第四章之后,掌握流是成为真正 Node 开发高手道路上的下一步。
stream 核心模块提供了构建基于事件的流类的抽象工具。你可能会使用实现流的模块,而不是自己创建。但为了充分利用流,了解它们真正的工作方式非常重要。本章的设计目标就是理解流、使用 Node 的内置流 API,以及最终创建和测试你自己的流。尽管 stream 模块在概念上具有抽象性质,但一旦你掌握了主要概念,你将开始看到流在各个方面的应用。
下一节提供了对流的概述,并讨论了 Node 0.10 版本支持的两种 API。
5.1. 流简介
在 Node 中,流是一个由多个不同对象遵循的 抽象接口。当我们谈论流时,我们指的是一种做事的方式——在某种程度上,它们是一种协议。流可以是可读的或可写的,并且使用 EventEmitter 的实例实现——有关事件的内容,请参阅第四章。流提供了在对象之间创建数据流的方法,并且可以像乐高积木一样模块化组合。
5.1.1. 流的类型
流总是涉及某种类型的 I/O,并且可以根据它们处理的 I/O 类型进行分组。以下类型的流来自 James Halliday 的 stream-handbook (github.com/substack/stream-handbook/),将给你一个关于你可以用流做什么的广泛概念:
-
内置 —Node 的许多核心模块实现了流接口;例如,
fs.createReadStream。 -
HTTP —虽然技术上属于网络流,但有一些流模块旨在与各种网络技术一起工作。
-
解析器 —历史上解析器一直是使用流实现的。Node 的流行第三方模块包括 XML 和 JSON 解析器。
-
浏览器 —Node 的事件驱动流已扩展到浏览器中,为与客户端代码的接口提供了独特的机会。
-
音频 —James Halliday 编写了一些具有流接口的创新音频模块。
-
RPC (远程过程调用) —通过网络发送流是实现进程间通信的有用方式。
-
测试 —有流友好的测试库,以及用于测试流本身的工具。
-
控制、元数据和状态 —流还有更抽象的用途,以及专为操作和管理其他流而设计的模块。
要理解流为什么重要,首先考虑在没有流的情况下处理数据会发生什么。让我们通过比较 Node 的异步、同步和基于流的 API 来更详细地探讨这一点。
5.1.2. 何时使用流
当使用fs.readFileSync以同步方式读取文件时,程序将会阻塞,并且所有数据都将被读入内存。使用fs.readFile将防止程序阻塞,因为它是一个异步方法,但它仍然会将整个文件读入内存。
如果有一种方法可以告诉fs.readFile将数据块读入内存,处理它,然后请求更多数据,那将是溪流的作用所在。
当处理大文件时,内存会成为一个问题——例如压缩的备份存档、媒体文件、大型日志文件等。您可以使用带有合适缓冲区的fs.read来读取整个文件,一次读取特定长度的数据。或者,更理想的是,您可以使用fs.createReadStream提供的流式 API。图 5.1(#ch05fig01)说明了使用fs.createReadStream一次只读取文件的一部分,与使用fs.readFile读取整个文件相比。
图 5.1. 使用可流式 API 意味着 I/O 操作可能使用更少的内存。

溪流的设计本身就是异步的。而不是将整个文件读入内存,只会读取一定量的数据(即缓冲区的大小),执行所需的操作,然后将结果写入输出溪流。这种方法几乎可以与 Node 的惯用方法相媲美。更重要的是,溪流是用普通的 JavaScript 实现的。以fs.createReadStream为例——它提供了一个更可扩展的解决方案,但最终只是用更好的 API 封装了简单的文件系统操作。
Node 的流式 API 感觉非常符合惯用方法,但溪流在计算机科学中已经存在很长时间了。在下一节中,我们将简要地考察这一历史,以便为您了解溪流的起源和用途提供一些背景信息。
5.1.3. 历史
那么,溪流是从哪里起源的呢?在历史上,计算机科学中的溪流(stream)被用来解决与 Node 中的溪流类似的问题。例如,在 C 语言中,表示文件的标准方式是通过使用溪流。当一个 C 程序启动时,它可以访问标准输入输出溪流。在 Node 中,标准输入输出溪流也是可用的,并且可以用来允许程序在 shell 中处理大量数据。
传统上,溪流被用来实现高效的解析器。在 Node 中也是如此:node-formidable模块(github.com/felixge/node-formidable)被 Connect 用来通过溪流高效地解析表单数据,而像 Node 的redis模块(npmjs.org/package/redis)这样的数据库模块则使用溪流来表示与服务器的连接,并通过按需解析来响应。
如果你熟悉 Unix,你可能已经知道流了。如果你使用过管道或 I/O 重定向,那么你已经使用过流了。你可以将 Node 流想象成 Unix 管道——除了数据是通过函数而不是命令行程序过滤外。下一节将解释 Node 中流的演变,直到 0.10 版本,那时它们发生了重大变化。
旧流与新流
流是 Node 的核心模块的一部分,因此与早期版本保持向后兼容。截至本文撰写时,Node 的版本为 0.10,其流 API 已经发生了重大变化。尽管它仍然保持向后兼容,但新的流语法在某些方面比早期版本更严格,但最终更灵活。这归结为pipe的行为——管道现在必须从Readable流开始,并在Writable流结束。在 Node 早期版本中发现的util.pump方法现在已被弃用,转而采用新的pipe语义。
Node 中流的演变源于使用基于事件的 API 以高效方式解决非阻塞 I/O 问题的愿望。像util.pump这样的旧解决方案试图在智能使用“drain”事件中找到效率——这是可写流清空且可以再次安全写入时发出的。这听起来很像暂停流,而处理暂停流是 0.10 之前的流 API 无法有效处理的事情。
现在,Node 已经达到一个核心开发者已经看到人们使用流解决的问题类型的点,因此新的 API 由于新的流原语类而更加丰富。表 5.1 显示了从 Node 0.10 开始可用的类摘要。
表 5.1. streams2中可用的类摘要
| 名称 | 用户方法 | 描述 |
|---|---|---|
| stream.Readable | _read(size) | 用于生成数据的 I/O 源 |
| stream.Writable | _write(chunk, encoding, callback) | 用于写入底层输出目标 |
| stream.Duplex | _read(size), _write(chunk, encoding, callback) | 可读且可写的流,如网络连接 |
| stream.Transform | _flush(size), _transform(chunk, encoding, callback) | 一种双工流,以某种方式更改数据,对匹配输入数据大小与输出大小没有限制 |
学习如何利用流,当与实现流的第三方模块一起工作时,将带来回报。在下一节中,将检查一些流行的面向流的模块。
5.1.4. 第三方模块中的流
在 Node 中,流的主要用途是为类似 I/O 的源创建基于事件的 API;解析器、网络协议和数据库模块是关键示例。当需要组合时,使用流实现网络协议可能很方便——想想如果数据可以通过 gzip 模块的单次调用通过 pipe 传递,向网络协议添加数据压缩会有多容易。
同样,可以流式传输数据的数据库库可以更有效地处理大量结果集;而不是将所有结果收集到一个数组中,一次可以流式传输一个项目。
Mongoose MongoDB 模块 (mongoosejs.com/) 有一个名为 QueryStream 的对象,可以用来流式传输文档。mysql 模块 (npmjs.org/package/mysql) 也可以流式传输查询结果,尽管这个实现目前还没有实现 stream.Readable 类。
你还可以在其他地方找到更多关于流的创造性用法。James Halliday 的 baudio 模块(参见 图 5.2)可以用来生成行为就像任何其他流的音频流——音频数据可以通过 pipe 路由到其他流,并通过标准音频软件进行录音回放:
图 5.2. James Halliday(substack)的 baudio 模块支持音频流的生成(来自 github.com/substack/baudio)。

var baudio = require('baudio');
var n = 0;
var b = baudio(function (t) {
var x = Math.sin(t * 262 + Math.sin(n));
n += Math.sin(t);
return x;
});
b.play();
当选择用于你的 Node 项目的网络或数据库库时,我们强烈建议确保它有一个可流式传输的 API,因为它将帮助你编写更优雅的代码,同时可能提供性能优势。
所有流类共同的一点是它们都继承自 EventEmitter。这一点将在下一节中探讨。
5.1.5. 流继承自 EventEmitter
每个 stream 模块基类都会发出各种事件,这些事件取决于基类是否是可读的、可写的或两者都是。流继承自 EventEmitter 的这一事实意味着你可以绑定到各种标准事件来管理流,或者创建你自己的自定义事件来表示更特定于领域的操作。
当与 stream.Readable 实例一起工作时(参见 表 5.2 以获取选择流基类的指导),readable 事件很重要,因为它表示流已准备好调用 stream.read()。
表 5.2. 选择流基类
| 问题 | 解决方案 |
|---|---|
| 你想要包装一个具有可流式传输 API 的底层 I/O 源。 | Readable |
| 你想要将程序输出用于其他地方,或在程序内部的其他地方发送数据。 | Writable |
| 你想要以某种方式通过解析来更改数据。 | Transform |
| 你想要包装一个可以接收消息的数据源。 | Duplex |
| 你想要从流中提取数据而不改变它,从测试到分析。 | PassThrough |
将监听器附加到 data 将使流的行为类似于旧的流 API,其中当数据可用时,数据会传递给 data 监听器,而不是通过调用 stream.read()。
error 事件在 技巧 28 中有详细说明。如果流在接收数据时遇到错误,将会发出此事件。
end 事件表示流已接收到文件结束字符的等效字符,并且不会接收更多数据。还有一个 close 事件表示底层资源已被关闭,这与 end 不同,Node API 文档指出并非所有流都会发出此事件,因此一个经验法则是绑定到 end。
stream.Writable 类将表示流结束的语义更改为 close 和 finish。这两个之间的区别在于,当调用 writable.end() 时会发出 finish 事件,而 close 表示底层 I/O 资源已被关闭,这并不总是必需的,这取决于底层流的性质。
当将流传递给 stream.Readable.prototype.pipe 方法时,会发出 pipe 和 unpipe 事件。这可以用来调整流在 pipe 时的行为。监听器将目标流作为第一个参数接收,因此可以检查此值以改变流的行为。这是一个更高级的技术,在 技巧 37 中有介绍。
关于本章的技术
本章中的所有技术都使用 streams2 API。这是 Node 0.10 和 0.12 中找到的新 API 风格的昵称。如果你使用 Node 0.8,可以通过 readable-stream 模块支持向前兼容性 (github.com/isaacs/readable-stream)。
在下一节中,你将学习如何使用流解决实际问题。首先,我们将讨论一些 Node 的内置流,然后我们将继续创建全新的流并进行测试。
5.2. 内置流
Node 的核心模块本身是使用 stream 模块实现的,因此很容易开始使用流而无需构建自己的类。下一个技巧将通过文件系统和网络流 API 介绍一些这种功能。
技巧 27 使用内置流创建静态 Web 服务器
Node 的核心模块通常具有可流式接口。它们可以比它们的同步替代方案更有效地解决许多问题。
问题
你希望以高效的方式将文件从网络服务器发送到客户端,并且这种方式能够扩展到大型文件。
解决方案
使用 fs.createReadStream 打开文件并将其 stream 发送到客户端。可选地,可以通过另一个流将结果 stream.Readable 进行 pipe 操作以处理如压缩等功能。
讨论
Node 的核心模块fs和net都提供了可流式接口。fs模块有辅助方法来自动创建可流式类的实例。这使得使用流来解决一些基于 I/O 的问题相当直接。
要了解流的重要性并将其与非流代码进行比较,请考虑以下使用 Node 核心模块制作的简单静态文件 Web 服务器的示例:
var http = require('http');
var fs = require('fs');
http.createServer(function(req, res) {
fs.readFile(__dirname + '/index.html', function(err, data) { //
if (err) {
res.statusCode = 500;
res.end(String(err));
} else {
res.end(data);
}
});
}).listen(8000);
尽管此代码使用了非阻塞的fs.readFile方法,但它可以通过使用fs.createReadStream来轻松改进。原因是它会将整个文件读入内存。这在小文件中可能看起来是可以接受的,但如果你不知道文件有多大呢?静态 Web 服务器通常需要提供可能很大的二进制资产,因此需要一个更灵活的解决方案。
以下列表演示了一个流式静态 Web 服务器。
列表 5.1。一个使用流的简单静态 Web 服务器

这个例子使用的代码比第一个版本少,并且提高了效率。现在,而不是将整个文件读入内存,一次只读取一个缓冲区并将其发送到客户端。如果客户端连接速度慢,网络流将通过请求 I/O 源暂停,直到客户端准备好更多数据来表示这一点。这被称为背压,是使用流为您的 Node 程序带来的额外好处之一。
我们可以将这个例子进一步扩展。流不仅高效且可能更具有语法上的优雅,而且它们也是可扩展的。静态 Web 服务器通常使用 gzip 压缩文件。下一个列表将此功能添加到之前的示例中,使用流。
列表 5.2。一个使用 gzip 的静态 Web 服务器

现在如果您在浏览器中打开 http://localhost:8000 并使用其调试工具查看网络操作,您应该会看到内容是通过 gzip 进行传输的。图 5.3 展示了运行示例后浏览器报告的内容。
图 5.3。网络检查器确认内容已被压缩。

这可以通过几种其他方式来扩展——您可以使用所需的任何数量的pipe调用。例如,文件可以通过 HTML 模板引擎进行管道传输,然后压缩。只需记住,一般模式是readable.pipe(writable)。
注意,这个例子被简化了,以说明流的工作原理,但不足以实现一个生产级的 HTTP 资产服务器。
现在您已经看到了如何使用流来解决常见问题的详细示例,是时候看看拼图中的另一部分:错误处理。
技巧 28 流错误处理
流类继承自EventEmitter,这意味着合理的错误处理是标准配置。这项技术解释了如何处理由流生成的错误。
问题
您想捕获由流生成的错误。
解决方案
添加一个error监听器。
讨论
EventEmitter的标准行为是在发出error事件时抛出异常——除非有监听器附加到error事件上。监听器的第一个参数将是引发错误的错误,它是Error对象的一个后代。
以下列表显示了一个故意生成的错误及其合适的error监听器示例。
列表 5.3. 在流中捕获错误

在这里,我们尝试打开一个不存在的文件
,这会触发一个'error'事件。传递给处理器的错误对象
通常会有额外的信息来帮助追踪错误。例如,stack属性可能包含行号信息,并且可以调用console.trace()来生成完整的堆栈跟踪。在列表 5.3 中,console.trace()将显示跟踪到 Node 的events.js核心模块中的ReadStream实现。这意味着你可以看到错误最初是在哪里发出的。
现在你已经看到了 Node 的一些核心模块是如何使用流的,下一节将探讨第三方模块是如何使用它们的。
5.3. 第三方模块和流
流是 Node 中最典型的特性之一,因此,在开源的 Node 环境中,几乎到处都能看到可流式接口的应用。在接下来的技术中,你将学习如何使用一些流行 Node 模块中找到的可流式接口。
技术第二十九章:使用第三方模块中的流
许多开源开发者已经认识到流的重要性,并将流式接口集成到他们的模块中。在这个技术中,你将学习如何识别这样的实现,并使用它们更有效地解决问题。
问题
你想知道如何使用 npm 下载的流行第三方模块中的流。
解决方案
查看模块的文档或源代码,以确定它是否实现了可流式 API,以及如何使用它。
讨论
我们选择了三个流行的模块作为实现流式接口的第三方模块的示例。这次对野外的流的引导应该能给你一个很好的想法,了解开发者是如何使用流的,以及你如何在你的项目中利用流。
在下一节中,你将发现一些使用流行的 Web 框架 Express 的关键方法。
使用 Express 中的流
Express Web 框架(expressjs.com/)实际上在 Node 的核心 HTTP 模块周围提供了一个相对轻量级的包装。这包括Request和Response对象。Express 使用它自己的方法和值装饰这些对象,但底层对象是相同的。这意味着你在技术 27 中学到的关于向浏览器流式传输数据的一切都可以在这里重用。
一个简单的 Express 路由示例——一个为特定的 HTTP 方法和 URL 运行的回调——使用res.send来响应一些数据:
var express = require('express');
var app = express();
app.get('/', function(req, res) {
res.send('hello world');
});
app.listen(3000);
res 对象实际上是一个 响应 对象,它继承自 Node 的 http.Server-Response。在 技术 27 中您看到,可以使用 pipe 方法将 HTTP 请求流式传输。Express 以一种允许缓冲区和对象与 res.send 方法一起工作,并且对于流,您仍然可以使用 pipe 方法的方式构建。
列表 5.4 是一个 Express 网络应用程序,它将使用 Express 3 运行,并通过使用 pipe 从自定义可读流中流式传输内容。
列表 5.4. 使用流的 Express 应用程序

我们的定制可读流 StatStream 继承自 stream.Readable
并实现了 _read 方法,该方法仅发送内存使用数据
。每当您想要创建一个可读流时,都必须实现 _read 方法。当将响应发送回浏览器时,流可以被管道传输到 Express 提供的 res 对象
而无需额外的工作。
Express 3 中附带 send 模块的实现使用了 fs.createReadStream,如 技术 27 中所述。以下示例代码取自 send 的源代码:
SendStream.prototype.stream = function(path, options){
TODO: this is all lame, refactor meeee
var self = this;
var res = this.res;
var req = this.req;
pipe
var stream = fs.createReadStream(path, options);
this.emit('stream', stream);
stream.pipe(res);
正确处理像 HTTP Content-Range 头部这样的东西需要更多的工作,但这个片段展示了利用内置的流式 API,如 fs.createReadStream,可以导致足够强大的解决方案,足以支撑主要开源项目。
使用 Mongoose 与流结合
Mongoose 模块([mongoosejs.com/](http://mongoosejs.com/))为 MongoDB 数据库服务器([http://www.mongodb.org/](http://www.mongodb.org/))提供了一个名为 QueryStream 的接口,该接口提供了类似 Node 0.8 风格的流式查询结果。这个类在内部使用,允许使用 stream 方法进行结果流式传输。以下代码展示了将查询结果通过一个假设的可写流进行管道传输的示例:
User
.where('role')
.equals('admin')
.stream()
.pipe(writeStream);
这种模式——使用一个类来封装外部 I/O 源的流式行为,然后通过简单的调用暴露流——是 Node 核心模块所采用的风格,并且受到第三方模块作者的欢迎。通过 streams2 API 使用可继承的简单抽象类,这一点已经变得更加清晰。
使用 MySQL 与流结合
第三方 mysql 模块(npmjs.org/package/mysql)通常被 Node 开发者视为一个低级模块,应该与更复杂的库(如 Sequelize www.sequelizejs.com/ 对象关系映射器(ORM))一起构建。但 mysql 模块本身不应被低估,并且支持使用 pause 和 resume 进行流式传输结果。以下是一个基本 API 风格的示例:
var query = connection.query('SELECT * FROM posts');
query
.on('result', function(row) {
connection.pause();
processRow(row, function() {
connection.resume();
});
});
这个流式 API 使用特定领域的事件名称——还有一个 'fields' 事件。要暂停结果流,必须调用 connection.pause。这会向底层的 MySQL 连接发出信号,表示结果应暂时停止,直到接收者准备好接收更多数据。
摘要
在这个技巧中,你看到了一些流行的第三方模块如何使用流。它们的特点是处理 I/O——HTTP 和数据库连接都是基于网络或文件的协议,两者都可能涉及网络连接和文件系统操作。一般来说,寻找实现流式接口的 Node 网络和数据库模块是个好主意,因为它们有助于扩展程序,并以可读、惯用的风格编写代码。
现在你已经了解了如何使用流,你可能迫不及待地想学习如何创建自己的流。下一节将介绍使用每个基流类的方法,并展示如何正确地从它们继承。
5.4. 使用流基类
Node 的基流类为解决流最擅长的那些问题提供了模板。例如,stream.Transform 非常适合解析数据,而 stream.Readable 则非常适合将底层 API 封装成流式接口。
下一个技巧解释了如何从流基类继承,然后进一步的技巧将详细介绍如何使用每个基类。
技巧 30 正确地从流基类继承
Node 的流基类可以用作创建新模块和子类的起点。了解每个基类解决的问题以及如何正确地从它们继承是很重要的。
问题
你希望通过创建流式 API 来解决问题,但不确定使用哪个基类以及如何使用它。
解决方案
确定与当前问题最接近的基类,并使用 Object.prototype.call 和 util.inherits 从它继承。
讨论
Node 的流基类(已在表 5.1 中总结),应作为创建自己的流式类或模块的基础。它们是 抽象类,这意味着在使用之前你必须实现它们的方法。这通常通过继承来完成。
所有流基类都位于 stream 核心模块中。这五个基类是 Readable、Writable、Duplex、Transform 和 PassThrough。从根本上说,流要么是可读的,要么是可写的,但 Duplex 流两者都是。如果你考虑 I/O 接口的行为——网络连接可以是可读的也可以是可写的。例如,如果 ssh 只能发送数据,那就特别没有用。
Transform 流建立在 Duplex 流之上,但也会以某种方式改变数据。一些 Node 内置模块使用 Transform 流,因此它们在本质上很重要。一个例子是 crypto 模块。
表 5.2 提供了一些提示,帮助你选择要使用哪个基类。
从基类继承
如果你已经了解了 JavaScript 中的继承,你可能想通过使用 MyStream.prototype = new stream.Readable(); 来从流基类继承。这被认为是一种不良做法,更好的做法是使用 ECMAScript 5 的 Object.create 模式。此外,必须运行基类的构造函数,因为它提供了必要的设置代码。下一个示例展示了这种模式。
列表 5.5. 从 stream.Readable 基类继承

Node 包含一个名为 util.inherits 的实用方法,它可以替代 Object.create 使用,但两种方法都被 Node 开发者广泛使用。这个例子使用 Object.create 方法
而不是 util.inherits,这样你可以看到 util.inherits 是如何工作的。
注意,在 列表 5.5 中,options 参数
被传递给原始的 Readable 构造函数。这很重要,因为 Node 支持一组标准的选项来配置流。对于 Readable,选项如下:
-
highWaterMark— 在暂停从底层数据源读取之前,存储在内部缓冲区中的字节数。 -
encoding— 导致缓冲区自动解码。可能的值包括utf8和ascii。 -
objectMode— 允许流以对象流的形式行为,而不是字节流。
objectMode 选项允许 JavaScript 对象由流处理。技术 31 中提供了一个示例。
摘要
在这个技术中,你已经看到了如何使用 Node 的流基类来创建自己的流实现。这涉及到使用 util.inherits 来设置类,然后使用 .call 来调用原始构造函数。我们还介绍了一些这些基类使用的选项。
正确地从基类继承是一回事,但实际实现一个流类又是另一回事。技术 31 详细解释了对于 Readable 基类的实现,但在这个特定情况下,它涉及到实现一个名为 _read 的方法,用于从底层数据源读取数据并将其 push 到由基类本身管理的内部队列中。
技术编号 31 实现可读流
可读流可以用来提供围绕 I/O 源的灵活 API,也可以作为解析器。
问题
你希望使用可流式 API 包装 I/O 源,该 API 提供了一个比底层数据可能的更高层次的接口。
解决方案
通过从 stream.Readable 类继承并创建一个 _read(size) 方法来实现可读流。
讨论
实现一个自定义的 stream.Readable 类在需要围绕底层数据源的高级抽象时非常有用。例如,我(亚历克斯)正在做一个项目,客户发送了包含数百万条记录的 JSON 文件,这些记录通过换行符分隔。我决定编写一个快速的 stream.Readable 类,该类读取缓冲区中的数据,每当遇到换行符时,使用 JSON.parse 来解析记录。
下一个示例展示了如何使用 stream.Readable 来解析以换行符分隔的 JSON 记录。
列表 5.6. JSON 行解析器


列表 5.6 使用了一个构造函数,JSONLineReader
,它继承自 stream.Readable
,以从文件中读取和解析 JSON 行。JSONLineReader 的源也将是一个可读流,因此绑定了一个 readable 事件的监听器,这样 JSONLineReader 的实例就知道何时开始读取数据
。
_read 方法
检查缓冲区是否为空
,如果是,则从源读取更多数据并将其添加到内部缓冲区。然后当前行索引递增,如果找到行结束符,则从缓冲区中裁剪出第一行
。一旦找到完整的行,它将使用 object 事件进行解析和发射
——类的用户可以绑定到这个事件以接收在源流中找到的每一行 JSON。
当运行此示例时,文件中的数据将通过类的实例流动。内部,数据将被排队。每当执行 source.read 时,将返回最新的“数据块”,这样就可以在 JSONLineReader 准备好时对其进行处理。一旦读取了足够的数据并找到换行符,数据将被分割到第一个换行符,然后通过调用 this.push
收集结果。
一旦调用 this.push,stream.Readable 将排队结果并将其转发到消费流。这允许流通过使用 pipe 进一步由可写流处理。在这个例子中,使用自定义的 object 事件发射 JSON 对象。此示例的最后几行附加了一个事件监听器来处理这些结果
。
Readable.prototype._read 方法的 size 参数是建议性的。这意味着底层实现可以使用它来知道要获取多少数据——这并不总是需要的,所以你不必总是实现它。在先前的例子中,我们解析了整行,但某些数据格式可以分块解析,在这种情况下,大小参数将很有用。
在我基于此示例的原始代码中,我使用生成的 JSON 对象来填充数据库。数据还被重定向并压缩到另一个文件中。使用流使得在最终项目中编写和读取都变得既简单又方便。
列表 5.6 中的示例使用了字符串,但对象呢?大多数直接处理 I/O 的流(如文件、网络协议等)将使用原始字节或字符字符串。但有时创建 JavaScript 对象的流很有用。列表 5.7 展示了如何安全地继承自 stream.Readable 并传递 objectMode 选项来设置处理 JavaScript 对象的流。
列表 5.7. 配置为使用 objectMode 的流

列表 5.7 中的 MemoryStream 示例使用对象作为数据,因此将 objectMode 作为选项传递给 Readable 构造函数
。然后使用 process.memoryUsage 生成一些合适的数据
。当这个类的实例发出 readable 信号
,表示它已准备好被读取时,内存使用数据就会被记录到控制台。
当使用 objectMode 时,流的底层行为会改变,以移除内部缓冲区合并和长度检查,并在读取和写入时忽略大小参数。
技巧 32 实现可写流
可写流可以用来将数据输出到底层的 I/O 溢出。
问题
你可能想要使用一个你想要用流式接口包装的 I/O 目标来输出程序数据。
解决方案
从 stream.Writable 继承并实现一个 _write 方法,以将数据发送到底层资源。
讨论
正如你在 技巧 29 中看到的,许多第三方模块为网络服务和数据库提供了流式接口。遵循这一趋势是有利的,因为它允许你的类与 pipe API 一起使用,这有助于保持代码块的可重用性和解耦。
你可能只是想实现一个可写流作为 pipe 链的终点,或者实现一个不受支持的 I/O 资源。一般来说,你需要正确地继承自 stream.Writable——有关推荐的实现方式,请参阅 技巧 30,然后添加一个 _write 方法。
_write 方法需要做的只是当数据被写入时调用一个提供的回调。以下代码显示了方法参数和示例 _write 实现的整体结构:

_write 方法提供一个回调
,你可以在写入完成后调用它。这允许 _write 是异步的。这里简单使用的 customWriteOperation 方法
仅作为示例——在实际实现中,它将执行底层的 I/O。这可能涉及通过套接字与数据库通信,或将数据写入文件。提供给回调的第一个参数应该是一个错误
,这样 _write 就可以在需要时传播错误。
Node 的stream.Writable基类不需要知道数据是如何被写入的,它只关心操作是否成功。可以通过传递一个Error对象到callback来报告失败。这将导致error事件被触发。记住,这些stream基类是从EventEmitter继承的,所以您通常应该添加一个监听器到error来捕获并优雅地处理任何错误。
下一个列表展示了一个stream.Writable类的完整实现。
列表 5.8. 可写流的示例实现

这个简短的例子将输入文本转换为绿色文本。可以通过运行node writable.js或通过管道将文本通过它来使用cat file.txt | node writable.js。
虽然这是一个简单的例子,但它说明了实现流式类是多么容易,所以您下次想要使存储数据的某个东西与pipe一起工作时应该考虑这样做。
数据块和编码
write函数的encoding参数仅在字符串而不是缓冲区被使用时相关。可以通过在实例化可写流时传递的选项中将decodeStrings设置为false来使用字符串。
流并不总是处理Buffer对象,因为某些实现已经优化了字符串的处理,所以在某些情况下直接处理字符串可能更有效。
技术 33 使用双向流进行数据传输和接收
双向流允许数据传输和接收。这项技术将向您展示如何创建自己的双向流。
问题
您想要创建一个可读可写的 I/O 源的可流式接口。
解决方案
从stream.Duplex继承并实现_read和_write方法。
讨论
双向流是Writable和Readable流的组合,这些流在技术 31 和 32 中进行了解释。因此,双向流需要从stream.Duplex继承,并为_read和_write方法提供实现。请参阅技术 30 以了解如何从流基类继承。
列表 5.9 展示了一个小的stream.Duplex类,它从stdin和stdout读取和写入数据。它提示输入数据,然后使用 ANSI 转义码为颜色写入数据。
列表 5.9. 一个双向流


在列表 5.9 中的HungryStream类将显示一个提示,等待输入,然后返回带有 ANSI 颜色代码的输入。为了跟踪提示的状态,使用了一个名为waiting的内部属性
。当 Node 自动调用_write方法时,将waiting属性设置为false,表示已收到输入,然后将带有颜色代码的数据推送到内部缓冲区。最后,自动传递给_write的回调函数被执行
。
当类等待数据时,_read方法推送一条充当提示的消息
。可以通过将标准输入流通过HungryStream的一个实例管道化,然后再通过标准输出流返回来使其交互式
。
双工流的好处是它们可以位于管道的中间。一个更简单的方法是使用stream.PassThrough基类,它只传递数据,允许你插入管道的中间并跟踪数据流过时的状态。图图 5.4 显示了数据块如何通过双工流对象从输入流流向输出流。
图 5.4. 一个双工流

野外的几个stream.Duplex实现实现了_write方法,但将_read方法保留为空白占位符。这纯粹是为了利用双工流作为可以通过管道增强其他流行为的工具。例如,Naomi Kyoto 的hiccup(github.com/naomik/hiccup)可以用来模拟底层 I/O 源的缓慢或不规则行为。这种流的新颖用法在编写自动化测试时非常有用。
双工流对于将可读流管道连接到可写流并分析数据非常有用。转换流专门设计用于更改数据;接下来介绍stream.Transform和_transform方法。
技巧 34 使用转换流解析数据
流长期以来一直被用作创建高效解析器的方法。在 Node 中可以使用stream.Transform基类来完成此操作。
问题
你希望使用流以内存高效的方式将数据转换为另一种格式。
解决方案
继承自stream.Transform并实现_transform方法。
讨论
表面上,转换流听起来有点像双工流。它们也可以位于管道链的中间。区别在于,它们预期会转换数据,并且通过编写_transform方法来实现。此方法的签名与_write类似——它接受三个参数,chunk、encoding和callback。当数据被转换后,应执行回调,这允许转换流异步解析数据。
列表 5.10 展示了一个转换流,它可以解析(尽管是简化的)CSV 数据。CSV 应包含逗号分隔的值,没有额外的空格或引号,并应使用 Unix 行结束符。
列表 5.10. 使用转换流实现的 CSV 解析器


解析 CSV 涉及跟踪几个变量——当前值、文件的标题和当前行号
。为此,可以使用具有合适属性的stream.Transform子类。_transform实现
是此示例中最复杂的一部分。它接收一块数据,使用for循环逐字符迭代
。如果字符是逗号,则保存当前值(如果有的话)
。如果当前字符是换行符,则将行转换为 JSON 表示
。此示例是同步的,因此可以在方法末尾安全地执行提供给_transform的回调
。已包含toObject方法,以便更容易地将标题和值的内部表示转换为 JavaScript 对象
。
示例中的最后一行创建了一个可读的 CSV 数据文件流,并将其通过 CSV 解析器,然后该输出再次通过stdout管道传输,以便可以查看结果
。这也可以通过压缩模块直接支持压缩 CSV 文件,或者你可以想到使用pipe和流进行任何其他操作。
此示例并未实现现实世界中 CSV 文件可能包含的所有内容,但它确实展示了使用stream.Transform构建流式解析器并不复杂,这取决于文件格式或协议。
现在你已经学会了如何使用基类,你可能想知道列表 5.10 中的options参数是用来做什么的。下一节将详细介绍如何使用选项来优化流吞吐量,并详细说明一些更高级的技术。
5.5. 高级模式和优化
流基类接受各种选项来定制其行为,其中一些选项可用于调整性能。本节包含优化流的技巧,使用较旧的流 API,根据输入调整流,以及测试流。
技巧 35 优化流
内置流和用于构建自定义流的类允许配置内部缓冲区大小。了解如何优化此值以获得所需性能特性是有用的。
问题
你想从文件中读取数据,但担心速度或内存性能。
解决方案
优化流缓冲区大小以适应应用程序的需求。
讨论
内置的流函数接受一个缓冲区大小参数,这使得可以根据给定的应用程序调整性能特征。fs.createReadStream 方法接受一个 options 参数,它可以包含一个 bufferSize 属性。此选项传递给 stream.Readable,因此它将控制用于在文件被用于其他地方之前临时存储文件数据的内部缓冲区。
由 zlib.createGzip 创建的流是 streams.Transform 的一个实例,而 Zlib 类为其存储数据创建了自己的内部缓冲区对象。控制这个缓冲区的大小也是可能的,但这次选项属性是 chunkSize。Node 的文档有一个关于优化 zlib 内存使用的部分,^([1]) 基于 zlib/zconf.h 头文件中的文档,这是实现 zlib 本身所使用的低级源代码的一部分。
¹ 请参阅“内存使用调整”——
nodejs.org/docs/latest/api/all.html#all_process_memoryusage.
在实践中,根据缓冲区大小推动 Node 的流以展示不同的 CPU 性能特征相当困难。但为了说明这个概念,我们包含了一个小的基准测试脚本,其中包含了一些关于测量流性能的有趣想法。接下来的列表尝试收集关于内存和经过时间的统计数据。
列表 5.11. 流的基准测试


这是一个很长的例子,但它只是使用了一些 Node 内置的功能来收集不同缓冲区大小的流随时间变化的内存统计数据。benchStream 函数执行了大部分工作,并多次执行。它使用 hrtime 记录当前时间
,这比 Date.now() 返回的测量更精确。输入流是 Unix 字典文件,它通过 gzip 流管道传输,然后输出到文件
。然后 benchStream 使用 setInterval 对内存使用进行周期性检查
。当输入流结束时
,将根据压缩前后的值计算内存使用量。
run 函数将输入文件的缓冲区和 gzip 缓冲区加倍
以展示对内存和随时间读取流所需时间的影響。当输入文件的读取完成时,将打印内存使用量和经过时间
。输入文件由 benchStream 函数返回,这样 run 就可以在基准测试完成后轻松调用。run 函数将被反复调用
,具体取决于传递给它的第一个参数
。
注意,已经使用了 process.hrtime 来准确基准测试经过时间。此方法可用于基准测试,因为它很精确,并且接受一个 time 参数来自动计算经过时间。
我(亚历克斯)用 20MB 的文件运行了这个程序,试图产生比 /usr/share/dict/words 更有趣的结果,并且我已经包括了结果的图表在 图 5.5 中。
图 5.5. 流的内存使用图形表示

我发现当我尝试各种文件时,结果表明经过的时间远不如内存使用受影响大。这表明,通常使用较小的缓冲区并对内存使用更加谨慎是可取的,尽管这个测试应该与负载测试基准一起重复进行,才能真正看到 Node 处理这些缓冲区需要多长时间。
Node 有一个较旧的流 API,它对暂停流的语义不同。尽管应该尽可能使用较新的 API,但可以在较新的 API 旁边使用较旧的 API。下一个技巧将演示如何使用使用较旧 API 编写的模块。
技巧 36 使用旧的流 API
在 Node 0.10(技术上为 0.9.4)之前,流有不同的 API。使用该 API 编写的代码可以通过将其包装成表现得像较新的 stream.Readable 类来与较新的 API 一起使用。
问题
您想使用一个实现旧式流 API 并使用较新 API 的类的模块。
解决方案
使用 Readable.prototype.wrap。
讨论
较旧的流 API 有可读和可写流,但暂停流只是“建议性的”。这导致了一个不同的 API 设计,该设计不是基于较新的 streams2 类。随着人们逐渐意识到可流式类是多么有用,npm 上出现了大量模块。尽管较新的 API 解决了较旧设计中的关键问题,但仍有一些有用的模块尚未更新。
幸运的是,可以使用 stream 模块提供的 Readable.prototype.wrap 方法将较旧的类包装起来。它实际上将较旧的接口包装起来,使其表现得像较新的 stream.Readable 类——它有效地创建了一个使用较旧类作为其数据源的 Readable 实例。
列表 5.12 展示了一个使用较旧 API 实现并已用较新的 Readable 类包装的流示例。
列表 5.12. 已被包装的旧式流

列表 5.12 中的示例展示了一个从 Node 0.8 流模块继承的简单类。readable 属性
是旧 API 的一部分,表示这是一个可读流。另一个表明这是遗留流的是 data 事件
。较新的 Readable.prototype.wrap 方法
是将所有这些转换为与 streams2 API 风格兼容的东西。最后,包装后的流被管道传输到 Node 0.10 流
。
现在您应该能够使用较旧的流与新的 API 一起使用了!
有时,流需要根据提供的输入类型改变其行为。下一个技巧将探讨实现这一点的各种方法。
技巧 37 基于目标适应流
流类通常是为了解决特定问题而设计的,但通过检测流的使用方式,也有可能自定义它们的行为。
问题
当流被管道传输到 TTY(用户的 shell)时,你希望流有不同的行为。
解决方案
将监听器绑定到 pipe 事件,然后使用 stream.isTTY 来检查流是否绑定到终端。
讨论
这项技术是适应流行为到其环境的具体示例,但一般方法也可以适应其他问题。有时检测流是否将输出写入 TTY 或其他(例如文件)是有用的,因为每种情况都希望有不同的行为。例如,当打印到 TTY 时,一些命令将使用 ANSI 颜色,但通常不建议在写入文件时这样做,因为奇怪的字符会弄乱结果。
Node 使检测当前进程是否连接到 TTY 变得简单——只需使用 process.stdout.isTTY 和 process.stdin.isTTY。这些是布尔属性,它们来自 Node 源代码中的 OS 级绑定(在 lib/tty.js 中)。
适应流输出的策略是创建一个新的 stream.Writable 类,并根据 isTTY 设置一个内部属性。然后添加一个监听器到 pipe 事件,该事件根据作为监听器回调的第一个参数传递的新管道流来改变 isTTY。
列表 5.13 通过使用两个类来演示这一点。第一个类,MemoryStream,继承自 stream.Readable,并基于 Node 的内存使用情况生成数据。第二个类,OutputStream,监视它所绑定的流,以便它能告诉可读流它期望的输出类型。
列表 5.13. 使用 isTTY 来适应流的行为


在内部,Node 使用 isTTY 来适应 repl 模块和 readline 接口的行為。列表 5.13 中的示例跟踪 process.stdout.isTTY 的状态
以确定原始输出流是什么,然后将该值复制到后续目的地
。当终端是 TTY 时,使用颜色
;否则输出纯文本。
流,就像其他任何东西一样,都应该进行测试。接下来的技术介绍了一种为你的自定义流类编写单元测试的方法。
技巧 38 测试流
就像你写的任何其他东西一样,强烈建议你测试你的流。这项技术解释了如何使用 Node 的内置 assert 模块来测试继承自 stream.Readable 的类。
问题
你已经编写了自己的流类,并想要为它编写一个单元测试。
解决方案
使用一些合适的样本数据来驱动你的流类,然后调用 read() 或 write() 来收集结果,并将它们与预期的输出进行比较。
讨论
测试流的常见模式,在 Node 的源代码中使用,并被许多开源开发者使用,是使用样本数据驱动测试流,然后将最终结果与预期值进行比较。
这中最困难的部分可能是找到合适的数据进行测试。有时创建一个文本文件或测试术语中的固定装置很容易,可以通过管道将其用于驱动流。如果你正在测试一个面向网络的流,那么你应该考虑使用 Node 的 net 或 http 模块来创建“模拟”服务器,以生成合适的测试数据。
列表 5.14 是 技术 34 中的 CSV 解析器的修改版本;它已被转换为模块,以便我们可以轻松测试它。列表 5.15 是相关的测试,它创建了一个 CSVParser 实例,然后通过它推送一些值。
列表 5.14. CSVParser 流


CSVParser 类使用 module.exports 导出,以便单元测试
可以加载它。当在类的实例上调用 push 时,_transform 方法
将稍后运行。接下来是这个类的一个简单的单元测试。
列表 5.15. 测试 CSVParser 流

使用了固定文件 sample.csv 将数据管道传输到 CSVParser 实例。然后使用 assert.deepEqual 方法来轻松比较预期数组和实际数组。
一个监听器被附加到 exit
,因为我们想在运行断言之前等待流完成数据处理。然后从解析器中读取数据
,并将其推送到数组中以进行断言检查
——预期值首先定义
。这种模式在 Node 的自身流测试中使用,并且是测试框架如 Mocha 和 node-tap 提供的轻量级版本。
5.6. 摘要
在本章中,你看到了内置的流式 API 的工作方式,如何使用 Node 提供的基类创建新的和独特的流,以及如何使用一些更高级的技术用流来结构化程序。正如你在 技术 36 中所看到的,构建新的流始于正确地从基类继承——别忘了测试这些流!有关测试的更多信息,请参阅 技术 38。
正如你所见,有一些关于流的创新用法,比如 substack 的 baudio 模块 (github.com/substack/baudio),它通过声音波流进行交流。还有两个流 API:原始的 Node 0.8 及以下 API 和更新的 streams2 API。通过 readable-stream 模块 (github.com/isaacs/readable-stream) 支持向前兼容性,通过包装流 (技术 36) 实现向后兼容性。
与流一起工作的很大一部分是处理文件。在下一章中,我们将详细探讨 Node 的文件系统处理。
第六章. 文件系统:文件同步和异步方法
本章涵盖
-
理解
fs模块及其组件 -
配置文件和文件描述符的处理
-
使用文件锁定技术
-
递归文件操作
-
编写文件数据库
-
监控文件和目录
正如我们在前面的章节中提到的,Node 的核心模块通常坚持使用低级 API。这允许各种(甚至竞争的)高级概念(如 Web 框架、文件解析器和命令行工具)作为第三方模块存在。fs(或文件系统)模块也不例外。
fs模块通过提供
-
POSIX 文件 I/O 原语
-
文件流
-
批量文件 I/O
-
文件监控
与其他 I/O 模块(如net和http)相比,fs模块的独特之处在于它既有异步 API 也有同步 API。这意味着它提供了一个执行阻塞 I/O 的机制。文件系统也有同步 API 的原因很大程度上是因为 Node 本身的内部工作方式,即模块系统和require的同步行为。
本章的目标是向您展示一系列不同复杂度的技术,以便在处理文件系统模块时使用。我们将探讨
-
加载配置文件的异步和同步方法
-
文件描述符的处理
-
建议性文件锁定技术
-
递归文件操作
-
编写文件数据库
-
监控文件和目录变化
但在我们深入这些技术之前,让我们首先从高层次上了解您可以使用文件系统 API 做什么,以便捕捉其功能并提供一些关于哪个工具可能是最佳选择的见解。
6.1. fs 模块概述
fs模块包括常见 POSIX 文件操作的包装器,以及批量、流和监控操作。它还为许多操作提供了同步 API。让我们从高层次上浏览不同的组件。
6.1.1. POSIX 文件 I/O 包装器
从宏观角度来看,文件系统 API 中的大多数方法都是围绕标准 POSIX 文件 I/O 调用(mng.bz/7EKM)的包装。这些方法将具有类似的名字。例如,readdir调用(linux.die.net/man/3/readdir)在 Node 中有fs.readdir的对应方法:
var fs = require('fs');
fs.readdir('/path/to/dir', function (err, files) {
console.log(files); // [ 'fileA', 'fileB', 'fileC', 'dirA', 'etc' ]
});
表 6.1 显示了 Node 支持的 POSIX 文件方法列表,包括它们的功能描述。
表 6.1. Node 支持的 POSIX 文件方法
| POSIX 方法 | fs 方法 | 描述 |
|---|---|---|
| rename(2) | fs.rename | 更改文件名 |
| truncate(2) | fs.truncate | 截断或扩展文件到指定长度 |
| ftruncate(2) | fs.ftruncate | 与 truncate 相同,但接受文件描述符 |
| chown(2) | fs.chown | 修改文件所有者和组 |
| fchown(2) | fs.fchown | 与 chown 相同,但接受文件描述符 |
| lchown(2) | fs.lchown | 与 chown 相同,但不跟随符号链接 |
| chmod(2) | fs.chmod | 修改文件权限 |
| fchmod(2) | fs.fchmod | 与 chmod 相同,但接受文件描述符 |
| lchmod(2) | fs.lchmod | 与 chmod 相同,但不跟随符号链接 |
| stat(2) | fs.stat | 获取文件状态 |
| lstat(2) | fs.lstat | 与 stat 相同,但如果提供了链接信息而不是链接指向的内容,则返回信息 |
| fstat(2) | fs.fstat | 与 stat 相同,但接受文件描述符 |
| link(2) | fs.link | 创建硬链接 |
| symlink(2) | fs.symlink | 创建指向文件的符号链接 |
| readlink(2) | fs.readlink | 读取符号链接的值 |
| realpath(2) | fs.realpath | 返回规范化的绝对路径名 |
| unlink(2) | fs.unlink | 删除目录条目 |
| rmdir(2) | fs.rmdir | 删除目录 |
| mkdir(2) | fs.mkdir | 创建目录 |
| readdir(2) | fs.readdir | 读取目录内容 |
| close(2) | fs.close | 删除文件描述符 |
| open(2) | fs.open | 打开或创建文件以供读取或写入 |
| utimes(2) | fs.utimes | 设置文件访问和修改时间 |
| futimes(2) | fs.futimes | 与 utimes 相同,但接受文件描述符 |
| fsync(2) | fs.fsync | 将文件数据与磁盘同步 |
| write(2) | fs.write | 将数据写入文件 |
| read(2) | fs.read | 从文件中读取数据 |
POSIX 方法提供了一组常见文件操作的底层 API。例如,这里我们使用了一些同步 POSIX 方法将数据写入文件,然后检索这些数据:

当涉及到读写文件时,通常你不需要这么低的级别,而是可以使用流式或批量方法。
6.1.2. 流式传输
fs 模块提供了 fs.createReadStream 和 fs.createWriteStream 的流式 API。fs.createReadStream 是一个 Readable 流,而 fs.createWriteStream 是一个 Writeable。流式 API 可以通过 pipe 连接到其他流。例如,以下是一个使用流复制文件的简单应用程序:

当你想一次处理数据的一部分或想将数据源链接在一起时,文件流是有益的。要深入了解流,请查看第五章 chapter 5。
6.1.3. 批量文件 I/O
文件系统 API 还包括一些用于读取(fs.readFile)、写入(fs.writeFile)或追加(fs.appendFile)的批量方法。
批量方法在你想一次性将文件加载到内存或完全写入时很有用:

6.1.4. 文件监控
fs模块还提供了一些用于监视文件(fs.watch和fs.watchFile)的机制。当你想知道文件是否以某种方式发生变化时,这很有用。fs.watch使用底层操作系统的通知,使其非常高效。但是fs.watch可能在网络驱动器上很挑剔或者根本不起作用。在这种情况下,可以使用效率较低的fs.watchFile方法,它使用stat轮询。
我们将在本章的后面部分更详细地探讨文件监视。
6.1.5. 同步替代方案
Node 的同步文件系统 API 非常显眼。每个同步方法末尾都附加了一个大的Sync,这使得其目的难以忽视。同步方法适用于所有 POSIX 和批量 API 调用。一些例子包括readFileSync、statSync和readdirSync。Sync告诉你这个方法将阻塞你的单线程 Node 进程,直到它完成。一般来说,同步方法应该在首次设置应用程序时使用,而不是在回调中:

当然,规则也有例外,但重要的是理解使用同步方法对性能的影响。
测试服务器性能
我们如何知道在 Web 服务器请求处理中的同步执行速度较慢?一个很好的测试方法是使用 ApacheBench (en.wikipedia.org/wiki/ApacheBench)。我们之前的例子显示了在每次请求上同步服务 10 MB 文件而不是在应用程序设置期间缓存时,性能下降了约 2 倍。以下是测试中使用的命令:
ab -n 1000 -c 100 "http://localhost:3000"
在完成快速概述之后,我们现在可以开始了解一些你在与文件系统工作时将使用的技巧。
技巧 39 加载配置文件
将配置保存在单独的文件中可能很有用,特别是对于在多个环境中运行的应用程序(如开发、测试和生产)。在这个技巧中,你将了解如何加载配置文件的来龙去脉。
问题
你的应用程序将配置存储在单独的文件中,并且它依赖于在启动时拥有该配置。
解决方案
使用同步文件系统方法在应用程序的初始设置中拉取配置。
讨论
同步 API 的一个常见用途是在应用程序启动时加载配置或其他数据。假设我们有一个简单的配置文件,以 JSON 格式存储,如下所示:
{
"site title": "My Site",
"site base url": "http://mysite.com",
"google maps key": "92asdfase8230232138asdfasd",
"site aliases": [ "http://www.mysite.com", "http://mysite.net" ]
}
让我们首先看看我们如何以异步方式完成这个任务,这样你就可以看到差异。例如,假设doThisThing依赖于配置文件中的信息。异步地,我们可以这样写:

这将有效,对于某些设置可能是可取的,但也会导致所有依赖于配置的内容都嵌套在同一个级别。这可能会变得很丑陋。通过使用同步版本,我们可以更简洁地处理事情:

使用 Sync 方法的一个特点是,每当发生错误时,它都会被抛出:

关于 require 的注意事项
我们可以在 Node 中将 JSON 文件作为模块 require,因此我们的代码甚至可以进一步缩短:
var config = require('./config.json');
doThisThing(config);
但这种方法有一个注意事项。模块在 Node 中全局缓存,所以如果我们还有另一个文件也 require config.json 并对其进行修改,那么在应用程序中使用该模块的所有地方都会进行修改。因此,当你想要修改对象时,建议使用 readFileSync。如果你选择使用 require,则将对象视为冻结(只读);否则,你可能会遇到难以追踪的 bug。你可以通过使用 Object.freeze 显式地冻结一个对象。
这与异步方法不同,异步方法使用错误参数作为回调的第一个参数:

在我们加载配置文件的示例中,我们更喜欢使应用程序崩溃,因为它没有那个文件就无法运行,但有时你可能想要处理同步错误。
技巧 40:使用文件描述符
与文件描述符一起工作可能一开始会让人感到困惑,如果你没有处理过它们。这项技术作为介绍,展示了如何在 Node 中使用它们的几个示例。
问题
你想要访问文件描述符来进行写入或读取。
解决方案
使用 Node 的 fs 文件描述符方法。
讨论
文件描述符(FD)是与操作系统管理的进程中的打开文件相关联的整数(索引)。当进程打开文件时,操作系统通过为每个文件分配一个唯一的整数来跟踪这些打开的文件,然后它可以使用这个整数来查找有关文件更多的信息。
虽然名字中带有 文件,但它涵盖的不仅仅是常规文件。文件描述符可以指向目录、管道、网络套接字和常规文件,仅举几例。Node 可以访问这些低级位。大多数进程都有一个标准的文件描述符集合,如表 6.2 所示。
表 6.2. 常见文件描述符
| 流 | 文件描述符 | 描述 |
|---|---|---|
| stdin | 0 | 标准输入 |
| stdout | 1 | 标准输出 |
| stderr | 2 | 标准错误 |
在 Node 中,当我们想要写入 stdout 时,通常习惯使用 console.log 语法:
console.log('Logging to stdout')
如果我们使用 process 全局上可用的流对象,我们可以更明确地完成相同的事情:
process.stdout.write('Logging to stdout')
但还有另一种,远未广泛使用的方法,使用 fs 模块将内容写入 stdout。fs 模块包含一些方法,它们将 FD 作为第一个参数。我们可以使用 fs.writeSync 将内容写入文件描述符 1(或 stdout):
fs.writeSync(1, 'Logging to stdout')
同步日志
console.log 和 process.stdout.write 实际上是在底层同步方法,前提是 TTY 是一个文件流
从 open 和 openSync 调用返回一个文件描述符作为数字:

文件系统文档中指定了处理文件描述符的多种方法。
通常,文件描述符更有趣的使用发生在你从父进程继承或创建子进程,其中描述符是共享或传递的情况下。我们将在稍后的章节中讨论这个问题。
技巧 41 使用文件锁定
当协作进程需要访问一个保持文件完整性和数据不丢失的公共文件时,文件锁定非常有用。在这个技巧中,我们将探讨如何编写自己的文件锁定模块。
问题
你想要锁定一个文件以防止进程篡改它。
解决方案
使用 Node 的内置功能设置文件锁定机制。
讨论
在单线程的 Node 进程中,文件锁定通常是你可以不必担心的事情。但你可能会有其他进程访问相同文件的情况,或者一个 Node 进程集群访问相同文件的情况。
在这些情况下,可能会发生竞争条件和数据丢失(更多关于此内容请参阅 mng.bz/yTLV)。大多数操作系统提供强制锁(在内核级别强制执行)和咨询锁(不强制执行;这些只有在涉及的过程订阅相同的锁定方案时才起作用)。如果可能的话,通常更倾向于使用咨询锁,因为强制锁过于强硬,可能难以解锁(kernel.org/doc/Documentation/filesystems/mandatory-locking.txt)。
使用第三方模块进行文件锁定
Node 没有内置直接锁定文件的支持(无论是强制锁还是咨询锁)。但可以使用 flock 等系统调用(linux.die.net/man/2/flock)进行文件的咨询锁定,这些调用在第三方模块中可用(github.com/baudehlo/node-fs-ext)。
你可以使用 锁文件 而不是直接使用 flock 等方法来锁定文件。锁文件是普通文件或目录,其 存在 表示其他资源当前正在使用,不应被篡改。创建锁文件需要是原子的(没有竞争条件)以避免冲突。由于是咨询性的,所有参与进程都必须遵守当锁文件存在时达成的相同规则。这如图 6.1 所示。
图 6.1. 使用锁文件在协作进程之间进行咨询锁定

假设我们有一个名为 config.json 的文件,该文件可能随时被任何数量的进程更新。为了避免数据丢失或损坏,更新进程可以创建一个 config.lock 文件,并在进程完成后将其删除。每个进程都会同意在更新之前检查锁文件的存在。
Node 提供了一些开箱即用的方法来执行此操作。我们将探讨几个选项:
-
使用独占标志创建锁文件
-
使用
mkdir创建锁文件
让我们先看看如何使用独占标志。
使用独占标志创建锁文件
fs 模块为涉及打开文件的所有方法(如 fs.writeFile、fs.createWriteStream 和 fs.open)提供了一个 x 标志。此标志告诉操作系统文件应以独占模式(O_EXCL)打开。当使用时,如果文件已存在,则文件将无法打开:

打开文件时的标志组合
在打开文件时,你可以传递各种标志组合;要查看所有标志的列表,请参阅 fs.open 文档:nodejs.org/api/fs.html#fs_fs_open_path_flags_mode_callback。
如果另一个进程已经创建了锁文件,我们希望失败。我们失败是因为我们不希望在另一个进程使用资源时篡改锁文件背后的资源。因此,在我们的情况下,拥有独占标志机制变得非常有用。但与其写入一个空文件,不如将 PID(进程 ID)放入此文件中,这样如果发生任何坏事,我们将知道哪个进程最后拥有锁:

使用 mkdir 创建锁文件
如果锁文件位于网络驱动器上,独占模式可能工作得不好,因为某些系统不尊重网络驱动器上的 O_EXCL 标志。为了解决这个问题,另一种策略是将锁文件创建为一个目录。mkdir 是一个原子操作(无竞争),具有出色的跨平台支持,并且与网络驱动器配合良好。如果存在目录,mkdir 将失败。在这种情况下,PID 可以存储在该目录内的一个文件中:

创建一个锁文件模块
到目前为止,我们已经讨论了几种创建锁文件的方法。我们还需要一个机制在我们完成时删除它们。此外,为了成为良好的锁文件公民,我们应该在进程退出时删除任何创建的锁文件。许多功能都可以封装在一个简单的模块中:

这里是一个示例用法:

要查看使用独占模式更完整功能的实现,请查看锁文件第三方模块(github.com/isaacs/lockfile)。
技巧 42 递归文件操作
是否曾经需要删除目录及其所有子目录(类似于rm -rf)?给定一个路径创建目录和任何中间目录?在目录树中搜索特定文件?递归文件操作很有用,但很难做对,尤其是在异步操作中。但了解如何执行它们是掌握 Node 事件编程的良好练习。在这个技术中,我们将通过创建一个用于搜索目录树的模块来深入了解递归文件操作。
问题
你想在目录树中搜索一个文件。
解决方案
使用递归并结合文件系统原语。
Discussion
当一个任务跨越多个目录时,事情变得更有趣,尤其是在异步的世界中。你可以通过一次调用fs.mkdir来模拟mkdir命令行的功能,但对于像mkdir -p(对于创建中间目录很有用)这样的更复杂的事情,你必须递归地思考。这意味着我们问题的解决方案将取决于“相同问题的较小实例的解决方案”(“递归(计算机科学)”:en.wikipedia.org/wiki/Recursion_(computer_science))。
在我们的例子中,我们将编写一个查找模块。我们的查找模块将递归地在给定的起始路径中查找匹配的文件(类似于find/start/path -name='file-in-question'),并以数组的形式提供这些文件的路径。
假设我们有一个以下的目录树:

从根目录搜索模式/file.*将给我们以下结果:
[ 'dir-a/dir-b/dir-c/file-e.png',
'dir-a/dir-b/file-c.js',
'dir-a/dir-b/file-d.txt',
'dir-a/file-a.js',
'dir-a/file-b.txt' ]
那么,我们该如何构建它呢?首先,fs模块为我们提供了一些我们需要的基本操作:
-
fs.readdir/fs.readdirSync—给定一个路径,列出所有文件(包括目录)。 -
fs.stat/fs.statSync—提供有关指定路径上文件的信息,包括该路径是否是目录。
我们将公开同步(findSync)和异步(find)实现。findSync将像其他Sync方法一样阻塞执行,比其异步对应物稍快,但可能在非常大的目录树上失败(因为 JavaScript 还没有适当的尾调用:people.mozilla.org/~jorendorff/es6-draft.html#sec-tail-position-calls)。
为什么同步函数稍微快一些?
同步函数不会延迟到以后,即使异步对应物发生得非常快。同步函数立即发生,当你已经在 CPU 上时,你保证只等待必要的 I/O 完成所需的时间。但同步函数将在等待期间阻止其他事情发生。
另一方面,find可能会稍微慢一些,但不会在大型树中失败(因为由于调用是异步的,栈会定期清除)。find不会阻塞执行。
让我们先看看findSync的代码:

由于一切都是同步的,我们可以在最后使用return来获取所有结果,因为它永远不会到达那里,直到所有递归都完成。第一个发生的错误会抛出,如果需要,可以在 try/catch 块中捕获。让我们看看一个示例用法:

现在我们切换一下,看看如何使用find实现异步处理这个问题:

我们不能像同步版本那样直接return我们的结果;当我们知道我们已经完成时,我们需要用它们调用回调。为了知道我们已经完成,我们使用一个计数器(asyncOps)。我们还必须意识到,每当有回调时,确保我们有一个闭包在异步调用完成后我们期望的任何变量周围(这就是为什么我们从标准的for循环切换到forEach调用——更多关于这一点在mng.bz/rqEA)。
我们的计数器(asyncOps)在我们执行异步操作(如fs.readdir或fs.stat)之前增加。在异步操作的回调中计数器会递减。具体来说,它会在任何其他异步调用之后递减(否则我们会过早地回到0)。在成功的情况下,当所有递归异步工作完成后,asyncOps会达到0,我们可以调用回调并返回结果(if (asyncOps == 0) cb(null, results))。在失败的情况下,asyncOps永远不会达到0,并且其中一个错误处理程序已经被触发,并且已经调用回调返回了错误。
此外,在我们的例子中,我们不能确定fs.stat将是最后一个被调用的,因为我们可能有一个没有文件的目录链,所以我们在这两个地方进行检查。我们还有一个简单的error包装器,以确保我们永远不会调用回调超过一次,因为这将导致以后难以追踪的错误。如果您的异步操作返回一个值,如我们的示例或一个错误,确保您永远不会调用回调超过一次非常重要,因为这会导致以后难以追踪的错误。
计数器的替代方案
计数器并不是唯一可以跟踪一组异步操作完成的机制。根据应用程序的要求,递归传递原始回调可能有效。例如,可以查看第三方mkdirp模块(github.com/substack/node-mkdirp)。
现在我们有了异步版本(find),可以使用标准的 Node 风格回调签名来处理该操作的返回结果:
var finder = require('./finder');
finder.find(/file*/, '/path/to/root', function (err, results) {
if (err) return console.error(err);
console.log(results);
});
并行操作的第三方解决方案
并行操作可能难以跟踪,并且容易成为 bug 的源头,因此你可能想使用像async(github.com/caolan/async)这样的第三方库来帮助。另一个选择是使用像Q(github.com/kriskowal/q)这样的 promises 库。
技巧 43:编写文件数据库
Node 的核心fs模块为你提供了构建复杂性的工具,就像你在上一个技术中看到的递归操作。它还使你能够执行其他复杂任务,例如创建文件数据库。在这个技术中,我们将编写一个文件数据库,以便查看fs模块中的其他部分,包括流式传输和协作。
问题
你想要一个简单且快速的数据存储结构,并具有一些一致性保证。
解决方案
使用内存数据库和只追加日志记录。
讨论
我们将编写一个简单的键/值数据库模块。数据库将为当前状态提供内存访问以实现速度,并在磁盘上使用只追加存储格式以实现持久性。使用只追加存储将为我们提供以下:
-
高效的磁盘 I/O 性能 — 我们始终写入文件的末尾。
-
耐用性 — 文件的前一个状态永远不会以任何方式改变。
-
简单创建备份的方法 — 我们可以在任何时刻复制文件以获取数据库在该点的状态。
文件中的每一行都是一个记录。记录只是一个具有两个属性(key和value)的 JSON 对象。key是一个表示对value查找的字符串。value可以是任何可序列化为 JSON 的内容,包括字符串和数字。让我们看看一些示例记录:
{"key":"a","value":23}
{"key":"b","value":["a","list","of","things"]}
{"key":"c","value":{"an":"object"}}
{"key":"d","value":"a string"}
如果一条记录被更新,文件中稍后也会找到具有相同键的新版本记录:
{"key":"d","value":"an updated string"}
如果一条记录已被删除,它稍后也会在文件中找到,值为null:
{"key":"b","value":null}
当数据库被加载时,日志将从顶部到底部流式传输,在内存中构建数据库的当前状态。记住,数据不会被删除,因此可以存储以下数据:
{"key":"c","value":"my first value"}
...
{"key":"c","value":null}
...
{"key":"c","value":{"my":"object"}}
在这种情况下,在某个时刻我们将"my first value"作为键c保存。后来我们删除了这个键。然后,最近,我们将键设置为{"my":"object"}。最近的条目将被加载到内存中,因为它代表了数据库的当前状态。
我们讨论了数据如何持久化到文件系统。接下来让我们谈谈我们将公开的 API:

让我们深入代码,开始构建这个模块。我们将编写一个Database模块来存储我们的逻辑。它将继承自EventEmitter,这样我们就可以向消费者发出事件(例如,当数据库加载了所有数据并且我们可以开始使用它时):

我们想流式传输存储的数据,并在完成时发出“load”事件。流式传输将使我们能够处理正在读取的数据。流式传输也是异步的,允许宿主应用程序在数据加载时执行其他操作:

在从文件中读取数据时,我们发现所有存在的完整记录。
将我们的写入结构化以结构化我们的读取
当触发readable事件时,我们刚刚pop()的数据是什么?最后一个记录发现总是空字符串(''),因为我们每行都以换行符(\n)结束。
一旦我们加载了数据并发出load事件,客户端就可以开始与数据交互。让我们看看那些方法,从最简单的get方法开始:

让我们看看如何存储更新:

现在我们为删除键添加一些糖:

那里有一个简单的数据库模块。最后一件事:我们需要导出构造函数:
module.exports = Database;
可以在这个模块上做出各种改进,比如刷新写入(mng.bz/2g19)或在失败时重试。有关更完整的基于 Node 的数据库模块的示例,请查看node-dirty(github.com/felixge/node-dirty)或nstore(github.com/creationix/nstore)。
技巧 44 监视文件和目录
当客户端通过 FTP 等将文件添加到目录中或修改文件后重新加载 Web 服务器时,是否需要处理文件?您可以通过监视文件更改来完成这两项操作。
Node 为文件监视提供了两种实现。我们将在这项技术中讨论两者,以便了解何时使用其中一个或另一个。但核心是,它们实现了相同的功能:监视文件(和目录)。
问题
您想监视一个文件或目录,并在更改时执行操作。
解决方案
使用fs.watch和fs.watchFile。
讨论
在 Node 核心中很少看到同一目的的多个实现。Node 的文档建议,如果可能,您应首选fs.watch而不是fs.watchFile,因为它被认为更可靠。但fs.watch在操作系统之间并不一致,而fs.watchFile是一致的。为什么会有这种混乱?
关于 fs.watch 的故事
Node 的事件循环通过操作系统来处理其单线程环境中的异步 I/O。这也提供了性能优势,因为操作系统可以立即通知进程某些新的 I/O 操作已准备好处理。操作系统有不同的方式来通知进程关于事件(这就是为什么我们有libuv)。文件监视工作的成果是fs.watch方法。
fs.watch将这些不同类型的事件系统结合成一个具有公共 API 的方法,以提供以下功能:
-
在文件更改事件始终被触发方面,这是一个更可靠的实现
-
一种更快的实现方式,因为通知在发生时立即传递给 Node
让我们看看下一个更老的方法。
关于 fs.watchFile 的故事
文件监视的另一种,更老的实施方式称为 fs.watchFile。它没有接入通知系统,而是通过间隔轮询来查看是否发生了变化。
fs.watchFile 在检测变化方面并不完整,也不够快。但使用 fs.watchFile 的优点是它在各个平台之间是一致的,并且在网络文件系统(如 SMB 和 NFS)上工作得更可靠。
哪个更适合我?
更推荐使用 fs.watch,但由于它在各个平台之间不一致,测试它是否按您期望的那样工作(并且最好有一个测试套件)是个好主意。
让我们编写一个程序来帮助我们玩转文件监视,并查看每个 API 提供了什么。首先,创建一个名为 watcher.js 的文件,内容如下:
var fs = require('fs');
fs.watch('./watchdir', console.log);
fs.watchFile('./watchdir', console.log);
现在在您的 watcher.js 文件所在的目录中创建一个名为 watchdir 的目录:
mkdir watchdir
然后,打开几个终端。在第一个终端中,运行
node watcher
在第二个终端中,切换到 watchdir:
cd watchdir
在您打开的两个终端(最好是并排打开)中,我们将在 watchdir 中进行更改,并查看 Node 是否能够检测到这些更改。让我们创建一个新的文件:
touch file.js
我们可以看到 Node 的输出:

好吧,所以现在我们创建了一个文件;让我们用相同的命令更新它的修改时间:
touch file.js
现在我们查看 Node 输出,我们看到只有 fs.watch 捕获了这个变化:
change file.js
因此,如果您在监视目录时使用 touch 更新文件对您的应用程序很重要,fs.watch 提供了支持。
fs.watchFile 和目录
在监视目录时,许多对文件的更新不会被 fs.watchFile 捕获。如果您想通过 fs.watchFile 获取这种行为,请监视单个文件。
让我们尝试移动我们的文件:
mv file.js moved.js
在我们的 Node 终端中,我们看到以下输出,表明两个 API 都检测到了变化:

这里的主要目的是使用您想要利用的确切用例来测试 API。希望这个 API 在未来会变得更加稳定。阅读文档以获取最新的开发信息 (nodejs.org/api/fs.html#fs_fs_watch_filename_options_listener)。以下是一些帮助导航的提示:
-
运行您的测试用例,优先使用
fs.watch。事件是否如您预期的那样被触发? -
如果您打算监视单个文件,不要监视它所在的目录;您可能会触发更多的事件。
-
如果在更改之间比较文件状态很重要,
fs.watchFile提供了即时的解决方案。否则,您需要使用fs.watch手动管理状态。 -
虽然
fs.watch在 Mac 上工作,但这并不意味着它会在 Linux 服务器上以完全相同的方式工作。确保开发和生产环境都经过测试以实现所需的功能。
去吧,明智地观察!
6.2. 摘要
在本章中,我们讨论了使用fs模块的多种技术。我们在查看配置文件加载和递归文件处理的同时,涵盖了异步和同步的使用。我们还探讨了文件描述符和文件锁定。最后,我们实现了一个文件数据库。
希望这已经扩展了你使用fs模块的一些概念的理解。以下是一些要点:
-
同步方法可能比它们的异步对应方法更优雅、更简单,但要注意性能问题,尤其是如果你正在编写服务器。
-
建议性文件锁定是一种有助于跨多个进程共享资源的机制,只要所有进程都遵循相同的合同。
-
需要在完成后获得某种响应的并行异步操作需要被跟踪。虽然了解如何使用计数器或递归技术是有帮助的,但考虑使用经过良好测试的第三方模块如
async。 -
根据你将如何使用特定文件来确定采取哪种行动。如果它是一个大文件或可以分块处理,考虑使用流式方法。如果它是一个小文件或你需要等到整个文件加载完毕才能使用,考虑使用批量方法。如果你想更改文件的一个特定部分,你可能想坚持使用 POSIX 文件方法。
在下一章中,我们将探讨 Node 的另一种主要 I/O 形式:网络。
第七章:网络:Node 的真正“Hello, World”
本章涵盖
-
网络概念及其与 Node 的关系
-
TCP、UDP 和 HTTP 客户端和服务器
-
DNS
-
网络加密
Node.js 平台本身被誉为编写快速和可扩展网络应用程序的解决方案。要编写面向网络的软件,你需要了解网络技术和协议是如何相互关联的。在下一节中,我们解释了网络是如何围绕具有清晰边界的科技堆栈设计的;此外,Node 是如何实现这些协议以及它们的 API 看起来像什么。
在本章中,你将了解 Node 的网络模块是如何工作的。这包括dgram、dns、http和net模块。如果你对网络术语如套接字、数据包和协议不确定,那么不用担心:我们还介绍了关键的网络安全概念,为你提供网络编程的坚实基础。
7.1. 网络中的 Node
本节是关于网络介绍的。你将了解网络层、数据包、套接字——所有构成网络的东西。这些概念对于理解 Node 的网络 API 至关重要。
7.1.1. 网络术语
网络术语很快就会变得令人不知所措。为了让大家达成共识,我们包括了表 7.1,它总结了本章将形成基础的主要概念。
表 7.1. 网络概念
| Term | Description |
|---|---|
| Layer | 代表一组相关网络协议的逻辑组。我们工作的应用层是最高层;物理层是最低层。 |
| HTTP | 超文本传输协议——建立在 TCP 之上的应用层客户端-服务器协议。 |
| TCP | 传输控制协议——允许客户端到服务器的双向通信,并在此基础上构建应用层协议,如 HTTP。 |
| UDP | 用户数据报协议——一种轻量级协议,通常在需要速度而牺牲可靠性的情况下选择。 |
| Socket | IP 地址和端口号的组合通常被称为套接字。 |
| Packet | TCP 数据包也称为段——数据块和头部信息的组合。 |
| Datagram | 与数据包相当的 UDP 协议。 |
| MTU | 最大传输单元——协议数据单元的最大大小。每一层都可以有一个 MTU:IPv4 至少为 68 字节,以太网 v2 为 1,500 字节。 |
要理解 Node 的网络 API,了解层、数据包、套接字以及网络由什么组成的所有其他事物至关重要。如果你不了解 TCP(传输控制协议)和 UDP(用户数据报协议)之间的区别,那么你很难知道何时使用这些协议。在本节中,我们介绍了你需要了解的术语,并进一步探讨了这些概念,以便你离开本节时有一个坚实的基础。
如果你负责实现运行在 HTTP 之上或甚至使用 UDP 的低延迟游戏代码的高级协议,那么你应该理解这些概念中的每一个。我们将在接下来的几节中详细分解这些概念。
层
构成互联网和一般互联网技术的协议和标准堆栈可以建模为层。最低层代表物理媒体——以太网、蓝牙、光纤——针脚、电压和网络适配器的世界。作为软件开发者,我们工作在比底层硬件更高的层次。当我们使用 Node 与网络通信时,我们关注的是互联网协议(IP)套件中的应用层和传输层。
层最好通过视觉表示。图 7.1\图 7.1 将逻辑网络层与数据包相关联。低层的物理和数据链路层协议封装了高级协议。
图 7.1. 协议被分为七个逻辑层。数据包在连续的层中被协议封装。

数据包在连续的层中被协议包裹。一个 TCP 数据包,可能代表一系列 HTTP 请求中的部分数据包,包含在 IP 数据包的数据部分中,而这个 IP 数据包反过来又被以太网数据包包裹。回到图 7.1,HTTP 请求的 TCP 数据包穿越了传输层和应用层:TCP 是传输层,用于创建更高层的 HTTP 协议。其他层也参与其中,但我们并不总是知道每一层使用的是哪些具体的协议:HTTP 总是通过 TCP/IP 传输,但除此之外,可以使用 Wi-Fi 或以太网——你的程序不会知道区别。
展示了网络层是如何被每个协议包裹的。请注意,数据在层与层之间移动的步数永远不会超过一个——我们不会谈论传输层协议与网络层的交互。
图 7.2. 网络层包裹

当编写 Node 程序时,你应该认识到 HTTP 是通过 TCP 实现的,因为 Node 的http模块建立在net模块中找到的底层 TCP 实现之上。但你不需要了解以太网、10BASE-T 或蓝牙是如何工作的。
TCP/IP
你可能听说过 TCP/IP——这就是我们所说的互联网协议套件,因为传输控制协议(TCP)和互联网协议(IP)是这一标准定义的最重要和最早的协议。
在互联网协议中,主机通过 IP 地址来识别。在 IPv4 中,地址是 32 位的,这限制了可用的地址空间。IP 在过去十年中一直是争议的焦点,因为地址正在耗尽。为了解决这个问题,开发了一种新的协议版本,称为 IPv6。
你可以通过使用net模块使用 Node 建立 TCP 连接。这允许你实现核心模块不支持的应用层协议:IRC、POP,甚至 FTP 都可以使用 Node 的核心模块实现。如果你发现自己需要与非标准 TCP 协议通信,可能是你公司内部使用的某种协议,那么net.Socket和net.createConnection将使这项工作变得简单。
Node 支持多种方式同时支持 IPv4 和 IPv6:dns模块可以查询 IPv4 和 IPv6 记录,而net模块可以传输和接收 IPv4 和 IPv6 网络上的数据。
关于 IP 有趣的是,它不保证数据完整性或交付。为了可靠通信,我们需要一个传输层协议,如 TCP。有时,交付并不是总是必需的,尽管当然更受欢迎——在这些情况下,需要一个更轻量级的协议,这就是 UDP 的作用。下一节将更详细地探讨 TCP 和 UDP。
UDP 及其与 TCP 的比较
数据报是 UDP 中通信的基本单元。这些消息是自包含的,包含源地址、目标地址和一些用户数据。UDP 不保证交付或消息顺序,也不提供防止数据重复的保护。你将使用的大多数与 Node 程序一起使用的协议都将建立在 TCP 之上,但有时 UDP 很有用。如果交付不是关键,但需要性能,那么 UDP 可能是一个更好的选择。一个例子是流媒体视频服务,偶尔的故障可以作为增加更多吞吐量的可接受权衡。
TCP 和 UDP 都使用相同的网络层——IP。它们都为应用层协议提供服务。但它们非常不同。TCP 是一个面向连接且可靠的字节流服务,而 UDP 基于数据报,不保证数据的交付。
与此相对的是 TCP,它是一个全双工^([1])面向连接的协议。在 TCP 中,给定连接只有两个端点。在端点之间传递的信息的基本单元被称为段——数据块和头部的组合。当你听到数据包这个词时,通常指的是 TCP 段。
¹ 全双工:可以在同一连接中发送和接收消息。
尽管 UDP 数据包包含校验和,这有助于检测在数据报穿越互联网过程中可能发生的损坏,但不会自动重传损坏的数据包——如果需要,这取决于你的应用程序来处理。包含无效数据的数据包将被有效地静默丢弃。
每个数据包,无论是 TCP 还是 UDP,都有一个源地址和目标地址。但源地址和目标程序也同样重要。当你的 Node 程序连接到 DNS 服务器或接受传入的 HTTP 连接时,必须有一种方法来映射在网络中传输的数据包和生成它们的应用程序之间。要完全描述一个连接,你需要额外的信息。这被称为端口号——端口号和地址的组合称为套接字。继续阅读以了解更多关于端口号以及它们与套接字的关系。
套接字
从程序员的视角来看,网络的基本单元是套接字。套接字是 IP 地址和端口号的组合——既有 TCP 套接字也有 UDP 套接字。正如你在上一节中看到的,TCP 连接是全双工的——打开到特定主机的连接允许通信流向该主机以及从该主机流向。尽管“套接字”这个词是正确的,但历史上“套接字”指的是伯克利套接字 API。
伯克利套接字 API
1983 年发布的伯克利套接字是用于处理互联网套接字的 API。这是 TCP/IP 套件的原始 API。尽管其起源在于 Unix,但 Microsoft Windows 包括一个与伯克利套接字紧密遵循的网络堆栈。
对于标准的 TCP/IP 服务,有一些众所周知的端口号。它们包括 DNS、HTTP、SSH 等。这些端口号通常由于历史原因而通常是奇数。TCP 和 UDP 端口号是不同的,因此它们可以重叠。如果一个应用层协议需要 TCP 和 UDP 连接,那么惯例是使用相同的端口号来处理这两个连接。一个既使用 UDP 又使用 TCP 的协议示例是 DNS。
在 Node 中,你可以使用net模块创建 TCP 套接字,UDP 由dgram模块支持。其他网络协议也得到支持——DNS 是一个很好的例子。
以下几节将探讨 Node 核心模块中包含的应用层协议。
7.1.2. Node 的网络模块
Node 提供了一套网络模块,允许你构建 Web 和其他服务器应用程序。在接下来的几节中,我们将介绍 DNS、TCP、HTTP 和加密。
DNS
域名系统(DNS)是为连接到互联网(或甚至私有网络)的资源提供命名的系统。Node 有一个名为dns的核心模块,用于查找和解析地址。与其他核心模块一样,dns具有异步 API。在这种情况下,实现也是异步的,除了某些由线程池支持的方法。这意味着 Node 中的 DNS 查询速度快,同时还有一个友好且易于学习的 API。
你通常不需要使用这个模块,但我们包括了一些技术,因为这个 API 非常强大,对于网络编程来说可能很有用。大多数应用层协议,包括 HTTP,都接受主机名而不是 IP 地址。
Node 还提供了我们更熟悉的网络协议模块——例如,HTTP。
HTTP
HTTP 对于大多数 Node 开发者来说都很重要。无论你是构建 Web 应用程序还是调用 Web 服务,你很可能以某种方式与 HTTP 交互。Node 的http核心模块建立在net、stream、buffer和events模块之上。它是低级的,但可以用来创建简单的 HTTP 服务器和客户端,而不需要太多的努力。
由于网络对于 Node 开发的重要性,我们包括了几个探索 Node 的http模块的技术。此外,当我们处理 HTTP 时,我们通常需要使用加密——Node 也通过crypto和tls模块支持加密。
加密
你应该了解术语SSL——安全套接字层——因为它是指如何将安全的网页发送到网络浏览器。不过,不仅仅是 HTTP 流量被加密——其他服务,如电子邮件,也会加密消息。加密的 TCP 连接使用 TLS:传输层安全性。Node 的tls模块是使用 OpenSSL 实现的。
这种加密类型被称为公钥加密学。客户端和服务器都必须有私钥。然后服务器可以使其公钥可用,以便客户端可以加密消息。要解密这些消息,需要访问服务器的私钥。
Node 通过允许创建支持多个加密套件的 TCP 服务器来支持 TLS。TCP 服务器本身继承自net.Server——一旦你掌握了 Node 中的 TCP 客户端和服务器,加密连接只是这些原则的扩展。
如果你想使用 Node 部署 Web 应用程序,对 TLS 有扎实的理解是非常重要的。人们越来越关注安全和隐私,不幸的是,SSL/TLS 被设计成这样的方式,程序员的错误可能导致安全漏洞。
在我们继续本章的技术之前,我们想介绍 Node 网络的一个最终方面:Node 是如何提供异步 API 给那些在系统级别有时会阻塞的网络技术的。
7.1.3. 非阻塞网络和线程池
本节深入探讨了 Node 的底层实现,以了解网络是如何在底层工作的。如果你对网络上下文中“异步”的确切含义感到困惑,那么请继续阅读,以了解使 Node 的网络 API 工作的背景信息。
记住,在 Node 中,当 API 接受回调并立即返回时,它们被称为异步。在操作系统级别,I/O 操作也可以是异步的,或者它们可以是同步的,并使用线程包装以表现出异步性。
Node 采用几种技术来提供异步网络 API。主要的是非阻塞系统调用和线程池来包装阻塞系统调用。
在幕后,Node 的大部分网络代码是用 C 和 C++编写的——Node 源代码中的 JavaScript 代码为你提供了对libuv和c-ares提供的功能的异步绑定。
图 7.3 显示了 Apple 的 Instruments 工具记录了一个 Node 程序发起 50 个 HTTP 请求的活动。HTTP 请求是非阻塞的——每个请求都使用在主线程上运行的回调进行。
图 7.3. Node 在发起 HTTP 请求时的线程

对于 HTTP 和其他 TCP 连接,Node 能够通过系统级的非阻塞 API 访问网络。
当编写网络或文件系统代码时,Node 代码看起来是异步的:你传递一个函数给一个方法,该方法将在 I/O 操作达到所需状态时执行该函数。但对于文件操作,底层实现不是异步的:而是使用线程池。
在处理 I/O 操作时,了解非阻塞 I/O、线程池和异步 API 之间的区别对于真正理解 Node 的工作方式非常重要。
对于那些想了解更多关于 libuv 和网络的人来说,免费提供的书籍《libuv 简介》(nikhilm.github.io/uvbook/networking.html#tcp) 中有一节关于网络,涵盖了 TCP、DNS 和 UDP。
接下来是第一组网络技术:TCP 客户端和服务器。
7.2:TCP 客户端和服务器
Node 有一个简单的 API 用于创建 TCP 连接和服务器。大多数最低级别的类和方法都可以在 net 模块中找到。在下一个技术中,你将学习如何创建 TCP 服务器并跟踪连接到它的客户端。酷的地方在于,像 HTTP 这样的高级协议是建立在 TCP API 之上的,所以一旦你掌握了 TCP 客户端和服务器,你就可以真正开始利用 HTTP API 的一些更微妙的功能。
技巧 45:创建 TCP 服务器并跟踪客户端
net 模块构成了 Node 许多网络功能的基础。这个技术演示了如何创建一个 TCP 服务器。
问题
你想要启动自己的 TCP 服务器,绑定到一个端口,并在网络上发送数据。
解决方案
使用 net.createServer 创建服务器,然后调用 server.listen 来将其绑定到端口。要连接到服务器,可以使用命令行工具 telnet 或使用其客户端对应物 net.connect 创建进程内的客户端连接。
讨论
net.createServer 方法返回一个对象,可以用来在给定的 TCP 端口上监听传入的连接。当客户端建立新的连接时,传递给 net.createServer 的回调将运行。这个回调接收一个连接对象,它扩展了 EventEmitter。
服务器对象本身是 net.Server 的一个实例,它只是 net.Socket 类的一个包装器。值得注意的是,net.Socket 是使用双工流实现的——关于流的更多信息,请参阅第五章。
在深入理论之前,让我们看看一个你可以运行并使用 telnet 连接到它的示例。下面的列表显示了一个简单的 TCP 服务器,它接受连接并将数据回显给客户端。
列表 7.1:简单的 TCP 服务器

要尝试这个示例,运行 node server.js 来启动服务器,然后运行 telnet localhost 8000 使用 telnet 连接到它。你可以多次连接以查看 ID 递增。如果你断开连接,应该会打印出包含正确客户端 ID 的消息。
大多数使用 TCP 客户端和服务器的程序都会加载 net 模块
。一旦加载,就可以使用 net.createServer 创建 TCP 服务器,这实际上只是 new net.Server 的一个带有 listener 事件监听器的快捷方式。服务器实例化后,可以使用 server.listen 将其设置为在指定端口上监听连接
。
为了回显客户端发送的数据,使用 pipe
。套接字是流,因此你可以像在第五章中看到的那样使用标准流 API 方法。
在这个例子中,我们通过递增一个“全局”值
来跟踪连接的客户端数量
,该值用于跟踪客户端的数量。连接回调中创建的本地变量 clientId 存储在回调的作用域中,以跟踪每个已连接的客户端。
当客户端连接
或断开连接
时,将显示此值。传递给服务器回调的客户端参数实际上是一个套接字——你可以使用 client.write 向其写入,数据将通过网络发送。
需要注意的重要事项是添加到服务器回调中套接字上的任何事件监听器都将共享相同的范围——它将在回调内部创建任何变量的闭包。这意味着客户端 ID 对每个连接都是唯一的,你也可以存储其他客户端可能需要的值。这形成了客户端-服务器应用程序在 Node 中采用的一种常见模式。
下一个技术在这个例子基础上,通过在同一个进程中添加客户端连接来扩展。
技术编号 46 使用客户端测试 TCP 服务器
Node 使在同一个进程中创建 TCP 服务器和客户端变得轻而易举——这是一种特别适用于测试你的网络程序的方法。在本技术中,你将学习如何创建 TCP 客户端,并使用它们来测试服务器。
问题
你想测试一个 TCP 服务器。
解决方案
使用 net.connect 连接到服务器的端口。
讨论
由于 TCP 和 UDP 端口的工作方式,在同一个进程中创建多个服务器和客户端是完全可能的。例如,Node HTTP 服务器也可以在另一个端口上运行简单的 TCP 服务器,该端口允许远程管理 telnet 连接。
在 技术 45 中,我们演示了一个可以通过为每个客户端分配一个唯一 ID 来跟踪客户端连接的 TCP 服务器。现在让我们编写一个测试来确保它正确工作。
列表 7.2 展示了如何创建到进程内服务器的客户端连接,然后对服务器通过网络发送的数据运行断言。当然,从技术上讲,这并不是在真实网络中运行,因为所有操作都在同一个进程中完成,但它可以很容易地适应这种方式;只需将程序复制到服务器,并在客户端指定其 IP 地址或主机名即可。
列表 7.2. 创建 TCP 客户端以测试服务器


这是一个较长的示例,但它围绕一个相对简单的方法:net.connect。此方法接受一些可选参数来描述远程主机。这里我们只指定了端口号,但第二个参数可以是主机名或 IP 地址——localhost是默认值
。它还接受一个回调,可以在客户端连接后用于向另一端写入数据。请记住,TCP 服务器是全双工的,所以两端都可以接收和发送数据。
在这个示例中,runTest函数将在服务器开始监听后运行一次
。它接受一个预期的客户端 ID 和一个名为done的回调
。当客户端连接、通过订阅data事件
接收一些数据并断开连接时,回调将被触发。
无论客户端何时断开连接,都会触发end事件。我们将done回调绑定到这个事件
。当测试在data回调中完成时,我们调用client.end来手动断开套接字,但是当服务器关闭连接时,也会触发end事件。
data事件是主要测试执行的地方
。预期的消息被传递到assert.equal与传递给事件监听器的事件数据。数据是一个缓冲区,所以toString被调用以确保断言可以工作。一旦测试完成,并且触发了end事件
,传递给runTest的回调将被执行。
错误处理
如果你需要收集由 TCP 连接生成的错误,只需订阅net.connect返回的EventEmitter对象的error事件。如果不这样做,将会抛出异常;这是 Node 的标准行为。
不幸的是,在处理不同网络连接的集合时,这并不容易处理。在这种情况下,更好的技术是使用domain模块。使用domain.create()创建一个新的域将导致错误事件被发送到该域;然后你可以通过订阅域上的error事件在集中式错误处理器中处理它们。
更多关于域的信息,请参阅技术 21。
我们在这里通过在回调中调用一个来使用两个runTest调用。一旦两者都运行,就会检查预期的断言数量
,然后关闭服务器
。
这个示例突出了两个重要的事情:客户端和服务器可以在进程中一起运行,并且 Node TCP 客户端和服务器易于单元测试。如果这个示例中的服务器是一个我们无法控制的远程服务,那么我们可以创建一个“模拟”服务器,专门用于测试我们的客户端代码。这构成了大多数开发者为使用 Node 编写的 Web 应用程序编写测试的基础。
在下一个技术中,我们将通过查看 Nagle 算法及其如何影响网络流量的性能特征来深入了解 TCP 网络。
技巧 47 提高低延迟应用程序
尽管 Node 的 net 模块相对较高级,但它确实提供了一些低级功能访问。这方面的一个例子是对 TCP_NODELAY 标志的控制,它决定了是否使用 Nagle 算法。这项技术解释了 Nagle 算法是什么,何时应该使用它,以及如何为特定套接字关闭它。
问题
你想在实时应用程序中提高连接延迟。
解决方案
使用 socket.setNoDelay() 来启用 TCP_NODELAY。
讨论
有时将事物批量移动比单独移动更有效率。每天有成千上万的产品在全球范围内运输,但它们并不是一个接一个地被携带——相反,它们根据最终目的地被分组在运输集装箱中。TCP 正是以这种方式工作的,而这个特性是由 Nagle 算法实现的。
Nagle 算法指出,当一个连接有尚未被确认的数据时,应该保留小的数据段。这些小数据段将被批量合并成更大的数据段,以便在接收方确认足够的数据后进行传输。
在传输许多小数据包的网络中,通过合并小的输出消息并发送它们一起,可以减少拥塞。但有时,延迟比其他所有因素都重要,因此传输小数据包很重要。
这对于交互式应用程序尤其如此,如 ssh 或 X 窗口系统。在这些应用程序中,应该无延迟地传递小消息,以创建实时反馈的感觉。图 7.4 说明了这个概念。
图 7.4. 当使用 Nagle 算法时,较小的数据包被收集到较大的有效载荷中。

某些类别的 Node 程序从关闭 Nagle 算法中受益。例如,你可能创建了一个 REPL,它在用户输入消息时逐个字符地传输,或者一个传输玩家位置数据的游戏。下面的列表显示了一个禁用 Nagle 算法的程序。
列表 7.3. 关闭 Nagle 算法

要使用此示例,请在终端中运行程序 node nagle.js,然后使用 telnet 8000 连接到它。服务器关闭 Nagle 算法
,然后强制客户端使用字符模式
。字符模式是 Telnet 协议(RFC 854)的一部分,并且会在按下键时导致 Telnet 客户端发送数据包。
接下来,使用 unref
来使程序在没有更多客户端连接时退出。最后,使用 data 事件来捕获客户端发送的字符并将它们打印到服务器的终端
。
这种技术可以作为创建低延迟应用程序的基础,其中数据完整性很重要,因此排除了 UDP。如果你真的想对数据的传输有更多的控制,那么请继续阅读一些使用 UDP 的技术。
7.3. UDP 客户端和服务器
与 TCP 相比,UDP 是一个更简单的协议。这可能意味着你需要做更多的工作:而不是能够依赖数据的发送和接收,你必须适应 UDP 更易变的本性。UDP 适用于查询-响应协议,这就是为什么它被用于域名系统 (DNS)。它也是无状态的——如果你想传输数据,并且你更重视低延迟而不是数据完整性,那么 UDP 是一个好的选择。这听起来可能有些不寻常,但确实有一些应用程序符合这些特征:媒体流协议和在线游戏通常使用 UDP。
如果你想要构建一个视频流媒体服务,你可以通过 TCP 传输视频,但每个数据包都会有大量的开销来确保传输。使用 UDP,数据可能会丢失,但没有简单的发现方法,但在视频方面,你并不关心偶尔的故障——你只想尽可能快地获取数据。事实上,一些视频和图像格式可以承受一定程度的数据丢失:JPEG 格式对损坏的字节具有一定的容错性。
下一个技术将 Node 的文件流与 UDP 结合起来,创建一个简单的服务器,可以用来传输文件。尽管这可能会导致数据丢失,但在你更关心速度的情况下,它可能是有用的。
技巧 48 使用 UDP 传输文件
这种技术实际上是将数据从流发送到 UDP 服务器,而不是创建一个通用的文件传输机制。你可以用它来学习 Node 的数据报 API 的基础知识。
问题
你想使用数据报从客户端向服务器传输数据。
解决方案
使用 dgram 模块创建数据报套接字,然后使用 socket.send 发送数据。
讨论
发送数据报类似于使用 TCP 套接字,但 API 略有不同,数据报有自己的规则,反映了 UDP 数据包的实际结构。要设置服务器,请使用以下代码片段:

这个例子创建了一个将作为服务器
的套接字,并将其绑定到一个端口
。端口可以是任何你想要的,但在 TCP 和 UDP 中,前 1,023 个端口是特权端口。
客户端 API 与 TCP 套接字不同,因为 UDP 是一个 无状态 协议。你必须一次写入一个数据包,并且数据包(数据报)必须相对较小——小于 65,507 字节。数据报的最大大小取决于网络的最大传输单元 (MTU)。64 KB 是上限,但通常不使用,因为大的数据报可能会被网络无声地丢弃。
创建客户端套接字与服务器相同——使用 dgram.createSocket。发送数据报需要有效负载的缓冲区、表示消息在缓冲区中起始位置的偏移量、消息长度、服务器端口、远程 IP 以及一个可选的回调函数,当消息发送后将被触发:
var message = 'Sample message';
socket.send(new Buffer(message), 0, message.length, port, remoteIP);
列表 7.4 将客户端和服务器合并到一个程序中。要运行它,您必须发出两个命令:node udp-client-server.js server 来运行服务器,然后 node udp-client-server.js client remoteIP 来启动客户端。如果本地运行,则可以省略 remoteIP 选项;我们设计这个示例为单个文件,这样您可以轻松地将其复制到另一台计算机以测试通过互联网或本地网络发送数据。
列表 7.4. 一个 UDP 客户端和服务器


当您运行此示例时,它首先检查命令行选项以查看是否需要客户端或服务器
。它还接受客户端的可选参数,以便您可以连接到远程服务器
。
如果指定了客户端,则将通过创建一个新的数据报套接字来创建一个新的客户端
。这涉及到使用来自 fs 模块的读取流,以便我们有数据发送到服务器
——我们使用了 __filename 来使其读取当前文件,但您也可以让它发送任何文件。
在发送任何数据之前,我们需要确保文件已被打开并且准备好读取,因此订阅了 readable 事件
。此事件的回调执行 sendData 函数。这将针对文件的每个块重复调用——文件是使用 inStream.read
逐块读取的,因为如果数据报太大,它们可能会被静默丢弃。使用 socket.send 方法将数据推送到服务器
。读取文件时返回的 message 对象是 Buffer 的实例,并且可以直接传递给 socket.send。
当所有数据都已读取后,最后一个块被设置为 null。调用 socket.unref
方法来使程序在套接字不再需要时退出——在这种情况下,一旦发送了最后一条消息。
数据报包布局和数据报大小
UDP 数据报相对简单。它们由源端口、目标端口、数据报长度、校验和以及有效负载数据组成。长度是数据包的总大小——头部大小加上有效负载的大小。在为 UDP 数据包决定应用程序的缓冲区大小时,您应该记住传递给 socket.send 的长度仅适用于缓冲区(有效负载),并且整个数据包大小必须在网络上的 MTU 之下。数据报的结构如下所示。

UDP 头部为 8 字节,后面跟着可选的有效负载,对于 IPv4 最多为 65,507 字节,对于 IPv6 最多为 65,527 字节。
服务器比客户端简单。它以相同的方式设置套接字
,然后订阅两个事件。第一个事件是message,当接收到数据报时发出
。数据通过使用process.stdout.write写入终端。这比使用console.log更好,因为它不会自动添加新行。
当服务器准备好接受连接时,会发出listening事件
。显示一条消息以表明这一点,这样你知道可以安全地尝试连接客户端。
尽管这是一个简单的例子,但 UDP 与 TCP 的不同之处立即显而易见——你需要注意你发送的消息的大小,并意识到消息可能会丢失。尽管数据报有校验和,但丢失或损坏的数据包不会报告给应用层,这意味着可能发生数据丢失。通常,在确保完整性排在低延迟和吞吐量之后的情况下,最好使用 UDP 发送数据。
在下一个技巧中,你将看到如何通过向客户端发送消息来在此基础上构建示例,实际上是通过 UDP 设置双向通信通道。
技巧 49 UDP 客户端/服务器应用程序
UDP 通常用于查询-响应协议,如 DNS 和 DHCP。这个技巧演示了如何将消息发送回客户端。
问题
你已经创建了一个响应请求的 UDP 服务器,但你想向客户端发送消息。
解决方案
一旦创建了一个服务器并且它已经接收到了消息,就根据传递给message事件的rinfo参数创建一个回客户端的数据报连接 回。可选地,通过组合客户端端口号和 IP 地址创建一个唯一的引用,以发送后续消息。
讨论
聊天服务器是 Node 程序员的新网络编程的典型示例,但这个例子有一个转折——它使用 UDP 而不是 TCP 或 HTTP。
TCP 连接与 UDP 不同,这在 Node 的网络 API 设计中很明显。TCP 连接表示为双向事件的流,因此向发送者回送消息很简单——一旦客户端连接,你就可以在任何时候使用client.write向它写入消息。另一方面,UDP 是 无连接 的——消息是在没有与客户端的活跃连接的情况下接收的。
尽管有一些协议级别的相似之处,使你能够对客户端的消息做出响应,但是。TCP 和 UDP 连接都使用源端口和目的端口。给定一个合适的网络设置,根据这些信息打开回客户端的连接是可能的。在 Node 中,包含在每次message事件中的rinfo对象包含相关细节。图 7.5 显示了使用此方案在两个客户端之间消息的流动。
图 7.5. 尽管 UDP 不是全双工的,但给定两端的端口号,仍然可以在两个方向上创建连接。

列表 7.5 展示了一个客户端-服务器程序,允许客户端通过 UDP 连接到中心服务器并相互发送消息。服务器将每个客户端的详细信息存储在数组中,因此可以唯一地引用每个客户端。通过存储客户端的地址和端口,甚至可以在同一台机器上运行多个客户端——可以在同一台计算机上多次运行此程序。
列表 7.5. 向客户端发送消息


此示例基于 技术 48——您可以用类似的方式运行它。输入 node udp-chat.js server 以启动服务器,然后输入 node udp-chat.js client 以连接客户端。您应该运行多个客户端才能使其工作;否则,消息将无法路由到任何地方。
readline 模块已被用于以友好的方式捕获用户输入
。像您看到的许多其他核心模块一样,这个模块也是基于事件的。每当输入一行文本时,它都会触发 line 事件
。
在用户可以发送消息之前,会发送一个初始的 join 消息
。这只是为了让服务器知道它已经连接——服务器代码使用它来存储对客户端的唯一引用
。
Client 构造函数将 socket.send 包装在一个名为 sendData 的函数中
。这样,每当输入一行文本时,就可以轻松地发送消息。此外,当客户端本身收到消息时,它会在控制台打印出来并创建一个新的提示 
服务器接收到的消息
通过组合端口和远程地址
来创建对客户端的唯一引用。我们从 rinfo 对象中获取所有这些信息,并且可以在同一台机器上运行多个客户端,因为端口将是客户端的端口,而不是服务器监听的端口(这不会改变)。要了解这是如何可能的,请回想一下,UDP 头部包括源端口和目的端口,就像 TCP 一样。
最后,每当看到一条不是控制消息
的消息时,就会遍历每个客户端并发送该消息
。发送消息的客户端不会收到副本。因为我们已经将每个 rinfo 对象的引用存储在 clients 数组中,所以可以向客户端发送消息。
客户端-服务器网络是 HTTP 的基础。尽管 HTTP 使用 TCP 连接,但它与您迄今为止看到的协议类型略有不同:它是无状态的。这意味着您需要不同的模式来模拟它。下一节将详细介绍如何创建 HTTP 客户端和服务器。
7.4. HTTP 客户端和服务器
今天,我们大多数人都在使用 HTTP——无论是生产或消费网络服务,还是构建 Web 应用程序。HTTP 协议是无状态的,建立在 TCP 之上,Node 的 HTTP 模块也是类似地建立在它的 TCP 模块之上。
当然,你可以使用自己用 TCP 构建的协议。毕竟,HTTP 是建立在 TCP 之上的。但由于 Web 浏览器和用于处理基于 Web 服务的工具的普遍存在,HTTP 对于涉及远程系统之间通信的许多问题来说是一个自然的选择。
在下一节中,你将学习如何使用 Node 的核心模块编写一个基本的 HTTP 服务器。
技巧 50:HTTP 服务器
在这个技巧中,你将学习如何使用 Node 的 http 模块创建 HTTP 服务器。尽管这比使用建立在 Node 之上的 Web 框架要费时,但流行的 Web 框架通常在内部使用相同的技巧,并且它们公开的对象是从 Node 的标准类派生出来的。因此,理解底层模块和类对于广泛使用 HTTP 是有用的。
问题
你想要运行 HTTP 服务器并对其进行测试。
解决方案
使用 http.createServer 和 http.createClient。
讨论
http.createServer 方法是创建一个新的 http.Server 对象的快捷方式,该对象从 net.Server 派生出来。HTTP 服务器被扩展以处理 HTTP 协议的各个元素——解析头部、处理响应代码,并在套接字上设置各种事件。Node 的 HTTP 处理代码的主要重点是解析;使用 Joyent 自己的 C 解析库的 C++ 包装器。这个库可以提取头部字段和值、Content-Length、请求方法、响应状态代码等等。
下面的列表显示了一个使用 http 模块的小型“Hello World” Web 服务器。
列表 7.6. 一个简单的 HTTP 服务器

http 模块包含 Node 的客户端和服务器 HTTP 类
。http.createServer 创建一个新的服务器对象并返回它。参数是一个回调函数,该函数接收 req 和 res 对象——分别是请求和响应
。如果你使用过像 Express 和 restify 这样的高级 Node Web 框架,你可能对这些对象很熟悉。
传递给 http.createServer 的监听器回调函数的有趣之处在于,它的行为与传递给 net.createServer 的监听器非常相似。实际上,机制是相同的——我们正在创建 TCP 套接字,但在其上叠加 HTTP。HTTP 协议与 TCP 套接字通信的主要概念区别是状态问题:HTTP 是一种无状态协议。创建和销毁 TCP 套接字 按请求 是完全可接受的,实际上也是典型的。这部分解释了为什么 Node 的底层 HTTP 实现是低级的 C++ 和 C:它需要快速且尽可能少地使用内存。
在 列表 7.6 中,监听器为每个请求运行。在 技术 45 中的 TCP 示例中,服务器在客户端连接期间保持连接打开。因为 HTTP 连接只是 TCP 套接字,我们可以像 列表 7.6 中的套接字一样使用 res 和 req:res.write 将写入套接字 ,并且可以通过
res.writeHead 写入头部信息 ,这是套接字连接和 HTTP API 明显分叉的地方——底层的套接字将在响应写入后立即关闭。
服务器设置完成后,我们可以通过 server.listen 来设置它监听一个端口 。
现在我们已经可以创建服务器了,让我们看看如何创建 HTTP 请求。http.request 方法将创建新的连接 ,并接受一个
options 参数对象和一个回调函数,当建立连接时将运行该回调函数。这意味着我们仍然需要将一个 data 监听器附加到回调函数传递的 response 上,以获取任何发送的数据。
data 回调确保服务器响应具有预期的格式:检查正文内容和状态码 。通过调用
server.unref 来停止服务器监听连接,当最后一个客户端断开连接时,这意味着脚本会干净地退出。这使得查看是否遇到任何错误变得容易。
HTTP 模块的一个小特性是 http.STATUS_CODES 对象。这允许通过查找整数状态码来生成可读的消息:http.STATUS_CODES[302] 将评估为 Moved Temporarily。
现在你已经看到了如何创建 HTTP 服务器,在下一个技术中,我们将探讨状态在 HTTP 客户端中的作用——尽管 HTTP 是无状态协议——通过实现 HTTP 重定向。
技术 51 跟随重定向
Node 的 http 模块提供了一个方便的 API 来处理 HTTP 请求。但它不遵循重定向,由于重定向在网络上非常常见,因此掌握这项技术非常重要。你可以使用一个流行的第三方模块来处理重定向,比如 Mikeal Rogers 的流行 request 模块^([2)),但通过查看如何使用核心模块实现它,你会对 Node 有更深入的了解。
在这个技术中,我们将探讨如何使用简单的 JavaScript 在多个请求之间保持状态。这允许正确地跟随重定向,而不会创建重定向循环或其他问题。
问题
你想要下载页面,并在必要时跟随重定向。
解决方案
一旦理解了协议的基本原理,处理重定向就相当直接。HTTP 标准定义了表示发生重定向的状态码,并且它还指出客户端应检测无限重定向循环。为了满足这些要求,我们将使用一个简单的原型类来保留每个请求的状态,在需要时进行重定向,并检测重定向循环。
讨论
在这个例子中,我们将使用 Node 的核心http模块向一个我们知道将生成重定向的 URL 发起GET请求。为了确定给定的响应是否为重定向,我们需要检查返回的状态码是否以 3 开头。3xx 系列的所有状态码都表示发生了某种类型的重定向。
根据规范,这是我们需要处理的所有状态码的完整集合:
-
300 —多种选择
-
301 —永久移动
-
302 —找到
-
303 —另见
-
304 —未修改
-
305 —另见代理
-
307 —临时重定向
每个这些状态码如何处理取决于应用程序。例如,对于搜索引擎来说,识别返回 301 状态码的响应可能非常重要,因为这表示搜索引擎的 URL 列表应该永久更新。对于这种技术,我们只需要跟随重定向,这意味着只需要一个语句就足以检查请求是否被重定向:if (response.statusCode >= 300 && response.statusCode < 400)。
测试重定向循环更为复杂。请求不能再独立存在——我们需要跟踪多个请求的状态。最容易的方法是使用一个包含实例变量以计算发生重定向次数的类。当计数器达到限制时,将引发错误。图 7.6 显示了如何处理 HTTP 重定向。
图 7.6. 重定向是循环的,请求将一直进行,直到遇到 200 状态码。

在编写任何代码之前,考虑我们需要什么样的 API 非常重要。由于我们已经确定应该使用“类”来管理状态,那么我们模块的用户将需要实例化这个类的实例。Node 的http模块是异步的,我们的代码也应该如此。这意味着为了获取结果,我们必须将一个回调传递给一个方法。
这个回调的签名应该使用与 Node 核心模块相同的格式,其中错误变量是第一个参数。以这种方式设计 API 的优势在于使错误处理变得简单直接。发起 HTTP 请求可能会导致多个错误,因此正确处理它们非常重要。
以下列表将所有这些内容组合起来以成功跟随重定向
列表 7.7. 发起一个跟随重定向的 HTTP GET 请求


运行此代码将显示最后获取的 URL 和请求被重定向的次数。尝试用几个 URL 来查看会发生什么:即使导致 DNS 错误的非存在 URL 也应该导致错误信息打印到stderr。
在加载必要的模块
后,使用Request
构造函数创建一个表示请求生命周期的对象。以这种方式使用类可以将实现细节整洁地封装起来,不让用户看到。同时,Request.prototype.get方法做了大部分工作。它设置了一个标准的 HTTP 请求,或者在需要时使用 HTTPS,然后每当遇到重定向时都会递归地调用自身。请注意,URL 必须被解析
成一个对象,我们用它来创建与 Node 的http模块兼容的options对象。
检查请求协议(HTTP 或 HTTPS),以确保我们使用 Node 的http或https模块中的正确方法。一些服务器被配置为始终将 HTTP 流量重定向到 HTTPS。如果不检查协议,这个方法会反复获取原始 HTTP URL,直到达到maxRedirects——这是一个微不足道的错误,但很容易避免。
一旦收到响应,就会检查statusCode
。只要没有达到maxRedirects,就会增加重定向次数
。这个过程会一直重复,直到不再有 300 范围内的状态,或者遇到的重定向太多。
当最终请求完成(或者如果没有重定向,则是第一个请求)时,将运行用户提供的callback函数。这里使用了标准的 Node API 签名error, result,以保持与 Node 的核心模块的一致性。当达到maxRedirects或通过监听error事件创建 HTTP 请求时,将生成错误。
用户提供的回调函数在最后一个请求完成后运行,允许回调函数访问请求的资源。这是通过在最后一个请求的end事件触发后运行回调函数,并将事件处理程序绑定到当前的Request实例
来处理的。绑定事件处理程序意味着它将能够访问用户可能需要的任何有用的实例变量——包括存储在this.error中的错误。
最后,我们创建一个Request实例
来尝试这个类。如果你愿意,可以用它来访问其他 URL。
这种技术说明了一个重要的观点:状态很重要,尽管 HTTP 在技术上是一个无状态协议。一些配置错误的 Web 应用程序和服务器可以创建重定向循环,这会导致客户端无限期地获取 URL,直到被强制停止。
虽然 列表 7.7 展示了 Node 的 HTTP 和 URL 处理的一些功能,但它并不是一个完整的解决方案。对于更高级的 HTTP API,请查看 Mikeal Rogers 的 Request (github.com/mikeal/request),这是一个广泛使用的简化 Node HTTP API。
在下一个技巧中,我们将剖析一个简单的 HTTP 代理。这扩展了在此处讨论的客户端和服务器技巧,并可以扩展以创建许多有用的应用程序。
技巧 52 HTTP 代理
HTTP 代理的使用频率比你想象的要高——ISP 使用透明代理来提高网络效率,企业系统管理员使用缓存代理来减少带宽,Web 应用程序 DevOps 使用它们来提高其应用程序的性能。这个技巧只是触及了代理的表面——它捕获 HTTP 请求和响应,然后将它们镜像到目标位置。
问题
你想要捕获并重新传输 HTTP 请求。
解决方案
使用 Node 内置的 HTTP 模块来充当简单的 HTTP 代理。
讨论
代理服务器提供了一定程度的重定向,这有助于各种有用的应用程序:缓存、日志和安全相关软件。这个技巧探讨了如何使用核心 http 模块来创建 HTTP 代理。从根本上说,所需的是一个捕获请求的 HTTP 服务器,然后是一个克隆它们的 HTTP 客户端。
http.createServer 和 http.request 方法可以捕获并重新传输请求。我们还需要解释原始请求,以便我们可以安全地复制它——url 核心模块有一个理想的 URL 解析方法,可以帮助我们做到这一点。
下一个列表展示了在 Node 中创建工作代理是多么简单。
列表 7.8. 使用 http 模块创建代理


要使用此示例,你的电脑需要一些配置。找到你的系统互联网选项,然后查找 HTTP 代理。从那里你应该能够将 localhost:8080 作为代理输入。或者,如果可能的话,在浏览器的设置中添加代理。一些浏览器不支持此操作;Google Chrome 将打开系统代理对话框。
图 7.7 展示了如何在 Mac 上配置代理。确保你点击“确定”,然后在主“网络”对话框中应用以保存设置。并且记得完成操作后禁用代理!
图 7.7. 要使用我们创建的 Node 代理,将 localhost:8080 设置为 Web 代理服务器。

一旦你的系统配置好使用代理,就可以在壳中使用 node listings/network/proxy.js 启动 Node 进程。现在当你访问网页时,你应该能看到连续的请求和响应被记录到控制台。
此示例通过首先使用http模块创建一个服务器
来工作。当浏览器发起请求时,将触发回调。我们使用了url.parse(url是另一个核心模块)来分离 URL 的各个部分,以便可以将它们作为参数传递给http.request。解析后的 URL 对象与http.request期望的参数兼容,因此这很方便
。
在请求的回调中,我们可以订阅需要重复回传给浏览器的事件。data事件很有用,因为它允许我们捕获来自服务器的响应,并通过res.write将其传递回客户端
。我们还通过关闭与浏览器的连接来响应服务器连接的结束
。状态码也是基于服务器的响应写回客户端的
。
客户端发送的任何数据也会通过订阅浏览器的data事件代理到远程服务器
。同样,浏览器原始请求也会监视end事件,以便将其反映回代理请求
。
最后,用作代理的 HTTP 服务器被设置为监听 8080 端口
。
此示例创建了一个特殊的服务器,它位于浏览器和浏览器想要与之通信的服务器之间。它可以扩展来做许多有趣的事情。例如,你可以根据远程客户端缓存和压缩图像文件,向移动浏览器发送高度压缩的图像。你甚至可以根据规则删除某些内容;一些广告拦截器和家长控制过滤器就是这样工作的。
我们到目前为止一直在使用 DNS,并没有真正过多地考虑它。DNS 使用 TCP 和 UDP 来支持其基于请求/响应的协议。幸运的是,Node 通过一个简洁的异步 DNS 模块为我们隐藏了这种复杂性。下一节将演示如何使用 Node 的dns模块发起 DNS 请求。
7.5. 发起 DNS 请求
Node 的 DNS 模块位于net模块之外,在dns中。当使用http或net模块连接到远程服务器时,Node 会使用dns.lookup内部查找 IP 地址。
技巧 53 发起 DNS 请求
Node 有多种方法来发起 DNS 请求。在这个技术中,你将学习如何以及为什么应该使用每种方法将域名解析为 IP 地址。
当你查询 DNS 记录时,结果可能包括不同记录类型的答案。DNS 是一个分布式数据库,所以它不仅仅用于解析 IP 地址——一些记录,如TXT,用于在 DNS 本身的基础上构建功能。
表 7.2 包含了每种类型及其相关的dns模块方法的列表。
表 7.2. DNS 记录类型
| 类型 | 方法 | 描述 |
|---|---|---|
| A | dns.resolve | A 记录存储 IP 地址。它可以有一个关联的生存时间(TTL)字段,以指示记录应该更新的频率。 |
| TXT | dns.resolveTxt | 可以由其他服务用于在 DNS 之上构建的附加功能的文本值。 |
| SRV | dns.resolveSrv | 服务记录定义了服务的“位置”数据;这通常包括端口号和主机名。 |
| NS | dns.resolveNs | 用于名称服务器本身。 |
| CNAME | dns.resolveCname | 正常名称记录。这些设置为域名而不是 IP 地址。 |
问题
你希望快速查找单个或多个域名。
解决方案
dns.lookup 方法可以用来查找 IPv4 或 IPv6 地址。当查找多个地址时,使用 dns.resolve 可能更快。
讨论
根据 Node 的文档,dns.lookup 是由线程池支持的,而 dns.resolve 使用的是 c-ares 库,这要快一些。dns.lookup API 稍微友好一些——它使用 getaddrinfo,这与系统上的其他程序更一致。确实,Socket.prototype.connect 方法,以及任何从 net 模块中的对象继承的 Node 的核心模块,都使用 dns.lookup 以保持一致性:

这个例子加载了 dns 模块
,然后使用 dns.lookup 查找 IP 地址
。API 是异步的,因此我们必须传递一个回调来接收 IP 地址以及查找地址时引发的任何错误。请注意,必须提供域名,而不是 URL——这里不要包含 http://。
如果一切运行正确,你应该会看到 68.180.151.75 作为 IP 地址打印出来。相反,如果你在离线时运行前面的示例,那么应该会打印出一个相当有趣的错误:

错误对象包括一个标准错误代码
以及引发错误的系统调用
。你可以在程序中使用错误代码来检测这种错误何时被引发,并适当地处理它。同时,syscall 属性对我们程序员来说很有用:它表明错误是由操作系统提供的、位于我们 Node 代码之外的服务生成的。
现在比较一下使用 dns.resolve 的版本:

API 看起来与前面的示例相似,除了 dns.resolve
。你仍然会看到一个包含 ECONNREFUSED 的错误对象,如果 DNS 服务器无法到达,但这次结果不同:我们收到一个地址数组而不是单个结果。在这个例子中,你应该看到 [ '68.180.151.75' ],但一些服务器可能返回多个地址。
Node 的 dns 模块灵活、友好且快速。它可以从不频繁的单个请求扩展到批量请求。
Node 网络套件的最后一部分需要查看的是可能最难学习,但矛盾的是,正确实现这一点是最重要的:加密。下一节介绍了使用 tls 和 https 模块的 SSL/TLS。
7.6. 加密
Node 的加密模块tls使用 OpenSSL 传输层安全性/安全套接字层(TLS/SSL)。这是一个公钥系统,其中每个客户端和服务器都有自己的私钥。服务器将其公钥公开,以便客户端可以以只有该服务器才能解密的方式加密后续通信。
tls模块被用作https模块的基础——这允许 HTTP 服务器和客户端通过 TLS/SSL 进行通信。不幸的是,TLS/SSL 是一个充满潜在陷阱的世界。Node 根据链接的 OpenSSL 版本可能支持不同的加密算法。你可以在使用tls.createServer创建服务器时指定你想要使用的加密算法,但我们建议除非你有这方面的专业知识,否则使用默认设置。
在下面的技术中,你将学习如何启动一个使用 SSL 和自签名证书的 TCP 服务器。之后,我们以一个技术结束本章,展示在 Node 中加密 Web 服务器通信是如何工作的。
技术编号 54:使用加密的 TCP 服务器
TLS 可以用来加密使用net.createServer创建的服务器。这个技术演示了如何通过首先创建必要的证书,然后启动客户端和服务器来实现这一点。
问题
你想要加密通过 TCP 连接发送和接收的通信。
解决方案
使用tls模块启动客户端和服务器。使用 OpenSSL 设置所需的证书文件。
讨论
在处理加密时,无论是 Web 服务器、邮件服务器还是任何基于 TCP 的协议,掌握的主要事情是如何正确设置密钥和证书文件。公钥加密依赖于公私钥对——客户端和服务器都需要一对。但还需要一个额外的文件:证书颁发机构(CA)的公钥。
在这个技术中,我们的目标是创建一个 TLS 客户端和服务器,在 TLS 握手后都报告authorized。当双方都验证了对方的身份时,会报告这种状态。当与 Web 服务器证书一起工作时,你的 CA 将是那些商业上分发证书的知名组织。但为了测试目的,你可以成为自己的 CA 并签名证书。这对于在不需要公开可验证证书的自己的系统之间进行安全通信也是很有用的。
这意味着在你可以运行任何 Node 示例之前,你需要证书。这需要 OpenSSL 命令行工具。如果你没有它们,你应该能够通过操作系统的包管理器或通过访问www.openssl.org来安装它们。
openssl工具将命令作为第一个参数,然后是后续的选项。例如,openssl req用于 X.509 证书签名请求(CSR)管理。要颁发由你控制的机构签名的证书,你需要执行以下命令:
-
genrsa—生成 RSA 证书;这是我们私钥。 -
req—创建 CSR。 -
x509—使用 CSR 签名私钥以生成公钥。
当过程被这样分解时,理解起来相当容易:证书需要一个权威机构并必须被签名,我们需要一个公钥和一个私钥。当创建一个针对商业证书颁发机构签名的公钥和私钥时,这个过程是相似的,如果你想要购买用于公共 Web 服务器的证书,你会这样做。
创建公钥和私钥的完整命令列表如下:

在创建私钥
之后,你将创建一个 CSR。当提示“通用名称”
时,输入你的计算机主机名,你可以在 Unix 系统的终端中通过输入 hostname 来找到它。这很重要,因为当你的代码发送或接收证书时,它将检查名称值与传递给 tls.connect 方法的 servername 属性是否匹配。
下一个列表读取服务器的密钥并使用 tls.createServer 启动一个服务器。
列表 7.9. 使用 TLS 加密的 TCP 服务器

列表 7.9 中的网络代码与 net.createServer 方法非常相似——这是因为 tls 模块继承自它。其余的代码用于管理证书,不幸的是,这个过程留给了我们处理,并且经常是程序员错误的来源,这可能会损害安全性。首先,我们加载私钥
和公钥
,并将它们传递给 tls.createServer。我们还加载客户端的公钥作为证书颁发机构
——当使用商业获得的证书时,这一步通常是不必要的。
当客户端连接时,我们希望向他们发送一些数据,但在这个示例中,我们真正想要看到的是客户端是否被授权
。客户端授权是通过设置 requestCert 选项
来强制执行的。
这个服务器可以用 node tls.js 运行——但是缺少了一些东西:一个客户端!下一个列表包含一个可以连接到这个服务器的客户端。
列表 7.10. 使用 TLS 的 TCP 客户端

客户端与服务器类似:加载私钥
和公钥
,这次服务器被当作 CA
处理。服务器的名称通过使用 os.hostname
设置为与 CSR 中的通用名称相同的值——如果你将其设置为其他值,你可以手动输入名称。之后客户端连接,显示它是否能够授权证书,然后读取服务器发送的数据并将其管道传输到标准输出
。
测试 SSL/TLS
在测试安全证书时,很难判断问题是否出在你的代码中或其他地方。一种解决方法是使用 openssl 命令行工具来模拟客户端或服务器。以下命令将启动一个连接到给定证书文件的客户端:
openssl s_client -connect 127.0.0.1:8000 \
-CAfile ./server-cert.pem
openssl 工具将显示有关连接的大量额外信息。当我们编写本技巧中的示例时,我们使用它来确定我们生成的证书的通用名称值是错误的。
当你调用 tls.createServer 时,会实例化一个 tls.Server 对象。这个构造函数调用 net.Server——每个网络模块之间有一个清晰的继承链。这意味着 TLS 服务器与 net.Server 发射的事件是相同的。
在下一个技巧中,你将看到如何使用 HTTPS,以及这与 tls 和 net 模块的关系。
技巧 55 加密 Web 服务器和客户端
虽然可以在 Apache 和 nginx 等其他 Web 服务器后面托管 Node 应用程序,但有时你将想要运行自己的 HTTPS 服务器。这个技巧介绍了 https 模块,并展示了它与 tls 模块的关系。
问题
你想要运行一个支持 SSL/TLS 的服务器。
解决方案
使用 https 模块和 https.createServer。
讨论
要运行本技巧中的示例,你需要遵循创建合适的自签名证书的步骤,如技巧 54 中所述。一旦你设置了公钥和私钥,你就可以运行示例了。
以下列表显示了 HTTPS 服务器。
列表 7.11. 使用 TLS 加密的简单 HTTP 服务器

列表 7.11 中的服务器基本上与技巧 54 中的相同。再次强调,私钥
和公钥
被加载并传递给 https.createServer。
当浏览器请求页面时,我们检查 req.socket.authorized 属性以查看请求是否被授权。此状态将返回给浏览器。如果你想用浏览器尝试此操作,确保你在地址栏中输入 https://;否则它将不起作用。你会看到一个警告消息,因为浏览器将无法验证服务器的证书——这是正常的;因为你创建了服务器,所以你知道发生了什么。服务器将响应说你是 未授权的,因为它也无法授权你。
要创建一个可以连接到此服务器的客户端,请遵循下面的代码。
列表 7.12. HTTPS 客户端示例

此示例设置了客户端的私钥
和公钥
,这是浏览器在发起安全请求时透明地执行的操作。它还将服务器设置为证书颁发机构
,这通常是不需要的。用于 HTTP 请求的主机名是机器的当前主机名
。
一旦完成所有这些设置,就可以发起 HTTPS 请求。这是通过 https.request 完成的
。API 与 http 模块相同。在这个例子中,服务器将确保 SSL/TLS 授权程序是有效的,因此服务器将返回文本以指示连接是否完全授权。
在实际的 HTTPS 代码中,你可能不会创建自己的 CA。如果你有希望使用 HTTPS 进行通信的内部系统——可能是用于测试或通过互联网进行 API 请求——这可能会很有用。当你对公共网络服务器发起 HTTPS 请求时,Node 将能够为你验证服务器的证书,因此你不需要设置 key、cert 和 ca 选项。
https 模块还有一些其他特性——有一个 https.get 便捷方法,可以更轻松地发起 GET 请求。否则,这就结束了我们在 Node 中关于加密的技术集合。
安全对
在转向其他领域之前,还有一个美味的领域等待探索:SecurePair。这是 tls 模块中的一个类,可以用来创建一个安全的数据流对:一个读取和写入加密数据,另一个读取和写入明文。这可能会让你能够将任何内容流式传输到加密输出。
有一个便捷方法:tls.createSecurePair。当 SecurePair 建立安全连接时,它将触发一个 secure 事件,但你仍然需要检查 cleartext.authorized 以确保证书已被适当授权。
7.7. 概述
本章内容较多,但这是因为 Node 的网络编程很重要。Node 建立在优秀的网络编程基础上;缓冲区、流和异步 I/O 都有助于构建一个非常适合编写下一代面向网络程序的环境。
通过本章,你应该能够理解 Node 如何融入更广泛的网络软件世界。无论你是开发 Unix 守护进程、基于 Windows 的游戏服务器,还是下一个大型网络应用,你现在应该知道从哪里开始了。
众所周知,网络和加密密切相关。使用 Node 的 tls 和 https 模块,你应该能够编写可以与其他系统通信的网络客户端和服务器,而不必担心窃听者。
下一章是关于 Node 核心模块 child_process 的最后一章,它探讨了与其他命令行程序交互的技术。
第八章. 子进程:将外部应用程序与 Node 集成
本章涵盖
-
执行外部应用程序
-
分离子进程
-
Node 进程之间的进程间通信
-
使 Node 程序可执行
-
创建作业池
-
同步子进程
没有一个平台是孤岛。虽然用 JavaScript 编写一切很有趣,但我们可能会错过其他平台上已经存在的有价值的应用程序。以 GraphicsMagick 为例(www.graphicsmagick.org/):一个功能齐全的图像处理工具,非常适合调整刚刚上传的巨大个人资料照片。或者以 wkhtmltopdf(wkhtmltopdf.org/)为例,一个无头 Webkit PDF 生成器,非常适合将 HTML 报告转换为 PDF 下载。在 Node 中,child_process 模块允许我们执行这些应用程序和其他应用程序(包括 Node 应用程序),以便与我们的程序一起使用。幸运的是,我们不必重新发明轮子。
child_process 模块提供了四种执行外部应用程序的方法。所有方法都是异步的。正确的方法将取决于你的需求,如图 8.1 所示。
图 8.1. 选择正确的方法

-
execFile—执行外部应用程序,给定一组参数,并在进程退出后回调带有缓冲输出的结果。 -
spawn—给定一组参数执行外部应用程序,并提供 I/O 流式接口和进程退出时的事件接口。 -
exec—在 shell 中执行一个或多个命令,并在进程退出后回调带有缓冲输出的结果。 -
fork—以独立进程执行 Node 模块,给定一组参数,提供类似于spawn的流式和事件接口,并在父进程和子进程之间设置一个进程间通信(IPC)通道。
在本章中,我们将深入了解如何充分利用这些方法,给出实际示例,说明你想要在每个地方使用哪种方法。稍后,我们将探讨一些其他技术,用于与子进程一起工作:分离进程、进程间通信、文件描述符和池化。
8.1. 执行外部应用程序
在本节中,我们将探讨所有可以异步与外部程序工作的方式。
技巧 56 执行外部应用程序
如果能够在用户上传的图片上运行一些图像处理(使用 ImageMagick),或者使用 xmllint 验证 XML 文件,那岂不是很好?Node 使得执行外部应用程序变得简单。
问题
你想要执行一个外部应用程序并获取输出。
解决方案
使用 execFile(见图 8.2)。
图 8.2. execFile 方法缓冲结果并提供回调接口。

讨论
如果你想要运行一个外部应用程序并获取结果,使用 execFile 会使其变得简单直接。它会为你缓冲输出并提供回调中的结果和任何错误。假设我们想要运行参数为 hello world 的 echo 程序。使用 execFile,我们会这样做:

Node 是如何知道在哪里找到外部应用程序的?为了回答这个问题,我们需要查看底层操作系统中路径的工作方式。
8.1.1. 路径和 PATH 环境变量
Windows/UNIX 有一个 PATH 环境变量(envvar: en.wikipedia.org/wiki/PATH_(variable))。PATH 包含了可执行程序存在的目录列表。如果一个程序存在于这些目录之一中,它可以不通过绝对或相对路径找到应用程序。
Node 在幕后使用 execvp 搜索应用程序,当没有提供绝对或相对位置时。我们可以在我们之前的例子中看到这一点,因为像 echo 这样的常见系统应用程序的目录通常已经存在于 PATH 中。
如果应用程序所在的目录不在 PATH 中,你需要像在命令行中一样明确地提供位置:
cp.execFile('./app-in-this-directory' ...
cp.execFile('/absolute/path/to/app' ...
cp.execFile('../relative/path/to/app' ...
要查看 PATH 中列出的目录,你可以在 Node REPL 中运行一个简单的单行命令:
$ node
> console.log(process.env.PATH.split(':').join('\n'))
/usr/local/bin
/usr/bin/bin
...
如果你想要避免将不在 PATH 中的外部应用程序的位置包含在内,一个选项是在你的 Node 应用程序内部将任何新的目录添加到 PATH 中。只需在 execFile 调用之前添加这一行:
process.env.PATH += ':/a/new/path/to/executables';
现在,任何在该新目录中的应用程序都可以不提供 execFile 的路径而访问。
8.1.2. 执行外部应用程序时的错误
如果你的外部应用程序不存在,你会得到一个 ENOENT 错误。这通常是由于应用程序名称或路径中的拼写错误导致的,结果是 Node 找不到该应用程序,如 图 8.3 所示。
图 8.3. 常见的子进程错误

如果外部应用程序确实存在但 Node 无法访问它(通常是由于权限不足),你会得到一个 EACCES 或 EPERM 错误。这通常可以通过以具有足够权限的用户运行你的 Node 程序或更改外部应用程序的权限以允许访问来缓解。
如果外部应用程序有一个非零的退出状态 (mng.bz/MLXP),你也会得到一个错误,这用于指示应用程序无法执行它被分配的任务(在 UNIX 和 Windows 上)。Node 将提供退出状态作为错误对象的一部分,并且还会提供写入到 stdout 或 stderr 的任何数据:

当你只想执行一个应用程序并获取输出(或丢弃它)时,execFile 是很棒的,例如,如果你想使用 ImageMagick 运行图像处理命令,并且只关心它是否成功。但是,如果一个应用程序有大量的输出或者你想对返回的数据进行更多的实时分析,使用流是一个更好的方法。
技巧 57:流和外部应用程序
想象一个使用外部应用程序输出的 Web 应用程序。当数据被提供时,你可以同时将其推送到客户端。流允许你在数据输出时从子进程中提取数据,而不是在数据缓冲后提供。如果你预计外部应用程序将输出大量数据,这将很有用。为什么?缓冲大量数据可能会占用大量内存。此外,这允许在数据可用时消费数据,从而提高响应速度。
问题
你想执行一个外部应用程序并流式传输其输出。
解决方案
使用 spawn(见图 8.4)。
图 8.4. spawn 方法返回一个用于 I/O 的流接口。

讨论
spawn 方法具有与 execFile 类似的函数签名:
cp.execFile('echo', ['hello', 'world'], ...);
cp.spawn('echo', ['hello', 'world'], ...);
应用程序是第一个参数,而应用程序的参数/标志数组是第二个参数。但是,spawn 不会像提供已缓冲输出的回调那样接收参数,而是依赖于流:

由于 spawn 是基于流的,因此它非常适合处理大量输出或在读取数据时与数据交互。流的所有其他优点也适用。例如,child.stdin 是一个 Writeable 流,因此你可以将其连接到任何 Readable 流以获取数据。对于 child.stdout 和 child.stderr,它们是 Readable 流,可以连接到任何 Writeable 流。
API 对称性
ChildProcess API (child.stdin, child.stdout, child.stderr) 与父 process 流 (process.stdin, process.stdout, process.stderr) 具有很好的对称性。
8.1.3. 将外部应用程序连接起来
UNIX 精神的大部分内容是构建只做一件事并且做得很好的应用程序,然后通过一个公共接口(即纯文本)在这些应用程序之间进行通信。
让我们编写一个 Node 程序来举例说明这一点,通过使用 spawn 将三个处理文本流的简单应用程序组合在一起。cat 应用程序将读取文件并输出其内容。sort 应用程序将文件作为输入并按顺序提供输出。uniq 应用程序将排序后的文件作为输入,并输出没有重复行的排序文件。这如图 8.5 所示。
图 8.5. 使用 spawn 将外部应用程序连接起来

让我们看看如何使用 spawn 和流来实现这一点:

使用 spawn 的流接口允许无缝地与 Node 中的任何流对象一起工作,包括将外部应用程序连接起来。但有时我们需要底层 shell 的功能来执行外部应用程序的强大组合。为此,我们可以使用 exec。
应用你所学的
您能想到一种避免使用 cat 程序的方法吗?基于您在第六章中学习的 fs 模块和流?
技巧 58:在 shell 中执行命令
Shell 编程是构建实用脚本或命令行应用程序的常见方式。您可以使用 Bash 或 Python 脚本,但使用 Node,您可以使用 JavaScript。虽然您可以使用 execFile 或 spawn 手动执行子 shell,但 Node 提供了一个方便的、跨平台的方法供您使用。
问题
您需要使用底层 shell 功能(如管道、重定向、文件块)来执行命令并获取输出。
解决方案
使用 exec(见图 8.6)。
图 8.6. exec 方法在子 shell 中运行我们的命令。

讨论
如果您需要在 shell 中执行命令,可以使用 exec。exec 方法使用 /bin/sh 或 cmd.exe(在 Windows 上)来运行命令。在 shell 中运行命令意味着您可以访问您特定 shell 提供的所有功能(如管道、重定向和后台运行)。
单个命令参数
与 execFile 和 spawn 不同,exec 方法没有单独的参数/标志参数,因为在 shell 上可以运行多个命令。
例如,让我们将我们在上一技巧中使用的相同三个应用程序连接起来,生成一个排序后的唯一名称列表。但这次,我们将使用常见的 UNIX shell 功能,而不是流:

关于 shell
UNIX 用户应记住,Node 使用映射到 /bin/sh 的任何内容进行执行。这通常在大多数现代操作系统上将是 Bash,但您可以选择将其重新映射到您喜欢的另一个 shell。需要管道功能的 Windows 用户可以使用流和 spawn,如技巧 57 中所述。
8.1.4. 安全性和 shell 命令执行
能够访问 shell 非常强大且方便,但应谨慎使用,尤其是在处理用户输入时。
假设我们正在使用 xmllint (xmlsoft.org/xmllint.html)来解析和检测用户上传的 XML 文件中的错误,其中用户提供了一个模式进行验证:
cp.exec('xmllint --schema '+req.query.schema+' the.xml');
如果用户提供了“site.com/schema.xsd”,它将被替换,并运行以下命令:
xmllint --schema http://site.com/schema.xsd the.xml
但由于参数包含用户输入,它很容易成为命令(或 shell)注入攻击的受害者(golemtechnologies.com/articles/shell-injection)——例如,恶意用户提供了“; rm -rf / ;”,导致以下注释运行(请勿在您的终端中运行此命令!):
xmllint --schema ; rm -rf / ; the.xml
如果您还没有猜到,这意味着“启动新命令(;),强制递归地删除文件系统根目录下的所有文件/目录(rm -rf /),并在其后跟有内容时结束命令(;)”。
换句话说,这种注入可能会删除 Node 进程在整个操作系统中具有权限访问的所有文件!这只是可以运行的命令之一。任何你的进程用户可以访问的(文件、命令等)都可以被利用。
如果你需要运行一个应用程序且不需要 shell 功能,使用execFile更安全(并且稍微快一点):
cp.execFile('xmllint', ['--schema', req.query.schema, 'the.xml']);
在这里,由于不是在 shell 中运行,并且外部应用程序可能不理解该参数并会引发错误,因此恶意注入攻击会失败。
技巧 59 分离子进程
Node 可以用来启动外部应用程序,然后允许它们独立运行。例如,假设你有一个在 Node 中的管理型 Web 应用程序,它允许你启动一个与云服务提供商的长运行同步进程。如果该 Node 应用程序崩溃,你的同步进程将被终止。为了避免这种情况,你需要分离外部应用程序,这样它就不会受到影响。
问题
你有一个需要 Node 启动但随后能够退出而子进程仍然运行的长运行外部应用程序。
解决方案
分离一个派生的子进程(见图 8.7)。
图 8.7. 分离的子进程独立于 Node 进程存在

讨论
通常,任何子进程都会在父 Node 进程终止时被终止。子进程被称为附加到父进程。但是spawn方法包括将子进程分离并提升为进程组领导者的能力。在这种情况下,如果父进程被终止,子进程将继续运行直到完成。
当你想要 Node 设置长运行外部进程的执行,而你又不希望 Node 在启动后监视它时,这种场景很有用。
这是detached选项,可以作为spawn的第三个选项参数进行配置:
var child = cp.spawn('./longrun', [], { detached: true });
在这个例子中,longrun将被提升为进程组领导者。如果你运行这个 Node 程序并强制终止它(Ctrl-C),longrun将继续执行直到完成。
如果你没有强制终止,你会注意到父进程会一直存活,直到子进程完成。这是因为子进程的 I/O 连接到了父进程。为了断开 I/O,你必须配置stdio选项。
8.1.5. 在子进程和父进程之间处理 I/O
stdio选项定义了子进程的 I/O 将被重定向到何处。它接受一个数组或一个字符串作为值。字符串值只是简写,将扩展为常见的数组配置。
该数组结构使得索引对应于子进程中的文件描述符,而值指示特定文件描述符(FD)的 I/O 应重定向到何处。
文件描述符是什么?
如果您对文件描述符感到困惑,请查看第六章中的技术 40 以获取介绍。
默认情况下,stdio配置如下:
stdio: 'pipe'
这是对以下数组值的简写:
stdio: [ 'pipe', 'pipe', 'pipe' ]
这意味着文件描述符 0-2 将在ChildProcess对象上作为流(child.stdio[0]、child.stdio[1]、child.stdio[2])可用。但由于 FD 0-2 通常指的是stdin、stdout和stderr,它们也作为现在熟悉的child.stdin、child.stdout和child.stderr流提供。
pipe值连接父进程和子进程,因为这些流保持打开状态,等待写入或读取数据。但为了使用这种技术,我们希望断开这两个进程的连接,以便退出 Node 进程。一种暴力方法是简单地销毁创建的所有流:
child.stdin.destroy();
child.stdout.destroy();
child.stderr.destroy();
虽然这会起作用,但鉴于我们不打算使用它们,最好从一开始就不创建流。相反,如果我们想将 I/O 导向其他地方,可以分配一个文件描述符,或者使用ignore完全丢弃它。
让我们看看使用这两个选项的解决方案。我们想要ignore文件描述符 0(stdin),因为我们不会向子进程提供任何输入。但让我们捕获文件描述符 1 和 2(stdout、stderr)的任何输出,以防我们稍后需要进行一些调试。以下是我们可以如何实现这一点:

这将断开子进程和父进程之间的 I/O。如果我们运行此应用程序,子进程的输出将最终出现在日志文件中。
8.1.6. 引用计数和子进程
我们几乎完成了。子进程将继续存在,因为它已断开,并且 I/O 已从父进程断开。但父进程仍然保留对子进程的内部引用,并且不会退出,直到子进程完成并且引用被移除。
您可以使用child.unref()方法告诉 Node 不要将此子进程引用包含在其计数中。以下完整的应用程序现在将在启动子进程后退出:

为了回顾,断开进程需要三件事:
-
detached选项必须设置为true,以便子进程成为自己的进程领导者。 -
stdio选项必须配置为使父进程和子进程断开连接。 -
在父进程中使用
child.unref()必须切断对子进程的引用。
8.2. 执行 Node 程序
可以使用任何先前的技术来执行 Node 应用程序。然而,在接下来的技术中,我们将专注于充分利用 Node 子进程。
技术编号 60:执行 Node 程序
当在 Node 中编写 shell 脚本、实用程序或其他命令行应用程序时,将其制作成可执行文件对于方便使用和可移植性非常有用。如果您将命令行应用程序发布到 npm,这也很有用。
问题
您想将 Node 程序制作成可执行脚本。
解决方案
将文件设置为可由您的底层平台执行。
讨论
Node 程序可以通过使用 node 可执行文件以任何我们已经描述的方式作为子进程运行:
var cp = require('child_process');
cp.execFile('node', ['myapp.js', 'myarg1', 'myarg2' ], ...
但在许多情况下,拥有独立的可执行文件更方便,您可以像这样使用您的应用程序:
myapp myarg1 myarg2
创建可执行文件的过程将根据您是在 Windows 还是 UNIX 上而有所不同。
Windows 上的可执行文件
假设我们有一个简单的单行 hello.js 程序,该程序会回显传递的第一个参数:
console.log('hello', process.argv[2]);
要运行此程序,我们输入
$ node hello.js marty
hello marty
要创建 Windows 可执行文件,我们可以创建一个简单的批处理脚本,调用 Node 程序。为了保持一致性,让我们称它为 hello.bat:

现在,我们可以通过简单地运行以下命令来执行我们的 hello.js 程序:
$ hello tom
hello tom
作为子进程运行需要 .bat 扩展名:
var cp = require('child_process');
cp.execFile('hello.bat', ['billy'], function (err, stdout) {
console.log(stdout); // hello billy
});
UNIX 上的可执行文件
将 Node 程序转换为大多数 UNIX 系统上的可执行脚本,我们不需要像 Windows 一样单独的批处理文件;我们只需通过在文件顶部添加以下内容来修改 hello.js 本身:

然后为了真正使文件可执行,我们运行以下命令:
$ chmod +x hello.js
我们可以像这样运行命令:
$ ./hello.js jim
hello jim
该文件也可以重命名,使其看起来更像是一个独立程序:
$ mv hello.js hello
$ ./hello jane
hello jane
作为子进程执行此程序将与其命令行对应物看起来相同:
var cp = require('child_process');
cp.execFile('./hello', ['bono'], function (err, stdout) {
console.log(stdout); // hello bono
});
在 npm 中发布可执行文件
对于包含可执行文件的包的发布,使用 UNIX 习惯,npm 将为 Windows 进行适当的调整。
技巧 61:Node 模块分叉
Web Workers (mng.bz/UG63) 为浏览器和 JavaScript 提供了一种优雅的方式来在主线程之外运行计算密集型任务,并在父进程和工作者之间内置通信流。这消除了将计算分解成片段以不破坏用户体验的痛苦工作。在 Node 中,我们有相同的概念,但 API 略有不同,使用 fork。这有助于我们将任何繁重的工作分离到单独的进程中,保持事件循环平稳运行。
问题
您想管理独立的 Node 进程。
解决方案
使用 fork(见图 8.8)。
图 8.8。fork 命令在单独的进程中运行 Node 模块,并设置通信通道。

讨论
有时候拥有独立的 Node 进程很有用。其中一种情况是计算。由于 Node 是单线程的,计算任务会直接影响整个进程的性能。这可能适用于某些工作,但当涉及到网络编程时,它将严重影响性能,因为当进程被占用时,请求无法得到服务。在分叉进程中运行这些类型的任务可以使主应用程序保持响应。分叉的另一个用途是共享文件描述符,其中子进程可以接受父进程接收到的传入连接。
Node 提供了一种很好的方式来在其他的 Node 程序之间进行通信。在底层,它设置了以下 stdio 配置:
stdio: [ 0, 1, 2, 'ipc' ]
这意味着默认情况下,所有输出和输入都是直接从父进程继承的;没有 child.stdin、child.stdout 或 child.stderr:
var cp = require('child_process');
var child = cp.fork('./myChild');
如果你想要提供一个类似于 spawn 默认的 I/O 配置(这意味着你得到 child.stdin 等),你可以使用 silent 选项:
var cp = require('child_process');
var child = cp.fork('./myChild', { silent: true });
进程间通信的内部机制
尽管存在许多机制来提供进程间通信(IPC;见 mng.bz/LGKD),Node IPC 通道将使用 UNIX 域套接字 (mng.bz/1189) 或 Windows 命名管道 (mng.bz/262Q)。
与分叉的 Node 模块通信
fork 方法打开了一个 IPC 通道,允许 Node 进程之间进行消息传递。在子进程中,它暴露了 process.on('message') 和 process.send() 作为接收和发送消息的机制。在父进程中,它提供了 child.on('message') 和 child.send()。
让我们创建一个简单的回声模块,它将发送从父进程接收到的任何消息:

应用程序现在可以使用 fork 来消费此模块:

在进程之间发送数据保持类型信息,这意味着你可以通过电线发送任何有效的 JSON 值,并且它保留了类型:
child.send(230);
child.send('a string');
child.send(true);
child.send(null);
child.send({ an: 'object' });
从分叉的 Node 模块断开连接
由于我们在父进程和子进程之间打开了一个 IPC 通道,两者都会保持活动状态,直到子进程断开连接(或以其他方式退出)。如果你需要断开 IPC 通道,你可以从父进程中显式地做到这一点:
child.disconnect();
技巧 62 运行工作
当你需要运行 常规 计算工作,按需分叉进程将很快消耗你的 CPU 资源。更好的做法是保持一个可用的 Node 进程池,随时准备工作。这项技术将探讨这一点。
问题
你有一些常规工作不想在主事件循环上运行。
解决方案
使用 fork 并管理一个工作池。
讨论
我们可以使用 fork 内置的 IPC 通道来创建处理计算密集型任务(或工作)的模式。它基于我们之前的技巧,但增加了一个重要的约束:当父进程向子进程发送任务时,它期望收到确切的一个结果。以下是这在父进程中的工作方式:

但接收结果只是可能的结果之一。为了使 doWork 函数具有弹性,我们将考虑
-
子进程由于任何原因退出
-
意外的错误(如关闭 IPC 通道或无法分叉)
在代码中处理这些将涉及更多的监听器:

这是一个好的开始,但我们面临的风险是在工作进程完成工作但后来退出或发生错误的情况下多次调用我们的回调。让我们添加一些状态并稍微整理一下:

到目前为止,我们只看了父进程。子工作进程接收一个工作,并在完成时向父进程发送一条确切的消息:
process.on('message', function (job) {
// do work
process.send(result);
});
8.2.1. 工作池
目前,我们的 doWork 函数每次需要执行一些工作时会启动一个新的子进程。这并不是免费的,正如 Node 文档所述:
这些子节点仍然是全新的 V8 实例。假设每个新节点至少需要 30ms 的启动时间和 10MB 的内存。也就是说,你不能创建成千上万的它们。
一种高效地解决这个问题的方式不是每次你想执行一些计算密集型任务时都启动一个新的进程,而是维护一个可以处理负载的长运行进程池。
让我们扩展 doWork 函数,创建一个处理工作池的模块。以下是我们将添加的一些额外约束:
-
只启动与机器上的 CPU 数量相等的子工作进程。
-
确保新的工作能够获得一个可用的工作进程,而不是一个当前正在处理中的进程。
-
当没有可用的工作进程时,维护一个任务队列,以便在进程可用时执行。
-
按需进行进程的
fork。
让我们看看实现这个功能的代码:

应用所学知识
根据池的需求,可能还有其他约束,例如在失败时重试工作或终止长运行的工作。你将如何使用前面的示例来实现重试或超时?
8.2.2. 使用 pooler 模块
假设我们想要根据用户对服务器的请求运行一个计算密集型任务。首先,让我们扩展我们的子工作进程来模拟一个密集型任务:

现在我们有一个要运行的样本子进程,让我们将所有这些与一个简单的应用程序结合起来,该应用程序使用 pooler 模块和工作模块:

池化可以节省启动和销毁子进程的开销。它利用了 fork 中内置的通信通道,使得 Node 能够有效地管理一组子进程中的工作。
进一步了解
要进一步研究工作池,请查看第三方 compute-cluster 模块(github.com/lloyd/node-compute-cluster)。
我们已经讨论了异步子进程执行,这是当你需要处理多个 I/O 点时的情况,比如服务器。但有时你只是想依次执行命令,而不需要额外的开销。让我们看看下一个例子。
8.3. 同步工作
非阻塞 I/O 对于保持事件循环流畅运行至关重要,无需等待一个难以管理的子进程完成。然而,当你想要阻塞时,它有额外的编码开销,这并不愉快。一个很好的例子是编写 shell 脚本。幸运的是,同步子进程也是可用的。
技巧 63 同步子进程
同步子进程方法是 Node 场景中的新加入功能。它们首次在 Node 0.12 版本中引入,以高效且熟悉的方式解决了一个非常实际的问题:shell 脚本。在 Node 0.12 之前,使用了一些巧妙但性能不佳的技巧来获得类似同步的行为。现在,同步方法成为了一等公民。
在这项技术中,我们将涵盖子进程模块中所有可用的同步方法。
问题
你想要同步执行命令。
解决方案
使用execFileSync、spawnSync和execFile。
讨论
到目前为止,我们希望这些同步方法看起来非常熟悉。实际上,它们在函数签名和目的上与我们在本章之前讨论的相同,只有一个重要区别——当被调用时,它们会阻塞并运行到完成。
如果你只想同步执行单个命令并获取输出,请使用execFileSync:

如果你想要同步执行多个命令,并且一个命令的输入依赖于另一个命令的输出,请使用spawnSync:

结果同步子进程包含发生了很多细节,这是使用spawnSync的另一个优点:

最后,还有execSync,它以同步方式执行子 shell 并运行给定的命令。当在 JavaScript 中编写 shell 脚本时,这可能会很有用:

这将输出以下内容:

使用同步子进程方法进行错误处理
如果execSync或execFileSync返回非零退出状态,将会抛出异常。错误对象将包括我们使用spawnExec返回的所有内容。我们将能够访问重要信息,如状态码和stderr流:

该程序产生以下输出:
exit status was 1
stderr /usr/bin/cd: line 4:cd:
non-existent-dir: No such file or directory
我们讨论了execFile和execFileSync中的错误处理。那么spawnSync呢?由于spawnSync返回运行进程时发生的所有内容,它不会抛出异常。因此,你需要负责检查成功或失败。
8.4. 摘要
在本章中,你学习了如何通过使用child_process模块在 Node 中集成外部应用程序的不同用法。以下是一些总结性的提示:
-
当你只需要执行外部应用程序时,使用
execFile。它快速、简单,并且在与用户输入交互时更安全。 -
当你想对子进程的 I/O 做更多操作,或者当你预期进程会有大量输出时,使用
spawn。它提供了一个很好的可流式接口,并且在与用户输入交互时也更安全。 -
当你想访问你的 shell 功能(管道、重定向、大块数据)时,使用
exec。许多 shell 允许一次性运行多个应用程序。但是,在使用用户输入时要小心,因为将不受信任的输入放入exec调用中从不是个好主意。 -
当你想将 Node 模块作为独立进程运行时,请使用
fork。这允许计算和文件描述符处理(如传入套接字)由主 Node 进程之外处理。 -
将
spawn生成的进程分离,以便在 Node 进程死亡后它们仍能存活。这允许 Node 被用来设置长时间运行的过程,并让它们独立运行。 -
将一组 Node 进程池化,并使用内置的 IPC 通道来节省每次
fork时启动和销毁进程的开销。这对于构建 Node 进程的计算集群非常有用。
这标志着我们对 Node 基础知识的深入探究结束。我们专注于特定的核心模块功能,关注 Node 的惯用原则。在下一节中,我们的关注点将扩展到核心概念之外,进入现实世界的开发食谱。
第二部分. 真实世界配方
在本书的第一部分,我们深入探讨了 Node 的标准库。现在,我们将更广泛地查看许多 Node 程序会遇到的真实世界配方。Node 最著名的是编写快速基于网络的程序(高性能 HTTP 解析,易于使用的框架如 Express),因此我们专门用了一章来介绍 Web 开发。
此外,还有章节帮助你通过测试预先了解 Node 程序的行为,以及通过调试进行事后分析。最后,我们为你部署应用程序到生产环境做好准备。
第九章. 网络:构建更精简、更强大的 Web 应用程序
本章涵盖
-
使用 Node 进行客户端开发
-
浏览器中的节点
-
服务器端技术及 WebSocket
-
将 Express 3 应用程序迁移到 Express 4
-
测试 Web 应用程序
-
全栈框架和实时服务
本章的目的是将你关于网络、缓冲区、流和测试的知识结合起来,用 Node 编写更好的 Web 应用程序。这里有基于浏览器的 JavaScript、服务器端代码和测试的实用技术。
Node 可以帮助你编写更好的 Web 应用程序,无论你的背景如何。如果你是客户端开发者,你会发现它可以帮助你更高效地工作。你可以用它来预处理客户端资源和管理客户端工作流程。如果你曾经想要快速启动一个 HTTP 服务器来构建单页 Web 应用程序的 CSS 或 CoffeeScript,或者甚至只是一个网站,那么 Node 是一个很好的选择。
本系列的前一本书《Node.js 实战》详细介绍了使用 Connect 和 Express 进行 Web 开发,以及像 Jade 和 EJS 这样的模板语言。在本章中,我们将在此基础上构建一些想法,因此如果你是 Node 的完全新手,我们建议你也阅读《Node.js 实战》。如果你已经在使用 Express,那么我们希望你在本章中能找到一些新内容;我们包括了结构化 Express 应用程序的技术,以便随着项目的增长和成熟,使它们更容易扩展。
本章的第一节介绍了一些关注浏览器的技术。如果你是一个困惑的前端开发者,因为你的客户端库需要 Node 而使用 Node,那么你应该从这里开始。如果你是一个希望将 Node 引入浏览器的服务器端开发者,那么请跳到技术 66 以了解如何在浏览器中使用 Node 模块。
9.1. 前端技术
这一节全部关于 Node 及其与客户端技术的关系。你将看到如何在 Node 中使用 DOM,以及如何在 DOM 中使用 Node,并运行你自己的本地开发服务器。如果你是从网页设计背景来到 Node 的,那么这些技术应该能帮助你在我们深入服务器端示例之前,快速进入状态。但如果你是从服务器端背景来的,你可能想看看 Node 如何帮助自动化前端任务。
第一种技术展示了如何创建一个快速、静态的服务器,用于简单的网站或单页网页应用。
技巧 64 静态站点的快速服务器
有时候你只是想启动一个网络服务器来工作于静态网站或单页网页应用。Node 是这一选择的好选择,因为它很容易启动网络服务器。它还可以很好地封装客户端工作流程,使得与他人协作更容易。你不必手动运行客户端 JavaScript 和 CSS 上的程序,而是可以编写其他人可以共享的 Node 程序。
这项技术介绍了三种启动网络服务器的方法:一个简短的 Connect 脚本、一个命令行网络服务器,以及一个使用 Grunt 的迷你构建系统。
问题
你想要快速启动一个网络服务器,以便开发静态网站或单页应用。
解决方案
使用 Connect,一个命令行网络服务器,或者像 Grunt 这样的客户端工作流程工具。
讨论
纯粹的 HTML、JavaScript、CSS 和图片可以在没有服务器的情况下通过浏览器查看。但因为在大多数网页开发任务中,文件最终都会出现在某个服务器上,所以你通常需要一个服务器来制作静态网站。这是一项繁琐的任务,但并不需要这样!浏览器的能力也意味着你可以通过调用外部网页 API 来创建复杂的网页应用:单页网页应用,或所谓的无服务器应用。
在无服务器网页应用的情况下,你可以通过使用构建工具来预处理和打包客户端资源,从而更高效地工作。这项技术将向你展示如何启动一个用于开发静态站点的网络服务器,以及如何使用像 Grunt 这样的工具轻松启动一个小项目,而不会遇到太多麻烦。
虽然你可以使用 Node 内置的http模块来提供静态网站,但这需要做很多工作。你需要做诸如检测每个文件的内容类型并发送正确的 HTTP 头信息等事情。虽然http核心模块是一个坚实的基础,但你可以通过使用第三方模块来节省时间。
首先,让我们看看如何使用 Connect,即用于创建流行的 Express 网页框架的 HTTP 中间件模块,来启动一个网络服务器。第一个列表展示了这有多么简单。
列表 9.1. 快速静态网络服务器

要使用 列表 9.1 中的示例,你需要安装 Connect。你可以通过运行 npm install connect 来完成,但创建一个 package.json 文件会更好,这样其他人更容易了解你的项目是如何工作的。即使你的项目是一个简单的静态网站,创建一个 package.json 文件也会帮助你的项目在未来成长。你需要记住的命令只有这些:npm init 和 npm install --save connect。第一个命令为当前目录创建一个清单文件,第二个命令将安装 Connect 并将其保存到新的 package.json 文件中的依赖列表中。记住这些,你将很快就能创建新的 Node 项目。
createServer 方法
是从 Node 的 http.createServer 衍生出来的,但它被 Connect 在幕后添加的一些东西所包装。用于服务当前目录中文件的 static 服务器中间件组件
被用来从当前目录 (__dirname 有两个下划线表示“当前目录”)中提供文件,但如果你愿意,你也可以更改目录。例如,如果你在 public/ 中有客户端资源,那么你可以使用 connect.static(__dirname + '/public')。
最后,服务器被设置为监听端口 8080
。这意味着如果你运行这个脚本并在浏览器中访问 http://localhost:8080/file.html,你应该能看到 file.html。
如果你从设计师那里收到了一堆 HTML 文件,并且你想使用服务器来查看它们,因为它们使用了以斜杠 (/) 开头的图像和 CSS 文件路径,那么你也可以使用命令行网络服务器。npm 上有这些服务器,它们都支持不同的选项。一个例子是 Jesse Keane 的 glance。你可以在 GitHub 上找到它 github.com/jarofghosts/glance,以及在 npm 上作为 glance。
要在命令行上使用 glance,请导航到包含你想要查看的 HTML 文件的目录。然后全局安装 glance,使用 npm install --global glance,并输入 glance。现在访问 http://localhost:61403/file,其中 file 是你想要查看的文件,你应该能在浏览器中看到它。
glance 可以通过多种方式配置——你可以使用 --port 将端口从 61403 改为其他值,并使用 --dir 指定要服务的目录。输入 --help 获取选项列表。它还有一些关于 404 错误的默认设置——图 9.1 展示了 404 错误的样貌。
图 9.1. Glance 内置了错误页面。

运行网络服务器的第三种方式是使用像 Grunt 这样的任务运行器。这允许你以其他人可以复制的方式自动化客户端任务。使用 Grunt 有点像前两种方法的结合:它需要一个像 Connect 这样的网络服务器模块和一个命令行工具。
要使用 Grunt 为客户端项目,你需要做三件事:
1. 安装
grunt-cli模块。2. 创建一个 package.json 文件来管理您项目的依赖关系。
3. 使用一个运行 Web 服务器的 Grunt 插件。
第一步很简单:使用 npm install -g grunt-cli 将 grunt-cli 作为全局模块安装。现在您可以从包含它们的任何项目中运行 Grunt 任务,只需输入 grunt 即可。
接下来,为您的项目创建一个新的目录。切换到这个新目录并输入 npm init——您可以按 Return 键接受每个默认值。现在您需要安装一个 Web 服务器模块:npm install --save-dev grunt grunt-contrib-connect 就可以完成这项工作。
之前的命令还安装了 grunt 作为开发依赖。这样做的原因是它将 Grunt 锁定在当前版本——如果您查看 package.json,您会看到类似 "grunt": "~0.4.2" 的内容,这意味着 Grunt 首次安装的版本是 0.4.2,但在未来的 0.4 分支上将会使用新版本。Grunt 等模块的流行迫使 npm 支持一种称为 依赖关系 peer 的功能。依赖关系 peer 允许 Grunt 插件表达对 Grunt 特定版本的依赖,因此我们将使用的 Connect 模块实际上在其 package.json 文件中有一个 peerDependencies 属性。这种做法的好处是您可以确信插件将在 Grunt 变化时正常工作——否则,随着 Grunt API 的变化,插件可能会突然中断,而没有任何明显的原因。
Grunt 的替代方案
在撰写本文时,Grunt 是 Node 最受欢迎的构建系统。但新的替代方案已经出现,并且正在迅速获得采用。一个例子是 Gulp (gulpjs.com/),它利用了 Node 的流式 API,并且语法轻量,易于学习。
如果这一切对您来说都是新的,我们包括了您项目应该看起来像的截图 图 9.2。
图 9.2. 使用 Grunt 的项目通常有一个 package.json 和一个 Gruntfile.js。

现在我们已经设置了一个新的项目,最后要做的就是创建一个名为 Gruntfile.js 的文件。这个文件包含了一组 grunt 将为您运行的任务列表。接下来的列表展示了一个使用 grunt-contrib-connect 模块的示例。
列表 9.2. 用于服务静态文件的 Gruntfile

您还应该创建一个名为 public 的目录,并包含一个 index.html 文件——这个 HTML 文件可以包含您喜欢的内容。之后,从与 Gruntfile.js 相同的目录中输入 grunt connect,服务器应该会启动。您也可以直接输入 grunt,因为我们已经将默认任务设置为 connect:server
。
Gruntfile 使用 Node 的标准模块系统,并接收一个名为 grunt 的对象,可以用来定义任务。插件通过 grunt.loadNpmTasks 加载,允许您引用使用 npm 安装的模块
。大多数插件都有不同的选项,这些选项通过传递对象给 grunt.initConfig 来设置——我们已经定义了服务器端口和基本路径,您可以通过修改 base 属性来更改它们
。
使用 Grunt 启动 Web 服务器比编写一个微小的 Connect 脚本或运行glance要麻烦,但如果你看看 Grunt 的插件列表(gruntjs.com/plugins),你会看到超过 2,000 个条目,涵盖了从构建优化的 CSS 文件到 Amazon S3 集成的所有内容。如果你曾经需要连接客户端 JavaScript 或生成图像精灵,那么很可能有一个插件可以帮助你自动化这个过程。
在下一个技术中,你将学习如何在 Node 中重用客户端代码。我们还将向你展示如何在 Node 进程中渲染网页内容。
技巧 65 在 Node 中使用 DOM
经过一些工作,在 Node 中模拟浏览器是可能的。如果你想要制作网络爬虫——将网页转换为结构化内容的程序,这很有用。从技术上讲,这比看起来要复杂得多。浏览器不仅提供 JavaScript 运行时,还有在 Node 中不存在的文档对象模型(DOM)API。
围绕 DOM 的如此丰富的库集合有时很难想象没有它们就能解决问题。如果有一种方法可以在 Node 中运行像 jQuery 这样的库就好了!在这个技巧中,你将学习如何通过在 Node 程序中使用浏览器 JavaScript 来实现这一点。
问题
你想在 Node 中重用依赖于 DOM 的客户端代码,或者渲染整个网页。
解决方案
使用提供 DOM 层的第三方模块。
讨论
W3C DOM 是一个定义良好的标准。当设计师在与浏览器不兼容性作斗争时,他们通常在处理这样一个事实:标准需要一定程度的解释,浏览器制造商自然会对标准进行略微不同的解释。如果你的目标只是运行依赖于 JavaScript DOM API 的 JavaScript,那么你很幸运:这些标准可以很好地重新创建,这样你就可以在 Node 中运行流行的客户端库。
解决这个问题的早期方案之一是jsdom(github.com/tmpvar/jsdom)。此模块接受一个环境规范,然后提供一个window对象。如果你使用npm install -g jsdom安装它,你应该能够运行以下示例:

这个示例接受 HTML
,获取一些远程脚本
,然后给你一个看起来非常像浏览器window对象的window对象
。它足够好,以至于你可以使用 jQuery 来操作 HTML 片段——jQuery 就像在浏览器中运行一样工作。这很有用,因为现在你可以编写处理 HTML 文档的脚本,就像你可能习惯的那样:而不是使用解析器,你可以使用你熟悉的工具查询和操作 HTML。这对于编写像网络爬虫这样的简洁代码非常有用,否则这将是令人沮丧和繁琐的。
其他人对 jsdom 的方法进行了迭代,简化了底层依赖。如果你真的只想以 jQuery 类似的方式处理 HTML,那么你可以使用cheerio (npmjs.org/package/cheerio)。这个模块更适合网页抓取,所以如果你正在编写下载、处理和索引 HTML 的内容,那么cheerio是一个不错的选择。
在下面的示例中,你将看到如何使用cheerio处理来自真实网页的 HTML。实际的 HTML 来自 manning.com/index.html,但由于设计经常变化,我们在代码示例中保留了一份页面的副本。你可以在 cheerio-manning/index.html 中找到它。下面的列表打开 HTML 文件,并使用 CSS 选择器查询它,这是由cheerio提供的。
列表 9.3. 使用cheerio抓取网页

使用fs.readFile加载 HTML。如果你真的要这样做,你可能想要使用 HTTP 下载页面——你可以自由地将fs.readFile替换为http.get来通过网络获取 Manning 的索引页面。我们在第七章(kindle_split_016.html#ch07)、技术 51(kindle_split_016.html#ch07lev2sec10)“跟随重定向”中有一个详细的http.get示例。
一旦 HTML 被获取,它就会被传递给cheerio.load
。将结果设置为名为$的变量只是一种约定,如果你习惯了 jQuery,这将使你的代码更容易阅读,但你也可以给它起其他名字。
现在一切都已经设置好了,你可以查询 HTML;使用$('.Releases a strong')
来查询文档中最新发布的书籍。它们位于一个带有Releases类的div中,作为锚标签。
使用releases.each遍历每个元素,就像在 jQuery 中一样。回调的上下文被更改为当前元素,因此调用this.text()来获取节点包含的文本
。
由于 Node 拥有如此广泛的第三方模块,你可以使用这个例子来做各种惊人的事情。添加 Redis 进行缓存和排队处理网站,然后抓取结果并将其投放到 Elasticsearch,你就拥有了自己的搜索引擎!
现在你已经看到了如何在 Node 中运行针对浏览器的 JavaScript,但反过来呢?你可能有一些想要在客户端重用的 Node 代码,或者你可能只想使用 Node 的模块系统来组织你的客户端代码。就像我们可以在 Node 中模拟 DOM 一样,我们也可以在浏览器中做到这一点。在下一个技术中,你将学习如何通过在浏览器中运行你的 Node 脚本来实现这一点。
技术编号 66 在浏览器中使用 Node 模块
Node 对 JavaScript 的一个卖点是可以将现有的浏览器编程技能用于服务器。但如果没有任何更改,如何在浏览器中重用 Node 代码呢?这难道不是很酷吗?这里有一个例子:你在 Node 中定义了数据模型,它们执行数据验证等操作,你希望在浏览器中重用它们,以便在数据无效时自动显示错误消息。
这几乎可能,但并不完全可能:不幸的是,浏览器有一些怪癖必须解决。此外,像 require 这样的重要功能在客户端 JavaScript 中不存在。在这个技术中,你将看到如何将针对 Node 的代码转换为与大多数网络浏览器一起工作。
问题
你想使用 require() 来结构化你的客户端代码,或者在浏览器中重用整个 Node 模块。
解决方案
使用像 Browserify 这样的程序,它能够将 Node JavaScript 转换为浏览器友好的代码。
讨论
在这种技术中,我们将使用 Browserify (browserify.org/) 将 Node 模块转换为浏览器友好的代码。其他解决方案也存在,但到目前为止,Browserify 是更成熟和流行的解决方案之一。尽管它不仅仅是为了支持 require() 而进行修补,它还可以转换依赖于 Node 的流和网络 API 的代码。你甚至可以使用它递归地将 npm 中的模块转换为浏览器模块。
为了了解它是如何工作的,我们首先来看一个简短的自包含示例。要开始,使用 npm 安装 Browserify:npm install -g browserify。一旦安装了 Browserify,你就可以使用 browserify index.js -o bundle.js 将你的 Node 模块转换为 Browser 脚本。任何 require 语句都会导致文件被包含在 bundle.js 中,所以你不应该更改此文件。相反,每次原始文件有更改时,都要覆盖它。
列表 9.4 展示了一个使用 EventEmitter 和 utils.inherit 来构建小型消息类基础的示例 Node 程序。
列表 9.4. 浏览器中的 Node 模块

在这个脚本上运行 Browserify 会生成一个大约 1,000 行的包!但我们可以像在任何 Node 程序中一样使用 require
,我们熟悉和喜爱的 Node 模块将正常工作,如 列表 9.4 中通过使用 util.inherits 和 EventEmitter
所见。
使用 Browserify,你还可以使用 require 和 module.exports,这比手动操作 <script> 标签要好。上一个例子可以扩展为做到这一点。在 列表 9.5 中,Browserify 被用来创建一个客户端脚本,该脚本可以使用 require 加载 MessageBus 和 jQuery,并在消息发出时修改 DOM。
列表 9.5. 浏览器中的 Node 模块

通过创建一个包含jquery作为依赖项的 package.json 文件,你可以使用 Browserify 加载 jQuery
。在这里,我们使用它来附加一个DOMContentLoaded监听器
并在接收到消息时将段落附加到容器元素中。
源映射
如果你使用 Browserify 生成的 JavaScript 文件出现错误,那么在堆栈跟踪中解开行号可能会很困难,因为这些行号指的是单体包中的行号。如果你在构建包时包含--debug标志,那么 Browserify 将生成指向原始文件和行号的映射。
这些映射需要一个兼容的调试器——你还需要告诉你的浏览器的调试工具使用它们。在 Chrome 中,你需要在 Chrome 的开发工具选项下选择“启用源映射”。
要使这生效,你只需要将module.exports = MessageBus添加到列表 9.4 的示例中,然后使用browserify index.js -o bundle.js生成包,其中 index.js 是列表 9.5。Browserify 将忠实地跟随 index.js 中的require语句,从./node_modules中拉入 jQuery 和从 messagebus.js 中的MessageBus类。
由于人们可能会忘记如何构建脚本,你可以在 package.json 文件中添加一个scripts条目,如下所示:"build": "browserify index.js -o bundle.js"。本书的可下载代码示例包括一个示例 package.json 文件和一个适合在浏览器中运行整个示例的 HTML 文件。
使用 Browserify 构建包还有另一种方法:将 Browserify 作为 Node 程序中的模块使用。要使用它,你需要创建一个Browserify实例
,然后告诉它你想要构建哪些文件
:

你可以将这些作为更复杂构建过程的一部分,或者将其放入 Grunt 任务中以自动化你的构建过程。现在你已经看到了如何在浏览器中使用 Node 模块以及如何在 Node 中模拟浏览器,现在是时候学习如何改进你的服务器端 Web 应用程序了。
9.2. 服务器端技术
本节包括构建 Web 应用程序的一般技术。如果你已经在使用 Express,那么你可以使用这些技术来改进你的 Express 程序的组织方式。Express 旨在保持简单,这使得它非常灵活,但有时并不容易看到如何以最佳方式使用它。我们创建的模式和解决方案来自于过去几年使用 Express 构建商业和开源 Web 应用程序的经验。我们希望它们能帮助你编写更好的 Web 应用程序。
Express 3 和 4
本节中的技术主要针对 Express 3。大多数都将与 Express 4 兼容,或者可能需要一些小的修改。有关迁移到 Express 4 的更多信息,请参阅技术编号 75。
技术编号 67 Express 路由分离
Express 的文档和流行的教程通常将所有代码组织在一个文件中。在实际项目中,这最终会变得难以管理。这项技术使用 Node 的模块系统将相关路由分离到文件中,并包括绕过 Express app 对象位于不同文件中的方法。
问题
您的主要 Express 应用程序文件变得非常大,您希望有更好的方式来组织所有这些路由。
解决方案
使用路由分离将相关路由拆分为模块。
讨论
Express 是一个极简框架,因此它不会在组织项目时手把手地引导你。如果你不留意,一开始简单的项目可能会变得难以管理。成功组织大型项目的秘诀是拥抱 Node 的模块系统。
首要的攻击途径是路由,但你也可以使用 Express 将这项技术应用到开发的各个方面。你甚至可以将应用程序视为自包含的 Node 模块,并在其他应用程序中挂载它们。
这是一些 Express 路由的典型示例:

完整的示例项目可以在 listings/web/route_separation/app_monolithic.js 中找到。它包含一组用于创建、查找和更新笔记的 CRUD 路由。这样的应用程序还会有其他 CRUD 路由:也许笔记可以组织到笔记本中,肯定会有一些用户账户管理,以及设置提醒等额外功能。一旦你有大约四到五个这样的路由集,应用程序文件可能会有数百行代码。
如果你将这个项目写成单个、大型的文件,那么它很容易出现许多问题。很容易在变量意外地成为全局而不是局部时出错,因此在某些条件下可能会遇到危险的副作用。Node 有一个内置的解决方案,可以应用于 Express 和其他 Web 框架:目录作为模块。
要使用模块重构路由,首先创建一个名为 routes 的目录,或者如果你更喜欢,可以创建一个名为 controllers 的目录。然后创建一个名为 index.js 的文件。在我们的例子中,它将是一个简单的三行文件,导出笔记路由:

在这里,我们只有一个路由模块,可以使用 require 和相对路径加载
。接下来,将整个路由集复制并粘贴到 routes/notes.js 中。然后删除路由定义部分——例如,app.get('/notes',,并用导出替换它:module.exports.index = function(req, res) {}。
重构后的文件应该看起来像下面的列表。
列表 9.6. 没有其他应用程序的路由模块

每个路由函数都使用 CRUD 启发的名称导出(index、create、update、show)
。相应的 app.js 文件现在可以清理了。下面的列表展示了这可以看起来多么整洁。
列表 9.7. 重构后的 app.js 文件

所有路由都可以一次性使用require('./routes')加载!
。这既方便又整洁,因为减少了会弄乱 app.js 的require语句。您只需删除旧的路由回调并添加对每个路由函数的引用!
。
不要在这个文件中放置app.listen调用;相反,导出app!
。这使得测试应用程序更加容易。导出app对象的另一个优点是,您可以从应用程序的任何地方轻松加载app.js模块。Express 允许您获取和设置配置值,因此如果需要在路由之外的位置引用这些设置,使app可访问可能很有用。另外请注意,res.app在路由内部可用,因此您不需要经常传递app对象。
如果您想轻松加载 app.js 而不创建服务器,那么将应用程序文件命名为 app.js,并有一个单独的 server.js 文件,该文件调用app.listen。您可以在 package.json 中设置server属性以使用node server.js,这样人们就可以使用npm start启动应用程序——您也可以省略server属性,因为node server.js是默认的,但最好定义它,以便人们知道您希望他们如何使用它。
将目录作为模块
这种技术将所有路由放在一个目录中,然后通过一个 index.js 文件导出它们,这样就可以一次性使用require('./routes')来加载。
这种模式可以在其他地方重用。它非常适合组织中间件、数据库模块和配置文件。
有关使用目录作为模块来组织配置文件的示例,请参阅技术 69。
该技术的完整示例可以在listings/web/route-separation中找到,其中还包括了示例测试,以防您想对您自己的项目进行单元测试。
正确组织 Express 项目非常重要,但还有一些工作流程问题可能会减慢开发速度。例如,当您在 Web 应用程序上工作时,您通常会进行许多小的更改,然后刷新浏览器以查看结果。大多数 Node 框架要求在看到更改生效之前重启进程,所以下一个技术将探讨这是如何工作的以及如何有效地解决这个问题。
技术编号 68:自动重启服务器
虽然 Node 自带了监控文件变化的工具,但使用它们进行高效工作可能需要大量工作。这项技术探讨了fs.watch,并介绍了一个流行的第三方工具,该工具可以在编辑文件时自动重启 Web 应用程序。
问题
每次编辑文件时,您都需要重启您的 Node Web 应用程序。
解决方案
使用文件监视器来自动重启应用程序。
讨论
如果你习惯了像 PHP 或 ASP 这样的语言,Node 的进程内服务器模型可能会显得有些不寻常。Node 模型的一个主要区别在于,当文件发生变化时,你需要重启进程。如果你考虑一下require和 V8 的工作方式,那么这就有道理了——文件通常只加载和解释一次。
解决这个问题的一种方法是在文件发生变化时检测到这一点,然后重启应用程序。Node 很好地利用了非阻塞 I/O,非阻塞文件系统 API 的一个特性是可以使用监听器来等待特定事件。为了解决这个问题,你可以为你的项目中的所有文件设置文件系统事件处理器。然后,当文件发生变化时,你的事件处理器可以重启项目。
Node 在fs模块中提供了一个名为fs.watch的 API。在撰写本文时,这个 API 是不稳定的——这意味着它可能在 Node 的后续版本中发生变化。这种方法已在第六章、第 6.1.4 节中介绍。让我们看看它是如何与网络应用程序一起使用的。图 9.8 显示了一个可以监视和重新加载简单网络服务器的程序。
列表 9.8. 重新加载 Node 进程

使用fs.watch监视文件变化稍微有些复杂,但你可以使用基于文件轮询而不是 I/O 事件的fs.watchFile。列表 9.8 的工作方式是启动一个子进程——在这种情况下是node server.js!,然后监视该文件的变化!。进程的启动和停止由child_process核心模块管理,使用kill方法停止子进程!。
在 Mac OS 上,我们发现最好也使用watcher.close停止监视文件!,尽管 Node 的文档表明fs.watch应该是“持久的”。一旦完成所有这些,watch函数就会递归调用以再次启动网络服务器!。
这个例子可以用如下服务器.js 文件运行:
var http = require('http');
var server = http.createServer(function(req, res) {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('This is a super basic web application');
});
server.listen(8080);
这确实可行,但并不十分优雅。而且它也不完整。大多数 Node 网络应用程序由多个文件组成,因此文件监视逻辑会变得更加复杂。仅仅递归遍历父目录是不够的,因为有许多你不想监视的文件——你不想监视.git中的文件,如果你正在编写 Express 应用程序,你可能也不想监视视图模板,因为它们在开发模式下按需加载且不缓存。
突然自动重启 Node 程序似乎不再那么简单,这就是第三方模块可以提供帮助的地方。解决这个问题的最广泛使用的模块之一是 Remy Sharp 的 nodemon (nodemon.io/)。它默认情况下非常适合监视 Express 应用程序,您甚至可以使用它来自动重启任何类型的程序,无论是用 Node 编写还是 Python、Ruby 等等。
要尝试它,请输入 npm install -g nodemon,然后导航到包含 Node 网络应用程序的目录。如果您想使用一个小型示例脚本,可以使用我们来自 listings/web/watch/server.js 的示例。
通过输入 nodemon server.js 开始运行和监视 server.js,您会发现您可以编辑 res.end 中的文本,并且更改将在您下次加载 http://localhost:8080/ 时反映出来。
您可能会注意到在更改可见之前会有短暂的延迟——这仅仅是 Nodemon 在设置 fs.watch,或者如果您的操作系统上不可用,则为 fs.watchFile。您可以通过输入 rs 并按回车键强制它重新加载。
Nodemon 有一些其他功能可以帮助您在网页应用程序上工作。输入 nodemon --help 将显示命令行选项列表,但您可以通过创建一个 nodemon.json 文件来获得更大的、与版本控制系统友好的控制。这允许您指定要忽略的文件数组,您还可以通过使用 execMap 设置将文件扩展名映射到程序名称。Nodemon 的文档包括一个示例文件,说明了每个功能。
下一个列表是一个示例 Nodemon 配置,您可以将其适应到您自己的项目中。
列表 9.9. Nodemon 的配置文件
![214fig01.jpg]
基本选项允许您忽略特定的路径 ![1.jpg],并列出要监视的多个路径 ![3.jpg]。此示例使用 execMap 自动运行带有 --harmony 标志的 node 以适用于所有 JavaScript 文件 ![2.jpg]。Nodemon 还可以设置环境变量——只需将一些值添加到 env 属性 ![4.jpg]。
¹
--harmony用于启用 Node 可用的所有新 ECMAScript 功能。
一旦您的流程通过 Nodemon 流程化,接下来要做的就是改进您的项目配置。大多数项目都需要一定程度的配置——例如,包括数据库连接细节和远程 API 的授权凭证。下一项技术将探讨配置您的网络应用程序的方法,以便您可以轻松地将它部署到多个环境,以测试模式运行,甚至调整本地开发期间的行为。
技巧 69 配置网络应用程序
这种技术探讨了配置 Node 网络应用程序的常见模式。我们将包括 Express 的示例,但您也可以将这些模式用于其他网络框架。
问题
您有配置选项,这些选项在开发、测试和生产之间会有所不同。
解决方案
使用 JSON 配置文件、环境变量或模块来管理设置。
讨论
大多数 Web 应用程序都需要一些配置值才能正确运行:数据库连接字符串、缓存设置和电子邮件服务器凭据是典型的。存储应用程序设置的方法有很多,但在安装第三方模块之前,请考虑你的需求:
-
在版本控制仓库中留下数据库凭据是否可以接受?
-
你真的需要配置文件,还是可以将设置嵌入到应用程序中?
-
如何在不同的应用程序部分访问配置值?
-
你的部署环境是否提供了一种存储配置值的方法?
第一点取决于你的项目或组织的政策。如果你正在构建开源 Web 应用程序,你不想在公共仓库中留下数据库账户,因此配置文件可能不是最佳解决方案。你希望人们能够快速轻松地安装你的应用程序,但你不希望意外泄露你的密码。同样,如果你在一个拥有数据库管理员的大型组织中工作,他们可能不介意让每个人都直接访问数据库。
在这种情况下,你可以将配置值作为部署环境的一部分来设置。环境变量是配置 Unix 和 Windows 程序行为的标准方式,你可以使用 process.env 来访问它们。这个基本示例是使用 NODE_ENV 设置在部署环境之间切换。以下列表显示了 Express 用于存储配置值的模式。
列表 9.10. 配置 Express 应用程序

Express 有一个用于设置应用程序配置值的 API:app.set、app.get
和 app.configure。你还可以使用 app.enable 和 app.disable 来切换布尔值,以及使用 app.enabled 和 app.disabled 来查询它们。app.configure 块与 if (process.env.NODE_ENV === 'development')
和 if (process.env.NODE_ENV === 'production')
等价,所以如果你不想使用 app.configure,你实际上不需要它。它将在 Express 4 中被移除。如果你没有使用 Express,你只需查询 process.env。
NODE_ENV 环境变量由 shell 控制。如果你想以生产模式运行 列表 9.10,你可以输入 NODE_ENV=production node config.js,你应该会看到它打印出生产数据库字符串。你也可以输入 export NODE_ENV=production,这将导致应用程序在当前 shell 运行期间始终以生产模式运行。
我们使用 PORT
来设置端口的理由是因为这是 Heroku 默认使用的名称。这允许 Heroku 的内部 HTTP 路由器覆盖应用程序监听的端口。
你可以在代码中使用 process.env 而不是 app.get,但使用 app 对象感觉更干净。你不需要传递 app——如果你已经使用了来自 技术 67 的路由分离模式,那么你将通过 res.app 访问它。
如果你更愿意使用配置文件,最简单快捷的方法是使用文件夹作为模块技术与 JSON 文件结合。创建一个名为 config/ 的文件夹,然后创建一个 index.js 文件,并为每个环境创建一个 JSON 文件。下一个列表显示了 index.js 文件应该是什么样子。
列表 9.11. JSON 配置文件加载器

Node 的模块系统允许你使用 require 加载 JSON 文件
,因此你可以加载每个环境的配置文件,然后使用 NODE_ENV
导出相关的配置。然后每当你需要访问设置时,只需使用 var config = require('./config')——你将得到一个包含当前环境设置的普通 JavaScript 对象。下一个列表显示了使用此技术的示例 Express 应用程序。
列表 9.12. 加载配置目录

这几乎感觉像是作弊!你只需要调用 require ('./config'),你就有你的设置了。Node 的模块系统也应该缓存该文件,所以一旦你调用了 require,它就不需要再次评估 JSON 文件。你可以在应用程序的任何地方重复调用 require('./config')。
这种技术利用了 JavaScript 在对象上设置和访问值的轻量级语法,以及 Node 的模块系统。它适用于许多类型的项目。
配置还有另一种方法:使用第三方模块。在掌握最后一种技术之后,你可能认为这就足够了,但第三方模块可以提供很多功能,包括命令行选项解析。可能你经常需要在不同的选项之间切换,因此使用命令行选项覆盖应用程序设置是很有吸引力的。
网络框架 Flatiron (flatironjs.org/) 有一个名为 nconf (npmjs.org/package/nconf) 的应用程序配置模块,它可以处理配置文件、环境变量和命令行选项。每个都可以设置优先级,因此你可以使命令行选项覆盖配置文件。这是一个处理选项的统一框架。
下一个列表显示了如何使用 nconf 配置 Express 应用程序。
列表 9.13. 使用 nconf 配置 Express 应用程序

在这里,我们已告诉nconf优先考虑命令行选项,但如果可用,也会读取配置文件!。您不需要创建配置文件,如果您使用nconf.save,nconf可以为您创建一个。这意味着您可以让应用程序的用户更改设置并持久保存它们。当nconf配置为使用数据库保存设置时,这效果最好——它内置了 Redis 支持。
可以使用nconf.set!设置默认值。如果您在没有任何选项的情况下运行此示例,它应该使用端口 3000,但如果您以node app.js --port 3001启动它,它将使用您通过--port传递的内容。获取设置就像nconf.get!一样简单。
您不需要传递nconf对象!设置存储在内存中。您的项目中的其他文件可以通过使用require加载nconf并调用nconf.get来访问设置。下一个列表再次加载nconf,然后尝试访问db设置。
列表 9.14. 在应用程序的其他地方加载nconf

尽管看起来var nconf = require('nconf')可能返回一个干净的nconf副本,但实际上并不是这样!。
一个组织良好且配置仔细的 Web 应用程序仍然可能会出错。当您的应用程序崩溃时,您会希望日志帮助调试问题。下一个技术将帮助您改进应用程序处理错误的方式。
技巧 70 精美的错误处理
这种技术探讨了使用Error构造函数来捕获和处理应用程序中的错误。
问题
您希望集中处理错误以简化您的 Web 应用程序。
解决方案
使用包含 HTTP 状态代码的错误类继承自Error,并使用中间件组件根据内容类型处理错误。
讨论
JavaScript 有一个Error构造函数,您可以从它继承来表示特定类型的错误。在 Web 开发中,一些错误经常出现:不正确的 URL、查询参数或表单值的不正确参数,以及认证失败。这意味着您可以定义包含 HTTP 代码的错误,同时包含Error提供的典型内容。
而不是在 HTTP 路由器中根据错误条件进行分支,您应该调用next(err)。下一个列表显示了它是如何工作的。
列表 9.15. 将错误传递给中间件

在这个例子中,错误类别已在单独的文件中定义!,您可以在列表 9.16 中找到它。路由处理程序包括一个第三个参数,next!,在之前技术中我们使用的标准req, res参数之后。
您的许多路由处理程序将加载数据库中的数据,无论是 MySQL、PostgreSQL、MongoDB 还是 Redis,因此此示例基于一个通用的异步数据库 API。如果数据库 API 遇到错误,则提前返回并调用 next,包括错误对象作为第一个参数。这将把错误传递给下一个中间件组件
。此路由处理程序还有一个额外的逻辑部分——如果数据库中没有找到笔记,则使用 next 实例化错误对象并传递
。
下一个列表展示了如何从 Error 继承。
列表 9.16. 继承错误并包含状态码

在这里,我们选择创建两个类。我们不仅定义了 NotFound,还创建了 HTTPError
并从它继承
。这样做是为了更容易追踪错误是否与 HTTP 相关,或者是否是其他原因。基本的 HTTPError 类从 Error
继承。
在 NotFound 错误中,我们捕获了堆栈跟踪以帮助调试
,并设置了一个 statusCode 属性
,该属性可以报告给浏览器。
下一个列表展示了如何在典型的 Express 应用程序中创建一个错误处理中间件组件。
列表 9.17. 使用错误处理中间件组件


这个中间件组件相当简单,但它有一些在生产中我们发现效果很好的调整。要获取 next 传递的错误对象,请确保使用 app.use 回调的四参数形式
。此外,请注意,这个中间件组件位于链的末尾,因此您需要将其放在所有其他中间件和路由定义之后。
您可以条件性地打印堆栈跟踪,以便在测试预期错误时它们不可见
——错误可能是测试的一部分,您不希望堆栈跟踪弄乱测试输出。
由于这将在主应用程序文件中集中处理错误,因此根据条件返回不同的格式是个好主意。如果您的应用程序同时提供 JSON API 和 HTML 页面,这很有用。您可以使用 app.format 来实现这一点
,它通过检查请求的 Accept 头中的 MIME 类型来工作。JSON 响应可能不是必需的,但您的 API 可能会返回格式良好的错误,这些错误可以被客户端消费——当您请求 JSON 时,处理突然以 HTML 响应的 API 可能会很困难。
在您的测试中的某个地方,您应该检查这些错误是否按预期工作。以下片段显示了一个 Mocha 测试,确保当预期时返回 404,并且以预期的格式返回:

此代码片段包括两个请求。第一个检查我们是否得到一个 404 错误 ![1.jpg],第二个设置 Accept 头部信息以确保我们得到 JSON ![2.jpg]。这是通过 SuperTest 实现的,它将在响应中返回 JSON,因此断言可以检查我们是否得到了我们期望的格式 ![3.jpg]。此示例的完整源代码可以在 listings/web/error-handling 中找到。
错误电子邮件速查表
如果你打算在应用程序中添加当发生意外错误时发送电子邮件通知的功能,以下是在电子邮件中包含以帮助调试的一些内容列表:
-
错误对象的字符串版本
-
err.stack的内容——这是 Node 包含的错误对象的非标准属性 -
请求方法和 URL
-
如果有的话,Express 的
req.route属性 -
远程 IP,在 Express 中为
req.ip -
请求体,你可以使用
inspect(req.body)将其转换为字符串
这种错误处理模式在 Express 应用程序中广泛使用,甚至内置在 restify 框架中(npmjs.org/package/restify)。如果你记得将错误对象传递给 next,你会发现测试和调试 Express 应用程序更容易。
错误也可以作为带有有用记录的电子邮件发送。为了最大限度地利用错误电子邮件,请在电子邮件中包含请求和错误对象,以便您可以确切地看到问题出在哪里。此外,您可能不想发送有关某些状态代码的错误详细信息,但这取决于您。
在这个技巧中,我们提到了将代码适配以与 REST API 一起工作。下一个技巧将更深入地探讨 REST 的世界,并为 Express 和 restify 提供示例。
技巧 71 RESTful 网络应用程序
在某个阶段,您可能想向您的应用程序添加一个 API。这项技术完全是关于构建 RESTful API。这里有 Express 和 restify 的示例,以及如何创建使用正确 HTTP 动词和惯用 URL 的 API 的技巧。
问题
你想在 Express、restify 或其他 Web 框架中创建一个 RESTful Web 服务。
解决方案
使用正确的 HTTP 方法、URL 和头部信息来构建直观的 RESTful API。
讨论
REST 代表 表征状态转移,^([2]) 除非你想在面试中给人留下深刻印象,否则记住这一点并没有太大的帮助。网络开发者通常将其与 SOAP(简单对象访问协议)相对比,SOAP 被视为一种更企业化、更严格的创建 Web API 的方式。实际上,确实存在严格的 REST API,但关键的区别在于 REST 在根本层面上拥抱 HTTP——HTTP 方法本身具有语义意义。
² 更多关于 REST 的信息,请参阅 Fielding 关于该主题的论文
mng.bz/7Fhj。
如果你曾经制作过基本的 HTML 表单,你应该熟悉使用GET和POST请求。在 REST 中,这些 HTTP 动词有特定的含义。例如,POST将创建一个资源,而GET意味着获取一个资源。
Node 开发者通常创建使用 JSON 的 API。JSON 是 Node 中生成和读取结构化数据格式最简单的方式,但它也适用于客户端 JavaScript。但是 REST 并不暗示 JSON——你可以自由使用任何数据格式。某些客户端和服务期望 XML,我们甚至见过那些与 CSV 和 Excel 等电子表格格式一起工作的。
所需的数据格式由请求的Accept头部指定。对于 JSON,应该是application/json,对于 XML 则是application/xml。还有其他有用的请求头部——Accept-Version可以用来请求 API 的不同版本。这允许客户端锁定到一个受支持的版本,同时你可以自由地改进服务器而不会破坏向后兼容性——你总是可以比人们更新客户端更快地更新你的服务器。
Express 在 Node 的http核心模块之上提供了一个轻量级层,但它不包括任何除内存会话和 cookie 之外的数据持久性功能。你必须决定使用哪个数据库和数据库模块。restify 也是如此:它不会自动将数据从 HTTP 映射到离线存储;你需要找到一种方法来实现这一点。
Restify 在表面上与 Express 相似。区别在于 Express 具有帮助你构建 Web 应用程序的功能,包括渲染模板。相反,restify 专注于构建 REST API,这带来了一组不同的要求。Restify 通过使用 HTTP 头部实现语义版本控制,使得轻松地为 API 的不同版本提供服务变得容易,并且有一个基于事件的 API 用于发射和监听与 HTTP 相关的事件和错误。它还支持节流,因此你可以控制响应的速度。
图 9.3 展示了一个典型的 RESTful API,它允许创建、读取、更新和删除页面对象。
图 9.3. 向 REST API 发送请求

要开始构建 REST API,你应该考虑你的对象是什么。想象你正在构建一个内容管理系统:它可能包含页面、用户和图像。如果你想添加一个按钮,允许页面在“已发布”和“草稿”之间切换,并且如果你已经有一个支持PATCH /pages/:id请求的 REST API,你只需将按钮绑定到一些客户端 JavaScript 或一个将{ state: 'published' }或{ state: 'draft' }发送到/pages/:id的表单即可。如果你被提供了一个只有PUT /pages/:id的 Express 应用程序,那么你可能可以从现有实现中推导出PATCH的代码。
复数还是单数?
当你设计 API 的 URI 端点时,你应该通常使用复数名词。这意味着 /pages 以及 /pages/1 用于特定页面,而不是 /page/1。如果端点是一致的,那么使用你的 API 会更容易。
你可能会发现有一些资源应该使用单数名词,因为只有一个这样的项目。如果语义上合理,可以使用单数名词,但使用时要保持一致。例如,如果你的 API 需要用户登录,并且你不想暴露唯一的用户 ID,那么 /account 可能是用户账户管理的合理端点,如果对于特定用户只有一个账户。
表 9.1 展示了 HTTP 动词和典型响应。请注意,PUT 和 PATCH 有不同但相似的含义——PATCH 意味着修改资源中的某些字段,而 PUT 意味着 替换 整个资源。通过这种方式构建应用程序可能需要一些实践,但它很实用且易于测试,因此值得正确学习。如果你对这些 HTTP 术语不熟悉,那么在为你的应用程序设计 API 时,请使用 表 9.1。
表 9.1. 选择正确的 HTTP 动词
| 动词 | 描述 | 响应 |
|---|---|---|
| GET /animals | 获取动物列表。 | 一组动物对象数组 |
| GET /animals/:id | 获取单个动物。 | 一个单个动物对象,或一个错误 |
| POST /animals | 通过发送单个动物的属性来创建一个动物。 | 新的动物 |
| PUT /animals/:id | 更新单个动物记录。所有属性将被替换。 | 更新的动物 |
| PATCH /animals/:id/ | 更新单个动物记录,但只更改指定的字段。 | 更新的动物 |
在 Express 应用程序中,这些 URL 和方法是通过路由映射的。路由指定 HTTP 动词和部分 URL。你可以将这些映射到任何你喜欢的函数,但如果你使用来自 技术 67 的路由分离模式,这是建议的,那么你应该使用与相关 HTTP 动词相近的方法名。列表 9.18 展示了 Express 中 RESTful 资源的路由,以及一些使其工作的必要配置。
列表 9.18. Express 中的 RESTful 资源

此示例使用了一些中间件来自动解析 JSON 请求
,并且通过查询参数 _method 覆盖了 HTTP 方法 POST
。这意味着 PUT、PATCH 和 DELETE HTTP 动词实际上是由 _method 查询参数确定的。这是因为大多数浏览器只能发送 GET 或 POST,所以 _method 是许多 Web 框架使用的技巧。
列表 9.18 中的路由定义了每个常用的 RESTful 资源方法
。表 9.1 展示了这些路由如何映射到操作。
表 9.2. 将路由映射到响应
| 动词,URL | 描述 |
|---|---|
| GET /pages | 一组页面。 |
| GET /pages/:id | 包含指定 id 的页面的对象。 |
| POST /pages | 创建一个页面。 |
| PATCH /pages/:id | 加载 id 对应的页面,并更改一些字段。 |
| PUT /pages/:id | 替换 id 对应的页面。 |
| DELETE /pages/:id | 删除 id 对应的页面。 |
列表 9.19 是路由处理器的示例实现。它有一个通用的 Node 数据库 API——一个真实的 Redis、MongoDB、MySQL 或 PostgreSQL 数据库模块不会太远,所以你应该能够适应它。
列表 9.19. RESTful 路由处理器


虽然这个例子很简单,但它说明了很重要的一点:你应该保持你的路由处理器轻量级。它们处理 HTTP,然后让代码的其他部分处理底层业务逻辑。这个例子中使用的另一个模式是错误处理——通过调用 next(err) 传递错误!
。尽量将错误处理代码集中化和通用化——技术 70 有更多细节。
要将 JSON 返回到浏览器,使用 res.send() 并传入一个 JavaScript 对象!
。Express 知道如何将对象转换为 JSON,所以你只需要做这件事。
所有这些路由处理器都使用相同的模式:将查询或请求体映射到数据库可以使用的某个东西,然后调用相应的数据库方法。如果你使用 ORM 或 ODM——一个更抽象的数据库层——那么你可能会有类似于 PATCH 的东西!这可以是一个允许你只更新指定字段的 API 方法。关系数据库和 MongoDB 就是这么工作的。
如果你下载这本书的源代码,你将获得尝试完整示例所需的其他文件。要运行它,请输入 npm start。一旦服务器运行,你可以使用以下一些 Curl 命令与服务器通信。
第一个命令创建了一个页面:

首先,我们使用 -H 选项指定 Content-Type!
。接下来,请求被设置为使用 POST,请求体作为 JSON 字符串包含在内!
。URL 是 /pages,因为我们正在创建一个资源!
。
Curl 是一个探索 API 的有用工具,一旦你理解了基本选项。需要记住的是 -H 用于设置头部,-X 用于设置 HTTP 方法,以及 -d 用于请求体。
要查看页面列表,只需使用 curl http://localhost:3000/pages。要更改内容,尝试使用 PATCH:
curl -H "Content-Type: application/json" \
-X PATCH -d '{ "page": { "title": "The Moon" } }' \
http://localhost:3000/pages/1
Express 在创建 RESTful 网络服务方面还有一些其他的技巧。记住,一些 REST API 使用其他数据格式,比如 XML?如果你两者都需要怎么办?你可以通过使用 res.format 来解决这个问题:

要使用 XML 而不是 JSON,你必须在请求中包含 Accept 头部。使用 Curl,你可以这样做:
curl -H 'Accept: application/xml' \
http://localhost:3000/pages/1
只需记住,Accept 用于请求服务器提供特定的格式,而 Content-Type 用于告诉服务器你发送的格式。有时在单个请求中包含两者是有意义的!
现在你已经了解了 Express 中的 REST API 的工作方式,我们可以将其与 restify 进行比较。用于构建 Express 应用的模式可以用于 restify 项目。两个重要的模式是路由分离,如 技术 67 中所述,以及将应用程序定义在服务器之外的单独文件中(以便于测试和内部重用)。列表 9.20 是 列表 9.18 在 restify 中的对应版本。
列表 9.20. 一个 restify 应用

使用 restify,服务器实例通过一些初始配置选项创建
。你不必传递任何选项,但在这里我们指定了一个名称。这些选项实际上与 Node 内置的 http.Server.listen 相同,因此你可以传递 SSL/TLS 证书的选项,如果你想使用加密的话。restify 特有的选项,在 Express 中不可用,包括 formatters,它允许你设置 res.send 将用于自定义内容类型的函数。
此示例使用 bodyParser 解析请求体中的 JSON
。这就像上一个示例中的 Express 中间件组件。
路由定义与 Express 的定义相同
。实际的回调函数略有不同。列表 9.21 展示了 列表 9.19 的翻译。看看你是否能找出其中的差异。
列表 9.21. Restify 路由


首先要注意的是路由处理程序的回调参数与 Express
相同。实际上,你几乎可以直接从 Express 应用中提取等效代码。尽管如此,也有一些差异:req.param() 不存在——你需要使用 req.params 代替,注意这是一个对象而不是一个方法
。与 Express 一样,使用整数调用 res.send() 将向客户端返回状态码
。
使用其他 HTTP 头部
在这个技术中,你已经看到了如何使用 Content-Type 和 Accept 头部来处理不同的数据格式。在构建 API 时,你应该考虑其他有用的头部。
其中一个受 restify 支持的头部是 Accept-Version。当你定义一个路由时,你可以包含一个可选的第一个参数,该参数包含选项,而不是通常的字符串。version 属性允许你的 API 根据不同的 Accept-Version 头部做出不同的响应。
例如,使用 app.get({ path: '/pages', version: '1.1.8' }),将 .v1.pages 路由绑定到特定版本 1.1.8。如果你必须在 2.0.0 中更改你的 API,那么你可以这样做而不会破坏旧客户端。
在 Express 应用程序中使用此标题没有任何阻碍,但在 restify 中会更简单。如果你决定采取这种方法,你应该了解 major.minor.patch 在语义版本控制中的工作方式 (semver.org/)。
如果你下载了完整示例并运行它(listings/web/restify),你可以尝试我们之前描述的一些 Curl 命令。创建、更新和显示应该以相同的方式工作。
了解 Express 和 restify 应用程序相似是有用的,因为你可以开始组合由这两个框架组成的应用程序。它们都基于 Node 的 http 模块,这意味着技术上你可以在 Express 中挂载一个 restify 应用程序使用 app.use(restifyApp)。如果 restify 应用程序在其自己的模块中,这会工作得很好——你可以使用 npm 安装它,或者将其放在自己的目录中。
Express 和 restify 都使用中间件,你会发现结构良好的应用程序具有松散耦合的中间件,可以在不同的项目中重用。在下一个技术中,你将看到如何编写自己的中间件,这样你就可以开始用有用的功能,如自定义日志记录来装饰应用程序。
技巧 72 使用自定义中间件
你已经看到了中间件被用于错误处理,你也使用了一些 Express 的内置中间件。你还可以使用中间件为路由添加自定义行为;这可能添加了新功能,改进了日志记录,或者基于身份验证或权限控制访问。
中间件的好处是它可以提高你应用程序中的代码重用性。这个技术将教会你如何编写自己的中间件,这样你就可以在项目之间共享代码,并以更可读的方式组织项目。
问题
你希望添加行为——以可重用和可测试的方式——当访问某些路由时被触发。
解决方案
编写你自己的中间件。
讨论
当你第一次开始使用 Express 时,中间件听起来像是一个复杂的概念,其他人用它来编写扩展 Express 的插件。但实际上,编写中间件是使用 Express 的基本部分,你应该尽快开始编写中间件。而且如果你能编写路由,那么你就可以编写中间件:它基本上是相同的 API!
在 技巧 70 中,你看到了如何使用中间件组件处理错误。错误处理是一个特殊情况——你必须包含一个第四个参数来捕获错误对象:app.use(function(err, req, res, next) {。对于其他中间件,你可以只使用三个参数,就像标准的路由处理程序一样。这是最简单的中间件组件:

通过向app.use传递匿名回调
,中间件组件将始终运行,除非之前的中间件组件未能调用next。当您的代码完成后,您可以调用next
来触发堆栈中的下一个中间件组件。这意味着两件事:支持异步 API,并且添加中间件的顺序很重要。
以下示例展示了您如何在中间件中使用异步 API。此示例基于根据会话中设置的用户 ID 加载用户的思想:

此中间件将对每个请求触发
。它从数据库中加载用户账户,但仅在会话中设置了用户的 ID
。加载用户的代码是异步的,因此next可以在短暂的延迟后调用。有几个地方会调用next:例如,如果在加载用户时遇到错误,next将带有错误调用
。
在这个例子中,加载的用户被设置为res.locals属性
。通过使用res.locals,您将能够在其他中间件、路由处理程序和模板中访问用户。
这不一定是最有效的使用中间件的方式。以这种方式包含匿名函数意味着它很难测试——您只能通过启动整个 Express 应用程序来测试中间件。您可能想编写更简单的单元测试,这些测试不使用 HTTP 请求,因此最好将此代码重构为函数。该函数将具有相同的签名,并像这样使用:

通过将所有中间件模块化
,您可以从其他位置加载中间件,无论是完全不同的项目、测试代码,还是分离的路由中。这个功能将中间件解耦,以改善其可重用性。
如果您正在使用技术 67 中的路由分离模式,那么这样做是有意义的,因为中间件可以应用于可能定义在不同文件中的特定路由。假设您正在使用技术 71 中的 RESTful API 风格,并且您的页面资源只能由已登录用户更新,但应用程序的其他部分应该对任何人可访问。您可以这样限制对页面资源路由的访问:

在这个片段中,为名为pages的资源定义了路由。一些路由对任何人可访问
,但创建或更新页面仅限于系统上有账户的人
。这是通过在定义路由时将loadUser中间件组件作为第二个参数来实现的。实际上,可以使用多个参数——您可以有通用的用户加载路由,然后是一个更具体的权限检查路由,确保用户是管理员或拥有更改页面的必要权利。
图 9.4 展示了请求如何穿过几个回调,直到最终将响应发送回客户端。有时这可能会在其他中间件有机会运行之前完成响应——如果遇到错误并将其传递给 next(err)。
图 9.4. 请求可以穿过几个回调,直到最终响应被发送。

你甚至可以将中间件应用到路由批处理中。在 Express 应用程序中,常见到类似 app.all('/admin/*', middleware.loadUser); 的用法。
如果你使用模块来管理你的中间件,并通过将共享功能移动到单独的文件来简化路由处理程序,那么你会发现将中间件组织成模块成为组织应用程序的基本架构工具。
如果你正在设计一个新的 Express 应用程序,你应该从中间件的角度来思考。问问自己你将处理哪些类型的 HTTP 请求,以及它们可能需要什么样的过滤。
现在是时候将这些想法结合成一个工作示例了。列表 9.22 展示了处理包含 XML 的请求的一种方法。中间件被用来解析 XML,将其转换为普通的 JavaScript 对象。这意味着两件事:只有你代码的一小部分需要关心 XML,而且你还可以潜在地添加对其他数据格式的支持。
列表 9.22. 三种类型的中间件


总结来说,这个示例定义了三个中间件组件来解析 XML,验证它,然后要么以 JSON 对象的形式响应,要么显示错误。我们在这里使用了一个任意的数据验证库 ![1.jpg]——你的数据库模块可能自带类似的功能。
路由处理 页面 资源,页面的预期格式是 XML。它作为请求体传递并进行验证。当将无效数据发送到服务器时,使用错误对象 ValidatorError ![2.jpg] 返回 400 错误。XML 解析器 ![3.jpg] 使用标准基于事件的 API ![4.jpg] 读取请求体。这个中间件组件对每个请求都进行调用 ![8.jpg],因为它直接传递给 app.use,但它只有在 Content-Type 设置为 XML 时才会运行。
数据验证中间件组件 ![5.jpg] 确保页面标题已被设置——这只是一个我们选择的任意示例,用来说明这种验证是如何工作的。如果数据无效,当调用 next 时会传递一个 ValidatorError 实例 ![6.jpg]。这将触发错误处理中间件组件 ![7.jpg]。
只有在特定的请求中对数据进行验证。这是通过在定义 /pages 路由时传递 checkValidXml 来完成的 ![9.jpg]。
全局错误处理器是最后添加的中间件组件 ![10.jpg]。这应该是始终如此,因为中间件的执行顺序是按照定义的顺序。一旦调用 res.send,则不会进行更多处理,因此不会触发错误。
要尝试这个示例,请运行node server.js,然后使用curl将 XML 发布到服务器:
curl -H "Content-Type: application/xml" \
-X POST -d '<page><title>Node in Practice</title></page>' \
http://localhost:3000/pages
你应该尝试省略标题以确保引发 400 错误!
这种方法可以用于 XML、JSON、CSV 或你喜欢的任何其他数据格式。它非常适合最小化处理 XML 的代码,但还有其他方法可以在 Node 网络应用程序中编写解耦代码。在下一个技巧中,你将看到 Node 的一个基本功能——事件——如何被用作另一个有用的架构模式。
技巧 73 使用事件解耦功能
在平均的 Express 应用程序中,大多数代码被组织成方法和模块。这可能在某些情况下使功能共享变得不方便,尤其是如果你想在你应用程序中整齐地分离关注点。这个技术以发送电子邮件作为例子,说明了一些不适合放入路由器、模型或视图中的东西。事件被用来解耦电子邮件和路由器,将电子邮件相关的代码保持在 HTTP 代码之外。
问题
你想做一些与 HTTP 无关的事情,比如发送电子邮件,但不确定如何构建代码以便它能够整齐地解耦且易于测试。
解决方案
使用易于访问的EventEmitter对象,例如 Express 的app对象。
讨论
Express 和 restify 应用程序通常遵循模型-视图-控制器(MVC)模式。模型用于保存数据,控制器是路由处理器,视图是 views 目录中的模板。
有些代码并不适合整齐地归入这些类别。例如,你会把处理电子邮件的代码放在哪里?显然,电子邮件生成不属于路由,因为电子邮件与 HTTP 无关。但就像路由处理器一样,它确实需要模板。它也不是真正的模型,因为它不与数据库交互。
如果你把处理电子邮件的代码放入模型中会怎样?在这种情况下,给定一个User模型的实例,你希望在创建新账户时发送电子邮件。你可以在User.prototype.registerUser方法中放置电子邮件代码。问题是,你可能并不总是想在用户创建时发送电子邮件。在测试期间可能不方便,或者是一些周期性的维护任务。
为什么发送电子邮件并不完全适合模型或 HTTP 路由可以通过思考 SOLID 原则(en.wikipedia.org/wiki/SOLID)来理解。有两个原则与我们相关:单一职责原则和依赖倒置原则。
单一职责原则规定,处理 HTTP 路由的类实际上真的不应该发送电子邮件,因为这些是不同的职责,不应该混合在一起。控制反转是依赖倒置的一种特定类型,可以通过移除直接调用来实现——而不是调用emails.sendAccount-Creation,你的电子邮件处理类应该响应事件。
对于 Node 程序员来说,事件是他们可用的重要工具之一。幸运的是,SOLID 原则表明我们可以通过移除我们的电子邮件代码,并用抽象和通用的事件来替换它,从而编写更好的 HTTP 路由器。这些事件然后可以被相关类响应。
图 9.5 显示了我们的理想化应用程序结构可能的样子。但我们如何实现这一点呢?以 Express 应用程序为例;它们通常没有合适的全局事件对象。你可以在某个中心位置技术上创建一个全局变量,比如调用express()的文件,但这会引入全局共享状态,这将破坏我们之前描述的原则。
图 9.5. 如果根据 SOLID 原则组织,应用程序可能更容易理解。

幸运的是,Express 在请求中包含对app对象的引用。路由处理程序,接受req, res参数,始终可以在res.app中访问app。app对象继承自EventEmitter,因此我们可以用它来广播事件发生。如果你的路由处理程序创建并保存了新用户,那么它也可以调用res.app.emit('user:created', user),或者类似的东西——只要命名方案一致,你可以使用任何命名方案来表示事件。然后你可以监听user:created事件并相应地做出反应。这可能包括发送电子邮件通知,或者甚至记录有关用户的有用统计数据。
以下列表显示了如何在应用程序对象上监听事件。
列表 9.23. 使用事件来构建应用程序

在此示例中定义了一个用于注册用户的路由
,然后定义了一个事件监听器并将其绑定到一个发送电子邮件的方法
。
路由在下一列表中显示。
列表 9.24. 发射事件

此列表包含User对象的示例模型。如果用户成功创建,则app对象上会发出user:created。本书的可下载代码包括一个更完整的示例,其中包含发送电子邮件的代码,但移除直接调用并遵循单一责任原则的基本原则在这里得到了体现。
在应用程序内部使用事件进行通信,当你需要使代码更容易让其他开发者理解时很有用。也有时候你需要与客户端代码进行通信。下一项技术将向你展示如何在 Node 应用程序中利用 WebSockets,同时仍然能够访问会话等资源。
技巧 74 使用 WebSockets 与会话
Node 对实时 Web 有很强的支持。采用面向事件、异步 API 意味着支持 WebSockets 是一个自然的选择。此外,在同一进程中运行两个服务器是微不足道的:WebSocket 服务器和标准 Node HTTP 服务器可以愉快地共存。
这种技术展示了如何重用我们迄今为止与 WebSocket 服务器一起使用的 Connect 和 Express 中间件。如果你的应用程序允许用户登录,并且你想添加 WebSocket 支持,那么继续阅读以了解如何掌握 WebSocket 中的会话。
问题
你想为现有的 Express 应用程序添加 WebSocket 支持,但你不确定如何访问会话变量,比如用户是否当前已登录。
解决方案
在你的 WebSocket 服务器上重用 Connect 的 cookie 和 session 中间件。
讨论
这种技术假设你对 WebSockets 有一定的了解。为了回顾:HTTP 请求是无状态的,相对较短的生命周期。它们非常适合下载文档,以及请求资源的状态改变。但关于从服务器到服务器的数据流怎么办呢?
某些类型的事件起源于服务器。想想一个网络邮件服务。当你创建并发送一条消息时,你将其推送到服务器,服务器将其发送给收件人。如果收件人正在查看他们的收件箱,他们的浏览器没有简单的方法来更新。他们可以使用 Ajax 请求定期检查新消息,但这并不优雅。服务器知道它有新的消息要发送给收件人,所以如果它能直接将那条消息推送到用户,那就更好了。
这就是 WebSocket 发挥作用的地方。它们在概念上类似于我们在第七章中看到的 TCP 套接字:在客户端和服务器之间建立了一个双向的桥梁。为此,你需要在标准的 Express 服务器或普通的 Node http服务器之外,还需要一个 WebSocket 服务器。图 9.6 说明了这在典型的 Node 网络应用中的工作方式。
图 9.6. 一个 Node 网络应用应该支持标准的 HTTP 请求和 WebSockets。

HTTP 请求是短暂的,有特定的端点,并使用POST和PUT等方法。WebSockets 是持久的,没有特定的端点,也没有方法。它们在概念上是不同的,但由于它们用于与同一应用程序通信,它们通常需要访问相同的数据。
这给会话带来了问题。我们之前看过的 Express 示例使用了中间件来自动加载会话。Connect 中间件基于 HTTP 请求和响应,那么我们如何将它们映射到持久且双向的 WebSockets 上呢?为了理解这一点,我们需要看看 WebSockets 和会话是如何工作的。
会话是基于包含在 cookie 中的唯一标识符加载的。cookie 会随每个 HTTP 请求一起发送。WebSockets 通过一个标准的 HTTP 请求启动,请求升级到 WebSocket。这意味着有一个点可以抓取请求中的 cookie,然后加载会话。对于每个 WebSocket,您都可以存储对用户会话的引用。现在您可以使用会话执行所有需要执行的操作:验证用户是否已登录,设置首选项,等等。
图 9.7 扩展了图 9.6,展示了如何通过结合用于解析 cookie 和加载会话的 Connect 中间件来使用 WebSockets。
图 9.7. 通过 WebSockets 访问会话

现在您已经知道了各个部分是如何配合工作的,那么您是如何构建它的呢?cookie 解析中间件组件可以在express.cookieParser中找到。这实际上是一个简单的方法,它从请求头中获取 cookie,然后将 cookie 字符串解析成单独的值。它接受一个参数,secret,这是用于签名 cookie 的值。一旦 cookie 被解密,您就可以从中获取会话 ID 并加载会话。
Express 中的会话是基于存储和检索值的异步 API 建模的。它们可以由数据库支持,或者您可以使用内置的基于内存的类。通过传递会话 ID 和回调函数到sessionStore.get,如果会话 ID 正确,则会加载会话。
在这个技术中,我们将使用ws WebSocket 模块(www.npmjs.org/package/ws)。这是一个快速但功能最少的实现,其 API 与 Socket.IO 非常不同。如果您想了解 Socket.IO,那么Node in Action有一些非常优秀的教程。在这里,我们使用一个更简单的模块,这样您就可以真正看到 WebSockets 是如何工作的。
要使ws加载会话,您需要解析 HTTP 升级请求中的 cookie,然后调用sessionStore.get。以下是一个完整示例,展示了它是如何工作的。
列表 9.25. 使用 WebSockets 的 Express 应用程序


此示例首先加载和配置了 cookie 解析器!和会话存储!。我们使用签名 cookie,所以请注意,在稍后加载会话时使用ws.upgradeReq.signedCookies。
Express 已配置为使用会话中间件组件!,我们已创建了一个可用于测试的路由!。只需在浏览器中加载 http://localhost:3000/random,即可在会话中设置一个随机值,然后访问 http://localhost:3000/以查看它被打印出来。
ws模块通过使用普通的构造函数WebSocketServer来处理 WebSockets。要使用它,您需要用 Node HTTP 服务器对象实例化它——我们在这里传递了 Express 服务器!一旦服务器启动,它将在创建连接时发出事件!。
此示例的客户端代码向服务器发送 JSON,因此有一些代码用于解析 JSON 字符串并检查其是否有效
。这对于此示例来说并非完全必要,但我们包括它以展示 ws 在大多数实际情况下需要这种额外的工作才能使用。
一旦 WebSocket 服务器建立了连接,会话 ID 可以通过升级请求中的 cookies 访问
。这与 Express 在幕后所做的工作类似——我们只需要手动将升级请求的引用传递给 cookie-parser 中间件组件。然后使用会话存储的 get 方法加载会话
。一旦会话被加载,就会向客户端发送一条包含会话值的消息
。
运行此示例所需的关联客户端实现如下所示。
列表 9.26. 客户端 WebSocket 实现

它所做的只是定期向服务器发送消息。直到你访问 http://localhost:3000/random,它将显示 undefined。如果你打开两个窗口,一个到 http://localhost:3000/random,另一个到 http://localhost:3000/,你将能够不断刷新随机页面,以便 WebSocket 视图显示新的值。
运行此示例需要 Express 3 和 ws 0.4——我们在书的全列表中包含了你需要的一切。
下一个技术提供了从 Express 3 迁移到 Express 4 的技巧。
技巧 75 将 Express 3 应用程序迁移到 Express 4
本书是在 Express 4 发布之前编写的,因此我们的 Express 示例是以框架的 3 版本为基础编写的。我们包括这个技巧是为了帮助你迁移,同时也是为了让你看到版本 4 与之前版本的不同之处。
问题
你有一个 Express 3 应用程序,并希望将其升级以使用 Express 4。
解决方案
更新你的应用程序配置,安装缺失的中间件,并利用新的路由 API。
讨论
从 Express 3 到 4 的大多数更新都经过了很长时间。某些更改已在 Express 3 的文档中提及,因此 API 变化并不令人意外,甚至大部分情况下也不太剧烈。你可能大部分时间都会花费在替换曾经与 Express 一起提供的中间件上,因为 Express 4 除了 express.static 之外不再有任何内置的中间件组件。
express.static 中间件组件使 Express 能够挂载包含 JavaScript、CSS 和图像资源的 public 文件夹。这被保留下来是因为它很方便,但其他中间件组件已经不再使用。这意味着如果你之前使用了 bodyParser,你需要使用 npm install --save body-parser。请参考表 9.1,其中包含旧中间件名称和较新的等效名称。只需记住,你需要使用 npm install --save 安装你需要的每个组件,然后在你的 app.js 文件中 require 它。
表 9.3. 迁移 Express 中间件组件
| Express 3 | Express 4 npm 包 | 描述 |
|---|---|---|
| bodyParser | body-parser | 解析 URL 编码和 JSON POST 请求体 |
| compress | compression | 压缩服务器的响应 |
| timeout | connect-timeout | 允许请求在耗时过长时超时 |
| cookieParser | cookie-parser | 从 HTTP 头部解析 cookies,并将结果留在 req.cookies 中 |
| cookieSession | cookie-session | 使用 cookies 提供简单的会话支持 |
| csrf | csurf | 向会话中添加一个令牌,可用于保护表单免受 CSRF 攻击 |
| error-handler | errorhandler | Connect 默认使用的错误处理器 |
| session | express-session | 简单的会话处理器,可以通过 stores 扩展,将会话写入数据库或文件 |
| method-override | method-override | 将新的 HTTP 请求方法映射到 _method 请求变量 |
| logger | morgan | 日志格式化 |
| response-time | response-time | 跟踪响应时间 |
| favicon | serve-favicon | 发送 favicons,包括一个内置的默认图标,如果你还没有的话 |
| directory | serve-index | 目录列表,类似于 Apache 的目录索引 |
| vhost | vhost | 允许路由匹配子域名 |
你可能不会使用这些模块中的大多数。在我的应用程序中,我(亚历克斯)通常只有 body-parser、cookie-parser、csurf、express-session 和 method-override,因此迁移并不困难。以下列表显示了一个使用这些中间件组件的小型应用程序。
列表 9.27. Express 4 中间件

要安装 Express 4 和必要的中间件,你应该在新的目录中运行以下命令:
npm install --save body-parser cookie-parser \
csurf express-session method-override \
serve-favicon express
这将安装所有必需的中间件模块以及 Express 4,并将它们保存到 package.json 文件中。一旦你使用 require 加载了中间件组件![],你就可以像在 Express 3 中一样使用 app.use 将它们添加到应用程序的堆栈中。路由处理器可以像在 Express 3 中一样添加![]。
官方迁移指南
Express 的作者编写了一份迁移指南,可在 GitHub 上的 Express wiki 中找到。^([3)] 这包括每个更改的快速概述。
³
github.com/visionmedia/express/wiki/Migrating-from-3.x-to-4.x
你不能再使用 app.configure 了,但停止使用它应该很容易。如果你使用 app.configure 只是为了特定环境执行某些操作,那么只需使用带有 process.env.NODE_ENV 的条件语句。以下示例假设有一个虚构的中间件组件 logger,它可以被设置为嘈杂,这在测试运行时可能不是所希望的:
if (process.env.NODE_ENV !== 'test') {
app.use(logger({ verbose: true }));
}
新的路由 API 强调了可以在不同端点上挂载的微型应用程序的概念。这意味着你的 RESTful 资源可以省略 URL 中的资源名称。你不再需要编写 app.get('/songs', songs.index),现在你可以编写 songs.get('/', index) 并使用 app.use 在 /songs 上挂载 songs。这与 技术 67 中的路由分离模式很好地结合在一起。
下一个列表展示了如何使用新的路由 API。
列表 9.28. Express 4 中间件

在创建了一个新的路由
之后,你可以像以前一样添加路由,使用 HTTP 动词如 get
。这个功能很酷,你还可以添加仅限于这些路由的中间件:只需调用 songs.use。在 Express 的旧版本中,这曾经更复杂。
一旦你设置了一个路由器,你可以使用 URL 前缀
来挂载它。这意味着你可以做诸如在不同的 URL 上挂载相同的路由处理程序以轻松地别名它们的事情。
如果你将路由器放在它们自己的文件中,并在你的主 app.js 文件中挂载它们,那么你甚至可以将路由器作为模块在 npm 上分发。这意味着你可以从可重用的路由器中组合应用程序。
我们关于 Express 4 的最后一件事是新的 router.param 方法。这允许你在某些路由参数存在时运行异步代码。假设你有 '/songs/:song_id',而 :song_id 应该始终是数据库中有效的歌曲。使用 route.param,你可以在任何路由处理程序运行之前验证该值是否为数字并且存在于数据库中!
router.param('song_id', function(req, res, next, id) {
Song.find(id, function(err, song) {
if (err) {
return next(err);
} else if (!song) {
return next(new Error('Song not found'));
}
req.song = song;
next();
});
});
router.get('/songs/:song_id', function(req, res, next) {
res.send(req.song);
});
在这个例子中,Song 被假定为从数据库中获取歌曲的一个类。实际的路由处理程序现在非常简单,因为它只会在找到有效的歌曲时运行。否则,next 将会跳过执行并传递一个错误给错误处理中间件。
这部分关于网络应用程序开发技术的讨论就到这里。在我们进入下一章之前,还有一件重要的事情需要说明。就像其他所有事情一样,网络应用程序应该得到充分的测试。下一节将介绍我们在测试网络应用程序时发现的一些有用技术。
9.3. 测试网络应用程序
测试可能会感觉像是一项繁琐的工作,但它也可以是验证想法不可或缺的工具,尤其是当你没有用户界面创建网络 API 时。
第十章介绍了 Node 中的测试,技术 84 提供了一个测试 Web 应用的示例。在下一个技术中,我们将扩展这个示例来展示如何测试认证路由。
技术编号 76:测试认证路由
测试框架如 Mocha 使测试易于阅读和编写,SuperTest 有助于保持与 HTTP 相关的测试整洁。但认证支持通常不会内置到这样的模块中。在这个技术中,你将学习一种处理测试中认证的方法,这种方法足够通用,可以与其他测试模块一起重用。
问题
你想测试你的应用程序中基于会话的用户名和密码的部分。
解决方案
在测试的设置阶段发送一个登录请求,然后重用 cookies 进行后续测试。
讨论
一些 Web 框架和测试库为你处理会话,因此你可以测试路由而不必过多担心登录。这对于我们在这本书中之前使用过的 Mocha 和 SuperTest 来说并不成立,所以你需要了解一些关于会话如何工作的知识。
Express 从 Connect 使用的会话处理是基于 cookie 的。一旦 cookie 被设置,就可以用来加载用户的会话。这意味着要编写一个测试来访问应用程序的安全部分,你需要发送一个登录请求,获取 cookies,然后使用这些 cookies 进行后续请求。这个过程在图 9.8 中展示。
图 9.8。你可以通过捕获 cookie 来测试认证路由。

要编写访问认证路由的测试,你需要一个测试用户账户,这通常涉及到创建数据库固定值。你将在第十章和技术 87 中了解到固定值。
一旦数据准备就绪,你可以使用 SuperTest 之类的库向你的会话处理端点发送一个带有用户名和密码的POST请求。Cookies 是通过 HTTP 头部传输的,因此你可以从res.headers['set-cookie']中读取它们。你还应该进行断言以确保账户已登录。
现在任何新的请求只需要设置Cookie头部,使用res.headers中的值,你的测试用户就会登录。下一个列表展示了这是如何工作的。
列表 9.29。测试认证请求

测试的第一部分加载所需的模块并设置一个示例用户
。这通常会被存储在数据库中,或者通过固定值设置。接下来,使用用户名和密码发送一个POST请求
。会话 cookie 将在set-cookie头部中可用
。
要访问登录后的路由
,请设置Cookie头部,使用之前保存的 cookies
。你应该会发现请求被处理得就像用户正常登录一样。
通过查看 Connect 的会话中间件组件的工作方式,可以学会理解带有会话的测试的技巧。其他中间件在测试期间并不容易管理,所以下一个技巧引入了测试 接口 的概念,这将允许你在测试期间控制中间件。
技巧 77 为中间件注入创建接口
中间件是灵活的和可组合的。这种模块化方法使得基于 Connect 的应用程序易于工作。但中间件也有一个缺点:可测试性。一些中间件使得路由本身难以测试。这个技巧通过创建 接口 来探讨如何解决这个问题。
问题
你正在使用使你的应用程序难以测试的中间件。
解决方案
找到在测试期间可以替换中间件的接口。
讨论
术语 接口 是一种正式的描述代码中可以更改而不需要编辑原始代码的地方的方式。这个概念被扩展到适用于 JavaScript 等语言,由 Stephen Vance 在他的书 Quality Code: Software Testing Principles, Practices, and Patterns 中提出。^([4])
⁴
www.informit.com/store/quality-code-software-testing-principles-practices-9780321832986在我们的代码中,接口为我们提供了控制该代码并在测试环境中执行它的机会。任何我们可以执行、覆盖、注入或控制的代码的地方都可能是一个接口。
这的一个例子是 Connect 的 csrf 中间件组件。它创建了一个会话变量,可以包含在表单中以避免跨站请求伪造攻击。假设你有一个允许注册用户创建日历条目的网络应用程序。如果你的网站没有使用 CSRF 保护,有人可以创建一个网页,诱骗你的网站用户删除他们的日历项目。攻击可能看起来像这样:
<img src="http://calendar.example.com/entry/1?_method=delete">
用户的浏览器将尽职尽责地加载托管在外部站点上的图像源。但它以可能危险的方式引用你的站点。为了防止这种情况,每次请求都会生成一个随机令牌并将其插入到表单中。攻击者无法访问令牌,因此攻击被缓解。
不幸的是,仅仅将 express.csrf 添加到渲染表单的路由中并不完全可测试。测试无法在没有首先加载表单并刮除包含 CSRF 令牌的会话变量之前向路由处理程序发送帖子。
为了解决这个问题,你需要将 express.csrf 控制在自己的手中。重构它以创建一个接口:将其放置在一个包含你其他自定义中间件的模块中,然后在测试期间进行更改。你不需要测试 express.csrf,因为 Express 和 Connect 的作者已经为你做了这项工作——相反,你可以在测试期间更改其行为。
另外两个选项是可用的:检查process.env.NODE_ENV是否设置为test,然后分支到仅测试版本的 CSRF 中间件组件,或者修补express.csrf的内部结构,以便你可以提取密钥令牌。这两种方法都存在问题:第一种意味着你不能获得 100%的代码覆盖率——你的生产代码必须包含测试代码。第二种方法可能是脆弱的:它对 Connect 未来更改 CSRF 工作方式过于敏感。
我们将使用的基于缝隙的概念要求你创建一个中间件文件,如果你还没有的话。这只是一个将所有中间件组合成一个可以轻松加载的模块的文件。然后你需要创建一个函数,它围绕express.csrf包装,或者只是返回它。以下是一个基本示例。
列表 9.30. 控制中间件

这只是导出原始的csrf中间件组件
,但现在在测试期间注入不同的行为要容易得多。下面的列表显示了这样的测试可能的样子。
列表 9.31. 在测试期间注入新行为

这个测试在加载其他任何内容之前加载我们的自定义中间件模块,然后替换csrf方法
。当它加载app并使用 Super-Test 发起请求时,Express 将使用我们注入的中间件组件,因为middleware.js将被缓存。_csrf值被设置,以防任何视图期望它
,并且请求应该返回 200 而不是 403(禁止)
。
可能看起来我们并没有做很多事情,但通过重构express.csrf的加载方式,我们已经能够以更可测试的方式运行我们的应用程序。你可能更喜欢进行两次请求以确保csrf中间件组件被正常使用,但这项技术也可以用于其他事情。你可以控制任何中间件进行测试。如果你在测试期间不想运行某些内容,寻找允许你注入所需行为的缝隙,或者尝试使用简单的 JavaScript 或 Node 模式创建缝隙——你不需要复杂的依赖注入框架;你可以利用 Node 的模块系统。
下一个技术是在这些想法的基础上构建的,允许测试与远程服务的模拟版本进行交互。如果你正在为访问远程服务(如支付网关)的应用程序编写测试,这将使事情变得更容易。
技巧 78 测试依赖于远程服务的应用程序
第三方模块可以帮助你将你的应用程序与 GitHub、Twitter 和 Facebook 等远程服务集成。但你是如何测试依赖于这些远程服务应用程序的呢?这项技术探讨了插入远程依赖项存根的方法,以使你的测试更快、更易于维护。
问题
您正在使用社交网络进行身份验证,或者使用接受支付的服务,并且您不希望您的测试访问这些远程依赖。
解决方案
找到您的应用程序、远程服务和您想要测试的内容之间的缝隙,然后插入您自己的 HTTP 服务器来模拟远程依赖的部分。
讨论
大多数 Web 应用程序都需要,但很容易出错的事情之一是用户账户。使用支持 GitHub、Google、Facebook 和 Twitter 等公司提供的授权服务的 Node 模块既快又可能比创建定制解决方案更安全。
采用这些服务中的一个相对容易,但您如何测试它呢?在技术 76 中,您看到了如何编写认证路由的测试。这涉及到登录并保存会话 cookie,以便后续请求看起来是经过认证的。您不能使用相同的方法来测试远程服务,因为您的测试将不得不向真实的生产服务发出请求。您可以使用测试账户,但如果您想离线运行测试呢?
为了解决这个问题,您需要在您的应用程序和远程服务之间创建一个缝隙。每当您的应用程序尝试与远程服务通信时,您需要插入一个发出类似响应的假版本。在单元测试中,模拟对象模拟其他对象。您想要模拟一个服务。
您的应用程序需要满足以下两个要求才能实现这一点:
-
可配置的远程服务
-
可以替代远程服务的 Web 服务器
第一个条件意味着您的应用程序应该允许更改远程服务的 URL。如果它需要连接到auth.example.com/signin,那么在测试期间您需要指定 http://localhost:3001/signin。端口号完全由您决定——我们见过的一些解决方案使用一系列端口号,以便在相同的测试中同时运行多个服务。
第二个条件可以按您希望的任何方式处理。如果您使用 Express,您可以使用定义了有限路由集的 Express 服务器开始——只需要足够的路由和代码来模拟远程服务。这个服务器可以放在它自己的模块中,并在需要它的测试中加载。
实际上这不需要太多的代码,所以一旦您理解了原理,就不应该太难重用它来处理几乎任何 API。如果您试图模拟的 API 没有很好地记录,那么您可能需要捕获真实请求来了解它是如何工作的。
调查远程 API
有时候远程 API 没有很好地记录。一旦您超越了基本的 API 调用,肯定会有一些部分不容易理解。在这种情况下,我们发现最好使用命令行工具如curl发出请求,并在 HTTP 日志工具中观察请求和响应。
如果你使用的是 Windows,那么 Fiddler (www.telerik.com/fiddler) 是绝对必要的。它被描述为一个 HTTP 调试代理,并且也支持 HTTPS。

Glance 内置了错误页面。
对于 Linux 和 Mac OS,mitmproxy (mitmproxy.org/) 是一个强大的选择。它允许实时观察、转储、保存和重放 HTTP 流量。我们发现它非常适合调试我们自己的支持桌面应用程序的 Node.js 驱动的 API,以及了解某些流行支付网关的怪癖。
在接下来的三个列表中,你将看到如何创建一个测试可以用来模拟 PayPal 的一些行为的 模拟服务器。第一个列表显示了应用程序本身。
列表 9.32. 一个使用 PayPal 的小型网店

文件顶部附近的 PayPal 类传递的设置用于控制 PayPal 的行为。其中之一,payPalUrl,可能是 www.sandbox.paypal.com/cgi-bin/webscr 以测试 PayPal 的测试服务器。在这里,我们使用本地 URL,因为我们打算运行自己的模拟服务器。
如果这是一个真实的项目,你应该使用配置文件来存储这些选项。每个环境一个配置文件是有意义的。然后测试配置可以指向本地服务器,测试环境可以使用 PayPal 沙盒,而生产环境可以使用 PayPal.com。有关配置文件的更多信息,请参阅技术 69。
要进行支付,用户将被转发到 PayPal 的托管表单。我们的演示 PayPal 类具有生成此 URL 的能力,并且它将使用 payPalUrl
。此示例还展示了支付通知处理
——在 PayPal 的术语中称为 IPN。
我们在这里添加的一个额外功能是调用 emit
。这使得测试变得更加容易,因为我们的测试现在可以监听 purchase:accepted 事件。这对于设置电子邮件处理也非常有用——有关更多信息,请参阅技术 73。
现在是模拟 PayPal 服务器。它需要做的只是处理 IPN 请求。它基本上需要说,“是的,那个购买已被验证。”它还可以选择性地报告错误,这样我们也可以测试我们自己的错误处理。下一个列表显示了这个微型模拟服务器的外观。
列表 9.33. 模拟 PayPal 的 IPN 请求

在实际生活中,PayPal 商店在销售流程接近尾声时从 PayPal 接收一个包含订单详情的 POST 请求。你需要接收那个订单并将其发送回 PayPal 进行验证。这可以防止攻击者构建一个 POST 请求,欺骗你的应用程序认为已进行了虚假购买。
此示例包括一个切换器,可以打开错误
。我们在这里不会使用它,但在实际项目中它很有用,因为您会想测试错误处理的方式。会有客户遇到错误,因此确保它们得到优雅的处理至关重要。
一切准备就绪后,我们只需发送回文本 VERIFIED
。这就是 PayPal 所做的全部——有时它可能会让人感到令人沮丧的晦涩难懂!
最后,让我们看看一个将所有这些内容结合在一起的测试。接下来的列表使用模拟 PayPal 服务器和我们的应用程序进行购买。
列表 9.34. 测试 PayPal


此测试设置了一个示例订单
,它需要一个客户
。我们还创建了一个具有与 PayPal IPN 请求相同字段的对象——这是我们打算发送到我们的模拟 PayPal 服务器进行验证的。在每个测试的
和
之前,我们必须启动和停止模拟 PayPal 服务器。这是因为我们不希望在不必要时运行服务器——这可能会使其他测试行为异常。
当用户在我们的网站上填写订单表单时,它将被发布到一个生成 PayPal URL 的路由。PayPal URL 将将用户的浏览器转发到 PayPal 进行支付。列表 9.34 包含了这个测试
,它生成的 URL 将以我们从 列表 9.32 的本地测试 PayPal URL 开头。
还有对 PayPal 发送的通知的测试
。这是我们关注的需要 PayPal 模拟服务器的测试。首先,我们必须向我们的服务器 /paypal/success 发送 POST 请求,并带上通知对象
——这是 PayPal 通常会做的——然后我们的应用程序将向 PayPal 发送 HTTP 请求,这将击中模拟服务器,然后返回 VERIFIED。测试简单地确保返回 200,但它也能够监听 purchase:accepted 事件,这表示特定的购买已完成。
这可能看起来工作量很大,但一旦您的远程服务通过模拟服务器进行模拟,您将能够更有效地工作。您的测试运行得更快,您还可以离线工作。您还可以让模拟服务生成各种不同寻常的响应,如果这是您的目标之一,这将有助于您获得更好的测试覆盖率。
这是我们在本章中涵盖的最后一个与网络相关的技术。接下来的几节将讨论 Node 网络开发的最新趋势。
9.4. 全栈框架
在本章中,您已经看到了如何使用 Node 的内置模块、Connect 和 Express 构建网络应用程序。有一类新兴的新框架被称为 全栈框架。它们提供了创建丰富、基于浏览器的应用程序所需的特性,例如数据绑定,同时也处理服务器端的问题,如建模业务逻辑和数据持久性。
如果你决心使用 Express,那么你仍然可以今天就开始使用全栈框架。MEAN 解决方案堆栈使用 MongoDB、Express、AngularJS 和 Node。可能存在许多 MEAN 实现,但 Linnovate 的 MEAN Stack (github.com/linnovate/mean) 目前是最受欢迎的。它包含 Mongoose 数据模型、Passport 授权和 Twitter Bootstrap 用户界面。如果你在一个已经熟悉 Bootstrap、AngularJS 和 Mongoose 的团队中工作,那么这是一个快速启动新项目的好方法。
书籍 Getting MEAN^([5]) 介绍了全栈开发,并涵盖了 Mongoose 模型、RESTful API 设计以及使用 Facebook 和 Twitter 的账户管理。
⁵ Getting MEAN by Simon Holmes:
www.manning.com/sholmes/.
另一个基于 Express 和 MongoDB 的框架是 Derby (derbyjs.com/)。Derby 使用 Racer 而不是 Mongoose 来实现数据模型。这允许使用操作转换 (OT) 同步来自不同客户端的数据。OT 是专门为支持协作系统而设计的,因此 Derby 是开发受 Etherpad (etherpad.org/) 启发的软件的好选择。它还具有客户端功能,如模板和数据绑定。
如果你喜欢 Express 但想要更多功能,那么我们还没有介绍的一个选择是 PayPal 的 Kraken (krakenjs.com/)。这个框架通过添加配置、控制器、Grunt 任务和测试的子目录来为 Express 项目添加更多结构。它还支持开箱即用的国际化。
一些框架几乎完全专注于浏览器,仅使用 Node 进行敏感操作和数据持久化。一个流行的例子是 Meteor (www.meteor.com/)。像 Derby 和 MEAN Stack 一样,它使用 MongoDB,但创建者计划支持其他数据库。它基于 pub/sub 架构,其中 JSON 文档在客户端和服务器之间推送。客户端保留文档的内存副本——服务器发布文档集,而客户端订阅它们。这意味着浏览器中的大多数模型相关代码都可以同步编写。
Meteor 接受了反应式编程,这是一种目前在桌面开发领域流行的范式。这允许 反应式计算 与方法绑定。如果你将一个函数订阅到这样的值,当值改变时,函数将被重新运行。在实际应用中的总体效果是代码流线化——基本上减少了 pub/sub 管理和事件处理代码。
Hoodie (hood.ie/)是 Meteor 的竞争对手。它使用 CouchDB,适合移动应用,因为它在可能的情况下同步数据。几乎所有的事情都可以在本地发生。它内置了账户管理,就像hoodie.account.signUp('alex@example.com', 'pass')这样简单。甚至还有一个全局公共存储库,因此数据可以保存为特定用户使用,或者通过给定应用程序对所有人可用。
在 Node 网络框架场景中有很多活动,但我们还没有提到 Node 网络开发的另一个方面:实时开发。
9.5. 实时服务
Node 是网络实时服务的自然选择。从广义上讲,这涉及三种类型的应用程序:统计服务器、协作服务和像游戏服务器这样的对延迟敏感的应用程序。
使用 Express 启动服务器并收集有关您的其他应用程序、服务器、天气传感器数据或喂狗机器人的数据并不困难。不幸的是,做得好并不简单。如果你每次有人玩你的免费 iOS 游戏时都记录一些东西,那么当每分钟有数千个事件发生时会发生什么?你如何扩展它,或者实时查看关键信息?
一些公司面临着巨大的这个问题,幸运的是,其中一些公司已经创建了开源工具,我们可以重用。一个例子是 Square 的 Cube (square.github.io/cube/)。Cube 允许你收集带时间戳的事件,然后对它们进行度量。它使用 MongoDB,因此你可以将数据输出到生成图表的东西。Square 有一个用于可视化数据的解决方案,称为 Cubism.js (square.github.io/cubism/),它实时渲染新值(见图 9.9)。
图 9.9. Cubism.js 实时显示时间序列值。

Etherpad 项目 (etherpad.org/)是一个由 Node 驱动的协作文档编辑器。它允许用户在修改文档时聊天,并使用颜色编码变化,以便容易看到每个人在做什么。它基于本书中的一些模块:Mikeal Rogers 的request、Express 和 Socket.IO。
WebSockets 使得这些项目成为可能。没有 WebSockets,向客户端推送数据将会更加繁琐。Node 有一套丰富的 WebSockets 实现——Socket.IO (socket.io/)是最受欢迎的,但也有ws (www.npmjs.org/package/ws),它声称是速度最快的 WebSocket 实现。
套接字和流之间存在平行关系;SocketStream (socketstream.org/)旨在通过构建完全围绕流的 Web 应用程序来弥合差距。它使用 HTML5 history.pushState API 与单页应用程序、Connect 中间件以及与浏览器的代码共享。
9.6. 摘要
在本章中,你已经看到了 Node 如何与现代 Web 开发相结合。它可以用于改进客户端工具——现在客户端开发者安装 Node 和 Node 构建工具已经成为常态。
Node 也被用于服务器端开发。Express 是主要的 Web 框架,但许多项目可以使用 Connect 的子集启动。其他框架与 Express 类似,但有不同的重点。Restify 是一个例子,可以用来创建严格的 RESTful API (技术 71)。
编写结构良好的 Express 应用程序意味着你应该采用 Node 社区已经采用的某些模式和习惯用法。这包括错误处理 (技术 70)、文件夹作为模块和路由分离 (技术 67) 以及通过事件解耦 (技术 73)。
在浏览器中使用 Node 模块 (技术 66) 和 Node 中的客户端代码 (技术 65) 也越来越普遍。
如果你想编写更好的代码,你应该尽快采用测试驱动开发。我们包括了一些使你能够测试诸如身份验证 (技术 76) 和模拟远程 API (技术 78) 的技术,但编写测试来思考新代码是提高你的 Node Web 应用程序的最好方法之一。你可以这样做的一种方式是,每次你想向 Web 应用程序添加新路由时,先编写测试。练习使用 Super-Test 或类似的 HTTP 请求库,并使用它来规划新的 API 方法、网页和表单。
下一章将向您展示如何编写更好的测试,无论是简单的脚本还是数据库驱动的 Web 应用程序。
第十章. 测试:自信代码的关键
本章涵盖
-
断言、自定义断言和自动化测试
-
确保事物按预期失败
-
Mocha 和 TAP
-
测试 Web 应用程序
-
持续集成
-
数据库固定值
假设你想要向在线商店添加一种新的货币。首先,你会添加一个测试来定义预期的计算:小计、税和总计。然后你会编写代码使这个测试通过。本章将帮助你通过查看 Node 的内置测试功能来学习如何编写测试:assert模块和你可以设置在 package.json 文件中的测试脚本。我们还介绍了两个主要的测试框架:Mocha 和node-tap。
测试简介
本章假设您在编写单元测试方面有一些经验。表 10.1 包含了所使用术语的定义;如果您想了解我们所说的断言、测试用例或测试框架是什么,可以参考此表。
表 10.1. 节点测试概念
| 术语 | 描述 |
|---|---|
| 断言 | 允许您测试表达式的逻辑语句。由 assert 核心模块支持;例如:assert.equal(user.email, 'name@example.com');。 |
| 测试用例 | 测试特定概念的断言之一或多个。在 Mocha 中,测试用例看起来像这样:it('should calculate the square of a number', function() { assert.equal(square(4), 16);
}); |
| 测试框架 | 运行测试并汇总输出的程序。生成的报告有助于诊断测试失败时的问题。在之前的示例基础上,使用 Mocha 的测试框架看起来像这样:var assert = require('assert'); var square = require('./square');
describe('平方数', function() {
it('should calculate the square of a number', function() {
assert.equal(square(4), 16);
});
it('should return 0 for 0', function() {
assert.equal(square(0), 0);
});
}); |
| 配置 | 在运行测试之前通常准备好的测试数据。假设您想测试用户账户系统。您可以预先定义用户及其密码,然后在测试中包含密码以确保用户可以正确登录。在 Node 中,JSON 是配置文件格式的流行格式,但您也可以使用数据库、SQL 转储或 CSV 文件。这取决于您的应用程序需求。 |
|---|---|
| 模拟 | 模拟另一个对象的对象。模拟通常用于替换在单元测试中既慢又难以运行的 I/O 操作;例如,从远程 Web API 下载数据或访问数据库。 |
| 存根 | 存根方法用于在测试期间替换功能。例如,用于与磁盘或远程 API 等 I/O 源通信的方法可以存根以返回预定义的数据。 |
| 持续集成服务器 | 持续集成服务器在项目通过版本控制服务器更新时自动运行测试。 |
对于测试的更详细介绍,《单元测试的艺术,第二版》(Roy Osherove,Manning,2013;manning.com/osherove2/) 提供了编写可维护和可读性测试的逐步示例。《通过示例的测试驱动开发》(Kent Beck,Addison-Wesley,2002;mng.bz/UT12) 是另一本关于该主题的知名基础书籍。
与 Node 一起工作的一个优点是,社区早期就采用了测试,所以没有缺少帮助你编写快速且可读的测试的模块。你可能想知道测试有什么好处,为什么我们在开发早期就编写它们。好吧,测试在做出承诺之前探索想法很重要——你可以把它们看作是小型的、灵活的实验。它们还传达了你的意图,这意味着它们有助于记录和扩展项目关键部分的想法。测试还可以通过允许你检查更改是否破坏了现有功能来帮助减少成熟项目的维护工作。
首先要了解的是 Node 的assert模块。此模块允许你定义一个期望,当它不满足时将抛出错误。表达和确认期望是测试的主要目的,所以你将在本章中看到很多断言。虽然你不必使用assert来编写测试,但它是一个内置的核心模块,类似于你在其他语言中可能使用过的断言库。本章的第一套技术都是关于断言的。
为了让每个人都能跟上进度,下一节包括了一个列表,列出了在测试工作中常用的术语。
10.1. 使用 Node 进行测试的介绍
为了让新来的自动化测试人员更容易理解,我们包括了表 10.1,该表定义了常见的术语。此表还概述了我们所说的特定术语,因为一些编程社区对相同的术语使用略有不同。
Node 直接支持的表 10.1 中的唯一功能是断言。其他功能是通过第三方库提供的——你将在技巧 86 中了解 CI 服务器,在技巧 87 中了解模拟和固定值。你不必使用所有这些工具来编写测试,实际上你只需使用断言模块就可以编写测试。下一节介绍了assert模块,这样你就可以开始编写基本测试了。
10.2. 使用断言编写简单测试
到目前为止,我们简要地提到断言用于测试表达式。但这究竟涉及什么?通常,断言是当条件不满足时引发异常的函数。失败的断言就像在商店里你的信用卡被拒绝一样——无论你尝试多少次,你的程序都不会运行。断言的想法已经存在很长时间了;甚至 C 语言也有断言。
在 C 语言中,标准库包括用于验证表达式的assert()宏。在 Node 中,我们有assert核心模块。还有其他断言模块,但assert是内置的,易于使用和扩展。
CommonJS 单元测试
assert 模块基于 CommonJS 单元测试 1.1 规范(wiki.commonjs.org/wiki/Unit_Testing/1.1)。因此,尽管它是一个内置的核心模块,但你也可以使用其他断言模块。底层原则始终相同。
本节介绍了 Node 的内置断言。通过遵循第一个技巧,你可以使用 assert 核心模块通过使用 assert.equal 来检查相等性,并通过使用 npm 脚本来自动化测试的运行.^([1])
¹ 这是由 package.json 文件中的
scripts属性定义的。有关此功能的详细信息,请参阅npm help scripts。
技巧 79 使用内置模块编写测试
你是否曾经尝试为重要的功能编写快速测试,但发现自己迷失在测试库文档中?实际上开始编写测试可能很难;似乎有很多东西要学。如果你只是开始使用 assert 模块,那么你现在就可以编写测试,而无需任何特殊库。
当你编写一个小的模块且不想安装任何依赖项时,这会非常棒。这个技巧演示了如何编写干净、表达力强、单文件测试。
问题
你对你的模块、类或函数的输入和输出值有一个清晰的认识,并且你希望当输出值与输入值不匹配时,这一点是明确的。
解决方案
使用 assert 模块和 npm 脚本。
讨论
Node 内置了一个断言模块。你可以将其视为一个用于检查预期结果与实际结果之间差异的工具包。内部是通过比较 实际 值与 预期 值来完成的。assert.equal 方法完美地展示了这一点:参数是 actual, expected。还有一个第三个可选参数:message。传递一个消息使得在测试失败时更容易理解发生了什么。
假设你正在编写一个在线商店,该商店计算订单价格,并且你已经以每件 $3.99 的价格卖出了三件商品。你可以确保使用以下方法正确计算价格:
assert.equal(
order.subtotal, 11.97,
'The price of three items at $3.99 each'
);
在只有一个必需参数的方法中,如 assert(value),预期值是 true,因此它使用相同的模式。
要查看测试失败时会发生什么,请尝试运行下一个列表。
列表 10.1. assert 模块

在大多数测试文件中,你首先看到的是加载 assert 模块的代码!。assert 变量也是从 assert.ok 别名的一个函数——这意味着你可以使用 assert() 或 assert.ok()!。
容易忘记 assert.equal 的参数顺序,所以你可能发现自己经常查看 Node 的文档。实际上,参数的顺序并不重要——有些人可能觉得先列出预期值更容易,这样他们就可以扫描代码中的值——但是你应该保持一致。这就是为什么这个例子明确指出了 actual 和 expected 的命名!。
此测试有一个有意的错误函数
。你可以使用 node assertions.js 运行测试,它应该显示一个带有堆栈跟踪的错误:

这些堆栈跟踪可能难以阅读。但因为我们已经包含了一个与失败的断言相关的消息,我们可以看到错误的描述。我们还可以看到断言失败在文件 assertions.js 的第 7 行
。
assert 模块有许多其他有用的测试值方法。最重要的是 assert.deepEqual,它可以检查两个对象之间的相等性。这是很重要的,因为 assert.equal 只能比较浅层相等性。浅层相等性用于比较原始值,如字符串或数字,而 deepEqual 可以比较包含嵌套对象和值的对象。
当你编写返回复杂对象的测试时,你可能会发现 deepEqual 很有用。想想之前提到的在线商店示例。你的购物车可能看起来像这样:{ items: [ { name: "Coffee beans", price: 4.95 } ], subtotal: 4.95 }。这是一个包含购物车项目数组的对象,以及由另一个对象计算的小计。现在,为了检查这个整个对象与你在单元测试中定义的对象,你会使用 assert.deepEqual,因为它能够比较对象,而不仅仅是原始值。
deepEqual 方法可以在下一个列表中看到。
列表 10.2. 测试对象相等

此示例使用 assert 模块
来测试由构造函数创建的对象,以及一个假设的登录系统。登录系统意外地将普通用户加载为管理员
。
assert.deepEqual 方法
将遍历对象中的每个属性,以查看是否有任何不同的。当它遇到 user.permissions.admin 并发现值不同时,将抛出 AssertionError 异常。
如果你查看 assert 模块的文档,你会看到许多其他有用的方法。你可以使用 notDeepEqual 和 notEqual 来反转逻辑,甚至可以使用 strictEqual 和 notStrictEqual 来执行类似于 === 的严格相等检查。
测试还有另一个方面,那就是确保事情以我们期望的方式失败。下一个技巧将探讨测试失败。
技巧 80 测试错误
程序最终都会失败,但当我们遇到失败时,我们希望它们产生有用的错误。这项技术是确保预期的错误被抛出,以及如何在测试期间引发异常。
问题
你想测试你的错误处理代码。
解决方案
使用 assert.throws 和 assert.ifError。
讨论
我们作为 Node 开发者使用的约定之一是,异步方法应该将错误作为第一个参数返回。当我们设计自己的模块时,我们知道错误可能发生的地方。理想情况下,我们应该测试这些情况,以确保正确的错误被传递给回调。
下面的列表展示了如何确保错误没有被传递给异步函数。
列表 10.3. 处理异步 API 的错误

虽然 assert.ifError 可以同步工作,但将其用于测试传递错误给回调的异步函数在语义上是有意义的。列表 10.3 使用了一个名为 readConfigFile 的异步函数
来读取配置文件。实际上,这可能是网络应用的数据库配置,或者类似的东西。如果找不到文件,则返回默认值
。任何其他错误——这是重要的一部分——将被传递给回调
。
这意味着 assert.ifError 测试
可以轻松地检测是否发生了意外的错误。如果项目的结构发生变化,导致抛出了不寻常的错误,那么这个测试将捕获它,并在开发者发布可能危险的代码之前警告他们。
现在让我们来看看在测试期间抛出异常的情况。在我们的测试中,我们不必使用 try 和 catch,而是可以使用 assert.throws。
要使用 assert.throws,你必须提供要运行的函数和预期的错误构造函数。因为传递了一个函数,所以它与异步 API 一起使用效果很好,因此你可以用它来测试依赖于 I/O 操作的事情。
下一个列表展示了如何使用 assert.throws 与一个虚构的用户账户系统。
列表 10.4. 确保抛出异常

断言
检查以确保抛出了预期的异常。第一个参数是要测试的函数,在这种情况下是 loginAdmin,第二个是预期的错误
。
这突出了 assert.throws 的两个特点:它可以与异步 API 一起使用,因为它传递了一个函数,并且它期望某种类型的错误对象。当使用 Node 开发项目时,使用 util.inherits 从内置的 Error 构造函数继承是一个好主意。这允许人们轻松地捕获你的错误,如果需要,你可以用额外的属性来装饰它们,这些属性包括有用的附加信息。
在这种情况下,我们创建了 PermissionError
,这是一个清晰的名字,因此是自文档化的——如果有人在堆栈跟踪中看到 PermissionError,他们会知道出了什么问题。随后在 login-Admin 函数
中抛出了 PermissionError。
这种技术深入探讨了 assert 模块的错误处理。结合前面的技巧,你应该对如何使用断言测试各种情况有很好的理解。使用 assert.equal 你可以快速比较数字和字符串,这涵盖了检查发票中的价格或网络应用程序账户处理代码中的电子邮件地址等很多问题。很多时候,assert.ok(别名为 assert())就足够了,因为它是一种快速便捷的检查真值表达式的手段。但如果你想要真正充分利用 assert 模块,还有最后一件事需要掌握;继续阅读以了解如何创建自定义断言。
技巧 81 创建自定义断言
Node 的内置断言可以扩展以支持特定于应用程序的表达式。有时你会发现自己反复使用相同的代码来测试事物,似乎有更好的方法。例如,假设你正在使用正则表达式在assert.ok中检查有效的电子邮件地址。编写自定义断言可以解决这个问题,而且比你想象的要简单。学习如何编写自定义断言也将帮助你从内到外理解断言模块。
问题
你在测试中重复了很多代码,如果只有正确的断言,这些代码是可以被替换的。
解决方案
扩展内置的 assert 模块。
讨论
assert 模块围绕一个单一函数构建:fail。assert.ok 实际上是通过逻辑反转调用 fail,所以看起来像这样:if (!value) fail(value)。如果你查看 fail 的工作方式,你会看到它只是抛出一个 assert.AssertionError:
function fail(actual, expected, message, operator, stackStartFunction) {
throw new assert.AssertionError({
message: message,
actual: actual,
expected: expected,
operator: operator,
stackStartFunction: stackStartFunction
});
}
错误对象被添加了使测试报告者更容易分解失败位置和原因的属性。编写此模块的人知道其他人会想编写自己的断言,因此导出了fail函数,这意味着它可以被重用。
编写自定义断言涉及以下步骤:
1. 定义一个与现有断言库签名类似的方法。
2. 当期望不匹配时调用
fail。3. 测试以确保失败结果产生一个
AssertionError。
列表 10.5 将这些步骤组合起来定义一个自定义断言,以确保正则表达式匹配。
列表 10.5. 自定义断言

此示例加载断言模块
并定义一个名为 match 的函数,该函数运行 assert.fail 以在正则表达式不匹配实际值时生成正确的异常
。要记住的关键细节是定义参数列表,使其与其他断言模块中的方法保持一致——这里的示例基于 assert.equal。
列表 10.5 也包含了一些测试。实际上这些测试会在一个单独的文件中,但在这里它们展示了自定义断言的工作方式。首先我们检查它是否通过一个简单的测试,通过匹配字符串与正则表达式
,然后使用 assert.throws 确保测试在应该失败时确实失败了
。
您自己的领域特定语言
使用自定义断言只是创建您自己的测试领域特定语言(DSL)的一种技术。如果您发现测试用例之间有代码重复,那么完全可以将这些代码封装在函数或类中。
例如,setUpUserAccount({ email: 'user@example.com' }) 比三到四行设置代码更易读,尤其是在测试用例之间重复时。
这个例子可能看起来很简单,但了解如何编写自定义断言可以提高您对底层模块的了解。自定义断言可以帮助清理测试,其中期望由于将概念挤压到内置断言中而变得不那么表达。如果您想要能够说出像 assert.httpStatusOK 这样的东西,现在您就可以做到了!
将断言处理完毕后,是时候看看如何跨多个文件组织测试了。接下来的技术将介绍可以用来组织测试文件组并更轻松运行它们的测试工具。
10.3. 测试工具
测试工具或自动化测试框架通常指的是一个设置运行时环境并运行测试,然后收集和比较结果的程序。由于它是自动化的,测试可以被包括持续集成(CI)服务器在内的其他系统运行,这在技术 86 中有所介绍。技术 86。
测试工具用于执行测试文件组。这意味着您可以使用单个命令轻松运行大量测试。这不仅使您更容易运行测试,也使您的合作者更容易。您甚至可能决定在开始任何其他事情之前,先为所有项目使用测试工具。接下来的技术将向您展示如何创建自己的测试工具,以及如何通过向 package.json 文件中添加脚本来节省时间。
技术编号 82:使用测试工具组织测试
假设您正在处理一个项目,它一直在增长,使用单个测试文件开始感觉杂乱无章。它难以阅读,并导致混淆,进而导致错误。因此,您希望使用一些相关联的单独文件。也许您甚至希望一次运行一个测试文件,以帮助在出错时追踪问题。
测试工具解决了这个问题。
问题
您希望编写组织成测试用例和测试套件的测试。
解决方案
使用测试工具。
讨论
首先,让我们考虑一下什么是测试框架。在 Node 中,测试框架是一个可以通过输入脚本名称来运行的命令行脚本。在最基本的情况下,它必须运行一组测试文件并显示发生的任何错误。我们不需要做任何特别的事情来做到这一点——失败的断言将抛出异常;否则程序将静默退出,返回代码为0。
这意味着基本的测试框架只是node test/*.js,其中 test/是一个包含一组测试文件的目录。我们可以做得更好。所有 Node 项目都应该有一个 package.json 文件。这个文件中的一个属性是scripts,其中一个默认脚本就是test。你在这里设置的任何字符串都将像 shell 命令一样执行。
下面的列表展示了一个包含测试脚本的示例 package.json 文件。
列表 10.6. 包含测试脚本的 package.json

将node test-runner.js test.js test2.js设置为test脚本
,其他开发者现在可以通过输入npm test来运行你的测试。这比记住一个特定项目的命令要容易得多。
让我们通过查看测试框架是如何工作的来扩展这个例子。测试框架是一个 Node 程序,它运行测试文件组。因此,我们应该能够给这样的程序提供一个要测试的文件列表。每当测试失败时,它应该显示堆栈跟踪,这样我们就可以轻松地追踪失败的源头。
此外,它应该在测试失败时以非零返回代码退出。这允许以自动化的方式运行测试——其他软件可以轻松地看到测试是否失败,而无需解析测试的文本输出。这就是持续集成(CI)服务器的工作方式:每当代码提交到版本控制系统(如 Git)时,它们会自动运行测试。
下一个列表展示了这个系统应该看起来像的测试文件。
列表 10.7. 一个示例测试文件

it函数
看起来很奇怪,但它是一个全局函数,将由我们的测试框架提供。它给每个测试案例一个名称,这样在运行测试时更容易理解结果。包含失败的测试
以便我们可以看到测试失败时会发生什么。最后一个测试案例
即使在第二个测试失败的情况下也应该运行。
现在,最后一部分:下一个列表包含一个能够执行这些测试的程序。
列表 10.8. 以规定的方式运行测试

这个例子可以通过传递测试文件作为参数来运行:node test-runner.js test.js test2.js test-n.js。it函数被定义为全局
,并被命名为it,这样测试及其输出在逻辑上读起来更合理。当结果被打印
时,这很有意义。
因为it接受一个测试用例名称和一个回调,所以回调可以在我们想要的任何条件下运行。在这种情况下,我们是在try/catch语句中运行它
,这意味着我们可以捕获失败的断言并向用户报告错误
。
测试是通过在传递给命令行参数的每个文件上调用require来加载的
。在更完善的程序版本中,文件处理需要更复杂。例如,需要支持通配符表达式。
一个失败的测试用例会导致exitCode变量被设置为非零值。在退出处理程序中,这个值会通过process.exit返回给控制进程
。
尽管这是一个最小化的示例,但它可以用npm test运行,通过it为测试用例提供一点语法糖,改进了比简单的断言文件更详细的错误报告,并在出错时返回非零退出代码。这是大多数流行的 Node 测试框架(如 Mocha)的基础,我们将在下一节中探讨。
10.4 测试框架
如果你正在启动一个新项目,那么你应该尽早安装一个测试框架。假设你正在构建一个在线博客系统,或者可能是一个简单的内容管理系统。你希望允许人们登录,但只允许特定的用户访问管理界面。通过使用 Mocha 或node-tap这样的测试框架,你可以编写针对这些特定问题的测试:用户注册账户,以及管理员登录到管理界面。你可以为这些关注点创建单独的测试文件,或者将它们作为“用户账户测试”下的测试用例组来打包。
测试框架包括运行测试和其他使编写和维护测试更简单的脚本。本节介绍了 Mocha 测试框架在技巧 84 和测试任何协议(TAP;testanything.org/)在技巧 85 中的应用——这两个是 Node 社区中受欢迎的测试框架。Mocha 轻量级:它运行测试,提供三种结构测试用例的风格,^([2])并期望你使用 Node 的assert模块或另一个第三方模块。相反,实现了 TAP 的node-tap使用了一个包含断言的 API。
² Mocha 支持基于行为驱动开发(BDD)、测试驱动开发(TDD)和 Node 模块系统(exports)的 API 风格。
技巧 83 使用 Mocha 编写测试
对于 Node 来说,有众多测试框架,因此选择合适的框架有些困难。Mocha 是一个受欢迎的选择,因为它维护得很好,并且具有合适的特性和约定的平衡。
通常,您会使用测试框架来组织项目的测试。您可能希望使用其他人熟悉的测试框架,这样他们可以轻松导航和协作,而无需学习新的模块。也许您只是想找到一种每次都以相同方式运行测试的方法,或者从自动化系统中触发它们。
问题
您需要以其他开发者熟悉的方式组织测试,并使用单个命令运行测试。
解决方案
使用 Node 的许多开源测试框架之一,如 Mocha。
讨论
在您进行其他操作之前,必须从 npm 安装 Mocha。最佳安装方式是使用 npm install --save-dev mocha。--save-dev 选项会导致 npm 将 Mocha 安装到 node_modules/ 并更新您的项目 package.json 文件,以包含 npm 中的最新版本。它将被保存为开发依赖项。
列表 10.9 展示了使用 Mocha 编写的简单测试示例。它使用 assert 核心模块进行断言,并且应该使用 mocha 命令行二进制文件来调用。您应该在 package.json 中的 "test" 属性中添加 "./node_modules/mocha/bin/mocha test/*.js"——有关如何做到这一点的更多详细信息,请参阅 技术 82。
Mocha 版本
我们在本章中使用的 Mocha 版本是 1.13.x。我们更倾向于在本项目本地安装 Mocha 来运行测试,而不是将其作为系统范围内的 Node 模块。这意味着可以使用 ./node_modules/mocha/bin/mocha test/*.js 来运行测试,而不是仅仅输入 mocha。这允许不同的项目使用不同版本的 Mocha,以防 API 在主要版本之间发生重大变化。
另一种选择是使用 npm install --global mocha 全局安装 Mocha,然后通过输入 mocha 来运行项目的测试。如果它找不到任何测试,将会显示错误。
列表 10.9. 一个简单的 Mocha 测试

Mocha 提供了 describe 和 it 函数。describe 函数可以用来将相关的测试用例分组在一起,而 it 包含形成测试用例的断言集合
。
对于异步测试需要特殊处理。这涉及到在测试用例的回调中包含一个 done 参数
,然后在测试完成后调用 done()
。在这个例子中,将在随机间隔后触发超时,这意味着我们需要在 index.randomTimeout 方法中调用 done。相应的测试文件将在下一列表中展示。
列表 10.10. 一个用于测试的示例模块

控制同步和异步行为
如果没有将 done 作为参数传递给 it,那么 Mocha 将同步运行测试。内部,Mocha 会查看传递给 it 的回调函数的 length 属性,以确定是否包含参数。这就是它在异步和同步行为之间切换的方式。如果你包含一个参数,那么 Mocha 将等待 done 被调用,直到达到超时时间。
此模块定义了两种方法:一种用于平方数字
和另一种在随机时间后运行回调
。这足以展示 Mocha 的主要功能,如 列表 10.9 所示。
要为 Mocha 设置项目,我们在这个例子中使用的 index.js 文件应该在其自己的目录中,并且在同一级别应该有一个 package.json 文件,其中 scripts 属性的 test 子属性设置为 "./node_modules/mocha/bin/mocha test/*.js"。还应该有一个包含 example_test.js 的 test/directory。^([3]) 在所有这些就绪后,你可以使用 npm test 运行测试。
³ 文件可以命名为任何名称,只要它在 test/directory 目录下。
当运行测试时,你应该注意到一些点出现。这些标记着一个完成的测试用例。当测试用时超过预设的时间时,它们会改变颜色,表示运行速度比可接受的速度慢。由于 index.randomTimeout 防止第二个测试随机时间完成,因此有时 Mocha 会认为测试运行得太慢。你可以通过将 --slow 传递给 Mocha 来增加这个阈值,如下所示:./node_modules/mocha/bin/mocha --slow 2000 test/*.js。现在你不必为看似缓慢的测试感到内疚了!
每个测试的断言
在 列表 10.9 中,每个测试用例只有一个断言。有些人认为这是最佳实践——它可以导致可读性强的测试。
但我们更喜欢每个测试一个概念的思路。这种风格将测试用例构建在定义良好的概念周围,使用绝对必要的断言数量。这通常是一个小数字,但偶尔也可能超过一个。
要查看所有命令行选项,请输入 node_modules/mocha/bin/mocha --help 或访问 mochajs.org/。
我们在 列表 10.11 中包含了最终的 package.json 文件,以防你自己在编写时遇到困难。你可以使用 npm install 安装 Mocha 及其依赖项。
列表 10.11. Mocha 示例项目的 JSON 文件
{
"name": "mocha-example-1",
"version": "0.0.0",
"description": "A basic Mocha example",
"main": "index.js",
"dependencies": {},
"devDependencies": {
"mocha": "~1.13.0"
},
"scripts": {
"test": "./node_modules/mocha/bin/mocha --slow 2000 test/*.js"
},
"author": "Alex R. Young",
"license": "MIT"
}
在这个技巧中使用了 assert 核心模块,但如果你愿意,可以将其替换为另一个断言库。其他库也可用,如 chai (npmjs.org/package/chai) 和 should.js (github.com/visionmedia/should.js)。
Mocha 通常用于测试 Web 应用程序。在下一个技巧中,你将看到如何使用 Mocha 测试使用 Node 编写的 Web 应用程序。
技巧 84 使用 Mocha 测试 Web 应用程序
假设你正在使用 Node 构建一个 Web 应用程序。你希望以允许你发送请求并接收响应的方式运行它——你想要发送 HTTP 请求来测试 Web 应用程序是否按预期工作。
问题
你正在构建一个 Web 应用程序,并希望使用 Mocha 来测试它。
解决方案
使用 Mocha 和标准 http 模块编写测试。考虑使用专为测试设计的 HTTP 模块来简化你的代码。
讨论
要理解 Node 中的 Web 应用程序测试,关键是学会从 HTTP 的角度思考。这种技术从 Mocha 测试和 http 核心模块开始。一旦你理解了其中的原理并能以这种方式编写测试,我们将介绍一个第三方 HTTP 测试模块来演示如何简化此类测试。首先演示内置的 http 模块,因为它有助于了解幕后发生的事情,并确切了解如何构建此类测试。
以下列表显示了测试的外观。
列表 10.12. 一个 Mocha 测试用例的 Web 应用程序


这个例子是对一个可以平方数字的 Web 服务的测试。这是一个简单的 Web 服务,它期望 GET 请求并以纯文本响应。这个测试套件的目标是确保它返回预期的结果,并在发送无效数据时正确地引发错误。测试旨在模拟浏览器——或者更确切地说,其他 HTTP 客户端——为此,服务器和客户端都在同一个进程中运行。
要运行一个 Web 服务,你所需要做的就是使用 http.create-Server() 创建一个 Web 服务器。具体如何做在 列表 10.13 中有展示。在讨论那之前,让我们先完成对这个测试的查看。
测试首先通过创建一个用于发送 HTTP 请求的函数
来开始。这是为了减少测试用例中可能存在的重复。这个函数可以是它自己的模块,可以在其他测试文件中使用。在发送请求后,它监听响应对象上的 data 事件以存储服务器返回的任何数据
。然后它运行提供的回调
,该回调是从测试用例中传递进来的。
图 10.1 展示了 Node 如何在同一个进程中运行服务器和客户端,以使 Web 应用程序测试成为可能。
图 10.1. Node 可以运行一个 Web 服务器并对它进行请求,以支持 Web 应用程序测试。

这里的一个例子是对 /square 方法的测试,确保 4 * 4 === 16
。一旦完成,我们也确保无效的 HTTP 查询参数会导致服务器响应 500 错误
。
在整个过程中使用了标准的断言模块,并使用 res.statusCode 来测试返回预期的状态码。
下一个列表显示了相应 Web 服务的实现。
列表 10.13. 一个可以平方数字的 Web 应用程序

在做任何其他事情之前,使用http.createServer创建一个服务器。在文件末尾附近,使用.listen(8000)使服务器启动并监听连接。每当收到与 URL 匹配/平方的请求时,就会解析 URL 以获取一个数值参数
,然后将该数字平方并发送给客户端
。当预期的参数不存在时,将返回 500
。
列表 10.12 中可以改进的部分是request方法。我们不是围绕http.request定义包装器,而是可以使用专门为测试 Web 请求而设计的库。
我们选择的模块是 TJ Holowaychuk 编写的SuperTest (github.com/visionmedia/supertest),他也是 Mocha 的作者。还有其他类似的库。一般思路是简化 HTTP 请求并允许对请求进行断言。
你可以通过运行npm install --save-dev supertest将 SuperTest 添加到这个示例的开发依赖项中。
以下列表展示了如何使用SuperTest模块重构测试。
列表 10.14。使用 SuperTest 重构的 Mocha 测试

虽然在功能上与列表 10.12 相同,但这个例子通过移除创建 HTTP 请求的样板代码来改进它。SuperTest模块更容易理解,并且允许用更少的代码表达断言,同时仍然保持异步。SuperTest期望一个 HTTP 服务器的实例
,在这种情况下是我们想要测试的应用程序。一旦应用程序被传递给SuperTest的主要函数request,我们就可以使用request().get来发出GET请求。其他 HTTP 动词也得到支持,并且在使用post()与send方法时可以发送表单参数。
SuperTest的方法是可链式的,因此一旦发出请求,我们就可以使用expect来做出断言。此方法是多态的——它检查参数的类型并根据情况行事。如果你传递一个数字
,它将确保 HTTP 状态是那个数字。正则表达式将使其检查响应体是否匹配
。这些期望非常适合这个测试的要求。
可以检查任何 HTTP 状态,因此当我们实际期望一个 500 时,我们可以测试它
。
虽然了解如何制作简单的 Web 应用程序并使用内置的http模块进行测试很有用,但我们希望你能看到第三方模块如SuperTest如何简化你的代码并使你的测试更清晰。
Mocha 捕捉了当前 Node 测试状态的潮流,但还有其他同样有效的方法。接下来的技术介绍了 TAP 和 Test Anything Protocol,由于 Node 的维护者和核心贡献者的支持。
技巧 85:Test Anything Protocol
测试框架输出根据编程语言和测试框架而异。有一些努力旨在统一这些报告。Node 社区采纳的一个这样的努力是 Test Anything Protocol (testanything.org)。使用 TAP 的测试将产生轻量级的流式结果,这些结果可以被兼容的工具消费。
假设你需要一个与 Test Anything Protocol 兼容的测试框架,这可能是因为你有使用 TAP 的其他工具,或者因为你已经从其他语言中熟悉了它。可能是因为你不喜欢 Mocha 的 API,想要一个替代方案,或者对 Node 中测试的其他解决方案感兴趣。
问题
你希望使用一个旨在与其他系统互操作性的测试框架。
解决方案
使用 Isaac Z. Schlueter 的 tap 模块。
讨论
TAP 是独特的,因为它旨在通过指定实现者可以使用的协议来连接测试框架和工具。该协议是基于流的,轻量级且可读。与其他基于 XML 的更重的标准相比,TAP 实现和使用都很容易。
重要的是,tap 模块 (npmjs.org/package/tap) 是由 Node 的前维护者 Isaac Z. Schlueter 编写的。这是 Node 社区中一位极具影响力的人的重要认可。
本技术示例使用 技术 83 中使用的数字平方和随机超时模块,这样你可以比较 TAP 和 Mocha 中测试的外观。
以下列表显示了测试的外观。对于相应的模块,请参阅 列表 10.10。
列表 10.15. 使用 TAP 进行测试

这与 Mocha 示例不同,因为它不假设存在任何全局测试相关方法,如 it 和 describe:在执行任何其他操作之前,必须设置对 tap.test 的引用
。然后使用 t.test() 方法
定义测试,如果需要可以嵌套。嵌套允许将相关关注点分组,因此在这种情况下,我们为每个要测试的方法创建了一个测试用例。
tap 模块内置了断言,我们在整个测试文件中使用了这些断言
。一旦测试用例完成,必须调用 t.end()
。这是因为 tap 模块假设测试是异步的,所以 t.end() 可以在异步回调中调用。
另一种方法是使用 t.plan
。此方法表示预期有 n 个断言。一旦最后一个断言被调用,测试用例将完成运行。与上一个测试用例不同,第二个测试用例可以省略对 t.end() 的调用
。
此测试可以使用 ./node_modules/tap/bin/tap.js test/*_test.js 运行。你可以将此行添加到 package.json 文件中 scripts 属性的 test,以便使用 npm test 运行。
如果你使用tap脚本运行测试,你会看到一些干净的输出,这些输出汇总了每个断言的结果。这是由tap的一个子模块tap-results生成的。tap-results模块的目的是从 TAP 流中收集行,并计算跳过、通过和失败的数量,以生成一个简化的报告;
ok test/index_test.js ................................... 3/3
total ................................................... 3/3
ok
由于tap模块的设计,你可以自由地使用node test/index_test.js来运行测试。这将打印出 TAP 流而不是:
# Alex's handy mathematics module
# square
ok 1 should be equal
# randomTimeout
ok 2 (unnamed assert)
1..2
# tests 2
# pass 2
# ok
使用tap模块编写的测试在测试失败时仍会向 shell 返回非零退出代码——你可以使用echo $?来查看退出代码。尝试使列表 10.15 中的测试故意失败并查看$?。
TAP 的设计围绕生成和消费流与 Node 的设计非常契合。在大多数项目中,测试必须与其他自动化系统交互,无论是部署系统还是 CI 服务器,这也是生活的一个事实。与这个协议一起工作比与重量级的 XML 标准一起工作要容易得多,所以希望它会越来越受欢迎。
图 10.2 说明了node-tap的一些子模块是如何用来测试程序的。控制从不同的模块转移到你的测试,然后回到你的程序,再通过报告器输出,报告器收集并分析结果。关于这一点,关键要认识到node-tap的子模块是可以重用和替换的——如果你不喜欢tap-results显示结果的方式,它可以被替换成其他东西。
图 10.2. node-tap使用几个可重用的子模块来协调测试。

除了测试框架之外,现实世界的测试还依赖于几个更重要的工具和技术。下一节将向你展示如何使用持续集成服务器和数据库固定值,以及如何模拟 I/O。
10.5. 测试工具
当你在团队中工作时,你希望快速看到有人提交了破坏测试的更改。本节将帮助你设置持续集成服务器,以便你可以这样做。它还包括其他与项目相关的问题的技术,如使用测试数据库和模拟 Web 服务。
技巧 86 持续集成
你的测试正在运行,但如果有人的更改导致项目崩溃会发生什么呢?持续集成(CI)服务器用于自动运行测试。由于大多数测试工具在失败时返回非零退出代码,它们在概念上足够简单。它们的真正价值在于它们可以轻松地连接到像 GitHub 这样的服务,并在测试失败时向团队成员发送电子邮件或即时消息。
问题
你想看到团队成员提交了有问题的代码,这样你就不会不小心发布它。
解决方案
使用持续集成服务器。
讨论
你在一个团队中工作,并想查看测试何时开始失败。你已经使用了一个版本控制系统,如 Git,并希望在代码提交到受跟踪的仓库时运行测试。或者,你已经编写了一个开源项目,并想在 GitHub 或 Bitbucket 页面上表明它经过了良好的测试。
有许多流行的开源和专有持续集成服务。在这个技术中,我们将查看 Travis CI (travis-ci.org/),因为它对开源项目是免费的,并且在 Node 社区中很受欢迎。如果你想安装本地的开源 CI 服务器,可以看看 Jenkins (jenkins-ci.org/)。
Travis CI 提供了一个链接到显示你的项目构建状态的图片。要添加项目,在 travis-ci.org 使用你的 GitHub 账户登录,然后转到 travis-ci.org/profile 中的个人资料页面。你会看到一个 GitHub 项目的列表,将开关切换到开启状态将导致仓库通过一个服务钩子更新,该钩子会在你向 GitHub 推送更新时通知 Travis CI。
一旦你完成了这些,你需要在仓库中添加一个 .travis.yml 文件,以告诉 Travis CI 代码所依赖的环境。你只需要设置 Node 版本。
让我们通过一个完整的示例,并在 Travis 上设置一个项目,以便你可以看到它是如何工作的。你需要三个文件:一个 package.json 文件,一个测试文件,以及 .travis.yml 文件。下面的列表显示了我们将要测试的文件。
列表 10.16. 一个简单的测试,用于尝试使用 Travis CI

这只是一个简单的测试
,我们可以用它来查看 Travis CI 能做什么。运行后,它应该得到一个退出代码为零的结果——输入 node test.js 然后输入 echo $? 来查看退出代码。将此文件放在一个新目录中,这样你就可以稍后为它设置 Git 仓库。在那之前,我们需要创建一个 package.json 文件。下面的列表是一个简单的 package.json,允许使用 npm test 运行测试。
列表 10.17. 一个基本的 package.json 文件
{
"name": "travis-example",
"version": "0.0.0",
"description": "A sample project for setting up Travis CI and Node.",
"main": "test.js",
"scripts": {
"test": "node test.js"
},
"author": "Alex R. Young",
"license": "MIT"
}
最后,你需要一个 .travis.yml 文件。它不需要做太多,只需告诉 Travis CI 你正在使用 Node。
列表 10.18. Travis CI 配置
language: node_js
node_js:
- "0.10"
现在,前往 GitHub.com 并登录;然后点击新建仓库以创建一个公共仓库。我们将其命名为 travis-example,这样人们就知道它纯粹是教育性的。遵循如何提交和推送项目到 GitHub 的说明——你需要在放置前面三个代码文件的目录中运行 git init,然后运行 git add . 和 git commit -m 'Initial commit'。然后使用 git remote add <url> 与 GitHub 给你的仓库 URL,并使用 git push -u origin master 推送。
前往 travis-ci.org/profile 中的你的个人资料,并将你的新项目切换到开启状态。你可能需要告诉 Travis CI 同步你的项目列表——页面顶部附近有一个按钮。
在你可以在 Travis CI 上看到任何测试运行之前,还有最后一步。在 test.js 中进行单个更改——如果你喜欢,可以添加另一个断言,然后提交并 git push 修改。这将导致 GitHub 向 Travis CI 发送 API 请求,从而运行你的测试。
Travis CI 知道如何运行 Node 测试——默认为 npm test。如果你将此技术应用于现有项目并且使用其他命令(例如 make test),则可以通过在 YML 文件中设置 script 值来更改 Travis CI 运行的命令。有关文档,请参阅“配置你的构建”部分(about.travis-ci.org/docs/user/build-configuration/#script)。
如果你访问 Travis CI 的主页,你现在应该看到一个包含测试运行详细信息的控制台日志。图 10.3 展示了成功的测试看起来是什么样子。
图 10.3. Travis CI 运行测试

现在你已经成功运行了测试,你应该编辑 test.js 以使测试失败,以查看会发生什么。
Travis 可以配置为使用你在现实世界项目中运行测试时预期的大多数东西——可以添加数据库和其他服务(about.travis-ci.org/docs/user/database-setup/),甚至虚拟机。
为你的项目配置具有合适固定数据的数据库是测试最重要的部分之一。下一个技巧将展示如何为你的测试设置数据库。
技巧 87 数据库固定数据
大多数应用程序都需要以某种方式持久化数据,并且测试数据是否正确存储非常重要。此技术探讨了在 Node 中处理数据库固定数据的三个解决方案:加载数据库转储、在测试期间创建数据和使用模拟。
问题
你需要测试存储在数据库中的代码,或者执行某些其他类型的 I/O,例如通过网络发送数据。你不想在测试期间访问此 I/O 资源,或者你希望在测试之前预载数据。无论如何,你的应用程序高度依赖于 I/O 服务,并且你希望仔细测试你的代码如何与之交互。
解决方案
在测试之前预载数据,或者模拟 I/O 层。
讨论
代码编写得好不好,看它是否易于测试。执行 I/O 的代码本能地感觉难以测试,但如果 API 清晰解耦,则不应该如此。
例如,如果你的代码执行 HTTP 请求,那么正如你在之前的技巧中看到的,你可以在测试中运行一个定制的 HTTP 服务器来模拟远程服务。这被称为模拟。但有时你不想模拟 I/O。你可能希望编写测试,以便对真实数据库进行更改,尽管是一个测试可以安全地销毁和重新创建的数据库实例。这类测试被称为集成测试——它们“集成”不同的软件层以深入测试行为。
这种技术展示了两种处理集成测试数据库数据的方法;然后我们将通过演示如何使用模拟来扩大范围。首先:使用数据库转储预加载数据。
数据库转储
使用数据库转储是数据库数据固定技术中的重型工具。你所需要的是在所有其他测试之前能够运行一些代码,以便清除数据库并插入一个原始副本。如果这些测试数据是从数据库转储的,那么你可以使用现有的数据库工具来准备和导出数据。
列表 10.19 使用 Mocha 和 MySQL,但你可以将相同的原理应用于其他数据库和测试框架。有关 Mocha 的更多信息,请参阅技巧 83。
列表 10.19. assert模块


此示例的基本原理是在其他测试之前运行数据库导入。如果你在自己的测试中使用这种方法,请确保导入首先清除数据库。关系型数据库可以使用DROP TABLE IF EXISTS等命令来完成此操作。
要实际运行此测试,需要在其他测试之前将文件名传递给mocha,并确保使用test环境。例如,如果列表 10.19 被命名为test/init.js,那么你可以在 shell 中运行以下命令:NODE_ENV=test ./node_modules/.bin/mocha test/init.js test/**/*_test.js。或者简单地将命令放在你的项目package.json文件下的scripts,test部分。
使用ran变量
来确保导入器只运行一次
。Mocha 的before函数用于
运行导入器一次,但如果test/init.js意外在其他地方加载(可能是因为运行mocha test/**/*.js),那么导入将会发生两次。
要导入数据,定义了loadFixture函数
并在before回调中运行
。它接受一个文件名和一个回调,因此使用起来非常方便,可以异步操作。此外,还会进行额外的检查以确保导入只在test环境中运行
。这里的理由是数据库设置将由应用程序的其他部分根据NODE_ENV设置,你不想通过覆盖开发或生产数据库的测试数据来丢失数据。
最后,导入数据的 shell 命令是通过
构建起来的,并使用child_process
运行。这取决于数据库——我们以 MySQL 为例,但类似的方法也可以与 MongoDB、PostgreSQL 或几乎任何具有命令行工具的数据库一起使用。
使用转储文件作为固定数据有一些好处:您可以使用您喜欢的数据库工具(我们喜欢 Sequel Pro)来编写测试数据,并且很容易理解它是如何工作的。如果您更改数据库的模式或与数据一起工作的“模型”类,那么您需要更新您的固定数据。
使用您的 ORM 创建测试数据
另一种方法是程序化地创建数据。这种风格需要设置代码——在before回调或测试框架中的等效操作中运行——使用您的模型类创建数据库记录。
下一个列表显示了它是如何工作的。
列表 10.20. 使用 ORM 准备测试数据


此示例可以使用 Mocha 运行,尽管它没有使用真实的数据库层,但User类
模拟了您可能会在关系数据库或甚至是 NoSQL 数据库的库中看到的行为。定义了一个具有异步 API
的save函数,以便测试看起来接近现实世界的测试。
在将每个测试用例组合在一起的describe块中,定义了一个名为user的变量
。这将被以下一些测试用例使用。它在其作用域之上定义,以便它们都可以访问它,但也是因为我们希望在before块中异步持久化它。这会在测试用例之前运行
。
模拟数据库
在本技术中将要讨论的最终方法是模拟数据库 API。尽管您应该始终编写一些集成测试,但您也可以编写根本不接触数据库的测试。相反,数据库 API 被抽象化。
我应该为测试数据使用 ORM 吗?
与列表 10.19 中的数据库转储示例类似,使用 ORM 创建测试数据对于需要真正与数据库服务器通信的集成测试非常有用。这比使用数据库转储需要更多的编程工作,但如果您想在 ORM 层中调用数据库之上的方法,这可能会很有用。这种技术的缺点是,数据库模式更改可能需要在多个测试文件中进行更改。
JavaScript 允许在定义对象之后修改它们。这意味着您可以使用自己的方法覆盖数据库模块的部分以返回测试数据。有一些库旨在使此过程更容易、更易于表达。一个做得特别好的模块是 Sinon.JS。下一个示例使用 Sinon.JS 和 Mocha 来模拟数据库模块。
列表 10.21 展示了一个示例,该示例模拟了一个使用 Redis 作为用户账户数据库的类。测试的目标是检查密码加密是否正确工作。
列表 10.21. 模拟数据库


这个例子是大型项目的一部分,包括 package.json 文件和正在被测试的 User 类——它可以在代码示例中找到,位于 testing/mocha-sinon。
在第三行你会注意到一些新内容:sinon.mock 包装了整个数据库模块
。这个数据库模块是我们定义的,它加载了 node-redis 模块,然后连接到数据库。在这个测试中,我们不想连接到真实的数据库,所以调用 sinon.mock 来代替它。这种方法可以应用于使用 MySQL、PostgreSQL 等其他项目的项目。只要你的项目设计成集中数据库配置,你就可以轻松地将其替换为模拟。
接下来我们设置一些我们想要用于这个用户的字段
。在一个集成测试中,这些字段将由数据库返回。我们不想在这里这样做,所以在 before 回调中,我们使用模拟来重新定义 Redis hmget 的行为
。模拟 API 是可链式的,所以我们可以通过使用 .callsArgWith 来链式定义我们想要 我们的 版本的 hmget 要做什么。
.callsArgWith 的语义可能令人困惑,所以这里有一个如何工作的分解。在 User 类中,hmget 被这样调用:
this.db.hmget('user:' + this.id, 'fields', function(err, fields) {
this.fields = JSON.parse(fields);
cb(err, this);
}.bind(this));
如你所见,它需要三个参数:记录键、要获取的哈希值,然后是一个接收可选错误对象和加载值的回调。当我们模拟这个时,我们需要告诉 Sinon.JS 如何响应。因此,callsArgWith 的第一个参数是回调的索引,它是 2,然后是回调应该接收的参数。我们传递 null 作为错误,并将用户字段序列化为 strong。这给了我们 callsArgWith(2, null, JSON.stringify(fields))。
这个测试很有用,因为测试的目的是确保用户可以登录,但只能使用正确的密码。登录代码实际上并不需要数据库访问,所以最好传递预定义的值,而不是麻烦去访问数据库。此外,因为代码将 JSON 序列化到 Redis,我们不需要专门的库来序列化和解码 JSON——我们可以使用内置的 JSON 对象。
现在你应该知道何时以及如何使用集成测试、模拟和存根。所有这些技术都将帮助你编写更好的测试,但只有在你正确使用它们的情况下。表 10.2 提供了这些技术的总结,并解释了何时使用每个技术。
表 10.2. 何时使用集成测试、模拟和存根
| 技术 | 何时使用 |
|---|---|
| 集成测试 | 这意味着测试模块组。在这里,我们使用这个术语来区分访问真实数据库的测试和以某种方式用兼容的 API 替换数据库访问的测试。你应该使用集成测试来确保你的数据库按预期运行。集成测试可以帮助验证性能,但这高度依赖于你的测试数据。它可能会使你的测试与数据库耦合得更紧密,这意味着如果你更改数据库或数据库 API,你可能还需要更改你的测试代码。 |
| 数据库导出 | 这是一种在测试前将数据(在测试前)预加载到测试数据库中的方法。这需要在准备数据时做大量的前期工作,并且如果数据库模式有任何更改,数据也需要维护。这种额外的工作量可以通过方法的简单性来抵消——你不需要任何特殊的工具来创建 SQL、Mongo 或其他数据文件。当你为已经拥有数据库的项目编写测试时,你应该使用这种技术。也许你正在从其他编程语言或平台迁移到 Node,并且正在使用现有的数据库。你可以将生产数据(注意移除或模糊任何个人信息或其他敏感信息)导入到你的项目仓库中,然后将其导出的数据库放入你的项目中。 |
| ORM 固件 | 在测试运行之前创建一个导入文件,而不是使用你的 ORM 模块在测试代码中创建和存储数据。这可能会随着时间的推移变得难以维护——任何模式更改都意味着测试必须仔细更新。你应该在测试算法与底层数据紧密相关的情况下使用这种技术。通过将数据靠近使用它的代码,任何相关的问题都可以更容易地理解和解决。 |
| 模拟和存根 | 模拟是模拟其他对象的对象。在本章中,你看到了 Sinon.JS,这是一个用于处理测试中模拟和存根的库。当你不希望访问 I/O 资源时,你应该使用模拟。例如,如果你正在为与支付提供商(如 WorldPay 或 Stripe)通信的代码编写测试,那么你会创建一些像 Stripe 的 API 一样行为,但实际上并不与 Stripe 通信的对象。通常,确保测试永远不会需要访问互联网是更安全的,所以任何触网的东西都应该被模拟。 |
下次你想测试连接远程 Web 服务的代码,或者你需要编写针对数据库运行的测试时,你应该知道该怎么做。如果你觉得这个部分很有趣,并且想了解更多,请继续阅读,以获取一些关于下一步学习什么的想法。
10.6. 进一步阅读
测试是一个大主题,尽管本章已经很长,但仍有一些重要的话题需要考虑。Node 社区继续探索编写更好测试的方法,并将这些想法带到客户端开发中。其中一项发展是 Browserify (browserify.org)——这允许 Node 的模块模式和像 EventEmitter 和 stream.Readable 这样的核心模块在浏览器中使用。
一些 Node 开发者正在利用 Browserify 来编写更好的客户端测试。他们不仅可以利用流和 Node 的模块模式来更干净地管理依赖关系,还可以像在服务器上一样编写 Mocha 或 TAP 测试。Browserify 的作者 James Halliday 创建了 Testling,这是一个用于运行客户端测试的浏览器自动化模块。
除了持续集成服务器外,另一个有用的测试相关工具是覆盖率报告。这些报告分析代码,以查看在运行测试时项目中有多少部分被击中。可能会有函数、方法,甚至在if语句中的某些子句从未被执行,这意味着未经测试且可能存在错误的代码可能会被发布到生产环境中。
10.7. 摘要
在本章中,你学习了如何编写断言并扩展它们,以及如何使用两个流行的测试框架。在为你的 Node 项目编写测试时,你应该始终偏向于可读性——测试应该快速,但如果它们不传达意图,它们可能会在未来引起维护问题。
这里是对我们涵盖的主要点的回顾:
-
通过学习每个方法和如何确保错误得到正确处理来掌握
assert模块。 -
使用 Mocha 和
node-tap这样的测试工具来帮助使测试可读和维护。 -
为使用数据库的代码编写测试,通过加载数据或使用模拟和存根。
-
通过使用像 Sinon.JS 这样的第三方模块来改进模拟和存根。
-
为测试开发自己的领域特定语言——编写帮助保持测试用例精简和简洁的函数和类。
我们还没有涵盖的一个开发方面是调试 Node 程序。这可能是编写软件的一个重要部分,这取决于你的开发风格和背景。如果你对学习 Node 调试器的基础知识感兴趣,或者想了解更多,那么请继续阅读,深入了解使用 Node 进行调试。
第十一章. 调试:设计用于内省和解决问题
本章涵盖
-
处理未捕获的异常
-
检查 Node 应用程序
-
使用调试工具
-
分析应用程序和调查内存泄漏
-
使用 REPL 调查正在运行的过程
-
跟踪系统调用
理解在任何给定平台上错误是如何生成和处理的,对于构建稳定的应用程序至关重要。良好的错误内省和内置的测试是后来调试问题的最佳防御。在本章中,我们关注如何准备和在事情出错时应该做什么。
可能你的进程不断崩溃,或者它使用的内存比你预期的要多。也许它卡在 100%的 CPU 使用率上。我们将探讨这些以及其他你可能在 Node 应用程序中遇到的问题的调试解决方案。
在第一部分,我们将介绍 Node 应用程序的错误处理和检测设计。在下半部分,我们将探讨调试特定类型的问题。
11.1. 设计用于自省
当我们设计应用程序时,我们需要思考如何处理错误。相关的错误日志记录和干预需要深思熟虑。同时,为了捕捉错误,还需要对错误可能发生的位置有良好的理解。在这本书中,我们已经涵盖了 Node 应用程序中可能发生的各种错误形式。让我们在这里全面了解所有这些类型。
11.1.1. 显式异常
显式异常是那些明确地由throw关键字触发的。它们清楚地表明出了问题:
function formatName (name) {
if (!name) throw new Error("name is required");
...
}
显式异常由try/catch块处理:
try {
formatName();
} catch (err) {
console.log(err.message, err.stack);
}
如果你throw自己的异常,请记住以下指南:
-
throw只应在同步函数中使用;或者在异步函数中,在某些情况下,在异步操作发生之前使用是有意义的(例如 API 误用)。 -
总是
throw一个Error对象或从Error继承的对象。使用简单的字符串(如throw "Oh no!")不会生成堆栈跟踪,因此你将没有关于错误发生位置的信息。 -
不要在 Node 风格的回调函数中
throw;堆栈上没有东西可以捕获它!相反,直接处理错误或将错误传递给可以正确处理错误的另一个函数。
恢复throw
如果结构支持,你可以恢复异步块中使用throw的能力;一些值得注意的例子是域、承诺或生成器。
11.1.2. 隐式异常
隐式异常是指不是由throw关键字触发的任何运行时 JavaScript 错误。不幸的是,这些异常很容易悄悄地进入我们的代码。
一种常见的隐式异常是ReferenceError,它发生在无法找到变量或属性的引用时。
这里,我们看到一个无辜的data拼写错误导致了一个异常:
function (err, data) {
res.write(dat); // ReferenceError: dat is not defined
}
另一种常见的隐式异常是SyntaxError,最著名的是在无效的 JSON 数据上使用JSON.parse时触发:
JSON.parse("undefined"); // SyntaxError: Unexpected token u
将JSON.parse包裹在try/catch块中是个好主意,尤其是如果你不控制输入的 JSON 数据。
早期捕捉隐式异常
早期捕捉隐式异常的一个好方法是利用代码检查工具,如 JSHint 或 JSLint。将它们添加到你的构建过程中有助于保持你的代码整洁。我们将在本章后面更多地讨论这个话题。
11.1.3. 错误事件
在 Node 中,error事件可以从任何EventEmitter发出。如果未处理,Node 将抛出错误。如果没有处理,这些事件可能是最难调试的,因为很多时候它们是在异步操作(如流数据)期间触发的,那里的调用栈很小:
var EventEmitter = require('events').EventEmitter;
var ee = new EventEmitter();
ee.emit('error', new Error('No handler to catch me'));
这将输出以下内容:
events.js:72
throw er; // Unhandled 'error' event
^
Error: No handler to catch me
at Object.<anonymous> (/debugging/domain/ee.js:5:18)
at Module._compile (module.js:456:26)
at Object.Module._extensions..js (module.js:474:10)
at Module.load (module.js:356:32)
at Function.Module._load (module.js:312:12)
at Function.Module.runMain (module.js:497:10)
at startup (node.js:119:16)
at node.js:902:3
幸运的是,我们知道这个错误是从哪里来的;毕竟,我们刚刚编写了代码!但在更大的应用程序中,我们可能有在 DNS 层触发的错误,而我们不知道哪个使用 DNS 的模块出了问题。
因此,当可能时,处理错误事件:
ee.on('error', function (err) {
console.error(err.message, err.stack);
});
当你编写自己的EventEmitter时,请为你自己和 API 消费者行个方便,并为他们提供任何你向上传播的错误依赖的上下文。此外,在发出错误时使用Error对象而不是纯字符串,以便可以找到堆栈跟踪。
11.1.4. 错误参数
在异步操作期间发生的错误作为回调函数的第一个参数提供。与之前我们讨论的错误不同,这些错误不会直接导致异常。但它们可能是许多隐式异常的来源:
fs.readFile('/myfile.txt', function (err, buf) {
var data = buf.toString();
...
});
在这里,我们忽略了readFile返回的错误,可能假设我们总是会有一个文件数据的缓冲区来继续工作。不幸的是,有一天我们无法读取文件,我们有一个ReferenceError,因为buf未定义。
只处理异步错误更健壮。很多时候,这意味着简单地将错误传递给另一个可以优雅地处理错误的函数:
function handleError (err) {
console.error('Failed:', err.message, err.stack);
}
fs.readFile('/myfile.txt', function (err, buf) {
if (err) return handleError(err);
var data = buf.toString();
...
});
有效处理这四种类型的错误将为你未来在调试问题时提供更好的数据。
尽管我们付出了最大的努力和工具支持,但我们仍然可能错过异常,导致服务器崩溃。让我们看看如何设计我们的应用程序来处理这些情况,以便我们能够快速处理和修复未捕获的异常。
技巧 88 处理未捕获的异常
你如何有效地处理 Node 崩溃?当你与 Node 一起工作时,你首先发现的一件事是,每当未捕获异常时,它都会终止进程。了解这种行为存在的原因以及如何处理未捕获的异常,对于将健壮性构建到你的程序中非常重要。
问题
你有一个未捕获的异常正在使你的进程崩溃。
解决方案
记录异常,并优雅地关闭。
讨论
有时异常未被捕获。当这种情况发生时,Node 默认会终止进程。这有一个很好的原因,我们稍后会回到这个问题,但首先让我们谈谈我们如何改变这种默认行为。
在process对象上设置uncaughtException处理程序后,Node 将执行处理程序而不是终止你的程序:
process.on('uncaughtException', function (err) {
console.error(err);
});
是的!现在你的 Node 应用程序永远不会崩溃!虽然确实异常不会再使你的进程崩溃,但让 Node 程序继续运行的缺点很可能会超过其好处。如果你选择让应用程序继续运行,应用程序可能会泄漏资源并可能变得不稳定。
这是如何发生的?让我们看看一个我们打算长期运行的应用程序的例子:一个网络服务器。我们不会允许 Node 通过添加仅记录错误的uncaughtException处理程序来终止进程。你认为当我们处理用户请求时发生未捕获的异常会发生什么?

当请求进来时,会抛出一个异常,然后被uncaught-Exception处理程序捕获。请求会发生什么?它会泄漏,因为该连接将保持打开状态,直到客户端超时(我们也不再能访问res来给出响应)。
在图 11.1 中,你可以看到这种泄漏发生的示意图。如果我们没有异常,我们会没事,但由于我们有了异常,我们泄漏了资源。
图 11.1. 使用uncaughtException时泄漏资源

尽管这个例子被简化以使其清晰,未捕获的异常却是现实。大多数情况下,它将是无法正确关闭的套接字或文件的开销。未捕获的异常通常在代码中埋藏得更深,这使得确定正在泄漏的资源变得更加困难。
状态也可能受到影响,因为未捕获的异常会将你从当前上下文中移出,并将其置于一个完全不同的上下文中(uncaughtException处理程序),在那里你没有对对象的引用来清理东西。在我们的例子中,我们没有访问res对象来向客户端发送响应。
那么uncaughtException处理程序有什么好处呢?它使你的应用程序能够记录错误并优雅地重启。将uncaughtException处理程序视为在崩溃之前说再见的机会是明智的。记录错误,可能发送电子邮件或进行其他通知,然后优雅地关闭应用程序:

uncaughtException处理程序是最后的防线。理想情况下,异常应该在更接近源的地方被处理,以便采取行动防止泄漏和不稳定。为此,你可以使用域。
使用域处理未捕获的异常
而uncaughtException会在整个应用程序代码库上撒网以捕获错误,域允许你控制由域监控的代码部分并更接近源处理异常(更多内容请参阅第四章)。让我们实现之前提到的相同的uncaughtException示例,但使用域代替:

使用域允许我们沙盒化我们的服务器代码,同时仍然可以访问res对象以向用户提供响应,这是对之前示例的改进。但尽管我们能够向用户提供响应并关闭连接,关闭进程仍然是最佳实践。
如果你使用域,保留一个uncaughtException处理程序作为所有错误的情况,这些错误可能通过你的某个域遗漏,或者你的域错误处理程序抛出异常而没有其他域来捕获它,这并不是一个坏主意。
让我们转向一种有助于在应用程序中建立内省并防止错误发生的方法:代码检查!
技巧 89:检查 Node 应用程序
代码检查工具可以帮助捕捉到多种应用程序错误,当它们被正确调整时。在我们之前的例子中,我们拼写错误了res,这导致了一个未捕获的异常。尽管代码是有效的 JavaScript,但我们访问了一个未定义的变量。代码检查工具会捕捉到这个错误。
问题
你想要捕捉潜在的编码错误和异常。
解决方案
使用代码检查工具。
讨论
让我们讨论如何设置一个应用程序以使用 Node 的 JSHint。JSHint 是一个活跃维护的代码检查工具,它为 JavaScript 代码库提供了一系列可定制的选项。
首先,我们假设你已经在你的项目中设置了一个 package.json 文件(如果没有:npm init)。接下来,让我们将jshint添加到我们的开发依赖中:
npm install jshint --save-dev
现在我们来配置 JSHint,让它知道它在处理什么。我们只需在项目根目录中抛出一个.jshintrc文件——它包含一个 JSON 配置。让我们看看 Node 项目的基本配置:

JSHint 有很多选项(jshint.com/docs/options/),这些选项可以根据你的编码风格和意图调整规则,但这里仅展示了一些良好的基本默认设置。
要运行 JSHint,请将以下行添加到你的package.json文件中的"scripts"块(如果你没有"scripts"块,只需添加一个):

你可以从项目根目录以这种方式运行 JSHint:
npm run lint
JSHint 会给出输出,告诉你它发现了什么错误,你可以纠正或更新选项以更好地适应你的编码风格和意图。类似于测试,当你在推送代码时运行代码检查工具是有帮助的,因为很容易忘记运行,而且可以自动化。
现在我们已经探讨了防止和有效处理应用程序错误的方法,让我们转而看看当问题发生时我们可以使用的调试工具。
11.2. 调试问题
我们有测试、日志和代码风格检查。当问题发生时,我们实际上如何调试和修复它们呢?幸运的是,有针对不同情况的各种工具。在本节中,我们将探讨在运行应用程序时可能遇到的各种可能不相关的问题,以及解决这些问题的技术。我们将从使用调试器开始,然后转向性能分析、内存泄漏、生产环境调试和跟踪。
技巧 90 使用 Node 的内置调试器
当你需要逐步分析应用程序的状态时,调试器可以是一个无价的工具,Node 的内置调试器也不例外。Node 的内置工具允许你监视变量、通过断点暂停执行、进入和退出应用程序的某些部分、查看回溯、运行交互式上下文感知的 REPL,等等。
不幸的是,许多人因为一开始可能觉得命令行工具令人生畏而回避它。我们希望通过向您展示它提供的功能来打破这种观念,并展示它的强大之处。
问题
你想运行调试器来设置断点、监视变量以及逐步执行你的应用程序。
解决方案
使用 node debug。
讨论
让我们用一个简单的程序来调试,以演示调试器的某些功能:
var a = 0;
function changeA () {
a = 50;
}
function addToA (toAdd) {
a += toAdd;
}
changeA();
addToA(25);
addToA(25);
要运行内置的调试工具,只需使用 debug 命令:
node debug myprogram
它将以调试器在第一行可执行代码上中断的方式启动应用程序:
< debugger listening on port 5858
connecting... ok
break in start.js:1
1 var a = 0;
2
3 function changeA () {
debug>
要查看所有可用的命令和调试变量,你可以键入 help:
debug> help
Commands: run (r), cont (c), next (n), step (s), out (o),
backtrace (bt), setBreakpoint (sb), clearBreakpoint (cb),
watch, unwatch, watchers, repl, restart, kill, list, scripts,
breakOnException, breakpoints, version
要从默认的起始断点继续,只需键入 cont,或简写为 c。由于我们没有其他断点,应用程序将终止:
debug> cont
program terminated
debug>
但我们仍然在调试器中,并且可以通过使用 run 命令(简写为 r)再次重新启动应用程序。
debug> run
< debugger listening on port 5858
connecting... ok
break in start.js:1
1 var a = 0;
2
3 function changeA () {
debug>
我们又回到了业务。我们还可以使用 restart 命令重新启动应用程序,或者如果我们需要,可以使用 kill 命令手动终止应用程序。
应用程序的核心是字母 A,因此让我们通过为它创建一个 watch 表达式来看看它在应用程序执行过程中的变化。watch 函数接受一个要监视的表达式作为参数:
debug> watch('a')
我们可以使用 watchers 命令来查看我们正在监视的所有内容的状态:
debug> watchers
0: a = undefined
目前我们在赋值为 0 之前就暂停了,所以我们是 undefined。让我们使用 next(或简写为 n) 步入下一行:
debug> next
break in start.js:11
Watchers:
0: a = 0
9 }
10
11 changeA();
12 addToA(25);
13 addToA(25);
debug>
嗯,这很方便:调试器在我们步入下一部分时为我们输出了监视器。如果我们再次键入 watchers,我们会看到类似的输出:
debug> watchers
0: a = 0
如果我们想删除一个监视表达式,我们可以使用 unwatch 命令,给出与创建它相同的表达式。
默认情况下,调试器将打印出当前暂停行前后的一两行,以提供上下文感。但有时我们想看到更多发生的事情。我们可以使用 list 命令,给出我们想要查看的当前暂停行周围的行数:
debug> list(5)
6
7 function addToA (toAdd) {
8 a += toAdd;
9 }
10
11 changeA();
12 addToA(25);
13 addToA(25);
14
15 });
debug>
我们目前在第 11 行,changeA函数。如果我们输入next,我们会移动到下一行,即addToA函数,但让我们更深入地调查changeA函数。要做到这一点,我们只需使用step命令(或简称s):
debug> step
break in start.js:4
Watchers:
0: a = 0
2
3 function changeA () {
4 a = 50;
5 }
6
debug>
现在我们在这个函数中,我们可以使用out命令随时从中退出。一旦我们到达末尾,我们会自动退出,所以我们也可以使用next;让我们试试:
debug> next
break in start.js:5
Watchers:
0: a = 50
3 function changeA () {
4 a = 50;
5 }
6
7 function addToA (toAdd) {
debug>
如您所见,我们的监视器更新了,显示现在a是50。让我们进入下一行:
debug> next
break in start.js:12
Watchers:
0: a = 50
10
11 changeA();
12 addToA(25);
13 addToA(25);
14
debug>
现在我们回到了changeA函数之后的行。让我们再次进入这个下一个函数。你还记得那个命令吗?
debug> step
break in start.js:8
Watchers:
0: a = 50
6
7 function addToA (toAdd) {
8 a += toAdd;
9 }
10
debug>
让我们探索调试器的另一个有趣方面:内置的 REPL!我们可以通过使用repl命令来访问它:
debug> repl
Press Ctrl + C to leave debug repl
>
这是一个标准的 REPL,当你使用repl命令时,它了解其周围的上下文。因此,例如,我们可以输出toAdd参数的值:
> toAdd
25
我们还可以将状态引入应用程序。让我们创建一个全局的b变量:
> b = 100100
在许多方面,这表现得就像标准的 Node REPL 一样,所以你可以在那里做的很多事情,你在这里也可以做。
您可以随时使用 Ctrl-C 退出 REPL 模式。我们现在就做。你知道你已经退出了,因为你会回到你的调试提示符:
debug>
我们在 REPL 中停留了一段时间,所以当我们暂停时,我们可能失去了上下文。让我们再次使用list来找到我们的方向:
debug> list()
3 function changeA () {
4 a = 50;
5 }
6
7 function addToA (toAdd) {
*8 a += toAdd;*
9 }
10
11 changeA();
12 addToA(25);
13 addToA(25);
哦,是的,没错,我们在第 8 行。嗯,你知道的,我们真正想要的是让changeA函数将a赋值为 100。这是一个如此美好的数字,与这样一个美好的字母相配!但我们忘记在开始调试器时这么做。没问题!我们可以使用setBreakpoint函数(或简称sb)在这里设置一个断点来保存我们的位置:
debug> setBreakpoint()
3 function changeA () {
4 a = 50;
5 }
6
7 function addToA (toAdd) {
*8 a += toAdd;
9 }
10
11 changeA();
12 addToA(25);
13 addToA(25);
debug>
注意,现在我们的第 8 行旁边有一个星号(*),表示我们在那里设置了一个断点。让我们更改代码文件中的那个函数并保存它:
function changeA () {
a = 100;
}
回到我们的调试器中,我们可以重新启动应用:
debug> restart
program terminated<
debugger listening on port 5858
connecting... ok
Restoring breakpoint debug.js:8
break in start.js:1
1 var a = 0;
2
3 function changeA () {
debug>
看起来我们的程序已经重新启动,我们设置的断点仍然有效。它获取了我们的更改吗?让我们看看:
debug> list(20)
1 var a = 0;
2
3 function changeA () {
4 a = 100;
5 }
6
7 function addToA (toAdd) {
8 a += toAdd;
9 }
10
11 changeA();
12 addToA(25);
13 addToA(25);
14
15 });
debug>
另一种在应用程序代码中直接设置断点的方法是使用debugger关键字:
function changeA () {
debugger;
a = 100;
}
如果我们再次重新启动应用程序,我们将在任何debugger行上停止。我们也可以使用clearBreakpoint(或简称cb)来清除断点。
让我们看看另一个话题:未捕获的异常。让我们在changeA函数中引入一个讨厌的ReferenceError:
function changeA () {
a = 100;
foo = bar;
}
如果我们使用restart重新启动应用程序,然后使用cont跳过初始断点,我们的应用程序会因为未捕获的异常而崩溃。我们可以使用breakOnException来在这些异常上设置断点:
debug> breakOnException
debug>
现在,我们不会崩溃,而是首先中断,这样我们就可以在程序终止之前检查应用程序的状态并使用 REPL。
有用的多文件调试器命令
这种场景只查看了一个不包含其他模块的单个文件。调试器还有一些在多个文件中非常有用的命令。使用backtrace(或简称bt)来获取当前暂停的任何行的调用栈。你还可以使用scripts来获取已加载的文件列表以及当前所在的文件指示器。
如果你习惯了使用图形界面工具进行应用程序调试,那么内置的调试器一开始可能会感觉有些奇怪,但一旦你掌握了它的用法,它实际上非常灵活!只需在工作的地方快速输入一个debugger语句并启动它即可。
技巧 91 使用 Node 检查器
想要使用 Chrome DevTools 界面进行内置调试器的所有操作吗?有一个模块可以做到这一点!它被称为node-inspector。在这个技巧中,我们将探讨如何设置它并开始调试。
问题
你想使用 Chrome DevTools 调试 Node 应用程序。
解决方案
使用node-inspector。
讨论
Node 通过暴露一个调试端口允许远程调试,第三方模块和工具可以将其挂钩(包括内置的调试器)。一个流行的模块是node-inspector,它将 Node 的调试信息集成到 Chrome DevTools 界面中。
要设置node-inspector,只需安装它:
npm install node-inspector -g
不要忘记使用-g标志全局安装它。一旦安装,你可以通过运行以下命令来启动它:
node-inspector
现在node-inspector已经准备好并会告诉你如何访问它:
$ node-inspector
Node Inspector v0.7.0-2
info - socket.io started
Visit http://127.0.0.1:8080/debug?port=5858 to start debugging.
然后,你可以在任何 Blink 浏览器(如 Chrome 或 Opera)中访问该 URL。但是,我们没有打开调试端口的 Node 程序来开始调试,因此我们收到一个错误消息,如图 11.2 所示。
图 11.2. 未找到调试代理时的错误屏幕

让我们暂时让它运行,并编写一个小应用程序进行调试:
var http = require('http');
var server = http.createServer();
server.on('request', function (req, res) {
res.end('Hello World');
});
server.listen(3000);
现在我们可以运行这个应用程序并暴露调试端口:
$ node --debug test.js
debugger listening on port 5858
我们的应用程序现在会告诉我们调试器正在 5858 端口监听。如果我们刷新我们的 Node 检查器网页,它会看起来更有趣,如图 11.3 所示。
图 11.3. Node 检查器连接到调试器

我们可以使用检查器就像内置调试器一样设置断点和监视表达式。它还包括一个类似于 REPL 的控制台,允许你在应用程序暂停时检查其状态。
node-inspector和内置调试器之间的一个区别是 Node 不会自动在第一个表达式处中断。为了启用这一点,你必须使用--debug-brk标志:
node --debug-brk test.js
这会告诉调试器在第一条语句处中断,直到检查器可以单步执行或继续执行。如果我们重新加载检查器,我们可以看到它暂停在第一条语句,如图 11.4 所示。
图 11.4. 使用--debug-brk标志

node-inspector正在不断发展以支持更多 Chrome DevTools 的功能。
我们已经探讨了两种在 Node 中使用调试工具的方法:命令行调试器和node-inspector。现在,让我们切换到另一个用于解决性能相关问题的工具:分析器。
技巧 92 分析 Node 应用程序
分析性能的目的是回答这个问题:我的应用程序在哪里花费了时间?例如,你可能有一个长时间运行的 Web 服务器,当你访问特定的路由时,它会卡在 100%的 CPU 使用率。一开始,你可能想查看所有触及该路由的函数,看看是否有任何异常,或者你可以运行一个分析器,让 Node 告诉你它卡在了哪里。在这个技术中,你将学习如何使用分析器并解释结果。
问题
你想知道你的应用程序在哪里花费了时间。
解决方案
使用node --prof。
讨论
Node 通过使用--prof命令行标志调用了底层的 V8 统计分析器。为了解释数据,理解它是如何工作的非常重要。
每两毫秒,分析器查看正在运行的应用程序,并记录当时正在执行的函数。这个函数可能是一个 JavaScript 函数,但它也可以来自 C++、共享库或 V8 垃圾回收。分析器将这些“tick”写入名为v8.log的文件中,然后由一个特殊的 V8 tick-processor 程序进行处理。
让我们看看一个简单的应用程序来了解它是如何工作的。这里有一个应用程序执行两种不同的任务——每两秒运行一个较慢的计算任务,并且更频繁地运行一个较快的 I/O 任务:
function makeLoad () {
for (var i=0;i<100000000000;i++);
}
function logSomething () {
console.log('something');
}
setInterval(makeLoad, 2000);
setInterval(logSomething, 0);
我们可以这样分析这个应用程序:
node --prof profile-test.js
如果我们让它运行大约 10 秒钟然后终止它,我们将在同一个目录下得到一个v8.log文件。日志本身并不太有帮助。让我们使用 V8 tick-processor 工具来处理日志。这些工具要求你在机器上从源代码构建 V8,但有一个方便的第三方模块允许你跳过这一步。只需运行以下命令来安装:
npm install tick -g
这将安装适合你操作系统的适当 tick 处理器,以便查看数据。然后你可以在与你的v8.log文件相同的目录中运行以下命令以获得一些更有帮助的输出:
node-tick-processor
你会得到类似以下输出的结果(为了显示结构而进行了缩写):
Statistical profiling result from v8.log,
(6404 ticks, 1 unaccounted, 0 excluded).
[Unknown]:
ticks total nonlib name
1 0.0%
[Shared libraries]:
ticks total nonlib name
4100 64.0% 0.0% /usr/lib/system/libsystem_kernel.dylib
211 3.3% 0.0% /Users/wavded/.nvm/v0.10.24/bin/node
...
[JavaScript]:
ticks total nonlib name
1997 31.2% 96.4% LazyCompile: *makeLoad profile-test.js:1
7 0.1% 0.3% LazyCompile: listOnTimeout timers.js:77
5 0.1% 0.2% RegExp: %[sdj%]
...
[C++]:
ticks total nonlib name
[GC]:
ticks total nonlib name
1 0.0%
[Bottom up (heavy) profile]:
Note: percentage shows a share of a particular caller in
the total amount of its parent calls.
Callers occupying less than 2.0% are not shown.
ticks parent name
4100 64.0% /usr/lib/system/libsystem_kernel.dylib
1997 31.2% LazyCompile: *makeLoad profile-test.js:1
1997 100.0% LazyCompile: ~wrapper timers.js:251
1997 100.0% LazyCompile: listOnTimeout timers.js:77
让我们看看每个部分代表什么:
-
未知 —对于那个 tick,分析器无法找到与指针相关联的有意义的函数。这些在输出中都有记录,但除此之外帮助不大,可以安全忽略。
-
共享库 —这些通常是底层的 C++/C 共享库;大量的 I/O 操作也在这里进行。
-
JavaScript —这通常是最有意思的部分;它包括你的应用程序代码以及 Node 和 V8 内部的原生 JavaScript 代码。
-
C++ —这是 V8 中的 C++代码。
-
GC —这是 V8 垃圾回收器。
-
自下而上(繁重)分析 — 这显示了分析器找到的最高调用者的更详细堆栈。
在我们的例子中,我们可以看到*makeLoad是热点的 JavaScript 函数,占用了 1997 个 tick:
[JavaScript]:
ticks total nonlib name
1997 31.2% 96.4% LazyCompile: *makeLoad profile-test.js:1
7 0.1% 0.3% LazyCompile: listOnTimeout timers.js:77
5 0.1% 0.2% RegExp: %[sdj%]
这是有意义的,因为它有一些繁重的计算。另一个值得注意的有趣部分是RegExp: %[sdj%],它被util.format使用,而util.format被console.log使用。
分析器的任务是向您显示运行最频繁的函数。这并不一定意味着函数运行缓慢,但它确实意味着函数中发生了很多事情,或者它被频繁调用。结果应该作为线索,帮助您了解可以做什么来提高性能。在某些情况下,发现某些函数运行得很快可能会令人惊讶;在其他时候,这可能是预期的。分析是解决性能相关问题的谜题的一部分。
另一个可能导致性能相关问题的潜在来源是内存泄漏,尽管,显然它们首先是一个内存关注的问题,可能对性能产生影响。接下来让我们看看如何处理内存泄漏。
技巧 93 调试内存泄漏
在 Ajax 和 Node 时代之前,并没有太多努力去调试 JavaScript 内存泄漏,因为页面浏览是短暂的。但内存泄漏可能发生,尤其是在 Node 程序中,服务器进程预计可以持续运行数天、数周或数月。您如何调试泄漏应用程序?我们将探讨一种在本地或生产环境中都有效的方法。
问题
您想调试一个泄漏内存的程序。
解决方案
使用heapdump和 Chrome DevTools。
讨论
让我们编写一个泄漏应用程序来演示如何使用一些工具来调试内存泄漏。让我们创建一个 leak.js 程序:

我们如何知道这个应用程序的内存正在增长?我们可以坐着观察top或其他进程监控应用程序。我们还可以通过记录内存使用来测试它。为了获得准确的读取,让我们在记录内存使用之前强制进行垃圾回收。让我们将以下代码添加到我们的 leak.js 文件中:
setInterval(function () {
gc();
console.log(process.memoryUsage());
}, 10000)
为了使用gc()函数,我们需要通过运行带有--expose-gc标志的应用程序来暴露它:
node --expose-gc leak.js
现在,我们可以看到一些输出,清楚地显示我们的内存使用正在增长:
{ rss: 15060992, heapTotal: 6163968, heapUsed: 2285608 }
{ rss: 15331328, heapTotal: 6163968, heapUsed: 2428768 }
{ rss: 15495168, heapTotal: 8261120, heapUsed: 2548496 }
{ rss: 15585280, heapTotal: 8261120, heapUsed: 2637936 }
{ rss: 15757312, heapTotal: 8261120, heapUsed: 2723192 }
{ rss: 15835136, heapTotal: 8261120, heapUsed: 2662456 }
{ rss: 15982592, heapTotal: 8261120, heapUsed: 2670824 }
{ rss: 16089088, heapTotal: 8261120, heapUsed: 2814040 }
{ rss: 16220160, heapTotal: 9293056, heapUsed: 2933696 }
{ rss: 16510976, heapTotal: 10324992, heapUsed: 3085112 }
{ rss: 16605184, heapTotal: 10324992, heapUsed: 3179072 }
{ rss: 16699392, heapTotal: 10324992, heapUsed: 3267192 }
{ rss: 16777216, heapTotal: 10324992, heapUsed: 3293760 }
{ rss: 17022976, heapTotal: 10324992, heapUsed: 3528376 }
{ rss: 17117184, heapTotal: 10324992, heapUsed: 3635264 }
{ rss: 17207296, heapTotal: 10324992, heapUsed: 3728544 }
虽然我们知道我们的增长相当稳定,但我们并不真正知道从输出中“什么”正在泄漏。为此,我们需要拍摄一些堆快照,并将它们与我们的应用程序进行比较,看看有什么变化。我们将使用第三方heapdump模块(github.com/bnoordhuis/node-heapdump)。heapdump模块允许我们以编程方式或通过向进程发送信号(仅限 UNIX)来拍摄快照。这些快照可以使用 Chrome DevTools 进行处理。
让我们先安装这个模块:
npm install heapdump --save-dev
然后将其包含在我们的 leak.js 文件中,并对其进行配置以每 10 秒输出一个堆快照:
var heapdump = require('heapdump');
var string = '1 string to rule them all';
var leakyArr = [];
var count = 2;
setInterval(function () {
leakyArr.push(string.replace(/1/g, count++));
}, 0);
setInterval(function () {
if (heapdump.takeSnapshot()) console.log('wrote snapshot');
}, 10000);
现在,每 10 秒钟,包含快照的进程的当前工作目录中就会写入一个文件。每次拍摄快照时都会自动执行垃圾回收。让我们运行我们的应用程序以写入几个快照然后终止它:
$ node leak3.js
wrote snapshot
wrote snapshot
现在我们可以看到写入了什么:
$ ls
heapdump-29701132.649984.heapsnapshot
heapdump-29711146.938370.heapsnapshot
文件以它们各自的日期时间戳保存。数字越大,快照越新。现在我们可以将这些文件加载到 Chrome DevTools 中。打开 Chrome,然后开发者工具,转到配置文件标签页,右键单击配置文件以加载快照文件(见图 11.5)。
图 11.5. 将堆快照加载到 Chrome DevTools 中

要比较我们的两个快照,让我们按我们获取它们的顺序加载它们(见图 11.6)。
图 11.6. 加载第二个快照进行比较

现在我们已经将它们加载,我们可以进行一些调查。让我们选择第二个,然后选择比较选项。Chrome 将自动选择之前的快照进行比较(见图 11.7)。
图 11.7. 使用比较视图

现在,我们可以在我们的视图中立即看到一些有趣的东西——很多字符串被创建并且没有被垃圾回收(见图 11.8)。
图 11.8. 检查快照之间的内存分配

因此我们可以看到字符串可能在这里是个问题。但哪些字符串被创建了?在这里,我们必须做一些调查。展开(字符串)树将首先显示最大的字符串——通常是应用程序源代码和 Node 核心和 V8 中使用的某些较大的字符串。但当我们向下滚动时,我们开始看到在我们的应用程序中生成的字符串,而且很多。通过点击一个,我们可以看到保留树,或其与其他对象的关系(见图 11.9)。
图 11.9. 深入到内存中创建的数据类型

在这个练习中,我们有一种预感,我们可能会在leaky-Arr变量中存储的字符串中泄漏。但这个练习显示了代码和检查内存使用工具之间的关系。作为一名开发者,你会了解你的源代码,你从 DevTools 中获得的线索将专门针对你的代码和模块。比较视图可以提供关于变化的精彩快照。
我们只讨论了创建快照的一种方法。你还可以向带有heapdump的进程发送 SIGUSR2(在*NIX 系统上)以随意拍摄快照:
kill -USR2 1120
只需记住,它将快照写入进程的当前工作目录,如果当前工作目录不可由进程用户写入,则将静默失败。
根据你的需求,你也可以编程地变得聪明。例如,你可以设置heapdump在达到某个内存阈值后或如果它以某个预期限制增长时拍摄快照。
在将快照写入磁盘时,你可以为生产环境中的堆快照付出一点性能代价。让我们把注意力转向另一个在生产环境中使用的技术,它具有最小的代价,并允许你检查应用程序状态:使用 REPL。
技巧 94 使用 REPL 检查运行中的程序
将调试器附加到生产进程不是一个可行的选项,因为我们不希望暂停执行或为运行 V8 调试器添加性能税。那么我们如何调试实时或性能敏感的问题呢?我们可以使用 REPL 深入到进程并检查或更改状态。在这个技巧中,我们首先将查看 REPL 在 Node 中的工作方式,以及如何设置自己的 REPL 服务器和客户端。然后我们将转向检查运行中的进程。
问题
你想与一个正在运行的进程交互以检查或更改其状态。
解决方案
在进程中设置一个 REPL 并设置一个 REPL 客户端以访问。
讨论
Node REPL 是一种很好的方式,可以用来在 JavaScript 和 Node 中进行实验。与 REPL 一起玩耍的最简单方法是运行 Node 而不带任何参数,如图 11.10 所示。
图 11.10. 样本 Node REPL 会话

但你可以使用内置的 repl 模块创建自己的 REPL。事实上,当你键入 node 时,Node 使用的是同一个模块。让我们创建自己的 REPL:

执行此程序创建了一个看起来和功能都与 node 相似的 REPL:
$ node repl-basic.js
> 10 + 20
30
>
但我们不需要使用进程的 stdio 进行输入和输出;我们可以使用 UNIX 或 TCP 套接字!这允许我们从外部连接到长时间运行的进程。让我们创建一个 TCP REPL 服务器:

现在如果我们启动我们的 REPL 服务器,它将监听端口 1337:
$ node repl-tcp.js
node repl listening on 1337
我们可以使用 TCP 客户端如 telnet 或 Netcat 连接到它。你可以在另一个终端窗口中这样做:
$ nc localhost 1337
> 10 + 20
30
> exit
$
那很酷!但它不像我们的基本 REPL(见图 11.11)或 node 命令:
图 11.11. 使用 Netcat 对 REPL 服务器进行操作

-
Tab 键不会自动完成可用的属性和变量。
-
我们没有 readline 支持,所以上箭头键不会给我们任何命令历史。
-
没有颜色或粗体输出。
原因有两个。首先,repl 模块无法确定我们正在运行一个 TTY(终端)会话,因此它提供了一个最小化的接口,避免了使用 ANSI/VT100 转义代码进行颜色和格式化。这些转义代码最终会成为像 Netcat 这样的客户端的噪音。其次,我们的客户端不像 TTY。它没有发送正确的输入代码以获得诸如自动完成行为或历史记录等好处。
为了改变这种行为,我们需要修改服务器和客户端。首先,为了发送正确的 ANSI/VT100 转义代码,例如颜色和粗体输出,我们需要将终端选项添加到我们的 REPL 配置中:

第二,为了获取输入自动完成和 readline,我们需要创建一个可以发送原始 TTY 输入到服务器的 REPL 客户端。我们可以使用 Node 创建它:

现在,我们可以启动带有终端支持的 REPL 服务器:
$ node repl-tcp-terminal.js
node repl listening on 1337
我们可以在另一个终端会话中使用我们的 REPL 客户端连接到服务器:
$ node repl-client.js
> 10 + 20
30
> .exit
$
现在,我们的 REPL 会话表现得就像我们正在运行 node 或基本的 REPL 一样。我们可以使用自动完成功能,访问我们的命令历史,并获得彩色输出。太棒了!
检查运行中的进程
我们已经讨论了如何使用 repl 模块创建连接点并使用各种客户端访问它。我们这样做是为了让您熟悉在应用程序上设置 REPL 实例,以便您可以使用它们来检查运行中的进程。现在,让我们实际操作,使用 REPL 服务器仪表化现有应用程序,并使用我们创建的 REPL 客户端与之交互。
首先,让我们创建一个基本的 HTTP 服务器:
var http = require('http');
var server = http.createServer();
server.on('request', function (req, res) {
res.end('Hello World');
});
server.listen(3000);
console.log('server listening on 3000');
这看起来应该很熟悉。但让我们通过添加以下代码将此服务器公开给我们的 REPL 服务器:

关于 useGlobal 的说明
当启用时,每次你创建一个新的变量(如 var a = 1),它将被放入全局上下文(global.a === 1)。但 a 现在也将可以在事件循环中稍后运行的函数中访问。
我们通过在 r.context 上设置属性来公开服务器。我们可以将任何我们想要公开给 REPL 的内容与之交互。需要注意的是,我们也可以覆盖上下文中已经存在的内容。这包括所有标准 Node 全局变量,如 global、process 或 Buffer。
现在我们已经公开了我们的服务器,让我们看看我们如何检查和调试我们的 HTTP 服务器。首先,让我们启动我们的 HTTP 和 REPL 服务器:
$ node repl-app.js
server listening on 3000
repl listening on 1337
现在,让我们使用我们的 REPL 客户端来连接到服务器:
$ node repl-client.js
>
我们可以立即访问有用的信息。例如,我们可以看到我们的进程运行了多长时间,或者它的内存使用情况如何:

我们还公开了 server 对象,我们可以通过简单地输入 server 来访问它:
> server
{ domain: null,
_events:
...
_connectionKey: '4:0.0.0.0:3000' }
让我们看看当前有多少活动连接:
> server.connections
0
显然,在生产环境中这会更有趣,因为我们是我们唯一使用服务器的用户,我们还没有建立连接!让我们在我们的浏览器中访问 http://localhost:3000 并再次检查连接,看看它们是否有所变化:

这有效。让我们添加更复杂的仪表。你能想到一种方法来使用 REPL 开始计算进入我们服务器的请求数量吗?
添加仪表
REPL 的一个强大功能是能够添加仪表来帮助我们理解应用程序的行为,因为它正在发生。这对于那些重启应用程序会丢失我们宝贵的状态且我们不知道如何重复问题的复杂问题特别有用。
由于我们的 HTTP 服务器是一个EventEmitter,我们可以添加另一个请求处理器,它将在每个请求上被调用,使用 REPL 来用我们想要的操作进行仪器化:

现在我们正在跟踪传入的请求。让我们在我们的浏览器上刷新几次,看看是否有效:
> numReqs
8
极好。由于我们可以访问请求对象,我们可以检查我们可用的任何请求信息:IP 地址、头部、路径等等。在这个例子中,我们暴露了一个 HTTP 服务器,但任何对象都可以放在你的应用程序中合适的位置。你甚至可以考虑编写一个模块,在 REPL 中暴露常用方法。
一些问题不能在应用层面解决,需要更深入的系统检查。通过追踪获得更深入理解的一种方法。
技巧 95 追踪系统调用
理解底层系统调用的工作原理可以帮助你真正理解一个平台。例如,Python 和 Node 都具备执行 DNS 查找的功能,但在底层它们采取的方法不同。如果你想知道为什么一个的行为与另一个不同,追踪工具会向你展示这一点!
在本质上,追踪工具监控应用程序或多个应用程序正在进行的底层系统调用(通常是 C 函数名称、参数和返回值),并对数据进行有趣的处理(如记录或统计)。
追踪有助于生产。如果你有一个进程卡在 100%并且不确定原因,追踪器可以帮助暴露系统级别的底层状态。例如,你可能在这个例子中发现你超出了进程允许的打开文件数,所有 I/O 尝试都被拒绝,导致问题发生。由于追踪工具不像分析器那样影响性能,它们可以成为宝贵的资产。
问题
你想了解你的应用程序在系统层面的发生情况。
解决方案
使用针对操作系统的特定追踪工具进行深入检查。
讨论
我们之前讨论的所有技术都是系统无关的。这个是针对操作系统的。有很多不同的工具,但大多数都是特定于操作系统的。在我们的例子中,我们将使用名为strace的 Linux 特定工具。类似工具也存在于 Mac OS X/Solaris(dtruss)和 Windows(ProcessMonitor:technet.microsoft.com/en-us/sysinternals/bb896645.aspx))。
追踪程序本质上是在进程中进行系统调用的转储。如果你对底层操作系统不熟悉,准备学习吧!我们将通过追踪一个简单的应用程序来了解当我们运行它时在操作系统层面发生了什么,以学习如何阅读追踪日志。
让我们编写一个极其简单的程序来追踪:
console.log('hello world');
这看起来足够无辜。为了看到幕后发生的事情,让我们追踪这个:
sudo strace -o trace.out node hello
你会看到程序输出“hello world”并按预期退出。但我们也得到了 trace.out 中每个系统调用的转储。让我们检查那个文件。
在顶部,我们可以看到我们的第一个调用,这是有意义的。我们正在执行/usr/bin/node,传递给它node和hello参数:
execve("/usr/bin/node", ["node", "hello"], [/* 24 vars */]) = 0
如果你曾经想知道为什么process.argv[0]是node,process.argv[1]是我们 Node 程序的路径,现在你可以看到底层调用是如何进行的!strace输出告诉我们传递的参数和返回值。
要了解更多关于execve是什么(以及任何其他系统调用)的信息,如果可用,我们只需查看主机上的man页面(最佳选项),或者如果不可用,在网上查找:
man execve
更多关于 man 命令的信息
手册页还包括一些有用的错误代码,例如,在操作系统上 ENOENT 或 EPERM 代表什么。许多这些错误代码可以在openman页面上找到。
让我们进一步检查这个文件。许多初始调用是加载libuv需要的共享库。然后我们到达我们的应用程序:
getcwd("/home/wavded", 4096) = 13
...
stat("/home/wavded/hello", 0x7fff082fda08) = -1
ENOENT (No such file or directory)
stat("/home/wavded/hello.js",
{st_mode=S_IFREG|0664, st_size=27, ...}) = 0
我们可以看到 Node 获取当前工作目录,然后查找要运行的文件。注意,我们没有使用.js 扩展名来执行我们的应用程序,所以 Node 首先查找名为“hello”的程序,但没有找到,然后查找 hello.js 并成功。如果我们使用.js 扩展名运行它,你就不会看到第一个 stat 调用。
让我们看看下一个有趣的片段:
open("/home/wavded/hello.js", O_RDONLY) = 9
fstat(9, {st_mode=S_IFREG|0664, st_size=27, ...}) = 0
...
read(9, "console.log('hello world')\n", 27) = 27
close(9) = 0
在这里,我们以只读模式打开 hello.js 文件并分配一个文件描述符。文件描述符是由操作系统分配的整数。但为了理解后续的调用,我们应该注意,9是 hello.js 的分配号,直到我们看到随后的close调用。
在open之后,我们接着看到一个fstat来获取文件的大小。然后我们在read行中读取文件的内容。strace输出还显示了我们用来存储文件的缓冲区内容。然后我们关闭文件描述符。
跟踪输出文件不会显示正在运行的应用程序代码。我们只看到正在运行的系统效果。也就是说,我们不会看到 V8 解析或执行我们的console.log,但我们会看到底层的写入到stdout。让我们看看下一个:
write(1, "hello world\n", 12) = 12
回想一下第六章,每个进程都有三个自动分配的文件描述符,分别是 stdin(0)、stdout(1)和 stderr(2)。我们可以看到这个write调用使用 stdout(1)来写入hello world。我们还看到console.log为我们添加了换行符。
我们程序最终在trace.out的最后一行退出:
exit_group(0)
这里的零(0)代表进程退出代码。在这种情况下,它是成功的。如果我们使用process.exit(1)或其他状态退出,我们会看到这个数字在这里反映出来。
跟踪运行中的进程
到目前为止,我们已经使用strace启动并跟踪了一个程序,直到它退出。那么,如何连接到一个正在运行的进程呢?
在这里,我们只需获取进程的 PID:
ps ax | grep node
行首的第一个数字是我们的 PID:
32476 ? Ssl 0:08 /usr/bin/node long-running.js
一旦我们有了我们的进程 ID(PID),我们就可以对它运行strace:
sudo strace -p 32476
所有当前运行的系统调用都将输出到控制台。
这在调试 CPU 被卡住的实时问题时可以作为一个很好的第一道防线。例如,如果我们超出了进程的ulimit,这通常会导致 CPU 被卡住,因为open系统调用会不断失败。在进程上运行strace会迅速显示大量的ENFILE错误。并且从openman页面,我们可以看到一个关于错误的良好条目:
ENFILE The system limit on the total number of
open files has been reached.
列出打开的文件
在这个情况下,我们可以使用另一个实用的 Linux 工具lsof,通过 PID 获取一个进程打开的文件列表,以便进一步调查我们现在打开了什么。
我们还可以让 CPU 达到 100%并打开strace,然后你会看到以下内容反复出现:
futex(0x7ffbe00008c8, FUTEX_WAKE_PRIVATE, 1) = 1
这大部分只是事件循环的噪音,很可能你的应用程序代码在某处陷入了无限循环。像node --prof这样的工具在这种情况下会有所帮助。
关于其他操作系统工具
我们查看的实际系统调用在其他操作系统上可能会有所不同。例如,你会在 Linux 上看到epoll调用,你永远不会在 Mac OS X 上看到,因为libuv在 Mac 上使用kqueue。尽管大多数操作系统都有 POSIX 方法,如open,但函数签名和错误代码可能会有所不同。了解你托管和开发 Node 应用程序的机器,以便最好地使用跟踪工具!
家庭作业!
制作一个简单的 HTTP 服务器并跟踪它。你能找出端口在哪里被绑定,连接在哪里被接受,以及响应在哪里写回客户端吗?
11.3. 摘要
在本章中,我们探讨了调试 Node 应用程序。首先,我们专注于错误处理和预防:
-
你是如何处理应用程序生成的错误的?
-
你是如何被通知崩溃的?你是否设置了域名或
uncaughtException处理程序? -
你是否在使用 lint 工具来帮助防止异常?
然后,我们专注于调试特定问题。我们使用了 Node 和第三方模块中可用的各种工具。重要的是要知道正确的工具,这样当问题出现时,你可以评估它并获得有用的信息:
-
你需要能够设置断点、监视表达式和逐步执行你的代码吗?使用内置的
debug命令或node-inspector。 -
你需要查看你的应用程序在哪里花费时间吗?使用 Node 内置的剖析器(
node --prof)。 -
你的应用程序是否使用了比预期更多的内存?请获取堆快照并检查结果。
-
你想在不停机或不受性能影响的情况下调查一个正在运行的过程吗?设置并使用 REPL 服务器。
-
你想查看正在进行的系统调用吗?使用你的操作系统的跟踪工具。
在下一章中,我们将深入探讨使用 Node 编写 Web 应用程序!
第十二章. Node 在生产环境中的部署:安全部署应用程序
本章涵盖
-
将 Node 应用程序部署到自己的服务器
-
将 Node 应用程序部署到云提供商
-
管理生产环境中的包
-
记录
-
使用代理和集群进行扩展
一旦你构建并测试了 Node 应用程序,你将希望发布它。像 Heroku 和 Nodejitsu 这样的流行 PaaS(平台即服务)提供商使部署变得简单,但你也可以部署到私有服务器。一旦你的代码发布出去,你将需要应对意外错误、服务中断和错误,并监控性能。
本章向您展示如何安全地发布和维护 Node 程序。它涵盖了使用 Apache 和 nginx 的私有托管服务器、WebSockets、水平扩展、自动化部署、记录以及提高性能的方法。
12.1. 部署
在本节中,你将学习如何将 Node 应用程序部署到流行的云提供商和自己的私有服务器。根据你应用程序或雇主的要求,你可能只会使用这些方法之一,但了解两者都是有益的。例如,Heroku 采用的基于 Git 的工作流程已经影响了人们将应用程序部署到他们控制的服务器上的方式,只要有一点知识,你就可以设置服务器而无需从 DevOps 专家那里寻求帮助。
我们首先介绍的技术基于 Windows Azure、Heroku 和 Nodejitsu。这可能是今天部署 Web 应用程序最简单的方法,云提供商提供了免费计划,使共享你的工作变得便宜且痛苦。
技巧 96 将 Node 应用程序部署到云
该技术概述了如何使用 Node 与 PaaS 提供商,并提供了如何在生产环境中配置和维护应用程序的技巧。重点是部署和维护的实用方面,而不是定价或商业模式。
你可以免费试用 Heroku 和 Azure,所以如果你曾经想在云中运行 Node 应用程序,就跟随操作。
问题
你已经构建了一个 Node Web 应用程序,并希望将其运行在服务器上供人们使用。
解决方案
使用像 Heroku 或 Nodejitsu 这样的 PaaS 提供商。
讨论
我们将探讨三种云部署选项:Nodejitsu、Heroku 和 Windows Azure。所有这些服务都允许你部署 Node Web 应用程序,但它们处理事情的方式略有不同。上传应用程序和配置应用程序的方法不同,尽管基本概念是相同的。
Nodejitsu 是一个有趣的案例,因为它专注于 Node。另一方面,Windows Azure 支持微软的软件开发工具、编程语言和数据库。Azure 甚至具有超越 Web 应用程序托管的功能,如数据库和 Active Directory 集成。Heroku 则依赖于一个丰富的合作伙伴社区,提供附加组件,而 Azure 则更像是一个全面服务提供。
如果您查看本书提供的源代码,您应该在 production/inky 中找到一个小的 Express 应用程序。这是我们用来研究这项技术的应用程序,您可以用它作为示例应用程序来尝试每个服务提供商。Nodejitsu 和 Azure 的文档包括基于 Node 的http模块的示例,但您真的需要有一个包含 package.json 的示例来了解典型 Node 应用程序的工作方式。
我们将要查看的第一个服务提供商是 Nodejitsu (www.nodejitsu.com/)。Nodejitsu 总部位于纽约,在北美和西欧设有数据中心。Nodejitsu 成立于 2010 年,并得到了 Bloomberg Beta 基金的支持。
要开始使用 Nodejitsu,您需要注册一个账户。访问 Nodejitsu.com 并注册。如果您打算通过 Nodejitsu 发布开源项目,可以不选择定价计划进行注册。
Nodejitsu 有一个名为jitsu的命令行客户端。您可以使用npm install -g jitsu来安装它。一旦 npm 完成安装,您需要登录——输入jitsu login并输入您的用户名和密码。这将保存一个 API 令牌到一个名为~/.jitsuconf 的文件中,这样您的密码就不会在本地存储。图 12.1 展示了在终端中这个过程的样子。
图 12.1. jitsu命令行客户端允许您登录。

要部署一个应用程序,输入jitsu deploy。jitsu命令将提示您有关应用程序的问题,然后将其设置为在临时子域上运行。如果您使用的是 Express 应用程序,它将自动将NODE_ENV设置为生产环境,但您可以在 Web 界面中编辑此设置以及其他环境变量。实际上,Web 界面可以完成jitsu命令的大部分功能,这意味着您不一定需要一个开发者来执行基本维护任务,如重启应用程序。
图 12.2 展示了 Nodejitsu 的 Web 界面预览,该界面被称为WebOps。它允许您停止和启动应用程序,管理环境变量,回滚到应用程序的早期版本,甚至实时流式传输日志。
图 12.2. WebOps 管理界面

毫不奇怪,Nodejitsu 主要针对 Node 应用程序进行了优化,部署过程也受到了 npm 的强烈影响。如果您对 npm 和 package.json 文件有深入了解,并且您的项目都是 Node 应用程序,那么您在 Nodejitsu 上会感到宾至如归。
另一个受 Node 开发者欢迎的 PaaS 解决方案是 Heroku。Heroku 支持多种编程语言和平台,包括 Node,成立于 2007 年。自那以后,它已被 Salesforce.com 收购,并使用基于 Ubuntu 服务器的虚拟化解决方案。要使用 Heroku,你需要在 heroku.com 上注册。创建免费账户很容易,你甚至可以在免费层上运行生产应用程序。像域名别名和 SSL 这样的基本功能是付费的,所以不需要太多要求就能达到每月约 20 美元,但如果你不介意使用 Heroku 子域名,你可以免费运行。
一旦你创建了账户,你将需要从 toolbelt.heroku.com 安装 Heroku Toolbelt。它提供了 Linux、Mac OS X 和 Windows 的安装程序。安装完成后,你将拥有一个名为 heroku 的命令行客户端,可以用来创建和管理应用程序。在使用它之前,你必须登录;可以使用 heroku login 来完成登录,它的工作方式与 Nodejitsu 的 jitsu 命令非常相似。你只需要登录一次,因为它会存储一个用于后续请求的令牌。图 12.3 展示了它应该看起来是什么样子。
图 12.3. 使用 Heroku 登录

使用 Heroku 部署的下一步是准备你的仓库。你需要执行 git init 并提交你的项目。如果你正在使用我们的代码示例并且已经从 Git 中检出,那么你应该将你想要部署的具体项目从我们的工作树中复制出来。完整的步骤如下:
1.
git init2.
git add.3.
git commit -m 'Create new project'4.
heroku create5.
git push heroku master
heroku create 命令设置了一个名为 heroku 的远程仓库,并且第一次向其推送 git push 将触发创建一个临时的 herokuapp.com 子域名。
如果你的应用程序可以通过 npm start 启动,它应该就能正常工作。如果不能,你可能需要在应用程序中添加一个名为 Procfile 的文件,该文件包含 web: node yourapp.js。此文件列出了应用程序需要运行的过程——它可能包括后台工作进程。
如果你正在使用一个期望 NODE_ENV 被设置的 Express 应用程序,那么你需要手动使用 Heroku 来做这件事。命令是 heroku config:set NODE_ENV=production,但请注意,这在使用 Nodejitsu 时是自动完成的。
我们将要讨论的最后一家 PaaS 提供商是 Windows Azure。Microsoft 的 Azure 平台可以通过完全的 Web 界面使用,但还有一个可以通过 npm install -g azure-cli 安装的命令行界面。图 12.4 展示了命令行工具的外观。
图 12.4. Azure CLI 工具

Azure 也提供了适用于 Linux、Mac OS X 和 Windows 的 SDK 下载。下载可在 www.windowsazure.com/en-us/downloads/ 找到。
要开始使用 Azure,你需要使用 Microsoft 账户登录到www.windowsazure.com。这个账户你也可以用于其他 Microsoft 服务,所以如果你已经有 Microsoft 的电子邮件账户,你应该能够登录。Azure 的注册流程有额外的安全步骤:使用信用卡和电话号码来验证你的账户,所以它比 Heroku 或 Nodejitsu 稍微繁琐一些。
一旦你创建了 Windows Azure 账户,你将想要访问门户页面。接下来,转到计算,网站,然后快速创建。只需记住你正在创建一个“网站”,你应该没问题——Microsoft 支持广泛的与他们的现有工具(如 Visual Studio)部分定制的服务,这些服务针对.NET 开发,所以对于 Mac 和 Unix 开发者来说可能会令人困惑。
一旦你的应用程序创建完成,你需要将其与源代码控制仓库关联起来。别担心,你可以使用 GitHub!在我们继续之前,请确认你正在查看类似图 12.5 的页面。
图 12.5. 创建网站后 Azure 的 Web 界面

云配置
PaaS 提供商似乎都有自己的应用程序配置方法。当然,你可以将配置设置保存在你的代码或 JSON 文件中,但有时将它们存储在仓库之外是有用的。
例如,我们构建开源 Web 应用程序,我们也在 Heroku 上运行它们,因此我们将数据库密码保存在开源仓库之外,并使用heroku config:set代替。
点击你的应用程序名称,选择从源代码控制设置部署,然后在右侧查找站点 URL。从这里,你将能够从大量的仓库和服务提供商中进行选择,但我们使用 GitHub 测试了我们的应用程序。Azure 抓取了代码并设置了一个 Node 应用程序——它是我们在 Heroku 上使用的相同 Express 代码(listings/production/inky),并且第一次就成功了。
表 12.1 展示了如何在讨论的每个云提供商上获取和设置配置值。
表 12.1. 设置环境变量
| 提供商 | 设置 | 移除 | 列表 |
|---|---|---|---|
| Nodejitsu | jitsu env set name value | jitsu env delete name | jitsu env list |
| Heroku | heroku config:set name=value | heroku config:unset name | heroku config |
| Azure | azure site appsetting add name=value | azure site appsetting delete name | azure site appsetting list |
尽管 Azure 的注册要求可能看起来不如 Heroku 和 Nodejitsu 方便,但它确实有一些好处:如果你在使用.NET,那么你可以使用你现有的工具。此外,微软的文档非常出色,包括 Linux 和 Mac OS X 的设置和部署指南(www.windowsazure.com/en-us/documentation/articles/web-sites-nodejs-develop-deploy-mac/)。
你自己的服务器、租用的服务器或便宜的虚拟主机都有它们自己的优势。如果你想完全控制你的服务器,或者如果你的企业已经拥有自己的服务器或数据中心,那么请继续阅读,了解如何将 Node 部署到你的服务器上。
技巧 97 使用 Apache 和 nginx 与 Node 结合
将 Node 部署到运行 Apache 或 nginx 的私有服务器上是完全可能的,并且在某些情况下是推荐的。这种技术展示了如何在 Apache 和 nginx 后面运行 Node 程序。
问题
你想在你的服务器上运行一个 Node Web 应用程序。
解决方案
使用 Apache 或 nginx 代理和像 runit 这样的服务管理器。
讨论
虽然 PaaS 解决方案易于使用,但有时你必须使用专用硬件,或者你可以完全控制的虚拟机。大型企业通常在其自己的数据中心有投资,因此切换到外部服务提供商是没有意义的。
虚拟化已经改变了 Web 托管。Linux 虚拟机已经是在线应用程序托管的关键解决方案好几年了,像 Amazon Elastic Compute Cloud 这样的服务使得按需创建和销毁服务变得容易。
因此,你可能会在某个时候面临将 Node 应用程序部署到需要配置和维护的服务器上的问题。如果你已经熟悉基本系统管理任务,那么你可以重用你现有的技能和软件。否则,你必须熟悉 Web 服务器守护进程以及用于保持 Node 程序运行和从错误中恢复的工具。
这种技术提供了 Apache 和 nginx 的示例。它们都是 Web 服务器,但它们的配置格式非常不同,并且它们是以不同的方式构建的。图 12.6 显示了在本节中我们将创建的基本服务器架构。
图 12.6. 与 Apache 或 nginx 一起运行的 Node 程序

实际上运行一个 Web 服务器并不是必要的——有方法可以使 Node 程序安全地访问端口 80。但我们假设你正在部署到一个已经存在网站的服务器。此外,有些人更喜欢从 Apache 或 nginx 服务静态资源。
对于这两个服务器,使用的技术是相同的:代理。以下列表展示了如何使用 Apache 来实现这一点。
列表 12.1. 使用 Apache 代理请求到 Node 应用程序

清单 12.1 中的指令应添加到您的 Apache 配置文件中。要找到正确的文件,在您的服务器上输入 apache2 -V,并查找 HTTPD_ROOT 和 SERVER_CONFIG_FILE 的值——将它们连接起来将给出正确的路径和文件。你可能不希望将所有请求都重定向到你的 Node 应用程序,因此你可以将代理设置添加到 VirtualHost 块中。
使用这三行,对 / 的请求现在将被代理到监听在端口 3000 的进程
。在这种情况下,该进程被假定为使用 node server.js 或 npm start 运行的 Node 程序,但从技术上讲,它可以是任何 HTTP 服务器。LoadModule 指令告诉 Apache 使用代理
和 HTTP 代理
模块。
如果你忘记启动 Node 进程,或者退出它,那么 Apache 将返回 503 错误。为了避免这种错误,你需要一种方法来保持 Node 进程运行,并在服务器启动时运行它。一种方法是用 runit (smarden.org/runit/)。
如果你使用的是 Debian 或 Ubuntu,你可以使用 apt-get install runit 安装 runit。一旦它就绪,创建一个可以启动你的 Node 进程的 shell 脚本。首先,为你的项目创建一个目录:sudo mkdir /etc/service/nodeapp。然后,创建一个用于脚本的文件:sudo touch /etc/service/nodeapp/run。然后编辑该文件,使其看起来像下面的列表。
列表 12.2. 使用 runit 运行程序

我们的服务器使用 nvm (github.com/creationix/nvm) 来管理已安装的 Node 版本,因此我们将它的位置添加到 $PATH
;否则 shell 找不到 node 和 npm 的安装位置。你可能需要根据 which node 的输出进行修改,或者完全删除它。最后两行
只是将目录更改为你的 Node 项目的位置,然后使用 npm start 启动它。
应用程序可以使用 sudo sv start /etc/service/nodeapp 启动,并使用 sudo sv stop /etc/service/nodeapp 停止。一旦 Node 进程运行,你可以通过杀死它来测试它,然后检查它是否由 runit 自动重启。
现在你已经知道了 Apache 如何处理代理,以及如何保持进程运行,让我们看看 nginx。Nginx 通常用作 web 服务器,但从技术上讲,它是一个支持 HTTP、HTTPS 和电子邮件的反向代理服务器。为了使 nginx 代理到 Node 应用程序,你可以使用 Proxy 模块,它使用 proxy_pass 指令的方式与 Apache 类似。
清单 12.3 包含了 nginx 需要的设置。像 Apache 一样,你也可以将 server 块放在虚拟主机文件中。
清单 12.3. 使用 nginx 代理请求到 Node 应用程序

如果您在同一台服务器上有多个应用程序,则可以使用不同的端口,但在这里我们使用了 3000
。这个例子基本上与 Apache 相同——您告诉服务器要代理的位置,然后是端口。当然,这个例子也可以与 runit 结合使用。
如果您不想运行 Apache 或 nginx,则可以在没有网络服务器的情况下运行 Node 网络应用程序。继续阅读,了解如何使用防火墙规则和其他技术来完成此操作。
技巧 98 在端口 80 上安全运行 Node
您仍然可以在没有 Apache 这样的网络服务器守护进程的情况下运行 Node。为此,您基本上需要将外部端口 80 转发到一个内部的无权限端口。这种技术在 Linux 中提供了一些实现方式。
问题
您不想使用 Apache 或 nginx。
解决方案
使用防火墙规则将端口 80 重定向到另一个无权限端口。
讨论
在大多数操作系统中,绑定到端口 80 需要特殊权限。这意味着如果您尝试使用 app.listen(80) 而不是我们大多数示例中使用的端口 3000,您将看到 Error: listen EACCES。这是因为您的当前用户账户没有权限绑定到端口 80。
您可以通过运行 sudo npm start 来绕过这个限制,但这很危险。理想情况下,您希望您的 Node 程序以非 root 用户身份运行。
在 Linux 中,可以通过使用 iptables 将流量从端口 80 重定向到更高的端口号。Linux 使用 iptables 来管理防火墙规则,因此您只需要一个将端口 80 映射到 3000 的规则:
iptables -t nat -I PREROUTING -p tcp --dport\
80 -j REDIRECT --to-port 3000
要使此更改永久生效,您需要将规则保存到一个文件中,该文件会在网络接口设置时运行。一般方法是将规则保存到一个文件中,例如 /etc/iptables.up.rules,然后编辑 /etc/network/interfaces 以使用它:
auto eth0
iface eth0 inet dhcp
pre-up iptables-restore < /etc/iptables.up.rules
post-down iptables-restore < /etc/iptables.down.rules
这在很大程度上取决于您的操作系统;这些规则是从 Debian 和 Ubuntu 的文档中改编的,但在其他 Linux 发行版中可能会有所不同。
这种技术的缺点是它将流量映射到监听该端口的任何进程。一个替代方案是授予 Node 二进制文件额外的能力。您可以通过安装 libcap2 来做到这一点。
在 Debian 和 Ubuntu 中,您可以使用 sudo apt-get install libcap2-bin。然后您只需要授予 Node 二进制文件访问特权端口的权限:
sudo setcap cap_net_bind_service=+ep /usr/local/bin/node
您可能需要更改 Node 的路径——如果您不确定它在哪,请检查 which node 的输出。使用能力来做到这一点的一个缺点是现在 node 二进制文件可以绑定到 1-1024 之间的所有端口,因此它不如将其限制在端口 80 上那样具体。
一旦您将能力应用到二进制文件上,它就会固定,直到文件更改。这意味着如果您升级 Node,您将需要再次运行此命令。
现在您的应用程序正在服务器上运行,您将希望确保它永远运行。有许多不同的方法可以实现这一点;下一个技巧概述了 runit 和 forever 模块。
技巧 99 保持 Node 进程运行
程序不可避免地会崩溃,当这种情况发生时,这是不幸的。重要的是您如何处理故障——用户应该被告知,程序应该优雅地恢复。这项技术完全是关于无论发生什么情况都要保持节点程序运行。
问题
您的程序在半夜崩溃了,直到您重新启动它,客户都无法使用该服务。
解决方案
使用进程监控器自动重新启动节点程序。
讨论
保持节点程序运行主要有两种方式:服务监控或管理其他节点程序的节点程序。第一种方法是一种通用的、特定于操作系统的技术。您已经在技术 97 中看到了 runit。Runit 支持服务监控,这意味着它会检测进程何时停止运行,并尝试重启它。
另一个守护进程管理器是 Upstart (upstart.ubuntu.com/)。如果您使用 Ubuntu,您可能已经见过 Upstart。要使用它,您需要一个配置文件,该文件描述了如何管理节点程序。列表 12.4 包含了一个示例,您可以根据您的服务器进行修改——它应该保存在 /etc/init/nodeapp.conf 中,其中 nodeapp 是您应用程序的名称。
列表 12.4. 使用 Upstart 管理节点程序

此配置文件告诉 Upstart,如果应用程序因任何原因而死亡,则重新启动应用程序 (upstart.ubuntu.com/wiki/Stanzas#respawn)。它设置了一个类似于在终端中输入 echo $PATH 时在您的终端中看到的 PATH
。然后它声明程序应在运行级别 2 和 3 上运行
——运行级别 2 通常是网络守护进程启动时。
运行级别
Unix 系统根据供应商的不同,处理运行级别的策略也不同。Linux 标准基规范将运行级别 2 描述为多用户模式,将 3 描述为具有网络的多用户模式。在 Debian 中,2-5 被分组为具有控制台登录和显示管理器的多用户模式。然而,Ubuntu 将运行级别 2 视为具有网络的可视化多用户模式,因此在使用 Upstart 之前,您应该检查您的系统如何实现运行级别。
Upstart 的 script 段落允许您包含一个简短的脚本,这意味着您可以执行诸如将 NODE_ENV 设置为 production 之类的操作。应用程序本身是通过 exec 指令启动的。我们通过将标准输出和标准错误重定向到日志文件来提供了日志支持
。
与 runit 相比,设置 Upstart 可能需要更多的工作,但我们已经连续三年在生产环境中使用它而没有遇到任何问题。两者都比传统的停止/启动初始化脚本更容易设置和维护,但您还可以使用另一种技术:监控其他节点程序的节点程序。
Node 进程管理器通过使用一个小程序来确保另一个程序持续运行。这个程序很简单,因此比更复杂的 Web 应用程序更不容易崩溃。为此最流行的模块之一是 forever (www.npmjs.org/package/forever),它可以作为命令行程序或程序化使用。
大多数人通过命令行界面使用它。基本用法是 forever start app.js,其中 app.js 是你的 Web 应用程序。尽管如此,它还有很多其他选项:它可以管理日志文件,甚至可以将你的程序包装起来,使其表现得像守护进程。
要以守护进程的方式启动你的程序,请使用以下选项:
forever start -l forever.log -o out.log -e err.log app.js
这将启动 app.js,并创建一些额外的文件:一个用于存储活动进程当前 PID 的文件,一个日志文件和一个错误日志文件。一旦程序开始运行,你可以像这样优雅地停止它:
forever stop app.js
forever 可以与任何 Node 程序一起使用,但它通常被视为一个用于长时间运行 Web 应用的工具。命令行界面使其与其他 Unix 程序一起使用变得容易。
部署使用 WebSockets 的应用程序可能会带来一系列独特的要求。在使用 PaaS 提供商时可能会更困难,因为它们可以杀死持续时间超过一定秒数的请求。如果你使用 WebSockets,请查看下一项技术,以确保你的设置可以在生产中工作。
技巧 100 在生产中使用 WebSockets
Node 对 WebSockets 非常出色——同一个进程可以同时服务标准的 HTTP 请求和较新的 WebSocket 协议。但具体如何部署在生产中使用 WebSockets 的程序呢?继续阅读以了解如何使用 Web 服务器和云提供商来完成这项任务。
问题
你想在生产中使用 WebSockets。
解决方案
确保你使用的服务提供商或代理支持 HTTP Upgrade 头。
讨论
WebSockets 非常神奇,但仍然被托管提供商几乎当作二等公民对待。Nodejitsu 是第一个支持 WebSockets 的 PaaS 提供商,它使用 node-http-proxy (github.com/nodejitsu/node-http-proxy) 来实现这一点。几乎所有的解决方案都涉及代理。要了解原因,你需要看看 WebSockets 的工作方式。
HTTP 实质上是一种无状态协议,这意味着服务器和客户端之间的所有交互都可以用包含所有所需状态的请求和响应来建模。这种封装级别导致了现代客户端/服务器 Web 应用的设计。
这种方法的缺点是,底层协议不支持长时间运行的全双工连接。有许多基于此类 TCP 连接的应用程序;视频流和会议、实时消息和游戏是突出的例子。随着网络浏览器的发展,以支持更丰富、更复杂的应用程序,我们自然地试图使用 HTTP 来模拟这些类型的应用程序。
WebSocket 协议是为了支持长连接而开发的,类似于 TCP。它通过使用标准的 HTTP 握手来工作,客户端通过这个握手来确认服务器是否支持 WebSockets。这个机制是通过一个名为Upgrade的新头来实现的。由于 HTTP 客户端和服务器通常会被各种非标准头信息轰炸,因此不支持Upgrade的服务器应该没问题——客户端只需回退到传统的 HTTP 轮询。
由于服务器必须以不同的方式处理 WebSocket 连接,因此实际上运行两个服务器是有意义的。在 Node 程序中,我们通常有一个http.listen用于我们的标准 HTTP 请求,还有一个“内部”WebSocket 服务器。
在技术 97 中,您看到了如何使用 nginx 与 Node 结合。示例中使用了代理来将请求从 nginx 传递到您的 Node 进程,这意味着 Node 进程可以绑定到不同于 80 的端口。通过使用相同的技巧,您可以让 nginx 支持 WebSockets。一个典型的nginx.conf配置可能如下所示。
列表 12.5. 向 nginx 添加 WebSocket 支持

添加proxy_http_version 1.1和proxy_set_header Upgrade
可以使 nginx 过滤 WebSocket 请求到您的 Node 进程。此示例还将跳过 WebSocket 请求的缓存。
既然我们提到了 Nodejitsu 支持 WebSockets,那么 Heroku 呢?嗯,您目前需要将其作为附加组件启用,这意味着您需要运行一个heroku命令:
heroku labs:enable websockets
Heroku 的 Web 服务器通常会在大约 75 秒后杀死请求,但启用此附加组件意味着带有Upgrade头的请求应该可以持续运行,直到网络允许。
有时候您可能无法轻松地使用 WebSockets。一个例子是 Apache 的旧版本,其中代理模块不支持它们。在这种情况下,使用一个在所有其他内容之前运行的代理服务器可能更好。
HAProxy (haproxy.1wt.eu/)是一个灵活的代理服务器。其用法与 nginx 类似,也是基于事件的,因此在 Node 社区中得到了广泛的应用。如果您使用的是 Apache 的旧版本,您可以根据诸如 URL 或头信息等选项将 Web 请求代理到 Apache 或 Node。
如果你想在 Debian 或 Ubuntu 上安装 HAProxy,可以使用 sudo apt-get install haproxy。一旦设置好,你需要编辑 /etc/default/haproxy 并设置 ENABLED=1——这仅仅是因为它附带默认配置,所以默认情况下是禁用的。列表 12.6 是一个示例配置,它可以将请求路由到运行在 3000 端口的 Node Web 应用程序,但可以从外部使用 80 端口访问。
列表 12.6. 使用 HAProxy 与 Node 应用程序

这应该与 WebSocket 一起工作,我们使用了较长的超时时间,这样 HAProxy 就不会关闭 WebSocket 连接,这些连接通常是长期存在的
。如果你运行一个监听 3000 端口的 Node 程序,那么在用 sudo /etc/init.d/haproxy restart 重启 HAProxy 后,你的应用程序应该可以通过 80 端口访问。
你可以使用 表 12.2 来找到适合你应用程序的 Web 服务器。
表 12.2. 比较服务器选项
| 服务器 | 功能 | 适用于 |
|---|---|---|
| Apache |
-
快速的资产服务
-
与现有的 Web 平台(PHP、Ruby)配合良好
-
许多模块,如代理、URL 重写
-
虚拟主机
| 可能已经在服务器上 |
|---|
| nginx |
-
基于事件的架构,非常快
-
易于配置
-
代理模块与 Node 和 WebSocket 配合良好
-
虚拟主机
| 在你想要托管 Node 应用程序的同时托管静态网站,但还没有设置 Apache 或旧服务器的情况下 |
|---|
| HAProxy |
-
基于事件的,速度快
-
可以将路由到同一台机器上的其他 Web 服务器
-
与 WebSocket 配合良好。
| 扩展到集群以处理高流量网站,或复杂的异构设置 |
|---|
| 原生 Node 代理 |
-
重复使用你的 Node 编程知识
-
灵活
| 如果你想扩展并且有一个技术精湛的 Node 团队,那么很有用 |
|---|
哪个服务器适合我?
本章没有涵盖所有服务器选择——我们主要关注 Unix 服务器上的 Apache 和 nginx。即便如此,在这些选项之间做出选择可能仍然困难。我们包括了 表 12.2,这样你可以快速比较每个选项。
你可以通过使用 backend 指令来给多个“后端”命名,让 HAProxy 了解它们。在 列表 12.7 中我们只有一个——node_backend。也可以运行 Apache,并根据域名将某些请求路由到它:
frontend http-in
mode http
bind *:80
acl static_assets hdr_end(host) -i static.manning.com
backend static_assets
mode http
server www_static localhost:8080
如果你有现有的 Apache 虚拟主机——可能用于托管静态资产、博客和网站——并且你想要在同一台服务器上添加 Node,Apache 可以设置为监听不同的端口,这样 HAProxy 就可以位于其前面,然后将请求路由到端口 3000 的 Express 和端口 8080 的现有 Apache 网站。Apache 允许你使用 Listen 8080 指令更改端口。
您可以使用相同的 acl 选项根据 URL 路由 WebSocket。假设您已经在 Node 应用程序中将 WebSocket 服务器挂载到 /chat。您可以有服务器的一个特定实例,专门处理 WebSocket,并通过使用 path_beg 条件路由。以下列表显示了这是如何工作的。
列表 12.7. 使用 HAProxy 与 WebSocket

HAProxy 可以根据许多参数匹配请求。这里我们使用了 hdr(Upgrade) -i WebSocket 来测试是否使用了 Upgrade 标头
。如您所见,这表示 WebSocket 握手。
通过使用 path_beg 并使用 acl is_websocket 标记匹配的路由
,您现在可以根据前缀表达式 if is_websocket 路由请求。
所有这些 HAProxy 选项都可以组合起来路由请求到您的 Node 应用程序、Apache 服务器和特定的 WebSocket Node 服务器。这意味着您可以在完全不同的进程中运行 WebSocket,甚至另一个内部 Web 服务器。HAProxy 是扩展 Node 程序的绝佳选择——您可以在多个服务器上运行应用程序的多个实例。
HAProxy 提供了一个 weight 选项,允许您通过在 backend 中添加 balance roundrobin 来实现 轮询 负载均衡。
您可以最初部署应用程序,不需要 nginx 或 HAProxy 在前面,但一旦准备好,您可以通过使用代理来扩展。如果您目前没有性能问题,那么了解代理可以执行诸如将 WebSocket 路由到不同的服务器和处理轮询负载均衡等操作是有价值的。如果您已经有一个使用 Apache 2.2.x 的服务器,它不兼容代理 WebSocket,那么您可以在 Apache 前面放置 HAProxy。
如果您使用 HAProxy,您仍然需要使用像 runit 或 Upstart 这样的监控守护进程来管理您的 Node 进程,但这已被证明是一个极其灵活的解决方案。
我们还没有讨论的另一种方法是使用一个轻量级的 Node 程序作为代理本身。实际上,PaaS 提供商如 Nodejitsu 在幕后就是这样做的。
选择正确的服务器架构只是成功部署 Node 应用程序的第一步。您还应该考虑性能和可扩展性。接下来的三个技巧包括关于缓存和运行 Node 程序集群的建议。
12.2. 缓存和扩展
本节主要关于同时运行多个 Node 应用程序,但我们还包含了一种提供缓存详细信息的技巧。如果您可以让客户端做更多的工作,为什么不呢?
技巧 101:HTTP 缓存
尽管 Node 以其高性能的 Web 应用程序而闻名,但您仍然有方法可以加快速度。缓存是主要的技巧,您在部署应用程序之前应该考虑缓存。本技巧介绍了 HTTP 缓存背后的概念。
问题
你希望减少向你的应用程序发送请求所需的时间。
解决方案
检查以确保你正确地使用了 HTTP 缓存。
讨论
现代网络应用程序可能非常大:图像资源、字体、CSS、JavaScript 和 HTML 的总和构成了一个庞大的负载,分布在几个 HTTP 请求中。即使使用最佳的压缩工具,下载量仍然可能达到兆字节。为了避免要求用户等待他们在网站上进行的每个操作,最佳策略可能是完全消除下载的需求。
浏览器会在本地缓存内容,并且可以查看缓存以确定资源是否需要下载。这个过程由 HTTP 缓存头部 和条件请求控制。在这个技术中,我们将介绍缓存头部并解释它们是如何工作的,这样当你在 WebKit 检查器等调试工具中查看你的应用程序提供响应时,你就会知道预期的缓存头部。
主要的两个头部是 Cache-Control 和 Expires。Cache-Control 头部允许服务器指定一个指令来控制资源如何被缓存。基本指令如下:
-
public—允许在浏览器和服务器之间的任何中间代理中缓存。 -
private—仅允许浏览器缓存资源。 -
no-store—不要缓存资源(但某些客户端在特定条件下仍然会缓存)。
有关 Cache-Control 指令的完整列表,请参阅超文本传输协议 1.1 规范(www.w3.org/Protocols/rfc2616/rfc2616.html)。
Expires 头部告诉浏览器何时替换本地资源。日期应采用 RFC 1123 格式:Fri, 03 Apr 2014 19:06 BST。HTTP/1.1 规范指出,不应使用超过一年的日期,因此不要设置过远的未来日期,因为其行为是未定义的。
这两个头部允许服务器告诉客户端资源应该何时被缓存。大多数 Node 框架,如 Express,会为你设置这些头部——例如,Connect 部分的静态资源服务中间件会将 maxAge 设置为 0 以指示应该进行缓存验证。如果你在浏览器的调试工具中的网络控制台查看,你应该看到 Express 正在以 Cache-Control: public, max-age=0 和基于文件日期的 Last-Modified 日期提供静态资源。
Connect 的static中间件,位于send模块中,通过使用stat.mtime.toUTCString获取最后文件修改的日期来实现这一点。浏览器将为资源发送一个标准的 HTTP GET请求,并带有两个额外的请求头部:If-Modified-Since和If-None-Match。Connect 将检查If-Modified-Since与文件修改日期,并根据修改日期响应 HTTP 304。这样的 304 响应将没有主体,因此浏览器可以条件性地使用本地内容而不是再次下载资源。
图 12.7 从浏览器的角度展示了 HTTP 缓存的概览。
图 12.7. 浏览器要么使用本地缓存,要么根据前一个请求的头部信息进行条件请求。

条件缓存对于可能发生变化的大资产,如图片,非常适用,因为检查资源是否需要重新下载的GET请求成本要低得多。这被称为基于时间的条件请求。还有基于内容的条件请求,其中使用资源的摘要来查看资源是否已更改。
基于内容的条件请求使用 ETag。ETag是实体标签的缩写,允许服务器根据其内容验证缓存中的资源。Connect 的静态中间件生成 ETag 如下:
exports.etag = function(stat) {
return '"' + stat.size + '-' + Number(stat.mtime) + '"';
};
现在对比一下 Express 如何为动态内容生成 ETag——这通常是使用res.send发送的内容,如 JavaScript 对象或字符串:
exports.etag = function(body){
return '"' + crc32.signed(body) + '"';
};
第一个示例使用文件修改时间和大小来创建哈希。第二个使用基于内容的哈希函数。这两种技术都向浏览器发送基于内容的标签,但它们已经根据资源类型进行了性能优化。
静态服务器开发者面临压力,需要尽可能使服务器运行得更快。如果你使用 Node 的内置http模块,你必须考虑所有这些缓存头部信息,然后优化像 ETag 生成这样的东西。这就是为什么建议使用像 Express 这样的模块——它将根据合理的默认行为处理所需头部的细节,这样你就可以专注于开发你的应用程序。
缓存是一种优雅的性能提升方式,因为它有效地允许你通过让客户端做更多工作来减少流量。另一种选择是使用基于 Node 的 HTTP 代理在进程或服务器集群之间进行路由。继续阅读以了解如何做到这一点,或者跳转到技巧 103 以了解如何使用 Node 的 cluster 模块来管理多个 Node 进程。
技巧 102 使用 Node 代理进行路由和扩展
本地开发很简单,因为你通常一次只运行一个 Node 应用程序。但生产服务器可以托管多个应用程序,并在多个 CPU 核心上运行相同的应用程序以提高性能。到目前为止,我们已经讨论了网页和代理服务器,但这项技术专注于纯 Node 服务器。
问题
你想使用纯 Node 解决方案来托管多个应用程序,或者扩展应用程序。
解决方案
使用像 Nodejitsu 的 http-proxy 这样的代理服务器模块。
讨论
这种技术展示了如何使用 Node 程序来路由流量。它与技术 100 中的代理服务器示例类似,因此你可以将这些想法重新应用于 HAProxy 或 nginx。但有时在代码中表达路由逻辑可能比使用设置文件更容易。
此外,正如你在本书中之前所见,Node 程序作为一个单独的进程运行,通常不会充分利用可能具有多个 CPU 和 CPU 核心的现代服务器。因此,你可以使用这里的技术根据你的生产需求来路由流量,同时运行你应用程序的多个实例,以便更好地利用服务器的资源,减少响应延迟,并希望让你的客户满意。
Nodejitsu 的 http-proxy (www.npmjs.org/package/http-proxy) 是围绕 Node 内置的 http 核心模块的一个轻量级包装器,它使得通过代码定义代理变得更容易。如果你跟随了我们的 Node 网络开发章节,你应该对基本用法很熟悉。下面的列表是一个简单的代理,它将流量重定向到另一个端口。
列表 12.8. 使用 http-proxy 将流量重定向到另一个端口

此示例通过使用 http-proxy 的 target 选项将流量重定向到端口 3000
。此模块是事件驱动的,因此可以通过设置错误监听器来处理错误
。代理服务器本身被设置为监听端口 9000
,但我们只是使用它以便你可以轻松运行它——在生产中会使用端口 80。
传递给 createProxyServer 的选项可以定义其他路由逻辑。如果设置了 ws: true,则 WebSocket 将单独路由。这意味着你可以创建一个代理服务器,将 WebSocket 路由到某个应用程序,而将标准请求路由到其他地方。让我们通过一个更详细的示例来看看。下面的列表显示了如何将 WebSocket 请求路由到另一个应用程序。
列表 12.9. 分别路由 WebSocket 连接

此示例创建了两个代理服务器:一个用于网页请求,另一个用于 WebSocket
。主面向网页的服务器在 WebSocket 被初始化时发出 upgrade 事件,并且这个事件被拦截,以便请求可以被路由到其他地方
。
这种技术可以扩展为根据任何你喜欢的规则路由流量——如果你能从request对象中推断出某些信息,你可以相应地路由流量。同样的想法也可以用来将流量映射到多台机器。这允许你创建一个服务器集群,这有助于你扩展应用程序。以下列表可以用来代理到多个服务器。
列表 12.10. 使用服务器多个实例进行扩展

此示例使用一个包含每个代理服务器选项的数组,并为每个选项创建一个代理服务器实例!图片。然后你需要做的就是创建一个标准的 HTTP 服务器,并将请求映射到每个服务器!图片。此示例使用基本的轮询实现——在每个请求之后,计数器都会增加,所以下一个请求将被映射到不同的服务器。你可以轻松地将此示例重新配置为映射到任意数量的服务器。
在具有多个 CPU 和 CPU 核心的单个服务器上,映射此类请求可能很有用。如果你运行你的应用程序多次,并将每个实例设置为监听不同的端口,那么你的操作系统应该在每个不同的 CPU 核心上运行每个 Node 进程。此示例使用localhost,但你也可以使用另一个服务器,从而将应用程序在多个服务器之间进行集群。
与此技术使用额外服务器进行扩展相比,下一个技术使用 Node 的内置功能来管理同一 Node 程序的多个副本。
技术 103 使用集群进行扩展和弹性
JavaScript 程序被认为是单线程的。它们实际上是否使用单线程取决于平台,但从概念上讲,它们作为单线程执行。这意味着你可能需要做额外的工作来扩展你的应用程序以利用多个 CPU 和核心。
这种技术演示了核心模块cluster,并展示了它与可扩展性、弹性和你的 Node 应用程序之间的关系。
问题
你想提高你应用程序的响应时间,或者增加其弹性。
解决方案
使用cluster模块。
讨论
在技术 102 中,我们提到了在代理后面运行多个 Node 进程。在这个技术中,我们将解释这是如何在 Node 的一侧纯粹工作的。你可以使用这个技术中的想法,无论是否有代理服务器来负载均衡。无论如何,目标都是相同的:更好地利用可用的处理器资源。
图 12.8 显示了一个系统,该系统有两个 CPU,每个 CPU 有四个核心。一个 Node 程序正在该系统上运行,但只完全利用了一个核心。
图 12.8. 在单个核心上运行的 Node 进程

有原因使得图 12.8 并不完全准确。根据操作系统的不同,进程可能会在核心之间移动,尽管可以说 Node 程序是一个单独的进程,但它仍然使用多个线程。假设你启动了一个使用 MySQL 数据库、静态文件服务、用户会话等功能的 Express 应用程序。即使它将以单个进程运行,它仍然会有八个独立的线程。
我们被训练成将 Node 程序视为单线程的,因为 JavaScript 平台在概念上是单线程的,但幕后,Node 的库如libuv将使用线程来提供异步 API。这给了我们基于事件的编程风格,而无需担心线程的复杂性。
如果你正在部署 Node 应用程序,并想在多核、多 CPU 系统上获得更好的性能,那么你需要开始更多地思考 Node 在这一层面的工作方式。如果你在一个多核系统上运行单个应用程序,你希望得到图 12.9 中所示的效果。
图 12.9。通过运行多个进程来利用更多核心。

在这里,我们在除了一个核心之外的所有核心上运行 Node 程序,其想法是保留一个核心供系统使用。你可以使用os核心模块来获取系统的核心数。在我们的系统中,运行require('os').cpus().length返回 4——这是我们的核心数,而不是 CPU 数——Node 的 API cpus方法返回一个表示每个核心的对象数组:
[{ model: 'Intel(R) Core(TM) i7-4650U CPU @ 1.70GHz',
speed: 1700,
times:
{ user: 11299970, nice: 0, sys: 8459650, idle: 93736040, irq: 0 } },
{ model: 'Intel(R) Core(TM) i7-4650U CPU @ 1.70GHz',
speed: 1700,
times:
{ user: 5410120, nice: 0, sys: 2514770, idle: 105568320, irq: 0 } },
{ model: 'Intel(R) Core(TM) i7-4650U CPU @ 1.70GHz',
speed: 1700,
times:
{ user: 10825170, nice: 0, sys: 6760890, idle: 95907170, irq: 0 } },
{ model: 'Intel(R) Core(TM) i7-4650U CPU @ 1.70GHz',
speed: 1700,
times:
{ user: 5431950, nice: 0, sys: 2498340, idle: 105562910, irq: 0 } } ]
有了这个信息,我们可以自动调整应用程序以扩展到目标服务器。接下来,我们需要一种方法来分叉我们的应用程序,使其可以以多个进程运行。假设你有一个 Express 网络应用程序:你如何安全地将其扩展,而无需完全重写它?主要问题是通信:一旦你开始运行应用程序的多个实例,它如何安全地访问共享资源,如数据库?对此有平台无关的解决方案,这需要一个大型的项目重写——pub/sub 服务器、对象代理、分布式系统——但我们将使用 Node 的cluster模块。
cluster模块提供了一种运行多个工作进程的方法,这些进程共享对底层文件句柄和套接字的访问。这意味着你可以用工作进程的工作主进程包装 Node 应用程序。如果你像在数据库中访问用户会话这样的操作,工作进程不需要访问共享状态;所有工作进程都将有权访问数据库连接,因此你不需要在进程之间设置任何通信。
列表 12.11 是使用 Express 应用程序进行集群的基本示例。我们只包含了加载主 Express 应用程序的 server.js 文件,即 app.js。这是我们构建 Node 网络应用程序的首选方法——使用.listen(port)设置服务器的部分与应用程序本身位于不同的文件中。在这种情况下,将服务器和应用程序分开还有额外的优点,即更容易将集群添加到项目中。
列表 12.11. 集群化 Node 网络应用程序
基本模式是加载cluster核心模块 ,然后确定应该使用多少核心
。
cluster.isMaster允许代码在这是第一个(或主进程)时分支,然后使用cluster.fork 按需创建工人。
每个工人都会重新运行这段代码,因此当工人遇到else分支时,服务器可以运行特定于工人的代码 。在这个例子中,工人开始监听 HTTP 连接,从而启动 Express 应用程序。
本书代码示例中包含了这个代码的完整示例,可以在 production/inky-cluster 中找到。
如果你是一个 Unix 黑客,这一切都应该看起来非常熟悉。fork()的语义对 C 程序员来说很熟悉。它的工作方式是,每当使用系统调用fork()时,当前进程就会被克隆。子进程可以访问打开的文件、网络连接和内存中的数据结构。为了避免性能问题,使用了一种称为写时复制的系统。这允许相同的内存位置被使用,直到尝试写入,此时每个被克隆的进程都会收到原始内容的副本。进程被克隆后,它们是隔离的。
正确处理集群应用程序还需要一个额外的步骤:工人退出恢复。如果你的某个工人遇到错误并且进程结束,那么你将想要重新启动它。关于这一点很酷的是,任何其他活跃的工人仍然可以处理请求,因此集群不仅可以提高请求延迟,还可能提高正常运行时间。下面的列表是对列表 12.11 的修改,以从工人退出中恢复。
列表 12.12. 从工人意外死亡中恢复
cluster模块是事件驱动的,因此主进程可以监听像exit 这样的事件,表示工人已死亡。此事件的回调函数会接收到一个
worker对象,因此你可以获取有关工人的有限信息。之后,你只需要再次fork ,你将回到完整的工人数量。
从主进程崩溃中恢复
你可能想知道当主进程本身死亡时会发生什么。尽管主进程应该保持简单以降低这种情况发生的可能性,但崩溃仍然是可能的。为了最小化停机时间,你仍然应该使用像forever模块或 Upstart 这样的进程管理器来管理你的集群应用程序。这两种解决方案都在技巧 99 中进行了探讨。
你可以用 Express 应用程序运行这个示例,然后使用kill强制工作者退出。这样的会话记录应该看起来像这样:
Running 3 total workers
Worker PID: 58733
Worker PID: 58732
Worker PID: 58734
Worker 1 died
Worker PID: 58737
三个工作者在运行直到发出kill 58734命令,然后一个新的工作者被派生出来并启动了58737。
一旦你设置了集群,还有一件事要做:基准测试。我们将使用ab(httpd.apache.org/docs/2.0/programs/ab.html),Apache 基准测试工具。它的使用方式如下:
ab -n 10000 -c 100 http://localhost:3000/
这一次使用 100 个并发请求发送了 10,000 个请求。在我们的系统上使用三个工作者每秒产生了 260 个请求,而单个进程版本每秒产生了 171 个请求。集群确实更快,但这真的像我们的 HAProxy 或 nginx 的轮询示例那样工作得一样好吗?
cluster模块的优势在于你可以用 Node 脚本来脚本化它。这意味着你的开发者应该能够理解它,而不是必须学习 HAProxy 或 nginx 的工作原理来进行负载均衡。使用额外的代理服务器进行负载均衡没有cluster那样的进程间通信选项——你可以使用process.send和cluster.workers[id].on('message', fn)在工作者之间进行通信。
但是具有专用负载均衡功能的代理有更广泛的负载均衡算法选择。就像所有事情一样,明智的做法是在测试 HAProxy、nginx 和 Node 的集群模块上投入时间,看看哪个最适合你的应用程序和你的团队。
此外,专门的负载均衡服务器可以代理多个服务器的请求——你可以从中央服务器代理到多个使用cluster核心模块以利用服务器多核 CPU 的 Node 应用程序服务器,这在技术上是可以实现的。
在这种异构设置中,你需要跟踪你的应用程序实例正在做什么。下一节专门介绍维护生产环境中的 Node 程序。
12.3. 维护
无论你的服务器架构多么稳固,你仍然需要维护你的生产系统。本节中的技术都是关于维护你的 Node 程序;首先,使用 npm 进行包优化。
技巧 104:包优化
这种技术完全是关于 npm 以及它如何使部署更高效。如果你觉得你的模块文件夹可能有点大,那么继续阅读以获取一些解决问题的想法。
问题
当你的应用程序发布到生产环境时,它似乎比预期的要大。
解决方案
尝试使用一些 npm 的维护功能,如 npm prune 和 npm shrinkwrap。
讨论
当你在 Heroku 上部署应用程序时,它会清楚地显示应用程序的大小:每次发布都会显示以兆字节为单位的 slug size,Heroku 上的最大大小为 300 MB。slug size 与依赖项密切相关,因此随着应用程序的增长和新依赖项的添加,你可能会注意到它会有显著的增加。
即使你不在 Heroku 上使用,你也应该意识到你的应用程序大小。它将影响你发布新代码的速度,而发布新代码应该尽可能快。当部署速度快时,发布错误修复和新功能就不再是那么繁琐,风险也更小。
一旦你检查了 package.json 中的依赖项并移除了不必要的依赖项,你还可以使用一些其他技巧来减小应用程序的大小。npm prune 命令会移除 package.json 中不再列出的包,但它也适用于依赖项本身,因此有时可以显著减小应用程序的存储占用。
你还应该考虑使用 npm prune --production 来从生产版本中移除 devDependencies。我们发现生产版本中有不需要的测试框架。如果你已经将 ./node_modules 添加到 git 中,那么 Heroku 会为你运行 npm prune,但它目前不会运行 npm prune --production。
为什么检查 ./node_modules?
可能会有诱惑将 ./node_modules 添加到 .gitignore 中,但不要这样做!当你正在开发将要部署的应用程序时,你应该在存储库中保留 ./node_modules。这将帮助其他人运行你的应用程序,并使在生产环境中重现通过测试和所有其他内容的本地设置变得更加容易。
不要对通过 npm 发布的模块这样做。开源库在安装期间应使用 npm 来管理依赖项。
你还可以使用 npm shrinkwrap 命令来潜在地改进部署。这将创建一个名为 npm-shrinkwrap.json 的文件,该文件指定了每个依赖项的确切版本,但它不仅仅如此——它还会递归地捕获每个子模块的版本。npm-shrinkwrap.json 文件可以提交到你的存储库中,npm 在部署时会使用它来获取每个包的确切版本。
shrinkwrap 也可以用于协作,因为它意味着人们可以在开发过程中复制你电脑上已经存在的模块。当你独自工作了几个月后有人加入项目时,这会很有帮助。
一些 PaaS 提供商也提供了排除文件从部署的功能。例如,Heroku 可以接受一个 .slugignore 文件,它就像 .gitignore 一样工作——你可以创建一个像这样的文件来忽略测试和本地种子数据:
/test
/seed-data
/docs
通过利用 npm 内置的功能,你可以创建稳固且可维护的包,减少部署时间,并提高部署可靠性。
即使有一个配置良好、可扩展且部署周到的应用程序,你仍然会遇到问题。当事情出错时,你需要日志。继续阅读以了解处理日志文件和日志服务的技巧。
技巧 105 记录和记录服务
当事情出错时——不是“如果”,而是“当”——你需要日志来揭示发生了什么。在典型的服务器上,日志是文本文件。但对于像 Heroku 和 Nodejitsu 这样的 PaaS 提供商呢?对于这些平台,你需要日志服务。
问题
你想在自有的服务器上记录来自 Node 应用的日志,或者在 PaaS 提供商上。
解决方案
要么将日志重定向到文件并使用 logrotate,要么使用第三方日志服务。
讨论
在 Unix 中,一切都是文件,这在很大程度上决定了系统管理员和 DevOps 专家对日志文件的看法。日志只是文件:程序将数据流进它们,我们将数据流出。这种设置对我们这些生活在命令行的人来说很方便——通过 grep、sed 和 awk 等命令将文件管道化,即使是千兆大小的日志也能轻松处理。
因此,无论你做什么,你都会想要正确使用 console.log 和 console.error。了解 err.stack 也不会有害——Node 中的 Error 实例在定义时获得一个 stack 属性,这在调试生产中的问题时非常有帮助。有关编写日志的更多信息,请参阅第二章中的技巧 6(technique 6)。
使用 console.error 和 console.log 的好处是你可以将输出管道化到不同的位置。以下命令将数据从标准输出(console.log)重定向到 application.log,并将标准错误(console.error)重定向到 errors.log:
npm start 1> application.log 2> errors.log
你需要记住的是大于符号用于重定向输出,使用数字指定输出流:1 是标准输出,2 是标准错误。
过了一段时间,你的日志文件会变得很大。幸运的是,现代 Unix 系统通常自带日志轮转包。这将在一段时间内分割文件,并可选择压缩它们。logrotate 包可以在 Debian 或 Ubuntu 中使用 apt-get install logrotate 安装。一旦安装了它,你将为想要轮转的每一组日志文件需要一个配置文件。以下列表显示了一个示例配置,你可以根据你的应用程序进行定制。
列表 12.13. logrotate 配置

在列出你想要轮换的日志文件之后,你可以列出你想要使用的选项。logrotate 有许多选项,它们在 man logrotate 中有文档说明。这里列出的第一个选项 daily
只表示我们希望每天轮换文件。下一行使 logrotate 保持 20 个文件;之后文件将被删除
。第三个选项将确保旧日志文件被压缩,这样它们就不会占用太多空间
。
第四种选项,copytruncate
,对于使用简单标准 I/O 基于日志的应用程序来说更为重要。它使 logrotate 复制当前日志文件然后截断。这意味着你的应用程序不需要关闭和重新打开标准输出——它应该无需任何特殊配置就能正常工作。
使用标准 I/O 和 logrotate 对于单个服务器和简单应用程序来说效果很好,但如果你在集群中运行应用程序,可能会发现管理日志很困难。有一些 Node 模块专门用于日志记录,并提供针对集群的特定选项。有些人甚至更喜欢使用这些模块,因为它们以标准日志文件格式生成输出。
使用 log4node 模块 (github.com/bpaquet/log4node) 与使用 console.log 类似,但它具有使其在集群中使用更方便的功能。它为所有工作进程创建一个日志文件,并监听 USR2 信号以确定何时重新打开文件。它支持配置选项,包括日志级别和信息前缀,因此你可以在测试期间保持日志安静或在关键的生产系统中增加详细程度。
winston (github.com/flatiron/winston) 是一个支持多种传输方式的日志模块,包括 Cassandra,这允许你将日志写入进行集群化。这意味着如果你有一个每小时写入数百万条日志条目的应用程序,那么你可以使用多个服务器以更可靠的方式捕获日志。
winston 支持远程日志服务,包括商业服务如 Papertrail。Papertrail 和 Loggly(见 图 12.10)是商业服务,你可以将日志管道传输到这些服务,通常使用 syslogd 协议。它们还会索引日志,因此根据查询,搜索千兆字节日志的速度非常快。
图 12.10. Loggly 的仪表板

Loggly 这样的服务对于 Heroku 来说是绝对关键的。Heroku 只存储最后 5,000 条日志条目,这些条目在运行典型应用程序的几分钟内可能会被淹没。如果你已将 Node 应用程序部署到 Heroku,并使用 console.log、log4node 或 winston,那么你只需通过启用附加组件就能重定向你的日志。
使用 Heroku,可以通过选择计划名称并从你的项目目录运行heroku addons:add Loggly:PlanName来配置 Loggly。键入heroku addons:open loggly将打开 Loggly 的 Web 界面,但在 Heroku 的管理面板下的资源部分也有链接。使用标准 I/O 进行的任何日志记录都应该直接发送到 Loggly。
如果你正在使用winston,那么有可用的 Loggly 传输。一个是winston-loggly(github.com/indexzero/winston-loggly),它可以用于通过非 Heroku 服务或你自己的私有服务器轻松访问 Loggly。
由于可以使用winston.add(winston.transports.Loggly, options)更改 Winston 传输,所以如果你已经在使用winston,你不需要做任何特殊的事情来支持 Loggly。
你可以使用与你的应用程序一起使用的日志记录标准:Syslog 协议(RFC 5424)。Syslog 消息包具有标准格式,所以你通常不会手动生成它们。像winston这样的模块通常支持 syslog,所以你可以与你的 Node 应用程序一起使用它,但使用它的两个主要好处是:第一个是消息具有标准化的日志级别,因此过滤日志更容易。一些例子包括级别 0,称为紧急,和级别 4,这是警告。第二个好处是协议定义了消息如何在网络上发送,这意味着你可以让你的 Node 应用程序与运行在远程服务器上的 syslog 守护程序通信。
一些日志服务,如 Loggly 和 Splunk,可以充当 syslog 服务器;或者,你可以在专用硬件或虚拟机上运行自己的守护程序。通过使用标准化的协议如 syslog,你可以根据需求变化在日志提供者之间切换。
这是关于 Node 特定生产问题的最后一个技术。下一节概述了一些与扩展性和弹性相关的问题。
12.4. 关于扩展性和弹性的进一步说明
在本章中,我们展示了如何使用代理和cluster模块来扩展 Node 程序。我们提到cluster的一个优点是更易于进程间通信。如果你在独立的服务器上运行应用程序,Node 进程如何进行通信?
一个简单的答案可能是 HTTP——你可以构建一个用于通信的内部 REST API。如果你需要更快的信息响应,甚至可以使用 WebSockets。当我们面临这个问题时,我们使用了 RabbitMQ(www.rabbitmq.com/)。这允许我们的 Node 应用程序实例通过共享的消息总线相互发送消息,从而在整个集群中分配工作。
该项目是一个使用 Node 程序下载和抓取内容的搜索引擎。工作被分类为爬虫、下载和抓取。Node 进程的集群会从队列中获取工作,然后将新任务推回队列。
npm 上有几种 RabbitMQ 客户端的实现——我们使用了 amqplib (www.npmjs.org/package/amqplib)。还有 RabbitMQ 的竞争对手——zeromq (zeromq.org/) 是一个高度专注且简单的替代品。
另一个选择是使用托管发布/订阅服务。一个例子是 Pusher (pusher.com/),它使用 WebSocket 来帮助扩展应用。这种方法的优点是 Pusher 可以发送任何消息,包括移动客户端。你不必将消息限制在 Node 程序中,可以创建消息通道,供网页、移动甚至桌面客户端订阅。
最后,如果你正在使用私有服务器,你需要监控资源使用情况。StrongLoop (strongloop.com/) 为 Node 提供了监控和集群工具,而 New Relic (New Relic) 也现在有了针对 Node 的特定功能。New Relic 可以帮助你分析实时应用中时间花费的地方,因此你可以用它来发现数据库访问、视图渲染和应用逻辑中的瓶颈。
使用像 Heroku、Nodejitsu 和 Microsoft 这样的服务提供商,以及 StrongLoop 和 New Relic 提供的工具,运行 Node 软件在生产环境中已经迅速成熟并变得完全可行。
12.5. 摘要
在本章中,你看到了如何在 PaaS 提供商上运行 Node,包括 Heroku、Nodejitsu 和 Windows Azure。你还学习了在私有服务器上运行 Node 的问题:安全访问端口 80(技术 98)以及 WebSocket 如何与生产需求相关(技术 100)。
无论你的代码有多快,如果你的应用很受欢迎,那么你可能会遇到性能问题。在我们的扩展部分,你已经学习了所有关于缓存(技术 101)、代理(技术 102)和用 cluster 扩展(技术 103)的内容。
为了确保你的应用稳定运行,我们在 npm 上包含了与维护相关的技术(技术 104)和日志记录(技术 105)。现在,如果出现任何问题,你应该有足够的信息来解决它。
现在,你应该知道如何构建 Node 网络应用并以可维护和可扩展的状态发布它们。
第三部分:编写模块
在我们深入研究 Node 的核心库并探讨现实世界的解决方案时,我们一直在构建一个故事,这个故事引导我们进入 Node 生态系统最大的部分:通过第三方模块开发推动的社区驱动创新。核心提供了我们构建的乐高积木,而解决方案提供了构建时的工具和洞察力,因此我们最终构建的内容取决于我们自己!
我们还有最后一章,将带您了解构建模块并将其贡献给社区的所有细节。
第十三章:编写模块:掌握 Node 的核心
本章涵盖
-
规划模块
-
设置 package.json 文件
-
与依赖和语义版本一起工作
-
添加可执行脚本
-
测试模块
-
发布模块
不可否认,Node 的包管理器(npm)可能是迄今为止任何平台所拥有的 最佳 包管理器。npm 的核心是一套用于安装、管理和创建 Node 模块的工具。入门门槛低,没有繁琐的仪式。事情“自然而然”地“工作”得很好。如果您还没有被说服,我们希望这一章能鼓励您再次审视。
本章的副标题是“掌握 Node 的核心”。我们选择这个标题是因为用户贡献的模块构成了 Node 生态系统的绝大多数。核心团队早期就决定 Node 将有一个 小 的标准库,其中包含构建优秀模块所需的核心功能。我们知道理解这些核心功能对于构建模块至关重要,因此我们将这一章留到了最后。在 Node 中,您可能会发现针对特定协议或客户端的 5 或 10 种不同的实现,我们对此表示 无妨,因为这允许在该领域通过实验推动创新。
通过我们的实验,我们学到的一点是 小型模块很重要。大型模块往往难以维护和测试。Node 允许小型模块简单地组合在一起,以解决更多和更复杂的问题。
Node 的 require 系统基于 CommonJS(wiki.commonjs.org/wiki/Modules/1.1),以避免依赖地狱的方式管理这些依赖。模块依赖于同一模块的不同版本是完全正常的,正如图 13.1 所示。
图 13.1. Node 避免依赖地狱

除了标准依赖之外,您还可以指定开发和同伴依赖(稍后详述),并让 npm 帮您检查这些依赖。
依赖图
如果您想查看项目的依赖图,只需在项目根目录下输入 npm ls 即可获取列表。
在 npm 的历史早期,就决定了一个差异,即默认在本地级别管理依赖项,这是由 Ruby gem bundler 推广的。这将在你的项目内部捆绑模块(位于 node_modules 文件夹中),使得在多个项目中依赖地狱成为一个非问题,因为没有全局共享的模块状态。
安装全局模块
如果你想要安装全局模块,可以使用 npm install -g module-name,这在需要系统级可执行文件时非常有用,例如。
希望我们已经激起了你对探索各种模块创建技术的兴趣!在本章中,我们将关注围绕以下内容的各种技术
-
有效地利用 package.json 文件
-
使用 npm 进行各种模块创建任务
-
开发模块的最佳实践
我们的技术将按照从空项目目录到完成并发布的 npm 模块的逻辑顺序进行。尽管我们试图将尽可能多的概念放入一个模块中,但你可能会发现你的模块可能只需要这些步骤中的一小部分。当我们无法将一个概念放入模块中时,我们将专注于一个隔离的使用案例来说明这一点。
13.1. 思考
我们想要构建什么样的 API?人们应该如何使用它?它有一个明确的目的吗?在我们开始编写模块时,我们需要问一些问题。在本节中,我们将探讨研究和验证模块想法的过程。但首先,让我们介绍一个我们想要解决的问题,这将为我们后续的进展提供背景。
13.1.1. 更快的斐波那契模块
在 Node.js 早期历史中,最著名的批评之一(尽管可能有些误导)是“Node.js 是癌症”(pages.citebite.com/b2x0j8q1megb),作者认为在运行中的 Web 服务器上,Node 的单线程系统对 CPU 密集型任务的处理非常糟糕。
实现是一个常见的递归方法来计算斐波那契数列(en.wikipedia.org/wiki/Fibonacci_number),可以如下实现:

这种实现方法在 V8 中速度较慢,并且由于 JavaScript 中还没有适当的尾调用,它无法计算非常大的数字,因为会导致栈溢出。
让我们编写一个模块,帮助世界摆脱缓慢的斐波那契计算,以便从头到尾学习模块开发。
技巧 106 为我们的模块规划
因此,我们想要开始编写一个模块。我们应该如何着手?在我们开始编写代码之前,我们能做些什么?事实证明,提前规划可以非常有帮助,并能在未来节省痛苦。让我们看看如何做好这一点。
问题
你想要编写一个模块。在规划阶段,你应该采取哪些步骤?
解决方案
研究现有内容,并确保你的模块只做一件事。
讨论
明确阐述你模块的目的非常重要。如果你不能将其简化为一句话,它可能做得太多。这就是 Unix 哲学的一个重要方面发挥作用的地方:让每个程序只做一件事,并且做好。
概览格局
首先,了解已经存在什么是有好处的。有人为我的问题实现了解决方案吗?我可以贡献那里吗?其他人是如何处理的?一个很好的方法是搜索 npmjs.org 或从命令行进行搜索:

让我们看看一些更有趣的结果:
fibonacci Calculates fibonacci numbers for one or endless iterations....
=franklin 2013-05-01 1.2.3 fibonacci math bignum endless
fibonacci-async So, you want to benchmark node.js with fibonacci once...
=gottox 2012-10-29 0.0.2
fibonacci-native A C++ addon to compute the nth fibonacci number.
=avianflu 2012-03-21 0.0.0
在这里,我们可以看到三个不同实现的名称和描述。我们还看到了最后一次发布的版本和日期。看起来有几个版本较旧,版本号较低,这可能意味着 API 正在变化或仍在进行中。但顶部结果看起来在 1.2.3 版本下相当成熟,并且最近进行了更新。让我们通过运行以下命令来获取更多信息:
npm docs fibonacci
如果指定了,npm 文档命令将加载模块的主页,或者 npmjs 搜索结果,看起来像图 13.2。
图 13.2. npmjs.com 包详情页面

npmjs 结果页面有助于给出一个模块的整体印象。我们可以看到这个模块依赖于 bignum 模块,并且一年前进行了更新,我们可以查看其 README 以了解 API。
虽然这个模块看起来相当不错,但让我们创建一个模块作为实验,尝试一些处理斐波那契数列的其他想法。在我们的案例中,让我们创建一个模块,我们将在这个模块中尝试不同的实现,并使用纯 JavaScript(没有所需的 bignum 依赖项)来基准测试我们的结果。
拥抱做好一件事
一个模块应该是简单且可插入的。在这种情况下,让我们尝试用一句话定义我们模块的目的:
尽可能快地使用 JavaScript 计算斐波那契数
这是个相当不错的开始:它清晰且简洁。当这个概念不再适用时,我们就超出了我们的范围,可能需要写另一个扩展这个模块而不是添加更多内容的模块。对于这个项目,添加一个返回此函数结果的 Web 服务器端点可能更适合在一个依赖于这个模块的新模块中完成。
当然,这不是一个严格的要求,但它有助于我们明确模块的目的,并使我们的最终用户清楚。这个声明非常适合添加到我们的 package.json(我们稍后会看到)和我们的 README 文件顶部。
我们最终需要一个模块名称,虽然一开始不是必要的,但为了在未来的技术中引用它,让我们称我们的模块为 fastfib。现在就创建一个名为 fastfib 的目录,它将作为我们的项目目录:
mkdir fastfib && cd fastfib
现在我们已经定义了我们希望我们的模块做到的“一件事”,并且有了我们的裸项目目录,让我们通过下一个技术来验证我们的模块想法,看看它是否真的可行。
技巧 107 证明我们的模块想法
因此,我们现在有了重点;接下来是什么?是时候证明我们的想法了。这是我们思考模块 API 表面的步骤。它是可用的吗?它是否实现了其目的?让我们看看这一点。
问题
在证明模块想法时,你应该先编写什么代码?
解决方案
通过 TDD 查看 API 表面。
讨论
了解你希望你的模块如何工作是很重要的。在 fastfib 中,我们将同步计算斐波那契序列。我们能想到的最简单、最容易使用的 API 是什么?
fastfib(3) // => 2
对,只是一个简单的函数调用,返回结果。
在构建异步 API 时,建议使用 Node 回调签名,因为它将很好地与几乎任何控制流库一起工作。如果我们的模块是异步的,它将看起来像这样:
fastfib(3, function (err, result) {
console.log(result); // => 2
});
我们有同步 API。在本章的开头,我们向您展示了一个我们想要改进的实现。由于我们想要一个基线来比较其他实现,让我们通过创建一个名为 recurse.js 的文件并将其放入 lib 文件夹中,将这个递归实现引入我们的项目中:

定义入口点
每个模块都有一个入口点:当我们使用 require 关键字在其他地方调用它时得到的对象/函数/构造函数。由于我们知道我们将在 lib 目录内尝试不同的实现,我们不希望 lib/recurse.js 成为入口点,因为它可能会改变。
通常情况下,项目根目录下的 index.js 是最有意义的入口点。很多时候,让入口点尽可能最小化,仅将提供 API 所需的部分连接起来是有意义的。现在让我们创建这个文件:
module.exports = require('./lib/recurse');
现在当模块的消费者执行require('fastfib')时,他们将得到这个文件,进而得到我们的递归实现。然后我们只需在需要更改公开实现时切换这个文件。
测试我们的实现
现在我们已经有了 fastfib 的第一个实现,让我们确保我们实际上有一个合法的斐波那契实现。为此,让我们创建一个名为 test 的文件夹,并在其中添加一个名为 index.js 的单个文件:
var assert = require('assert');
var fastfib = require ('../');
assert.equal(fastfib(0), 0);
assert.equal(fastfib(1), 1);
assert.equal(fastfib(2), 1);
assert.equal(fastfib(3), 2);
assert.equal(fastfib(4), 3);
assert.equal(fastfib(5), 5);
assert.equal(fastfib(6), 8);
assert.equal(fastfib(7), 13);
assert.equal(fastfib(8), 21);
assert.equal(fastfib(9), 34);
assert.equal(fastfib(10), 55);
assert.equal(fastfib(11), 89);
assert.equal(fastfib(12), 144);
// if we get this far we can assume we are on the right track
现在我们可以运行我们的测试套件,看看我们是否在正确的轨道上:
node test
我们没有抛出任何错误,所以看起来我们的实现至少是准确的。
对我们的实现进行基准测试
现在我们已经定义了 fastfib 实现的 API 和测试,我们该如何确定它的速度有多快呢?为此,我们将使用 jsperf.com 项目背后的一个可靠 JavaScript 基准测试工具,称为 Benchmark.js(benchmarkjs.com/)。让我们将其包含到我们的项目中:
npm install benchmark
让我们创建另一个名为 benchmark 的文件夹,并在其中添加一个 index.js 文件,代码如下:

让我们现在从根模块目录运行我们的基准测试:
$ node benchmark
results:
recurse 392 5.491
看起来我们能够计算 recurse(20) 392 次在 ~5.5 秒内。让我们看看我们是否可以改进这一点。原始的递归实现没有尾调用优化,所以我们应该能够在这里获得提升。让我们在 lib 文件夹中添加另一个名为 tail.js 的实现,内容如下:

现在,将测试添加到 benchmark/index.js 文件中,看看通过在文件顶部添加实现我们是否做得更好:

让我们看看我们做得怎么样:

哇!尾递归真的帮助加快了我们的斐波那契计算。所以让我们将其切换为我们的默认实现,并在我们的主 index.js 文件中:
module.exports = require('lib/tail');
并确保我们的测试通过:
node test
没有错误;看起来我们仍然没问题。如前所述,由于 JavaScript 中尚未支持,适当的尾调用实现仍然会在堆栈太大时崩溃。所以让我们再尝试一个实现,看看我们是否能做得更好。为了避免在较大的数字序列上发生堆栈溢出,让我们创建一个迭代实现,并在 lib/iter.js 中创建它:

让我们将这个实现添加到 benchmark/index.js 文件中:

让我们看看我们做得怎么样:
$ node benchmark
results:
recurse 392 5.456
tail 266836 5.455
iter 1109532 5.474
迭代方法比尾递归版本快 4 倍,比原始函数快 2830 倍。看起来我们确实有一个快速斐波那契算法,并且已经证明了我们的实现。让我们更新 benchmark/index.js 文件以断言 iter 现在应该是最快的:
assert.equal(
this.filter('fastest').pluck('name')[0],
'iter',
'expect iter to be the fastest'
);
然后更新我们的主 index.js 以指向我们最快的版本:
module.exports = require('./lib/iter');
并测试我们的实现是否仍然正确:
node test
仍然没有错误,所以我们没问题!如果我们后来发现 V8 优化尾调用流程比我们的迭代方法更快,我们的基准测试将失败,我们可以切换实现。现在让我们回顾一下我们的整体模块结构:

看起来我们已经证明了我们的想法。重要的是要吸取的教训是实验!尝试不同的实现!你很可能一开始不会做对,所以利用这个时间来实验,直到你满意为止。在这个特定的技术中,我们尝试了三种不同的实现,直到我们找到了一个。
现在来看看模块开发的下一步:设置 package.json 文件。
13.2. 构建 package.json 文件
现在我们有一个我们喜欢的想法,并且我们已经证明我们的想法确实做了我们想要的事情,我们将通过 package.json 文件来描述这个模块。
技巧 108 设置 package.json 文件
package.json 是管理你的模块核心数据、常用脚本和依赖项的中心文件。无论你最终是否发布你的模块,或者只是用它来管理你的内部项目,设置 package.json 都将有助于推动你的开发。在这个技术中,我们将讨论如何设置 package.json 以及如何使用 npm 来填充你的 package.json。
问题
你需要创建一个 package.json 文件。
解决方案
使用内置的 npm 工具。
讨论
npm init 命令提供了一个很好的逐步界面来设置 package.json。让我们在我们的 fastfib 项目目录中运行此命令:

包选项
要详细了解每个包选项的详细信息,请运行 npm help json 查看官方文档(www.npmjs.org/doc/json.html)。
当你设置用户配置($HOME/.npmrc)以预先填充值时,运行 npm init 会变得更加简单。以下是你可以设置的所有选项:
npm config set init.author.name "Marc Harter"
npm config set init.author.email "wavded@gmail.com"
npm config set init.author.url "http://wavded.com"
npm config set init.license "MIT"
使用这些选项,npm init 不会询问你作者,而是自动填充值。它还将默认许可协议为 MIT。
关于现有模块的说明
如果你已经在设置 package.json 文件之前安装了模块,npm init 足够智能,可以将它们添加到 package.json 中,并使用正确的版本!
完成初始化后,你将在目录中拥有一个看起来像这样的漂亮的 package.json 文件:

现在我们已经对 package.json 文件有了良好的开端,我们可以通过直接修改 JSON 文件或使用其他 npm 命令来修改文件的不同部分来添加更多属性。npm init 命令只是触及了我们可以用 package.json 文件做的事情的表面。随着我们继续前进,我们将查看我们可以添加的更多内容。
为了查看更多的 package.json 配置和模块开发的其它方面,让我们转到下一个技巧。
技巧 109:处理依赖项
Node 在 npm 上发布了超过 80,000 个模块。在我们的 fastfib 模块中,我们已经利用了其中之一:基准模块。在 package.json 文件中明确定义依赖项有助于维护我们模块的完整性,无论是我们自己还是其他人安装和修改时。package.json 文件告诉 npm 在运行 npm install 时需要获取哪些依赖项以及获取哪个版本的依赖项。如果在 package.json 文件中未包含依赖项,将导致错误。
问题
如何有效地管理依赖项?
解决方案
使用 npm 保持 package.json 文件与模块需求同步。
讨论
package.json 文件允许你定义四种类型的依赖项对象,如图 13.3 所示。
图 13.3. 依赖项的不同类型

依赖项的类型如下所示:
-
dependencies —模块正常工作所必需的
-
devDependencies —仅用于开发,如测试、基准测试和服务器重新加载工具
-
optionalDependencies —不是你的模块正常工作所必需的,但可能在某些方面增强功能
-
peerDependencies —为了正常运行,需要安装另一个模块
让我们依次查看我们的项目,并在进行过程中讨论在 package.json 文件中添加和删除的内容。
主依赖项和开发依赖项
目前使用 npm init 生成的 package.json 文件在 dependencies 对象中列出了 benchmark。如果我们查看我们的列表,由于几个原因,这并不成立。第一个原因是,我们的主入口点(index.js)在其 require 链中永远不会需要 benchmark,所以最终用户不需要它:
index.js requires ./lib/iter.js which requires nothing
第二个原因是,基准测试通常只是那些在我们模块上工作的人的开发专属事情。为了从我们的依赖中移除它,我们可以使用 npm remove 命令,并通过使用--save 标志将其从 package.json 文件中移除:
$ npm remove benchmark --save
unbuild benchmark@1.0.0
然后,我们可以使用带有--save-dev 标志的 npm install 将其安装到我们的开发依赖中:
$ npm install benchmark --save-dev
benchmark@1.0.0 node_modules/benchmark
现在如果我们查看我们的 package.json 文件,我们会看到 benchmark 现在是 devDependencies 对象的一部分:
"devDependencies": {
"benchmark": "¹.0.0"
},
这是一种强制方式来展示如何使用 npm 移除和安装命令。我们也可以只是将 benchmark 移动到我们的文本编辑器中的 package.json 文件内,避免卸载和重新安装。
现在我们已经将 benchmark 放在了正确的位置,所以当其他人想要使用我们的模块时,它不会安装。
可选依赖
可选依赖不是项目运行的必需品,但它们将与常规依赖项一起安装。与常规依赖项的唯一区别是,如果可选依赖安装失败,它将被忽略,并且模块应该继续正确安装。
这通常适用于可以通过包含原生插件来获得提升的模块。例如,hiredis 是用于提升 redis 模块性能的原生 C 插件。但它不能在所有地方安装,所以它尝试安装,但如果安装失败,redis 模块将回退到 JavaScript 实现。在父模块中检查依赖项的典型模式如下:

假设我们想要支持 fastfib 的更大范围的序列号。我们可以通过运行以下命令添加 bignum 原生插件来启用该功能:
npm install bignum --save-optional
然后,如果我们检测到 bignum 模块能够在我们的 index.js 文件中安装,我们可以选择性地使用那个迭代:

不幸的是,bignum 实现会慢得多,因为它不能被 V8 编译器优化。如果我们包含了这个可选依赖和实现,我们将违反我们拥有最快斐波那契的目标,所以现在我们先将其删除。但这说明了你可能想要如何使用可选依赖(例如,如果你想要支持尽可能高的斐波那契数作为目标)。
家庭作业
故意省略了 bignum 实现的代码和测试;尝试实现一个使用 bignum 的版本,并查看从我们的测试套件中获得什么性能基准。
同级依赖
依赖项([blog.nodejs.org/2013/02/07/peer-dependencies/](http://blog.nodejs.org/2013/02/07/peer-dependencies/))是依赖项场景中的最新成员。依赖项告诉安装你的模块的人:“我期望这个模块存在于你的项目中,并且是这个版本,以便我的模块能够工作”。这种依赖项最常见的类型是插件。
一些有插件的流行模块包括
-
Grunt
-
Connect
-
winston
-
Mongoose
假设我们真的想添加一个 Connect 中间件组件,该组件在每个请求上计算一个斐波那契数;谁不会呢,对吧?为了使它工作,我们需要确保我们编写的 API 将针对正确的 Connect 版本工作。例如,我们可能相信对于 Connect 2,我们可以可靠地说我们的模块将工作,但我们不能为 Connect 1 或 3 做出保证。为此,我们可以在 package.json 文件中添加以下内容:

在这个技术中,我们探讨了在 package.json 文件中可以定义的四种依赖类型。如果你想知道¹.0.0 或 2.x 代表什么,我们将在下一个技术中深入探讨,但让我们首先谈谈更新现有依赖项。
保持依赖项更新
保持模块健康也意味着保持你的依赖项更新。幸运的是,有一些工具可以帮助你做到这一点。一个内置的工具是 npm outdated,它将严格匹配你的 package.json 文件以及你依赖项中的所有 package.json 文件,以查看是否有任何更新的版本匹配。
让我们故意修改 package.json 文件,使基准模块过时,因为 npm install 给我们的是最新版本:

然后让我们运行 npm outdated 看看我们得到什么:
$ npm outdated
Package Current Wanted Latest Location
benchmark 1.0.0 0.2.2 1.0.0 benchmark
看起来我们目前安装的是 1.0.0,但根据我们刚刚修改的 package.json,我们想要匹配⁰.2.0 的最新包,这将给我们版本 0.2.2。我们还可以看到可用的最新包是 1.0.0。位置行将告诉我们它在哪里找到了过时的依赖项。
你直接需要的过时依赖项
有时,只查看你的过时依赖项,而不是子依赖项(在大型项目中可能会变得非常大),会更好。你可以通过运行npm outdated --depth 0来实现这一点。
如果我们想更新到期望的版本,我们可以运行
npm update benchmark --save-dev
这将安装 0.2.2 并更新我们的 package.json 文件到⁰.2.2。
让我们再次运行 npm outdated:
$ npm outdated
Package Current Wanted Latest Location
benchmark 0.2.2 0.2.2 1.0.0 benchmark
看起来我们现在和我们的期望版本匹配了。如果我们想更新到最新版本怎么办?这很简单:我们可以安装最新的版本,并通过运行以下命令将其保存到我们的 package.json 中:
npm install benchmark@latest --save-dev
版本标签和范围
注意使用@latest 标签来获取模块的最新发布版本。npm 还支持指定版本和版本范围的能力!(www.npmjs.org/doc/cli/npm-install.html)
我们到目前为止已经谈了一些关于版本号的内容,但它们确实需要一种独特的技巧,因为理解它们的意义和如何有效地使用它们非常重要。理解语义版本控制将帮助你更好地为你的模块和依赖项定义版本。
技巧 110 语义版本控制
如果你不太熟悉语义版本控制,你可以在semver.org上了解更多信息。图 13.4 捕捉了主要观点。
图 13.4. 语义版本控制

这里是如何在官方文档中描述的:^([1])
¹ 来自
semver.org/.给定版本号 MAJOR.MINOR.PATCH,增加以下:
1. 当进行不兼容的 API 更改时,使用 MAJOR 版本。
2. 当以向后兼容的方式添加功能时,使用 MINOR 版本。
3. 当进行向后兼容的 bug 修复时,使用 PATCH 版本。
实际上,这些规则可以被忽略或松散地遵循,因为毕竟,没有人强制你的版本号。此外,许多作者喜欢在早期阶段玩弄他们的 API,并希望不要立即达到版本 24.0.0!但 semver 可以给你,作为模块作者和模块消费者,在版本号本身中提供关于自上次发布以来可能发生了什么的线索。
在这个技巧中,我们将探讨如何在 fastfib 库中有效地使用 semver。
问题
你想在你的模块和依赖项中有效地使用 semver。
解决方案
为了有一个安全的升级路径,并清楚地传达你的模块版本意图,你需要了解你底层项目。
讨论
我们目前在项目中有一个开发依赖项,这在 package.json 文件中看起来像这样:
"devDependencies": {
"benchmark": "¹.0.0"
},
这就是 npm 默认如何在 package.json 文件中包含版本的方式。这对于大多数模块作者的行为来说是个好主意:
-
如果版本小于 1.0.0,例如⁰.2.0,那么允许安装任何更高的 PATCH 版本。在先前的技巧中,我们看到这最终变成了 benchmark 模块的 0.2.2 版本。
-
如果版本是 1.0.0 或更高版本,例如¹.0.0,那么允许安装任何更高的 MINOR 版本。通常,1.0.0 被认为是稳定的,MINOR 版本在本质上不会破坏。
这意味着当另一个用户安装你的模块依赖项时,他们将会得到你版本范围内允许的最新版本。例如,如果 Benchmark.js 明天发布了 1.1.0 版本,尽管你目前机器上有 1.0.0 版本,但他们将会得到 1.1.0 版本,因为它仍然匹配版本范围。
版本运算符
Node 支持一系列特殊运算符来定制多个版本或版本范围。你可以在 semver 文档中查看它们(www.npmjs.org/doc/misc/semver.html)。
依赖项版本控制
在编写模块时,使用用户将安装的特定版本号可以增加对依赖项的信心。这样,你知道你测试过的版本将在依赖链中运行相同。由于我们知道我们的测试套件与基准 1.0.0 兼容,让我们通过以下命令将其锁定为仅该版本:

我们本可以手动更新我们的 package.json。让我们看看它现在的样子:

现在我们已经锁定我们的依赖项,我们可以始终使用 npm outdated 来查看是否存在新版本,然后使用--save-exact 标志通过 npm install 来更新我们的 package.json!
模块版本化
如前所述,许多模块作者使用小于 1.0.0 的版本来表示 API 尚未完全实现,并且可能在后续版本中发生变化。通常,当版本号达到 1.0.0 时,模块将具有一定的稳定性,尽管 API 表面可能会增长,但现有功能不应有太大变化。这符合当模块保存到 package.json 文件时 npm 的行为。
目前我们在 package.json 文件中将我们的 fastfib 模块版本设为 0.1.0。它相当稳定,但在我们将其提升到 1.0.0 状态之前,可能还有其他我们想要做出的更改,所以我们将它保持在 0.1.0。
变更日志
对于模块作者来说,有一个变更日志总结用户在发布新版本时应注意的内容也是有帮助的。以下是一个这样的格式:
Version 0.5.0?--?2014-04-03
---
added; feature x
removed; feature y [breaking change!]
updated; feature z
fixed; bug xx
Version 0.4.3?--?2014-03-25
---
在变更日志中应清楚地注明重大变更,特别是对于小版本,以便用户知道如何为更新做准备。一些作者喜欢在他们的主要 README 中保留变更日志或有一个单独的变更日志文件。
我们已经涵盖了关于版本化我们的依赖项和模块的一些理解和工具;让我们看看我们还能向模块的消费者暴露什么。
13.3. 最终用户体验
在我们将模块推出去供消费之前,测试它实际上是否工作会很好。当然,我们已经有了一个测试套件,所以我们知道我们的逻辑是合理的,但最终用户安装模块时的体验是什么?我们如何除了 API 之外向用户暴露可执行脚本?我们可以支持哪些版本的 Node?在本节中,我们将探讨这些问题,从添加可执行脚本开始。
技巧 111 添加可执行脚本
想要在模块安装时暴露一个可执行文件吗?例如,Express 包括一个可以从命令行运行的 express 可执行文件,可以帮助初始化新项目:

npm 本身是一个可安装的模块,具有 npm 可执行文件,我们已经在本章的各个地方使用过它。
可执行文件可以帮助最终用户以不同的方式使用你的模块。在本技巧中,我们将查看如何向 fastfib 添加一个可执行脚本,并将其包含在我们的 package.json 中,以便与我们的模块一起安装。
问题
如何添加一个可执行脚本?
解决方案
你如何为包添加命令行工具和脚本,并在 package.json 文件中链接它们?
讨论
我们已经构建了 fastfib 模块,但如果我们想向最终用户公开 fastfib 可执行文件,让他们能够运行类似 fastfib 40 的命令并打印出第 40 个斐波那契数,会怎么样?这将允许我们的模块在命令行以及程序化方式下使用。
为了做到这一点,让我们创建一个包含 index.js 文件的 bin 目录,其中包含以下内容:

现在我们有了我们的应用程序可执行文件,我们如何将其作为 fastfib 命令公开,以便有人安装我们的模块时可以使用?为此,我们需要更新我们的 package.json 文件。在 main 下面添加以下行:

使用 npm link 测试可执行文件
我们可以通过使用 npm link 来测试我们的可执行文件。link 命令将创建一个指向我们的实时模块的全局符号链接,模拟全局安装包,就像用户全局安装模块一样。
让我们从 fastfib 目录运行 npm link:

现在我们已经全局链接了我们的可执行文件,让我们试试它:
$ fastfib 40
102334155
由于这些链接已经就位,任何编辑都会在全局范围内反映出来。让我们更新 bin/index.js 文件的最后一行来宣布我们的结果:
console.log('The result is', fastfib(seqNo));
如果我们再次运行 fastfib 可执行文件,我们会立即得到更新:
$ fastfib 40
The result is 102334155
我们在我们的模块中添加了一个 fastfib 可执行文件。重要的是要注意,本技术中讨论的所有内容都是完全跨平台兼容的。Windows 没有符号链接或#!语句,但 npm 通过额外的代码包装可执行文件,以便在运行 npm link 或 npm install 时获得相同的行为。
链接是一个如此强大的工具,我们将在下一个技术中专门介绍它!
技术编号 112:尝试模块
除了使用 npm link 在全局范围内测试我们的可执行文件外,我们还可以使用 npm link 在其他地方尝试我们的模块。比如说,我们想在另一个项目中尝试我们闪亮的新模块,看看它是否适用。我们不必发布我们的模块并安装它,我们只需链接到它,并像在另一个项目的上下文中使用它一样玩弄模块。
问题
你想在发布模块之前尝试它,或者你想要修改你的模块并在另一个项目中测试这些更改,而不必首先重新发布。
解决方案
使用 npm link
讨论
在前一种技术中,我们展示了如何使用 npm link 来测试可执行脚本的运行行为。这表明我们可以在开发过程中测试我们的可执行文件,但现在我们想要模拟模块的本地安装,而不是全局安装。
让我们从设置另一个项目开始。由于我们本章从我们癌症般的斐波那契网络服务器实现开始,让我们从头到尾,创建一个小项目,将 fastfib 作为 Web 服务公开。
创建一个名为 fastfibserver 的新项目,并在其中放入一个名为 server.js 的单个文件,内容如下:

我们已经设置了服务器,但如果我们要运行 node server,它现在还不会工作,因为我们还没有在这个项目中安装 fastfib 模块。为此,我们使用 npm link:

现在如果我们运行我们的 Web 服务器,它将成功运行:
$ node server
fastfibber running on port 3000
访问我们的网站将给出第 40 个斐波那契数,如图 13.5 所示。
图 13.5. fastfibserver 的示例输出

另一种链接方式
由于我们在之前的技术中已经将我们的模块全局链接到 package.json 中,在 fastfib 项目中运行 npm link,我们也可以在 fastfibserver 项目中运行 npm link fastfib 来设置链接。
使用 npm link 也有助于在另一个模块的上下文中调试你的模块。在运行需要你的模块的项目时,会出现一些边缘情况,这些情况最好在运行项目时调试。一旦你 npm link 了模块,任何更改将立即生效,无需重新发布和重新安装。这允许你在调试时修复模块代码库中的问题。
到目前为止,我们已经定义并实现了我们的模块,包括测试,设置了我们的依赖项,锁定了我们依赖项和模块的版本,添加了命令行可执行文件,并练习了使用我们的模块。接下来,我们将看看 package.json 文件的另一个方面——engines 部分,以及跨多个 Node 版本测试我们的模块。
技巧 113 在多个 Node 版本上测试
很遗憾,并不是每个人都能在 Node 版本发布时升级到最新版本。公司需要时间来适应所有代码到新版本,有些可能永远不会更新。了解我们的模块可以在哪些版本的 Node 上运行,以便 npm 知道谁可以安装和运行它,这一点很重要。
问题
你想在多个版本的 Node 上测试你的模块,并且只想为这些版本安装你的应用程序。
解决方案
通过在多个版本的 Node 上运行测试,确保 package.json 文件中的 engines 对象准确无误。
讨论
我们在首次设置 package.json 时运行的 npm init 脚本不包括 engines 部分,这意味着 npm 将在任何版本的 Node 上安装它。乍一看,我们可能会认为这没问题,因为我们运行的是相当原始的 JavaScript 代码。但如果没有实际测试,我们真的不知道。
通常,补丁版本更新(例如 Node 0.10.2 到 0.10.3)不应该破坏你的模块。但至少测试你的模块在次要和主要版本更新之间是一个好主意,因为 V8 得到了相当大的升级,Node 的 API 可能会发生变化。目前,我们一直在使用 Node 的 0.10 分支,一切运行良好。所以,让我们从以下内容开始,将其添加到 package.json 文件的末尾:

这只是一个开始,但实际情况似乎我们应该能够支持更早版本的 Node。我们如何测试这一点呢?
可用的流行选项有很多:
-
在您的机器上安装多个 Node 版本
-
使用 Travis CI 的多 Node 版本支持 (
travis-ci.org/) -
使用适用于您环境的第三方多版本测试模块(如 dnt—
github.com/rvagg/dnt)
关于 Node 版本
在 Node 中,所有奇数版本的次要版本都被视为不稳定。因此,0.11.0 是 0.12.0 的不稳定版本,依此类推。您通常不需要测试任何现有的不稳定发布版本。通常,模块作者只会测试即将完成的最新不稳定版本。
对于我们的技术,我们将专注于安装多个 Node 版本,因为这可以在测试 Node 即将发布的版本中的新功能以及测试我们的模块时派上用场。
我们将使用的工具是 nvm (github.com/creationix/nvm; Windows 的对应版本是 nvmw: github.com/hakobera/nvmw). 以下说明将针对 nvm,但一旦安装,nvmw 的命令也将相似。
安装时,运行

现在我们已经安装了它,让我们继续测试我们的 fastfib 模块的 Node 0.8 版本。首先让我们安装 Node 0.8:
$ nvm install 0.8
######################################################## 100.0%
Now using node v0.8.26
nvm 去获取了 0.8 分支的最新版本以进行测试。如果我们想指定一个补丁,我们可以这样做,但现阶段这已经足够了。注意我们也在使用这个版本。我们可以通过运行以下命令来验证:
$ node -v
v0.8.26
现在,所有 Node 和 npm 交互都在为 Node 0.8.26 定制的隔离环境中进行。如果我们要安装更多版本,它们将各自在它们自己的隔离环境中。我们使用 nvm use 在它们之间切换。例如,如果您想切换回系统安装的 Node 版本,您可以执行以下操作:
nvm use system
要回到 Node 版本 0.8.26:
nvm use 0.8
让我们在 0.8.26 上运行我们的测试套件,看看我们做得怎么样:
$ npm test
> fastfib@0.1.0 test /Users/wavded/Dev/fastfib
> node test && node benchmark
results:
recurse 432 5.48
tail 300770 5.361
iter 1109759 5.428
看起来不错!让我们更新我们的 package.json 以包括 0.8 版本:

如果我的模块失去了对特定 Node 版本的支持怎么办?
这完全没问题。使用较旧版本 Node 的用户将获得适用于其版本的最新发布的支持包。
我们已经测试了 Node 版本 0.10 和 0.8;请尝试测试您自己的几个其他版本。完成后,请切换回系统 Node 版本。
现在我们已经查看了一系列步骤,使我们的模块处于可用的状态,以便其他人使用,让我们发布它!
13.4. 发布
在我们结束本章时,我们将通过查看在 npm 上公开发布模块或私下内部使用模块来关注模块的发布。
技巧 114 发布模块
呼呼!我们已经通过许多不同的技术使我们的模块准备好发布。我们知道可能会有变化,但我们准备将我们的第一个版本发布到野外,以便在其他项目中作为依赖项使用。这项技术探讨了发布的各个方面。
问题
您希望将您的模块公开发布。
解决方案
如果你还没有注册 npm,请注册并发布。
讨论
如果你第一次发布模块,你需要自己在 npm 上注册。幸运的是,这非常简单。运行以下命令并按照提示操作:
npm adduser
完成后,npm 会将你的凭据保存到.npmrc 文件中。
更改现有账户详情
adduser 命令也可以用来更改账户详情(除了用户名)并使用现有账户注册新安装。
注册后,发布模块就像添加用户一样简单。但在我们深入之前,让我们了解一下发布模块时的一些良好实践。
在发布之前
在发布之前,最重要的是回顾关于语义版本化的技巧 110:
-
你的版本号是否准确地反映了自上次推送以来的更改?如果你是第一次推送,这并不那么重要。
-
你在发布时更新了变更日志吗?虽然这不是必需的,但对于依赖你的项目的用户来说,这可以非常有帮助,让他们对这次发布可以期待什么有一个高层次的认识。
此外,检查你的测试是否通过,以避免发布有缺陷的代码。
发布到 npm
一旦你准备好发布,只需从项目根目录运行以下命令即可:
npm publish
npm 会响应发布操作的成功或失败。如果成功,它将指示已推送到公共注册表的版本。
你能感觉到 npm 希望你尽可能轻松地将模块发布出去吗?
撤销发布
虽然我们希望发布顺利进行,但有时我们会错过想要发布的某些内容,或者在实际发布后发现有些内容是错误的。建议你不要撤销模块(尽管存在这种能力)。原因是依赖该模块和/或版本的用户的获取将不再可能。
通常,修复问题,增加补丁版本,然后再次运行 npm publish。这样做的一个简单方法是运行以下命令:

npm 不允许你覆盖现有版本,因为这也会影响到已经下载了该特定版本的用户。
有一些情况下,你真的希望阻止用户使用特定版本。例如,可能在 0.2.5 及更高版本中修复了严重的安全漏洞,但你还有用户依赖于比这更早的版本。npm 可以通过使用 npm deprecate 来帮助你传达这一信息。
假设在未来,我们发现 fastfib 版本 0.2.5 及以下存在一个关键漏洞,我们想要警告使用这些模块的用户。我们可以运行以下命令:

现在,如果任何用户安装了 fastfib 0.2.5 或更低版本,他们将收到 npm 指定的警告。
技巧 115 保持模块私有
虽然开源可以是一个有趣且协作的环境,但有时你希望你的项目保持私有。这尤其适用于为客户完成的工作。在决定是否发布之前,先在内部构建一个模块也可能很有用。npm 可以保护你的模块,并为你保持其私有性。在这个技术中,我们将讨论如何配置你的模块以保持私有,以及如何在项目中包含私有模块。
问题
你希望保持你的模块私有并内部使用。
解决方案
在你的 package.json 文件中配置私有,并在内部共享。
讨论
假设我们想让 fastfib 只在内部使用。为了确保它不会意外发布,我们在 package.json 文件中添加以下内容:
"private": true
这会告诉 npm 不要使用 npm publish 发布你的包。
这个设置对于特定客户端的项目来说效果很好。但如果你想在开发团队内部跨项目共享一组核心模块,那该怎么办呢?为此,有几个不同的选项。
使用 Git 共享私有模块
npm 支持几种你可以分享内部模块的方式,这些方式设置起来非常简单。如果你使用 Git 仓库,npm 使这变得非常简单。
以 GitHub 为例(尽管它可以是 任何 Git 远程)。假设我们的私有仓库位于
git@github.com:mycompany/fastfib.git
我们可以使用 npm install 将其包含在我们的 package.json 依赖项中(或直接修改 package.json):
npm install git+ssh://git@github.com:mycompany/fastfib.git --save
非常棒!默认情况下,这将拉取 master 分支的内容。如果我们想指定一个特定的 commit-ish(标签、分支或 SHA-1—git-scm.com/book/en/Git-Internals-Git-Objects),我们也可以这样做!以下是一些在 package.json 文件中的示例:

包含公开仓库
你可能已经猜到了,你还可以使用公开的 Git 仓库。如果你真的需要一些尚未发布到 npm 的功能或修复,这可能会很有帮助。更多示例,请参阅 package.json 文档(www.npmjs.org/doc/json.html#Git-URLs-as-Dependencies)。
将私有模块作为 URL 共享
如果你没有使用 Git 或者希望你的构建系统输出包,你可以指定一个 npm 可以找到 tarball 的 URL 端点。要打包你的模块,你可以使用以下 tar 命令:

tar -czf fastfib.tar.gz fastfib
从这里,我们可以将这个文件上传到 web 服务器,并使用以下命令安装:
npm install http://internal-server.com/fastfib.tar.gz --save
关于公开端点的说明
尽管通常不常用,但包的 tarball 也可以与公开端点一起使用;通常来说,发布到 npm 更为方便和简单。
使用私有 npm 仓库共享模块
对于私有仓库,另一个选项是托管你自己的私有 npm 注册表,并让 npm publish 将内容推送到该仓库。为了实现 npm 的完整功能,这需要安装 CouchDB 的最新版本,而 CouchDB 又需要 Erlang。
由于这涉及到根据你的操作系统而定的各种技巧/头痛问题,我们在这里不会涵盖设置实例的过程。希望这个过程很快就会简化。如果你想进行实验,请查看 npm-registry-couchapp 项目 (github.com/npm/npm-registry-couchapp)。
13.5. 摘要
第三方模块是创新发生的地方。npm 使得这一切变得简单而有趣!随着像 GitHub 这样的社交编码网站的兴起,模块的协作也变得容易。在本章中,我们探讨了模块开发的许多不同方面。让我们总结一下我们学到了什么。
在开始工作于一个模块时,考虑以下事项:
-
定义你的模块想法。你能用一句话总结它吗?
-
检查你的模块想法。是否已有其他模块在做你想要做的事情?使用 npm search 或 npmjs.org 进行搜索。
一旦你确定了一个想法,就证明它。从一个你想要与之合作的简单 API 开始。编写实现和测试,并在过程中安装任何需要的依赖项。
在你证明你的想法(或可能是在此期间)之后,考虑以下事项:
-
你已经初始化了 package.json 文件吗?运行 npm init 以获取表示当前项目状态的骨架。
-
与你的依赖项一起工作。有些是可选的,仅用于开发吗?确保在 package.json 文件中指明这一点。
-
检查 package.json 中的 semver 范围。你信任 package.json 文件中指定的版本范围吗?使用 npm outdated 检查更新。
-
你的代码将在哪些版本的 Node 上运行?使用 nvm 或像 Travis CI 这样的构建系统来检查。在 package.json 文件中指定版本范围。
-
在另一个项目中使用 npm link 尝试你的模块。
当你准备好发布时,只需使用 npm publish 即可。考虑为你的用户提供变更日志,并尝试遵循语义版本控制,这样用户对每个版本可以期待的内容有一个合理的了解。
这本书就到这里了!我们希望到这一点,你已经能够掌握 Node 的核心基础,了解如何在现实场景中应用这些基础,以及如何通过编写自己的 Node 模块(我们希望能在 npm 上看到!)来超越标准开发。
一个不断增长的 Node 社区可以帮你继续提升你的旅程。请查看附录,以充分利用这个社区。如果你对我们有任何具体问题,请访问 #nodejsinpractice Google 群组 (groups.google.com/forum/#!forum/nodejsinpractice),感谢阅读!
附录. 社区
本节将帮助你充分利用日益增长的 Node 社区。编程社区可以帮助你解决文档中没有直接回答的问题。通过与志同道合的人在一起——无论是线上还是线下——你可以更有效地学习。
A.1. 提问
有时候你只是想知道如何做某件事,看起来应该很简单,但实际上并不简单。有时你认为你可能在 Node 中发现了一个严重的错误。无论情况如何,当你需要帮助而 Node 的 API 文档无法满足时,你可以使用几个官方渠道。
首先是 Node 邮件列表,这是 nodejs Google Group (groups.google.com/group/nodejs)。你可以通过电子邮件订阅或使用 Google 的网页界面。网页界面允许搜索帖子,因此你可以查看是否有人之前提出过你的问题。
该团体有来自知名社区成员的贡献,包括 Isaac Schlueter、Mikeal Rogers 和 Tim Caswell,因此这是一个获取帮助和了解 Node 的一般的好地方。
此外,还有一个官方的 IRC 聊天室:irc.freenode.net 上的 #node.js。尽管它非常繁忙,所以要做好接收大量信息的准备。在 #node.js 中确实会发生信息丰富的讨论,所以一些耐心可能会得到回报!
如果你喜欢 Stack Exchange 网络,你可以使用 node.js 标签发帖提问 (stackoverflow.com/questions/tagged/node.js)。
如果你更喜欢社交网络,Node 用户组 (github.com/joyent/node/wiki/Node-Users) 在 Node wiki 中列出了数百个与开发者时区相关的 Twitter 账号,因此你可以这样寻找可以交谈的人。提示:本书的作者也被列出来了!
最后,如果你的问题与某个特定模块有关,你应该检查该模块的文档以获取社区信息。例如,Express 网络框架有自己的 express-js Google Group (groups.google.com/group/express-js)。
A.2. 闲逛
你的城市可能有活跃的 Node meet-up 社团。例如,伦敦 Node.js 用户组 (lnug.org/)、墨尔本 Node.JS Meetup 社团 (www.meetup.com/MelbNodeJS/) 和加利福尼亚山景城的 BayNode (meetup.com/BayNode/)。
此外,还有主要的 Node 会议,包括 NodeConf (nodeconf.com/) 和 NodeConf EU (nodeconfeu.com/)。
为了帮助你找到更多的 meet-up 社团和会议,Node.js Meatspace 页面 github.com/knode/node-meatspace 经常更新。当然,你还可以在 meetup.com 上搜索。
A.3. 阅读
如果你正在寻找阅读材料,你将找到一些优秀的社区出版物。自然地,reddit.com/r/node 收集了一些优秀的帖子,但 Medium 也有收藏,包括 medium.com/node-js-javascript。
一些知名的 Node.js 开发者也有博客,你可以查看。Isaac Z. Schlueter (blog.izs.me/)、James Halliday (substack.net/; see 图 A.1) 和 Tim Caswell 都有个人博客,他们在那里写关于 Node.js 的内容。Tim 的 howtonode.org 有适合初学者的材料,但也会帮助你跟踪新动态。
图 A.1. James Halliday 关于 Node.js 和测试的博客

此外,还有一些商业博客,它们来自有才华的 Node.js 开发者。Joyent 在 joyent.com/blog 的博客经常有一些与部署 Node.js 相关的有趣帖子,StrongLoop 的博客“在循环”在 strongloop.com/strongblog 上也是如此。
Nodejitsu 在 blog.nodejitsu.com 的博客提供了有关部署的建议,并且还展示了模块作者谈论他们的工作。
A.4. 社区为社区提供培训
Node.js 教学中的一个有趣发展是 NodeSchool (nodeschool.io/; see 图 A.2). 你可以自己安装课程,但也有一些由社区运营的现场培训活动。NodeSchool 提供了设置培训活动的材料,因此它们在全球范围内迅速传播。网站上有关即将举行的活动有更多详细信息。
图 A.2. 使用 NodeSchool 学习 Node.js

A.5. 推广你的开源项目
如果你打算参与 Node.js 社区,最好的方式之一就是分享你的工作。但现在 npm 非常流行,因此很难让你的模块引起注意。
要真正产生影响,你应该考虑推广你的开源项目。如果知名的 Node.js 博客作者有联系表单或 Twitter 账号,告诉他们你做了什么并不会伤害到任何人。只要你礼貌,并且为你的工作提供一些背景信息以便于理解,那么这真的可以帮助你获得反馈并提高你的技能。


浙公网安备 33010602011771号