精通-NodeJS-全-

精通 NodeJS(全)

原文:zh.annas-archive.org/md5/54EB7E80445F684EF94B4738A0764C40

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

互联网不再是被动消费的静态网站的集合。浏览器(和移动设备)用户希望获得更丰富和互动的体验。在过去的十年左右,网络应用程序已经开始类似于桌面应用程序。此外,对信息的社交特性的认识已经激发了新型界面和可视化的发展,这些界面和可视化模拟了动态网络状态,用户可以实时查看变化,而不是被困在过去的静态快照中。

尽管我们对软件的期望已经改变,但作为软件开发人员可用的工具并没有改变太多。计算机速度更快,多核芯片架构很常见。数据存储更便宜,带宽也更便宜。然而,我们仍然继续使用在十亿用户网站和云端虚拟机群的一键式管理之前设计的工具进行开发。

由于这个原因,网络应用程序的开发仍然是一个过于昂贵和缓慢的过程。开发人员使用不同的语言、编程风格,使代码维护、调试等变得复杂。非常经常,扩展问题出现得太早,超出了通常是一个小而经验不足的团队的能力。流行的现代软件功能,如实时数据、多人游戏和协作编辑空间,需要能够承载数千个同时连接而不弯曲的系统。然而,我们仍然局限于旨在帮助我们构建 CRUD 应用程序的框架,将单个关系数据库绑定到单个服务器上的单个用户,在桌面计算机上的浏览器上运行多页网站。

Node 帮助开发人员构建更具规模的网络应用程序。Node 基于 C++构建,并捆绑了 Google 的 V8 引擎,速度快,并且理解 JavaScript。Node 将世界上最流行的编程语言和最快的 JavaScript 编译器结合在一起,并通过 C++绑定轻松访问操作系统。Node 代表了网络软件设计和构建方式的变革。

本书内容

第一章 理解 Node 环境,简要描述了 Node 试图解决的特定问题,它们在 Unix 设计哲学中的历史和根源,以及 Node 作为系统语言的强大之处。我们还将学习如何在 V8(Node 的引擎)上编写优化的现代 JavaScript,包括对语言最新特性的简要介绍,这将帮助您升级您的代码。

第二章 理解异步事件驱动编程,深入探讨了 Node 设计的基本特征:事件驱动、异步编程。通过本章的学习,您将了解事件、回调和定时器在 Node 中的使用,以及事件循环如何实现跨文件系统、网络和进程的高速 I/O。我们还将了解现代并发建模构造,从默认的 Node 回调模式到 Promises、Generators、async/await 和其他流程控制技术。

第三章 在节点和客户端之间传输数据,描述了 I/O 数据流如何通过大多数网络软件编织在一起,由文件服务器发出或者作为对 HTTP GET 请求的响应进行广播。在这里,您将学习 Node 如何通过 HTTP 服务器、可读和可写文件流以及其他 I/O 集中的 Node 模块和模式的示例来促进网络软件的设计、实现和组合。您将深入了解流的实现,掌握 Node 堆栈的这一基本部分。

第四章 使用 Node 访问文件系统,介绍了在 Node 中访问文件系统时需要了解的内容,如何创建文件流进行读写,以及处理文件上传和其他网络文件操作的技术。您还将使用 Electron 实现一个简单的文件浏览应用程序。

第五章 管理许多同时的客户端连接,向您展示了 Node 如何帮助解决当代协作 Web 应用程序所需的高容量和高并发环境所伴随的问题。通过示例,学习如何高效地跟踪用户状态,路由 HTTP 请求,处理会话,并使用 Redis 数据库和 Express Web 应用程序框架对请求进行身份验证。

第六章 创建实时应用程序,探讨了 AJAX、服务器发送事件和 WebSocket 协议,在构建实时系统时讨论它们的优缺点,以及如何使用 Node 实现每个协议。我们通过构建一个协作文档编辑应用程序来结束本章。

第七章 使用多个进程,教授如何在多核处理器上分发 Node 进程集群,以及其他扩展 Node 应用程序的技术。对单线程和多线程环境编程的差异进行调查,讨论如何在 Node 中生成、分叉和与子进程通信,包括使用 PM2 进程管理器的部分。我们还构建了一个记录和显示多个同时连接的客户端通过一组 Web 套接字的鼠标操作的分析工具。

第八章 扩展您的应用程序,概述了一些检测何时扩展、如何扩展以及如何在多个服务器和云服务上扩展 Node 应用程序的技术,包括如何使用 RabbitMQ 作为消息队列,使用 NGINX 代理 Node 服务器,以及在应用程序中使用亚马逊网络服务的示例。本章以我们构建一个部署在 Heroku 上的强大的客户服务应用程序结束,您将学习如何使用 Twilio SMS 网关与 Node 配合使用。

第九章 微服务,介绍了微服务的概念——小型、独立的服务——以及我们是如何从单片和 3 层堆栈发展到大型独立服务的动态协作模式的。我们将学习如何使用 Seneca 和 Node 创建自动发现服务网格,使用 AWS Lambda 在云中创建无限可扩展的无服务器应用程序,最后,如何创建 Docker 容器并使用 Kubernetes 编排它们的部署。

第十章 测试您的应用程序,解释了如何使用 Node 实现单元测试、功能测试和集成测试。我们将深入探讨如何使用本机调试和测试模块、堆转储和 CPU 分析,最终使用 Mocha 和 Chai 构建测试套件。我们将涵盖使用 Sinon 进行模拟、存根和间谍,使用 Chrome DevTools 实时调试运行中的 Node 进程,以及如何使用 Puppeteer 实现 UI 代码的无头测试。

附录 A,将您的工作组织成模块,提供了使用 npm 包管理系统的技巧。在这里,您将学习如何创建、发布和管理包。

附录 B,创建自己的 C++附加组件,简要介绍了如何构建自己的 C++附加组件以及如何在 Node 中使用它们。我们还介绍了新的NAN(Node 的本机抽象)工具以及它如何帮助您编写跨平台、未来证明的附加组件。

本书所需内容

您需要对 JavaScript 有一定的了解,并在您的开发机器或服务器上安装 Node 的副本,版本为 9.0 或更高。您应该知道如何在这台机器上安装程序,因为您需要安装 Redis,以及其他类似 Docker 的库。安装 Git,并学习如何克隆 GitHub 存储库,将极大地改善您的体验。

您应该安装 RabbitMQ,以便您可以跟随使用消息队列的示例。当然,使用 NGINX 代理 Node 服务器的部分将需要您安装和使用该 Web 服务器。要构建 C++附加组件,您需要在系统上安装适当的编译器。

本书中的示例是在基于 UNIX 的环境(包括 Mac OS X)中构建和测试的,但您也应该能够在基于 Windows 的操作系统上运行所有 Node 示例。您可以从www.nodejs.org获取适用于您系统的安装程序和二进制文件。

本书适用对象

本书适用于希望构建高容量网络应用程序的开发人员,例如社交网络、协作文档编辑环境、实时数据驱动的网络界面、网络游戏和其他 I/O 密集型软件。如果您是客户端 JavaScript 开发人员,阅读本书将教会您如何使用您已经了解的语言成为服务器端程序员。如果您是 C++黑客,Node 是使用该语言构建的开源项目,为您提供了一个绝佳的机会,在一个庞大且不断增长的社区中产生真正的影响,甚至通过帮助开发这一激动人心的新技术而获得名声。

本书还适用于技术经理和其他寻求了解 Node 的能力和设计理念的人。本书充满了 Node 如何解决现代软件公司在高并发、实时应用程序和通过不断增长的网络传输大量数据方面面临的问题的示例。Node 已经被企业所接受,您应该考虑将其用于您的下一个项目。

我们正在使用 Node 的最新版本(写作时为 9.x)。这是您需要准备好的唯一一本书,以便在未来几年中随着 Node 在企业中的持续发展。

约定

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

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“如果我们查看我们的find-byte.c文件,我们会看到我们的render方法返回包装在View组件中的内容”。

代码块设置如下:

const s1 = "first string";
const s2 = "second string";
let s3 = s1 + s2;

任何命令行输入或输出都以以下方式编写:

$ node --version

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

const char *s1 = "first string";
const char *s2 = "second string";
int size = strlen(s1) + strlen(s2);
char *buffer = (char *)malloc(size + 1);
strcpy(buffer, s1);
strcat(buffer, s2);
free(buffer);

新术语重要单词以粗体显示。屏幕上显示的单词,例如菜单或对话框中的单词,会以这种方式出现在文本中:“单击“下一步”按钮会将您移至下一个屏幕。”

警告或重要说明会以这样的方式出现在框中。

提示和技巧会以这样的方式出现。

第一章:了解节点环境

介绍 - JavaScript 作为系统语言

当 John Bardeen、Walter Brattain 和 William Shockley 于 1947 年发明了晶体管时,他们以至今仍在发现的方式改变了世界。从他们的革命性基石开始,工程师可以设计和制造比之前可能的数字电路复杂得多的数字电路。随后的每一个十年都见证了这些设备的新一代:更小、更快、更便宜,通常是数量级的提升。

到了 20 世纪 70 年代,公司和大学能够负担得起足够小以适合单个房间的大型计算机,并且足够强大,可以同时为多个用户提供服务。小型计算机是一种新的、不同类型的设备,需要新的和不同类型的技术来帮助用户充分利用这台机器。贝尔实验室的 Ken Thompson 和 Dennis Ritchie 开发了 Unix 操作系统和编程语言 C 来编写它。他们在系统中构建了进程、线程、流和分层文件系统等结构。今天,这些结构是如此熟悉,以至于很难想象计算机以其他方式工作。然而,它们只是由这些先驱者构建的结构,旨在帮助像我们这样的人理解内存和存储器中的数据模式。

C 是一种系统语言,对于熟悉输入汇编指令的开发人员来说,它是一种安全且功能强大的简写替代方案。在微处理器的熟悉环境中,C 使得低级系统任务变得容易。例如,你可以搜索一个内存块以找到特定值的字节。

// find-byte.c 
int find_byte(const char *buffer, int size, const char b) {
   for (int i = 0; i < size; i++) {
         if (buffer[i] == b) {
               return i;
         }
   }
   return -1; 
}

到了 20 世纪 90 年代,我们可以用晶体管构建的东西再次发生了变化。个人电脑(PC)足够轻便和便宜,可以在工作场所和宿舍的桌面上找到。提高的速度和容量使用户可以从仅字符的电传打印机引导到具有漂亮字体和彩色图像的图形环境。通过以太网卡和电缆,你的计算机可以在互联网上获得静态 IP 地址,网络程序可以连接并与地球上的任何其他计算机发送和接收数据。

正是在这样的技术背景下,Sir Tim Berners-Lee 发明了万维网,Brendan Eich 创建了 JavaScript。JavaScript 是为熟悉 HTML 标签的程序员设计的,它是一种超越静态文本页面的动画和交互的方式。在网页的熟悉环境中,JavaScript 使得高级任务变得容易。网页充满了文本和标签,因此合并两个字符串很容易。

// combine-text.js
const s1 = "first string";
const s2 = "second string";
let s3 = s1 + s2;

现在,让我们将每个程序移植到另一种语言和平台。首先,从之前的combine-text.js,让我们编写combine-text.c

// combine-text.c 
const char *s1 = "first string";
const char *s2 = "second string";
int size = strlen(s1) + strlen(s2);
char *buffer = (char *)malloc(size + 1); // One more for the 0x00 byte that terminates strings 
strcpy(buffer, s1);
strcat(buffer, s2);
free(buffer); // Never forget to free memory!

两个字符串文字很容易定义,但之后就变得更加困难。没有自动内存管理,作为开发人员,你需要确定需要多少内存,从系统中分配内存,写入数据而不覆盖缓冲区,然后在之后释放它。

其次,让我们尝试相反的操作:从之前的find-byte.c代码,让我们编写find-byte.js。在 Node 之前,不可能使用 JavaScript 来搜索特定字节的内存块。在浏览器中,JavaScript 无法分配缓冲区,甚至没有字节类型。但是在 Node 中,这既可能又容易。

// find-byte.js
function find_byte(buffer, b) {
  let i;
  for (i = 0; i < buffer.length; i++) {
    if (buffer[i] == b) {
      return i;
    }
  }
  return -1; // Not found
}
let buffer = Buffer.from("ascii A is byte value sixty-five", "utf8");
let r = find_byte(buffer, 65); // Find the first byte with value 65
console.log(r); // 6 bytes into the buffer

从相隔几十年的计算机和人们使用它们的方式的计算机世代中出现,驱动这两种语言 C 和 JavaScript 的设计、目的或用途本来没有必然要结合在一起的真正原因。但它们确实结合在一起了,因为在 2008 年谷歌发布了 Chrome,2009 年 Ryan Dahl 编写了 Node.js。

应用之前仅用于操作系统的设计原则。Chrome 使用多个进程来渲染不同的标签,确保它们的隔离。Chrome 是开源发布的,构建在 WebKit 上,但其中的一部分是全新的。在丹麦的农舍里从头开始编码,Lars Bak 的 V8 使用隐藏类转换、增量垃圾收集和动态代码生成来执行(而不是解释)比以往更快的 JavaScript。

在 V8 的支持下,Node 可以多快地运行 JavaScript?让我们编写一个小程序来展示执行速度:

// speed-loop.js
function main() {
  const cycles = 1000000000;
  let start = Date.now();
  for (let i = 0; i < cycles; i++) {
    /* Empty loop */
  }
  let end = Date.now();
  let duration = (end - start) / 1000;
  console.log("JavaScript looped %d times in %d seconds", cycles, duration);
}
main();

以下是speed-loop.js的输出:

$ node --version
v9.3.0
$ node speed-loop.js
JavaScript looped 1000000000 times in 0.635 seconds

for循环的主体中没有代码,但是您的处理器正在忙于递增i,将其与cycles进行比较,并重复这个过程。我写这篇文章时已经是 2017 年末了,我用的是一台配备 2.8 GHz 英特尔酷睿 i7 处理器的 MacBook Pro。Node v9.3.0 是当前版本,循环十亿次只需要不到一秒

纯 C 有多快?让我们看看:

/* speed-loop.c */
#include <stdio.h>
#include <time.h>
int main() {
  int cycles = 1000000000;
  clock_t start, end;
  double duration;
  start = clock();
  for (int i = 0; i < cycles; i++) {
    /* Empty loop */
  }
  end = clock();
  duration = ((double)(end - start)) / CLOCKS_PER_SEC;
  printf("C looped %d times in %lf seconds\n", cycles,duration);
  return 0;
}

以下是speed-loop.c的输出:

$ gcc --version
Apple LLVM version 8.1.0 (clang-802.0.42)
$ gcc speed-loop.c -o speed-loop
$ ./speed-loop
C looped 1000000000 times in 2.398294 seconds

为了进行额外的比较,让我们尝试一种解释性语言,比如 Python:

# speed-loop.py

import time

def main():

  cycles = 1000000000
  start = time.perf_counter()

  for i in range(0, cycles):
    pass # Empty loop

  end = time.perf_counter()
  duration = end - start
  print("Python looped %d times in %.3f seconds" % (cycles, duration))

main()

以下是speed-loop.py的输出:

$ python3 --version
Python 3.6.1
$ python3 speed-loop.py
Python looped 1000000000 times in 31.096 seconds

Node 运行的速度足够快,以至于您不必担心您的应用程序可能会因执行速度而变慢。当然,您仍然需要考虑性能,但受到语言和平台选择以外的因素的限制,比如算法、I/O 和外部进程、服务和 API。由于 V8 编译 JavaScript 而不是解释它,Node 让您享受高级语言特性,如自动内存管理和动态类型,而无需放弃本地编译二进制的性能。以前,您必须选择其中一个;但现在,您可以两者兼得。这太棒了。

20 世纪 70 年代的计算是关于微处理器的,20 世纪 90 年代的计算是关于网页的。今天,2017 年,另一代新的物理计算技术再次改变了我们的机器。您口袋里的智能手机通过无线方式与云中的可扩展的按需付费软件服务进行通信。这些服务在 Unix 的虚拟化实例上运行,Unix 又在数据中心的物理硬件上运行,其中一些数据中心非常大,被策略性地放置在附近的水电站中获取电流。有了这样新颖和不同的机器,我们不应该感到惊讶,用户的可能性和开发人员的必要性也是新的和不同的,再次。

Node.js 将 JavaScript 想象成一个类似于 C 的系统语言。在网页上,JavaScript 可以操作头部和样式。作为系统语言,JavaScript 可以操作内存缓冲区、进程和流、文件和套接字。这种时代错位是由 V8 的性能所可能的,它将语言发送回 20 年前,将其从网页移植到微处理器芯片上。

“Node 的目标是提供一种简单的方式来构建可扩展的网络程序。”

  • Node.js 的创始人 Ryan Dahl

在本书中,我们将学习专业 Node 开发人员用来解决当今软件挑战的技术。通过掌握 Node,您正在学习如何构建下一代软件。在本章中,我们将探讨 Node 应用程序的设计方式,以及它在服务器上的印记的形状和质地,以及 Node 为开发人员提供的强大的基本工具和功能集。在整个过程中,我们将逐渐探讨更复杂的示例,展示 Node 简单、全面和一致的架构如何很好地解决许多困难的问题。

Unix 的设计哲学

随着网络应用程序规模的扩大,它必须识别、组织和维护的信息量也在增加。这种信息量,以 I/O 流、内存使用和处理器负载的形式,随着更多的客户端连接而扩大。这种信息量的扩大也给软件开发人员带来了负担。通常出现扩展问题,通常表现为无法准确预测大型系统的行为,从而导致其较小的前身的行为失败:

  • 一个为存储几千条记录设计的数据层能容纳几百万条记录吗?

  • 用于搜索少量记录的算法是否足够高效,可以搜索更多记录吗?

  • 这个服务器能处理 10000 个同时的客户端连接吗?

创新的边缘是锋利的,切割迅速,给人更少的时间来思考,特别是当错误的代价被放大时。构成应用程序整体的对象的形状变得模糊且难以理解,特别是当对系统中的动态张力做出反应性的临时修改时。在规范中描述为一个小子系统的东西可能已经被补丁到了许多其他系统中,以至于其实际边界被误解。当这种情况发生时,准确追踪整体复合部分的轮廓就变得不可能了。

最终,一个应用程序变得不可预测。当一个人无法预测应用程序的所有未来状态或变化的副作用时,这是危险的。许多服务器、编程语言、硬件架构、管理风格等等,都试图克服随着增长而带来的风险问题,失败威胁着成功。通常情况下,更复杂的系统被作为解决方案出售。任何一个人对信息的掌握都是脆弱的。复杂性随着规模而增加;混乱随着复杂性而来。随着分辨率变得模糊,错误就会发生。

Node 选择了清晰和简单,回应了几十年前的一种哲学:

"编写程序,做一件事,并且做得很好。

编写程序以便协同工作。

编写处理文本流的程序,因为这是一个通用的接口。

-Peter H. Salus,《Unix 四分之一世纪》,1994

从他们创建和维护 Unix 的经验中,Ken ThompsonDennis Ritchie提出了一个关于人们如何最好构建软件的哲学。Ryan Dahl在 Node 的设计中遵循这一哲学,做出了许多决定:

  • Node 的设计偏向简单而不是复杂

  • Node 使用熟悉的 POSIX API,而不是试图改进

  • Node 使用事件来完成所有操作,不需要线程

  • Node 利用现有的 C 库,而不是试图重新实现它们的功能

  • Node 偏向文本而不是二进制格式

文本流是 Unix 程序的语言。JavaScript 从一开始就擅长处理文本,作为一种 Web 脚本语言。这是一个自然的匹配。

POSIX

POSIX可移植操作系统接口,定义了 Unix 的标准 API。它被采用在基于 Unix 的操作系统和其他系统中。IEEE 创建并维护 POSIX 标准,以使来自不同制造商的系统兼容。在运行 macOS 的笔记本电脑上使用 POSIX API 编写 C 程序,以后在树莓派上构建它会更容易。

作为一个共同的基准,POSIX 古老、简单,最重要的是,所有类型的开发人员都熟悉。在 C 程序中创建一个新目录,使用这个 API:

int mkdir(const char *path, mode_t mode);

这就是 Node 的特点:

fs.mkdir(path[, mode], callback)

文件系统模块的 Node 文档一开始就告诉开发人员,这里没有什么新东西:

文件 I/O 是通过标准 POSIX 函数的简单包装提供的。

nodejs.org/api/fs.html

对于 Node 来说,Ryan Dahl实现了经过验证的 POSIX API,而不是试图自己想出一些东西。虽然在某些方面或某些情况下,这样的尝试可能更好,但它会失去 POSIX 给其他系统训练有素的新 Node 开发人员带来的即时熟悉感。

通过选择 POSIX 作为 API,Node 并不受限于上世纪 70 年代的标准。任何人都可以轻松编写自己的模块,调用 Node 的 API,同时向上呈现不同的 API。这些更高级的替代方案可以在达尔文式的竞争中证明自己比 POSIX 更好。

一切皆事件

如果程序要求操作系统在磁盘上打开一个文件,这个任务可能会立即完成。或者,磁盘可能需要一段时间才能启动,或者操作系统正在处理其他文件系统活动,需要等待才能执行新的请求。超越应用程序进程空间内存操作的任务,涉及到计算机、网络和互联网中更远的硬件,无法以相同的方式快速或可靠地进行编程。软件设计师需要一种方法来编写这些可能缓慢和不可靠的任务,而不会使他们的应用程序整体变得缓慢和不可靠。对于使用 C 和 Java 等语言的系统程序员来说,解决这个问题的标准和公认的工具是线程。

pthread_t my_thread;
int x = 0;
/* Make a thread and have it run my_function(&x) */
pthread_create(&my_thread, NULL, my_function, &x);

如果程序向用户提问,用户可能会立即回答。或者,用户可能需要一段时间来思考,然后再点击“是”或“否”。对于使用 HTML 和 JavaScript 的 Web 开发人员,这样做的方法是事件,如下所示:

<button onclick="myFunction()">Click me</button>

乍一看,这两种情况可能看起来完全不同:

  • 在第一种情况下,低级系统正在将内存块从程序传输到程序,毫秒的延迟可能太大而无法测量

  • 在第二种情况下,一个巨大的软件堆栈的顶层正在向用户提问

然而,在概念上,它们是相同的。Node 的设计意识到了这一点,并且在两者都使用了事件。在 Node 中,有一个线程,绑定到一个事件循环。延迟任务被封装,通过回调函数进入和退出执行上下文。I/O 操作生成事件数据流,并通过单个堆栈进行传输。并发由系统管理,抽象出线程池,并简化对内存的共享访问。

Node 向我们展示了 JavaScript 作为系统语言并不需要线程。此外,通过不使用线程,JavaScript 和 Node 避免了并发问题,这些问题会给开发人员带来性能和可靠性挑战,即使是对于熟悉代码库的开发人员也可能难以理解。在《第二章》《理解异步事件驱动编程》中,我们将深入探讨事件和事件循环。

标准库

Node 是建立在标准开源 C 库上的。例如,TLSSSL协议是由OpenSSL实现的。不仅仅是采用 API,OpenSSL 的 C 源代码也包含在 Node 中并编译进去。当你的 JavaScript 程序对加密密钥进行哈希处理时,实际上并不是 JavaScript 在进行工作。你的 JavaScript 通过 Node 调用了 OpenSSL 的 C 代码。实质上,你在对本地库进行脚本编写。

使用现有和经过验证的开源库的设计选择帮助了 Node 的多个方面:

  • 这意味着 Node 可以迅速出现在舞台上,具有系统程序员需要和期望的核心功能,这些功能已经存在。

  • 它确保性能、可靠性和安全性与库相匹配

  • 它也没有破坏跨平台使用,因为所有这些 C 库都已经被编写和维护多年,可以编译到不同的架构上

以前的平台和语言在努力实现软件可移植性时做出了不同的选择。例如,100% Pure Java™ StandardSun Microsystems的一个倡议,旨在促进可移植应用程序的开发。与其利用混合堆栈中的现有代码,它鼓励开发人员在 Java 中重写所有内容。开发人员必须通过编写和测试新代码来保持功能、性能和安全性达到标准。另一方面,Node 选择了一种设计,可以免费获得所有这些功能。

扩展 JavaScript

当他设计 Node 时,JavaScript 并不是Ryan Dahl的最初语言选择。然而,经过探索,他发现了一种现代语言,没有对流、文件系统、处理二进制对象、进程、网络等功能的看法。JavaScript 严格限制在浏览器中,对于这些功能没有用处,也没有实现这些功能。

受 Unix 哲学的指导,达尔坚持了一些严格的原则:

  • Node 程序/进程在单个线程上运行,通过事件循环来排序执行

  • Web 应用程序具有大量 I/O 操作,因此重点应该放在加快 I/O 上

  • 程序流程总是通过异步回调来指导

  • 昂贵的 CPU 操作应该拆分成单独的并行进程,并在结果到达时发出事件

  • 复杂的程序应该由简单的程序组装而成

总的原则是,操作绝对不能阻塞。Node 对速度(高并发)和效率(最小资源使用)的渴望要求减少浪费。等待过程是一种浪费,特别是在等待 I/O 时。

JavaScript 的异步、事件驱动设计完全符合这一模式。应用程序表达对未来某个事件的兴趣,并在该事件发生时得到通知。这种常见的 JavaScript 模式应该对你来说很熟悉:

Window.onload = function() {
  // When all requested document resources are loaded,
  // do something with the resulting environment
}
element.onclick = function() {
  // Do something when the user clicks on this element
}

I/O 操作完成所需的时间是未知的,因此模式是在发出 I/O 事件时请求通知,无论何时发生,都允许其他操作在此期间完成。

Node 为 JavaScript 添加了大量新功能。主要是提供了事件驱动的 I/O 库,为开发人员提供了系统访问权限,这是浏览器中的 JavaScript 无法做到的,比如写入文件系统或打开另一个系统进程。此外,该环境被设计为模块化,允许将复杂的程序组装成更小更简单的组件。

让我们看看 Node 如何导入 JavaScript 的事件模型,扩展它,并在创建强大系统命令的接口时使用它。

事件

Node API 中的许多函数会发出事件。这些事件是events.EventEmitter的实例。任何对象都可以扩展EventEmitter,为 Node 开发人员提供了一种简单而统一的方式来构建紧密的异步接口以调用对象方法。

以下代码将 Node 的EventEmitter对象设置为我们定义的函数构造函数的原型。每个构造的实例都将EventEmitter对象暴露给其原型链,提供对事件 API 的自然引用。计数器实例方法会发出事件,然后监听它们。创建一个Counter后,我们监听增加的事件,指定一个回调,Node 在事件发生时会调用它。然后,我们调用增加两次。每次,我们的Counter都会增加它持有的内部计数,然后发出增加的事件。这将调用我们的回调,将当前计数传递给它,我们的回调会将其记录下来:

// File counter.js
// Load Node's 'events' module, and point directly to EventEmitter there
const EventEmitter = require('events').EventEmitter;
// Define our Counter function
const Counter = function(i) { // Takes a starting number
  this.increment = function() { // The counter's increment method
    i++; // Increment the count we hold
    this.emit('incremented', i); // Emit an event named incremented
  }
}
// Base our Counter on Node's EventEmitter
Counter.prototype = new EventEmitter(); // We did this afterwards, not before!
// Now that we've defined our objects, let's see them in action
// Make a new Counter starting at 10
const counter = new Counter(10);
// Define a callback function which logs the number n you give it
const callback = function(n) {
  console.log(n);
}
// Counter is an EventEmitter, so it comes with addListener
counter.addListener('incremented', callback);
counter.increment(); // 11
counter.increment(); // 12

以下是counter.js的输出:

$ node counter.js
11
12

要删除绑定到counter的事件侦听器,请使用此代码:

counter.removeListener('incremented', callback).

为了与基于浏览器的 JavaScript 保持一致,counter.oncounter.addListener是可以互换的。

Node 将EventEmitter引入 JavaScript,并使其成为你的对象可以扩展的对象。这大大增加了开发人员的可能性。使用EventEmitter,Node 可以以事件导向的方式处理 I/O 数据流,执行长时间运行的任务,同时保持 Node 异步、非阻塞编程的原则:

// File stream.js
// Use Node's stream module, and get Readable inside
let Readable = require('stream').Readable;
// Make our own readable stream, named r
let r = new Readable;
// Start the count at 0
let count = 0;
// Downstream code will call r's _read function when it wants some data from r
r._read = function() {
  count++;
  if (count > 10) { // After our count has grown beyond 10
    return r.push(null); // Push null downstream to signal we've got no more data
  }
  setTimeout(() => r.push(count + '\n'), 500); // A half second from now, push our count on a line
};
// Have our readable send the data it produces to standard out
r.pipe(process.stdout);

以下是stream.js的输出:

$ node stream.js
1
2
3
4
5
6
7
8
9
10

这个例子创建了一个可读流r,并将其输出传输到标准输出。每 500 毫秒,代码会递增一个计数器,并将带有当前计数的文本行推送到下游。尝试自己运行程序,你会看到一系列数字出现在你的终端上。

在第 11 次计数时,r将 null 推送到下游,表示它没有更多的数据要发送。这关闭了流,而且没有更多的事情要做,Node 退出了进程。

后续章节将更详细地解释流。在这里,只需注意将数据推送到流上会触发一个事件,你可以分配一个自定义回调来处理这个事件,以及数据如何向下游流动。

Node 一贯将 I/O 操作实现为异步的、事件驱动的数据流。这种设计选择使得 Node 具有出色的性能。与为长时间运行的任务(如文件上传)创建线程(或启动整个进程)不同,Node 只需要投入资源来处理回调。此外,在流推送数据的短暂时刻之间的长时间段内,Node 的事件循环可以自由地处理其他指令。

作为练习,重新实现stream.js,将r产生的数据发送到文件而不是终端。你需要使用 Node 的fs.createWriteStream创建一个新的可写流w

// File stream2file.js
// Bring in Node's file system module
const fs = require('fs');
// Make the file counter.txt we can fill by writing data to writeable stream w
const w = fs.createWriteStream('./counter.txt', { flags: 'w', mode: 0666 });
...
// Put w beneath r instead
r.pipe(w);

模块化

在他的书《Unix 编程艺术》中,Eric Raymond 提出了模块化原则

“开发人员应该通过明确定义的接口将程序构建成由简单部分连接而成的程序,这样问题就是局部的,程序的部分可以在未来版本中被替换以支持新功能。这个原则旨在节省调试复杂、冗长和难以阅读的代码的时间。”

大型系统很难理解,特别是当内部组件的边界模糊不清,它们之间的交互又很复杂时。将大型系统构建成由小的、简单的、松耦合的部分组成的原则对软件和其他领域都是一个好主意。物理制造、管理理论、教育和政府都受益于这种设计哲学。

当开发人员开始将 JavaScript 用于更大规模和更复杂的软件挑战时,他们遇到了这个挑战。还没有一个好的方法(后来也没有一个通用的标准方法)来从更小的程序组装 JavaScript 程序。例如,你可能在顶部看到带有这些标签的 HTML 页面:

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

这种方法虽然有效,但会导致一系列问题:

  • 页面必须在需要或使用任何依赖之前声明所有潜在的依赖。如果在运行过程中,你的程序遇到需要额外依赖的情况,动态加载另一个模块是可能的,但是是一种单独的黑客行为。

  • 脚本没有封装。每个文件中的代码都写入同一个全局对象。添加新的依赖可能会因为名称冲突而破坏之前的依赖。

  • fileA无法将fileB作为一个集合来处理。像fileB.function1这样的可寻址上下文是不可用的。

<script>标签可能是一个很好的地方,用于提供诸如依赖关系意识和版本控制等有用的模块服务,但它并没有这些功能。

这些困难和危险使得创建和使用 JavaScript 模块感觉比轻松更加危险。一个具有封装和版本控制等功能的良好模块系统可以扭转这一局面,鼓励代码组织和共享,并导致一个高质量的开源软件组件生态系统。

JavaScript 需要一种标准的方式来加载和共享离散的程序模块,在 2009 年找到了 CommonJS 模块规范。Node 遵循这个规范,使得定义和共享被称为模块的可重用代码变得容易。

选择了一个简单而令人愉悦的设计,一个包就是一个 JavaScript 文件的目录。关于包的元数据,比如它的名称、版本和软件许可证,存储在一个名为package.json的额外文件中。这个文件的 JSON 内容既容易被人类阅读,也容易被机器读取。让我们来看一下:

{
  "name": "mypackage1",
  "version": "0.1.2",
  "dependencies": {
    "jquery": "³.1.0",
    "bluebird": "³.4.1",
  },
  "license": "MIT"
}

这个package.json定义了一个名为mypackage1的包,它依赖于另外两个包:jQueryBluebird。在包名旁边是一个版本号。版本号遵循语义化版本(SemVer)规则,格式为主版本号.次版本号.修订版本号。查看你的代码正在使用的包的递增版本号,这就是它的含义:

  • 主要版本:API 的目的或结果发生了变化。如果你的代码调用了更新的函数,可能会出现错误或产生意外的结果。找出发生了什么变化,并确定它是否影响了你的代码。

  • 次要版本:包增加了功能,但仍然兼容。运行所有的测试,然后就可以使用了。如果你感兴趣,可以查看文档,因为可能会有新的、更高级的 API 部分,以及你熟悉的函数和对象。

  • 修订版本:包修复了一个 bug,提高了性能,或者进行了一些重构。运行所有的测试,然后就可以使用了。

包使得可以从许多小的、相互依赖的系统构建大型系统。也许更重要的是,包鼓励分享。关于 SemVer 的更详细信息可以在附录 A 中找到,将你的工作组织成模块,在那里更深入地讨论了 npm 和包。

“我在这里描述的不是一个技术问题。这是一群人聚在一起做出决定,迈出一步,开始一起构建更大更酷的东西。”

– Kevin Dangoor,CommonJS 的创始人

CommonJS 不仅仅是关于模块,实际上它是一整套标准,旨在消除一切阻碍 JavaScript 成为世界主导语言的东西,开源开发者Kris Kowal在 2009 年的一篇文章中解释了这一点。他将这些障碍中的第一个称为缺乏一个良好的模块系统。第二个障碍是缺乏一个标准库,包括文件系统的访问、I/O 流的操作,以及字节和二进制数据块的类型。如今,CommonJS 以给 JavaScript 提供了一个模块系统而闻名,而 Node 则是给了 JavaScript 系统级的访问:

arstechnica.com/information-technology/2009/12/commonjs-effort-sets-javascript-on-path-for-world-domination/

CommonJS给了 JavaScript 包。有了包之后,JavaScript 需要的下一件事就是包管理器。Node 提供了 npm。

npm 作为包的注册表有两种访问方式。首先,在网站www.npmjs.com,你可以链接和搜索包,基本上是在寻找合适的包。统计数据显示了包在过去一天、一周和一个月内被下载的次数,展示了它的受欢迎程度和使用情况。大多数包都链接到开发者的个人资料页面和 GitHub 上的开源代码,这样你就可以看到代码,了解最近的开发活动,并评判作者和贡献者的声誉。

访问 npm 的第二种方式是通过与 Node 一起安装的命令行工具 npm。使用 npm 作为工作站的传统软件包管理器,您可以全局安装软件包,在 shell 的路径上创建新的命令行工具。npm 还知道如何创建、读取和编辑package.json文件,并可以为您创建一个新的、空的 Node 软件包,添加它所需的依赖项,下载所有的代码,并保持一切更新。

除了 Git 和 GitHub,npm 现在正在实现上世纪 70 年代确定的软件开发梦想:代码可以更频繁地被重复使用,软件项目不需要经常从头开始编写。

早期尝试通过 CVS 和 Subversion 等版本控制系统以及像SourceForge.net这样的开源代码共享网站来实现这一目标,侧重于更大的代码和人员单位,并没有取得太多成果。

GitHub 和 npm 在两个重要方面采取了不同的方法:

  • 更看重独立开发者的个人工作而不是社区会议和讨论,开发者可以更多地专注于代码而不是对话

  • 偏爱小型、原子化的软件组件而不是完整的应用程序,封装的组合不仅发生在子例程和对象的微观层面,而且在更重要的应用程序设计的宏观层面上也发生了。

即使文档也可以通过新的方法变得更好:在单片软件应用程序中,文档往往是产品发货后可能发生或可能不会发生的事后想法。

对于组件,出色的文档对于向世界推销您的软件包是必不可少的,使其每天获得更多的公共下载量,并且作为开发者保持的社交媒体账户也会有更多的关注者。

Node 的成功在很大程度上归功于作为 Node 开发者可用的软件包的数量和质量。

有关创建和管理 Node 软件包的更详细信息可以在附录 A,将您的工作组织成模块中找到。

要遵循的关键设计理念是:尽可能使用软件包构建程序,并在可能的情况下共享这些软件包。您的应用程序的形状将更清晰,更易于维护。重要的是,成千上万的其他开发人员的努力可以通过 npm 直接包含到应用程序中,并且间接地通过共享软件包由 Node 社区的成员测试、改进、重构和重新利用。

与流行观念相反,npm 并不是 Node Package Manager 的缩写,绝不应该被用作或解释为首字母缩写

docs.npmjs.com/policies/trademark

网络

浏览器中的 I/O 受到严格限制,这是有很好的原因的——如果任何给定网站上的 JavaScript 可以访问您的文件系统,例如,用户只能点击他们信任的新网站的链接,而不是他们只是想尝试的网站。通过将页面保持在有限的沙盒中,Web 的设计使得从 thing1.com 导航到 thing2.com 不会像双击 thing1.exe 和 thing2.exe 那样产生后果。

当然,Node 将 JavaScript 重新塑造为系统语言,使其直接且无障碍地访问操作系统内核对象,如文件、套接字和进程。这使得 Node 可以创建具有高 I/O 需求的可扩展系统。很可能你在 Node 中编写的第一件事是一个 HTTP 服务器。

Node 支持标准的网络协议,除了 HTTP,还有 TLS/SSL 和 UDP。借助这些工具,我们可以轻松地构建可扩展的网络程序,远远超出了 JavaScript 开发人员从浏览器中了解的相对有限的 AJAX 解决方案。

让我们编写一个简单的程序,向另一个节点发送一个 UDP 数据包:

const dgram = require('dgram');
let client = dgram.createSocket("udp4");
let server = dgram.createSocket("udp4");
let message = process.argv[2] || "message";
message = Buffer.from(message);
server
.on('message', msg => {
  process.stdout.write(`Got message: ${msg}\n`);
  process.exit();
})
.bind(41234);
client.send(message, 0, message.length, 41234, "localhost");

打开两个终端窗口,分别导航到您的代码包的第八章下的“扩展应用程序”文件夹。现在我们将在一个窗口中运行 UDP 服务器,在另一个窗口中运行 UDP 客户端。

在右侧窗口中,使用以下命令运行receive.js

$ node receive.js

在左侧,使用以下命令运行send.js

$ node send.js

执行该命令将导致右侧出现消息:

$ node receive.js
Message received!

UDP 服务器是EventEmitter的一个实例,在绑定端口接收到消息时会发出消息事件。使用 Node,您可以使用 JavaScript 在 I/O 级别编写应用程序,轻松移动数据包和二进制数据流。

让我们继续探索 I/O、进程对象和事件。首先,让我们深入了解 Node 核心的机器 V8。

V8、JavaScript 和优化

V8 是谷歌的 JavaScript 引擎,用 C++编写。它在虚拟机(Virtual Machine)内部编译和执行 JavaScript 代码。当加载到谷歌 Chrome 中的网页展示某种动态效果,比如自动更新列表或新闻源时,您看到的是由 V8 编译的 JavaScript 在工作。

V8 管理 Node 的主进程线程。在执行 JavaScript 时,V8 会在自己的进程中执行,其内部行为不受 Node 控制。在本节中,我们将研究通过使用这些选项来获得的性能优势,学习如何编写可优化的 JavaScript,以及最新 Node 版本(例如 9.x,我们在本书中使用的版本)用户可用的尖端 JavaScript 功能。

标志

有许多可用于操纵 Node 运行时的设置。尝试这个命令:

$ node -h

除了--version等标准选项外,您还可以将 Node 标记为--abort-on-uncaught-exception

您还可以列出 v8 可用的选项:

$ node --v8-options

其中一些设置可以帮助您度过难关。例如,如果您在像树莓派这样的受限环境中运行 Node,您可能希望限制 Node 进程可以消耗的内存量,以避免内存峰值。在这种情况下,您可能希望将--max_old_space_size(默认约 1.5GB)设置为几百 MB。

您可以使用-e参数将 Node 程序作为字符串执行;在这种情况下,记录出您的 Node 副本包含的 V8 版本:

$ node –e "console.log(process.versions.v8)"

值得您花时间尝试 Node/V8 的设置,既可以提高效用,也可以让您对发生的事情(或可能发生的事情)有更深入的了解。

优化您的代码

智能代码设计的简单优化确实可以帮助您。传统上,在浏览器中工作的 JavaScript 开发人员不需要关注内存使用优化,因为通常对于通常不复杂的程序来说,他们有很多内存可用。在服务器上,情况就不同了。程序通常更加复杂,耗尽内存会导致服务器崩溃。

动态语言的便利之处在于避免了编译语言所施加的严格性。例如,您无需明确定义对象属性类型,并且实际上可以随意更改这些属性类型。这种动态性使得传统编译变得不可能,但为 JavaScript 等探索性语言开辟了一些有趣的新机会。然而,与静态编译语言相比,动态性在执行速度方面引入了显著的惩罚。JavaScript 的有限速度经常被认为是其主要弱点之一。

V8 试图为 JavaScript 实现编译语言所观察到的速度。V8 将 JavaScript 编译为本机机器代码,而不是解释字节码,或使用其他即时技术。由于 JavaScript 程序的精确运行时拓扑无法提前知道(语言是动态的),编译包括两阶段的推测性方法:

  1. 最初,第一遍编译器(完整编译器)尽快将您的代码转换为可运行状态。在此步骤中,类型分析和代码的其他详细分析被推迟,优先考虑快速编译-您的 JavaScript 可以尽可能接近即时执行。进一步的优化是在第二步完成的。

  2. 一旦程序启动运行,优化编译器就开始监视程序的运行方式,并尝试确定其当前和未来的运行时特性,根据需要进行优化和重新优化。例如,如果某个函数以一致类型的相似参数被调用了成千上万次,V8 将使用基于乐观假设的优化代码重新编译该函数,假设未来的类型将与过去的类型相似。虽然第一次编译步骤对尚未知和未类型化的功能签名保守,但这个函数的可预测纹理促使 V8 假设某种最佳配置文件,并根据该假设重新编译。

假设可以帮助我们更快地做出决定,但可能会导致错误。如果函数 V8 的编译器只针对某种类型签名进行了优化,现在却使用违反该优化配置文件的参数调用了该函数怎么办?在这种情况下,V8 别无选择:它必须取消优化该函数。V8 必须承认自己的错误,并撤销已经完成的工作。如果看到新的模式,它将在未来重新优化。然而,如果 V8 在以后再次取消优化,并且如果这种优化/取消优化的二进制切换继续,V8 将简单地放弃,并将您的代码留在取消优化状态。

让我们看看一些方法来设计和声明数组、对象和函数,以便您能够帮助而不是阻碍编译器。

数字和跟踪优化/取消优化

ECMA-262 规范将 Number 值定义为“与双精度 64 位二进制格式 IEEE 754 值对应的原始值”。关键是 JavaScript 中没有整数类型;有一个被定义为双精度浮点数的 Number 类型。

出于性能原因,V8 在内部对所有值使用 32 位数字。这里讨论的技术原因太多,可以说有一位用于指向另一个 32 位数字,如果需要更大的宽度。无论如何,很明显 V8 将数字标记为两种类型的值,并在这些类型之间切换将会花费一些代价。尽量将您的需求限制在可能的情况下使用 31 位有符号整数。

由于 JavaScript 的类型不确定性,允许切换分配给插槽的数字的类型。例如,以下代码不会引发错误:

let a = 7;
a = 7.77;

然而,像 V8 这样的推测性编译器将无法优化这个变量赋值,因为它猜测a将始终是一个整数的假设是错误的,迫使取消优化。

我们可以通过设置一些强大的 V8 选项,执行 Node 程序中的 V8 本机命令,并跟踪 v8 如何优化/取消优化您的代码来演示优化/取消优化过程。

考虑以下 Node 程序:

// program.js
let someFunc = function foo(){}
console.log(%FunctionGetName(someFunc));

如果您尝试正常运行此程序,您将收到意外的令牌错误-在 JavaScript 中无法在标识符名称中使用模数(%)符号。带有%前缀的这个奇怪的方法是什么?这是一个 V8 本机命令,我们可以通过使用--allow-natives-syntax标志来打开执行这些类型的函数:

node --allow-natives-syntax program.js
// 'someFunc', the function name, is printed to the console.

现在,考虑以下代码,它使用本机函数来断言关于平方函数的优化状态的信息,使用%OptimizeFunctionOnNextCall本机方法:

let 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 在优化平方函数时没有问题,因为操作数只声明一次并且从未改变。现在,将以下行追加到你的文件中,然后再次运行它:

%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]

这份非常有表现力的优化报告非常清楚地讲述了故事:一度优化的平方函数在我们改变一个数字类型后被取消了优化。鼓励你花一些时间编写代码并使用这些方法进行测试。

对象和数组

正如我们在研究数字时所学到的,当你的代码是可预测的时,V8 的工作效果最好。对于数组和对象也是如此。几乎所有以下的不良实践之所以不好,是因为它们会造成不可预测性。

记住,在 JavaScript 中,对象和数组在底层非常相似(导致了一些奇怪的规则,给那些取笑这门语言的人提供了无穷无尽的素材!)。我们不会讨论这些差异,只会讨论重要的相似之处,特别是在这两种数据结构如何从类似的优化技术中受益。

避免在数组中混合类型。最好始终保持一致的数据类型,比如全部整数全部字符串。同样,尽量避免在数组中改变类型,或者在初始化后改变属性赋值的类型。V8 通过创建隐藏类来跟踪类型来创建对象的蓝图,当这些类型改变时,优化蓝图将被销毁并重建——如果你幸运的话。访问github.com/v8/v8/wiki/Design%20Elements获取更多信息。

不要创建带有间隙的数组,比如以下的例子:

let a = [];
a[2] = 'foo';
a[23] = 'bar';

稀疏数组之所以不好,是因为 V8 可以使用非常高效的线性存储策略来存储(和访问)你的数组数据,或者它可以使用哈希表(速度要慢得多)。如果你的数组是稀疏的,V8 必须选择两者中效率较低的那个。出于同样的原因,始终从零索引开始你的数组。同样,永远不要使用delete来从数组中删除元素。你只是在那个位置插入一个undefined值,这只是创建稀疏数组的另一种方式。同样,要小心用空值填充数组——确保你推入数组的外部数据不是不完整的。

尽量不要预先分配大数组——边用边增长。同样,不要预先分配一个数组然后超出那个大小。你总是希望避免吓到 V8,使其将你的数组转换为哈希表。每当向对象构造函数添加新属性时,V8 都会创建一个新的隐藏类。尽量避免在实例化后添加属性。以相同的顺序在构造函数中初始化所有成员。相同的属性+相同的顺序=相同的对象。

记住,JavaScript 是一种动态语言,允许在实例化后修改对象(和对象原型)。因此,V8 为对象分配内存的方式是怎样的呢?它做出了一些合理的假设。在从给定构造函数实例化一定数量的对象之后(我相信触发数量是 8),假定这些对象中最大的一个是最大尺寸,并且所有后续实例都被分配了那么多的内存(初始对象也被类似地调整大小)。每个实例基于这个假定的最大尺寸被分配了 32 个快速属性槽。任何额外的属性都被放入一个(更慢的)溢出属性数组中,这个数组可以调整大小以容纳任何进一步的新属性。

对于对象和数组,尽量尽可能地定义数据结构的形状,包括一定数量的属性、类型等等,以便未来使用。

函数

通常经常调用函数,应该是你主要优化的焦点之一。包含 try-catch 结构的函数是不可优化的,包含其他不可预测结构的函数也是不可优化的,比如witheval。如果由于某种原因,您的函数无法优化,请尽量减少使用。

一个非常常见的优化错误涉及使用多态函数。接受可变函数参数的函数将被取消优化。避免多态函数。

关于 V8 如何执行推测优化的优秀解释可以在这里找到:ponyfoo.com/articles/an-introduction-to-speculative-optimization-in-v8

优化的 JavaScript

JavaScript 语言不断变化,一些重大的变化和改进已经开始进入本机编译器。最新 Node 构建中使用的 V8 引擎支持几乎所有最新功能。调查所有这些超出了本章的范围。在本节中,我们将提到一些最有用的更新以及它们如何简化您的代码,帮助您更容易理解和推理,更易于维护,甚至可能更高效。

在本书中,我们将使用最新的 JavaScript 功能。您可以使用 Promise、Generator 和 async/await 构造,从 Node 8.x 开始,我们将在整本书中使用这些功能。这些并发运算符将在第二章中深入讨论,理解异步事件驱动编程,但现在一个很好的收获是,回调模式正在失去其主导地位,特别是 Promise 模式正在主导模块接口。

实际上,最近在 Node 的核心中添加了一个新方法util.promisify,它将基于回调的函数转换为基于 Promise 的函数:

const {promisify} = require('util');
const fs = require('fs');

// Promisification happens here
let readFileAsync = promisify(fs.readFile);

let [executable, absPath, target, ...message] = process.argv;

console.log(message.length ? message.join(' ') : `Running file ${absPath} using binary ${executable}`);

readFileAsync(target, {encoding: 'utf8'})
.then(console.log)
.catch(err => {
  let message = err.message;
  console.log(`
    An error occurred!
    Read error: ${message}
  `);
});

能够轻松地promisify fs.readFile非常有用。

您是否注意到其他可能对您不熟悉的新 JavaScript 结构?

帮助变量

在整本书中,您将看到letconst。这些是新的变量声明类型。与var不同,let块作用域;它不适用于其包含的块之外:

let foo = 'bar';

if(foo == 'bar') {
    let foo = 'baz';
    console.log(foo); // 1st
}
console.log(foo); // 2nd

// baz
// bar
// If we had used var instead of let:
// baz
// baz

对于永远不会改变的变量,请使用const,表示constant。这对编译器也很有帮助,因为如果变量保证永远不会改变,编译器可以更容易地进行优化。请注意,const仅适用于赋值,以下是非法的:

const foo = 1;
foo = 2; // Error: assignment to a constant variable

但是,如果值是对象,const无法保护成员:

const foo = { bar: 1 }
console.log(foo.bar) // 1
foo.bar = 2;
console.log(foo.bar) // 2

另一个强大的新功能是解构,它允许我们轻松地将数组的值分配给新变量:

let [executable, absPath, target, ...message] = process.argv;

解构允许您快速将数组映射到变量名。由于process.argv是一个数组,它始终包含 Node 可执行文件的路径和执行文件的路径作为前两个参数,我们可以通过执行node script.js /some/file/path将文件目标传递给上一个脚本,其中第三个参数分配给target变量。

也许我们还想通过这样的方式传递消息:

node script.js /some/file/path This is a really great file!

问题在于This is a really great file!是以空格分隔的,因此它将被分割成每个单词的数组,这不是我们想要的:

[... , /some/file/path, This, is, a, really, great, file!]

剩余模式在这里拯救了我们:最终参数...message将所有剩余的解构参数合并为一个数组,我们可以简单地join(' ')成一个字符串。这也适用于对象:

let obj = {
    foo: 'foo!',
    bar: 'bar!',
    baz: 'baz!'
};

// assign keys to local variables with same names
let {foo, baz} = obj;

// Note that we "skipped" #bar
console.log(foo, baz); // foo! baz!

这种模式对于处理函数参数特别有用。在使用剩余参数之前,您可能会以这种方式获取函数参数:

function (a, b) {
    // Grab any arguments after a & b and convert to proper Array
    let args = Array.prototype.slice.call(arguments, f.length);
}

以前是必要的,因为arguments对象不是真正的数组。除了相当笨拙外,这种方法还会触发像 V8 这样的编译器中的非优化。

现在,你可以这样做:

function (a, b, ...args) {
    // #args is already an Array!
}

展开模式是反向的剩余模式——你可以将单个变量扩展为多个:

const week = ['mon','tue','wed','thur','fri'];
const weekend = ['sat','sun'];

console.log([...week, ...weekend]); // ['mon','tue','wed','thur','fri','sat','sun']

week.push(...weekend);
console.log(week); // ['mon','tue','wed','thur','fri','sat','sun']

箭头函数

箭头函数允许你缩短函数声明,从function() {}简单 () => {}。实际上,你可以替换一行代码:

SomeEmitter.on('message', function(message) { console.log(message) });

至于:

SomeEmitter.on('message', message => console.log(message));

在这里,我们失去了括号和大括号,更紧凑的代码按预期工作。

箭头函数的另一个重要特性是它们不会分配自己的this——箭头函数从调用位置继承this。例如,以下代码不起作用:

function Counter() {
    this.count = 0;

    setInterval(function() {
        console.log(this.count++);
    }, 1000);
}

new Counter();

setInterval内的函数是在setInterval的上下文中调用的,而不是Counter对象的上下文,因此this没有任何与计数相关的引用。也就是说,在函数调用站点,this是一个Timeout对象,你可以通过在先前的代码中添加console.log(this)来检查自己。

使用箭头函数,this在定义的时候被分配。修复代码很容易:

setInterval(() => { // arrow function to the rescue!
  console.log(this);
  console.log(this.count++);
}, 1000);
// Counter { count: 0 }
// 0
// Counter { count: 1 }
// 1
// ...

字符串操作

最后,你会在代码中看到很多反引号。这是新的模板文字语法,除其他功能外,它(终于!)使得在 JavaScript 中处理字符串变得更不容易出错和繁琐。你在示例中看到了如何轻松表达多行字符串(避免'First line\n' + 'Next line\n'这种构造)。字符串插值也得到了类似的改进:

let name = 'Sandro';
console.log('My name is ' + name);
console.log(`My name is ${name}`);
// My name is Sandro
// My name is Sandro

这种替换在连接许多变量时特别有效,因为每个${expression}的内容都可以是任何 JavaScript 代码:

console.log(`2 + 2 = ${2+2}`)  // 2 + 2 = 4

你也可以使用repeat来生成字符串:'ha'.repeat(3) // hahaha

现在字符串是可迭代的。使用新的for...of结构,你可以逐个字符地拆分字符串:

for(let c of 'Mastering Node.js') {
    console.log(c);
    // M
    // a
    // s
    // ...
}

或者,使用展开操作符:

console.log([...'Mastering Node.js']);
// ['M', 'a', 's',...]

搜索也更容易。新的方法允许常见的子字符串查找而不需要太多仪式:

let targ = 'The rain in Spain lies mostly on the plain';
console.log(targ.startsWith('The', 0)); // true
console.log(targ.startsWith('The', 1)); // false
console.log(targ.endsWith('plain')); // true
console.log(targ.includes('rain', 5)); // false

这些方法的第二个参数表示搜索偏移,默认为 0。The在位置 0 被找到,所以在第二种情况下从位置 1 开始搜索会失败。

很好,编写 JavaScript 程序变得更容易了。下一个问题是当程序在 V8 进程中执行时发生了什么?

进程对象

Node 的process 对象提供了有关当前运行进程的信息和控制。它是EventEmitter的一个实例,可以从任何范围访问,并公开非常有用的低级指针。考虑下面的程序:

const size = process.argv[2];
const n = process.argv[3] || 100;
const buffers = [];
let i;
for (i = 0; i < n; i++) {
  buffers.push(Buffer.alloc(size));
  process.stdout.write(process.memoryUsage().heapTotal + "\n");
}

让 Node 使用类似这样的命令运行process.js

$ node process.js 1000000 100

程序从process.argv获取命令行参数,循环分配内存,并将内存使用情况报告回标准输出。你可以将输出流到另一个进程或文件,而不是记录回终端:

$ node process.js 1000000 100 > output.txt

Node 进程通过构建单个执行堆栈开始,全局上下文形成堆栈的基础。这个堆栈上的函数在它们自己的本地上下文中执行(有时被称为作用域),这个本地上下文保持在全局上下文中。将函数的执行与函数运行的环境保持在一起的方式被称为闭包。因为 Node 是事件驱动的,任何给定的执行上下文都可以将运行线程提交给处理最终执行上下文。这就是回调函数的目的。

考虑下面的简单接口示意图,用于访问文件系统:

如果我们实例化Filesystem并调用readDir,将创建一个嵌套的执行上下文结构:

(global (fileSystem (readDir (anonymous function) ) ) )

在 Node 内部,一个名为libuv的 C 库创建和管理事件循环。它连接到可以产生事件的低级操作系统内核模式对象,例如定时器触发、接收数据的套接字、打开读取的文件和完成的子进程。它在仍有事件需要处理时循环,并调用与事件相关的回调。它在非常低的级别上进行操作,并且具有非常高效的架构。为 Node 编写的libuv现在是许多软件平台和语言的构建块。

与此同时,执行堆栈被引入到 Node 的单进程线程中。这个堆栈保留在内存中,直到libuv报告fs.readdir已经完成,此时注册的匿名回调触发,解析唯一的待处理执行上下文。由于没有进一步的事件待处理,也不再需要维护闭包,整个结构可以安全地被拆除(从匿名开始逆序),进程可以退出,释放任何分配的内存。构建和拆除单个堆栈的方法就是 Node 的事件循环最终所做的。

REPL

Node 的REPLRead-Eval-Print-Loop)代表了 Node 的 shell。要进入 shell 提示符,通过终端输入 Node 而不传递文件名:

$ node

现在您可以访问正在运行的 Node 进程,并可以向该进程传递 JavaScript 命令。此外,如果输入一个表达式,REPL 将回显表达式的值。作为这一点的一个简单例子,您可以使用 REPL 作为一个口袋计算器:

$ node
> 2+2
4

输入2+2表达式,Node 将回显表达式的值4。除了简单的数字文字之外,您可以使用这种行为来查询、设置和再次查询变量的值:

> a
ReferenceError: a is not defined
 at repl:1:1
 at sigintHandlersWrap (vm.js:22:35)
 at sigintHandlersWrap (vm.js:96:12)
 at ContextifyScript.Script.runInThisContext (vm.js:21:12)
 at REPLServer.defaultEval (repl.js:346:29)
 at bound (domain.js:280:14)
 at REPLServer.runBound [as eval] (domain.js:293:12)
 at REPLServer.<anonymous> (repl.js:545:10)
 at emitOne (events.js:101:20)
 at REPLServer.emit (events.js:188:7)
> a = 7
7
> a
7

Node 的 REPL 是一个很好的地方,可以尝试、调试、测试或以其他方式玩耍 JavaScript 代码。

由于 REPL 是一个本地对象,程序也可以使用实例作为运行 JavaScript 的上下文。例如,在这里我们创建了自己的自定义函数sayHello,将其添加到 REPL 实例的上下文中,并启动 REPL,模拟 Node shell 提示符:

require('repl').start("> ").context.sayHello = function() {
  return "Hello";
};

在提示符处输入sayHello(),函数将向标准输出发送Hello

让我们把这一章学到的一切都应用到一个交互式的 REPL 中,允许我们在远程服务器上执行 JavaScript:

  1. 创建两个文件client.jsserver.js,并输入以下代码。

  2. 在自己的终端窗口中运行每个程序,将两个窗口并排放在屏幕上:

// File client.js
let net = require("net");
let sock = net.connect(8080);
process.stdin.pipe(sock);
sock.pipe(process.stdout);

// File server.js
let repl = require("repl")
let net = require("net")
net.createServer((socket) => {
  repl
  .start({
    prompt: "> ",
    input: socket,
    output: socket,
    terminal: true
  }).on('exit', () => {
    socket.end();
  })
}).listen(8080);

client.js程序通过net.connect创建一个新的套接字连接到端口8080,并将来自标准输入(您的终端)的任何数据通过该套接字传输。同样,从套接字到达的任何数据都被传输到标准输出(返回到您的终端)。通过这段代码,我们创建了一种方式,将终端输入通过套接字发送到端口8080,并监听套接字可能发送回来的任何数据。

另一个程序server.js结束了循环。这个程序使用net.createServer.listen来创建和启动一个新的 TCP 服务器。代码传递给net.createServer的回调接收到绑定套接字的引用。在该回调的封闭内部,我们实例化一个新的 REPL 实例,给它一个漂亮的提示符(这里是>,但可以是任何字符串),指示它应该同时监听来自传递的套接字引用的输入,并广播输出,指示套接字数据应该被视为终端数据(具有特殊编码)。

现在我们可以在客户端终端中输入console.log("hello"),并看到显示hello

要确认我们的 JavaScript 命令的执行发生在服务器实例中,可以在客户端输入console.log(process.argv),服务器将显示一个包含当前进程路径的对象,即server.js

只需几行代码,我们就创建了一种远程控制 Node 进程的方式。这是迈向多节点分析工具、远程内存管理、自动服务器管理等的第一步。

总结

有经验的开发人员都曾经面对过 Node 旨在解决的问题:

  • 如何有效地为成千上万的同时客户提供服务

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

  • 防止 I/O 操作成为瓶颈

  • 消除单点故障,从而确保可靠性

  • 安全可预测地实现并行性

随着每一年的过去,我们看到协作应用程序和软件负责管理并发水平,这在几年前被认为是罕见的。管理并发,无论是在连接处理还是应用程序设计方面,都是构建可扩展架构的关键。

在本章中,我们概述了 Node 的设计者试图解决的关键问题,以及他们的解决方案如何使开发人员社区更容易创建可扩展、高并发的网络系统。我们看到了 JavaScript 被赋予了非常有用的新功能,它的事件模型得到了扩展,V8 可以配置以进一步定制 JavaScript 运行时。通过示例,我们学习了 Node 如何处理 I/O,如何编程 REPL,以及如何管理输入和输出到进程对象。

Node 将 JavaScript 转化为系统语言,创造了一个有用的时代错位,既可以脚本套接字,也可以按钮,并跨越了几十年的计算机演变学习。

Node 的设计恢复了 20 世纪 70 年代 Unix 原始开发人员发现的简单性的优点。有趣的是,计算机科学在这段时间内反对了这种哲学。C++和 Java 倾向于面向对象的设计模式、序列化的二进制数据格式、子类化而不是重写以及其他政策,这些政策导致代码库在最终在自身复杂性的重压下崩溃之前往往增长到一百万行或更多。

然后出现了网络。浏览器的“查看源代码”功能是一个温和的入口,它将数百万网络用户带入了新一代软件开发人员的行列。Brendan Eich 设计 JavaScript 时考虑到了这些新手潜在开发人员。很容易从编辑标签和更改样式开始,然后很快就能编写代码。与新兴初创公司的年轻员工交谈,现在他们是专业开发人员、工程师和计算机科学家,许多人会回忆起“查看源代码”是他们开始的方式。

回到 Node 的时间扭曲,JavaScript 在 Unix 的创始原则中找到了类似的设计和哲学。也许将计算机连接到互联网给聪明人带来了新的、更有趣的计算问题要解决。也许又出现了一代新的学生和初级员工,并再次反抗他们的导师。无论出于何种原因,小型、模块化和简单构成了今天的主导哲学,就像很早以前一样。

在未来几十年,计算技术会发生多少次变化,足以促使当时的设计师编写与几年前教授和接受为正确、完整和永久的软件和语言截然不同的新软件?正如阿瑟·C·克拉克所指出的,试图预测未来是一项令人沮丧和危险的职业。也许我们会看到计算机和代码的几次革命。另一方面,计算技术很可能很快就会进入一个稳定期,在这段时间内,计算机科学家将找到并确定最佳的范例来教授和使用。现在没有人知道编码的最佳方式,但也许很快我们会知道。如果是这样的话,那么现在这个时候,当创建和探索以找到这些答案是任何人的游戏时,是一个非常引人入胜的时刻,可以与计算机一起工作和玩耍。

我们展示 Node 如何以一种有原则的方式智能地构建应用程序的目标已经开始。在下一章中,我们将更深入地探讨异步编程,学习如何管理更复杂的事件链,并使用 Node 的模型开发更强大的程序。

第二章:理解异步事件驱动编程

“预测未来的最好方法是创造它。”

– Alan Kay

通过使用事件驱动的异步 I/O 来消除阻塞进程是 Node 的主要组织原则。我们已经了解到这种设计如何帮助开发人员塑造信息并增加容量。Node 允许您构建和组织轻量级、独立的、无共享的进程,这些进程通过回调进行通信,并与可预测的事件循环同步。

随着 Node 的流行度增长,设计良好的事件驱动系统和应用程序的数量也在增加。要使一种新技术成功,它必须消除现有的问题,并/或以更低的时间、精力或价格成本为消费者提供更好的解决方案。在其年轻而富有活力的生命周期中,Node 社区已经合作证明了这种新的开发模式是现有技术的可行替代方案。基于 Node 的解决方案的数量和质量为企业级应用程序提供了进一步的证明,表明这些新想法不仅是新颖的,而且是受欢迎的。

在本章中,我们将更深入地探讨 Node 如何实现事件驱动编程。我们将首先解开事件驱动语言和环境从中获得和处理的想法和理论,以消除误解并鼓励掌握。在介绍事件之后,我们将重点介绍 Node.js 技术——事件循环。然后,我们将更详细地讨论 Node 如何实现定时器、回调和 I/O 事件,以及作为 Node 开发人员如何使用它们。我们还将讨论使用现代工具(如PromisesGeneratorsasync/await)管理并发的方法。在构建一些简单但典型的文件和数据驱动应用程序时,我们将实践这些理论。这些示例突出了 Node 的优势,并展示了 Node 如何成功地简化了网络应用程序设计。

Node 的独特设计

首先,让我们准确地看一下当您的程序要求系统执行不同类型的服务时的总时间成本。I/O 是昂贵的。在下图中(取自Ryan Dahl关于 Node 的原始演示),我们可以看到典型系统任务消耗多少个时钟周期。I/O 操作的相对成本令人震惊:

L1 缓存 3 个周期
L2 缓存 14 个周期
RAM 250 个周期
磁盘 41,000,000 个周期
网络 240,000,000 个周期

原因是很明显的:磁盘是一个物理设备,一个旋转的金属盘——存储和检索数据比在固态设备(如微处理器和存储芯片)之间移动数据要慢得多,或者说比在优化的芯片上的 L1/L2 缓存要慢得多。同样,数据在网络上不是瞬间移动的。光本身需要 0.1344 秒才能环绕地球!在一个由数十亿人定期在速度远远慢于光速的距离上相互交流的网络中,有许多弯路和少数直线,这种延迟会积累起来。

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

随着云计算和基于浏览器的软件的出现,您的数据已经离开了本地磁盘,存在于远程磁盘上,并且您通过网络——互联网访问这些数据。数据访问时间再次显著减慢。网络 I/O 很慢。尽管如此,越来越多的公司正在将其应用程序的部分迁移到云中,一些软件甚至完全基于网络。

Node 旨在使 I/O 快速。它是为这个新的网络软件世界设计的,其中数据分布在许多地方,必须快速组装。许多传统的构建 Web 应用程序的框架是在一个单一用户使用桌面计算机,使用浏览器定期向运行关系数据库的单个服务器发出 HTTP 请求的时代设计的。现代软件必须预期成千上万个同时连接的客户端通过各种网络协议在任意数量的独特设备上同时更改庞大的共享数据池。Node 专门设计为帮助那些构建这种网络软件的人。

Node 设计所反映的思维突破一旦被认识到,就变得简单易懂,因为大多数工作线程都在等待——等待更多指令,等待子任务完成等。例如,被分配为服务命令“格式化我的硬盘”的进程将把所有资源用于管理工作流程,类似以下内容:

  • 向设备驱动程序通知已发出格式请求

  • 空闲,等待不可知的时间长度

  • 接收格式完成的信号

  • 通知客户端

  • 清理;关闭:

在前面的图中,我们看到一个昂贵的工人正在向客户收取固定的时间单位费用,无论是否正在做任何有用的工作(客户对活动和空闲一视同仁地付费)。换句话说,并不一定是真的,而且往往不是真的,组成总任务的子任务每个都需要相似的努力或专业知识。因此,为这种廉价劳动力支付高价是浪费的。

同情地说,我们还必须认识到,即使准备好并能够处理更多工作,这个工人也无法做得更好——即使是最有诚意的工人也无法解决 I/O 瓶颈的问题。这个工人是I/O 受限的。

相反,想象一种替代设计。如果多个客户端可以共享同一个工人,那么当一个工人因 I/O 瓶颈而宣布可用时,另一个客户端的工作可以开始吗?

Node 通过引入一个系统资源(理想情况下)永远不会空闲的环境,使 I/O 变得通用。Node 实现的事件驱动编程反映了降低整体系统成本的简单目标,主要通过减少 I/O 瓶颈的数量来鼓励共享昂贵的劳动力。我们不再拥有无能为力的僵化定价的劳动力块;我们可以将所有努力减少为精确界定形状的离散单位,因此可以实现更准确的定价。

一个协作调度了许多客户端工作的环境会是什么样子?这种事件之间的消息传递是如何处理的?此外,并发、并行、异步执行、回调和事件对 Node 开发人员意味着什么?

协作

与先前描述的阻塞系统相比,更可取的是一个协作工作环境,工人定期被分配新任务,而不是空闲。为了实现这样的目标,我们需要一个虚拟交换机,将服务请求分派给可用的工人,并让工人通知交换机他们的可用性。

实现这一目标的一种方法是拥有一个可用劳动力池,通过将任务委派给不同的工人来提高效率:

这种方法的一个缺点是需要进行大量的调度和工人监视。调度程序必须处理源源不断的请求,同时管理来自工人的关于他们可用性的消息,将请求整理成可管理的任务并高效地排序,以便最少数量的工人处于空闲状态。

也许最重要的是,当所有工人都被预订满了会发生什么?调度程序是否开始从客户那里丢弃请求?调度也是资源密集型的,调度程序的资源也是有限的。如果请求继续到达,而没有工人可用来为其提供服务,调度程序会怎么做?管理队列?我们现在有一个情况,调度程序不再做正确的工作(调度),而是负责簿记和保持列表,进一步延长每个任务完成所需的时间。每个任务需要一定的时间,并且必须按到达顺序进行处理。这个任务执行模型堆叠了固定的时间间隔——时间片。这是同步执行。

排队

为了避免过载任何人,我们可以在客户和调度程序之间添加一个缓冲区。这个新的工人负责管理客户关系。客户不直接与调度程序交谈,而是与服务经理交谈,将请求传递给经理,并在将来的某个时候接到通知,说他们的任务已经完成。工作请求被添加到一个优先级工作队列(一个订单堆栈,最重要的订单在顶部),这个经理等待另一个客户走进门。

以下图表描述了情况:

调度程序试图通过从队列中提取任务,将工人完成的任何包传回,并通常维护一个理智的工作环境,以确保没有任何东西被丢弃或丢失,来使所有工人保持忙碌。与沿着单个时间线逐个进行任务不同,多个同时运行在其自己的时间线上的任务并行运行。如果所有工人都处于空闲状态且任务队列为空,那么办公室可以休息一会儿,直到下一个客户到来。

这是 Node 通过异步工作而不是同步工作来获得速度的粗略示意图。现在,让我们深入了解 Node 的事件循环是如何工作的。

理解事件循环

在我们分解事件循环时,以下三点很重要:

  • 事件循环在与您的 JavaScript 代码运行的相同(单个)线程中运行。阻塞事件循环意味着阻塞整个线程。

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

  • 事件循环将许多 I/O 操作委托给libuv,后者管理这些操作(使用 OS 本身的能力,如线程池),并在结果可用时通知事件循环。易于理解的单线程编程模型通过多线程的效率得到了加强。

例如,以下while循环永远不会终止:

let stop = false;
setTimeout(() => {
  stop = true;
}, 1000);

while (stop === false) {};

即使有人可能期望,在大约一秒钟内,将布尔值true分配给变量stop,触发while条件并中断其循环;这永远不会发生。为什么?这个while循环通过无限运行来使事件循环饥饿,贪婪地检查和重新检查一个永远不会有机会改变的值,因为事件循环永远不会有机会安排我们的定时器回调进行执行。这证明了事件循环(管理定时器)并且在同一个线程上运行。

根据 Node 文档,“事件循环是 Node.js 执行非阻塞 I/O 操作的关键,尽管 JavaScript 是单线程的,但通过尽可能地将操作卸载到系统内核来实现。” Node 的设计者所做的关键设计选择是将事件循环实现为并发管理器。例如,通过libuv,OS 传递网络接口事件来通知基于 Node 的 HTTP 服务器与本地硬件的网络连接。

以下是事件驱动编程的描述(摘自:www.princeton.edu/~achaney/tmve/wiki100k/docs/Event-driven_programming.html),不仅清楚地描述了事件驱动范式,还向我们介绍了事件在 Node 中的处理方式,以及 JavaScript 是这种范式的理想语言。

在计算机编程中,事件驱动编程或基于事件的编程是一种编程范式,其中程序的流程由事件决定 - 即传感器输出或用户操作(鼠标点击,按键)或来自其他程序或线程的消息。事件驱动编程也可以被定义为一种应用架构技术,其中应用程序具有一个主循环,明确定义为两个部分:第一个是事件选择(或事件检测),第二个是事件处理[...]。事件驱动程序可以用任何语言编写,尽管在提供高级抽象的语言中更容易,比如闭包。有关更多信息,请访问www.youtube.com/watch?v=QQnz4QHNZKc

Node 通过将许多阻塞操作委托给 OS 子系统来使单个线程更有效,只有在有数据可用时才会打扰主 V8 线程。主线程(执行中的 Node 程序)通过传递回调来表达对某些数据的兴趣(例如通过fs.readFile),并在数据可用时得到通知。在数据到达之前,不会对 V8 的主 JavaScript 线程施加进一步的负担。如何做到的?Node 将 I/O 工作委托给libuv,如引用所述:nikhilm.github.io/uvbook/basics.html#event-loops

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

  • Matteo Collina *创建了一个有趣的模块,用于对事件循环进行基准测试,可在以下网址找到:github.com/mcollina/loopbench

考虑以下代码:

const fs = require('fs');
fs.readFile('foo.js', {encoding:'utf8'}, (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 的操作如下:

  1. 使用 V8 API 在 C++中创建了一个进程对象。然后将 Node.js 运行时导入到这个 V8 进程中。

  2. fs模块附加到 Node 运行时。V8 将 C++暴露给 JavaScript。这为您的 JavaScript 代码提供了对本机文件系统绑定的访问权限。

  3. fs.readFile方法传递了指令和 JavaScript 回调。通过fs.bindinglibuv被通知文件读取请求,并传递了原始程序发送的回调的特别准备版本。

  4. libuv调用了必要的操作系统级函数来读取文件。

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

  6. 当操作系统完全读取文件描述符时,通过内部机制通知libuv,并调用传递给libuv的回调,从而为原始 JavaScript 回调准备重新进入主(V8)线程。

  7. 原始的 JavaScript 回调被推送到事件循环,并在循环的近期刻度上被调用。

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

  9. 由于没有进一步的回调在飞行中,进程退出。

在这里,我们看到了 Node 实现的关键思想,以实现快速、可管理和可扩展的 I/O。例如,如果在前面的程序中对foo.js进行了 10 次读取调用,执行时间仍然大致相同。每个调用都将由libuv尽可能高效地管理(例如,通过使用线程并行化调用)。尽管我们的代码是用 JavaScript 编写的,但实际上我们部署了一个非常高效的多线程执行引擎,同时避免了操作系统异步进程管理的困难。

现在我们知道了文件系统操作可能是如何工作的,让我们深入了解 Node 在事件循环中如何处理每种异步操作类型。

事件循环排序、阶段和优先级

事件循环通过阶段进行处理,每个阶段都有一个要处理的事件队列。来自 Node 文档:

对开发人员相关的阶段如下:

  • 定时器:延迟到未来某个指定的毫秒数的回调,比如setTimeoutsetInterval

  • I/O 回调:在被委托给 Node 的管理线程池后返回到主线程的准备好的回调,比如文件系统调用和网络监听器

  • 轮询/检查:主要是根据setImmediatenextTick的规则排列在堆栈上的函数

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

要了解更多关于 Node 如何绑定到libuv和其他核心库的信息,请查看fs模块的代码:github.com/nodejs/node/blob/master/lib/fs.js。比较fs.readfs.readSync方法,观察同步和异步操作的实现方式的不同;注意在fs.read中传递给原生binding.read方法的包装回调。要深入了解 Node 设计的核心部分,包括队列实现,请阅读 Node 源代码:github.com/joyent/node/tree/master/src。查看fs_event_wrap.cc中的FSEventWrap。调查req_wrap类,这是 V8 引擎的包装器,在node_file.cc和其他地方部署,并在req_wrap.h中定义。

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

接下来,让我们看看 Node 的事件接口。

监听事件

现代网络软件因为各种原因变得越来越复杂,并且在很多方面改变了我们对应用程序开发的看法。大多数新平台和语言都试图解决这些变化。Node 也不例外,JavaScript 也不例外。

学习 Node 意味着学习事件驱动编程,将软件组合成模块,创建和链接数据流,生成和消耗事件及其相关数据。基于 Node 的架构通常由许多小进程和/或服务组成,这些进程和/或服务通过事件进行通信 - 内部通过扩展EventEmitter接口并使用回调,外部通过几种常见的传输层之一(例如 HTTP,TCP),或通过覆盖这些传输层之一的薄消息传输层(例如 0MQ,Redis PUBSUB 和 Kafka)。

这些进程很可能由几个免费、开源和高质量的 npm 模块组成,每个模块都配备了单元测试和/或示例和/或文档。

上一章向您介绍了EventEmitter接口。这是我们在逐章移动时将遇到的主要事件接口,因为它为许多暴露事件接口的 Node 对象提供了原型类,例如文件和网络流。不同模块 API 暴露的各种closeexitdata和其他事件都表示了EventEmitter接口的存在,随着我们的学习,我们将了解这些模块和用例。

在本节中,我们的目标是讨论一些较少为人知的事件源:信号、子进程通信、文件系统更改事件和延迟执行。

信号

事件驱动编程就像硬件中断编程。中断正是其名称所暗示的。它们利用中断控制器、CPU 或任何其他设备正在执行的任务,要求立即为它们的特定需求提供服务。

事实上,Node 进程对象公开了标准可移植操作系统接口(POSIX)信号名称,因此 Node 进程可以订阅这些系统事件。

正如en.wikipedia.org/wiki/POSIX_signal 所定义的,“信号是 Unix、类 Unix 和其他符合 POSIX 标准的操作系统中使用的一种有限的进程间通信形式。它是异步通知,发送给进程或同一进程中的特定线程,以通知其发生的事件。”

这是将 Node 进程暴露给操作系统信号事件的一种非常优雅和自然的方式。可以配置监听器来捕获指示 Node 进程重新启动或更新某些配置文件,或者简单地进行清理和关闭的信号。

例如,当控制终端检测到Ctrl + C(或等效)按键时,SIGINT信号将发送到进程。此信号告诉进程已请求中断。如果 Node 进程已将回调绑定到此事件,则该函数可能在终止之前记录请求,执行其他清理工作,甚至忽略请求:

// sigint.js
console.log("Running...");

// After 16 minutes, do nothing
setInterval(() => {}, 1e6); // Keeps Node running the process

// Subscribe to SIGINT, so some of our code runs when Node gets that signal
process.on("SIGINT", () => {
    console.log("We received the SIGINT signal!");
    process.exit(1);
});

以下是sigint.js的输出:

$ node sigint.js
Running...
(then press Ctrl+C)
We received the SIGINT signal!

此示例启动了一个长时间间隔,因此 Node 不会因无其他任务而退出。当您通过控制进程的终端从键盘发送Ctrl + C时,Node 会从操作系统接收信号。您的代码已订阅了该事件,Node 会运行您的函数。

现在,考虑这样一种情况,即 Node 进程正在进行一些持续的工作,例如解析日志。能够向该进程发送信号,例如更新配置文件或重新启动扫描,可能是有用的。您可能希望从命令行发送这些信号。您可能更喜欢由另一个进程执行此操作 - 这种做法称为进程间通信(IPC)。

创建一个名为ipc.js的文件,并键入以下代码:

// ipc.js
setInterval(() => {}, 1e6);
process.on("SIGUSR1", () => {
    console.log("Got a signal!");
});

运行以下命令:

$ node ipc.js

与以前一样,Node 将在运行空函数之前等待大约 16 分钟,保持进程开放,因此您将不得不使用*Ctrl *+ C来恢复提示符。请注意,即使在这里,我们没有订阅 SIGINT 信号,这也可以正常工作。

SIGUSR1(和SIGUSR2)是用户定义的信号,由操作系统不知道的特定操作触发。它们用于自定义功能。

要向进程发送命令,必须确定其进程 ID。有了 PID,您就可以寻址进程并与其通信。如果ipc.js在通过 Node 运行后分配的 PID 是123,那么我们可以使用kill命令向该进程发送SIGUSR1信号:

$ kill –s SIGUSR1 123

在 UNIX 中查找给定 Node 进程的 PID 的一个简单方法是在系统进程列表中搜索正在运行的程序名称。如果ipc.js当前正在执行,可以通过在控制台/终端中输入以下命令行来找到其 PID:

使用ps aux | grep ipc.js命令。试试看。

子进程

Node 设计的一个基本部分是在并行执行或扩展系统时创建或分叉进程,而不是创建线程池。我们将在本书中以各种方式使用这些子进程。现在,重点将放在理解如何处理子进程之间的通信事件上。

要创建一个子进程,需要引入 Node 的child_process模块,并调用fork方法。传递新进程应执行的程序文件的名称:

let cp = require("child_process");
let child = cp.fork(__dirname + "/lovechild.js");

您可以使用这种方法保持任意数量的子进程运行。在多核机器上,操作系统将分配分叉出的进程到可用的硬件核心上。将 Node 进程分布到核心上,甚至分布到其他机器上,并管理 IPC 是一种稳定、可理解和可预测的方式来扩展 Node 应用程序。

扩展前面的示例,现在分叉进程(parent)可以发送消息,并监听来自分叉进程(child)的消息。以下是parent.js的代码:

// parent.js
const cp = require("child_process");
let child = cp.fork(__dirname + "/lovechild.js");

child.on("message", (m) => {
  console.log("Child said: ", m); // Parent got a message up from our child
});
child.send("I love you"); // Send a message down to our child

以下是parent.js的输出:

$ node parent.js
Parent said:  I love you
Child said:  I love you too
(then Ctrl+C to terminate both processes)

在那个文件旁边,再创建一个文件,命名为lovechild.js。这里的子代码可以监听消息并将其发送回去:

// lovechild.js
process.on("message", (m) => {
  console.log("Parent said: ", m); // Child got a message down from the parent
  process.send("I love you too"); // Send a message up to our parent
});

不要自己运行lovechild.js--parent.js会为您进行分叉!

运行parent.js应该会分叉出一个子进程并向该子进程发送消息。子进程应该以同样的方式回应:

Parent said:  I love you
Child said:  I love you too

运行parent.js时,请检查您的操作系统任务管理器。与之前的示例不同,这里将有两个 Node 进程,而不是一个。

另一个非常强大的想法是将网络服务器的对象传递给子进程。这种技术允许多个进程,包括父进程,共享服务连接请求的责任,将负载分布到核心上。

例如,以下程序将启动一个网络服务器,分叉一个子进程,并将父进程的服务器引用传递给子进程:

// net-parent.js
const path = require('path');
let child = require("child_process").fork(path.join(__dirname, "net-child.js"));
let server = require("net").createServer();

server.on("connection", (socket) => {
  socket.end("Parent handled connection");
});

server.listen(8080, () => {
  child.send("Parent passing down server", server);
});

除了将消息作为第一个参数发送给子进程之外,前面的代码还将服务器句柄作为第二个参数发送给自己。我们的子服务器现在可以帮助家族的服务业务:

// net-child.js
process.on("message", function(message, server) {
  console.log(message);
  server.on("connection", function(socket) {
    socket.end("Child handled connection");
  });
});

这个子进程应该会在您的控制台上打印出发送的消息,并开始监听连接,共享发送的服务器句柄。

重复连接到localhost:8080的服务器将显示由子进程处理的连接或由父进程处理的连接;两个独立的进程正在平衡服务器负载。当与之前讨论的简单进程间通信协议相结合时,这种技术展示了Ryan Dahl的创作如何成功地提供了构建可扩展网络程序的简单方法。

我们只用了几行代码就连接了两个节点。

我们将讨论 Node 的新集群模块,它扩展并简化了之前在第七章中讨论的技术,使用多个进程。如果您对服务器处理共享感兴趣,请访问集群文档:nodejs.org/dist/latest-v9.x/docs/api/cluster.html

文件事件

大多数应用程序都会对文件系统进行一些操作,特别是那些作为 Web 服务的应用程序。此外,专业的应用程序可能会记录有关使用情况的信息,缓存预渲染的数据视图,或者对文件和目录结构进行其他更改。Node 允许开发人员通过fs.watch方法注册文件事件的通知。watch方法会在文件和目录上广播更改事件。

watch方法按顺序接受三个参数:

  • 正在被监视的文件或目录路径。如果文件不存在,将抛出ENOENT(没有实体)错误,因此建议在某个有用的先前点使用fs.exists

  • 一个可选的选项对象,包括:

  • 持久(默认为 true 的布尔值):Node 会保持进程活动,只要还有事情要做。将此选项设置为false,即使你的代码仍然有一个文件监视器在监视,也会让 Node 关闭进程。

  • 递归(默认为 false 的布尔值):是否自动进入子目录。注意:这在不同平台上的实现不一致。因此,出于性能考虑,你应该明确控制你要监视的文件列表,而不是随意监视目录。

  • 编码(默认为utf8的字符串):传递文件名的字符编码。你可能不需要更改这个。

  • listener函数,接收两个参数:

  • 更改事件的名称(renamechange之一)

  • 已更改的文件名(在监视目录时很重要)

这个例子将在自身上设置一个观察者,更改自己的文件名,然后退出:

const fs = require('fs');
fs.watch(__filename, { persistent: false }, (event, filename) => {
  console.log(event);
  console.log(filename);
})

setImmediate(function() {
  fs.rename(__filename, __filename + '.new', () => {});
});

两行,rename和原始文件的名称,应该已经打印到控制台上。

在任何时候关闭你的观察者通道,你想使用这样的代码:

let w = fs.watch('file', () => {});
w.close();

应该注意,fs.watch在很大程度上取决于主机操作系统如何处理文件事件,Node 文档中也提到了这一点:

“fs.watch API 在各个平台上并不完全一致,并且在某些情况下不可用。”

作者在许多不同的系统上对该模块有非常好的体验,只是在 OS X 实现中回调函数的文件名参数为空。不同的系统也可能强制执行大小写敏感性,无论哪种方式。然而,一定要在你特定的架构上运行测试 —— 信任,但要验证。

或者,使用第三方包!如果你在使用 Node 模块时遇到困难,请检查 npm 是否有替代方案。在这里,作为fs.watch的问题修复包装器,考虑Paul Millerchokidar。它被用作构建系统(如 gulp)的文件监视工具,以及许多其他项目。参考:www.npmjs.com/package/chokidar

延迟执行

有时需要推迟执行一个函数。传统的 JavaScript 使用定时器来实现这一目的,使用众所周知的setTimeoutsetInterval函数。Node 引入了另一种推迟执行的方式,主要是作为控制回调函数在 I/O 事件和定时器事件之间执行顺序的手段。

正如我们之前看到的,管理定时器是 Node 事件循环的主要工作之一。两种延迟事件源,使开发人员能够安排回调函数的执行在排队的 I/O 事件之前或之后,分别是process.nextTicksetImmediate。现在让我们来看看这些。

process.nextTick

作为原生 Node 进程模块的一种方法,process.nextTick类似于熟悉的setTimeout方法,它延迟执行其回调函数直到将来的某个时间点。然而,这种比较并不完全准确;所有请求的nextTick回调函数列表都被放在事件队列的头部,并在当前脚本的执行之后(JavaScript 代码在 V8 线程上同步执行)和 I/O 或定时器事件之前,按顺序处理。

在函数中使用nextTick的主要目的是将结果事件的广播推迟到当前执行堆栈上的监听器在调用者有机会注册事件监听器之前,给当前执行的程序一个机会将回调绑定到EventEmitter.emit事件。

把这看作是一个模式,可以在任何想要创建自己的异步行为的地方使用。例如,想象一个查找系统,可以从缓存中获取,也可以从数据存储中获取新鲜数据。缓存很快,不需要回调,而数据 I/O 调用需要它们。

第二种情况中回调的需求支持对回调行为的模拟,在第一种情况中使用nextTick。这允许一致的 API,提高了实现的清晰度,而不会使开发人员负担起确定是否使用回调的责任。

以下代码似乎设置了一个简单的事务;当EventEmitter的一个实例发出开始事件时,将Started记录到控制台:

const events = require('events');
function getEmitter() {
  let emitter = new events.EventEmitter();
  emitter.emit('start');
  return emitter;
}

let myEmitter = getEmitter();

myEmitter.on("start", () => {
  console.log("Started");
});

然而,你可能期望的结果不会发生!在getEmitter中实例化的事件发射器在返回之前发出start,导致后续分配的监听器出现错误,它到达时已经晚了一步,错过了事件通知。

为了解决这种竞争条件,我们可以使用process.nextTick

const events = require('events');
function getEmitter() {
  let emitter = new events.EventEmitter();
  process.nextTick(() => {
    emitter.emit('start');
  });
  return emitter;
}

let myEmitter = getEmitter();
myEmitter.on('start', () => {
  console.log('Started');
});

这段代码在 Node 给我们start事件之前附加了on("start")处理程序,并且可以正常工作。

错误的代码可能会递归调用nextTick,导致代码无休止地运行。请注意,与在事件循环的单个轮次内对函数进行递归调用不同,这样做不会导致堆栈溢出。相反,它会使事件循环饥饿,使微处理器上的进程繁忙,并可能阻止程序发现 Node 已经完成的 I/O。

setImmediate

setImmediate在技术上是定时器类的成员,与setIntervalsetTimeout一起。但是,它与时间无关——没有毫秒数等待发送参数。

这个方法实际上更像是process.nextTick的一个同级,有一个非常重要的区别:通过nextTick排队的回调将在 I/O 和定时器事件之前执行,而通过setImmediate排队的回调将在 I/O 事件之后调用。

这两种方法的命名令人困惑:Node 实际上会在你传递给setImmediate的函数之前运行你传递给nextTick的函数。

这个方法确实反映了定时器的标准行为,它的调用将返回一个对象,可以传递给clearImmediate,取消你对以后运行函数的请求,就像clearTimeout取消使用setTimeout设置的定时器一样。

定时器

定时器用于安排将来的事件。当需要延迟执行某些代码块直到指定的毫秒数过去时,用于安排特定函数的周期性执行等等时,就会使用它们。

JavaScript 提供了两个异步定时器:setInterval()setTimeout()。假设读者完全了解如何设置(和取消)这些定时器,因此将不会花费太多时间讨论语法。我们将更多地关注定时和间隔的陷阱和不太为人知的细节。

关键要点是:在使用定时器时,不应该对定时器触发注册的回调函数之前实际过去的时间量或回调的顺序做任何假设。Node 定时器不是中断。定时器只是承诺尽可能接近指定的时间执行(但绝不会提前),与其他事件源一样,受事件循环调度的约束。

关于定时器你可能不知道的一件事是-我们都熟悉setTimeout的标准参数:回调函数和超时间隔。你知道传递给callback函数的还有许多其他参数吗?setTimeout(callback, time, [passArg1, passArg2…])

setTimeout

超时可以用来推迟函数的执行,直到未来的某个毫秒数。

考虑以下代码:

setTimeout(a, 1000);
setTimeout(b, 1001);

人们会期望函数b会在函数a之后执行。然而,这并不能保证-a可能在b之后执行,或者反过来。

现在,考虑以下代码片段中存在的微妙差异:

setTimeout(a, 1000);
setTimeout(b, 1000);

在这种情况下,ab的执行顺序是可以预测的。Node 基本上维护一个对象映射,将具有相同超时长度的回调分组。Isaac Schlueter,Node 项目的前任领导,现任 npm Inc.的首席执行官,这样说:

正如我们在groups.google.com/forum/#!msg/nodejs-dev/kiowz4iht4Q/T0RuSwAeJV0J上发现的,“[N]ode 为每个超时值使用单个低级定时器对象。如果为单个超时值附加多个回调,它们将按顺序发生,因为它们位于队列中。但是,如果它们位于不同的超时值上,那么它们将使用不同的线程中的定时器,因此受[CPU]调度程序的影响。”

在相同的执行范围内注册的定时器回调的顺序并不能在所有情况下可预测地决定最终的执行顺序。此外,超时的最小等待时间为一毫秒。传递零、-1 或非数字的值将被转换为这个最小值。

要取消超时,请使用clearTimeout(timerReference)

setInterval

有许多情况可以想象到定期执行函数会很有用。每隔几秒轮询数据源并推送更新是一种常见模式。每隔几毫秒运行动画的下一步是另一种用例,还有收集垃圾。对于这些情况,setInterval是一个很好的工具:

let intervalId = setInterval(() => { ... }, 100);

每隔 100 毫秒,发送的回调函数将执行,这个过程可以使用clearInterval(intervalReference)来取消。

不幸的是,与setTimeout一样,这种行为并不总是可靠的。重要的是,如果系统延迟(比如一些糟糕的写法的阻塞while循环)占据事件循环一段时间,那么在这段时间内设置的间隔将在堆栈上排队等待结果。当事件循环变得不受阻塞并解开时,所有间隔回调将按顺序被触发,基本上是立即触发,失去了它们原本意图的任何时间延迟。

幸运的是,与基于浏览器的 JavaScript 不同,Node 中的间隔通常更加可靠,通常能够在正常使用场景中保持预期的周期性。

unref 和 ref

一个 Node 程序没有理由保持活动状态。只要还有等待处理的回调,进程就会继续运行。一旦这些被清除,Node 进程就没有其他事情可做了,它就会退出。

例如,以下愚蠢的代码片段将使 Node 进程永远运行:

let intervalId = setInterval(() => {}, 1000);

即使设置的回调函数没有任何有用或有趣的内容,它仍然会被调用。这是正确的行为,因为间隔应该一直运行,直到使用clearInterval停止它。

有一些情况下,使用定时器来对外部 I/O、某些数据结构或网络接口进行一些有趣的操作,一旦这些外部事件源停止发生或消失,定时器本身就变得不必要。通常情况下,人们会在程序的其他地方捕获定时器的无关状态,并从那里取消定时器。这可能会变得困难甚至笨拙,因为现在需要不必要地纠缠关注点,增加了复杂性。

unref方法允许开发人员断言以下指令:当这个定时器是事件循环处理的唯一事件源时,继续终止进程。

让我们将这个功能测试到我们之前的愚蠢示例中,这将导致进程终止而不是永远运行:

let intervalId = setInterval(() => {}, 1000);
intervalId.unref();

请注意,unref是启动定时器时返回的不透明值的一个方法,它是一个对象。

现在,让我们添加一个外部事件源,一个定时器。一旦这个外部源被清理(大约 100 毫秒),进程将终止。我们向控制台发送信息来记录发生了什么:

setTimeout(() => {
  console.log("now stop");
}, 100);

let intervalId = setInterval(() => {
  console.log("running")
}, 1);

intervalId.unref();

你可以使用ref将定时器恢复到正常行为,这将撤消unref方法:

let intervalId = setInterval(() => {}, 1000);
intervalId.unref();
intervalId.ref();

列出的进程将继续无限期地进行,就像我们最初的愚蠢示例一样。

快速测验!运行以下代码后,日志消息的预期顺序是什么?

const fs = require('fs');
const EventEmitter = require('events').EventEmitter;
let pos = 0;
let messenger = new EventEmitter();

// Listener for EventEmitter
messenger.on("message", (msg) => {
  console.log(++pos + " MESSAGE: " + msg);
});

// (A) FIRST
console.log(++pos + " FIRST");

//  (B) NEXT
process.nextTick(() => {
  console.log(++pos + " NEXT")
})

// (C) QUICK TIMER
setTimeout(() => {
  console.log(++pos + " QUICK TIMER")
}, 0)

// (D) LONG TIMER
setTimeout(() => {
  console.log(++pos + " LONG TIMER")
}, 10)

// (E) IMMEDIATE
setImmediate(() => {
  console.log(++pos + " IMMEDIATE")
})

// (F) MESSAGE HELLO!
messenger.emit("message", "Hello!");

// (G) FIRST STAT
fs.stat(__filename, () => {
  console.log(++pos + " FIRST STAT");
});

// (H) LAST STAT
fs.stat(__filename, () => {
  console.log(++pos + " LAST STAT");
});

// (I) LAST
console.log(++pos + " LAST");

这个程序的输出是:

FIRST (A).
MESSAGE: Hello! (F).
LAST (I).
NEXT (B).
QUICK TIMER (C).
FIRST STAT (G).
LAST STAT (H).
IMMEDIATE (E).
LONG TIMER (D).

让我们分解上述代码:

A、F 和 I 在主程序流中执行,因此它们将在主线程中具有第一优先级。这是显而易见的;你的 JavaScript 按照它们被编写的顺序执行指令,包括发出回调的同步执行。

主调用堆栈耗尽后,事件循环现在几乎可以开始处理 I/O 操作。这是nextTick请求被执行的时刻,它们排在事件队列的最前面。这时 B 被显示出来。

其余的顺序应该是清楚的。定时器和 I/O 操作将被处理(C、G、H),然后是setImmediate回调的结果(E),始终在执行任何 I/O 和定时器响应之后到达。

最后,长时间超时(D)到达,这是一个相对遥远的未来事件。

请注意,重新排列此程序中的表达式不会改变输出顺序,除了可能重新排列 STAT 结果之外,这只意味着它们以不同的顺序从线程池返回,但仍然作为与事件队列相关的正确顺序的一组。

并发和错误

Node 社区的成员每天都在开发新的包和项目。由于 Node 的事件性质,回调渗透到这些代码库中。我们已经考虑了事件可能如何通过回调排队、分发和处理的关键方式。让我们花点时间概述最佳实践,特别是关于设计回调和处理错误的约定,并讨论在设计复杂的事件和回调链时一些有用的模式。特别是,让我们看看在本书中会看到的新 Promise、Generator 和 async/await 模式,以及现代 Node 代码的其他示例。

并发管理

自从项目开始以来,简化控制流一直是 Node 社区关注的问题。事实上,这种潜在的批评是Ryan Dahl在向 JavaScript 开发者社区介绍 Node 时讨论的第一个预期批评之一。

由于延迟代码执行通常需要在回调中嵌套回调,因此 Node 程序有时会开始类似于侧向金字塔,也被称为“末日金字塔”。你见过吧:深度嵌套的代码,4 层或 5 层甚至更深,到处都是花括号。除了语法上的烦恼,你也可以想象在这样的调用堆栈中跟踪错误可能会很困难——如果第三层的回调抛出异常,谁负责处理这个错误?第二层吗?即使第二层正在读取文件,第三层正在查询数据库?这有意义吗?很难理解异步程序流的含义。

回调

幸运的是,Node 的创建者们早早就就如何构造回调达成了理智的共识。遵循这一传统是很重要的。偏离会带来意外,有时是非常糟糕的意外,总的来说,这样做会自动使 API 变得笨拙,而其他开发人员会迅速厌倦。

一个要么通过执行callback返回函数结果,要么处理callback接收到的参数,要么在 API 中设计callback的签名。无论考虑的是哪种情况,都应该遵循与该情况相关的惯例。

传递给callback函数的第一个参数是任何错误消息,最好是以错误对象的形式。如果不需要报告错误,这个位置应该包含一个空值。

当将callback传递给函数时,它应该被分配到函数签名的最后一个位置。API 应该一贯地按照这种方式设计。

在错误和callback之间可能存在任意数量的参数。

创建错误对象:new Error("Argument must be a String!")

Promises

就像一些政客一样,Node 核心在支持 Promises 之前反对它们。Mikeal Rogers在讨论为什么 Promises 从最初的 Node 核心中被移除时,提出了一个强有力的论点,即将功能开发留给社区会导致更强大的核心产品。你可以在这里查看这个讨论:web.archive.org/posts/broken-promises.html

从那时起,Promises 已经获得了非常庞大的追随者,Node 核心也做出了改变。Promises 本质上是标准回调模式的替代品,而标准回调模式在 Node 中随处可见。曾经,你可能会这样写:

API.getUser(loginInfo, function(err, user) {
    API.getProfile(user, function(err, profile) {
        // ...and so on
    }
});

如果 API 改为"Promisified"(回想一下前一章中的util.promisify?),你对前面的异步控制流的描述将使用 Promise 链来描述:

let promiseProfile = API.getUser(loginInfo)
.then(user => API.getProfile(user))
.then(profile => {
    // do something with #profile
})
.catch(err => console.log(err))

这至少是一个更紧凑的语法,读起来更容易一些,操作的链条更长;然而,这里有更多有价值的东西。

promiseProfile引用了一个 Promise 对象。Promises 只执行一次,达到错误状态(未完成)或完成状态,你可以通过then提取最后的不可变值,就像我们之前对 profile 所做的那样。当然,Promises 可以被分配给一个变量,并且该变量可以传递给尽可能多的消费者,甚至在解决之前。由于then只有在有值可用时才会被调用,无论何时,Promises 都被称为未来状态的承诺。

也许最重要的是,与回调不同,Promises 能够管理许多异步操作中的错误。如果你回头看一下本节开头的示例回调代码,你会看到每个回调中都有err参数,反映了 Node 的核心错误优先回调风格。每个错误对象都必须单独处理,因此前面的代码实际上会开始看起来更像这样:

API.getUser(loginInfo, function(err, user) {
  if(err) {
    throw err;
  }
  API.getProfile(user, function(err, profile) {
    if(err) {
      throw err;
    }
    // ...and so on
  }
});

观察每个错误条件必须单独处理。在实践中,开发人员希望对这段代码进行"手动"包装,比如使用try...catch块,以某种方式捕获这个逻辑单元中的所有错误并以集中的方式进行管理。

使用 Promises,你可以免费获得这些。任何catch语句都会捕获链中之前的任何then抛出的错误。这使得创建一个通用的错误处理程序变得轻而易举。更重要的是,Promises 允许执行链在错误发生后继续。你可以将以下内容添加到前面的 Promise 链中:

.catch(err => console.log(err))
.then(() => // this happens no matter what happened previously)

通过 Promises,你可以在更少的空间中组合相当复杂的异步逻辑流,缩进有限,错误处理更容易处理,值是不可变的且可交换的。

Promise 对象的另一个非常有用的特性是,这些未来解析的状态可以作为一个块来管理。例如,想象一下,为了满足对用户配置文件的查询,你需要进行三次数据库调用。与其总是串行地链式调用这些调用,你可以使用Promise.all

const db = {
  getFullName: Promise.resolve('Jack Spratt'),
  getAddress: Promise.resolve('10 Clean Street'),
  getFavorites: Promise.resolve('Lean'),
};

Promise.all([
  db.getFullName() 
  db.getAddress() 
  db.getFavorites() 
])
.then(results => {
  // results = ['Jack Spratt', '10 Clean Stree', 'Lean']
})
.catch(err => {...})

在这里,所有三个 Promise 将被同时触发,并且将并行运行。并行运行调用当然比串行运行更有效率。此外,Promise.all保证最终的 thenable 接收到一个按照调用者位置同步结果位置排序的结果数组。

你最好熟悉一下完整的 Promise API,你可以在 MDN 上阅读:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise

尽管 Promises 现在是原生的,但仍然存在一个“用户空间”模块,bluebird,它继续提供一个引人注目的替代 Promises 实现,具有附加功能,通常执行速度更快。你可以在这里阅读更多关于 bluebird 的信息:bluebirdjs.com/docs/api-reference.html

async/await

与其用一个专门的数据结构来包装满足条件,比如一个带有许多函数块和括号和特殊上下文的 Promise,为什么不简单地让异步表达式既能实现异步执行,又能实现程序的进一步执行(同步)直到解决?

await操作符用于等待一个 Promise。它只在async函数内部执行。async/await并发建模语法自 Node 8.x 以来就可用。这里演示了async/await被用来复制之前的Promise.all的例子:

const db = {
  getFullName: Promise.resolve('Jack Spratt'),
  getAddress: Promise.resolve('10 Clean Street'),
  getFavorites: Promise.resolve('Lean'),
}

async function profile() {
  let fullName = await db.getFullName() // Jack Spratt
  let address = await db.getAddress() // 10 Clean Street
  let favorites = await db.getFavorites() // Lean

  return {fullName, address, favorites};
}

profile().then(res => console.log(res) // results = ['Jack Spratt', '10 Clean Street', 'Lean'

不错,对吧?你会注意到profile()返回了一个 Promise。一个async函数总是返回一个 Promise,尽管我们在这里看到,函数本身可以返回任何它想要的东西。

Promises 和async/await像老朋友一样合作。这里有一个递归目录遍历器,演示了这种合作:

const {join} = require('path');
const {promisify} = require('util');
const fs = require('fs');
const readdir = promisify(fs.readdir);
const stat = promisify(fs.stat);

async function $readDir (dir, acc = []) {
  await Promise.all((await readdir(dir)).map(async file => {
    file = join(dir, file);
    return (await stat(file)).isDirectory() && acc.push(file) && $readDir(file, acc);
  }));
  return acc;
}

$readDir(`./dummy_filesystem`).then(dirInfo => console.log(dirInfo));

// [ 'dummy_filesystem/folderA',
// 'dummy_filesystem/folderB',
// 'dummy_filesystem/folderA/folderA-C' ]

这个递归目录遍历器的代码非常简洁,只比上面的设置代码稍长一点。由于await期望一个 Promise,而Promise.all将返回一个 Promise,所以通过readDir Promise 返回的每个文件运行,然后将每个文件映射到另一个等待的 Promise,该 Promise 将处理任何递归进入子目录,根据需要更新累加器。这样阅读,Promise.all((await readdir(dir)).map的结构读起来不像一个基本的循环结构,其中深层异步递归以一种简单易懂的过程化、同步的方式进行建模。

一个纯 Promise 的替代版本可能看起来像这样,假设与async/await版本相同的依赖关系:

function $readDir(dir, acc=[]) {
  return readdir(dir).then(files => Promise.all(files.map(file => {
    file = join(dir, file);
    return stat(file).then(fobj => {
      if (fobj.isDirectory()) {
        acc.push(file);
        return $readDir(file, acc);
      }
    });
  }))).then(() => acc);
};

这两个版本都比回调函数更清晰。async/await版本确实兼顾了两者的优点,并创建了一个简洁的表示,类似于同步代码,可能更容易理解和推理。

使用async/await进行错误处理也很容易,因为它不需要任何特殊的新语法。对于 Promises 和catch,同步代码错误存在一个小问题。Promises 捕获发生在then块中的错误。例如,如果你的代码调用的第三方库抛出异常,那么该代码不会被 Promise 包装,而且该错误不会被catch捕获。

使用async/await,你可以使用熟悉的try...catch语句:

async function makeError() {
    try {
        console.log(await thisDoesntExist());
    } catch (error) {
        console.error(error);
    }
}

makeError();

这避免了所有特殊错误捕获结构的问题。这种原生的、非常可靠的方法将捕获try块中任何地方抛出的任何东西,无论执行是同步还是异步。

生成器和迭代器

生成器是可以暂停和恢复的函数执行上下文。当你调用一个普通函数时,它可能会return一个值;函数完全执行,然后终止。生成器函数将产生一个值然后停止,但是生成器的函数上下文不会被销毁(就像普通函数一样)。你可以在以后的时间点重新进入生成器并获取更多的结果。

一个例子可能会有所帮助:

function* threeThings() {
    yield 'one';
    yield 'two';
    yield 'three';
}

let tt = threeThings();

console.log(tt); // {} 
console.log(tt.next()); // { value: 'one', done: false }
console.log(tt.next()); // { value: 'two', done: false }
console.log(tt.next()); // { value: 'three', done: false }
console.log(tt.next()); // { value: undefined, done: true }

通过在生成器上标记一个星号(*)来声明生成器。在第一次调用threeThings时,我们不会得到一个结果,而是得到一个生成器对象。

生成器符合新的 JavaScript 迭代协议(developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#iterator),对于我们的目的来说,这意味着生成器对象公开了一个next方法,该方法用于从生成器中提取尽可能多的值。这种能力来自于生成器实现了 JavaScript 迭代协议。那么,什么是迭代器?

正如developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Iterators_and_Generators所说,

“当对象知道如何一次从集合中访问一个项,并跟踪其在该序列中的当前位置时,它就是一个迭代器。在 JavaScript 中,迭代器是提供了一个 next()方法的对象,该方法返回序列中的下一个项。此方法返回一个具有两个属性的对象:done 和 value。”

我们可以仅使用迭代器来复制生成器示例:

function demoIterator(array) {
  let idx = 0;
  return {
    next: () => {
      return idx < array.length ? {
        value: array[idx++],
        done: false
      } : { done: true };
    }
  };
}
let it = demoIterator(['one', 'two', 'three']);
console.log(it); // { next: [Function: next] }
console.log(it.next()); // { value: 'one', done: false }
console.log(it.next()); // { value: 'two', done: false }
console.log(it.next()); // { value: 'three', done: false }
console.log(it.next()); // { done: true }

你会注意到,结果与生成器示例几乎相同,但在第一个结果中有一个重要的区别:迭代器只是一个具有 next 方法的对象。它必须完成维护自己的内部状态的所有工作(在先前的示例中跟踪idx)。生成器是迭代器的工厂;此外,它们完成了维护和产生自己的状态的所有工作。

从迭代器继承,生成器产生具有两个属性的对象:

  • done:一个布尔值。如果为 true,则生成器表示它没有剩余的内容可以yield。如果你把生成器想象成流(这不是一个坏的类比),那么你可能会将这种模式与流结束时Readable.read()返回 null 的模式进行比较(或者如果你愿意,也可以将其与Readable在完成时推送 null 的方式进行比较)。

  • value:最后一个yield的值。如果done为 true,则应该忽略。

生成器被设计用于迭代上下文,与循环类似,提供了函数执行上下文的强大优势。你可能已经写过类似这样的代码:

function getArraySomehow() {
  // slice into a copy; don't send original
  return ['one','two','buckle','my','shoe'].slice(0); 
}

let state = getArraySomehow();
for(let x=0; x < state.length; x++) {
    console.log(state[x].toUpperCase());
}

这是可以的,但也有缺点,比如需要创建对外部数据提供程序的本地引用,并在此块或函数终止时维护该引用。我们应该将state设置为全局变量吗?它应该是不可变的吗?例如,如果底层数据发生变化,例如向数组添加了一个新元素,我们如何确保state被更新,因为它与我们应用程序的真实状态是断开的?如果有什么意外地覆盖了state会怎么样?数据观察和绑定库存在,设计理论存在,框架存在,可以正确地封装数据源并将不可变版本注入执行上下文;但如果有更好的方法呢?

生成器可以包含和管理自己的数据,并且即使发生变化也可以yield正确的答案。我们可以使用生成器实现先前的代码:

function* liveData(state) {
    let state = ['one','two','buckle','my','shoe'];
    let current;

    while(current = state.shift()) {
        yield current;
    }
}

let list = liveData([]);
let item;
while (item = list.next()) {
    if(!item.value) {
        break;
    }
    console.log('generated:', item.value);
}

生成器方法处理所有发送回值的“样板”,并自然地封装了状态。但在这里似乎没有显著的优势。这是因为我们正在使用生成器执行顺序和立即运行的迭代。生成器实际上是用于承诺一系列值的情况,只有在请求时才生成单个值,随着时间的推移。我们真正想要创建的不是一次性按顺序处理数组,而是创建一个连续的通信过程链,每个过程“tick”都计算一个结果,并能看到先前过程的结果。

考虑以下情况:

function* range(start=1, end=2) {
    do {
        yield start;
    } while(++start <= end)
}

for (let num of range(1, 3)) {
    console.log(num);
}
// 1
// 2
// 3

您可以向生成器传递参数。我们通过传递范围边界来创建一个range状态机,进一步调用该机器将导致内部状态改变,并将当前状态表示返回给调用者。虽然为了演示目的,我们使用了遍历迭代器(因此生成器)的for...of方法,但这种顺序处理(会阻塞主线程直到完成)可以被异步化

生成器的运行/暂停(而不是运行/停止)设计意味着我们可以将迭代看作不是遍历列表,而是捕获一组随时间变化的过渡事件。这个想法对于响应式编程en.wikipedia.org/wiki/Reactive_programming)是核心的。让我们通过另一个例子来思考一下生成器的这种特殊优势。

对于这些类型的数据结构,还有许多其他操作。这样想可能会有所帮助:生成器对未来值的序列就像 Promises 对单个未来值一样。Promises 和生成器都可以在生成时传递(即使有些最终值仍在解析中,或者尚未排队等待解析),一个通过next()接口获取值,另一个通过then()接口获取值。

错误和异常

一般来说,在编程中,术语错误异常经常可以互换使用。在 Node 环境中,这两个概念并不相同。错误和异常是不同的。此外,在 Node 中,错误和异常的定义并不一定与其他语言和开发环境中类似的定义相一致。

在 Node 程序中,错误条件通常是应该被捕获和处理的非致命条件,最明显地体现在典型的 Node 回调模式所显示的错误作为第一个参数约定中。异常是一个严重的错误(系统错误),一个明智的环境不应该忽视或尝试处理。

在 Node 中会遇到四种常见的错误上下文,并且应该有可预测的响应:

  • 同步上下文:这通常发生在函数的上下文中,检测到错误的调用签名或其他非致命错误。函数应该简单地返回一个错误对象;new Error(…),或者其他一致的指示函数调用失败的指示器。

  • 异步上下文:当期望通过触发callback函数来响应时,执行上下文应该传递一个Error对象,并将适当的消息作为该callback的第一个参数。

  • 事件上下文:引用 Node 文档:“当EventEmitter实例遇到错误时,典型的操作是触发一个错误事件。错误事件在 node 中被视为特殊情况。如果没有监听器,那么默认操作是打印堆栈跟踪并退出程序。”在预期的情况下使用事件。

  • Promise 上下文:Promise 抛出或以其他方式被拒绝,并且此错误在.catch块中被捕获。重要提示:您应该始终使用真正的Error对象拒绝 Promises。 Petka Antonov,流行的 Bluebird Promises 实现的作者,讨论了为什么:github.com/petkaantonov/bluebird/blob/master/docs/docs/warning-explanations.md

显然,这些情况是在控制的方式下捕获错误,而不是在整个应用程序不稳定之前。在不过分陷入防御性编码的情况下,应该努力检查输入和其他来源的错误,并妥善处理它们。

始终返回正确的Error对象的另一个好处是可以访问该对象的堆栈属性。错误堆栈显示错误的来源,函数链中的每个链接以及导致错误的函数。典型的Error.stack跟踪看起来像这样:

> console.log(new Error("My Error Message").stack);
 Error: My Error Message
     at Object.<anonymous> (/js/errorstack.js:1:75)
     at Module._compile (module.js:449:26)
     at Object.Module._extensions..js (module.js:467:10)
     ...

同样,堆栈始终可以通过console.trace方法获得:

> console.trace("The Stack Head")
 Trace: The Stack Head
     at Object.<anonymous> (/js/stackhead.js:1:71)
     at Module._compile (module.js:449:26)
     at Object.Module._extensions..js (module.js:467:10)
     ...

应该清楚这些信息如何帮助调试,有助于确保我们应用程序的逻辑流是正确的。

正常的堆栈跟踪在十几个级别后会截断。如果更长的堆栈跟踪对您有用,请尝试Matt Inslerlongjohngithub.com/mattinsler/longjohn

此外,运行并检查您的捆绑包中的js/stacktrace.js文件,以获取有关在报告错误或测试结果时如何使用堆栈信息的一些想法。

异常处理是不同的。异常是意外或致命错误,已经使应用程序不稳定。这些应该小心处理;处于异常状态的系统是不稳定的,未来状态不确定,并且应该优雅地关闭和重新启动。这是明智的做法。

通常,异常在try/catch块中捕获:

try {
  something.that = wontWork;
} catch (thrownError) {
  // do something with the exception we just caught
} 

在代码库中使用try/catch块并尝试预期所有错误可能变得难以管理和笨拙。此外,如果发生您没有预料到的异常,未捕获的异常会怎么样?您如何从上次中断的地方继续?

Node 没有标准内置的方法来处理未捕获的关键异常。这是平台的一个弱点。未捕获的异常将继续通过执行堆栈冒泡,直到它到达事件循环,在那里,就像在机器齿轮中的扳手一样,它将使整个进程崩溃。我们最好的办法是将uncaughtException处理程序附加到进程本身:

process.on('uncaughtException', (err) => {
  console.log('Caught exception: ' + err);
 });

setTimeout(() => {
  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 设计中的这个漏洞,但它已经被弃用。正确处理和报告错误仍然是 Node 平台的一个真正弱点。核心团队正在努力解决这个问题:nodejs.org/en/docs/guides/domain-postmortem/

最近,引入了类似的机制来捕获无法控制的 Promise,当您未将 catch 处理程序附加到 Promise 链时会发生这种情况:

process.on('unhandledRejection', (reason, Prom) => {
  console.log(`Unhandled Rejection: ${p} reason: ${reason}`);
});

unhandledRejection处理程序在 Promise 被拒绝并且在事件循环的一个回合内未附加错误处理程序时触发。

考虑事项

任何开发人员都在经常做出具有深远影响的决定。很难预测从新代码或新设计理论中产生的所有可能后果。因此,保持代码的简单形式并迫使自己始终遵循其他 Node 开发人员的常见做法可能是有用的。以下是一些您可能会发现有用的准则:

  • 通常,尽量追求浅层代码。这种重构在非事件驱动的环境中并不常见。通过定期重新评估入口和出口点以及共享函数来提醒自己。

  • 考虑使用不同的、可组合的微服务来构建你的系统,我们将在第九章中讨论,微服务

  • 在可能的情况下,为callback重新进入提供一个公共上下文。闭包在 JavaScript 中是非常强大的工具,通过扩展,在 Node 中也是如此,只要封闭的回调的上下文帧长度不过大。

  • 给你的函数命名。除了在深度递归结构中非常有用之外,当堆栈跟踪包含不同的函数名称时,调试代码会更容易,而不是匿名函数。

  • 认真考虑优先级。给定结果到达或callback执行的顺序实际上是否重要?更重要的是,它是否与 I/O 操作有关?如果是,考虑使用nextTicksetImmediate

  • 考虑使用有限状态机来管理你的事件。状态机在 JavaScript 代码库中非常少见。当callback重新进入程序流时,它很可能改变了应用程序的状态,而异步调用本身的发出很可能表明状态即将改变。

使用文件事件构建 Twitter 动态

让我们应用所学知识。目标是创建一个服务器,客户端可以连接并从 Twitter 接收更新。我们首先创建一个进程来查询 Twitter 是否有带有#nodejs标签的消息,并将找到的消息以 140 字节的块写入到tweets.txt文件中。然后,我们将创建一个网络服务器,将这些消息广播给单个客户端。这些广播将由tweets.txt文件上的写事件触发。每当发生写操作时,都会从上次已知的客户端读取指针异步读取 140 字节的块。这将一直持续到文件末尾,同时进行广播。最后,我们将创建一个简单的client.html页面,用于请求、接收和显示这些消息。

虽然这个例子显然是刻意安排的,但它展示了:

  • 监听文件系统的更改,并响应这些事件

  • 使用数据流事件来读写文件

  • 响应网络事件

  • 使用超时进行轮询状态

  • 使用 Node 服务器本身作为网络事件广播器

为了处理服务器广播,我们将使用服务器发送事件SSE)协议,这是 HTML5 的一部分,正在标准化的新协议。

首先,我们将创建一个 Node 服务器,监听文件的更改,并将任何新内容广播给客户端。打开编辑器,创建一个名为server.js的文件:

let fs = require("fs");
let http = require('http');

let theUser = null;
let userPos = 0;
let tweetFile = "tweets.txt";

我们将接受一个单个用户连接,其指针将是theUseruserPos将存储此客户端在tweetFile中上次读取的位置:

http.createServer((request, response) => {
  response.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Access-Control-Allow-Origin': '*'
  });

  theUser = response;

  response.write(':' + Array(2049).join(' ') + '\n');
  response.write('retry: 2000\n');

  response.socket.on('close', () => {
    theUser = null;
  });
}).listen(8080);

创建一个监听端口8080的 HTTP 服务器,它将监听并处理单个连接,存储response参数,表示连接服务器和客户端的管道。response参数实现了可写流接口,允许我们向客户端写入消息:

let sendNext = function(fd) {
  let buffer = Buffer.alloc(140);
  fs.read(fd, buffer, 0, 140, userPos * 140, (err, num) => {
    if (!err && num > 0 && theUser) {
      ++userPos;
      theUser.write(`data: ${buffer.toString('utf-8', 0, num)}\n\n`);
      return process.nextTick(() => {
        sendNext(fd);
      });
    }
  });
};

我们创建一个函数来向客户端发送消息。我们将从绑定到我们的tweets.txt文件的可读流中拉取 140 字节的缓冲区,每次读取时将我们的文件位置计数器加一。我们将这个缓冲区写入到将我们的服务器与客户端绑定的可写流中。完成后,我们使用nextTick排队重复调用相同的函数,重复直到出现错误、不再接收数据或客户端断开连接:

function start() {
  fs.open(tweetFile, 'r', (err, fd) => {
    if (err) {
      return setTimeout(start, 1000);
    }
    fs.watch(tweetFile, (event, filename) => {
      if (event === "change") {
        sendNext(fd);
      }
    });
  });
};

start();

最后,我们通过打开tweets.txt文件并监视任何更改来启动这个过程,每当写入新的推文时调用sendNext。当我们启动服务器时,可能还没有存在要读取的文件,因此我们使用setTimeout进行轮询,直到存在一个文件。

现在我们有一个服务器在寻找文件更改以进行广播,我们需要生成数据。我们首先通过npm为 Node 安装TWiT Twitter 包。

然后我们创建一个进程,其唯一工作是向文件写入新数据:

const fs = require("fs");
const Twit = require('twit');

let twit = new Twit({
  consumer_key: 'your key',
  consumer_secret: 'your secret',
  access_token: 'your token',
  access_token_secret: 'your secret token'
});

要使用这个示例,您需要一个 Twitter 开发者帐户。或者,还有一个选项,可以更改相关代码,简单地将随机的 140 字节字符串写入tweets.txt: require("crypto").randomBytes(70).toString('hex'):

let tweetFile = "tweets.txt";
let writeStream = fs.createWriteStream(tweetFile, {
  flags: "a" // indicate that we want to (a)ppend to the file
});

这将建立一个流指针,指向我们的服务器将要监视的同一个文件。

我们将写入这个文件:

let cleanBuffer = function(len) {
  let buf = Buffer.alloc(len);
  buf.fill('\0');
  return buf;
};

因为 Twitter 消息永远不会超过 140 字节,所以我们可以通过始终写入 140 字节的块来简化读/写操作,即使其中一些空间是空的。一旦我们收到更新,我们将创建一个消息数量 x 140 字节宽的缓冲区,并将这些 140 字节的块写入该缓冲区:

let check = function() {
  twit.get('search/tweets', {
    q: '#nodejs since:2013-01-01'
  }, (err, reply) => {
    let buffer = cleanBuffer(reply.statuses.length * 140);
    reply.statuses.forEach((obj, idx) => {
      buffer.write(obj.text, idx*140, 140);
    });
    writeStream.write(buffer);
  })
  setTimeout(check, 10000);
};

check();

现在我们创建一个函数,每 10 秒被要求检查是否包含#nodejs标签的消息。Twitter 返回一个消息对象数组。我们感兴趣的是消息的#text属性。计算表示这些新消息所需的字节数(140 x 消息数量),获取一个干净的缓冲区,并用 140 字节的块填充它,直到所有消息都被写入。最后,这些数据被写入我们的tweets.txt文件,导致发生变化事件,我们的服务器得到通知。

最后一部分是客户端页面本身。这是一个相当简单的页面,它的操作方式应该对读者来说很熟悉。需要注意的是使用 SSE 监听本地主机上端口8080。当从服务器接收到新的推文时,应该清楚地看到一个列表元素被添加到无序列表容器#list中:

<!DOCTYPE html>
<html>
<head>
    <title></title>
</head>

<script>

window.onload = () => {
  let list = document.getElementById("list");
  let evtSource = new EventSource("http://localhost:8080/events");

  evtSource.onmessage = (e) => {
    let newElement = document.createElement("li");
    newElement.innerHTML = e.data;
    list.appendChild(newElement);
  }
}

</script>
<body>

<ul id="list"></ul>

</body>
</html>

要了解更多关于 SSE 的信息,请参阅第六章,创建实时应用程序

或者您可以访问:developer.mozilla.org/en-US/docs/Web/API/Server-sent_events

总结

使用事件进行编程并不总是容易的。控制和上下文切换,定义范式,通常会使新手对事件系统感到困惑。这种看似鲁莽的失控和由此产生的复杂性驱使许多开发人员远离这些想法。入门编程课程的学生通常会形成这样一种心态,即程序流程可以被指示,一个执行流程不是从 A 到 B 顺序进行的程序会使人难以理解。

通过研究架构问题的演变,Node 现在正试图解决网络应用程序的问题——在扩展和代码组织方面,一般数据和复杂性量级方面,状态意识方面,以及明确定义的数据和过程边界方面。我们学会了如何智能地管理这些事件队列。我们看到了不同的事件源如何可预测地堆叠以供事件循环处理,以及远期事件如何使用闭包和智能回调排序进入和重新进入上下文。我们还了解了新的 Promise、Generator 和 async/await 结构,旨在帮助管理并发。

现在我们对 Node 的设计和特性有了基本的领域理解,特别是使用它进行事件编程的方式。现在让我们转向更大、更高级的应用程序知识。

第三章:在节点和客户端之间流式传输数据

“壶口滴水成河。”

  • 佛陀

我们现在更清楚地了解了 Node 的事件驱动、I/O 集中的设计理念如何在其各种模块 API 中体现,为开发提供了一致和可预测的环境。

在本章中,我们将发现如何使用 Node 从文件或其他来源中提取数据,然后使用 Node 进行读取、写入和操作,就像使用 Node 一样容易。最终,我们将学习如何使用 Node 开发具有快速 I/O 接口的网络服务器,支持高并发应用程序,同时在成千上万的客户端之间共享实时数据。

为什么使用流?

面对一个新的语言特性、设计模式或软件模块,一个新手开发者可能会开始使用它,因为它是新的和花哨的。另一方面,一个有经验的开发者可能会问,为什么需要这个?

文件很大,所以需要流。一些简单的例子可以证明它们的必要性。首先,假设我们想要复制一个文件。在 Node 中,一个天真的实现看起来像这样:

// First attempt
console.log('Copying...');
let block = fs.readFileSync("source.bin");
console.log('Size: ' + block.length);
fs.writeFileSync("destination.bin", block);
console.log('Done.');

这非常简单。

调用readFileSync()时,Node 会将source.bin的内容(一个与脚本相同文件夹中的文件)复制到内存中,返回一个名为blockByteBuffer

一旦我们有了block,我们可以检查并打印出它的大小。然后,代码将block交给writeFileSync,它将内存块复制到一个新创建或覆盖的文件destination.bin的内容中。

这段代码假设以下事情:

  • 阻塞事件循环是可以的(不是!)

  • 我们可以将整个文件读入内存(我们不能!)

正如你在上一章中所记得的,Node 会一个接一个地处理事件,一次处理一个事件。良好的异步设计使得 Node 程序看起来好像同时做了各种事情,既对连接的软件系统又对人类用户来说,同时还为代码中的开发者提供了一个易于理解和抵抗错误的逻辑呈现。这一点尤为真实,尤其是与可能编写来解决相同任务的多线程代码相比。你的团队甚至可能已经转向 Node,以制作一个改进的替代品来解决这样一个经典的多线程系统。此外,良好的异步设计永远不会阻塞事件循环。

阻塞事件循环是不好的,因为 Node 无法做其他事情,而你的一个阻塞代码行正在阻塞。前面的例子,作为一个简单的脚本,从一个地方复制文件到另一个地方,可能运行得很好。它会在 Node 复制文件时阻塞用户的终端。文件可能很小,等待的时间很短。如果不是,你可以在等待时打开另一个 shell 提示符。这样,它与cpcurl等熟悉的命令并没有什么不同。

然而,从计算机的角度来看,这是相当低效的。每个文件复制不应该需要自己的操作系统进程。

此外,将之前的代码合并到一个更大的 Node 项目中可能会使整个系统不稳定。

你的服务器端 Node 应用程序可能同时让三个用户登录,同时向另外两个用户发送大文件。如果该应用程序执行之前的代码,两个下载将会停滞,三个浏览器会一直旋转。

所以,让我们一步一步地来修复这个问题:

// Attempt the second
console.log('Copying...');
fs.readFile('source.bin', null, (error1, block) => {
  if (error1) {
    throw error1;
  }
  console.log('Size: ' + block.length);
  fs.writeFile('destination.bin', block, (error2) => {
    if (error2) {
      throw error2;
    }
    console.log('Done.');
  });
});

至少现在我们不再使用在它们标题中带有Sync的 Node 方法。事件循环可以再次自由呼吸。

但是:

  • 大文件怎么办?(大爆炸)

  • 你那里有一个相当大的金字塔(厄运)

尝试使用一个 2GB(2.0 x 2³⁰,或 2,147,483,648 字节)的源文件来运行之前的代码:

RangeError: "size" argument must not be larger than 2147483647
 at Function.Buffer.allocUnsafe (buffer.js:209:3)
 at tryCreateBuffer (fs.js:530:21)
 at Object.fs.readFile (fs.js:569:14)
 ...

如果你在 YouTube 上以 1080p 观看视频,2GB 的流量大约可以让你看一个小时。之前的RangeError发生是因为2,147,483,647在二进制中是1111111111111111111111111111111,是最大的 32 位有符号二进制整数。Node 在内部使用这种类型来调整和寻址ByteBuffer的内容。

如果你交给我们可怜的例子会发生什么?更小,但仍然非常大的文件是不确定的。当它工作时,是因为 Node 成功地从操作系统获取了所需的内存。在复制操作期间,Node 进程的内存占用量会随着文件大小而增加。鼠标可能会变成沙漏,风扇可能会嘈杂地旋转起来。承诺会有所帮助吗?:

// Attempt, part III
console.log('Copying...');
fs.readFileAsync('source.bin').then((block) => {
  console.log('Size: ' + block.length);
  return fs.writeFileAsync('destination.bin', block);
}).then(() => {
 console.log('Done.');
}).catch((e) => {
  // handle errors
});

不,本质上不是。我们已经扁平化了金字塔,但大小限制和内存问题仍然存在。

我们真正需要的是一些既是异步的,又是逐步的代码,从源文件中获取一小部分,将其传送到目标文件进行写入,并重复该循环,直到完成,就像古老的灭火队一样。

这样的设计会让事件循环在整个时间内自由呼吸。

这正是流的作用:

// Streams to the rescue
console.log('Copying...');
fs.createReadStream('source.bin')
.pipe(fs.createWriteStream('destination.bin'))
.on('close', () => { console.log('Done.'); });

在实践中,规模化的网络应用通常分布在许多实例中,需要将数据流的处理分布到许多进程和服务器中。在这里,流文件只是一个数据流,被分成片段,每个片段可以独立查看,而不受其他片段的可用性的影响。你可以写入数据流,或者监听数据流,自由动态分配字节,忽略字节,重新路由字节。数据流可以被分块,许多进程可以共享块处理,块可以被转换和重新插入,数据流可以被精确发射和创造性地管理。

回顾我们在现代软件和模块化规则上的讨论,我们可以看到流如何促进独立的共享无事务的进程的创建,这些进程各自完成一项任务,并且组合起来可以构成一个可预测的架构,其复杂性不会妨碍对其行为的准确评估。如果数据接口是无争议的,那么数据映射可以准确建模,而不考虑数据量或路由的考虑。

在 Node 中管理 I/O 涉及管理绑定到数据流的数据事件。Node Stream 对象是EventEmitter的一个实例。这个抽象接口在许多 Node 模块和对象中实现,正如我们在上一章中看到的那样。让我们首先了解 Node 的 Stream 模块,然后讨论 Node 中如何通过各种流实现处理网络 I/O;特别是 HTTP 模块。

探索流

根据 Bjarne Stoustrup 在他的书《C++程序设计语言》(第三版)中的说法:

“为编程语言设计和实现通用的输入/输出设施是非常困难的... I/O 设施应该易于使用、方便、安全;高效、灵活;最重要的是完整。”

让人不惊讶的是,一个专注于提供高效和简单 I/O 的设计团队,通过 Node 提供了这样一个设施。通过一个对称和简单的接口,处理数据缓冲区和流事件,使实现者不必关心,Node 的 Stream 模块是管理内部模块和模块开发人员异步数据流的首选方式。

在 Node 中,流只是一系列字节。在任何时候,流都包含一个字节缓冲区,这个缓冲区的长度为零或更大:

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

Node 还提供了第二种类型的流:对象流。对象流不是通过流动内存块,而是通过 JavaScript 对象传输。字节流传输序列化数据,如流媒体,而对象流适用于解析的结构化数据,如 JSON 记录。

数字流可以用流体的类比来描述,其中个别字节(水滴)被推送通过管道。在 Node 中,流是表示可以异步写入和读取的数据流的对象。

Node 的哲学是非阻塞流,I/O 通过流处理,因此 Stream API 的设计自然地复制了这一一般哲学。事实上,除了以异步、事件方式与流交互外,没有其他方式——Node 通过设计阻止开发人员阻塞 I/O。

通过抽象流接口暴露了五个不同的基类:ReadableWritableDuplexTransformPassThrough。每个基类都继承自EventEmitter,我们知道它是一个可以绑定事件监听器和发射器的接口。

正如我们将要学习的,并且在这里强调的,流接口是一个抽象接口。抽象接口充当一种蓝图或定义,描述了必须构建到每个构造的流对象实例中的特性。例如,可读流实现需要实现一个public read方法,该方法委托给接口的internal _read方法。

一般来说,所有流实现都应遵循以下准则:

  • 只要存在要发送的数据,就向流写入,直到该操作返回false,此时实现应等待drain事件,表示缓冲的流数据已经清空。

  • 继续调用读取,直到收到null值,此时等待可读事件再恢复读取。

  • 几个 Node I/O 模块都是以流的形式实现的。网络套接字、文件读取器和写入器、stdinstdout、zlib 等都是流。同样,当实现可读数据源或数据读取器时,应该将该接口实现为流接口。

重要的是要注意,在 Node 的历史上,Stream 接口在某些根本性方面发生了变化。Node 团队已尽最大努力实现兼容的接口,以便(大多数)旧程序可以继续正常运行而无需修改。在本章中,我们不会花时间讨论旧 API 的具体特性,而是专注于当前的设计。鼓励读者查阅 Node 的在线文档,了解迁移旧程序的信息。通常情况下,有一些模块会用方便、可靠的接口包装流。一个很好的例子是:github.com/rvagg/through2.

实现可读流

产生数据的流,另一个进程可能感兴趣的,通常使用Readable流来实现。Readable流保存了实现者管理读取队列、处理数据事件的发射等所有工作。

要创建一个Readable流,请使用以下方法:

const stream = require('stream');
let readable = new stream.Readable({
  encoding: "utf8",
  highWaterMark: 16000,
  objectMode: true
});

如前所述,Readable作为一个基类暴露出来,可以通过三种选项进行初始化:

  • encoding:将缓冲区解码为指定的编码,默认为 UTF-8。

  • highWaterMark:在停止从数据源读取之前,保留在内部缓冲区中的字节数。默认为 16 KB。

  • objectMode:告诉流以对象流而不是字节流的方式运行,例如以 JSON 对象流而不是文件中的字节流。默认为false

在下面的示例中,我们创建一个模拟的Feed对象,其实例将继承Readable流接口。我们的实现只需要实现Readable的抽象_read方法,该方法将向消费者推送数据,直到没有更多数据可以推送为止,然后通过推送null值来触发Readable流发出一个end事件:

const stream = require('stream');

let Feed = function(channel) {
   let readable = new stream.Readable({});
   let news = [
      "Big Win!",
      "Stocks Down!",
      "Actor Sad!"
   ];
   readable._read = () => {
      if(news.length) {
         return readable.push(news.shift() + "\n");
      }
      readable.push(null);
   };
   return readable;
};

现在我们有了一个实现,消费者可能希望实例化流并监听流事件。两个关键事件是readableend

只要数据被推送到流中,readable事件就会被触发。它会提醒消费者通过Readableread方法检查新数据。

再次注意,Readable实现必须提供一个private _read方法,为消费者 API 公开的public read方法提供服务。

当我们向Readable实现的push方法传递null值时,end事件将被触发。

在这里,我们看到一个消费者使用这些方法来显示新的流数据,并在流停止发送数据时提供通知:

let feed = new Feed();

feed.on("readable", () => {
   let data = feed.read();
   data && process.stdout.write(data);
});
feed.on("end", () => console.log("No more news"));
// Big Win!
// Stocks Down!
// Actor Sad!
// No more news

同样,我们可以通过使用objectMode选项来实现对象流:

const stream = require('stream');

let Feed = function(channel) {
   let readable = new stream.Readable({
      objectMode : true
   });
   let prices = [{price : 1},{price : 2}];
   readable._read = () => {
      if(prices.length) {
         return readable.push(prices.shift());
      }
      readable.push(null);
   };
   return readable;
};

在设置为 objectMode 后,每个推送的块都预期是一个对象。因此,该流的读取器可以假定每个read()事件将产生一个单独的对象:

let feed = new Feed();
feed.on("readable", () => {
   let data = feed.read();
   data && console.log(data);
});
feed.on("end", () => console.log("No more news"));
// { price: 1 }
// { price: 2 }
// No more news

在这里,我们看到每个读取事件都接收一个对象,而不是缓冲区或字符串。

最后,Readable流的read方法可以传递一个参数,指示从流的内部缓冲区中读取的字节数。例如,如果希望逐字节读取文件,可以使用类似于以下的例程来实现消费者:

let Feed = function(channel) {
   let readable = new stream.Readable({});
   let news = 'A long headline might go here';
   readable._read = () => {
      readable.push(news);
      readable.push(null);
   };
   return readable;
};

请注意,我们将整个新闻推送到流中,并以 null 终止。流已经准备好了整个字节字符串。现在消费者:

feed.on('readable', () => {
   let character;
   while(character = feed.read(1)) {
      console.log(character.toString());
   }
});
// A
// 
// l
// o
// n
// ...
// No more bytes to read

在这里,应该清楚的是Readable流的缓冲区一次性填满了许多字节,但是却是离散地读取。

推送和拉取

我们已经看到Readable实现将使用push方法来填充用于读取的流缓冲区。在设计这些实现时,重要的是考虑如何管理流的两端的数据量。向流中推送更多数据可能会导致超出可用空间(内存)的复杂情况。在消费者端,重要的是要保持对终止事件的意识,以及如何处理数据流中的暂停。

我们可以将通过网络传输的数据流的行为与水流经过软管进行比较。

与水流经过软管一样,如果向读取流中推送的数据量大于消费者端通过read方法有效排出的数据量,就会产生大量背压,导致数据在流对象的缓冲区中开始积累。由于我们正在处理严格的数学限制,read方法根本无法通过更快地读取来释放这种压力——可用内存空间可能存在硬性限制,或者其他限制。因此,内存使用可能会危险地增加,缓冲区可能会溢出,等等。

因此,流实现应该意识到并响应push操作的响应。如果操作返回false,这表明实现应该停止从其源读取(并停止推送),直到下一个_read请求被发出。

与上述内容相结合,如果没有更多数据可以推送,但将来预期会有更多数据,实现应该push一个空字符串(""),这不会向队列中添加任何数据,但确保将来会触发一个readable事件。

虽然流缓冲区最常见的处理方式是向其push(将数据排队),但有时您可能希望将数据放在缓冲区的前面(跳过队列)。对于这些情况,Node 提供了一个unshift操作,其行为与push相同,除了在缓冲区放置数据的差异之外。

可写流

Writable流负责接受某个值(一系列字节,一个字符串)并将数据写入目标。将数据流入文件容器是一个常见的用例。

创建Writable流:

const stream = require('stream');
let readable = new stream.Writable({
  highWaterMark: 16000,
  decodeStrings: true
});

Writable流构造函数可以用两个选项实例化:

  • highWaterMark:在写入时流缓冲区将接受的最大字节数。默认值为 16 KB。

  • decodeStrings:是否在写入之前将字符串转换为缓冲区。默认为true

Readable流一样,自定义的Writable流实现必须实现_write处理程序,该处理程序将接收发送给实例的write方法的参数。

你应该将Writable流视为一个数据目标,比如你正在上传的文件。在概念上,这与Readable流中push的实现类似,其中一个推送数据直到数据源耗尽,并传递null来终止读取。例如,在这里,我们向流写入了 32 个“A”字符,它将把它们记录下来:

const stream = require('stream');

let writable = new stream.Writable({
   decodeStrings: false
});

writable._write = (chunk, encoding, callback) => {
   console.log(chunk.toString());
   callback();
};

let written = writable.write(Buffer.alloc(32, 'A'));
writable.end();

console.log(written);

// AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
// true

这里有两个关键点需要注意。

首先,我们的_write实现在写入回调后立即触发callback函数,这个回调函数始终存在,无论实例的write方法是否直接传递了callback。这个调用对于指示写入尝试的状态(失败或成功)非常重要。

其次,调用 write 返回了true。这表明在执行请求的写操作后,Writable实现的内部缓冲区已经被清空。如果我们发送了大量数据,足以超过内部缓冲区的默认大小,会怎么样呢?

修改前面的例子,以下将返回false

let written = writable.write(Buffer.alloc(16384, 'A'));
console.log(written); // Will be 'false'

write返回false的原因是它已经达到了highWaterMark选项的默认值 16 KB(16 * 1,024)。如果我们将这个值改为16383write将再次返回true(或者可以简单地增加它的值)。

write返回false时,你应该怎么做?你肯定不应该继续发送数据!回到我们水管的比喻:当流满时,应该等待它排空后再发送更多数据。Node 的流实现会在安全写入时发出drain事件。当write返回false时,在发送更多数据之前监听drain事件。

综合我们所学到的知识,让我们创建一个highWaterMark值为 10 字节的Writable流。然后设置一个模拟,我们将推送一个大于highWaterMark的数据字符串到stdout,然后等待缓冲区溢出并在发送更多数据之前等待drain事件触发:

const stream = require('stream');

let writable = new stream.Writable({
   highWaterMark: 10
});

writable._write = (chunk, encoding, callback) => {
   process.stdout.write(chunk);
   callback();
};

function writeData(iterations, writer, data, encoding, cb) {
   (function write() {

      if(!iterations--) {
         return cb()
      }

      if (!writer.write(data, encoding)) {
         console.log(` <wait> highWaterMark of ${writable.writableHighWaterMark} reached`);
         writer.once('drain', write);
      }
   })()
}

writeData(4, writable, 'String longer than highWaterMark', 'utf8', () => console.log('finished'));

每次写入时,我们都会检查流写入操作是否返回 false,如果是,我们会在再次运行我们的write方法之前等待下一个drain事件。

你应该小心实现正确的流管理,尊重写事件发出的“警告”,并在发送更多数据之前正确等待drain事件的发生。

Readable 流中的流体数据可以很容易地重定向到 Writable 流。例如,以下代码将接收终端发送的任何数据(stdin 是一个 Readable 流)并将其回显到目标 Writable 流(stdout):process.stdin.pipe(process.stdout)。当将 Writable 流传递给 Readable 流的 pipe 方法时,将触发 pipe 事件。类似地,当将 Writable 流从 Readable 流的目标中移除时,将触发 unpipe 事件。要移除 pipe,使用以下方法:unpipe(destination stream)

双工流

双工流 既可读又可写。例如,在 Node 中创建的 TCP 服务器公开了一个既可读又可写的套接字:

const stream = require("stream");
const net = require("net");

net.createServer(socket => {
  socket.write("Go ahead and type something!");
  socket.setEncoding("utf8");
  socket.on("readable", function() {
    process.stdout.write(this.read())
  });
})
.listen(8080);

执行时,此代码将创建一个可以通过 Telnet 连接的 TCP 服务器:

telnet 127.0.0.1 8080

在一个终端窗口中启动服务器,打开一个单独的终端,并通过 telnet 连接到服务器。连接后,连接的终端将打印出 Go ahead and type something! ——写入套接字。在连接的终端中输入任何文本(按下 ENTER 后)将被回显到运行 TCP 服务器的终端的 stdout(从套接字读取),创建一种聊天应用程序。

这种双向(双工)通信协议的实现清楚地展示了独立进程如何形成复杂和响应灵敏的应用程序的节点,无论是在网络上通信还是在单个进程范围内通信。

构造 Duplex 实例时发送的选项将合并发送到 ReadableWritable 流的选项,没有额外的参数。实际上,这种流类型简单地承担了两种角色,并且与其交互的规则遵循所使用的交互模式的规则。

Duplex 流假定了读和写两种角色,任何实现都需要实现 ­_write_read 方法,再次遵循相关流类型的标准实现细节。

转换流

有时需要处理流数据,通常在写入某种二进制协议或其他 即时 数据转换的情况下。Transform 流就是为此目的而设计的,它作为一个位于 Readable 流和 Writable 流之间的 Duplex 流。

使用与初始化典型 Duplex 流相同的选项初始化 Transform 流,Transform 与普通的 Duplex 流的不同之处在于其要求自定义实现仅提供 _transform 方法,而不需要 _write_read 方法。

_transform 方法将接收三个参数,首先是发送的缓冲区,然后是一个可选的编码参数,最后是一个回调函数,_transform 期望在转换完成时调用。

_transform = function(buffer, encoding, cb) {
  let transformation = "...";
  this.push(transformation);
  cb();
};

让我们想象一个程序,它可以将 ASCII(美国信息交换标准代码) 代码转换为 ASCII 字符,从 stdin 接收输入。您输入一个 ASCII 代码,程序将以对应该代码的字母数字字符作出响应。在这里,我们可以简单地将输入传输到 Transform 流,然后将其输出传输回 stdout

const stream = require('stream');
let converter = new stream.Transform();

converter._transform = function(num, encoding, cb) {
   this.push(String.fromCharCode(new Number(num)) + "\n");
   cb();
};

process.stdin.pipe(converter).pipe(process.stdout);

与此程序交互可能会产生类似以下的输出:

65 A
66 B
256 Ā
257 ā

在本章结束时,将演示一个更复杂的转换流示例。

使用 PassThrough 流

这种流是 Transform 流的一个简单实现,它只是将接收到的输入字节传递到输出流。如果不需要对输入数据进行任何转换,只是想要轻松地将 Readable 流传输到 Writable 流,这是很有用的。

PassThrough流具有类似于 JavaScript 的匿名函数的好处,使得可以轻松地断言最小的功能而不需要太多的麻烦。例如,不需要实现一个抽象基类,就像对Readable流的_read方法所做的那样。考虑以下使用PassThrough流作为事件间谍的用法:

const fs = require('fs');
const stream = require('stream');
const spy = new stream.PassThrough();

spy
.on('error', (err) => console.error(err))
.on('data', function(chunk) {
    console.log(`spied data -> ${chunk}`);
})
.on('end', () => console.log('\nfinished'));

fs.createReadStream('./passthrough.txt').pipe(spy).pipe(process.stdout);

通常,Transform 或 Duplex 流是你想要的(在这里你可以设置_read_write的正确实现),但在某些情况下,比如测试中,可以将“观察者”放在流上是有用的。

创建一个 HTTP 服务器

HTTP 是建立在请求/响应模型之上的无状态数据传输协议:客户端向服务器发出请求,服务器然后返回响应。由于促进这种快速模式的网络通信是 Node 设计的出色之处,Node 作为一个用于创建服务器的工具包获得了早期广泛的关注,尽管它当然也可以用于做更多的事情。在本书中,我们将创建许多 HTTP 服务器的实现,以及其他协议服务器,并将在更深入的上下文中讨论最佳实践,这些上下文是特定的业务案例。预期你已经有一些类似的经验。出于这两个原因,我们将快速地从一般概述中进入一些更专业的用途。

在最简单的情况下,HTTP 服务器会响应连接尝试,并在数据到达和发送时进行管理。通常使用http模块的createServer方法创建一个 Node 服务器:

const http = require('http');
let server = http.createServer((request, response) => {
   response.writeHead(200, { 
      'Content-Type': 'text/plain'
   });
   response.write("PONG");
   response.end();
}).listen(8080);

server.on("request", (request, response) => {
   request.setEncoding("utf8");
   request.on("readable", () => console.log(request.read()));
   request.on("end", () => console.log("DONE"));
});

http.createServer返回的对象是http.Server的一个实例,它扩展了EventEmitter,在网络事件发生时广播,比如客户端连接或请求。前面的代码是编写 Node 服务器的常见方式。然而,值得指出的是,直接实例化http.Server类有时是区分不同服务器/客户端交互的一种有用方式。我们将在接下来的示例中使用这种格式。

在这里,我们创建一个基本的服务器,它只是在连接建立时报告,并在连接终止时报告:

const http = require('http');
const server = new http.Server();
server.on('connection', socket => {
   let now = new Date();
   console.log(`Client arrived: ${now}`);
   socket.on('end', () => console.log(`client left: ${new Date()}`));
});
// Connections get 2 seconds before being terminated
server.setTimeout(2000, socket => socket.end());
server.listen(8080);

在构建多用户系统时,特别是经过身份验证的多用户系统,服务器-客户端事务的这一点是客户端验证和跟踪代码的绝佳位置,包括设置或读取 cookie 和其他会话变量,或向在并发实时应用程序中共同工作的其他客户端广播客户端到达事件。

通过添加一个请求的监听器,我们可以得到更常见的请求/响应模式,作为一个Readable流进行处理。当客户端 POST 一些数据时,我们可以像下面这样捕获这些数据:

server.on('request', (request, response) => {
   request.setEncoding('utf8');
   request.on('readable', () => {
      let data = request.read();
      data && response.end(data);
   });
});

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

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

通过使用连接事件,我们可以很好地将我们的连接处理代码分开,将其分组到清晰定义的功能域中,正确地描述为响应特定事件执行的功能域。在上面的示例中,我们看到了如何设置一个定时器,在两秒后启动服务器连接。

如果只是想设置在套接字被假定超时之前的不活动毫秒数,只需使用server.timeout = (Integer)num_milliseconds。要禁用套接字超时,请传递一个值0(零)。

现在让我们看看 Node 的 HTTP 模块如何用于进入更有趣的网络交互。

发出 HTTP 请求

网络应用程序通常需要进行外部 HTTP 调用。HTTP 服务器也经常被要求为向其发出请求的客户端执行 HTTP 服务。Node 提供了一个简单的接口来进行外部 HTTP 调用。

例如,以下代码将获取www.example.org的 HTML 首页:

const http = require('http');
http.request({ 
   host: 'www.example.org',
   method: 'GET',
   path: "/"
}, function(response) {
   response.setEncoding("utf8");
   response.on("readable", () => console.log(response.read()));
}).end();

正如我们所看到的,我们正在使用一个Readable流,可以写入文件。

管理 HTTP 请求的一个流行的 Node 模块是 Mikeal Roger 的 request:github.com/request/request

因为通常使用HTTP.requestGET外部页面,Node 提供了一个快捷方式:

http.get("http://www.example.org/", response => {
  console.log(`Status: ${response.statusCode}`);
}).on('error', err => {
  console.log("Error: " + err.message);
});

现在让我们看一些更高级的 HTTP 服务器实现,其中我们为客户端执行一般的网络服务。

代理和隧道

有时,为一个服务器提供作为代理或经纪人的功能对其他服务器很有用。这将允许一个服务器将负载分发给其他服务器,例如。另一个用途是为无法直接连接到该服务器的用户提供对安全服务器的访问。一个服务器为多个 URL 提供答复是很常见的——使用代理,一个服务器可以将请求转发给正确的接收者。

由于 Node 在其网络接口中具有一致的流接口,我们可以用几行代码构建一个简单的 HTTP 代理。例如,以下程序将在端口8080上设置一个 HTTP 服务器,该服务器将通过获取网站的首页并将该页面传送回客户端来响应任何请求:

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

server.on("request", (request, socket) => {
   console.log(request.url);
   http.request({ 
      host: 'www.example.org',
      method: 'GET',
      path: "/",
      port: 80
   }, response => response.pipe(socket))
   .end();
});

server.listen(8080, () => console.log('Proxy server listening on localhost:8080'));

继续启动这个服务器,并连接到它。一旦这个服务器接收到客户端套接字,它就可以自由地从任何可读流中向客户端推送内容,这里,www.example.orgGET结果被流式传输。一个外部内容服务器管理应用程序的缓存层可能成为代理端点的例子。

使用类似的想法,我们可以使用 Node 的原生CONNECT支持创建一个隧道服务。隧道涉及使用代理服务器作为客户端的中间人与远程服务器进行通信。一旦我们的代理服务器连接到远程服务器,它就能在该服务器和客户端之间来回传递消息。当客户端和远程服务器之间无法直接建立连接或不希望建立连接时,这是有利的。

首先,我们将设置一个代理服务器来响应HTTP CONNECT请求,然后向该服务器发出CONNECT请求。代理接收我们客户端的Request对象,客户端的套接字本身,以及隧道流的头部(第一个数据包):

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

proxy.on('connect', (request, clientSocket, head) => {
  let reqData = url.parse(`http://${request.url}`);
  let remoteSocket = net.connect(reqData.port, reqData.hostname, () => {
    clientSocket.write('HTTP/1.1 200 \r\n\r\n');
    remoteSocket.write(head);
    remoteSocket.pipe(clientSocket);
    clientSocket.pipe(remoteSocket);
   });
}).listen(8080);

let request = http.request({
  port: 8080,
  hostname: 'localhost',
  method: 'CONNECT',
  path: 'www.example.org:80'
});
request.end();

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

一旦我们向运行在端口 8080 上的本地隧道服务器发出请求,它将建立与目的地的远程套接字连接,并保持这个远程套接字和(本地)客户端套接字之间的“桥梁”。远程连接当然只看到我们的隧道服务器,这样客户端可以以某种匿名的方式连接到远程服务(这并不总是一种不正当的做法!)。

HTTPS、TLS(SSL)和保护您的服务器

Web 应用程序的安全性近年来已成为一个重要的讨论话题。传统应用程序通常受益于主要部署基础的主要服务器和应用程序堆栈中设计成熟的安全模型。出于某种原因,Web 应用程序被允许进入客户端业务逻辑的实验世界,并由一层薄薄的帷幕保护着开放的 Web 服务。

由于 Node 经常部署为 Web 服务器,社区有责任开始确保这些服务器的安全。HTTPS 是一种安全的传输协议——本质上是通过在 SSL/TLS 协议之上叠加 HTTP 协议而形成的加密 HTTP。

为开发创建自签名证书

为了支持 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 服务器。这些文件的内容只需作为选项传递给 Node 服务器即可:

const https = require('https');
const fs = require('fs');
https.createServer({
  key: fs.readFileSync('server-key.pem'),
  cert: fs.readFileSync('server-cert.pem')
}, (req, res) => {
  ...
}).listen(443);

在开发过程中,可以从www.startssl.com/获得免费的低保障 SSL 证书,这是自签名证书不理想的情况。此外,www.letsencrypt.org已经开始了一个激动人心的倡议,为所有人提供免费证书(更安全的网络)。

安装真正的 SSL 证书

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

设置专业证书只需要更改我们之前介绍的 HTTPS 选项。不同的提供商将有不同的流程和文件名。通常,您需要从提供商那里下载或以其他方式接收private .key文件,已签名的域证书.crt文件,以及描述证书链的捆绑文件:

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

重要的是要注意,ca参数必须作为数组发送,即使证书的捆绑已经连接成一个文件。

请求对象

HTTP 请求和响应消息是相似的,包括以下内容:

  • 状态行,对于请求来说,类似于 GET/index.html HTTP/1.1,对于响应来说,类似于 HTTP/1.1 200 OK

  • 零个或多个头部,对于请求可能包括Accept-Charset: UTF-8 或 From: user@server.com,对于响应可能类似于Content-Type: text/html 和 Content-Length: 1024

  • 消息正文,对于响应可能是一个 HTML 页面,对于POST请求可能是一些表单数据

我们已经看到了 Node 中 HTTP 服务器接口预期暴露一个请求处理程序,以及这个处理程序将被传递一些形式的请求和响应对象,每个对象都实现了可读或可写流。

我们将在本章后面更深入地讨论POST数据和Header数据的处理。在此之前,让我们先了解如何解析请求中包含的一些更直接的信息。

URL 模块

每当向 HTTP 服务器发出请求时,请求对象将包含 URL 属性,标识目标资源。这可以通过request.url访问。Node 的 URL 模块用于将典型的 URL 字符串分解为其组成部分。请参考以下图示:

我们看到url.parse方法是如何分解字符串的,每个部分的含义应该是清楚的。也许很明显,如果query字段本身被解析为键/值对会更有用。这可以通过将true作为parse方法的第二个参数来实现,这将把上面给出的查询字段值更改为更有用的键/值映射:

query: { filter: 'sports', maxresults: '20' }

这在解析 GET 请求时特别有用。url.parse还有一个与这两个 URL 之间的差异有关的最后一个参数:

  • http://www.example.org

  • //www.example.org

这里的第二个 URL 是 HTTP 协议的一个(相对较少知道的)设计特性的一个例子:协议相对 URL(技术上是网络路径引用),而不是更常见的绝对 URL。

要了解更多关于如何使用网络路径引用来平滑资源协议解析的信息,请访问:tools.ietf.org/html/rfc3986#section-4.2

正在讨论的问题是:url.parse将以斜杠开头的字符串视为路径,而不是主机。例如,url.parse("//www.example.org")将在主机和路径字段中设置以下值:

host: null,
 path: '//www.example.org'

我们实际上想要的是相反的:

host: 'www.example.org',
 path: null

为了解决这个问题,将true作为url.parse的第三个参数传递,这表明斜杠表示主机,而不是路径:

url.parse("//www.example.org", null, true);

也有可能开发人员想要创建一个 URL,比如通过http.request进行请求时。所述 URL 的各个部分可能分布在各种数据结构和变量中,并且需要被组装。您可以通过将从url.parse返回的对象传递给url.format方法来实现这一点。

以下代码将创建 URL 字符串http://www.example.org

url.format({
  protocol: 'http:',
  host: 'www.example.org'
});

同样,您还可以使用url.resolve方法来生成 URL 字符串,以满足需要连接基本 URL 和路径的常见情况:

url.resolve("http://example.org/a/b", "c/d"); //'http://example.org/a/c/d'
url.resolve("http://example.org/a/b", "/c/d"); 
//'http://example.org/c/d'
url.resolve("http://example.org", "http://google.com"); //'http://google.com/'

Querystring 模块

正如我们在URL模块中看到的,查询字符串通常需要被解析为键/值对的映射。Querystring模块将分解现有的查询字符串为其部分,或者从键/值对的映射中组装查询字符串。

例如,querystring.parse("foo=bar&bingo=bango")将返回:

{
  foo: 'bar',
  bingo: 'bango'
}

如果我们的查询字符串没有使用正常的"&"分隔符和"="赋值字符格式化,Querystring模块提供了可定制的解析。

Querystring的第二个参数可以是自定义的分隔符字符串,第三个参数可以是自定义的赋值字符串。例如,以下将返回与先前给出的具有自定义格式的查询字符串相同的映射:

let qs = require("querystring");
console.log(qs.parse("foo:bar^bingo:bango", "^", ":"));
// { foo: 'bar', bingo: 'bango' }

您可以使用Querystring.stringify方法组成查询字符串:

console.log(qs.stringify({ foo: 'bar', bingo: 'bango' }));
// foo=bar&bingo=bango

与解析一样,stringify还接受自定义的分隔符和赋值参数:

console.log(qs.stringify({ foo: 'bar', bingo: 'bango' }, "^", ":"));
// foo:bar^bingo:bango

查询字符串通常与GET请求相关联,在?字符后面看到。正如我们之前看到的,在这些情况下,使用url模块自动解析这些字符串是最直接的解决方案。然而,以这种方式格式化的字符串也会在处理POST数据时出现,在这些情况下,Querystring模块是真正有用的。我们将很快讨论这种用法,但首先,关于 HTTP 头部的一些内容。

处理头

向 Node 服务器发出的每个 HTTP 请求可能包含有用的头信息,客户端通常希望从服务器接收类似的包信息。Node 提供了简单的接口来读取和写入头信息。我们将简要介绍这些简单的接口,澄清一些细节。最后,我们将讨论如何在 Node 中实现更高级的头使用,研究 Node 服务器可能需要适应的一些常见网络责任。

典型的请求头将如下所示:

头是简单的键/值对。请求键始终小写。在设置响应键时,可以使用任何大小写格式。

读取头很简单。通过检查request.header对象来读取头信息,这是头键/值对的一对一映射。要从前面的示例中获取accept头,只需读取request.headers.accept

通过设置 HTTP 服务器的maxHeadersCount属性,可以限制传入头的数量。

如果希望以编程方式读取头,Node 提供了response.getHeader方法,接受头键作为其第一个参数。

当写入头时,请求头是简单的键/值对,我们需要更具表现力的接口。由于响应通常必须发送状态码,Node 提供了一种简单的方法来准备响应状态行和头组的一条命令:

response.writeHead(200, {
  'Content-Length': 4096,
  'Content-Type': 'text/plain'
});

要单独设置头,可以使用response.setHeader,传递两个参数:头键,然后是头值。

要使用相同名称设置多个头,可以将数组传递给response.setHeader

response.setHeader("Set-Cookie", ["session:12345", "language=en"]);

有时,在排队后可能需要删除响应头。这可以通过使用response.removeHeader来实现,将要删除的头名称作为参数传递。

必须在写入响应之前写入头。在发送响应后写入头是错误的。

使用 cookies

HTTP 协议是无状态的。任何给定的请求都没有关于先前请求的信息。对于服务器来说,这意味着确定两个请求是否来自同一个浏览器是不可能的。为了解决这个问题,发明了 cookie。cookie 主要用于在客户端(通常是浏览器)和服务器之间共享状态,存在于浏览器中的小型文本文件。

Cookie 是不安全的。Cookie 信息在服务器和客户端之间以纯文本形式流动。中间存在任意数量的篡改点。例如,浏览器允许轻松访问它们。这是一个好主意,因为没有人希望他们的浏览器或本地机器上的信息被隐藏,超出他们的控制。

尽管如此,cookie 也被广泛用于维护状态信息,或者维护状态信息的指针,特别是在用户会话或其他身份验证方案的情况下。

假设您对 cookie 的一般功能很熟悉。在这里,我们将讨论 Node HTTP 服务器如何获取、解析和设置 cookie。我们将使用一个回显发送 cookie 值的服务器的示例。如果没有 cookie 存在,服务器将创建该 cookie,并指示客户端再次请求它。

考虑以下代码:

const http = require('http');
const url = require('url');
http.createServer((request, response) => {
  let cookies = request.headers.cookie;
  if(!cookies) {
    let cookieName = "session";
    let cookieValue = "123456";
    let numberOfDays = 4;
    let expiryDate = new Date();
    expiryDate.setDate(expiryDate.getDate() + numberOfDays);

    let cookieText = `${cookieName}=${cookieValue};expires=${expiryDate.toUTCString()};`;
    response.setHeader('Set-Cookie', cookieText);
    response.writeHead(302, {'Location': '/'});
    return response.end();
  }

  cookies.split(';').forEach(cookie => {
    let m = cookie.match(/(.*?)=(.*)$/);
    cookies[m[1].trim()] = (m[2] || '').trim();
  });

  response.end(`Cookie set: ${cookies.toString()}`);
}).listen(8080);

首先,我们创建一个检查请求头中的 cookie 的服务器:

let server = http.createServer((request, response) => {
  let cookies = request.headers.cookie;
  ...

请注意,cookie 存储为request.headerscookie属性。如果该域不存在 cookie,我们将需要创建一个,给它命名为session,值为123456

if (!cookies) {
  ...
  let cookieText = `${cookieName}=${cookieValue};expires=${expiryDate.toUTCString()};`;
  response.setHeader('Set-Cookie', cookieText);
  response.writeHead(302, {
    'Location': '/'
  });
  return response.end();
}

如果我们第一次设置了这个 cookie,客户端被指示再次向同一服务器发出请求,使用 302 Found 重定向,指示客户端再次调用我们的服务器位置。由于现在为该域设置了一个 cookie,随后的请求将包含我们的 cookie,我们将处理它:

cookies.split(';').forEach(cookie => {
 let m = cookie.match(/(.*?)=(.*)$/);
 cookies[m[1].trim()] = (m[2] || '').trim();
});
response.end(`Cookie set: ${cookies.toString()}`);

现在,如果你访问localhost:8080,你应该看到类似于这样的显示:

Cookie set: AuthSession=c3Bhc3F1YWxpOjU5QzkzRjQ3OosrEJ30gDa0KcTBhRk-YGGXSZnT; io=QuzEHrr5tIZdH3LjAAAC

理解内容类型

客户端通常会传递一个请求头,指示预期的响应 MIME(多用途互联网邮件扩展)类型。客户端还会指示请求体的 MIME 类型。服务器将类似地提供有关响应体的 MIME 类型的头信息。例如,HTML 的 MIME 类型是 text/html。

正如我们所见,HTTP 响应有责任设置描述其包含的实体的头。同样,GET请求通常会指示资源类型,MIME 类型,它期望作为响应。这样的请求头可能看起来像这样:

Accept: text/html

接收这样的指令的服务器有责任准备一个符合发送的 MIME 类型的实体主体,如果能够这样做,它应该返回类似的响应头:

Content-Type: text/html; charset=utf-8

因为请求还标识了所需的特定资源(例如/files/index.html),服务器必须确保返回给客户端的请求资源实际上是正确的 MIME 类型。虽然看起来很明显,由扩展名html标识的资源实际上是 MIME 类型 text/html,但这并不确定——文件系统不会阻止将图像文件命名为html扩展名。解析扩展名是一种不完美的确定文件类型的方法。我们需要做更多的工作。

UNIX 的file程序能够确定系统文件的 MIME 类型。例如,可以通过运行以下命令来确定没有扩展名的文件(例如resource)的 MIME 类型:

file --brief --mime resource

我们传递参数指示file输出资源的 MIME 类型,并且输出应该是简要的(只有 MIME 类型,没有其他信息)。这个命令可能返回类似于text/plain; charset=us-ascii的内容。在这里,我们有一个解决问题的工具。

有关文件实用程序的更多信息,请参阅:man7.org/linux/man-pages/man1/file.1.html

回想一下,Node 能够生成子进程,我们有一个解决方案来准确确定系统文件的 MIME 类型的问题。我们可以使用 Node 的child_process模块的 Node 命令exec方法来确定文件的 MIME 类型,就像这样:

let exec = require('child_process').exec;
exec("file --brief --mime resource", (err, mime) => {
  console.log(mime);
});

这种技术在从外部位置流入的文件进行验证时也很有用。遵循“永远不要相信客户端”的原则,检查文件发布到 Node 服务器的Content-type头是否与本地文件系统中存在的接收文件的实际 MIME 类型匹配,这总是一个好主意。

处理 favicon 请求

当通过浏览器访问 URL 时,通常会注意到浏览器标签中或浏览器地址栏中有一个小图标。这个图标是一个名为favicon.ico的图像,它在每个请求中都会被获取。因此,一个 HTTP GET 请求通常会结合两个请求——一个用于获取 favicon,另一个用于获取请求的资源。

Node 开发人员经常对这种重复的请求感到惊讶。任何一个 HTTP 服务器的实现都必须处理 favicon 请求。为此,服务器必须检查请求类型并相应地处理它。以下示例演示了一种这样做的方法:

const http = require('http');
http.createServer((request, response) => { 
  if(request.url === '/favicon.ico') {
    response.writeHead(200, {
      'Content-Type': 'image/x-icon'
    });
    return response.end();
  }
  response.writeHead(200, {
    'Content-Type': 'text/plain'
  });
  response.write('Some requested resource');
  response.end();

}).listen(8080);

这段代码将简单地发送一个空的图像流用于 favicon。如果有一个要发送的 favicon,你可以简单地通过响应流推送这些数据,就像我们之前讨论过的那样。

处理 POST 数据

在网络应用程序中使用的最常见的REST方法之一是 POST。根据REST规范,POST不是幂等的,与大多数其他众所周知的方法(GETPUTDELETE等)相反。这是为了指出POST数据的处理往往会对应用程序的状态产生重大影响,因此应该小心处理。

我们现在将讨论处理最常见类型的通过表单提交的POST数据。更复杂的POST类型——多部分上传——将在第四章中讨论,使用 Node 访问文件系统

让我们创建一个服务器,该服务器将向客户端返回一个表单,并回显客户端使用该表单提交的任何数据。我们需要首先检查请求的URL,确定这是一个表单请求还是表单提交,在第一种情况下返回表单的HTML,在第二种情况下解析提交的数据:

const http = require('http');
const qs = require('querystring');

http.createServer((request, response) => {
   let body = "";
   if(request.url === "/") {
      response.writeHead(200, {
         "Content-Type": "text/html"
      });
      return response.end(
         '<form action="/submit" method="post">\
         <input type="text" name="sometext">\
         <input type="submit" value="Send some text">\
         </form>'
      );
   }
}).listen(8080);

请注意,我们响应的表单只有一个名为sometext的字段。这个表单应该以sometext=entered_text的形式将数据 POST 到路径/submit。为了捕获这些数据,添加以下条件:

if(request.url === "/submit") {
   request.on('readable', () => {
      let data = request.read();
      data && (body += data);
   });
   request.on('end', () => {
      let fields = qs.parse(body);
      response.end(`Thanks for sending: ${fields.sometext}`);
   });
}

一旦我们的POST流结束,我们使用Querystring.parse解析主体,从中得到一个键/值映射,我们可以从中取出名称为sometext的表单元素的值,并向客户端响应我们已经收到他们的数据。

使用 Node 创建和流式传输图像

经过对启动和转移数据流的主要策略的讨论,让我们通过创建一个服务来流式传输(恰当地命名为)PNG可移植网络图形)图像来实践这个理论。然而,这不会是一个简单的文件服务器。目标是通过将在单独的进程中执行的ImageMagick转换操作的输出流管道传输到 HTTP 连接的响应流中来创建 PNG 数据流,其中转换器正在将 Node 运行时中存在的虚拟DOM文档对象模型)中生成的另一个SVG可缩放矢量图形)数据流进行转换。让我们开始吧。

这个示例的完整代码可以在你的代码包中找到。

我们的目标是使用 Node 根据客户端请求动态生成饼图。客户端将指定一些数据值,然后将生成表示该数据的 PNG。我们将使用D3.js库,该库提供了用于创建数据可视化的 Javascript API,以及jsdom NPM 包,该包允许我们在 Node 进程中创建虚拟 DOM。此外,我们将使用ImageMagickSVG(可缩放矢量图形)表示转换为PNG(便携式网络图形)表示。

访问github.com/tmpvar/jsdom了解jsdom的工作原理,访问d3js.org/了解如何使用 D3 生成 SVG。

此外,我们创建的 PNG 将被写入文件。如果未来的请求将相同的查询参数传递给我们的服务,我们将能够立即传送现有的渲染结果,而无需重新生成。

饼图代表一系列百分比,其总和填满圆的总面积,以切片形式可视化。我们的服务将根据客户端发送的值绘制这样的图表。在我们的系统中,客户端需要发送总和为 1 的值,例如.5,.3,.2。因此,当服务器收到请求时,需要获取查询参数,并创建一个将来与相同查询参数映射的唯一键:

let values = url.parse(request.url, true).query['values'].split(",");
let cacheKey = values.sort().join('');

在这里,我们看到 URL 模块正在起作用,提取我们的数据值。此外,我们通过首先对值进行排序,然后将它们连接成一个字符串来创建一个键,我们将使用它作为缓存的饼图的文件名。我们对值进行排序的原因是:通过发送.5 .3 .2 和.3 .5 .2 可以得到相同的图表。通过排序和连接,这两者都变成了文件名.2 .3 .5。

在生产应用程序中,需要做更多工作来确保查询格式正确,数学上正确等。在我们的示例中,我们假设正在发送正确的值。

创建、缓存和发送 PNG 表示

首先,安装 ImageMagick:www.imagemagick.org/script/download.php。我们将生成一个 Node 进程来与安装的二进制文件进行交互,如下所示。

在动态构建图表之前,假设已经存在一个存储在变量svg中的 SVG 定义,它将包含类似于这样的字符串:

<svg width="200" height="200">
<g transform="translate(100,100)">
<defs>
  <radialgradient id="grad-0" gradientUnits="userSpaceOnUse" cx="0" cy="0" r="100">
  <stop offset="0" stop-color="#7db9e8"></stop>
 ...

要将 SVG 转换为 PNG,我们将生成一个子进程来运行 ImageMagick 转换程序,并将我们的 SVG 数据流式传输到该进程的stdin,该进程将输出一个 PNG。在接下来的示例中,我们将继续这个想法,将生成的 PNG 流式传输到客户端。

我们将跳过服务器样板代码 -- 只需说明服务器将在 8080 端口运行,并且将有一个客户端调用一些数据来生成图表。重要的是我们如何生成和流式传输饼图。

客户端将发送一些查询字符串参数,指示此图表的values(例如 4,5,8,切片的相对大小)。服务器将使用 jsdom 模块生成一个“虚拟 DOM”,其中插入了 D3 图形库,以及一些 javascript(在您的代码包中的pie.js),以便获取我们收到的值并使用 D3 绘制 SVG 饼图,所有这些都在服务器端虚拟 DOM 中完成。然后,我们获取生成的 SVG 代码,并使用 ImageMagick 将其转换为 PNG。为了允许缓存,我们使用缓存值形成一个字符串文件名作为 cacheKey 存储这个 PNG,并在写入时将流式传输的 PNG 返回给客户端:

jsdom.env({
   ...
   html : `<!DOCTYPE html><div id="pie" style="width:${width}px;height:${height}px;"></div>`,
   scripts : ['d3.min.js','d3.layout.min.js','pie.js'], 
   done : (err, window) => {
      let svg = window.insertPie("#pie", width, height, values).innerHTML;
      let svgToPng = spawn("convert", ["svg:", "png:-"]);
      let filewriter = fs.createWriteStream(cacheKey);

      filewriter.on("open", err => {
         let streamer = new stream.Transform();
         streamer._transform = function(data, enc, cb) {
            filewriter.write(data);
            this.push(data);
            cb();
         };
         svgToPng.stdout.pipe(streamer).pipe(response);
         svgToPng.stdout.on('finish', () => response.end());

         // jsdom's domToHTML will lowercase element names
         svg = svg.replace(/radialgradient/g,'radialGradient');

         svgToPng.stdin.write(svg);
         svgToPng.stdin.end();
         window.close();
      });
   }
});    

回顾我们关于流的讨论,这里发生的事情应该是清楚的。我们使用 jsdom 生成一个 DOM(window),运行insertPie函数生成 SVG,然后生成两个流:一个用于写入缓存文件,一个用于 ImageMagick 进程。使用TransformStream(可读和可写)我们实现了其抽象的_transform方法,以期望从 ImageMagick 流的stdout输入数据,将该数据写入本地文件系统,然后重新将数据推回流中,然后将其传送到响应流。我们现在可以实现所需的流链接:

svgToPng.stdout.pipe(streamer).pipe(response);

客户端接收到一个饼图,并且一个副本被写入到本地文件缓存中。在请求的饼图已经被渲染的情况下,它可以直接从文件系统中进行流式传输。

fs.exists(cacheKey, exists => {
  response.writeHead(200, {
    'Content-Type': 'image/png'
  });
  if (exists) {
    fs.createReadStream(cacheKey).pipe(response);
    return;
  }
 ...

如果您启动服务器并将以下内容粘贴到浏览器中:

http://localhost:8080/?values=3,3,3,3,3

您应该看到一个饼图显示出来:

虽然有些不自然,但希望这能展示不同进程链如何通过流连接,避免在内存中存储任何中间数据,特别是在通过高流量网络服务器传递数据时尤其有用。

摘要

正如我们所了解的,Node 的设计者成功地创建了一个简单、可预测且方便的解决方案,解决了在不同来源和目标之间实现高效 I/O 的挑战性设计问题,同时保持了易于管理的代码。它的抽象流接口促进了一致的可读和可写接口的实例化,以及将这个接口扩展到 HTTP 请求和响应、文件系统、子进程和其他数据通道,使得使用 Node 进行流编程成为一种愉快的体验。

现在我们已经学会了如何设置 HTTP 服务器来处理从许多同时连接的客户端接收的数据流,以及如何向这些客户端提供缓冲流的数据,我们可以开始更深入地参与使用 Node 构建企业级并发实时系统的任务。

第四章:使用 Node 访问文件系统

"我们有持久对象——它们被称为文件。"

– Ken Thompson

文件只是一块数据,通常保存在硬盘等硬介质上。文件通常由一系列字节组成,其编码映射到其他模式,如一系列数字或电脉冲。几乎可以有无限数量的编码,其中一些常见的是文本文件、图像文件和音乐文件。文件具有固定长度,要读取它们,必须由某种阅读器解密其字符编码,例如 MP3 播放器或文字处理器。

当文件在传输中,从某个存储设备吸取后通过电缆移动时,它与通过电线运行的任何其他数据流没有区别。它以前的固态只是一个稳定的蓝图,可以轻松且无限地复制。

我们已经看到事件流如何反映了 Node 设计的核心设计原则,其中字节流应该被读取和写入,并被传送到其他流中,发出相关的流事件,如end。文件很容易被理解为数据的容器,其中充满了可以部分或完整提取或插入的字节。

除了它们与流的自然相似性之外,文件还显示了对象的特征。文件具有描述访问文件内容的接口的属性——具有属性和相关访问方法的数据结构。

文件系统反映了文件应该如何组织的一些概念——它们如何被识别,它们存储在哪里,如何被访问等等。UNIX 用户常用的文件系统是 UFS(Unix 文件系统),而 Windows 用户可能熟悉 NTFS(新技术文件系统)。

有趣的是,Plan 9 操作系统的设计者(包括 Ken Thompson 在内的一个团队)决定将所有控制接口表示为文件系统,以便所有系统接口(跨设备,跨应用程序)都被建模为文件操作。将文件视为一等公民是 UNIX 操作系统也使用的哲学;使用文件作为命名管道和套接字的引用等等,使开发人员在塑造数据流时拥有巨大的力量。

文件对象也是强大的,它们所在的系统公开了必须易于使用、一致且非常快速的基本 I/O 接口。不足为奇,Node 的file模块公开了这样的接口。

我们将从这两个角度考虑在 Node 中处理文件:文件数据内容如何流入和流出(读取和写入),以及如何修改文件对象的属性,如更改文件权限。

此外,我们将介绍 Node 服务器的责任,接受文件上传并处理文件请求。通过示例演示目录迭代器和文件服务器,Node 的文件系统 API 的全部范围和行为应该变得清晰。

最后,我们将使用 GitHub 的 Electron 框架将 JavaScript 带回桌面,制作我们自己的桌面应用程序,一个简单的文件浏览器。

目录和文件夹的迭代

通常,文件系统将文件分组成集合,通常称为目录。通过目录导航以找到单个文件。一旦找到目标文件,文件对象必须被包装成一个公开文件内容以供读取和写入的接口。

由于 Node 开发通常涉及创建既接受又发出文件数据的服务器,因此应该清楚这个活跃和重要的 I/O 层的传输速度有多重要。正如前面提到的,文件也可以被理解为对象,而对象具有某些属性。

文件类型

在 UNIX 系统上通常遇到的有六种类型的文件:

  • 普通文件:这些文件包含一维字节数组,不能包含其他文件。

  • 目录:这些也是以特殊方式实现的文件,可以描述其他文件的集合。

  • 套接字:用于 IPC,允许进程交换数据。

  • 命名管道:像ps aux | grep node这样的命令创建了一个管道,

一旦操作终止,它就会被销毁。命名管道是持久的、可寻址的,并且可以被多个进程用于 IPC。

  • 设备文件:这些是 I/O 设备的表示,接受数据流的进程;/dev/null通常是字符设备文件的一个例子(接受 I/O 的串行数据流),/dev/sda是块设备文件的一个例子(允许数据块的随机访问 I/O),代表一个数据驱动器。

  • 链接:这些是指向其他文件的指针,有两种类型:硬链接和符号链接。硬链接直接指向另一个文件,并且与目标文件无法区分。符号链接是间接指针,并且可以与普通文件区分开。

大多数 Node 文件系统交互只涉及前两种类型,第三种类型只是通过 Node API 间接涉及。对剩余类型的更深入解释超出了本讨论的范围。然而,Node 通过file模块提供了完整的文件操作套件,读者应该至少对文件类型的全部范围和功能有一定的了解。

学习命名管道将奖励那些对了解 Node 如何设计以与流和管道一起工作感兴趣的读者。在终端中尝试这个:

$ mkfifo namedpipe

如果你得到了当前目录的扩展列表-ls -l,将会显示类似于这样的列表:

prw-r--r-- 1 system staff 0 May 01 07:52 namedpipe

注意文件模式中的p标志(第一个段,带有破折号)。你已经创建了一个命名的(p)ipe。现在,输入到同一个终端中,将一些字节推送到命名管道中:

echo "hello" > namedpipe

看起来好像进程已经挂起了。其实没有——管道,就像水管一样,必须在两端打开才能完成它们刷新内容的工作。我们已经把一些字节放进去了……现在呢?

打开另一个终端,导航到相同的目录,并输入以下内容:

$ cat namedpipe.

hello将出现在第二个终端中,作为namedpipe的内容被刷新。请注意,第一个终端不再挂起——它已经刷新了。如果你回忆一下第三章中关于 Node 流的讨论,在节点和客户端之间流式传输数据,你会注意到与 Unix 管道有些相似之处,这是有意为之的。

文件路径

Node 提供的大多数文件系统方法都需要操作文件路径,为此,我们使用path模块。我们可以使用这个模块来组合、分解和关联路径。不要手动拆分你自己的路径字符串,也不要使用正则表达式和连接例程,尝试通过将路径操作委托给这个模块来规范化你的代码:

  • 在处理源不可信或不可靠的文件路径字符串时,使用path.normalize来确保可预测的格式:
const path = require('path'); 
path.normalize("../one////two/./three.html"); 
// -> ../one/two/three.html 
  • 在构建路径段时,使用path.join
path.join("../", "one", "two", "three.html"); 
// -> ../one/two/three.html 
  • 使用path.dirname来剪切路径中的目录名:
path.dirname("../one/two/three.html"); 
// ../one/two
  • 使用path.basename来操作最终的路径段:
path.basename("../one/two/three.html"); 
// -> three.html 

// Remove file extension from the basename 
path.basename("../one/two/three.html", ".html"); 
// -> three 
  • 使用path.extname从路径字符串的最后一个句点(.)开始切片到末尾:
var pstring = "../one/two/three.html"; 
path.extname(pstring); 
// -> .html 
  • 使用path.relative来找到从一个绝对路径到另一个绝对路径的相对路径:
path.relative( 
  '/one/two/three/four',  
  '/one/two/thumb/war' 
); 
// -> ../../thumb/war 
  • 使用path.resolve来将路径指令列表解析为绝对路径:
path.resolve('/one/two', '/three/four'); 
// -> /three/four 
path.resolve('/one/two/three', '../', 'four', '../../five') 
// -> /one/five 

将传递给path.resolve的参数视为一系列cd调用:

cd /one/two/three 
cd ../ 
cd four 
cd ../../five 
pwd 
// -> /one/five 

如果传递给path.resolve的参数列表未能提供绝对路径,那么当前目录名称也会被使用。例如,假设我们在/users/home/john/中:

path.resolve('one', 'two/three', 'four'); 
// -> /users/home/john/one/two/three/four

这些参数解析为一个相对路径one/two/three/four,因此,它是以当前目录名称为前缀的。

文件属性

文件对象公开了一些属性,包括有关文件数据的一组有用的元数据。例如,如果使用 Node 运行 HTTP 服务器,将需要确定通过 GET 请求的任何文件的文件长度。确定文件上次修改的时间在许多类型的应用程序中都有用。

要读取文件的属性,使用fs.stat

fs.stat("file.txt", (err, stats) => { 
  console.log(stats); 
}); 

在上面的例子中,stats将是描述文件的fs.Stats对象:

  dev: 2051, // id of device containing this file 
  mode: 33188, // bitmask, status of the file 
  nlink: 1, // number of hard links 
  uid: 0, // user id of file owner 
  gid: 0, // group id of file owner 
  rdev: 0, // device id (if device file) 
  blksize: 4096, // I/O block size 
  ino: 27396003, // a unique file inode number 
  size: 2000736, // size in bytes 
  blocks: 3920, // number of blocks allocated 
  atime: Fri May 3 2017 15:39:57 GMT-0500 (CDT), // last access 
  mtime: Fri May 3 2017 17:22:46 GMT-0500 (CDT), // last modified 
  ctime: Fri May 3 2017 17:22:46 GMT-0500 (CDT)  // last status change 

fs.Stats对象公开了几个有用的方法来访问文件属性数据:

  • 使用stats.isFile来检查标准文件

  • 使用stats.isDirectory来检查目录

  • 使用stats.isBlockDevice来检查块设备文件

  • 使用stats.isCharacterDevice来检查字符类型设备文件

  • fs.lstat之后使用stats.isSymbolicLink来查找符号链接

  • 使用stats.isFIFO来识别命名管道

  • 使用stats.isSocket来检查套接字

还有两个可用的stat方法:

  • fs.fstat(fd, callback): 类似于fs.stat,只是传递了文件描述符fd而不是文件路径

  • fs.lstat(path, callback): 对符号链接进行fs.stat将返回目标文件的fs.Stats对象,而fs.lstat将返回链接文件本身的fs.Stats对象

以下两种方法简化了文件时间戳的操作:

  • fs.utimes(path, atime, mtime, callback): 更改path上的文件的访问和修改时间戳。文件的访问和修改时间以 JavaScript Date对象的实例存储。例如,Date.getTime将返回自 1970 年 1 月 1 日午夜(UTC)以来经过的毫秒数。

  • fs.futimes(fd, atime, mtime, callback): 更改文件描述符fd上的访问和修改时间戳;它类似于fs.utimes

有关使用 JavaScript 操作日期和时间的更多信息,请访问:

developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date

打开和关闭文件

Node 项目的一个非正式规则是不要不必要地从现有的操作系统实现细节中抽象出来。正如我们将看到的,文件描述符的引用出现在整个 Node 的文件 API 中。对于POSIX可移植操作系统接口),文件描述符只是一个(非负)整数,唯一地引用特定的文件。由于 Node 的文件系统方法是基于 POSIX 建模的,因此文件描述符在 Node 中表示为整数并不奇怪。

回想一下我们讨论过的设备和操作系统的其他元素是如何表示为文件的,那么标准 I/O 流(stdinstdoutstderr)也会有文件描述符是合理的。事实上,情况就是这样的:

console.log(process.stdin.fd); // 0 
console.log(process.stdout.fd); // 1 
console.log(process.stderr.fd); // 2 

fs.fstat(1, (err, stat) => { 
  console.log(stat); // an fs.Stats object 
}); 

文件描述符易于获取,并且是传递文件引用的便捷方式。让我们看看如何通过检查如何执行低级文件打开和关闭操作来创建和使用文件描述符。随着本章的进行,我们将研究更精细的文件流接口。

fs.open(path, flags, [mode], callback)

尝试在path处打开文件。callback将接收操作的任何异常作为其第一个参数,并将文件描述符作为其第二个参数。在这里,我们打开一个文件进行读取:

fs.open("path.js", "r", (err, fileDescriptor) => { 
  console.log(fileDescriptor); // An integer, like `7` or `23` 
}); 

flags接收一个字符串,指示调用者期望在返回的文件描述符上执行的操作类型。它们的含义应该是清楚的。

  • r:打开文件进行读取,如果文件不存在则抛出异常。

  • r+:打开文件进行读取和写入,如果文件不存在则抛出异常。

  • w:打开文件进行写入,如果文件不存在则创建文件,并且如果文件存在则将文件截断为零字节。

  • wx:类似于w,但以独占模式打开文件,这意味着如果文件已经存在,它将不会被打开,打开操作将失败。如果多个进程可能同时尝试创建相同的文件,则这很有用。

  • w+:打开文件进行读取和写入,如果文件不存在则创建文件,并且如果文件存在则将文件截断为零字节。

  • wx+:类似于wx(和w),此外还打开文件进行读取。

  • a:打开文件进行追加,如果文件不存在则创建文件。

  • ax:类似于a,但以独占模式打开文件,这意味着如果文件已经存在,它将不会被打开,打开操作将失败。如果多个进程可能同时尝试创建相同的文件,则这很有用。

  • a+:打开文件进行读取和追加,如果文件不存在则创建文件。

  • ax+:类似于ax(和a),此外还打开文件进行读取。

当操作可能创建新文件时,使用可选的mode以八进制数字形式设置此文件的权限,默认为 0666(有关八进制权限的更多信息,请参阅fs.chmod):

fs.open("index.html", "w", 755, (err, fd) => { 
   fs.read(fd, ...); 
}); 

fs.close(fd, callback)

fs.close(fd, callback) 方法关闭文件描述符。回调函数接收一个参数,即调用中抛出的任何异常。关闭所有已打开的文件描述符是一个好习惯。

文件操作

Node 实现了用于处理文件的标准 POSIX 函数,UNIX 用户会很熟悉。我们不会深入讨论这个庞大集合的每个成员,而是专注于一些常用的例子。特别是,我们将深入讨论打开文件描述符和操作文件数据的方法,读取和操作文件属性,以及在文件系统目录中移动。然而,鼓励读者尝试整套方法,以下列表简要描述了这些方法。请注意,所有这些方法都是异步的,非阻塞文件操作。

fs.rename(oldName, newName, callback)

fs.rename(oldName, newName, callback) 方法将oldName处的文件重命名为newName。回调函数接收一个参数,即调用中抛出的任何异常。

fs.truncate(path, len, callback)

fs.truncate(path, len, callback) 方法通过len字节更改path处文件的长度。如果len表示比文件当前长度更短的长度,则文件将被截断为该长度。如果len更大,则文件长度将通过附加空字节(x00)进行填充,直到达到len。回调函数接收一个参数,即调用中抛出的任何异常。

fs.ftruncate(fd, len, callback)

fs.ftruncate(fd, len, callback) 方法类似于fs.truncate,不同之处在于不是指定文件,而是将文件描述符作为fd传递。

fs.chown(path, uid, gid, callback)

fs.chown(path, uid, gid, callback) 方法更改path处文件的所有权。使用此方法设置用户uid或组gid是否可以访问文件。回调函数接收一个参数,即调用中抛出的任何异常。

fs.fchown(fd, uid, gid, callback)

fs.fchown(fd, uid, gid, callback) 方法与fs.chown类似,不同之处在于不是指定文件路径,而是将文件描述符作为fd传递。

fs.lchown(path, uid, gid, callback)

fs.lchown(path, uid, gid, callback) 方法与fs.chown类似,不同之处在于对于符号链接,更改的是链接文件本身的所有权,而不是引用的链接。

fs.chmod(path, mode, callback)

fs.chmod(path, mode, callback) 方法更改path处文件的mode(权限)。您正在设置该文件的读取(4)、写入(2)和执行(1)位,可以以八进制数字形式发送:

[r]读取 [w]写入 E[x]执行 总计
所有者 4 2 1 7
4 0 1 5
其他 4 0 1 5
chmod(755)

您也可以使用符号表示,例如g+rw表示组读写,类似于我们之前在file.open中看到的参数。有关设置文件模式的更多信息,请参阅:en.wikipedia.org/wiki/Chmod

回调函数接收一个参数,在调用中抛出的任何异常。

fs.fchmod(fd, mode, callback) ----

fs.fchmod(fd, mode, callback)方法类似于fs.chmod,不同之处在于不是指定文件路径,而是将文件描述符作为fd传递。

fs.lchmod(path, mode, callback)

fs.lchmod(path, mode, callback)方法类似于fs.chmod,不同之处在于对于符号链接,只会更改链接文件本身的权限,而不会更改引用链接的权限。

fs.link(srcPath, dstPath, callback)

fs.link(srcPath, dstPath, callback)srcPathdstPath之间创建一个硬链接。这是创建指向完全相同文件的许多不同路径的一种方法。例如,以下目录包含一个target.txt文件和两个硬链接—a.txtb.txt—它们各自指向这个文件:

请注意,target.txt是空的。如果更改目标文件的内容,链接文件的长度也将更改。考虑更改目标文件的内容:

echo "hello" >> target.txt  

这导致了这种新的目录结构,清楚地展示了硬引用:

回调函数接收一个参数,在调用中抛出的任何异常。

fs.symlink(srcPath, dstPath, [type], callback)

fs.symlink(srcPath, dstPath, [type], callback)方法在srcPathdstPath之间创建一个符号链接。与使用fs.link创建的硬链接不同,符号链接只是指向其他文件的指针,并且本身不会对目标文件的更改做出响应。默认的链接type是文件。其他选项是目录和 junction,最后一个是 Windows 特定类型,在其他系统上被忽略。回调函数接收一个参数,在调用中抛出的任何异常。

将我们在fs.link讨论中描述的目录更改与以下内容进行比较:

与硬链接不同,当它们的目标文件(在本例中为target.txt)更改长度时,符号链接的长度不会改变。在这里,我们看到将目标长度从零字节更改为六字节对任何绑定的符号链接的长度没有影响:

fs.readlink(path, callback)

给定path处的符号链接返回目标文件的文件名:

fs.readlink('a.txt', (err, targetFName) => { 
  console.log(targetFName); // target.txt 
}); 

fs.realpath(path, [cache], callback)

fs.realpath(path, [cache], callback)方法尝试找到path处文件的真实路径。这是查找文件的绝对路径,解析符号链接,甚至清理多余的斜杠和其他格式不正确的路径的有用方法。考虑这个例子:

fs.realpath('file.txt', (err, resolvedPath) => { 
  console.log(resolvedPath); // `/real/path/to/file.txt` 
}); 

或者,考虑这个:

fs.realpath('.////./file.txt', (err, resolvedPath) => { 
  // still `/real/path/to/file.txt` 
}); 

如果要解析的一些路径段已知,可以传递一个映射路径的cache

let cache = {'/etc':'/private/etc'}; 
fs.realpath('/etc/passwd', cache, (err, resolvedPath) => { 
  console.log(resolvedPath); // `/private/etc/passwd` 
});

fs.unlink(path, callback)

fs.unlink(path, callback)方法删除path处的文件,相当于删除文件。回调函数接收一个参数,在调用中抛出的任何异常。

fs.rmdir(path, callback)

fs.rmdir(path, callback)方法删除path处的目录,相当于删除目录。

请注意,如果目录不为空,这将抛出异常。回调函数接收一个参数,在调用中抛出的任何异常。

fs.mkdir(path, [mode], callback)

fs.mkdir(path, [mode], callback)方法在path处创建一个目录。要设置新目录的模式,请使用fs.chmod中描述的权限位图。

请注意,如果此目录已经存在,将抛出异常。回调函数接收一个参数,在调用中抛出的任何异常。

fs.exists(path, callback)

fs.exists(path, callback)方法检查path处是否存在文件。回调将接收一个布尔值 true 或 false。

fs.fsync(fd, callback)

在发出写入文件的某些数据的请求和该数据完全存在于存储设备上之间的瞬间,候选数据存在于核心系统缓冲区中。这种延迟通常不相关,但在一些极端情况下,例如系统崩溃,有必要坚持文件反映稳定存储设备上已知状态。

fs.fsync将由文件描述符fd引用的文件的所有核心数据复制到磁盘

(或其他存储设备)。回调函数接收一个参数,即调用中抛出的任何异常。

同步性

方便的是,Node 的file模块为我们介绍的每个异步方法提供了同步对应方法,以Sync为后缀表示。例如,fs.mkdir的同步版本是fs.mkdirSync

同步调用还能够直接返回其结果,无需回调。在第三章中演示了在 HTTPS 服务器中创建流数据跨节点和客户端的过程中,我们既看到了同步代码的一个很好的用例,也看到了直接分配结果而无需回调的示例:

key: fs.readFileSync('server-key.pem'), 
cert: fs.readFileSync('server-cert.pem') 

嘿!Node 不是严格执行异步编程吗?阻塞代码不总是错误的吗?鼓励所有开发人员遵循非阻塞设计,并鼓励避免同步编码——如果面临一个同步操作似乎是唯一的解决方案的问题,那么很可能是问题被误解了。然而,确实存在一些需要在执行进一步指令之前完全存在于内存中的文件对象的边缘情况(阻塞操作)。如果这是唯一可能的解决方案(这可能并不是!),Node 给开发人员提供了打破异步传统的权力。

开发人员经常使用的一个同步操作(也许是在不知不觉中)是require指令:

require('fs') 

require所指向的依赖项完全初始化之前,后续的 JavaScript 指令将不会执行(文件加载会阻塞事件循环)。Ryan Dahl在 2013 年 7 月的 Google Tech Talk 上提到,他在引入同步操作(特别是文件操作)到 Node 中遇到了困难:

根据www.youtube.com/watch?v=F6k8lTrAE2g

“我认为这是一个可以接受的妥协。几个月来,放弃异步模块系统的纯度让我感到痛苦。但是,我认为这样做是可以的。

……

能够只需插入“require, require, require”而无需执行 onload 回调,这样简化了代码很多……我认为这是一个相对可以接受的妥协。[...]你的程序实际上有两个部分:加载和启动阶段……你并不真的关心它运行得有多快……你将加载模块和其他东西……你的守护进程的设置阶段通常是同步的。当你进入用于处理请求的事件循环时,你需要非常小心。[...]我会给人们同步文件 I/O。如果他们在服务器上这样做……那不会太糟糕,对吧?重要的是永远不要让他们进行同步网络 I/O。”

同步代码的优势在于极其可预测,因为在完成此指令之前不会发生其他任何事情。当启动服务器时,这种情况很少发生,Dahl 建议一点确定性和简单性可以走得更远。例如,服务器初始化时加载配置文件可能是有意义的。

有时,在 Node 开发中使用同步命令的愿望只是在请求帮助;开发人员被深度嵌套的回调结构所压倒。如果曾经面对这种痛苦,请尝试一些在第二章中提到的回调控制库,理解异步事件驱动编程

浏览目录

让我们应用我们所学到的知识,创建一个目录迭代器。这个项目的目标是创建一个函数,该函数将接受一个目录路径,并返回一个反映文件目录层次结构的 JSON 对象,其节点由文件对象组成。我们还将使我们的目录遍历器成为一个更强大的基于事件的解析器,与 Node 哲学一致。

要移动到嵌套目录中,必须首先能够读取单个目录。Node 的文件系统库提供了fs.readdir命令来实现这一目的:

fs.readdir('.', (err, files) => { 
  console.log(files); // list of all files in current directory 
}); 

记住一切都是文件,我们需要做的不仅仅是获取目录列表;我们必须确定文件列表中每个成员的类型。通过添加fs.stat,我们已经完成了大部分逻辑:

(dir => { 
  fs.readdir(dir, (err, list) => { 
    list.forEach(file => { 
      fs.stat(path.join(dir, file), (err, stat) => { 
        if (stat.isDirectory()) { 
          return console.log(`Found directory: ${file}`); 
        }
        console.log(`Found file: ${file}`); 
      }); 
    }); 
  }); 
})("."); 

这个自执行函数接收一个目录路径参数("."),将该目录列表折叠成一个文件名数组,为其中的每个文件获取一个fs.Stats对象,并根据指示的文件类型(目录或非目录)做出决定下一步该做什么。在这一点上,我们也已经映射了一个单个目录。

我们现在必须映射目录中的目录,将结果存储在反映嵌套文件系统树的 JSON 对象中,树上的每个叶子都是一个文件对象。递归地将我们的目录读取器函数路径传递给子目录,并将返回的结果附加为最终对象的分支是下一步:

let walk = (dir, done) => { 
  let results = {}; 
  fs.readdir(dir, (err, list) => { 
    let pending = list.length;    
    if (err || !pending) { 
      return done(err, results); 
    } 
    list.forEach(file => { 
      let dfile = require('path').join(dir, file); 
      fs.stat(dfile, (err, stat) => { 
        if(stat.isDirectory()) { 
          return walk(dfile, (err, res) => { 
            results[file] = res; 
            !--pending && done(null, results); 
          }); 
        }  
        results[file] = stat; 
        !--pending && done(null, results); 
      }); 
    }); 
  }); 
}; 
walk(".", (err, res) => { 
  console.log(require('util').inspect(res, {depth: null})); 
});

我们创建一个walk方法,该方法接收一个目录路径和一个回调函数,该回调函数在walk完成时接收目录图或错误,遵循 Node 的风格。创建一个非常快速的、非阻塞的文件树遍历器,包括文件统计信息,不需要太多的代码。

现在,让我们在遇到目录或文件时发布事件,使任何未来的实现都能够灵活地构建自己的文件系统表示。为此,我们将使用友好的EventEmitter对象:

let walk = (dir, done, emitter) => { 
  ... 
  emitter = emitter || new (require('events').EventEmitter); 
  ... 
  if (stat.isDirectory()) { 
    emitter.emit('directory', dfile, stat); 
    return walk(dfile, (err, res) => { 
      results[file] = res; 
      !--pending && done(null, results); 
    }, emitter); 
  }  
  emitter.emit('file', dfile, stat); 
  results[file] = stat; 
  ... 
  return emitter; 
} 
walk("/usr/local", (err, res) => { 
  ... 
}).on("directory", (path, stat) => { 
  console.log(`Directory: ${path} - ${stat.size}`); 
}).on("file", (path, stat) => { 
  console.log(`File: ${path} - ${stat.size}`); 
}); 
// File: index.html - 1024 
// File: readme.txt - 2048 
// Directory: images - 106 
// File images/logo.png - 4096 
// ... 

现在我们知道如何发现和处理文件,我们可以开始从中读取和写入。

从文件中读取

在我们讨论文件描述符时,我们提到了一种打开文件、获取文件描述符并最终通过该引用推送或拉取数据的方法。读取文件是一个常见的操作。有时,精确管理读取缓冲区可能是必要的,Node 允许逐字节控制。在其他情况下,人们只是想要一个简单易用的无花俏流。

逐字节读取

fs.read方法是 Node 提供的读取文件的最低级别的方法。

fs.read(fd, buffer, offset, length, position, callback)

文件由有序字节组成,这些字节可以通过它们相对于文件开头的position进行寻址(位置零[0])。一旦我们有

文件描述符fd,我们可以开始读取length字节数,并将其插入到Buffer对象buffer中,插入从给定的缓冲区offset开始。例如,要将从可读文件fd的位置 309 开始的 8,366 字节复制到

一个从offset为 100 开始的buffer,我们将使用fs.read(fd, buffer, 100, 8366, 309, callback)

以下代码演示了如何以 512 字节块打开和读取文件:

fs.open('path.js', 'r', (err, fd) => { 
  fs.fstat(fd, (err, stats) => { 
    let totalBytes = stats.size; 
    let buffer = Buffer.alloc(totalBytes); 
    let bytesRead = 0; 
    // Each call to read should ensure that chunk size is 
    // within proper size ranges (not too small; not too large). 
    let read = chunkSize => { 
      fs.read(fd, buffer, bytesRead, chunkSize, bytesRead, (err, numBytes, bufRef) => { 
        if((bytesRead += numBytes) < totalBytes) { 
          return read(Math.min(512, totalBytes - bytesRead)); 
        } 
        fs.close(fd); 
        console.log(`File read complete. Total bytes read: ${totalBytes}`); 
        // Note that the callback receives a reference to the 
        // accumulating buffer  
        console.log(bufRef.toString()); 
      }); 
    } 
    read(Math.min(512, totalBytes)); 
  }); 
}); 

生成的缓冲区可以被传送到其他地方(包括服务器响应对象)。也可以使用 Node 的Buffer对象的方法进行操作,例如使用buffer.toString("utf8")将其转换为 UTF8 字符串。

一次获取整个文件

通常,我们只需要获取整个文件,而不需要任何仪式或精细控制。Node 提供了一个快捷方法来实现这一点。

fs.readFile(path, [options], callback)

获取path文件中包含的数据可以在一步中完成:

fs.readFile('/etc/passwd', (err, fileData) => { 
  if(err) { 
    throw err; 
  } 
  console.log(fileData); 
  // <Buffer 48 65 6C 6C 6F ... > 
}); 

我们看到callback接收一个缓冲区。可能更希望以常见编码(如 UTF8)接收文件数据。我们可以使用options对象指定返回数据的编码以及读取模式,该对象有两个可能的属性:

  • encoding:一个字符串,如utf8,默认为 null(无编码)

  • flag:文件模式作为字符串,默认为r

修改上一个例子:

fs.readFile('/etc/passwd', (err, { encoding : "utf8" }, fileData) => { 
  ... 
  console.log(fileData); 
  // "Hello ..." 
});

创建可读流

虽然fs.readFile是一种完成常见任务的简单方法,但它有一个重大缺点,即在将文件的任何部分发送到回调之前,需要将整个文件读入内存。对于大文件或未知大小的文件,这不是一个好的解决方案。

在上一章中,我们学习了数据流和Stream对象。虽然文件可以很容易和自然地使用可读流处理,但 Node 提供了一个专用的文件流接口,提供了一种紧凑的文件流功能,无需额外的构造工作,比fs.readFile提供的更灵活。

fs.createReadStream(path, [options])

fs.createReadStream(path, [options])方法返回path文件的可读流对象。然后,您可以对返回的对象执行流操作,例如pipe()

以下选项可用:

  • flags:文件模式参数作为字符串。默认为r

  • encodingutf8asciibase64之一。默认为无编码。

  • fd:可以将path设置为 null,而不是传递文件描述符。

  • mode:文件模式的八进制表示,默认为 0666。

  • bufferSize:内部读取流的块大小,以字节为单位。默认为 64 * 1024 字节。您可以将其设置为任何数字,但内存分配严格受主机操作系统控制,可能会忽略请求。参考:groups.google.com/forum/?fromgroups#!topic/nodejs/p5FuU1oxbeY

  • autoClose:是否自动关闭文件描述符(类似于fs.close)。默认为 true。如果您正在跨多个流共享文件描述符,则可能希望将其设置为 false 并手动关闭,因为关闭描述符将中断任何其他读取器。

  • start:从这个位置开始阅读。默认为 0。

  • end:在这个位置停止阅读。默认为文件字节长度。

逐行读取文件

逐字节读取文件流对于任何文件解析工作都足够了,但特别是文本文件通常更适合逐行读取,例如读取日志文件时。更准确地说,可以将任何流理解为由换行字符分隔的数据块,通常在 UNIX 系统上是rn。Node 提供了一个本地模块,其方法简化了对数据流中的换行分隔块的访问。

Readline 模块

Readline模块有一个简单但强大的目标,即使得逐行读取数据流更容易。其接口的大部分设计是为了使命令行提示更容易,以便更容易设计接受用户输入的接口。

记住 Node 是为 I/O 设计的,I/O 操作通常涉及在可读和可写流之间移动数据,并且stdoutstdin是与fs.createReadStreamfs.createWriteStream返回的文件流相同的流接口,我们将看看如何使用这个模块类似地提示文件流以获取一行文本。

要开始使用Readline模块,必须创建一个定义输入流和输出流的接口。默认接口选项优先使用作为终端接口。我们感兴趣的选项如下:

  • input:必需。正在监听的可读流。

  • output:必需。正在写入的可写流。

  • terminal:如果输入和输出流都应该像 Unix 终端或电传打字机TTY)一样对待,则设置为 true。对于文件,您将其设置为 false。

通过这个系统,读取文件的行变得非常简单。例如,假设有一个列出英语常用单词的字典文件,一个人可能希望将列表读入数组进行处理:

const fs = require('fs'); 
const readline = require('readline'); 

let rl = readline.createInterface({ 
  input: fs.createReadStream("dictionary.txt"), 
  terminal: false 
}); 
let arr = []; 
rl.on("line", ln => { 
  arr.push(ln.trim()) 
}); 
// aardvark 
// abacus 
// abaisance 
// ...  

请注意,我们禁用了 TTY 行为,自己处理行而不是重定向到输出流。

正如预期的那样,与 Node I/O 模块一样,我们正在处理流事件。可能感兴趣的事件监听器如下所列:

  • line:接收最近读取的行,作为字符串

  • pause:每当流被暂停时调用

  • resume:每当流恢复时调用

  • close:每当流关闭时调用

除了line之外,这些事件名称反映了Readline方法,使用Readline.pause暂停流,使用Readline.resume恢复流,使用Readline.close关闭流。

写入文件

与读取文件一样,Node 提供了丰富的工具集来写入文件。我们将看到 Node 如何使得将文件内容按字节进行定位变得如此简单,就像将连续的数据流导入单个可写文件一样。

逐字节写入

fs.write方法是 Node 提供的写入文件的最低级别方法。该方法使我们可以精确控制字节将被写入文件的位置。

fs.write(fd, buffer, offset, length, position, callback)

要将buffer中位置 309 和 8,675 之间的字节集合(长度为 8,366)插入到由文件描述符fd引用的文件中,从位置 100 开始:

let buffer = Buffer.alloc(8675); 
fs.open("index.html", "w", (err, fd) => { 
  fs.write(fd, buffer, 309, 8366, 100, (err, writtenBytes, buffer) => { 
    console.log(`Wrote ${writtenBytes} bytes to file`); 
    // Wrote 8366 bytes to file 
  }); 
}); 

请注意,对于以追加(a)模式打开的文件,一些操作系统可能会忽略position值,始终将数据添加到文件的末尾。此外,在不等待回调的情况下多次调用fs.write对同一文件是不安全的。在这种情况下,请使用fs.createWriteStream

有了这样精确的控制,我们可以智能地构造文件。在下面(有点牵强的)例子中,我们创建了一个基于文件的数据库,其中包含了一个单一团队 6 个月的棒球比分的索引信息。我们希望能够快速查找这个团队在某一天是赢了还是输了(或者没有比赛)。

由于一个月最多有 31 天,我们可以(随机地)在这个文件中创建一个 6 x 31 的数据网格,将三个值中的一个放在每个网格单元中:L(输)、W(赢)、N(未比赛)。为了好玩,我们还为我们的数据库创建了一个简单的CLI命令行界面)和一个基本的查询语言。这个例子应该清楚地说明了fs.readfs.writeBuffer对象是如何精确地操作文件中的字节的:

const fs = require('fs'); 
const readline = require('readline'); 
let cells  = 186; // 6 x 31 
let buffer = Buffer.alloc(cells); 
let rand;
while(cells--) { 
  //  0, 1 or greater 
  rand = Math.floor(Math.random() * 3); 
  //  78 = "N", 87 = "W", 76 = "L" 
  buffer[cells] = rand === 0 ? 78 : rand === 1 ? 87 : 76; 
} 
fs.open("scores.txt", "r+", (err, fd) => { 
  fs.write(fd, buffer, 0, buffer.length, 0, (err, writtenBytes, buffer) => {          
    let rl = readline.createInterface({ 
      input: process.stdin, 
      output: process.stdout 
    }); 

    let quest = () => { 
      rl.question("month/day:", index => { 
        if(!index) { 
          return rl.close(); 
        } 
        let md = index.split('/'); 
        let pos = parseInt(md[0] -1) * 31 + parseInt(md[1] -1); 
        fs.read(fd, Buffer.alloc(1), 0, 1, pos, (err, br, buff) => { 
          let v = buff.toString(); 
          console.log(v === "W" ? "Win!" : v === "L" ? "Loss..." : "No game"); 
          quest(); 
        }); 
      }); 
    }; 
    quest(); 
  }); 
}); 

一旦运行,我们只需输入一个月/日对,就可以快速访问该数据单元。为输入值添加边界检查将是一个简单的改进。将文件流通过可视化 UI 可能是一个不错的练习。

写入大块数据

对于简单的写操作,fs.write可能过于复杂。有时,所需的只是一种创建具有一些内容的新文件的方法。同样常见的是需要将数据追加到文件的末尾,就像在日志系统中所做的那样。fs.writeFilefs.appendFile方法可以帮助我们处理这些情况。

fs.writeFile(path, data, [options], callback)

fs.writeFile(path, data, [options], callback)方法将data的内容写入到path处的文件中。data 参数可以是一个缓冲区或字符串。

一个字符串。以下选项可用:

  • 编码:默认为utf8。如果数据是一个缓冲区,则忽略此选项。

  • mode:文件模式的八进制表示,默认为 0666。

  • flag:写入标志,默认为w

使用方法很简单:

fs.writeFile('test.txt', 'A string or Buffer of data', err => { 
  if (err) { 
    return console.log(err); 
  } 
  // File has been written 
}); 

fs.appendFile(path, data, [options], callback)

类似于fs.writeFile,不同之处在于data被追加到path文件的末尾。此外,flag选项默认为a

创建可写流

如果要写入文件的数据以块的形式到达(例如文件上传时发生的情况),通过WritableStream对象接口将数据流式传输提供了更灵活和高效的方式。

fs.createWriteStream(path, [options])

fs.createWriteStream(path, [options])方法返回path文件的可写流对象。

以下选项可用:

  • flags:文件模式参数作为字符串。默认为w

  • encodingutf8asciibase64中的一个。默认为无编码。

  • mode:文件模式的八进制表示,默认为 0666。

  • start:表示写入应该开始的文件中的位置的偏移量。

例如,这个小程序作为世界上最简单的文字处理器,将所有终端输入写入文件,直到终端关闭:

let writer = fs.createWriteStream("novel.txt", 'w'); 
process.stdin.pipe(writer);

注意事项

打开文件描述符并从中读取的副作用很小,因此在正常开发中,很少会考虑实际发生了什么。通常情况下,读取文件不会改变它。

在写入文件时,必须解决许多问题,例如:

  • 是否有足够的可写存储空间?

  • 是否有另一个进程同时访问该文件,甚至擦除它?

  • 如果写入操作失败或在流中途被非自然地终止,必须采取什么措施?

我们已经看到了独占写模式标志(wx),它可以在多个写入进程同时尝试创建文件的情况下提供帮助。一般来说,对文件进行写入时可能会面临的所有问题的完整解决方案都很难得出,或者简要陈述。Node 鼓励异步编程。然而,特别是在文件系统方面,有时需要同步、确定性的编程。鼓励您牢记这些和其他问题,并尽可能保持 I/O 非阻塞。

提供静态文件

任何使用 Node 创建 Web 服务器的人都需要对 HTTP 请求做出智能响应。对于 Web 服务器的资源的 HTTP 请求期望得到某种响应。一个基本的静态文件服务器可能看起来像这样:

http.createServer((request, response) => { 
  if(request.method !== "GET") { 
    return response.end("Simple File Server only does GET"); 
  } 
  fs 
  .createReadStream(__dirname + request.url) 
  .pipe(response); 
}).listen(8000); 

该服务器在端口8000上服务 GET 请求,期望在相对路径等于 URL 路径段的本地文件中找到。我们看到 Node 是如何简单地让我们流式传输本地文件数据的,只需将ReadableStream传输到代表客户端套接字连接的WritableStream中。这是在几行代码中安全实现大量功能。

最终,将会添加更多内容,例如处理标准 HTTP 方法的例程,处理错误和格式不正确的请求,设置适当的标头,管理网站图标请求等等。

让我们使用 Node 构建一个相当有用的文件服务器,它将通过流式传输资源来响应 HTTP 请求,并且将遵守缓存请求。在这个过程中,我们将涉及如何管理内容重定向。在本章的后面,我们还将看到如何实现文件上传。请注意,一个完全符合 HTTP 所有特性的 Web 服务器是一个复杂的东西,因此我们正在创建的应该被视为一个良好的开始,而不是终点。

重定向请求

有时,客户端会尝试GET一个 URL,但该 URL 不正确或不完整,资源可能已经移动,或者有更好的方法来发出相同的请求。其他时候,POST可能会在客户端无法知道的新位置创建一个新资源,需要一些响应头信息指向新创建的 URI。让我们看看使用 Node 实现静态文件服务器时可能会遇到的两种常见重定向场景。

重定向基本上需要两个响应头:

  • Location:这表示重定向到可以找到内容主体的位置

  • Content-Location:这意味着指示请求者将在响应主体中找到实体的原始位置的 URL

此外,这些头还有两个特定的用例:

  • 提供有关新创建资源位置的信息

POST的响应

  • 通知客户端请求资源的替代位置

GET的响应

LocationContent-Location头与 HTTP 状态代码有许多可能的配对,特别是3xx(重定向)集。实际上,这些头甚至可以在同一个响应中一起出现。鼓励用户阅读 HTTP/1.1 规范的相关部分,因为这里只讨论了一小部分常见情况。

位置

使用201状态代码响应POST表示已创建新资源并将其 URI 分配给Location头,客户端可以在将来使用该 URI。请注意,由客户端决定是否以及何时获取此资源。因此,严格来说,这不是重定向。

例如,系统可能通过将新用户信息发布到服务器来创建新帐户,期望接收新用户页面的位置:

    POST /path/addUser HTTP/1.1
    Content-Type: application/x-www-form-urlencoded
    name=John&group=friends 
    ...
    Status: 201 
    Location: http://website.com/users/john.html  

同样,在接受但尚未完成的情况下,服务器将指示202状态。在前面的例子中,如果创建新用户记录的工作已被委托给工作队列,那么这将是情况。

我们将在本章后面看到一个实际的实现,演示这种用法,当我们讨论文件上传时。

Content-Location

当对具有多个表示形式的资源进行GET请求,并且这些表示形式可以在不同的资源位置找到时,应该返回特定实体的content-location头。例如,内容格式协商是Content-Location处理的一个很好的例子。可能有兴趣检索给定月份的所有博客文章,可能可以在 URL 上找到,比如:http://example.com/september/。带有application/jsonAccept头的 GET 请求将以 JSON 格式接收响应。对 XML 的请求将接收该表示形式。

如果正在使用缓存机制,这些资源可能具有替代的永久位置,比如http://example.com/cache/september.jsonhttp://example.com/cache/september.xml。将通过Content-Location发送此附加位置信息,响应对象类似于这样:

    Status: 200 
    Content-Type: application/json
    Content-Location: http://blogs.com/cache/allArticles.json
    ... JSON entity body  

在请求的 URL 已经被永久或临时移动的情况下,可以使用3xx状态代码组和Content-Location来指示此状态。例如,要重定向到已永久移动的 URL,应发送 301 代码:

function requestHandler(request,response) { 
  let newPath = "/thedroids.html"; 
  response.writeHead(301, { 
    'Content-Location': newPath 
  }); 
  response.end(); 
} 

实施资源缓存

作为一个一般规则,永远不要浪费资源向客户端传递无关的信息。对于 HTTP 服务器,重新发送客户端已经拥有的文件是不必要的 I/O 成本,这是实现 Node 服务器的错误方式,会增加延迟以及支付被挪用的带宽的财务损失。

浏览器维护已经获取的文件的缓存,并且实体标签ETag)标识这些文件。ETag 是服务器发送的响应头,用于唯一标识它们返回的实体,比如一个文件。当服务器上的文件发生变化时,该服务器将为该文件发送一个不同的 ETag,允许客户端跟踪文件的更改。

当客户端向服务器请求其缓存中包含的资源时,该请求将包含一个If-None-Match头,该头设置为与所述缓存资源相关联的 ETag 的值。If-None-Match头可以包含一个或多个 ETag:

If-None-Match : "686897696a7c876b7e" 
If-None-Match : "686897696a7c876b7e", "923892329b4c796e2e"

服务器理解这个头部,并且只有在发送的 ETags 中没有一个与当前资源实体标记匹配时,才会返回所请求资源的完整实体主体。如果发送的 ETags 中有一个与当前实体标记匹配,服务器将以 304(未修改)状态进行响应,这应该导致浏览器从其内部缓存中获取资源。

假设我们有一个fs.Stats对象可用,使用 Node 可以轻松地管理资源的缓存控制:

let etag = crypto.createHash('md5').update(stat.size + stat.mtime).digest('hex'); 
if(request.headers['if-none-match'] === etag) { 
  response.statusCode = 304; 
  return response.end(); 
} else { 
  // stream the requested resource 
} 

我们通过创建当前文件大小和最后修改时间的 MD5 来为当前文件创建一个etag,并与发送的If-None-Match头进行匹配。如果两者不匹配,资源表示已更改,新版本必须发送回请求的客户端。请注意,应该使用哪种特定算法来创建etag并没有正式规定。示例技术对大多数目的应该能够很好地工作。

嘿!Last-ModifiedIf-Unmodified-Since呢?这些都是很好的头部,也在缓存文件的情况下很有用。事实上,当响应实体请求时,应该尽可能设置Last-Modified头部。我们在这里描述的使用 ETag 的技术将与这些标签类似地工作,实际上,鼓励同时使用 ETags 和这些其他标签。有关更多信息,请参阅:www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3.4

处理文件上传

很可能任何阅读这句话的人都至少有一次从客户端上传文件到服务器的经历。有些人甚至可能实现了文件上传服务,一个将接收并对多部分数据流执行有用操作的服务器。在流行的开发环境中,这个任务变得非常容易。例如,在 PHP 环境中,上传的数据会自动处理并全局可用,被整洁地解析和打包成文件或表单字段值的数组,而开发人员无需编写一行代码。

不幸的是,Node 将文件上传处理的实现留给开发人员,这是一个具有挑战性的工作,许多开发人员可能无法成功或安全地完成。

幸运的是,Felix Geisendorfer 创建了Formidable模块,这是 Node 项目中最重要的早期贡献之一。这是一个广泛实施的企业级模块,具有广泛的测试覆盖范围,它不仅使处理文件上传变得轻而易举,而且可以用作处理表单提交的完整工具。我们将使用这个库来为我们的文件服务器添加文件上传功能。

有关 HTTP 文件上传设计的更多信息,以及开发人员必须克服的棘手实现问题,请参阅www.w3.org/TR/html401/interact/forms.html#h-17.13.4.2中的多部分/表单数据规范,以及 Geisendorfer 关于Formidable的构想和演变的分解debuggable.com/posts/parsing-file-uploads-at-500-mb-s-with-node-js:4c03862e-351c-4faa-bb67-4365cbdd56cb

首先,通过 npm 安装formidable

 npm install formidable 

现在你可以require它:

    let formidable = require('formidable');  

我们将假设文件上传将通过路径发布到我们的服务器上

/uploads/,并且上传通过一个看起来像这样的 HTML 表单到达:

<form action="/uploads" enctype="multipart/form-data" method="post"> 
Title: <input type="text" name="title"><br /> 
<input type="file" name="upload" multiple="multiple"><br /> 
<input type="submit" value="Upload"> 
</form> 

这个表单将允许客户端为上传写一些标题,并选择一个(或多个)文件进行上传。在这一点上,我们服务器的唯一责任是正确检测到何时发出了POST请求,并将相关请求对象传递给 Formidable。

我们不会涵盖 formidable API 设计的每个部分,但我们将专注于库公开的关键POST事件。由于 formidable 扩展了EventEmitter,我们使用on(eventName,callback)格式来捕获文件数据、字段数据和终止事件,向客户端发送响应,描述服务器成功处理了什么:

http.createServer((request, response) => { 
  let rm = request.method.toLowerCase(); 
  if(request.url === '/uploads' && rm === 'post') { 
    let form = new formidable.IncomingForm(); 
    form.uploadDir = process.cwd(); 
    let resp = ""; 
    form 
    .on("file", (field, File) => { 
      resp += `File: ${File.name}<br />`; 
    }) 
    .on("field", (field, value) => { 
      resp += `${field}: ${value}<br />`; 
    }) 
    .on("end", () => { 
      response.writeHead(200, {'content-type': 'text/html'}); 
      response.end(resp); 
    }) 
    .parse(request); 
    return; 
  } 
}).listen(8000); 

我们在这里看到一个formidable实例如何通过其parse方法接收http.Incoming对象,以及如何使用该实例的uploadDir属性设置传入文件的写入路径。该示例将此目录设置为本地目录。真实的实现可能会将目标定位到专用的上传文件夹,甚至将接收到的文件定向到存储服务,通过 HTTP 和Location头接收最终的存储位置(也许是通过 HTTP 接收)。

还要注意文件事件回调如何接收 formidable File对象作为第二个参数,其中包含重要的文件信息,包括以下内容:

  • size:上传文件的大小,以字节为单位

  • * path:上传文件在本地文件系统上的当前位置,例如

作为/tmp/bdf746a445577332e38be7cde3a98fb3

  • name:文件在客户端文件系统上存在的原始名称,例如lolcats.jpg

  • type:文件的 MIME 类型,例如image/png

在几行代码中,我们已经实现了大量的POST数据管理。Formidable 还提供了处理进度指示器、处理网络错误等工具,读者可以通过访问以下网址了解更多信息:github.com/felixge/node-formidable

把所有东西放在一起

回顾我们在上一章中关于 favicon 处理的讨论,并加上我们对文件缓存和文件上传的了解,我们现在可以构建一个简单的文件服务器来处理GETPOST请求:

http.createServer((request, response) => { 
  let rm = request.method.toLowerCase(); 
  if(rm === "post") { 
    let form = new formidable.IncomingForm(); 
    form.uploadDir = process.cwd(); 
    form 
    .on("file", (field, file) => { 
      // process files 
    }) 
    .on("field", (field, value) => { 
      // process POSTED field data 
    }) 
    .on("end", () => { 
      response.end("Received"); 
    }) 
    .parse(request); 
    return; 
  } 
  // Only GET is handled if not POST
  if(rm !== "get") { 
    return response.end("Unsupported Method"); 
  } 
  let filename = path.join(__dirname, request.url); 
  fs.stat(filename, (err, stat) => { 
      if(err) { 
        response.statusCode = err.errno === 34 ? 404 : 500; 
      return response.end() 
      }  
    var etag = crypto.createHash('md5').update(stat.size + stat.mtime).digest('hex');     
    response.setHeader('Last-Modified', stat.mtime); 
    if(request.headers['if-none-match'] === etag) { 
      response.statusCode = 304; 
      return response.end(); 
    } 
    response.setHeader('Content-Length', stat.size); 
    response.setHeader('ETag', etag); 
    response.statusCode = 200; 
    fs.createReadStream(filename).pipe(response); 
  }); 
}).listen(8000); 

注意 404(未找到)和 500(内部服务器错误)状态代码。

Content-Length以字节为单位,而不是字符。通常,您的数据将是单字节字符(hello 是五个字节长),但并非总是如此。如果您确定流缓冲区的长度,请使用Buffer.byteLength

一个简单的文件浏览器

现在,让我们利用我们对文件和 Node 的了解来做一些真正(希望如此)没有网页可以做到的事情;让我们直接浏览您个人计算机的整个硬盘!为了实现这一点,我们将使用 JavaScript 和 Node 家族的两个强大的最近添加:ElectronVue.js

从终端开始,使用以下命令:

$ mkdir hello_files
$ cd hello_files
$ npm init
$ npm install -S electron

默认答案很好,除了入口点——不要输入index.js,而是输入main.js。完成后,你应该有一个像这样的package.json文件:

{
  "name": "hello_files",
  "version": "0.0.1",
  "description": "A simple file browser using Node, Electron, and Vue.js",
  "main": "main.js",
  "dependencies": {
    "electron": "¹.7.9"
  }
}

现在,让我们来看看这三个命令:

$ ./node_modules/.bin/electron --version
$ ./node_modules/.bin/electron
$ ./node_modules/.bin/electron .

尝试第一个命令,以确保 npm 在您的计算机上获得了一个可用的 Electron 副本。截至目前,当前版本是 v1.7.9。第二个命令将执行 electron "empty",即在不给它一个应用程序运行的情况下。第三个命令告诉 electron 在这个文件夹中运行应用程序:Electron 将读取package.json来查找并运行main.js

或者,您可以使用-g全局安装 Electron,然后使用以下命令更轻松地到达可执行文件:

$ npm install -g electron

$ electron --version
$ electron
$ electron .

Electron

让我们运行第二个命令。结果可能会让人惊讶:一个图形窗口出现在您的屏幕上!:

这是什么?Electron 是什么?让我们以几种方式回答这个问题:对于最终用户,对于开发人员或产品所有者,底层,以及在本章末尾,从 JavaScript 的历史和发展的角度来看。

对于最终用户,Electron 应用程序只是一个普通的桌面应用程序。用户甚至无法知道它是用 Electron 制作的。开箱即用的流程完全相同:用户可以从他们喜欢的应用商店获取应用程序,或者从你的网站下载setup.exe。日常体验也是一样的:应用程序在开始菜单或 dock 上有一个图标,菜单在应该的地方,文件|打开...对话框——所有用户期望从桌面应用程序中获得的功能。例如,你可能在 Windows 或 macOS 上使用 Slack,并且可能会惊讶地发现 Slack 是用 Electron 制作的。

对于开发人员或产品所有者来说,Electron 是制作桌面应用程序的好方法。开发人员现在可以在桌面上使用他们在网络上学到的现代和强大的技术。你喜欢的所有 npm 模块也可以一起使用。产品所有者喜欢能够在 Windows、Mac 和 Linux 上同时发布 1.0 版本,几乎不需要额外的开发或测试。业务利益相关者喜欢能够让一个 Web 开发人员团队同时负责 Web 和桌面项目,而不是不得不雇佣新的专门的团队(每个目标操作系统一个)来熟悉每个单独的本地桌面堆栈。

在底层,Electron 非常惊人。它由 Chromium 和 Node 的部分组成,从 Chromium 获取页面渲染的能力,从 Node 获取缓冲区、文件和套接字等能力。Chromium 和 Node 都包含 V8,当然在 V8 内部有一个 JavaScript 事件循环。在一项令人印象深刻的工程壮举中,Electron 将这两个事件循环合并在一起,允许单个 JavaScript 事件运行代码,影响屏幕和系统。

Electron 是由 GitHub 制作的,GitHub 也开发了 Atom 文本编辑器。为了使 Atom 像网络一样易于修改,GitHub 使用了网络技术构建了它。意识到其他软件团队可能希望以这种方式构建桌面应用程序,GitHub 首先将他们的工具作为 Atom Shell 发布,并将名称简化为 Electron。

现在我们已经让 Electron 运行起来了,让我们把 Electron 变成我们自己的应用程序。electron .命令会让 Electron 查看package.json来确定它应该做什么。在那里,我们指向了main.js

// main.js

const electron = require('electron');
const app = electron.app;
const BrowserWindow = electron.BrowserWindow;

const path = require('path');
const url = require('url');

let mainWindow; // Keep this reference so the window doesn't close

function createWindow() {
  mainWindow = new BrowserWindow({width: 800, height: 800});
  mainWindow.loadURL(url.format({
    pathname: path.join(__dirname, 'index.html'),
    protocol: 'file:',
    slashes: true
  }));
  mainWindow.webContents.openDevTools();
  mainWindow.on('closed', () => {
    mainWindow = null;
  });
}

app.on('ready', createWindow);

app.on('window-all-closed', () => {
  app.quit();
});

你可以想象 Node 正在运行这个文件,尽管实际上运行它的可执行文件是 Electron(当然,Electron 内部包含了 Node 和 V8)。请注意代码如何可以引入熟悉的 Node 模块,比如pathurl,以及一些新的模块,比如electronmain.js中的代码创建了一个特殊的 Electron 浏览器窗口,宽 800 像素,高 800 像素,并将其导航到index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Hello, files</title>
  </head>
  <body>
    <p>
      <input type="button" value="Reload the app after changing the code" onClick="window.location.reload()"/>
    </p>
    <div id="app">
      <p>{{ location }}</p>
      <button @click="up">..</button>
      <listing v-for="file in files" v-bind:key="file.id" v-bind:item="file"></listing>
      <p><img v-bind:src="img/image"/></p>
    </div>
    <script src="img/vue"></script>
    <script>
      require('./renderer.js')
    </script>
  </body>
</html>

这看起来也很熟悉,符合我们在网络上的期望。我们将在本章后面讨论 Vue;现在,请注意页面顶部的重新加载按钮和末尾的script标签。

在开发时,按钮是很有用的。你可以通过点击重新加载按钮来查看对这个页面或它引入的 JavaScript 进行更改后的结果,而不是在命令行重新启动 Electron 进程。Electron 不显示 Chromium 的默认浏览器工具栏,其中包含重新加载按钮,但在 macOS 的菜单栏上有“查看,重新加载”,并且可以更容易地在页面上放置一个重新加载按钮。

要理解末尾的script标签,最好先对 Electron 的进程架构有一个基本的了解。

Electron 进程

由 Chromium 构建,Electron 继承了 Chromium(和 Chrome)的每个标签一个进程的架构。使用 Electron 运行我们的应用程序时,只有一个“标签”:你屏幕上的窗口,但仍然有两个进程。进程代表底层浏览器,你可以从命令行启动它,然后它读取package.json,然后运行main.js。Electron 的主进程可以创建新的BrowserWindow对象,并处理影响桌面应用程序整体生命周期的事件,从启动到关闭。

然而,在 Electron 打开的页面上,另一个进程,渲染器进程,运行其中的 JavaScript。只有渲染器进程能够执行与 GUI 相关的任务,比如操作 DOM。

Node 在两个进程中都可用。如果一个模块期望 DOM 存在,它可能无法在主进程中工作。例如,jQuery 在 Electron 的主进程中无法加载,但在渲染器进程中可以正常工作,而 Handlebars 在两者中都可以正常工作。

在某些情况下,一个 Electron 进程中的代码需要执行某个动作或从另一个进程中的代码获取答案,解决方案是 Node 的标准进程间通信工具,稍后在第七章中描述,使用多个进程。此外,Electron 方便地将其中一些封装在自己的 API 中。

渲染器进程

到目前为止,我们已经看到 Electron 启动,运行main.js,并打开index.html。总之,整个过程是这样工作的:

Electron 的进程执行以下操作:

  • 读取package.json,然后告诉它

  • 运行main.js

这会导致 Electron 启动一个渲染器进程来执行此操作:

  • 解析 index.html,然后

  • 运行renderer.js

让我们看看那里的代码:

// renderer.js

const Promise = require("bluebird");
const fs = Promise.promisifyAll(require("fs"));
const path = require("path");

Vue.component('listing', {
  props: ['item'],
  template: '<div @click="clicked(item.name)">{{ item.name }}</div>',
  methods: {
    clicked(n) {
      go(path.format({ dir: app.location, base: n }));
    }
  }
});

var app = new Vue({
  el: '#app',
  data: {
    location: process.cwd(),
    files: [],
    image: null
  },
  methods: {
    up() {
      go(path.dirname(this.location));
    }
  }
});

function go(p) {

  if (p.endsWith(".bmp") || p.endsWith(".png") || p.endsWith(".gif") || p.endsWith(".jpg")) {

    // Image
    app.image = "file://" + p; // Show it

  } else {

    // Non-image
    app.image = null;

    // See if it's a directory or not
    fs.lstatAsync(p).then((stat) => {

      if (stat.isDirectory()) {

        // Directory, list its contents
        app.location = p;
        fs.readdirAsync(app.location).then((files) => {
          var a = [];
          for (var i = 0; i < files.length; i++)
            a.push({ id: i, name: files[i] });
          app.files = a;
        }).catch((e) => {
          console.log(e.stack);
        });
      } else {
        // Non-directory, don't go there at all
      }
    }).catch((e) => {
      console.log(e.stack);
    });
  }
}

go(app.location);

首先,这段代码引入了 bluebird promise 库,并将其设置为Promise。对Promise.promisifyAll()的调用创建了诸如fs.lstatAsync()之类的函数,这是fs.lstat()的 promise 版本。

我们应用的核心逻辑被分解为一个名为go()的单个函数,该函数传递给应用程序想要查看的绝对文件系统路径。如果路径是一个图像,应用程序会在页面上显示它。如果路径是一个目录,应用程序会列出文件夹的内容。

为了执行这个逻辑,前面的代码首先简单地查找一个常见的图像文件扩展名。如果不存在,一个异步步骤会使用fs.lstatAsync()来查看磁盘,然后能够调用stat.isDirectory()。如果是一个目录,另一个 promise 调用fs.readdirAsync()会获取目录列表。

这是我们简单的 Electron 文件浏览器的运行情况:

Vue.js

我们应用的用户体验由Vue.js提供支持,这是一个用于构建和轻松更改网页内容的前端 JavaScript 框架。与 React 一样,Vue 允许您对组件进行模板化,将它们放在页面上,并在底层数据发生变化时进行更改。

React 使用 JSX 将 HTML 标记与 JavaScript 代码组合在一起。这需要一个预处理器,比如Babel,将 JSX 部分转译成 ES6 JavaScript。在典型的 React 堆栈中,webpack管理着一个构建过程,其中包括 Babel,将您的开发文件转换并组合成您将运行、测试和最终部署的文件。webpack 开发服务器会在您编写代码时显示您的网站,甚至在您更改代码时自动刷新。

然而,Vue 不需要一个转译步骤。您可以将它与 webpack 一起使用,但也可以只使用一个脚本标签,就像我们应用程序的index.html中的这个一样:

<script src="img/vue"></script>

这种灵活性使得使用 Vue 很容易入门,在 Electron 中运行 Vue 也很容易,这也是我们选择它作为这个示例应用程序的原因。

回到index.html页面,看看这些行:

<div id="app">
  <p>{{ location }}</p>
  <button @click="up">..</button>
  <listing v-for="file in files" v-bind:key="file.id" v-bind:item="file"></listing&gt;
  <p><img v-bind:src="img/image"/></p>
</div>
<script src="img/vue"></script>

此外,在renderer.js脚本中,看看这部分:

var app = new Vue({
  el: '#app',
  data: {
    location: process.cwd(),
    files: [],
    image: null
  },
  methods: {
    up() {
      go(path.dirname(this.location));
    }
  }
});

在页面中,<div id="app">标识div作为我们的应用程序,在脚本中,var app = new Vue({});创建了连接到并控制app div的新 JavaScript 对象。app内部的数据对象定义了出现在 div 中的值,因此也出现在页面上。例如,app.location,通过与上面的data对象的一些巧妙的内部链接,显示在{{ location }}出现的页面上。Vue 甚至会监视对data.location的更改-将其设置为一个新值,页面将自动更新。有了这个能力,Vue 被称为reactive

使用我们刚刚构建的文件浏览器在本地磁盘上浏览一下,并想象一下你现在可以使用 Node 和 Electron 创建的所有桌面应用程序。

在本章的开头,我们问过,“Electron 是什么?”并构思了不同的答案,想象了不同的利益相关者,并考虑了不同的观点。

Electron 让 JavaScript 离 Kris Kowal 在第一章中提到的语言目标更近了一步,即“理解 Node 环境”,这意味着能够在任何地方运行并做任何事情。此外,考虑到 JavaScript 在过去几十年的计算中的地位,它以一些讽刺的方式实现了这一目标。

Brendan Eich 在 1990 年代创建了 JavaScript,用于在个人电脑上运行的浏览器中的网页中脚本化小任务,这些电脑刚刚获得了位图显示和图形操作系统。在那里,JavaScript 被严格限制在浏览器标签的沙盒中。沙盒执行严格的安全要求,并限制了它,不能查看一些文件等。靠近用户和屏幕,JavaScript 可以验证表单数据,并实时更改 CSS。在生命的第一阶段,大多数时候,JavaScript 都在动画化一些文本。

Node 将 JavaScript 带到了服务器,使其远离了图形屏幕,但也使其摆脱了浏览器的限制。在那里,JavaScript 成为了一种能干而完整的系统语言,访问文件和套接字以执行有用和强大的任务。在生命的第二阶段,大多数时候,JavaScript 迁移了数据库。

Electron 将 JavaScript 带回了客户端。就像一个漂泊的封建武士在被流放多年后返回家乡一样,JavaScript 带着 ES6 功能和在服务器荒原上开发的 npm 模块回来了,它与强大的伙伴(有时也是敌人)如 C++和 Java 一起被使用和开发。在桌面上并且拥有 Electron 的支持,它可以在浏览器的受限范围之外使用这些能力。在生命的第三阶段,JavaScript 真的可以做任何事情。

总结

在本章中,我们看到了 Node 的 API 是对本地文件系统绑定的全面映射,为开发人员提供了完整的功能范围,同时需要非常少的代码或复杂性。此外,我们还看到文件如何轻松地包装成Stream对象,以及这种与 Node 设计的一致性如何简化了不同类型 I/O 之间的交互,比如网络数据和文件之间的交互。使用 Electron,我们构建了一个作为跨平台本地应用程序运行的文件浏览器,为 Node 开发人员打开了一个全新的世界。

我们还学到了一些关于如何使用 Node 构建服务器,以满足常规客户端的期望,轻松实现文件上传和资源缓存。在介绍了 Node 的关键特性之后,现在是时候在构建能够处理成千上万客户端的大型应用程序中使用这些技术了。

第五章:管理许多同时客户连接

“如果每个人都帮助撑起天空,那么一个人就不会感到疲倦。”

  • Tshi 谚语

在网络软件的不可预测和突发环境中管理成千上万个同时客户事务的同时保持高吞吐量是开发人员对他们的 Node 实现的一个期望。鉴于历史上失败和不受欢迎的解决方案,处理并发问题甚至被赋予了自己的数字缩写:“C10K 问题”。应该如何设计能够自信地为 10,000 个同时客户提供服务的网络软件?

如何构建高并发系统的最佳方法的问题在过去几十年引发了许多理论争论,主要是在线程和事件之间。

“线程允许程序员编写直线代码,并依赖操作系统通过透明地在线程之间切换来重叠计算和 I/O。另一种选择,事件,允许程序员通过将代码结构化为一个单线程处理程序来显式地管理并发,以响应事件(如非阻塞 I/O 完成、特定于应用程序的消息或定时器事件)。”

- “高并发系统的设计框架”  (韦尔什,格里布尔,布鲁尔和卡勒,2000),第 2 页。

在上述引用中提出了两个重要观点:

  • 开发人员更喜欢编写结构化代码(直线;单线程),以尽可能隐藏多个同时操作的复杂性

  • I/O 效率是高并发应用的主要考虑因素

直到最近,编程语言和相关框架并不是(必然)针对在分布式网络或甚至跨处理器上执行的软件进行优化。算法应该是确定性的;写入数据库的数据应该立即可供阅读。在这个时代的最终一致性数据库和异步控制流中,开发人员不能再期望在任何给定时间点知道应用程序的精确状态;这对高并发系统的架构师来说是一种有时令人费解的挑战。

正如我们在第二章中所学到的,理解异步事件驱动编程,Node 的设计试图结合线程和事件的优势,通过在单个线程上为所有客户提供服务(一个包装 JavaScript 运行时的事件循环),同时将阻塞工作(I/O)委托给一个优化的线程池,通过事件通知系统通知主线程状态变化。

清楚地思考以下 HTTP 服务器实现,运行在单个 CPU 上,通过将回调函数包装在请求的上下文中,并将执行上下文推送到一个不断被清空和重建的堆栈中,该堆栈绑定到事件循环的单个线程中,以响应每个客户请求:

require('http').createServer((req, res) => {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('Hello client from ${req.connection.remoteAddress}`);
  console.log(req);
}).listen(8000);

从图上看,情况是这样的:

另一方面,像 Apache 这样的服务器为每个客户请求启动一个线程:

这两种方法非常不同。Node 设计中隐含的声明是:当程序流沿着单个线程组织时,更容易推理高并发软件,并且即使在单线程执行模型中,减少 I/O 延迟也会增加可以支持的同时客户数量。第二个声明将在以后进行测试,但现在,让我们看看构建自然扩展的基本流程有多容易。

我们将演示如何使用 Node 跟踪和管理并发进程之间的关系,特别是那些同时为多个客户提供服务的进程。我们的目标是建立对在 Node 服务器或其他进程中如何对状态进行建模的基本理解。一个大型在线社交网络如何为您提供根据您的友谊或兴趣定制的信息?您的购物车如何在多次购物会话中保持不变,甚至包含基于您的购买历史的建议?一个客户端如何与其他客户端进行交互?

理解并发性

我们都会同意世界上有意想不到的事件,其中许多事件恰好发生在同一时间。很明显,任何给定系统的状态可能由任意数量的子状态组成,即使是微小的状态变化的全部后果也很难预测——蝴蝶煽动翅膀的力量足以将一个更大的系统推入另一个状态。此外,我们也知道,系统的体积和形状随着时间的推移以难以预测的方式发生变化。

在他 1981 年撰写的博士论文《*Actor 语义的基础》中,William Clinger 提出他的工作是:

“……受到高度并行计算机的前景的激励,这些计算机由数十、数百甚至数千个独立的微处理器组成,每个微处理器都有自己的本地存储器和通信处理器,通过高性能通信网络进行通信。”

事实证明,Clinger 有所发现。并发是由许多同时执行的操作组成的系统的属性,我们现在正在构建的网络软件类似于他所设想的,只是规模更大,数百甚至数千是下限,而不是上限。

Node 使并发变得容易访问,同时可以跨多个核心、多个进程和多台机器进行扩展。重要的是要注意,Node 对程序的简单性和一致性的重视程度与成为最快解决方案的重视程度一样高,通过采用和强制非阻塞 I/O 来提供高并发性,以及通过设计良好和可预测的接口。这就是 Dahl 说的“Node 的目标是提供一种构建可扩展网络程序的简单方法”的意思。

令人高兴的是,Node 非常快。

并发不等于并行。

将问题分解为较小的问题,将这些较小的问题分散到一个可用的人员或工人池中并行处理,并同时交付并行的结果,可以解决问题。

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

Rob Pike,一位通用的巫师黑客和 Google Go 编程语言的共同发明者,以这种方式定义并发:

“并发是一种构造事物的方式,使您可以可能使用并行性来做得更好。但并行性不是并发的目标;并发的目标是一个良好的结构。”

成功的高并发应用程序开发框架提供了一种简单而富有表现力的词汇,用于描述这样的系统。

Node 的设计表明,实现其主要目标——提供一种构建可扩展网络程序的简单方法——包括简化共存进程的执行顺序的结构和组合。Node 帮助开发人员更好地组织他们的代码,解决了在一个程序中同时发生许多事情(比如为许多并发客户提供服务)的问题。

这并不是说 Node 是为了保持简单的接口而设计的,而牺牲效率——恰恰相反。相反,这个想法是将实现高效并行处理的责任从开发人员转移到系统的核心设计中,使开发人员可以通过简单和可预测的回调系统来构建并发,远离死锁和其他陷阱。

Node 的简洁来得正是时候,因为社交和社区网络与世界数据一起增长。系统正在被扩展到很少有人预测的规模。现在是进行新思考的好时机,比如如何描述和设计这些系统,以及它们如何相互请求和响应。

请求路由

HTTP 是建立在请求/响应模型之上的数据传输协议。使用这个协议,我们中的许多人向朋友传达我们的当前状态,为家人买礼物,或者与同事通过电子邮件讨论项目。令人震惊的是,许多人已经开始依赖这个基础性的互联网协议。

通常,浏览器客户端会向服务器发出 HTTP GET 请求。然后服务器返回所请求的资源,通常表示为 HTML 文档。HTTP 是无状态的,这意味着每个请求或响应都不保留先前请求或响应的信息——通过网页的前后移动,整个浏览器状态都会被销毁并从头开始重建。

服务器从客户端路由状态更改请求,最终导致返回新的状态表示,客户端(通常是浏览器)重新绘制或报告。当 WWW 首次构想时,这个模型是有意义的。在很大程度上,这个新网络被理解为一个分布式文件系统,任何人都可以通过网络浏览器访问,可以通过 HTTP 请求(例如 GET)从网络上的某个位置(Internet Protocol 或 IP 地址)的文件服务器计算机(服务器)请求特定资源(例如报纸文章),只需输入 URL(例如www.example.org/articles/april/showers.html)。用户请求一个页面,页面出现,可能包含到相关页面的(超)链接。

然而,由于无状态协议不保留上下文信息,服务器操作员几乎不可能在一系列请求中与访问者建立更有趣的关系,或者访问者动态地将多个响应聚合成一个视图。

此外,请求的表达能力受到协议本身的限制,也受到服务器内容不足以有用地支持更具描述性词汇的限制。在很大程度上,请求就像指着一个对象说“给我那个”。考虑典型 URL 的部分:

我们可以看到,在描述简单资源位置时,客户端工作量很大,查询参数和资源目标成为一个笨拙的事后想法,一旦使用了多个资源描述符,几乎变得无法使用。虽然在简单文档和不变的层次结构的时代,这是可行的,但现代网络软件的需求和复杂性使原始概念变得不可行并需要改进。

传递越来越复杂的键/值对以维护用户状态的笨拙性开始让这个新媒介的抱负受挫。很快,开发人员意识到,对互联网作为世界信息、软件和商业的实用通信层的日益依赖需要更精细的方法。

随着时间的推移,这些限制已经通过对 HTTP 协议的改进、引入 JavaScript 到浏览器、诸如浏览器 cookie 等技术以及开发人员构建产品和服务来利用这些进步的创新的结合而被克服。

然而,HTTP 协议本身仍然被个体文件样式资源存在于一个独特和永久路径,并由一个通常不具描述性的名称标识的相同主题所支配。

现在许多服务器上实际存在的是一个复杂的软件,指定了数据模型的网络接口。与这些类型的网络应用程序通信涉及到获取和设置该数据模型的状态,无论是一般的还是特定于向发出请求的客户端的状态。

部署实时解决方案的客户端在服务器上设置和获取资源状态表示。应用服务器必须在每个请求中报告客户端的状态与多个进程(数据库、文件、规则引擎、计算引擎等)的关系,并且通常在应用状态发生变化时单方面报告(例如,用户失去访问权限)。客户端通常不是浏览器,而是其他服务器。它们应该如何通信?

理解路线

路由将 URL 映射到操作。与构建应用程序界面以 URL 路径到包含一些逻辑的特定文件的方式不同,使用路由进行设计涉及将特定功能分配给 URL 路径和请求方法的不同组合。例如,一个接受城市列表请求的 Web 服务可能以这种方式被调用:

GET /services/cities.php?country=usa&state=ohio 

当您的服务器收到此请求时,它将把 URL 信息传递给一个 PHP 进程,该进程将执行cities.php中的应用逻辑,比如读取查询、解析国家和州、调用数据库、构建响应并返回。Node 具有作为服务器和应用环境的双重优势。服务器可以直接处理请求。因此,使用 URL 作为简单的意图陈述更有意义:

GET /listCities/usa/ohio 

在 Node 服务器中,我们可能会使用以下代码来处理这些城市的请求:

let app = http.createServer((request, response) => {
  let url = request.url;
  let method = request.method;
  if (method === "GET") {
    if (url === "/listCities/usa/ohio") {
      database.call("usa","ohio", (err, data) => {
        response.writeHead(200, {'Content-Type': 'application/json' });
        // Return list of cities in Ohio, USA
        response.end(JSON.stringify(data));
      });
    }
    if (url === "/listCities/usa/arizona") { ... }
    if (url === "/listCities/canada/ontario") { ... }
  }
})

有一个好的和一个坏的跳出来:

  • URL 处理清晰地组织在一个地方

  • 代码是不可思议的重复

写出每种可能的路线是行不通的。我们将保持组织,但需要在路线中创建变量,更倾向于定义一个通用的路线表达式,如下所示:

/listCities/:country/:state 

方法listCities可以接受countrystate 变量参数,用冒号(:)前缀标识。在我们的服务器中,我们需要将这个符号表达式转换成正则表达式。在这种情况下,RegExp /^\/listCities\/([^\/\.]+)\/([^\/\.]+)\/?$/可以用来从我们的示例 URL 中提取有序值,形成一个类似于值映射的值映射:

{ country: "usa", state: "ohio" } 

通过将请求视为表达式,我们的服务器设计变得更加理智,将任何国家/州的组合都很好地路由到一个公共处理程序函数:

if (request.method === "GET") {
  let match = request.url.match(/^\/listCities\/([^\/\.]+)\/([^\/\.]+)\/?$/);
  if (match) {
    database.call(match[1],match[2],function(err, data) {…}
  }
}

这种形式的请求路由在 Node 社区中赢得了争论,成为各种框架和工具的默认行为。事实上,这种关于路由请求的思考方式已经在许多其他开发环境中得到了接受,比如 Ruby on Rails。因此,大多数 Node 的 Web 应用程序框架都是围绕路由开发的。

Node 最流行的 Web 应用程序框架是 T.J. Holowaychuk 的 Express 框架,我们将在本书中经常使用这个框架来设计路由服务器。您可以通过运行npm install express来安装它。

使用 Express 路由请求

Express 简化了定义路由匹配例程的复杂性。我们的示例可能以以下方式使用 Express 编写:

const express = require('express');
let app = express();
app.get('/listCities/:country/:state', (request, response) => {
  let country = request.params.country;
  let state = request.params.state;
  response.end(`You asked for country: ${country}and state: ${state}`);
});
app.listen(8080);

GET /listCities/usa/ohio
// You asked for country: usa and state: ohio
GET /didnt/define/this
// Cannot GET /didnt/define/this
GET /listCities // note missing arguments
// Cannot GET /listCities

实例化 Express 提供了一个完全成型的 Web 服务器,包装在一个易于使用的应用程序开发 API 中。我们的城市服务已经清晰定义,并声明了其变量,期望通过 GET 调用(也可以使用app.post(...)app.put(...),或任何其他标准的HTTP方法)。

Express 还引入了请求处理程序链的概念,在 Express 中被理解为中间件。在我们的示例中,我们调用一个单个函数来处理城市请求。如果在调用数据库之前,我们想要检查用户是否经过身份验证呢?我们可以在主要服务方法之前添加一个authenticate()方法:

let authenticate = (request, response, next) => {
  if (validUser) {
    next();
  } else {
    response.end("INVALID USER!");
  }
}
app.get('/listCities/:country/:state', authenticate, (request, response) => { ... });

中间件可以链接,换句话说,简化了复杂执行链的创建,很好地遵循了模块化规则。已经开发了许多类型的中间件,用于处理网站图标、日志记录、上传、静态文件请求等。要了解更多,请访问:expressjs.com/

在为 Node 服务器配置路由请求的正确方式已经建立之后,我们现在可以开始讨论如何识别发出请求的客户端,为该客户端分配一个唯一的会话 ID,并通过时间管理该会话。

使用 Redis 跟踪客户端状态

在本章的一些应用程序和示例中,我们将使用Redis,这是由Salvatore Sanfilippo开发的内存键/值(KV)数据库。有关 Redis 的更多信息,请访问:redis.io。Redis 的一个知名竞争对手是Memcachedmemcached.org)。

一般来说,任何必须维护许多客户端会话状态的服务器都需要一个高速数据层,具有几乎即时的读/写性能,因为请求验证和用户状态转换可能在每个请求上发生多次。传统的基于文件的关系数据库在这个任务上往往比内存 KV 数据库慢。我们将使用 Redis 来跟踪客户端状态。

Redis 是一个在内存中运行的单线程数据存储。它非常快,专注于实现多个数据结构,如哈希、列表和集合,并对这些数据执行操作(如集合交集和列表推送和弹出)。有关安装 Redis 的说明,请访问:redis.io/topics/quickstart

与 Redis 交互:

$ redis-cli 

值得注意的是,亚马逊的 ElastiCache 服务可以将 Redis 作为内存缓存“云”化,具有自动扩展和冗余功能,网址为:aws.amazon.com/elasticache/

Redis 支持预期操作的标准接口,例如获取或设置键/值对。要get存储在键上的值,请首先启动 Redis CLI:

 $ redis-cli
 redis> get somerandomkey
 (nil)

当键不存在时,Redis 会返回(nil)。让我们set一个键:

redis> set somerandomkey "who am I?"
redis> get somerandomkey
"who am I?"

要在 Node 环境中使用 Redis,我们需要某种绑定。我们将使用 Matt Ranney 的node_redis模块。使用以下命令行通过 npm 安装它:

$ npm install redis 

要在 Redis 中设置一个值并再次获取它,我们现在可以在 Node 中这样做:

let redis = require("redis");
let client = redis.createClient();
client.set("userId", "jack", (err) => {
  client.get("userId", (err, data) => {
    console.log(data); // "jack"
  });
});

存储用户数据

管理许多用户意味着至少跟踪他们的用户信息,一些长期存储(例如地址、购买历史和联系人列表),一些会话数据短期存储(自登录以来的时间、最后一次游戏得分和最近的答案)。

通常,我们会创建一个安全的接口或类似的东西,允许管理员创建用户帐户。读者在本章结束时将清楚如何创建这样的接口。在接下来的示例中,我们只需要创建一个用户,作为志愿者。让我们创建Jack

redis> hset jack password "beanstalk"
redis> hset jack fullname "Jack Spratt"

这将在 Redis 中创建一个键—Jack—包含一个类似的哈希:

{
  "password": "beanstalk",
  "fullname": "Jack Spratt"
}

如果我们想要创建一个哈希并一次添加多个 KV 对,我们可以使用hmset命令来实现前面的操作:

redis> hmset jack password "beanstalk" fullname "Jack Spratt"

现在,Jack存在了:

redis> hgetall jack
 1) "password"
 2) "beanstalk"
 3) "fullname"
 4) "Jack Spratt"

我们可以使用以下命令来获取存储在 Jack 账户中特定字段的值:

redis> hget jack password // "beanstalk"

处理会话

服务器如何知道当前客户端请求是否是先前请求链的一部分?Web 应用程序通过长事务链与客户端进行交互——包含要购买的商品的购物车即使购物者离开进行一些比较购物也会保留。我们将称之为会话,其中可能包含任意数量的 KV 对,例如用户名、产品列表或用户的登录历史。

会话是如何开始、结束和跟踪的?有许多方法可以解决这个问题,这取决于不同体系结构上存在的许多因素。特别是,如果有多个服务器用于处理客户端,那么会话数据是如何在它们之间共享的?

我们将使用 cookie 来存储客户端的会话 ID,同时构建一个简单的长轮询服务器。请记住,随着应用程序的复杂性增加,这个简单的系统将需要扩展。此外,长轮询作为一种技术正在为我们在讨论实时系统构建时将要探索的更强大的套接字技术所取代。然而,在服务器上同时保持许多连接的客户端,并跟踪它们的会话时所面临的关键问题应该得到证明。

Cookie 和客户端状态

Netscape 在 1997 年提供了有关 cookie 的初步规范:

根据web.archive.org/web/20070805052634/http://wp.netscape.com/newsref/std/cookie_spec.html,“Cookie 是一种通用机制,服务器端连接(如 CGI 脚本)可以使用它来存储和检索与连接的客户端一侧有关的信息。简单、持久的客户端状态的添加显著扩展了基于 Web 的客户端/服务器应用程序的功能。服务器在向客户端返回 HTTP 对象时,还可以发送一个状态信息片段,客户端将存储该状态。该状态对象包括一个描述该状态有效的 URL 范围。客户端以后在该范围内发出的任何 HTTP 请求都将包括将当前状态对象的值从客户端传输回服务器。状态对象称为 cookie,没有强制的原因。”

在这里,我们首次尝试修复HTTP 的无状态性,特别是会话状态的维护。这是一个很好的尝试,它仍然是 Web 的一个基本部分。

我们已经看到如何使用 Node 读取和设置 cookie 头。Express 使这个过程变得更容易:


const express = require('express');
const cookieParser = require('cookie-parser');
const app = express();

app.use(cookieParser());

app.get('/mycookie', (request, response) => {
   response.end(request.cookies.node_cookie);
});

app.get('/', (request, response) => {
   response.cookie('node_cookie', parseInt(Math.random() * 10e10));
   response.end("Cookie set");
});

app.listen(8000);

注意use方法,它允许我们为 Express 打开 cookie 处理中间件。在这里,我们看到每当客户端访问我们的服务器时,该客户端都会被分配一个随机数作为 cookie。通过导航到/mycookie,该客户端可以看到 cookie。

一个简单的轮询

接下来,让我们创建一个并发环境,一个有许多同时连接的客户端。我们将使用一个长轮询服务器来做到这一点,通过stdin向所有连接的客户端进行广播。此外,每个客户端将被分配一个唯一的会话 ID,用于标识客户端的http.serverResponse对象,我们将向其推送数据。

长轮询是一种技术,其中服务器保持与客户端的连接,直到有数据可发送。当数据最终发送到客户端时,客户端重新连接到服务器,进程继续进行。它被设计为对短轮询的改进,短轮询是盲目地每隔几秒钟检查一次服务器是否有新信息的低效技术,希望有新数据。长轮询只需要在向客户端传递实际数据后重新连接。

我们将使用两个路由。第一个路由使用斜杠(/)描述,即根域请求。对该路径的调用将返回一些形成客户端 UI 的 HTML。第二个路由是/poll,客户端将使用它在接收到一些数据后重新连接服务器。

客户端 UI 非常简单:它的唯一目的是向服务器发出 XML HTTP 请求(XHR)(服务器将保持该请求直到接收到一些数据),在接收到一些数据后立即重复此步骤。我们的 UI 将在无序列表中显示接收到的消息列表。对于 XHR 部分,我们将使用 jQuery 库。可以使用任何类似的库,并且构建纯 JavaScript 实现并不困难。

HTML:

<ul id="results"></ul> 

JavaScript:

function longPoll() {
  $.get('http://localhost:2112/poll', (data) => {
    $('<li>' + data + '</li>').appendTo('#results');
    longPoll();
  });
}
longPoll();

在上面的客户端代码中,您应该看到这将如何工作。客户端对/poll 进行 GET 调用,并将等待直到接收到数据。一旦接收到数据,它将被添加到客户端显示,并进行另一个/poll 调用。通过这种方式,客户端保持与服务器的长连接,并且仅在接收到数据后重新连接。

服务器也很简单,主要负责设置会话 ID 并保持并发客户端连接,直到数据可用,然后将数据广播到所有连接的客户端。数据通过 redis pub/sub 机制可用。这些连接通过会话 ID 进行索引,使用 cookie 进行维护:

const fs = require('fs');
const express = require('express');
const cookieParser = require('cookie-parser');
const redis = require("redis");
const receiver = redis.createClient();
const publisher = redis.createClient();
const app = express();

app.use(cookieParser());

let connections = {};

app.get('/poll', (request, response) => {
   let id = request.cookies.node_poll_id;
   if(!id) {
      return;
   }
   connections[id] = response;
});

app.get('/', (request, response) => {
    fs.readFile('./poll_client.html', (err, data) => {
       response.cookie('node_poll_id', Math.random().toString(36).substr(2, 9));
        response.writeHead(200, {'Content-Type': 'text/html'});
        response.end(data);
    });
});

app.listen(2112);

receiver.subscribe("stdin_message");
receiver.on("message", (channel, message) => {
   let conn;
   for(conn in connections) {
      connections[conn].end(message);
   }
    console.log(`Received message: ${message} on channel: ${channel}`);
});

process.stdin.on('readable', function() {
   let msg = this.read();
   msg && publisher.publish('stdin_message', msg.toString());
});

在命令行上运行此服务器,并通过浏览器连接到服务器(http://localhost:2112)。将显示一个带有文本“Results:”的页面。返回到命令行并输入一些文本-此消息应立即显示在您的浏览器中。当您在命令行上继续输入时,您的消息将被路由到连接的客户端。您也可以尝试使用多个客户端进行此操作--请注意,您应该使用不同的浏览器,隐身模式或其他方法来区分每个客户端。

虽然这是用于演示的玩具服务器(您可能不应该使用长轮询--更好的选项在第六章中提出,创建实时应用程序),但最终应该看到如何使用一些业务逻辑来更新状态,然后捕获这些状态更改事件,然后使用类似 Redis pub/sub 的机制广播到监听客户端。

验证连接

与建立客户端会话对象相结合,Node 服务器通常需要身份验证凭据。Web 安全的理论和实践是广泛的。

我们希望将我们的理解简化为两种主要的身份验证场景:

  • 当传输协议是 HTTPS 时

  • 当它是 HTTP 时

第一个自然是安全的,第二个不是。对于第一个,我们将学习如何在 Node 中实现基本身份验证,对于第二个,将描述一种挑战-响应系统。

基本身份验证

如前所述,基本身份验证在传输中发送包含用户名/密码组合的明文,使用标准 HTTP 头。这是一个简单而广为人知的协议。发送正确头的任何服务器都将导致任何浏览器显示登录对话框,如下所示:

尽管如此,这种方法仍然不安全,在传输中发送非加密的明文数据。为了简单起见,我们将在 HTTP 服务器上演示此身份验证方法,但必须强调的是,在实际使用中,服务器必须通过安全协议进行通信,例如 HTTPS。

让我们使用 Node 实现此身份验证协议。利用之前在 Redis 中开发的用户数据库,我们通过检查用户对象以验证提交的凭据,处理失败和成功来验证提交的凭据:

http.createServer(function(req, res) {

   let auth = req.headers['authorization']; 
   if(!auth) {   
      res.writeHead(401, {'WWW-Authenticate': 'Basic realm="Secure Area"'});
      return res.end('<html><body>Please enter some credentials.</body></html>');
   }

   let tmp = auth.split(' ');   
   let buf = Buffer.from(tmp[1], 'base64'); 
   let plain_auth = buf.toString();   
   let creds = plain_auth.split(':'); 
   let username = creds[0];

   // Find this user record
   client.get(username, function(err, data) {
      if(err || !data) {
         res.writeHead(401, {'WWW-Authenticate': 'Basic realm="Secure Area"'});
         return res.end('<html><body>You are not authorized.</body></html>');
      }
      res.statusCode = 200;
      res.end('<html><body>Welcome!</body></html>');
   });
}).listen(8080);

通过在新的客户端连接上发送401状态和'authorization'头,将创建一个类似于上一个屏幕截图的对话框,通过这段代码:

  res.writeHead(401, {'WWW-Authenticate': 'Basic realm="Secure Area"'});
  return res.end('<html><body>Please enter some credentials.</body></html>');

通过这种方式,可以设计一个简单的登录系统。由于浏览器会自然地提示用户请求访问受保护的域,甚至登录对话框也会被处理。

握手

在无法建立 HTTPS 连接的情况下考虑的另一种身份验证方法是挑战/响应系统:

在这种情况下,客户端请求服务器访问特定用户、用户名、ID 或类似的内容。通常,这些数据将通过登录表单发送。让我们模拟一个挑战/响应场景,使用我们之前创建的用户 Jack 作为示例。

挑战/响应系统的一般设计和目的是避免在网络上传输任何明文密码数据。因此,我们需要决定一个加密策略,客户端和服务器都共享。在我们的示例中,让我们使用 SHA256 算法。Node 的 crypto 库包含了创建这种类型哈希所需的所有工具。客户端可能没有,所以我们必须提供一个。我们将使用由 Chris Veness 开发的一个,可以从以下链接下载:github.com/chrisveness/crypto/blob/master/sha256.js.

要启动此登录,客户端需要为用户 Jack 发送身份验证请求:

GET /authenticate/jack 

作为响应,客户端应该收到服务器生成的公钥——挑战。现在,客户端必须形成一个以此键为前缀的 Jack 的密码字符串。从中创建一个 SHA256 哈希,并将生成的哈希传递给/login/。服务器也将创建相同的 SHA256 哈希——如果两者匹配,则客户端已经通过身份验证:

<script src="img/sha256.js"></script>
<script>
$.get("/authenticate/jack", (publicKey) => {
    if (publicKey === "no data") {
    return alert("Cannot log in.");
  }
  // Expect to receive a challenge: the client should be able to derive a SHA456 hash
  // String in this format: publicKey + password. Return that string.
  let response = Sha256.hash(publicKey + "beanstalk");
  $.get("/login/" + response, (verdict) => {
    if (verdict === "failed") {
      return alert("No Dice! Not logged in.");
    }
    alert("You're in!");
  });
});
</script>

服务器本身非常简单,由两个提到的身份验证路由组成。我们可以在以下代码中看到,当收到用户名(jack)时,服务器将首先检查 Redis 中是否存在用户哈希,如果找不到这样的数据,则中断握手。如果记录存在,我们创建一个新的随机公钥,组成相关的 SHA256 哈希,并将此挑战值返回给客户端。此外,我们将此哈希设置为 Redis 中的一个键,其值为发送的用户名:

const crypto = require('crypto');
const fs = require('fs');
const express = require('express');
const redis = require("redis");

let app = express();
let client = redis.createClient();

app.get('/authenticate/:username', (request, response) => {
  let publicKey = Math.random();
  let username = request.params.username; // This is always "jack"
  // ... get jack's data from redis
  client.hgetall(username, (err, data) => {
    if (err || !data) {
      return response.end("no data");
    }
    // Creating the challenge hash
    let challenge = crypto.createHash('sha256').update(publicKey + data.password).digest('hex');
    // Store challenge for later match
    client.set(challenge, username);
    response.end(challenge);
  });
});
app.get('/login/:response', (request, response) => {
  let challengehash = request.params.response;
  client.exists(challengehash, (err, exists) => {
    if (err || !exists) {
    return response.end("failed");
    }
  });
  client.del(challengehash, () => {
    response.end("OK");
  });
});

/login/路由处理程序中,我们可以看到如果响应存在于 Redis 中,则会进行检查,并且如果找到,则立即删除该键。这是有几个原因的,其中之一是防止其他人发送相同的响应并获得访问权限。我们也通常不希望这些现在无用的键堆积起来。这带来了一个问题:如果客户端从不响应挑战会怎么样?由于键清理仅在进行/login/尝试时发生,因此此键将永远不会被删除。

与大多数 KV 数据存储不同,Redis 引入了键过期的概念,其中设置操作可以为键指定生存时间TTL)。例如,在这里,我们使用setex命令将键userId设置为值183,并指定该键应在一秒后过期:

 client.setex("doomed", 10, "story", (err) => { ... }); 

这个功能为我们的问题提供了一个很好的解决方案。通过用以下行替换client.set(challenge, username);行:

client.setex(challenge, 5, username); 

我们确保无论如何,这个键都会在5秒内消失。以这种方式做事也可以作为一种轻量级的安全措施,留下一个非常短的时间窗口使响应保持有效,并自然地怀疑延迟的响应。

使用 JSON Web 令牌进行身份验证

基本的身份验证系统可能需要客户端在每个请求上发送用户名和密码。要启动基于令牌的身份验证会话,客户端只需发送一次凭据,然后收到一个令牌作为交换,并在随后的请求中只发送该令牌,获取该令牌提供的任何访问权限。不再需要不断传递敏感凭据。

JWT 的一个特别优势是,服务器不再需要维护一个共同的凭据数据库,因为只有发行机构需要验证初始登录。在使用 JWT 时,无需维护会话存储。因此,发行的令牌(可以将其视为访问卡)可以在任何识别和接受它的域(或服务器)内使用。在性能方面,现在请求的成本是解密哈希的成本,而不是进行数据库调用来验证凭据的成本。我们还避免了在移动设备上使用 cookie 时可能遇到的问题,跨域问题(cookie 是与域名绑定的),某些类型的请求伪造攻击等。

如果您想要与 Express 集成,express-jwt模块可能会很有用:github.com/auth0/express-jwt

让我们看一下 JWT 的结构,然后构建一个简单的示例,演示如何发出,验证和使用 JWT 来管理会话。

JWT 令牌具有以下格式:

<base64-encoded header>.<base64-encoded claims>.<base64-encoded signature>

每个部分都以 JSON 格式描述。header只是描述令牌的类型和加密算法。考虑以下示例:

{ 
  "typ":"JWT", 
  "alg":"HS256" 
}

在这里,我们声明这是一个 JWT 令牌,使用 HMAC SHA-256 进行加密。有关加密的更多信息,请参阅nodejs.org/api/crypto.html,以及如何在 Node 中执行加密。JWT 规范本身可以在以下网址找到:tools.ietf.org/html/rfc7519

claims部分概述了安全性和其他约束条件,任何接收 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 的sub(主题)标识 JWT 的主题,例如应用程序的用户——一个永远不会重新分配的唯一值,例如发行服务的名称和唯一用户 ID。

最后,使用任何您喜欢的键/值对附加一些有用的数据。在这里,让我们称之为令牌数据 sessionData。请注意,我们需要加密这些数据——JWT 的签名部分防止篡改会话数据,但 JWT 本身并不加密(尽管您始终可以加密整个令牌本身)。

最后一步是创建一个签名,如前所述,防止篡改——JWT 验证器专门检查签名和接收到的数据包之间的不匹配。

接下来是一个示例服务器和客户端的框架,演示如何实现基于 JWT 的身份验证系统。我们将使用jwt-simple包来实现各种签名和验证步骤,而不是手动实现。随时浏览您的代码包中的/jwt文件夹,其中包含我们将在接下来解压缩的完整代码。

要请求令牌,我们将使用以下客户端代码:

function send(route, formData, cb) {
  if(!(formData instanceof FormData)) {
    cb = formData;
    formData = new FormData();
  }
  let caller = new XMLHttpRequest();
  caller.onload = function() {
     cb(JSON.parse(this.responseText));
  };
  caller.open("POST", route);
  token && caller.setRequestHeader('Authorization', 'Bearer ' + token);
  caller.send(formData);
}

当我们以某种方式收到usernamepassword时:

formData = new FormData();
formData.append("username", "sandro");
formData.append("password", 'abcdefg');

send("/login", formData, function(response) {
  token = response.token;
  console.log('Set token: ' + token);
});

接下来我们将实现服务器代码。现在,请注意我们有一个发送方法,该方法在某个时候期望有一个全局令牌设置,以便在进行请求时传递。最初的/login是我们请求该令牌的地方。

使用 Express,我们创建以下服务器和/login路由:

const jwt = require('jwt-simple');
const app = express();
app.set('jwtSecret', 'shhhhhhhhh');

...

app.post('/login', auth, function(req, res) {
   let nowSeconds     = Math.floor(Date.now()/1000);
   let plus7Days  = nowSeconds + (60 * 60 * 24 * 7);
   let token = jwt.encode({
      "iss" : "http://blogengine.com", 
      "aud" : ["http://blogsearch.com", "http://blogstorage"],
      "sub" : "blogengine:uniqueuserid",
      "iat" : nowSeconds,
      "exp" : plus7Days,
      "sessionData" : encrypt(JSON.stringify({
         "department" : "sales"
      }))
   }, app.get('jwtSecret'));

   res.send({
      token : token
   })
})

请注意,我们将jwtsecret存储在应用服务器上。这是在签署令牌时使用的密钥。当尝试登录时,服务器将返回jwt.encode的结果,该结果编码了前面讨论过的 JWT 声明。就是这样。从现在开始,任何客户端只要向正确的受众提到这个令牌,就可以与这些受众成员提供的任何服务进行交互,有效期为自发行日期起的 7 天。这些服务将实现类似以下内容的内容:

app.post('/tokendata', function(req, res) { 
   let </span>token = req.get('Authorization').replace('Bearer ', '');
   let decoded = jwt.decode(token, app.get('jwtSecret'));
   decoded.sessionData = JSON.parse(decrypt(decoded.sessionData));
   let now = Math.floor(Date.now()/1000);
   if(now > decoded.exp) {
      return res.end(JSON.stringify({
         error : "Token expired"
      }));
   }
   res.send(decoded)
});

在这里,我们只是获取Authorization头(去掉Bearer)并通过jwt.decode进行解码。服务至少必须检查令牌是否过期,我们通过比较自纪元以来的当前秒数和令牌的过期时间来实现这一点。使用这个简单的框架,您可以创建一个易于扩展的身份验证/会话系统,使用安全标准。不再需要维护与公共凭据数据库的连接,个别服务(可能部署为微服务)可以使用 JWT 验证请求,而几乎不会产生 CPU、延迟或内存成本。

总结

Node 提供了一组工具,可帮助设计和维护面对 C10K 问题的大规模网络应用程序。在本章中,我们已经迈出了第一步,创建了具有许多同时客户端的网络应用程序,跟踪它们的会话信息和凭据。这种并发性的探索展示了一些路由、跟踪和响应客户端的技术。我们提到了一些简单的扩展技术,例如使用 Redis 数据库构建的发布/订阅系统来实现进程内消息传递。我们还提到了各种认证机制,从基本认证到基于 JSON Web Tokens 的基于令牌的认证。

我们现在准备深入探讨实时软件的设计——在使用 Node 实现高并发和低延迟之后的逻辑下一步。我们将扩展我们在长轮询讨论中概述的想法,并将它们放在更健壮的问题和解决方案的背景下。

进一步阅读

并发性和并行性是丰富的概念,经过了严格的研究和辩论。当应用架构设计支持线程、事件或某种混合时,架构师很可能对这两个概念持有看法。鼓励您深入理论,阅读以下文章。对辩论的准确理解将提供一个客观的框架,可用于评估选择(或不选择)Node 的决定:

第六章:创建实时应用程序

“唯一不变的是变化。”

  • 赫拉克利特

什么是实时软件?好友列表在有人加入或退出时立即更新。交通信息会自动流入正在寻找最佳回家路线的司机的智能手机。在线报纸的体育版会在实际比赛中得分时立即更新比分和排名。这类软件的用户期望对变化的反应能够快速传达,这种期望要求软件设计者特别关注减少网络延迟。数据 I/O 更新必须在亚秒级时间范围内发生。

让我们退一步,考虑一下 Node 环境和社区的一般特点,使其成为创建这类响应式网络应用程序的绝佳工具。

可以说,Node 设计的一些验证可以在庞大的开发者社区中找到,这些开发者正在贡献企业级 Node 系统。多核、多服务器的企业系统正在使用大部分用 JavaScript 编写的免费软件创建。

为什么有这么多公司在设计或更新产品时都向 Node 迁移?以下列举了原因:

  • Node 提供了出色的 npm 包管理系统,可以轻松与 Git 版本控制系统集成。浅显易懂的学习曲线帮助即使是经验不足的开发人员也能安全地存储、修改和分发新的模块、程序和想法。开发人员可以在私人 Git 存储库上开发私有模块,并使用 npm 在私人网络中安全地分发这些存储库。因此,Node 用户和开发人员的社区迅速扩大,一些成员声名鹊起。如果你建造它,他们就会来

  • Node 打破了系统访问的障碍,突然释放了大批技术娴熟的程序员的才华,为一个需要在基础设施上进行许多改进的热门新项目提供了机遇生态系统。关键在于:Node 将并发的机会与原生 JavaScript 事件相结合;其设计精巧的 API 允许使用众所周知的编程范式的用户利用高并发 I/O。如果你奖励他们,他们就会来

  • Node 打破了网络访问的障碍,让一大批 JavaScript 开发人员的工作和抱负开始超越客户端开发者可用的小沙盒。不应忘记,从 1995 年引入 JavaScript 到现在已经过去了 20 多年。几乎一个开发人员的一代人一直在努力尝试在以事件驱动的开发环境中实现新的网络应用想法,而这个环境以其限制而闻名,甚至被定义。Node 一夜之间消除了这些限制。如果你清理路径,他们就会来

  • Node 提供了一种构建可扩展网络程序的简单方法,其中网络 I/O 不再是瓶颈。真正的转变不是从另一个流行系统到 Node,而是摆脱了需要昂贵和复杂资源来构建和维护需要突发并发的高效应用程序的观念。如果可以廉价实现一个弹性和可扩展的网络架构,那么释放出的资源可以用来解决其他紧迫的软件挑战,比如并行化数据过滤、构建大规模多人游戏、构建实时交易平台或协作文档编辑器,甚至在热系统中实现实时代码更改。信心带来进步。如果你让它变得容易,他们就会来

Node 在那些构建动态网页的人已经开始遇到服务器无法顺利处理许多小型同时请求的限制时出现。软件架构师现在必须解决一些有趣的问题:实时的规则是什么——用户是否满意于很快,还是现在是唯一正确的响应?最好的设计系统满足这些用户需求的方式是什么?

在本章中,我们将调查开发人员在构建实时网络应用程序时可以使用的三种标准技术:AJAX、WebSockets 和服务器发送事件(SSE)。我们本章的目标是了解每种技术的优缺点,并使用 Node 实现每种技术。记住我们的目标是实现一个一致的架构,反映 Node 的事件流设计,我们还将考虑每种技术作为可读、可写或双工流的表现能力。

我们将以构建一个协作代码编辑器来结束本章,这应该展示了 Node 为那些希望构建实时协作软件的人提供的机会。当您逐步学习示例并构建自己的应用程序时,这些都是值得自问的一些问题:

  • 我预计每秒要处理的消息量是多少?在高峰时段和非高峰时段,预计会有多少同时连接的客户端?

  • 传输的消息的平均大小是多少?

  • 如果我能接受偶尔的通信中断或丢失的消息,是否可以通过这种让我获得更低的平均延迟?

  • 我真的需要双向通信吗,还是一方几乎负责所有消息量?我是否需要一个复杂的通信接口?

  • 我的应用程序将在哪些网络中运行?在客户端和我的 Node 服务器之间会有代理服务器吗?支持哪些协议?

  • 我需要一个复杂的解决方案,还是简单直接,甚至稍慢一些的解决方案会在长远带来其他好处?

引入 AJAX

2005 年,Jesse James Garrett 发表了一篇文章,试图将他所看到的网站设计方式的变化压缩成一种模式。在研究了这一趋势之后,Garrett 提出,动态更新页面代表了一种新的软件浪潮,类似于桌面软件,他创造了缩写AJAX来描述推动这种快速向Web 应用程序发展的技术概念。

这是他用来展示一般模式的图表:

原始文章链接:

adaptivepath.org/ideas/ajax-new-approach-web-applications/.

在 2000 年前后,Garrett的图表中提到的"AJAX 引擎"实际上已经存在于大多数常见的浏览器中,甚至在一些浏览器中更早。这些浏览器中的 JavaScript 实现了XMLHttpRequest (XHR)对象,使网页能够从服务器请求 HTML 或其他数据的片段。部分更新可以动态应用于网页,从而为新型用户界面创造了机会。例如,最新的活动图片可以神奇地出现在用户面前,而无需用户主动请求页面刷新或点击下一张图片按钮。

更重要的是,Garrett 还理解了互联网的同步、无状态世界正在变成异步、有状态的世界。客户端和服务器之间的对话不再因突然失忆而中断,可以持续更长时间,共享越来越有用的信息。Garret 将此视为网络软件新一代的转变。

回应呼叫

如果可以在不需要完全重建状态和状态显示的情况下引入更改到 Web 应用程序中,更新客户端信息将变得更加便宜。客户端和服务器可以更频繁地交流,定期交换信息。服务器可以识别、记住并立即响应客户端的愿望,通过反应式界面收集用户操作,并几乎实时地在 UI 中反映这些操作的影响。

使用 AJAX,支持实时更新每个客户端对整个应用程序状态的视图的多用户环境的构建涉及客户端定期轮询服务器以检查重要更新:

轮询状态的重大缺点是,其中许多请求将是徒劳的。客户端变成了一个破碎的记录,不断地请求状态更新,无论这些更新是否可用或即将到来。当应用程序花费时间或精力执行不必要的任务时,应该存在一些明显的好处,以抵消这种成本。此外,每次徒劳的调用都会增加建立然后拆除 HTTP 连接的成本。

这样的系统只能在定期间隔内获取状态的快照,由于轮询间隔可能增加到几秒钟,以减少冗余的网络通信,我们对状态变化的意识可能开始显得迟钝,稍微落后于最新消息。

在上一章中,我们看到了一个更好的解决方案——长轮询,即让服务器保持与客户端的连接,直到有新数据可用。

这种改进的 AJAX 技术并没有完全摆脱建立和拆除网络连接的成本,但显著减少了这类昂贵操作的数量。总的来说,AJAX 无法提供流畅的、类似流的事件接口,需要大量的服务来持久化状态,因为连接经常中断然后重新建立。

然而,AJAX 仍然是一些应用的真正选择,特别是简单的应用,其中理想的轮询间隔相当明确,每次轮询都有很大机会收集有用的结果。让我们使用 Node 构建一个能够与股票报告服务通信的服务器,并构建一个定期请求该服务器以检查更改并报告它们的轮询客户端。

创建股票行情

最终,我们将创建一个应用程序,允许客户端选择一只股票,并观察与该股票相关的数据点的变化,如其价格,并突出正面或负面的变化:

要创建客户端,我们的工作很少。我们只需要每隔几秒钟轮询我们的服务器,更新我们的界面以反映任何数据更改。让我们使用 jQuery 作为我们的 AJAX 库提供程序。要使用 jQuery 从服务器获取 JSON,通常会这样做:

function fetch() {
  $.getJSON("/service", (data) => {
    // Do something with data
    updateDisplay(data);
    // Call again in 5 seconds
    setTimeout(fetch, 5000);
  });
}
fetch(); 

Node 服务器将接收此更新请求,执行一些 I/O(检查数据库,调用外部服务),并以数据响应,客户端可以使用。

在我们的示例中,Node 将用于连接到 IEX Developer Platform (iextrading.com/developer/),该平台免费提供股票报价。

我们将构建一个 Node 服务器,监听客户端请求更新给定股票代码(如“IBM”)的数据。然后,Node 服务器将为该股票代码创建一个 YQL 查询,并通过http.get执行该查询,将接收到的数据包装好发送回调用客户端。

这个包还将被分配一个新的callIn属性,表示客户端在再次调用之前应该等待的毫秒数。这是一个有用的技术要记住,因为我们的股票数据服务器将比客户端更好地了解交通状况和更新频率。我们的服务器可以在每次调用后重新校准这个频率,甚至要求客户端停止调用,而不是盲目地按照固定的时间表检查。

由于这种设计,特别是视觉设计,可以通过多种方式完成,我们将简单地看一下我们客户需要的核心功能,包含在以下的fetch方法中:

function fetch() {
  clearTimeout(caller);
  let symbol = $("#symbol").val();

  $.getJSON(`/?symbol=${symbol}`, function(data) {
    if(!data.callIn) {
      return;
    }
    caller = setTimeout(fetch, data.callIn);
    if(data.error) {
      return console.error(data.error);
    }
    let quote = data.quote;
    let keys = fetchNumericFields(quote);

    ...

    updateDisplay(symbol, quote, keys);
  });
}

在这个页面上,用户将股票符号输入到 ID 为#symbol的输入框中。然后从我们的数据服务中获取这些数据。在前面的代码中,我们看到通过$.getJSON jQuery方法进行服务调用,接收到 JSON 数据,并使用 Node 发送回来的callIn间隔设置了setTimeout属性。

我们的服务器负责与数据服务协商前面的客户端调用。假设我们有一个正确配置的服务器成功地从客户端接收股票符号,我们需要打开到服务的 HTTP 连接,读取任何响应,并返回这些数据:

https.get(query, res => {
 let data = "";
 res.on('readable', function() {
   let d;
   while(d = this.read()) {
     data += d.toString();
   }
 }).on('end', function() {
   let out = {};
   try {
     data = JSON.parse(data);
     out.quote = data;
     out.callIn = 5000;

     Object.keys(out.quote).forEach(k => {
       // Creating artificial change (random)
       // Normally, the data source would change regularly.
       v = out.quote[k];
       if(_.isFinite(v)) {
         out.quote[k] = +v + Math.round(Math.random());
       }
     })

   } catch(e) {
     out = {
       error: "Received empty data set",
       callIn: 10000
     };
   }
   response.writeHead(200, {
     "Content-type" : "application/json"
   });
   response.end(JSON.stringify(out));
  });
}).on('error', err => {
  response.writeHead(200, {
    "Content-type" : "application/json"
  });
  response.end(JSON.stringify({
    error: err.message,
    callIn: null
  }));
});

在这里,我们看到了一个很好的例子,说明为什么让服务器,作为主要的状态观察者,调节客户端轮询的频率是一个好主意。如果成功接收到数据对象,我们将轮询间隔(callIn)设置为大约五秒。如果发生错误,我们将延迟增加到 10 秒。很容易看出,如果重复发生错误,我们可能会做更多的事情,例如进一步限制连接。鉴于这一点,应用程序可能会对向外部服务发出请求的速率有限制(例如限制一小时内可以发出的调用次数);这也是一个确保不断的客户端轮询不会超过这些速率限制的有用技术。

AJAX 是创建实时应用程序的原始技术。在某些情况下仍然有用,但已被更高效的传输方式取代。在离开这一部分时,让我们记住一些轮询的优缺点:

优点 缺点
REST 的理论和实践是可用的,允许更标准化的通信 建立和断开连接会对网络延迟产生成本,特别是如果经常这样做
不需要任何特殊的协议服务器,轮询可以很容易地使用标准的 HTTP 服务器实现 客户端必须请求数据;服务器无法单方面更新客户端以响应新数据的到来
HTTP 是众所周知且一贯实施的 即使长轮询也会使需要维持持久连接的网络流量翻倍
数据是盲目地推送和拉取,而不是在频道上平稳地广播和监听

现在让我们进入讨论一些较新的协议,部分设计用于解决我们在 AJAX 中发现的一些问题:WebSockets 和 SSE。

使用 socket.io 进行双向通信

我们已经熟悉套接字是什么。特别是,我们知道如何使用 Node 建立和管理 TCP 套接字连接,以及如何通过它们双向或单向地传输数据。

W3C 提出了一个套接字 API,允许浏览器通过持久连接与套接字服务器通信。socket.io是一个库,为那些使用 Node 开发的人提供了一个基于 Node 的套接字服务器和一个用于不支持原生WebSocket API 的浏览器的仿真层,从而便于建立持久套接字连接。

让我们首先简要看一下原生 WebSocket API 是如何实现的,以及如何使用 Node 构建支持该协议的套接字服务器。然后,我们将使用socket.io和 Node 构建一个协作绘图应用程序。

WebSocket API 的完整规范可以在以下网址找到:www.w3.org/TR/websockets/. 有关socket.io的文档和安装说明可以在以下网址找到:socket.io/

使用 WebSocket API

套接字通信是高效的,只有当其中一方有有用的东西要说时才会发生:

这种轻量级模型非常适合需要在客户端和服务器之间进行高频消息传递的应用程序,例如在多人网络游戏或聊天室中发现的情况。

根据 W3C,WebSocket API 旨在“使 Web 应用程序能够与服务器端进程保持双向通信。”假设我们已经在localhost:8080上运行了一个套接字服务器,我们可以从包含以下 JavaScript 行的浏览器连接到此服务器:

let conn = new WebSocket("ws://localhost:8080", ['json', 'xml']); 

WebSocket需要两个参数:以ws://为前缀的 URL 和一个可选的子协议列表,可以是服务器可能实现的协议的数组或单个字符串。

要建立安全的套接字连接,请使用wss://前缀。与 HTTPS 服务器一样,您将需要 SSL 证书。

一旦发出套接字请求,浏览器可以处理连接事件、打开、关闭、错误和消息:

<head>
  <title></title>
   <script>

     let conn = new WebSocket("ws://localhost:8080", 'json');
     conn.onopen = () => {
       conn.send('Hello from the client!');
     };
     conn.onerror = (error) => {
       console.log('Error! ' + error);
     };
     conn.onclose = () => {
       console.log("Server has closed the connection!");
     };
     conn.onmessage = (msg) => {
       console.log('Received: ' + msg.data);
     };
   </script>
</head>

在这个例子中,我们将使用 ws 模块在 Node 中实现一个WebSocket服务器:github.com/websockets/ws。使用 npm 安装 ws(npm i ws)后,建立一个 Node 套接字服务器非常简单:

let SocketServer = require('ws').Server;
  let wss = new SocketServer({port: 8080});
  wss.on('connection', ws => {
    ws.on('message', (message) => {
      console.log('received: %s', message);
    });
    ws.send("You've connected!");
 });

在这里,我们可以看到服务器只是简单地监听来自客户端的connectionmessage事件,并根据需要做出响应。如果有必要终止连接(也许是如果客户端失去授权),服务器可以简单地发出close事件,客户端可以监听该事件:

ws.close(); 

因此,使用 WebSocket API 创建双向通信的应用程序的一般示意图如下:

本地 WebSocket 浏览器实现用于与我们的自定义 Node 套接字服务器进行通信,该服务器处理来自客户端的请求,并在必要时向客户端广播新数据或信息。

socket.io

如前所述,socket.io旨在提供一个仿真层,将在支持它的浏览器中使用本机WebSocket实现,并在旧浏览器中(如长轮询)使用其他方法来模拟本机 API。这是一个重要的事实要记住:仍然有一些旧的浏览器存在。

尽管如此,socket.io在隐藏浏览器差异方面做得非常好,并且在套接字提供的控制流对于您的应用程序的通信模型是一种理想选择时,它仍然是一个很好的选择。

在前面示例中使用的WebSocket实现(ws)中,可以清楚地看到套接字服务器独立于任何特定的客户端文件。我们编写了一些 JavaScript 来在客户端上建立WebSocket连接,独立地使用 Node 运行套接字服务器。与这种本机实现不同,socket.io需要在服务器上安装自定义客户端库以及socket.io服务器模块:

socket.io可以使用npm包管理器进行安装:

$ npm install socket.io 

设置客户端/服务器套接字配对非常简单。

在服务器端:

let io = require('socket.io').listen(8080);
io.sockets.on('connection', socket => {
  socket.emit('broadcast', { message: 'Hi!' });
  socket.on('clientmessage', data => {
    console.log("Client said" + data);
  });
});

在客户端:

<script src="img/socket.io.js"></script>
 <script>
   let socket = io.connect('http://localhost:8080');
   socket.on('broadcast', data => {
     console.log(`Server sent: ${JSON.stringify(data)}`);
     socket.emit('clientmessage', { message: 'ohai!' });
   });
 </script> 

我们可以看到客户端和服务器都使用相同的文件socket.io.js。使用socket.io的服务器在请求时会自动处理向客户端提供socket.io.js文件。还应该注意到socket.io API 与标准 NodeEventEmitter接口非常相似。

协作绘图

让我们使用socket.io和 Node 创建一个协作绘图应用。我们想要创建一个空白画布,同时显示所有连接客户端所做的笔迹

从服务器端来看,要做的事情很少。当客户端通过移动鼠标更新坐标时,服务器只需将此更改广播给所有连接的客户端:

io.sockets.on('connection', socket => {
  let id = socket.id;

  socket.on('mousemove', data => {
    data.id = id;
    socket.broadcast.emit('moving', data);
  });

  socket.on('disconnect', () => {
    socket.broadcast.emit('clientdisconnect', id);
  });
});

socket.io会自动生成一个唯一的 ID 用于每个 socket 连接。每当发生新的绘图事件时,我们将传递这个 ID,允许接收端客户端跟踪有多少用户连接。同样,当一个客户端断开连接时,所有其他客户端都会被指示删除对这个客户端的引用。稍后,我们将看到这个 ID 在应用 UI 中如何使用,以维护表示所有连接客户端的指针。

这是一个很好的例子,展示了使用 Node 和 Node 社区创建的包来创建多用户网络应用是多么简单。让我们来分析一下这个服务器在做什么。

因为我们需要提供客户端用于绘制的 HTML 文件,所以服务器设置的一半涉及创建一个静态文件服务器。为了方便起见,我们将使用 node-static 包:github.com/cloudhead/node-static。我们的实现将为任何连接的客户端提供一个index.html文件。

我们的socket.io实现期望从客户端接收mousemove事件,它的唯一任务是向所有连接的客户端发送这些新坐标,它通过其broadcast方法通过发出一个移动事件来实现。当一个客户端通过绘制一条线改变画布状态时,所有客户端都将收到更新画布状态所需的信息,以实时更新他们的画布状态视图。

通信层建立完成后,我们现在必须创建客户端视图。如前所述,每个客户端将加载一个包含必要的 canvas 元素和监听移动事件的 JavaScript 的index.html文件,以及将客户端绘制事件广播到我们的服务器的socket.io发射器:

<head>
     <style type="text/css">
     /* CSS styling for the pointers and canvas */
     </style>
     <script src="img/socket.io.js"></script>
     <script src="img/script.js"></script>
 </head>
 <body>
     <div id="pointers"></div>
     <canvas id="canvas" width="2000" height="1000"></canvas>
 </body>

创建一个pointers元素来保存所有连接客户端光标的可见表示,这些表示将随着连接客户端移动其指针和/或绘制某些东西而更新。

script.js文件中,我们首先在canvas元素上设置事件监听器,监听mousedownmousemove事件的组合,指示绘图动作。请注意,我们创建了一个 50 毫秒的时间缓冲,延迟每次绘制事件的广播,略微降低了绘图的分辨率,但避免了过多的网络事件:

let socket = io.connect("/");
let prev = {};
let canvas = document.getElementById('canvas');
let context = canvas.getContext('2d');
let pointerContainer = document.getElementById("pointers");

let pointer = document.createElement("div");
pointer.setAttribute("class", "pointer");

let drawing = false;
let clients = {};
let pointers = {};

function drawLine(fromx, fromy, tox, toy) {
  context.moveTo(fromx, fromy);
  context.lineTo(tox, toy);
  context.stroke();
}
function now() {
  return new Date().getTime();
}
let lastEmit = now();
canvas.onmouseup = canvas.onmousemove = canvas.onmousedown = function(e) {
  switch(e.type) {
    case "mouseup":
      drawing = false;
      break;

    case "mousemove":
      if(now() - lastEmit > 50) {
        socket.emit('mousemove', {
          'x' : e.pageX,
          'y' : e.pageY,
          'drawing' : drawing
        });
        lastEmit = now();
      }
      if(drawing) {
        drawLine(prev.x, prev.y, e.pageX, e.pageY);
        prev.x = e.pageX;
        prev.y = e.pageY;
      }
      break;

    case "mousedown":
      drawing = true;
      prev.x = e.pageX;
      prev.y = e.pageY;
      break;

    default: 
      break;
  }
};

每当发生绘图动作(mousedownmousemove事件的组合),我们会在客户端的机器上绘制请求的线条,然后通过socket.emit('mousemove', ...)将这些新坐标广播到我们的socket.io服务器,记得传递绘图客户端的id值。服务器将通过socket.broadcast.emit('moving', data)广播它们,允许客户端监听器在它们的canvas元素上绘制等效的线条:

socket.on('moving', data => {
  if (!clients.hasOwnProperty(data.id)) {
    pointers[data.id] = pointerContainer.appendChild(pointer.cloneNode());
  }
  pointers[data.id].style.left = data.x + "px";
  pointers[data.id].style.top = data.y + "px";

  if (data.drawing && clients[data.id]) {
    drawLine(clients[data.id].x, clients[data.id].y, data.x, data.y);
  }
  clients[data.id] = data;
  clients[data.id].updated = now();
});

在这个监听器中,如果发送的客户端 ID 以前没有看到过,客户端将建立一个新的客户端指针,并且动画化一条线的绘制和客户端指针,从而在单个客户端视图中创建多个光标绘制不同线条的效果。

回想一下我们在服务器上跟踪的clientdisconnect事件,我们还使客户端能够监听这些断开连接,从视图(可视化指针)和我们的clients对象中删除丢失客户端的引用:

socket.on("clientdisconnect", id => {
  delete clients[id];
  if (pointers[id]) {
    pointers[id].parentNode.removeChild(pointers[id]);
  }
}); 

socket.io是一个很好的工具,用于构建交互式的、多用户的环境,需要连续快速的双向数据传输。

现在,让我们来看看socket.io的优缺点:

优点 缺点
对于实时游戏、协作编辑工具和其他应用程序来说,快速的双向通信至关重要 允许的持久套接字连接数量可以在服务器端或任何中间位置进行限制
比标准 HTTP 协议请求的开销更低,降低了在网络上发送数据包的价格 许多代理和反向代理都会使套接字实现混乱,导致客户端丢失
套接字的事件驱动和流式特性在概念上与 Node 架构相吻合——客户端和服务器只是通过一致的接口来回传递数据 需要自定义协议服务器,通常需要自定义客户端库

另一个有趣的项目是 SockJS,它在许多不同的语言中实现了套接字服务器,包括 Node.js。查看:github.com/sockjs/sockjs-node

监听服务器发送的事件

SSE 是简单而具体的。它们在大多数数据传输是从服务器到客户端单向进行时使用。传统和类似的概念是推送技术。SSE 传递带有简单格式的文本消息。许多类型的应用程序被动地接收简短的状态更新或数据状态更改。SSE 非常适合这些类型的应用程序。

WebSocket一样,SSE 也消除了 AJAX 的冗余交流。与WebSocket不同,SSE 连接只关注从服务器向连接的客户端广播数据:

通过将路径传递给EventSource构造函数,客户端连接到支持 SSE 的服务器:

let eventSource = new EventSource('/login'); 

EventSource的这个实例现在将在从服务器接收到新数据时发出可订阅的数据事件。

使用 EventSource API

EventSource实例发出可订阅的数据事件,每当从服务器接收到新数据时,就像Readable流在 Node 中发出数据事件一样,正如我们在这个示例客户端中所看到的:

<script>
  let eventSource = new EventSource('/login');
  eventSource.addEventListener('message', (broadcast) => {
    console.log("got message: " + broadcast);
  });
  eventSource.addEventListener('open', () => {
    console.log("connection opened");
  });
  eventSource.addEventListener('error', () => {
    console.log("connection error/closed");
  });
 </script> 

EventSource实例会发出三个默认事件:

  • open:当连接成功打开时,将触发此事件

  • message:分配给此事件的处理程序将接收一个对象,其data属性包含广播消息

  • error:每当服务器发生错误,或服务器断开连接或以其他方式与此客户端断开连接时,都会触发此事件

作为标准 HTTP 协议的一部分,响应 SSE 请求的服务器需要进行最少的配置。以下服务器将接受EventSource绑定并每秒向绑定的客户端广播当前日期:

const http = require("http");
const url = require("url");
http.createServer((request, response) => {
  let parsedURL = url.parse(request.url, true);
  let pathname = parsedURL.pathname;
  let args = pathname.split("/");
  let method = args[1];
  if (method === "login") {
    response.writeHead(200, {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      "Connection": "keep-alive"
    });
    response.write(":" + Array(2049).join(" ") + "\n");
    response.write("retry: 2000\n");
    response.on("close", () => {
      console.log("client disconnected");
    });
    setInterval(() => {
      response.write("data: " + new Date() + "\n\n");
    }, 1000);
    return;
  }
}).listen(8080);

该服务器监听请求并选择在路径/login上进行的请求,将其解释为对EventSource绑定的请求。建立EventSource连接只是简单地通过使用Content-Type头部为text/event-stream来响应请求。此外,我们指示客户端的Cache-Control行为应设置为no-cache,因为我们期望在此通道上有大量原始材料。

从连接点开始,此客户端的response对象将保持一个开放的管道,可以通过write发送消息。让我们看看接下来的两行:

response.write(":" + Array(2049).join(" ") + "\n");
response.write("retry: 2000\n");

这第一次写入是为了调整一些浏览器中 XHR 实现的特性,最终需要所有 SSE 流都以 2KB 填充为前缀。这个写入操作只需要发生一次,对后续消息没有影响。

SSE 的一个优点是,客户端在连接断开时会自动尝试重新连接服务器。重试的毫秒数将因客户端而异,并且可以使用重试字段进行控制,我们在这里使用它来设置两毫秒的重试间隔。

最后,我们监听客户端的关闭事件,当客户端断开连接时触发,并开始以一秒的间隔广播时间:

setInterval(() => {
  response.write("data: " + new Date() + "\n\n");
 }, 1000);

一个网站可能会绑定到这个时间服务器并显示当前的服务器时间:

<html>
 <head>
     <script>
       let ev = new EventSource('/login');
       ev.addEventListener("message", broadcast => {
         document.getElementById("clock").innerHTML = broadcast.data;
       });
     </script>
 </head>
 <body>
     <div id="clock"></div>
 </body>
 </html>

因为连接是单向的,任意数量的服务可以很容易地设置为发布者,客户端通过新的EventSource实例分别绑定到这些服务。例如,可以通过修改前面的服务器,使其定期发送process.memoryUsage()的值,轻松实现服务器监视。作为练习,使用 SSE 重新实现我们在 AJAX 部分中介绍的股票服务。

EventSource 流协议

一旦服务器建立了客户端连接,它现在可以随时通过这个持久连接发送新消息。这些消息由一个或多个文本行组成,由以下四个字段中的一个或多个分隔:

  • event:这是一个事件类型。发送此字段的消息将触发客户端的一般EventSource事件处理程序处理任何消息。如果设置为诸如latestscore之类的字符串,客户端的message处理程序将不会被调用,处理将委托给使用EventSource.addEventListener('latestscore'…)绑定的处理程序。

  • data:这是要发送的消息。这始终是String类型,尽管它可以有用地传输通过JSON.stringify()传递的对象。

  • id:如果设置,此值将出现为发送的消息对象的lastEventID属性。这对于对客户端进行排序、排序和其他操作非常有用。

  • 重试:重新连接间隔,以毫秒为单位。

发送消息涉及组成包含相关字段名称并以换行符结尾的字符串。这些都是有效的消息:

response.write("id:" + (++message_counter) + "\n");
response.write("data: I'm a message\n\n");
response.write("retry: 10000\n\n");
response.write("id:" + (++message_counter) + "\n");
response.write("event: stock\n");
response.write("data: " + JSON.stringify({price: 100, change: -2}) + "\n\n");
response.write("event: stock\n");
response.write("data: " + stock.price + "\n");
response.write("data: " + stock.change + "\n");
response.write("data: " + stock.symbol + "\n\n");
response.write("data: Hello World\n\n");

我们可以看到也可以设置多个data字段。需要注意的一点是在最后一个数据字段之后发送双换行("\n\n")。之前的字段应该只使用单个换行。

默认的EventSource客户端事件(openmessageclose)足以对大多数应用程序接口进行建模。服务器发送的所有广播都在唯一的message处理程序中捕获,该处理程序负责路由消息或以其他方式更新客户端,就像在使用 JavaScript 处理 DOM 中的事件时工作时事件委托会起作用一样。

在需要许多唯一的消息标识符的情况下,压倒一个单一处理函数可能不是理想的。我们可以使用 SSE 消息的event字段来创建自定义事件名称,客户端可以单独绑定,从而整洁地分离关注点。

例如,如果正在广播两个特殊事件actionAactionB,我们的服务器将像这样结构化它们:

 event: actionA\n
 data: Message A here\n\n

 event: actionB\n
 data: Message B here\n\n

我们的客户端将以正常方式绑定到它们,如下面的代码片段所示:

ev.addEventListener("actionA", (broadcast) => {
  console.log(broadcast.data);
});
ev.addEventListener("actionB", (broadcast) => {
  console.log(broadcast.data);
}); 

在单个消息处理函数变得过长或过于复杂的情况下,考虑使用唯一命名的消息和处理程序。

提问和获取答案

如果我们想要创建一个与兴趣相关的接口怎么办?让我们构建一个应用程序,使任意数量的人可以提问和/或回答问题。我们的用户将加入社区服务器,看到一个开放问题的列表以及对这些问题的答案,并在添加新问题或答案时实时获取更新。有两个关键活动需要建模:

  • 每个客户端必须在另一个客户端提问或发布答案时得到通知。

  • 客户端可以提问或提供答案

在一个大量的同时贡献者的大型群体中,最大的变化会发生在哪里?

任何个别的客户端都可以提出几个问题或提供几个答案。客户端还可以选择问题,并查看答案。我们只需要满足少量的客户端到服务器的请求,比如向服务器发送新问题或答案。大部分工作将在满足客户端请求数据(问题的答案列表)和向所有连接的客户端广播应用程序状态更改(添加新问题;给出新答案)方面。在这种协作应用程序中存在的一对多关系意味着单个客户端广播可能会创建与连接的客户端数量相等的服务器广播,从 1 到 10,000 或更多。SSE 在这里非常合适,所以让我们开始吧。

此应用程序的三个主要操作如下:

  • 提问

  • 回答问题

  • 选择问题

这些操作中的任何一个都会改变应用程序的状态。由于这个状态必须在所有客户端上反映出来,我们将在服务器上存储应用程序的状态——所有问题、答案以及客户端与这些数据对象的关系。我们还需要唯一标识每个客户端。通常,人们会使用数据库来持久化其中一些信息,但出于我们的目的,我们将简单地将这些数据存储在我们的 Node 服务器中:

let clients = {};
let clientQMap = {};
let questions = {};
let answers    = {};

function removeClient(id) {
  if(id) {
    delete clients[id];
    delete clientQMap[id];
  }
}

除了 questionsanswers 存储对象之外,我们还需要存储客户端对象本身——客户端被分配一个唯一的 ID,可以用来查找信息(比如客户端的套接字),当进行广播时使用。

我们只想向对特定问题感兴趣的客户端广播答案数据——因为客户端 UI 只显示单个问题的答案,当然我们不会向客户端不加区分地广播答案。因此,我们保留了一个 clientQMap 对象,它将一个问题映射到所有关注该问题的客户端,通过 ID。

removeClient 方法很简单:当客户端断开连接时,该方法会从池中删除其数据。稍后我们会再次看到这一点。

有了这个设置,接下来我们需要构建我们的服务器来响应 /login 路径,这是由 EventSource 用于建立连接的。这个服务负责为客户端配置一个适当的事件流,将这个 Response 对象存储起来以备后用,并为用户分配一个唯一标识符,这个标识符将在将来的客户端请求中用于识别客户端并获取该客户端的通信套接字:


 http.createServer((request, response) => {
   let parsedURL = url.parse(request.url, true);
   let pathname = parsedURL.pathname;
   let args = pathname.split("/");
   //  Lose initial null value
   args.shift();
   let method = args.shift();
   let parameter = decodeURIComponent(args[0]);
   let sseUserId = request.headers['_sse_user_id_'];
   if (method === "login") {
     response.writeHead(200, {
       "Content-Type": "text/event-stream",
       "Cache-Control": "no-cache"
   });
   response.write(":" + Array(2049).join(" ") + "\n"); // 2kB
   response.write("retry: 2000\n");
   removeClient(sseUserId);
   // A very simple id system. You'll need something more secure.
   sseUserId = (USER_ID++).toString(36);
   clients[sseUserId] = response;
   broadcast(sseUserId, {
     type : "login",
     userId : sseUserId
   });
   broadcast(sseUserId, {
     type : "questions",
     questions : questions
   });
   response.on("close", () => {
     removeClient(sseUserId);
   });

   // To keep the conn alive we send a "heartbeat" every 10 seconds.
   // https://bugzilla.mozilla.org/show_bug.cgi?id=444328
   setInterval(() => {
     broadcast(sseUserId, new Date().getTime(), "ping");
   }, 10000);
   return;
}).listen(8080);

在建立请求参数之后,我们的服务器会检查请求中的 _sse_user_id_ 头部,这是在初始 EventSource 绑定中分配给用户的唯一字符串,位于 /login 中:

sseUserId = (USER_ID++).toString(36);
clients[sseUserId] = response;

然后通过即时广播将此 ID 发送给客户端,我们利用这个机会发送当前批次的问题:

broadcast(sseUserId, sseUserId, "login");

现在客户端负责在进行调用时传递这个 ID。通过监听 /login 事件并存储传递的 ID,客户端可以在进行 HTTP 调用时自我识别:

 evSource.addEventListener('login', broadcast => {
   USER_ID = JSON.parse(broadcast.data);
 });
 let xhr = new XMLHttpRequest();
 xhr.open("POST", "/...");
 xhr.setRequestHeader('_sse_user_id_', USER_ID);
 ...

请记住,我们刚刚从服务器到客户端创建了一个单向事件流。这个通道用于与客户端通信,而不是 response.end() 或类似的方法。在 /login 中引用的广播方法完成了广播流事件的任务,如下面的代码所示:

let broadcast = function(toId, msg, eventName) {
  if (toId === "*") {
    for (let p in clients) {
      broadcast(p, msg);
    }
    return;
  }
  let clientSocket = clients[toId];
  if (!clientSocket) {
    return;
  }
  eventName && clientSocket.write(`event: ${eventName}\n`);
  clientSocket.write(`id: ${++UNIQUE_ID}\n`);
  clientSocket.write(`data: ${JSON.stringify(msg)}\n\n`);
 }

从下往上扫描这段代码。注意广播的主要目的是获取客户端 ID,查找该客户端的事件流,并向其写入,如果需要,接受自定义事件名称。然而,由于我们将定期向所有连接的客户端广播,我们允许使用特殊的 * 标志来指示大规模广播。

现在一切都设置好了,只需要为此应用程序的三个主要操作定义服务:添加新问题和答案,以及记住每个客户端正在关注的问题。

当提出问题时,我们确保问题是唯一的,将其添加到我们的question集合中,并告诉所有人新的问题列表:

if (method === "askquestion") {
  // Already asked?
  if (questions[parameter]) {
    return response.end();
  }
  questions[parameter] = sseUserId;    
  broadcast("*", {
    type : "questions",
    questions : questions
  });
  return response.end();
} 

处理答案几乎相同,只是这里我们只想将新答案广播给询问正确问题的客户端:

if (method === "addanswer") {
     ...
  answers[curUserQuestion] = answers[curUserQuestion] || [];
  answers[curUserQuestion].push(parameter);
  for (var id in clientQMap) {
    if (clientQMap[id] === curUserQuestion) {
      broadcast(id, {
        type : "answers",
        question : curUserQuestion,
        answers : answers[curUserQuestion]
      });
    }
  }
  return response.end();
}

最后,通过更新clientQMap来存储客户端兴趣的更改:

if (method === "selectquestion") {
  if (parameter && questions[parameter]) {
    clientQMap[sseUserId] = parameter;
    broadcast(sseUserId, {
      type : "answers",
      question : parameter,
      answers : answers[parameter] ? answers[parameter] : []
    });
  }
   return response.end();
}

虽然我们不会深入讨论客户端 HTML 和 JavaScript,但我们将看看如何处理一些核心事件。

假设 UI 以 HTML 呈现,一侧列出答案,另一侧列出问题,包含用于添加新问题和答案的表单,以及用于选择要跟随的问题的表单,我们的客户端代码非常轻量且易于跟踪。在与服务器进行初始/login握手后,此客户端只需通过 HTTP 发送新数据即可。服务器响应的处理被整洁地封装成三个事件,使得事件流处理变得易于跟踪:

 let USER_ID = null;
 let evSource = new EventSource('/login');
 let answerContainer = document.getElementById('answers');
 let questionContainer = document.getElementById('questions');

 let showAnswer = (answers) => {
   answerContainer.innerHTML = "";
   let x = 0;
   for (; x < answers.length; x++) {
     let li = document.createElement('li');
     li.appendChild(document.createTextNode(answers[x]));
     answerContainer.appendChild(li);
   }
 }

 let showQuestion = (questions) => {
   questionContainer.innerHTML = "";
   for (let q in questions) {
     //... show questions, similar to #showAnswer
   }
 }

 evSource.addEventListener('message', (broadcast) => {
   let data = JSON.parse(broadcast.data);
   switch (data.type) {
     case "questions":
       showQuestion(data.questions);
     break;
     case "answers":
       showAnswer(data.answers);
     break;
     case "notification":
       alert(data.message);
     break;
     default:
       throw "Received unknown message type";
     break;
   }
 });

 evSource.addEventListener('login', (broadcast) => {
   USER_ID = JSON.parse(broadcast.data);
 });

此界面只需等待新的问题和答案数据,并在列表中显示它。三个回调足以使此客户端保持最新状态,无论有多少不同的客户端更新应用程序的状态。

优点 缺点
轻量级:通过使用原生 HTTP 协议,可以使用几个简单的标头创建 SSE 服务器 不一致的浏览器支持需要为客户端到服务器通信创建自定义库,不支持的浏览器通常会进行长轮询
能够单方面向客户端发送数据,而无需匹配客户端调用 单向:不适用于需要双向通信的情况
自动重新连接断开的连接,使 SSE 成为可靠的网络绑定 服务器必须每隔大约 10 秒发送“心跳”以保持连接活动
简单,易于定制,易于理解的消息格式

EventSource不受所有浏览器支持(特别是 IE)。可以在以下网址找到 SSE 的出色仿真库:github.com/Yaffle/EventSource

构建协同文档编辑应用程序

现在我们已经研究了构建协同应用程序时要考虑的各种技术,让我们使用操作转换OT)来组合一个协同代码编辑器。

在这里,OT 将被理解为一种允许许多人同时编辑同一文档的技术——协同文档编辑。Google 以以下方式描述了他们(现已关闭的)Wave 项目:

正如svn.apache.org/repos/asf/incubator/wave/whitepapers/operational-transform/operational-transform.html所说,“协同文档编辑意味着多个编辑者能够同时编辑共享文档。当用户可以逐个按键地看到另一个人所做的更改时,它是实时和并发的。Google Wave 提供了富文本文档的实时并发编辑。”。

参与 Wave 项目的工程师之一是 Joseph Gentle,Gentle 先生很友好地编写了一个模块,将 OT 技术带到了 Node 社区,命名为ShareJS,后来成为了ShareDB,Derby web 框架的 OT 后端(derbyjs.com/)。我们将使用此模块创建一个允许任何人创建新的协同编辑文档的应用程序。

此示例大量借鉴了 ShareDB GitHub 存储库中包含的许多示例。要深入了解 ShareDB 的可能性,请访问:github.com/share/sharedb

首先,我们需要一个代码编辑器来绑定我们的 OT 层。对于这个项目,我们将使用优秀的 Quill 编辑器,可以从以下地址克隆:github.com/quilljs/quill。Quill 特别适用于与 ShareDB 一起使用,因为它被设计为将文档表示为 JSON 中的一系列变更集(github.com/ottypes/rich-text),这些变更集可以映射到 ShareDB 理解的 OT 类型。虽然超出了本节的范围,但读者可能会对 OT 如何工作,特别是这两个库如何工作感兴趣。

作为一个协作的实时应用程序,我们将使用ws套接字服务器来管理客户端和数据库之间的通信,并使用Express来管理提供静态文件,如index.html

在本章的代码捆绑包中,将会有一个 sharedb 文件夹。要安装并尝试它,请运行以下命令:

npm i
npm run build
npm start
// Now navigate to localhost:8080 and start editing.
// Open another browser to localhost:8080 to see collaboration in action!

主要文件将是client.jsserver.js。将使用Browserify捆绑client.js文件,生成客户端将使用的 JavaScript。让我们看看client.js文件:

const sharedb = require('sharedb/lib/client');
const richText = require('rich-text');
const Quill = require('quill');

sharedb.types.register(richText.type);

const socket = new WebSocket('ws://' + window.location.host);
const connection = new sharedb.Connection(socket);

window.disconnect = () => connection.close();
window.connect = () => connection.bindToSocket(new WebSocket('ws://' + window.location.host));

// 0: Name of collection
// 1: ID of document
let doc = connection.get('examples', 'richtext');

doc.subscribe(err => {
  if(err) {
    throw err;
  }
  let quill = new Quill('#editor', {
    theme: 'snow'
  });
  quill.setContents(doc.data);
  // ... explained below
});

该文件的标题只是实例化了 ShareDB,将其文档类型设置为rich-text,并为实例提供了与服务器的通信套接字。为了演示的目的,我们将在单个集合examples和一个文件richtext上操作。这种集合/文档配对是您在 ShareDB 中处理文档的方式,并且很快将在我们即将看到的server.js文件中反映出来。在更高级的实现中,您可能需要创建某种集合/文档管理层,将这些集合链接到特定用户,添加用户帐户、权限等。

一旦我们订阅了服务器,我们就将一个新的 Quill 实例绑定到#editor元素,将其内容(quill.setContents)设置为服务器返回的当前文档,并声明我们想要使用snow主题,其 css 已包含在index.html中:

<!DOCTYPE html>
<html lang="en">
<head>
  ...
  <link href="quill.snow.css" rel="stylesheet">
</head>
<body>
  <div id="editor"></div>
  <script src="img/bundle.js"></script>
</body>
</html>

剩下的就是创建将 OT 功能暴露给客户端的 Node 服务器。为此,我们需要接受来自服务器的 OT 更改(增量)并将这些更改应用到 Quill 编辑器,并在用户使用 Quill 编辑器时向服务器报告更改:

doc.subscribe(err => {
  ...
  quill.setContents(doc.data);
  quill.on('text-change', (delta, oldDelta, source) => {
   ...
   doc.submitOp(delta, {
     source: quill
   });
  });
  doc.on('op', (op, source) => {
    ...
    quill.updateContents(op);
  });
}

我们现在已经设置好了,每当 Quill 编辑器中有text-change时,我们将更新文档数据库,并在共享文档上有新的op时,我们将updateContents到任何连接的客户端编辑器。

服务器实现在很大程度上反映了客户端实现:

const http = require('http');
const express = require('express');
const ShareDB = require('sharedb');
const richText = require('rich-text');
const WebSocket = require('ws');
const WebSocketJSONStream = require('websocket-json-stream');

ShareDB.types.register(richText.type);

const app = express();
app.use(express.static('static'));
app.use(express.static('node_modules/quill/dist'));

const backend = new ShareDB();
const connection = backend.connect();

// 0: Name of collection
// 1: ID of document
let doc = connection.get('examples', 'richtext');

doc.fetch(err => {
  if (err) {
    throw err;
  }
  if (doc.type === null) {
    return doc.create([
      {insert: 'Say Something!'}
    ], 'rich-text', startServer);
  }
  startServer();
});

function startServer() {
  const server = http.createServer(app);
  const wss = new WebSocket.Server({server: server});
  wss.on('connection', (ws, req) => {
    backend.listen(new WebSocketJSONStream(ws));
  });
  server.listen(8080, () => console.log('Editor now live on http://localhost:8080'));
}

我们需要所有的库,注意 websocket-json-stream 的要求,这是一个在套接字上创建 JSON 对象流的库,需要表示我们将使用的 JSON 变更集。

然后,我们建立客户端期望的集合/文档设置,如果文档不存在,则使用一些虚拟文本“说点什么!”创建文档。唯一剩下的事情就是将 ShareDB 后端绑定到这个双向 JSON 对象流:

backend.listen(new WebSocketJSONStream(ws))

该服务器现在可以用于在所有请求具有相同名称的文档的客户端之间共享文档状态,从而促进协作编辑。

总结

在本章中,我们已经讨论了构建实时应用程序时使用的三种主要策略:AJAX、WebSocket 和 SSE。我们已经展示了使用 Node 可以用非常少的代码开发复杂的协作应用程序。我们还看到了一些策略如何使客户端/服务器通信建模为事件数据流接口。我们考虑了这些各种技术的优缺点,并且通过一些清晰的示例介绍了每种技术的最佳使用场景。

此外,我们已经展示了如何在 Node 服务器中构建和管理客户端标识符和状态数据,以便状态更改可以安全地封装在一个中心位置,并安全可靠地广播到许多连接的客户端。通过使用操作转换,展示了与 Node 社区开发的模块的质量,我们创建了一个协作代码编辑系统。

在下一章中,我们将学习如何协调多个同时运行的 Node 进程的努力。通过示例,我们将学习如何使用 Node 实现并行处理,从生成运行 Unix 程序的许多子进程到创建负载均衡 Node 套接字服务器集群。

第七章:使用多个进程

“现在很遗憾的是,现在几乎没有多余的信息。”

– 奥斯卡·王尔德

对于目睹着越来越多的应用程序产生的数据量急剧增加的人来说,I/O 效率的重要性是不言而喻的。用户生成的内容(博客、视频、推文和帖子)正在成为互联网内容的主要类型,这一趋势与社交软件的兴起同步进行,其中对内容之间的交集进行映射产生了另一层数据的指数级增长。

一些数据储存库,如谷歌、Facebook 和其他数百家公司,通过 API 向公众公开其数据,通常是免费的。这些网络每个都收集了令人惊讶的内容、观点、关系等大量数据,这些数据还通过市场研究和各种类型的流量和使用分析进一步增加。这些 API 大多是双向的,既收集并储存成员上传的数据,又提供这些数据。

Node 已经在这一数据扩张期间到来。在本章中,我们将探讨 Node 如何满足对大量数据进行排序、合并、搜索和其他操作的需求。调整软件,使其能够安全、廉价地处理大量数据,在构建快速和可扩展的网络应用程序时至关重要。

我们将在下一章中处理特定的扩展问题。在本章中,我们将研究在设计多个 Node 进程共同处理大量数据的系统时的一些最佳实践。

作为讨论的一部分,我们将研究在构建数据密集型应用程序时的并行策略,重点是如何利用多个 CPU 环境、使用多个工作进程,并利用操作系统本身来实现并行性的效率。通过示例来演示如何将这些独立而高效的处理单元组装成应用程序的过程。

如第五章中所述,管理许多同时的客户端连接,并发性并不等同于并行性。并发的目标是为程序提供良好的结构,简化模拟处理多个同时进行的进程所固有的复杂性。并行性的目标是通过将任务或计算的部分分配给多个工作进程来提高应用程序的性能。值得回顾的是Clinger对“…数十、数百甚至数千个独立微处理器,每个都有自己的本地内存和通信处理器,通过高性能通信网络进行通信”的愿景。

我们已经讨论了 Node 如何帮助我们理解非确定性控制流。让我们还记得 Node 的设计者遵循模块化规则,鼓励我们编写简单的部分,并通过清晰的接口连接起来。这条规则导致了对简单的网络化进程的偏好,这些进程使用共同的协议进行通信。相关的规则是简单规则,如下所述:

正如en.wikipedia.org/wiki/Unix_philosophy所说,“开发人员应该通过寻找将程序系统分解为小而简单的协作部分的方法来设计简单。这条规则旨在阻止开发人员对编写“错综复杂且美丽的复杂性”产生情感,而这些实际上是容易出错的程序。”

在我们继续阅读本章内容时,记住这条规则是很好的。为了控制不断增长的数据量,我们可以构建庞大、复杂和强大的单体,希望它们能够保持足够的规模和强大。或者,我们可以构建小而有用的处理单元,可以组合成任意大小的单一处理团队,就像超级计算机可以由成千上万甚至数百万台廉价的处理器构建而成一样。

在阅读本章时,进程查看器将非常有用。Unix 系统的一个很好的工具是htop,可以从以下网址下载:hisham.hm/htop/。该工具提供了 CPU 和内存使用情况的视图;在这里,我们可以看到负载是如何分布在所有八个核心上的:

让我们开始研究线程和进程。

Node 的单线程模型

Node 环境的整体展示了多线程并行性的效率和适用于具有高并发性特征的应用程序的表达语法。使用 Node 不会限制开发人员、开发人员对系统资源的访问,或者开发人员可能想要构建的应用程序类型。

然而,令人惊讶的是,对 Node 的许多持久批评都是基于这种误解。正如我们将看到的,认为 Node 不是多线程的,因此慢,或者还没有准备好投入使用,简单地错过了重点。JavaScript 是单线程的;Node 堆栈不是。JavaScript 代表了用于协调执行多个多线程 C++进程的语言,甚至是您开发人员创建的定制 C++附加组件。Node 提供 JavaScript,通过 V8 运行,主要作为建模并发的工具。此外,您可以仅使用 JavaScript 编写整个应用程序,这只是该平台的另一个好处。您不必一直使用 JavaScript-如果您选择,可以在 C++中编写大部分应用程序。

在本章中,我们将尝试解决这些误解,为使用 Node 进行乐观开发铺平道路。特别是,我们将研究跨核心、进程和线程分配工作的技术。目前,本节将尝试澄清单个线程的能力有多大(提示:通常您所需要的就是这个)。

单线程编程的好处

很难找到任何数量可观的专业软件工程师愿意否认多线程软件开发是痛苦的。然而,为什么做得好这么难呢?

并不是说多线程编程本身很困难-困难在于线程同步的复杂性。使用线程模型构建高并发性非常困难,特别是在状态共享的模型中。一旦应用程序超出最基本的形状,几乎不可能预料到一个线程中的操作可能如何影响其他所有线程。纠缠和冲突迅速增加,有时会破坏共享内存,有时会创建几乎不可能追踪的错误。

Node 的设计者选择认识到线程的速度和并行化优势,而不要求开发人员也这样做。特别是,Node 的设计者希望免除开发人员管理伴随线程系统的困难。

  • 共享内存和锁定行为导致系统在复杂性增加时变得非常难以理解。

  • 任务之间的通信需要实现各种同步原语,如互斥锁和信号量、条件变量等。一个本来就具有挑战性的环境需要高度复杂的工具,扩展了完成甚至相对简单系统所需的专业知识水平。

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

  • 由于在线程之间和它们的状态之间保持可靠的边界是如此困难,确保一个库(对于 Node 来说是一个模块)是线程安全的需要大量的开发人员时间。我能知道这个库不会破坏我的应用的某个部分吗?保证线程安全需要库开发人员的极大细心,而这些保证可能是有条件的;例如,一个库在读取时可能是线程安全的,但在写入时可能不是。

单线程的主要论点是,在并发环境中控制流是困难的,特别是当内存访问或代码执行顺序是不可预测的时候:

  • 开发人员不再需要关注任意锁定和其他冲突,可以专注于构建可预测顺序的执行链。

  • 由于并行化是通过使用多个进程完成的,每个进程都有一个独立和不同的内存空间,进程之间的通信保持简单——通过简单性原则,我们不仅实现了简单和无错的组件,还实现了更容易的互操作性。

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

  • 线程安全是开发人员不再需要浪费时间担心的一个问题。由于单线程并发消除了多线程并发中存在的冲突,开发可以更快地进行,更加稳固。在下图中,我们可以看到左侧如何跨线程共享状态需要细心管理以防止冲突,而右侧的“无共享”架构避免了冲突和阻塞动作:

由事件循环高效管理的单个线程为 Node 程序带来了稳定性、可维护性、可读性和韧性。重要的消息是,Node 继续向开发人员提供多线程的速度和能力——Node 设计的精华使得这种能力变得透明,反映了 Node 既定目标的一部分,即为最多的人带来最大的力量,而最少的困难。

在下图中,展示了两种单线程模型和多线程模型之间的差异:

没有逃脱阻塞操作的可能性——例如,从文件中读取始终需要一些时间。单线程同步模型迫使每个任务在开始之前等待其他任务完成,消耗更多时间。使用线程可以并行启动多个任务,甚至在不同的时间,总执行时间不会超过最长运行线程所需的时间。当使用线程时,开发人员需要负责同步每个单独线程的活动,使用锁定或其他调度工具。当线程数量增加时,这可能变得非常复杂,而在这种复杂性中存在非常微妙和难以发现的错误。

与其让开发人员为这种复杂性而苦苦挣扎,Node 本身管理 I/O 线程。您无需微观管理 I/O 线程;只需设计一个应用程序来建立数据可用性点(回调),以及一旦该数据可用就执行的指令。线程在底层提供了相同的效率,但它们的管理通过一个易于理解的接口暴露给开发人员。

多线程已经是本地和透明的

Node 的 I/O 线程池在操作系统范围内执行,并且其工作分布在核心之间(就像操作系统安排的任何其他作业一样)。当您运行 Node 时,您已经利用了其多线程执行。

在即将讨论的子进程和集群模块中,我们将看到这种并行性的实现。我们将看到 Node 并没有被剥夺操作系统的全部功能。

正如我们之前所看到的,在讨论 Node 的核心架构时,执行 JavaScript 程序的 V8 线程绑定到libuv,后者作为主要的系统级 I/O 事件分发器。在这种情况下,libuv处理由相关 JavaScript 进程或模块命令请求的定时器、文件系统调用、网络调用和其他 I/O 操作,例如fs.readFilehttp.createServer。因此,主 V8 事件循环最好被理解为一个控制流编程接口,由高效的、多线程的系统代理libuv支持和驱动。

Bert Belder,Node 的核心贡献者之一,也是libuv的核心贡献者之一。事实上,Node 的发展引发了libuv开发的同时增加,这种反馈循环只会提高这两个项目的速度和稳定性。它已经合并并取代了形成 Node 原始核心的libeolibev库。

考虑雷蒙德的另一条规则,分离原则:“分离策略和机制;分离接口和引擎。”驱动 Node 的异步、事件驱动编程风格的引擎是libuv;该引擎的接口是 V8 的 JavaScript 运行时。继续看雷蒙德的话:

"实现这种分离的一种方法是,例如,将您的应用程序编写为由嵌入式脚本语言驱动的 C 服务例程库,其中控制流程由脚本语言而不是 C 编写。"

在单个可预测线程的抽象中编排超高效的并行操作系统进程的能力是有意设计的,而不是妥协。

它总结了应用程序开发过程如何改进的务实分析,绝对不是对可能性的限制。

libuv 的详细拆包可以在以下网址找到:github.com/nikhilm/uvbookBurt Belder也在以下网址深入讲解了 libuv 和 Node 在内部是如何工作的:www.youtube.com/watch?v=PNa9OMajw9w

创建子进程

软件开发不再是单片程序的领域。在网络上运行的应用程序不能放弃互操作性。现代应用程序是分布式和解耦的。我们现在构建连接用户与分布在互联网上的资源的应用程序。许多用户同时访问共享资源。如果整个复杂系统被理解为解决一个或几个明确定义的相关问题的程序接口的集合,那么这样的系统更容易理解。在这样的系统中,预期(并且是可取的)进程不会空闲。

Node 的早期批评是它没有多核意识,也就是说,如果 Node 服务器在具有多个核心的机器上运行,它将无法利用这种额外的计算能力。在这个看似合理的批评中隐藏着一种基于草人的不公正偏见:一个程序如果无法显式分配内存和执行线程以实现并行化,就无法处理企业级问题。

这种批评是持久的。这也是不正确的。

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

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

在这里,我们生成了ls进程(列出目录),并从生成的readable流中读取,接收到类似以下内容:

-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 进程(子进程)分配了 10MB 的内存,并表示一个至少需要 30 毫秒启动的新 V8 实例。虽然您不太可能生成成千上万个这样的进程,但了解如何查询和设置用户创建进程的操作系统限制是有益的;htop 或 top 将报告当前运行的进程数量,或者您可以在命令行中使用ps aux | wc –lulimit Unix 命令(ss64.com/bash/ulimit.html)提供了有关操作系统上用户限制的重要信息。通过传递ulimit,-u 参数将显示可以生成的最大用户进程数。通过将其作为参数传递来更改限制:ulimit –u 8192

child_process模块表示一个公开四个主要方法的类:spawnforkexecexecFile。这些方法返回一个扩展了EventEmitterChildProcess对象,公开了一个用于管理子进程的接口和一些有用的函数。我们将看一下它的主要方法,然后讨论常见的ChildProcess接口。

生成进程

这个强大的命令允许 Node 程序启动并与通过系统命令生成的进程进行交互。在前面的示例中,我们使用 spawn 调用了一个本机操作系统进程ls,并传递了lh.参数给该命令。通过这种方式,任何进程都可以像通过命令行启动一样启动。该方法接受三个参数:

  • 命令:要由操作系统 shell 执行的命令

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

  • 选项:用于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)。此指令允许重新定义、继承这些描述符等。

考虑以下子进程程序的输出:

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

在这里,父进程将监听child.stdout。相反,如果我们希望子进程继承其父进程的stdio,这样当子进程写入process.stdout时,发出的内容会通过管道传输到父进程的process.stdout,我们将传递相关的父进程文件描述符给子进程,覆盖其自己的文件描述符:

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

在这种情况下,子进程的输出将直接传输到父进程的标准输出通道。此外,有关此类模式的更多信息,请参见 fork 如下。

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

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

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

  • ignore:文件描述符 0-2 将附加到/dev/null。对于其他文件描述符,将不会在子进程上设置引用的文件描述符。

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

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

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

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

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

除了将stdio设置作为数组传递之外,还可以将某些常见的分组传递

通过传递以下这些快捷字符串值之一来实现:

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

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

  • 'inherit' = [process.stdin, process.stdout, process.stderr]

  • [0,1,2]

我们已经展示了使用spawn来运行 Node 程序作为子进程的一些示例。虽然这是一个完全有效的用法(也是尝试 API 选项的好方法),但spawn主要用于运行系统命令。有关将 Node 进程作为子进程运行的更多信息,请参阅 fork 的讨论如下。

应该注意的是,生成任何系统进程的能力意味着可以使用 Node 来运行安装在操作系统上的其他应用程序环境。如果安装了流行的 PHP 语言,就可以实现以下功能:

const spawn = require('child_process').spawn;
let php = spawn("php", ['-r', 'print "Hello from PHP!";']);
php.stdout.on('readable', () => {
  let d;
  while (d = this.read()) {
    console.log(d.toString());
  }
});
// Hello from PHP!

运行一个更有趣、更大的程序同样容易。

除了通过这种技术异步地运行 Java 或 Ruby 或其他程序,我们还对 Node 的一个持久的批评有了一个很好的回答:JavaScript 在处理数字或执行其他 CPU 密集型任务方面不如其他语言快。这是真的,从这个意义上说,Node 主要针对 I/O 效率进行了优化,并帮助管理高并发应用程序,并且 JavaScript 是一种解释性语言,没有专注于重型计算。

然而,使用spawn,可以很容易地将大量计算和长时间运行的例程传递给其他环境中的独立进程,例如分析引擎或计算引擎。当这些操作完成时,Node 的简单事件循环将确保通知主应用程序,无缝地集成产生的数据。与此同时,主应用程序可以继续为客户端提供服务。

分叉进程

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

可选的选项对象可以作为第三个参数传递,具有以下参数:

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

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

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

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

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

forkspawn之间的一个重要区别是,前者的子进程在完成时不会自动退出。这样的子进程在完成时必须显式退出,可以通过process.exit()轻松实现。

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

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

同样,这将简单地写入一个不断增加的数字。记住,使用fork,子进程将继承其父进程的stdio,我们只需要创建子进程即可在运行父进程的终端中获得输出:

var fork = require('child_process').fork;
fork('./emitter.js');
// -> 0 -> 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10 ...

这里可以演示静默选项;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通信通道使得与分叉子进程的通信变得更加容易和清晰。考虑以下文件,它生成一个子进程并与其通信:

// parent.js
const fork = require('child_process').fork;
let cp = fork('./child.js');
cp.on('message', msgobj => {
    console.log(`Parent got message: ${msgobj.text}`);
});
cp.send({
    text: 'I love you'
});

我们看到现在有一个通信通道可用,通过它父进程可以发送消息,同时也可以接收来自子进程的消息,如下所示:

// child.js
process.on('message', 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

我们将很快深入探讨这个重要的跨进程通信概念。

缓冲进程输出

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

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

  • 选项:这是一个可选参数:

  • cwd(字符串):这设置了命令进程的工作目录。

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

  • encoding(字符串):这是子进程数据流的编码。默认值为'utf8'

  • timeout(数字):这指定等待进程完成的毫秒数,此时子进程将收到killSignal.maxBuffer值。

  • killSignal.maxBuffer(数字):这是stdoutstderr上允许的最大字节数。当超过这个数字时,进程将被杀死。默认为 200 KB。

  • killSignal(字符串):在超时后,子进程接收到此信号。默认为SIGTERM

  • 回调:这个接收三个参数:一个Error对象(如果有的话),stdout(包含结果的Buffer对象),stderr(包含错误数据的Buffer对象,如果有的话)。如果进程被杀死,Error.signal将包含杀死信号。

当您想要exec的缓冲行为,但是针对的是一个 Node 文件时,请使用execFile。重要的是,execFile不会生成一个新的子 shell,这使得它的运行成本稍微降低。

与您的子进程通信

所有ChildProcess对象的实例都扩展了EventEmitter,公开了用于管理子数据连接的有用事件。此外,ChildProcess对象公开了一些有用的方法,用于直接与子进程交互。现在让我们来看一下这些方法,首先是属性和方法:

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

  • child.stdin: 这是一个对应于子进程标准输入的WritableStream

  • child.stdout: 这是一个对应于子进程标准输出的ReadableStream

  • child.stderr: 这是一个对应于子进程标准错误的ReadableStream

  • child.pid: 这是一个整数,表示分配给子进程的进程 ID(PID)。

  • child.kill: 尝试终止子进程,发送一个可选的信号。如果未指定信号,则默认为SIGTERM(有关信号的更多信息,请访问:en.wikipedia.org/wiki/Signal_(IPC))。虽然方法名称听起来是终端的,但不能保证杀死进程 - 它只是向进程发送一个信号。危险的是,如果尝试对已经退出的进程进行kill,则可能会导致新分配了死进程的 PID 的另一个进程接收到信号,后果不可预测。此方法应该触发close事件,该事件用于关闭进程的信号。

  • child.disconnect(): 此命令断开子进程与其父进程之间的 IPC 连接。然后,子进程将会优雅地死去,因为它没有 IPC 通道来保持其存活。您也可以在子进程内部调用process.disconnect()。一旦子进程断开连接,该子引用上的connected标志将被设置为false

向子进程发送消息

正如我们在讨论fork时所看到的,并且在spawnipc选项上使用时,子进程可以通过child.send发送消息,消息作为第一个参数传递。可以将 TCP 服务器或套接字句柄作为第二个参数传递。通过这种方式,TCP 服务器可以将请求分布到多个子进程。例如,以下服务器将套接字处理分布到等于可用 CPU 总数的多个子进程。每个分叉的子进程都被赋予一个唯一的 ID,在启动时报告。每当 TCP 服务器接收到一个套接字时,该套接字将作为一个句柄传递给一个随机的子进程:

// tcpparent.js
const fork = require('child_process').fork;
const net = require('net');
let children = [];
require('os').cpus().forEach((f, idx) => {
 children.push(fork('./tcpchild.js', [idx]));
});
net.createServer((socket) => { 
 let rand = Math.floor(Math.random() * children.length);
 children[rand].send(null, socket);
}).listen(8080)

然后,该子进程发送一个唯一的响应,证明了套接字处理正在分布式进行:

// tcpchild.js
let id = process.argv[2];
process.on('message', (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.

多次访问该端点。您应该看到您的请求是由不同的子进程处理的。

使用多个进程解析文件

许多开发人员将承担的任务之一是构建日志文件处理器。日志文件可能非常大,有数兆字节长。任何一个单独处理非常大文件的程序都很容易遇到内存问题,或者运行速度太慢。逐块处理大文件是有意义的。我们将构建一个简单的日志处理器,将大文件分成多个部分,并将每个部分分配给几个子工作进程,以并行运行它们。

此示例的完整代码可以在代码包的logproc文件夹中找到。我们将专注于主要例程:

  • 确定日志文件中的行数

  • 将它们分成相等的块

  • 为每个块创建一个子进程并传递解析指令

  • 组装并显示结果

为了获得文件的字数,我们使用child.execwc命令,如下面的代码所示:

child.exec(`wc -l ${filename}`, function(e, fL) {
  fileLength = parseInt(fL.replace(filename, ""));

  let fileRanges = [];
  let oStart = 1;
  let oEnd = fileChunkLength;

  while(oStart < fileLength) {
    fileRanges.push({
      offsetStart: oStart,
      offsetEnd: oEnd
    })
    oStart = oEnd + 1;
    oEnd = Math.min(oStart + fileChunkLength, fileLength);
  } 
  ...
}

假设我们使用 500,000 行的fileChunkLength。这意味着将创建四个子进程,并且每个子进程将被告知处理文件中的 500,000 行的范围,例如 1 到 500,000:

let w = child.fork('bin/worker');
w.send({
  file: filename,
  offsetStart: range.offsetStart,
  offsetEnd: range.offsetEnd
});
w.on('message', chunkData => {
  // pass results data on to a reducer.
});

这些工作进程本身将使用子进程来获取它们分配的块,使用sed,这是 Unix 的本地流编辑器:

process.on('message', (m) => {
  let filename = m.file;
  let sed = `sed -n '${m.offsetStart},${m.offsetEnd}p' ${filename}`;
  let reader = require('child_process').exec(sed, {maxBuffer: 1024e6}, (err, data, stderr) => {

     // Split the file chunk into lines and process it.
     //
     data = data.split("\n");
     ...
  })
})            

在这里,我们执行sed –n '500001,1000001p' logfile.txt命令,该命令会提取给定范围的行并返回它们以进行处理。一旦我们处理完数据的列(将它们相加等),子进程将把数据返回给主进程(如前所述),数据结果将被写入文件,否则将被操作,或者发送到stdout,如下图所示:

这个示例的完整文件要长得多,但所有额外的代码只是格式和其他细节——我们已经描述的 Node 子进程管理足以创建一个并行化的系统,用于处理数百万行代码,只需几秒钟。通过使用更多的进程分布在更多的核心上,日志解析速度甚至可以进一步降低。

在您的代码包中的/logproc文件夹中查看README.MD文件,以尝试此示例。

使用集群模块

正如我们在处理大型日志文件时所看到的,一个主父控制器对多个子进程的模式非常适合 Node 的垂直扩展。作为对此的回应,Node API 已经通过cluster模块进行了增强,该模块正式化了这种模式,并有助于更容易地实现它。继续 Node 的核心目标,帮助构建可扩展的网络软件更容易,cluster的特定目标是促进在许多子进程之间共享网络端口。

例如,以下代码创建了一个共享相同 HTTP 连接的工作进程的cluster

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

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

if(cluster.isWorker) {
   http.createServer((req, res) => {
      res.writeHead(200);
      res.end(`Hello from ${cluster.worker.id}`);
   }).listen(8080);
}

我们将很快深入了解细节。现在,请注意cluster.fork没有带任何参数。fork没有命令或文件参数会做什么?在cluster中,默认操作是fork当前程序。我们在cluster.isMaster期间看到,操作是fork子进程(每个可用的 CPU 一个)。当这个程序在分叉的上下文中重新执行时,cluster.isWorker将为true,并且将启动一个在共享端口上运行的新 HTTP 服务器。多个进程共享单个服务器的负载。

使用浏览器启动并连接到此服务器。您将看到类似Hello from 8的内容,这是与负责处理您的请求的唯一cluster.worker.id值相对应的整数。自动处理所有工作进程的负载平衡,因此刷新浏览器几次将导致显示不同的工作进程 ID。

稍后,我们将通过一个示例来介绍如何在集群中共享套接字服务器。现在,我们将列出集群 API,它分为两部分:可用于集群主进程的方法、属性和事件,以及可用于子进程的方法、属性和事件。在这种情况下,使用 fork 定义工作进程,child_process的该方法的文档也可以应用于这里:

  • cluster.isMaster:这是一个布尔值,指示进程是否为主进程。

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

  • cluster.worker:这将引用当前工作进程对象,仅对子进程可用。

  • cluster.workers:这是一个哈希,包含对所有活动工作进程对象的引用,以工作进程 ID 为键。在主进程中使用此方法循环遍历所有工作进程对象。这仅存在于主进程中。

  • cluster.setupMaster([settings]):这是一种方便的方法,用于传递默认参数映射,以在 fork 子进程时使用。如果所有子进程都将 fork 相同的文件(通常情况下),通过在这里设置,可以节省时间。可用的默认值如下:

  • exec(字符串):这是进程文件的文件路径,默认为__filename

  • args(数组):这包含作为参数发送到子进程的字符串。默认情况下,使用process.argv.slice(2)获取参数。

  • silent(布尔值):这指定是否将输出发送到主进程的 stdio,默认为 false。

  • cluster.fork([env]):创建一个新的工作进程。只有主进程可以调用此方法。要将键值对映射暴露给子进程的环境,请发送一个对象到env

  • cluster.disconnect([callback]):用于终止集群中的所有工作进程。一旦所有工作进程都已经优雅地死亡,如果集群进程没有更多事件需要等待,它将自行终止。要在所有子进程过期时收到通知,请传递callback

集群事件

集群对象发出几个事件,如下所列:

  • fork:当主进程尝试 fork 一个新的子进程时触发。这与online不同。这接收一个worker对象。

  • online:当主进程收到子进程完全绑定的通知时触发。这与fork事件不同,并接收一个worker对象。

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

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

  • exit:每当子进程死亡时,都会触发此事件。该事件接收三个参数:一个worker对象,退出代码数字和导致进程被杀死的信号字符串,如SIGNUP

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

工作进程对象属性

工作进程具有以下属性和方法:

  • worker.id:这是分配给工作进程的唯一 ID,也代表cluster.workers索引中的工作进程键。

  • worker.process:这指定了一个引用工作进程的ChildProcess对象。

  • worker.suicide:最近已经对其进行了killdisconnect调用的工作进程将其suicide属性设置为true

  • worker.send(message, [sendHandle]):参考之前提到的child_process.fork()

  • worker.kill([signal]):杀死一个工作进程。主进程可以检查该工作进程的suicide属性,以确定死亡是有意还是意外的。发送的默认信号值是SIGTERM

  • worker.disconnect():这指示工作人员断开连接。重要的是,与工作人员的现有连接不会立即终止(与kill一样),而是允许它们正常退出,然后工作人员完全断开连接。这是因为现有连接可能存在很长时间。定期检查工作人员是否实际断开连接可能是一个很好的模式,也许可以使用超时。

工作人员事件

工作人员也会发出事件,例如以下列表中提到的事件:

  • message:参考child_process.fork

  • online:这与cluster.online相同,只是检查仅针对指定的工作人员

  • listening:这与cluster.listening相同,只是检查仅针对指定的工作人员

  • disconnect:这与cluster.disconnect相同,只是检查仅针对指定的工作人员

  • exit:参考child_processexit事件

  • setup:在cluster.setupMaster执行后调用

现在,根据我们现在对cluster模块的了解,让我们实现一个实时工具,用于分析许多用户同时与应用程序交互时发出的数据流。

使用 PM2 管理多个进程

PM2 旨在成为企业级进程管理器。如其他地方所讨论的,Node 在 Unix 进程中运行,其子进程和集群模块用于在跨多个核心扩展应用程序时生成更多进程。PM2 可用于通过命令行和以编程方式进行部署和监视 Node 进程。PM2 免除了开发人员配置集群样板的复杂性,自动处理重启,并提供了开箱即用的高级日志记录和监视工具。

全局安装 PM2:npm install pm2 -g

使用 PM2 的最简单方法是作为一个简单的进程运行程序。以下程序将每秒递增并记录一个值:

// script.js
let count = 1;
function loop() {
  console.log(count++);
  setTimeout(loop, 1000);
}
loop();

在这里,我们从script.js中派生一个新的进程,在后台永远运行,直到我们停止它。这是运行守护进程的绝佳方式:

pm2 start script.js 
// [PM2] Process script.js launched

脚本启动后,您应该在终端中看到类似于以下内容:

大多数值的含义应该是清楚的,例如您的进程使用的内存量,它是否在线,它已经运行了多长时间等(模式和观看字段将很快解释)。进程将继续运行,直到停止或删除。

要在启动进程时为其设置自定义名称,请将--name参数传递给 PM2:pm2 start script.js --name 'myProcessName'

可以随时通过命令pm2 list查看所有正在运行的 PM2 进程的概述。

PM2 提供其他简单的命令:

  • pm2 stop <app_name | id | all>:按名称停止进程,id 或停止所有进程。已停止的进程将保留在进程列表中,并且可以稍后重新启动。

  • pm2 restart <app_name | id | all>:重新启动进程。在所有进程列表中显示了进程重新启动的次数。要在达到某个最大内存限制(比如 15M)时自动重新启动进程,请使用命令pm2 start script.js --max-memory-restart 15M

  • pm2 delete <app_name | id | all>:删除进程。此进程无法重新启动。pm2 delete all 删除所有 PM2 进程。

  • pm2 info <app_name | id>:提供有关进程的详细信息。

您将经常使用pm2 info <processname>。确保script.js作为 PM2 进程运行,使用PM2 list,然后使用pm2 info script检查该进程信息:

注意为错误和其他日志给出的路径。请记住,我们的脚本每秒递增一个整数并记录该计数。如果您cat /path/to/script/out/log,您的终端将显示已写入输出日志的内容,这应该是一个递增的数字列表。错误同样会写入日志。此外,您可以使用pm2 logs实时流式传输输出日志:

要清除所有日志,请使用pm2 flush

您还可以以编程方式使用 PM2。要复制我们使用 PM2 运行scripts.js的步骤,首先创建以下脚本programmatic.js

const pm2 = require('pm2');

pm2.connect(err => {
   pm2.start('script.js', { 
      name: 'programmed script runner',
      scriptArgs: [
         'first',
         'second',
         'third'
      ],
      execMode : 'fork_mode'
   }, (err, proc) => {
      if(err) {
         throw new Error(err);
      }
   });
});

此脚本将使用 pm2 模块将script.js作为进程运行。继续使用node programmatic.js运行它。执行pm2 list应该显示编程脚本运行器是活动的:

要确保,请尝试pm2 logs——您应该看到数字正在递增,就像以前一样。您可以在此处阅读有关完整编程选项的信息:pm2.keymetrics.io/docs/usage/pm2-api/

监控

PM2 使进程监控变得简单。要查看进程的 CPU 和内存使用情况的实时统计信息,只需输入命令pm2 monit

相当不错,对吧?在通过 PM2 管理的生产服务器上,您可以使用此界面快速查看应用程序的状态,包括内存使用情况和运行日志。

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, 
...
{
  "pid": 13352,
  "name": "programmed script runner",
  "pm2_env": {
    "instance_var": "NODE_APP_INSTANCE",
    "exec_mode": "fork_mode",
...
  "pm_id": 8, // our script.js process "monit": {
  "memory": 19619840, "cpu": 0 
...

创建一个基于 Web 的 UI,每隔几秒轮询您的服务器,获取进程信息,然后绘制图表,由于 PM2 的这一内置功能,变得更加简单。PM2 还有一个选项,可以在所有管理的脚本上设置一个监视器,这样监视的脚本的任何更改都会导致自动进程重启。这在开发过程中非常有用。

作为演示,让我们创建一个简单的 HTTP 服务器并通过 PM2 运行它:

// server.js
const http = require('http');
http.createServer((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 进程文件进行更多涉及配置。

进程文件

继续使用pm2 delete all杀死所有正在运行的 PM2 进程。然后,创建以下process.json文件:

// process.json
{
  "apps" : [{
    "name" : "server",
    "script" : "./server.js",
    "watch" : true,
    "env": {
      "NODE_ENV": "development"
    },
    "instances" : 4,
    "exec_mode" : "cluster"
  }]
}

我们将使用此部署定义在 PM2 上启动我们的应用程序。请注意,apps 是一个数组,这意味着您可以列出几个不同的应用程序,并使用不同的配置同时启动它们。我们将在下面解释这些字段,但现在,请使用pm2 start process.json执行此清单。您应该会看到类似于这样的内容:

部署多进程(集群)应用程序如此简单。PM2 将自动在实例之间平衡负载,在清单中通过instances属性设置为 4 个 CPU,exec_modecluster(默认模式为“fork”)。在生产环境中,您可能希望在最大核心数之间平衡负载,只需将instances设置为0即可。此外,您可以看到我们通过env:设置了环境变量,您可以在此处为服务器创建devprod(甚至stage)配置,设置 API 密钥和密码以及其他环境变量。

打开浏览器并访问localhost:8080,以查看服务器是否正在运行。请注意,在我们的 JSON 清单中,我们将watch设置为true。这告诉 PM2 在您的存储库中更改任何文件时自动重新启动应用程序,跨所有核心。通过更改服务器上的“Hello”消息为其他内容来测试它。然后重新加载localhost:8080,您将看到新消息,表明服务器已重新启动。如果列出正在运行的 PM2 进程,您将看到重新启动的次数:

试着多次尝试。重新启动是稳定的,快速的,自动的。

您还可以为监视器指定特定的文件:

{
  "apps" : [{
    ...
    "watch": [
      "tests/*.test",
      "app" 
    ],
    "ignore_watch": [
      "**/*.log"
    ],
    "watch_options": {
      "followSymlinks": false
    },
    ...
  }]
}

在这里,我们告诉 PM2 只监视/test中的.test文件和/app目录,忽略任何.log 文件的更改。在底层,PM2 使用 Chokidar (github.com/paulmillr/chokidar#api)来监视文件更改,因此您可以通过在watch_options上设置 Chokidar 选项来进一步配置监视器。请注意,您可以在这些设置中使用 glob 表达式(和正则表达式)。

您可以在此处阅读 PM2 进程文件的完整选项列表:pm2.keymetrics.io/docs/usage/application-declaration/

一些需要注意的地方:

  • max_restarts:PM2 允许的不稳定重新启动次数。

  • min_uptime:在被视为不稳定并触发重新启动之前,应用程序被给予启动的最短时间。

  • autorestart:是否在崩溃时重新启动。

  • node_args:将命令行参数传递给 Node 进程本身。例如:node_args: "--harmony"相当于node --harmony server.js

  • max_memory_restart:当内存使用量超过此阈值时发生重新启动。

  • restart_delay:特别是在watch场景中,您可能希望在文件更改时延迟重新启动,等待一段时间再做出反应。

由于 PM2,服务器应用程序的实时开发变得更加容易。

多个工作结果的实时活动更新

利用我们所学到的知识,我们将构建一个多进程系统来跟踪所有访问者对示例网页的行为。这将由两个主要部分组成:一个由 WebSocket 驱动的客户端库,它将在用户移动鼠标时广播每次移动,以及一个管理界面,可视化用户交互以及用户连接和断开系统的时间。我们的目标是展示如何设计一个更复杂的系统(例如跟踪和绘制用户可能进行的每次点击、滑动或其他交互)。

最终的管理界面将显示几个用户的活动图表,并类似于这样:

由于该系统将跟踪所有用户所做的每次鼠标移动的 X 和 Y 位置,我们将使用cluster将这连续的数据流跨越所有可用的机器核心,集群中的每个工作进程都共享承载大量套接字数据的负担,这些数据被馈送到一个共享端口。继续访问本章的代码包,并按照/watcher文件夹中的README.MD说明进行操作。

一个很好的开始是设计模拟客户端页面,它负责捕获所有鼠标移动事件并通过WebSocket将它们广播到我们的集群套接字服务器。我们正在使用本机的WebSocket实现;您可能希望使用一个库来处理旧版浏览器(如Socket.IO):

<head>
  <script>
    let connection = new WebSocket('ws://127.0.0.1:8081', ['json']);
      connection.onopen = () => {
        let userId = 'user' + Math.floor(Math.random()*10e10);
        document.onmousemove = e => {
          connection.send(JSON.stringify({id: userId, x: e.x, y: e.y}));
        }
      };
  </script>
</head>

在这里,我们只需要简单地打开基本的mousemove跟踪,它将在每次移动时广播用户鼠标的位置到我们的套接字。此外,我们还发送一个唯一的用户 ID,因为跟踪客户端身份对我们来说以后很重要。请注意,在生产环境中,您将希望通过服务器端身份验证模块实现更智能的唯一 ID 生成器。

为了使这些信息传达给其他客户端,必须设置一个集中的套接字服务器。正如前面提到的,我们希望这个套接字服务器是集群的。每个集群子进程,都是以下程序的副本,将处理客户端发送的鼠标数据:

const SServer = require('ws').Server;
let socketServer = new SServer({port: 8081});
socketServer.on('connection', socket => {
  let lastMessage = null;
  function kill() => {
    if (lastMessage) {                                              
      process.send({kill: lastMessage.id});            
    }
  }
  socket.on('message', message => {
    lastMessage = JSON.parse(message);   
    process.send(lastMessage);                                                                  
  });
  socket.on('close', kill);
  socket.on('error', kill);
});

在这个演示中,我们使用了Einar Otto Stangvik的非常快速和设计良好的套接字服务器库ws,它托管在 GitHub 上:github.com/websockets/ws

值得庆幸的是,我们的代码仍然非常简单。我们有一个监听消息的套接字服务器(记住客户端发送的是一个带有鼠标XY以及用户 ID 的对象)。最后,当接收到数据时(message事件),我们将接收到的 JSON 解析为一个对象,并通过process.send将其传递回我们的集群主。

还要注意我们如何存储最后一条消息(lastMessage),出于簿记原因,当连接终止时,我们将需要将此连接上看到的最后一个用户 ID 传递给管理员。

现在已经设置好了捕捉客户端数据广播的部分。一旦接收到这些数据,它是如何传递给先前展示的管理界面的?

我们设计这个系统时考虑了扩展性,并希望将数据的收集与广播数据的系统分离。我们的套接字服务器集群可以接受来自成千上万客户端的持续数据流,并且应该针对这一点进行优化。换句话说,集群应该将广播鼠标活动数据的责任委托给另一个系统,甚至是其他服务器。

在下一章中,我们将研究更高级的扩展和消息传递工具,比如消息队列和 UDP 广播。对于我们在这里的目的,我们将简单地创建一个 HTTP 服务器,负责管理来自管理员的连接并向他们广播鼠标活动更新。我们将使用 SSE 来实现这一点,因为数据流只需要单向,从服务器到客户端。

HTTP 服务器将为管理员登录实现一个非常基本的验证系统,以一种允许我们的套接字集群向所有成功连接广播鼠标活动更新的方式保留成功的连接。它还将作为一个基本的静态文件服务器,当请求时发送客户端和管理 HTML,尽管我们只关注它如何处理两个路由:“admin/adminname”和/receive/adminname。一旦服务器被理解,我们将进入我们的套接字集群如何连接到它。

第一个路由/admin/adminname主要负责验证管理员登录,还要确保这不是重复登录。一旦确认了身份,我们就可以向管理界面发送一个 HTML 页面。用于绘制先前图片中的图表的特定客户端代码将不在这里讨论。我们需要的是与服务器建立 SSE 连接,以便界面的图表工具可以实时接收鼠标活动的更新。返回的管理员页面上的一些 JavaScript 建立了这样的连接:

let ev = new EventSource('/receive/adminname');
ev.addEventListener("open", () => {
  console.log("Connection opened");
});
ev.addEventListener("message", data => {
  //  Do something with mouse data, like graph it.
}

在我们的服务器上,我们实现了/receive/adminname路由:

if (method === "receive") {
  // Unknown admin; reject
  if (!admins[adminId]) {
    return response.end();
  }
  response.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    "Connection": "keep-alive"
  });
  response.write(":" + Array(2049).join(" ") + "\n");
  response.write("retry: 2000\n");
  response.on("close", () => {
    admins[adminId] = {};
  });
  setInterval(() => {
    response.write("data: PING\n\n");
  }, 15000);
  admins[adminId].socket = response;
  return;
}

这个路由的主要目的是建立 SSE 连接并存储管理员的连接,以便我们以后可以向其广播。

现在我们将添加一些部分,将鼠标活动数据传递给可视化界面。使用集群模块跨核心扩展这个子系统是我们的下一步。集群主现在只需要等待来自其提供套接字服务的子进程的鼠标数据,就像之前描述的那样。

我们将使用在之前的集群讨论中提出的相同思想,简单地将先前的套接字服务器代码分叉到所有可用的 CPU 上:

if (cluster.isMaster) {
  let i;
  for (i = 0; i < numCPUs; i++) {
    cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
  console.log(`worker ${worker.process.pid} died`);
})

// Set up socket worker listeners
Object.keys(cluster.workers).forEach(id => {
  cluster.workers[id].on('message', msg => {
    let a;
    for (a in admins) {
      if (admins[a].socket) {
        admins[a].socket.write(`data: ${JSON.stringify(msg)}\n\n`);
      }
    }
  });
});

鼠标活动数据通过套接字传输到一个集群工作进程,并通过process.send广播到之前描述的集群主进程。在每个工作进程的消息中,我们遍历所有连接的管理员,并使用 SSE 将鼠标数据发送到他们的可视化界面。管理员现在可以观察客户端的到来和离开,以及他们个人的活动水平。

为了测试系统,首先以默认管理员身份登录,网址为http://localhost:2112/admin/adminname。你应该会看到一个青绿色的背景,目前为空,因为没有连接的客户端。接下来,通过打开一个或多个浏览器窗口并导航到http://localhost:2112来创建一些客户端,你会看到一个空白屏幕。随意在屏幕上移动鼠标。如果你返回管理员界面,你会看到你的鼠标移动(一个或多个客户端)正在被跟踪和绘制成图表。

总结

这是我们真正开始测试 Node 可扩展性目标的第一章。在考虑了关于并发和并行思考方式的各种论点之后,我们理解了 Node 如何成功地在并发模型中包裹了所有这些复杂性,使其易于理解和稳健,同时保持了线程和并行处理的优势。

深入了解了进程的工作方式,特别是子进程如何相互通信,甚至生成更多的子进程,我们看了一些用例。将原生 Unix 命令进程与自定义 Node 进程无缝结合的示例,让我们找到了一种高效且简单的处理大文件的技术。然后,集群模块被应用于如何在多个工作进程之间共享处理繁忙套接字的问题,这种在进程之间共享套接字句柄的能力展示了 Node 设计的一个强大方面。我们还了解了一个生产级的进程管理器 PM2,以及它如何使管理单个进程和集群变得更容易。

在看到了 Node 应用如何进行垂直扩展之后,我们现在可以研究跨多个系统和服务器的水平扩展。在下一章中,我们将学习如何将 Node 与亚马逊和 Twilio 等第三方服务连接,设置多个 Node 服务器在代理后面,并且更多内容。

第八章:扩展您的应用程序

“进化是一个不断分支和扩张的过程。”

  • Stephen Jay Gould

可扩展性和性能并不是相同的东西:

“‘性能’和‘可扩展性’这两个术语通常可以互换使用,

但这两者是不同的:性能衡量的是单个请求执行的速度,而可扩展性衡量的是请求在负载增加时保持其性能的能力。例如,一个请求的性能可能被报告为在三秒内生成有效响应,但请求的可扩展性衡量的是请求在用户负载增加时保持这三秒的响应时间的能力。”

  • Steven Haines,《Pro Java EE 5》

在上一章中,我们看到了 Node 集群如何用于提高应用程序的性能。通过使用进程和工作进程的集群,我们学会了如何在面对许多同时请求时高效地交付结果。我们学会了垂直扩展 Node,通过堆叠可用 CPU 的性能来增加吞吐量,保持相同的占用空间(单个服务器)。

在本章中,我们将专注于横向可扩展性;其思想是由自给自足和独立的单元(服务器)组成的应用程序可以通过添加更多单元而无需改变应用程序的代码来进行扩展。

我们希望创建一个架构,其中任意数量的优化和封装的 Node 驱动服务器可以根据不断变化的需求进行添加或减少,动态扩展而无需进行系统重写。我们希望在不同系统之间共享工作,将请求推送到操作系统、另一个服务器、第三方服务,同时使用 Node 的事件驱动并发方式智能地协调这些 I/O 操作。

通过架构的并行性,我们的系统可以更有效地管理增加的数据量。必要时,专门的系统可以被隔离,甚至可以独立扩展或以其他方式进行集群化。

Node 特别适合处理横向扩展架构的两个关键方面。

首先,Node 强制执行非阻塞 I/O,这样任何一个单元的卡住都不会导致整个应用程序崩溃。由于没有任何单一的 I/O 操作会阻塞整个系统,因此可以放心地集成第三方服务,鼓励解耦的架构。

其次,Node 非常重视支持尽可能多的快速网络通信协议。无论是通过共享数据库、共享文件系统还是消息队列,Node 的高效网络和Stream层允许许多服务器在平衡负载方面同步它们的努力。例如,能够高效地管理共享套接字连接有助于在扩展服务器集群和进程集群时使用。

在本章中,我们将探讨如何在运行 Node 的许多服务器之间平衡流量,这些不同的服务器如何进行通信,以及这些集群如何绑定并从专门的云服务中获益。

何时进行扩展?

关于应用程序扩展的理论是一个复杂而有趣的话题,它不断得到完善和扩展。对于不同的环境和需求,全面讨论这个话题将需要几本书。对于我们的目的,我们只需学会如何识别何时需要进行扩展(甚至缩减)。

拥有一个灵活的架构,可以根据需要添加和减少资源,对于一个具有弹性的扩展策略至关重要。垂直扩展的解决方案并不总是足够(简单地添加内存或 CPU 不会带来必要的改进)。何时应考虑横向扩展?

监控服务器是至关重要的。检查服务器上运行的 Node 进程所占用的 CPU 和内存使用情况的一个简单但有用的方法是使用 Unix 的ps进程状态)命令,例如,ps aux | grep node。更健壮的解决方案是安装一个交互式进程管理器,比如 Unix 系统的 HTOP(hisham.hm/htop/)或基于 Windows 的系统的 Process Explorer(docs.microsoft.com/en-us/sysinternals/downloads/process-explorer)。

网络延迟

当网络响应时间超过某个阈值时,比如每个请求花费几秒钟,很可能系统已经远远超过了稳定状态。

虽然发现这个问题最简单的方法是等待客户对网站速度慢的投诉,但最好是针对等效的应用环境或服务器创建受控的压力测试。

ABApache Bench)是一种对服务器进行粗略压力测试的简单直接的方式。这个工具可以以多种方式进行配置,但通常用于测量服务器的网络响应时间的测试是比较直接的。

例如,让我们测试一下这个简单的 Node 服务器的响应时间:

http.createServer(function(request, response) { 
    response.writeHeader(200, {"Content-Type": "text/plain"});   
    response.write("Hello World");   
    response.end();   
}).listen(2112) 

以下是如何对该服务器运行 10,000 个请求进行测试,并发数为 100(即同时请求的数量):

ab -n 10000 -c 100 http://yourserver.com/ 

如果一切顺利,您将收到类似于这样的报告:

 Concurrency Level:      100
 Time taken for tests:   9.658 seconds
 Complete requests:      10000
 Failed requests:        0
 Write errors:           0
 Total transferred:      1120000 bytes
 HTML transferred:       110000 bytes
 Requests per second:    1035.42 [#/sec] (mean)
 Time per request:       96.579 [ms] (mean)
 Time per request:       0.966 [ms] (mean, across all concurrent requests)
 Transfer rate:          113.25 [Kbytes/sec] received

 Connection Times (ms)
 min  mean[+/-sd] median   max
 Connect:        0    0   0.4      0       6
 Processing:    54   96  11.7     90     136
 Waiting:       53   96  11.7     89     136
 Total:         54   96  11.6     90     136

 Percentage of the requests served within a certain time (ms)
 50%     90
 66%     98
 ...
 99%    133
 100%    136 (longest request) 

这份报告中包含了很多有用的信息。特别是,应该寻找失败的请求和长时间运行的请求的百分比。

存在更复杂的测试系统,但ab是性能的一个快速脏快照。养成创建与生产系统相似的测试环境并对其进行测试的习惯。

在运行 Node 进程的同一台服务器上运行ab,当然会影响测试速度。测试运行程序本身会占用大量服务器资源,因此您的结果将是误导性的。ab的完整文档可以在此找到:httpd.apache.org/docs/2.4/programs/ab.html

热 CPU

当 CPU 使用率开始接近最大值时,开始考虑增加处理客户端请求的单位数量。请记住,虽然在单 CPU 机器上添加一个新的 CPU 会带来立即和巨大的改进,但在 32 核机器上添加另一个 CPU 不一定会带来同等的改进。减速并不总是由于计算速度慢造成的。

如前所述,htop是了解服务器性能的一个很好的方式。它实时可视化了每个核心所承受的负载,这是了解发生了什么的一个很好的方式。此外,服务器的负载平均值以三个值的形式得到了很好的总结。这是一个良好的服务器:

Load average: 0.00 0.01 0.00

这些值代表什么?什么是“好”的或“坏”的负载平均值?

这三个数字都是在衡量 CPU 使用率,呈现了一分钟、五分钟和十五分钟间隔的测量值。通常情况下,短期负载会比长期负载更高。如果平均而言,您的服务器随着时间的推移并没有过度紧张,那么客户很可能会有良好的体验。

在单核机器上,负载平均值应保持在 0.00 和 1.00 之间。任何请求都会花费一些时间,问题是请求是否花费了比必要更多的时间,以及是否由于负载过大而出现了延迟。

如果将 CPU 视为管道,测量值为 0.00 意味着推送一滴水时没有过多的摩擦或延迟。测量值为 1.00 表示我们的管道已经达到容量;水流畅地流动,但任何额外的推送水的尝试都将面临延迟或背压。这会转化为网络上的延迟,新请求加入不断增长的队列。

多核机器只是将测量边界乘以。当负载平均值达到 4.00 时,拥有四个核心的机器已经达到容量。

您选择如何对负载平均值做出反应取决于应用程序的具体情况。对于运行数学模型的服务器来说,看到它们的 CPU 平均值达到最大容量并不罕见;在这种情况下,您希望所有可用资源都专门用于执行计算。另一方面,运行在容量上限的文件服务器可能值得调查。

一般来说,负载平均值超过 0.60 应该进行调查。事情并不紧急,但可能会有问题。一个经过所有已知优化后仍经常达到 1.00 的服务器显然是扩展的候选者,当然,任何超过这个平均值的服务器也是如此。

Node 还通过os模块提供本机进程信息:

const os = require('os'); 
// Load average, as an Array 
console.log(os.loadavg()); 
// Total and free memory 
console.log(os.totalmem()); 
console.log(os.freemem()); 
// Information about CPUs, as an Array 
console.log(os.cpus()); 

套接字使用

然而,当持久套接字连接的数量开始超过任何单个 Node 服务器的容量时,无论优化多少,都需要考虑将处理用户套接字的服务器分散开来。使用socket.io,可以使用以下命令随时检查连接的客户端数量:

io.sockets.clients() 

一般来说,最好通过应用程序内的某种跟踪/日志系统来跟踪 Web 套接字连接计数。

许多文件描述符

当操作系统打开的文件描述符数量接近其限制时,很可能有过多的 Node 进程活动,文件已打开,或者其他文件描述符(如套接字或命名管道)正在使用中。如果这些高数字不是由于错误或糟糕的设计,那么是时候添加一个新的服务器了。

可以使用lsof来检查任何类型的打开文件描述符的数量:

# lsof | wc -l     // 1345

数据蔓延

当单个数据库服务器管理的数据量开始超过数百万行或数千兆字节的内存时,就是考虑扩展的时候了。在这里,您可以选择简单地将单个服务器专用于您的数据库,开始共享数据库,甚至在早期而不是晚期转移到托管的云存储解决方案。从数据层故障中恢复很少是一个快速的修复,一般来说,对于像所有您的数据这样重要的事情,有一个单一的故障点是危险的。

如果您正在使用 Redis,info命令将提供大部分您需要的数据,以做出这些决定。考虑以下示例:

redis> info 
# Clients 
connected_clients:1 
blocked_clients:0 
# Memory 
used_memory:17683488 
used_memory_human:16.86M 
used_memory_rss:165900288 
used_memory_peak:226730192 
used_memory_peak_human:216.23M 
used_memory_lua:31744 
mem_fragmentation_ratio:9.38 
# CPU 
used_cpu_sys:13998.77 
used_cpu_user:21498.45 
used_cpu_sys_children:1.60 
used_cpu_user_children:7.19 
... 

有关INFO的更多信息,请参阅:redis.io/commands/INFO

对于 MongoDB,您可以使用db.stats()命令:

> db.stats(1024) 
{    "collections" : 3, 
 "objects" : 5, 
 "avgObjSize" : 39.2, 
 "dataSize" : 0, 
 "storageSize" : 12, 
 "numExtents" : 3, 
 "indexes" : 1, 
 "indexSize" : 7, 
 "fileSize" : 196608, 
 "nsSizeMB" : 16, 
 ... 
 "ok" : 1 } 

传递参数1024标记stats以以千字节显示所有值。

更多信息请参阅:docs.mongodb.com/v3.4/reference/method/db.stats/

服务器监控工具

有几种可用于监控服务器的工具,但很少有专门为 Node 设计的。一个强有力的候选者是N|Solid (nodesource.com/products/nsolid),这是一个由许多 Node 核心的关键贡献者组成的公司。这个云服务很容易与 Node 应用集成,提供一个有用的仪表板,可视化 CPU 使用情况、平均响应时间等。

其他值得考虑的监控工具列在以下:

运行多个 Node 服务器

购买几台服务器然后在它们上面运行一些 Node 进程是很容易的。然而,如何协调这些不同的服务器,使它们成为单个应用程序的一部分呢?这个问题的一个方面涉及将多个相同的服务器集群在一个入口点周围。客户端连接如何在一组服务器之间共享?

水平扩展是将架构分割成网络不同节点并协调它们的过程。云计算在这里相关,简单地意味着将应用程序的一些功能定位到远程服务器上,而不是在其他地方运行的服务器上。没有单点故障(理论上如此),整个系统更加健壮。停车场问题是沃尔玛可能面临的另一个考虑因素——在购物节日期间,您将需要成千上万个停车位,但在一年的其他时间,很难证明这种对空间的投资是合理的。就服务器而言,动态扩展的能力既支持向上扩展也支持向下扩展,这与构建固定的垂直存储单元相矛盾。向正在运行的服务器添加硬件也比启动并无缝链接另一个虚拟机到您的应用程序更复杂。

当然,文件服务速度并不是您可能使用像 NGINX 这样的代理服务器的唯一原因。通常情况下,网络拓扑特性使得反向代理成为更好的选择,特别是当集中常见服务(如压缩)时。关键是 Node 不应该仅仅因为其有效地提供文件的能力而被排除在外。

正向和反向代理

代理是代表另一个人或事物行事的人或事物。

正向代理通常代表私有网络中的客户端,代理对外部网络的请求,例如从互联网检索数据。在本书的前面,我们看了如何使用 Node 设置代理服务器,其中 Node 服务器充当中间人,将客户端的请求转发到其他网络服务器,通常是通过互联网。

早期的网络提供商如 AOL 的功能如下:

网络管理员在必须限制对外部世界(即互联网)的访问时使用正向代理。如果网络用户通过电子邮件附件从somebadwebsite.com下载恶意软件,管理员可以阻止对该位置的访问。对办公网络施加对社交网络网站的访问限制也是可能的。一些国家甚至以这种方式限制对公共网站的访问。

反向代理,并不奇怪,以相反的方式工作,接受来自公共网络的请求,并在客户端几乎看不到的私有网络中处理这些请求。客户端对服务器的直接访问首先委托给反向代理:

这是我们可能使用的代理类型,用于在许多 Node 服务器之间平衡客户端的请求。客户端X不直接与任何给定的服务器通信。代理Y是第一次接触点,能够将X引导到负载较小的服务器,或者距离X更近的服务器,或者在某种程度上是X在此时访问的最佳服务器。

我们现在将看一下如何在扩展 Node 时实现反向代理,讨论使用NGINX(发音为Engine X)的负载均衡 Node 服务器的实现,以及使用本机 Node 模块的实现。

使用 http-proxy 模块

多年来,人们建议在 Node 服务器前面放置一个 Web 服务器(如 NGINX)。理由是成熟的 Web 服务器能更有效地处理静态文件传输。虽然这对于早期的 Node 版本可能是真的(这些版本也受到新技术面临的错误的影响),但从纯速度来说,这不一定再是真的。更重要的是,使用内容交付网络(CDN)和其他边缘服务,你的应用程序可能需要的静态文件已经被缓存了,你的服务器本来就不会提供这些文件。

Node 旨在促进网络软件的创建,因此并不奇怪会开发出几个代理模块。一个受欢迎的生产级 Node 代理是 http-proxy。让我们看看如何使用它来平衡对不同 Node 服务器的请求。

我们的整个路由堆栈将由 Node 提供。一个 Node 服务器将运行我们的代理,监听端口80。我们将涵盖以下三种情况:

  • 在同一台机器上运行多个不同端口的 Node 服务器

  • 使用一个盒子作为纯路由器,代理到外部 URL

  • 创建一个基本的轮询负载均衡器

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

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

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

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

let 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 服务器上:

let 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 指令中看到的那样,我们只需提供要平衡的服务器列表:

const httpProxy = require('http-proxy'); 
let addresses = [ 
  { host: 'one.example.com', port: 80 }, 
  { host: 'two.example.com', port: 80 } 
]; 
httpProxy.createServer((req, res, proxy) => { 
  let target = addresses.shift(); 
  proxy.proxyRequest(req, res, target); 
  addresses.push(target); 
}).listen(80); 

在这个例子中,我们平等对待服务器,按顺序循环使用它们。选择的服务器被代理后,它将返回到列表的末尾。

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

redbird 模块是建立在 http-proxy 之上的一个非常先进的反向代理。除其他功能外,它还内置了自动 SSL 证书生成和 HTTP/2 支持。了解更多信息:github.com/OptimalBits/redbird

在 Digital Ocean 上部署 NGINX 负载均衡器

由于 Node 非常高效,大多数网站或应用程序可以在垂直维度上满足其所有扩展需求。Node 可以使用少量 CPU 和普通内存处理大量流量。

NGINX 是一个非常受欢迎的高性能 Web 服务器,通常用作代理服务器。NGINX 与 Node 开发人员的设计非常相似,这是一种巧合:

www.linuxjournal.com/magazine/nginx-high-performance-web-server-and-reverse-proxy所述,“NGINX 能够以更少的资源处理更多的请求,因为它的架构。它由一个主进程组成,将工作委托给一个或多个工作进程。每个工作进程使用 Linux 内核的特殊功能(epoll/select/poll)以事件驱动或异步方式处理多个请求。这使得 NGINX 能够快速处理大量并发请求,几乎没有额外开销。”

NGINX 还使负载均衡变得非常容易。在以下示例中,我们将看到通过 NGINX 代理自带负载均衡功能。

Digital Ocean 是一个价格便宜且易于设置的云托管提供商。我们将在该服务上构建一个 NGINX 负载均衡器。

要注册,请访问:www.digitalocean.com。基本套餐(在撰写本文时)需要支付五美元的费用,但经常提供促销代码;简单的网络搜索应该能找到可用的代码。创建并验证一个帐户以开始使用。

Digital Ocean 的套餐被描述为滴,具有特定的特征-存储空间量、传输限制等。我们的需求足够一个基本套餐。此外,您还将指定托管区域以及要在滴中安装的操作系统(在本例中,我们将使用最新版本的 Ubuntu)。创建一个滴,并检查您的电子邮件以获取登录说明。完成!

您将收到实例的完整登录信息。现在,您可以打开终端并使用这些登录凭据通过 SSH 登录到您的主机。

在您初始登录时,您可能希望更新您的软件包。对于 Ubuntu,您将运行apt-get updateapt-get upgrade。其他软件包管理器有类似的命令(例如,RHEL/CentOs 的yum update)。

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

还有一步:我们希望为我们的新用户提供一些管理权限,因为我们将以该用户身份安装软件。在 Unix 术语中,您希望为这个新用户提供sudo访问权限。无论您选择哪个操作系统,都很容易找到如何执行此操作的说明。基本上,您需要更改/etc/sudoers文件。记住要使用lvisudo这样的命令来执行此操作;不要手动编辑 sudoers 文件!您可能还希望在这一点上限制根登录并进行其他 SSH 访问管理。

在终端成功执行sudo -i后,您将能够在不用每个命令都加上sudo前缀的情况下输入命令。以下示例假定您已经执行了此操作。

现在,我们将为两个 Node 服务器创建一个 NGINX 负载均衡器前端。这意味着我们将创建三个滴:一个用于负载均衡器,另外两个用作 Node 服务器。最后,我们将得到一个类似于这样的架构:

安装和配置 NGINX

让我们安装 NGINX 和 Node/npm。如果您仍然以 root 用户登录,请注销并重新验证您刚刚创建的新用户。要安装 NGINX(在 Ubuntu 上),只需输入以下命令:

apt-get install nginx

大多数其他 Unix 软件包管理器都有 NGINX 安装程序。

要启动 NGINX,请使用以下命令:

service nginx start

NGINX 的完整文档可以在此处找到:www.nginx.com/resources/wiki/start/

现在,您应该能够将浏览器指向您分配的 IP(如果您忘记了,请检查您的收件箱),并看到类似于这样的内容:

现在,让我们设置 NGINX 将平衡的两个服务器。

在 Digital Ocean 中创建另外两个滴。您需要在这些服务器上安装 NGINX。像之前一样在这些服务器上配置权限,并在两个滴中安装 Node。管理 Node 安装的简单方法是使用Tim Caswell 的 NVM(Node Version Manager)。NVM 本质上是一个 bash 脚本,提供一组命令行工具,便于管理 Node 版本,允许您轻松切换版本。要安装它,请在终端中运行以下命令:

curl https://raw.githubusercontent.com/creationix/nvm/v7.10.1/install.sh | sh

现在,安装您首选的 Node 版本:

nvm install 9.2.0

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

nvm use 9.2.0

为了测试我们的系统,我们需要在这两台机器上设置 Node 服务器。在每台服务器上创建以下程序文件,将**更改为每个服务器上的唯一内容(例如onetwo):

const http = require('http');
http.createServer((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 配置脚本中指示应该平衡的上游服务器。我们刚刚创建的两个 Node 服务器将成为上游服务器。NGINX 将被配置为平衡对每个请求:

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

您将在负载均衡器 Droplet 上的/etc/nginx/sites-available/default找到默认的 NGINX 服务器配置文件。

在生产中,您可能希望创建一个自定义目录和配置文件,但出于我们的目的,我们将简单地修改默认配置文件(在开始修改之前,您可能需要备份)。

在 NGINX 配置文件的顶部,我们希望定义一些上游服务器,这些服务器将成为重定向的候选者。这只是一个带有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:8001server 127.0.0.1:8002

继续更改 NGINX 配置(如果遇到困难,请查阅本书代码包中的nginx.config文件)。更改后,使用以下命令重新启动 NGINX:

service nginx restart

或者,如果您愿意,可以使用此方法:

service nginx stop
service nginx start

假设另外两个运行 Node 服务器的 Droplet 处于活动状态,您现在应该能够将浏览器指向启用了 NGINX 的 Droplet,并查看来自这些服务器的消息!

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

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

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,网址为:www.haproxy.org

消息队列-RabbitMQ

确保分布式服务器保持可靠的通信渠道的最佳方法之一是将远程过程调用的复杂性捆绑到一个独立的单元中-一个消息队列。当一个进程希望向另一个进程发送消息时,消息可以简单地放在这个队列上-就像应用程序的待办事项列表一样,队列服务负责确保消息被传递以及将任何重要的回复传递回原始发送者。

有一些企业级消息队列可用,其中许多部署了AMQP (高级消息队列协议)。我们将专注于一个非常稳定和知名的实现-RabbitMQ。

要在您的环境中安装 RabbitMQ,请按照以下网址找到的说明进行操作:www.rabbitmq.com/download.html

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

service rabbitmq-server start

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

npm install amqp

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

const amqp = require('amqp');

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


consumer.on('error', err => {
 
console.log(err);

});


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

  
q.bind(exchange, '#');

  
q.subscribe(message => {
   // Messages are buffers
   console.log(message.data.toString('utf8'));
  
});
  
  
exchange.publish("some-topic", "Hello!");
 
});

});

我们现在正在监听来自绑定到端口5672的 RabbitMQ 服务器的消息。

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

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

consumer.on('ready', function() { 
  // ... 
  exchange.publish("some-topic", "Hello!"); 
}); 
// Hello! 

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

交换机的类型

RabbitMQ 提供三种类型的交换机:直连扇出主题。这些差异体现在每种类型的交换机如何处理路由键-发送到exchange.publish的第一个参数。

直连交换机直接匹配路由键。像下面这样的队列绑定匹配发送到'room-1'的消息:

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

由于不需要解析,直接交换能够在一段时间内处理比主题交换更多的消息。

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

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

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

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

consumer.on('ready', function() { 
  // When all 3 queues are ready, publish. 
  let cnt = 3; 
  let 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!"); 
  } 
  let exchange = consumer.exchange('topical', {type: "topic"}); 
  consumer.queue('queue-1', q => { 
    q.bind(exchange, 'animals.*.shorthaired'); 
    q.subscribe(message => { 
      console.log(`animals.*.shorthaired -> ${message.data.toString('utf8')}`); 
    }); 
    queueReady(); 
  }); 
  consumer.queue('queue-2', q => {     
    q.bind(exchange, '#'); 
    q.subscribe(function(message) { 
      console.log('# -> ' + message.data.toString('utf8')); 
    }); 
    queueReady(); 
  }); 
  consumer.queue('queue-3', q => {     
    q.bind(exchange, '*.cats.*'); 
    q.subscribe(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 通常不可靠。在大多数应用程序中,它以高概率传递消息。当需要完美可靠性时,比如在银行应用程序中,它就不合适了。它是监控和日志应用程序以及非关键消息服务的绝佳选择。

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

const dgram = require("dgram"); 
let socket = dgram.createSocket("udp4"); 
socket.on("message", (msg, info) => { 
  console.log("socket got: " + msg + " from " + 
  info.address + ":" + info.port); 
}); 
socket.bind(41234); 
socket.on("listening", () => { 
  console.log("Listening for datagrams."); 
}); 

bind命令需要三个参数,如下所示:

  • 端口:整数端口号。

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

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

这个套接字现在将在通过41234端口接收数据报时发出message事件。事件回调将数据报本身作为第一个参数接收,并将数据包信息映射作为第二个参数接收:

  • 地址:源 IP

  • 家族:IPv4 或 IPv6 之一

  • 端口:源端口

  • 大小:消息的大小(以字节为单位)

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

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

发送消息甚至更容易:

let client = dgram.createSocket("udp4"); 
let message = Buffer.from('UDP says Hello!', 'utf8'); 
client.send(message, 0, message.length, 41234, "localhost", (err, bytes) => client.close()); 

send方法采用client.send(buffer, offset, length, port, host, callback)形式:

  • buffer:包含要发送的数据报的缓冲区。

  • offset:一个整数,指示数据报在缓冲区中的位置。

  • length:数据报中的字节数。与offset结合使用,此值标识缓冲区中的完整数据报。

  • port:标识目标端口的整数。

  • address:一个指示数据报目标 IP 的字符串。

  • callback:一个可选的回调函数,在发送完成后调用。

数据报的大小不能超过 65507 字节,这相当于2¹⁶-1(65535)字节,减去 UDP 头部使用的 8 字节,减去 IP 头部使用的 20 字节。

现在我们有另一个进程间通信的候选者。为我们的节点应用程序设置一个监视服务器将会相当容易,它监听一个 UDP 套接字,接收来自其他进程发送的程序更新和统计信息。协议速度足够快,适用于实时系统,任何数据包丢失或其他 UDP 故障都将在总体数据量中占比不足。

进一步扩展广播的想法,我们还可以使用dgram模块创建一个多播服务器。多播只是一对多的服务器广播。我们可以广播到一系列已永久保留为多播地址的 IP 地址:

正如可以在www.iana.org/assignments/multicast-addresses/multicast-addresses.xhtml找到的那样,“IP 多播的主机扩展[RFC1112]指定了 Internet 协议(IP)的主机实现支持多播所需的扩展。多播地址在 224.0.0.0 到 239.255.255.255 的范围内。”

此外,224.0.0.0224.0.0.255之间的范围进一步保留用于特殊路由协议。此外,某些端口号被分配用于 UDP(和 TCP),可以在en.wikipedia.org/wiki/List_of_TCP_and_UDP_port_numbers找到列表。

所有这些令人着迷的信息的要点是,有一块 IP 和端口保留给 UDP 和/或多播使用,我们将使用其中一些来实现 Node 上的 UDP 多播。

使用 Node 进行 UDP 多播

设置多播 UDP 服务器和标准服务器之间唯一的区别是绑定到一个特殊的 UDP 端口进行发送,并指示我们希望监听所有可用的网络适配器。我们的多播服务器初始化看起来像以下代码片段:

let socket = dgram.createSocket('udp4'); 
let multicastAddress = '230.1.2.3'; 
let multicastPort = 5554; 
socket.bind(multicastPort); 
socket.on("listening", function() { 
    this.setMulticastTTL(64); 
    this.addMembership(multicastAddress); 
}); 

一旦我们决定了多播端口和地址并绑定了,我们就会捕获listening事件并配置我们的服务器。最重要的命令是socket.addMembership,它告诉内核加入multicastAddress的多播组。其他 UDP 套接字现在可以订阅这个地址的多播组。

数据报像任何网络数据包一样在网络中跳跃。setMulticastTTL方法用于设置数据报在被放弃之前允许跳转的最大次数(生存时间)。可接受的范围是 0-255,在大多数系统上默认为 1。这通常不是一个需要担心的设置,但在精确的限制有意义的情况下是可用的,比如数据包廉价而跳数昂贵的情况。

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

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

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

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

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

let cnt = 1; 
let sender; 
(sender = function() { 
  let msg = Buffer.from(`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', (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', (message, remote) => { 
  let msg = Buffer.from('Calling original group!', 'utf8'); 
  // 230.1.2.3 is the multicast address 
  socket.send(msg, 0, msg.length, multicastPort, '230.1.2.3'); 
}).bind(multicastPort, '230.3.2.1'); 

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

在您的应用程序中使用亚马逊网络服务

当几千个用户变成几百万个用户,当数据库扩展到几 TB 的数据时,维护应用程序的成本和复杂性开始压倒那些经验、资金和/或时间不足的团队。面对快速增长时,有时将应用程序的一个或多个方面的责任委托给基于云的服务提供商是很有用的。AWS(亚马逊网络服务)就是这样一套云计算服务,由amazon.com提供。

您需要一个 AWS 账户才能使用这些示例。我们将要探索的所有服务对于低容量开发用途都是免费或几乎免费的。要在 AWS 上创建一个账户,请访问以下链接:aws.amazon.com/。创建账户后,您将能够通过 AWS 控制台管理所有服务:aws.amazon.com/console/

在本节中,我们将学习如何使用三种流行的 AWS 服务:

  • 为了存储文档和文件,我们将连接到亚马逊S3

(简单存储服务)

  • 亚马逊的键/值数据库,DynamoDB

  • 为了管理大量的电子邮件,我们将利用亚马逊的SES

(简单电子邮件服务)

为了访问这些服务,我们将使用 Node 的 AWS SDK,可以在以下位置找到

在以下链接:github.com/aws/aws-sdk-js

要安装模块,请运行以下命令:

npm install aws-sdk  

aws-sdk模块的完整文档可以在以下网址找到:docs.aws.amazon.com/AWSJavaScriptSDK/latest/index.html

身份验证

注册 AWS 的开发人员被分配了两个标识符:

  • 一个公共Access Key ID(一个 20 个字符的字母数字序列)。

  • 一个Secret Access Key(一个 40 个字符的序列)。保持您的 Secret Key 私密非常重要。

亚马逊还为开发人员提供了识别要通信的区域的能力,比如"us-east-1"。这使开发人员能够针对最近的服务器(区域端点)发出请求。

区域端点和两个身份验证密钥对于发出请求是必要的。

有关区域端点的详细信息,请访问:docs.aws.amazon.com/general/latest/gr/rande.html

由于我们将在接下来的每个示例中使用相同的凭据,让我们创建一个可重用的config.json文件:

{ 
  "accessKeyId" : "your-key",  
  "secretAccessKey" : "your-secret", 
  "region" : "us-east-1", 
  "apiVersions" : { 
     "s3" : "2006-03-01", 
     "ses" : "2010-12-01", 
     "dynamodb" : "2012-08-10" 
  } 
} 

我们还会配置我们将用于服务的特定 API 版本。如果亚马逊的服务 API 发生变化,这将确保我们的代码将继续工作。

现在可以用两行代码初始化 AWS 会话。假设这两行代码存在于接下来的任何示例代码之前:

const AWS = require('aws-sdk'); 
AWS.config.loadFromPath('./config.json');

错误

在尝试这些服务时,可能会偶尔出现错误代码。由于其复杂性和云计算的性质,这些服务有时会发出令人惊讶或意外的错误。例如,因为 S3 在某些区域和情况下只能保证最终一致性,尝试读取刚写入的密钥可能并不总是成功。我们将探索每个服务的完整错误代码列表,它们可以在以下位置找到:

由于在开始阶段很难预测错误可能出现的位置,因此在进行操作时使用domain模块或其他错误检查代码是很重要的。

此外,亚马逊安全和一致性模型的一个微妙但基本的方面是其 Web 服务器时间和发出请求的服务器理解的时间的严格同步。最大允许的差异为 15 分钟。虽然这似乎是一个很长的时间,但事实上时间漂移是非常常见的。在开发过程中要注意类似以下的 403:禁止错误:

  • SignatureDoesNotMatch:这个错误意味着签名已经过期

  • RequestTimeTooSkewed:请求时间与当前时间的差异太大

如果遇到此类错误,发出请求的服务器的内部时间可能已经偏移。如果是这样,那么该服务器的时间将需要同步。在 Unix 上,可以使用NTP(网络时间协议)来实现同步。一个解决方案是使用以下命令:

rdate 129.6.15.28
ntpdate 129.6.15.28  

有关 NTP 和时间同步的更多信息,请访问:www.pool.ntp.org/en/use.html

让我们开始使用 AWS 服务,从分布式文件服务 S3 开始。

使用 S3 存储文件

S3 可用于存储任何人们期望能够存储在文件系统上的文件。最常见的用途是存储媒体文件,如图像和视频。S3 也是一个非常优秀的文档存储系统,特别适合存储小型 JSON 对象或类似的数据对象。

此外,S3 对象可以通过 HTTP 访问,这使得检索非常自然,并且支持 REST 方法,如 PUT/DELETE/UPDATE。S3 的工作方式非常类似于人们对典型文件服务器的期望,它分布在遍布全球的服务器上,并提供了在实际上无限的存储容量。

S3 使用存储桶的概念作为硬盘的一种类比。每个 S3 帐户最多可以包含 100 个存储桶(这是一个硬性限制),每个存储桶中的文件数量没有限制。

处理存储桶

创建存储桶很容易:

const AWS = require('aws-sdk');

 // You should change this to something unique. AWS bucket names must
// be unique and are shared across ALL USER namespaces.

const bucketName = 'nodejs-book';

AWS.config.loadFromPath('../config.json');


const S3 = new AWS.S3();


S3.createBucket({

 Bucket: bucketName

}, (err, data) => {
 
if(err) {
  throw err;
 }
 
 
console.log(data);

});

我们将收到一个包含Location存储桶和RequestId的数据映射:

{
Location: '/masteringnodejs.examples'
}

很可能会对一个存储桶进行许多不同的操作。为了方便起见,aws-sdk允许在所有后续操作的参数列表中自动定义存储桶名称:

let S3 = new AWS.S3({ 
  params: { Bucket: 'nodejs-book' } 
}); 
S3.createBucket((err, data) => { // ... }); 

使用listBuckets获取现有存储桶的数组:

S3.listBuckets((err, data) => { 
  console.log(data.Buckets); 
}); 
//    [ { Name: 'nodejs-book', 
//        CreationDate: Mon Jul 15 2013 22:17:08 GMT-0500 (CDT) }, 
//        ... 
//    ] 

存储桶名称对所有 S3 用户都是全局的。S3 的单个用户不能使用另一个用户已经声明的存储桶名称。如果我有一个名为foo的存储桶,其他 S3 用户就永远不能使用那个存储桶名称。这是许多人忽视的一个陷阱。

处理对象

让我们在 S3 的nodejs-book存储桶中添加一个文档:

const AWS = require('aws-sdk');
AWS.config.loadFromPath('../config.json');


const S3 = new AWS.S3({

 params: {
  Bucket: 'nodejs-book'
 }

});


let body = JSON.stringify({ foo: "bar" });

let s3Obj = {

 Key: 'demos/putObject/first.json',

 Body: body,

 ServerSideEncryption: "AES256",

 ContentType: "application/json",

 ContentLength: body.length,

 ACL: "private"
};


S3.putObject(s3Obj, (err, data) => {

 if(err) {
  throw err;
 }
 
 
 console.log(data);

});

如果 PUT 成功,它的回调将收到类似以下内容的对象:

{ ETag: '"9bb58f26192e4ba00f01e2e7b136bbd8"',
ServerSideEncryption: 'AES256'}

鼓励您查阅 SDK 文档并尝试putObject接受的所有参数。在这里,我们专注于唯一的两个必填字段,以及一些有用和常见的字段:

  • Key:用于在此存储桶中唯一标识您的文件的名称。

  • Body:包含文件主体的缓冲区、字符串或流。

  • ServerSideEncryption:是否在 S3 内加密文件。目前唯一的选项是 AES256(这是一个很好的选项!)。

  • ContentType:标准 MIME 类型。

  • ContentLength:指示数据报的目标 IP 的字符串。

  • ACL:预定义的访问权限,例如privatepublic-read-write。请参阅 S3 文档。

Key对象类似于文件系统路径是一个好主意,有助于以后进行排序和检索。事实上,亚马逊的 S3 控制台在其 UI 中反映了这种模式:

让我们将图像流式传输到 S3:

fs.stat("./testimage.jpg", (err, stat) => {


 let s3Obj = {

  Key    : 'demos/putObject/testimage.jpg',

  Body   : fs.createReadStream("./testimage.jpg"),

  ContentLength : stat.size,

  ContentType  : "image/jpeg",

  ACL    : "public-read"
 
};

 
 S3.putObject(s3Obj, (err, data) => {
  if(err) {
   throw err;
  }
  console.log(data);
 
});

});

由于我们给了这张图片public-read权限,因此可以在以下位置访问:s3.amazonaws.com/nodejs-book/demos/putObject/testimage.jpg

从 S3 获取对象并将其流式传输到本地文件系统甚至更容易:

let outFile = fs.createWriteStream('./fetchedfile.jpg'); 
S3.getObject({ 
  Key : 'demos/putObject/testimage.jpg' 
}).createReadStream().pipe(outFile); 

或者,我们可以在 HTTP 分块传输上捕获数据事件:

S3.getObject({ 
  Key : 'demos/putObject/testfile.jpg' 
}) 
.on('httpData', chunk => outFile.write(chunk)) 
.on('httpDone', () => outFile.end()) 
.send(); 

删除对象时,请执行以下操作:

S3.deleteObject({ 
  Bucket : 'nodejs-book', 
  Key : 'demos/putObject/optimism.jpg' 
}, (err, data) => { // ... }); 

要删除多个对象,请传递一个数组(最多 1,000 个对象):

S3.deleteObjects({ 
  Bucket : 'nodejs-book', 
  Delete : { 
    Objects : [{ 
      Key : 'demos/putObject/first.json' 
    }, { 
      Key : 'demos/putObject/testimage2.jpg' 
    }] 
  } 
}, (err, data) => { // ... });

使用 Node 服务器的 AWS

整合我们对 Node 服务器、通过管道流式传输文件数据和 HTTP 的了解,就可以清楚地知道如何在几行代码中将 S3 挂载为文件系统:

http.createServer(function(request, response) {  
  let requestedFile = request.url.substring(1); 
  S3.headObject({ 
    Key : requestedFile 
  }, (err, data) => { 
    // 404, etc. 
    if(err) { 
      response.writeHead(err.statusCode); 
      return response.end(err.name); 
    } 
    response.writeHead(200, { 
      "Last-Modified" : data.LastModified, 
      "Content-Length" : data.ContentLength, 
      "Content-Type" : data.ContentType, 
      "ETag" : data.ETag 
    }); 
    S3.getObject({ 
      Key : requestedFile 
    }).createReadStream().pipe(response); 
  }); 
}).listen(8080); 

标准的 Node HTTP 服务器接收请求 URL。我们首先尝试使用aws-sdk方法headObject进行 HEAD 操作,完成两件事:

  • 我们将确定文件是否可用

  • 我们将拥有构建响应所需的标头信息

在处理任何非 200 状态代码错误后,我们只需要设置响应标头并将文件流式传输回请求者,就像之前演示的那样。

这样的系统也可以作为故障安全,在两个方向上操作;如果 S3 或文件不可用,我们可以绑定到另一个文件系统,从那里流式传输。相反,如果我们首选的本地文件系统失败,我们可以切换到备用的 S3 文件系统。

有关使用 302 重定向类似地挂载 AWS 文件系统的示例,请参考 Packt 网站提供的代码包中的amazon/s3-redirect.js文件。

S3 是一个功能强大的数据存储系统,具有比我们所涵盖的更高级的功能,例如对象版本控制、下载支付管理和将对象设置为种子文件。有了对流的支持,aws-sdk模块使 Node 开发人员可以轻松地像使用本地文件系统一样使用 S3。

使用 DynamoDB 获取和设置数据

DynamoDBDDB)是一个 NoSQL 数据库,提供非常高的吞吐量和可预测性,可以轻松扩展。DDB 专为数据密集型应用程序设计,执行大规模的 map/reduce 和其他分析查询,延迟低且可靠。也就是说,它也是一种出色的通用 Web 应用程序数据库解决方案。

宣布 DynamoDB 的白皮书产生了很大的影响,引发了对 NoSQL 数据库的真正兴趣,并激发了许多人,包括Apache Cassandra。该白皮书涉及高级概念,但值得仔细研究;可在以下位置找到:www.allthingsdistributed.com/files/amazon-dynamo-sosp2007.pdf

Dynamo 数据库是表的集合,表是项目的集合,项目是属性的集合。表中的每个项目(或者您更喜欢的行)必须具有作为表索引的主键。除了主键外,每个项目可以具有任意数量的属性(最多 65 KB)。

这是一个具有五个属性的项目,其中一个属性作为主键(Id):

{
     Id = 123                                        
     Date = "1375314738466" 
     UserId = "DD9DDD8892" 
     Cart = [ "song1", "song2" ] 
     Action = "buy" 
} 

让我们创建一个具有主键和辅助键的表:

const AWS = require('aws-sdk'); 
AWS.config.loadFromPath('../config.json'); 
let db = new AWS.DynamoDB();  
db.createTable({ 
  TableName: 'purchases', 
  AttributeDefinitions : [{ 
    AttributeName : "Id", AttributeType : "N" 
  }, { 
    AttributeName : "Date", AttributeType : "N" 
  }], 
  KeySchema: [{  
    AttributeName: 'Id',  KeyType: 'HASH'  
  }, {  
    AttributeName: 'Date',  KeyType: 'RANGE'  
  }], 
  ProvisionedThroughput: { 
    ReadCapacityUnits: 2, 
    WriteCapacityUnits: 2 
  } 
}, (err, data) => console.log(util.inspect(data))); 

回调将接收类似于此的对象:

{     
  TableDescription: {  
    AttributeDefinitions: [ // identical to what was sent], 
    CreationDateTime: 1375315748.029, 
    ItemCount: 0, 
    KeySchema: [ // identical to what was sent ], 
    ProvisionedThroughput: {  
      NumberOfDecreasesToday: 0, 
      ReadCapacityUnits: 2, 
      WriteCapacityUnits: 2  
    }, 
    TableName: 'purchases', 
    TableSizeBytes: 0, 
    TableStatus: 'CREATING'  
  }  
} 

表的创建/删除不是立即的;实质上,您正在排队创建表(请注意TableStatus)。在(近)将来的某个时候,表将存在。由于 DDB 表定义不能在不删除表并重建它的情况下更改,因此在实践中,这种延迟不应影响您的应用程序——只需构建一次,然后使用项目。

DDB 表必须具有模式,指示将作为键的项目属性,由KeySchema定义。KeySchema中的每个属性可以是RANGEHASH。必须有一个这样的索引;最多可以有两个。每个添加的项目必须包含任何定义的键,以及所需的任意数量的其他属性。

KeySchema中的每个项目必须与AttributeDefinitions中的项目数量匹配。在AttributeDefinitions中,每个属性可以是数字("N")或字符串("S")。在添加或修改属性时,始终需要通过类型和名称来标识属性。

要添加项目,请使用以下内容:

db.putItem({ 
  TableName : "purchases", 
  Item : { 
    Id : {"N": "123"},                                        
    Date : {"N": "1375314738466"}, 
    UserId : {"S" : "DD9DDD8892"}, 
    Cart : {"SS" : [ "song1", "song2" ]}, 
    Action : {"S" : "buy"} 
  } 
}, () => { // ... }); 

除了我们的主键和(可选的)辅助键之外,我们还想为我们的项目添加其他属性。每个属性必须属于以下类型之一:

  • S:字符串

  • N:数字

  • B:Base64 编码的字符串

  • SS:字符串数组(字符串集)

  • NS:数字数组(数字集)

  • BS:Base64 编码字符串数组(Base64 集)

所有项目都需要具有相同数量的列;再次强调,动态模式是 DDB 的特性。

假设我们创建了一个如下表所示的表:

Id Date Action Cart UserId
123 1375314738466 购买 DD9DDD8892
124 1375314738467 购买 DD9EDD8892
125 1375314738468 购买 DD9EDD8890

现在,让我们执行一些搜索操作。

搜索数据库

有两种类型的搜索操作可用:查询扫描。对于具有单个主键的表进行扫描将无一例外地搜索表中的每个项目,并返回与您的搜索条件匹配的项目。这在除小型数据库外的任何情况下都可能非常慢。查询是直接的键查找。我们将首先查看查询。请注意,在此示例中,我们将假设此表只有一个主键。

要获取项目124ActionCart属性,我们使用以下代码:

db.getItem({ 
  TableName : "purchases", 
  Key : { 
    Id : { "N" : "124" } 
  }, 
  AttributesToGet : ["Action", "Cart"] 
}, (err, res) => console.log(util.inspect(res, { depth: 10 }))); 

这将返回以下内容:

{   
  Item: {  
    Action: { S: 'buy' },  
      Cart: { SS: [ 'song2', 'song4' ] }  
  }  
} 

要选择所有属性,只需省略AttributesToGet的定义。

扫描更昂贵,但允许更复杂的搜索。在进行扫描时,辅助键的实用性特别明显,可以避免扫描整个表的开销。在我们的第一个扫描示例中,我们将假设只有一个主键。然后,我们将展示如何使用辅助键过滤扫描。

要获取所有Cart属性包含song2的记录,请使用以下代码:

db.scan({ 
  TableName : "purchases", 
  ScanFilter : { 
    "Cart": { 
      "AttributeValueList" : [{ 
        "S":"song2" 
      }], 
      "ComparisonOperator" : "CONTAINS" 
    }, 
  } 
}, (err, res) => { 
  console.log(util.inspect(res, { 
    depth: 10 
  })); 
}); 

这将返回Id为 123 和 124 的项目的所有属性值。

现在让我们使用我们的辅助键进一步过滤:

db.scan({ 
  TableName : "purchases", 
  ScanFilter : { 
    "Date": { 
      "AttributeValueList" : [{ 
        "N" : "1375314738467" 
      }], 
        "ComparisonOperator" : "EQ" 
      }, 
      "Cart": { 
         "AttributeValueList" : [{ 
           "S" : "song2" 
         }], 
         "ComparisonOperator" : "CONTAINS" 
      }, 
   } 
}, (err, res) => { 
  console.log(util.inspect(res, {depth: 10})); 
}); 

此新过滤器将结果限制为项目 124。

通过 SES 发送邮件

亚马逊以这种方式描述 SES 旨在解决的问题:

“为发送营销和交易消息构建大规模的电子邮件解决方案通常是企业面临的一个复杂且昂贵的挑战。为了优化成功交付的电子邮件百分比,企业必须处理诸如电子邮件服务器管理、网络配置以及满足严格的互联网服务提供商(ISP)标准等麻烦。”

除了任何系统增长中固有的典型网络扩展问题之外,由于垃圾邮件的普遍存在,提供电子邮件服务变得特别困难。很难发送大量的未经请求的电子邮件而不被列入黑名单,即使收件人愿意接收它们。垃圾邮件控制系统是自动化的;您的服务必须被列入白名单,这是各种电子邮件提供商和垃圾邮件跟踪器使用的,以避免您的电子邮件低于客户收件箱以外的地方的百分比。邮件服务必须在正确的人群中有良好的声誉,否则它几乎没有用处。

亚马逊的 SES 服务具有必要的声誉,为应用程序开发人员提供可靠的基于云的电子邮件服务,能够处理几乎无限量的电子邮件。在本节中,我们将学习 SES 如何被 Node 应用程序用作可靠的邮件投递服务。

确保您通过访问您的开发者控制台获得 SES 访问权限。当您首次注册 SES 时,您将获得沙箱访问权限。在此模式下,您只能使用亚马逊的邮箱模拟器,或者发送邮件到您已验证的地址(例如您自己的地址)。您可以申请生产访问权限,但出于我们的目的,您只需要验证一个电子邮件地址进行测试。

使用诸如 SES 之类的服务的成本会随着邮件量的增加而增加,您可能需要定期检查您的配额:

let ses = new AWS.SES(); 
ses.getSendQuota((err, data) => { 
  console.log(err, data); 
});  

要发送消息,请执行以下操作:

ses.sendEmail({ 
  Source : "spasquali@gmail.com", 
  Destination : { 
    ToAddresses : [ "spasquali@gmail.com" ] 
  }, 
  Message : { 
    Subject: { Data : "NodeJS and AWS SES" }, 
    Body : { 
      Text : { Data : "It worked!" } 
    } 
  } 
}, (err, resp) => console.log(resp));

回调将接收类似以下输出:

RequestId: '623144c0-fa5b-11e2-8e49-f73ce5ee2612'

MessageId: '0000014037f1a167-587a626e-ca1f-4440-a4b0-81756301bc28-000000'

多个收件人、HTML 正文内容以及邮件服务中所期望的所有其他功能都是可用的。

使用 Twilio 在 Heroku 上创建一个短信机器人

我们将构建一个作为客户服务应用程序的应用程序,客户服务代理可以处理来自客户的短信请求并回复它们。系统将有两个部分。

  • 第一部分:一个客户端应用程序,在您的本地计算机上运行,它会启动一个由 React 驱动的 Web 界面,显示传入的短信消息,指示消息的情绪(客户生气了吗?开心吗?),并允许您回复消息。请注意,即使这个服务器在本地计算机上运行,它也可以部署到 Heroku 或其他地方--目标是演示不同位置的多台服务器如何智能地相互通信。

  • 第二部分:一个交换机,通过 Twilio 短信网关接收到的消息,处理它们,并将消息分发到任意数量的客户端服务器--如果您有 10 名客户服务代表使用客户端应用程序连接到交换机,交换机接收到的消息将均匀分布到这些客户端上。这第二个应用程序将部署在 Heroku 上。

您首先需要在Heroku上注册一个帐户,这是一个类似于 Digital Ocean 的云服务器提供商:www.heroku.com

Heroku 提供免费帐户,因此您将能够构建以下应用程序而无需任何费用。

一旦您有了帐户,请登录并下载适用于您的系统的 Heroku CLI:devcenter.heroku.com/articles/getting-started-with-nodejs#set-up。按照该页面上的步骤登录 Heroku 的命令行工具。

确保您已安装 Git(git-scm.com/book/en/v2/Getting-Started-Installing-Git)。

在本地文件系统上创建一个目录,并将以下两个存储库克隆到该文件夹中:

thankyou存储库是客户端应用程序。现在,您将部署switchboard存储库到 Heroku。

使用您的终端导航到switchboard存储库并将副本部署到 Heroku:

> heroku create

您应该在终端中看到类似以下内容的显示:

Heroku 在您的服务器上建立了一个 Git 端点。现在,运行以下命令:

> git remote

您将看到返回的两个元素列表:herokuorigin。这些是您的本地switchboard存储库正在跟踪的两个远程分支,一个在 Heroku 上,一个是您最初克隆的。

下一步是将本地存储库推送到 Heroku 存储库:

> git push heroku master

您应该看到很多安装说明。当一切顺利完成后,导航到您的应用程序 URL。您应该看到有一个应用程序错误。Heroku 为您的应用程序提供完整的日志。现在让我们访问它们,以发现出了什么问题:

> heroku logs

要在 Heroku 服务器上保持日志活动的运行尾部,请使用:

> heroku logs --tail

您应该看到关于环境变量缺失的几个错误,特别是对于 Twilio。应用程序期望这些在应用程序环境中设置,并且它们还没有被设置。让我们现在来做。

使用 Twilio Webhooks 接收和发送短信

switchboard 应用程序最终提供了一个单一的服务——建立一个 REST 启用的端点,Twilio 可以通过该端点调用接收到的短信。它在 LevelDB(一个非常快速的键/值存储库)中按电话号码存储这些消息的日志,并向连接到 switchboard 的客户端广播新消息。

整个应用程序的逻辑流程将如下所示:

我们可以看到我们的应用程序的逻辑是从 Twilio 收到的短信开始,并支持客户端的响应。这是构建NoUI或纯短信应用程序的基本模式。这种模式越来越受欢迎,通常以聊天机器人、AI 助手等形式出现。我们将很快深入了解应用程序。

现在,我们需要启用 Twilio 桥接。

首先,您需要在 Twilio 上创建一个测试帐户以获取一些 API 变量。转到www.twilio.com并注册一个测试帐户。确保设置一个测试电话号码;我们将向该号码发送短信。

完成后,从帐户仪表板获取 Twilio API 密钥、电话号码和电话号码 SID。现在,您需要将该信息添加到 Heroku 的环境变量中,以及其他一些密钥。转到您的 Heroku 仪表板,找到您的实例,点击它,然后导航到设置 | 显示配置变量:

这是您在运行的 Node 进程中向process.env添加键/值对的地方。添加以下键/值对:

  • TWILIO_AUTH_TOKEN / <您的 auth 令牌>

  • TWILIO_SID / <您的 sid>

  • TWILIO_PHONE_NUMBER_SID / <您的电话号码 sid>

  • TWILIO_DEFAULT_FROM / <您分配的电话号码>

  • SOCK_PORT / 8080

  • URL / <您的服务器 URL(没有尾随斜杠)

保存这些新的环境变量后,您的应用程序将在 Heroku 上自动重新启动。再次尝试您的应用程序 URL。如果一切正常,您应该看到一个消息,如 Switchboard is active。

您可以使用命令行快速将应用程序加载到浏览器中,方法是> heroku open

虽然我们不会使用 shell,但您可以通过终端登录到 Heroku 服务器,方法是> heroku run bash

要与 Twilio 进行通信,我们将使用官方的 Node 库:github.com/twilio/twilio-node。重要的代码可以在 switchboard 存储库的router/sms/文件夹中找到。该模块将允许我们注册一个 webhook 来接收消息,并对这些消息进行响应。

现在,让我们构建 switchboard。

switchboard

switchboard 将有一个单一的责任——与 Twilio 进行通信。由于我们使用 webhooks,我们需要创建一个服务器,可以接收来自 Twilio 的 POST 数据。对于 web 服务器,我们将使用restify包(www.restify.com)。这是一个非常快速的 Node 服务器实现,专门设计用于快速、高负载的基于 REST 的 API。由于 switchboard 专注于处理来自 Twilio 的传入消息,并且其出站流量是通过 WebSockets,因此不需要像 Express 这样的更高级服务器,后者旨在通过模板、会话等来促进视图的呈现。

让我们看一下实例化一个接受包含短信消息数据的 Twilio POST 消息的 restify 服务器的代码,以及将客户端绑定到 switchboard 的套接字服务器:

// router/index.js
const restify = require('restify');
const SServer = require("ws").Server;
const server = restify.createServer({
  // additional configuration can be done here
});
server.use(restify.plugins.acceptParser(server.acceptable));
server.use(restify.plugins.bodyParser());

function responder(req, res) {
   res.send(200, 'Switchboard is active');
}
server.get('/', responder);

// process.env.PORT is set by heroku (add it yourself if hosting elsewhere)
server.listen(process.env.PORT, () => {});

// Get the LevelDB interface, and its readable stream
require('./Db')((db, dbApi) => {

  // Configure the sms webhook routing
  require('./sms')(server, dbApi);

  // Configure a leveldb datastream listener which has the job
  // of informing clients of data changes.
  require('./dataStream.js')(db, Clients);

  // Configure the socket listener for client connections
  let wss = new SServer(server);

  wss.on("connection", clientConn => {
    clientConn.on("close", () => {
      // remove clients, close the connection
    });

    clientConn.on("message", payload => {
      // ...more on this later
    });

    // Say something nice to clients when they connect
    clientConn.send(JSON.stringify({
      type: 'alert',
      text: 'How can I help you?'
    }));
  });
});

通过非常少的代码,我们已经建立了一个 Web 服务器,并使用了一个套接字服务器(使用了优秀的ws模块,您可以在github.com/websockets/ws获取)。鼓励您查看 switchboard 存储库中的代码,那里应该很容易理解客户端连接管理的细节。特别是要调查router/Db/index.js文件,其中建立了一个levelDB连接,并定义了用于存储消息历史记录的 API(api.addToNumberHistory)。对于我们的目的,请注意server.get方法,它为简单的ping服务建立了一个 GET 请求的处理程序,以便我们需要检查 switchboard 是否可用。接下来,我们将添加重要的 webhook 路由。

Twilio webhook 代码如下:

// router/sms/index.js
require('./sms')(server, dbApi);

该文件中的代码如下:

const env = process.env;
const twilioAPI = require('twilio')(env.TWILIO_SID, env.TWILIO_AUTH_TOKEN);
module.exports = (server, dbApi) => {
  let smsUrl = env.URL + '/smswebhook';
  twilioAPI.incomingPhoneNumbers(env.TWILIO_PHONE_NUMBER_SID).update({
    smsUrl: smsUrl
  });    

  server.post('/smswebhook', (req, res) => {
    let dat = req.body;
    let meta = {
      message : dat.Body,
      received : Date.now(),
      fromCountry : dat.FromCountry,
      phoneNumber : dat.From
    };

    dbApi
    .addToNumberHistory(dat.From, meta)
    .then(newVal => console.log('Received message from', dat.From))
    .catch(err => console.log("levelERRR:", err));
    res.end();
  });
};

考虑到我们正在连接和使用全球短信网关,代码非常简单。在使用我们之前在 Heroku 上设置的环境变量实例化 Twilio API 之后,我们可以方便地使用此 API 来以编程方式建立 webhook,避免手动登录 Twilio 仪表板的过程:

   let smsUrl = `${env.URL}/smswebhook`;
   twilioAPI.incomingPhoneNumbers(env.TWILIO_PHONE_NUMBER_SID).update({
     smsUrl: smsUrl
   });    

更重要的是,这种技术使我们能够动态重新配置 Twilio 端点;能够随时更改处理程序的名称或其他内容总是很好的。

Twilio POST 的主体和我们将要接收的内容如下:

{
  ToCountry: 'US',
  ToState: 'NY',
  SmsMessageSid: 'xxxxxx',
  NumMedia: '0',
  ToCity: 'SOUTH RICHMOND HILL',
  FromZip: '11575',
  SmsSid: 'xxxxxx',
  FromState: 'NY',
  SmsStatus: 'received',
  FromCity: 'SOUTH RICHMOND HILL',
  Body: 'Hi! This is a test message!',
  FromCountry: 'US',
  To: '+5555554444',
  ToZip: '11244',
  NumSegments: '1',
  MessageSid: 'xxxxxx',
  AccountSid: 'xxxxxx',
  From: '+555555555',
  ApiVersion: '2010-04-01' 
}

在 hook 处理程序中,我们获取关键信息——发送者的号码和消息——并通过api.addToNumberHistory方法将它们存储在levelDB中(返回一个 Promise)。现在我们准备通知客户端消息。我们该如何做?

客户端通过 websocket 连接。在写入数据库后,我们可以在同一个函数体中简单地将消息发送给客户端。然而,现在我们的代码开始变得复杂,承担了两个责任(接收和发送),而不仅仅是一个(接收)。这可能看起来像一个小问题,但这是功能蔓延出现的地方——也许接下来我们在这个函数中添加日志记录等。此外,如果我们负责通知客户端有新消息,我们还需要确认数据库写入是否成功;这通常并不明确,假阳性并不罕见。

让我们创建一个通知系统,用于广播变更集。每当向数据库写入新消息时——确认写入——宣布更新事件,并注册事件处理程序。在我们的初始服务器代码中,使用以下行绑定了此功能:

require('./dataStream.js')(db, Clients);

捕获更改集并广播它们的代码使用了Domenic Tarrlevel-live-stream包:

module.exports = (db, Clients) => {
  const dbStream = require('level-live-stream')(db);

  // When a new write has been successfully written...
  dbStream.on('data', data => {
    let number = data.key;
    let val = data.value;

    // Find any clients that are listening on this number
    let boundClient = Clients.withNumber(number);

    // Send the current history to this client
    if(boundClient) {
      try {
        return boundClient.send(JSON.stringify({
          type: 'update',
          list: val
        }));
      } catch(e) {
        Clients.delete(boundClient);
      }
    }

    // Try to find an available client to handle this number
    let waitingClient = Clients.nextAvailable();

    if(waitingClient) {
      // This client is no longer `available`. Assign client a number.
      // Then send number history.
      Clients.set(waitingClient, number);
      waitingClient.send(JSON.stringify({
        type: 'update',
        list: val
      }));
    }
    // TODO: handle situations without available clients
  });
};

使用level-live-stream,我们能够将逻辑集中在正确的事件上——确认写入数据库——使得这个微服务仅负责查找可用的客户并向他们发送更新的消息历史。请注意,这个示例中存储和引用客户的方式并不是最终确定的。您可能希望继续遵循做好一件事的理念,并创建另一个负责经纪连接的小型服务。例如,我们可以从这个示例中删除所有客户端代码,并创建另一个服务,暴露一个getNextAvailableClient方法。这种编排微服务的组合策略将在下一章中进一步讨论。

我们现在可以接收、存储和广播传入的短信消息。只剩下一个功能——将客户端响应发送回 Twilio,继续短信对话。这些响应的组成由我们接下来将讨论的thankyou应用程序执行。然而,这些响应最终是由交换机指导的(回想一下前面的序列图),以下是您可以使用的非常简短的代码,通过 Twilio 网关发送短信消息:

// router/sms/sendResponse.js
const Twilio = require('twilio');
const twilioAPI = new Twilio(process.env.TWILIO_SID, process.env.TWILIO_AUTH_TOKEN);
module.exports = (number, message) => twilioAPI.messages.create({
  to: number,
  from: process.env.TWILIO_DEFAULT_FROM,
  body: message
});

您应该回想一下在我们的基本服务器代码中注册的 websocket 的on message监听器,它使用前面的功能来响应调用者。我们现在可以扩展该监听器:

// router/index.js
...
clientConn.on("message", payload => {
  try {
    payload = JSON.parse(payload);
 } catch(e) {
    return;
 }

 switch(payload.type) {
   case 'available':
     Client.set(clientConn, 'available');
   break;

   case 'response':
     let number = Clients.get(clientConn);
     // Add to message history when bound client
     // sends valid message to a known number.
     if(/^\+?[0-9]*$/.test(number)) {
       dbApi.addToNumberHistory(number, {
         message : payload.message,
         received : Date.now(),
         phoneNumber : number
       })
       .then(() => require('./sms/sendResponse.js')(number, payload.message))
       .catch(err => console.log("response send error:", err));
     }
     break;
  }
});

我们再次看到了addToNumberHistory,因为响应当然是对话历史的一部分。一旦传出消息被添加到数据库记录中,通过 Twilio 发送它。您注意到了什么吗?这只是我们要做的事情之一。另一个是将更新的消息历史发送回客户端,以便这个响应可以出现在他们的历史视图中。通常,这是使用一些客户端 JavaScript 来完成的,当客户端输入响应并点击发送时,它会乐观地更新客户端状态,希望消息能够到达交换机。但如果没有呢?

我们看到了这里改变集方法如何帮助。如果客户端消息未能到达交换机或以其他方式失败,levelDB将永远不会更新,并且客户端的历史状态将不会与数据层表示的规范历史不同步。虽然在这种微不足道的应用程序中可能并不重要,但如果您正在构建事务性软件,这将很重要。

现在,让我们来看看应用程序的另一半——thankyou客户端。

感谢界面

总之,我们希望创建一个系统,其中交换机接收短信消息并将其传递给在本地笔记本电脑或类似设备上运行的服务代表的对话界面。这个客户端在thankyou存储库中定义,看起来像这样:

在这里,我们看到了交换机管理的消息历史,以及发送响应的界面。重要的是,消息中有表示情绪(眨眼的快乐,悲伤的愤怒)的图标,以及以人类可读的格式(几秒钟前)的时间戳。thankyou的目标将是捕获传入(和传出)的消息,对消息流进行情感分析,并显示结果。我们将使用 React 来构建 UI。

React 需要一个构建系统,我们正在使用BrowserifyGulpBrowserSync。这些技术的工作原理超出了本章的范围。查看gulpfile.jsthankyou存储库的/source目录的内容。有许多关于这些流行技术的在线教程。

由于我们正在为一个真实的 UI 提供服务,因此在这个项目中,我们将使用 Express 来构建我们的 Node 服务器。不过,服务器非常简单。它只负责提供单个视图,该视图包含在一个名为index.html的文件中。

// /source/views/index.html

<!DOCTYPE html>
<html>
<head>
   <title>Untitled</title>
   <link rel="stylesheet" type="text/css" href="css/app.css">
</head&gt;
<body>

<div id="main"></div>
<div id="page_controls">
   <span id="message-composer"></span>
</div>

<script src="img/jquery-3.2.1.min.js" integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4=" crossorigin="anonymous"></script>
<script src="img/socketConnector.js"></script>
<script src="img/components.js"></script>
<script src="img/app.js"></script>
</body>
</html>

React 组件由构建系统捆绑到components.js中;JavaScript 文件类似地捆绑到app.js中,样式表捆绑到app.css中。jQuery DOM 操作库用于简单的元素效果和管理消息撰写器。正如前面提到的,我们不会深入研究客户端 JavaScript。但是,简要查看用于构建时间线的 React 组件将会很有用,因为这个组件最终将接收来自交换机的新消息。

这是支持thankyou的主要 UI 组件:

// source/jsx/MessageComposer.jsx
export class Timeline extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      phone : '',
      messages: []
    };
  }
  componentDidMount() {
    ws.onmessage = mOb => {
      let data = JSON.parse(mOb.data);
      if(data.messages && data.phone) {
        return this.setState(data);
      }
    }
  }

  render() {
    return <div>
      <MessageHistory history={this.state} />
      </div>
  }
}

export class MessageHistory extends React.Component {
  constructor(props) {
    super(props);
  }
  render() {
    let history = this.props.history;
     return <div id="timeline_container">
       <div className="history_header">
         <figure>{history.phone}</figure>
       </div>
         <ul> 
           { history.messages.map(function(it) { 
              return <li className="message_event" key={it.received}>
                <div className={"event_icn icon-emo-" + it.sentiment}></div>
                <div className="event_content">
                  <p>{it.message}</p>
                </div>
                <div className="event_date">
                  {it.date}
                </div>
              </li>
           }) }
        </ul>
      </div>;
    }
}

render(
  <Timeline />, document.getElementById('main')
);

即使您不了解 React,您也应该能够看到MessageHistory组件扩展了Timeline组件。Timeline组件负责维护应用程序状态,或者在我们的情况下,当前的消息历史记录。

这是MessageHistory中的关键 UI 代码:

{ history.messages.map(function(it) {
   return <li className="message_event" key={it.received}>
      <div className={"event_icn icon-emo-" + it.sentiment}></div>
      <div className="event_content">
         <p>{it.message}</p>
      </div>
      <div className="event_date">
         {it.date}
      </div>
   </li>
}) }

您可能还记得交换机处理的消息历史记录:

dbApi.addToNumberHistory(number, {
  message : payload.message,
  received : Date.now(),
  phoneNumber : number
})

这是MessageHistory中渲染数据到 UI 视图的部分。我们不会深入研究 UI 代码,但您应该注意到交换机没有生成的一个属性:it.sentiment。在我们讨论thankyou如何与交换机通信时,请记住这一点。

由于交换机通过 WebSockets 接收和发送消息,Timeline有这样一个引用:

ws.onmessage = mOb => {
  let data = JSON.parse(mOb.data);
  if(data.messages && data.phone) {
    return this.setState(data);
  }
}

套接字代码包含在我们的index.html文件中:

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

也是这样的:

let ws = new WebSocket('ws://' + host + ':8080');
ws.sendMessage = function(command, msg) {
  this.send(JSON.stringify({
    command : command || '',
    message : msg || ''
  }));
};

这段代码将ws引用放在客户端的全局范围内(window.ws)。虽然通常不是最佳做法,但对于我们简单的 UI 来说,这使得 React 组件可以轻松获取相同的套接字引用。这个引用也在MessageComposer组件中使用,该组件接受来自客户端的响应:

// source/jsx/MessageComposer.jsx
export class MessageComposer extends React.Component {
  constructor(props) {
    super(props);
  }
  sendMessage(ev) {
    // Get message; clear input; exit if
    let input = document.getElementById('composer');
    let msg = input.value;

    input.value = '';

    if(msg.trim() === '') {
      return;
    }

    ws.sendMessage('response', msg);
  }
  render() {
    return <span>
      <textarea id="composer"></textarea><button onClick={this.sendMessage}>send</button>
    </span>
  }
}

render(
  <MessageComposer />, document.getElementById('message-composer')
);

该组件呈现一个文本区域,可以在其中撰写并通过套接字发送响应到交换机。

现在,让我们看看客户端服务器如何与客户端 UI 通信,通过与交换机的通信进行协调。

客户端服务器在router/index.js中定义:

const http = require('http');
const express = require('express');
const bodyParser = require('body-parser');

let defaultPort = 8080;
let app = express();

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({
  extended: true
}));
app.use(express.static('./public'));

let server = http.createServer(app);
server.listen(process.env.PORT || defaultPort);

console.log('HTTP server listening on', process.env.PORT || defaultPort);

// The client is connected to a local socket server, which sends client->LocalSS->customer messages.
// The local SS is connected to switchboard, receiving SMS->switchboard->LocalSS->client messages.
require('./bindSocketServer.js')(server);

这是一个标准的 Express 设置。请注意以下行:

require('./bindSocketServer.js')(server);

这是主要的经纪人逻辑。回想一下应用程序顺序图,这里是接收和传递交换机消息的地方,通过另一个套接字,最终传递给 UI 和 React 渲染器。同样,服务器监听来自 UI 的消息,并将其传递给交换机:

// router/bindSocketServer.js
const WebSocket = require('ws');
const SServer = WebSocket.Server;

let arrayToStream = require('./transformers/arrayToStream.js');
let timeTransformer = require('./transformers/time.js');
let sentimentTransformer = require('./transformers/sentiment.js');
let accumulator = require('./transformers/accumulator.js');

const sbUrl = process.env.SWITCHBOARD_URL;

module.exports = server => {
  // Bind the local socket server, which communicates
  // with web clients.
  (new SServer({
     server: server
  })).on('connection', localClientSS => {
    let keepalive;
    // A remote SMS gateway
    let switchboard = new WebSocket(sbUrl);

    // ... boilerplate ping/pong functionality

    // handle 
    switchboard.onmessage = event => {
      let data = event.data;
      try {
        data = JSON.parse(data);
      } catch(e) {
        return console.log(`Unable to process data: ${data}`);
      }
      // switchboard sent an update
      if(data.type === 'update') {
        // Transform messages into expected format for UI.
        arrayToStream(data.list.reverse())
        .pipe(timeTransformer({
          format: "%s ago"
        }))
        .pipe(sentimentTransformer('message'))
        .pipe(accumulator((err, messages) => {
          if(err) {
            return console.log(err);
          }
          localClientSS.sendMessage({
            messages: messages,
            phone: messages[0].phoneNumber
          })
         }));
        }
      };

      // Need to configure handlers so we can bidirectionally
      // communicate with client UI (snd/rcv messages)
      localClientSS.sendMessage = obj => {
        localClientSS.send(JSON.stringify(obj));
      };

      // Client UI is responding
      localClientSS.on('message', payload => {
        try {
          payload = JSON.parse(payload);
        } catch(e) {
          return;
        }
        switch(payload.command) {
          case 'response':
            switchboard.send(JSON.stringify({
              type: 'response',
              message : payload.message
            }));
            break;
            default:
              // do nothing
            break;
         }
      });

      // ... handle socket close, etc.
};

现在,考虑到交换机的设计,这应该是有意义的。从底部开始,我们看到当客户端套接字服务器localClientSS接收到消息时,它会验证并将消息传递给交换机,其中消息将被添加到此客户端处理的电话号码的消息历史记录中。更有趣的是接收来自交换机的消息的代码,它执行情感分析并将时间戳转换为可读的句子。

为了执行这些转换,从交换机(以 JSON 格式的数组)接收到的有效负载被转换为对象流,使用arrayToStream.js模块。流在第三章中有介绍,在节点和客户端之间传输数据;我们只是创建一个Readable流,将数组中的每个元素作为一个独立的对象传输。当我们应用转换时,真正的乐趣开始了。让我们看一下情感分析的代码(处理历史对象的'message'属性),使用through2模块(github.com/rvagg/through2)简化转换流的创建和设计,当然还有情感模块(github.com/thisandagain/sentiment)来评估消息的情绪:

// router/transformers/sentiment.js
const through2 = require('through2');
const sentiment = require('sentiment');

module.exports = targProp => {
  if(typeof targProp !== 'string') {
     targProp = 'sentiment';
  }

  return through2.obj(function(chunk, enc, callback) {
    // Add #sentiment property
    let score = sentiment(chunk[targProp]).score;

    // Negative sentiments
    if(score < 0) {
      chunk.sentiment = score < -4 ? 'devil' : 'unhappy';
    }

    // Positive sentiments
    else if(score >= 0) {
      chunk.sentiment = score > 4 ? 'wink' : 'happy';
    }

    this.push(chunk);
    callback()
  });
};

功能很简单;对于从交换机发送的历史数组中的每个对象,确定消息的情感分数。负分数是不好的,范围从非常糟糕(魔鬼)到不开心。我们同样对正面情感进行评分,范围从非常好(眨眼)到开心。一个新的sentiment属性被添加到消息对象中,正如我们之前在考虑MessageHistory组件时看到的那样,这将设置消息在 UI 中接收的图标。

如果你想自己继续开发这个应用程序,你应该将存储库 fork 到你自己的 GitHub 账户上,并使用这些个人存储库重复这个过程。这将允许你推送更改,或者以其他方式修改应用程序以满足你自己的需求。

交换机感谢的协调应该给你一些关于如何使用服务、套接字、REST 端点和第三方 API 来分发功能的想法,帮助你通过添加(或删除)组件来扩展整个堆栈。通过使用转换流,你可以在不阻塞的情况下应用“即时”流数据转换,管理服务器上的数据模型,并将布局留给 UI 本身。

摘要

大数据应用程序已经给网络应用程序的开发者带来了重大责任,需要为规模做好准备。Node 在创建一个网络友好的应用程序开发环境方面提供了帮助,可以轻松连接到网络上的其他设备,比如云服务,特别是其他 Node 服务器。

在本章中,我们学习了一些扩展 Node 服务器的好策略,从分析 CPU 使用率到跨进程通信。有了我们对消息队列和 UDP 的新知识,我们可以构建水平扩展的 Node 服务器网络,通过简单复制现有节点来处理越来越多的流量。通过使用 Node 和 NGINX 进行负载均衡和代理,我们可以自信地为我们的应用程序增加容量。当与 Digital Ocean、AWS 和 Twilio 提供的云服务配合使用时,我们可以尝试进行企业规模的开发、数据存储和广播,而成本低廉,而且不会给我们的应用程序增加太多复杂性。

随着我们的应用程序不断增长,我们需要持续关注每个部分以及整体的行为。随着我们不断添加新的组件和功能,一些是本地的,一些是通过云端的,甚至可能是用另一种语言编写的,作为开发者,我们如何智能地跟踪和规划添加和其他变化?在下一章中,我们将学习微服务,一种通过许多小的、协作的水平分布的网络服务来开发一个应用程序的方式。

第九章:微服务

让每个人都打扫自己门前的雪,那么整个世界都会变得干净。

  • 歌德

随着软件变得更加复杂,任何一个人,甚至一个团队,都无法完全了解整个架构。互联网的兴起促进了“前端”(一台计算机上运行 JavaScript、CSS、HTML 的浏览器)和“后端”(另一台计算机运行数据库和 HTTP 服务器)的概念,在单个服务器上统一交付一个产品——网页。用户可能会点击一个按钮,服务器会发出一个调用,该服务器可能会检查数据库,并最终交付一个 HTML 页面。

速度已经加快。现代用户期望功能强大且高度互动的移动应用程序能够以低成本进行娱乐或推动业务,并进行定期更新。现在,一个人可以创建一个在几个月内获得数百万用户的应用程序。从一个人到在几个月甚至几年内支持数百万并发用户的公司规模扩展,需要高效的团队和工程管理。

如今的基于网络的应用程序由几个独立的子系统组成,它们必须合作来满足更大系统的业务或其他要求。例如,许多 Web 应用程序将呈现基于浏览器的界面,由一个或多个库和/或 UI 框架组成,将用户操作转换为在手机、微控制器和笔记本电脑上运行的 JavaScript 控制器发出的正式网络请求,最终与执行用不同语言编程的业务逻辑单元的任意数量的服务器通信,这些服务器可能共享一个或多个数据库,甚至跨多个数据中心,它们本身会发起和协调更长的一系列请求到云 API 或其他服务器等等。

如今,任何复杂的软件很少仅限于一台机器或单一代码库。在本章中,我们将探讨将独立的组件组合成分布式架构的最新流行技术,每个组件都是一个小的、明确定义的、可热重载的服务,或者称为微服务。微服务允许您重连、重写、重用和重新部署应用程序的模块化部分,使变更更容易。

为什么要使用微服务?

将较大的系统构建成较小的专用单元并不是一个新的想法。面向对象编程遵循相同的原则。Unix 就是这样构建的。支持可组合网络软件的架构(CORBA、WebObjects、NetBeans)是几十年前的想法。新的是网络软件带来的利润规模。几乎每个业务领域的客户都需要新的软件和新的功能,软件开发人员不断根据不断变化的市场条件交付和/或完善这些功能。微服务实际上是一个管理理念,其目标是减少将业务/客户需求变化反映到代码中所需的时间。目标是降低变更成本。

构建软件没有绝对的“正确方式”,每种语言设计都偏向于一个或几个关键原则,特别是指导系统如何扩展的原则,通常会影响部署方式。Node 社区的一些关键原则——由小程序组成的模块化系统,事件驱动,I/O 聚焦,网络聚焦——与支持微服务的原则密切相关:

  1. 一个系统应该被分解成许多小服务,每个服务只做一件事,而不是更多。这有助于清晰度。

  2. 支持服务的代码应该简短而简单。Node 社区的一个常见指导原则是将程序限制在大约 100 行代码附近。这有助于可维护性。

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

  4. 数据模型应该是分散的,一个常见(但不是必需的)微服务模式是每个服务维护自己的数据库或类似模型。服务是无状态的。这加强了(3)。

  5. 独立的服务易于复制(或删除)。在微服务架构中,扩展(双向)是一个自然的特性,因为可以根据需要添加或删除新的节点。这也使得轻松进行实验,可以测试原型服务,测试或临时部署新功能等。

  6. 独立的无状态服务可以独立替换或升级(或降级),而不受它们所属系统的状态的影响。这打开了更加专注、离散的部署和重构的可能性。

  7. 失败是不可避免的,因此系统应设计成能够优雅地失败。局部化故障点(1, 2),隔离故障(3, 4),并实施恢复机制(当错误边界明确定义、小且非关键时更容易),通过减少不可靠性的范围来促进健壮性。

  8. 测试对于任何非平凡的系统都是必不可少的。明确简单的无状态服务易于测试。测试的一个关键方面是模拟——存根模拟服务,以测试服务的互操作性。清晰界定的服务也易于模拟,因此可以智能地组合成可测试的系统。

这个想法很简单:更小的服务更容易单独思考,鼓励规范的正确性(几乎没有灰色地带)和 API 的清晰性(受限的输出集遵循受限的输入集)。作为无状态和解耦的服务,有助于系统的可组合性,有助于扩展和可维护性,使它们更容易部署。此外,这种类型的系统可以进行非常精确、离散的监控。

有了这个大致的草图,让我们回到过去,调查一些基础架构模式,比如“3 层”架构,以及它们的特点如何导致了微服务的概念。将这一进展带到现在,然后我们将看看现代网络应用程序的不可思议的规模如何迫使重新构想经典的客户端->服务器->数据库设置,这个新世界通常最好由微服务组成。

构建基于微服务的 Web API 时,拥有能够精确控制处理调用、标头、POST 主体、响应等的工具将非常有用,特别是在调试时。我建议安装Postmanwww.getpostman.com/),以及浏览器的扩展程序,可以“美化”JSON 对象。对于 Chrome 来说,一个很好的选择是JSON Formatterchrome.google.com/webstore/detail/json-formatter/bcjindcccaagfpapjjmafapmmgkkhgoa?hl=en)。

从 3 层到 4 层

要了解微服务如何改进您的 Node 应用程序,您必须了解它们旨在解决的问题,以及以前如何解决这些问题。重要的是要知道微服务导向架构可能适用的地方,以及为什么这样的变化将帮助您。让我们看看多层分布式网络架构是如何随着时间的推移发展的。

单体

这是一个单体:

它很大,是一个整体,是垂直增长的。可能很难在没有巨大的努力、巨大的危险和巨大的成本的情况下重新塑造或修改。当有人将架构描述为单片式时,他们使用前面的隐喻来暗示某种非常庞大、不可移动的东西,以至于使试图改进它或全面调查其全部组成部分的人望而却步。

考虑一个简单的应用,比如一个待办清单。清单管理器需要创建添加删除和其他改变清单的功能。该应用的代码可能类似于这样的伪代码:

let orm = require('some-orm');

module.exports = {
  create: list  => orm.createList(list),
  add: (list, item) => List(list).insert(new Item(item)),
  delete: (list, item) => List(list).delete(item)
};

这个例子展示了单体设计思维。数据在同一台机器上,UI 控制器和进程逻辑在同一上下文中(封闭的 Node 模块),功能在同一个文件和同一个操作系统进程中。你不需要支持微服务来理解,随着用户账户、草稿和媒体附件、共享、多设备同步和其他功能被添加到你的待办应用中,最初的单一的、单体化的存储所有应用逻辑的仓库变得过于密集,需要被分解成多个部分。

如果你将这些函数中的每一个都分解成一个独立的进程,在自己的内存空间中运行,纯粹且不依赖于任何其他进程,以至于可以更新、关闭、复制、部署、测试,甚至替换而不对系统的任何其他部分产生影响,那么微服务就是从这种思维方式中产生的。

构建软件时,使用标准的面向对象编程,或者将所有函数或结构都放在一个文件或一小组文件中,期望软件在单台机器上运行是完全可以的。这种架构模型可能适用于大多数人;在现代硬件上,运行简单的 Node 服务器的单核机器可能能够处理数千个并发用户执行非平凡的、数据库驱动的任务。通过增加更多的核心或内存来扩展垂直架构来扩展不断增长的应用程序是完全可以的。通过启动几台已经垂直扩展的服务器并在它们之间平衡负载来扩展架构也是可以的。这种策略仍然被一些价值数十亿美元的公司使用。

如果构建单体架构是符合你需求的正确选择,那是可以的。在其他时候,微服务可能是正确的选择。你可能不需要使用去中心化的数据源;当服务发生变化时,你可能不需要热重载。广泛使用的数据库 MYSQL 通常是垂直扩展的。当限制被推动时,只需向数据库服务器添加更多的处理核心、内存和存储空间,或者创建同一数据库的多个副本并在它们之间平衡请求。这种单体架构易于理解,通常是有弹性的。

垂直扩展架构(单体架构)的优势是什么?:

  • 测试和调试:应用程序中发生的事情始于应用程序本身,独立于随机网络效应。这在测试和调试时可能会有所帮助。

  • 强一致性:持久的本地数据库连接可以帮助保证事务完整性,包括回滚。分布式数据库,特别是被许多客户端并发访问的数据库,要保持同步要困难得多,并且通常被描述为最终一致,这可能是一个问题,特别是如果你是一家银行。

  • 简单性:一个设计良好的应用,例如一个在同一逻辑空间内与单个数据库绑定的单个 REST API,可以很容易地描述,并且是可预测的。通常,一个人可以理解整个系统,甚至可以独自运行它!这是一个非常重要的优势,特别是在员工入职速度增加和个人创业机会方面。

  • 线性扩展:如果可能的话,通过在单台机器上加倍内存容量来加倍容量是一个非常简单的升级。在某些时候,这种解决方案可能不够用,但这一点可能比你想象的要远得多。相对容易预测增加负载的成本和扩展系统所需的步骤。

一些公司或开发者将遇到绝对需要分布式架构的规模。一些聪明的数据对象设计和通过单个数据库相关的组件化 UI,设计良好并且维护良好,可能足够长时间,甚至永远。在许多方面,流行的 Ruby on Rails 框架继续支持单体和集成系统的价值,这是其创始人 David Heinemeier Hansson 在rubyonrails.org/doctrine/#integrated-systems上强烈主张的立场。

从单片到三层架构

可以说,现在很少有人真正构建单片应用程序。人们现在所谓的单片通常是一个三层应用程序,具体化了以下概念层:

  • 表示层:客户端请求、查看和修改信息的接口。通常与应用程序层通信。

  • 应用程序层:连接表示层和数据层的逻辑

  • 数据层:信息持久化和组织

一个 Node 开发者可能会认识到,应用程序由一个客户端框架(如 React)(表示层)组成,由使用 Express 构建的应用程序层提供服务,通过某种连接器与 MongoDB 数据库通信,例如 Mongoose。这些是 LAMP 堆栈,MEAN 堆栈。系统架构师很久以来就知道将应用程序分离成不同的系统是一种明智的策略。在许多方面,这种架构反映了模型视图控制(MVC)模型,其中 M=数据,V=表示,C=应用程序。

这种架构是如何产生的?

首先,人们认识到系统中有非常明显的应该分开理解的部分。基于浏览器的 UI 与您的数据库或 Web 服务器无关。它可能通过各种抽象层来反映您的数据库结构(例如,通过兴趣链接的个人资料显示),但这种系统特性最终是一个设计决策,而不是一个必要条件。独立于布局网格或交互组件维护和更新您的数据库是有道理的。不幸的是,这些不同的东西可能会因为懒惰的设计或快节奏的商业环境的变化而纠缠在一起,还有其他原因。

其次,当更改一个层的一部分最终需要重新测试整个系统时,快速、持续的部署和集成就会变得更加困难。集成测试必须要么涉及真实系统,要么创建人工模拟,两者都不可靠,都可能导致破坏性结果。同样,部署是整体的——即使在概念上是不同的,实际上每个部分都与其他部分紧密相连,每个部分的完整性都必须通过验证整体的完整性来验证。庞大的测试套件反映了它们试图覆盖的应用程序设计的巨大和密集。

专注于确切的三层使弹性变得困难。新的数据模型、功能、服务,甚至可能是一次性的 UI 添加(例如来自 Facebook 的完全独立的登录系统)必须在三个层之间进行链接,并且必须仔细地(有些是人为地)进行与许多或所有现有数据模型、功能、业务逻辑、UI 的集成。随着新的缓存机制(CDN)和 API 驱动的开发的出现,三层系统的人为性开始让开发人员感到沮丧。

面向服务的体系结构

微服务的概念在很大程度上是对围绕面向服务的体系结构(SOA)的想法的改进和重新定义,维基百科对此的定义如下:

“[SOA]是一种软件设计风格,应用组件通过网络上的通信协议向其他组件提供服务。...服务是可以远程访问并独立操作和更新的离散功能单元,例如在线检索信用卡对账单。”

面向服务的架构在明确定义功能时非常有意义。如果您正在运行在线商店,您可能希望将搜索功能和付款功能与注册系统和客户端 UI 服务器分开。我们可以看到这里的基本思想是创建逻辑上自包含并通过网络访问的功能——其他系统组件(包括服务)可以使用而不会相互冲突。

将类似功能分离为单独的服务是对 3 层架构的常见调整,在该架构中,服务器上的业务逻辑可能将其职责委托给第三方 API。例如,一个身份管理服务如Auth0可能用于管理用户帐户,而不是将其本地存储在数据库中。这意味着登录的业务逻辑作为外部服务的代理。财务交易,如销售,通常被委托给外部提供者,日志收集和存储也是如此。对于可能将其服务作为 API 提供的公司,整个 API 管理可能被委托给云服务,如 Swagger 或 Apiary。

可能是由于架构趋势向服务的方向发展,由第三方服务管理曾经在现场功能上的功能(如缓存和其他 API),一种通常称为“4 层架构”的新思想引起了系统架构师的关注。

4 层和微服务

现代分布式应用程序开发的最近几年已经形成了一种有利于扩展的模式共识。首先让我们考虑一下“4 层架构”通常指的是什么,然后再看微服务是如何定义这类系统设计的。

4 层架构扩展和扩展了 3 层架构:

  • 层 1: 3 层架构中的数据层被服务层取代。这种思路很简单:数据以如此之大的规模、以如此多种不同的方式、通过如此多种不同的技术存储,并且在质量和类型上变化如此之快,以至于“单一真相来源”的概念,比如单一数据库,已经不再可行。数据通过抽象接口公开,其内部设计(调用 Redis 数据库和/或从 Gmail 收取收件箱和/或从政府数据库读取天气数据)是一个“黑匣子”,只需返回预期格式的数据。

  • 层 2: 4 层架构引入了聚合层的概念。正如数据现在被分解为服务(1),业务逻辑也被隔离到单独的服务中。正如我们稍后将在讨论 Lambda 架构时看到的,获取数据或调用子例程的方式已经模糊成了一个通用的 API 驱动模型,其中具有一致接口的单独服务生成协议感知数据。这一层组装和转换数据,将聚合的源数据增加和过滤成以结构化、可预测的方式建模的数据。这一层可能被称为后端或应用层。这是开发人员编程数据流通道的地方,按照约定(编程)的协议。通常,我们希望在这里生成结构化数据。

  • 其余层是通过将表示层分为两个部分来创建的:

  • 第三层: 交付层:此层意识到客户端配置文件(移动设备、桌面、物联网等),将聚合层提供的数据转换为特定于客户端的格式。缓存数据可以通过 CDN 或其他方式在此处获取。在这里可能会选择要插入网页的广告。此层负责优化从聚合层接收到的数据,以适应个别用户。这一层通常可以完全自动化。

  • 第四层: 客户端层:此层定制了交付层通常为特定客户返回的内容。这可以是为移动设备呈现数据流(可能是响应式 CSS 结构或特定设备的本机格式),也可以是个性化视图的反映(仅图像或语言翻译)。在这里,相同的数据源可以与特定的业务合作伙伴对齐,符合SLA(服务级别协议)或其他业务功能。

显著的变化是将呈现层分成两个部分。Node 经常出现在交付层,代表客户端查询聚合层,定制从聚合层接收到的数据响应给客户端。

总的来说,我们已经转向了一个架构,其中不再期望个别服务以任何特定方式反映调用者的需求,就像 Express 服务器中的面向浏览器的模板引擎可能会有的那样。服务无需共享相同的技术或编程语言,甚至不需要相同的操作系统版本或类型。架构师们相反宣布了一定类型的拓扑结构,具有明确定义的通信点和协议,通常分布在:1)数据源,2)数据聚合器,3)数据整形器和 4)数据显示器。

部署微服务

在本节中,我们将考虑微服务的几种变体,看一看开发人员如何使用 Node 进行微服务的一些常见方式。我们将从Seneca开始,这是一个用于 Node 的微服务框架。然后,我们将继续使用Amazon Lambda开发基于云的微服务。从那里,我们将尝试使用Docker容器模拟一个Kubernetes集群,探索现代容器化微服务编排。

使用 Seneca 的微服务

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

首先,让我们创建一个响应三种模式的服务,其中一种模式返回“Hello!”,另外两种模式是不同的说“Goodbye!”的方式。

创建一个名为hellogoodbye.js的文件,其中包含以下代码:

// hellogoodbye.js
const seneca = require('seneca')({ log: 'silent' });
const clientHello = seneca.client(8080);
const clientGoodbye = seneca.client(8081);

seneca
.add({
role: 'hello',
cmd:'sayHello'
}, (args, done) => done(null, {message: "Hello!"}))
.listen(8082);

seneca
.add({
role: 'goodbye',
cmd:'sayGoodbye'
}, (args, done) => done(null, {message: "Goodbye"}))
.add({
role: 'goodbye',
cmd:'reallySayGoodbye'
}, (args, done) => done(null, {message: "Goodbye!!"}))
.listen(8083);

clientHello.act({
role: 'hello',
cmd: 'sayHello'
}, (err, result) => console.log(result.message));

clientGoodbye.act({
role: 'goodbye',
cmd: 'sayGoodbye'
}, (err, result) => console.log(result.message));

clientGoodbye.act({
role: 'goodbye',
cmd: 'reallySayGoodbye'
}, (err, result) => console.log(result.message));

Seneca 的工作原理是服务客户端监听特定的命令模式,并根据模式匹配将其路由到正确的处理程序。我们的第一项工作是设置两个 Seneca 服务客户端,监听端口80808081。可以看到服务已经被组织成两个组,一个是“hello 服务”有一个方法,另一个是“goodbye 服务”有另一个方法。现在我们需要向这些服务添加操作。为此,我们需要告诉 Seneca 在进行匹配特定模式的服务调用时如何操作,这里使用特定的对象键进行定义。如何定义您的服务对象是开放的,但“cmd”和“role”模式是常见的——它可以帮助您创建逻辑组和标准的命令调用签名。我们将在接下来的示例中使用该模式。

考虑到上述代码,我们看到当收到一个 JSON 对象,其中cmd字段设置为sayHellorolehello时,服务处理程序应该返回{ message: "Hello!" }。 "goodbye"角色方法同样被定义。在文件底部,您可以看到我们如何可以通过 Node 直接调用这些服务。很容易想象这些服务定义如何可以分解成几个模块导出到单独的文件中,根据需要动态导入,并以有组织的方式组合应用程序(这是微服务架构的目标)。

为了摆脱显示的日志数据,您可以使用require('seneca')({ log: 'silent' })来初始化您的 Seneca 实例。

由于 Seneca 服务默认监听 HTTP,您可以通过直接调用 HTTP,在/act路由上进行操作,从而实现相同的结果:

curl -d "{\"cmd\":\"sayHello\",\"role\":\"hello\"}" http://localhost:8082/act
// {"message":"Hello!"}

这种自动的 HTTP 接口为我们提供了可自动发现的网络服务,这非常方便。我们已经可以感受到微服务模式:简单、独立、小的功能块,使用标准的网络数据模式进行通信。Seneca 为我们提供了免费的编程和网络接口,这是一个额外的好处。

一旦开始创建大量的服务,就会变得难以跟踪哪个服务组在哪个端口上运行。服务发现是微服务架构引入的一个困难的新问题。Seneca 通过其mesh插件解决了这个问题,该插件将服务发现添加到您的 Seneca 集群中。让我们创建一个简单的计算器服务来演示。我们将创建两个服务,每个服务监听不同的端口,一个执行加法,另一个执行减法,以及一个基本服务来实例化网格。最后,我们将创建一个简单的脚本,使用不需要知道其位置的服务执行加法/减法操作,通过网格。

此示例的代码位于您的代码包中的/seneca文件夹中。首先,您需要安装两个模块:

npm i seneca-balance-client seneca-mesh

现在,我们创建一个基本节点,将启用网格:

// base.js
require('seneca')().use('mesh', {
  base: true
});

一旦启动了这个节点,其他服务一旦连接到网格,就会自动被发现。

add服务块如下所示:

// add.js
require('seneca')()
.add({
  role: 'calculator',
  cmd: 'add'
}, (args, done) => {
  let result = args.operands[0] + args.operands[1];
  done(null, {
    result : result
  })
})
.use('mesh', {
  pin: {
    role: 'calculator',
    cmd: 'add'
  }
})
.listen({
  host: 'localhost',
  port: 8080
});

subtract服务看起来完全相同,只是更改了它使用的数学运算符,当然它的cmd将是“subtract”)。

使用熟悉的角色/cmd 模式,我们将add命令附加到calculator组,类似于我们在之前的示例中定义“hello”服务的方式,具有执行加法操作的处理程序。

我们还指示我们的服务listen在本地主机上的特定端口接收调用,就像我们通常做的那样。新的是我们使用use网格网络,使用pin属性指示此服务将响应的角色和 cmd,使其在网格中可发现。

进入您的代码包中的/seneca文件夹,并在单独的终端中按照以下顺序启动以下三个文件:base.js->add.js->subtract.js。我们的计算器的逻辑单元已经独立设置并独立运行,这是微服务的一般目标。最后一步是与它们进行交互,我们将使用以下calculator.js文件:

// calculator.js
require('seneca')({ log: 'silent' })
.use('mesh')
.ready(function() {

  let seneca = this;

  seneca.act({
    role: 'calculator',
    cmd: 'add',
    operands: [7,3]
  }, (err, op) => console.log(`Addition result -> ${op.result}`));

  seneca.act({
    role: 'calculator',
    cmd:'subtract',
    operands: [7,3]
  }, (err, op) => console.log(`Subtraction result -> ${op.result}`));
});

除了在 Seneca 的ready处理程序中运行我们的操作(这是一个有用的做法),当然还有我们对meshuseseneca.act语句看起来与我们之前使用的“hello”操作一样,不是吗?它们是相同的,除了一个重要的细节:我们没有使用.listen(<port>)方法!不需要像在hellogoodbye.js示例中那样创建绑定到特定端口的新 Seneca 客户端,因为网格网络服务是自动发现的。我们可以简单地进行调用,而不需要知道服务存在于哪个端口。继续运行上述代码。您应该会看到以下结果:

Addition result -> 10
Subtraction result -> 4

这样可以提供很大的灵活性。通过以这种方式构建您的计算器,每个操作都可以被隔离到自己的服务中,并且您可以根据需要添加或删除功能,而不会影响整个程序。如果某个服务出现错误,您可以修复并替换它,而不会停止整个计算器应用程序。如果某个操作需要更强大的硬件或更多内存,您可以将其转移到自己的服务器上,而不会停止计算器应用程序或更改应用程序逻辑。很容易看出,与它们都耦合到一个集中的服务管理器相比,串联数据库、身份验证、事务、映射和其他服务可以更容易地进行建模、部署、扩展、监视和维护。

无服务器应用程序

从这些分布式系统的设计中产生的抽象,主要建立在微服务上,暗示了一个自然的下一步。为什么传统意义上还需要服务器?服务器是设计在单体时代的大型、强大的机器。如果我们的思维是以小型、资源节约、独立于周围环境的行为者为基础,那么我们应该部署微服务到“微服务器”上吗?这种思路导致了一个革命性的想法:AWS Lambda。

AWS Lambda

亚马逊的 AWS Lambda 技术的引入推动了我们今天所拥有的无服务器运动。亚马逊这样描述 Lambda:

"AWS Lambda 允许您在不需要预配或管理服务器的情况下运行代码...使用 Lambda,您可以为几乎任何类型的应用程序或后端服务运行代码-而无需进行任何管理。只需上传您的代码,Lambda 会处理运行和扩展您的代码所需的一切。您可以设置代码自动从其他 AWS 服务触发或直接从任何 Web 或移动应用程序调用它。"

Lambda 是一种技术,允许您创建由 JavaScript 编写的微服务组成的无限可扩展的计算云。您不再管理服务器,只管理函数(Lambda 函数)。扩展的成本是根据使用而不是计数来衡量的。调用 1 次 Lambda 服务的成本比调用每个 9 次 Lambda 服务一次要高。同样,您的服务可以处于空闲状态,从不被调用,而不会产生任何费用。

Lambda 函数是功能性虚拟机。Lambda 函数本质上是一个容器化的 Node 应用程序,可以自动构建和部署,包括底层服务和基础设施的安全更新和进一步维护。您永远不需要管理 Lambda 函数,只需编写它们执行的代码。

另一方面,您牺牲了在服务器架构上开发提供的一些灵活性。在撰写本文时,每个 Lambda 函数的限制如下:

资源 限制
内存分配范围 最小= 128 MB / 最大= 1536 MB(每次增加 64 MB)。如果超过最大内存使用量,函数调用将被终止。
临时磁盘容量("/tmp"空间) 512 MB
文件描述符数量 1,024
进程和线程数量(总和) 1,024
每个请求的最大执行持续时间 300 秒
调用请求体有效负载大小(请求响应/同步调用) 6 MB
调用请求体有效负载大小(事件/异步调用) 128 K

在设计应用程序时,需要牢记这些限制。通常,Lambda 函数不应依赖持久性,应做好一件事,并且快速完成。这些限制还意味着您不能在 Lambda 函数内部启动本地数据库或其他进程应用程序。

Lambda 发布时,它专门设计用于 Node;您可以通过 Node 运行时使用 JavaScript 编写 Lambda 函数。这一事实至少表明了 Node 对于现代应用程序开发的重要性。虽然现在支持其他语言,但 Lambda 仍将 Node 视为一流公民。在本节中,我们将使用 Lambda 计算云开发一个应用程序。

虽然与 Lambda 的设置过程现在比项目首次发布时要容易得多,但您仍然需要构建大量自动样板,并且需要进行大量手动工作来进行更改。因此,在 Node 生态系统中出现了许多非常高质量的 Lambda 专注的“无服务器”框架。以下是一些主要的框架:

在接下来的示例中,我们将使用claudia,它设计良好、文档完善、维护良好,并且易于使用。claudia的开发者是这样说的:

“……如果您想构建简单的服务并使用 AWS Lambda 运行它们,而且您希望找到一个低开销、易于入门的工具,并且只想使用 Node.js 运行时,Claudia 是一个不错的选择。如果您想要导出 SDK,需要对服务的分发、分配或发现进行精细控制,需要支持不同的运行时等等,那么请使用其他工具。”

API 网关是一个完全托管的 AWS 服务,“使开发人员能够轻松创建、发布、维护、监控和保护任何规模的 API”。我们现在将使用 Claudia 和 AWS API 网关来组装一个由 Lambda 驱动的微服务的可扩展 Web 服务器。

使用 Claudia 和 API 网关进行扩展

首先,您需要在 Amazon Web Services(AWS)aws.amazon.com创建一个开发者账户。这个账户设置是免费的。此外,大多数 AWS 服务都有非常慷慨的免费使用额度,在这些限制内,您可以在学习和开发过程中使用 AWS 而不产生任何费用。使用 Lambda,每个月的前一百万个请求是免费的。

创建开发者账户后,登录到您的仪表板,然后从“服务”选项卡中选择 IAM。现在,您将添加一个用户,我们将在这些示例中使用。Claudia 需要权限与您的 AWS 账户通信。通常情况下,您不希望在应用程序中使用根账户权限,这应该被理解为您账户的“子用户”。AWS 提供了一个身份和访问管理(IAM)服务来帮助处理这个问题。让我们创建一个具有 IAM 完全访问权限、Lambda 完全访问权限和 API 网关管理员权限的 AWS 配置文件。

从侧边栏中,选择用户,然后点击“添加用户”:

如上所示,创建一个名为claudia的新用户,为该用户提供编程访问权限。

完成后,点击“下一步:权限”按钮。现在,我们需要将此 IAM 账户附加到 Lambda 和 API 网关服务,并赋予它管理员权限:

在选择“直接附加现有策略”后,您将看到下面出现一个长长的选项清单。为claudia用户选择以下三个权限:AdministratorAccess、AmazonAPIGatewayAdministrator,当然还有 AWSLambdaFullAccess。

点击“审核”后,您应该会看到以下内容:

好的。点击“创建用户”,并复制提供的访问密钥 ID 和秘密访问密钥(稍后会用到)。现在,您已经准备好使用claudia部署 Lambda 函数了。

安装 claudia 并部署服务

要开始安装claudia模块,请输入以下命令:

npm install claudia -g

现在,您应该存储刚刚为claudia用户创建的凭证。这里的一个好模式是在您的主目录中存储一个 AWS 配置文件(在 OSX 上,这将是/Users/<yoursystemusername>)。一旦进入您的主目录,创建.aws/credentials目录和文件,并使用您的 IAM 用户密钥:

[claudia] 
aws_access_key_id = YOUR_ACCESS_KEY 
aws_secret_access_key = YOUR_ACCESS_SECRET

在这里,我们指示claudia是 AWS 配置文件名称,针对这些 IAM 凭证。当我们运行部署时,AWS 将被告知此配置文件和凭证。

现在,让我们创建一个可通过网络访问的 HTTP 端点,返回字符串“Hello from AWS!”。

创建一个新目录,并使用npm init初始化一个npm包,使用任何您喜欢的名称。要使用 AWS API Gateway,我们还需要安装claudia的扩展:

npm install claudia-api-builder

接下来,将以下app.js文件添加到此目录:

const ApiBuilder = require('claudia-api-builder');
const api = new ApiBuilder();

module.exports = api;

api.get('/hello', function () {
    return 'Hello from AWS!';
});

使用claudia ApiBuilder,我们将一个 Lambda 函数附加到/hello路由上处理 GET 请求。令人惊讶的是,我们已经完成了!要部署,请在终端中输入以下内容:

AWS_PROFILE=claudia claudia create --region us-east-1 --api-module app

AWS_PROFILE环境变量引用了我们凭证文件中的[claudia]配置文件标识符,并且我们使用--region标志来建立部署区域。

如果一切顺利,您的端点将被部署,并且将返回类似以下的信息:

{
  "lambda": {
    "role": "claudiaapi-executor",
    "name": "claudiaapi",
    "region": "us-east-1"
  },
  "api": {
    "id": "s8r80rsu22",
    "module": "app",
    "url": "https://s8r80rsu22.execute-api.us-east-1.amazonaws.com/latest"
  }
}

返回的 URL 指向我们的 API 网关。现在,我们需要添加我们 Lambda 函数的名称,该名称在我们之前定义的 GET 处理程序中设置为'hello'

api.get('/hello', function () ...

复制并粘贴返回的 URL 到浏览器中,并添加您的 Lambda 函数的名称:

https://s8r80rsu22.execute-api.us-east-1.amazonaws.com/latest/hello

您将看到以下消息:

Hello from AWS!

这很容易。更新函数同样容易。返回到代码并更改函数返回的字符串消息,然后运行:

AWS_PROFILE=claudia claudia update

成功时将返回一个 JSON 对象,指示有关函数的代码大小和其他有用信息。继续在浏览器中重新加载端点,您将看到更新的消息。这些是零停机时间更新——您的服务在部署新代码时永远不会停止工作。在这里,我们满足了创建“独立的,无状态的服务可以独立地替换或升级(或降级)的关键目标,而不管它们所形成的任何系统的状态如何”。

现在,您可以通过返回 AWS 仪表板并访问 Lambda 服务来验证 Lambda 函数的存在:

![

我们可以看到列出的包名称(claudiaapi)和我们正在使用的 Node 运行时(在撰写本文时 AWS 上最高可用的版本)。如果单击函数,您将看到 Lambda 函数的管理页面,包括其代码以及用于管理最大执行时间和内存限制的界面。

app.js中的处理程序函数更改为以下内容:

api.get('/hello', function (request, context, callback) {
    return request;
});

您将看到三个新参数传递给handlerrequestcontextcallbackcontext参数包含有关此调用的 Lambda 上下文的有用信息,例如调用 ID,被调用函数的名称等。有用的是,claudia在传递的request对象的lambdaContext键中镜像 Lambda 上下文。因此,使用claudia时,您只需要处理request参数,这简化了事情。

要了解有关 Lambda 事件上下文的更多信息,请参阅:docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-context.html

现在,使用claudia update更新您的 Lambda 函数,并检查 URL。您应该看到返回大量 JSON 数据,这是您可以使用的请求事件信息的总和。有关此数据对象的更多信息,请访问:github.com/claudiajs/claudia-api-builder/blob/master/docs/api.md#the-request-object

您可以在github.com/anaibol/awesome-serverless找到一些有关无服务器开发信息和链接的有趣集合。

容器化的微服务

亚马逊 AWS 基础设施能够创建像 Lambda 这样的服务,因为他们的工程师在客户创建另一个云函数或 API 时不再提供硬件(即新的物理服务器)。相反,他们提供轻量级的虚拟机(VM)。当您注册时,没有人会将一个大的新金属箱放到机架上。软件是新的硬件。

容器的目标是提供与虚拟化服务器提供的相同的一般架构思想和优势——大规模生产虚拟化的独立机器。主要区别在于,虽然虚拟机提供自己的操作系统(通常称为Hypervisor),但容器需要主机操作系统提供实际的内核服务(例如文件系统、其他设备以及资源管理和调度),因为它们不需要携带自己的操作系统,而是寄生在主机操作系统上,容器非常轻便,使用更少的(主机)资源,并且能够更快地启动。在本节中,我们将介绍任何开发人员如何使用领先的容器技术 Docker 来廉价地制造和管理许多虚拟化服务器。

这是一个关于虚拟环境之间区别的很好的 StackOverflow 讨论:stackoverflow.com/questions/16047306/how-is-docker-different-from-a-normal-virtual-machine

Docker 网站(www.docker.com/)上的这张图片提供了一些关于 Docker 团队如何以及为什么他们认为他们的技术适合未来应用程序开发的信息:

回顾我们对 4 层架构的讨论,我们可以看到开发人员问自己一个问题:如果我的应用程序由许多在云中独立开发、测试、部署和管理的通信服务组成,那么我们是否可以用“本地”服务做同样的事情,并为每个服务提供独立的容器,以便可以独立开发、测试、部署等?减少实施变更成本是容器化和微服务的目标。一个容器生成一个本地化的、独立的服务,具有受保护的本地内存,可以快速启动和重新启动,单独测试,并且可以静默失败,完全适合微服务架构:

  • 明确定义的责任领域

  • 隔离的依赖和状态

  • 进程是可丢弃的

  • 轻量级且易于启动和复制

  • 优雅的终止,零应用程序停机时间

  • 可以独立测试

  • 可以独立监视

开始使用 Docker

Docker 生态系统有三个主要组件。文档中是这样说的:

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

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

  • Docker 注册表。Docker 注册表保存镜像。这些是公共(或私有!)存储,你可以上传或下载镜像。这些镜像可以是你自己创建的,也可以使用其他人之前创建的镜像。你可以将 Docker 注册表视为 Docker 框架的共享部分。你可以创建应用程序的镜像,以在任意数量的隔离容器中运行,并与其他人共享这些镜像。最受欢迎的是Docker Hubhub.docker.com/),但你也可以自己操作。

将 Node 应用程序组合成许多独立的进程的概念自然与 Docker 背后的哲学相吻合。Docker 容器是沙箱化的,无法在没有你的知识的情况下在其主机上执行指令。然而,它们可以向它们的主机操作系统公开一个端口,从而允许许多独立的虚拟容器链接到一个更大的应用程序中。

学习一下如何找到关于你的操作系统的信息,哪些端口正在使用,由哪些进程使用等是个好主意。我们之前提到过 HTOP,你应该至少熟悉一下如何收集网络统计信息——大多数操作系统都提供了netstat实用程序,用于发现哪些端口是打开的,谁在监听它们。例如,netstat -an | grep -i "listen"

下载并安装Docker 社区版www.docker.com/community-edition)或Docker 工具箱docs.docker.com/toolbox/overview/)。可以在以下网址找到两者之间的比较:docs.docker.com/docker-for-mac/docker-toolbox/。如果你使用工具箱,在提示时选择 Docker Quickstart Terminal,这将在你的系统上生成一个终端并安装必要的组件。安装过程可能需要一段时间,所以不要惊慌!完成后,你应该在终端中看到类似以下的内容:

docker is configured to use the default machine with IP 192.158.59.101

请注意 Docker 机器的名称是"default"。

为了了解镜像是如何工作的,运行docker run hello-world命令。你应该看到机器拉取一个镜像并将其容器化——正在发生的详细信息将被打印出来。如果现在运行docker images命令,你会看到类似这样的东西:

这个命令将告诉你一些关于你的 Docker 安装的信息:docker info

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

首先,我们需要构建一个要运行的应用程序。创建一个文件夹来放置你的应用程序文件。在该文件夹中,创建一个/app文件夹;这是我们将放置服务器文件的地方。与所有 Node 应用程序一样,我们需要创建一个package.json文件。进入/app文件夹并运行npm init,给这个包一个名字"docker-example"。然后,用npm i express安装 Express。

现在,创建一个简单的 Express 服务器并将其保存到app/index.js中:

// index.js
const express = require('express');
const port = 8087;
const app = express();
const message = `Service #${Date.now()} responding`;
app.get('/', (req, res) => {
    res.send(message);
});
app.listen(port, () => console.log(`Running on http://localhost:${port}`));

继续启动服务器:

> node app.js
// Running on http://localhost:8087

现在,你可以将浏览器指向端口8087的主机,看到类似Service #1513534970093 responding的唯一消息显示。很好。创建一个唯一消息(通过Date.now())是有原因的,当我们讨论服务扩展时,这将更有意义。现在,让我们使用 Docker 将这些文件构建成一个容器。

创建一个 Dockerfile

我们的目标是描述此应用程序在其中执行的环境,以便 Docker 可以在容器中复制该环境。此外,我们希望将我们应用程序的源文件添加到这个新的虚拟化环境中运行。换句话说,Docker 可以充当构建器,遵循您提供的关于如何构建应用程序图像的指令。

首先,您应该有一个包含应用程序文件的文件夹。这是您的源代码存储库,您的 docker 图像将在其中构建。如前所述,Dockerfile 是用于构建应用程序的指令列表。Dockerfile 描述了构建过程。您通常会在 Dockerfile 中声明容器将运行的操作系统版本,以及您可能需要完成的任何操作系统安装,例如 Node。

创建一个Dockerfile文件(无扩展名):

# Dockerfile
FROM node:9
LABEL maintainer="your@email.com"
ENV NODE_ENV=development
WORKDIR /app
COPY ./app .
RUN npm i
EXPOSE 8087
CMD [ "npm", "start" ]

您在此文件中看到各种指令,并且还有一些其他指令可用于更复杂的构建。我们将从简单开始。要深入了解 Dockerfile,可以通过完整的文档运行:docs.docker.com/engine/reference/builder/

FROM指令用于设置您将构建的基本图像。我们将基于node:9构建,这是包含最新 Node 的图像。更复杂的图像通常包括在此处,通常围绕常见模式构建。例如,此图像实现了MEAN(Mongo Express Angular Node)堆栈:hub.docker.com/r/meanjs/mean/FROM应该是 Dockerfile 中的第一个指令。

您可以通过LABEL为图像设置(可选的)元数据。可以有多个LABEL声明。这对于版本管理、信用等非常有用。我们还为 Node 进程设置了一些环境变量(ENV),如您在process.env中所期望的那样。

我们为应用程序指定工作目录(WORKDIR),并将我们机器上的所有本地文件COPY到容器的文件系统中;容器是隔离的,无法访问自身以外的文件系统,因此我们需要从我们的文件系统构建其文件系统。

现在,我们建立启动指令。RUN npm i安装package.jsonEXPOSE我们服务器运行的端口(8087)到外部世界(再次,容器是隔离的,没有权限的情况下无法暴露内部端口),并运行命令(CMDnpm start。您可以设置多个RUNCMD指令,以启动应用程序所需的任何内容。

我们现在准备构建和运行容器。

运行容器

在包含 Dockerfile 的目录中运行以下命令:

docker build -t mastering-docker .(注意末尾的句点)。

Docker 现在将获取所有基本依赖项并根据您的指令构建图像:

您刚刚创建了您的第一个 Docker 图像!要查看您的图像,请使用docker images

在这里,我们看到我们创建的图像mastering-docker,以及我们的图像基于的图像node:9。请注意冒号是用于创建图像的标记版本 -- 我们最终使用的是node图像标记为9。稍后再讨论版本控制。

下一步是将图像容器化并运行。使用此命令:

docker run -p 8088:8087 -d mastering-docker

如果一切顺利,您将能够使用docker ps命令列出正在运行的 Docker 进程:

回想一下EXPOSE 8087指令?我们需要将容器暴露的端口映射到本地操作系统网络接口,我们在运行命令中使用-p 8088:8087标记了这个映射,我们可以在上面的屏幕截图中看到PORTS下的映射。

-d标志指示 Docker 我们想要以分离模式运行容器。这可能是您想要做的,将容器在后台运行。没有这个标志,当您终止终端会话时,容器将终止。

您现在正在一个完全与本地机器隔离的容器中运行一个 Node 服务器。通过在浏览器中导航到localhost:8088来尝试它。能够构建完全隔离的构建,具有完全不同的操作系统、数据库、软件版本等,然后知道您可以将完全相同的容器部署到数据中心而不改变任何内容,这是非常棒的。

以下是一些更有用的命令:

  • 删除一个容器:docker rm <containerid>

  • 删除所有容器:docker rm $(docker ps -a -q)

  • 删除一个镜像:docker rmi <imageid>

  • 删除所有镜像:docker rmi $(docker images -q)

  • 停止或启动一个容器:docker stop (或 start) <containerid>

使用 Kubernetes 编排容器

基于微服务的架构由独立的服务组成。我们刚刚看到容器如何用于隔离不同的服务。现在,问题是如何管理和协调这 10、20、100、1,000 个服务容器?“手动”似乎不是正确的方法。Kubernetes 自动化容器编排,帮助您处理部署、扩展和集群健康的问题。由 Google 开发,它是一种成熟的技术,用于在 Google 自己的庞大数据中心中编排数百万个容器。

我们将安装一个名为 Minikube 的应用程序,它在本地机器的 VM 中运行一个单节点 Kubernetes 集群,因此您可以在部署之前在本地测试开发 Kubernetes 集群。由于您在本地进行的集群配置与“真实”的 Kubernetes 集群镜像,一旦满意,您可以在生产环境中部署您的定义而不需要进行任何更改。

创建一个基本的 Kubernetes 集群

您将需要某种 VM 驱动程序来运行 Minikube,默认情况下,Minikube 使用 VirtualBox。您可以在以下网址找到安装说明:www.virtualbox.org/wiki/Downloads。VirtualBox 作为免费的 hypervisor 独立存在,用于支持其他有用的开发人员工具,如 Vagrant。

现在,我们将安装 kubectl(将其视为“Kube Control”),即 Kubernetes 命令行界面。请按照以下说明操作:kubernetes.io/docs/tasks/tools/install-kubectl/

最后,我们安装 Minikube:kubernetes.io/docs/tasks/tools/install-minikube/

使用minikube start启动集群(这可能需要一段时间,所以请耐心等待)。输出足够描述:您将启动一个虚拟机,获取一个 IP 地址,并构建一个 Kubernetes 集群。输出应该以类似“Kubectl is now configured to use the cluster”的内容结束。您可以随时使用minikube status检查其状态:

minikube: Running
cluster: Running
kubectl: Correctly Configured: pointing to minikube-vm at 192.160.80.100

要查看 kubectl 是否配置为与 Minikube 通信,请尝试kubectl get nodes,这应该显示 Minkube 机器'minikube'处于'就绪'状态。

此虚拟机是通过 VirtualBox 运行的。在您的机器上打开 Virtualbox Manager。您应该会看到列出了名为"minikube"的机器。如果是这样,太好了;Kubernetes 集群正在您的机器上运行!

您可以使用 Minikube 测试不同的 Kubernetes 版本。要获取可用版本,请运行minikube get-k8s-versions。一旦有了版本,使用minikube start --kubernetes-version v1.8.0在该版本上启动 Minikube。

现在,我们将使用 Kubernetes 来部署我们之前使用 Docker 容器化的“hello world”服务器。有用的是,Minikube 管理自己的 Docker 守护程序和本地存储库。我们将使用它来构建接下来的内容。首先,使用eval $(minikube docker-env)链接到 Minikube 的 Docker。当您想要将控制权返回到主机 Docker 守护程序时,请尝试eval $(minikube docker-env -u)

返回到包含我们服务器的文件夹并构建我们的 Docker 镜像(注意末尾的点):

docker build -t mastering-kube:v1 .

当该过程完成后,您应该在终端中看到类似这样的显示:

Successfully built 754d44e83976
Successfully tagged mastering-kube:v1

你可能已经注意到我们的镜像名称上有 :v1 后缀。我们在 Dockerfile 中声明 Node 时就看到了这一点(还记得 FROM Node:9 指令吗)?如果你运行 docker images,你会看到标签被应用了:

以后,如果我们想要发布 mastering-kube 的新版本,我们只需使用新标签构建,这将创建一个独立的镜像。这是您随着时间管理容器镜像版本的方法。

现在,让我们使用该镜像启动一个容器,并将其部署到我们的 Kubernetes 集群中:

kubectl run kubernetes-demo --image=mastering-kube:v1

在这里,我们声明了一个名为 kubernetes-demo 的新部署,应该导入版本为 v1mastering-kube 镜像。如果一切正常,您应该在终端中看到部署 "kubernetes-demo" 已创建。您可以使用 kubectl get deployments 列出部署:

我们刚刚在 Kubernetes 集群中部署了一个单个 Pod。Pod 是 Kubernetes 的基本组织单元,它们是容器的抽象包装。Pod 可能包含一个或多个容器。Kubernetes 管理 Pod,Pod 管理它们自己的容器。每个 Pod 都有自己的 IP 地址,并且与其他 Pod 隔离,但是 Pod 中的容器之间不相互隔离(例如,它们可以通过 localhost 进行通信)。

Pod 提供了一个抽象,即在某个地方(本地、AWS、数据中心)运行的单个机器,以及在该单个机器上运行的所有容器。通过这种方式,您可以在云中的不同位置运行 Pod 的单个 Kubernetes 集群。Kubernetes 是跨不同位置的机器主机的抽象,它可以让您编排它们的行为,而不管它们是托管在 AWS 上的 VM 还是您办公室的笔记本电脑,就像您可能使用 ORM 来抽象数据库细节一样,让您可以自由更改部署的技术组成,而不必更改配置文件。

使用 kubectl get pods 命令,您现在应该看到类似这样的内容:

最后一步是将此部署的 Pod 作为服务暴露出来。运行此命令:

kubectl expose deployment kubernetes-demo --port=8087 --type=LoadBalancer

如果成功,您应该看到消息服务 "kubernetes-demo" 已暴露。要查看服务,请使用 kubectl get services

注意我们是如何创建一个负载均衡类型的部署的,暴露了一个映射到我们的 mastering-kube 服务(容器)的 Kubernetes 服务,可以通过为这个部署的 Pod 分配的唯一 IP 进行访问。让我们找到那个 URL:

minikube service kubernetes-demo --url

您应该收到一个 URL(注意 Kubernetes 正在运行自己的 DNS),并浏览到该 URL,您应该看到类似这样的消息:

Service #1513534970093 responding

通过 Minikube,您可以在一个步骤中在浏览器中启动您的服务:minikube service kubernetes-demo

很好。然而,Kubernetes 的真正魔力在于部署如何扩展和响应网络条件。

回想一下这个部署是负载均衡的,让我们在同一个 Pod 中创建多个共享负载的容器(不太像你可能会使用 Node 的 Cluster 模块来平衡负载的方式)。运行以下命令:

kubectl scale deployment kubernetes-demo --replicas=4

您应该收到消息部署 "kubernetes-demo" 已扩展。让我们确保这是真的。再次运行 kubectl get pods。您应该看到我们的部署已经自动扩展了它平衡的 Pod 数量:

这很容易。让我们进行一个快速测试,以证明负载正在跨多个容器进行平衡。我们将使用 AB(Apache Bench) 进行快速基准测试和响应显示。使用以下命令针对我们的服务 URL 进行测试(用你本地服务的 URL 替换 URL):

ab -n 100 -c 10 -v 2 http://192.168.99.100:31769/

上面的所有内容都是为了模拟对我们的服务器的 100 次调用,这是为了检查它是否如预期般响应。我们将收到类似以下的输出:

Service #1513614868094 responding
LOG: header received:
 HTTP/1.1 200 OK
X-Powered-By: Express
...
Connection: close

Service #1513614581591 responding
...

Service #1513614867927 responding
...

请记住,我们已经在 4 个容器之间进行了缩放的服务器有一个带有唯一时间戳的常量消息:

// Per-server unique message
const message = `Service #${Date.now()} responding`; 

app.get('/', (req, res) => {
    res.send(message);
});

ab返回的响应差异证明了对一个端点的调用是在多个服务器/容器之间进行负载均衡的。

如果你发现 Minikube 处于奇怪或不平衡的状态,只需清除它的主目录并重新安装。例如:rm -rf ~/.minikube; minikube start。你也可以使用minikube delete完全删除 Kubernetes 集群。

虽然命令行工具非常有用,但你也可以访问 Kubernetes 集群的仪表板。你可以通过在终端中输入kubectl proxy来启动一个仪表板来监视你的集群。你会看到类似于这样的显示:Starting to serve on 127.0.0.1:8001。这指向仪表板服务器。在浏览器中打开这个服务器上的/ui路径(127.0.0.1:8001/ui),你应该会看到一个完整描述你的集群的 UI:

在这里,我们可以看到所有的 pod、状态等,特别是我们的 Pod 容器的 4/4 缩放。在本章的后面,我们将更深入地了解如何使用仪表板来检查正在运行的集群。

Minikube 提供了一个快捷方式,可以自动打开这个仪表板:minikube dashboard

现在,让我们看一下如何使用YAML(尚未标记语言)来创建 Pod 声明,避免我们一直在做的手动配置,并简化后续的部署。

声明 Pod 部署

在本节中,我们将创建一个具有三个容器的 Pod,演示使用 YAML 文件管理配置声明如何简化部署过程,以及同一 Pod 中的容器如何相互通信。

在你的代码包中,将有一个名为/kubernetes的目录,其布局如下:

/kubernetes
 /rerouter /responder three-containers.yaml

每个目录定义了一个 Docker 容器,该容器定义了一个 Express 服务器,将成为这个 Pod 中的容器。我们将把这些视为单独的服务,并演示它们如何通过localhost相互通信。

首先,让我们看一下 YAML 文件:

apiVersion: v1
kind: Pod
metadata:
  name: three-containers
spec:
  restartPolicy: OnFailure
  volumes:
  - name: shared-data
    emptyDir: {}

  containers:
  - name: service-rerouter
    image: rerouter:v1
    volumeMounts:
    - name: shared-data
      mountPath: /app/public

  - name: service-responder
    image: responder:v1

  - name: service-os
    image: debian
    volumeMounts:
    - name: shared-data
      mountPath: /pod-data
    command: ["/bin/sh"]
    args: ["-c", "echo Another service wrote this! > /pod-data/index.html"]

这个清单是一个kindPod)的清单,具有一个定义了三个containersspec,一个共享的volume(稍后会详细介绍),以及一个restartPolicy,表明只有在容器以失败代码退出时才应重新启动容器。

当容器需要共享数据时,就会使用卷。在容器内部,数据存储是暂时的——如果容器重新启动,那些数据就会丢失。共享卷是在 Pod 内部容器之外保存的,因此可以通过容器的重新启动和崩溃来持久保存数据。更重要的是,单个 Pod 中的许多容器可以写入和读取共享卷,从而创建一个共享的数据空间。我们的服务将使用这个卷作为共享文件系统,希望使用它的容器可以添加一个挂载路径——我们马上就会看到它是如何工作的。有关卷的更多信息,请访问:kubernetes.io/docs/concepts/storage/volumes/

首先,进入/rerouter文件夹并构建 docker 镜像:docker build -t rerouter:v1 .。请注意,在上面的 Pod 清单中列出了这个镜像:

image: rerouter:v1

这个容器的nameservice-rerouter,它提供了一个处理两个路由的 Express 服务器:

  1. 当调用根路由(/)时,它将在/public目录中查找一个index.html文件。

  2. 当调用/rerouter时,它将把用户重定向到这个 Pod 中的另一个服务,即监听端口8086的服务:

const express = require('express');
const port = 8087;
const app = express();

app.use(express.static('public'));

app.get('/rerouter', (req, res) => {
    res.redirect('http://localhost:8086/oneroute');
});

app.listen(port, () => console.log(`Running on http://localhost:${port}`)); 

如果您查看service-rerouter的声明,您会看到它已经挂载到路径/app/public上的共享卷。此 Pod 中的任何容器现在都可以写入共享卷,它写入的内容将最终出现在此容器的/public文件夹中(可用作提供静态文件)。我们创建了一个容器服务,就是这样:

- name: service-os
    image: debian
    volumeMounts:
    - name: shared-data
      mountPath: /pod-data
    command: ["/bin/sh"]
    args: ["-c", "echo Another service wrote this! > /pod-data/index.html"]

service-os容器将包含 Debian 操作系统,并将共享卷挂载到路径/pod-data。现在,任何写入文件系统的操作实际上都将写入此共享卷。使用系统 shell(/bin/sh),当此容器启动时,它将向共享卷echo一个index.html文件,其中包含“另一个服务写了这个!”的内容。由于此容器在回显后没有其他事情要做,它将终止。因此,我们将重启策略设置为仅在失败时重启 - 我们不希望此容器不断重启。添加终止的“辅助”服务,这些服务有助于构建 Pod 容器,然后退出的模式对于 Kubernetes 部署是常见的。

请记住,service-rerouter还声明了它的卷挂载shared-data在路径/app/public上,service-os生成的index.html文件现在将出现在该文件夹中,可用于提供服务:

- name: service-rerouter
  image: rerouter:v1
  volumeMounts:
  - name: shared-data
    mountPath: /app/public

继续并为/responder文件夹中的应用程序构建 docker 镜像,就像您为/rerouter一样。service-responder容器解析单个路由/oneroute,返回一个简单的消息:

const express = require('express');
const port = 8086;
const app = express();
app.get('/oneroute', (req, res) => {
    res.send('\nThe routing worked!\n\n');
});
app.listen(port, () => console.log(`Running on http://localhost:${port}`));

此容器将用于演示service-rerouter如何跨(共享的)localhost重定向 Kubernetes 为此 Pod 设置的 HTTP 请求。由于service-responder绑定在端口8086上,service-rerouter(在端口8087上运行)可以通过 localhost 路由到它:

// rerouter/app/index.js
res.redirect('http://localhost:8086/oneroute');

因此,我们已经展示了 Pod 内的容器如何共享共同的网络和数据卷。假设您已成功构建了rerouter:v1responder:v1的 Docker 镜像,请使用以下命令执行 Pod 清单:

kubectl create -f three-containers.yaml

您应该看到创建的 Pod“three-containers”。使用minikube dashboard打开仪表板。您应该看到 three-containers Pod:

单击 three-containers 以显示描述:

很好,一切都在运行。现在,让我们通过连接到我们的容器来验证一切是否正常。

获取service-router的 shell:

kubectl exec -it three-containers -c service-rerouter -- /bin/bash

安装 curl:

apt-get install curl

您的工作目录应该有一个/public文件夹。里面应该有一个index.html文件,由service-os容器创建。获取该文件的内容:cat public/index.html。如果一切正常,您应该看到消息“另一个服务写了这个!”,您会记得这是service-os服务创建的,通过共享卷 - public/index.html文件,service-rerouter将提供服务。

现在,让我们调用/rerouter路由,它应该重定向到localhost:8086/oneroute/上的service-responder服务器,并接收其响应“路由服务正常工作!”:

curl -L http://localhost:8087/rerouter

这演示了同一 Pod 中的容器如何通过本地主机跨端口范围进行通信,就像它们所包含的 Pod 是单个主机一样。

Mesos 是编排的另一个选择(mesos.apache.org/),CoreOS 也是:coreos.com/

这只是 Docker 和 Kubernetes 如何部署以简化扩展的表面。特别是在微服务架构上。您可以通过声明性清单进一步编排整个舰队的服务和部署。例如,很容易看出我们之前设计的 Seneca 微服务如何适应 Pods。现在您可以抽象出个别服务器的实现细节,并开始以声明方式思考,简单地描述您所期望的部署拓扑(副本、卷、恢复行为等),并让 Kubernetes 将其变为现实,这比命令式地微观管理成千上万的服务要好得多。

摘要

在本章中,我们深入研究了各种架构模式,从单体到 4 层。在这个过程中,我们开始考虑如何从微服务构建高度动态的应用程序,探索它们在可扩展性、灵活性和可维护性方面的一般优势。我们看了看微服务的 Seneca 框架,其基于模式的执行模型易于构建和遵循,特别是在与自动发现的网格服务的优势相结合时。跳入完全分布式模式,我们使用 Claudia 部署了无服务器 Lambda 函数,使用 API-Gateway 将 RESTful API 推送到 AWS 云中,始终可用并且成本低廉地实现了几乎无限的扩展。通过 Docker 和 Kubernetes(在 Minikube 的帮助下),我们深入探讨了如何构建独立虚拟机的集群,声明和部署容器的 Pods,以满足需求。

在本书的下一章,我们将学习软件开发人员可能最重要的技能:如何测试和调试您的代码。现在我们已经学会将应用程序的逻辑部分分离成独立的部分,我们可以开始探索这种设计在测试方面的优势,无论是在抽象的测试工具中还是在实际的代码情况中。

第十章:测试您的应用程序

“当地形与地图不符时,请相信地形。”

  • 瑞士军刀手册

由于 Node 是由一个完全致力于代码共享的社区构建的,模块之间的互操作性非常重要,因此毫不奇怪的是,在 Node 的生态系统中,代码测试工具和框架在创立后不久就进入了。事实上,通常吝啬的核心 Node 团队很早就添加了assert模块,这表明他们认识到测试是开发过程的基本部分。

测试不仅仅是一个检测错误和修复缺陷的过程。例如,测试驱动开发坚持在任何代码存在之前进行测试!一般来说,测试是在软件中对现有行为和期望行为进行比较的过程,其中新信息不断地反馈到过程中。在这个意义上,测试涉及对期望进行建模,并验证单个功能、组成单元和实现路径是否满足每个利益相关者的期望,无论是在组织内部还是超出组织范围。

因此,测试也是关于管理风险。通过这种方式,异常可以被识别和量化,而地形中的颠簸现在可以有用地影响我们对地图的当前理解,从而缺陷的数量减少,我们的信心提高。测试帮助我们衡量何时完成。

在本章中,我们将专注于一些已知和有用的测试 Node 应用程序的模式,调查用于代码完整性测试的原生 Node 工具,使用 Mocha 框架进行一般测试,以及无头浏览器测试,最后一种允许在 Node 环境中测试基于浏览器的 JavaScript。我们还将看看测试的另一面——调试——并将两者结合起来。

当您阅读本章时,牢记将测试哲学融入项目可能很难做到。编写正确的测试比编写一些测试更困难。测试正确的事情比测试所有事情更困难(完整的代码覆盖很少意味着什么都不会出错)。一个好的测试策略应该尽早实施——这是您开始下一个 Node 项目时需要考虑的事情。

为什么测试很重要

一个好的测试策略通过积累证据和增加清晰度来建立信心。在公司内部,这可能意味着某些执行业务策略的标准已经得到满足,从而允许发布新的服务或产品。项目团队内的开发人员获得了一个自动的法官,确认或否认提交到代码库的更改是否合理。有了一个良好的测试框架,重构就不再危险;曾经对具有新想法的开发人员施加负面压力的“如果你破坏了它,你就拥有它”的警告不再那么可怕。有了一个良好的版本控制系统和测试/发布流程,任何破坏性的更改都可以在没有负面影响的情况下回滚,释放好奇心和实验精神。

三种常见的测试类型是:单元测试、功能测试和集成测试。虽然我们在本章的目标不是提出关于如何测试应用程序的一般理论,但简要总结单元测试、功能测试和集成测试是有用的,团队的哪些成员对每种测试最感兴趣,以及我们如何构建(或拆分)一个可测试的代码库。

单元测试

单元测试关注系统行为的单元。每个被测试的单元应该封装一个非常小的代码路径集,没有纠缠。当一个单元测试失败时,这应该理想地表明整体功能的一个孤立部分出现了问题。如果一个程序有一组明确定义的单元测试,整个程序的目的和预期行为应该很容易理解。单元测试对系统的小部分应用了有限的视角,不关心这些部分如何被包装成更大的功能块。

一个示例单元测试可以这样描述:当123值传递给“validate_phone_number()”方法时,测试应该返回 false。对于这个单元的功能没有困惑,程序员可以放心使用它。

单元测试通常由程序员编写和阅读。类方法是良好的单元测试候选者,其他服务端点的输入签名稳定且被充分理解,预期输出可以被准确验证。通常假定单元测试运行速度快。如果一个单元测试执行时间很长,很可能是被测试的代码比应该的复杂。

单元测试不关心函数或方法将如何接收其输入,或者它将如何在一般情况下被使用。对于add方法的测试不应该关心该方法是否将被用于计算器或其他地方,它应该简单地测试两个整数输入(3,4)是否会导致该单元产生正确的结果(7)。单元测试不关心它在依赖树中的位置。因此,单元测试通常会模拟存根数据源,例如将两个示例整数传递给add方法。只要输入是典型的,它们不必是实际的。此外,良好的单元测试是可靠的:不受外部依赖的影响,它们应该保持有效,无论周围的系统如何变化。

单元测试只确认单个实体在隔离状态下工作。测试单元能否在组合时良好工作是功能测试的目的。

功能测试

在单元测试关注特定行为的同时,功能测试旨在验证功能的各个部分。根词function的模棱两可,特别是对程序员来说,可能会导致混淆,即单元测试被称为功能测试,反之亦然。功能测试将许多单元组合成一个功能体,例如当用户输入用户名和密码并点击发送时,该用户将被登录到系统。我们很容易看到这个功能组将包括许多单元测试,一个用于验证用户名,一个用于处理按钮点击,等等。

功能测试通常是应用程序中某个特定领域的负责人关心的事情。虽然程序员和开发人员将继续实施这些测试,但产品经理或类似的利益相关者通常会设计它们(并在它们失败时抱怨)。这些测试在很大程度上检查较大的产品规格是否得到满足,而不是技术上的正确性。

前面给出的validate_phone_number的示例单元测试可能构成一个功能测试的一部分,描述如下:当用户输入错误的电话号码时,在该用户的国家显示一个描述正确格式的帮助消息。一个应用程序会帮助那些在电话号码上犯错误的用户,这是一个非常抽象的努力,与简单验证电话号码这样的技术实体完全不同。功能测试可以被认为是一些单元的抽象模型,它们如何一起满足产品需求。

由于功能测试是针对许多单元的组合进行的,因此可以预期,与孤立的单元测试不同,执行它们将涉及混合来自任意数量的外部对象或系统的关注点。在前面的登录示例中,我们看到一个相对简单的功能测试如何涉及数据库、UI、安全性和其他应用层。由于它的组合更复杂,功能测试花费的时间比单元测试多一点是可以接受的。功能测试预计变化不如单元测试频繁,因此功能的变化通常代表主要发布,而不是通常表示较小变化的单元测试修改。

请注意,与单元测试一样,功能测试本身与功能组在整个应用程序中的关系无关。因此,可以使用模拟数据作为运行功能测试的上下文,因为功能组本身不关心其对一般应用程序状态的影响,这是集成测试的领域。

集成测试

集成测试确保整个系统正确连接在一起,以便用户感觉应用程序正常工作。因此,集成测试通常验证整个应用程序的预期功能,或者验证一组重要产品功能中的一个。

集成测试与讨论中的其他测试类型最重要的区别在于,集成测试应在真实环境中执行,使用真实数据库和实际域数据,在服务器和其他系统上模拟目标生产环境。这样,集成测试很容易破坏以前通过的单元和功能测试。

例如,对于validate_phone_number的单元测试可能会通过像555-123-4567这样的输入,但在集成测试中,它将无法通过一些真实(且有效)的系统数据,比如555.123.4567。同样,功能测试可能成功测试理想系统打开帮助对话框的能力,但当与新的浏览器或其他运行时集成时,发现无法实现预期的功能。一个在单个本地文件系统上运行良好的应用程序,在分布式文件系统上运行时可能会失败。

由于增加了这种复杂性,系统架构师——能够对系统正确性应用更高层次的视角的团队成员——通常设计集成测试。这些测试可以发现孤立测试无法识别的连接错误。毫不奇怪,集成测试通常需要很长时间才能运行,通常设计为不仅运行简单场景,而且模拟预期的高负载、真实环境。

本地节点测试和调试工具

自从诞生以来,对经过测试的代码的偏好一直是 Node 社区理念的一部分,这反映在大多数流行的 Node 模块,甚至简单的模块,都附带了测试套件。而在许多年里,没有可用的测试工具,JavaScript 在浏览器端的开发一直备受困扰,而相对年轻的 Node 分发包含了许多测试工具。也许正因为如此,为 Node 开发了许多成熟且易于使用的第三方测试框架。这使得开发人员没有借口编写未经测试的代码!让我们来看看一些用于调试和测试 Node 程序的提供的工具。

写入控制台

控制台输出是最基本的测试和调试工具,提供了一个快速查看脚本中发生情况的方式。全局可访问的console.log通常用于调试。

Node 已经丰富了标准输出机制,增加了更多有用的方法,比如console.error(String, String…),它将参数打印到stderr而不是stdout,以及console.dir(Object),它在提供的对象上运行util.inspect(参见下文)并将结果写入stdout

当开发人员想要跟踪代码执行所需时间时,通常会看到以下模式:

let start = new Date().getTime();
for (x = 0; x < 1000; x++) {
  measureTheSpeedOfThisFunction();
}
console.log(new Date().getTime() - start);
// A time, in milliseconds 

console.timeconsole.timeEnd方法标准化了这种模式:

 console.time('Add 1000000 records');
 let rec = [];
 for (let i = 0; i < 1000000; i++) {
     rec.push(1);
 }
 console.timeEnd('Add 1000000 records');
 //  > Add 1000000 records: 59ms

确保将相同的标签传递给timeEnd(),以便 Node 可以找到您使用time()开始的测量。Node 将秒表结果打印到stdout。在本章后面讨论断言模块和执行堆栈跟踪时,我们将看到其他特殊的控制台方法。

格式化控制台输出

在记录简单字符串时,上述方法都非常有用。更常见的是,有用的日志数据可能需要进行格式化,可以通过将几个值组合成单个字符串,或者通过整齐地显示复杂的数据对象来处理。util.formatutil.inspect方法可以用来处理这些情况。

util.format(format,[arg,arg…])方法

此方法允许将格式化字符串组成占位符,每个占位符都捕获并显示传递的附加值。考虑以下示例:

> util.format('%s:%s', 'foo','bar')
 'foo:bar' 

在这里,我们看到两个占位符(以为前缀)按顺序被传递的参数替换。占位符期望以下三种类型的值之一:

  • %s:字符串

  • %d:数字,可以是整数或浮点数

  • %j:JSON 对象

如果发送的参数数量多于占位符数量,则额外的参数将通过util.inspect()转换为字符串,并连接到输出的末尾,用空格分隔:

> util.format('%s:%s', 'foo', 'bar', 'baz');
 'foo:bar baz' 

如果没有发送格式化字符串,则参数将被简单地转换为字符串并用空格分隔连接。

util.inspect(object,[options])方法

当需要对象的字符串表示时,请使用此方法。通过设置各种选项,可以控制输出的外观:

  • showHidden:默认为 false。如果为 true,则会显示对象的不可枚举属性。

  • depth:对象定义(例如 JSON 对象)可以被深度嵌套。默认情况下,util.inspect只会遍历对象的两个级别。使用此选项来增加(或减少)深度。

  • colors:允许对输出进行着色(请查看以下代码片段)。

  • customInspect:如果正在处理的对象定义了inspect方法,则将使用该方法的输出,而不是 Node 的默认stringification方法(参见以下代码片段)。默认为 true。

设置自定义检查器:

const util = require('util');
let obj = function() {
   this.foo = 'bar';
};
obj.prototype.inspect = function() {
   return "CUSTOM INSPECTOR";
};
console.log(util.inspect(new obj));
// CUSTOM INSPECTOR
console.log(util.inspect(new obj, { customInspect: false }));
// { foo: 'bar' }

当记录复杂对象或对象的值过大以至于使控制台输出无法阅读时,这可能非常有用。如果您的 shell 在终端中显示漂亮的颜色,如果颜色设置为 true,util.inspect也会显示漂亮的颜色。您甚至可以自定义颜色以及它们的使用方式。默认情况下,颜色只表示数据类型。

以下是默认设置,如在util.inspect.styles中设置的:

{
   number: 'yellow',
   boolean: 'yellow',
   string: 'green',
   date: 'magenta',
   regexp: 'red'
   null: 'bold',
   undefined: 'grey',
   special: 'cyan',
 } 

在上述代码中,Node 以青色显示特殊类别中的函数。这些默认颜色分配可以与util.inspect.colors对象中存储的支持的 ANSI 颜色代码之一进行交换:粗体,斜体,下划线,反向,白色,灰色,黑色,蓝色,青色,绿色,品红色,红色和黄色。例如,要将对象的数字值显示为绿色而不是默认的黄色,请使用以下代码:

 util.inspect.styles.number = "green";
 console.log(util.inspect([1,2,4,5,6], {colors: true}));
 // [1,2,3,4,5,6] Numbers are in green

Node 调试器

大多数开发人员都使用 IDE 进行开发。所有良好的开发环境的一个关键特性是可以访问调试器,它允许在程序中设置断点,以便在需要检查状态或运行时的其他方面的地方进行检查。

V8 带有一个强大的调试器(通常用于 Google Chrome 浏览器的开发者工具面板),并且此调试器可供 Node 访问。它是使用 inspect 指令调用的:

> node inspect somescript.js 

现在可以在节点程序中实现简单的逐步调试和检查。考虑以下程序:

// debug-sample.js
setTimeout(() => {
  let dummyVar = 123;
  debugger;
  console.log('world');
}, 1000);
console.log('hello'); 

dummyVar一会儿就会有意义。现在注意debugger指令。在没有该行的情况下执行此程序会像您期望的那样运行:打印hello,等待一秒,然后打印world。有了调试器指令,运行 inspect 会产生这样的结果:

> node inspect debug-sample.js
< Debugger listening on ws://127.0.0.1:9229/b3f76643-9464-41d0-943a-d4102450467e
< For help see https://nodejs.org/en/docs/inspector
< Debugger attached.
Break on start in debug-sample.js:1
> 1 (function (exports, require, module, __filename, __dirname) { setTimeout(() => {
 2 let dummyVar = 123;
 3 debugger;
debug>

调试器指令创建一个断点,一旦命中,Node 会给我们一个 CLI 来执行一些标准的调试命令:

  • contc:从上一个断点继续执行,直到下一个断点

  • steps:步进,即继续运行直到命中新的源行(或断点),然后将控制返回给调试器

  • nextn:与step相同,但在新的源行上进行的函数调用会在不停止的情况下执行

  • outo:跳出,即执行当前函数的其余部分并返回到父函数

  • backtracebt:跟踪到当前执行帧的步骤

  • setBreakpoint()sb():在当前行设置断点

  • setBreakpoint(Integer)sb(Integer):在指定行设置断点

在指定的行

  • clearBreakpoint()cb():清除当前行的断点

  • clearBreakpoint(Integer)cb(Integer):清除断点

在指定的行

  • run:如果调试器的脚本已终止,这将重新启动它

  • restart:终止并重新启动脚本

  • pausep:暂停运行的代码

  • kill:终止正在运行的脚本

  • quit:退出调试器

  • version:显示 V8 版本

  • scripts:列出所有加载的脚本

重复上次的调试器命令,只需在键盘上按Enter。你的腕管道会感谢你。

回到我们正在调试的脚本:在调试器中输入cont将产生以下输出:

...
debug> cont
< hello // A pause will now occur because of setTimeout
break in debug-sample.js:3
 1 (function (exports, require, module, __filename, __dirname) { setTimeout(() => {
 2 let dummyVar = 123;
> 3 debugger;
 4 console.log('world');
 5 }, 1000);
debug>

现在我们停在第 3 行的调试器语句处(注意尖括号)。例如,如果现在输入next(或n),调试器将跳到下一条指令并停在console.log('world')处。

在断点处通常有用进行一些状态检查,比如变量的值。您可以从调试器中跳转到repl以执行此操作。目前,我们在debugger语句处暂停。如果我们想要检查dummyVar的值怎么办?

debug> repl
Press Ctrl + C to leave debug repl
> dummyVar
123

作为一个实验,再次运行脚本,使用next而不是cont,在最后一个上下文执行之前。不断按 Enter(重复上次的命令),尝试跟踪正在执行的代码。几步之后,您会注意到timers.js脚本将被引入到这个执行上下文中,并且您会看到类似以下的内容:

debug> next
break in timers.js:307
 305 threw = false;
 306 } finally {
>307 if (timerAsyncId !== null) {
 308 if (!threw)
 309 emitAfter(timerAsyncId);
debug>

在这一点上在调试器中运行scripts命令,列出当前加载的脚本。您会看到类似这样的内容:

debug> scripts
* 39: timers.js <native>
71: debug-sample.js

尝试使用强大的 V8 调试器来暂停、检查和在 Node 程序中进行导航的各种方法。除了常见的调试需求外,调试器在执行代码时以深层次显示 Node 的操作非常出色。

在本章的后面,我们将回顾其他可用于 Node 开发人员的调试和测试技术和工具。现在,让我们考虑assert模块,以及如何使用 Node 提供的这个本地测试框架。

assert 模块

Node 的assert模块用于简单的单元测试。在许多情况下,它足以作为测试的基本脚手架,或者用作测试框架(如 Mocha,稍后我们将看到)的断言库。使用起来很简单:我们想要断言某些事情的真实性,并在我们的断言不为真时抛出错误。考虑这个例子:

> require('assert').equal(1,2,'Not equal!')
AssertionError [ERR_ASSERTION]: Not equal!
>

如果断言为真(两个值相等),则不会返回任何内容:

> require('assert').equal(1,1,"Not equal!")
undefined

遵循 UNIX 的沉默规则(当程序没有令人惊讶、有趣或有用的内容时,它应该保持沉默),断言只有在断言失败时才返回一个值。返回的值可以使用可选的消息参数进行自定义,就像前面的部分所示的那样。

assert模块 API 由一组具有相同调用签名的比较操作组成:实际值,期望值和可选消息(在比较失败时显示)。还提供了作为快捷方式或处理特殊情况的替代方法。

必须区分身份比较(===)和相等比较(==),前者通常被称为严格相等比较(就像在assertAPI 中一样)。由于 JavaScript 采用动态类型,当使用==相等运算符比较不同类型的两个值时,会尝试强制(或转换)一个值为另一个值,一种通用的操作。看看这个例子:

1 == "1" // true
false == "0" // true
false == null // false

请注意,使用身份比较时结果更可预测:

1 === "1" // false
false === "0" // false
false === null // false

要记住的是,在比较之前,===运算符不执行类型强制转换,而相等运算符在类型强制转换后进行比较。

将字符串和数字相等使 JavaScript 成为新手编程的宽容语言,并且很快,创建了一个错误,现在更有经验的程序员无意中隐藏在更大的代码库中。像Brendan Eich这样的语言作者做出这样的决定,并且很少能够在以后改变如此基本的行为,他们必须通过无休止的争论和争议来捍卫他们的决定,因为程序员们因此而抨击和赞扬他们的语言。

此外,因为对象可能包含相同的值但不是由相同的构造函数派生,因此具有相同值的两个对象的身份是不同的;身份要求两个操作数引用同一对象:

let a = function(){};
let b = new a;
let c = new a;
let d = b;
console.log(a == function(){}) // false
console.log(b == c) // false
console.log(b == d) // true
console.log(b.constructor === c.constructor); // true

最后,深度相等的概念用于对象比较,其中身份不需要完全相同。如果两个对象都拥有相同数量的自有属性,相同的原型,相同的键集(尽管不一定是相同的顺序),并且每个属性的值是等效的(而不是相同的),则两个对象是深度相等的:

const assert = require('assert');
let a = [1,2,3];
let b = [1,2,3];
assert.deepEqual(a, b); // passes, so nothing is output
assert.strictEqual(a, b); // throws Assertion error

通过设计一些断言测试来测试您对值如何相互理解的假设是很有用的。结果可能会让您感到惊讶。

以下是 Node 的 assert 模块中的函数,根据您可能使用它们的方式进行组织:

equal            notEqual
strictEqual      notStrictEqual
deepEqual        notDeepEqual
deepStrictEqual  notDeepStrictEqual
ok
ifError
fail
throws           doesNotThrow

使用带有相等名称的断言函数遵循与==运算符相同的规则,而严格相等就像使用===一样。此外,选择一个标题中带有深度的函数,或者不带,以选择我们之前探索的所需行为。最简单的函数assert.ok,如果您自己编写逻辑来等同,可能就是您所需要的全部。

Node 的异步函数将错误对象返回给您的回调函数。将此对象传递给assert.ifError(e),如果e被定义,ifError将抛出它。当执行已经到达代码中不应该执行的部分时,使用assert.fail()是最有用的。当异常被try/catch块捕获时,这是最有用的:

// assertthrows.js
const assert = require('assert');
try {
   assert.fail(1,2,'Bad!','NOT EQ') 
} catch(e) { 
   console.log(e);
}

运行上述代码会产生以下输出:

{ AssertionError [ERR_ASSERTION]: Bad!
 at Object.<anonymous> (/Users/sandro/Desktop/clients/ME/writing/Mastering_V2/chapter_ten/code/assertthrows.js:4:9)
 at Module._compile (module.js:660:30)
 ...
 at bootstrap_node.js:618:3
   generatedMessage: false,
 name: 'AssertionError [ERR_ASSERTION]',
 code: 'ERR_ASSERTION',
 actual: 1,
 expected: 2,
 operator: 'NOT EQ' }

控制台 API 中提供了用于记录断言结果的快捷方法:

> repl
> console.assert(1 == 2, 'Nope!')
AssertionError [ERR_ASSERTION]: Nope!

或者,您可以使用assert.throwsassert.doesNotThrow确认函数始终抛出或从不抛出。

有关 JavaScript 中比较的详细解释,请参阅:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Comparison_Operators Node 的 assert 模块受 CommonJS 测试规范的强烈影响,该规范可以在以下网址找到: wiki.commonjs.org/wiki/Unit_Testing.

沙箱

在某些情况下,您可能希望在一个单独且更有限的上下文中运行脚本,使其与较大应用程序的范围隔离开来。对于这些情况,Node 提供了vm模块,一个沙盒环境,包括一个新的 V8 实例和一个用于运行脚本块的有限执行上下文:

const vm = require('vm');
let sandbox = {
    count: 2
};
let suspectCode = '++count;';
vm.runInNewContext(suspectCode, sandbox);
console.log(sandbox);
// { count: 3 }

在这里,我们看到提供的沙盒成为提供的脚本的局部执行范围。运行的脚本只能在提供的沙盒对象中操作,并且甚至被拒绝访问标准的 Node 全局对象,例如正在运行的进程,我们可以通过更改前面的代码来进行演示:

suspectCode = '++count; process.exit()';
vm.runInNewContext(suspectCode, sandbox);

// evalmachine.<anonymous>:1
// ++count; process.exit()
//          ^
//
// ReferenceError: process is not defined
// at evalmachine.<anonymous>:1:10
// at ContextifyScript.Script.runInContext (vm.js:59:29)
// ...

该模块不能保证完全安全的监狱,以便可以安全地执行完全不受信任的代码。如果有这种需求,请考虑以适当的系统级权限运行一个单独的进程。由于vm会启动一个新的 V8 实例,每次调用都会耗费一些毫秒的启动时间和大约两兆字节的内存。只有在值得这种性能成本的情况下才使用vm

为了测试代码,vm模块可以非常有效,特别是在强制代码在有限上下文中运行的能力方面。例如,在执行单元测试时,可以创建一个特殊的环境,并使用模拟环境中的模拟数据来测试脚本。这比创建一个带有虚假数据的人工调用上下文要好。此外,这种沙盒化将允许更好地控制新代码的执行上下文,提供良好的内存泄漏保护和其他在测试过程中可能出现的意外冲突。

区分局部范围和执行上下文

在进一步介绍示例之前,我们需要区分进程的局部范围和其执行上下文。这种区分有助于理解两个主要vm方法之间的区别:vm.runInThisContextvm.runInNewContext

在任何时候,V8 可能有一个或更可能是几个执行上下文。这些上下文充当单独的容器,V8 可以在其中执行一些更多的 JavaScript。在使用 Chrome 时,您可以将这些执行上下文视为导航到不同网站的不同标签页。

一个站点上的脚本无法看到或干扰另一个站点上的脚本。Node 进程的执行上下文代表 V8 中的运行时上下文,包括本地 Node 方法和其他全局对象(process、console、setTimeout 等)。

通过vm.runInNewContext执行的脚本无法访问任何范围;它的上下文限制在之前传递给它的沙盒对象中。

通过vm.runInThisContext执行的脚本可以访问 Node 进程的全局执行范围,但无法访问局部范围。我们可以通过以下方式进行演示:

const vm = require('vm');

global.x = 1; // global scope
let y = 1; // local scope

vm.runInThisContext('x = 2; y = 3');
console.log(x, y); // 2, 1 <- only global is changed

eval('x = 3; y = 4');
console.log(x, y); // 3, 4 <- eval changes x, y

因此,脚本是通过vm在上下文中运行的。

预编译上下文和脚本通常很有用,特别是当每个都将被重复使用时。使用vm.createContext([sandbox])来编译一个执行上下文,并传入一个键/值映射。在下一节中,我们将看看如何将这些上下文应用于预编译的脚本。

使用编译上下文

收到 JavaScript 代码字符串后,V8 编译器将尽力将代码优化为更高效的编译版本。每次vm上下文方法接收代码字符串时,都必须进行这个编译步骤。如果您的代码不会改变并且至少被重用一次,最好使用new vm.Script(code, [filename])来编译它一次。

我们可以在从runInThisContextrunInNewContext继承的上下文中执行这些编译后的脚本。在这里,我们在两个上下文中运行编译后的脚本,演示了xy变量被递增存在于完全隔离的范围中:

const vm = require('vm');

global.x = 0;
global.y = 0;

let script = new vm.Script('++x, ++y;');
let emulation = vm.createContext({ x:0, y:0 });

for (let i = 0; i < 1000; i++) {
   script.runInThisContext(); // using global
   script.runInNewContext(emulation); // using own context
}

console.log(x, y); // 1000 1000
console.log(emulation.x, emulation.y); // 1000 1000

如果两个脚本都修改了相同的上下文中的xy,输出将会是2000 2000

请注意,如果runInNewContext脚本没有传递仿真层(沙盒),它将抛出ReferenceError: x is not defined,既不能访问本地变量也不能访问全局变量xy的值。试一下。

现在我们已经了解了断言和创建测试上下文的一些内容,让我们使用一些常见的测试框架和工具编写一些真正的测试。

使用 Mocha、Chai 和 Sinon 进行测试

为您的代码编写测试的一个巨大好处是,您将被迫思考您编写的代码是如何工作的。难以编写的测试可能表明难以理解的代码。

另一方面,通过良好的测试实现全面覆盖,有助于他人(和您)了解应用程序的工作原理。在本节中,我们将看看如何使用测试运行器Mocha来描述您的测试,使用Chai作为其断言库,并在需要对测试进行模拟时使用Sinon。我们将使用redis来演示如何针对模拟数据集创建测试(而不是针对生产数据库进行测试,这当然是一个坏主意)。我们将使用npm作为测试脚本运行器。

首先,设置以下文件夹结构:

/testing

/scripts

/spec

现在,在/testing文件夹中使用npm init初始化一个package.json文件。您可以在提示时只需按Enter,但当要求测试命令时,请输入以下内容:

mocha ./spec --require ./spec/helpers/chai.js --reporter spec

这为我们的项目设置了我们将需要的模块的导入。稍后我们将讨论 Chai 的作用。现在,可以说在这个测试命令中,Mocha 被引用为依赖信息的配置文件。

继续安装所需的库到这个包中:

npm install --save-dev mocha chai sinon redis

/scripts文件夹将包含我们将要测试的 JavaScript。/spec文件夹将包含配置和测试文件。

随着我们的进展,这将变得更有意义。现在,要认识到对 npm 的test属性的分配表明我们将使用 Mocha 进行测试,Mocha 的测试报告将是spec类型,并且测试将存在于/spec目录中。我们还需要一个 Chai 的配置文件,这将在我们继续进行时进行解释。重要的是,这现在已经在 npm 中创建了一个脚本声明,允许您使用npm test命令运行测试套件。在接下来的开发中,每当需要运行我们将要开发的 Mocha 测试时,请使用该命令。

Mocha

Mocha 是一个测试运行器,不关心测试断言本身。Mocha 用于组织和运行您的测试,主要通过使用describeit操作符。概括地说,Mocha 测试看起来像这样:

describe("Test of Utility Class", function() {
  it("should return a date", function(){
   // Test date function somehow and assert success or failure
  });
  it("should return JSON", function() {
   // Test running some string through #parse 
  });
});

正如您所看到的,Mocha 测试套件留下了测试如何描述和组织的空间,并且不假设测试断言的设计方式。它是您测试的组织工具,另外还旨在生成可读的测试定义。

您可以设置同步运行的测试,如前面所述,也可以使用传递给所有回调的完成处理程序异步运行:

describe("An asynchronous test", () => { 
  it("Runs an async function", done => { 
    // Run async test, and when finished call... done(); 
  }); 
}); 

块也可以嵌套:

describe("Main block", () => { 
  describe("Sub block", () => { 
    it("Runs an async function", () => { 
      // A test running in sub block 
    }); 
  }); 
  it("Runs an async function", () => { 
    // A test running in main block 
  }); 
});

最后,Mocha 提供了hooks,使您能够在测试之前和/或之后运行一些代码:

  • beforeEach()在描述块中的每个测试之前运行

  • afterEach()在描述块中的每个测试之后运行

  • before()在任何测试之前运行一次代码-在任何beforeEach运行之前

  • after()在所有测试运行后运行一次代码-在任何afterEach运行之后

通常,这些用于设置测试上下文,例如在测试之前创建一些变量并在其他一些测试之前清理它们。这个简单的工具集足够表达大多数测试需求。此外,Mocha 提供了各种测试报告程序,提供不同格式的结果。随着我们构建一些真实的测试场景,我们将在后面看到这些。

Chai

正如我们之前在 Node 的原生断言模块中看到的,基本上,测试涉及断言我们期望某些代码块执行的内容,执行该代码,并检查我们的期望是否得到满足。Chai 是一个断言库,提供了更具表现力的语法,提供了三种断言样式:expectshouldassert。我们将使用 Chai 来提供断言(测试),并将其包装在 Mocha 的it语句中,更青睐expect样式的断言。

请注意,虽然Chai.assert是模仿核心 Node 断言语法的,但 Chai 通过附加方法来增强对象。

首先,我们将创建一个配置文件chai.js

let 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断言样式公开为全局。同样,Sinon 也被公开为全局(更多关于 Sinon 的内容将在下一节中介绍)。这个文件将增强 Mocha 测试运行上下文,以便我们可以在每个测试文件中使用这些工具而不必重新声明它们。expect样式的断言读起来像一个句子,由tobeis等单词组成。考虑以下例子:

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

要探索在创建期望测试链时可用的广泛单词列表,请查阅完整文档:chaijs.com/api/bdd/。正如前面所述,Mocha 对于如何创建断言并没有意见。我们将在接下来的测试中使用expect来创建断言。

考虑测试以下对象中的 capitalize 函数:

let Capitalizer = () => {
  this.capitalize = str => { 
    return str.split('').map(char => { 
      return char.toUpperCase(); 
    }).join(''); 
  }; 
};

我们可能会这样做:

describe('Testing Capitalization', () => { 
  let capitalizer = new Capitalizer(); 
  it('capitalizes a string', () => {
    let result = capitalizer.capitalize('foobar'); 
    expect(result).to.be.a('string').and.equal('FOOBAR'); 
  }); 
});

这个 Chai 断言将是真的,Mocha 也会报告相同的结果。您将用这些描述和断言块构建整个测试套件。

接下来,我们将看看如何将 Sinon 添加到我们的测试过程中。

Sinon

在测试环境中,您通常在模拟生产环境的现实情况,因为访问真实用户、数据或其他实时系统是不安全或不可取的。因此,能够模拟环境是测试的一个重要部分。此外,您通常希望检查的不仅仅是调用结果;您可能还想测试给定函数是否在正确的上下文中被调用或使用正确的示例。Sinon 是一个帮助您模拟外部服务、模拟函数、跟踪函数调用等的工具。

sinon-chai 模块在github.com/domenic/sinon-chai上扩展了 Chai 的 Sinon 断言。

关键的 Sinon 技术是间谍、存根和模拟。此外,您可以设置虚假计时器,创建虚假服务器等等(访问:sinonjs.org/)。本节重点介绍前三者。让我们看看每个的一些例子。

间谍

来自 Sinon 文档:

“测试间谍是一个记录其所有调用的参数、返回值、this 的值和抛出的异常(如果有的话)的函数。测试间谍可以是一个匿名函数,也可以包装一个现有函数。”

间谍收集了它正在跟踪的函数的信息。看看这个例子:

const sinon = require('sinon'); 

let argA = "foo"; 
let argB = "bar"; 
let 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 的发布/订阅功能:

const redis = require("redis"); 
const client1 = redis.createClient(); 
const 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', () => { 
    client1.subscribe("channel");
    expect(client1.subscribe.calledOnce); 
  }); 
  it('tests that #nowPublish works', done => { 
    let callback = sinon.spy(); 
    client1.subscribe('channel', callback); 
    client1.on('subscribe', () => { 
      nowPublish('channel', 'message'); 
        expect(callback.calledWith('message')); 
        expect(client1.subscribe.calledTwice); 
        done(); 
    }); 
  }); 
});

在这个例子中,我们在 spy 和 Mocha 中做了更多。 我们使用 spy 代理 client1 的原生 subscribe 方法,重要的是在 Mocha 的 before 和 after 方法中设置和拆卸 spy 代理(恢复原始功能)。 Chai 断言证明subscribenowPublish都正常运行,并且接收到正确的参数。 有关间谍的更多信息可以在以下网址找到:sinonjs.org/releases/v4.1.2/spies

存根

测试存根是具有预编程行为的函数(间谍)。 它们支持完整的测试间谍 API,以及可用于更改存根行为的方法。 存根在用作间谍时,可以包装现有函数,以便可以伪造该函数的行为(而不仅仅是记录函数执行,就像我们之前在间谍中看到的那样)。

假设您的应用程序中有一些功能会调用一些 HTTP 端点。 代码可能是这样的:

http.get("http://www.example.org", res => { 
  console.log(`Got status: ${res.statusCode}`); 
}).on('error', e => { 
  console.log(`Got error: ${e.message}`); 
});

成功时,调用将记录Got status: 200。 如果端点不可用,您将看到类似Got error: getaddrinfo ENOTFOUND的内容。

您可能需要测试应用程序处理替代状态代码以及明确错误的能力。 您可能无法强制端点发出这些代码,但是如果发生这种情况,您必须为它们做好准备。 存根在这里非常有用,可以创建合成响应,以便可以全面地测试响应处理程序。

我们可以使用存根来模拟响应,而不实际调用http.get方法:

const http = require('http'); 
const sinon = require('sinon'); 

sinon.stub(http, 'get').yields({ 
  statusCode: 404 
}); 

// This URL is never actually called 
http.get("http://www.example.org", res => { 
  console.log(`Got response: ${res.statusCode}`); 
  http.get.restore(); 
})

这个存根通过包装原始方法来产生模拟响应,但实际上从未调用原始方法,导致从通常返回状态代码200的调用中返回404。 重要的是,注意我们在完成后如何restore存根方法到其原始状态。

例如,以下代码描述了一个模块,该模块进行 HTTP 调用,解析响应,并在一切正常时返回'handled',在 HTTP 响应意外时返回'not handled'

const http = require('http'); 
module.exports = function() => { 
  this.makeCall = (url, cb) => { 
    http.get(url, res => { 
      cb(this.parseResponse(res)); 
    }) 
  } 
  this.parseResponse = 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方法可以处理我们需要处理的所有响应代码,使用存根来模拟整个预期的响应范围:

let Caller = require('../scripts/Caller.js'); 
describe('Testing endpoint responses', function() { 
  let caller = new Caller(); 
  function setTestForCode(code) { 
    return done => { 
      sinon.stub(caller, 'makeCall').yields(caller.parseResponse({ 
        statusCode: code 
      })); 
      caller.makeCall('anyURLWillDo', h => { 
        expect(h).to.be.a('string').and.equal('handled'); 
        done(); 
      }); 
    } 
  } 
  afterEach(() => 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/releases/v4.1.2/stubs/

模拟

模拟(和模拟期望)是具有预编程行为(如存根)和预编程期望的虚假方法(如间谍)。 如果未按预期使用模拟,模拟将使您的测试失败。 模拟可以用来检查被测试单元的正确使用,而不是在事后检查期望,它们强制执行实现细节。

在下面的示例中,我们检查特定函数被调用的次数,以及它是否以特定的预期参数被调用。 具体来说,我们再次使用模拟来测试 Utilities 的capitalize方法:

const sinon = require('sinon'); 
let Capitalizer = require('../scripts/Capitalizer.js'); 
let capitalizer = new Capitalizer(); 

let arr = ['a','b','c','d','e']; 
let mock = sinon.mock(capitalizer); 

// Expectations 
mock.expects("capitalize").exactly(5).withArgs.apply(sinon, arr); 
// Reality
arr.map(capitalizer.capitalize);
// Verification
console.log(mock.verify());

// true

utilities上设置模拟之后,我们将一个五元素数组映射到capitalize,期望capitalize被调用五次,数组的元素作为参数(使用apply将数组展开为单独的参数)。 然后检查名为mock.verify的方法,以查看我们的期望是否得到满足。 和往常一样,在完成后,我们使用mock.restore取消包装 utilities 对象。 您应该在终端中看到 true 被记录。

现在,从被测试的数组中删除一个元素,使期望受挫。当您再次运行测试时,您应该在输出的顶部附近看到以下内容:

ExpectationError: Expected capitalize([...]) 5 times (called 4 times)

这应该澄清模拟旨在产生的测试结果类型。

请注意,模拟的函数不会执行;mock会覆盖其目标。在前面的示例中,没有任何数组成员会通过capitalize运行。

让我们重新审视我们之前使用模拟测试 Redis pub/sub的示例:

const redis = require("redis"); 
const client = redis.createClient(); 

describe('Mocking pub/sub', function() { 
  let 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方法将仅接收一次确切的参数通道。Mocha 期望mock.verify返回true。要使此测试失败,添加一行client.subscribe('channel'),产生类似以下的内容:

ExpectationError: Unexpected call: subscribe(channel)

有关如何使用模拟的更多信息,请访问:sinonjs.org/releases/v4.1.2/mocks/

使用 Nightmare 和 Puppeteer 进行无头测试

测试 UI 是否有效的一种方法是支付几个人通过浏览器与网站进行交互,并报告他们发现的任何错误。这可能会变得非常昂贵,最终也不可靠。此外,这需要将潜在失败的代码投入生产以进行测试。最好在发布任何内容“到野外”之前,测试浏览器视图是否在测试过程本身中正确呈现。

一个被剥夺了按钮和其他控件的浏览器,本质上是一个验证和运行 JavaScript、HTML 和 CSS,并创建视图的程序。验证的 HTML 在您的屏幕上呈现出来只是人类只能用眼睛看到的结果。机器可以解释编译代码的逻辑,并查看与该代码的交互结果,而无需视觉组件。也许是因为眼睛通常在头部,由服务器上的机器运行的浏览器通常被称为无头浏览器。

我们将看一下两个无头浏览器测试自动化库:Nightmare (github.com/segmentio/nightmare) 和 Puppeteer (github.com/GoogleChrome/puppeteer)。Nightmare 使用Electron作为其浏览器环境,而 Puppeteer 使用无头Chromium。它们都为您提供了一个可编写脚本的环境,围绕浏览器上下文,使您能够对该页面进行各种操作,例如抓取屏幕截图,填写并提交表单,或者根据 CSS 选择器从页面中提取一些内容。与我们之前的工作保持一致,我们还将学习如何使用 Mocha 和 Chai 来利用这些无头浏览器测试。

让我们熟悉这两个工具,然后看看它们如何集成到您的测试环境中。

Nightmare

Nightmare 为处理 Web 内容提供了一个非常富有表现力的 API。让我们立即使用一个示例 Mocha 测试来验证网页的文档标题:

const Nightmare = require('nightmare');

describe(`Nightmare`, function() {
  let nightmare;

  beforeEach(() => nightmare = Nightmare({
    show: false
  }));

  afterEach(function(done) {
    nightmare.end(done);
  });

  it(`Title should be 'Example Domain'`, function(done) {
    nightmare
    .goto('http://example.org')
    .title()
    .then(title => expect(title).to.equal(`Example Domain`))
    .then(() => done())
    .catch(done);
  });
});

在这里,我们使用 Mocha 的beforeEachafterEach来预期许多测试块,为每个测试创建一个新的 Nightmare 实例,并通过nightmare.end自动清理这些实例。您不一定要这样做,但这是一个有用的样板。Nightmare 接受一个反映 Electron 的BrowserWindow选项的配置对象 (github.com/electron/electron/blob/master/docs/api/browser-window.md#new-browserwindowoptions),在这里,我们使用show属性,使渲染实例可见——视图弹出在您的屏幕上,以便您可以观看页面的操作。特别是在进行导航和 UI 交互的测试中,看到这些操作是很有用的。在这里和接下来的测试中尝试一下。

这个测试很容易阅读。在这里,我们简单地前往一个 URL,获取该页面的标题,并进行断言以测试我们是否有正确的标题。请注意,Nightmare 被设计为与 Promises 原生地配合工作,你看到的Promise链是基于 Node 原生的 Promises 构建的。如果你想使用另一个Promise库,你可以这样做:

const bbNightmare = Nightmare({
  Promise: require('bluebird')
});

bbNightmare.goto(...)

与页面交互是无头浏览器测试的必不可少的部分,让你编写自动运行的 UI 测试。例如,你可能想测试你的应用程序的登录页面,或者当提交时搜索输入返回正确的结果和正确的顺序。让我们向这个套件添加另一个测试,一个在 Yahoo 上搜索 Nightmare 主页并查询链接文本的测试:

it('Yahoo search should find Nightmare homepage', done => {
    nightmare
    .goto('http://www.yahoo.com')
    .type('form[action*="/search"] [name=p]', 'nightmare.js')
    .click('form[action*="/search"] [type=submit]')
    .wait('#main')
    .evaluate(() => document.querySelector('#main .searchCenterMiddle a').href)
    .then(result => expect(result).to.equal(`http://www.nightmarejs.org/`))
    .then(() => done())
    .catch(done);
})

你可以看到这是如何工作的。使用 CSS 选择器在 Yahoo 的首页上找到搜索框,输入'nightmare.js'并点击提交按钮提交表单。等待一个新的元素#main出现,表示结果页面已经被渲染。然后我们创建一个evaluate块,它将在浏览器范围内执行。这是一个进行自定义 DOM 选择和操作的好地方。在这里,我们找到第一个链接,并检查它是否是我们期望的链接。这个简单的模式可以很容易地修改为点击你网站上的链接以确保链接正常工作,或者在结果页面上运行几个选择器以确保正确的结果被传递。

在你的测试中,你可能会发现重复的模式。想象一下,从选择器定位的链接中提取文本是你测试中的常见模式。Nightmare 允许你将这些转化为自定义操作。让我们在 Nightmare 上创建一个自定义的getLinkText操作,并在我们的测试中使用它。首先,在实例化 Nightmare 之前,定义一个新的action

Nightmare.action('getLinkText', function(selector, done) {
    // `this` is the nightmare instance
    this.evaluate_now(selector => {
        return document.querySelector(selector).href;
    }, done, selector)
});

现在,用我们自定义操作的调用替换原始的 evaluate 指令:

...
.wait('#main')
.getLinkText('#main .searchCenterMiddle a') // Call action
...

我们只是将我们的原始指令转换为一个操作块,使用自定义的名称和函数签名,并从我们的测试链中调用它。虽然这个例子是人为的,但很容易想象更复杂的操作,甚至是你的工程师可能会利用的操作库,作为一种测试的编程语言。请注意,在操作中使用evaluate_now而不是evaluate。Nightmare 将排队evaluate指令,而我们的操作已经被排队(作为原始测试链的一部分),我们希望立即在我们的操作中评估该命令,而不是重新排队。

有关 Nightmare 的更多信息,请访问:github.com/segmentio/nightmare#api

Puppeteer

Puppeteer 是一个全新的 Google 项目,专注于使用 Chromium 引擎创建浏览器测试 API。该团队正在积极地针对最新的 Node 版本,利用 Chromium 引擎的所有最新功能(访问:github.com/GoogleChrome/puppeteer/issues/316)。特别是,它旨在鼓励在编写测试时使用 async/await 模式。

以下是之前使用 Puppeteer 编写的文档标题示例:

it(`Title should be 'Example Domain'`, async function() {
    let browser = await puppeteer.launch({
        headless: true
    });

    let page = await browser.newPage();
    await page.goto(`http://example.org`);
    let title = await page.title();
    await browser.close();

    expect(title).to.equal(`Example Domain`);
});

请注意async函数包装器。这种模式非常紧凑,考虑到测试经常必须在浏览器上下文中跳进跳出,async/await在这里感觉很合适。我们还可以看到 Puppeteer API 受到 Nightmare API 的影响。与 Nightmare 一样,Puppeteer 接受一个配置对象:github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#puppeteerlaunchoptions。Nightmare 的show的等价物是headless,它将 Chrome 置于无头模式。重写前面的 Nightmare 雅虎搜索示例为 Puppeteer 可能是一个很好的练习。完整的文档可在此处找到:github.com/GoogleChrome/puppeteer/blob/master/docs/api.md

以下是一个使用 Puppeteer 读取 NYTimes、拦截图像渲染调用并取消它们,然后对无图像页面进行截图并将其写入本地文件系统的 Mocha 测试:

it(`Should create an imageless screenshot`, async function() {

    let savePath = './news.png';
    const browser = await puppeteer.launch({
        headless: true
    });

    const page = await browser.newPage();
    await page.setRequestInterception(true);
    page.on('request', request => {
        if (request.resourceType === 'image') {
            request.abort();
        }
        else {
            request.continue();
        }
    });
    await page.goto('http://www.nytimes.com');
    await page.screenshot({
        path: savePath,
        fullPage: true
    });
    await browser.close();

    expect(fs.existsSync(savePath)).to.equal(true);
});

要创建 PDF,您只需用以下内容替换screenshot部分:

savePath = './news.pdf';
await page.pdf({path: savePath});

开发人员经常构建测试套件,以在各种移动设备尺寸上对同一页面进行截图,甚至进行视觉差异检查,以检查您的网站是否在所有情况下都正确渲染(例如,github.com/mapbox/pixelmatch)。您甚至可以创建一个服务,选择几个 URL 的片段并将它们组合成一个单独的 PDF 报告。

Navalia 是另一个具有有趣的使用无头 Chrome API 进行测试的新框架;您可以在此处找到它:github.com/joelgriffith/navalia

现在,您应该有足够的信息来开始为您的应用程序实施 UI 测试。一些超现代的应用程序甚至涉及在 AWS Lambda 上运行 Chromium(参见第九章,微服务),让您外包您的测试工作。Nightmare 和 Puppeteer 都是现代化、维护良好、有文档的项目,非常适合 Node 测试生态系统。

现在,让我们深入了解一下当 Node 进程运行时幕后发生了什么,以及在测试和调试时如何更加精确。

测试地形

测试 Node 也可能需要更科学、更实验性的努力。例如,内存泄漏是臭名昭著的难以追踪的 bug。您将需要强大的进程分析工具来取样、测试场景,并了解问题的根源。如果您正在设计一个必须处理大量数据的日志分析和总结工具,您可能需要测试各种解析算法并排名它们的 CPU/内存使用情况。无论是测试现有的流程还是作为软件工程师,收集资源使用信息都很重要。本节将讨论如何对运行中的进程进行数据快照,并如何从中提取有用的信息。

Node 已经本地提供了一些进程信息。基本跟踪 Node 进程使用了多少内存很容易通过process.memoryUsage()获取:

{
  rss: 23744512,
  heapTotal: 7708672,
  heapUsed: 5011728,
  external: 12021 
}

你可以编写脚本来监视这些数字,也许在内存分配超过某个预定阈值时发出警告。有许多公司提供这样的监控服务,比如Keymetricskeymetrics.io),他们是 PM2 的制造商和维护者。还有像node-reportgithub.com/nodejs/node-report)这样的模块,它提供了一个很好的方式,在进程崩溃、系统信号或其他原因终止时生成系统报告。伟大的模块memeyegithub.com/JerryC8080/Memeye)使得创建显示这种系统数据的基于浏览器的仪表板变得容易。

Node 进程有几个原生信息源。请访问文档:nodejs.org/api/process.html

让我们首先学习如何收集更广泛的内存使用统计信息,对运行中的进程进行分析,收集关键的 V8 性能数据概要等等。

测试进程、内存和 CPU

Node 有原生工具,可以让你对运行中的 V8 进程进行分析。这些是带有摘要的快照,捕获了 V8 在编译进程时对待进程的统计信息,以及在有选择地优化代码时所做的操作和决策的类型。当尝试追踪例如一个函数运行缓慢的原因时,这是一个强大的调试技术。

任何 Node 进程都可以通过简单地传递--prof(用于 profile 的标志)来生成 V8 日志。让我们用一个例子来看 V8 进程分析是如何工作的。阅读大型日志文件是 Node 开发人员将遇到的一个相当复杂且常见的任务。让我们创建一个日志读取器并检查其性能。

进程分析

在你的代码包中,本章的/profiling目录下将有一个logreader.js文件。这只是读取代码包中也有的dummy.log文件。这是一个如何使用stream.Transform处理大型文件的很好的例子:

const fs = require('fs');
const stream = require('stream');
let lineReader = new stream.Transform({ 
   objectMode: true 
});

lineReader._transform = function $transform(chunk, encoding, done) {
   let data = chunk.toString();
   if(this._lastLine) {
      data = this._lastLine + data;
   }
   let lines = data.split('\n');
   this._lastLine = lines.pop();
   lines.forEach(line => this.push(line));
   done();
};

lineReader._flush = function $flush(done) {
     if(this._lastLine) {
       this.push(this._lastLine);
     }
     this._lastLine = null;
     done();
};

lineReader.on('readable', function $reader() {
   let line;
   while(line = this.read()) {
      console.log(line);
   }
});

fs.createReadStream('./dummy.log').pipe(lineReader);

需要注意的重要事情是,主要函数已经被命名,并以$为前缀。这通常是一个很好的做法——你应该总是给你的函数命名,原因特别与调试相关。我们希望这些名称出现在我们即将生成的报告中。

要生成一个 v8 日志,可以使用--prof参数运行此脚本:

node --prof logreader.js

现在你应该在当前工作目录中看到一个名为isolate-0x103000000-v8.log的 V8 日志文件。继续看一下它——日志有点令人生畏,但如果你搜索一下,比如$reader,你会发现 V8 是如何记录它对调用堆栈和编译工作的结构的实例。不过,这显然不是为人类阅读而设计的。

通过对该日志运行以下命令,可以创建这个 profile 的一个更有用的摘要:

node --prof-process isolate-0x103000000-v8.log > profile

几秒钟后,进程将完成,目录中将存在一个新文件,名为 profile。继续打开它。里面有很多信息,深入研究所有含义远远超出了本章的范围。尽管如此,你应该能看到摘要清晰地总结了关键的 V8 活动,用 ticks 来衡量(还记得我们在第二章中关于事件循环的讨论吗,理解异步事件驱动编程?)。例如,考虑这一行:

8   50.0%    LazyCompile: *$reader /../profiling/logreader.js:26:43

在这里,我们可以看到$reader消耗了 8 个 ticks,进行了懒编译,并且被优化了(*)。如果它没有被优化,它将被标记为波浪线(~)。如果你发现一个未优化的文件消耗了大量的 ticks,你可能会尝试以最佳方式重写它。这可以是解决应用程序堆栈中较慢部分的强大方式。

堆转储

正如我们之前学到的,堆本质上是对内存的大量分配,在这种特定情况下,它是分配给 V8 进程的内存。通过检查内存的使用情况,你可以追踪内存泄漏等问题,或者简单地找出内存使用最多的地方,并根据需要对代码进行调整。

用于获取堆转储的事实模块是heapdumpgithub.com/bnoordhuis/node-heapdump),由自项目开始以来一直是核心 Node 开发者的Ben Noordhuis创建。

继续安装该模块并创建一个包含以下代码的新文件:

// heapdumper.js
const path = require('path');
const heapdump = require('heapdump');

heapdump.writeSnapshot(path.join(__dirname, `${Date.now()}.heapsnapshot`));

运行该文件。你会发现生成了一个名为1512180093208.heapsnapshot的文件。这不是一个可读的文件,但它包含了重建堆使用情况视图所需的一切。你只需要正确的可视化软件。幸运的是,你可以使用 Chrome 浏览器来做到这一点。

打开 Chrome DevTools。转到内存选项卡。你会看到一个选项来加载堆转储:

加载刚刚创建的文件(注意,它必须.heapsnapshot扩展名)。加载后,点击堆图标,你会看到类似以下的内容:

点击 Summary 以激活下拉菜单,并选择 Statistics。现在你会看到类似以下的图表:

熟悉如何读取堆转储是对任何 Node 开发者有用的技能。要了解内存分配的好介绍,请尝试:developer.chrome.com/devtools/docs/memory-analysis-101。运行 Chrome DevTools UI 的源代码是开放和免费的,github.com/ChromeDevTools/devtools-frontend,协议本身也是如此。想想你可能如何定期对运行中的进程进行堆转储,并使用 DevTools 测试系统健康状况,无论是我们演示的方式还是通过自定义构建。

虽然我们在演示中使用 Chrome,其他工具也可以连接到这个协议。查看nodejs.org/en/docs/inspector/github.com/ChromeDevTools/awesome-chrome-devtools#chrome-devtools-protocol

Chrome DevTools 有更多对开发者有用的功能。现在让我们来看看这些功能。

将 Node 连接到 Chrome DevTools

Chrome 调试协议最近与 Node 核心集成github.com/nodejs/node/pull/6792),这意味着现在可以使用 Chrome DevTools(和其他工具)调试运行中的 Node 进程。这不仅包括观察内存分配,还包括收集 CPU 使用情况的实时反馈,以及直接调试您的活动代码——例如添加断点和检查当前变量值。这是专业 Node 开发者的重要调试和测试工具。让我们深入了解一下。

为了演示目的,我们将创建一个执行一些重要工作的快速服务器:

// server.js
const Express = require('express');
let app = Express();

function $alloc() {
    Buffer.alloc(1e6, 'Z');
}

app.get('/', function $serverHandler(req, res) => {

    let d = 100;
    while(d--){ $alloc() }

    res.status(200).send(`I'm done`);
})

app.listen(8080);

注意$alloc$serverHandler的命名函数;这些函数名将用于跟踪我们的进程。现在,我们将启动该服务器,但使用一个特殊的--inspect标志指示 Node 我们计划检查(调试)该进程:

node --inspect server.js

你应该看到类似以下的显示:

Debugger listening on ws://127.0.0.1:9229/bc4d2b60-0d01-4a66-ad49-2e990fa42f4e
For help see https://nodejs.org/en/docs/inspector

看起来调试器是激活的。要查看它,打开 Chrome 浏览器并输入以下内容:

chrome://inspect

你应该看到你启动的进程被列出。你可以检查该进程,或者通过点击 Open dedicated DevTools for Node 加载一个活动的调试屏幕,从现在开始,它将附加到你使用--inspect启动的任何 Node 进程。

CPU 分析

在另一个浏览器窗口中打开并导航到我们的测试服务器localhost:8080。您应该会看到显示“我完成了”(如果没有,请返回并启动server.js,如之前所述)。保持打开;您将很快重新加载此页面。

在调试器 UI 中点击“Memory”,您会看到之前的界面。这是我们之前看到的调试器的独立版本。

现在,点击“Profiler”,这是调试 CPU 行为(特别是执行时间)的界面,然后点击“开始”。返回到浏览器并重新加载“我完成了”页面几次。返回调试器并点击“停止”。您应该会看到类似于这样的东西:

请注意三个较大的,这些块是由我们的服务器处理程序的三次运行生成的。使用鼠标,选择其中一个块并放大:

在这里,我们看到了处理我们请求时 V8 活动的全面分解。还记得$alloc吗?通过将鼠标悬停在其时间轴上,您可以检查它消耗的总 CPU 时间。如果我们放大到右下角的 send 部分,我们还可以看到我们的服务器执行 HTTP 响应花费了 1.9 毫秒:

玩弄一下这个界面。除了帮助您找到和调试应用程序中较慢的部分之外,在开发测试时,您还可以使用此工具来创建对正常运行预期行为的心理地图,并设计健康测试。例如,您的一个测试可能会调用特定的路由处理程序,并根据一些预定的最大执行时间阈值来判断成功或失败。如果这些测试总是开启,定期探测您的实时应用程序,它们甚至可能触发自动限流行为、日志条目或向工程团队发送紧急电子邮件。

实时调试

也许,这个界面最强大的功能是它能够直接调试运行中的代码,并测试实时应用程序的状态。在调试器中点击“Sources”。这是实际脚本组成 Node 进程的界面。您应该会看到我们的server.js文件的挂载版本:

有趣的事实:在这里,您可以看到 Node 如何实际包装您的模块,以便全局exportsrequiremodule__filename__dirname变量对您可用。

让我们在第 11 行设置一个断点。只需点击数字;您应该会看到这个:

还记得我们之前关于 Node 调试器的讨论吗?同样的原则也适用于这里;我们将能够使用这个界面来逐步执行代码,定期停止执行,并检查应用程序状态。

为了演示,让我们导致这段代码在我们的服务器上执行。返回到您的浏览器并重新加载localhost:8080,调用路由并最终触发您刚刚设置的断点。调试器界面应该会弹出,并且看起来会像这样:

除了清楚地指示我们所在的位置(在$serverHandler函数内的行号),界面还有用地显示了while循环的当前迭代中d的值。还记得我们之前关于 Node 调试器的讨论吗?同样的原则也适用于这里。如果您将鼠标悬停在右侧的调试控件图标上,您会看到第二个是步进功能。我们在一个循环中;让我们步进到下一个迭代。继续点击步进几次。您是否注意到在您通过这个循环时d的值是如何更新的?

如果您探索右侧的界面,您可以深入了解程序的当前状态,全面分解所有作用域变量、全局引用等。通过使用step into控制,您可以观察每个请求通过执行堆栈的进展,跟踪 Node 运行时的执行过程。您将受益于这个练习,并更清楚地了解您的代码(以及 Node 的工作原理)。这将帮助您成为更好的测试编写者。

有一个 Chrome 插件,使与检查的 Node 进程交互变得简单,只需点一下鼠标即可;它可以在以下链接找到:chrome.google.com/webstore/detail/nodejs-v8-inspector-manag/gnhhdgbaldcilmgcpfddgdbkhjohddkj

Mathias Buus创建了一个有趣的工具,为那些罕见但令人抓狂的进程不在预期结束时提供了非常有用的调试信息,您可以在以下链接找到它:github.com/mafintosh/why-is-node-running

Matteo Collina的出色的loopbench (github.com/mcollina/loopbench) 及其针对 Node 服务器的打包版本 (github.com/davidmarkclements/overload-protection) 不仅可用于提供测试和调试信息,还可用于开发智能、自我调节的服务器,当运行过热时会自动卸载(或重定向)负载,这是独立、联网节点的分布式应用架构中的一个很好的特性。

摘要

Node 社区从一开始就支持测试,并为开发人员提供了许多测试框架和本地工具。在本章中,我们探讨了为什么测试对现代软件开发如此重要,以及有关功能、单元和集成测试的一些内容,它们是什么,以及如何使用它们。通过 vm 模块,我们学习了如何为测试 JavaScript 程序创建特殊的上下文,并在此过程中掌握了一些用于沙盒化不受信任代码的技巧。

此外,我们学习了如何使用丰富的 Node 测试和错误处理工具,从更具表现力的控制台日志记录到 Mocha 和 Sinon 的模拟,再到一行追踪和调试堆和实时代码。最后,我们学习了两种不同的无头浏览器测试库,学习了每种测试可能的两种方式,以及这些虚拟浏览器如何与其他测试环境集成。

现在您可以测试您的代码,去尝试 Node 的强大功能。

第十一章:将工作组织成模块

“复杂性必须从已经工作的简单系统中增长。”

– 凯文·凯利,“失控”

Node 的简单模块管理系统鼓励开发可持续增长和可维护的代码库。Node 开发人员有幸拥有一个丰富的生态系统,其中包含了清晰定义的具有一致接口的软件包,易于组合,并通过 npm 交付。在开发解决方案时,Node 开发人员会发现许多他们需要的功能片段已经准备就绪,并且可以迅速将这些开源模块组合成更大的、但仍然一致和可预测的系统。Node 的简单且可扩展的模块架构使得 Node 生态系统迅速增长。

在本章中,我们将介绍 Node 如何理解模块和模块路径的细节,如何定义模块,如何在 npm 软件包存储库中使用模块,以及如何创建和共享新的 npm 模块。通过遵循一些简单的规则,您会发现很容易塑造应用程序的结构,并帮助他人使用您创建的内容。

模块软件包将被互换使用,以描述由require()编译和返回的文件或文件集合。

如何加载和使用模块

在我们开始之前,看一下这三个命令:

$ node --version
v8.1.2 $ npm --version
5.5.1 $ npm install npm@latest -g

要安装 Node,您可能会在您喜欢的网络浏览器中导航到nodejs.org/en/,下载适合您操作系统的安装程序应用,并点击一些确定按钮。当您这样做时,您也会得到 npm。然而,npm 经常更新,所以即使您最近更新了 Node,您可能没有最新版本的 npm。

此外,下载和安装新的 Node 安装程序将更新 Node,但并不总是更新 npm,因此使用npm install npm@latest -g来确保您拥有最新版本。

Node 的设计者认为,大多数模块应该由开发人员在用户空间开发。因此,他们努力限制标准库的增长。在撰写本文时,Node 的标准模块库包含以下简短的模块列表:

网络和 I/O 字符串和缓冲区 实用工具

| TTY UDP/Datagram

HTTP

HTTPS

Net

DNS

TLS/SSL

Readline

FileSystem | Path Buffer

Url

StringDecoder

QueryString | Utilities VM

Readline

Domain

Console

Assert |

加密和压缩 环境 事件和流

| ZLIB Crypto

PunyCode | Process OS

模块 | 子进程集群

Events

Stream |

模块是通过全局的require语句加载的,它接受模块名称或路径作为单个参数。作为 Node 开发人员,您被鼓励通过创建新模块或自己的模块组合来增强模块生态系统,并与世界分享它们。

模块系统本身是在 require(module)模块中实现的。

模块对象

一个 Node 模块只是一个 Javascript 文件。将可能对外部代码有用的函数(以及其他任何东西)引用到 exports 中,如下所示:

// library1.js
function function1a() {
  return "hello from 1a";
}
exports.function1a = function1a;

我们现在有一个可以被另一个文件所需的模块。回到我们的主应用程序,让我们使用它:

// app.js
const library1 = require('./library1'); // Require it
const function1a = library1.function1a; // Unpack it
let s = function1a(); // Use it
console.log(s);

请注意,不需要使用.js后缀。我们将很快讨论 Node 如何解析路径。

让我们将我们的库变得更大一点,扩展到三个函数,如下所示:

// library1.js
exports.function1a = () => "hello from 1a";
exports.function1b = () => "hello from 1b";
exports.function1c = () => "hello from 1c";

// app.js
const {function1a, function1b, function1c} = require('./library1'); // Require and unpack
console.log(function1a());
console.log(function1b());
console.log(function1c());

解构赋值,随着 ES6 引入到 JavaScript 中,是一种很好的方式,可以在一行代码中将许多由所需模块导出的函数分配给它们的本地变量。

模块、导出和 module.exports

当您检查 Node 模块的代码时,您可能会看到一些模块使用module.exports导出它们的功能,而其他模块则简单地使用exports

module.exports.foo = 'bar';
// vs...
exports.foo = 'bar';

有区别吗?简短的答案是否定的。在构建代码时,你可以大多数情况下将属性分配给任何一个。上面提到的两种方法都会“做”同样的事情--导出模块的属性'foo'在两种情况下都将解析为'bar'。

更长的答案是它们之间存在微妙的差异,与 JavaScript 引用工作方式有关。考虑模块首先是如何包装的:

// https://github.com/nodejs/node/blob/master/lib/module.js#L92
Module.wrap = function(script) {
    return Module.wrapper[0] + script + Module.wrapper[1];
};

Module.wrapper = [
    '(function (exports, require, module, __filename, __dirname) { ',
    '\n});'
];

创建模块时,它将使用上述代码进行包装。这就是如何将 __dirname 和当然 exports 的“全局变量”注入到您的执行范围中的脚本(内容)中的方式:

// https://github.com/nodejs/node/blob/master/lib/module.js#L625
var wrapper = Module.wrap(content);

var compiledWrapper = vm.runInThisContext(wrapper, {
    filename: filename,
    lineOffset: 0,
    displayErrors: true
});

...
result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname);

回想一下第十章中关于vm上下文的讨论,测试您的应用程序Module构造函数本身演示了exports只是Module对象上的一个空对象文字:

// https://github.com/nodejs/node/blob/master/lib/module.js#L70
function Module(id, parent) {
    this.id = id;
    this.exports = {};
    this.parent = parent;
    updateChildren(parent, this, false);
    this.filename = null;
    this.loaded = false;
    this.children = [];
}

总结一下,在最终编译中,module.exports包含的内容将被返回给require

// https://github.com/nodejs/node/blob/master/lib/module.js#L500
var module = new Module(filename, parent);
...
Module._cache[filename] = module;
...
return module.exports;

总之,当您创建一个模块时,实质上是在定义其在此上下文中的导出:

var module = { exports: {} };
var exports = module.exports;
// ...your code, which can apply to either

因此,exports只是对module.exports的引用,这就是为什么在exports对象上设置 foo 与在module.exports上设置 foo 是相同的。但是,如果您将exports设置为其他内容module.exports不会反映出这种变化:

function MyClass() {
    this.foo = 'bar';
}

// require('thismodule').foo will be 'bar'
module.exports = new MyClass();

// require('thismodule').foo will be undefined
exports = new MyClass();

正如我们在上面看到的,只有module.exports被返回;exports从未被返回。如果exports覆盖了对module.exports的引用,那么该值永远不会逃离编译上下文。为了安全起见,只需使用module.exports

Node 的核心模块也是使用标准的module.exports模式定义的。您可以通过浏览定义控制台的源代码来查看这一点:github.com/nodejs/node/blob/master/lib/console.js

模块和缓存

一旦加载,模块将被缓存。模块是基于其解析后的文件名缓存的,相对于调用模块进行解析。对 require(./myModule)的后续调用将返回相同的(缓存的)对象。

为了证明这一点,假设我们有三个(在这种情况下设计不佳的)模块,每个模块都需要另外两个模块:

// library1.js
console.log("library 1 -\\");
const {function2a, function2b, function2c} = require('./library2');
const {function3a, function3b, function3c} = require('./library3');
exports.function1a = () => "hello from 1a";
exports.function1b = () => "hello from 1b";
exports.function1c = () => "hello from 1c";
console.log("library 1 -/");
// library2.js
console.log("library 2 --\\");
const {function1a, function1b, function1c} = require('./library1');
const {function3a, function3b, function3c} = require('./library3');
exports.function2a = () => "hello from 2a";
exports.function2b = () => "hello from 2b";
exports.function2c = () => "hello from 2c";
console.log("library 2 --/");
// library3.js
console.log("library 3 ---\\");
const {function1a, function1b, function1c} = require('./library1');
const {function2a, function2b, function2c} = require('./library2');
exports.function3a = () => "hello from 3a";
exports.function3b = () => "hello from 3b";
exports.function3c = () => "hello from 3c";
console.log("library 3 ---/");

如果没有缓存,需要其中任何一个将导致无限循环。但是,由于 Node 不会重新运行已加载(或当前正在加载)的模块,所以一切正常:

$ node library1.js
library 1 -\
library 2 --\
library 3 ---\
library 3 ---/
library 2 --/
library 1 -/

$ node library2.js
library 2 --\
library 1 -\
library 3 ---\
library 3 ---/
library 1 -/
library 2 --/

$ node library3.js
library 3 ---\
library 1 -\
library 2 --\
library 2 --/
library 1 -/
library 3 ---/

但是,请注意,通过不同的相对路径(例如../../myModule)访问相同的模块将返回不同的对象;可以将缓存视为由相对模块路径键入。

可以通过require('module')._cache获取当前缓存的快照。让我们来看一下:

// app.js
const u = require('util');
const m = require('module');
console.log(u.inspect(m._cache));
const library1 = require('./library1');
console.log("and again, after bringing in library1:")
console.log(u.inspect(m._cache));

{
  'C:\code\example\app.js': Module {
    id: '.',
    exports: {},
    parent: null,
    filename: 'C:\\code\\example\\app.js',
    loaded: false,
    children: [],
    paths:
    [ 'C:\\code\\example\\node_modules',
      'C:\\code\\node_modules',
      'C:\\node_modules' ]
  }
}

and again, after bringing in library1:

{ 
  'C:\code\example\app.js': Module {
    id: '.',
    exports: {},
    parent: null,
    filename: 'C:\\code\\example\\app.js',
    loaded: false,
    children: [ [Object] ],
    paths: [ 
      'C:\\code\\example\\node_modules',
      'C:\\code\\node_modules',
      'C:\\node_modules' 
    ] 
  },
  'C:\code\example\library1.js': Module {
    id: 'C:\\code\\example\\library1.js',
    exports: { 
      function1a: [Function],
      function1b: [Function],
      function1c: [Function] 
    },
    parent: Module {
      id: '.',
      exports: {},
      parent: null,
      filename: 'C:\\code\\example\\app.js',
      loaded: false,
      children: [Array],
      paths: [Array] 
    },
    filename: 'C:\\code\\example\\library1.js',
    loaded: true,
    children: [],
    paths: [ 
      'C:\\code\\example\\node_modules',
      'C:\\code\\node_modules',
      'C:\\node_modules' 
    ] 
  }
}

模块对象本身包含几个有用的可读属性:

  • module.filename:定义此模块的文件名。您可以在前面的代码块中看到这些路径。

  • module.loaded:模块是否正在加载过程中。如果加载完成,则为布尔值 true。在前面的代码中,library1 已经加载完成(true),而 app 仍在加载中(false)。

  • module.parent:需要此模块的模块(如果有)。您可以看到 library1 是如何知道 app 需要它的。

  • module.children:此模块所需的模块(如果有)。

您可以通过检查require.main === module来确定模块是直接执行的(通过node module.js)还是通过require('./module.js'),在前一种情况下将返回 true。

Node 如何处理模块路径

由于模块化应用程序组合是 Node 的方式,您经常会看到(并使用)require 语句。您可能已经注意到,传递给 require 的参数可以采用许多形式,例如核心模块的名称或文件路径。

以下伪代码摘自 Node 文档,按顺序描述了解析模块路径时所采取的步骤:

// require(X) from module at path Y
REQUIRE(X) 
  1\. If X is a core module,
    a. return the core module
    b. STOP
  2\. If X begins with '/'
    a. set Y to be the filesystem root
  3\. If X begins with './' or '/' or '../'
    a. LOAD_AS_FILE(Y + X)
    b. LOAD_AS_DIRECTORY(Y + X)
  4\. LOAD_NODE_MODULES(X, dirname(Y))
  5\. THROW "not found"
LOAD_AS_FILE(X)
  1\. If X is a file, load X as JavaScript text. STOP
  2\. If X.js is a file, load X.js as JavaScript text. STOP
  3\. If X.json is a file, parse X.json to a JavaScript Object. STOP
  4\. If X.node is a file, load X.node as binary addon. STOP
LOAD_INDEX(X)
  1\. If X/index.js is a file, load X/index.js as JavaScript text. STOP
  2\. If X/index.json is a file, parse X/index.json to a JavaScript Object. STOP
  3\. If X/index.node is a file, load X/index.node as a binary addon. STOP
LOAD_AS_DIRECTORY(X)
  1\. If X/package.json is a file,
    a. Parse X/package.json, and look for "main" field.
    b. let M = X + ("main" field)
    c. LOAD_AS_FILE(M)
    d. LOAD_INDEX(M)
  2\. LOAD_INDEX(X)
LOAD_NODE_MODULES(X, START)
  1\. let DIRS=NODE_MODULES_PATHS(START)
  2\. for each DIR in DIRS:
    a. LOAD_AS_FILE(DIR/X)
    b. LOAD_AS_DIRECTORY(DIR/X)
NODE_MODULES_PATHS(START)
  1\. let PARTS = path split(START)
  2\. let I = count of PARTS - 1
  3\. let DIRS = []
  4\. while I >= 0,
    a. if PARTS[I] = "node_modules" CONTINUE
    b. DIR = path join(PARTS[0 .. I] + "node_modules")
    c. DIRS = DIRS + DIR
    d. let I = I - 1
  5\. return DIRS

文件路径可以是绝对的或相对的。请注意,本地相对路径不会被隐式解析,必须声明。例如,如果你想要从当前目录中要求myModule.js文件,至少需要在文件名前加上./– require('myModule.js')将不起作用。Node 将假定你要么引用一个核心模块,要么引用./node_modules文件夹中的模块。如果两者都不存在,将抛出一个MODULE_NOT_FOUND错误。

如前面的伪代码所示,这个node_modules查找会从调用模块或文件的解析路径开始向上查找目录树。例如,如果位于/user/home/sandro/project.js的文件调用了require('library.js'),Node 将按照以下顺序寻找:

/user/home/sandro/node_modules/library.js
/user/home/node_modules/library.js
/user/node_modules/library.js
/node_modules/library.js

将文件和/或模块组织到目录中总是一个好主意。有用的是,Node 允许通过它们所在的文件夹的两种方式来引用模块。给定一个目录,Node 首先会尝试在该目录中找到一个package.json文件,或者寻找一个index.js文件。我们将在下一节讨论package.json文件的使用。在这里,我们只需要指出,如果 require 传递了./myModule目录,它将寻找./myModule/index.js

如果你设置了NODE_PATH环境变量,那么 Node 将使用该路径信息来进行进一步搜索,如果通过正常渠道找不到请求的模块。出于历史原因,还将搜索$HOME/.node_modules$HOME/.node_libraries$PREFIX/lib/node$HOME代表用户的主目录,$PREFIX通常是 Node 安装的位置。

创建一个包文件

正如在讨论 Node 如何进行路径查找时提到的,模块可能包含在一个文件夹中。如果你正在开发一个适合作为别人使用的模块的程序,你应该将该模块组织在它自己的文件夹中,并在该文件夹中创建一个package.json文件。

正如我们在本书的示例中所看到的,package.json文件描述了一个模块,有用地记录了模块的名称、版本号、依赖关系等。如果你想通过 npm 发布你的包,它必须存在。在本节中,我们将仅概述该文件的一些关键属性,并对一些不常见的属性提供更多详细信息。

尝试$ npm help json以获取所有可用 package.json 字段的详细文档,或访问:docs.npmjs.com/files/package.json

package.json文件必须符合 JSON 规范。属性和值必须用双引号引起来,例如。

简单初始化

你可以手动创建一个包文件,或者使用方便的$ npm init命令行工具,它会询问一些问题并为你生成一个package.json文件。让我们来看看其中的一些:

  • 名称:(必需)这个字符串将被传递给require(),以加载你的模块。让它简短和描述性,只使用字母数字字符;这个名称将被用在 URL、命令行参数和文件夹名称中。尽量避免在名称中使用jsnode

  • 版本:(必需)npm 使用语义化版本,以下都是有效的:

  • =1.0.2 <2.1.2

  • 2.1.x

  • ~1.2

有关版本号的更多信息,请访问:docs.npmjs.com/misc/semver

  • 描述:当人们在npmjs.org上搜索包时,他们将会读到这个。让它简短和描述性。

  • 入口点(主要):这是应该设置module.exports的文件;它定义了模块对象定义的位置。

  • 关键字:一个逗号分隔的关键字列表,将帮助其他人在注册表中找到你的模块。

  • 许可证:Node 是一个喜欢宽松许可证的开放社区。MITBSD在这里都是不错的选择。

您可能还希望在开发模块时将private字段设置为true。这样可以确保 npm 拒绝发布它,避免意外发布尚未完善或时间敏感的代码。

向 package.json 添加脚本

另一个优势是 npm 也可以用作构建工具。包文件中的scripts字段允许您设置在某些 npm 命令后执行的各种构建指令。例如,您可能希望最小化 Javascript,或执行一些其他构建依赖项的过程,每当执行npm install时,您的模块都需要。可用的指令如下:

  • prepublishpublishpostpublish:通过npm publish命令以及在本地npm install命令中没有任何参数时运行。

  • prepublishOnly:在npm publish命令上发布之前运行。

  • prepare:在包发布之前和在npm install命令中没有任何参数的情况下运行。在prepublish之后但在prepublishOnly之前运行。

  • prepack:在通过npm packnpm publish打包 tarball 之前运行,并在安装 git 依赖项时运行。

  • postpack:在 tarball 生成并移动到其最终位置后运行。

  • preinstallinstallpostinstall:通过npm install命令运行。

  • preuninstalluninstallpostuninstall:通过npm uninstall命令运行。

  • preversionversionpostversion:通过npm version命令运行。

  • preshrinkwrapshrinkwrappostshrinkwrap:通过npm shrinkwrap命令运行。

  • pretesttestposttest:通过npm test命令运行。

  • prestopstoppoststop:通过npm stop命令运行。

  • prestartstartpoststart:通过npm start命令运行。

  • prerestartrestartpostrestart:通过npm restart命令运行。请注意,如果没有提供restart脚本,npm restart将运行stopstart脚本。

应该清楚的是,pre-命令将在其主要命令(如publish)执行之前运行,而 post-命令将在其主要命令执行之后运行。

npm 作为一个使用自定义脚本的构建系统

您不仅限于仅使用此预定义的默认脚本命令包。在包文件中扩展脚本集合,例如构建说明,是一种非常常见的做法。考虑以下脚本定义:

"dev": "NODE_ENV=development node --inspect --expose-gc index.js"

当通过npm run dev命令运行此命令时,我们以调试模式(--inspect)启动一个假设的服务器,并公开垃圾收集器,以便我们可以跟踪其对我们应用程序性能的影响。

这也意味着 npm 脚本在许多情况下可以完全替代更复杂的构建系统,如gulpwebpack。例如,您可能希望使用Browserify来捆绑您的应用程序以进行部署,而该构建步骤很容易在脚本中描述:

"scripts" : {
  "build:browserify" : "browserify -t [babelify --presets [react]] src/js/index.js -o build/app.js"
}

执行npm run build:browserify后,Browserify 将处理 src/js/index.js 文件,通过一个可以编译 React 代码(babelify)的转换器(-t)运行它,并将结果输出(-o)到 build/app.js。

此外,npm 脚本在 npm 的主机系统上运行,因此您可以执行系统命令并访问本地安装的模块。您可能要实现的另一个构建步骤是 JavaScript 文件的最小化,并将编译后的文件移动到目标文件夹:

"build:minify": "mkdir -p dist/js uglify src/js/**/*.js > dist/js/script.min.js"

在这里,我们使用 OS 命令 mkdir 创建编译文件的目标文件夹,在一个文件夹中对所有 JavaScript 文件执行最小化(本地安装的)uglify模块,并将结果的最小化脚本捆绑重定向到一个单独的构建文件。

现在我们可以向我们的脚本集合添加一个通用的构建命令,并在需要部署新构建时简单地使用npm run build

"build": "npm run build:minify && npm run build:browserify"

可以以这种方式链接任意数量的步骤。您可以添加测试,运行文件监视器等。

对于您的下一个项目,考虑使用 npm 作为构建系统,而不是用大型和抽象的系统来复杂化您的堆栈,当它们出现问题时很难调试。例如,公司Mapbox使用 npm 脚本来管理复杂的构建/测试流水线:github.com/mapbox/mapbox-gl-js/blob/master/package.json

注册包依赖项

很可能一个给定的模块本身会依赖于其他模块。这些依赖关系在package.json文件中使用四个相关属性声明:

  • dependencies:您的模块的核心依赖应该驻留在这里。

  • devDependencies:在开发模块时,您可能依赖于一些对于将来使用它的人来说并不必要的模块。通常测试套件会包含在这里。这将为使用您的模块的人节省一些空间。

  • bundledDependencies:Node 正在迅速变化,npm 包也在变化。您可能希望将一定的依赖包锁定到一个单独的捆绑文件中,并将其与您的包一起发布,以便它们不会通过正常的npm update过程发生变化。

  • optionalDependencies:包含可选的模块。如果找不到或安装不了这些模块,构建过程不会停止(与其他依赖加载失败时会停止的情况不同)。然后您可以在应用程序代码中检查此模块的存在。

依赖通常使用 npm 包名称定义,后面跟着版本信息:

"dependencies" : {
  "express" : "3.3.5"
}

但是,它们也可以指向一个 tarball:

"foo" : "http://foo.com/foo.tar.gz"

您可以指向一个 GitHub 存储库:

"herder": "git://github.com/sandro-pasquali/herder.git#master"

它们甚至可以指向快捷方式:

"herder": "sandro-pasquali/herder"

这些 GitHub 路径也可用于npm install,例如,npm install sandro-pasquali/herder

此外,在只有具有适当身份验证的人才能安装模块的情况下,可以使用以下格式来获取安全存储库:

"dependencies": {
  "a-private-repo":
    "git+ssh://git@github.com:user/repo.git#master"
}

通过按类型正确组织您的依赖项,并智能地获取这些依赖项,使用 Node 的包系统应该很容易满足构建需求。

发布和管理 NPM 包

当您安装 Node 时,npm 会被自动安装,并且它作为 Node 社区的主要包管理器。让我们学习如何在 npm 存储库上设置帐户,发布(和取消发布)模块,并使用 GitHub 作为替代源目标。

为了发布到 npm,您需要创建一个用户;npm adduser将触发一系列提示,要求您的姓名、电子邮件和密码。然后您可以在多台机器上使用此命令来授权相同的用户帐户。

要重置您的 npm 密码,请访问:npmjs.org/forgot

一旦您通过 npm 进行了身份验证,您就可以使用npm publish命令发布您的包。最简单的方法是从您的包文件夹内运行此命令。您也可以将另一个文件夹作为发布目标(记住该文件夹中必须存在package.json文件)。

您还可以发布一个包含正确配置的包文件夹的 gzipped tar 归档文件。

请注意,如果当前package.json文件的version字段低于或等于现有已发布包的版本,npm 会抱怨并拒绝发布。您可以使用--force参数与publish来覆盖此行为,但您可能希望更新版本并重新发布。

要删除一个包,请使用npm unpublish <name>[@<version>]。请注意,一旦一个包被发布,其他开发人员可能会依赖于它。因此,强烈建议您不要删除其他人正在使用的包。如果您想要阻止某个版本的使用,请使用 npm deprecate <name>[@<version>] <message>

为了进一步协助协作,npm 允许为一个包设置多个所有者:

  • npm owner ls <package name>:列出对模块具有访问权限的用户

  • npm owner add <user> <package name>:添加的所有者将拥有完全访问权限,包括修改包和添加其他所有者的能力

  • npm owner rm <user> <package name>:删除所有者并立即撤销所有权限

所有所有者都拥有相同的权限—无法使用特殊访问控制,例如能够给予写入但不能删除的权限。

全局安装和二进制文件

一些 Node 模块作为命令行程序非常有用。与其要求像$ node module.js这样运行程序,我们可能希望在控制台上简单地键入$ module并执行程序。换句话说,我们可能希望将模块视为安装在系统 PATH 上的可执行文件,并且因此可以从任何地方访问。使用 npm 可以通过两种方式实现这一点。

第一种最简单的方法是使用-g(全局)参数安装包如下:

$ npm install -g module

如果一个包旨在作为应该全局安装的命令行应用程序,将package.json文件的preferGlobal属性设置为true是一个好主意。该模块仍将在本地安装,但用户将收到有关其全局意图的警告。

确保全局访问的另一种方法是设置包的bin属性:

"name": "aModule",
  "bin" : {
    "aModule" : "./path/to/program"
}

当安装此模块时,aModule将被理解为全局 CLI 命令。任意数量的此类程序可以映射到bin。作为快捷方式,可以映射单个程序,如下所示:

"name": "aModule",
  "bin" : "./path/to/program"

在这种情况下,包本身的名称(aModule)将被理解为活动命令。

其他存储库

Node 模块通常存储在版本控制系统中,允许多个开发人员管理包代码。因此,package.jsonrepository字段可用于指向这样的存储库,如果需要合作,可以将开发人员指向这样的存储库。考虑以下示例:

"repository" : {
  "type" : "git",
  "url" : "http://github.com/sandro-pasquali/herder.git"
}
"repository" : {
  "type" : "svn",
  "url" : "http://v8.googlecode.com/svn/trunk/"
}

同样,您可能希望使用 bugs 字段将用户指向应该提交错误报告的位置:

"bugs": {
  "url": "https://github.com/sandro-pasquali/herder/issues"
}

锁定文件

最终,npm install 是一个命令,它从package.json构建一个node_modules文件夹。但是,它总是生成相同的文件夹吗?答案有时是,我们将在稍后详细介绍。

如果您创建了一个新项目,或者最近将 npm 更新到版本 5,您可能已经注意到熟悉的package.json旁边有一个新文件—package-lock.json

里面的内容如下:

{
  "name": "app1",
  "version": "1.0.0",
  "lockfileVersion": 1,
  "dependencies": {
    "align-text": {
      "version": "0.1.4",
      "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz",
      "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=",
      "dev": true
    },
    "babel-core": {
      "version": "6.25.0",
      "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.25.0.tgz",
      "integrity": "sha1-fdQrBGPHQunVKW3rPsZ6kyLa1yk=",
      "dev": true,
      "dependencies": {
        "source-map": {
          "version": "0.5.6",
          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz",
          "integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI=",
          "dev": true
        }
      }
    }
  }
}

部分内容会立即变得熟悉。这里是您的项目依赖的 npm 包。依赖项的依赖项会适当地嵌套:align-text不需要任何东西,而babel-core需要source-map

除了package.json之外的真正有用的部分是通过解析和完整性字段提供的。在这里,您可以看到 npm 下载并解压缩以创建npm_modules中相应文件夹的确切文件,更重要的是,该文件的加密安全哈希摘要。

使用package-lock.json,您现在可以获得一个确切和可重现的node_modules文件夹。提交到源代码控制中,您可以在代码审查期间的差异中看到依赖模块版本何时发生了变化。此外,到处都是哈希值,您可以更加确信您的应用程序依赖的代码没有被篡改。

package-lock.json在这里;它很长,充满了哈希值,但实际上,您可以忽略它。npm 5 中文件的外观并没有改变您习惯的 npm install 和 npm update 等命令的行为。要解释为什么有帮助,有两个开发人员在遇到该文件时通常会提出的常见问题(或感叹):

  1. 这意味着我的node_modules文件夹将由这些哈希值组成,对吗?

  2. 为什么我的package-lock.json文件一直在变化?

答案是(1)不是,(2)这就是为什么。

当 npm 发现一个包的新版本时,它会下载并更新你的node_modules文件夹,就像之前一样。使用 npm 5,它还会更新package-lock.json,包括新的版本号和新的哈希值。

此外,大多数情况下,这就是你希望它做的。如果有一个包的新版本是你正在开发的项目所依赖的,你可能希望 npm install 给你最新的版本。

但是,如果你不想让 npm 这样做呢?如果你希望它获取确切的这些版本和确切的这些哈希值的模块呢?要做到这一点,不在package-lock.json中,而是回到package.json中,并处理语义版本号。看看这三个:

  • 1.2.3

  • ~1.2.3

  • ¹.2.3

1.2.3确切表示那个版本,没有更早的,也没有更晚的。~1.2.3匹配该版本或任何更新的版本。第三个例子中的插入符号¹.2.3将引入该版本或更晚的版本,但保持在 1 版本。插入符号是默认的,很可能已经在你的package.json文件中写好了。这是有道理的,因为对第一个数字的更改表示一个可能与先前版本不兼容的主要版本,反过来可能会破坏你之前的代码。

除了这三个常见的例子之外,语义版本和 npm 支持的比较器、运算符、标识符和范围还有一个完整的语言。好奇的读者可以在docs.npmjs.com/misc/semver查看。但是,请记住保持简单!你现在的合作者和未来的自己会感谢你。

所以,npm 正在改变你的node_modules文件夹和package-lock.json,因为你告诉它在package.json中使用^。你可以删除所有的插入符号,让 npm 坚持使用确切的版本,但在你想要这样做的情况下,有一个更好的方法:

$ npm shrinkwrap

npm 的shrinkwrap命令实际上只是将package-lock.json重命名为npm-shrinkwrap.json。其重要性在于 npm 后续如何使用这些文件。当发布到 npm 时,package-lock.json会留下,因为它可能会随着你正在使用的依赖项的新版本的出现而改变。另一方面,npm-shrinkwrap.json旨在与你的模块一起发布。

当 npm 在一个带有npm-shrinkwrap.json文件的项目上操作时,shrinkwrap文件及其确切的版本和哈希值,而不是package.json及其版本范围,决定了 npm 如何构建node_modules文件夹。就像上世纪 90 年代商场里软件商店的纸板盒一样,你知道里面的东西是从工厂出来时没有改变的,因为去掉了塑料包装。

第十二章:创建你自己的 C++插件

如果同一工作的两个人总是意见一致,那么其中一个是无用的。如果他们总是意见不一致,那么两个都是无用的。

  • Darryl F. Zanuck

Node 的一个非常常见的描述是:NodeJS 允许在服务器上运行 Javascript。这当然是真的;但也是误导的。Node 的成就在于以这样一种方式组织和链接强大的 C++库,使它们的效率可以被利用,而不需要理解它们的复杂性,所有这些都是通过将本地 C++库链接到Node 的 JavaScript 驱动运行时来实现的。Node 的目标是通过将并发模型包装到一个易于理解的单线程环境中,来抽象出多用户、同时多线程 I/O 管理的复杂性,并且已经被数百万网络开发人员充分理解。

简单来说,当你使用 Node 时,你最终是在使用 C++绑定到你的操作系统,这是一种适用于企业级软件开发的语言,没有人会认真质疑。

这种与 C++程序的本地桥接证明了 Node 不适合企业级的说法是错误的。这些说法混淆了 Javascript 在 Node 堆栈中的实际角色。在 Node 程序中经常使用的 Redis 和其他数据库驱动程序的绑定是 C 绑定——快速,接近底层。正如我们所看到的,Node 的简单进程绑定(spawn、exec 等)促进了强大系统库与无头浏览器和 HTTP 数据流的平滑集成。我们能够访问一套强大的本地 Unix 程序,就好像它们是 Node API 的一部分。当然,我们也可以编写自己的插件。

对于成功的消费者技术,这是一些特征的简述,由Keith Devlin教授在"微积分:最成功的技术之一"(www.youtube.com/watch?v=8ZLC0egL6pc)中描述:

  • 它应该消除完成任务的困难或单调乏味。

  • 它应该易于学习和使用。

  • 如果有的话,它应该比流行的方法更容易学习和使用。

  • 一旦学会,就可以在没有持续专家指导的情况下使用。用户仍然能够记住和/或推导出大部分或全部规则,以及随着时间的推移与技术的交互。

  • 它应该可以在不知道它是如何工作的情况下使用。

希望当你考虑 Node 旨在解决的问题类别和它提供的解决方案形式时,你会很容易地在 Node 所代表的技术中看到上述五个特征。Node 学习和使用起来很有趣,具有一致和可预测的界面。重要的是,“在幕后”Node 运行着强大的工具,开发人员只需要理解它们的 API。

令人惊讶的是,Node、V8、libuv 和组成 Node 堆栈的其他库都是开源的,这是一个重要的事实,进一步区别了 Node 与许多竞争对手。不仅可以直接向核心库做出贡献,还可以剪切和粘贴代码块和其他例程来用于自己的工作。事实上,你应该把自己成长为更好的 Node 开发人员看作是同时成为更好的 C++程序员的机会。

这不是 C++的入门指南,让你自己去学习。不要感到害怕!C 语言家族使用的形式和习惯用法与你已经习惯使用的 JavaScript 非常相似。语法和流程控制应该看起来非常熟悉。你应该能够轻松理解以下示例的设计和目标,并且可以通过 C++编程来解决不清楚的部分的含义。逐步扩展这些示例是进入 C++编程世界的一个很好的方式。

你好,世界

让我们构建我们的第一个插件。为了保持传统,这个插件将生成一个 Node 模块,将打印出“Hello World!”即使这是一个非常简单的例子,但它代表了您将构建的所有后续 C++插件的结构。这使您可以逐步尝试新的命令和结构,以易于理解的步骤增加您的知识。

为了使接下来的步骤起作用,您需要在系统上安装 C/C++编译器和 Python 2.7。在操作系统上构建本机代码的工具是特定于该操作系统的(由维护或拥有它的社区或公司提供)。以下是一些主要操作系统的说明:

  • 例如,在 macOS 上,苹果提供了 Xcode,一个集成开发环境(IDE),其中包括一个编译器。

  • 对于 Windows,微软的编译器随 Visual Studio 一起提供。还有一个可用于此目的的 npm 包—npm i -g windows-build-tools

  • 在 Linux 和其他地方,GCC,GNU 编译器集合很常见。还需要GNU MakePython

C++程序员可能会受益于学习 V8 的嵌入方式,网址为:github.com/v8/v8/wiki/Embedder%27s-Guide

编译本地代码时,通常还有另一种软件——构建自动化工具。这个工具指导编译器执行的步骤,将您的源代码转换为本机二进制代码。对于 C 语言,最早的工具之一是 Make。当然,您也可以直接输入编译器,但是 Make 可以让您重新运行相同的一组命令,记录这些命令是什么,并将这些命令传输给另一个开发人员。Make 是在 1976 年 4 月开发的,自那时以来一直在持续使用。

Visual Studio 和 Xcode 不使用像 Make 这样基于脚本的工具。相反,它们将构建步骤和设置保存在二进制文件中,并允许开发人员通过单击复选框和在图形对话框中输入文本来编辑它们。这种方法看起来更友好,但可能更繁琐和容易出错。

为了更方便,谷歌开发了一个名为GYP的工具,用于生成您的项目。这是一个元构建系统,从您那里(以文本格式)获取信息,并生成本机编译器或 IDE 所需的构建文件。GYP 将为您生成所需的文件,而不是打开 Visual Studio 或 Xcode 并在菜单和复选框上单击。对于任何一个花了一个晚上(或几个晚上)在设置中寻找以修复损坏的本机构建的开发人员来说,GYP 是一种神奇的魔法。

谷歌最初创建了 GYP 来构建 Chrome 和 V8,但作为一个开源项目,一个社区将其带到了一个不断扩大的新用途列表。为了构建本机 Node 插件,Node 团队创建并维护了node-gyp,其中包含了谷歌的 GYP。使用上述命令在系统上全局安装node-gyp,并通过获取版本来验证它是否存在。您可以在下面的链接中找到node-gyp的安装说明:github.com/nodejs/node-gyp

您可能还记得我们在第一章中关于 Unix 设计哲学的讨论,特别是道格·麦克罗伊的指令“编写处理文本流的程序,因为那是一个通用接口”。

对于编译器自动化的任务,Make 在 20 世纪 70 年代遵循了这一准则,而苹果和微软在 20 世纪 90 年代打破了这一规则,他们使用了图形 IDE 和二进制项目文件,而现在在这个十年中,谷歌用 GYP 恢复了它。

为了了解我们要去哪里,可能有助于看一下我们最终会得到什么。完成后,我们将拥有一个模块定义文件夹,其中包含一些文件。首先我们将创建的结构如下:

/hello_module
  binding.gyp
  hello.cc
  index.js

/hello_module模块文件夹包含一个 C++文件(hello.cc),GYP 的指令文件(binding.gyp),以及一个方便的包装器index.js),其目的将很快清楚。

创建一个名为hello.cc的文件,其中包含以下内容:

#include <node.h>

namespace hello_module {

    using v8::FunctionCallbackInfo;
    using v8::Isolate;
    using v8::Local;
    using v8::Object;
    using v8::String;
    using v8::Value;

    // Our first native function
    void sayHello(const FunctionCallbackInfo<Value>& args) {
      Isolate* isolate = args.GetIsolate();
      args.GetReturnValue().Set(String::NewFromUtf8(isolate, "Hello Node from native code!"));
    }

    // The initialization function for our module
    void init(Local<Object> exports) {
      NODE_SET_METHOD(exports, "sayHello", sayHello);
    }

    // Export the initialization function
    NODE_MODULE(NODE_GYP_MODULE_NAME, init)
}

在包含了 Node 的 C 头文件之后,为我们的代码定义了一个命名空间,并声明了我们需要使用的 V8 的各个部分,有三个部分。void sayHello函数是我们将要导出的本地函数。在下面,init是一个必需的初始化函数,用于设置这将成为的 Node 模块的导出(这里,函数名"sayHello"绑定到它的 C++对应部分),NODE_MODULE()是一个 C++宏,实际上导出了 GYP 配置为导出的模块。由于它是一个宏,在该行的末尾没有分号。

你正在将 C++代码嵌入 V8 运行时,以便 Javascript 可以绑定到相关的范围。V8 必须对你的代码中进行的所有新分配进行范围限制,因此,你需要将你编写的代码包装起来,扩展 V8。为此,你将看到在接下来的示例中,Handle<Value>语法的几个实例,将 C++代码包装起来。将这些包装器与将在初始化函数中定义并推送到NODE_MODULE的内容进行比较,应该清楚地表明 Node 是如何通过 V8 桥接绑定到 C++方法的。

要了解更多关于 V8 嵌入 C++代码的信息,请查看:github.com/v8/v8/wiki/Getting%20Started%20with%20Embedding

除了hello.cc,还要创建一个包含以下代码的binding.gyp

{
 "targets": [
   {
     "target_name": "hello",
     "sources": [ "hello.cc" ]
   }
 ]
} 

在你有多个源文件需要编译的情况下,只需将更多的文件名添加到源数组中。

这个清单告诉 GYP 我们希望看到hello.cc编译成一个名为hello.node的文件(target_name)在/Release文件夹中的编译二进制代码。现在我们有了 C++文件和编译指令,我们需要编译我们的第一个本地插件!

/hello_module文件夹中运行以下命令:

 $ node-gyp configure

基本上,configure生成一个 Makefile,build命令运行它。在运行configure命令之后,你可以查看 GYP 创建的/build文件夹,以熟悉它们;它们都是你可以检查的文本文件。在安装了 Xcode 的 Mac 上,它将包含一些文件,包括一个 300 行的 Makefile。如果成功,configure命令的输出应该看起来像这样:

$ node-gyp configure
 gyp info it worked if it ends with ok
 gyp info using node-gyp@3.6.2
 gyp info using node@8.7.0 | darwin | x64
 gyp info spawn /usr/bin/python
 gyp info spawn args [ '/usr/local/lib/node_modules/node-gyp/gyp/gyp_main.py',
 gyp info spawn args   'binding.gyp',
 gyp info spawn args   '-f',
 gyp info spawn args   'make',
 gyp info spawn args   '-I',

 ...

 gyp info spawn args   '--generator-output',
 gyp info spawn args   'build',
 gyp info spawn args   '-Goutput_dir=.' ]
 gyp info ok

接下来,尝试build命令,它会运行这个 Makefile。输出看起来像这样:

$ node-gyp build
 gyp info it worked if it ends with ok
 gyp info using node-gyp@3.6.2
 gyp info using node@8.7.0 | darwin | x64
 gyp info spawn make
 gyp info spawn args [ 'BUILDTYPE=Release', '-C', 'build' ]
     CXX(target) Release/obj.target/hello_native/hello.o
     SOLINK_MODULE(target) Release/hello_native.node
 gyp info ok 

现在,你会看到一个新的/build/Release文件夹,其中包含(其他内容之间)二进制hello.node文件。

要删除/build文件夹,可以运行node-gyp clean。作为一个构建快捷方式,你可以使用node-gyp configure build(一行)来配置和构建一步完成,或者简单地使用node-gyp rebuild,它会一次运行clean configure build。更多的命令行选项可以在这里找到:github.com/nodejs/node-gyp#command-options

现在,始终保持在/hello_module文件夹中,创建以下index.js文件:

// index.js
module.exports = require('./build/Release/hello');

这个文件将作为这个模块的导出程序。根据你如何编写你的 C++代码,你可能会利用这个机会将你的模块的本地接口制作成一个特定于 Node 的 API。现在,让我们直接导出hello函数,省去开发者在使用require时遵循我们的构建文件夹结构的麻烦。

为了完成"模块化",为这个模块创建一个package.json文件,并将"入口点"值设置为index.js

现在,让我们演示如何在你的代码中使用这个模块。跳到上一级目录,创建一个文件,该文件将需要我们刚刚创建的模块。考虑以下示例:

const {sayHello} = require('./hello_module');
console.log(sayHello())

使用解构,我们从我们的模块返回的对象中提取sayHello函数。现在,执行这段代码:

$ node hello.js
Hello Node from native code!

现在,你既是 C++程序员,也是 Node 扩展程序员了!

注意我们如何以一种微妙而强大的方式使用相同熟悉的require语句。它不是引入更多 JavaScript 编写的 Node 模块,而是检测并加载我们新创建的本地附加程序。

一个计算器

当然,人们永远不会费心编写一个附加程序来简单地回显字符串。更有可能的是,您希望为您的 Node 程序公开 API 或接口。让我们创建一个简单的计算器,有两种方法:add 和 subtract。在这个例子中,我们将演示如何将参数从 Javascript 传递给附加程序中的方法,并将任何结果发送回来。

这个示例的完整代码将在您的代码包中找到。程序的核心部分可以在这个片段中看到,我们在这里为我们的两种方法定义了一个接口,每种方法都期望接收两个数字作为参数:

#include <node.h>

namespace calculator_module {

  using v8::Exception;
  using v8::FunctionCallbackInfo;
  using v8::Isolate;
  using v8::Local;
  using v8::Number;
  using v8::Object;
  using v8::String;
  using v8::Value;

  void Add(const FunctionCallbackInfo<Value>& args) {
    Isolate* isolate = args.GetIsolate();

    // Check argument arity
    if (args.Length() < 2) {
      isolate->ThrowException(Exception::TypeError(
        String::NewFromUtf8(isolate, "Must send two argument to #add")));
      return;
    }

    // Check argument types
    if (!args[0]->IsNumber() || !args[1]->IsNumber()) {
      isolate->ThrowException(Exception::TypeError(
        String::NewFromUtf8(isolate, "#add only accepts numbers")));
      return;
    }

    // The actual calculation now
    double value = args[0]->NumberValue() + args[1]->NumberValue();
    Local<Number> num = Number::New(isolate, value);

    // Set the return value (using the passed in FunctionCallbackInfo<Value>&)
    args.GetReturnValue().Set(num);
  }

  void Subtract(const FunctionCallbackInfo<Value>& args) {
    Isolate* isolate = args.GetIsolate();

    if (args.Length() < 2) {
      isolate->ThrowException(Exception::TypeError(
        String::NewFromUtf8(isolate, "Must send two argument to #subtract")));
      return;
    }

    if (!args[0]->IsNumber() || !args[1]->IsNumber()) {
      isolate->ThrowException(Exception::TypeError(
        String::NewFromUtf8(isolate, "#subtract only accepts numbers")));
      return;
    }

    double value = args[0]->NumberValue() - args[1]->NumberValue();
    Local<Number> num = Number::New(isolate, value);

    args.GetReturnValue().Set(num);
  }

  void Init(Local<Object> exports) {
    NODE_SET_METHOD(exports, "add", Add);
    NODE_SET_METHOD(exports, "subtract", Subtract);
  }

  NODE_MODULE(NODE_GYP_MODULE_NAME, Init)
}

我们可以很快看到两种方法已经被限定范围:AddSubtractSubtract几乎与Add定义相同,只是操作符有所改变)。在Add方法中,我们看到一个Arguments对象(让人想起 Javascript 的 arguments 对象),它被检查长度(我们期望两个参数)和参数类型(我们想要数字:!args[0]->IsNumber() || !args[1]->IsNumber())。仔细看看这个方法是如何结束的:

Local<Number> num = Number::New(args[0]->NumberValue() + args[1]->NumberValue());
 return scope.Close(num);

虽然似乎有很多事情要做,但实际上非常简单:V8 被指示为一个名为num的数字分配空间,以便赋予我们两个数字相加的值。当这个操作完成后,我们关闭执行范围并返回num。我们不必担心这个引用的内存管理,因为这是由 V8 自动处理的。

最后,在下面的代码块中,我们不仅看到了这个特定程序如何定义它的接口,而且还看到了 Node 模块和 exports 对象在深层次上是如何关联的:

void Init(Handle<Object> exports) {
  exports->Set(String::NewSymbol("add"),
    FunctionTemplate::New(Add)->GetFunction());
  exports->Set(String::NewSymbol("subtract"),
    FunctionTemplate::New(Subtract)->GetFunction());
 }

就像我们的“hello”示例一样,在这里我们看到了新的符号(这些只是字符串类型)addsubtract,它们代表了我们的新 Node 模块的方法名称。它们的函数签名是使用易于遵循的FunctionTemplate::New(Add)->GetFunction())蓝图实现的。

现在很容易从 Node 程序中使用我们的计算器:

let calculator = require('./build/Release/calculator');
console.log(calculator.add(2,3));
console.log(calculator.subtract(3,2));
// 5
// 1

仅仅从这个简单的开始,我们就可以实现有用的 C++模块。现在,我们将深入一些,并且我们将从nan(Node 的本地抽象)中得到一些帮助。

使用 NAN

nangithub.com/nodejs/nan)是一个提供帮助程序和宏的头文件集,旨在简化附加程序的创建。根据文档,nan 主要是为了保持您的 C++代码在不同的 Node 版本之间的兼容性而创建的:

由于 V8(以及 Node 核心)的疯狂变化,跨版本保持本地附加程序编译的愉快,特别是从 0.10 到 0.12 到 4.0,是一场小噩梦。这个项目的目标是存储开发本地 Node.js 附加程序所需的所有逻辑,而无需检查NODE_MODULE_VERSION并陷入宏纠缠。

在接下来的示例中,我们将使用 nan 来构建一些本地附加程序。让我们使用 nan 重新构建我们的hello world示例。

你好,nan

为您的项目创建一个文件夹,并添加以下 package.json 文件:

// package.json
{
  "name": "hello",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "node-gyp rebuild",
    "start": "node index.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "nan": "².8.0",
    "node-gyp": "³.6.2"
  },
  "gypfile": true
}

我们在这里添加了一些新东西,比如指示存在一个gypfile。更重要的是,我们为编译和运行我们的模块创建了一些方便的脚本:buildstart。当然,我们还指出模块的主执行文件是index.js(我们很快就会创建)。还要注意,当您npm install这个包时,GYP 会注意到binding.gyp文件并自动构建 - 一个/build文件夹将与安装一起创建。

现在,创建我们的 GYP 绑定文件。注意添加了include_dirs。这确保了nan头文件对编译器是可用的:

// binding.gyp
{
  "targets": [{
     "include_dirs": [
        "<!(node -e \"require('nan')\")"
      ],
      "target_name": "hello",
      "sources": [
        "hello.cc"
      ]
  }]
}

现在,我们重写主 C++文件以利用 nan 的帮助程序:

#include <nan.h>

NAN_METHOD(sayHello) {
    auto message = Nan::New("Hello Node from NAN code!").ToLocalChecked();
    // 'info' is an implicit bridge object between JavaScript and C++
    info.GetReturnValue().Set(message);
}

NAN_MODULE_INIT(Initialize) {
    // Similar to the 'export' statement in Node -- export the sayHello method
    NAN_EXPORT(target, sayHello);
}

// Create and Initialize function created with NAN_MODULE_INIT macro
NODE_MODULE(hello, Initialize);

在这里,我们可以看到长长的包含列表是不必要的。代码的其余部分遵循与我们原始示例相同的模式,但现在通过 NAN 前缀的快捷方式运行初始化和函数定义。请注意,我们可以直接在模块对象上键入sayHello方法(NAN_EXPORT(target, sayHello)),而不需要在require语句接收的接口上指定sayHello

最后一步是证明这个模块可以绑定到 Node。创建以下index.js文件:

const {Hello} = require('./build/Release/hello');
console.log(Hello());

现在,我们要做的就是构建:

$ npm run build

然后,我们将运行它:

$ node index.js
// Hello Node from NAN code!

异步插件

根据 Node 程序的典型模式,插件也实现了异步回调的概念。正如人们可能在 Node 程序中期望的那样,执行昂贵和耗时操作的 C++插件应该理解异步执行函数的概念。

让我们创建一个模块,公开两种最终调用相同函数的方法,但一种是同步调用,另一种是异步调用。这将使我们能够演示如何创建带有回调的本机模块。

我们将把我们的模块分成 4 个文件,分离功能。创建一个新目录,并从上一个示例中复制package.json文件(将name更改为其他内容),然后添加以下binding.gyp文件:

{
  "targets": [
    {
      "target_name": "nan_addon",
      "sources": [
        "addon.cc",
        "sync.cc",
        "async.cc"
      ],
      "include_dirs": ["<!(node -e \"require('nan')\")"]
    }
  ]
}

完成后,您的模块文件夹将看起来像这样:

我们将创建一个包含异步方法(async.cc)的文件,一个包含同步方法(sync.cc)的文件,每个文件将在addon.h中以不同方式调用的公共函数,以及将所有内容“绑定”在一起的主addon.cc文件。

在模块文件夹中创建addons.h

// addons.h
using namespace Nan;

int Run (int cycles) {
    // using volatile prevents compiler from optimizing loop (slower)
    volatile int i = 0;
    for (; i < cycles; i++) {}
    return cycles;
}

在这个文件中,我们将创建一个“模拟”函数,其责任只是浪费周期(时间)。因此,我们创建一个低效的函数Run。使用volatile关键字,我们吓唬 V8 使其取消优化这个函数(我们警告 V8 这个值将不可预测地改变,吓跑了优化器)。其余部分将简单地运行请求的周期数并反映它发送的值...慢慢地。这是我们的异步和同步代码都将执行的函数。

要同步执行Run,创建以下sync.cc文件:

// sync.cc
#include <nan.h>
int Run(int cycles);

// Simple synchronous access to the `Run()` function
NAN_METHOD(RunSync) {
 // Expect a number as first argument
 int cycles = info[0]->Uint32Value();
 int result = Run(cycles);

 info.GetReturnValue().Set(result);
}

正如我们之前看到的,info将包含传递给此RunSync方法的参数。在这里,我们获取请求的周期数,将这些参数传递给Run,并返回该函数调用产生的任何内容。

现在,创建我们的异步方法async.cc的文件。创建异步代码稍微复杂:

// async.cc
#include <nan.h>

using v8::Local;
using v8::Number;
using v8::Value;
using namespace Nan;

int Run(int cycles);

class Worker : public AsyncWorker {
 public:
  Worker(Callback *callback, int cycles)
    : AsyncWorker(callback), cycles(cycles) {}
  ~Worker() {}

  // This executes in the worker thread.
  // #result is being place on "this" (private.result)
  void Execute () {
    result = Run(cycles);
  }

  // When the async work is complete execute this function in the main event loop
  // We're sending back two arguments to fulfill standard Node callback
  // pattern (error, result) -> (Null(), New<Number>(result))
  void HandleOKCallback () {
    HandleScope scope;
    Local<Value> argv[] = {
        Null()
      , New<Number>(result)
    };
    callback->Call(2, argv);
  }

 private:
  int cycles;
  int result;
};

NAN_METHOD(RunAsync) {
  int cycles = To<int>(info[0]).FromJust();
  Callback *callback = new Callback(To<v8::Function>(info[1]).ToLocalChecked());

  AsyncQueueWorker(new Worker(callback, cycles));
}

从底部开始,您会看到我们正在创建一个方法,该方法期望第一个参数(info[0])是一个整数,该整数被赋给cycles。然后我们创建一个新的Callback对象作为callback,并将callbackcycles传递给Worker构造函数,将结果实例传递给AsyncQueueWorker(设置我们的异步方法)。

现在,让我们看看如何配置异步Worker

跳到Worker的底部,注意为这个类建立私有属性cyclesresult。在 JavaScript 中,相当于创建一个具有this.cyclesthis.result的本地变量上下文--在接下来的内容中使用的本地变量。

为了满足工作模板,我们需要实现两个关键函数:ExecuteHandleOKCallbackExecute在工作线程中执行我们的Run函数(来自addons.h),并将返回的值赋给result。一旦Run完成,我们需要将这个结果发送回原始的 JavaScript 回调,我们的 Node 模块接口会发送。HandleOKCallback准备参数列表(argv),按照标准的错误优先 Node 回调模式的预期:我们将第一个错误参数设置为Null(),第二个参数设置为result。通过callback->Call(2, argv),原始回调将使用这两个参数进行调用,并相应地进行处理。

最后一步是创建模块导出文件index.js

const addon = require('./build/Release/nan_addon');
const width = 1e9;

function log(type, result, start) {
    const end = Date.now() - start;
    console.log(`${type} returned <${result}> in ${end}ms`)
}

function sync() {
    const start = Date.now();
    const result = addon.runSync(width);
    log('Sync', result, start);
}

function async() {
    const start = Date.now();
    addon.runAsync(width, (err, result) => {
        log('Async', result, start);
    });
}

console.log('1');
async();
console.log('2');
sync();
console.log('3');

创建完这个文件后,继续通过npm run build(或node-gyp rebuild)构建您的模块,并使用node index.js执行此文件。您应该在终端中看到类似以下的内容:

1
2
Sync returned <1000000000> in 1887ms
3
Async returned <1000000000> in 1889ms

这有什么意义呢?我们正在证明我们可以创建独立于单个 Node 进程线程的 C++函数。如果addon.runAsync不是异步运行的,输出将如下所示:

1
Async returned <1000000000> in 1889ms
2
Sync returned <1000000000> in 1887ms
3

然而,我们看到运行时记录了 1,runAsync进入了线程,记录了 2,然后是同步函数runSync,阻塞了事件循环(在同一个单一的 JavaScript 线程中运行)。完成后,这个同步函数宣布了它的结果,循环继续执行下一个指令记录 3,最后,待处理的回调被执行,runAsync的结果最后出现。

即使您不是 C++程序员,这里还有很多探索的空间。借助nan这些简单的构建模块,您可以构建行为越来越复杂的插件。当然,最大的优势是能够将长时间运行的任务交给操作系统,在一个非常快速的编译语言中运行。您的 Node 项目现在可以充分利用 C++的力量。

结束语

能够轻松地将 C++模块与 Node 程序链接起来是一种强大的新范式。因此,可能会有诱惑力,热情洋溢地开始为程序的每个可识别的部分编写 C++插件。虽然这可能是学习的一种有效方式,但从长远来看,这并不一定是最好的主意。尽管通常编译后的 C++运行速度比 JavaScript 代码更快,但要记住 V8 最终是在 JavaScript 代码上使用另一种类型的编译。在 V8 中运行的 JavaScript 非常高效。

此外,我们不希望在高并发环境中设计复杂的交互时失去 JavaScript 的简单组织和可预测的单线程运行时。请记住,Node 的出现部分是为了使开发人员在执行 I/O 时免于使用线程和相关复杂性。因此,请牢记一些规则。

C++模块实际上会更快吗?答案并不总是肯定的。跳转到不同的执行上下文,然后再返回到 V8 需要时间。Felix Geisendorfer的演讲描述了他构建快速 MySQL 绑定的工作,提供了一些关于在做出这些决定时应该如何思考的见解,网址为:www.youtube.com/watch?v=Kdwwvps4J9A。总的来说,除非真的需要做一些深入和昂贵的事情,需要更接近底层,否则应该坚持使用 JavaScript。

拆分代码库如何影响可维护性?虽然很难有任何开发人员建议使用效率低下的代码,但有时微不足道的性能提升并不能克服复杂性的增加,这可能导致更难以找到的错误或在共享或管理代码库时出现困难(包括未来尚未雇佣的团队成员)。

Node 已经将一个美丽的 JavaScript API 与一个非常强大且易于扩展的应用程序堆栈合并在一起。有了将 C++集成到你的应用程序中的能力,没有理由将 Node 排除在下一个项目考虑的技术列表之外。

链接和资源

关于编写 Node 插件的额外指南和资源可以在网上找到:

posted @ 2024-05-23 16:01  绝不原创的飞龙  阅读(20)  评论(0编辑  收藏  举报