Node-部署指南-全-

Node 部署指南(全)

原文:zh.annas-archive.org/md5/4918761b5a5e44ec344b9d61c57faabe

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在过去的几年里,Node.js 已经进入到了财富 500 强公司的技术栈、以移动为先的初创公司、成功的互联网企业以及其他企业的技术栈中。除了验证其作为平台的力量外,这种成功也暴露了管理、部署和监控 Node.js 应用程序的工具短缺。尽管 Node.js 社区是开放和协作的,但关于专业 Node.js 开发者如何设计、测试并将代码推送到生产环境中的全面信息仍然难以找到。

本书试图通过解释和记录正在工作的 Node.js 开发者可以使用的技术和工具来填补这一知识差距,以创建可扩展、智能、健壮和可维护的企业级软件。

在简要介绍 Node 及其设计背后的哲学之后,你将学习如何在本地服务器和云中安装和更新你的应用程序。然后,通过逐步进行负载均衡和其他扩展技术,解释如何处理流量量的突然变化和形状,实施版本控制,并设计内存高效、有状态、分布式应用程序。

一旦你完成了创建生产就绪系统的必要基础工作,你将需要测试和部署它们。本书充满了真实的代码示例,结构上像一个逐步的工作手册,解释了在测试、部署、监控和维护每个成功的软件运行马拉松的每个阶段应使用的成功策略。

当你完成这本书后,你将学会一套可直接应用于解决你今天所面临问题的可重用模式,并为如何构建和部署你的下一个项目奠定信心基础。

本书涵盖内容

第一章,欣赏 Node,深入探讨了 Node.js 背后的思考,帮助你清晰地思考 Node.js 擅长什么,不擅长什么,以及如何利用 Node.js 解决现实世界的挑战。

第二章,安装和虚拟化 Node 服务器,将教你如何创建一个基本的 Node.js 应用程序并在服务器上运行它。你还将学习如何在某些流行的云托管提供商上执行相同操作,例如 DigitalOcean 和 Heroku,此外,你还将学习如何使用 Docker 创建轻量级、易于复制的虚拟机。

第三章,扩展 Node,探讨了垂直和水平扩展技术。你将学习如何使用集群模块在单个服务器上最大化 Node 的效率,以及如何协调多个分布式 Node.js 服务器来处理增加的网络流量,了解 Nginx 负载均衡、使用消息队列设置代理以及在过程中协调进程间通信。

第四章,管理内存和空间,展示了良好的工程实践永远不会过时。我们首先讨论微服务,介绍设计由小型、专注、通信进程组成的系统的通用技术。接下来,深入探讨如何优化 JavaScript,特别是针对 V8 编译器。从几个在 Redis 中高效存储和检索事件数据的示例开始,我们研究缓存策略、会话管理和使用 CDN 来减少服务器负载。

第五章,监控应用程序,解释了在应用程序部署后如何有效地监控应用程序的策略。包括使用各种第三方监控工具的示例,以及概述如何构建自己的自定义系统采样和日志模块的示例。最后,我们探讨调试技术,检查几个帮助您找到和防止运行时瓶颈的工具和策略。

第六章,构建和测试,介绍了创建应用程序构建管道时的一些考虑因素。提供了使用 Gulp、Browserify 和 npm 创建构建工具的完整示例,以及使用 Mocha 进行测试、使用 Sinon 进行模拟和利用 PhantomJS 进行无头浏览器测试的信息。

第七章,部署和维护,指导您了解整个部署管道,从设置虚拟化开发环境到将持续集成集成到您的流程中。您将了解如何使用 GitHub webhooks 和 Vagrant,以及如何使用 Jenkins 自动化您的部署过程。此外,npm 软件包管理器将被彻底剖析,并讨论依赖关系管理策略。

你需要为此书准备的内容

你需要安装 Node v. 0.12.5 或更高版本,最好是在基于 Unix 的操作系统上,例如 Linux 或 Mac OS X。你还需要安装一些工具,主要是为了设置开发和部署示例:

在您阅读本书的过程中,如有必要,将提供这些和其他包的进一步安装和配置说明。

本书面向的对象

这本书是为那些准备在生产环境中部署大型 Node.js 应用程序的 Node.js 开发者设计的。它旨在通过将示例置于现实情境中,关注模块化设计,并使用广泛的测试、主动监控和以团队为中心的维护策略,更详细地向中级 Node.js 开发者介绍该平台。那些希望提高他们编写的 JavaScript/Node 程序的质量和效率,并交付能够承受企业级流量的稳健系统的人,会喜欢这本书。没有 Node.js 平台经验的 DevOps 工程师也将从 Node.js 社区如何实施他们已知的技术的宝贵信息中受益。

约定

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“因此,一旦我们加载了页面,我们就将按键(sendKeys)发送到#source输入框中,输入意大利语单词"Ciao"。”

代码块设置如下:

variable = produceAValue()
print variable
// some value is output when #produceAValue is finished.

任何命令行输入或输出都应如下编写:

> This happens first
> Then the contents are available, [file contents shown]

新术语重要词汇以粗体显示。屏幕上显示的单词,例如在菜单或对话框中,在文本中显示如下:“你应该看到您刚刚部署了一些 Node!显示。”

注意

警告或重要注意事项以如下方式显示:

提示

技巧和窍门看起来像这样。

读者反馈

我们欢迎读者的反馈。让我们知道您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。

要向我们发送一般反馈,只需发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及本书的标题。

如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南,链接为www.packtpub.com/authors

客户支持

现在您已经成为 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

下载示例代码

您可以从您在www.packtpub.com的账户下载所有已购买 Packt 出版物的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

错误清单

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误清单,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详细信息来报告它们。一旦您的错误得到验证,您的提交将被接受,错误将被上传到我们的网站或添加到该标题的错误清单部分。

要查看之前提交的错误清单,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在错误清单部分。

盗版

在互联网上盗版受版权保护的材料是一个跨所有媒体的持续问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过发送电子邮件到<copyright@packtpub.com>并附上疑似盗版材料的链接联系我们。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面所提供的帮助。

询问

如果您在这本书的任何方面遇到问题,您可以通过发送电子邮件到<questions@packtpub.com>联系我们,我们将尽力解决问题。

第一章: 欣赏 Node

在撰写本书时,Node 正接近其存在的第五个年头,其使用量在这五年中逐年增长。Node 失败的机会已经到来,并且已经过去。Node 是由一支技术精湛的核心团队和非常活跃的社区构建的严肃技术,他们专注于不断改进其速度、安全性和实用性。

每天开发者都会面临 NodeJS 旨在解决的问题。以下是一些例子:

  • 在单个服务器之外扩展网络应用程序

  • 防止 I/O 瓶颈(数据库、文件和网络访问)

  • 监控系统使用情况和性能

  • 测试系统组件的完整性

  • 安全可靠地管理并发

  • 将代码更改和错误修复推送到生产环境

在本书中,我们将探讨部署、扩展、监控、测试和维护您的 Node 应用程序的技术。重点将放在如何将 Node 的事件驱动、非阻塞模型应用于软件设计和部署的这些方面。

在 2014 年 2 月 28 日,Eran Hammer 向由 PayPal 组织和赞助的大型开发者会议 NodeDay 的与会者发表了主题演讲。他开始演讲时,列举了一些与他雇主沃尔玛相关的数字:

  • 11,000 家门店

  • 每年半兆美元的净销售额

  • 2.2 百万名员工

  • 世界上最大的私营雇主

他继续说道:

*"55%的黑色星期五流量,这是我们一年中的超级碗……我们在黑色星期五实现了大约 40%的年度收入。55%的流量来自移动端……这 55%的流量 100%通过 Node 完成。[...] 我们能够处理...如此巨大的流量,只需要相当于两个 CPU 和 30 吉字节 RAM。就是这样。这就是 Node 在黑色星期五处理 100%移动端 Node 流量的需求。[...] 沃尔玛全球电子商务业务价值 100 亿美元,到今年年底,所有这 100 亿美元都将通过 Node 完成。"[...]
--Eran Hammer,沃尔玛实验室高级架构师

由于各种原因,现代网络软件的复杂性正在增长,并且在许多方面改变了我们思考应用程序开发的方式。大多数新的平台和语言都在试图应对这些变化。Node 也不例外——JavaScript 也不例外。

了解 Node 意味着了解事件驱动编程、将软件模块化、创建和链接数据流,以及产生和消费事件及其相关数据。基于 Node 的架构通常由许多小型进程和/或服务组成,它们通过事件进行通信——内部通过扩展EventEmitter接口和使用回调,外部通过几个常见的传输层之一(例如,HTTP、TCP)或通过覆盖这些传输层之一的薄消息层(例如,0MQ、Redis PUBSUB 和 Kafka)。这些进程可能由几个免费、开源且高质量的npm模块组成,每个模块都附带单元测试和/或示例和/或文档。

在本章中,我们将快速浏览 Node,突出其旨在解决的问题、其设计所暗示的解决方案以及这对你的意义。我们还将简要讨论一些我们将在后续章节中更全面探讨的核心主题,例如如何构建高效稳定的 Node 服务器、如何最好地利用 JavaScript 为你的应用程序和团队服务,以及如何思考和利用 Node 以获得最佳效果。

让我们从理解 Node 设计的方式和原因开始。

理解 Node 的独特设计

I/O 操作(磁盘和网络)显然更昂贵。以下表格显示了典型系统任务消耗的时钟周期(来自 Ryan Dahl 的原始演示——www.youtube.com/watch?v=ztspvPYybIY):

L1-cache 3 周期
L2-cache 14 周期
RAM 250 周期
Disk 41,000,000 周期
Network 240,000,000 周期

原因很清楚:磁盘是一个物理设备,一个旋转的金属盘片——存储和检索数据比在固态设备(如微处理器和内存芯片)之间移动数据或确实在片上优化的 L1/L2 缓存中操作要慢得多。同样,数据在网络上的点对点传输不会瞬间完成。光本身需要 0.1344 秒才能绕地球一周!在一个由数十亿人使用、在远距离以远低于光速的速度进行大量交互、有许多迂回和很少直线的网络中,这种延迟会累积起来。

当我们的软件在我们的办公桌上运行在个人电脑上时,网络上几乎没有通信发生。与文字处理器或电子表格交互中的延迟或中断与磁盘访问时间有关。我们做了大量工作来提高磁盘访问速度。数据存储和检索变得更快,软件变得更加响应,现在用户期望他们的工具具有这种响应性。

随着云计算和基于浏览器的软件的出现,你的数据已经离开了本地磁盘,存在于远程磁盘上,而你通过网络——互联网来访问这些数据。数据访问时间再次变慢,大幅下降。网络 I/O 速度缓慢。尽管如此,越来越多的公司将他们的应用程序的部分迁移到中,有些软件完全是基于网络的。

Node 被设计成使 I/O 快速。它被设计用于这个新的网络软件世界,其中数据存在于许多地方,并且必须快速组装。许多用于构建 Web 应用程序的传统框架是在这样一个时代设计的,当时单个用户在台式计算机上使用浏览器定期向运行关系型数据库的单个服务器发出 HTTP 请求。现代软件必须预测成千上万的客户端同时通过各种网络协议在任何数量的独特设备上并发地更改巨大的共享数据池。Node 被特别设计来帮助那些构建这种网络软件的人。

并发、并行性、异步执行、回调和事件对 Node 开发者意味着什么?

并发

按顺序或程序性地运行代码是一个合理的想法。当我们执行任务时,我们往往会这样做,而且长期以来,编程语言都是自然程序性的。显然,在某个时刻,你发送给处理器的指令必须以可预测的顺序执行。如果我想将 8 乘以 6,然后将结果除以 144 除以 12,最后将总和加到 10 上,这些操作的顺序必须按顺序进行:

( (8x6) / (144/12) ) + 10

操作的顺序不应该是以下这样:

(8x6) / ( (144/12) + 10 )

这是有逻辑的,也容易理解。早期的计算机通常只有一个处理器,处理一个指令会阻止后续指令的处理。但事情并没有保持这种状态,我们已经远远超越了单核计算机。

如果你考虑之前的例子,应该很明显,计算144/128x6可以独立完成——一个不需要等待另一个。一个问题可以被分解成更小的问题,并分布到一组可用的人或工人中并行处理,然后将结果组合成一个正确排序的最终计算。

同时解决单个数学问题的一个部分,多个进程的例子是并行性

Google 的 Go 编程语言的共同发明者 Rob Pike 这样定义并发

"并发是一种构建事物的方式,这样你就可以,也许,利用并行性做得更好。但并行性不是并发的目标;并发的目标是良好的结构。"

并发不是并行。一个表现出并发的系统允许开发者构建应用程序,好像多个独立的过程同时执行许多可能相关的事情。成功的高并发应用程序开发框架提供了一个易于理解的词汇表来描述和构建这样的系统。

Node 的设计表明,实现其主要目标——提供一种简单的方式来构建可扩展的网络程序——包括简化共存进程的执行顺序的结构和组成。Node 帮助开发者更好地组织代码,在程序中许多事情同时发生(例如,服务多个并发客户端)时,更好地思考程序。

让我们来看看并行性和并发性、线程和进程之间的区别,以及 Node 如何以独特的方式吸收每个部分的优点。

并行和线程

以下图描述了传统的微处理器可能如何执行之前讨论的简单程序:

并行和线程

程序被分解成单个指令,按顺序执行。这可行,但需要指令以串行方式处理,并且,在处理任何一条指令时,后续的指令必须等待。这是一个阻塞过程——执行链中的任何一段都会阻塞后续段的处理。这里有一个单线程的执行流。

然而,有一些好消息。处理器(实际上)完全控制着主板,没有其他处理器会清除内存或覆盖这个主要处理器可能操作的其他任何状态。速度是为了稳定性和安全性而牺牲的。

我们确实喜欢速度;然而,随着芯片设计师和系统程序员努力引入并行计算,之前讨论的模型迅速变得过时。目标不是只有一个阻塞线程,而是要有多条协作线程。

这种改进无疑提高了计算的效率,但也引入了一些问题,如下面的示意图所示:

并行和线程

此图说明了在单个进程中并行执行的协作线程,这减少了执行给定计算所需的时间。使用不同的线程来分解、解决和组合解决方案。由于许多子任务可以独立完成,整体完成时间可以显著减少。

线程在单个进程中提供并行性。一个线程代表一个单一的指令序列(顺序执行)。一个进程可以包含任意数量的线程。

线程同步的复杂性导致了困难。使用线程来模拟高度并发场景非常困难,尤其是在共享状态的模式中。如果异步执行的线程何时完成并不明确,那么很难预测一个线程采取的行动将如何影响所有其他线程:

  • 共享内存及其所需的锁定行为导致系统在复杂性增加时变得非常难以推理。

  • 任务之间的通信需要实现各种同步原语,如互斥锁和信号量、条件变量等。一个已经具有挑战性的环境需要高度复杂的工具,这增加了完成甚至相对简单系统所需的技能水平。

  • 竞态条件和死锁是这类系统中的常见陷阱。在共享程序空间内同时进行的读写操作会导致序列化问题,其中两个线程可能在进行不可预测的竞争以获得影响状态、事件或其他关键系统特性的权利。

  • 因为在线程及其状态之间保持可靠的边界如此困难,确保一个库(对于 Node 来说,它可能是一个模块)是线程安全的占据了开发者大量时间。我能知道这个库不会破坏我应用程序的某些部分吗?保证线程安全需要库开发者的极大勤奋,并且这些保证可能是条件性的:例如,一个库在读取时可能是线程安全的,但在写入时则不是。

我们希望利用线程提供的并行化能力,但可以不需要那些令人头疼的信号量和互斥锁的世界。在 Unix 世界中,有一个概念有时被称为简单规则开发者应该通过寻找将程序系统分解成小而直接的合作部分的方法来设计简单性。这个规则旨在阻止开发者对编写'复杂而美丽'的程序的喜爱,而这些程序实际上是有缺陷的程序

并发与进程

单个进程内的并行化是一个复杂的错觉,它是在令人费解的复杂芯片组和其它硬件深处实现的。真正的问题在于外观——关于系统的活动如何向开发者显示,以及如何被开发者编程。线程提供了超高效的并行化,但使得并发难以推理。

而不是让开发者与这种复杂性作斗争,Node 本身管理 I/O 线程,通过只要求在事件之间管理控制流来简化这种复杂性。需要微观管理I/O 线程;开发者只需设计一个应用程序来建立数据可用点(回调)以及当数据可用时要执行的指令。一条明确的指令流,以清晰、无冲突和可预测的方式明确地获取和释放控制,有助于开发:

  • 与此同时,开发者不必担心任意的锁定和其他冲突,可以专注于构建执行链,其顺序是可预测的。

  • 并行化是通过使用多个进程来实现的,每个进程都有自己的独立和独特的内存空间,因此进程间的通信保持简单——通过简单法则,我们不仅实现了简单且无错误的组件,还实现了更易于互操作。

  • 状态不是(任意地)在单个 Node 进程之间共享的。单个进程自动受到来自其他进程的意外访问的保护,这些进程热衷于内存重新分配或资源垄断。通信是通过使用基本协议的清晰通道进行的,所有这些都使得编写在进程之间产生不可预测变化的程序变得非常困难。

  • 线程安全是开发者无需浪费时间担忧的另一个问题。因为单线程并发消除了多线程并发中的冲突,开发可以更快地进行,并建立在更坚实的基础之上。

一个线程描述了由事件循环高效管理的异步控制流,为 Node 程序带来了稳定性、可维护性、可读性和弹性。最大的新闻是 Node 继续向开发者提供多线程的速度和力量——Node 设计的卓越之处使得这种力量变得透明,反映了 Node 声明的目标之一:以最少的困难将最多的力量带给最多的人。

事件

Node 中许多 JavaScript 扩展都会发出事件。这些是 events.EventEmitter 的实例。任何对象都可以扩展 EventEmitter,这为开发者提供了一个优雅的工具包,用于构建紧密的、异步的对象方法接口。

通过这个示例演示如何将 EventEmitter 对象设置为函数构造函数的原型。由于每个构造实例现在都将 EventEmitter 对象暴露给其原型链,this 提供了对事件应用程序编程接口API)的自然引用。因此,counter 实例方法可以发出事件,并且可以监听这些事件。在这里,每当调用 counter.increment 方法时,我们都会发出最新的计数,并将回调绑定到“incremented”事件,该事件简单地打印当前计数器的值到命令行:

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

var Counter = function(init) {
  this.increment = function() {
    init++;
    this.emit('incremented', init);
  }
}

util.inherits(Counter, EventEmitter);

var counter = new Counter(10);

var callback = function(count) {
  console.log(count);
}
counter.addListener('incremented', callback);

counter.increment(); // 11
counter.increment(); // 12

小贴士

下载示例代码

您可以从您在www.packtpub.com的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

要移除绑定到counter的事件监听器,请使用counter.removeListener('incremented', callback)

EventEmitter作为一个可扩展的对象,增加了 JavaScript 的表达能力。例如,它允许以事件驱动的方式处理 I/O 数据流,符合 Node 的异步、非阻塞编程原则:

var stream = require('stream');
var Readable = stream.Readable;
var util = require('util');

var Reader = function() {
  Readable.call(this);
  this.counter = 0;
}

util.inherits(Reader, Readable);

Reader.prototype._read = function() {
  if(++this.counter > 10) {
    return this.push(null);
  }
  this.push(this.counter.toString());
};

// When a #data event occurs, display the chunk.
//
var reader = new Reader();
reader.setEncoding('utf8');
reader.on('data', function(chunk) {
  console.log(chunk);
});
reader.on('end', function() {
  console.log('--finished--');
});

在这个程序中,我们有一个Readable流推送一系列数字——在该流的 data 事件上设置监听器,捕获发出的数字并记录它们——并在流结束时发送一条消息。很明显,监听器对每个数字只调用一次,这意味着运行这个集合并没有阻塞事件循环。因为 Node 的事件循环只需要承诺资源来处理回调,所以在每个事件的下一次空闲时间可以处理许多其他指令。

事件循环

在非网络软件中看到的代码通常是同步的或阻塞的。以下伪代码中的 I/O 操作也是阻塞的:

variable = produceAValue()
print variable
// some value is output when #produceAValue is finished.

以下迭代器将一次读取一个文件,输出其内容,然后读取下一个文件,直到完成:

fileNames = ['a','b','c']
while(filename = fileNames.shift()) {
  fileContents = File.read(filename)
  print fileContents
}
//	> a
//	> b
//	> c

这是一个适用于许多情况的优秀模型。然而,如果这些文件非常大呢?如果每个文件都需要 1 秒钟来获取,那么所有文件都需要 3 秒钟来获取。对一个文件的检索总是等待另一个检索完成,这既低效又慢。使用 Node,我们可以同时启动所有文件的读取操作:

var fs = require('fs');
var fileNames = ['a','b','c'];
fileNames.forEach(function(filename) {
  fs.readFile(filename, {encoding:'utf8'}, function(err, content) {
    console.log(content);
  });
});
//	> b
//	> a
//	> c

Node 版本将一次性读取所有三个文件,每次调用fs.readFile都会在未来的某个未知时刻返回其结果。这就是为什么我们无法总是期望文件按它们被数组化的顺序返回。我们可以期望所有三个文件在大约一个文件被检索所需的时间内返回——这比 3 秒要少。我们为了速度而牺牲了可预测的执行顺序,并且,就像线程一样,在并发环境中实现同步需要额外的工作。我们如何管理和描述不可预测的数据事件,以便我们的代码既易于理解高效?

Node 的设计者做出的关键设计选择是实现事件循环作为并发管理器。以下关于事件驱动编程的描述(摘自www.princeton.edu/~achaney/tmve/wiki100k/docs/Event-driven_programming.html)不仅清楚地描述了事件驱动范式,而且还介绍了 Node 中事件的处理方式以及 JavaScript 为何是这种范式的理想语言:

"在计算机编程中,事件驱动编程或基于事件的编程是一种编程范式,其中程序的流程由事件决定——即传感器输出或用户操作(鼠标点击、按键)或其他程序或线程的消息。"

事件驱动编程也可以被定义为一种应用程序架构技术,其中应用程序有一个主循环,该循环被明确地分为两个部分:第一部分是事件选择(或事件检测),第二部分是事件处理 [...]

事件驱动程序可以用任何语言编写,尽管在提供高级抽象的语言中这项任务更容易完成,例如闭包。"

正如前述引用中看到的,单线程执行环境会阻塞,因此可能会运行缓慢。V8 为 JavaScript 程序提供单个执行线程。

如何使这个单线程更高效?

Node 通过将许多阻塞操作委托给操作系统子系统来处理,仅在可用数据时才打扰主 V8 线程,从而使单个线程更高效。主线程(你正在执行的 Node 程序)通过传递回调来对某些数据(例如通过fs.readFile)表示关注,并在数据可用时得到通知。在此数据到达之前,不会对 V8 的主 JavaScript 线程造成进一步负担。如何做到?Node 将 I/O 工作委托给libuv,如nikhilm.github.io/uvbook/basics.html#event-loops中引用的那样:

"在事件驱动编程中,应用程序对某些事件表示关注,并在它们发生时做出响应。从操作系统或监控其他事件源收集事件的责任由 libuv 处理,用户可以注册回调,以便在事件发生时调用。"

前述引用中的用户是执行 JavaScript 程序的 Node 进程。回调是 JavaScript 函数,Node 通过事件循环管理回调的调用,以管理用户的回调调用。Node 管理由 libuv 填充的 I/O 请求队列,libuv 负责轮询操作系统以获取 I/O 数据事件,并将结果传递给 JavaScript 回调。

考虑以下代码:

var fs = require('fs');
fs.readFile('foo.js', {encoding:'utf8'}, function(err, fileContents) {
  console.log('Then the contents are available', fileContents);
});
console.log('This happens first');

本程序将产生以下输出:

> This happens first
> Then the contents are available, [file contents shown]

当 Node 执行此程序时,它会做以下操作:

  • Node 加载fs模块。这提供了对fs.binding的访问,它是在src/node.cc中定义的静态类型映射,它提供了 C++和 JS 代码之间的粘合剂。(groups.google.com/forum/#!msg/nodejs/R5fDzBr0eEk/lrCKaJX_6vIJ)。

  • fs.readFile方法接收指令和 JavaScript 回调。通过fs.binding,libuv 被通知文件读取请求,并传递由原始程序发送的特别准备好的回调版本。

  • libuv 在其自己的线程池中调用必要的操作系统级函数来读取文件。

  • JavaScript 程序继续,打印This happens first。因为有一个未解决的回调,事件循环继续旋转,等待该回调解决。

  • 当文件描述符被操作系统完全读取后,libuv(通过内部机制)会得到通知,并将回调传递给 libuv,这实际上是为原始 JavaScript 回调准备重新进入主(V8)线程。

  • 原始 JavaScript 回调被推送到事件循环队列,并在循环的下一个 tick 上调用。

  • 文件内容被打印到控制台。

  • 由于飞行过程中没有进一步的回调,进程退出。

在这里,我们看到 Node 实现快速、可管理和可扩展 I/O 的关键思想。例如,如果在前面的程序中对'foo.js'进行了 10 次读取调用,执行时间仍然大致相同。每次调用都会在 libuv 线程池中的自己的线程中并行执行。尽管我们用“JavaScript”编写了代码,但实际上我们部署了一个非常高效的多线程执行引擎,同时避免了线程管理的困难。

让我们以更多细节结束,具体说明 libuv 结果是如何返回到主线程的事件循环中的。

当套接字或其他流接口上有数据可用时,我们不能立即执行我们的回调。JavaScript 是单线程的,所以结果必须同步。我们无法在事件循环的 tick 过程中突然改变状态——这会创建一些经典的多线程应用程序问题,如竞态条件、内存访问冲突等。

当进入事件循环时,Node(实际上)复制了当前的指令队列(也称为),清空了原始队列,并执行其副本。处理这个指令队列的过程被称为tick。如果在开始这个 tick 时复制的指令链在单个主线程(V8)上异步接收结果(作为回调包装),这些结果(包装为回调)将被排队。一旦当前队列被清空,其最后一条指令完成,队列再次检查是否有指令要在下一个 tick 上执行。这种检查和执行队列的模式将重复(循环)直到队列为空,并且不再期望有进一步的数据事件,此时 Node 进程退出。

注意

github.com/joyent/node/issues/5798上,一些核心 Node 开发者关于process.nextTicksetImmediate实现的讨论提供了关于事件循环如何操作的非常精确的信息。

以下是一些被喂入队列的 I/O 事件:

  • 执行块:这些是 Node 程序的 JavaScript 代码块;它们可以是表达式、循环、函数等。这包括在当前执行上下文中发出的EventEmitter事件。

  • 定时器: 这些是在未来某个时间点(以毫秒为单位)延迟执行的回调函数,例如setTimeoutsetInterval

  • I/O: 这些是在委托给 Node 的管理线程池(如文件系统调用和网络监听器)后返回主线程的准备好的回调函数。

  • 延迟执行块: 这些主要是根据setImmediateprocess.nextTick的规则在堆栈上定位的函数。

有两件重要的事情需要记住:

  • 你不能启动和/或停止事件循环。事件循环在进程启动时开始,在没有任何进一步回调需要执行时结束。因此,事件循环可能永远运行。

  • 事件循环在单个线程上执行,但将 I/O 操作委托给 libuv,它管理一个线程池,并行化这些操作,并在结果可用时通知事件循环。通过多线程的效率强化了一个易于理解的单一线程编程模型。

要了解更多关于 Node 如何绑定到 libuv 和其他核心库的信息,请解析fs模块代码github.com/joyent/node/blob/master/lib/fs.js。比较fs.readfs.readSync方法,以观察同步和异步操作实现的差异——注意传递给fs.read中本地binding.read方法的wrapper回调。

要更深入地了解 Node 的设计核心,包括队列实现,请阅读 Node 源代码github.com/joyent/node/tree/master/src。在fs_event_wrap.ccnode.cc中的MakeCallback跟踪,调查req_wrap类,它是 V8 引擎的包装器,在node_file.cc和其他地方部署,并在req_wrap.h中定义。

Node 的设计对系统架构师的影响

Node 是一种新技术。在撰写本文时,它尚未达到 1.0 版本。已经发现并修复了安全漏洞。已经发现并修复了内存泄漏。在本章开头提到的 Eran Hammer 以及他在沃尔玛实验室的整个团队积极地为 Node 代码库做出贡献——特别是在他们发现漏洞时!许多其他致力于 Node 的大型公司也是如此,例如 PayPal。

如果你选择了 Node,并且你的应用程序已经增长到这样一个规模,以至于你觉得你需要阅读一本关于如何部署 Node 的书,那么你不仅有从社区中受益的机会,也许还能在实际上基于你的特定需求设计环境的一些方面。Node 是开源的,你可以提交 pull 请求。

除了事件之外,还有两个关键的设计方面,如果你打算进行高级 Node 工作,那么理解它们是很重要的:用小部分构建你的系统,并在它们之间传输数据时使用事件流。

从小系统构建大型系统

在他的书《Unix 编程艺术》中,埃里克·雷蒙德提出了模块化法则

"开发者应该用简单部分构建程序,这些部分通过定义良好的接口连接,这样问题就是局部的,程序的某些部分可以在未来的版本中替换,以支持新功能。这个规则旨在节省调试复杂代码的时间,这些代码复杂、长且难以阅读。"

这种从“小部件,松散连接”构建复杂系统的想法在管理理论、政府理论、制造业和其他许多环境中都可以看到。在软件开发方面,它建议开发者在更大的系统中只贡献最简单、最有用的组件。大型系统难以推理,特别是如果它们的组件边界模糊的话。

在构建可扩展的 JavaScript 程序时,主要困难之一是缺乏一个标准接口来将许多较小的程序组合成一个连贯的程序。例如,一个典型的 Web 应用程序可能会使用一系列的<script>标签在超文本标记语言HTML)文档的<head>部分加载依赖项:

<head>
  <script src="img/fileA.js"></script>
  <script src="img/fileB.js"></script>
</head>

这种类型的系统存在许多问题:

  • 所有潜在的依赖项必须在需要之前声明——动态包含需要复杂的技巧

  • 引入的脚本没有被强制封装——没有任何东西阻止两个文件写入同一个全局对象。命名空间可以轻易冲突,这使得任意注入变得危险。

  • fileA不能将fileB作为一个集合来引用——没有可寻址的上下文,例如fileB.method是不可用的。

  • <script>方法本身并不系统,这阻碍了设计有用的模块服务,如依赖项意识和版本控制。

  • 脚本不能轻易地移除或覆盖。

  • 由于这些危险和困难,共享并非易事,从而减少了在开放生态系统中协作的机会。

在应用程序中随意插入不可预测的代码片段会挫败预测性地塑造功能性的尝试。需要的是一种标准的方式来加载和共享离散的程序模块。

因此,Node 引入了的概念,遵循 CommonJS 规范。包是一组程序文件,与描述集合的清单文件捆绑在一起。依赖项、作者、目的、结构和其他重要元数据以标准方式公开。这鼓励从许多小型、相互依赖的系统构建大型系统。也许更重要的是,它鼓励共享:

*"我在这里描述的不是技术问题。这是人们聚在一起,做出决定,向前迈出一步,一起开始构建更大、更酷的东西的问题。"
--CommonJS 的创造者凯文·丹古尔

在许多方面,Node 的成功归功于开发者社区中通过 Node 的包管理系统npm分发的包的数量和质量的增长。这个系统在很大程度上帮助 JavaScript 成为系统编程的一个可行且专业的选择。

注意

对于 Node 新用户来说,可以在以下链接找到对 npm 的良好介绍:www.npmjs.org/doc/developers.html

在他的著作《C++编程语言,第三版》中,Bjarne Stroustrup 表示:

"为编程语言设计并实现一个通用的输入/输出设施是出了名的困难。[...] 输入/输出设施应该易于使用、方便且安全;高效且灵活;最重要的是,完整。"

一个专注于提供高效和简单 I/O 的设计团队通过 Node 提供这样的功能,这并不会让任何人感到惊讶。通过一个对称且简单的接口,该接口处理数据缓冲区和流事件,使得实现者无需自己处理,Node 的Stream模块是管理异步数据流的优选方式,对于内部模块,以及开发者可能创建的模块来说,也是如此。

注意

可以在github.com/substack/stream-handbook找到关于Stream模块的优秀教程。此外,Node 文档在nodejs.org/api/stream.html上非常全面。

在 Node 中,流简单地说是一系列字节,或者如果你愿意,是一系列字符。在任何时候,流都包含一个字节缓冲区,这个缓冲区的长度为零或更多。

由于流中的每个字符都有明确的定义,并且每种数字数据都可以用字节表示,因此流的任何部分都可以重定向,或管道化到任何其他流,流的不同部分可以发送到不同的处理器。这样,流输入和输出接口既灵活又可预测,并且可以轻松耦合。

除了事件之外,Node 以其对流的全面使用而独特。继续将应用程序组合成许多小进程,这些进程发出事件或对事件做出反应的想法,Node 的几个 I/O 模块和功能都作为流实现。网络套接字、文件读取器和写入器、stdin 和 stdout、Zlib 等,都是可以通过抽象的Stream接口轻松连接的数据生产者和/或消费者。熟悉 Unix 管道的人会看到一些相似之处。

通过抽象的Stream接口暴露了五个不同的基类:ReadableWritableDuplexTransformPassThrough。每个基类都继承自EventEmitter,这是我们已知的事件监听器和发射器可以绑定到的接口。Node 中的流是事件流,进程间数据传输通常使用流来完成。因为流可以轻松地链式连接和组合,它们是 Node 开发者的重要工具。

建议你在进一步学习之前,对什么是流以及它们在 Node 中的实现有一个清晰的理解,因为我们将在这本书的整个过程中广泛使用流。

使用全栈 JavaScript 发挥最大效用

JavaScript 已经成为一种全栈语言。所有浏览器都存在本地的 JavaScript 运行时。Node 使用的 JavaScript 解释器 V8,是谷歌 Chrome 浏览器背后的同一引擎。而且,该语言已经超越了覆盖软件栈的客户端和服务器层。JavaScript 用于查询 CouchDB 数据库,使用 MongoDB 进行 map/reduce,以及在 ElasticSearch 集合中查找数据。广受欢迎的JavaScript 对象表示法JSON)数据格式只是将数据表示为 JavaScript 对象。

当同一应用程序中使用不同的语言时,上下文切换的成本会增加。如果一个系统由用不同语言描述的部分组成,那么系统架构就变得更加难以描述、理解和扩展。如果系统的不同部分“说”不同的语言,每次跨方言的对话都需要昂贵的翻译。

理解上的低效会导致更高的成本和更脆弱的系统。这个系统的工程团队每个成员都必须精通这些多种语言,或者根据不同的技能组合成小组;工程师的寻找和/或培训成本高昂。当系统的关键部分对除了少数工程师之外的人都变得不透明时,跨团队协作很可能会减少,这使得产品升级和新增功能变得更加困难,并可能导致更多错误。

当这些困难减少或消除时,会打开哪些新的机会?

热代码

因为你的客户端和服务器将使用相同的语言,每个都可以将代码传递给对方以原生方式执行。如果你正在构建一个 Web 应用程序,这会打开非常有趣(且独特)的机会。

例如,考虑一个允许一个客户端对另一个环境进行更改的应用程序。这个工具允许软件开发者更改网站的 JavaScript,并允许他们的客户在浏览器中实时看到这些更改。这个应用程序必须做的事情是将实时代码在许多浏览器中转换,以便它反映更改。完成这一目标的一种方法是将更改集捕获到一个转换函数中,将该函数传递给所有连接的客户端,并在他们的本地环境中执行该函数,以更新它以反映 规范 视图。一个应用程序在演变时,会在 JavaScript 代码中发出一个 遗传 更新,其余的物种也会类似地演变。我们将在 第七章 部署和维护 中使用这种技术。

由于 Node 共享相同的 JavaScript 代码库,Node 服务器可以主动采取此行动。网络本身可以向其客户端广播要执行的代码。同样,客户端也可以向服务器发送代码以执行。很容易看出这如何允许热代码推送,其中 Node 进程向特定客户端发送一个唯一的原始 JavaScript 数据包以执行。

远程过程调用 (RPC) 不再需要中介层来在通信上下文之间进行翻译时,代码可以在网络中的任何地方存在,所需的时间或短或长,并且可以在多个上下文中执行,这些上下文的选择基于负载均衡、数据意识、计算能力、地理精度等因素。

Browserify

JavaScript 是 Node 和浏览器共有的语言。然而,Node 显著扩展了 JavaScript 语言,添加了许多客户端开发者不可用的命令和其他结构。例如,JavaScript 中没有与核心 Node Stream 模块等效的模块。

此外,npm 仓库正在迅速增长,在撰写本文时,包含超过 80,000 个 Node 包。其中许多包在客户端以及 Node 环境中同样有用。JavaScript 传播到服务器实际上创建了两个协作线程,产生了企业级的 JavaScript 库和模块。

Browserify 的开发是为了使共享 npm 模块和核心 Node 模块变得容易,并且可以无缝地与客户端一起使用。一旦一个包被 browserified,它就可以使用标准的 <script> 标签轻松地导入到浏览器环境中。安装 Browserify 非常简单:

npm install -g browserify

让我们构建一个示例。创建一个文件,math.js,就像编写 npm 模块一样:

module.exports = function() {
  this.add = function(a, b) {
    return a + b;
  }
  this.subtract = function(a, b) {
    return a - b;
  }
};

接下来,创建一个程序文件,add.js,它使用此模块:

var Math = require('./math.js');
var math = new Math;

console.log(math.add(1,3)); // 4

使用 Node 在命令行上执行此程序(> node add.js)将在您的终端上打印出 4。如果我们想在浏览器中使用我们的数学模块呢?客户端 JavaScript 没有使用 require 语句,所以我们将其 browserify:

browserify math.js -o bundle.js

Browserify 会遍历您的代码,找到require语句,并将这些依赖(以及这些依赖的依赖)自动打包成一个文件,您可以将这个文件加载到您的客户端应用中:

<script src="img/bundle.js"></script>

作为额外的奖励,这个包会自动将一些有用的 Node 全局变量引入到您的浏览器环境中:__filename__dirnameprocessBufferglobal。这意味着您在浏览器中可以使用例如process.nextTick

注意

Browserify 的创建者 James Halliday 是 Node 社区的活跃贡献者。您可以在github.com/substack访问他。此外,还有一个在线服务可以测试 browserified npm 模块,网址为 http://requirebin.com。完整文档可以在github.com/substack/node-browserify#usage找到。

另一个令人兴奋的项目,就像 Browserify 一样,利用 Node 来增强基于浏览器的 JavaScript 的功能,是Component。作者这样描述它:Component 目前是 ES6 模块和 Web Components 的临时解决方案。当所有现代浏览器开始支持这些功能时,Component 将开始更多地关注语义版本控制和服务器端打包,因为浏览器将能够处理其余部分。该项目仍在不断发展,但值得一观。以下是链接:github.com/componentjs/guide

摘要

在本章中,我们快速浏览了 Node。您了解了一些关于它为什么被设计成这样的原因,以及为什么这种事件驱动环境是解决网络软件现代问题的良好解决方案。在解释了事件循环以及相关的并发和并行性概念之后,我们简要地讨论了 Node 的软件组合哲学,即从小而松散连接的组件构建软件。您了解了全栈 JavaScript 提供的特殊优势,并探索了由此带来的应用新可能性。

您现在对我们将要部署的应用类型有了很好的理解,这种理解将帮助您在构建和维护 Node 应用时看到独特的关注点和考虑因素。在下一章中,我们将直接进入使用 Node 构建服务器、托管这些应用的选项以及构建、打包和分发它们的想法。

第二章。安装和虚拟化 Node 服务器

回想一下来自第一章的故事,欣赏 Node,讲述了沃尔玛如何将所有黑色星期五的移动流量通过 Node 处理,Node 部署在相当于 2 个 CPU 和 30GB RAM的配置上。这证明了 Node 处理 I/O 的效率非常高,即使是黑色星期五的沃尔玛级流量也可以仅用几个服务器来处理。这意味着,对于许多人来说,在单个服务器上运行 Node 应用程序就是他们需要做的全部。

然而,通常最好有多个服务器可供使用,例如冗余服务器以确保故障恢复,一个独立的数据库服务器,专门的媒体服务器,一个托管消息队列的,等等。按照将关注点分离到许多独立进程的想法,基于 Node 的应用程序通常由许多轻量级服务器组成,这些服务器分布在数据中心,甚至可能分布在几个数据中心。

在本章中,我们将具体探讨如何设置单个 Node 服务器,无论是实际还是虚拟的。目标是探索在响应扩展需求时大规模生产服务器的选项,并了解如何将这些服务器连接起来。你将学习如何自己设置 HTTP/S 服务器,以及如何使用 Node 进行隧道和代理。然后,我们将探讨一些流行的云托管解决方案以及如何在那些解决方案上设置 Node 服务器。最后,我们将讨论Docker,这是一种创建轻量级虚拟服务的新兴技术。

启动基本的 Node 服务器

HTTP 是在请求/响应模型上构建的数据传输协议。通常,客户端向服务器发出请求,收到响应,然后再次发出请求,依此类推。HTTP 是无状态的,这仅仅意味着每个请求或响应都不会保留有关先前请求或响应的信息。促进这种快速模式网络通信的是 Node 设计用来擅长的 I/O 类型。虽然 Node 代表了一个更有趣的整个技术栈,但它确实帮助工程师创建网络协议服务器。在本节中,我们将概述如何设置基本的 HTTP 服务器,然后探讨该协议的一些更专业的用途。

Hello world

HTTP 服务器响应连接尝试,并管理到达和发送的数据。Node 服务器通常使用 HTTP 模块的createServer方法创建:

var http = require('http');

var server = http.createServer(function(request, response) {
  console.log('Got Request Headers: ');
  console.log(request.headers);
  response.writeHead(200, {
    'Content-Type': 'text/plain'
  });
  response.write('PONG');
  response.end();
}).listen(8080);

http.createServer返回的对象是http.Server的一个实例,它扩展了EventEmitter,并在事件发生时广播网络事件,例如客户端连接或请求。大多数使用 Node 的服务器实现都使用这种方法进行实例化。然而,通过http.Server实例监听事件广播可以是在 Node 程序中组织服务器/客户端交互的更有效、甚至更自然的方式。

在这里,我们创建了一个基本的服务器,它简单地报告连接建立和终止的情况:

var http = require('http');
var server = new http.Server();

server.on("connection", function(socket) {
  console.log("Client arrived: " + new Date());
  socket.on("end", function() {
    console.log("Client left: " + new Date());
  });
})

server.listen(8080);

在构建多用户系统,尤其是经过身份验证的多用户系统时,服务器-客户端事务的这个点是一个进行客户端验证和跟踪代码的绝佳位置。可以设置和读取 cookies,以及其他会话变量。可以将客户端到达事件广播给其他在实时应用程序中交互的并发客户端。

通过添加请求监听器,我们达到了更常见的请求/响应模式,它被处理为一个Readable流。当客户端发送数据时,我们可以捕获这些数据,如下所示:

server.on("request", function(request, response) {
  request.setEncoding("utf8");
  request.on("readable", function() {
    console.log(request.read())
  });
});

使用curl向这个服务器发送一些数据:

curl http://localhost:8080 -d "Here is some data"

使用连接事件,我们可以很好地将我们的连接处理代码分离,将其分组到清晰定义的功能域中,这些域被正确地描述为响应特定事件而执行。

例如,我们可以在服务器连接上设置定时器。在这里,我们可以在大约 2 秒的窗口内未能发送新数据的客户端连接上进行终止:

server.setTimeout(2000, function(socket) {
  socket.write("Too Slow!", "utf8");
  socket.end();
});

发送 HTTP 请求

HTTP 服务器通常被要求为发起请求的客户端执行 HTTP 服务。最常见的情况是,这种代理服务是在浏览器中运行的受跨域请求限制的 Web 应用 behalf 上进行的。Node 提供了一个简单的接口来执行外部 HTTP 调用。

例如,以下代码将获取google.com的首页:

var http = require('http');

http.request({
  host: 'www.google.com',
  method: 'GET',
  path: "/"
}, function(response) {
  response.setEncoding('utf8');
  response.on('readable', function() {
    console.log(response.read())
  });
}).end();

在这里,我们只是简单地将一个Readable流输出到终端,但这个流可以很容易地被管道传输到一个Writable流,可能是一个文件句柄。请注意,您必须始终使用request.end方法来表示您已完成请求。

小贴士

一个流行的 Node 模块来管理 HTTP 请求是 Mikeal Rogers 的request

github.com/mikeal/request

由于通常使用HTTP.request来获取外部页面,Node 提供了一个快捷方式:

http.get("http://www.google.com/", function(response) {
  console.log("Status: " + response.statusCode);
}).on('error', function(err) {
  console.log("Error: " + err.message);
});

现在我们来看一些更高级的 HTTP 服务器实现,在这些实现中,我们为客户端执行通用网络服务。

代理和隧道

有时,提供一个让一个服务器作为其他服务器的代理或经纪人的手段是有用的。这将允许一个服务器将请求分发到其他服务器,例如。另一个用途是提供给无法直接连接到该服务器的用户访问受保护服务器的权限——这在限制互联网访问的国家很常见。通常,一个服务器通过代理回答多个 URL;那个服务器可以将请求转发给正确的接收者。

由于 Node 具有一致的网络接口,这些接口作为事件流实现,我们可以用几行代码就构建一个简单的 HTTP 代理。例如,以下程序将在端口8080上设置一个 HTTP 服务器,该服务器将对任何请求做出响应,通过获取谷歌的首页并将其管道传输回客户端:

var http = require('http');
var server = new http.Server();

server.on("request", function(request, socket) {
  http.request({
    host: 'www.google.com',
    method: 'GET',
    path: "/",
    port: 80
  }, function(response) {
    response.pipe(socket);
  }).end();
});

server.listen(8080);

一旦这个服务器收到客户端套接字,它就可以自由地将任何可读流的内容推回到客户端。在这里,对 www.google.com 的 GET 请求的结果就是这样流式传输的。人们可以很容易地看到,一个管理着应用程序缓存层的远程内容服务器可能成为一个代理端点。

使用类似的想法,我们可以创建一个使用 Node 的原生 CONNECT 支持的隧道服务:

var http = require('http');
var net = require('net');
var url = require('url');
var proxy = new http.Server();

proxy.on('connect', function(request, clientSocket, head) {
  var reqData = url.parse('http://' + request.url);
  var remoteSocket = net.connect(reqData.port, reqData.hostname, function() {
    clientSocket.write('HTTP/1.1 200 \r\n\r\n');
    remoteSocket.write(head);

    // The bi-directional tunnel
    remoteSocket.pipe(clientSocket);
    clientSocket.pipe(remoteSocket);
  });
}).listen(8080, function() {

我们已经设置了一个代理服务器,它会对请求 HTTP CONNECT 方法(on("connect"))的客户端做出响应,该方法包含请求对象、网络套接字绑定客户端和服务器以及隧道的 'head'(第一个数据包)。当从客户端收到 CONNECT 请求时,我们解析出 request.url,获取请求的主机信息,并打开请求的网络套接字。通过将远程数据管道到客户端并将客户端数据管道到远程连接,建立了一个双向数据隧道。现在我们只需要向我们的代理发送 CONNECT 请求,如下所示:

  var request = http.request({
    port: 8080,
    hostname: 'localhost',
    method: 'CONNECT',
    path: 'www.google.com:80'
  });
  request.end();

一旦收到我们 CONNECT 请求的状态 200 确认,我们就可以将请求数据包推送到这个隧道中,捕获响应并将这些内容输出到 stdout

  request.on('connect', function(res, socket, head) {
    socket.setEncoding("utf8");
    socket.write('GET / HTTP/1.1\r\nHost: www.google.com:80\r\nConnection: close\r\n\r\n');
    socket.on('readable', function() {
      console.log(socket.read());
    });
    socket.on('end', function() {
      proxy.close();
    });
  });
});

HTTPS、TLS(SSL)和保障你的服务器安全

Web 应用程序在规模、重要性和复杂性方面都得到了增长。因此,Web 应用程序的安全性已经成为一个重要的话题。由于一个或多个原因,早期的 Web 应用程序被允许进入客户端业务逻辑的实验世界,未加密的密码传输和开放的网络服务,而仅仅是被一层薄薄的帘子所保护。对于关注其信息安全的用户来说,这种情况变得越来越难找到。

由于 Node 通常被部署为 Web 服务器,社区开始对这些服务器的安全性负责是至关重要的。HTTPS 是一种安全的传输协议——本质上,是在 SSL/TLS 协议之上叠加 HTTP 协议的加密 HTTP。让我们学习如何确保我们的 Node 部署的安全性。

为开发创建自签名证书

为了支持 SSL 连接,服务器需要一个正确签名的证书。在开发过程中,简单地创建一个自签名证书会容易得多,这允许我们使用 Node 的 HTTPS 模块。

这些是创建开发证书所需的步骤。请记住,这个过程不会创建真正的证书,生成的证书 不安全——它只是允许我们在终端中从 HTTPS 环境中进行开发:

openssl genrsa -out server-key.pem 2048
openssl req -new -key server-key.pem -out server-csr.pem
openssl x509 -req -in server-csr.pem -signkey server-key.pem -out server-cert.pem

这些密钥现在可以用来开发 HTTPS 服务器。这些文件的内容只需作为选项传递给运行在(默认)SSL 端口 443 上的 Node 服务器:

var https = require('https');
var fs = require('fs');

https.createServer({
  key: fs.readFileSync('server-key.pem'),
  cert: fs.readFileSync('server-cert.pem')
}, function(req,res) {
   ...
}).listen(443)

注意

对于在开发过程中自签名证书不是理想选择的情况,可以从 www.startssl.com/ 获取免费的 低保证 SSL 证书。

安装真实 SSL 证书

为了将安全的应用程序从开发环境迁移到互联网暴露的环境,需要购买真实的证书。这些证书的价格逐年下降,应该很容易找到提供价格合理且安全级别足够的证书的供应商。一些供应商甚至提供免费的个人使用证书。

设置专业证书只需更改我们之前介绍的 HTTPS 选项。不同的供应商会有不同的流程和文件名。通常,您需要从您的供应商那里下载或接收一个私有的 #key 文件,您的签名域名证书 #crt 文件,以及一个描述证书链的通用 #ca 文件包:

var options = {
  key  : fs.readFileSync('mysite.key'),
  cert  : fs.readFileSync('mysite.com.crt'),
  ca  : [ fs.readFileSync('gd_bundle.crt') ]
};

重要的一点是,即使证书包已被连接成一个文件,#ca 参数也必须以 数组 的形式发送。

这里是本节的关键要点:

  • HTTP 套接字被抽象为事件流。这对于 Node 提供的所有网络接口都是正确的。这些流可以轻松地相互连接。

  • 由于流活动是事件驱动的,因此可以记录这些事件。可以在事件处理器中记录关于系统行为的非常精确的日志信息,或者通过将流通过一个可能监听并记录事件的 PassThrough Stream 参数进行管道传输来记录。

  • Node 在 I/O 服务方面表现出色。Node 服务器可以作为调度器,仅关注在客户端与任何数量的远程服务或本地操作系统上运行的专用进程之间进行通信的经纪人。

现在您已经知道了如何在 Node 中设置 HTTP 服务器并处理协议,那么就继续实验吧。在您的本地机器上创建一个小应用程序,允许用户读取 Twitter 流或连接到公共数据 API。习惯通过网络验证远程服务,并通过它们的 API 或作为它们数据的代理与之交互。习惯通过使用 Node 作为经纪人,通过集成远程网络服务来构建网络应用程序。

在生产环境中运行自己的服务器可能既昂贵又耗时,尤其是如果您不熟悉系统管理。因此,许多云托管公司应运而生,其中许多是为 Node 开发者专门设计的。

让我们来看看其中的一些。为了比较,相同的 Node 应用程序将部署在每个平台上——一个存储在 MongoDB 中的可编辑 JSON 文档,绑定到一个简单的基于浏览器的 用户界面UI)。鼓励您按顺序尝试这些服务,尽管这不是必需的。

在 Heroku 上安装应用程序

Heroku 是一个成熟的 PaaS 云托管解决方案,支持 Node 应用程序的开发。要开始,请访问www.heroku.com并提交一个电子邮件地址。Heroku 开始使用是免费的。在您确认了账户后,您就可以立即开始部署应用程序了。

扩展 Heroku 应用程序涉及增加您支付的费用中的dynos数量。每个dyno都是一个运行您应用程序的独立容器,您可以轻松地增加或减少应用程序使用的 dynos 数量。这样,您不需要购买任何托管套餐——您只需根据需要请求更多或更少的 dynos 即可进行扩展。

Heroku 允许您在许多平台和语言上部署应用程序——它不是以 Node 为中心的。如果您预计需要将非 Node 编写的服务添加到您的应用程序中,请记住这一点。

要控制 Heroku 远程实例,您将使用一个本地的工具包应用程序。一旦您加入 Heroku 并确认了您的注册,登录并转到仪表板上的应用程序部分。那里应该有关于安装 Heroku Toolbelt 的说明(toolbelt.heroku.com/)。

注意

heroku命令行客户端将被安装在/usr/local/heroku,并且/usr/local/heroku/bin将被添加到您的路径中。

一旦安装了 Toolbelt,打开终端并使用heroku login登录 Heroku。由于这是您第一次登录,您很可能会被要求生成一个公钥。一旦这个密钥生成并上传,您就安全了,并且从现在开始,您可以通过 Toolbelt 和命令行来管理您的 Heroku 部署。

如果 Heroku 在您的应用程序文件夹的根目录中找到一个package.json文件,它将识别您的应用程序为 Node 应用程序。我们的示例应用程序已经包含了一个,因此不需要再创建一个。然而,由于 Heroku 不是 Node 的专用主机,它不会自动在包文件的start属性中找到我们的-- server.js --应用程序的启动脚本:

"scripts": {
  "start": "node server.js"
}

相反,Heroku 需要所谓的Procfile。在我们的示例应用程序的根目录中创建一个Procfile文件,并将以下文本插入其中:

web: node server.js

它略有不同,但我们可以看出最终效果是相同的。Procfile 声明我们想要一个"web"进程——在执行node server.js命令后启动的进程将期望有 HTTP 流量路由到它。

注意

当您安装 Heroku Toolbelt 时,还会安装另一个应用程序:Foreman。Foreman 帮助您管理基于 Procfile 的应用程序。对我们来说,它最重要的作用是允许您在本地启动 Heroku 应用程序。虽然您可以直接通过 Node 更新 Node 包的scripts属性并直接运行应用程序,但这确实节省了一步。尝试foreman start并访问localhost:8080

在以下章节中,我们将探讨如何在 Heroku 上安装和管理存储库,以及如何将 MongoDB 添加到我们的应用程序中,并且我们将部署一个 JSON 编辑应用程序。

添加组件

在 Heroku 上,数据库被视为许多附加组件之一。从日志工具到缓存层,再到数据库,Heroku 提供了数十种附加组件。由于我们需要一个 MongoDB 实例来运行我们的应用程序,让我们安装一个。

注意,虽然来自 MongoLab 的开发者(沙盒)MongoDB 实例是免费的,但 Heroku 需要您使用信用卡验证您的账户。如果您没有信用卡,仍然可以通过其他服务获得免费的 MongoDB 云账户,并使用这些凭据为您的 Heroku 应用程序提供服务。最后,我们只需要一个可以连接的 MongoDB 端点。

要添加 MongoDB 账户,请运行 heroku addons:add mongolab 命令:

Adding mongolab on mighty-hamlet-7855... done, v14 (free)
Welcome to MongoLab. Your new subscription is being created and will be available shortly. Please consult the MongoLab Add-on Admin UI to check on its progress.

使用 heroku addons:docs mongolab 在浏览器中查看文档。

您刚刚为您的 Heroku 实例添加了一个配置选项。不出所料,您可以通过 heroku config 查看此信息,它将返回类似以下内容:

MONGOLAB_URI: mongodb://heroku_app2485743:ie02k3nnic3l0tjfgi3135inq@ds035488.mongolab.com:35488/heroku_app2487483

在数据库建立之后,现在让我们将我们的应用程序推送到 Heroku 并使其运行。

Git

在 Heroku 上部署应用程序涉及将您的本地版本推送到您刚刚配置的远程应用程序仓库。没有 heroku deploy 命令;您所做的是推送到 Git,从而在 Heroku 端触发 post-receive 钩子。这些钩子会部署您的应用程序。

注意

如果您不熟悉 Git,请访问 git-scm.com/book/en/Getting-Started-Git-Basics

让我们试试。在您的代码包中,存在一个 json-editor 文件夹。首先,进入该文件夹,并在 server.js 中更新 MongoDB 连接和身份验证代码,以便我们可以使用之前定义的数据库连接:

var mongodb = require('mongodb');
var db = new mongodb.Db('your_db_identifier',
  new mongodb.Server('dt019963.mongolab.com', 29960, {})
);
db.open(function (err, db_p) {
  if (err) { throw err; }
  db.authenticate('your_username', '6i490i5d3teoen62524vqkccgu', function (err, replies) {
    // You are now connected and authenticated.
  });
});

接下来,在您的终端中运行以下命令:

git init
git add .
git commit -m "initial commit"

这将初始化我们的应用程序为一个合适的 Git 仓库。现在,我们需要通知 Heroku 我们的新应用程序和新的 Git 仓库。让我们部署。

在您的代码包的 json-editor 文件夹中,使用 Heroku Toolbelt 创建您的第一个 Heroku 应用程序:

heroku create

如果一切顺利,您应该在终端中看到类似以下内容:

Creating mighty-hamlet-7855... done, stack is cedar
http://mighty-hamlet-7855.herokuapp.com/ | git@heroku.com:mighty-hamlet-7855.git
Git remote heroku added

如果你立即访问该 URL,你会收到一个错误消息。因为我们还没有推送我们的存储库,所以没有部署任何内容,这意味着没有可以展示的内容。要将应用程序部署到 Heroku,请推送您的本地 Git 仓库:

git push heroku master

这应该会产生大量的构建输出,清楚地告知您正在发生的事情:

-----> Node.js app detected
-----> Requested node range: 0.10.x
...
-----> Building runtime environment
-----> Discovering process types
 Procfile declares types -> web
...
-----> Launching... done, v3
 http://mighty-hamlet-7855.herokuapp.com/ deployed to Heroku

因此,将应用程序部署到 Heroku 自然地将实际的容器部署与通过 Git 进行应用程序版本管理结合起来。更重要的是,将您的 Git 仓库上的更改推送到 Heroku 将自动更新运行中的应用程序,允许“热”代码刷新。在某些情况下,能够持续部署您的应用程序可能非常有用,正如我们将在后面的章节中看到的。

在我们开始之前,请注意,你部署的应用程序的 URL 没有端口号。Heroku 会自动分配一个端口号,通过该端口号 Web 进程与应用程序通信——这不在我们的控制范围内。然而,它通过process.env.PORT提供给 Node 进程。因此,你需要将server.js中的}).listen(8081);行更改为}).listen(process.env.PORT || 8081);

我们现在可以启动我们的应用程序了。记住,我们正在部署一个基于 Procfile 的应用程序——进程被定义为某种类型。在我们的例子中,这种类型是“web”。我们还需要为我们的部署分配 dynos——我们需要从 Heroku 中请求一个进程来运行我们的应用程序。启动此类应用程序的命令如下:

heroku ps:scale web=1

这告诉 Heroku 给我们一个(1)个web类型的 dyno(也称为进程)。你也可以根据需要请求两个或更多。

运行那个命令。你应该会看到以下类似的内容:

Scaling dynos... done, now running web at 1:1X.

这告诉我们一切运行正常,我们有一个1个大小为1x的 dyno 正在处理我们的应用程序。你可以使用heroku ps命令来检查你的进程是否正在运行:

=== web (1X): `node server.js`
web.1: up 2014/04/04 17:40:34 (~ 27m ago)

我们的应用程序正在运行!访问你之前收到的 Heroku URL。你应该会看到一个 JSON 编辑器和我们的 MongoDB 文档:

Git

这是一个 JSON 编辑器,正在读取我们服务器上创建的 MongoDB 文档。它除了让你更改for属性的值之外,没有做太多。如果你查看index.html中的 JavaScript 代码,你会看到我们已经将客户端结构化为在文档中的值更改时通过/update路径向服务器发送更新:

var editor = new jsoneditor.JSONEditor(container, {
  change : function() {
    var json = editor.get();

    var xhr = new XMLHttpRequest();
    xhr.open('POST', '/update', true);
    xhr.onload = function () {
      console.log("POST RESPONSE: ", this.responseText);
    };
    xhr.send('data=' + JSON.stringify(json));
  },
  mode : "form"
});

尝试一下。使用编辑器将Deploying NodeJS更改为其他内容。如果你打开浏览器控制台,你应该会在更改此值时看到POST 响应:OK。在做出更改后,重新加载浏览器。你会看到新值——你做出的更改正在通过我们的 Heroku 实例持久化到 MongoDB。

管理配置变量

对于应用程序的某些方面可以进行配置是很正常的。例如,用于生产的部署的应用程序可能配置得与在开发环境中构建的应用程序不同。此外,认证凭证(例如我们用于 MongoDB 连接的凭证)将包含在环境变量中。

由于许多配置变量是敏感的,将它们包含在应用程序仓库或公开文件中是一个坏主意。如何以安全的方式在多个进程之间共享变量?一个解决方案是在通过命令行启动 Node 进程时传递环境变量。如果我们想通知 Node 进程它应该作为一个生产服务器执行,例如,我们可以这样做:

NODE_ENV=PRODUCTION node myprogram.js

在那个脚本中,我们可以通过process.env访问值:

console.log(process.env.NODE_ENV);
// production

虽然以这种方式传递配置变量在隐私方面非常有效,但每次为每个进程重复此操作可能会很繁琐,尤其是如果有许多变量。

Heroku 提供了一个界面来帮助管理环境变量。如果你登录到你的 Heroku 实例并访问设置部分,你会看到如下内容:

管理配置变量

这些环境变量将在应用程序启动和/或重启时自动传递给应用程序。使用编辑按钮,你可以添加或删除额外的设置。

管理你的部署

如果你的应用程序因任何原因崩溃,heroku ps将会显示这一点。你也可以通过heroku logs访问你的进程日志。就像当你启动你的进程一样,停止你的进程涉及到将 dynos 的规模缩小到零:

heroku ps:scale web=0

Heroku 允许你非常精确地扩展和配置你的进程,扩展到多个 dynos,添加各种工作者,并改变 dynos 本身的大小。在我们的示例中,我们使用基本的1x dyno,它具有最小的内存和计算能力,并且是最便宜的。更多信息,请访问devcenter.heroku.com/articles/dyno-sizedevcenter.heroku.com/articles/process-model

有时,你可能会提交一个错误的变化或想要重新部署之前的版本。不用担心!Toolbelt 允许你管理你的版本。

要列出版本,使用heroku releases

v11 Deploy 310fe56  nataxia@gmail.com 2014/05/04 18:19:45 (~ 6m ago)
v10 Deploy a0c6005  nataxia@gmail.com 2014/05/04 18:15:17 (~ 10m ago)
...

你可以获取有关特定版本的详细信息:

> heroku releases:info v11
=== Release v11
By:   spasquali@gmail.com
Change: Deploy 310fe56
When:  2014/04/04 18:19:45 (~ 8m ago)

通过简单的 Heroku 回滚可以回滚到立即之前的版本。你也可以回滚到特定的版本:

> heroku rollback v11
Rolling back mighty-hamlet-7855... done, v11

就像在推送更改时一样,回滚到的版本将自动“上线”。

小贴士

你可以使用heroku open从命令行直接打开你的应用程序。

在 OpenShift 上安装应用程序

红帽公司,一家企业级 Linux 公司,运营着 OpenShift,这是一个云托管解决方案。OpenShift 提供了多种部署应用程序的选项——通过基于 Web 的界面、通过命令行或通过在线 IDE。由于我们在其他部署示例中使用了命令行,我们将以相同的方式使用 OpenShift。

一旦你加入并确认了你的账户,你需要安装 OpenShift 客户端工具——rhc。为了本节的目的,我将使用 Mac OS X 客户端。无论你选择哪个包,命令集都是相同的:

sudo gem install rhc
gem update rhc

这将安装客户端并将其更新到最新版本。

安装完成后,你需要设置你的 SSH 密钥并通过运行一个rhc设置来与系统进行认证。只需输入你的认证信息,确认密钥的安装,并确认凭证的上传。

然后,您将被要求输入一个命名空间。这将在系统中作为您的标识符,其中还包括形成您部署实例的子域等。

OpenShift 的工作原理是齿轮卡式件

齿轮大致上是有一定计算单元、内存、磁盘、带宽等分配的容器,具有给定的卡式件容量。更大的齿轮性能更好,并且(通常)可以支持更多的卡式件。您可以将您的安装视为一组管理的运行时(卡式件),完全隔离并部署到一个或多个齿轮中。随着您的应用程序需要增长,您将添加齿轮和卡式件。当您添加卡式件时,OpenShift 系统将您的卡式件部署到您的部署中的正确齿轮——某些卡式件只能访问它们自己的齿轮,而其他卡式件可以访问所有齿轮。定价取决于使用的齿轮数量,以及这些齿轮的特性,隐含的卡式件插槽数量。

OpenShift 支持许多类型的开发环境、开源仓库、Web 框架、数据库等等——一个非常丰富的工具生态系统,比我们迄今为止查看的提供商提供的工具要多得多。您甚至可以开发自己的卡式件或使用社区卡式件。

系统使您能够动态地根据齿轮、卡式件或两者同时进行部署扩展。我们将使用的免费层提供三个小型齿轮。

安装 Node 应用程序和 MongoDB

在 OpenShift 生态系统中,Node 不是一个特殊的公民(就像 NodeJitsu 一样)或一组固定的进程类型之一(就像 Heroku 一样)。由于这种齿轮和卡式件概念提供的模块化,创建一个具有访问 MongoDB 实例的示例 Node 应用程序只需一行即可完成:

rhc app create MyApp nodejs-0.10 mongodb-2.4
Application Options
-------------------
Domain:   <your namespace>
Cartridges: nodejs-0.10, mongodb-2.4
Gear Size: default
Scaling:  no

Creating application 'MyApp' ...
...
Your application 'myapp' is now available.

URL:    http://yoursub.rhcloud.com/
SSH to:   5366e4cc500446d15300022d@yoursub.rhcloud.com
Git remote: ssh://5366e4cc500446d15300022d@yoursub.rhcloud.com/~/git/myapp.git/
Cloned to: /json_editor/myapp

如您所见,您的部署配置强大,允许 SSH 访问和 HTTP 访问,并且作为一个 Git 仓库已准备就绪——如果您查看您的 json-editor 文件夹,一个名为 myapp/ 的新文件夹已被创建。请继续访问您的 URL。同时提供了如何使用 Git 的完整说明以及如何通过其他方式访问您的应用程序。

我们现在想用我们自己的 json-editor 应用程序替换这个示例 Node 应用程序。

部署您的应用

当然,我们不希望使用 OpenShift 提供的示例应用程序。而不是重新配置,让我们保持 myapp/ 中的 .git 远程配置,并将以下文件和文件夹复制到我们的 json-editor/ 文件夹中的 myapp 文件夹:

index.html
jsoneditor.css
jsoneditor.js
package.json
server.js
/img

这些将覆盖 OpenShift 创建的任何类似文件,同时保留其他文件。确保您已更改目录到 myapp/,因为我们将从那里开始工作。

正如我们在 Heroku 上安装时做的那样,我们需要在启动 Node 服务器时咨询 process.env 对象。打开 server.js 并转到此行:

}).listen(8081);

现在,将行更改为以下内容:

}).listen(process.env.OPENSHIFT_NODEJS_PORT || 8081, process.env.OPENSHIFT_NODEJS_IP || "127.0.0.1");

我们现在准备部署我们的应用。更新 Git 中的所有本地文件,提交它们,并推送到 OpenShift:

git add .
git commit -m "first"
git push

如果一切顺利,您应该在输出结果的末尾看到以下内容:

remote: Starting MongoDB cartridge
remote: Starting NodeJS cartridge
remote: Starting application 'myapp' ...
remote: -------------------------
remote: Git Post-Receive Result: success
remote: Activation status: success
remote: Deployment completed with status: success

我们可以看到 Node 和 MongoDB 都是卡式(不是特殊进程或附加组件),以及一个成功的 post-receive 钩子将自动部署和激活我们的应用程序(这与我们在部署到 Heroku 时看到的情况类似)。

如果有任何问题,我们可以直接访问我们的部署日志。要通过 SSH 连接到您的应用程序(myapp),请使用rhc工具:

rhc ssh myapp

连接后,使用cd $OPENSHIFT_LOG_DIR跳转到您的日志目录。您应该看到两个日志:

mongodb.log
nodejs.log

这些是标准的 Linux 日志文件,您可以读取或以其他方式操作它们,例如,通过跟踪它们。

您也可以通过rhc跟踪日志:

rhc tail

小贴士

当您远程登录到您的虚拟容器时,您可以通过cd $OPENSHIFT_REPO_DIR跳转到应用程序的根目录。

通过rhc轻松控制您的应用程序。rhc提供了几个命令,通过rhc app <command>可用。以下是一些常用命令:

  • delete: 这将从服务器删除应用程序

  • force-stop: 这将停止所有应用程序进程

  • reload: 这将重新加载应用程序的配置

  • restart: 这将重新启动应用程序

  • show: 这显示了有关应用程序的信息

  • start: 这将启动应用程序

  • stop: 这将停止应用程序

  • tidy: 这将清理日志和tmp目录,并整理服务器上的git仓库

OpenShift 为那些想要对其部署的应用程序有更多控制权的人提供了一个灵活的选项——为高级用户提供的强大工具。

使用 Docker 创建轻量级虚拟容器

来自 Docker 网站的此图像([www.docker.com/](http://www.docker.com/))提供了关于 Docker 团队认为他们的技术如何适应应用程序开发未来的信息和原因:

使用 Docker 创建轻量级虚拟容器

上述图像简要描述了我们现在正在经历的应用程序架构的代际转变,同样可以用来描述 Node 的设计方式和原因。

根据网站上的描述,Docker …是一个开源引擎,它自动化了任何应用程序的部署,作为一个轻量级、便携、自给自足的容器,几乎可以在任何地方运行。一旦你创建了应用程序的 Docker 镜像,该镜像的运行实例可以在毫秒内启动。是的,就是几毫秒。Docker 让你在几秒钟内创建甚至数百个应用程序部署。

Docker 生态系统有三个主要组件。以下是文档中关于组件的一些信息:

  • Docker 容器:Docker 容器就像目录。一个 Docker 容器包含运行应用程序所需的一切。每个容器都是从一个 Docker 镜像创建的。Docker 容器可以运行、启动、停止、移动和删除。每个容器都是一个隔离且安全的应用程序平台。你可以将 Docker 容器视为 Docker 框架的run部分。

  • Docker 镜像:Docker 镜像是一个模板,例如,一个安装了 Apache 和你的 Web 应用的 Ubuntu 操作系统。Docker 容器是从镜像启动的。Docker 提供了一种简单的方式来构建新的镜像或更新现有的镜像。你可以将 Docker 镜像视为 Docker 框架的build部分。

  • Docker 仓库:Docker 仓库存储镜像。这些是公共(或私有)存储库,您可以上传或下载镜像。这些镜像可以是您自己创建的,或者您可以使用其他人之前创建的镜像。Docker 仓库允许您构建简单而强大的开发和部署工作流程。你可以将 Docker 仓库视为 Docker 框架的share部分。

你可以创建在任何数量的隔离容器中运行的应用程序镜像,如果你愿意,还可以与他人共享这些镜像。将 Node 应用程序组合成许多独立进程的概念与 Docker 背后的哲学自然吻合。Docker 容器是沙箱化的,拥有自己的文件系统等,并且在没有你知识的情况下无法在其主机上执行指令。然而,它们可以向其宿主操作系统暴露一个端口。在本章的后面部分,我们将学习如何使用 Node 将许多独立的虚拟容器链接成一个更大的应用程序。

首先,一些 Unix 命令

Docker 是一种新技术,在撰写本文时,它尚未在所有 Unix 版本上可用(尽管团队正在努力在不久的将来实现这一点)。我将安装 CentOS 上的 Docker。Docker 网站(www.docker.io/)定期更新有关如何在您喜欢的 Unix 版本上安装的信息。

了解你的操作系统细节很重要。要找出你的操作系统名称和版本,请使用cat /etc/*-release,它应该返回类似以下内容:

CentOS release 6.5 (Final)

或者你可以尝试cat /proc/version

Linux version 2.6.32-279.14.1.el6.x86_64 (mockbuild@cb79.bsys.dev.centos.org) (gcc version 4.4.6 20120305 (Red Hat 4.4.6-4) (GCC) ) #1 SMP Tue Nov 6 23:43:09 UTC 2012

当你开始创建虚拟机并绑定端口时,有时需要检查你的网络状态。你绝对应该安装一个好的进程查看器,例如HTOP (hisham.hm/htop/),这样你可以快速扫描/搜索你的打开进程列表。

要获取关于你的盒子网络连接的快速统计列表,请使用netstat,它将返回一个类似这样的列表:

首先,一些 Unix

你可以看到端口 8080 绑定到了 Node 进程 31878。你也可以直接查询与端口关联的进程 ID:

> fuser 8080/tcp
8080/tcp:      31878

要获取有关进程的更多信息,请输入 ls -l /proc/31878/exe

lrwxrwxrwx 1 root root 0 Oct 9 2013 /proc/31878/exe -> /root/nvm/v0.10.20/bin/node

要获取端口使用者的更多信息,尝试使用 lsof

> lsof -i :8080
COMMAND  PID USER  FD  TYPE  DEVICE NODE NAME
node  31878 root  10u IPv4 22570201 TCP *:webcache (LISTEN)

紧跟谁在听什么,这将有助于你在阅读本书的过程中。

开始使用 Docker

首先,你需要安装 Docker。所有支持的 Linux 发行版的安装说明可以在 docs.docker.io/installation/ 找到。

一旦安装了 Docker 服务,你需要启动它:

service docker start

然后,停止 Docker 服务:

service docker stop

如果一切正常,这个命令应该会告诉你有关你的 Docker 安装的一些信息:

docker info

Docker 容器运行着你的应用程序镜像。当然,你也可以自己创建这些镜像,但确实存在一个庞大的现有镜像生态系统。让我们创建一个运行 Express 的 Node 服务器镜像。

注意

要搜索 Docker 镜像仓库,请访问 index.docker.io/

首先,我们需要构建一个要运行的应用程序。创建一个文件夹来存放你的应用程序文件。就像所有的 Node 应用程序一样,我们需要创建一个 package.json 文件供 npm 解析:

{
  "name": "docker-example",
  "private": true,
  "version": "0.0.0",
  "description": "Example of running a Node app within a CENTOS container",
  "author": "Sandro Pasquali <spasquali@gmail.com>",
  "dependencies": {
    "express": "4.1.1"
  }
}

接下来,我们需要一个程序来启动 Express HTTP 服务器。创建以下文件,并将其命名为 server.js

var express = require('express');

var port = 8087;

var app = express();
app.get('/', function (req, res) {
  res.send('You just deployed some Node!\n');
});

app.listen(port);
console.log('Running on http://localhost:' + port);

现在,安装并启动你的应用程序:

npm install;
node app.js
// Running on http://localhost:8087

现在,你可以将你的浏览器指向端口 8087 的主机,并看到 You just deployed some Node! 的显示。

现在,我们将探讨如何使用 Docker 将这些文件构建成一个虚拟容器。

创建 Dockerfile

我们的目标是描述应用程序执行的环境,以便 Docker 能够在容器中重现该环境。此外,我们还想将应用程序的源文件添加到这个新虚拟化的环境中。Docker 可以作为一个构建器,按照你提供的指令构建应用程序的镜像。

首先,你应该有一个包含应用程序文件的文件夹。这是你的源代码仓库。在这个仓库中,创建一个 ./src 文件夹。我们很快就会了解到为什么创建这个文件夹。如果这个文件夹是你测试应用程序构建的地方,请删除 node_modules 文件夹。

Dockerfile 是构建应用程序的一系列指令。当然,你可以手动构建 Docker 镜像,但很可能会多次重复这些操作。Dockerfile 描述了一个构建过程。你通常会在 Dockerfile 中声明容器将运行的 Linux 版本以及你可能需要安装的任何操作系统——例如 Node 和 npm。此外,你将指出应用程序源代码的位置:位于之前创建的 ./src 文件夹中。

Dockerfile 始终基于另一个 Docker 镜像构建。通常,您会基于操作系统镜像构建。在这个例子中,我们将使用 CentOS 6.4。我的 Dockerfile 以关于我构建的 Docker 版本和这个镜像将基于哪个镜像构建的注释开始:

# DOCKER-VERSION 0.9.0
FROM  centos:6.4

我们现在已经建立了一个在容器中运行的操作系统。现在,我们将简单地列出设置构建环境的典型 Unix 命令。首先,我们需要 Node 和 npm:

# Enable EPEL for Node.js
RUN   rpm -Uvh http://download.fedoraproject.org/pub/epel/6/i386/epel-release-6-8.noarch.rpm
# Install Node.js and npm
RUN   yum install -y npm

太好了!现在我们的容器知道如何构建 Node 和 npm。现在让我们使用 ADD 指令将我们的应用程序打包到容器中的 ./src 目录:

# Bundle app source
ADD . /src

现在应用程序文件已打包到 ./src,让我们进入该目录并安装应用程序包:

# Install app
RUN cd /src; npm install

我们的应用程序现在已安装。注意,在 app.js 中,我们正在在端口 8087 上公开一个 Express 服务器。容器无法知道这一点,所以我们必须告诉容器在主机系统上设置端口重定向:

EXPOSE 8087

最后,容器被指示启动 Node 应用程序:

CMD ["node", "/src/app.js"]

就这些了。现在,创建一个名为(确切地)Dockerfile的文件,包含前面的指令。我们现在可以使用这个 Dockerfile 来构建一个 Docker 镜像。

构建和运行 Docker 镜像

构建 Docker 镜像的命令是 docker build。Docker 会在当前目录中查找 Dockerfile,并根据其中包含的指令构建一个镜像。由于我们很可能会重用这个镜像,所以给它一个特殊名称是一个好主意。要给镜像命名,请使用 –t 指令,后跟您选择的标签,然后是 Dockerfile 的路径(在这里,是当前目录):

docker build -t docker/example .

当您运行该命令时,您将在终端看到大量输出,因为请求的包正在下载和安装。这可能需要一些时间。幸运的是,Docker 缓存了这些安装——使用此 Dockerfile 或包含相同安装指令的其他 Dockerfile 的下一个构建将会快得多。

如果构建成功,您可以使用 docker images 命令列出镜像,输出可能如下所示:

REPOSITORY   TAG   IMAGE ID   CREATED    VIRTUAL SIZE
docker/example latest d8bb295407f1 20 minutes ago   667.8 MB
centos     6.4  539c0211cd76 2 months ago    300.6 MB

要删除一个镜像,请使用 docker rmi <image id>

我们的应用程序现在已容器化。我们可以使用以下命令运行它:

docker run -p 49001:8087 -d docker/example

–d 指令指示 Docker 以分离模式运行此镜像——在后台运行。49001:8087 这一部分是必要的,因为它将容器内 Express 服务器监听的 虚拟 端口(8087)映射到主机机器上的实际端口。

打开您的浏览器,将其指向主机机的端口 49001。您应该会看到 您刚刚部署了一些 Node!显示。我们之前创建的 Node 应用程序现在正在容器中运行。

为了演示 Docker 的作用,执行之前给出的相同 run 指令,但更改端口映射到类似 49002:8087 的值。通过更改端口,在另一个浏览器窗口中打开您的应用程序。现在,您有两个相同的应用程序副本在同一主机上以隔离的容器中运行。

注意

更多关于运行指令的详细信息可以在 docs.docker.io/reference/run/ 找到。

要了解更多关于端口重定向的信息,请访问 docs.docker.io/use/port_redirection/#port-redirection

你将想要能够检查正在运行的 Docker 实例。执行此操作的命令是 docker ps,它将显示类似以下的信息:

构建和运行 Docker 镜像

在这里,我们可以看到我们正在运行的两个容器,包括它们正在运行的信息以及它们的映射方式。要停止一个正在运行的容器,请使用 docker stop <容器 ID>。你可以使用 docker start <容器 ID> 来重新启动一个已停止的容器,或者当然,启动一个新的容器。这意味着停止一个容器并不会销毁它。要销毁容器,请使用 docker rm <容器 ID>

小贴士

要获取 Docker 命令的完整列表,只需在终端中输入 docker

摘要

在本章中,你学习了如何在本地和 云中 创建 Node 服务器和应用程序。通过使用 Node 和 MongoDB 在三个不同的 PaaS 提供商上部署了一个简单的文档编辑应用程序,你对寻求扩展其应用程序的 Node 开发者可用的资源有了初步的了解。你被介绍到了 Docker,它提供了一种强大的新容器化技术,使我们能够制作许多便宜的应用程序副本;只要有 Linux,就有 Docker 的部署目标。

在下一章中,我们将通过更详细地探讨 Node 可以如何垂直和水平扩展——跨核心和跨多台机器,将这些关于扩展的简单想法推向更远和更深。

第三章。扩展 Node

并发并行性一样,可伸缩性性能不是同一回事。

"性能"和"可伸缩性"这两个术语通常被互换使用,但两者是不同的:性能衡量单个请求可以执行的速率,而可伸缩性衡量请求在负载增加的情况下维持其性能的能力。例如,一个请求的性能可能报告为在 3 秒内生成有效的响应,但请求的可伸缩性衡量的是请求在用户负载增加时维持该 3 秒响应时间的能力。"
--《Pro Java EE 5, Steve Haines》

审查员声称 Node 无法跨核心扩展,因此无法在特定机器上优化性能,这种情况并不少见。这种信念基于两个错误的印象——Node"不擅长"CPU 密集型任务,以及它不能扩展,因为它的进程只能利用单个核心。这些说法通常进一步扩展到关于 Node 声称的非阻塞性是错误的断言,主要是通过想象锁定的线程和未充分利用的硬件。

可伸缩的应用程序在增加的负载下保持响应。可伸缩的应用程序意味着可以根据客户端连接和资源需求(如更多内存或存储空间)的变化,向系统添加更多节点或从系统中移除节点。Node 旨在使概念化、描述和实现可伸缩网络应用程序变得容易。主要重点是创建一个工具包,用于构建由通过事件驱动的网络流连接并通过标准协议通信的多个节点组成的结构。分布式系统更关注故障而不是性能,出现的问题是:我们如何在运行系统中智能地交换、添加和移除节点?

注意

解决C10K 问题,即优化网络套接字以同时处理大量客户端的问题en.wikipedia.org/wiki/C10k_problem),是许多现代应用程序工具和环境的关键设计目标,包括 Node。

我们将探讨两种常见的扩展策略——垂直扩展和水平扩展。垂直扩展(向上扩展)涉及增加单个服务器处理增加负载的能力,通常是通过在单个盒子上增加 CPU、内存、存储空间等。水平扩展系统(向外扩展)通过添加或减少服务器或其他网络资源来响应负载。通过利用这两种技术中的任何一种或同时使用它们,可以部署可伸缩的 Node 解决方案。

在多个核心上垂直扩展

正如我们在第一章中讨论的,欣赏 Nodelibuv在 Node 环境中用于管理多个 I/O 线程。操作系统本身也会调度线程,分配各种进程所需的工作。Node 提供了一种方式,让开发者可以通过创建和派生许多进程来利用这种操作系统级别的调度。在本节中,我们将学习如何将程序的任务分配给独立进程,以及如何将 Node 服务器的负载分配给多个协作服务器进程。

现代软件开发不再是单体程序的地盘。现代应用程序是分布式和松耦合的。我们现在构建的应用程序将用户与分布在整个互联网上的资源连接起来。许多用户同时访问共享资源。如果一个复杂系统被理解为一个解决一个或几个明确定义、相关问题的程序接口集合,那么它就更容易理解。在这样的系统中,预期(并且是所希望的)进程不应该处于空闲状态。

虽然单个 Node 进程在单个核心上运行,但可以通过使用child_process模块“启动”任意数量的 Node 进程。此模块的基本用法很简单:我们获取一个ChildProcess对象并监听数据事件。本例将调用 Unix 命令ls,列出当前目录:

var spawn = require('child_process').spawn;
var ls  = spawn('ls', ['-lh', '.']);
ls.stdout.on('readable', function() {
 var d = this.read();
 d && console.log(d.toString());
});
ls.on('close', function(code) {
 console.log('child process exited with code ' + code);
});

在这里,我们在ls进程(列出目录)上使用spawn,并从结果的可读流中读取,接收类似以下内容:

-rw-r--r-- 1 root root  43 Jul 9 19:44 index.html
-rw-rw-r-- 1 root root 278 Jul 15 16:36 child_example.js
-rw-r--r-- 1 root root 1.2K Jul 14 19:08 server.js

child process exited with code 0

可以以这种方式派生任意数量的子进程。这里需要注意的是,当派生或以其他方式创建子进程时,操作系统本身会将该进程的责任分配给特定的 CPU。Node 不负责操作系统如何分配资源。结果是,在具有八个核心的机器上,派生八个进程可能会导致每个进程被分配到独立的处理器。换句话说,子进程会自动由操作系统分配到 CPU 上,这反驳了 Node 不能充分利用多核环境的说法。

注意

每个新的 Node 进程(子进程)分配了 10 MB 的内存,并代表一个新的 V8 实例,该实例至少需要 30 毫秒才能启动。虽然你不太可能创建成千上万的这些进程,但了解如何查询和设置用户创建的进程的操作系统限制是有益的。你可以使用htoptop来报告当前正在运行的进程数,或者你可以从命令行使用ps aux | wc –l。Unix 命令ulimit(ss64.com/bash/ulimit.html)提供了关于操作系统上用户限制的重要信息。传递ulimit–u参数将显示可以派生的最大用户进程数。通过传递参数来更改限制——ulimit –u 8192

child_process模块代表一个类,公开四个主要方法:spawnforkexecexecFile。这些方法返回一个ChildProcess对象,它扩展了EventEmitter,公开了子事件接口,以及一些有助于管理子进程的函数。我们将查看其主要方法,并随后讨论常见的ChildProcess接口。

spawn(command, [arguments], [options])

此强大命令允许 Node 程序通过系统命令启动和交互子进程。在先前的示例中,我们使用spawn调用原生 OS 进程ls,将参数'-lh''.'传递给该命令。这样,任何进程都可以像通过命令行启动一样启动。此方法接受三个参数:

  • command:这是由操作系统 shell 执行的命令

  • arguments:这些是作为数组发送的可选命令行参数

  • options:这是spawn的设置的可选映射

spawn的选项允许其行为被仔细定制:

  • cwd(字符串):默认情况下,此命令将理解其当前工作目录与调用spawn的 Node 进程相同。使用此指令更改该设置。

  • env(对象):这用于向子进程传递环境变量,例如,我们使用环境对象启动子进程,如下所示:

    {
      name : "Sandro",
      role : "admin"
    }
    

    子进程环境将能够访问前面代码中指定的值。

  • detached(布尔值):当父进程启动子进程时,两个进程形成一个组,父进程通常是该组的领导者。要使子进程成为组领导者,请使用detached。这允许子进程在父进程退出后继续运行。因为父进程默认会等待子进程退出,所以你可以调用child.unref()来告诉父进程的事件循环它不应计算子进程引用并在没有其他工作存在时退出。

  • uid(数字):以标准系统权限设置子进程的 uid(用户身份),例如具有在子进程中执行权限的 uid。

  • gid(数字):以标准系统权限设置子进程的 gid(组身份),例如具有在子进程中执行权限的 gid。

  • stdio(字符串或数组):子进程有文件描述符,前三个是按顺序的process.stdinprocess.stdoutprocess.stderr标准 I/O 描述符(fds = 0,1,2)。此指令允许重新定义、继承等这些描述符。

通常,为了读取以下子进程程序输出,父进程会监听child.stdout

process.stdout.write(new Buffer("Hello!"));

如果我们想要一个子进程继承其父进程的 stdio,以便当子进程写入 process.stdout 时,所发出的内容会通过管道传输到父进程的 process.stdout 流,我们将传递相关的父文件描述符给子进程,以覆盖其自身的设置:

spawn("node", ['./reader.js', './afile.txt'], {
  stdio: [process.stdin, process.stdout, process.stderr]
});

在这种情况下,子进程的输出将直接通过管道传输到父进程的标准输出通道。此外,请参阅下文中的 fork,以获取有关此类模式更多信息。

三个(或更多)文件描述符中的每一个可以取六个值之一:

  • 'pipe':这将在子进程和父进程之间创建一个管道。由于前三个子进程文件描述符已经暴露给父进程(child.stdinchild.stdoutchild.stderr),这在更复杂的子进程实现中是必要的。

  • 'ipc':创建一个 IPC 通道,用于在子进程和父进程之间传递消息。子进程最多可以有一个 IPC 文件描述符。一旦建立此连接,父进程就可以通过 child.send 与子进程通信。如果子进程通过此文件描述符发送 JSON 消息,则可以使用 child.on("message") 捕获这些输出。如果你正在以子进程的形式运行 Node 程序,那么使用 ChildProcess.fork 可能是一个更好的选择,因为它内置了这种消息通道。

  • 'ignore':文件描述符 0–2 将连接到 /dev/null。对于其他文件描述符,不会在子进程中设置引用的文件描述符。

  • 一个流对象:这允许父进程与子进程共享一个流。为了演示目的,假设有一个子进程将将相同的内容写入任何提供的 Writable 流,我们可以这样做:

    var writer = fs.createWriteStream("./a.out");
    writer.on('open', function() {
      var cp = spawn("node", ['./reader.js'], {
        stdio: [null, writer, null]
      });
    });
    

    子进程现在将获取其内容并将其管道传输到它被发送到的任何输出流:

    fs.createReadStream('cached.data').pipe(process.stdout);
    
  • 一个整数:这是一个文件描述符 ID。

  • null, undefined:这些是默认值。对于文件描述符 0–2 (stdin, stdout, stderr),将创建一个管道。其他默认为 ignore

除了以数组的形式传递 stdio 设置外,还可以通过传递快捷字符串值来实现某些常见的分组:

  • 'ignore' = ['ignore', 'ignore', 'ignore']

  • 'pipe' = ['pipe', 'pipe', 'pipe']

  • 'inherit' = [process.stdin, process.stdout, process.stderr] or [0,1,2]

应当注意,能够启动任何系统进程的能力意味着可以使用 Node 运行操作系统上安装的其他应用程序环境。如果我们安装了流行的 PHP 语言,以下情况是可能的:

var spawn = require('child_process').spawn;

var php = spawn("php", ['-r', 'print "Hello from PHP!";']);

php.stdout.on('readable', function() {
  var d;
  while(d = this.read()) {
    console.log(d.toString());
  }
});

// Hello from PHP!

运行一个更有趣、更大的程序同样简单。

除了可以通过此技术轻松运行 Java、Ruby 或其他程序,并且异步执行之外,我们还有对 Node 持续批评的良好回应:JavaScript 在处理数字或其他 CPU 密集型任务方面不如其他语言快。这在 Node 主要优化 I/O 效率和帮助管理高并发应用程序的意义上是正确的,而 JavaScript 是一种解释型语言,没有强烈关注重计算。

然而,使用 spawn,可以非常容易地将大量计算和长时间运行的例程传递到分析引擎或计算引擎中的其他环境中的分离进程。Node 的简单事件循环将在这些操作完成时通知主应用程序,无缝集成结果数据。同时,主应用程序可以自由地继续服务客户端。

fork(modulePath, [arguments], [options])

就像 spawn 一样,fork 启动一个子进程,但设计用于运行 Node 程序,并具有内置通信的额外好处。与将系统命令作为 fork 的第一个参数传递不同,我们传递 Node 程序的路径。与 spawn 一样,命令行选项可以作为第二个参数发送,在分叉的子进程中可通过 process.argv 访问。

可以传递一个可选的对象作为其第三个参数,包含以下参数:

  • cwd (字符串): 默认情况下,此命令将理解其当前工作目录与调用 fork 的 Node 进程相同。使用此指令更改该设置。

  • env (对象): 这用于将环境变量传递给子进程。参见 spawn

  • encoding (字符串): 这设置了通信通道的编码。

  • execPath (字符串): 这是创建子进程时使用的可执行文件。

  • silent (布尔值): 默认情况下,使用 fork 创建的子进程将与父进程的 stdio 相关联(例如,child.stdoutparent.stdout 相同)。将此选项设置为 'true' 将禁用此行为。

forkspawn 之间的重要区别在于,前者在完成时其子进程不会自动退出。此类子进程在完成后必须显式退出,这可以通过 process.exit() 轻易实现。

在以下示例中,我们将创建一个子进程,该子进程每十分之一秒发出一个递增的数字,然后其父进程将其输出到系统控制台。首先,让我们看看子程序:

var cnt = 0;

setInterval(function() {
  process.stdout.write(" -> " + cnt++);
}, 100);

再次,这只会简单地写入一个持续增加的数字。当分叉一个子进程时,子进程将继承其父进程的 stdio 流,因此我们只需要创建子进程,就可以在运行父进程的终端中获取输出:

var fork = require('child_process').fork;
fork('./emitter.js');

// -> 0 -> 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10 ...

注意

这里可以演示 silent 选项。以下代码将关闭任何输出到终端:

fork('./emitter.js', [], { silent: true });

创建多个、并行的进程很容易。让我们增加创建的子进程数量:

fork('./emitter.js');
fork('./emitter.js');
fork('./emitter.js');

-> 0 -> 0 -> 0 -> 1 -> 1 -> 1 -> 2 -> 2 -> 2 -> 3 -> 3 -> 3 -> 4 ...

到目前为止,应该很清楚,使用 fork,我们正在创建许多并行执行上下文,这些上下文分布在所有机器核心上。

这已经很直接了,但 fork 提供的内置通信通道使得与使用 fork 的子进程通信变得更加简单和清晰。考虑以下两个代码片段:

父进程

var fork = require('child_process').fork;
var cp = fork('./child.js');
cp.on('message', function(msgobj) {
  console.log('Parent got message:', msgobj.text);
});

cp.send({
  text: "I love you"
});

子进程

process.on('message', function(msgobj) {
  console.log('Child got message:', msgobj.text);
  process.send({
    text: msgobj.text + ' too'
  });
});

通过执行父脚本,我们将在控制台看到以下内容:

Child got message: I love you
Parent got message: I love you too

exec(command, [options], callback)

在完整缓冲输出足以满足需求,无需通过事件管理数据的情况下,child_process 提供了 exec 方法。该方法接受三个参数:

command: 这是一个命令行字符串。与 spawnfork 不同,它们通过数组将参数传递给命令,这个第一个参数接受一个完整的命令字符串,例如 ps aux | grep node

  • options: 这是可选的。

  • cwd: 这是一个字符串。为命令进程设置工作目录。

  • env: 这是一个对象。它是一个键值对的映射,这些键值对将被暴露给子进程。

  • encoding: 这是一个字符串。它是子进程数据流的编码。默认值是 'utf8'

  • timeout: 这是一个数字。这是我们等待进程完成的毫秒数,此时子进程将收到 killSignal 信号。

  • maxBuffer: 这是一个数字。它是允许在 stdoutstderr 上的最大字节数。当超过此数字时,进程将被终止。默认值是 200 KB。

  • killSignal: 这是一个字符串。子进程在 timeout 后接收此信号。默认值是 SIGTERM。

  • callback: 这接收三个参数:一个 Error 对象(如果有);stdout(一个包含结果的 Buffer);和 stderr(一个包含错误数据的 Buffer,如果有)。如果进程被终止,Error.signal 将包含终止信号。

execFile

当你想使用 exec 的功能但针对 Node 文件时,请使用此方法。重要的是,execFile 不会启动新的子 shell,这使得它运行起来稍微便宜一些。

与子进程通信

所有 ChildProcess 对象的实例都扩展了 EventEmitter,暴露了用于管理子数据连接的有用事件。此外,ChildProcess 对象还暴露了与子进程直接交互的有用方法。现在让我们逐一介绍这些方法,从属性和方法开始。

child.connected

当子进程通过 child.disconnect() 与父进程断开连接时,此标志将设置为 false。

child.stdin

这是一个对应于子进程标准输入的 Writable 流。

child.stdout

这是一个对应于子进程标准输出的 Readable 流。

child.stderr

这是一个对应于子进程标准错误的 Readable 流。

child.pid

这是一个表示分配给子进程的 进程 IDPID)的整数。

child.kill([signal])

尝试终止一个子进程,发送一个可选的信号。如果没有指定信号,默认为 SIGTERM(有关信号的更多信息,请参阅 unixhelp.ed.ac.uk/CGI/man-cgi?signal+7)。虽然方法名听起来像是终端,但它并不能保证杀死进程——它只是向进程发送一个信号。危险的是,如果尝试对已经退出的进程执行 kill 操作,可能另一个进程(它已经被分配了已死亡进程的 PID)将接收到该信号,后果不可预测。你应该触发一个 close 事件,该事件将接收用于关闭进程的信号。

child.disconnect()

当对不属于其领导的进程组的子进程触发 child.disconnect() 时,子进程与其父进程之间的 IPC 连接将被切断,导致子进程优雅地死亡,因为它没有 IPC 通道来维持其存活。你还可以在子进程内部调用 process.disconnect()。一旦子进程断开连接,该子进程引用上的 connected 标志将被设置为 false。

child.send(message, [sendHandle])

正如我们在 fork 的讨论中看到的那样,当在 spawn 上使用 ipc 选项时,可以通过此方法向子进程发送消息。可以将 TCP 服务器或套接字对象作为消息的第二个参数传递。通过这种方式,TCP 服务器可以将请求分散到多个子进程中。例如,以下服务器将套接字处理分散到与可用的 CPU 总数相等的多个子进程中。每个分叉的子进程都会被分配一个唯一的 ID,它在启动时报告。每当 TCP 服务器接收到套接字时,该套接字就会作为句柄传递给一个随机的子进程。然后该子进程发送一个唯一的响应,表明套接字处理正在分散。以下代码片段展示了这一点:

父进程

var fork = require('child_process').fork;
var net = require('net');

var children = [];

require('os').cpus().forEach(function(f, idx) {
  children.push(fork("./child.js", [idx]));
});

net.createServer(function(socket) {
  var rand = Math.floor(Math.random() * children.length);
  children[rand].send(null, socket);
}).listen(8080);

子进程

var id = process.argv[2];
process.on('message', function(n, socket) {
  socket.write('child ' + id + ' was your server today.\r\n');
  socket.end();
});

在终端窗口中启动父服务器。在另一个窗口中,运行 telnet 127.0.0.1 8080。你应该会看到类似以下的内容,每个连接上都会显示一个随机的子进程 ID(假设存在多个核心):

Trying 127.0.0.1...
...
child 3 was your server today.
Connection closed by foreign host.

集群模块

我们看到,通过创建独立进程来分散工作可以帮助垂直扩展 Node 应用程序。Node API 已经通过 cluster 模块进一步扩展,该模块正式化了这种模式并扩展了它。继续 Node 的核心目标,即帮助使可扩展的网络软件更容易构建,cluster 模块的具体目标是促进多个子工作进程之间网络套接字的共享。

例如,以下代码创建了一个由工作进程组成的集群,所有进程共享相同的 HTTP 连接:

var cluster = require('cluster');
var http = require('http');
var numCPUs = require('os').cpus().length;

if(cluster.isMaster) {
  for(var i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
}

if(cluster.isWorker) {
  http.createServer(function(req, res) {
    res.writeHead(200);
    res.end("Hello from " + cluster.worker.id);
  }).listen(8080);
}

我们很快会深入细节。需要注意的是,这个程序根据它是以主进程还是子进程运行而执行不同的操作。在其第一次执行时,它是主进程,由 cluster.isMaster 指示。当主进程调用 cluster.fork 时,这个相同的程序会作为子进程被 fork,在这种情况下,每个 CPU 对应一个子进程。当这个程序在 fork 的上下文中重新执行时,cluster.isWorker 将为 true,并且会启动一个新的在共享端口上运行的 HTTP 服务器。多个进程正在为单个服务器分担负载。

使用浏览器连接到这个服务器。你会看到类似 Hello from 8 的内容,这是对应于分配处理你请求的工作者的唯一 cluster.worker.id ID 的整数。所有工作者的负载均衡是自动处理的,这样刷新浏览器几次就会显示不同的工作者 ID。

cluster API 分为两部分:集群主进程可用的方法、属性和事件,以及子进程可用的方法。由于在此上下文中工作者是通过 fork 定义的,因此可以在这里应用 child_process 方法的文档。

cluster.isMaster

这是一个布尔值,指示进程是否是主进程。

cluster.isWorker

这是一个布尔值,指示进程是否是从主进程 fork 出来的。

cluster.worker

这是当前工作对象的引用,并且仅对子进程可用。

cluster.workers

这是一个包含所有活动工作对象引用的哈希,键为工作者 ID。使用它来遍历所有工作对象。这仅存在于主进程中。

cluster.setupMaster([settings])

这是在子进程 fork 时传递默认参数映射的便捷方式。如果所有子进程都将使用 fork 在相同的文件上(这种情况很常见),你将在这里设置它来节省时间。可用的默认值如下:

  • exec:这是一个字符串。进程文件的文件路径默认为 __filename

  • args:这是一个数组。字符串作为参数发送到子进程。

  • silent:这是一个布尔值,用于确定是否将输出发送到主进程的 stdio

cluster.fork([env])

这会创建一个新的工作进程。只有主进程可以调用此方法。要向子进程的环境暴露键值对映射,请向 env 发送一个对象。

cluster.disconnect([callback])

这用于终止集群中的所有工作者。一旦所有工作者都优雅地死亡,如果没有其他事件等待,集群进程将自行终止。要通知所有子进程已过期,请传递 callback

cluster 事件

这个集群对象会发出几个事件:

  • fork:当主进程尝试在新的子进程中使用 fork 时会触发此事件。这不同于 online。它接收一个工作对象。

  • 在线: 当主进程收到通知,子进程已完全绑定时,会触发此事件。这不同于 fork 事件。它接收一个 worker 对象。

  • 监听: 当 worker 执行需要 listen() 调用的操作(如启动 HTTP 服务器)时,主进程将触发此事件。事件发出两个参数:一个 worker 对象和包含 addressportaddressType 的连接地址对象。

  • 断开: 当子进程断开连接时调用,这可以通过进程退出事件或调用 child.kill() 后发生。它将在 exit 事件之前触发——它们不是同一个事件。它接收一个 worker 对象。

  • 退出: 当子进程死亡时,会触发此事件。它接收三个参数:一个 worker 对象、退出代码数字以及导致进程被杀死的信号字符串,例如 SIGHUP。

  • 设置: 在 cluster.setupMaster 执行后调用。

worker.id

这是分配给 worker 的唯一 ID,它也代表了 worker 在 cluster.workers 索引中的键。

worker.process

这是一个引用 worker 的 ChildProcess 对象。

worker.suicide

这些最近被调用过 killdisconnect 的 worker,它们的 suicide 属性将被设置为 true。

worker.send(message, [sendHandle])

查看 在多个核心上垂直扩展 部分的 child_process.fork(),我在那里描述了 #fork 方法。

worker.kill([signal])

这会杀死一个 worker。主进程可以检查此 worker 的 suicide 属性,以确定死亡是故意的还是意外的。默认发送的信号是 SIGTERM。

worker.disconnect()

这指示 worker 断开连接。重要的是,现有的 worker 连接不会立即终止(如 kill 所做的那样),而是在 worker 完全断开连接之前允许它们正常退出。由于现有的连接可以存在很长时间,因此定期检查 worker 是否实际上已经断开连接是一个好习惯,可能使用超时来实现。

Workers 也会发出事件:

  • 消息: 查看 在多个核心上垂直扩展 部分的 child_process.fork(),我在那里描述了 #fork 方法。

  • 在线: 这与 cluster.online 相同,但检查仅针对指定的 worker

  • 监听: 这与 cluster.listening 相同,但检查仅针对指定的 worker

  • 断开: 这与 cluster.disconnect 相同,但检查仅针对指定的 worker

  • 退出: 查看 child_processexit 事件

  • 设置: 在 cluster.setupMaster 执行后调用

现在我们已经很好地理解了如何使用 Node 实现垂直扩展,让我们来看看一些处理水平扩展的方法

在不同机器上水平扩展

由于 Node 非常高效,大多数网站或应用程序都可以在垂直维度上满足所有扩展需求。正如我们从 Eran Hammer 在沃尔玛的经历中学到的那样,Node 只需使用少量 CPU 和非常普通的内存量就能处理巨大的流量。

尽管如此,水平扩展仍然可能是正确的选择,即使只是为了架构原因。无论多么健壮,只有一个故障点仍然存在一些风险。"停车场问题"也是沃尔玛可能面临的一个考虑因素——在购物假日,你需要成千上万的停车位,但在一年中的其他时间,这种对空空间的投资很难证明其合理性。从服务器的角度来看,能够动态地向上和向下扩展的能力,反对建立固定的垂直孤岛。向运行中的服务器添加硬件也是一个比启动和无缝连接另一个虚拟机到应用程序更复杂的过程。

在本节中,我们将探讨一些水平扩展的技术,考虑到使用原生 Node 技术、第三方解决方案以及一些跨服务器通信的想法。

使用 Nginx

Nginx(发音为Engine X)对于那些从隐藏 Node 服务器背后的代理中受益的架构来说,仍然是一个流行的选择。Nginx 是一个非常流行的性能高的 Web 服务器,通常用作代理服务器。根据www.linuxjournal.com/magazine/nginx-high-performance-web-server-and-reverse-proxy

"Nginx 由于其架构,能够以更少的资源每秒处理更多的请求。它由一个主进程组成,该进程将工作委托给一个或多个工作进程。每个工作进程以事件驱动或异步的方式处理多个请求,使用 Linux 内核的特殊功能(epoll/select/poll)。这使得 Nginx 能够快速处理大量的并发请求,同时开销非常小。"

它的设计与 Node 的相似性非常明显:跨进程的事件委托以及由操作系统协调的事件驱动、异步环境,提供高并发性。

代理是指代表他人行事的人或事物。

正向代理通常在私有网络中代表客户端工作,作为外部网络(如从互联网检索数据)的请求经纪人。早期的网络提供商,如 AOL,就是这样运作的:

使用 Nginx

网络管理员在需要限制对外部世界(即互联网)的访问时通常会使用正向代理。如果通过电子邮件附件从不良网站下载恶意软件,管理员可能会阻止对该位置的访问。办公室网络可能会对社交网站实施访问限制。一些国家甚至以这种方式限制对互联网的访问。

反向代理,不出所料,以相反的方式工作,接受来自公共网络的请求,并在客户端可能不太了解的私有网络中处理这些请求。客户端首先将直接访问服务器的权限委托给反向代理。以下图可以帮助说明这一点:

使用 Nginx

这是我们可以使用来平衡来自多个 Node 服务器的客户端请求的类型。客户端 X 不直接与任何给定服务器通信。经纪人 Y 是第一个接触点,能够将 X 引导到负载较轻的服务器,位于 X 附近,或者以其他方式,是 X 在此时访问的最佳服务器。

让我们看看 Nginx 如何被用作代理,特别是作为一个负载均衡器,通过在云托管服务Digital Cloud上部署这样的系统来实现。

在 DigitalOcean 上部署 Nginx 负载均衡器

DigitalOcean 是一家价格低廉且易于设置的云托管提供商。我们将在该服务上构建一个 Nginx 负载均衡器。

要注册,请访问www.digitalocean.com。基本套餐(在撰写本文时)收取 5 美元的费用,但通常会提供促销代码——简单的网络搜索应该会找到可用的代码。创建并验证账户以开始使用。

DigitalOcean 的套餐被描述为具有某些特性的水滴——存储空间的大小、传输限制等。基本套餐足以满足我们的需求。此外,你将在你的水滴中指定托管区域和要安装的操作系统(在这个例子中,我们将使用 Ubuntu 的最新版本)。创建一个水滴,并检查你的电子邮件以获取登录说明。你已经完成了!

你将收到实例的完整登录信息。你现在可以打开一个终端,使用这些登录凭证 SSH 到你的机器上。

注意

在你的首次登录时,你可能想更新你的软件包。对于 Ubuntu,你会运行apt-get updateapt-get upgrade。其他包管理器有类似的命令(例如,RHEL/CentOS 的yum update)。

在我们开始安装之前,让我们更改我们的 root 密码并创建一个非 root 用户(将 root 暴露给外部登录和软件安装是不安全的)。要更改 root 密码,请输入passwd并遵循终端中的说明。要创建新用户,输入adduser <新用户名>(例如,adduser john)。遵循下文提到的说明。

再多一步:由于我们将以该新用户身份安装软件,因此我们希望给这个新用户一些管理权限。在 Unix 术语中,你希望给这个新用户sudo访问权限。如何操作的说明很容易找到,无论你选择了哪个操作系统。本质上,你将想要更改/etc/sudoers文件。请记住使用visudo之类的命令来执行此操作——不要手动编辑sudoers文件!此时,你可能还想限制 root 登录并执行其他 SSH 访问管理。

提示

在你的终端中成功执行sudo -i后,你将能够输入命令而无需在每个命令前加上sudo前缀。以下示例假设你已经这样做过了。

现在,我们将为两个 Node 服务器创建一个 Nginx 负载均衡器前端。这意味着我们将创建三个 Droplet——一个用于均衡器,两个额外的 Droplet 作为 Node 服务器。最终,我们将得到一个类似以下架构:

在 DigitalOcean 上部署 Nginx 负载均衡器

安装和配置 Nginx

让我们安装 Nginx 和 Node/npm。如果你仍然以 root 身份登录,请注销并重新以你刚刚创建的新用户身份登录。要在 Ubuntu 上安装 Nginx,只需输入:

apt-get install nginx

大多数其他 Unix 包管理器都会有 Nginx 安装程序。要启动 Nginx,请使用:

service nginx start

注意

Nginx 的完整文档可以在wiki.nginx.org/Configuration找到。

现在,你应该能够将你的浏览器指向分配给你的 IP 地址(如果你忘记了,请检查你的收件箱)并看到如下内容:

安装和配置 Nginx

现在,让我们设置 Nginx 将进行负载均衡的两个服务器。

在 DigitalOcean 上创建另外两个 Droplet。你必须不要在这些服务器上安装 Nginx。按照我们之前的方式配置这些服务器的权限。现在,在这两个 Droplet 上安装 Node。使用 Tim Caswell 的Node 版本管理器NVM)管理你的 Node 安装是一个简单的方法。NVM 本质上是一个 bash 脚本,提供了一套命令行工具,便于 Node 版本管理,并允许你轻松地在版本之间切换。要安装它,请使用以下命令:

curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.25.4/install.sh | bash

现在,安装你喜欢的 Node 版本(这里我们要求 0.12 版本的最新发布版):

nvm install 0.12

你可能想在.bashrc.profile文件中添加一个命令,以确保每次启动 shell 时都使用特定的 node 版本:

# start with node 0.12
nvm use 0.12

为了测试我们的系统,我们需要在这两台机器上设置 Node 服务器。在每个服务器上创建以下程序文件,将替换为每个服务器上独特的值(例如onetwo):

var http = require('http');

http.createServer(function(req, res) {
   res.writeHead(200, {
    "Content-Type" : "text/html"
  });
  res.write('HOST **');
  res.end();
}).listen(8080)

在每个服务器上启动此文件(node serverfile.js)。现在每个服务器都将通过端口8080进行响应。

你现在应该能够通过将浏览器指向每个 Droplet 的 IP 地址 8080 来访问这个服务器。一旦你有两个服务器以不同的消息响应,我们就可以设置 Nginx 负载均衡器了。

使用 Nginx 在服务器之间进行负载均衡很简单。你只需要在 Nginx 配置脚本中指定哪些upstream服务器应该被平衡。我们刚刚创建的两个 Node 服务器是上游服务器。以下图表描述了 Nginx 如何均匀地将请求分布在上游服务器之间:

安装和配置 Nginx

每个请求首先由 Nginx 处理,Nginx 将检查其upstream配置,并根据配置情况将请求(反向)代理到实际处理请求的上游服务器。

你可以在你的负载均衡 droplet 的/etc/nginx/sites-available/default找到默认的 Nginx 服务器配置文件。在生产环境中,你很可能想创建一个自定义目录和配置文件,但就我们的目的而言,我们将简单地修改默认配置文件(在开始修改之前,你可能想备份一下)。

在 Nginx 配置文件的最顶部,我们希望定义upstream服务器,这些服务器将成为重定向的候选者。这只是一个简单的映射,使用任意键lb-servers,在接下来的服务器定义中将被引用:

upstream lb_servers {
  server first.node.server.ip;
  server second.node.server.ip;
}

现在我们已经建立了候选映射,我们需要配置 Nginx,使其以平衡的方式将请求转发到lb-servers的每个成员:

server {
  listen 80 default_server;
  listen [::]:80 default_server ipv6only=on;

  #root /usr/share/nginx/html;
  #index index.html index.htm;

  # Make site accessible from http://localhost/
  server_name localhost;

  location / {
    proxy_pass http://lb-servers; # Load balance mapped servers
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_set_header Host $host;
    proxy_cache_bypass $http_upgrade;
  }

  ... more configuration options not specifically relevant to our purposes
}

关键行是这一行:

proxy_pass http://lb-servers

注意lb-servers的名称与我们的上游定义名称相匹配。这应该使发生的事情变得清晰:监听端口80的 Nginx 服务器将请求传递给包含在lb-servers中的服务器定义。如果上游定义中只有一个服务器,那么该服务器将获得所有流量。如果定义了多个服务器,Nginx 将尝试在它们之间均匀分配流量。

注意

同样地,也可以使用相同的技巧在多个本地服务器之间平衡负载。只需在不同的端口上运行不同的 Node 服务器,例如server 127.0.0.1:8001; server 127.0.0.1:8002; ...

好吧,现在去修改 Nginx 配置(如果你遇到困难,可以参考这本书的代码包中的nginx.config文件)。一旦修改完成,使用以下命令重启 Nginx:

service nginx restart

或者,你可以使用以下命令:

service nginx stop
service nginx start

假设运行 Node 服务器的其他两个 droplets 正在运行,你现在应该能够将你的浏览器指向你的 Nginx 启用的 droplet,并看到那些服务器的消息!

由于我们可能希望更精确地控制流量在上游服务器之间的分布,因此可以应用进一步的上游服务器定义指令。

Nginx 使用加权轮询算法来平衡负载。为了控制流量分布的相对权重,我们使用weight指令:

upstream lb-servers {
  server first.node.server.ip weight=10;
  server second.node.server.ip weight=20;
}

这个定义告诉 Nginx 将两倍于第一台服务器的负载分配给第二台服务器。例如,内存或 CPU 更多的服务器可能会被优先考虑。另一种使用此系统的方法是创建一个 A/B 测试场景,其中一个包含提议的新设计的服务器接收一小部分总流量,这样就可以将测试服务器的指标(如销售额、下载量、参与时长等)与更广泛的平均值进行比较。

有三个其他有用的指令可用,它们共同管理连接失败:

  • max_fails:这是在将服务器标记为不可用之前,与服务器通信失败的次数。这些失败必须在fail_timeout定义的时期间发生。

  • fail_timeout:这是max_fails必须发生的时长,表示服务器不可用。这个数字还表示在服务器被标记为不可用后,Nginx 将再次尝试连接该标记服务器的时长。以下是一个示例:

    upstream lb-servers {
      server first.node.server.ip weight=10 max_fails=2 fail_timeout=20s;
      server second.node.server.ip weight=20 max_fails=10 fail_timeout=5m;
    }
    
  • backup:带有此指令的服务器只有在所有其他列出的服务器都不可用时才会被调用。

此外,还有一些针对上游定义的指令,可以添加一些控制客户端如何被导向上游服务器的控制:

  • least_conn:将请求传递给连接数最少的服务器。这提供了一种稍微智能一点的平衡,考虑了服务器负载以及权重。

  • ip_hash:这里的想法是为每个连接的 IP 创建一个哈希值,并确保来自特定客户端的请求始终被传递到同一服务器。

注意

另一个常用于平衡 Node 服务器的工具是专门的负载均衡器HAProxy,可在haproxy.1wt.eu/找到。

使用 Node 进行负载均衡

多年来,建议在 Node 服务器前放置一个 Web 服务器(如 Nginx)。声称成熟的 Web 服务器在处理静态文件传输方面更有效率。虽然这可能适用于早期的 Node 版本(它们确实遭受了新技术面临的错误),但从纯速度的角度来看,这不再一定是正确的。一些最近的基准测试证实了这一点:centminmod.com/siegebenchmarks/2013/020313/index.html

当然,文件传输速度不是使用像 Nginx 这样的代理的唯一原因。通常,网络拓扑特性使得反向代理成为更好的选择,尤其是在集中化常见服务(如压缩)有意义时。关键是 Node 不应该仅仅因为过时的偏见而排除在外,这些偏见认为它无法有效地提供文件服务。让我们看看一个纯 Node 基础的代理和平衡解决方案的例子,node-http-proxy

使用 node-http-proxy

Node 被设计用来促进网络软件的创建,因此开发出几个代理模块并不令人惊讶。NodeJitsu 团队发布了他们在生产中使用的代理——http-proxy。让我们看看我们如何使用它将请求路由到不同的 Node 服务器。

与 Nginx 不同,我们的整个路由堆栈将存在于 Node 中。监听端口80,一个 Node 服务器将运行我们的代理。我们将涵盖三种场景:在一个机器上使用单个盒子运行多个 Node 服务器,这些服务器在不同的端口上运行;使用一个盒子作为纯路由代理到外部 URL;以及创建一个基本的轮询负载均衡器。

作为初始示例,让我们看看如何使用此模块来重定向请求:

var httpProxy = require('http-proxy');

var proxy = httpProxy.createServer({
  target: {
    host: 'www.example.com',
    port: 80
  }
}).listen(80);

通过在我们的本地机器的端口80上启动此服务器,我们能够将用户重定向到另一个 URL。

要在单个机器上运行多个不同的 Node 服务器,每个服务器响应不同的 URL,您只需定义一个路由器:

var httpProxy = httpProxy.createServer({
  router: {
    'www.mywebsite.com'    : '127.0.0.1:8001',
    'www.myothersite.com'  : '127.0.0.1:8002',
  }
});
httpProxy.listen(80);

对于您每个不同的网站,您现在可以将您的 DNS 名称服务器(通过 ANAME 或 CNAME)指向相同的端点(无论此 Node 程序在哪里运行),并且它们将解析到不同的 Node 服务器。当您想运行多个网站但不想为每个网站创建新的物理服务器时,这很有用。另一种策略是在不同的 Node 服务器上处理同一网站内的不同路径:

var httpProxy = httpProxy.createServer({
  router: {
    'www.mywebsite.com/friends'  : '127.0.0.1:8001',
    'www.mywebsite.com/foes'  : '127.0.0.1:8002',
  }
});
httpProxy.listen(80);

这允许您的应用程序中的专用功能由独特配置的服务器处理。

设置负载均衡器也很简单。与 Nginx 的upstream指令一样,我们只需列出要平衡的服务器并循环通过它们:

var httpProxy = require('http-proxy');
var addresses = [
  {
    host: 'one.example.com',
    port: 80
  },
  {
    host: 'two.example.com',
    port: 80
  }
];

httpProxy.createServer(function(req, res, proxy) {
  var target = addresses.shift();
  proxy.proxyRequest(req, res, target);
  addresses.push(target);
}).listen(80);

与 Nginx 不同,我们负责实际进行负载均衡。在这个例子中,我们平等地对待服务器,按顺序循环。在选定的服务器被代理后,它将被返回到列表的末尾

应该很明显,这个例子可以很容易地扩展以适应其他指令,例如 Nginx 的weight

注意

代理 Node 的另一个好选择是 James Halliday 的bouncy模块,可在github.com/substack/bouncy找到。

使用消息队列

确保分布式服务器保持可靠的通信通道的最佳方法之一是将远程过程调用的复杂性打包到消息队列中。当一个服务器希望向另一个服务器发送消息时,消息可以简单地放置在这个队列上——就像您应用程序的“待办事项”列表——而队列服务则负责确保消息得到传递,并将任何重要的回复发送回原始发送者。

有几个企业级消息队列可供选择,其中许多部署了高级消息队列协议AMQP)。我们将关注一个非常稳定且广为人知的实现:RabbitMQ。

注意

要在你的环境中安装 RabbitMQ,请遵循www.rabbitmq.com/download.html中找到的说明。请注意,你还需要安装 Erlang(其说明可以在同一链接中找到)。

安装后,你可以使用以下命令启动 RabbitMQ 服务器:

service rabbitmq-server start

要使用 Node 与 RabbitMQ 交互,我们将使用 Theo Schlossnagle 的 node-amqp 模块:

npm install amqp

要使用消息队列,必须首先创建一个绑定到 RabbitMQ 的消费者,该消费者将监听发布到队列的消息。最基本的消费者将监听所有消息:

var amqp = require('amqp');

var consumer = amqp.createConnection({ host: 'localhost', port: 5672 });
var exchange;

consumer.on('ready', function() {
  exchange = consumer.exchange('node-topic-exchange', {type: "topic"});
  consumer.queue('node-topic-queue', function(q) {

    q.bind(exchange, '#');

    q.subscribe(function(message) {
      // Messages are buffers
      //
      console.log(message.data.toString('utf8'));
    });
  });
});

我们现在正在监听绑定到端口 5672 的 RabbitMQ 服务器的消息。很明显,localhost 可以替换为适当的服务器地址,并绑定到任意数量的分布式服务器。

一旦这个消费者建立了连接,它将建立它将监听并应绑定到的队列的名称。在这个例子中,我们创建了一个主题 exchange(默认),给它一个唯一的名称。我们还表明我们希望通过 # 监听所有消息。剩下要做的就是订阅队列,接收消息对象。随着我们的进展,我们将了解更多关于消息对象的内容。现在,请注意重要的 data 属性,它包含发送的消息。

现在我们已经建立了一个消费者,让我们向交换发布一条消息。如果一切顺利,我们将看到发送的消息出现在我们的控制台中:

consumer.on('ready', function() {

  ...

  exchange.publish("some-topic", "Hello!");
});

// Hello!

我们已经学到了足够的知识来实现有用的扩展工具。如果我们有多个分布式的 Node 进程,甚至在不同的物理服务器上,每个进程都可以通过 RabbitMQ 可靠地向其他进程发送消息。每个进程只需要简单地实现一个交换队列订阅者来接收消息,以及一个交换发布者,当需要发送消息时。

存在三种类型的交换:直接扇出主题。不同之处在于每种类型的交换处理路由键的方式——exchange.publish 发送的第一个参数。

直接交换直接匹配路由键。以下是一个队列绑定的例子:

queue.bind(exchange, 'room-1');

之前的队列绑定将匹配发送到 room-1 的消息。因为不需要解析,所以直接交换能够在一定时间内处理比主题交换更多的消息。

扇出交换是无差别的:它将消息路由到它绑定的所有队列,忽略路由键。这种类型的交换用于广泛的广播。

主题交换根据通配符 #* 匹配路由键。与其他类型不同,主题交换的路由键必须由点分隔的单词组成——例如 animals.dogs.poodle# 匹配零个或多个单词——它将匹配每条消息(就像我们在前面的例子中看到的那样),就像扇出交换一样。另一个通配符是 *,它匹配恰好一个单词。

可以使用与提供的主题交换示例几乎相同的代码实现直接和扇出交换,只需更改交换类型,并且绑定操作要了解它们将如何与路由键关联(扇出订阅者接收所有消息,无论键是什么;对于直接交换,路由键必须直接匹配)。

这个最后的例子应该能够说明主题交换是如何工作的。我们将创建三个具有不同匹配规则的队列,过滤每个队列从交换中接收到的消息:

consumer.on('ready', function() {

  // When all 3 queues are ready, publish.
  //
  var cnt = 3;
  var queueReady = function() {
    if(--cnt > 0) {
      return;
    }
    exchange.publish('animals.dogs.poodles', 'Poodle!');
    exchange.publish('animals.dogs.dachshund', 'Dachshund!');
    exchange.publish('animals.cats.shorthaired', 'Shorthaired Cat!');
    exchange.publish('animals.dogs.shorthaired', 'Shorthaired Dog!');
    exchange.publish('animals.misc', 'Misc!');
  }

  var exchange = consumer.exchange('topical', {type: "topic"});

  consumer.queue('queue-1', function(q) {

    q.bind(exchange, 'animals.*.shorthaired');
    q.subscribe(function(message) {
      console.log('animals.*.shorthaired -> ' + message.data.toString('utf8'));
    });

    queueReady();
  });

  consumer.queue('queue-2', function(q) {
    q.bind(exchange, '#');
    q.subscribe(function(message) {
      console.log('# -> ' + message.data.toString('utf8'));
    });

    queueReady();
  });

  consumer.queue('queue-3', function(q) {
    q.bind(exchange, '*.cats.*');
    q.subscribe(function(message) {
      console.log('*.cats.* -> ' + message.data.toString('utf8'));
    });

    queueReady();
  });
});

//  # -> Poodle!
//  animals.*.shorthaired -> Shorthaired Cat!
//  *.cats.* -> Shorthaired Cat!
//  # -> Dachshund!
//  # -> Shorthaired Cat!
//  animals.*.shorthaired -> Shorthaired Dog!
//  # -> Shorthaired Dog!
//  # -> Misc!

node-amqp 模块包含进一步的方法来控制连接、队列和交换,特别是从交换中删除队列和从队列中删除订阅者的方法。通常,在运行队列上动态更改其组成可能会导致意外的错误,因此请谨慎使用这些方法。

注意

要了解更多关于 AMQP(以及使用 node-amqp 设置时可用选项),请访问 www.rabbitmq.com/tutorials/amqp-concepts.html

使用 Node 的 UDP 模块

用户数据报协议UDP)是一种轻量级的核心互联网消息协议,允许服务器传递简洁的 数据报。UDP 设计时考虑了最小的协议开销,放弃了交付、排序和重复预防机制,以确保高性能。当不需要完美可靠性而需要高速传输时,UDP 是一个好的选择,例如在网络视频游戏和视频会议应用程序中。日志记录也是 UDP 的另一个流行用途。

这并不是说 UDP 通常不可靠。在大多数应用程序中,它以高概率传递消息。它只是不适合需要 完美 可靠性的情况,例如在银行应用程序中。它是监控和日志记录应用程序以及非关键消息服务的优秀候选者。

使用 Node 创建 UDP 服务器很简单:

var dgram = require('dgram');
var socket = dgram.createSocket('udp4');

socket.on('message', function(msg, info) {
  console.log('socket got: ' + msg + ' from ' +
  info.address + ':' + info.port);
});

socket.bind(41234);

socket.on('listening', function() {
  console.log('Listening for datagrams.');
});

绑定命令需要三个参数:

  • 端口: 这是整数端口号。

  • 地址: 这是一个可选的地址。如果没有指定,操作系统将尝试监听所有地址(这通常是您想要的)。您也可以尝试显式使用 0.0.0.0

  • 回调: 这是一个可选的回调,它不接受任何参数。

当此套接字通过端口 41234 接收到数据报时,它将现在发出一个 消息 事件。事件回调将消息本身作为第一个参数,以及一个包含数据包信息的映射作为第二个参数:

  • 地址: 这是原始 IP 地址

  • 家族: 这是 IPv4 或 IPv6 之一

  • 端口: 这是原始端口

  • 大小: 这是消息的字节数

此映射类似于调用 socket.address() 时返回的映射。

除了消息和监听事件外,UDP 套接字还会发出一个close事件和一个error事件,后者在发生错误时接收一个Error对象。要关闭 UDP 套接字(并触发close事件),请使用server.close()

发送消息甚至更简单:

var client = dgram.createSocket('udp4');
var message = new Buffer('UDP says Hello!');
client.send(message, 0, message.length, 41234, 'localhost', function(err, bytes) {
  client.close();
});

发送方法的形式为client.send(buffer, offset, length, port, host, callback)

  • 缓冲区:这是一个包含要发送的数据报的缓冲区

  • 偏移量:这是一个整数,指示数据报在缓冲区中的起始位置

  • 长度:这是数据报中的字节数。与偏移量结合,此值标识了缓冲区内的完整数据报

  • 端口:这是一个整数,用于标识目标端口

  • 地址:这是一个字符串,指示数据报的目标 IP 地址

  • 回调函数:这是一个可选的回调函数,在发送操作完成后被调用。

注意

数据报的大小不能超过 65,507 字节,这等于2¹⁶-1(65,535)字节减去 UDP 头部占用的 8 字节减去 IP 头部占用的 20 字节。

我们现在有另一个进程间通信的候选方案。为我们的 Node 应用程序设置一个监听 UDP 套接字的监控服务器相当容易,该服务器用于监听来自其他进程的程序更新和统计信息。该协议的速度足够快,适用于实时系统,并且任何数据包丢失或其他 UDP 问题,从总量的百分比来看都是微不足道的。

将广播的概念进一步扩展,我们还可以使用dgram模块创建一个多播服务器。一个“多播”简单地说是一个一对多的服务器广播。我们可以向永久保留为多播地址的 IP 地址范围进行广播。网站www.iana.org/assignments/multicast-addresses/multicast-addresses.xhtml上有以下说明:

"IP 多播的主机扩展 [RFC1112] 指定了主机实现互联网协议(IP)所需的支持多播的扩展。多播地址的范围是 224.0.0.0 到 239.255.255.255。"

此外,224.0.0.0 到 224.0.0.255 之间的范围进一步保留用于特殊路由协议。

此外,某些端口号被分配给 UDP(和 TCP)使用,可以在en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers找到这些端口号的列表。

所有这些有趣信息的要点是,我们知道有一块 IP 和端口是为 UDP 和/或多播保留的,我们现在将使用其中一些来实现 Node 上的多播。

设置多播 UDP 服务器和“标准”服务器之间的唯一区别是将多播服务器绑定到一个特殊的 UDP 端口,以表示我们想监听所有可用的网络适配器。我们的多播服务器初始化看起来像这样:

var socket = dgram.createSocket('udp4');

var multicastAddress   = '230.1.2.3';
var multicastPort   = 5554;

socket.bind(multicastPort);

socket.on('listening', function() {
  this.setMulticastTTL(64);
  this.addMembership(multicastAddress);
});

在请求多播端口绑定后,我们等待套接字监听事件,此时我们可以配置我们的服务器。

最重要的命令是socket.addMembership,它告诉内核加入multicastAddress处的多播组。其他 UDP 套接字现在可以订阅此地址的多播组。

数据报就像任何网络数据包一样在网络中跳跃。setMulticastTTL方法用于设置数据报在被丢弃且未交付之前允许的最大跳跃数(“生存时间”)。可接受的范围是 0–255,大多数系统上的默认值是 1(1)。这通常不是必须担心的一个设置,但如果深入了解网络拓扑对数据包交付的这方面有相关性,则它是可用的。

注意

如果你还想允许在本地接口上监听,请使用socket.setBroadcast(true)socket.setMulticastLoopback(true)。这通常不是必要的。

我们最终将使用此服务器向multicastAddress上的所有 UDP 监听器广播消息。现在,让我们创建两个客户端,它们将监听多播:

dgram.createSocket('udp4')
.on('message', function(message, remote) {
  console.log('Client1 received message ' + message + ' from ' + remote.address + ':' + remote.port);
})
.bind(multicastPort, multicastAddress);

dgram.createSocket('udp4')
.on('message', function(message, remote) {
  console.log('Client2 received message ' + message + ' from ' + remote.address + ':' + remote.port);
})
.bind(multicastPort, multicastAddress);

我们现在有两个客户端正在监听同一个多播端口。剩下要做的就是多播。在这个例子中,我们将使用setTimeout每秒发送一个计数器值:

var cnt = 1;
var sender;

(sender = function() {
  var msg = new Buffer("This is message #" + cnt);
  socket.send(
    msg,
    0,
    msg.length,
    multicastPort,
    multicastAddress
  );

  ++cnt;

  setTimeout(sender, 1000);

})();

上述代码将产生类似以下内容:

Client2 received message This is message #1 from 67.40.141.16:5554
Client1 received message This is message #1 from 67.40.141.16:5554
Client2 received message This is message #2 from 67.40.141.16:5554
Client1 received message This is message #2 from 67.40.141.16:5554
Client2 received message This is message #3 from 67.40.141.16:5554
...

我们有两个客户端正在监听来自特定组的广播。让我们添加另一个客户端,监听不同的组——比如说在多播地址230.3.2.1

dgram.createSocket('udp4')
.on('message', function(message, remote) {
  console.log('Client3 received message ' + message + ' from ' + remote.address + ':' + remote.port);
})
.bind(multicastPort, '230.3.2.1');

由于我们的服务器目前向不同的地址广播消息,我们需要更改我们的服务器配置,并使用另一个addMembership调用添加此新地址:

socket.on("listening", function() {
  this.addMembership(multicastAddress);
  this.addMembership('230.3.2.1');
});

我们现在可以向两个地址发送消息:

(sender = function() {
  socket.send(
    ...
    multicastAddress
  );

  socket.send(
    ...
    '230.3.2.1'
  );

  ...
})();

当然,没有任何东西阻止客户端向其组中的其他人广播,甚至向另一个组的成员广播:

dgram.createSocket('udp4')
.on('message', function(message, remote) {
  var msg = new Buffer("Calling original group!");
  socket.send(
    msg,
    0,
    msg.length,
    multicastPort,
    '230.1.2.3' // multicastAddress
  );
})
.bind(multicastPort, '230.3.2.1');

任何具有我们网络接口地址的 Node 进程现在都可以监听 UDP 多播地址上的消息,从而提供一个快速而优雅的进程间通信系统。

摘要

在本章中,我们探讨了 Node 应用程序如何进行垂直和水平扩展的方法。我们学习了如何在操作系统进程中使用spawn,以及在新的 Node 进程中使用forkcluster模块的概述展示了使用 Node 跨核心扩展是多么容易,以及如何通过内置的消息通道高效且轻松地将客户端连接分配给中央(主)枢纽。我们还探讨了水平分布的进程和服务器如何使用消息队列和 UDP 服务器进行通信,以及这些服务器如何使用 Nginx 或为该目的设计的 Node 模块进行负载均衡和代理。

扩展不仅关乎服务器和负载均衡。在下一章中,我们将探讨如何扩展和管理资源,了解内存管理技术,同步分布式服务之间的数据,同步数据缓存策略,以及如何处理大量同时连接。

第四章:管理内存和空间

今天的开发者可以轻松访问令人惊讶的低成本存储解决方案。从单体系统向由多个组件组成的分布式系统的转变具有某些优势,但不可避免地引入了一些新问题。例如,便宜的存储不应成为将所有东西无限制地推入内存或磁盘的借口。此外,在这种系统中状态存储在哪里?服务器集群是否共享一个公共数据库连接?在这种设置中数据是如何同步的?如果你使用的是无共享的 NoSQL架构,状态变化是如何在所有参与者之间通信的?

有许多需要考虑的因素。始终寻求使用最少的资源是一个好的指导原则。在本章中,我们将探讨降低 Node 程序中数据存储成本的方法,包括编写高效、优化代码的技巧。将讨论一些在分布式服务器之间高效共享数据的策略,包括缓存策略、微服务、进程间消息传递以及其他保持系统快速、轻量级和可扩展的技术。使用令牌高效管理用户会话数据以及使用 Redis 紧凑地存储大量用户活动数据的示例将帮助您将这些想法付诸实践。

应对大量人群

由于 Node 被设计用来简化网络应用程序的编写,因此使用 Node 的人通常在构建由许多通过消息队列、套接字、REST API 等连接的独立服务组成的应用程序。我将这些描述为由独立服务组成、通过网络耦合和协调的系统,这些系统对客户端来说似乎是集成的。在本节及随后的章节中,我们将考虑如何设计独立服务以实现内存效率,并具有较小的占用空间。

在本节及随后的内容中,当提到由许多小型协作服务组成的应用架构时,我们将使用微服务这个词。通常,我们将探讨如何通过维护表达性、可扩展性和可测试的系统来帮助系统保持可理解性,从而常常有助于保持系统不变得难以理解。

然后,我们将通过使用 Richard Rogers 为 Node 编写的微服务工具包Seneca (github.com/rjrodger/seneca)来将微服务理论付诸实践。最后,我们将探讨如何使用 Redis pub/sub 作为跨进程通信系统,从而展示另一种构建自己的微服务集群的方法。

微服务

任何非平凡的基于网络的程序都是由几个独立的子系统组成的,这些子系统必须协作以实现更大系统的商业或其他需求。例如,许多 Web 应用程序提供基于浏览器的界面,由一个或多个库和/或 UI 框架组成,将用户操作转换为针对多个 Web 协议发出的正式化网络请求。这些最终与运行实现各种类型业务逻辑的程序的服务器进行通信——所有这些共享一个或多个数据库,可能分布在几个数据中心。这些数据库启动并协调甚至更长的请求链。

因为没有绝对正确的软件构建方式,每个设计都偏向于一个或几个关键原则,特别是指导系统如何扩展的原则,这通常会影响其部署方式。Node 社区中的一些关键原则——由执行单一任务且表现良好的小程序组成的模块化系统,以事件驱动、I/O 关注和网络关注为特点——与支撑微服务的基础原则紧密一致。

微服务架构设计通常遵循以下原则:

  • 应将系统分解成许多小的服务,每个服务只做一件事。这有助于提高清晰度。

  • 驱动服务的代码应该简短且简单。Node 社区中的一个常见准则是将程序限制在约 100 行代码左右。这有助于可维护性。

  • 没有一个服务应该依赖于另一个服务的存在,甚至不应该知道其他服务的存在。服务是解耦的。这有助于可扩展性、清晰度和可维护性。

  • 数据模型应该是去中心化的,采用一个共同的(但不是必需的)微服务模式——即每个服务维护自己的数据库或类似模型。服务是无状态的(这加强了前面的观点)。

  • 独立的服务易于复制(或淘汰)。可扩展性(在两个方向上)是微服务架构的自然特性,因为可以根据需要添加或删除新的节点。这也使得易于实验,例如,原型服务可以被测试,新功能可以被测试或临时部署等。

  • 独立的无状态服务可以独立替换或升级(或降级),无论它们构成的部分系统的状态如何。这为更专注的、离散的部署和重构打开了可能性。

  • 失败是不可避免的,因此系统应该被设计成能够优雅地失败。定位故障点(本列表的第一和第二点),隔离故障(本列表的第三和第四点),并实施恢复机制(当错误边界明确、规模小且非关键时更容易实现)。通过减少不可靠性的范围来提高系统的鲁棒性。

  • 测试对于任何非平凡的系统都是至关重要的。明确的简单无状态服务很容易测试。测试的一个关键方面是模拟——为了测试服务互操作性而对服务的 存根化模拟。明确界定的服务也容易模拟,因此可以智能地组合到可测试的系统中。

这个想法很简单:较小的服务单独推理起来更容易,鼓励规格的正确性(几乎没有灰色区域)和 API 的清晰性(受限的输出集跟随受限的输入集)。由于是无状态的和解耦的,服务促进了系统的可组合性,有助于扩展和维护,并且更容易部署。此外,对这类系统进行非常精确的离散监控也是可能的。

Redis 发布/订阅

在上一章中,我们讨论了消息队列的使用,这是一种快速跨进程通信的优秀技术。Redis 提供了一个接口,允许连接的客户端订阅特定的通道并向该通道广播消息。这通常被描述为发布/订阅范式。当你不需要更复杂的消息交换和代理,而只需要一个简单快速的通知网络时,发布/订阅工作得很好。

让我们设置一个基本的发布/订阅示例,然后继续探讨如何使用发布/订阅创建一个微服务架构的示例,在这个架构中,许多执行特定任务的组件会传递服务请求并返回结果——所有这些协调都通过 Redis 完成。

首先,让我们看看发布/订阅最基础的示例——一个脚本,演示了如何订阅一个通道以及如何向该通道发布消息:

var redis = require("redis");

var publisher = redis.createClient();
var subscriber = redis.createClient();

subscriber.subscribe('channel5');

subscriber.on('message', function(channel, message) {
  console.log('channel: ', channel)
  console.log('message: ', message)
})

subscriber.on('subscribe', function() {
  publisher.publish('channel5', 'This is a message')
})

注意

我们正在使用 Matt Ranney 的 Redis npm 模块。更多信息请访问 github.com/mranney/node_redis

要创建一个发布者和一个订阅者,我们需要创建两个 Redis 客户端。请注意,一旦向客户端发出 subscribepsubscribe(稍后会更详细地介绍 psubscribe),该客户端将进入 订阅者模式,不再接受标准 Redis 命令。通常,你会创建两个客户端:一个用于监听订阅通道上的消息,另一个用于执行所有其他命令的标准 Redis 客户端。

还要注意,在发布任何消息之前,我们必须等待 subscriber 客户端上的 subscribe 事件被触发。Redis 不会保留已发布消息的队列,这涉及到等待订阅者。对于没有订阅者的消息,它会被简单地丢弃。以下内容基于 Redis 文档:

"…发布消息被分类到通道中,而不了解可能存在哪些(如果有的话)订阅者。订阅者表达对一个或多个通道的兴趣,并且只接收感兴趣的消息,而不了解可能存在哪些(如果有的话)发布者。这种发布者和订阅者的解耦可以允许更大的可伸缩性和更动态的网络拓扑。"

因此,我们必须在发布之前等待订阅者。一旦完成订阅,我们就可以向 channel5 频道发布,监听该频道的 subscriber 处理器接收我们的消息:

channel: channel5
message: This is a message

让我们进一步通过创建两个不同的 Node 进程来实现这一点,每个进程执行一个简单的(微)服务。我们将构建一个具有两个操作——加法和减法的计算器服务。一个单独的、专用的进程将执行每个操作,计算器服务与其辅助服务之间的双向通信将由 Redis pub/sub 管理。

首先,我们设计两个 Node 程序,一个用于加法,一个用于减法。这里我们只展示加法器:

var redis = require("redis");
var publisher = redis.createClient();
var subscriber = redis.createClient();

subscriber.subscribe('service:add');
subscriber.on('message', function(channel, operands) {
  var result = JSON.parse(operands).reduce(function(a, b) {
    return a + b;
  })
  publisher.publish('added', result);
})
subscriber.on('subscribe', function() {
  process.send('ok')
})

减法程序几乎相同,只是在它监听的频道和执行的运算上有所不同。这两个服务存在于 add.jssubtract.js 文件中。

我们可以看到这个服务做了什么。当它接收到 service:add 频道上的消息时,它会获取传递给它的两个操作数,将它们相加,并将结果发布到 added 频道。正如我们很快将看到的,计算器服务将在 added 频道上监听结果。您还会注意到一个对 process.send 的调用——这是用来通知计算器服务加法服务已准备就绪的。这很快就会变得更有意义。

现在,让我们构建 calculator.js 服务本身:

var redis = require("redis");
var publisher = redis.createClient();
var subscriber = redis.createClient();

var child_process = require('child_process');
var add = child_process.fork('add.js');
var subtract = child_process.fork('subtract.js');

add.on('message', function() {
  publisher.publish('service:add', JSON.stringify([7,3]))
})
subtract.on('message', function() {
  publisher.publish('service:subtract', JSON.stringify([7,3]))
})
subscriber.subscribe('result:added')
subscriber.subscribe('result:subtracted')
subscriber.on('message', function(operation, result) {
  console.log(operation + ' = ', result);
});

主要的计算器服务创建了两个运行 add.jssubtract.js 微服务的新的进程。通常,在一个真实系统中,这些其他服务的创建将是独立完成的,甚至可能是在完全不同的机器上。这种简化对我们的示例很有用,但它确实展示了一种在核心之间创建垂直扩展的简单方法。显然,在 Node 中使用 fork 的每个子进程都内置了一个通信通道,允许子进程与其父进程进行通信,正如计算器服务中使用 add.on(…)substract.on(...) 以及我们的计算服务中使用 process.send(…) 所见的那样。

一旦计算器服务收到其依赖服务准备就绪的通知,它将通过传递操作数在 service:addservice:subtract 频道上发布一个请求工作。正如我们之前看到的,每个服务都在自己的频道上监听,并执行请求的工作,发布一个结果,这个计算器服务可以接收并使用。当执行 calculator.js 时,您的终端将显示以下内容:

result:subtracted = 4
result:added = 10

之前,我们提到了 psubscribe 方法。p 前缀表示 模式,当您想使用典型的 glob 模式订阅频道时非常有用。例如,而不是计算器服务订阅带有公共 result: 前缀的两个频道,我们可以将其简化如下:

subscriber.psubscribe('result:*')
subscriber.on('pmessage', function(operation, result) {
  console.log(operation + ' = ', result);
})

现在,任何额外的服务都可以使用 result: 前缀发布结果,并且可以被我们的计算器捕获。请注意,p 前缀也必须在 pmessage 事件监听器中体现。

使用 Seneca 的微服务

Seneca 是一个基于 Node 的微服务构建工具,它帮助您将代码组织成由 patterns 触发的 actions。Seneca 应用程序由可以接受 JSON 消息并可选地返回 JSON 的服务组成。服务注册对具有特定特征的消息感兴趣。例如,一个服务可能会在显示 { cmd: "doSomething" } 的 JSON 消息广播时运行。

首先,让我们创建一个响应两个模式的服务,一个模式返回 "Hello!",另一个返回 "Goodbye!"。创建一个包含以下代码的 hellogoodbye.js 文件:

var seneca = require('seneca')();
var client = seneca.client(8080);

require('seneca')()
.add({
  operation:'sayHello'
},
function(args, done) {
  done(null, {message: "Hello!"})
})
.add({
  operation:'sayGoodbye'
},
function(args, done) {
  done(null, {message: "Goodbye!"})
})
.listen(8080);

client.act({ operation: "sayHello" }, function(err, result) {
  console.log(result.message);
})

client.act({ operation: "sayGoodbye" }, function(err, result) {
  console.log(result.message);
})

seneca() 的调用启动了一个服务,该服务将在 localhost8080 端口上监听以 JSON 格式呈现的模式——要么是 { operation: "sayHello" },要么是 { operation: "sayGoodbye" }。我们还创建了一个连接到 8080 上 Seneca 服务的 client 对象,并让该客户端针对这些模式进行操作。当程序执行时,您将在终端看到 Hello!Goodbye! 的显示。

因为 Seneca 服务默认监听 HTTP,所以您可以通过直接通过 HTTP 调用 /act 路由来达到相同的结果:

curl -d '{"operation":"sayHello"}' http://localhost:8080/act
// {"message":"Hello!"}

现在,让我们复制之前开发的计算器应用程序,这次使用 Seneca。我们将创建两个服务,每个服务监听不同的端口,一个执行加法,另一个执行减法。与之前的计算器示例一样,每个服务都将作为一个独立进程启动并远程调用。

创建一个名为 add.js 的文件,如下所示:

require('seneca')()
.add({
  operation:'add'
},
function(args, done) {
  var result = args.operands[0] + args.operands[1];
  done(null, {
    result : result
  })
})
.listen({
  host:'127.0.0.1',
  port:8081
})

接下来,创建一个与 add.js 相同的 subtract.js 文件,只需更改其操作参数及其算法:

...
.add({
  operation:'subtract'
},
...
  var result = args.operands[0] - args.operands[1];
...

打开两个终端,并启动两个服务:

node add.js
...
node subtract.js

为了演示这些服务的使用,创建一个 calculator.js 文件,将客户端绑定到每个服务的唯一端口,并对它们进行操作。请注意,您必须创建不同的 Seneca 客户端:

var add = require('seneca')().client({
  host:'127.0.0.1',
  port:8081
})
var subtract = require('seneca')().client({
  host:'127.0.0.1',
  port:8082
})
add.act({
  operation:'add',
  operands: [7,3]
},
function(err, op) {
  console.log(op.result)
})
subtract.act({
  operation:'subtract',
  operands: [7,3]
},
function(err, op) {
  console.log(op.result)
})

执行此程序将产生以下结果:

10 // adding
4 // subtracting

就像之前的例子一样,我们可以直接进行 HTTP 调用:

curl -d '{"operation":"add","operands":[7,3]}' http://127.0.0.1:8081/act
// {"result":10}

通过这种方式构建您的计算器,每个操作都可以隔离到自己的服务中,并且您可以根据需要添加或删除功能,而不会影响整个程序。如果某个服务出现错误,您可以修复并替换它,而无需停止通用计算器应用程序。如果一个操作需要更强大的硬件或更多内存,您可以将其转移到自己的服务器上,而无需停止计算器应用程序或更改您的应用程序逻辑——您只需更改目标服务的 IP 地址。同样,很容易看出,通过将数据库、身份验证、事务、映射和其他服务连接起来,它们可以比所有这些都耦合到一个中心服务管理器时更容易进行建模、部署、扩展、监控和维护。

减少内存使用

JavaScript 诞生并成长于浏览器环境中。在其大部分历史中,这也意味着 JavaScript 程序是在拥有巨大内存池的桌面系统上运行的。因此,许多 JavaScript 程序员在传统上并没有太多考虑在他们的应用程序中管理内存。

在 Node 的世界里,内存并不便宜。根据 Joyent(github.com/joyent/node/wiki/FAQ#what-is-the-memory-limit-on-a-node-process):

"目前,默认情况下,v8 在 32 位系统上的内存限制为 512 MB,在 64 位系统上的内存限制为 1 GB。可以通过将--max_old_space_size 设置为最大值~1024 (~1 GB) (32 位)和~1741 (~1.7 GiB) (64 位)来提高限制,但如果你遇到内存限制,建议将单个进程拆分为多个工作者进程。"

让我们探讨一些可能的策略来减少您的 Node 程序消耗的内存量。我们将以讨论如何在开发项目时利用 Redis 支持的两种内存高效数据结构来结束。

使用流,而不是缓冲区

Node.js 原生模块的设计和实现遵循一个简单的指令:保持一切异步。这个设计原则,按照惯例,也影响了 Node 社区贡献的模块的设计。

当一个进程以同步方式运行时,它会持有或锁定它完成所需的总内存量,此时所持有的内存会被刷新,通常将此结果返回给调用方法或进程。例如,以下操作会在返回之前将整个文件加载到内存中:

var http = require('http')
var fs = require('fs')
http.createServer(function(req, res) {
  fs.readFile('./somefile.js', function(err, data) {
    res.writeHead(200);
    res.end(data)
  })
}).listen(8000)

当请求localhost:8000时,somefile.js文件会从文件系统中完整地读取并返回给客户端。这是期望的效果——但是存在一个小问题。因为整个文件在返回之前被推入缓冲区,所以每个请求都需要分配与文件字节大小相等的内存量。虽然这个操作本身是异步的(允许其他操作进行),但仅仅对一个非常大的文件(例如几个 MB)进行几次请求就可能导致内存溢出并使 Node 进程崩溃。

Node 在创建可扩展的 Web 服务方面表现出色。其中一个原因是对提供健壮的Stream接口的关注。

一个更好的策略是将文件直接流式传输到 HTTP 响应对象(它是一个可写流):

http.createServer(function(req, res) {
  fs.createReadStream('./static_buffered.js').pipe(res);
}).listen(8000)

除了需要更少的代码外,数据会直接发送(通过管道)到输出流,使用的内存非常少。

另一方面,我们可以使用 Stream 来启用一个非常优雅且可组合的转换管道。有几种方法可以实现这个目标(例如使用Transform Stream),但我们将只创建自己的转换器。

此脚本将从process.stdin获取输入,并将接收到的内容转换为大写,然后将结果通过管道返回到process.stdout

var Stream = require('stream')
var through = new Stream;
through.readable = true;
through.writable = true;
through.write = function(buf) {
  through.emit('data', buf.toString().toUpperCase())
}
through.end = function(buf) {
  arguments.length && through.write(buf)
  through.emit('end')
}
process.stdin.pipe(through).pipe(process.stdout);

尽可能地将您的程序逻辑转换为离散的流转换,并构建有用的管道,这些管道可以对数据进行有益的操作,而不触及内存。

理解原型

JavaScript 是一种面向对象OO)的基于原型的语言。了解这意味着什么以及这种设计在正确使用时如何比许多传统的面向对象语言设计更节省内存,这对您来说很重要。由于在 Node 进程内部存储状态数据是一种常见做法(例如,套接字服务器内的连接数据查找表),我们应该利用语言的原型特性来最小化内存使用。以下是对基于经典继承的对象模型和 JavaScript 提供的对象系统在内存使用和效率方面的简要但尖锐的比较。

在基于类的系统中,一个包含创建自身实例的指令。换句话说,一个类描述了一个包含根据类规范构建的对象的集合,这包括诸如构造对象属性默认值之类的东西。要创建类的实例,必须有一个描述如何构建该实例的类定义。类也可以相互继承属性,创建具有与其他蓝图共享特征的新实例蓝图——一个描述对象来源的继承模型。

任何面向对象系统的首要目的是促进相关对象之间共享通用知识。例如,这就是使用继承模型创建两个点实例的方式:

理解原型

注意,现在这两个实例都维护了相同的属性结构。此外,两个点实例的属性 x 都是从基点类复制的。重要的是要注意,尽管这个属性值在这两个实例中是相同的,但 x 的值已经被复制到每个实例中。

在原型语言中,对象不需要类来定义它们的组成。例如,JavaScript 中的对象可以按字面意思创建:

var myPoint = {
  x : 100,
  y : 50
}

不需要在创建对象实例之前存储类定义,这已经更加节省内存。现在,考虑使用原型来复制之前讨论的基于继承的示例。在下面的代码中,我们看到单个对象myPoint作为第一个对象传递给Object.create,它返回一个以myPoint作为其原型的新的对象:

 var myPoint = {
  x: 100,
  y: 50
}
var pointA = Object.create(myPoint, {
  y: 100
})
var pointA = Object.create(myPoint, {
  y: 200
})

注意

Object.create是现代 JavaScript(ES5+)中创建对象的推荐方法。较老的浏览器将不支持这种语法。有关兼容性的更多信息,请访问kangax.github.io/compat-table/es5/#Object.create

这创建了以下对象结构:

理解原型

注意,每个点实例不存储那些值未显式声明的属性的副本。原型系统使用消息委派,而不是继承。当一个点实例收到消息 给我 x,并且它无法满足这个请求时,它将满足该消息的责任委派给它的原型(在这种情况下,原型确实有 x 的值)。在现实世界的场景中,对于大型和复杂对象,能够在不冗余复制相同字节的情况下跨许多实例共享默认值,这将导致更小的内存占用。此外,这些实例本身也可以作为其他对象的原型,无限期地继续委派链,并使用仅足以区分唯一对象属性的内存量来实现优雅的对象图。

内存效率还可以加快实例化速度。从前面的代码中应该很明显,将消息的责任委派给原型意味着你的扩展接收器需要更小的实例占用——每个对象需要分配的槽位更少。以下是有两个构造函数定义:

var rec1 = function() {}
rec1.prototype.message = function() { ... }
var rec2 = function() {
  this.message = function() { ... }
}

即使有这些简单的定义,由第一个构造函数构建的实例在内存消耗上通常会比由第二个构造函数构建的相同数量的实例少得多——new Rec1() 由于第二个无原型构造函数中看到的冗余复制,将比 new Rec2() 完成得更快。

注意

你可以在 jsperf.com/prototype-speeds 上看到两种实例化方法的性能比较。

智能地使用原型来减少对象中的内存使用和降低实例化时间。确定对象的静态或很少改变的属性和方法,并将它们放入原型中。这将允许你快速创建成千上万的对象,同时减少冗余。

使用 Redis 的内存高效数据结构

虽然你应该使用每个 Node 进程分配给你的内存,但可能还需要更多的内存。在本节中,我们将探讨 Redis,一个内存中、高速的数据库,以及它如何被用来有效地扩展程序可用的内存量。

在最基本的意义上,Redis 是一个快速的键值存储。我们稍后将会看到它如何被用作常用数据的缓存。然而,它还提供了强大的数据结构和 API,允许对这些结构进行复杂操作,从而帮助建模数据集及其之间的关系。在这里,我们将讨论如何使用 Redis 对 位操作bitops)和 HyperLogLog 的支持——两种空间高效且,更重要的是,空间可预测的内存结构来存储和分析数据的活动。

使用位操作分析用户随时间的行为

Redis 提供的一个更有趣的特性是能够将二进制数字作为键的值存储。可以使用 位运算符 AND、OR 和 XOR 来比较包含二进制值的多个键。通过应用位掩码将一系列位映射到其他二进制值,你可以进行非常快速且内存高效的比较分析。在本节中,我们将学习一些如何使用这种技术的典型示例。

Redis 数据库中的任何键都可以存储 (2³² - 1) 位或略小于 512 MiB。这意味着每个键可以设置大约 42.9 亿个列,或偏移量。这是一个由单个键引用的大量数据点。我们可以设置这些范围内的位来描述我们想要跟踪的项目特征,例如查看特定文章的用户数量。此外,我们可以使用位运算来收集其他维度的信息,例如文章观看者中女性的百分比。让我们看看几个例子。

设置、获取和计数位

假设我们正在提供许多不同的文章,并且每篇文章都分配了一个唯一的标识符。还假设我们网站上活跃着 100,000 名成员,并且每个用户也都有一个唯一的标识符——一个介于 1 和 100,000 之间的数字。使用位运算,我们可以轻松跟踪特定一天的文章观看活动,通过在 Redis 中创建一个键来实现,这可以通过组合文章的唯一键和日期字符串,并在该键上设置与文章观看关联的用户 ID 对应的位来完成。例如:

article:324:01-03-2014 : 00010100111010001001111...

这个键代表特定日期上的第 324 篇文章,通过在用户分配的 ID 对应的偏移量处 翻转位,有效地存储了那天观看者的唯一用户 ID。每当用户观看一篇文章时,获取该用户的 ID,使用该数字作为偏移值,并使用 setbit 命令在该偏移量处设置位:

redis.setbit('article:324:01-03-2014', userId, 1)

在接下来的内容中,我们将演示如何使用 Redis 位运算来高效地存储和分析数据。首先,让我们为三篇文章创建数据:

var redis = require('redis');
var client = redis.createClient();
var multi = client.multi();
//  Create three articles with randomized hits representing user views
var id = 100000;
while(id--) {
  multi.setbit('article1:today', id, Math.round(Math.random(1)));
  multi.setbit('article2:today', id, Math.round(Math.random(1)));
  multi.setbit('article3:today', id, Math.round(Math.random(1)));
}
multi.exec(function(err) {
  // done
})

在这里,我们简单地创建了三个 Redis 键,'article (1-3):today',并在每个键上随机设置了 100,000 个位——要么是 0,要么是 1。使用基于用户 ID 偏移量存储用户活动的技术,我们现在有了针对三个文章的假设性一天流量的样本数据。

注意

我们正在使用 Matt Ranney 的node_redis模块(github.com/mranney),它支持 Redis multi构造,允许在一个管道中执行多个指令,而不是单独调用每个指令所付出的成本。在执行多个操作时始终使用multi以加快操作速度。注意 Redis 提供的排序保证确保了有序执行,以及它的原子性保证,即事务中的所有或没有指令将成功。参见redis.io/topics/transactions

要计算查看文章的用户数量,我们可以使用bitcount

client.bitcount('article1:today', function(err, count) {
  console.log(count)
})

这很简单:看到文章的用户数量等于键上设置的位数量。现在,让我们计算文章的总阅读次数:

client.multi([
  ["bitcount", "article1:today"],
  ["bitcount", "article2:today"],
  ["bitcount", "article3:today"]
]).exec(function(err, totals) {
  var total = totals.reduce(function(prev, cur) {
    return prev + cur;
  }, 0);
  console.log("Total views: ", total);
})

一旦multi返回一个数组,其中包含每个操作(位计数)对应的 Redis 返回的结果,我们就reduce计数到一个表示我们所有文章总阅读次数的求和。

如果我们感兴趣的是用户 123 今天看了多少篇文章,我们可以使用getbit,它简单地返回给定偏移量的值(要么是 0 要么是 1)。结果将在 0–3 的范围内:

client.multi([
  ["getbit", "article1:today", 123],
  ["getbit", "article2:today", 123],
  ["getbit", "article3:today", 123]
]).exec(function(err, hits) {
  var total = hits.reduce(function(prev, cur) {
    return prev + cur;
  }, 0);
  console.log(total); // 0, 1, 2 or 3
})

这些是从位表示中获取信息非常有用且直接的方法。让我们更进一步,了解如何使用位掩码和 AND、OR 和 XOR 运算符进行位过滤。

位掩码和过滤结果

之前,我们学习了如何计算用户 123 看到的文章数量。如果我们想检查用户 123 是否阅读了这两篇文章呢?使用 bitop AND,这很容易实现:

client.multi([
  ['setbit', 'user123', 123, 1],
  ['bitop', 'AND','123:sawboth','user123','article1:today','article3:today'], 
  ['getbit', '123:sawboth', 123]
]).exec(function(err, result) {
  var sawboth = result[2];
  console.log('123 saw both articles: ', !!sawboth);
});

首先,我们创建一个掩码,它隔离了存储在键'user123'中的特定用户,该掩码在偏移量 123 处有一个单独的正位(再次表示用户的 ID)。两个或多个位表示的 AND 操作的结果不是由 Redis 作为值返回,而是写入到指定的键,在先前的例子中给出为'123:sawboth'。这个键包含位表示,回答了文章键是否同时包含在user123键相同偏移量处也有正位的位表示。

如果我们想找到至少看过一篇文章的总用户数呢?在这种情况下,bitop OR 工作得很好:

client.multi([
  ['bitop', 'OR','atleastonearticle','article1:today','article2:today','article3:today'],
  ['bitcount', 'atleastonearticle']
]).exec(function(err, results) {
  console.log("At least one: ", results[1]);
});

这里,'atleastonearticle'键标记了在三个文章中的任何一个中设置的偏移量上的位。

我们可以使用这些技术来创建一个简单的推荐引擎。例如,如果我们能够通过其他方式确定两篇文章是相似的(基于标签、关键词等),我们可以找到所有阅读了一篇并推荐另一篇的用户。为此,我们将使用 XOR 来找到所有阅读了第一篇文章或第二篇文章,但没有同时阅读两篇的用户。然后我们将该集合分成两个列表:阅读了第一篇文章的人和阅读了第二篇文章的人。然后我们可以使用这些列表来提供推荐:

client.multi([
  ['bitop','XOR','recommendother','article1:today','article2:today'], 
['bitop','AND','recommend:article1','recommendother','article2:today'], 
  ['bitop','AND','recommend:article2','recommendother','article1:today'], 
  ['bitcount', 'recommendother'], 
  ['bitcount', 'recommend:article1'], 
  ['bitcount', 'recommend:article2'], 
  ['del', 'recommendother', 'recommend:article1', 'recommend:article2'] 
]).exec(function(err, results) { 
  //  Note result offset due to first 3 setup ops 
  console.log("Didn't see both articles: ", results[3]); 
  console.log("Saw article2; recommend article1: ", results[4]); 
  console.log("Saw article1; recommend article2: ", results[5]); 
})

虽然不是必需的,但我们也会获取每个列表的计数,并在完成后删除结果键。

在 Redis 中,一个二进制值占用的总字节数是通过将最大偏移量除以 8 来计算的。这意味着存储一篇文章的 1,000,000 个用户的访问数据只需要 125 KB——并不是很多。如果你在数据库中有 1,000 篇文章,你可以用 125 MB 存储 1,000,000 个用户的完整访问数据——再次强调,这不是一个很大的内存或存储量,可以用来换取这样丰富的一套分析数据。此外,所需的存储量可以提前精确计算。

注意

查看代码包以了解如何构建一个喜欢此页面服务的示例,其中我们使用书签来触发任何 URL 上的喜欢,使用位操作来存储每次喜欢发生的时间(相对于给定日期的当前秒数)。

其他部署位操作想法的有用方式也容易找到。考虑如果我们为键分配 86,400 位(一天中的秒数)并设置与当天当前秒相对应的位,每当执行特定操作(如登录)时,我们就花费了86400 / 8 / 1000 = 10.8 KB来存储登录数据,这些数据可以很容易地使用位掩码进行过滤以提供分析数据。

作为练习,使用位掩码来展示文章阅读者的性别分布。假设我们在 Redis 中存储了两个键,一个反映被识别为女性的用户 ID,另一个反映男性:

users:female  : 00100001011000000011110010101...
users:male  : 11011110100111111100001101010...

使用位操作,我们通过性别过滤文章。

使用 HyperLogLog 来计数独特的匿名访客

使用数据库最常见的事情之一是存储和计数独特的事物。特定类型的事件发生了多少次?创建了多少标签?

考虑每个营销人员几乎都会做的任务:计算访问网页的独特访客数量。传统上,计数是通过在数据库中写入一行数据或在日志中写入一行文本来完成的,每当访客访问页面时。每次独特的访问都会使集合长度增加一。这些是简单直接的技术。

然而,存在一个问题:如果同一个人多次访问同一页面怎么办?每当用户 John 访问一个页面时,必须做一些工作来确定这是首次出现(记录它),还是重复出现(不记录它)。还有一个问题:表示唯一标识符的字节序列——通常是一个非常长的哈希——必须被存储。每个唯一的项目都会增加用于跟踪集合基数项目计数的总内存消耗。由于我们无法预先知道会有多少唯一点击,因此我们无法知道存储这种潜在活动所需的内存量;因此,当某个页面或另一个页面一夜之间变得非常受欢迎、病毒式传播等情况发生时,我们的系统可能会被压垮。

HyperLogLog 是一种概率数据结构,它允许在固定的内存分配内计数几乎无限数量的唯一项目。正如 Salvatore Sanfilippo 在 antirez.com/news/75 中所说:

"HyperLogLog 是一个显著的算法,因为它即使在只使用非常小的内存量时,也能提供集合基数的一个非常好的近似值。在 Redis 的实现中,它每个键只使用 12k 字节来计数,标准误差为 0.81%,并且你可以计数的项目数量没有限制,除非你接近 2⁶⁴ 个项目(这似乎不太可能)。"

在您的代码包中,您将找到一个包含简单计数应用的 /hyperloglog 文件夹。通过运行 server.js 来启动此应用,然后在浏览器中访问 localhost:8080。当您到达那里时,点击 发送特定值 按钮。您应该看到以下输出:

使用 HyperLogLog 计算唯一匿名访客

您已将值 123 插入到 HyperLogLog 键中,返回的数字(1)是该键集合的基数。点击相同的按钮几次——鉴于这种结构维护了唯一值的计数,数字不应改变。现在,尝试添加随机值。您将看到返回的数字增加。无论您在日志键中输入多少条记录,使用的内存量都是相同的。这种可预测性在扩展您的应用时非常好。

您可以在代码包中找到描述此客户端界面的 index.html 页面。客户端需要做的只是向 localhost:8080/log/<some value> 发送一个 XHR 请求。请随意浏览代码。更重要的是,让我们看看服务器上如何定义相关路由处理程序来插入值到 HyperLogLog 并检索日志基数:

var http   = require('http');
var redis  = require('redis');
var client = redis.createClient();
var hyperLLKey = 'hyper:uniques';

...

http.createServer(function(request, response) {

  var route  = request.url;
  var val    = route.match(/^\/log\/(.*)/);

...

  if(val) {
    val = val[1];
    return client.pfadd(hyperLLKey, val, function() {
      client.pfcount(hyperLLKey, function(err, card) {
        respond(response, 200, JSON.stringify({
          count: err ? 0 : card
        }))
      })
    });
  }
}).listen(8080)

在验证我们在 /log 路由上收到新值后,我们使用 PFADD 命令(在 Redis 中,如果在插入操作时键不存在,则自动创建)将该值添加到 hyperLLKey。一旦成功插入,就查询键的 PFCOUNT,并将更新后的集合基数返回给客户端。

此外,PFMERGE 命令允许您合并(创建多个 HyperLogLog 集合的并集)并获取结果集合的基数。以下代码将产生基数值为 10

var redis  = require('redis');
var client= redis.createClient();
var multi  = client.multi();

client.multi([
  ['pfadd', 'merge1', 1, 2, 3, 4, 5, 6, 10],
  ['pfadd', 'merge2', 1, 2, 3, 4, 5, 6, 7, 8, 9],
  ['pfmerge', 'merged', 'merge1', 'merge2'],
  ['pfcount', 'merged'],
  ['del', 'merge1', 'merge2', 'merged']
]).exec(function(err, result) {
  console.log('Union set cardinality', result[3]);
});

近似合并集合基数的能力让人联想到我们在探索位运算时看到的那些高效的分析可能性。当许多唯一值的计数在分析中很有用,并且一个不精确但非常接近的计数足够时(例如,跟踪今天登录的用户数量、查看的总页面数等),请考虑使用 HyperLogLog。

驯服 V8 和优化性能

V8 管理 Node 的主进程线程。当执行 JavaScript 时,V8 在其自己的进程中执行,并且其内部行为受 Node 控制。然而,我们可以编写 JavaScript 代码来帮助 V8 实现最佳的编译结果。在本节中,我们将重点介绍如何编写高效的 JavaScript,并查看我们可以传递给 V8 的特殊配置标志,这些标志有助于保持我们的 Node 进程快速且轻量。

小贴士

您可以通过输入以下命令来查看您的 Node 安装使用的 V8 版本:

node –e "console.log(process.versions.v8)"

优化 JavaScript

动态语言的便利性在于避免了编译语言强加的严格性。例如,您不需要显式定义对象属性类型,实际上可以随意更改这些属性类型。这种动态性使得传统的编译变得不可能,但为探索性语言(如 JavaScript)开辟了有趣的新机会。然而,与静态编译语言相比,动态性在执行速度方面引入了显著的惩罚。JavaScript 的有限速度经常被识别为其主要弱点之一。

V8 试图以编译语言的速度实现 JavaScript。V8 试图将 JavaScript 编译成原生机器代码,而不是解释字节码或使用其他即时技术。由于 JavaScript 程序的确切运行时拓扑结构无法提前知道(该语言是动态的),编译包括一个两阶段、推测性的方法:

  1. 最初,一次编译器尽可能快地将您的代码转换为可运行状态。在此步骤中,类型分析和代码的其他详细分析被推迟,以实现快速编译——您的 JavaScript 可以尽可能快地开始执行。进一步的优化在第二步完成。

  2. 一旦程序运行起来,优化编译器就开始执行其工作,监视程序运行情况并尝试确定其当前和未来的运行特性,根据需要优化和重新优化。例如,如果一个函数被多次以相似的一致类型参数调用,V8 会重新编译该函数以使用优化代码。虽然第一次编译步骤在未知和未类型化的函数签名上较为保守,但这个热点函数可预测的纹理促使 V8 假设一个特定的最优配置文件并据此重新编译。

假设帮助我们更快地做出决定,但可能导致错误。如果 V8 编译器刚刚针对某个类型签名优化了热点函数,现在却用违反该优化配置文件的参数调用该函数,那会怎样?在这种情况下,V8 别无选择:它必须去优化该函数——V8 必须承认其错误并撤销所做的工作。如果将来看到新的模式,它将重新优化。然而,如果 V8 在以后的时间必须再次去优化,并且这种优化/去优化的二进制切换持续进行,V8 将简单地放弃,并让你的代码保持在去优化状态。

V8 团队关注的两个重点是实现快速的属性访问和动态创建高效的机器代码。让我们看看如何设计数组的声明、对象和函数,以便你能够帮助而不是阻碍编译器。

数字和跟踪优化/去优化

ECMA-262 规范将Number值定义为与双精度、64 位二进制格式 IEEE 754 值相对应的原始值。重点是 JavaScript 中没有整数类型;有一个Number类型定义为双精度浮点数。

由于性能原因,V8 内部使用 32 位数字来表示所有值,这里不讨论过于技术性的原因。可以说,如果需要更大的宽度,则使用一个位来指向另一个 32 位数字。无论如何,很明显,V8 将数值分为两种类型,并在这些类型之间切换将消耗一些资源。尽可能将需求限制在 31 位有符号整数。

由于 JavaScript 的类型歧义性,允许将分配给槽位的数字类型进行切换。以下代码不会抛出错误:

var a = 7;
a = 7.77;

然而,像 V8 这样的投机编译器将无法优化这个变量赋值,因为其猜测a始终是整数的结果证明是错误的,迫使去优化。

我们可以通过使用在执行代码时提供的强大 V8 选项来演示这一点:在 Node 程序中执行 V8 原生命令并跟踪 V8 如何优化/去优化你的代码。

考虑以下 Node 程序:

var someFunc = function foo(){}
console.log(%FunctionGetName(someFunc));

如果您尝试正常运行,您会收到一个 Unexpected Token 错误——JavaScript 中不能在标识符名称中使用取模符号(%)。这个以 % 前缀为前缀的奇怪方法是什么?这是一个 V8 本地命令,我们可以通过使用 --allow-natives-syntax 标志来执行这些类型的函数,如下所示:

node --allow-natives-syntax program.js
// foo

注意

您可以通过浏览 V8 源代码了解可用的本地函数,code.google.com/p/v8/source/browse/trunk/src/runtime.cc?r=22500,并搜索 runtime_function

现在,考虑以下代码,它使用本地函数通过 %OptimizeFunctionOnNextCall 本地方法断言关于 square 函数优化状态的信息:

var operand = 3;
function square() {
  return operand * operand;
}
//  Make first pass to gather type information
square();
//  Ask that the next call of #square trigger an optimization attempt;
//  Call
%OptimizeFunctionOnNextCall(square);
square();

使用前面的代码创建一个文件,并使用以下命令执行它:

node --allow-natives-syntax --trace_opt --trace_deopt myfile.js

您将看到以下类似输出返回:

[deoptimize context: c39daf14679]
[optimizing: square / c39dafca921 - took 1.900, 0.851, 0.000 ms]

我们可以看到,V8 没有问题优化 square 函数,因为操作数只声明了一次且从未改变。现在,将以下行添加到您的文件中,并再次运行它:

%OptimizeFunctionOnNextCall(square);
operand = 3.01;
square();

在这次执行中,根据之前给出的优化报告,您现在应该收到以下类似输出:

**** DEOPT: square at bailout #2, address 0x0, frame size 8
[deoptimizing: begin 0x2493d0fca8d9 square @2]
...
[deoptimizing: end 0x2493d0fca8d9 square => node=3, pc=0x29edb8164b46, state=NO_REGISTERS, alignment=no padding, took 0.033 ms]
[removing optimized code for: square]

这个非常表达性的优化报告清楚地讲述了故事——之前优化的 square 函数在改变一个数字类型后进行了去优化。您被鼓励花时间编写代码,并使用这些方法现在以及您通过本节进行测试。

对象和数组

当我们调查数字时,我们了解到 V8 在您的代码可预测时工作得最好。这与数组和对象相同。以下所有 不良做法 几乎都是因为它们创造了不可预测性。

记住,在 JavaScript 中,对象和数组在底层非常相似。我们不会讨论这些差异,但只会讨论重要的相似之处,特别是关于这两种数据结构如何从类似的优化技术中受益。

避免在数组中混合类型。始终最好有一个一致的数据类型,例如 所有整数所有字符串。如果可能,避免在初始化后更改数组或属性赋值中的类型。V8 通过创建隐藏类来跟踪类型来创建对象的 蓝图,当这些类型发生变化时,优化蓝图将被销毁并重建——如果您幸运的话。有关更多信息,请参阅以下链接:

developers.google.com/v8/design

不要创建带有间隙的数组,以下是一个示例:

var a = [];
a[2] = 'foo';
a[23] = 'bar';

稀疏数组因为这个原因不好:V8 可以使用非常高效的线性存储策略来存储(和访问)你的数组数据,或者它可以使用哈希表(这要慢得多)。如果你的数组是稀疏的,V8 必须选择两种中效率较低的一种。出于同样的原因,始终从零索引开始你的数组。此外,永远不要使用delete从数组中删除元素。你只是在那个位置插入一个undefined值,这实际上是创建稀疏数组的一种方式。同样,小心使用空值填充数组——确保你推送到数组中的外部数据不是不完整的。

尽量不要预先分配大数组——随着需要增长。同样,不要预先分配一个数组然后超过那个大小。你总是想避免让 V8 将你的数组转换成哈希表。

每当向对象构造函数添加新属性时,V8 都会创建一个新的隐藏类。尽量在对象实例化后避免添加属性。在构造函数中初始化所有成员的顺序应相同。相同的属性 + 相同的顺序 = 相同的对象

记住 JavaScript 是一种动态语言,允许在实例化后修改对象(和对象原型)。因此,对象的形状和体积可以在事后改变,那么 V8 是如何为对象分配内存的呢?它做出了一些合理的假设。从给定的构造函数实例化了一定数量的对象(我相信是 8 个是触发数),其中最大的被认为是最大大小,所有后续实例都分配了这么多内存(初始对象也会相应地调整大小)。然后根据这个假设的最大大小为每个实例分配总共 32 个快速属性槽位。任何额外属性都会放入一个(较慢的)溢出属性数组中,该数组可以调整大小以适应任何进一步的新属性。

就像数组一样,对于对象,尽可能以未来兼容的方式定义你的数据结构的形状,包括一组属性、类型等。

函数

函数通常会被频繁调用,因此应该是你优化重点之一。包含 try-catch 结构的函数不可优化,包含其他不可预测结构的函数也不可优化,例如witheval。如果由于某种原因你的函数不可优化,请尽量减少其使用。

一个非常常见的优化错误涉及多态函数的使用。接受可变函数参数的函数将被优化。避免使用多态函数。

缓存策略

缓存,通常,是创建易于访问的资产中间版本的战略。当检索资产代价高昂——从时间、处理器周期、内存等方面来看——你应该考虑缓存该资产。例如,如果每次加拿大人访问时都必须从数据库中检索加拿大省份的列表,那么将这个列表存储在静态格式中,从而避免每次访问都执行昂贵的数据库查询操作,这是一个好主意。良好的缓存策略对于任何基于 Web 的应用程序至关重要,这些应用程序为大量渲染的数据视图提供服务,无论是 HTML 页面还是 JSON 结构。缓存内容可以以低廉的成本和速度提供服务。

无论何时部署不经常更改的内容,你很可能希望缓存你的文件。常见的两种静态资产类型包括:像公司标志这样的资产,作为内容文件夹中现有的文件,几乎不会改变。其他资产虽然更频繁地改变,但远不如每次请求资产时那么频繁。这类资产包括 CSS 样式表、用户联系名单、最新头条新闻等等。创建一个可靠且高效的缓存系统是一个非同小可的问题:

"在计算机科学中,只有两件难事:缓存失效和命名事物。"
--Phil Karlton

在本节中,我们将探讨两种缓存应用程序内容的方法。首先,我们将探讨使用 Redis 作为内存中的键值缓存来存储常用 JSON 数据,了解 Redis 键过期和键扫描。最后,我们将研究如何使用 CloudFlare 内容分发网络CDN)来管理你的内容,在这个过程中,我们将学习如何使用 Node 监控文件更改,并在检测到更改事件时使 CDN 缓存失效。

使用 Redis 作为缓存

在之前实现的示例 session-store 中,cookie 值存储在 Redis 中,并与传入的值进行匹配以提供简单的会话管理。这种对小型内存值进行定期检查的模式在多用户环境中很常见,为此开发了像memcached这样的技术。

Redis 完全能够作为一个类似的内存缓存系统运行。让我们实现一个简单的缓存层,使用 Redis 智能管理键关联和过期。

由于许多类型的信息将被缓存,为缓存键命名空间是一个好主意。我们将构建我们的缓存库,以便可以实例化单个命名空间感知的缓存 API:

var redis  = require('redis');
var util   = require('util');
var Promise = require('bluebird');
var Cache = function(config) {
  config = config || {};
  this.prefix = config.prefix ? config.prefix + ':' : 'cache:';

  var port = config.port || 6379;
  var host = config.host || 'localhost';

  this.client = redis.createClient(port, host, config.options || {});

  config.auth && this.client.auth(config.auth);
};

通常,我们的缓存层将与任何特定的服务器解耦,因此在这里我们设计了一个构造函数,它期望 Redis 的连接和认证信息。注意前缀参数。要实例化一个缓存实例,请使用以下代码:

var cache = new Cache({ prefix: 'articles:cache' });

还请注意,我们将通过bluebird库(github.com/petkaantonov/bluebird)使用Promises来实现缓存 API。

获取缓存值很简单:

Cache.prototype.get = function(key) {
  key = this.prefix + key;
  var client = this.client;
  return new Promise(function(resolve, reject) {
    client.hgetall(key, function(err, result) {
      err ? reject() : resolve(result);
    });
  });
};

所有缓存键都将实现为 Redis 哈希,因此GET操作将涉及在键上调用hmget。由 Promises 支持的 API 现在允许以下易于理解的语法:

cache.get('sandro').then(function(val) {
  console.log('cached: ' + val);
}).catch() {
  console.log('unable to fetch value from cache');
})

设置值只是传递一个对象的问题:

Cache.prototype.set = function(key, val, ttl) {
  var _this = this;
  var pkey = this.prefix + key;
  var client = this.client;
  var setArr = [];

  for(var k in val) {
    setArr[k] = val[k];
  }
  return new Promise(function(resolve, reject) {
    client.hmset(pkey, setArr, function(err) {
      err ? reject() : resolve();
      ttl && _this.expire(key, ttl);
    });
  });
};

当接收到val时,我们将它的键值映射反映在存储在key的 Redis 哈希中。可选的第三个参数ttl允许在 Redis 中设置一个标志,在经过一定秒数后使该键过期,并标记为删除。this.expire中的关键代码如下:

client.expire(key, ttl, function(err, ok) { // ...flagged for removal }

更多关于 Redis expire的信息,请访问 redis.io/commands/expire

remove方法只是对 Redis 键空间进行del操作,因此这里无需解释。更有趣的是clear方法的实现,用于从 Redis 中删除所有具有给定前缀的键:

Cache.prototype.clear = function() {
 var prefixMatch = this.prefix + '*';
 var client   = this.client;
 return new Promise(function(resolve, reject) {
  var multi = client.multi();
  (function scanner(cursor) {
   client.scan([+cursor, 'match', prefixMatch], function(err, scn) {
    if(err) {
     return reject();
    }
    // Add new delete candidates
    multi.del(scn[1]);
    // More? Continue scan.
    if(+scn[0] !== 0) {
     return scanner(scn[0]);
    }
    // Delete candidates, then resolve.
    multi.exec(resolve);
   })
  })(0);
 });
};

注意我们使用的scan方法来定位和删除匹配我们缓存前缀的键。Redis 被设计为高效,其设计者尽可能避免添加缓慢的功能。与其他数据库不同,Redis 没有高级的查找方法来搜索其键空间,开发者只能限于使用keys和基本通配符模式匹配。由于 Redis 键空间中通常有数百万个键,使用keys的操作不可避免地或由于粗心大意,可能会变得非常昂贵,因为长时间的操作会阻塞其他操作——事务是原子的,Redis 是单线程的。

scan方法允许您以迭代方式获取键空间的一定范围,从而实现(非阻塞)异步键空间扫描。扫描对象本身是无状态的,只传递一个指示是否有更多记录要获取的光标。使用这种技术,我们能够清除所有以我们的目标缓存键(模式:this.prefix + '*')为前缀的键。在每次扫描迭代中,我们使用multi.del函数将返回的任何键排队以供删除,直到扫描器返回零值(表示所有要查找的键都已返回),此时我们使用一个命令删除所有这些键。

将这些方法结合起来:

cache.set('deploying', { foo: 'bar' })
.then(function() {
 return cache.get('deploying');
})
.then(function(val) {
 console.log(val); // foo:bar
 return cache.clear();
})
.then(cache.close.bind(cache));

这是一个简单的缓存策略,可以帮助您入门。虽然自己管理键的过期时间是一种完全有效的技术,但随着您进入更大的生产实施,考虑直接配置 Redis 的驱逐策略。例如,您可能希望将redis.conf中的maxmemory值设置为缓存内存的最大上限,并配置 Redis 在内存限制达到时使用六种文档中记录的驱逐策略之一,例如最近最少使用LRU)。更多信息,请访问:redis.io/topics/lru-cache

将 CloudFlare 部署为 CDN

CDN 通常是一个全球性的服务器网络,这些服务器被租给无法自己出资和建设自己网络的公司。CDN 的设置是为了确保你的应用程序或其他内容对任何希望访问它的人来说都是可用的,无论他们选择在世界上的哪个地方访问,并且你的内容能够快速交付。Akamai 可能是最著名的 CDN,而 CloudFlare 是一个最近加入的,特别关注安全和“攻击防护”网络。

对于我们的目的来说,CloudFlare 提供了一个免费的服务层,这满足了大多数部署应用的需求。在接下来的示例中,你将学习如何使用 CloudFlare 启用缓存。然后我们将使用 cloudflare 模块在文件更改时清除你的域名文件,在这个过程中学习如何使用 Node 的 fs.watch 方法来监视文件更改。

注意

CloudFlare 还开始了一个雄心勃勃的努力,在其 CDN 上托管所有 JS,网址为 cdnjs.com/。与其他只托管最流行 JavaScript 库的流行托管服务不同,CloudFlare 托管了在 github.com/cdnjs/cdnjs 的 GitHub 开放项目中表示的所有项目。考虑通过此服务部署你的 JavaScript 文件。

首先,访问 www.cloudflare.com/sign-up 并设置一个免费账户。你需要一个域名来托管文件——遵循说明来配置你的域名服务器和其他 DNS 信息。一旦注册,你将收到一个身份验证令牌,并使用此令牌将 CDN 支持添加到你的应用程序中。CloudFlare 默认不缓存 HTML 文件。要启用 HTML 缓存,请访问你的仪表板,找到你的域名,打开选项菜单,并选择 页面规则。如果你的域名是 foo.com,以下页面规则将启用完全缓存:*foo.com/*。最后,在页面规则管理页面上找到 自定义缓存 下拉菜单并选择 缓存一切

现在,让我们与 CloudFlare 建立连接:

var http = require('http');
var fs = require('fs');
var cloudflare = require('cloudflare');
var config = {
  "token": "your token",
  "email": "your account email",
  "domain": "yourdomain.com",
  "subdomain": "www",
  "protocol": "http"
};
var cloudflareClient = cloudflare.createClient({
  email: config.email,
  token: config.token
});

在我们的示例中,我们将提供(并修改)一个单独的 index.html 文件。对于这个示例,我们将创建一个简单的服务器:

var indexFile = './index.html';
http.createServer(function(request, response) {
  var route = request.url;
  if(route === "/index.html") {
    response.writeHead(200, {
      "content-type": "text/html",
      "cache-control": "max-age=31536000"
    });
    return fs.createReadStream(indexFile).pipe(response);
  }
}).listen(8080);

注意 max-age 是如何在 cache-control 头部设置的。这将向 CloudFlare 指示我们希望缓存此文件。

服务器设置完成后,我们现在将添加以下 purge 方法:

function purge(filePath, cb) {
  var head = config.protocol + '://';
  var tail = config.domain + '/' + filePath;
  //  foo.com && www.foo.com each get a purge call
  var purgeFiles = [
    head + tail,
    head + config.subdomain + '.' + tail
  ];
  var purgeTrack = 2;
  purgeFiles.forEach(function(pf) {
    cloudflareClient.zoneFilePurge(config.domain, pf, function(err) {
      (--purgeTrack === 0) && cb();
    });
  });
};

当此方法传递一个文件路径时,它会要求 CloudFlare 清除该文件的缓存。注意我们如何必须使用两个清除操作来适应子域名。

在清除设置完成后,剩下的就是监视文件系统中的更改。这可以通过 fs.watch 命令来完成:

fs.watch('./index.html', function(event, filename) {
  if(event === "change") {
    purge(filename, function(err) {
      console.log("file purged");
   });
  }
});

现在,每当index.html文件发生变化时,我们的 CDN 将刷新其缓存的版本。创建该文件,启动服务器,并将你的浏览器指向localhost:8080,打开你的索引文件。在你的浏览器开发者控制台中检查响应头——你应该看到一个CF-Cache-Status: MISS记录。这意味着CloudFlareCF)已经从你的服务器获取并服务了原始文件——在第一次调用时,还没有缓存的版本,因此缓存未命中。重新加载页面。现在相同的响应头应该读取为CF-Cache-Status: HIT。你的文件已缓存!

尝试以某种方式更改索引文件。当你重新加载浏览器时,更改后的版本将被显示——其缓存的版本已被清除,文件再次从你的服务器获取,你将再次看到MISS头值。

你可能希望扩展此功能以包括更多的文件和文件夹。要了解更多关于fs.watch的信息,请访问nodejs.org/api/fs.html#fs_fs_watch_filename_options_listener

管理会话

HTTP 协议是无状态的。任何给定的请求都没有关于之前请求的信息。对于服务器来说,这意味着在没有进一步工作的前提下,确定两个请求是否来自同一浏览器是不可能的。这对于一般信息来说是可以的,但针对特定交互则需要通过某种唯一标识符来验证用户。唯一标识的客户可以接收针对特定内容的服务——从朋友列表到广告。

这种半永久性的客户端(通常是浏览器)与服务器之间的通信会持续一段时间——至少直到客户端断开连接。这段时间被称为会话。管理会话的应用程序必须能够创建一个唯一的用户会话标识符,跟踪会话期间已识别用户的活动,并在请求或由于某些其他原因(例如达到会话限制)时断开该用户。

在本节中,我们将实现一个用于会话管理的JSON Web TokenJWT)系统。JWT 相对于基于 cookie 的传统会话有优势,因为它们不需要服务器维护会话存储,因为 JWT 是自包含的。这极大地帮助了部署和扩展。它们也适合移动设备,并且可以在客户端之间共享。虽然是一个新标准,但 JWT 应被视为一个简单且可扩展的应用程序会话存储解决方案。

JSON Web Token 身份验证和会话管理

基本认证系统可能要求客户端在每次请求时发送用户名和密码。为了启动基于令牌的认证会话,客户端只需发送一次凭证,然后收到一个令牌作为交换,并在随后的请求中只发送那个令牌,从而获得该令牌提供的任何访问权限。不再需要不断传递敏感凭证,如下面的图示所示:

JSON Web Token 认证和会话

JWT 的一个特定优点是服务器不再负责维护对共享凭证数据库的访问,因为只有发行机构需要验证初始登录。当使用 JWT 时,无需维护会话存储。因此,发行的令牌(可以将其视为访问卡)可以在任何认可并接受它的域(或服务器)中使用。在性能方面,请求的成本现在是解密哈希的成本与调用数据库验证凭证的成本之比。我们还避免了在移动设备上使用 cookie 可能遇到的问题,例如跨域问题(cookie 是域绑定的)、某些类型的请求伪造攻击等。

让我们看看 JWT 的结构,并构建一个简单的示例,演示如何发行、验证以及其他如何使用 JWT 来管理会话。

JWT 令牌具有以下格式:

<base64-encoded header>.<base64-encoded claims>.<base64-encoded signature>

每个部分都以 JSON 格式进行描述。

头部简单地描述了令牌——它的类型和加密算法。以下代码作为示例:

{
  "typ":"JWT",
  "alg":"HS256"
}

这里,我们声明这是一个 JWT 令牌,它使用HMAC SHA-256进行加密。

注意

有关加密和如何在 Node 中执行加密的更多信息,请参阅nodejs.org/api/crypto.html。JWT 规范本身可以在self-issued.info/docs/draft-ietf-oauth-json-web-token.html找到。请注意,JWT 规范在撰写本文时处于草案状态,因此将来可能会有所变动。

声明部分概述了任何接收 JWT 的服务应检查的安全和其他约束。请参阅规范以获取完整说明。通常,JWT 声明将希望表明 JWT 何时被发行,谁发行的,何时过期,JWT 的主题是谁,以及谁应该接受 JWT:

{
  "iss" : "http://blogengine.com",
  "aud" : ["http://blogsearch.com", "http://blogstorage"],
  "sub" : "blogengine:uniqueuserid",
  "iat" : "1415918312",
  "exp" : "1416523112",
  "sessionData" : "<some data encrypted with secret>"
}

iat(签发时间)和exp(过期时间)声明都设置为表示自 Unix 纪元以来秒数的数值。iss(发行者)应该是一个描述 JWT 发行者的 URL。任何接收 JWT 的服务都必须检查aud(受众),并且如果 JWT 不在受众列表中,该服务必须拒绝 JWT。JWT 的sub(主题)标识 JWT 的主题,例如应用程序的用户——一个永远不会重新分配的唯一值,例如发行服务的名称和唯一的用户 ID。

最后,使用你选择的键值对附加有用的数据。在这里,让我们称令牌数据为sessionData。请注意,我们需要加密这些数据——JWT 的签名段可以防止篡改会话数据,但 JWT 本身并不是加密的(尽管你可以加密整个令牌)。

最后一步是创建一个签名,正如之前提到的,这可以防止篡改——JWT 验证器会特别检查签名与接收到的数据包之间的不匹配。

接下来是一个框架服务器和客户端示例,演示如何实现一个 JWT 驱动的认证系统。我们不会手动实现各种签名和验证步骤,而是使用jwt-simple包。请随意浏览你的代码包中的/jwt文件夹,其中包含我们将要解包的完整代码。

要请求令牌,我们将使用以下客户端代码:

var token;

function send(route, formData, cb) {
  if(!(formData instanceof FormData)) {
    cb = formData;
    formData = new FormData();
  }
  var caller = new XMLHttpRequest();
  caller.onload = function() {
    cb(JSON.parse(this.responseText));
  };
  caller.open("POST", route);
  token && caller.setRequestHeader('Authorization', 'Bearer ' + token);
  caller.send(formData);
}
// ...When we have received a username and password in some way
formData = new FormData();
formData.append("username", username);
formData.append("password", password);

send("/login", formData, function(response) {
  token = response.token;
  console.log('Set token: ' + token);
});

我们将在下一节实现服务器代码。现在,请注意,我们有一个send方法,它期望在某个时刻有一个全局的token设置,以便在发出请求时传递。初始的/login是我们请求该令牌的地方。

使用 Express 网络框架,我们创建了以下服务器和/login路由:

var express = require('express');
...
var jwt = require('jwt-simple');
var app = express();

app.set('jwtSecret', 'shhhhhhhhh');

app.post('/login', auth, function(req, res) {
  var nowSeconds   = Math.floor(Date.now()/1000);
  var plus7Days   = nowSeconds + (60 * 60 * 24 * 7);
  var token = jwt.encode({
    "iss" : "http://blogengine.com",
    "aud" : ["http://blogsearch.com", "http://blogstorage"],
    "sub" : "blogengine:uniqueuserid",
    "iat" : nowSeconds,
    "exp" : plus7Days
  }, app.get('jwtSecret'));

  res.send({
    token : token
  })
})

注意,我们在应用服务器上存储jwtsecret。这是我们在签名令牌时使用的密钥。当进行登录尝试时,服务器将返回jwt.encode的结果,该结果编码了之前讨论的 JWT 声明。就这样。从现在开始,任何提及此令牌并指向正确受众的客户端都将被允许在从签发日期起 7 天有效期内与任何受众成员提供的服务进行交互。这些服务将实现类似以下代码:

app.post('/someservice', function(req, res) {
  var token = req.get('Authorization').replace('Bearer ', '');
  var decoded = jwt.decode(token, app.get('jwtSecret'));
  var now = Math.floor(Date.now()/1000);
  if(now > decoded.exp) {
    return res.end(JSON.stringify({
      error : "Token expired"
    }));
  }
  res.send(<some sort of result>);
})

在这里,我们只是获取Authorization头(移除Bearer)并通过jwt.decode进行解码。一个服务至少必须检查令牌过期,我们在这里通过比较从epoch到令牌的过期时间的当前秒数来实现这一点。

使用这个简单的框架,你可以创建一个易于扩展的认证/会话系统,使用一个安全标准。不再需要维护与公共凭证数据库的连接,单个服务(可能作为微服务部署)可以使用 JWT 来验证请求,从而产生很少的 CPU 延迟或内存成本。

摘要

在本章中,我们涵盖了大量的内容。概述了 V8 解释器能够正确处理的编写高效 JavaScript 的最佳实践,包括对垃圾回收的探讨、Node 流的优势,以及如何部署 JavaScript 原型以节省内存。继续探讨降低存储成本的主题,我们研究了 Redis 如何以空间高效的方式帮助存储大量数据的各种方法。

此外,我们还探讨了构建可组合、分布式系统的策略。在关于微服务的讨论中,我们提到了将多个服务网络化并构建它们之间通信的网络的方法,从 pub/sub 到 Seneca 的模式和动作模型。结合缓存技术的例子,我们建立了一个相对完整的图景,展示了在规划应用程序资源管理时你可能需要考虑的问题。

在构建了一个相对复杂的架构之后,越来越有必要构建探针和其他监控工具来保持对正在发生的事情的掌控。在下一章中,我们将构建帮助您追踪运行应用程序拓扑变化的工具。

第五章:监控应用程序

分布式系统经常出现故障。更糟糕的是,它们经常部分故障。当操作发生故障,负责改变系统状态(例如,写入或删除操作)时,如何恢复正确的状态,尤其是当这些操作是并发时?更糟糕的是,一些操作会静默失败。因此,部分故障可能会使应用程序处于不确定的状态。预测一个不透明的系统将如何表现是困难的。

考虑以下来自《数据中心作为计算机:仓库规模机器设计导论》的引言:

"假设一个集群拥有超可靠的服务器节点,其平均故障间隔时间(MTBF)为 30 年(10,000 天)——远远超出在现实成本下通常能够实现的范围。即使有这些理想化的可靠服务器,一个由 10,000 个服务器组成的集群每天平均仍会看到一台服务器故障。因此,任何需要整个集群正常运行的应用程序,其 MTBF 都不会超过 1 天。"

失败,尤其是在大规模上,对员工的质量或硬件的质量都是无所谓的。关键在于,在常规使用中看似很大的数值,在网络环境中却相对较小,因为在几分钟或几秒钟内可能发生数十亿笔交易,并且数百或更多独立的系统正在交互。失败的演变往往是反直觉的。因此,为失败做好准备是个好主意,而且,在其他方面,这意味着减少任何单一失败导致整个系统崩溃的能力。

通常,为单个用户分配工作负载也需要将用户数据分布到许多独立进程中。此外,当系统的一部分发生故障时,必须将其恢复,以保持系统的两个特性——其容量以及当故障发生时处于飞行中的任何数据或事务。

在本章中,我将概述一些用于监控应用程序中发生情况的工具和技巧。我们将探讨您可以构建自己的监控和日志记录工具的方法,并讨论第三方工具。在这个过程中,您将了解以下内容:

  • 远程控制 Node 进程

  • 使用 New Relic 监控服务器

  • 捕获错误

  • 在您的应用程序中跟踪和记录活动的其他选项

处理失败

正如我们在第四章中概述的,“管理内存和空间”,隔离操作和智能监控应用程序有助于最大限度地减少单个失败子系统导致更大系统崩溃的可能性。在本节中,我们将探讨如何在 Node 程序中捕获错误和异常,以及如何优雅地关闭和/或重启变得不稳定的过程,无论是单个进程还是集群中的进程。

注意

以下是一篇关于使用 Node.js 处理错误的全面文章,推荐阅读:

www.joyent.com/developers/node/design/errors

在代码库中添加 try/catch 块并试图预测所有错误可能会变得难以管理且难以控制。此外,如果你没有预料到的异常发生了?你如何继续你离开的地方

Node 还没有一个很好的内置方式来处理未捕获的关键异常。这是该平台的一个弱点。一个未捕获的异常将继续在执行栈中冒泡,直到它击中事件循环,就像机器齿轮中的扳手,它将使整个进程崩溃。

一个选择是将uncaughtException处理器附加到进程本身,以下代码展示了这一点:

process.on('uncaughtException', function(err) {
  console.log('Caught exception: ' + err);
});
setTimeout(function() {
  console.log("The exception was caught and this can run.");
}, 1000);
throwAnUncaughtException();

上述代码的输出将如下所示:

> Caught exception: ReferenceError: throwAnUncaughtException is not defined
> The exception was caught and this can run.

虽然在异常代码之后的任何内容都不会执行,但超时仍然会触发,因为进程成功捕获了异常,从而拯救了自己。然而,这是一种处理异常非常笨拙的方式。

domain模块试图修复 Node 设计中这个漏洞。我们将接下来讨论域模块,作为一个更好的工具来处理异常。

'domain'模块

在异步代码中的错误处理也难以追踪:

function f() {
  throw new error("error somewhere!")
}
setTimeout(f, 1000*Math.random());
setTimeout(f, 1000*Math.random());

哪个函数导致了错误?很难说。也很难智能地插入异常管理工具。很难知道下一步该做什么。Node 的domain模块试图帮助解决这些问题和其他异常定位问题。这样,代码可以更精确地进行测试,错误可以更有效地处理。

在最简单的形式中,一个设置了一个上下文,在这个上下文中可以运行一个函数或其他“代码块”,使得在该隐式域绑定中发生的任何错误都会被路由到特定的域错误处理器。以下代码作为例子:

var domain = require('domain');
var dom = domain.create();
dom.on('error', function(err) {
  console.error('error', err.stack);
});

dom.run(function() {
  throw new Error("my domain error");
});
// error Error: my domain error
//  at /js/basicdomain.js:10:8
//  ...

在这里,我们创建一个域,并通过该域上下文中的run命令在该域内执行代码。这使得我们能够智能地捕获那些异常,隐式绑定在该上下文中创建的所有事件发射器、计时器和其他请求。

有时,一个方法可能是在其他地方创建的(不是在给定的domain.run函数调用隐式上下文中),但仍然最好与外部域相关联。add方法就是为了这样的显式绑定而存在的,以下代码展示了这一点:

var dom = domain.create();
dom.on("error", function(err) {
  console.log(err);
});

var somefunc = function() {
  throw new Error('Explicit bind error');
};
dom.add(somefunc);
dom.run(function() {
  somefunc();
});
// [Error: Explicit bind error]

这里,我们看到一个未在run上下文中隐式绑定的函数仍然可以显式地添加到该上下文中。要从域中移除执行上下文,请使用domain.remove。所有显式或隐式添加到域中的计时器、函数和其他发射器的数组可以通过domain.members访问。

就像 JavaScript 的bind方法将函数绑定到上下文一样,domain.bind方法同样允许将一个独立的函数绑定到域。以下代码展示了这一点:

var domain = require('domain');
var fs = require('fs');
var dom = domain.create();
dom.on("error", ...);
fs.readFile('somefile', dom.bind(function(err, data) {
  if(err) { throw new Error('bad file call'); }
}));
//  { [Error: bad call]
//  domain_thrown: true,
//  ...

在这里,我们看到任何函数都可以通过特定的错误域内联包装,这是一个特别有用的功能,可以用来管理回调中的异常。从域发出的错误对象具有以下特殊属性

  • error.domain:这是处理错误的域。

  • error.domainEmitter:如果EventEmitter在某个域内触发一个error事件,这将被标记。

  • error.domainBound:这是将错误作为其第一个参数传递的回调。

  • error.domainThrown:这是一个布尔值,表示错误是否被抛出。例如,以下回调将传递一个 ENOENT 错误作为其第一个参数,因此domainThrown将为 false:

    fs.createReadStream('nofile', callback)
    
    

注意

另一种方法,domain.intercept,与domain.bind功能相似,但简化了回调中的错误处理,使得开发者不再需要重复检查(甚至设置)每个回调的第一个参数cb(err, data)以查找错误。一个例子可以在你的代码包中的js/domainintercept.js文件中找到。

你可能还需要在域之间移动,根据需要进入和退出它们。为此,我们使用domain.enterdomain.exit方法。假设我们已经设置了两个域,dom1dom2,第一个发出domain 1 error,第二个发出domain 2 error,我们可以像下面这样在域上下文中移动:

dom1.add(aFuncThatThrows);
dom1.run(function() {
  dom1.exit();
  dom2.enter();
  aFuncThatThrows();
});
// domain 2 error

可以使用任意数量的enterexit事件。请注意,域对象本身没有发生变化——exit不会关闭域或执行任何此类操作。如果需要销毁域,你应该使用domain.dispose方法,这将尝试清理任何正在进行的域 I/O——中断流、清除定时器、忽略回调等。

捕获进程错误

以进程为导向的设计在 Node.js 应用程序中很常见,其中独立进程通过事件流相互通信。这些通道以及进程本身的错误必须被跟踪。在本节中,我们将探讨如何跟踪以及如何正确地抛出与进程事件相关的错误。

我们在第三章中介绍了child_process模块,扩展 Node。在这里,我们将更详细地介绍如何处理子进程及其父进程中的错误。

要启动 Node 程序,请使用child_process模块的fork方法。这将创建一个在调用父进程下的新子进程。此外,两个进程之间将自动设置一个 IPC 通道,其中子进程调用process.send向其父进程发送消息,父进程可以监听child.on('message')。创建两个文件,第一个命名为parent.js,另一个命名为child.js

// parent.js
var fork = require('child_process').fork;
var proc = fork('./child.js');

proc.on('message', function(msg) {
  console.log("Child sent: " + msg);
});
//  Keeps the parent running even if no children are alive.
process.stdin.resume();

// child.js
var cnt = 0;
setInterval(function() {
  process.send(++cnt);
}, 1000);

父进程使用fork创建的子进程将在每秒间隔增加并发出一个值,父进程将监听并回显到你的控制台。我们如何从父进程中捕获子进程的错误?

让我们在子进程中引发一个错误,通过使其throw。将以下行添加到child.js中:

...
process.send(++cnt);
throw new Error('boom!');

再次运行父进程将导致错误,并显示我们设置的消息。通常,父进程会在子进程死亡时采取行动——例如在新的子进程中使用fork或记录错误,或者两者都做。为了在父进程中捕获子进程错误,请将以下行添加到parent.js中:

proc.on('exit', function() {
  console.log("Child exited: ", arguments);
});

再次运行父脚本将导致除了原始错误外,还会显示以下内容:

Child exited:  { '0': 1, '1': null }

接收到的第一个参数是子进程在终止时传递的退出代码(如果父进程发送了终止信号,例如child.kill('SIGTERM'),这里的第二个参数将包含'SIGTERM')。

除了在父进程中处理子进程的错误之外,建议使用Domain模块来捕获和处理子进程本身内的错误。这样,你可以在子进程错误发生后正确地清理,并使用process.send()将任何额外的错误信息广播给父进程。

注意

当进程异常退出时,Node 将返回的退出代码可以在github.com/joyent/node/blob/master/doc/api/process.markdown#exit-codes找到。(注意,这是针对 Node 0.11.x 的——更早的版本总是返回退出代码 8。)

子进程也可以通过spawn创建,这与fork不同,因为它不是 Node 特定的;可以使用spawn启动任何操作系统进程。例如,这是执行ls命令的一种迂回方式;当你运行它时,你应该会收到目录列表:

var spawn = require('child_process').spawn;
var proc = spawn('ls',['-l']);
proc.stdout.setEncoding('utf8');
proc.stdout.on('data', function(data) {
  console.log(data)
});

注意与fork的不同之处。第一个参数是一个操作系统命令,第二个参数是传递给该命令的选项数组——相当于> ls -l。其次,我们没有访问自定义的 IPC(与fork一样——没有sendon('message')),但我们确实可以访问标准进程管道:stdinstdoutstderr。因为管道默认以缓冲区形式进行通信,所以我们设置了所需的编码,并简单地显示任何被启动进程写入stdout的数据。

另一种捕获子进程错误的方法应该对你来说很清楚。使用以下代码修改前面的代码:

var spawn = require('child_process').spawn;
var proc = spawn('ls',['-l', '/nonexistent/directory']);
proc.stderr.setEncoding('utf8');
proc.stderr.on('data', function(err) {
  console.log("Error", err)
});

当执行列出不存在目录内容这一尝试时,你应该看到以下内容:

Error ls: /nonexistent/directory: No such file or directory

通过监听stderr管道,可以捕获子进程中的错误。我们还可以更改stdio设置,使错误自动记录到文件。而不是在父进程中捕获子输出,我们使用具有自定义stdio选项的spawn在子进程中,将子进程的stdout直接重定向到文件:

var spawn = require('child_process').spawn;
// This will be the file we write to
var out = require('fs').openSync('./out.log', 'w+');
var proc = spawn('node', ['./spawn_child.js'], {
  // The options are: 0:stdin, 1:stdout, 2:stderr
  stdio : ['pipe', out, 'pipe']
});

接下来,我们将更深入地探讨日志记录策略。

日志记录

为什么需要记录数据?一个可能的答案是,现代应用程序产生的活动数据量超出了任何一个人的分析能力。我们无法实时有效地处理这么多信息。因此,有必要存储或记录大量细节,并使用智能工具将数据切割和排序成我们人类可以理解的形式。我们可以在日志中寻找模式,也许可以找到应用程序中的瓶颈或甚至错误,帮助我们改进系统的设计。我们可以从日志中获得商业智能,发现有助于我们理解客户偏好的使用模式,或者有助于我们设计新功能或增强现有功能的模式。

在接下来的内容中,我将向您介绍所有 Node 进程可用的信息,这些信息如何使用 UDP 进行记录,以及如何使用 Morgan 进行简单的请求记录。

注意

Etsy 团队的一个流行的开源日志和统计报告项目是 StatsD (github.com/etsy/statsd),它有一个很好的 Node 客户端在 github.com/sivy/node-statsd

让我们创建一个使用 UDP 的日志模块。关于 UDP 如何工作的详细信息可以在第三章([ch03.html "第三章. 扩展 Node"]Scaling Node)中找到,如果需要的话,请刷新您的记忆。需要记住的重要概念是,UDP 通过不保证消息到达来实现极高的性能。请注意,在 99% 的情况下,消息丢失的情况非常少,这使得 UDP 对于不需要完美保真度的应用程序(如日志应用程序)来说是一个速度和准确性的优秀平衡。

使用 UDP 进行日志记录

我们 UDP 日志模块的目标是为任何 Node 程序提供一个简单的日志接口。此外,我们希望允许许多独立进程写入同一日志文件。此模块的完整代码可以在您的代码包的 udp/logger 文件夹中找到。

从结尾开始,我们先来看一下客户端代码,然后再深入到日志记录器本身。所有客户端都将发送(至少)一个日志文件路径,可选的端口或主机信息,如果需要的话,一些处理函数,系统将正常工作:

var dgram = require('dgram');
var Logger = require('./logger');

logger = new Logger({
  file : './out.log',
  port : 41234,
  host : 'localhost',
  encoding : 'utf8',
  onError : function(err) {
    console.log("ERROR: ", err);
  },
  onReady : function() {
...
  }
});

我们可以看到,我们的模块在提供的端口上启动了一个服务器,并配置为通知客户端任何错误以及其就绪状态。通过完善 onReady 的剩余代码,我们还可以看到我们期望客户端如何使用 UDP 日志记录器:

console.log("READY");
var client = dgram.createSocket("udp4");
var udpm;
//  Flood it a bit.
for(var x=0; x < 10000; x++) {
  udpm = new Buffer("UDP write #" + x);
  logger.log('Test write #' + x);
  client.send(udpm, 0, udpm.length, 41234, "localhost");
}

客户可以选择调用模块的 log 函数或直接发送 UDP 消息。此外,我们预计可能会接收到许多消息。此外,我们预计任何数量的进程都可能将日志记录到 同一文件 中,因此我们必须处理管理消息洪流的问题。

日志模块如下:

var dgram = require('dgram');
var fs = require('fs');

module.exports = function(opts) {

  opts = opts || {};

  var file = opts.file;
  var host = opts.host || 'localhost';
  var port = opts.port || 41234;
  var encoding = opts.encoding || 'utf8';
  var onError = opts.onError || function() {};
  var onReady = opts.onReady || function() {};
  var socket = dgram.createSocket("udp4");
  var writeable = true;
  var _this = this;
  var stream;

  if(!file) {
    throw new Error("Must send a #file argument");
  }

  stream = fs.createWriteStream(file, {
    flags : 'a+'
  });
  stream.setMaxListeners(0);

  socket.bind(port, host);

  socket.on("listening", onReady);
  socket.on("error", onError);
  socket.on("message", function(msg) {
    this.log(msg.toString());
  });

  this.log = function(msg) {
    if(!stream) {
      throw new Error('No write stream available for logger.');
    }

    try {
      if(typeof msg !== 'string') {
        msg = JSON.stringify(msg);
      }
    } catch(e) {
      return onError("Illegal message type sent to #log. Must be a string, or JSON");
    };

    // You'll likely want to create retry limits here.
    //
    var writer = function() {
      if(!stream.write(msg + '\n', encoding)) {
        stream.once('drain', writer);
      }
    }
    writer();
  };
};

这是管理设置 UDP 服务器和我们的客户端接口所需的所有代码。注意 log 函数要么直接由客户端调用,要么通过我们的 UDP 绑定的 on('message') 处理器调用。这允许客户端从任何环境中调用我们的日志服务器——使用此模块、使用另一种语言、使用另一个服务器、不使用此模块等。

最后一个重要的问题是 log 中的背压管理。因为许多独立来源可能会访问我们的日志文件,当我们尝试使用 write 时,管理该资源的写入流可能已经达到其高水位()。当这种情况发生时,对 stream.write 的调用将返回 false,调用者应将其视为停止发送数据的信号。当这种情况发生时,我们绑定到 drain 事件(仅一次——见 nodejs.org/api/events.html#events_emitter_once_event_listener),该事件在消费者(我们的日志文件的写入管理器)准备好接受更多数据时触发。

使用 Morgan 记录日志

Morgan (github.com/expressjs/morgan) 是一个用于 Express 框架的 HTTP 记录器。如果你只需要为服务器记录 HTTP 连接数据,它非常适用且易于使用。我们将通过几个简短的 Express 示例来结束本节。

以下是最基本的 Morgan 使用方法:

var express = require('express')
var morgan = require('morgan')
var app = express()
app.use(morgan('combined'))
app.get('/', function (req, res) {
  res.send('hello, world!')
});
app.listen(8080);

此代码将创建一个监听端口 8080 的服务器,并将日志条目以 Apache Combined Log Format (httpd.apache.org/docs/1.3/logs.html#combined) 格式输出到 stdout

127.0.0.1 - - [20/Nov/2014:23:02:58 +0000] "GET / HTTP/1.1" 200 13 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.99 Safari/537.36"

除了格式参数外,Morgan 还接受各种选项。例如,要将日志数据流式传输到文件,请使用 stream 选项。对于此示例,将 app.use 声明替换为以下内容:

app.use(morgan('combined', {
  stream : require('fs').createWriteStream('./out.log')
}));

日志条目现在将被写入到 out.log

合并参数反映了 Morgan 内置的日志格式化器之一。这些格式化器由标记化的字符串组成,默认情况下有多个标记可用。例如,合并格式化器插入了以下字符串:

:remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"

应该很明显,前面的完全限定输出是通过给定的格式化器生成的,该格式化器将令牌映射到 Node 的 http 模块管理的 ClientRequestClientResponse 对象的标准属性。

Morgan 记录反映 ClientRequestClientResponse 对象状态的日志数据。skip 选项允许你根据这些对象的状态过滤日志。默认情况下,Morgan 记录每个请求。要仅记录错误,你需要在你的中间件定义中添加以下内容:

skip: function(req, res) {
  return res.statusCode < 400;
}

你还可以添加新令牌。在这里,我们创建了一个名为 'cache' 的令牌:

morgan.token('cache', function(req, res) {
  return req.headers['cache-control'];
});

这个新令牌(和/或现有令牌)可以用于自定义格式化器:

app.use(morgan('cache-control is :cache'))

Morgan 现在已初始化了一个自定义格式化器,它将像 cache-control is max-age=0 这样的内容写入到你的日志中。

要了解更多关于其他内置格式化程序和其他高级选项的信息,请访问项目页面。由于其灵活性,Morgan 可以定制以满足许多日志需求。

其他流行的选项可供考虑:

在不断变化的环境中修改行为

在运行中的系统中修改应用程序数据,这被比作在飞机飞行中更换引擎。幸运的是,我们开发者在一个虚拟世界中工作,这里的 物理定律 更为宽容。在本节中,我们将通过示例学习如何使用 Node 应用程序的远程监控方法来创建一个 遥控器

Node REPL

Node 的 Read-Eval-Print-LoopREPL)代表了 Node 壳。要进入壳提示符,请在终端中输入 Node,而不传递文件名:

> node

现在,你可以访问一个正在运行的 Node 进程,并可以向该进程传递 JavaScript 命令。例如,在输入 2+2 之后,壳会向 stdout 发送 4。Node 的 REPL 是尝试、调试、测试或以其他方式玩 JavaScript 代码的绝佳场所。

因为 REPL 是一个原生对象,程序也可以使用实例作为运行 JavaScript 的上下文。例如,在这里,我们创建了自己的自定义函数 sayHello,将其添加到 REPL 实例的上下文中,并启动 REPL,模拟 Node 壳提示符:

require('repl').start("> ").context.sayHello = function() {
  return "Hello"
};

在提示符中输入 sayHello() 将导致 Hello 被发送到 stdout

这也意味着你的 Node 进程可以对外界暴露一个 REPL 实例,该实例可以以某种方式访问该进程,提供一个 后门,通过它可以连接到进程,修改其上下文,改变其行为,或者如果它在某些方面出了问题,甚至可以将其关闭。让我们探讨与进程监控相关的可能应用。

使用以下代码创建两个文件,repl_client.jsrepl_server.js,并在各自的终端窗口中运行它们,以便你可以看到两个终端窗口:

/*  repl_client.js   */
var net = require('net');
var sock = net.connect(8080);
process.stdin.pipe(sock);
sock.pipe(process.stdout);

repl_client 文件简单地通过 net.connect 创建一个新的套接字连接到端口 8080,并将来自 stdin(你的终端)的任何数据通过该套接字传输。同样,来自套接字的数据也会被传输到 stdout(你的终端)。应该很清楚,我们已经创建了一种方法来接收输入并通过套接字将其发送到端口 8080,监听套接字可能发送回我们的任何数据。以下代码展示了这一点:

/*  repl_server.js  */
var repl = require('repl')
var net = require('net')
net.createServer(function(socket) {
  var inst = repl.start({
    prompt : 'repl_server> ',
    input    : socket,
    output  : socket,
    terminal  : true
  })

  inst.on('exit', function () {
    socket.end()
  })
}).listen(8080)

repl_server 文件闭合了循环。我们将首先使用 net.createServer 创建一个新的 传输控制协议TCP)服务器,通过 .listen 绑定到端口 8080。传递给 net.createServer 的回调将接收一个指向已绑定套接字的引用。在这个回调的封装中,我们实例化一个新的 REPL 实例,给它一个漂亮的 prompt(在这个例子中是 'repl_server>',但它可以是任何字符串),表示它应该监听来自传递的套接字引用的 input,并将 output 广播到该套接字引用,表示套接字数据应被视为 terminal 数据(它有特殊的编码)。

我们现在可以在客户端终端中输入一些内容,例如 console.log("hello"),然后看到 hello 被显示出来——REPL 服务器已经执行了我们通过 REPL 客户端发送的命令,并发送回评估后的响应。

为了确认我们的 JavaScript 命令的执行是在 repl_server 进程中进行的,请在客户端终端中输入 process.argv,服务器将显示一个包含当前进程路径的对象,该路径将是 /.../repl_server.js

此外,我们还可以向 REPL 的 context 中添加自定义方法,然后通过客户端访问这些方法。例如,将以下行添加到 repl_server.js 中:

  inst.context.sayHello = function() {
    return "Hello";
  }

重新启动服务器和客户端,然后在客户端终端中输入 sayHello()。你会看到 Hello 被显示出来。从这个演示中应该很明显,我们已经创建了一种远程监控 Node 进程的方法。

最后,REPL 提供了自定义命令,特别是 .save.load(点 (.) 前缀是故意的)。.save 命令将保存当前的 REPL 会话到一个文件中——你发送给 REPL 的所有命令都将写入到指定的文件中,这意味着它们可以被回放。要看到这个动作,打开一个 REPL 会话并运行一些命令,建立会话历史。然后,输入以下两个命令:

.save test.js
.load test.js
// Session saved to:test.js
// ... the output of the session commands, replayed

现在,让我们创建一个演示模块,当它包含在一个进程中的时候,它将通过 REPL 打开远程管理的通道。

远程监控和管理 Node 进程

在你的代码包中,你会找到 repl-monitor 包。这个模块将在指定的端口上暴露一个服务器,该服务器将提供当前进程的内存使用情况,允许远程进程读取这些信息并向被监控的进程发送指令。对于这个例子,我们将能够告诉进程在进程堆超过限制时停止在内存中存储东西,当它回到给定的阈值以下时再次开始存储东西。

我们还将演示 .load 的有用性,以创建高度动态的监控解决方案,可以在不重新启动目标进程的情况下进行调整。

注意,在应用程序的内部创建此类访问点应谨慎进行。虽然这些技术非常有用,但你必须小心保护对各种端口等的访问,主要通过限制对正确安全私有网络的访问来实现。

监控代码如下:

var repl = require('repl');
var net = require('net');
var events = require('events');
var Emitter = new events.EventEmitter();

module.exports = function(port) {
  net.createServer(function(socket) {
    var inst = repl.start({
      prompt : '',
      input    : socket,
      output  : socket,
      terminal  : false
    })

    inst.on('exit', function () {
      socket.end();
    })

    inst.context.heapUsed = function() {
      return process.memoryUsage().heapUsed;
    }

    inst.context.send = function(msgType, msg) {
      Emitter.emit(msgType, msg);
    }

  }).listen(port);

  return Emitter;
};

此模块在指定的端口上创建 REPL,并通过 REPL 上下文公开两个自定义方法,客户端可以使用这些方法。heapUsed方法返回特定的内存读取值,而send方法则通过返回的EventEmitter实例将消息广播到被监控的进程。需要注意的是,此 REPL 的output管道是连接的套接字(与input管道相同)。正如我们之前讨论的,这意味着调用进程将接收到它发送的 JavaScript 代码的执行结果。我们将在稍后提供更多相关信息。

接下来,我们将创建一个要监控的进程,这需要监控模块:

var listener = require('./monitor')(8080);

store = true;
var arr = [];

listener.on('stop', function() {
  console.log('stopped');
  store = false;
})

listener.on('start', function() {
  store = true;
})

var runner = function() {
  if(store === true) {
    arr.push(Math.random()*1e6);
    process.stdout.write('.');
  }
  setTimeout(runner, 100);
};

runner();

在这里,我们有一个不断向数组中添加内容的进程。通过监控模块,客户端可以连接到这个进程,检查内存使用情况,并广播启动或停止消息,该进程将监听并据此行动。

最后一步是创建一个用于远程进程管理的客户端。控制客户端很简单。我们通过 TCP(net)连接连接到 REPL,并定期轮询目标进程的内存状态:

var net = require('net');
var sock = net.connect(8080);

var threshold = 0;
var stopped = false;
sock.on('end', function() {
  clearInterval(writer);
  console.log('**** Process ended ****');
});
//  Keep checking for memory usage, stringifying the returned object
var writer = setInterval(function() {
  sock.write('heapUsed()\n');
}, 1000);

回想一下我们是如何将heapUsed方法添加到监控器的 REPL 上下文中的,当我们向 REPL input套接字写入时,我们应该期望得到一些值。这意味着我们必须向sock添加一个数据监听器:

sock.setEncoding('ascii');
sock.on('data', function(heapUsed) {

  //  Convert to number
  heapUsed = +heapUsed;

  //  Responses from commands will not be numbers
  if(isNaN(heapUsed)) {
    return;
  }

  if(!threshold) {
    threshold = heapUsed;
    console.log("New threshold: " + threshold)
  }

  console.log(heapUsed);

  //  If heap use grows past threshold, tell process to stop
  if((heapUsed - threshold) > 1e6) {
    !stopped && sock.write('.load stop_script.js\n');
    stopped = true;
  } else {
    stopped && sock.write('.load start_script.js\n');
    stopped = false;
  }
});

当我们收到内存探测读取值时,它被转换为整数,并与阈值值(基于第一次读取的值)进行比较。如果读取值超过预定的限制,我们告诉进程停止分配内存;当内存释放时,进程被告知继续。

重要的是,REPL 提供的特定机会是能够在远程进程的上下文中运行脚本。注意发送到socket.write的命令,每个命令都加载包含 JavaScript 的外部文件:

// Stop script
send("stop")
// Start script
send("start")

虽然这些单行代码只是简单地练习了我们之前讨论的消息接口,但你的实现并不妨碍使用更长的命令列表来满足更现实的部署需求。关键的是,这种进程控制解耦促进了动态进程管理,因为今天你使用的.load脚本可以在未来更改,而不需要修改目标进程。

现在,让我们来看看更全面的用于深入分析应用程序性能的技术。

对进程进行性能分析

在追踪内存泄漏和其他难以发现的错误时,拥有分析工具是非常有用的。在本节中,我们将探讨如何对运行中的进程进行快照,以及如何从中提取有用的信息。

Node 已经原生提供了一些进程信息。使用 process.memoryUsage() 可以轻松获取你的 Node 进程使用的内存量:

{ rss: 12361728, heapTotal: 7195904, heapUsed: 2801472 }

还有一些模块可以用来跟踪更多关于进程的信息。例如,usage 模块 (github.com/arunoda/node-usage) 提供了直接的内存和 CPU 使用信息。要探测当前进程,请使用以下代码:

var usage = require('usage');
usage.lookup(process.pid, function(err, result) {
  console.log(result);
});

这将产生以下结果:

{ memory: 15093760,
 memoryInfo: { rss: 15093760, vsize: 3109531648 },
 cpu: 3.8 }

在这里,我们可以看到进程的总内存使用量(以字节为单位)和 CPU 使用百分比。

注意

要了解 JavaScript 内存分析的好资源可以在 developer.chrome.com/devtools/docs/javascript-memory-profiling 找到。

能够查看 V8 在运行你的进程时看到的内容更有趣。任何 Node 进程都可以通过传递 --prof(用于配置文件)标志来简单地生成 v8.log。让我们创建一个日志读取器,并使用 tick 模块 (github.com/sidorares/node-tick) 来检查其性能,该模块将读取 V8 日志并生成执行配置文件的分解。

首先,全局安装该包:

npm install -g tick.

在你的代码包中,在为本章创建的 /profiling 目录下,将有一个名为 logreader.js 的文件。这个文件简单地读取(也在该文件夹中)的 dummy.log 文件,并将其内容输出到控制台。这是一个如何使用 Transform 流处理日志文件的优秀示例:

var fs = require('fs');
var stream = require('stream');
var lineReader = new stream.Transform({
  objectMode: true
});
lineReader._transform = function $transform(chunk, encoding, done) {
  var data = chunk.toString()
  if(this._lastLine) {
    data = this._lastLine + data;
  }
  var lines = data.split('\n');
  this._lastLine = lines.pop();
  lines.forEach(this.push.bind(this));
  done();
}

lineReader._flush = function $flush(done) {
  if(this._lastLine) {
    this.push(this._lastLine);
  }
  this._lastLine = null;
  done();
}
lineReader.on('readable', function $reader() {
  var line;
  while(line = lineReader.read()) {
    console.log(line);
  }
});
fs.createReadStream('./dummy.log').pipe(lineReader);

需要注意的重要一点是,主要函数已经被命名,并且以 $ 为前缀。这通常是一个好的实践——你应该始终为你的函数命名。具体原因与调试相关。我们希望这些名称出现在我们即将生成的报告中。

要生成 V8 日志,请使用 –-prof(配置文件)参数和 –-nologfile-per-isolate 参数运行此脚本以抑制默认日志文件的生成:

node --prof logreader --nologfile-per-isolate > v8.log

你现在应该能在当前工作目录中看到一个名为 v8.log 的日志文件。不妨查看一下——日志有些令人畏惧。这正是 tick 模块发挥作用的地方:

node-tick-processor > profile

此命令将生成一个更易读的配置文件并将其输出到 profile 文件。打开该文件并查看。这里有很多信息,深入探究其含义超出了本章的范围。然而,我们可以清楚地看到我们的脚本中各种函数(如 $transform)消耗了多少个 tick,我们还可以看到函数是否已优化。例如:

16   21.6%      0    0.0%   LazyCompile: ~$transform /Users/sandro/profiling/logreader.js:8

在这里,我们可以看到 $transform 占用了 16 个 tick,并且是懒编译的,波浪号 (~) 表示该函数没有被优化——如果它被优化了,你会在前面看到一个星号 (*) 前缀。

作为实验,创建一个包含以下代码的脚本,并使用 --prof 标志运行它:

while((function $badidea() {
  return 1;
})());

让这个无限循环运行一段时间,然后通过使用 Ctrl + C 终止进程。创建一个配置文件,就像我们之前做的那样,并查看它。应该很清楚,使用这些分析工具捕捉到昂贵的函数是多么容易。

对于运行最新 Node.js 构建版本(0.11.x 或更高版本和 io.js)的用户,有一个极其有用的可视化工具,只需在 Chrome 浏览器中运行以下命令即可访问——chrome://tracing/

分析进程

一旦你在浏览器中准备好了,点击 加载 按钮,上传你的 v8.log 文件。执行时间线在顶部展开,通过点击左侧的链接(V8: V8 PC),你可以访问星爆导航工具。星爆辐射调用栈,很好地可视化我们的应用程序中的工作。注意我们的 $transform 函数被列在右侧——给你的函数命名吧!

如果你想要了解更多关于分析 v8 的信息,以下是一些有用的链接:

使用第三方监控工具

Node 是一种新技术,目前还没有成熟的应用程序监控工具。一些独立开发者和在应用程序监控领域的知名公司已经跳进来填补这一空白。在本节中,我们将探讨 PM2 作为进程管理器和监控工具,并查看 Nodetime。

PM2

PM2 被设计成是一个企业级进程管理器。如前所述,Node 在 Unix 进程中运行,它的 child_processcluster 模块用于生成更多进程,通常是在跨多个核心扩展应用程序时。PM2 可以用于通过命令行和编程方式实现你的 Node 进程的部署和监控。在这里,我将专注于编程方式使用 PM2 进行进程管理,并展示如何使用它来监控和显示进程活动。

全局安装 PM2:

npm install pm2 -g

使用 PM2 最直接的方式是作为一个简单的进程运行器。以下程序将每秒递增并记录一个值:

// script.js
var count = 1;
function loop() {
  console.log(count++);
  setTimeout(loop, 1000);
};
loop();

在这里,我们使用 forkscript.js 的新进程中,将其在后台 永远 运行,直到我们停止它。这是一个运行守护进程的绝佳方式:

pm2 start script.js
// [PM2] Process script.js launched

一旦脚本启动,你应该在你的终端看到类似以下的内容:

PM2

大多数值的含义应该是清晰的,例如你的进程使用的内存量,是否在线,运行了多长时间等等(modewatching 字段将在稍后解释)。进程将继续运行,直到停止或删除。

要在启动时为你的进程设置自定义名称,将 --name 参数传递给 PM2,如下所示:pm2 start script.js --name 'myProcessName'

所有运行中的 PM2 进程的概述可以通过 pm2 list 命令随时调出。PM2 提供了其他简单的命令:

  • pm2 stop <app_name | id | all>:这是通过名称或 ID 停止进程或停止所有进程的方式。停止的进程将保留在进程列表中,以后可以重新启动。

  • pm2 restart <app_name | id | all>:这是用于重新启动进程的方式。所有进程列表中的 restarted 下都会显示进程重启的次数。要自动在进程达到最大内存限制(例如,15 M)时重新启动进程,请使用 pm2 start script.js --max-memory-restart 15M 命令。

  • pm2 delete <app_name | id | all>:这用于删除一个进程。该进程不能被重新启动。

  • pm2 info <app_name | id>:这提供了关于进程的详细信息,如下所示:PM2

注意给出的错误和其他日志路径。记住,我们的脚本每秒递增一个整数并记录该计数。如果你使用 cat /path/to/script/ out/log,你的终端将显示写入到 out 日志的内容,该日志应是一系列递增的数字。错误也会类似地写入到日志中。此外,你可以使用 pm2 logs 实时流式传输输出日志。例如,我们的 script.js 进程仍在输出递增的值:

PM2: 2014-07-19 23:20:51: Starting execution sequence in -fork mode- for app name:script id:1
PM2: 2014-07-19 23:20:51: App name:script id:1 online
script-1 (out): 2642
script-1 (out): 2643
script-1 (out): 2644
...

要清除所有日志,使用 pm2 flush

你也可以编程方式使用 PM2。首先,你需要在应用程序的 package.json 文件中本地安装 PM2,使用标准的 npm install pm2 命令。要复制我们使用 PM2 运行 scripts.js 的步骤,首先创建以下 programmatic.js 脚本:

// programmatic.js
var pm2 = require('pm2');
pm2.connect(function(err) {
  pm2.start('script.js', {
    name: 'programmed script runner',
    scriptArgs: [
      'first',
      'second',
      'third'
    ],
    execMode : 'fork_mode'
  }, function(err, proc) {
    if(err) {
      throw new Error(err);
    }
  });
});

此脚本将使用 pm2 模块以进程方式运行 script.js。请运行 node programmatic.js。执行 PM2 列表应显示 programmed script runner 正在运行。为了确保这一点,尝试 pm2 logs——你应该看到递增的数字,就像之前一样。

监控

PM2 使进程监控变得简单。要查看进程的 CPU 和内存使用情况的实时统计信息,只需输入 pm2 monit 命令:

监控

在这里,我们可以看到我们进程的 CPU 和内存使用情况的持续更新的图表。还有什么比这更简单吗?

PM2 还使得创建基于 Web 的监控界面变得简单——只需运行 pm2 web 即可。这个命令将启动一个监听端口 9615 的监控进程——运行 pm2 list 现在将列出名为 pm2-http-interface 的进程。运行 web 命令,然后在浏览器中导航到 localhost:9615。你将看到一个详细的进程、操作系统等的快照,作为一个 JSON 对象:

...
  "monit": {
    "loadavg": [
      1.89892578125,
      1.91162109375,
      1.896484375
    ],
    "total_mem": 17179869184,
    "free_mem": 8377733120,
...
"pm_id": 1, // our script.js process
  "monit": {
    "memory": 19619840,
    "cpu": 0
  }
...

由于 PM2 的这个内置功能,创建一个每几秒轮询你的服务器、获取进程信息并将其图形化的基于 Web 的 UI 变得简单得多。

PM2 还有一个选项可以在所有管理的脚本上设置监视器,以便任何对监视脚本的更改都会导致自动进程重启。这在开发时非常有用。作为演示,让我们创建一个简单的 HTTP 服务器并通过 PM2 运行它:

// server.js
var http = require('http');
http.createServer(function(req, resp) {
  if(req.url === "/") {
    resp.writeHead(200, {
      'content-type' : 'text/plain'
    });
    return resp.end("Hello World");
  }
  resp.end();
}).listen(8080);

localhost:8080 被访问时,这个服务器将回显 "Hello World"。使用 pm2 start server.js --watch --name 'watchedHTTPServer' 启动它。注意,如果你现在列出正在运行的过程,我们的命名进程将在 watching 列表中显示为 enabled。在浏览器中打开这个服务器。你应该会看到 "Hello World"。现在,导航到 server.js 脚本,将 "Hello World" 改为 "Hello World, I've changed!"。重新加载浏览器。注意变化。运行进程列表,你会看到这个服务器进程指示重启。这样做几次。由于 PM2,你的服务器应用程序的实时开发变得更容易了。

注意

一个具有与 PM2 类似功能的过程管理工具,更专注于提供开箱即用的全功能 Web UI 是 Guvnorgithub.com/tableflip/guvnor。其他流行的进程监控工具可以在 github.com/remy/nodemongithub.com/foreverjs/forever 找到。

我们将在第七章 部署和维护 中讨论使用 PM2 的应用程序部署策略,包括使用 PM2 的 cluster 模式。

Nodetime

Nodetime 是一个易于使用的 Node 监控工具。访问 www.nodetime.com 并注册。一旦这样做,你将看到一个包含要包含在你的应用程序中的代码的页面。保持这个页面打开,因为它将在我们启动应用程序时更新。

首先,我们将创建一个简单的 HTTP 服务器应用程序,对于每个请求都返回 "Hello World"

"use strict";
require('nodetime').profile({
  accountKey: 'your_account_key',
  appName: 'monitoring'
});

var http   = require('http');

http.createServer(function(request, response) {

  response.writeHead(200, {
    "content-type" : "text/html"
  });
  response.end('Hello World');

}).listen(8080)

将其保存为 server.js。执行它:

node server.js

注意,在你的浏览器中的 Nodetime 页面上,你将在 应用程序 部分看到 监控 出现。点击该链接——你现在将看到 Nodetime 的监控界面:

Nodetime

在浏览器中访问 localhost:8080 来击中服务器。这样做几次后,返回您的 Nodetime 界面,并使用下拉列表选择 OS / 平均负载,选择其他有用的指标。尝试 Process/V8 heap total (MB) 来查看 V8 如何分配内存。其他指标允许您检查执行此服务器进程的机器的配置文件,等等。

使用 New Relic 进行监控

New Relic 是一个广为人知的监控服务器和应用程序的工具,它已升级以支持 Node。它旨在供那些希望监控内存和 CPU 使用率以及网络活动、Node 进程健康状况等的人使用。在本节中,我们将探讨如何在您的服务器上安装它,并提供其使用示例。

安装过程涉及从 New Relic 网站申请许可证密钥,网址为 newrelic.com。设置您的账户非常简单。注册后,您将看到 New Relic 提供的监控工具列表——您需要选择 New Relic Servers。在接下来的步骤中,您将选择 Node.js 作为您的开发环境以及您将在其中工作的操作系统。我将使用 CentOS。在选择您的操作系统后,您应该会看到为您生成的安装说明,包括您的许可证密钥——只需剪切和粘贴即可。

您正在安装并启动一个服务器,该服务器将探测系统进程并向 New Relic 报告结果。此服务器必须使用您的许可证密钥与 New Relic 进行身份验证,这意味着您必须将此密钥存储在可访问的位置。因此,将在您的系统上存储一个配置文件。对于大多数 Unix 安装,此文件将存储在 /etc/newrelic/nrsysmond.cfg。阅读该文件中描述的配置选项,例如日志文件的位置。

许多第三方部署环境/主机通常提供与 New Relic 的简单集成,例如 Heroku (devcenter.heroku.com/articles/newrelic)。

一旦 New Relic 运行,将创建一个日志文件,如果一切顺利,该文件应包含类似以下内容的行,表明 New Relic 现在正在跟踪:

{
  "v": 0,
  "level": 30,
  "name": "newrelic",
  "hostname": "your.server.net",
  "pid": 32214,
  "time": "2015-02-16T19:52:20.295Z",
  "msg": "Connected to collector-114.newrelic.com:443 with agent run ID 39366519380378313.",
  "component": "collector_api"
}

我们将通过 newrelic 包连接到该服务器。一旦该包安装到您的应用程序目录中,您将需要对其进行配置。有些尴尬的是,这意味着将 newrelic.js 文件从 node_modules/newrelic 复制到您的应用程序根目录,修改其内容,并添加您的许可证密钥以及您的应用程序名称。日志级别字段对应于 Bunyan 使用的日志级别,因此您可能想访问项目页面以获取更多信息:github.com/trentm/node-bunyan

当您进入生产环境时,您会希望避免在newrelic.js文件中存储您的许可证密钥。您可以通过环境变量传递配置参数给 New Relic,而不是通过硬编码。例如,您可以通过NEW_RELIC_LICENSE_KEY环境变量传递您的许可证密钥。

newrelic包仓库可以在github.com/newrelic/node-newrelic找到。这个项目页面包含了关于 New Relic 的使用和配置、环境变量等方面的详细信息。此外,还有设置客户端监控的示例。

让我们在一个示例应用程序中添加 New Relic 监控。创建以下 Express 服务器:

var newrelic = require('newrelic');
var express = require('express');

var app = express();

app.get('/', function(req, res) {
  res.send('Hello World');
});

app.get('/goodbye', function(req, res) {
  res.send('Goodbye World');
});
app.listen(3000);
console.log('Server started on port 3000');

如果您想添加额外的路由或更改路由名称,可以这样做。我们将运行这个服务器,点击几次,然后使用 New Relic 检查它捕捉到了什么。启动服务器并发出一些请求。

一旦您对服务器进行了一些测试,请转到newrelic.com并登录。在页面顶部,您将看到一个导航菜单,您将处于APM部分。这里您可以访问您应用程序的各种监控工具。您应该会看到一个包括您之前设置的名称的应用程序列表。点击该名称,您将被带到仪表板概览(目前不会有太多信息)。但是,您应该会看到一些关于服务器路由活动的信息:

使用 New Relic 进行监控

在左侧,将会有一个更详细的导航面板。转到报告 | Web 事务,您将看到您设置的路径的更详细信息。如果您导航到服务器部分,您将看到包含详细系统信息的宿主仪表板。

现在,让我们创建服务器负载并看看 New Relic 在监控方面的表现。如果您有一个喜欢的负载压力工具,请继续向您的应用程序发送一些流量。如果您想学习一个简单且常见的压力测试工具,可以学习和使用 Apache Bench 工具(httpd.apache.org/docs/2.2/programs/ab.html)。还有免费的在线压力测试服务,例如loader.ioloadimpact.com

一旦您开始压力测试,请返回您应用程序和宿主服务器的 New Relic 仪表板。您将看到 New Relic 监控报告请求对内存、CPU 负载和其他关键指标的影响时定期更新的统计数据。

摘要

在本章中,我们探讨了部署最重要的方面之一——监控运行中的进程。从最基本和必要的水平开始——捕获错误——你学习了如何在单个进程级别和跨进程级别捕获错误。在讨论了使用 UDP 和第三方工具记录错误的技术之后,我们探讨了如何使用 Node 的 REPL 构建远程进程监控器,进而深入讨论了如何进行广泛的应用程序分析和内存分析。最后,我们探讨了如何部署 PM2 进程运行器以管理进程并可视化其活动。我们还探讨了如何使用基于云的 Nodetime 和 New Relic 服务来监控你的应用程序。

直接监控提供了对任何潜在威胁的关键实时洞察,但我们也必须尝试通过编写我们能够自信的健壮代码来限制未来错误的可能性。在下一章中,我们将探讨如何构建和测试我们的应用程序,以便它们能够激发信心。将介绍如何最佳地构建和组织你的应用程序,以便其设计清晰。这样,将剩余章节内容关于测试策略应用到你的应用程序中可以与你的持续开发自然地流动。

第六章。构建和测试

完美的代码是独角兽;优秀开发者所做的就是引入尽可能少的坏代码。因此,任何代码多少都有缺陷,错误和低效是软件开发中不可避免的病理。相应地,技术债务会随着代码量的增加而自然累积。以下是现代应用程序开发中一些更昂贵的技术现实:

  • 存在一些严格耦合的组件,它们在技术层面或业务层面都不容易改变。允许这种无原则的渗透会导致复杂的毛细血管网络在你的代码主体中生长。这些网络的边缘几乎无法追踪,具体化了纠缠,掩盖了某个函数的变化可能如何影响其他函数。

  • 糟糕的守门人让未经测试的代码进入生产环境,通常会导致快速修复,这反过来又可能导致难以解决的补丁和桥接代码,以及不断出现的顽固错误。

  • 存在一些在并行中独立构建的代码单元,没有客观的 大局观 指南,它们被粗略地合并到一个代码库中,并通过未记录的、临时的绑定连接在一起。

  • 重构的需求达到临界点,任何意义上的进一步开发几乎都变得不可能。扩展天花板是这种情况的典型,全面重写是不可避免的,并且几乎总是注定要失败。

债务会累积利息。软件,就像许多长期追求一样,需要不断的债务管理。减少债务对你有利。在前一章中,我们学习了如何以足够的详细程度分析 已部署 应用程序,以暴露错误、弱点和其他不受欢迎的特征。在本章中,我们将探讨帮助软件开发者和团队在应用程序的膜被突破之前捕捉错误的策略。我们还将探讨管理独立编写的程序集成的流程。

使用 Gulp、Browserify 和 Handlebars 构建

你正在工作的 JavaScript 代码在进入生产之前可能会被转换和增强。至少,它将被检查错误、压缩、打包等。只有在之后才会部署。因此,部署遵循构建步骤,并且必须明确定义构建中每个步骤的仪器化。

随着时间的推移,Node 社区内部已经出现了一些发展模式。其中许多模式映射到其他环境中,而另一些则是 全栈 JavaScript Node.js 世界的独特之处。在客户端和服务器上运行相同代码的能力可能是最突出的例子。由于部署的代码库通常包含编译后的最终结果(例如,CoffeeScript 和 SASS),因此部署工作流程被组装起来以运行预处理器、合并文件、创建源映射、压缩图像等。

在本节中,我们将探讨在 Node 构建和部署过程中经常遇到的三种技术。我们将使用 Gulp 来创建构建系统,使用 Browserify 来打包应用程序代码,以及使用 Handlebars 作为静态页面的模板语言。最后,我们将探讨如何通过使用 BrowserSync 来提升我们的开发体验。

使用 Gulp

创建一个新的文件夹,并在该文件夹中使用 npm init 初始化一个 package.json 文件。完成此操作后,你将得到一个看起来类似于以下内容的 package.json 文件:

{
  "name": "building",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

随着我们继续前进,这个基本框架将被完善并解释。重点是,你现在将使用 npm 将应用程序的模块和其他依赖项挂载在这个框架上,整洁地描述依赖项、测试工具等。由于我们将使用 Gulp 构建系统,因此首先安装 Gulp 模块并将其声明为该包的依赖项是合理的。运行以下两个命令:

npm install gulp --global
npm install gulp --save-dev

第一个命令全局安装 Gulp,这意味着你现在可以直接从命令行使用 gulp 命令(你也可以用 -g 简写 --global)。下一个命令本地安装 Gulp,向 package.json 文件添加以下新属性:

  "devDependencies": {
    "gulp": "³.8.10"
  }

Gulp 已被安装并保存为依赖项。我们已准备好构建构建系统。

构建系统的一个目标是在你的开发环境中进行仪表化,这样你可以在开发时自然地使用未压缩、未优化的代码,稍后可以发出命令将你的 原始 代码和资源转换为适合预演环境、生产环境等优化的状态。为开发者提供一个表达性和简单的语法来描述如何将源代码转换为可部署的代码是 Gulp 力求实现的。

在你的工作目录中创建两个新的文件夹:一个名为 /source 的文件夹和一个名为 /build 的文件夹。我们将创建一组指令,用于将 source/ 中的文件转换为 /build 中的文件。这组指令存储在一个特定命名为 gulpfile.js 的文件中。创建该文件并插入以下代码:

"use strict";
var fs = require('fs');
var gulp = require('gulp');
var buildDirectory = './build';
gulp.task('default', function(cb) {
  fs.exists(buildDirectory, function(yes) {
    if(yes) {
      return cb();
    }
    fs.mkdirSync(buildDirectory);
    cb();
  });
});

Gulp 基于运行一系列任务按特定顺序执行的理念。一般格式是 gulp.task(<任务名称>, <任务执行器>)。Gulpfile 通常通过添加多个此类任务定义来扩展。正如我们将看到的,任务可以命名为任何你想要的名称,但必须始终有一个名为 default 的默认任务,并且前面的代码建立了一个这样的任务来执行一个简单的事情:确保存在一个 /build 文件夹,如果不存在,则创建一个。

需要注意的一点是任务运行器函数接收的第一个参数:一个回调函数,这里命名为cb。由于 Node 程序通常运行异步代码,因此有一个机制来告知 gulp 任务已完成是很重要的。我们正在运行异步代码来检查文件夹的存在,因此我们使用这个回调系统,但请注意,如果你的代码是同步运行的,或者任务完成的时刻对后续任务无关紧要,你可以跳过运行回调,Gulp 将在任务运行器退出后立即继续执行下一个任务。

在包含你的 Gulpfile 的文件夹中运行gulp命令。你应该会看到以下类似的内容:

Using gulpfile ~/building/gulpfile.js
Starting 'default'...
Finished 'default' after 720 μs

为了检查任务是否正确执行其工作,删除/build文件夹并再次运行gulp。你会看到文件夹被重新创建。

小贴士

由于 Gulp 期望其 Gulpfile 包含一个默认任务,因此gulp命令只是gulp default的快捷方式。你可以通过运行gulp <taskname>来执行特定的任务。

在典型的构建过程中,将运行许多任务。每个任务应该尽可能简单和具体,Gulpfile 应该整齐地组织它们,以便它们按特定顺序执行。因此,默认任务通常本身不做什么,而是用作提示将要运行的任务列表的方式。让我们以前述代码更直接的方式重写代码:

gulp.task('initialize',function(cb) {
  fs.exists(buildDirectory, function(yes) {
    ...
    cb();
  });
});

gulp.task('default', ['initialize'], function() {
  console.log('Build is complete');
});

在这里,我们可以更清楚地看到gulp是如何工作的。将第二个数组参数传递给gulp任务的定义,列出当前任务所依赖的其他任务——一个任务将在所有依赖任务完成后才运行。让我们向这个执行链添加另一个任务,该任务将/source文件夹中的文件复制到/build文件夹。将以下内容添加到你的 Gulpfile 中:

gulp.task('move', function() {
  gulp
  .src('./source/**')
  .pipe(gulp.dest('./build'))
});

现在,告诉gulp关于这个新任务的信息:

gulp.task('default', ['initialize', 'move'], function() ...)

除了task之外,你还将频繁使用srcpipedest Gulp 命令。Gulp 是一个流式构建系统——在任务中,你通常将一组文件识别出来,对它们运行一系列转换,并将转换后的文件放置在有用的位置,通常是包含可部署应用程序的文件夹。src命令用于识别这个集合,并将包含的文件转换为可流式传输的对象,以便可以使用pipe对它们进行操作以使用 gulp 插件。我们将在稍后提供更多相关信息。

注意

Gulp 的src命令的参数通常包含通配符(例如,/source/**),这是一种用于在文件夹内定位文件的模式匹配方式。更多关于它们如何工作的信息可以在github.com/isaacs/node-glob#glob-primer找到。

上述代码在/source目录中创建一组文件,并将它们通过(内置的)dest gulp 插件管道传输,该插件将它们写入/build。再次运行 gulp。你会看到以下类似的内容:

Starting 'initialize'...
Starting 'move'...
Finished 'move' after 3.66 ms
Finished 'initialize' after 4.64 ms
Starting 'default'...
Build is complete
Finished 'default' after 19 μs

你看到了什么问题吗?move任务在initialize完成之前运行,这会创建一个竞争条件——在move尝试向其中添加文件之前,/build目录会被创建吗?构建应该尽可能快,为此,Gulp 旨在实现最大并发性——除非你指定了其他方式,否则 Gulp 将并发运行所有任务。如前述代码所示,initializemove是同时开始的。如何强制执行特定的顺序?

传递给default的依赖项列表的顺序并不反映它们的执行顺序。然而,它确实代表了一个列表,这些任务必须在执行default之前完成。为了确保moveinitialize之后执行,只需将initialize作为move的依赖项即可:

gulp.task('move', ['initialize'], function() {
  ...
});

建立构建脚手架

既然你已经了解了 Gulp 的工作原理,让我们构建一个代表性的构建过程。我们将逐步开发一个 Gulpfile。首先,使用以下代码:

"use strict";

//  npm install coffee-script -> this is used for test task
require('coffee-script/register');

var path = require('path');
var mkdirp = require('mkdirp');
var del  = require('del');
var source = require('vinyl-source-stream');
var buffer = require('vinyl-buffer');
var browserSync = require('browser-sync');
var gulp = require('gulp');
var coffee = require('gulp-coffee');
var coffeelint = require('gulp-coffeelint');
var sourcemaps = require('gulp-sourcemaps');
var changed = require('gulp-changed');
var concat = require('gulp-concat');
var handlebars = require('gulp-handlebars');
var browserify = require('browserify');
var sass = require('gulp-sass');
var wrap = require('gulp-wrap');
var mocha = require('gulp-mocha');
var uglify = require('gulp-uglify');
var minifyHTML = require('gulp-minify-html');

//  A map of relevant source/build folders
var buildDir     = './build';
var sourceDir     = './source';
var s_scriptsDir   = './source/scripts';
var b_scriptsDir   = './build/js';
var s_stylesDir   = './source/styles';
var b_stylesDir   = './build/css';
var s_templatesDir   = './source/templates';
var b_templatesDir   = './build/templates';
var s_testsDir    = './source/tests';

//  Clean out build directories before each build
gulp.task('clean', function(cb) {
  del([
    path.join(b_scriptsDir, '**/*.js'),
    path.join(b_stylesDir, '**/*.css'),
    path.join(b_templatesDir, '*.js'),
    path.join(buildDir, '*.html')
  ], cb);
});
gulp.task('scaffold', ['clean'], function() {
  mkdirp.sync(s_scriptsDir);
  mkdirp.sync(b_scriptsDir);
  mkdirp.sync(s_stylesDir);
  mkdirp.sync(b_stylesDir);
  mkdirp.sync(s_templatesDir);
  mkdirp.sync(b_templatesDir);
  mkdirp.sync(s_testsDir);
});

...

gulp.task('default', [
  'clean',
  'scaffold',
  'lint',
  'scripts',
  'styles',
  'templates',
  'browserify',
  'views',
  'test',
  'watch',
  'server'
]);

在此文件的顶部,你会看到很多 require 语句。除了path之外,它们都将被用作 Gulp 插件或辅助工具。你可以直接复制你代码包/building文件夹中找到的package.json文件,或者继续使用--save-dev指令安装它们:npm install --save-dev gulp-coffee gulp-changed [...]

此外,使用npm install --save jquery handlebars命令安装jqueryhandlebarsnpm 模块作为依赖项。当我们讨论 Browserify 时,我们将提供更多关于为什么这样做的原因。

cleanscaffold任务存在是为了为你的应用程序构建文件夹结构,并在每次新构建发生时清理相关的构建目录(为新构建的文件腾出空间,而不留下旧文件的残留)。看看那些任务;它们最终确保以下文件夹结构:

build
  css
  js
  templates
source
  scripts
  styles
  templates
tests

在接下来的演示中,我们将使用CoffeeScript编写我们的 JavaScript 代码,将.coffee文件存储在source/scripts目录中,这些文件将被编译并移动到build/js目录。build/css目录将接收存储在source/styles中的转换后的.scss文件。Handlebars 模板将被预编译并从source/templates移动到build/templates。最后,构成我们应用程序主要“页面”的.html文件将位于/source,并移动到根目录/build。稍后,我们将添加任务以通过 Web 服务器公开这些 HTML 视图。

在代码片段的底部,你会看到我们将定义的任务列表,这些任务将作为默认 Gulp 任务的依赖项绑定。让我们逐一过一遍这些任务。

文件检查涉及在脚本上运行语法检查器,强制执行各种规则,例如缩进、是否允许某些结构、是否强制使用分号等。我们将仅使用 CoffeeScript,因此我们使用gulp-coffeelint插件实现一个检查任务:

gulp.task('lint', ['scaffold'], function() {
  return gulp.src(path.join(s_scriptsDir, '**/*.coffee'))
  .pipe(coffeelint('./coffeelint.json'))
  .pipe(coffeelint.reporter('default'))
});

我们只是检查将要转换成 /js 构建文件夹中 JavaScript 文件的 CoffeeScript 文件的语法。任何差异都会报告到 stdout,但不会停止构建。应用包含语法规则的 coffeelint.json 文件。你应该调查此文件,并根据需要修改它——更多信息可以在 www.coffeelint.org 找到。

下一步是构建这些新清理过的脚本:

gulp.task('scripts', ['lint'], function() {
  return gulp.src(path.join(s_scriptsDir, '**/*.coffee'))
  .pipe(changed(b_scriptsDir, {extension: '.js'}))
  .pipe(sourcemaps.init())
  .pipe(coffee({bare: true}))
  .pipe(sourcemaps.write())
  .pipe(gulp.dest(b_scriptsDir))
});

这里正在进行几个构建步骤。我们可以简单地转换 CoffeeScript 文件到 JavaScript 文件,并将它们复制到 build/scripts 文件夹。然而,由于转换后的 JavaScript 文件不是原始源文件,我们需要创建一个 源映射——这是一个将错误映射到生成该 JavaScript 的 原始 CoffeeScript 源 的基本工具。这在我们在浏览器中进行调试时非常有价值。正如你在代码中所看到的,我们简单地使用 gulp-sourcemaps 插件来跟踪编译步骤,并且它会自动将源映射附加到生成的 JavaScript 文件,其外观大致如下:

//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbInNhbXBsZS5jb2ZmZWUiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsSUFBQSxJQUFBOztBQUFBLElBQUEsR0FBTyxxQkFBUCxDQUFBIiwiZmlsZSI6InNhbXBsZS5qcyIsInNvdXJjZVJvb3QiOiIvc291cmNlLyIsInNvdXJjZXNDb250ZW50IjpbImRheXMgPSBbMS4uN11cbiJdfQ==

gulp-changed 插件智能地跟踪是否有任何目标文件已更改,如果没有,则插件会将其从处理中移除。此插件可以显著减少处理大量文件的任务的执行时间。请注意,我们将扩展名参数设置为 .js 作为选项,因为原始文件扩展名(.coffeescript)将更改,并且插件必须被告知这种命名更改。

我们将使用 Sass CSS 预处理器(通过 .scss 扩展名表示)在我们的系统中创建样式。在以下任务定义中,它们被转换为标准 CSS。此外,它们使用 gulp-concat 插件捆绑成一个单独的输出文件(app.css):

gulp.task('styles', function() {
  return gulp.src(path.join(s_stylesDir, '**/*.scss'))
  .pipe(sass())
  .pipe(concat('app.css'))
  .pipe(gulp.dest(b_stylesDir));
});

在构建步骤中捆绑成一个单独的文件,全局样式可以通过单个 <link> 标签添加到任何视图中,同时在开发过程中保持样式文档的必要分离。

下一步稍微复杂一些。我们将使用 Handlebars 模板,它们(可能)看起来像这样:

<ul>
  {{#each days}}
    <li>{{this}}</li>
  {{/each}}
</ul>

为了让 Handlebars 向前面的迭代器提供一些 JSON 以进行处理,模板必须通过 Handlebars.template 方法编译成 JavaScript 函数。虽然这可以在客户端完成,但在构建步骤中简单地预编译我们的模板会更有效率。所以,我们将做的是将每个模板导出为一个单独的 Node 模块,这样就可以像使用模块一样使用它们。为了实现这一点,我们将使用 gulp-wrap 插件:

gulp.task('templates', function () {
  return gulp.src(path.join(s_templatesDir, '/**/*.hbs'))
  .pipe(handlebars())
  .pipe(wrap('var Handlebars = require("handlebars/runtime")["default"];module.exports = Handlebars.template(<%= contents %>);'))
  .pipe(gulp.dest(b_templatesDir));
});

这个任务将每个源文件包裹在将使用 Handlebars 运行时将源代码编译成可导出 JavaScript 函数的代码中。现在,模板可以在你的客户端代码中使用,而无需在运行时加载 Handlebars 或使用它进行编译。例如,使用以下代码:

var myTemplate = require("build/templates/myTemplate.js");
$(document.body).append(myTemplate({days: ['mon','tue','wed'...]}));

你可能会对自己说,“但是等等……客户端 JavaScript 没有require语句!”……你是对的!这就是 Browserify 的强大之处:

gulp.task('browserify', ['scripts', 'templates', 'views'], function() {
  return browserify(b_scriptsDir + '/app.js')
  .bundle()
  // Converts browserify out to streaming vinyl file object
  .pipe(source('app.js'))
  // uglify needs conversion from streaming to buffered vinyl file object
  .pipe(buffer())
  .pipe(uglify())
  .pipe(gulp.dest(b_scriptsDir));
});

browserify.org/所述:

"使用 Browserify,你可以像在 Node 中使用一样使用 require。"

这允许我们像在 Node 中运行一样编写我们的客户端应用程序代码,同时加入一个 DOM 文档。在前面的任务中,Browserify 自动获取所有app.js依赖(require的实例),将它们捆绑成一个将在客户端运行的文件,运行gulp-uglify插件来压缩生成的 JavaScript,并用 Browserified 捆绑包替换旧文件。app.js文件可以包含我们需要的所有代码,在一个文件中,从而简化并标准化客户端集成。

Browserify 不仅仅关于连接。重点是,使用 Browserify,我们可以在客户端和服务器上使用 npm 模块,标准化我们的流程,因此利用智能包管理来处理客户端 JavaScript。这是新的且重要的:我们在客户端获得了包管理和其标准加载系统的力量。虽然一些客户端框架提供了类似模块管理系统的东西,但没有任何这些黑客可以取代稳固的 npm 系统。考虑以下示例source/scripts/app.coffee文件:

$ = require("jquery")
days = require("../../build/js/sample.js")
complimentTemplate = require("../../build/templates/compliment.js")
helloTemplate = require("../../build/templates/hello.js")
daysTemplate = require("../../build/templates/days.js")
$ ->
  $("#hello").html helloTemplate(name: "Dave")
  $("#compliment").html complimentTemplate(compliment: "You're great!")
  $("#days").html daysTemplate(days: days)

如果你检查你的代码包,你会找到这个文件。注意我们是如何require jQuery 的 npm 模块版本,以及我们之前从 Handlebars 模板创建的预编译模板。然而,我们在客户端运行,因此我们可以使用 jQuery 操作将 HTML 添加到 DOM 中——这是两者的最佳结合。

views的任务非常简单:

gulp.task('views', ['scaffold'], function() {
  return gulp.src(path.join(sourceDir, '*.html'))
  .pipe(minifyHTML({
    empty: true
  }))
  .pipe(gulp.dest(buildDir))
});

我们只是在压缩 HTML 并将文件移动到构建目录,没有进行任何其他更改。

运行和测试你的构建

到目前为止,我们已经设置了所有任务来管理我们仓库的关键文件。让我们使用browser-sync来自动启动一个服务器和一个浏览器窗口,该窗口将从我们的构建目录加载index.html文件:

gulp.task('server', ['test','watch'], function() {
  browserSync({
    notify: false,
    port: 8080,
    server: {
      baseDir: buildDir
    }
  });
});

下一个任务将解释testwatch任务。现在,请注意添加服务器到构建过程是多么容易。这个任务在提供的端口上启动一个服务器,并自动将baseDir中找到的index.html加载到一个自动生成的浏览器窗口中。notify选项告诉 BrowserSync 不要在连接的浏览器中显示调试通知。现在,每次我们运行 Gulp,我们的应用程序都会在浏览器中加载。你的终端应该显示类似以下的信息:

运行和测试你的构建

BrowserSync 允许多个客户端查看你的构建,因此提供了一个外部访问 URL。此外,他们还将看到你的交互。例如,如果你滚动页面,连接的客户端的页面也会滚动。此外,UI URL 将暴露一个用于构建的仪表板,允许你控制连接的客户端、重新加载他们的视图等等。当你为团队或客户进行演示时,这是一个非常好的工具。要了解更多关于 BrowserSync 及其配置的信息,请访问 www.browsersync.io/

一个好的构建系统应该提供一个测试框架作为构建是否应该获得认证的最终裁决者。我们将在本章后面深入探讨使用 MochaChaiSinon 进行测试,所以在这里我们只演示一个非常简单的测试占位符,你可以在设计 Gulp 工作流程时在此基础上构建:

gulp.task('test', ['browserify'], function() {
  return gulp.src(path.join(s_testsDir, '**/*.coffee'), {
    read: false
  })
  .pipe(coffee({bare: true}))
  .pipe(mocha({
  reporter: 'spec'
  }));
});

在测试目录中有一个用 CoffeeScript 编写的测试文件:

days = require('../../build/js/sample.js')
assert = require("assert")
describe "days() data", ->
  it "should have a length of 7", ->
    assert.equal days().length, 7

这个测试将加载我们的模板模块之一,该模块导出一个包含七个成员的数组——一周中的日子。测试使用 Node 的核心 assert 库(将在本章后面详细讨论)来测试这个数组是否具有正确的七个字符长度。Mocha 通过 describeit 提供测试框架,允许你设计看起来像自然语言的测试。当你运行 Gulp 时,你应该看到类似以下内容(如果一切顺利):

运行和测试你的构建

最后的任务由另一个本地的 Gulp 方法提供:watchwatch 的目的是将文件监视器绑定到特定的目录,以便任何文件更改都会自动触发相关构建任务的重新运行。例如,如果你发现 source/scripts 目录中的任何文件发生了变化,你可能希望再次运行 scripts 任务。以下代码演示了如何(更改)某些文件夹自动触发一系列构建任务:

gulp.task('watch', ['scaffold'], function() {
  gulp.watch(path.join(s_scriptsDir, '**/*'), [
    'browserify', browserSync.reload
  ]);
  gulp.watch(path.join(s_templatesDir, '**/*'), [
    'browserify', browserSync.reload
  ]);
  gulp.watch(path.join(s_stylesDir, '**/*'), [
    'styles', browserSync.reload
  ]);
  gulp.watch(path.join(sourceDir, '*.html'), [
    'views', browserSync.reload
  ]);
});

你会注意到 BrowserSync 也会绑定到更改上,从而创建一个非常自然的发展过程。一旦你在浏览器中显示了一个正在运行的构建,你对例如 index.html 所做的任何更改都会 自动 反映在该视图中。当你更改 CSS 时,你会立即看到更改,依此类推。在开发过程中将不再需要不断重新加载;BrowserSync 会为你推送更改。

你可能还需要做许多其他事情。例如,你可能希望在将图像推送到生产之前压缩它们。作为一个练习,在你的源目录和构建目录中创建相关的图像文件夹,并使用 gulp-imagemin 实现一个 images 任务(github.com/sindresorhus/gulp-imagemin)。

注意

Gulp 背后的团队提供了一组建议的模式,用于在github.com/gulpjs/gulp/tree/master/docs/recipes中实现常见的构建任务。

这里有一个最后的注意事项:你将经常手动编码这类构建系统,通常重用相同的模式。因此,已经创建了某些自动化工具,这些工具通常可以将创建样板构建代码的过程简化为几个命令。其中之一是Yeoman(yeoman.io/),它使得构建常见的“堆栈”构建步骤、数据库、服务器和框架变得容易。其他值得注意的解决方案包括Brunch(brunch.io/)和Mimosa(mimosa.io/)。

使用 Node 的本地测试工具

测试仅仅是检查你对某物状态的假设是否错误的行为。这样,测试软件遵循科学方法,即你将表达一个理论,做出预测,并运行一个实验来查看数据是否与你的预测相符。

与科学家不同,软件开发者可以改变现实——爱因斯坦关于改变事实以适应理论的笑话实际上毫无讽刺意味地适用于测试过程。事实上,这是必需的!当你的测试(理论)失败时,你必须改变“世界”,直到测试不再失败。

在本节中,你将学习如何使用 Node 的本地调试器进行实时代码测试,以及如何使用assert模块进行预测、运行实验和测试结果。

Node 调试器

大多数开发者都使用 IDE 进行开发。所有良好开发环境的关键特性之一是访问调试器,它允许在程序中设置断点,以便在需要检查状态或其他运行时方面的地方。

V8 与一个强大的调试器一起分发(通常用于 Google Chrome 浏览器开发者工具面板),并且 Node 可以访问它。它通过debug指令调用,如下所示:

> node debug somescript.js

简单的逐步调试和检查现在可以在 Node 程序中实现。考虑以下程序:

myVar = 123;
setTimeout(function () {
  debugger;
  console.log("world");
}, 1000);
console.log("hello");

注意debugger指令。在不使用debug指令的情况下执行此程序将导致显示"hello",一秒后显示"world"。当使用指令时,你会看到以下内容:

> node debug somescript.js
< debugger listening on port 5858
connecting... ok
break in debug-sample.js:1
 1 myVar = 123;
 2 setTimeout(function () {
 3   debugger;
debug>

一旦遇到断点,我们将获得一个到调试器本身的 CLI,从其中我们可以执行标准的调试和其他命令:

  • cont, c:这将从最后一个断点继续执行,直到下一个断点

  • step, s:进入步骤——这将一直运行,直到遇到新的源行(或断点);之后,将控制权返回给调试器

  • next, n:这与前面的命令类似,但新源行上的函数调用将执行而不停止

  • out, o: 跳出——这将执行当前函数的剩余部分并返回到父函数

  • backtrace, bt: 这将以类似于以下方式追踪到当前执行帧的步骤:

    ...
    #3 Module._compile module.js:456:26
    #4 Module._extensions..js module.js:474:10
    #5 Module.load module.js:356:32
    ... etc.
    
  • setBreakpoint(), sb(): 这将在当前行设置一个断点

  • setBreakpoint(Integer), sb(Integer): 这将在指定的行设置一个断点

  • clearBreakpoint(), cb(): 这将在当前行清除一个断点

  • clearBreakpoint(Integer), cb(Integer): 这将在指定的行清除一个断点

  • run: 如果调试器的脚本已终止,这将再次启动它

  • restart: 这将终止并重新启动脚本

  • pause, p: 这将暂停正在运行的代码

  • kill: 这将终止正在运行的脚本

  • quit: 这将退出调试器

  • version: 这将显示 V8 版本

  • scripts: 这将列出所有已加载的脚本

    提示

    要重复最后的调试器命令,只需在键盘上按 Enter 键。

返回到我们正在调试的脚本,在调试器中输入 cont 将产生以下输出:

debug> cont
< hello // ... a pause of 1000 ms will now occur, then...
break in debug-sample.js:3
 1 myVar = 123;
 2 setTimeout(function () {
 3   debugger;
 4   console.log("world");
 5 }, 1000);
debug>

注意,当我们开始调试器时,尽管你可能会期望在 setTimeout 回调中的断点被达到之前执行 console.log('hello') 命令,但 "hello" 并没有被打印出来。调试器在运行时不会执行;它在编译时以及运行时进行评估,让你深入了解你的程序的字节码是如何被组装的,最终将如何执行,而不仅仅是编译后的打印输出,这是 console.log 提供的。

通常在断点处进行一些检查是有用的,例如检查变量的值。调试器还有一个额外的命令 repl,它允许这样做。目前,我们的调试器在成功解析脚本并执行 console.log('hello'),即第一个推入事件循环的函数后停止。如果我们想检查 myVar 的值呢?使用 repl

debug> repl
Press Ctrl + C to leave debug repl
> myVar
123

在这里尝试使用 REPL,实验它可能的使用方式。

在这一点上,我们的程序只剩下一个要执行的指令——打印 "world"。立即的 cont 命令将执行这个最后的命令,事件循环将没有其他事情要做,我们的脚本将终止:

debug> cont
< world
program terminated
debug>

作为实验,再次 run 脚本,在执行这个最终上下文之前使用 next 而不是 cont。继续按 Enter 并尝试跟随正在执行的代码。你将看到,在打印 "world" 之后,timers.js 脚本将被引入这个执行上下文,因为 Node 在触发超时后进行清理。在这个时候在调试器中运行 scripts 命令。你将看到类似以下的内容:

debug> next
break in timers.js:125
 123
 124   debug(msecs + ' list empty');
 125   assert(L.isEmpty(list));
 126   list.close();
 127   delete lists[msecs];
debug> scripts
* 37: timers.js
 46: debug-sample.js
debug>

实验各种方法将是有用的,了解 Node 在深层执行脚本时会发生什么,以及 Node 如何帮助满足你的调试需求。

注意

阅读以下文档可能会有所帮助,该文档描述了如何使用 Google Chrome 调试器接口:developers.google.com/chrome-developer-tools/docs/javascript-debugging#breakpoints

强烈推荐使用 Miroslav Bajtos 的 node-inspector 模块进行调试,它允许开发者从 Chrome 浏览器远程调试 Node 应用程序。您可以在github.com/node-inspector/node-inspector上找到更多相关信息。

'assert' 模块

Node 的 assert 模块用于简单的单元测试。在许多情况下,它足以作为测试的基本脚手架,或者用作测试框架(如我们稍后将看到的 Mocha)的断言库。其用法简单;我们想要断言某事的真实性,如果我们的断言不正确,则抛出错误。例如,使用以下命令:

> require('assert').equal(1,2,"Not equal!")
AssertionError: Not equal!
 at repl:1:20
 ...

如果断言为 true(两个值相等),则不会返回任何内容:

> require('assert').equal(1,1,"Not equal!")
undefined

遵循 UNIX 的沉默规则,当程序没有令人惊讶、有趣或有用的内容要说时,它应该保持沉默,断言仅在断言失败时返回值。返回的值可以通过使用可选的消息参数进行自定义,如前述代码所示。

assert 模块 API 由一组具有相同调用签名的比较操作组成——实际值、预期值以及当比较失败时显示的可选消息。还提供了作为快捷方式或特殊情况的处理器的方法。

必须区分 身份比较(===)相等比较(==);前者通常被称为严格相等比较(如 assert API 的情况)。由于 JavaScript 使用动态类型,当使用等号操作符 == 比较不同类型的两个值时,会尝试将一个值强制转换为另一个值——这是一种通分操作。例如,使用以下代码:

1 == "1" // true
false == "0" // true
false == null // false

如您所预期的那样,这类比较可能会导致令人惊讶的结果。注意当使用身份比较时,结果更加可预测:

1 === "1" // false
false === "0" // false
false === null // false

需要记住的是,=== 操作符在比较之前不执行类型强制转换,而等号操作符在类型强制转换之后进行比较。此外,由于 JavaScript 中的对象是通过引用传递的,因此具有相同值的两个对象的身份是不同的——对于对象来说,身份要求两个操作数都引用同一个对象

var a = function(){};
var b = new a;
var c = new a;
var d = b;
console.log(a == function(){}) // false
console.log(b == c) // false
console.log(b == d) // true
console.log(b.constructor === c.constructor); // true

最后,对于不需要精确身份的对象比较,使用了 深度相等 的概念。如果两个对象都具有相同数量的自有属性、相同的原型、相同的键集(尽管不一定按相同的顺序),并且每个属性的值都等效(但不相同),则这两个对象是深度相等的:

var a = [1,2,3];
var b = [1,2,3];
assert.deepEqual(a, b);  // passes
assert.strictEqual(a, b);  // throws AssertionError: [1,2,3] === [1,2,3]

通过设计断言测试来测试你对值之间相互理解的假设是有用的。结果可能会让你感到惊讶。

以下是对使用此模块可以进行的断言的总结:

  • assert.equal(actual, expected, [message]): 这用于测试使用 == 进行强制相等性。

  • assert.notEqual(actual, expected, [message]): 这用于测试使用 != 进行强制相等性。

  • assert.deepEqual(actual, expected, [message]): 这用于测试深度相等性。

  • assert.notDeepEqual(actual, expected, [message]): 这用于测试深度不等性。

  • assert.strictEqual(actual, expected, [message]): 这用于测试身份等价 ===。

  • assert.notStrictEqual(actual, expected, [message]): 这用于测试身份不匹配 !==。

  • assert(value, [message]): 如果发送的值不是真实的,则抛出错误。

  • assert.ok(value, [message]): 这与 assert(value) 相同。

  • assert.ifError(value): 如果值是真实的,则抛出错误。

  • assert.throws(block, [error], [message]): 这用于测试提供的代码块是否抛出错误。可选的错误值可以是错误构造函数、正则表达式或返回布尔值的验证函数。

  • assert.doesNotThrow(block, [error], [message]): 这用于测试提供的代码块是否没有抛出错误。

  • assert.fail(actual, expected, message, operator): 这会抛出异常。这在异常被 try/catch 块捕获时最有用。

console API 中有一个快捷方法来记录断言结果:

> console.assert(1 == 2, "Nope!")
AssertionError: Nope!

注意

对于如何在 JavaScript 中进行比较的更详细解释,请参阅 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Comparison_Operators

现在,让我们看看使用更高级的测试框架和工具进行测试。

使用 Mocha、Chai、Sinon 和 npm 进行测试

为你的代码编写测试的一个巨大好处是,你将被迫思考你所写的内容是如何工作的。难以编写的测试可能表明代码难以理解。另一方面,全面的测试覆盖和良好的测试有助于他人(和你)理解应用程序的工作方式。

在设置测试环境时,至少有三个概念需要考虑。

测试的目的是比较接收到的值和应用程序代码期望的值。如我们之前所见,Node 的 assert 模块是为了这个目的而设计的,但它的功能仅限于单个、孤立的断言。我们将使用 Chai 库 (chaijs.com),它为你提供了更丰富的语言和成语来做出断言。

一个应用程序被数百个测试覆盖并不罕见。当断言被分组,例如按功能或业务单元分组时,这些组可以为你提供关于应用程序状态的更清晰图景。设计和实施工具来进行这种分组,尤其是在异步代码中,是困难的。幸运的是,存在几个知名且设计良好的测试运行器供你使用。我们将使用 Mocha (mochajs.org),它使得组织、控制和显示测试结果变得更加容易。

测试通常在开发环境中进行,而不是在实时生产环境中。你如何为不在真实环境中运行的代码编写测试?例如,我如何测试我的代码处理无法本地连接的网络端点的响应的能力?我如何检查函数被发送的参数,而无需重写该函数?我们将使用 Sinon (sinonjs.org/),它允许你创建合成方法和其他模拟。

注意

其他流行的测试运行器包括 Jasmine (github.com/jasmine/jasmine) 和 Vows (github.com/vowsjs/vows)。Should (github.com/shouldjs/should.js) 是一个流行的断言库。

首先,设置一个包含以下结构的文件夹:

scripts
spec
  helpers

/scripts 文件夹包含我们将要测试的 JavaScript 代码。/spec 文件夹包含配置和测试文件。

现在,使用 npm init 初始化一个 package.json 文件。你可以在提示时直接按 Enter,但当你被要求输入测试命令时,请输入以下内容:

mocha ./spec --require ./spec/helpers/chai.js --reporter spec

随着我们继续前进,这会更有意义。现在,认识到这个分配给 npm 的 test 属性断言我们将使用 Mocha 进行测试。Mocha 的测试报告将是 spec 类型,并且该测试将存在于 /spec 目录中。我们还将需要一个 Chai 的配置文件,这将在接下来的某个部分中解释。重要的是,这已经在 npm 中创建了一个脚本声明,允许你使用 npm test 命令运行你的测试套件。在接下来的部分中,你需要运行 Mocha 测试时,请使用该命令。

如果你还没有安装,请使用 npm install mocha -g 全局安装 Mocha。同时,使用 npm install mocha chai sinon redis --save-dev 命令安装我们需要的本地测试模块。

Mocha

Mocha 是一个不关心测试断言本身的测试运行器。Mocha 用于组织和运行你的测试,主要通过使用 describeit 操作符。以下代码展示了这一点:

 describe("Test of Utility Class", function() {
  it("Running #date should return a date", function(){
    // Test date function
  });
  it("Running #parse should return JSON", function() {
    // Run some string through #parse
  });
});

如上图所示,Mocha 测试框架留出了测试的描述和组织方式,并且不对测试断言的设计做出任何假设。

你可以设置同步运行的测试,如前述代码所述,或者使用传递给所有 it 回调的完成处理程序异步运行:

describe("An asynchronous test", function() {
  it("Runs an async function", function(done) {
    // Run async test, and when finished call...
    done();
  });
});

块也可以嵌套:

describe("Main block", function() {
  describe("Sub block", function() {
    it("Runs an async function", function() {
      // A test running in sub block
    });
  });
  it("Runs an async function", function() {
    // A test running in main block
  });
});

最后,Mocha 提供 hooks,允许你在测试前后运行代码:

  • beforeEach()describe 块中的每个测试之前运行

  • afterEach()describe 块中的每个测试之后运行

  • before() 在任何测试之前运行代码——在 beforeEach 任何运行之前

  • after() 在所有测试运行之后运行代码——在 afterEach 任何运行之后

通常,这些用于设置测试上下文,例如在特定测试之前创建变量,并在某些其他测试之前清理这些变量。

这个简单的工具集合足以处理大多数测试需求。此外,Mocha 提供了各种测试报告器,提供不同格式的结果。当我们构建实际的测试场景时,我们将在后面看到这些报告器的实际应用。

Chai

如我们之前在 Node 的原生 assert 模块中看到的,在基础层面,测试涉及断言我们期望代码块执行的操作,执行该代码,并检查我们的期望是否得到满足。Chai 是一个具有更丰富语法的断言库,提供三种断言风格:expectshouldassert。我们将使用 Chai 为 Mocha it 语句提供断言(测试),优先使用 expect 断言风格。

注意

注意,虽然 Chai.assert 是基于核心 Node assert 语法构建的,但 Chai 通过添加额外的方法增强了该对象。

首先,我们将创建一个名为 chai.js 的配置文件:

var chai = require('chai');
chai.config.includeStack = true;
global.sinon = require('sinon');
global.expect = chai.expect;
global.AssertionError = chai.AssertionError;
global.Assertion = chai.Assertion;

将此文件放在 /spec/helpers 文件夹中。这将告诉 Chai 显示任何错误的完整堆栈跟踪,并将 expect 断言风格作为 global 变量暴露。同样,Sinon 也作为 global 变量暴露。此文件将增强 Mocha 测试运行上下文,这样我们就可以使用这些工具而无需在每个测试文件中重新声明它们。

expect 风格的断言读起来像一句话,由像 to, be, is 这样的单词组成的 句子。以下代码作为例子:

expect('hello').to.be.a('string')
expect({ foo: 'bar' }).to.have.property('foo')
expect({ foo: 'bar' }).to.deep.equal({ foo: 'bar' });
expect(true).to.not.be.false
expect(1).to.not.be.true
expect(5).to.be.at.least(10) // fails

要探索创建 expect 测试链时可用的大量 单词,请参阅完整的文档chaijs.com/api/bdd/

如前所述,Mocha 对你如何创建断言没有意见。我们将使用 expect 在接下来的测试中创建断言。

考虑测试以下对象中的 capitalize 函数:

var Utils = function() {
  this.capitalize = function(str) {
    return str.split('').map(function(char) {
      return char.toUpperCase();
    }).join('');
  };
};

我们可能会做类似这样的事情:

describe('Testing Utils', function() {
  var utils = new Utils();
  it('capitalizes a string', function() {
    var result = utils.capitalize('foobar');
    expect(result).to.be.a('string').and.equal('FOOBAR');
  });
});

这个 Chai 断言将是 true,Mocha 将报告相同的结果。这在上面的屏幕截图中有显示:

Chai

接下来,我们将看看如何将 Sinon 添加到我们的测试过程中。

Sinon

在测试环境中,你通常会模拟生产环境的现实情况,因为访问真实用户、数据或其他实时系统是不安全或不可取的。因此,能够模拟环境是测试的重要部分。此外,你通常会想检查的不仅仅是调用结果——你可能想测试某个函数是否在正确的上下文中被调用,或者是否使用了正确的示例。Sinon 是一个帮助你模拟外部服务、模拟函数、跟踪函数调用等的工具。

提示

sinon-chai模块扩展了 Chai,增加了 Sinon 断言。有关sinon-chai的更多信息,请访问github.com/domenic/sinon-chai

关键的 Sinon 技术是间谍存根模拟。此外,你可以设置假的计时器,创建假的服务器等(见sinonjs.org/)。本节重点介绍前三者。让我们逐一介绍每个的示例。

间谍

查看以下来自 Sinon 文档的文本,它定义了一个测试间谍:

"测试间谍是一个记录所有调用参数、返回值、this的值以及抛出的异常(如果有)的函数。测试间谍可以是一个匿名函数,或者它可以包装一个现有的函数。"

间谍收集它跟踪的函数的信息。例如:

var sinon = require('sinon');
var argA = "foo";
var argB = "bar";
var callback = sinon.spy();

callback(argA);
callback(argB);

console.log(
  callback.called,
  callback.callCount,
  callback.calledWith(argA),
  callback.calledWith(argB),
  callback.calledWith('baz')
);

这将记录以下内容:

true 2 true true false

间谍被调用了两次,一次是使用foo,一次是使用bar,从未使用baz

假设我们想测试我们的代码是否正确连接到 Redis 的 pub/sub 功能:

var redis = require("redis");
var client1 = redis.createClient();
var client2 = redis.createClient();

//  Testing this
function nowPublish(channel, msg) {
  client2.publish(channel, msg);
};
describe('Testing pub/sub', function() {
  before(function() {
    sinon.spy(client1, "subscribe");
  });
  after(function() {
    client1.subscribe.restore();
  });
  it('tests that #subscribe works', function() {
    client1.subscribe("channel");
    expect(client1.subscribe.calledOnce);
  });
  it('tests that #nowPublish works', function(done) {
    var callback = sinon.spy();
    client1.subscribe('channel', callback);
    client1.on('subscribe', function() {
      nowPublish('channel', 'message');
      expect(callback.calledWith('message'));
      expect(client1.subscribe.calledTwice);
      done();
    });
  });
});

在这个例子中,我们使用间谍和 Mocha 做了更多的事情。我们将间谍部署为代理client1的本地subscribe方法,重要的是在 Mocha 的beforeafter方法中设置和拆除间谍代理(恢复原始功能)。Chai 断言证明了subscribenowPublish都正常工作,并接收了正确的参数。

注意

更多关于间谍的信息可以在sinonjs.org/docs/#spies找到。

存根

当用作间谍时,存根可以围绕现有函数包装,以便它可以模拟该函数的行为(而不仅仅是像我们之前看到的那样记录函数执行)。查看以下来自 Sinon 文档的测试存根定义:

"测试存根是具有预编程行为的函数(间谍)。它们支持完整的测试间谍 API,以及可以用来改变存根行为的其他方法。"

假设你的应用程序中有一个功能,它调用 HTTP 端点。代码可能如下所示:

http.get("http://www.example.org", function(res) {
  console.log("Got status: " + res.statusCode);
}).on('error', function(e) {
  console.log("Got error: " + e.message);
});

当调用成功时,将记录Got status: 200。如果端点不可用,你将看到类似Got error: getaddrinfo ENOTFOUND的内容。

很可能你需要测试你的应用程序处理替代状态码的能力,当然,还有显式错误。你可能无法强制端点发出这些错误,但如果你遇到这些错误,你必须做好准备。在这里,存根很有用,可以创建合成响应,以便可以全面测试响应处理程序。

我们可以使用存根来模拟响应,而不实际调用http.get方法:

var http = require('http');
var sinon = require('sinon');
sinon.stub(http, 'get').yields({
  statusCode: 404
});
// This URL is never actually called
http.get("http://www.example.org", function(res) {
  console.log("Got response: " + res.statusCode);
  http.get.restore();
});

这个存根通过包装原始方法(该方法从未被调用)来产生模拟响应,导致从通常返回状态码 200 的调用返回404错误。重要的是要注意,我们在完成时如何将存根方法恢复到其原始状态。

例如,以下伪代码描述了一个模块,该模块执行 HTTP 调用,解析响应,并在一切顺利时响应'handled',如果 HTTP 响应意外,则响应'not handled'

var http = require('http');
module.exports = function() {
  this.makeCall = function(url, cb) {
    http.get(url, function(res) {
      cb(this.parseResponse(res));
    }.bind(this))
  }
  this.parseResponse = function(res) {
    if(!res.statusCode) {
      throw new Error('No status code present');
    }
    switch(res.statusCode) {
      case 200:
      return 'handled';
      break;
      case 404:
      return 'handled';
      break;
      default:
      return 'not handled';
      break;
    }
  }
}

以下 Mocha 测试确保Caller.parseReponse方法可以使用存根模拟整个预期的响应范围来处理我们需要的所有响应代码:

var Caller = require('../scripts/Caller.js');

describe('Testing endpoint responses', function() {
  var caller = new Caller();
  function setTestForCode(code) {
    return function(done) {
      sinon.stub(caller, 'makeCall').yields(caller.parseResponse({
        statusCode: code
      }));
      caller.makeCall('anyURLWillDo', function(h) {
        expect(h).to.be.a('string').and.equal('handled');
        done();
      });
    }
  }
  afterEach(function() {
    caller.makeCall.restore();
  });
  it('Tests 200 handling', setTestForCode(200));
  it('Tests 404 handling', setTestForCode(404));
  it('Tests 403 handling', setTestForCode(403));
});

通过代理原始的makeCall方法,我们可以测试parseResponse对一系列状态码的响应,而无需强制远程网络行为。注意,前面的测试应该失败(没有处理 403 代码的处理程序),这个测试的输出应该类似于以下内容:

存根

存根的完整 API 可以在sinonjs.org/docs/#stubs中看到。

模拟

与在事后检查期望不同,模拟可以用来检查被测试的单元是否被正确使用——它们强制实施实现细节。看看以下从 Sinon 文档中摘取的模拟定义:

"模拟(以及模拟期望)是具有预编程行为(如存根)的假方法(如间谍),以及预编程的期望。如果模拟没有被按预期使用,它将使你的测试失败。"

在以下示例中,我们不仅检查一个特定函数被调用的次数(通过间谍很容易做到),还检查它是否以特定的、预期的参数被调用。具体来说,我们再次测试Utilscapitalize方法,这次使用模拟:

var sinon = require('sinon');
var Utils = require('./Utils.js');
var utils = new Utils();
var arr = ['a','b','c','d','e'];
var mock = sinon.mock(utils);

// Expectations
mock.expects("capitalize").exactly(5).withArgs.apply(sinon,arr);

arr.map(utils.capitalize);
console.log(mock.verify());

utils上设置模拟后,我们将一个包含五个元素的数组映射到capitalize,期望capitalize被精确地调用五次,数组元素作为参数(使用apply将数组展开为单独的参数)。然后检查命名良好的mock.verify函数,看我们的期望是否得到满足。像往常一样,当我们完成时,我们使用mock.restore解包utils对象。你应该在你的终端看到true被记录。

现在,从测试的数组中移除一个元素,使期望变得令人沮丧。当你再次运行测试时,你应该在输出顶部附近看到以下内容:

ExpectationError: Expected capitalize([...]) 5 times (called 4 times)

这应该可以阐明模拟旨在产生的测试结果类型。

注意

注意到模拟函数不会执行——mock 会覆盖其目标。在上一个例子中,没有任何数组成员会通过 capitalize 执行。

让我们回顾一下之前的例子,这次我们将使用模拟来测试 Redis pub/sub:

var redis = require("redis");
var client = redis.createClient();

describe('Mocking pub/sub', function() {
  var mock = sinon.mock(client);
  mock.expects('subscribe').withExactArgs('channel').once();
  it('tests that #subscribe is being called correctly', function() {
    client.subscribe('channel');
    expect(mock.verify()).to.be.true;
  });
});

而不是检查结论,这里我们断言模拟的 subscribe 方法将只接收精确的参数 channel 一次。Mocha 期望 mock.verify 返回 true。为了使这个测试失败,添加一行更多的 client.subscribe('channel'),产生如下所示的内容:

ExpectationError: Unexpected call: subscribe(channel)

注意

关于如何使用模拟的更多信息可以在 sinonjs.org/docs/#mocks 找到。

使用 PhantomJS 和 CasperJS 进行自动化浏览器测试

测试 UI 是否正常工作的一种方法是为几个人支付费用,让他们通过浏览器与网站交互并报告他们发现的任何错误。这可以成为一个非常昂贵且最终不可靠的过程。此外,它还需要将可能失败的业务代码投入生产以进行测试。在发布任何视图之前,最好在构建过程中本身测试应用程序视图是否正确渲染。PhantomJS 就是为此需求而创建的,以及其他需求。

去掉了按钮和其他控制按钮的浏览器,本质上是一个验证和运行 JavaScript、HTML 和 CSS 的程序。验证的 HTML 在你的屏幕上以视觉形式呈现,这只是人类只能用眼睛看到的结果。服务器可以解释编译代码的逻辑并看到与该代码交互的结果,而不需要视觉组件。也许因为眼睛通常在人的头部,运行在服务器上的浏览器通常被称为无头浏览器。PhantomJS 提供了一个可由 JavaScript API 脚本化的 WebKit 引擎的无头版本。

使用 PhantomJS 进行无头测试

PhantomJS (phantomjs.org/build.html) 允许你创建可以在无头浏览器上下文中执行的脚本。它允许你在可脚本化的环境中捕获浏览器上下文,从而实现各种操作,例如将 HTML 页面加载到该上下文中。这允许你在该浏览器上下文中执行操作,例如操作已加载页面的 DOM。

例如,通过在浏览器中访问以下端点来获取 Twitter 用户的最新推文:http://mobile.twitter.com/<twitter user>。我们也可以使用 PhantomJS 在无头、可脚本化的环境中做同样的事情,然后编写代码来获取这些推文。创建一个包含以下代码的 phantom-twitter.js 文件:

var page = require('webpage').create();
var system = require('system');
var fs = require('fs');
var twitterId = system.args[1];

page.open(encodeURI("http://mobile.twitter.com/" + twitterId), function(status) {
  if(!status) {
    throw new Error("Can't connect to Twitter!");
  }
  var tweets = page.evaluate(function() {
    var _tweets = [];
    var coll = Array.prototype.slice.call(document.querySelectorAll('div.tweet-text'))
    coll.forEach(function(tweet) {
      _tweets.push(tweet.innerText);
    });
    return _tweets
  });
  fs.write(twitterId + '.json', JSON.stringify(tweets));
  phantom.exit();
});

现在,使用 CLI 将该脚本传递给 PhantomJS,发送你想要阅读的人的 Twitter 处理符作为参数:

phantomjs phantom-twitter.js kanyewest

将创建一个名为 kanyewest.json 的新文件,其中包含最近的推文,格式为 JSON。让我们来看看代码。

我们首先需要引入一些 PhantomJS 的核心模块,重要的是 page 库,它允许我们加载页面,以及 systemfs 模块(分别类似于 Node 的 processfs 模块)。我们将使用 system 来获取命令行参数,并使用 fs 将获取的推文写入文件系统。

page.open 命令执行了你预期的操作——将网页加载到 PhantomJS 上下文中。我们现在可以对渲染的 DOM 执行操作。在这种情况下,我们将在该页面的 JavaScript 上下文中使用 evaluate,获取由 div.tweet-text CSS 选择器标识的包含推文的元素,并移除 innerText。因为 evaluate 在无头 WebKit 的上下文中运行,我们无法访问外部的 PhantomJS 作用域,所以我们只需将评估作用域内找到的内容返回到外部作用域,在那里可以使用 fs 生成文件。

PhantomJS 提供了一个广泛的 API 来与 WebKit 交互(phantomjs.org/api/),允许脚本注入、创建屏幕截图、导航渲染页面等。可以使用这些工具创建一系列客户端测试。

当编写服务器测试时,你可能不想从命令行使用 PhantomJS。因此,已经编写了各种 Node-PhantomJS 桥接器,让你可以通过 Node 模块与 PhantomJS 交互。一个好的选择是 phantomjs (github.com/sgentle/phantomjs-node)。例如,以下代码将加载一个页面,如前所述,并执行 JavaScript 来获取页面的标题属性:

var phantom = require('phantom');
phantom.create(function(ph) {
  ph.createPage(function(page) {
    page.open("http://www.example.org", function(status) {
      page.evaluate(function() {
        return document.title;
      }, function(title) {
        console.log('Page title: ' + title);
        ph.exit();
      });
    });
  });
});

运行前面的代码应该会记录下类似以下内容:

Page title: Example Domain

使用 CasperJS 的导航场景

由于 PhantomJS 并非专门设计为测试运行器,其他人已经创建了工具来简化使用 PhantomJS 的测试。CasperJS (casperjs.org/) 是 PhantomJS 和 SlimerJS(使用 Firefox 的 Gecko 引擎)的导航和测试实用工具。

CasperJS 提供了一个广泛的工具集,使用表达式的 Promises-like 接口创建复杂的交互链。使用 CasperJS 描述页面交互测试需要更少的代码,并且更清晰。例如,前面的 phantom 示例演示了如何获取页面标题,可以简化如下:

casper.start('http://example.org/', function() {
  this.echo('Page title: ' + this.getTitle());
});
casper.run();

如果将前面的代码保存为名为 pagetitle.js 的文件,并使用 casperjs test pagefile.js 命令运行,你会看到以下记录:

Page title: Example Domain

一种更简洁的语法会产生相同的结果。让我们看看另一个示例,它演示了如何获取一页内容,点击该页上的链接,并从结果页面读取一些信息:

casper.start('http://google.com/', function() {
  this
  .thenEvaluate(function(term) {
    document.querySelector('input[name="q"]').setAttribute('value', term);
    document.querySelector('form[name="f"]').submit();
  }, 'node.js')
  .then(function() {
    this.click('h3.r a');
  })
  .then(function() {
    this.echo('New location: ' + this.getCurrentUrl());
  });
});
casper.run();

在这里,我们可以看到如何通过交互的 Promise-like 链式调用产生清晰和表达性强的代码。在获取 Google 的搜索页面后,我们将评估一段将node.js字符串插入其著名的搜索框并提交搜索表单的 JavaScript 代码。然后,CasperJS 被要求点击第一个结果链接(h3.r a),并最终显示当前 URL:

New location: http://nodejs.org/

这证明了发生了全页导航,此时我们可以链式调用更多的交互步骤。

最后,让我们使用一些 CasperJS 测试断言并演示如何在测试 Google 翻译服务时捕获网页快照:

casper.start('http://translate.google.com/', function() {
  this
  .sendKeys('#source', 'Ciao')
  .waitForText('Hello')
  .then(function() {
    this.test.assertSelectorHasText('#result_box', 'Hello');
  })
  .then(function() {
    this.capture('snapshot.png');
  });
});
casper.run();

Google 的翻译页面是动态的。当你输入翻译框时,服务会检测键盘事件,尝试根据任何可用的文本推断你使用的语言,并在“实时”提供翻译,所有这些都不需要刷新页面。换句话说,我们不是提交表单并等待结果页面。

因此,一旦页面加载完成,我们就向#source输入框输入意大利语单词"Ciao"的按键(sendKeys)。为了测试这会导致正确的翻译,我们等待出现"Hello"——当传递的文本出现在页面上时,waitForText会被触发。为了确保文本已到达正确的位置,我们断言具有#result_box选择器的元素包含"Hello"。如果一切顺利,你将看到以下日志:

PASS Find "Hello" within the selector "#result_box"

此外,在同一个文件夹中,你将找到snapshot.png图像,它可视化地展示了刚刚执行的基于 DOM 的交互:

使用 CasperJS 的导航场景

希望这能展示出 CasperJS 如何在你编写客户端测试时利用 PhantomJS 的力量。如果你想将 CasperJS 作为 Node 模块使用,可以尝试 SpookyJS (github.com/SpookyJS/SpookyJS)。

摘要

在本章中,我们探讨了测试和构建你的应用程序,以便你可以对其在生产环境中的能力有一个良好的认识。我们通过一个代表性的构建系统,使用 Gulp 和 Browserify,以及一些其他工具,展示了代码库如何被优化和打包以供部署。此外,你还了解了 Node 的本地调试工具和断言库。

从一开始,Node 社区就接受了测试,并为开发者提供了许多测试框架和原生工具。你学习了如何使用 Gulp、Mocha、Chai 和 Sinon 设置一个合适的测试系统,在这个过程中,你尝试了无头浏览器测试。

下一章将专注于将您已测试的构建部署到生产服务器。您将学习如何在虚拟机上设置本地开发环境,配置远程服务器,使用 webhooks 和 Jenkins 设置持续集成,维护应用程序依赖项,以及通常情况下,在做出更改时保持应用程序平稳运行。

第七章:部署和维护

在本书中,我们已经看到了将应用程序构建成定义良好的组件的优点。这个过程涉及安装许多支持系统,从应用程序将运行的操作系统,到您将支持的 Node 版本,再到各种 npm 模块、测试框架、分析工具以及其他支撑应用程序的子系统。您可能一直在单台机器上做所有这些——手动启动和停止服务器、更新配置和修改应用程序代码。您是否正在添加新模块?停止服务器,添加模块,然后重新启动服务器。

在生产环境中,这种临时性的开发几乎是不可能的,而且无论如何都相当繁琐。如何自动化并简化这个过程,以便在服务器负载平衡数量发生变化或增量推送新部署时,可以以最少的劳动完成,从而让负责运营的人员的生活变得更加简单?

在本章中,我们将学习以下内容:

  • 自动化应用程序的部署,包括对持续集成、交付和部署之间差异的探讨

  • 使用 Git 跟踪本地更改,并在适当的时候通过 webhooks 触发部署操作

  • 使用 Vagrant 将本地开发环境与已部署的生产服务器同步

  • 使用 Ansible 配置服务器

  • 使用 Jenkins 实现持续集成和部署,并通过一个完整的示例了解如何在源代码更改时自动化构建和部署

  • 维护 npm 包和依赖树,概述如何跟踪版本变化,并确保部署的应用程序保持最新

注意,应用程序部署是一个复杂的话题,涉及许多维度,这些维度通常在独特的需求集中被考虑。本章旨在介绍您将遇到的一些技术和主题。此外,请注意,第三章中讨论的扩展问题,扩展节点,是部署的一部分。同样,第二章中关于安装和虚拟化节点服务器的讨论也与此相关。您可能希望在处理以下部署场景时回顾这些主题。

使用 GitHub webhooks

在最基本层面上,部署涉及自动验证、准备和将新代码发布到生产环境。设置部署策略的最简单方法之一是在 Git 仓库中提交更改时通过使用webhooks触发发布。引用 GitHub 文档,webhooks 提供了一种在仓库上发生某些操作时将通知发送到外部 Web 服务器的方式

在第二章安装和虚拟化 Node 服务器中,我们看到了这个过程的简化示例,其中将更改推送到 Heroku 实例会导致您的生产构建自动更新。这个简单解决方案的一个问题是没有任何验证——如果您推送了坏代码,您的生产服务器会盲目地运行坏代码。在本节中,我们将使用 GitHub 钩子创建一个简单的持续部署工作流程,增加更现实的检查和平衡。

我们将构建一个本地开发环境,让开发者可以使用生产服务器代码的副本进行工作,进行更改,并立即看到这些更改的结果。由于这个本地开发构建与生产构建使用相同的仓库,因此配置所选环境的构建过程很简单,无需特殊努力即可创建多个生产和/或开发盒子

第一步是如果您还没有的话创建一个 GitHub (www.github.com) 账户。基本账户是免费的,并且易于设置。

现在,让我们看看 GitHub 钩子是如何工作的。

启用钩子

创建一个新文件夹,并插入以下package.json文件:

{
  "name": "express-webhook",
  "main": "server.js",
  "dependencies": {
    "express": "~4.0.0",
    "body-parser": "¹.12.3"
  }
}

这确保了 Express 4.x 已安装,并包含body-parser包,该包用于处理 POST 数据。接下来,创建一个名为server.js的基本服务器:

var express   = require('express');
var app       = express();
var bodyParser   = require('body-parser');
var port      = process.env.PORT || 8082;

app.use(bodyParser.json());
app.get('/', function(req, res) {
  res.send('Hello World!');
});
app.post('/webhook', function(req, res) {
  //  We'll add this next
});
app.listen(port);
console.log('Express server listening on port ' + port);

输入您创建的文件夹,使用npm install; npm start构建和运行服务器。访问localhost:8082/,您应该在浏览器中看到"Hello World!"

当给定仓库中的任何文件更改时,我们希望 GitHub 将有关更改的信息推送到/webhook。因此,第一步是为代码中提到的 Express 服务器创建一个 GitHub 仓库。转到您的 GitHub 账户,并创建一个名为'express-webhook'的新仓库。以下截图显示了这一过程:

启用钩子

仓库创建后,进入您的本地仓库文件夹,并运行以下命令:

git init
git add .
git commit -m "first commit"
git remote add origin git@github.com:<your username>/express-webhook

您现在应该有一个新的 GitHub 仓库和一个本地链接版本。下一步是配置这个仓库以广播仓库上的推送事件。导航到以下 URL:

https://github.com/<你的用户名>/express-webhook/settings

从这里,导航到Webhooks & Services | 添加钩子(您可能需要再次输入密码)。现在您应该看到以下屏幕:

启用钩子

这就是您设置钩子的地方。请注意,push事件已设置为默认,如果您被要求,您现在想禁用 SSL 验证。GitHub 需要一个目标 URL 来在更改事件上使用 POST。如果您将本地仓库放置在一个已经可以公开访问的 Web 位置,现在输入该位置,记得要附加/webhook路由,例如www.example.com/webhook

如果你是在本地机器或另一个受限网络上构建,你需要创建一个 GitHub 可以使用的安全隧道。你可以在这个免费服务localtunnel.me/找到这个服务。按照页面上的说明操作,并使用提供的自定义 URL 来配置你的 webhook。

注意

其他好的转发服务可以在forwardhq.com/meetfinch.com/找到。

现在 webhook 已经启用,下一步是通过触发一个推送事件来测试系统。创建一个名为readme.md的新文件(添加你想要的任何内容),保存它,然后运行以下命令:

git add readme.md
git commit -m "testing webhooks"
git push origin master

这将把更改推送到你的 GitHub 仓库。返回 GitHub 上的express-webhook仓库的Webhooks & Services部分。你应该会看到类似以下的内容:

启用 webhooks

这是个好事!GitHub 注意到了你的推送并尝试将更改信息传递到设置的 webhook 端点,但由于我们还没有配置/webhook路由,传递失败是预料之中的。通过点击最后的尝试来检查失败的传递负载——你应该会看到一个大的 JSON 文件。在这个负载中,你会找到类似以下的内容:

  "committer": {
    "name": "Sandro Pasquali",
    "email": "spasquali@gmail.com",
    "username": "sandro-pasquali"
  },
  "added": [
    "readme.md"
  ],
  "removed": [],
  "modified": []

现在应该很清楚 GitHub 在推送事件发生时将传递什么样的信息。你现在可以配置演示 Express 服务器中的/webhook路由来解析这些数据并使用这些信息,例如向管理员发送电子邮件。例如,使用以下代码:

app.post('/webhook', function(req, res) {
  console.log(req.body);
});

下次你的 webhook 触发时,整个 JSON 负载将显示出来。

让我们更进一步,分解 autopilot 应用程序,看看如何使用 webhook 来创建一个构建/部署系统。

使用 webhook 实现构建/部署系统

为了演示如何构建一个由 webhook 驱动的部署系统,我们将使用一个应用程序开发入门套件。前往github.com/sandro-pasquali/autopilot.git的仓库并使用 Fork。你现在有了autopilot仓库的副本,它包括常见 Gulp 任务的脚手架、测试、Express 服务器和一个我们现在将要探索的部署系统。

根据你是在生产环境中运行还是在开发环境中运行,autopilot 应用程序会实现特殊功能。虽然 autopilot 太大太复杂,无法在这里完全记录,但我们将查看系统的主要组件是如何设计和实现的,这样你就可以构建自己的或增强现有系统。以下是我们要检查的内容:

  • 如何在 GitHub 上以编程方式创建 webhook

  • 如何捕获和读取 webhook 负载

  • 如何使用负载数据来克隆、测试和集成更改

  • 如何使用 PM2 在代码更改时安全地管理和重启服务器

如果你还没有在 autopilot 仓库上使用 fork,现在就做。将 autopilot 仓库克隆到服务器或其他可以网络访问的地方。遵循如何在 GitHub 上连接和推送你创建的 fork 的说明,并熟悉如何拉取和推送更改、提交更改等操作。

注意

PM2 提供了一个基本的部署系统,你可能考虑将其用于你的项目(github.com/Unitech/PM2/blob/master/ADVANCED_README.md#deployment)。

使用 npm install; npm start 安装克隆的 autopilot 仓库。一旦 npm 安装了依赖项,一个交互式 CLI 应用程序将引导你完成配置过程。只需按 Enter 键回答所有问题,这将设置本地开发构建的默认值(我们将在稍后构建生产环境)。配置完成后,PM2 将启动一个新的开发服务器进程。你将在以下截图的 PM2 清单中看到它列在 autopilot-dev 下:

使用 webhook 实现构建/部署系统

你将在本开发构建的 /source 目录中进行更改。当你最终部署好生产服务器时,你将使用 git push 将本地更改推送到 GitHub 上的 autopilot 仓库,从而触发 webhook。GitHub 将使用 POST 方法对更改信息发送到我们在服务器上定义的 Express 路由,这将触发构建过程。构建运行器将从 GitHub pull 你的更改到临时目录,安装、构建和测试更改,如果一切顺利,它将替换你已部署仓库中的相关文件。此时,PM2 将重新启动,你的更改将立即可用。

从示意图上看,流程如下:

使用 webhook 实现构建/部署系统

要以编程方式在 GitHub 上创建 webhook,你需要创建一个访问令牌。以下图表解释了从 A 到 B 到 C 的步骤:

使用 webhook 实现构建/部署系统

我们将使用位于 github.com/mikedeboer/node-github 的 Node 库来访问 GitHub。我们将使用此包使用你刚刚创建的访问令牌在 GitHub 上创建钩子。

一旦你有了访问令牌,创建 webhook 就很容易了:

var GitHubApi = require("github");

github.authenticate({
  type: "oauth",
  token: <your token>
});
github.repos.createHook({
  "user": <your github username>,
  "repo": <github repo name>,
  "name": "web",
  "secret": <any secret string>,
  "active": true,
  "events": [
    "push"
  ],
  "config": {
    "url": "http://yourserver.com/git-webhook",
    "content_type": "json"
  }
}, function(err, resp) {
  ...
});

Autopilot 在启动时执行此操作,无需你手动创建钩子。

现在,我们正在监听更改。正如我们之前看到的,GitHub 将发送一个有效载荷,指示已添加的内容、已删除的内容以及已更改的内容。autopilot 系统的下一步是整合这些更改。

重要的是要记住,当您使用 webhooks 时,您无法控制 GitHub 发送变更集的频率——如果您的团队中有多个人可以推送,那么这些推送发生的时间是无法预测的。自动飞行系统使用 Redis 来管理请求队列,按顺序执行它们。您需要以这种方式管理多个更改。现在,让我们看看一种简单的方法来构建、测试和集成更改。

在您的代码包中,访问 autopilot/swanson/push.js。这是在同一文件夹中 buildQueue.js 使用了分叉的过程运行器。以下信息传递给它:

  • 我们将要克隆的 GitHub 仓库的 URL

  • 将该仓库克隆到的目录(<临时目录>/<提交哈希>

  • 变更集

  • 将要更改的生产仓库的位置

好吧,先阅读一下代码。使用几个 shell 脚本,我们将克隆更改后的仓库,并使用您熟悉的相同命令构建它——npm installnpm test 等等。如果应用程序构建无误,我们只需运行变更集,并用更改后的文件替换旧文件。

最后一步是重启我们的生产服务器,以便更改能够到达我们的用户。这正是 PM2 真正发挥其强大功能的地方。

当自动飞行系统在生产环境中运行时,PM2 会创建一个服务器集群(类似于 Node 的 cluster 模块)。这很重要,因为它允许我们逐步重启生产服务器。当我们使用新推送的内容重启集群中的一个服务器节点时,其他节点继续服务旧内容。这对于保持零停机时间生产至关重要。

希望自动飞行实现能给你一些如何改进此过程并定制到您自己需求的灵感。

同步本地和部署的构建

部署过程中最重要(并且常常是困难)的部分之一是确保应用程序开发、构建和测试的环境完美地模拟了应用程序将要部署到的环境。在本节中,您将学习如何使用 Vagrant 模拟或虚拟化您的部署应用程序将运行的环境。在演示了这种设置如何简化您的 本地 开发过程之后,我们将使用 Ansible 在 DigitalOcean 上配置一个远程实例。

使用 Vagrant 在本地开发

很长一段时间以来,开发者会直接在运行的服务器上工作,或者在自己的本地环境中拼凑生产环境的版本,通常编写临时的脚本和工具来简化他们的开发过程。在虚拟机时代,这不再是必要的。在本节中,我们将学习如何使用 Vagrant 在开发环境中模拟生产环境,这为你提供了一个现实中的 虚拟机 来测试生产代码,并使你的开发过程与本地机器过程隔离开。

根据定义,Vagrant 用于创建一个模拟生产环境的虚拟机。因此,我们需要安装 Vagrant、虚拟机和机器镜像。最后,我们还需要为我们的环境编写配置和预配置脚本。

前往 www.vagrantup.com/downloads 安装适合你的虚拟机的正确 Vagrant 版本。同样,在 www.virtualbox.org/wiki/Downloads 这里安装 VirtualBox。

现在你需要添加一个虚拟机来运行。在这个例子中,我们将使用 Centos 7.0,但你可以选择你喜欢的任何版本。为这个项目创建一个新的文件夹,进入它,并运行以下命令:

vagrant box add chef/centos-7.0

注意

有用的是,Vagrant 的创建者 HashiCorp 提供了一个 Vagrant 虚拟机搜索服务,请访问 atlas.hashicorp.com/boxes/search

你将被提示选择你的虚拟环境提供商——选择 virtualbox。所有相关文件和机器现在将被下载。请注意,这些虚拟机非常大,可能需要一些时间来下载。

你现在将创建一个名为 Vagrantfile 的 Vagrant 配置文件。与 npm 类似,init 命令会快速设置一个基础文件。此外,我们还需要通知 Vagrant 我们将使用的虚拟机:

vagrant init chef/centos-7.0

Vagrantfile 使用 Ruby 编写,并定义了 Vagrant 环境。现在打开它并扫描它。里面有很多注释,读起来很有用。注意初始化过程中插入的 config.vm.box = "chef/centos-7.0" 这一行。

现在你可以开始使用 Vagrant:

vagrant up

如果一切如预期进行,你的虚拟机已经在 Virtualbox 中启动。为了确认你的虚拟机正在运行,请使用以下代码:

vagrant ssh

如果你看到一个提示,那么你刚刚设置了一个虚拟机。你会看到你处于 CentOS 环境的典型家目录中。

要销毁你的虚拟机,运行 vagrant destroy。这将通过清理捕获的资源来删除虚拟机。然而,下一个 vagrant up 命令将需要做很多工作来重建。如果你只是想关闭你的机器,请使用 vagrant halt

Vagrant 作为一个虚拟化、类似生产环境的开发环境非常有用。为此,它必须配置为模拟生产环境。换句话说,你的 box 必须通过告诉 Vagrant 如何配置以及每次运行vagrant up时应安装什么软件来进行配置。

配置服务器的一种策略是创建一个 shell 脚本,直接配置我们的服务器,并将 Vagrant 配置过程指向该脚本。在 Vagrantfile 中添加以下行:

config.vm.provision "shell", path: "provision.sh"

现在,在托管 Vagrantfile 的文件夹中创建一个包含以下内容的文件:

# install nvm
curl https://raw.githubusercontent.com/creationix/nvm/v0.24.1/install.sh | bash
# restart your shell with nvm enabled
source ~/.bashrc
# install the latest Node.js
nvm install 0.12
# ensure server default version
nvm alias default 0.12

销毁任何正在运行的 Vagrant boxes。再次运行 Vagrant,你会在输出中注意到我们的配置 shell 脚本中的命令执行。

完成此操作后,以 root 身份进入你的 Vagrant box(Vagrant boxes 会自动分配 root 密码"vagrant"):

vagrant ssh
su

你会看到已经安装了 Node v0.12.x:

node -v

注意

允许Vagrant用户无密码 sudo 是标准做法。运行visudo并在sudoers配置文件中添加以下行:

vagrant ALL=(ALL) NOPASSWD: ALL

通常,当你开发应用程序时,你会在项目目录中修改文件。你可能会将 Vagrant box 中的某个目录绑定到本地代码编辑器,并以此方式开发。Vagrant 提供了一个更简单的解决方案。在你的 VM 中,有一个/vagrant文件夹,它映射到 Vagrantfile 存在的文件夹,这两个文件夹会自动同步。因此,如果你在你的本地机器上的正确文件夹中添加了server.js文件,该文件也会出现在 VM 的/vagrant文件夹中。

在你的本地文件夹或 VM 的/vagrant 文件夹中创建一个新的test文件。你会发现无论它最初是在哪里创建的,该文件都会同步到这两个位置。

让我们将本章早些时候的express-webhook仓库克隆到我们的 Vagrant box 中。在 provision.sh 中添加以下行:

# install various packages, particularly for git
yum groupinstall "Development Tools" -y
yum install gettext-devel openssl-devel perl-CPAN perl-devel zlib-devel -y
yum install git -y
# Move to shared folder, clone and start server
cd /vagrant
git clone https://github.com/sandro-pasquali/express-webhook
cd express-webhook
npm i; npm start

在 Vagrantfile 中添加以下内容,这将把 Vagrant box 上的端口8082(表示托管应用程序监听的端口)映射到主机上的端口8000

config.vm.network "forwarded_port", guest: 8082, host: 8000

现在,我们需要重新启动 Vagrant box(加载此新配置)并重新配置:

vagrant reload
vagrant provision

由于yum正在安装各种依赖项,这个过程可能需要一些时间。配置完成后,你应该看到以下内容作为最后一行:

==> default: Express server listening on port 8082

记住我们已将客户端端口8082绑定到主机端口8000,打开浏览器并导航到localhost:8000。你应该会看到"Hello World!"显示。

还要注意,在我们的配置脚本中,我们克隆到了(共享的)/vagrant文件夹。这意味着express-webhook的克隆应该可以在当前文件夹中看到,这将允许你更容易地访问代码库,并知道它将与 Vagrant box 上的版本自动同步。

使用 Ansible 进行配置

如我们之前所做的那样,手动配置机器并不容易扩展。首先,设置和管理环境变量可能过于困难。此外,编写自己的配置脚本容易出错,并且由于存在配置工具,如 Ansible,因此不再必要。

使用 Ansible,我们可以使用有组织的语法来定义服务器环境,而不是使用临时脚本,这使得配置的分布和修改更加容易。让我们使用 Ansible playbooks 重新创建之前开发的 provision.sh 脚本:

Playbooks 是 Ansible 的配置、部署和编排语言。它们可以描述你希望远程系统执行的政策或一般 IT 流程中的一系列步骤。

Playbooks 以YAML格式(一种人类可读的数据序列化语言)表示。首先,我们将更改 Vagrantfile 的 provisioner 为 Ansible。首先,在你的 Vagrant 文件夹中创建以下子目录:

provisioning
  common
    tasks

这些将在我们通过 Ansible 设置的过程中解释。

接下来,创建以下配置文件,并将其命名为 ansible.cfg

[defaults]
roles_path = provisioning
log_path = ./ansible.log

这表示 Ansible roles 可以在 /provisioning 文件夹中找到,并且我们希望在 ansible.log 中保留配置日志。Roles 用于将任务和其他功能组织到可重用的文件中。这些将在稍后解释。

config.vm.provision 定义修改为以下内容:

    config.vm.provision "ansible" do |ansible|
    ansible.playbook = "provisioning/server.yml"
    ansible.verbose = "vvvv"
    end

这告诉 Vagrant 在配置指令上依赖于 Ansible,并且我们希望配置过程是详细的——我们希望在配置步骤运行时获得反馈。此外,我们可以看到预期的 playbook 定义,provisioning/server.yml,是存在的。现在创建该文件:

---
- hosts: all
  sudo: yes
  roles:
    - common
  vars:
    env:
      user: 'vagrant'
    nvm:
      version: '0.24.1'
      node_version: '0.12'
    build:
      repo_path: 'https://github.com/sandro-pasquali'
      repo_name: 'express-webhook'

Playbooks 可以包含非常复杂的规则。此简单文件表示我们将使用名为 common 的单个角色来配置所有可用的主机。在更复杂的部署中,可以在 hosts 下设置 IP 地址列表,但在这里,我们只想为我们的单个服务器使用通用设置。此外,配置步骤将提供某些环境变量,形式为 env.usernvm.node_version 等。这些变量将在我们定义 common 角色时发挥作用,该角色将为我们的 Vagrant 服务器提供构建、克隆和部署 express-webhook 所需的程序。最后,我们断言 Ansible 默认应以管理员(sudo)身份运行——这是在 CentOS 上运行yum包管理器所必需的。

现在,我们可以定义 common 角色。在 Ansible 中,文件夹结构很重要,并且由 playbook 隐含。在我们的例子中,Ansible 期望角色位置(./provisioning,如ansible.cfg中定义)包含 common 文件夹(反映 playbook 中给出的common角色),该文件夹本身必须包含一个包含main.yml文件的tasks文件夹。这两个命名约定是特定的,并且是必需的。

最后一步是在 provisioning/common/tasks 中创建 main.yml 文件。首先,我们复制 yum 软件包加载器(请参考代码包中的文件以获取完整列表):

---
- name: Install necessary OS programs
 yum: name={{ item }} state=installed
 with_items:
 - autoconf
 - automake
 ...
 - git

在这里,我们可以看到 Ansible 的几个好处。为 yum 任务提供了一个人类可读的描述,并将其提供给循环结构,该结构将安装列表中的每一项。接下来,我们运行 nvm 安装程序,它只是简单地执行 nvm 的自动安装器:

- name: Install nvm
 sudo: no
 shell: "curl https://raw.githubusercontent.com/creationix/nvm/v{{ nvm.version }}/install.sh | bash"

注意,在这里我们正在覆盖 playbook 的 sudo 设置。这可以在每个任务的基础上完成,这给了我们在配置过程中在不同权限级别之间移动的自由。我们还能在执行 shell 命令的同时插入变量:

- name: Update .bashrc
 sudo: no
 lineinfile: >
 dest="/home/{{ env.user }}/.bashrc"
 line="source /home/{{ env.user }}/.nvm/nvm.sh"

Ansible 提供了极其有用的文件操作工具,在这里我们将看到一个非常常见的例子——更新用户的 .bashrc 文件。lineinfile 指令使得添加别名等操作变得简单直接。

剩余的命令遵循类似的模式,以结构化的方式实现我们服务器所需的配置指令。所有你需要用到的文件都在你的代码包中的 vagrant/with_ansible 文件夹里。一旦安装完毕,运行 vagrant up 就可以看到 Ansible 的实际应用。

Ansible 的一个优势在于它处理上下文的方式。当你开始你的 Vagrant 构建,你会注意到 Ansible 会收集事实,如下面的截图所示:

使用 Ansible 进行配置

简而言之,Ansible 分析其工作上下文,并且只执行必要的操作。如果你的某个任务已经运行过,那么下次你尝试 vagrant provision 时,该任务将不会再次运行。这对于 shell 脚本来说可不是这样!通过这种方式,编辑 playbooks 和重新配置不会重复改变已经改变的内容,从而节省时间。

Ansible 是一个强大的工具,可用于配置和更多复杂的部署任务。它的一大优势是可以在远程运行——与大多数其他工具不同,Ansible 使用 SSH 连接到远程服务器并执行操作。你不需要在生产服务器上安装它。我们鼓励你浏览 Ansible 文档 docs.ansible.com/index.html 以获取更多信息。

集成、交付和部署

在本章中,我们一直在探讨使用鼓励敏捷开发的部署系统,通常可以促进代码更新在近乎实时的情况下安全地交付到生产环境。部署的结构和/或理解方式的差异很常见,这通常取决于团队规模和管理结构等因素。以下几节将简要介绍三个典型类别,持续集成持续交付持续部署。最后,我们将使用 Jenkins,一个 CI 服务器,为 Node 应用程序设置一个构建/部署系统,配置为自动将更改部署到 Heroku 服务器。

持续集成

持续集成是将更改持续合并到主分支的过程(通常每天几次)。CI 的目标是让错误变得不耐烦和嘈杂,尽早出现并大声失败,而不是从几天或几周的工作中出现的更大、更复杂的批量合并中后期出现。通常在这里运行单元测试。请注意,更新的集成分支不一定持续部署,尽管它可能如此。目标是保持主分支新鲜、当前,并在必要时准备好部署。

持续交付

“交付”是这里的关键词。在所有更改都必须在发布之前由质量保证团队或其他利益相关者测试/审查的环境中,“交付”和“审查”是在提出更改时进行的。虽然持续交付不排除将代码交付到生产环境,但一般目标是在代码到达真实客户之前,将其交付到可以进行进一步功能测试、业务逻辑测试等的地方。

此测试环境应与生产环境相当,并且当测试通过时,应有一定的信心认为这些更改也可以部署到生产环境中。因为这个阶段通常被视为部署之前,所以它通常被称为预部署环境

阶段性更改通常可以一步部署,一个系统命令,或 GUI 中的一个按钮点击。

持续部署

持续部署是一种积极、乐观的策略,以构建应用程序的方式使其可以随时发布到生产环境,通常是在通过某些自动化测试后立即发布。这种策略通常导致每天发布许多版本,并且需要验证管道尽可能接近生产环境。

由于对发布代码的监督有限(或不存在),对应用程序性能的持续发布后检查是正常的。也就是说,信任但核实:在自动化测试后推送到生产环境,但定期检查您的访问量是否下降、响应时间是否上升,或其他指标是否异常。

虽然与持续交付类似,但这两者不应混淆。

使用 Jenkins 构建和部署

您已经学会了如何使用 GitHub webhooks 在代码推送到存储库时触发构建过程。从拉取和测试更改后的存储库到通知聊天服务器新的构建已发生,Jenkins 帮助您触发部署工作流程。当您的部署需求比简单地测试单个分支更复杂时,更强大的 CI 工具的好处就显现出来了。Jenkins 提供了管理构建权限、任务调度、触发部署、显示构建日志等工具。让我们使用 Jenkins 部署一个应用程序。

要安装 Jenkins,运行您环境中的安装程序,该程序可在 jenkins-ci.org/ 找到。还有允许您在“云”中安装 Jenkins 的服务,但我们将构建一个本地服务。安装成功后,浏览器将打开并显示 Jenkins 的“主页”UI,如下所示:

使用 Jenkins 构建和部署

在管理构建时,您将经常使用这个 Jenkins 仪表板

注意,Jenkins 默认将在端口 8080 上运行。您需要像处理 webhooks 一样,直接通过代理、转发或其他方式将此位置映射到一个可访问的 Web URL。转到 管理 Jenkins | 配置系统 并找到 Jenkins 位置 部分。添加 Jenkins URL,如下面的截图所示:

使用 Jenkins 构建和部署

如果您在 localhost 上运行 Jenkins,请回到本章前面我们讨论使用转发服务(如 localtunnel.me/)的部分。

注意

您可能会收到关于不安全 Jenkins 实例的警告。这是一个有效的投诉!虽然我们不会设置身份验证,但在任何实际的生产环境中,您都应该这样做。这并不难。访问 管理 Jenkins | 配置全局安全 来进行设置,或访问 wiki.jenkins-ci.org/display/JENKINS/Securing+Jenkins

下一步是配置 Jenkins 以与 Node.js 和 GitHub 一起工作。从仪表板导航到 管理 Jenkins | 管理插件 | 可用。您应该会看到一个可用插件的列表,您将从中搜索并安装 NodeJS 插件GitHub 插件。由于这些插件及其依赖项需要安装,这可能需要一些时间。如果任何安装提示您重新启动 Jenkins,您将在本节稍后提供的安装列表中找到如何操作的说明。

我们必须完成的关键集成是与 GitHub 的集成。在一个新的浏览器窗口中,访问您的 GitHub 账户并生成一个新的访问令牌。

复制生成的密钥。现在,您将向 Jenkins 提供这个访问令牌,以便它可以代表您在 GitHub 上执行操作,特别是在 webhooks 方面。返回到管理 Jenkins | 配置,并将此 OAuth 令牌和您的用户信息添加到GitHub Web Hook部分,如下所示:

使用 Jenkins 构建和部署

运行测试凭据以确保 Jenkins 可以使用您提供的令牌连接到 GitHub。

最后,我们需要向 Jenkins 提供我们的 GitHub 凭据,以便在发生更改时它可以拉取我们的仓库。导航到凭据并点击全局凭据。选择用户名和密码并添加您的凭据,这将确保您为这些凭据提供了一个有用的名称(您稍后需要引用这些凭据)。

因为您已经构建了自己的由 webhook 驱动的 CI 系统,所以您可能已经很明显为什么 Jenkins 要以这种方式配置。最终,我们正在配置 Jenkins 以响应 GitHub 仓库的推送事件,拉取更改后的仓库,并自动构建它。为此,我们需要配置 Jenkins,使其配置了 Node 节点,因此可以构建 Node 仓库。

导航到配置系统并添加一个 NodeJS 安装,如下所示:

使用 Jenkins 构建和部署

现在,您将配置 Jenkins 将使用的 Node 环境。您应该将此环境与您的生产服务器运行的环境相匹配。点击添加 NodeJS并按照说明操作。您可以选择自动安装,并在提供安装选项时选择从 nodejs.org 安装。确保添加您需要的任何全局 npm 包——例如 gulp、pm2、mocha 和其他对您的构建环境必要的工具。

如果你更愿意自己管理安装,只需使用“运行 Shell 命令”选项,并使用以下类似命令,添加你想要的全局安装:

curl https://raw.githubusercontent.com/creationix/nvm/v0.24.1/install.sh | bash; nvm install 0.12; nvm alias default 0.12; npm install gulp -g

记得保存您的更改!

我们几乎完成了 Jenkins CI 的配置。最后一步是创建一个构建项目。导航到新建项目,在项目名称字段中添加一个有用的项目名称,选择Freestyle 项目,然后点击确定。现在,导航到源代码管理,选择Git,添加 GitHub 仓库名称,选择访问该仓库的凭据,点击保存,然后您就可以开始构建,如下面的截图所示:

使用 Jenkins 构建和部署

返回 Jenkins 仪表板,你会看到你的构建项目列出来。点击项目名称,从左侧菜单中选择立即构建。如果一切顺利,你会看到构建历史表快速填充,如下所示:

使用 Jenkins 构建和部署

点击数字,如果一切顺利,您将看到有关构建的信息,表明 没有更改(您刚刚完成了一项妙手回春),一些关于 Git 修订版本的详细信息,等等。现在,真正的测试——对您的 GitHub 仓库进行更改,无论是通过推送更改还是简单地使用 GitHub 的编辑工具编辑文件。如果您返回到仪表板,您将看到 Jenkins 已将一个新的构建添加到 构建队列;不久构建将完成,您将在项目的构建历史中看到您刚刚所做的更改。您已经为您的项目创建了一个 CI 环境!

现在,我们需要进行部署。我们将使用 Heroku 进行部署,但请随意尝试您选择的任何提供商——只要它 支持 Git,Jenkins 就能够推送您的仓库。

部署到 Heroku

返回 第二章,安装和虚拟化 Node 服务器,并刷新您对如何在 Heroku 上构建的记忆。至少,您需要安装 Heroku Toolbelt 并进行身份验证。通过工具带连接到 Heroku 后,克隆我们之前创建的 express-webhook 仓库,并进入该文件夹。现在,运行 heroku create 在 Heroku 上构建一个机器。您应该会收到一个 URL 和一个类似于以下内容的 Git 端点:

https://floating-shelf-4947.herokuapp.com/ | https://git.heroku.com/floating-shelf-4947.git
Git remote heroku added

现在,是时候向服务器推送一些内容以便其运行了。执行以下命令将 express-webhook 应用程序推送到 Heroku:

git push heroku master

express-webhook 应用程序现在已部署到 Heroku。Heroku 将自动构建并启动应用程序。请访问我们之前收到的 URL,在浏览器中查看。下一步是使用 Jenkins 在您对应用程序仓库进行更改时自动将其部署到 Heroku。

您现在连接了两个 Git 仓库,您可以通过运行 git remote -v 来查看:

heroku  https://git.heroku.com/floating-shelf-4947.git (fetch)
heroku  https://git.heroku.com/floating-shelf-4947.git (push)
origin  https://github.com/sandro-pasquali/express-webhook (fetch)
origin  https://github.com/sandro-pasquali/express-webhook (push)

origin URL 是我们的 GitHub 仓库,而 heroku 代表 Heroku 维护的 Git 仓库。我们将通过 Jenkins 同步这两个仓库。

由于 Jenkins 最终将为我们进行推送,我们需要授予它访问您的 Heroku 机器的权限。我们将为 jenkins 用户生成一个密钥对,并将这些本地 SSH 密钥与 Heroku 关联起来,允许 Jenkins 执行推送等操作。以 jenkins 用户身份登录,并运行以下两个命令:

ssh-keygen -t rsa
heroku keys:add ~/.ssh/id_rsa.pub

Jenkins 现在可以与 Heroku 进行身份验证。剩下的工作就是通知 Jenkins 关于 Heroku 仓库的信息,并指导 Jenkins 在通过之前配置的 webhook 通知有更改时部署到 Heroku。

返回您的 Jenkins 项目,点击 配置,然后通过点击 添加仓库 将 Heroku Git 端点作为另一个仓库添加到 源代码管理 部分。填写 仓库 URL 字段以匹配您之前收到的:

部署到 Heroku

注意,你将 填写 凭据,因为我们之前已经使用 SSH 密钥将 Jenkins 连接到 Heroku。

现在,点击新仓库下方的“高级”按钮,给它一个名字——你将在下一步需要它。这里我们使用 heroku,但它可以是任何名字:

部署到 Heroku

现在,Jenkins 已经知道我们的 GitHub 仓库和 Heroku 仓库。最后一步是配置 Jenkins 将 GitHub 的更改推送到 Heroku。

滚动到 Jenkins 项目的“构建后操作”。点击“添加构建后操作”,选择“Git 发布者”。按照此处所示填写提供的表格:

部署到 Heroku

我们正在告诉 Jenkins 在每次成功构建后将 express-webhook GitHub 仓库的 master 分支推送到 heroku。这是部署步骤。保存你的更改——你已经完成了!

为了测试一切是否正常工作,修改你本地克隆的 express-webhook 中的 server.js 默认路由,使其产生不同的消息,并将此更改推送到 GitHub。如果你返回到 Jenkins 控制台,你将很快在项目的构建状态上看到以下进度指示器:

部署到 Heroku

如果一切顺利,你的项目将在仪表板上列出,表明它已成功构建。如果你刷新你的 Heroku URL,你也将看到你所做的更改。恭喜你成功为你的项目设置了持续部署!

现在你已经为 CI 和部署设置了结构,开始添加测试和其他构建步骤,并在你的 Node 环境中或使用你可用的许多 Jenkins 工具中运行它们。祝构建愉快!

包维护

JavaScript 本身不提供原生的包管理系统;npm 为 Node 应用程序完成这项工作。因此,良好的包管理策略是良好部署策略的关键部分。

包提供封装的好处。运行中的包只能通过它们导出的 API 访问。这种隔离减少了系统中潜在错误的数量,从而保护核心功能免受意外更改。然而,由于(不透明的)包本身可能需要其他包作为依赖项,一个应用程序的完整依赖图可能对开发者来说难以轻易看到。例如,如果你实现的包的功能突然发生变化,你如何调试它?错误是在包中吗?还是在它的依赖包之一中?

当你部署 Node 应用程序时,理解 npm 依赖图中的情况 是至关重要的。在本节中,我们将探讨如何保持包更新的同步,使用 Git 管理私有包,跟踪整个依赖图的健康状况,以及查看在应用程序的 package.json 文件中设置版本规则的最佳实践。

理解 Semver

语义版本控制 (Semver) 简单来说是一组规则,这些规则被提出以规范系统中依赖项的声明。Npm 在其包管理器中强制执行这些规则,因此理解它们如何管理依赖项将是这里讨论的重点。

以以下 npm 包文件为例:

"devDependencies": {
  "browserify": "⁶.1.0",
  "gulp": "~3.8.8",
  "foobar": " >=1.2.3 <1.3.0"
}

每个依赖项都有一个与 npm 仓库中的版本相对应的版本号。其中一些数字通过标记进一步修改,例如,一个 caret (^) 或一个 tilde (~),以及版本范围。让我们看看语义版本号中的每个部分代表什么,以及如何使用各种标记来调节这些部分。

版本号被分解为三个部分,如下所示:

理解 Semver

Semver 具体描述了允许的包版本范围,并暗示了包的当前稳定性或状态——包是否稳定,是否成熟等。编号按顺序进行:1.0.1 在 1.0.2 之前,1.0.2 在 2.0.0 之前。

Semver 描述的变更的重要性从左到右递增,其中包的主版本变更通常描述了与较低版本不兼容的变更——2.0 与 1.0 不兼容。根据 semver.org,你应该这样使用版本号:

"给定版本号 MAJOR.MINOR.PATCH,当进行不兼容的 API 变更时增加 MAJOR 版本,当以向后兼容的方式添加功能时增加 MINOR 版本,当进行向后兼容的错误修复时增加 PATCH 版本。"

然后,Semver 允许你根据提供有用的影响程度指示,为应用程序中依赖项的版本设置可接受的范围限制。以下是一些常见的使用示例:

  • "3" 表示只有主版本(3)必须满足,忽略次要或补丁值——3.0.0、3.6.3 和 3.99.99 都是可接受的。

  • "3.4.5" 表示只有那个版本是可接受的,没有变化。

  • "<, <=, > 和 >=" 范围比较符在许多编程语言中按预期工作,可以用来设置受控范围。>= 3.0.1 <= 3.2.1 接受 3.0.2 和 3.1.9,但不接受 3.0.0 或 3.2.2。

  • 1.3.4 >= 3.0.1 <= 3.2.1 接受前面所述的版本范围或 1.3.4 版本。

  • 相当于 >= 0.0.0 的 "*" 表示任何版本都是可接受的。

  • Hyphen 范围 (-) 描述了包含的集合。Hyphen 范围 1.0.0 - 2.0.0 匹配任何主版本为 1 的包。

  • x-ranges 为小版本和补丁版本提供了一种简写方式;1.2.x 等同于 >= 1.2.0 <= 1.3.0,而 1.x 等同于 >= 1.0.0 <= 2.0.0。

  • Tilde (~) 范围允许在指定小版本时进行补丁级别变更,未指定时进行小版本变更。~1.3.2 等同于 >= 1.3.2 < 1.4.0,~1.3 等同于 >= 1.3.0 < 1.4.0,而 ~1 等同于 >= 1.0.0 < 2.0.0。

  • 管理员符号(^)范围允许更改不修改最左边的非零数字。¹.2.0 等同于 >= 1.2.0 <= 2.0.0,⁰.2.1 等同于 >= 0.2.1 <= 0.3.0,而 ⁰.0.2 等同于 >= 0.0.2 < 0.0.3。

注意

更多详情,请访问 github.com/npm/node-semverdocs.npmjs.com/misc/semver。一个有用的工具,可以用来检查特定包与 Semver 元组的版本,可以在 semver.npmjs.com/ 找到。

正如我们在使用 npm install <packagename> --save 构造时看到的,npm 默认使用管理员符号前缀——npm 将为新安装的依赖项分配 ^<最新版本> 的版本号到 package.json 中。如果你想使用默认的波浪线前缀,请使用 npm config set save-prefix="~"

Semver 的另一个重要特性是预发布标签。这些标签允许你发布一个尚未准备好生产的包版本(预发布),你可能这样做是为了将其交给团队中的其他人、测试人员等,同时确保默认版本将在“正常”安装时安装。

当你发布一个 npm 包时,你可以使用 --tag 参数来标记那个版本。现在发布的包不再标记为 "latest",而是你分配给它的任何标签。比如说,我们标记了 alpha.7 包(并且使用 npm version <version>-alpha.7 更改了包的版本字段)。

现在,考虑这种情况,该包被列在 userland 的某个地方作为依赖项:

"my-package" : ">=1.03-alpha.1"

当这个包被安装时,npm 将安装 alpha.7 包——Semver 范围将适用,因为 alpha.7 大于 alpha.1。

让我们这样定义我们的包:

"my-package" : ">=1.03"

在前面的例子中,alpha.7 包将 不会 被安装。这样,我们可以看到,根据 Semver 规则,预发布标签仅在比较器(你设置的包版本)也包含预发布标签时才适用。这样,你可以在标记的包中安全地发布实验性的破坏性更改,因为只有完全了解标签名称(及其 alpha 特性)的人才会进行使用它所需的工作,而其他人将继续使用生产版本。

使用 npm 管理包

你将部署的最重要(且棘手)的应用程序管理策略之一是选择包和更新包版本。在本节中,我们将讨论维护你的 npm 包的好策略——如何保持你的包更新,如何锁定依赖项,如何从 Git 仓库加载包而不是 npm,等等。

通常,你希望平衡严格的 Semver 约束的相对安全性,以及尽可能保持与重要包的最新版本同步的需求,同时保持你的依赖树可预测且干净。制定一个好的策略将有助于应用程序维护。

以下六个方面是包维护的要点:

  • 维护对完整的 npm 依赖树的意识

  • 跟踪包的最新版本和安装版本之间的差异

  • 删除在包文件中定义的未使用包

  • 确保所有必需的依赖项都已安装

  • 确保所需的依赖项是你拥有的那些

  • 使用私有或其他不在 npm 仓库中的模块

其他包管理系统强制执行规则,即包的单一版本存在于所有依赖项中;npm 则不是这样。包通常需要其他包,因此同一包的多个版本可以进入 npm 构建。一个应用程序可能有 A 和 B 依赖项,其中 A 包需要 C 包的 1.0.1 版本,而 B 包需要 C 包的 2.0.1 版本。

考虑到在每次 npm 安装时,对依赖树中插入的包版本的控制是有限的(通常思考得很少)——无法保证你的应用程序在任何给定时间运行相同的代码。某一时刻安装的内容,如果在一个小时后或甚至一秒后重新安装,可能会发生根本性的变化。这是将风险引入生产系统的一个异常高的水平——类似于软件经理对谁、在哪里、何时进行更改漠不关心。

第一步是获取完整的安装分解。使用npm ls来完成此操作,它返回如下内容:

...
├─┬ mocha@1.21.5
│ ├── commander@2.3.0
│ ├─┬ debug@2.0.0
│ │ └── ms@0.6.2
│ ├── diff@1.0.8
│ ├── escape-string-regexp@1.0.2
│ ├─┬ glob@3.2.3
│ │ ├── graceful-fs@2.0.3
│ │ ├── inherits@2.0.1
│ │ └─┬ minimatch@0.2.14
│ │   ├── lru-cache@2.5.0
│ │   └── sigmund@1.0.0
│ ├── growl@1.8.1
...

如果你想将此树表示为 JSON,请使用--json标志:npm ls --json。要包括每个包的description字段的输出,请使用npm ls --long。你可以使用npm ls -g来获取全局安装包的此树。如果你只想知道哪些包已全局安装,请尝试ls npm root -g``。

定期保持已安装包的当前版本更新是你应该做的事情。包的版本很快就会过时。npm 提供了npm outdated工具来完成此目的(在这里,它使用了--long“扩展信息”参数)。下面的截图显示了这一点:

使用 npm 管理包

在这里,我们看到我们的应用程序的node_modules/redis文件夹中的package.json文件版本为 0.8.2(当前),最新版本为 0.12.1,并且根package.json文件中redis的期望 Semver 将与版本 0.12.1 相匹配。这表明自从在这个应用程序中运行npm install以来已经有一段时间了。一个非常有用的全局工具来执行这些检查是npm-check(github.com/dylang/npm-check),它提供了更详细的信息,如下面的截图所示:

使用 npm 管理包

此外,此工具还提供了一个交互式用户界面,它将自动更新你选择的包。

随着时间的推移,另一种会积累的残留物是未使用的包。这些包可能已安装在 node_modules 中但不再链接,或者这些包可能已为某个包定义但未在应用程序代码的任何地方使用。

要删除在 package.json 中不再列出的已安装包,你可以使用 npm prune。请注意,这仅仅是一种清理单个包文件夹内的 node_modules 文件夹的技术;它不是一个智能的全局工具,不能在整个树中删除未使用的包。

dependency-check 模块(github.com/maxogden/dependency-check)是另一个用于查找不必要的包的工具。假设存在这样的未使用依赖项,dependency-check 将会找到它:

dependency-check package.json --unused
Fail! Modules in package.json not used in code: express

相反,包可能需要在应用程序代码中使用,但未在包文件中列出。这种情况偶尔发生,当在开发过程中安装了必要的包但未保存到 package.json 中时,可能是由于用户忘记使用 --save 选项或其他原因。dependency-check 命令将遍历你的代码库中的所有文件,并找到此类情况,如下所示:

dependency-check package.json
Fail! Dependencies not listed in package.json: express

注意,预期你的应用程序的入口点在 package.json 中列出,因为 dependency-check 需要知道你的应用程序树根在哪里。因此,你应该确保你的所有包都有一个指向现有文件的 main 属性。如果你需要添加更多要检查的文件,请使用以下 --entry 参数:

dependency-check package.json --entry a.js b.js [...]

拥有一个指向你应用程序的 main 入口点是你应该遵循的重要通用实践。

最后一个可以帮助加快你的 npm 构建的工具有 npm dedupe。当触发时,npm 尝试减少冗余包安装的数量,"扁平化" 树结构,因此减少安装时间。考虑以下依赖树:

A
└─┬ B
│ └── C
└─┬ D
  └── C

在这里,A 包依赖于 B 和 D 包,而这两个包又各自依赖于 C 包。通常,C 会安装两次,一次为每个父包。然而,如果 B 和 D 用来针对 C 的 Semver 匹配 C 的单个版本,npm 将以这种方式减少树的大小,即 B 和 D 都从相同的单个已安装版本 C 中提取。请注意,Semver 规则仍然适用——npm 不会仅仅为了减少所需的安装次数而破坏版本要求。

应该很明显,我们一直在查看的许多工具都可以很好地集成到构建/部署过程中,例如,如果某个包未使用或已过时,则会发出警告。npm 本身也是一个 npm 包(github.com/npm/npm)——尝试在你的构建过程中使用 npm 进行编程,以执行这些检查之一。

设计依赖树

并非所有依赖项都同等重要。有些在开发模式下是必要的,但在生产中却没有意义。依赖项的位置和版本也可能变化,因为你可能并不总是使用 npm 仓库中的包,或者你可能想使用特定的版本。

npm 包文件中使用了三种类型的依赖项:dependenciesdevDependenciespeerDependencies。让我们看看它们之间的区别。

简单依赖可能是你最熟悉的。这些依赖项总是被安装,无论在什么环境下。你应该将必须存在于这个集合中的依赖项放置在这里,通常是生产构建所需的包。

当你在开发和构建时,你经常会使用工具,如 Mocha 或 gulp。然而,一旦经过验证的构建准备好投入生产,就没有必要将这些包与其一起放置。在生产中不需要的包应该放在 devDependencies 集合中。虽然 npm 总是会安装依赖项和 devDependencies,但你(并且应该)可以通过使用--production标志从部署安装中排除 devDependencies,如下所示:

npm install --production

有用的是,如果你运行npm config set production命令,~/.npmrc文件将被更新,使得所有未来的安装都将自动设置--production标志。例如,你的配置器可以执行此配置。

最后,peerDependencies 处理的是插件的案例。你熟悉各种 Grunt 插件。虽然这些插件是通过 npm 生态系统加载的,但它们需要其宿主程序(Grunt)才能运行。你可能认为每个插件都应该直接require('grunt')——但是哪个版本的 Grunt 呢?任何一个插件都可能依赖于其宿主程序的具体版本,但这些宿主程序也是包的直接依赖。因此,考虑以下声明:

"dependencies": {
  "grunt": "1.2.3",
  "gulp-plugin": "1.0.0" // requires grunt@2.0.0
}

前面的声明可能导致危险的冲突:

└── grunt@1.2.3
└─┬ gulp-plugin@1.0.0
  └── grunt@2.0.0

因此,peerDependencies 应该在具有特定宿主程序需求的插件类型包中使用,允许插件“携带”它们所需的宿主。如果 npm 尝试安装该宿主程序的不同版本,则会抛出错误。这当然会导致另一个问题——如果插件所需的主程序版本与主应用程序要求的版本不兼容,任何给定的插件都可能导致安装失败。peerDependencies 的复杂性在 Node 社区中仍然是一个持续讨论的话题(github.com/npm/npm/issues/5080)。

如前所述,npm 对包版本的限制不多,允许同一包存在多个版本,并且确实,版本(以及因此包的功能)可能会意外地发生变化。

确保你的应用程序状态的一种方法是通过使用 npm shrinkwrap 锁定依赖树。这个命令会触发 npm 生成包含对特定版本明确引用的 npm-shrinkwrap.json 文件。生成的文件包含如下定义:

"moment": {
  "version": "2.8.4",
  "from": "moment@².8.3",
  "resolved": "https://registry.npmjs.org/moment/-/moment-2.8.4.tgz"
},
"node-uuid": {
  "version": "1.4.2",
  "from": "node-uuid@¹.4.1",
  "resolved": "https://registry.npmjs.org/node-uuid/-/node-uuid-1.4.2.tgz"
}

应该清楚这种语法如何确保未来的安装将与之前相同。请注意,这是一个较为直接的方法,你可能并不经常需要它。然而,在生产环境中,当你需要在多台机器上部署相同代码时,shrinkwrap 的“捆绑包”可能正是你所需要的。

另一种确保你的包行为可见性的方法是完全控制它们。你可以将依赖项链接到 Git 仓库,无论是公开的还是私有的。例如,你可以直接从 Express 的 GitHub 仓库加载 Express:

dependencies : {
  "express" : "strongloop/express"
}

npm 假设 GitHub,因此你可以使用压缩语法,如前述代码所示。

你还可以使用 https/oauth 链接到私有 Git 仓库:

"package-name": "git+https://<github_token>:x-oauth-basic@github.com/<user>/<repo>.git"

你还可以按照以下方式使用 SSH:

"package-name": "git+ssh://git@github.com/<user>/<repo>.git"

npm 包管理器是 Node 生态系统的一个基本组成部分,Node 应用程序通常由数十个,甚至数百个包组成。如果你计划发布和维护一个大规模的 Node 应用程序,围绕包管理制定策略是一个重要的考虑因素。

摘要

在本章中,你学习了如何将本地构建部署到生产就绪环境中。强大的 Git webhook 工具被演示为创建持续集成环境的一种方式,并将这一知识应用于创建一个完整的构建/部署管道,该管道通过使用 Jenkins 配置的 CI 环境将 GitHub 仓库连接到 Heroku 部署。我们还介绍了 npm 使用的语义版本控制系统,甚至如何使用 Semver、npm 方法以及一些辅助库来保持我们的包树整洁和可预测。

从基本的 JavaScript 程序到完整应用程序的部署,在这本书中,我们游览了 Node 的设计和目标。我们探讨了 Node 事件驱动架构如何通过构建流的基础概念来影响我们设计网络软件的方式。为了创建快速、可部署的系统,我们探讨了虚拟化策略、编译器优化、负载均衡以及垂直和水平扩展策略。此外,通过引入微服务、进程间消息传递和队列作为构建分布式系统的一种方式,我们还考虑了由小型、专注的程序组成的软件的力量。

考虑到软件是由有缺陷的人类编写的,我们还涵盖了测试和维持运行中的应用程序的战略,学习预期失败并为此做好准备,借助原生和第三方日志和监控工具。我们学习了调试技术和优化策略,旨在减少本地和网络级别的瓶颈,以及如何在它们不可避免地出现时找到它们的来源。为了使开发更简单,我们研究了如何有效地使用集成工具和版本控制系统,配置虚拟机并使用无头浏览器进行测试,使开发者能够自由工作并承担风险,并带着智能部署策略带来的信心推送更改。构建智能构建管道,您了解了全栈 JavaScript、转译、实时更新和持续测试与集成的力量。

鼓励您修改和扩展示例代码以改进它或根据您的需求进行更改。希望随着您开始欣赏 Node.js 的力量、npm 生态系统和开源软件,您将开始自然地设计您的应用程序,以便在推向生产时需要很少的更改,并且您将分享您的发现,以便其他人也能做到同样的事情。

posted @ 2025-10-11 12:57  绝不原创的飞龙  阅读(7)  评论(0)    收藏  举报