JavaScript 权威指南第七版(GPT 重译)(七)(1)

简介: JavaScript 权威指南第七版(GPT 重译)(七)

第十六章:用 Node 进行服务器端 JavaScript

Node 是 JavaScript 与底层操作系统的绑定,使得编写 JavaScript 程序读写文件、执行子进程和在网络上通信成为可能。这使得 Node 作为以下用途变得有用:

  • 现代替代 shell 脚本的方式,不受 bash 和其他 Unix shell 繁琐语法的困扰。
  • 用于运行受信任程序的通用编程语言,不受 Web 浏览器对不受信任代码施加的安全约束。
  • 编写高效且高度并发的 Web 服务器的流行环境。

Node 的定义特点是其单线程事件驱动并通过默认异步 API 实现的并发性。如果你已经在其他语言中编程过但并没有做过太多 JavaScript 编码,或者如果你是一位经验丰富的客户端 JavaScript 程序员,习惯为 Web 浏览器编写代码,那么使用 Node 将需要一些调整,就像任何新的编程语言或环境一样。本章首先解释了 Node 的编程模型,重点是并发性,Node 用于处理流数据的 API,以及 Node 用于处理二进制数据的缓冲区类型。这些初始部分之后是突出和演示一些最重要的 Node API 的部分,包括用于处理文件、网络、进程和线程的 API。

一章不足以记录所有 Node 的 API,但我希望这一章能够解释足够的基础知识,让你能够在 Node 上提高效率,并确信你可以掌握任何你需要的新 API。

16.1 Node 编程基础

我们将从快速了解 Node 程序的结构以及它们如何与操作系统交互开始这一章节。

16.1.1 控制台输出

如果你习惯于为 Web 浏览器编程的 JavaScript,那么关于 Node 的一个小惊喜是 console.log() 不仅用于调试,而且是 Node 显示消息给用户或者更一般地向 stdout 流发送输出的最简单方式。以下是 Node 中经典的“Hello World”程序:

console.log("Hello World!");

有更低级别的方法可以写入 stdout,但没有比简单调用 console.log() 更花哨或更正式的方式。

在 Web 浏览器中,console.log()console.warn()console.error() 通常在开发者控制台中的输出旁边显示小图标,以指示日志消息的种类。Node 不会这样做,但使用 console.error() 显示的输出与使用 console.log() 显示的输出有所区别,因为 console.error() 写入 stderr 流。如果你正在使用 Node 编写一个程序,该程序旨在将 stdout 重定向到文件或管道,你可以使用 console.error() 将文本显示到用户将看到的控制台,即使使用 console.log() 打印的文本是隐藏的。

16.1.2 命令行参数和环境变量

如果你之前编写过设计为从终端或其他命令行界面调用的类 Unix 风格程序,你会知道这些程序通常主要从命令行参数获取输入,其次从环境变量获取输入。

Node 遵循这些 Unix 约定。一个 Node 程序可以从字符串数组 process.argv 中读取其命令行参数。这个数组的第一个元素始终是 Node 可执行文件的路径。第二个参数是 Node 正在执行的 JavaScript 代码文件的路径。在这个数组中的任何剩余元素都是你在调用 Node 时通过命令行传递的以空格分隔的参数。

例如,假设你将这个非常简短的 Node 程序保存到名为 argv.js 的文件中:

console.log(process.argv);

然后你可以执行该程序并看到如下输出:

$ node --trace-uncaught argv.js --arg1 --arg2 filename
[
  '/usr/local/bin/node',
  '/private/tmp/argv.js',
  '--arg1',
  '--arg2',
  'filename'
]

这里有几点需要注意:

  • process.argv 的第一个和第二个元素将是完全限定的文件系统路径,指向 Node 可执行文件和正在执行的 JavaScript 文件,即使你没有以这种方式输入它们。
  • 用于 Node 可执行文件本身的命令行参数由 Node 可执行文件消耗,不会出现在process.argv中。(在上面的示例中,--trace-uncaught命令行参数实际上并没有做任何有用的事情;它只是用来演示它不会出现在输出中。)任何出现在 JavaScript 文件名之后的参数(如--arg1filename)将出现在process.argv中。

Node 程序也可以从类 Unix 环境变量中获取输入。Node 通过process.env对象使这些变量可用。该对象的属性名称是环境变量名称,属性值(始终为字符串)是这些变量的值。

这是我系统上环境变量的部分列表:

$ node -p -e 'process.env'
{
  SHELL: '/bin/bash',
  USER: 'david',
  PATH: '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
  PWD: '/tmp',
  LANG: 'en_US.UTF-8',
  HOME: '/Users/david',
}

你可以使用node -hnode --help来查找-p-e命令行参数的作用。然而,作为提示,注意你可以将上面的行重写为node --eval 'process.env' --print

16.1.3 程序生命周期

node命令需要一个命令行参数来指定要运行的 JavaScript 代码文件。这个初始文件通常导入其他 JavaScript 代码模块,并可能定义自己的类和函数。然而,从根本上说,Node 会按顺序执行指定文件中的 JavaScript 代码。一些 Node 程序在执行文件中的最后一行代码后完成执行时退出。然而,通常情况下,一个 Node 程序将在执行初始文件后继续运行。正如我们将在接下来的章节中讨论的那样,Node 程序通常是异步的,基于回调和事件处理程序。Node 程序直到运行完初始文件并且所有回调都被调用且没有更多待处理事件时才会退出。一个基于 Node 的服务器程序监听传入的网络连接,理论上会永远运行,因为它总是会等待更多事件。

一个程序可以通过调用process.exit()来强制退出。用户通常可以通过在运行程序的终端窗口中键入 Ctrl-C 来终止 Node 程序。程序可以通过注册一个信号处理程序函数process.on("SIGINT", ()=>{})来忽略 Ctrl-C。

如果程序中的代码抛出异常而没有catch子句捕获它,程序将打印堆栈跟踪并退出。由于 Node 的异步特性,发生在回调或事件处理程序中的异常必须在本地处理或根本不处理,这意味着处理程序中异步部分发生的异常可能是一个困难的问题。如果你不希望这些异常导致程序完全崩溃,注册一个全局处理程序函数将被调用而不是崩溃:

process.setUncaughtExceptionCaptureCallback(e => {
    console.error("Uncaught exception:", e);
});

如果你的程序创建的 Promise 被拒绝并且没有.catch()调用来处理它,会出现类似的情况。截至 Node 13,这不是导致程序退出的致命错误,但会在控制台打印详细的错误消息。在未来的某个 Node 版本中,未处理的 Promise 拒绝预计将成为致命错误。如果你不希望未处理的拒绝打印错误消息或终止程序,注册一个全局处理程序函数:

process.on("unhandledRejection", (reason, promise) => {
    // reason is whatever value would have been passed to a .catch() function
    // promise is the Promise object that rejected

16.1.4 Node 模块

第十章记录了 JavaScript 模块系统,涵盖了 Node 模块和 ES6 模块。因为 Node 是在 JavaScript 拥有模块系统之前创建的,所以 Node 不得不创建自己的模块系统。Node 的模块系统使用require()函数将值导入模块,使用exports对象或module.exports属性从模块导出值。这些是 Node 编程模型的基本部分,并在§10.2 中详细介绍。

Node 13 添加了对标准 ES6 模块和基于 require 的模块(Node 称之为“CommonJS 模块”)的支持。这两种模块系统并不完全兼容,因此这有点棘手。Node 需要在加载模块之前知道该模块是否将使用require()module.exports,还是将使用importexport。当 Node 将 JavaScript 代码文件加载为 CommonJS 模块时,它会自动定义require()函数以及标识符exportsmodule,并且不会启用importexport关键字。另一方面,当 Node 将代码文件加载为 ES6 模块时,它必须启用importexport声明,并且不能定义额外的标识符如requiremoduleexports

告诉 Node 正在加载的模块的类型最简单的方法是将这些信息编码在文件扩展名中。如果您将 JavaScript 代码保存在以*.mjs结尾的文件中,那么 Node 将始终将其作为 ES6 模块加载,期望它使用importexport,并且不会提供require()函数。如果您将代码保存在以.cjs*结尾的文件中,那么 Node 将始终将其视为 CommonJS 模块,提供require()函数,并且如果您使用importexport声明,则会抛出 SyntaxError。

对于没有明确*.mjs.cjs扩展名的文件,Node 会在与文件相同的目录中查找名为package.json的文件,然后在每个包含目录中查找。一旦找到最近的package.json文件,Node 会检查 JSON 对象中的顶级type属性。如果type属性的值是“module”,那么 Node 会将文件加载为 ES6 模块。如果该属性的值是“commonjs”,那么 Node 会将文件加载为 CommonJS 模块。请注意,您不需要有package.json文件来运行 Node 程序:当找不到这样的文件时(或者找到文件但它没有type属性时),Node 会默认使用 CommonJS 模块。只有当您想要在 Node 中使用 ES6 模块而不想使用.mjs文件扩展名时,才需要使用这个package.json*技巧。

由于有大量使用 CommonJS 模块格式编写的现有 Node 代码,Node 允许 ES6 模块使用import关键字加载 CommonJS 模块。然而,反之则不成立:CommonJS 模块无法使用require()加载 ES6 模块。

16.1.5 Node 包管理器

安装 Node 时,通常也会得到一个名为 npm 的程序。这是 Node 包管理器,它帮助您下载和管理程序所依赖的库。npm 会在项目的根目录中的名为package.json的文件中跟踪这些依赖项(以及关于您的程序的其他信息)。由 npm 创建的这个package.json文件是您想要为项目使用 ES6 模块时会添加"type":"module"的地方。

本章不会详细介绍 npm(但请参见§17.4 以获取更多深入信息)。我在这里提到它是因为除非您编写的程序不使用任何外部库,否则您几乎肯定会使用 npm 或类似工具。例如,假设您将要开发一个 Web 服务器,并计划使用 Express 框架(https://expressjs.com)来简化任务。要开始,您可以为项目创建一个目录,然后在该目录中输入npm init。npm 会询问您项目名称、版本号等信息,然后根据您的回答创建一个初始的package.json文件。

现在要开始使用 Express,您可以输入npm install express。这告诉 npm 下载 Express 库以及其所有依赖项,并将所有包安装在本地*node_modules/*目录中:

$ npm install express
npm notice created a lockfile as package-lock.json. You should commit this file.
npm WARN my-server@1.0.0 No description
npm WARN my-server@1.0.0 No repository field.
+ express@4.17.1
added 50 packages from 37 contributors and audited 126 packages in 3.058s
found 0 vulnerabilities

当你使用 npm 安装一个包时,npm 会记录这种依赖关系——即你的项目依赖于 Express——在 package.json 文件中。有了在 package.json 中记录的这种依赖关系,你可以将你的代码和 package.json 的副本交给另一个程序员,他们只需输入 npm install 就可以自动下载和安装程序运行所需的所有库。

16.2 节点默认是异步的

JavaScript 是一种通用编程语言,因此完全可以编写乘法大矩阵或执行复杂统计分析等 CPU 密集型程序。但 Node 是为 I/O 密集型的程序(如网络服务器)而设计和优化的。特别是,Node 的设计使得轻松实现高度并发的服务器成为可能,可以同时处理许多请求。

然而,与许多编程语言不同,Node 不使用线程来实现并发。多线程编程通常很难正确完成,也很难调试。此外,线程是一个相对较重的抽象,如果你想编写一个能够处理数百个并发请求的服务器,使用数百个线程可能需要大量的内存。因此,Node 采用了 Web 使用的单线程 JavaScript 编程模型,这实际上是一种巨大的简化,使得创建网络服务器成为一种常规技能而不是一种神秘技能。

Node 通过将其 API 默认设置为异步和非阻塞来保持高并发水平,同时保持单线程编程模型。Node 非常认真地采取了非阻塞的方法,甚至可能会让你感到惊讶。你可能期望从网络读取和写入的函数是异步的,但 Node 更进一步,为从本地文件系统读取和写入文件定义了非阻塞异步函数。这是有道理的,当你考虑到:Node API 是在旋转硬盘仍然是标准的时代设计的,而在进行文件操作之前确实有毫秒级的阻塞“寻道时间”,等待磁盘旋转以开始文件操作。在现代数据中心,所谓的“本地”文件系统实际上可能在网络的某个地方,上面还有网络延迟。但即使异步读取文件对你来说是正常的,Node 仍然更进一步:例如,用于启动网络连接或查找文件修改时间的默认函数也是非阻塞的。

Node 的 API 中有一些同步但非阻塞的函数:它们运行完成并返回而无需阻塞。但大多数有趣的函数执行某种输入或输出,这些是异步函数,因此它们可以避免甚至最微小的阻塞。Node 是在 JavaScript 拥有 Promise 类之前创建的,因此异步 Node API 是基于回调的。(如果你还没有阅读或已经忘记第十三章,现在是回到那一章的好时机。)通常,你传递给异步 Node 函数的最后一个参数是一个回调函数。Node 使用 错误优先回调,通常用两个参数调用。错误优先回调的第一个参数通常在没有错误发生的情况下为 null,第二个参数是由你调用的原始异步函数产生的数据或响应。将错误参数放在第一位的原因是为了让你无法忽略它,你应该始终检查这个参数中是否有非空值。如果它是一个错误对象,甚至是一个整数错误代码或字符串错误消息,那么出现了问题。在这种情况下,你回调函数的第二个参数可能是 null

以下代码演示了如何使用非阻塞的readFile()函数读取配置文件,将其解析为 JSON,然后将解析后的配置对象传递给另一个回调函数:

const fs = require("fs");  // Require the filesystem module
// Read a config file, parse its contents as JSON, and pass the
// resulting value to the callback. If anything goes wrong,
// print an error message to stderr and invoke the callback with null
function readConfigFile(path, callback) {
    fs.readFile(path, "utf8", (err, text) => {
        if (err) {    // Something went wrong reading the file
            console.error(err);
            callback(null);
            return;
        }
        let data = null;
        try {
            data = JSON.parse(text);
        } catch(e) {  // Something went wrong parsing the file contents
            console.error(e);
        }
        callback(data);
    });
}

Node 早于标准化的 promises,但由于它在错误优先回调方面相当一致,使用util.promisify()包装器可以轻松创建基于 Promise 的变体。这是我们如何重写readConfigFile()函数以返回一个 Promise:

const util = require("util");
const fs = require("fs");  // Require the filesystem module
const pfs = {              // Promise-based variants of some fs functions
    readFile: util.promisify(fs.readFile)
};
function readConfigFile(path) {
    return pfs.readFile(path, "utf-8").then(text => {
        return JSON.parse(text);
    });
}

我们还可以使用asyncawait简化前面基于 Promise 的函数(再次,如果您尚未阅读第十三章,现在是一个好时机):

async function readConfigFile(path) {
    let text = await pfs.readFile(path, "utf-8");
    return JSON.parse(text);
}

util.promisify()包装器可以生成许多 Node 函数的基于 Promise 的版本。在 Node 10 及更高版本中,fs.promises对象有许多预定义的基于 Promise 的函数,用于处理文件系统。我们将在本章后面讨论它们,但请注意,在前面的代码中,我们可以用fs.promises.readFile()替换pfs.readFile()

我们曾经说过,Node 的编程模型是默认异步的。但为了程序员的方便,Node 确实定义了许多阻塞的同步变体函数,特别是在文件系统模块中。这些函数通常以Sync结尾的名称清晰标记。

当服务器首次启动并读取其配置文件时,它尚未处理网络请求,实际上几乎不可能发生并发。因此,在这种情况下,没有必要避免阻塞,我们可以安全地使用像fs.readFileSync()这样的阻塞函数。我们可以从这段代码中删除asyncawait,并编写我们的readConfigFile()函数的纯同步版本。这个函数不是调用回调或返回 Promise,而是简单地返回解析后的 JSON 值或抛出异常:

const fs = require("fs");
function readConfigFileSync(path) {
    let text = fs.readFileSync(path, "utf-8");
    return JSON.parse(text);
}

除了其错误优先的两参数回调之外,Node 还有许多使用基于事件的异步性的 API,通常用于处理流数据。我们稍后会更详细地介绍 Node 事件。

现在我们已经讨论了 Node 的积极非阻塞 API,让我们回到并发的话题。Node 的内置非阻塞函数使用操作系统版本的回调和事件处理程序。当您调用这些函数之一时,Node 会采取行动启动操作,然后向操作系统注册某种事件处理程序,以便在操作完成时通知它。您传递给 Node 函数的回调被内部存储,以便 Node 在操作系统向其发送适当事件时调用您的回调。

这种并发通常称为基于事件的并发。在其核心,Node 有一个运行“事件循环”的单个线程。当一个 Node 程序启动时,它运行您告诉它运行的任何代码。这段代码可能调用至少一个非阻塞函数,导致注册回调或事件处理程序与操作系统。 (如果没有,那么您编写了一个同步的 Node 程序,当它到达末尾时,Node 简单地退出。) 当 Node 到达程序末尾时,它会阻塞,直到发生事件,此时操作系统再次启动它。Node 将操作系统事件映射到您注册的 JavaScript 回调,然后调用该函数。您的回调函数可能调用更多的非阻塞 Node 函数,导致注册更多的操作系统事件处理程序。一旦您的回调函数运行完毕,Node 再次进入休眠状态,循环重复。

对于 Web 服务器和其他大部分时间都在等待输入和输出的 I/O 密集型应用程序来说,这种基于事件的并发方式是高效且有效的。只要使用非阻塞 API 并且存在一种从网络套接字到 JavaScript 函数的内部映射,Web 服务器就可以同时处理来自 50 个不同客户端的请求,而无需使用 50 个不同的线程。

16.3 缓冲区

在 Node 中你经常会使用的一种数据类型是 Buffer 类。一个 Buffer 很像一个字符串,只不过它是一系列字节而不是一系列字符。在核心 JavaScript 支持类型化数组之前(参见 §11.2),也没有 Uint8Array 来表示无符号字节的数组。Node 定义了 Buffer 类来填补这个需求。现在 Uint8Array 是 JavaScript 语言的一部分,Node 的 Buffer 类是 Uint8Array 的子类。

Buffer 与其 Uint8Array 超类的区别在于它设计用于与 JavaScript 字符串互操作:缓冲区中的字节可以从字符字符串初始化或转换为字符字符串。字符编码将某个字符集中的每个字符映射到一个整数。给定一个文本字符串和一个字符编码,我们可以将字符串中的字符 编码 为一系列字节。给定一个(正确编码的)字节序列和一个字符编码,我们可以将这些字节 解码 为一系列字符。Node 的 Buffer 类有执行编码和解码的方法,你可以通过这些方法来识别,因为它们期望一个 encoding 参数来指定要使用的编码。

在 Node 中,编码是以字符串形式指定的。支持的编码有:

"utf8"

这是在没有指定编码时的默认编码,也是你最有可能使用的 Unicode 编码。

"utf16le"

两字节的 Unicode 字符,采用小端序排序。编码为 \uffff 以上的码点会被编码为一对两字节序列。编码 "ucs2" 是一个别名。

"latin1"

每个字符一个字节的 ISO-8859-1 编码,定义了适用于许多西欧语言的字符集。因为字节和 latin-1 字符之间有一对一的映射,所以这种编码也被称为 "binary"

"ascii"

仅包含 7 位英文 ASCII 编码,是 "utf8" 编码的严格子集。

"hex"

这种编码将每个字节转换为一对 ASCII 十六进制数字。

"base64"

这种编码将每个三字节序列转换为四个 ASCII 字符。

这里有一些示例代码,演示如何使用 Buffer 以及如何进行字符串和 Buffer 之间的转换:

let b = Buffer.from([0x41, 0x42, 0x43]);          // <Buffer 41 42 43>
b.toString()                                      // => "ABC"; default "utf8"
b.toString("hex")                                 // => "414243"
let computer = Buffer.from("IBM3111", "ascii");   // Convert string to Buffer
for(let i = 0; i < computer.length; i++) {        // Use Buffer as byte array
    computer[i]--;                                // Buffers are mutable
}
computer.toString("ascii")                        // => "HAL2000"
computer.subarray(0,3).map(x=>x+1).toString()     // => "IBM"
// Create new "empty" buffers with Buffer.alloc()
let zeros = Buffer.alloc(1024);                   // 1024 zeros
let ones = Buffer.alloc(128, 1);                  // 128 ones
let dead = Buffer.alloc(1024, "DEADBEEF", "hex"); // Repeating pattern of bytes
// Buffers have methods for reading and writing multi-byte values
// from and to a buffer at any specified offset.
dead.readUInt32BE(0)       // => 0xDEADBEEF
dead.readUInt32BE(1)       // => 0xADBEEFDE
dead.readBigUInt64BE(6)    // => 0xBEEFDEADBEEFDEADn
dead.readUInt32LE(1020)    // => 0xEFBEADDE

如果你编写一个实际操作二进制数据的 Node 程序,你可能会大量使用 Buffer 类。另一方面,如果你只是处理从文件或网络读取或写入的文本,那么你可能只会遇到 Buffer 作为数据的中间表示。许多 Node API 可以将输入或返回输出作为字符串或 Buffer 对象。通常,如果你从这些 API 中传递一个字符串,或者期望返回一个字符串,你需要指定要使用的文本编码的名称。如果你这样做了,那么你可能根本不需要使用 Buffer 对象。

16.4 事件和 EventEmitter

正如描述的那样,Node 的所有 API 默认都是异步的。对于其中的许多 API,这种异步性采用的形式是两个参数的错误优先回调,当请求的操作完成时调用。但是一些更复杂的 API 是基于事件的。当 API 设计围绕对象而不是函数时,或者当需要多次调用回调函数时,或者当可能需要多种类型的回调函数时,通常会出现这种情况。例如,考虑 net.Server 类:这种类型的对象是一个服务器套接字,用于接受来自客户端的传入连接。当它首次开始监听连接时,会发出“listening”事件,每当客户端连接时会发出“connection”事件,当关闭并不再监听时会发出“close”事件。

在 Node 中,发出事件的对象是 EventEmitter 的实例或 EventEmitter 的子类:

const EventEmitter = require("events"); // Module name does not match class name
const net = require("net");
let server = new net.Server();          // create a Server object
server instanceof EventEmitter          // => true: Servers are EventEmitters

EventEmitters 的主要特点是它们允许您使用 on() 方法注册事件处理程序函数。EventEmitters 可以发出多种类型的事件,事件类型通过名称标识。要注册事件处理程序,请调用 on() 方法,传递事件类型的名称以及当事件发生时应该调用的函数。EventEmitters 可以使用任意数量的参数调用处理程序函数,您需要阅读特定 EventEmitter 的特定类型事件的文档,以了解您应该期望传递的参数:

const net = require("net");
let server = new net.Server();          // create a Server object
server.on("connection", socket => {     // Listen for "connection" events
    // Server "connection" events are passed a socket object
    // for the client that just connected. Here we send some data
    // to the client and disconnect.
    socket.end("Hello World", "utf8");
});

如果您更喜欢使用更明确的方法名称来注册事件侦听器,也可以使用 addListener()。您可以使用 off()removeListener() 来删除先前注册的事件侦听器。作为特例,您可以通过调用 once() 而不是 on() 来注册一个在第一次触发后将自动删除的事件侦听器。

当特定类型的事件发生在特定的 EventEmitter 对象上时,Node 会调用该 EventEmitter 上当前注册的所有处理程序函数来处理该类型的事件。它们按照从第一个注册到最后注册的顺序依次调用。如果有多个处理程序函数,它们将在单个线程上依次调用:请记住,Node 中没有并行处理。重要的是,事件处理函数是同步调用的,而不是异步调用的。这意味着 emit() 方法不会将事件处理程序排队以在以后的某个时间调用。emit() 会依次调用所有已注册的处理程序,并且在最后一个事件处理程序返回之前不会返回。

实际上,这意味着当内置的 Node API 发出事件时,该 API 基本上会阻塞在您的事件处理程序上。如果编写一个调用像 fs.readFileSync() 这样的阻塞函数的事件处理程序,直到同步文件读取完成,将不会发生进一步的事件处理。如果您的程序是一个需要响应的网络服务器之类的程序,那么重要的是保持事件处理程序函数非阻塞和快速。如果需要在事件发生时进行大量计算,通常最好使用处理程序使用 setTimeout() 异步调度该计算(参见 §11.10)。Node 还定义了 setImmediate(),它会在处理完所有挂起的回调和事件后立即调度一个函数。

EventEmitter 类还定义了一个emit()方法,导致注册的事件处理程序函数被调用。如果您正在定义自己的基于事件的 API,这很有用,但在使用现有 API 进行编程时通常不常用。emit()必须以事件类型的名称作为第一个参数调用。传递给emit()的任何其他参数都成为注册的事件处理程序函数的参数。处理程序函数还使用设置为 EventEmitter 对象本身的this值调用,这通常很方便。(请记住,箭头函数总是使用定义它们的上下文的this值,并且不能使用任何其他this值调用。尽管如此,箭头函数通常是编写事件处理程序的最方便方式。)

事件处理程序函数返回的任何值都会被忽略。但是,如果事件处理程序函数抛出异常,则它会从emit()调用中传播出去,并阻止执行任何在抛出异常之后注册的处理程序函数。

请记住,Node 的基于回调的 API 使用错误优先回调,重要的是您始终检查第一个回调参数以查看是否发生错误。对于基于事件的 API,等效的是“error”事件。由于基于事件的 API 通常用于网络和其他形式的流式 I/O,它们容易受到不可预测的异步错误的影响,大多数 EventEmitters 在发生错误时定义了一个“error”事件。每当使用基于事件的 API 时,您应该养成注册“error”事件处理程序的习惯。“error”事件在 EventEmitter 类中得到特殊处理。如果调用emit()来发出“error”事件,并且没有为该事件类型注册处理程序,则将抛出异常。由于这是异步发生的,因此您无法在catch块中处理异常,因此这种错误通常会导致程序退出。

16.5 流

在实现处理数据的算法时,几乎总是最容易将所有数据读入内存,进行处理,然后将数据写出。例如,您可以编写一个 Node 函数来复制文件,就像这样。¹

const fs = require("fs");
// An asynchronous but nonstreaming (and therefore inefficient) function.
function copyFile(sourceFilename, destinationFilename, callback) {
    fs.readFile(sourceFilename, (err, buffer) => {
        if (err) {
            callback(err);
        } else {
            fs.writeFile(destinationFilename, buffer, callback);
        }
    });
}

这个copyFile()函数使用异步函数和回调函数,因此不会阻塞,并适用于像服务器这样的并发程序。但请注意,它必须分配足够的内存来一次性容纳整个文件的内容。在某些情况下这可能没问题,但如果要复制的文件非常大,或者您的程序高度并发且可能同时复制许多文件时,它就会开始出现问题。这个copyFile()实现的另一个缺点是它在完成读取旧文件之前无法开始写入新文件。

解决这些问题的方法是使用流算法,其中数据“流”进入您的程序,被处理,然后流出您的程序。思路是您的算法以小块处理数据,完整数据集不会一次性保存在内存中。当流式解决方案可行时,它们更节省内存,并且也可能更快。Node 的网络 API 是基于流的,Node 的文件系统模块为读取和写入文件定义了流 API,因此您可能会在编写的许多 Node 程序中使用流 API。我们将在“流动模式”中看到copyFile()函数的流式版本。

Node 支持四种基本的流类型:

可读

可读流是数据的来源。例如,由fs.createReadStream()返回的流是可以读取指定文件内容的流。process.stdin是另一个可读流,返回标准输入的数据。

可写

可写流是数据的接收端或目的地。例如,fs.createWriteStream() 的返回值是一个可写流:它允许以块的形式向其写入数据,并将所有数据输出到指定的文件。

双工

双工流将可读流和可写流合并为一个对象。例如,net.connect() 返回的 Socket 对象和其他 Node 网络 API 返回的对象都是双工流。如果向套接字写入数据,则数据将通过网络发送到套接字连接的计算机。如果从套接字读取数据,则可以访问另一台计算机写入的数据。

转换

转换流也是可读和可写的,但与双工流有一个重要的区别:写入转换流的数据变得可读,通常以某种转换形式从同一流中读取。例如,zlib.createGzip() 函数返回一个转换流,用于压缩(使用 gzip 算法)写入其中的数据。类似地,crypto.createCipheriv() 函数返回一个转换流,用于加密或解密写入其中的数据。

默认情况下,流读取和写入缓冲区。如果调用可读流的 setEncoding() 方法,它将返回解码后的字符串,而不是 Buffer 对象。如果向可写缓冲区写入字符串,它将自动使用缓冲区的默认编码或您指定的任何编码进行编码。Node 的流 API 还支持“对象模式”,其中流读取和写入比缓冲区和字符串更复杂的对象。Node 的核心 API 都不使用此对象模式,但您可能会在其他库中遇到它。

可读流必须从某处读取数据,可写流必须将数据写入某处,因此每个流都有两个端点:一个输入和一个输出,或者一个源和一个目的地。基于流的 API 的棘手之处在于流的两端几乎总是以不同的速度流动。也许从流中读取数据的代码想要比实际写入流中的数据更快地读取和处理数据。或者反过来:也许数据被写入流中的速度比从流的另一端读取和提取数据的速度更快。流实现几乎总是包含一个内部缓冲区,用于保存已写入但尚未读取的数据。缓冲有助于确保在请求时有可读取的数据,并且在写入数据时有空间可用于保存数据。但是这两件事情都无法保证,基于流的编程的本质是读取者有时必须等待数据被写入(因为流缓冲区为空),写入者有时必须等待数据被读取(因为流缓冲区已满)。

在使用基于线程的并发性编程环境中,流式 API 通常具有阻塞调用:读取数据的调用在数据到达流之前不会返回,写入数据的调用会阻塞,直到流的内部缓冲区有足够的空间来容纳新数据。然而,在基于事件的并发模型中,阻塞调用是没有意义的,Node 的流式 API 是基于事件和回调的。与其他 Node API 不同,本章后面将描述的方法没有“Sync”版本。

通过事件协调流的可读性(缓冲区不为空)和可写性(缓冲区不满)的需求使得 Node 的流式 API 稍显复杂。这一复杂性加剧了这些 API 多年来的演变和变化:对于可读流,有两种完全不同的 API 可供使用。尽管复杂,但值得理解和掌握 Node 的流式 API,因为它们能够在程序中实现高吞吐量的 I/O。

接下来的小节演示了如何从 Node 的流类中读取和写入。

16.5.1 管道

有时,您需要从流中读取数据,然后将相同的数据写入另一个流。例如,想象一下,您正在编写一个简单的 HTTP 服务器,用于提供静态文件目录。在这种情况下,您需要从文件输入流中读取数据,并将其写入网络套接字。但是,您可以简单地将两个套接字连接在一起作为“管道”,让 Node 为您处理复杂性,而不是编写自己的处理读取和写入的代码。只需将可写流传递给可读流的pipe()方法:

const fs = require("fs");
function pipeFileToSocket(filename, socket) {
    fs.createReadStream(filename).pipe(socket);
}

以下实用函数将一个流导向另一个流,并在完成或发生错误时调用回调函数:

function pipe(readable, writable, callback) {
    // First, set up error handling
    function handleError(err) {
        readable.close();
        writable.close();
        callback(err);
    }
    // Next define the pipe and handle the normal termination case
    readable
        .on("error", handleError)
        .pipe(writable)
        .on("error", handleError)
        .on("finish", callback);
}

转换流在管道中特别有用,并创建涉及两个以上流的管道。以下是一个压缩文件的示例函数:

const fs = require("fs");
const zlib = require("zlib");
function gzip(filename, callback) {
    // Create the streams
    let source = fs.createReadStream(filename);
    let destination = fs.createWriteStream(filename + ".gz");
    let gzipper = zlib.createGzip();
    // Set up the pipeline
    source
        .on("error", callback)   // call callback on read error
        .pipe(gzipper)
        .pipe(destination)
        .on("error", callback)   // call callback on write error
        .on("finish", callback); // call callback when writing is complete
}

使用pipe()方法将数据从一个可读流复制到一个可写流很容易,但在实践中,通常需要以某种方式处理数据,因为它在程序中流动。一种方法是实现自己的转换流来进行处理,这种方法允许您避免手动读取和写入流。例如,下面是一个类似 Unix grep实用程序的函数:它从输入流中读取文本行,但只写入与指定正则表达式匹配的行:

const stream = require("stream");
class GrepStream extends stream.Transform {
    constructor(pattern) {
        super({decodeStrings: false});// Don't convert strings back to buffers
        this.pattern = pattern;       // The regular expression we want to match
        this.incompleteLine = "";     // Any remnant of the last chunk of data
    }
    // This method is invoked when there is a string ready to be
    // transformed. It should pass transformed data to the specified
    // callback function. We expect string input so this stream should
    // only be connected to readable streams that have had
    // setEncoding() called on them.
    _transform(chunk, encoding, callback) {
        if (typeof chunk !== "string") {
            callback(new Error("Expected a string but got a buffer"));
            return;
        }
        // Add the chunk to any previously incomplete line and break
        // everything into lines
        let lines = (this.incompleteLine + chunk).split("\n");
        // The last element of the array is the new incomplete line
        this.incompleteLine = lines.pop();
        // Find all matching lines
        let output = lines                     // Start with all complete lines,
            .filter(l => this.pattern.test(l)) // filter them for matches,
            .join("\n");                       // and join them back up.
        // If anything matched, add a final newline
        if (output) {
            output += "\n";
        }
        // Always call the callback even if there is no output
        callback(null, output);
    }
    // This is called right before the stream is closed.
    // It is our chance to write out any last data.
    _flush(callback) {
        // If we still have an incomplete line, and it matches
        // pass it to the callback
        if (this.pattern.test(this.incompleteLine)) {
            callback(null, this.incompleteLine + "\n");
        }
    }
}
// Now we can write a program like 'grep' with this class.
let pattern = new RegExp(process.argv[2]); // Get a RegExp from command line.
process.stdin                              // Start with standard input,
    .setEncoding("utf8")                   // read it as Unicode strings,
    .pipe(new GrepStream(pattern))         // pipe it to our GrepStream,
    .pipe(process.stdout)                  // and pipe that to standard out.
    .on("error", () => process.exit());    // Exit gracefully if stdout closes.

16.5.2 异步迭代

在 Node 12 及更高版本中,可读流是异步迭代器,这意味着在async函数中,您可以使用for/await循环从流中读取字符串或缓冲区块,使用的代码结构类似于同步代码。 (有关异步迭代器和for/await循环的更多信息,请参见§13.4。)

使用异步迭代器几乎和使用pipe()方法一样简单,当您需要以某种方式处理每个读取的块时,可能更容易。以下是我们如何使用async函数和for/await循环重写前一节中的grep程序的方法:

// Read lines of text from the source stream, and write any lines
// that match the specified pattern to the destination stream.
async function grep(source, destination, pattern, encoding="utf8") {
    // Set up the source stream for reading strings, not Buffers
    source.setEncoding(encoding);
    // Set an error handler on the destination stream in case standard
    // output closes unexpectedly (when piping output to `head`, e.g.)
    destination.on("error", err => process.exit());
    // The chunks we read are unlikely to end with a newline, so each will
    // probably have a partial line at the end. Track that here
    let incompleteLine = "";
    // Use a for/await loop to asynchronously read chunks from the input stream
    for await (let chunk of source) {
        // Split the end of the last chunk plus this one into lines
        let lines = (incompleteLine + chunk).split("\n");
        // The last line is incomplete
        incompleteLine = lines.pop();
        // Now loop through the lines and write any matches to the destination
        for(let line of lines) {
            if (pattern.test(line)) {
                destination.write(line + "\n", encoding);
            }
        }
    }
    // Finally, check for a match on any trailing text.
    if (pattern.test(incompleteLine)) {
        destination.write(incompleteLine + "\n", encoding);
    }
}
let pattern = new RegExp(process.argv[2]);   // Get a RegExp from command line.
grep(process.stdin, process.stdout, pattern) // Call the async grep() function.
    .catch(err => {                          // Handle asynchronous exceptions.
        console.error(err);
        process.exit();
    });

16.5.3 写入流和处理背压

前面代码示例中的异步grep()函数演示了如何将可读流用作异步迭代器,但它还演示了您可以通过将其传递给write()方法来简单地向可写流写入数据。write()方法将缓冲区或字符串作为第一个参数。 (对象流期望其他类型的对象,但超出了本章的范围。)如果传递缓冲区,则将直接写入该缓冲区的字节。如果传递字符串,则在写入之前将其编码为字节的缓冲区。当您将字符串作为write()的唯一参数传递时,可写流具有默认编码。默认编码通常为“utf8”,但您可以通过在可写流上调用setDefaultEncoding()来显式设置它。或者,当您将字符串作为write()的第一个参数传递时,可以将编码名称作为第二个参数传递。

write()可选地将回调函数作为其第三个参数。当数据实际写入并不再位于可写流的内部缓冲区中时,将调用此函数。 (如果发生错误,也可能调用此回调,但不能保证。您应在可写流上注册“error”事件处理程序以检测错误。)

write()方法具有非常重要的返回值。当您在流上调用write()时,它将始终接受并缓冲您传递的数据块。如果内部缓冲区尚未满,则返回true。或者,如果缓冲区现在已满或过满,则返回false。此返回值是建议性的,您可以忽略它——如果您继续调用write(),可写流将根据需要扩大其内部缓冲区。但请记住,首先使用流式 API 的原因是避免一次性在内存中保存大量数据的成本。

write()方法返回false的返回值是一种背压形式:流向你发送的消息,表示你写入数据的速度比处理速度快。对这种背压的正确响应是停止调用write(),直到流发出“drain”事件,表示缓冲区中再次有空间。例如,下面是一个向流写入数据的函数,并在可以继续向流写入更多数据时调用回调函数:

function write(stream, chunk, callback) {
    // Write the specified chunk to the specified stream
    let hasMoreRoom = stream.write(chunk);
    // Check the return value of the write() method:
    if (hasMoreRoom) {                  // If it returned true, then
        setImmediate(callback);         // invoke callback asynchronously.
    } else {                            // If it returned false, then
        stream.once("drain", callback); // invoke callback on drain event.
    }
}

有时可以连续调用write()多次,有时必须在写入之间等待事件,这导致算法变得笨拙。这就是使用pipe()方法如此吸引人的原因之一:当你使用pipe()时,Node 会自动为你处理背压。

如果你在程序中使用awaitasync,并将可读流视为异步迭代器,那么实现上面的write()实用程序的基于 Promise 的版本以正确处理背压是很简单的。在我们刚刚看过的异步grep()函数中,我们没有处理背压。下面示例中的异步copy()函数演示了如何正确处理背压。请注意,此函数只是将源流中的块复制到目标流中,并调用copy(source, destination)就像调用source.pipe(destination)一样:

// This function writes the specified chunk to the specified stream and
// returns a Promise that will be fulfilled when it is OK to write again.
// Because it returns a Promise, it can be used with await.
function write(stream, chunk) {
    // Write the specified chunk to the specified stream
    let hasMoreRoom = stream.write(chunk);
    if (hasMoreRoom) {                     // If buffer is not full, return
        return Promise.resolve(null);      // an already resolved Promise object
    } else {
        return new Promise(resolve => {    // Otherwise, return a Promise that
            stream.once("drain", resolve); // resolves on the drain event.
        });
    }
}
// Copy data from the source stream to the destination stream
// respecting backpressure from the destination stream.
// This is much like calling source.pipe(destination).
async function copy(source, destination) {
    // Set an error handler on the destination stream in case standard
    // output closes unexpectedly (when piping output to `head`, e.g.)
    destination.on("error", err => process.exit());
    // Use a for/await loop to asynchronously read chunks from the input stream
    for await (let chunk of source) {
        // Write the chunk and wait until there is more room in the buffer.
        await write(destination, chunk);
    }
}
// Copy standard input to standard output
copy(process.stdin, process.stdout);

在我们结束对流写入的讨论之前,再次注意,不响应背压可能导致程序使用的内存超出预期,当可写流的内部缓冲区溢出并不断增大时。如果你正在编写一个网络服务器,这可能是一个远程可利用的安全问题。假设你编写了一个通过网络传输文件的 HTTP 服务器,但你没有使用pipe(),也没有花时间处理write()方法的背压。攻击者可以编写一个 HTTP 客户端,发起对大文件(如图像)的请求,但实际上从未读取请求的主体。由于客户端没有从网络中读取数据,而服务器也没有响应背压,服务器上的缓冲区将会溢出。如果攻击者有足够的并发连接,这可能会演变成一个拒绝服务攻击,使你的服务器变慢甚至崩溃。

16.5.4 使用事件读取流

Node 的可读流有两种模式,每种模式都有自己的读取 API。如果你的程序不能使用管道或异步迭代,你将需要选择这两种基于事件的 API 之一来处理流。重要的是你只使用其中一种 API,不要混合使用这两种 API。

流动模式

流动模式中,当可读数据到达时,它会立即以“data”事件的形式发出。要在此模式下从流中读取数据,只需为“data”事件注册事件处理程序,流将在数据块(缓冲区或字符串)可用时将其推送给你。请注意,在流动模式下不需要调用read()方法:你只需要处理“data”事件。请注意,新创建的流不会立即处于流动模式。注册“data”事件处理程序会将流切换到流动模式。方便的是,这意味着流在注册第一个“data”事件处理程序之前不会发出“data”事件。

如果你正在使用流模式从可读流中读取数据,处理数据,然后将其写入可写流,那么你可能需要处理可写流的背压。如果write()方法返回false表示写入缓冲区已满,你可以在可读流上调用pause()来暂时停止data事件。然后,当你从可写流中收到“drain”事件时,你可以在可读流上调用resume()来重新开始data事件的流动。

流在流动模式下在达到流的末尾时会发出一个“end”事件。这个事件表示不会再发出更多的“data”事件。并且,像所有流一样,如果发生错误,将会发出一个“error”事件。

在流部分的开头,我们展示了一个非流式的copyFile()函数,并承诺会有一个更好的版本。以下代码展示了如何实现一个使用流动模式 API 并处理背压的流式copyFile()函数。这本来更容易通过pipe()调用来实现,但在这里作为协调从一个流到另一个流的数据流的多个事件处理程序的有用演示。

const fs = require("fs");
// A streaming file copy function, using "flowing mode".
// Copies the contents of the named source file to the named destination file.
// On success, invokes the callback with a null argument. On error,
// invokes the callback with an Error object.
function copyFile(sourceFilename, destinationFilename, callback) {
    let input = fs.createReadStream(sourceFilename);
    let output = fs.createWriteStream(destinationFilename);
    input.on("data", (chunk) => {          // When we get new data,
        let hasRoom = output.write(chunk); // write it to the output stream.
        if (!hasRoom) {                    // If the output stream is full
            input.pause();                 // then pause the input stream.
        }
    });
    input.on("end", () => {                // When we reach the end of input,
        output.end();                      // tell the output stream to end.
    });
    input.on("error", err => {             // If we get an error on the input,
        callback(err);                     // call the callback with the error
        process.exit();                    // and quit.
    });
    output.on("drain", () => {             // When the output is no longer full,
        input.resume();                    // resume data events on the input
    });
    output.on("error", err => {            // If we get an error on the output,
        callback(err);                     // call the callback with the error
        process.exit();                    // and quit.
    });
    output.on("finish", () => {            // When output is fully written
        callback(null);                    // call the callback with no error.
    });
}
// Here's a simple command-line utility to copy files
let from = process.argv[2], to = process.argv[3];
console.log(`Copying file ${from} to ${to}...`);
copyFile(from, to, err => {
    if (err) {
        console.error(err);
    } else {
        console.log("done.");
    }
});

JavaScript 权威指南第七版(GPT 重译)(七)(2)https://developer.aliyun.com/article/1485473

相关文章
|
前端开发 JavaScript 算法
JavaScript 权威指南第七版(GPT 重译)(七)(3)
JavaScript 权威指南第七版(GPT 重译)(七)
32 0
|
12天前
|
存储 前端开发 JavaScript
JavaScript 权威指南第七版(GPT 重译)(六)(4)
JavaScript 权威指南第七版(GPT 重译)(六)
90 2
JavaScript 权威指南第七版(GPT 重译)(六)(4)
|
12天前
|
前端开发 JavaScript API
JavaScript 权威指南第七版(GPT 重译)(六)(3)
JavaScript 权威指南第七版(GPT 重译)(六)
55 4
|
12天前
|
JSON 前端开发 JavaScript
JavaScript 权威指南第七版(GPT 重译)(五)(2)
JavaScript 权威指南第七版(GPT 重译)(五)
36 5
|
2月前
|
人工智能 自然语言处理 物联网
Predibase发布25个LoRA,超越GPT-4的Mistral模型
【2月更文挑战第24天】Predibase发布25个LoRA,超越GPT-4的Mistral模型
32 2
Predibase发布25个LoRA,超越GPT-4的Mistral模型
|
3月前
|
人工智能 搜索推荐 机器人
微软 Copilot 推出多个定制 GPT 模型,包括健身教练、度假计划师等
【2月更文挑战第9天】微软 Copilot 推出多个定制 GPT 模型,包括健身教练、度假计划师等
40 2
微软 Copilot 推出多个定制 GPT 模型,包括健身教练、度假计划师等
|
5月前
|
人工智能 搜索推荐 安全
GPT Prompt编写的艺术:如何提高AI模型的表现力
GPT Prompt编写的艺术:如何提高AI模型的表现力
184 0
|
4天前
|
机器学习/深度学习 传感器 人工智能
科技周报 | GPT商店上线即乱;大模型可被故意“教坏”?
科技周报 | GPT商店上线即乱;大模型可被故意“教坏”?
16 1
|
2月前
|
编解码 人工智能 语音技术
GPT-SoVits:刚上线两天就获得了1.4k star的开源声音克隆项目!效果炸裂的跨语言音色克隆模型!
GPT-SoVits:刚上线两天就获得了1.4k star的开源声音克隆项目!效果炸裂的跨语言音色克隆模型!
140 3
|
2月前
|
人工智能 自然语言处理 测试技术
四大模型横评,GPT-4原文复制最严重
【2月更文挑战第19天】四大模型横评,GPT-4原文复制最严重
37 1
四大模型横评,GPT-4原文复制最严重