Node 是 JavaScript 与底层操作系统绑定的结合,因而可以让 JavaScript 程序读写文件、执行子进程,以及实现网络通信,为此 Node 得到了广泛应用。Node 的典型特点是由其默认异步的 API 赋能的单线程基于事件的并发能力。
Node 编程基础
首先介绍 Node 程序的构成,看一看它们如何与操作系统交互。
控制台输出
console.log
是 Node 向用户显示消息的最简单方式,它向标准输出流(stdout)发送输出。console.error
类似,但向标准错误流(stderr)发送输出。
1 | console.log("output stdout"); |
命令行参数和环境变量
Node程序可以从字符串数组 process.argv
中读取其命令行参数:
- 这个数组的第一个元素始终是 Node 可执行文件的路径
- 第二个参数是 Node 执行的 JavaScript 代码文件的路径
- 数组中剩下的所有元素都是你在调用 Node 时,通过命令行传给它的空格分隔的参数
- 给 Node 可执行文件且由它解释的命令行参数会被 Node 可执行文件使用,不会出现在
process.argv
中。出现在 JavaScript 文件名之后的任何参数都会出现在process.argv
中
1 | console.log(process.argv); |
1 | # node --trace-uncaught args.js --arg1 1 --arg2 2 |
Node 程序也会从 Unix 风格的环境变量中获取输入。Node 把这些变量保存在 process.env
对象中使用。这个对象的属性名是环境变量的属性名,而属性值(始终是字符串)是对应变量的值。
程序生命周期
node 命令期待命令行参数指定要执行的 JavaScript 文件。这个初始的文件通常会导入其他 JavaScript 代码的模块,也可能定义它自己的类和函数。Node 基本上是自顶向下执行指定文件中的 JavaScript 代码。Node 程序在运行完初始文件、调用完所有回调、不再有未决事件之前不会退出。
- 程序通过调用
process.exit()
可以强制自己退出 - 用户通常需要在终端窗口中按
Ctrl-C
来终止运行中的 Node 程序。程序通过使用process.on("SIGINT", ()=>{})
注册信号处理函数可以忽略Ctrl-C
- 如果程序中的代码抛出异常,也没有 catch 子句捕获该异常,程序会打印栈追踪信息并退出。类似地,如果你的程序创建的一个期约被拒绝,而且没有
.catch()
调用处理它,也会遇到这种问题 - 如果你不想让这些异常导致程序崩溃,可以注册一个全局处理程序,以备调用,防止崩溃
1 | process.setUncaughtExceptionCaptureCallback((err) => { |
Node 模块
之前介绍过 JavaScript 模块系统,包括 Node 模块和 ES6 模块。因为 Node 是在 JavaScript 有模块系统之前创造的,所以它必须自己创造一个模块系统。Node 的模块系统使用 require()
函数向模块中导入值,使用 exports
对象或 module.exports
属性从模块中导出值
Node 13 增加了对标准 ES6 模块的支持,同时仍支持基于 require()
的模块(Node 称其为 CommonJS 模块
。这两个模块系统并非完全兼容,因此两者并存有些棘手:
- Node 在加载模块前,需要知道该模块会使用
require()
和module.exports
,还是 import 和 export - Node 在把一个 JavaScript 文件加载为 CommonJS 模块时,会自动定义
require()
函数以及标识符 exports 和 module,不会启用 import 和 export 关键字 - 在把一个文件加载为 ES6 模块时,它必须启用 import 和 export 声明,同时必须不定义 require、module 和 exports 等额外的标识符
告诉 Node 它要加载的是什么模块的最简单方式,就是将信息编码到不同的扩展名中:
- 如果你把 JavaScript 代码保存在
.mjs
结尾的文件中,那么 Node 始终会将它作为一个 ES6 模块来加载 - 如果把代码保存在
.cjs
结尾的文件中,那么 Node 始终会将它作为一个 CommonJSS 模块来对待
对于没有明确给出 .mjs
或 cjs
扩展名的文件,Node 会在同级目录及所有包含目录中查找一个名为 package.json
的文件。一旦找到最近的 package.json
文件,Node 会检查其中 JSON 对象的顶级 type 属性:
- 如果这个 type 属性的值是 module,Node 将该文件按 ES6 模块来加载
- 如果这个属性的值是 commonjs,那么 Node 就按 CommonJS 模块来加载该文件
- 如果没有找到这个文件(或找到该文件但它没有 type 属性),Node 默认会使用 CommonJS 模块
因为大量现有的 Node 代码使用的都是 CommonJS 模块格式,Node 允许 ES6 模块使用 import 关键字加载 CommonJS 模块。但反之则不可以:CommonJS 模块不能使用 require() 加载 ES6 模块。
Node 包管理器
你在安装 Node 的同时,也会得到一个名为 npm 的程序。这个程序就是 Node 的包管理器,它可以帮你下载和管理程序的依赖库。npm 通过位于程序根目录下的 package.json
文件跟踪依赖(以及与程序相关的其他信息)。
假设你打算开发一个 Web 服务器,为了省事计划使用 Express 框架:
- 那么首先你需要为这个项目创建一个目录,然后在该目录中运行
npm init
- npm 会询问项目名、版本号等信息,最终根据你的回答创建一个初始的
package.json
文件 - 为了使用 Express,需要运行
npm install express
。这个命令告诉 npm 下载Express
库及其所有依赖,并把所有包都安装到本地的 node_modules 目录下 - 在通过 npm 安装一个包时,npm 会在
package.json
文件中记录这个依赖 - 之后其他 Node 程序员依据这个文件就可以自动下载并安装运行你的程序所需的全部依赖库
Node 默认异步
JavaScript是一门通用的编程语言,因此完全可能用于计算密集型的应用程序。然而,Node是针对 I/O
密集型程序(如网络服务器)进行设计和优化的。特别地,Node 的设计让实现高并发(同时处理大量请求的)服务器非常容易。
与很多编程语言不同,Node 并不是通过线程来实现并发的。Node 采用了 Web 使用的单线程 JavaScript 编程模型,使得创建网络服务器变得极其简单,只需常规操作,没有神秘可言。
Node 程序可以运行多个操作系统进程,而 Node 10
及之后支持的 Worker 对象
是一种借鉴自浏览器的线程。如果你使用多个进程或者创建了一或多个 Worker 线程,并且你的程序运行在多核 CPU 的系统上,那么你的程序就不再是单线程,而是变成了真正的并行执行。
但是 Node 的进程和 Worker 避免了典型多线程编程的复杂性。因为它的进程或线程间通信是通过消息传递实现的,相互之间很难共享内存。
Node通过让其 API 默认异步和非阻塞实现了高层次的并发,同时保持了单线程的编程模型。Node 很严格地采用非阻塞并将其运用到了极致。Node API 中有些函数虽然是同步的但也不会阻塞。这些函数运行完成就立即返回,根本不需要阻塞。
Node 诞生于 JavaScript 有 Promise 类之前,因此异步 Node API 是基于回调的:
- 一般来说,你传给异步 Node 函数的最后一个参数始终是一个回调
- Node 使用错误在先的回调,函数调用时通常会传两个参数:
- 如果没有发生错误,那么这个错误在先的回调的第一个参数通常是 null
- 第二个参数就是你最初调用的异步函数产生的数据或返回的响应
- 之所以把错误参数放在第一位,是为了让你不可能忽略它,从而始终检查这个参数是否不是空值
如下演示了如何使用非阻塞的 readFile()
函数读取一个配置文件:
1 | const fs = require("fs"); |
Node 虽然先于标准化的期约问世,但由于它错误在先的回调相当一致,所以使用 util.promisify()
包装函数能够轻易创建其基于回调 API 的期约版:
1 | const util = require("util"); |
也可以使用 async 和 await 简化前面这个基于期约的函数:
1 | async function readConfigFile(path) { |
util.promisify() 包装函数可以生成很多Node函数的基于期约的版本。在 Node 10 及之后,fs.promises
对象提供了一些预定义的基于期约的函数,用于操作文件系统。
我们已经说过 Node 的编程模型默认是异步的。但考虑到程序员的方便,Node 也为其很多函数定义了阻塞、同步的版本,特别是文件系统模块中的函数。这些函数的名字最后通常都有明确的 Sync 字样。例如,服务器在初次启动并读取配置文件时,还不能处理网络请求,几乎也没有并发执行的可能。因此这时候没有必要避免阻塞,可以放心地使用 fs.readFileSync()
等阻塞函数。
Node 内置的非阻塞函数使用了操作系统的回调和事件处理程序。这种并发通常被称为基于事件的并发。其核心是 Node 用单线程运行一个 事件循环
。对于 Web 服务器和其他主要把时间花在等待输入和输出的 I/O 密集型应用,这种基于事件的并发效率又高、效果又好。
缓冲区
Node 中有一个比较常用的数据类型就是 Buffer,常用于从文件或网络读取数据。Buffer 类(或称缓冲区)非常类似字符串,只不过它是字节序列而非字符序列。在 JavaScript 语言支持 Uint8Array 之后,Node 的 Buffer 类就成为 Uint8Array 的子类。
Buffer 与其超类 Uint8Array 的区别在于,它是设计用来操作 JavaScript 字符串的。因此缓冲区里的字节可以从字符串初始化而来,也可以转换为字符串。Node 的 Buffer 类有执行编码和解码的方法,这些方法都接收一个 encoding 参数,用于指定要使用的编码。
1 | > let b = Buffer.from([0x41, 0x42, 0x43]) |
事件与 EventEmitter
如前所述,所有 Node API 默认都是异步的。对其中很多 API 而言,这种异步性的表现形式为 接收两个参数、且错误在先的回调
,当请求的操作完成时回调函数会被调用。但一些更复杂的 API 则是基于事件的。在 API 是围绕对象而非函数设计的。回调需要多次被调用,或者需要多种类型的回调时,通常会是这种情况。
在 Node 中,发送事件的对象都是 EventEmitter 或其子类的实例:
1 | > const EventEmitter = require("events") |
EventEmitter 的主要功能是允许我们使用 on() 方法注册事件处理程序:
- EventEmitter 可以发送多种事件,而事件类型以名字作为标识
- 要注册一个事件处理程序,可以调用 on() 方法并传入事件类型的名字,以及在该类型事件发生时应该被调用的函数
- 可以使用
off()
移除注册的事件处理程序 - 还有一种特殊情况,就是使用
once()
而非on()
注册的事件监听器在被调用一次之后就会被自动清除 - 当某个 EventEmitter 对象上发生特定的事件时,Node 会调用在该 EventEmitter 上针对该事件类型注册的所有处理程序,调用顺序是注册的顺序。如果有多个处理程序,它们会在一个线程上被顺序调用。更重要的,事件处理程序会被同步调用,而非异步调用。因此事件处理程序一般也不能执行阻塞操作,需要快速执行完成
- EventEmitter 类也定义了一个
emit()
方法,可以导致注册的事件处理程序被调用。这个方法在你定义自己的、基于事件的 API 时有用- 调用
emit()
时必须在第一个参数传入事件类型的名字 - 而传给
emit()
的所有后续参数都会成为注册的事件处理程序的参数 - 事件处理程序被调用时,其 this 值也会被设置为 EventEmitter 对象
- 事件处理程序返回的任何值都会被忽略。不过,如果某个事件处理程序抛出异常,则该异常会从
emit()
调用中传播出来,从而阻止该事件后续其他处理程序的执行
- 调用
基于事件的 API,当发生错误时,会产生对应的 错误
(error)事件。只要使用基于事件的 API,就应该习惯性地为这个 错误
事件注册处理程序。EventEmitter 类对这个 错误
事件进行了特殊处理。如果调用 emit()
发送的是错误
事件,且如果该事件没有注册处理程序,那么就会抛出一个异常。由于这是异步发生的,无法在 catch
块中处理这个异常,所以这种错误通常会导致程序退出。
流
基于流的算法,其本质就是把数据分割成小块,内存中不会保存全部数据。如果能够使用基于流的方案,则这种方案的内存利用率更高,处理速度也更快。Node 的网络 API 是基于流的,Node 的文件系统模块也定义了流 API 用于读写文件。因此你在写 Node 程序时很有可能用到流 API。
Node 支持 4 种基本的流:
- 可读流:可读流是数据源
- 可写流:可写流是数据的接收地或目的地
- 双工流:双工流把可读流和可写流组合为一个对象
- 转换流:转换流也是可读和可写的,但与双工流有一个重要区别:写入转换流的数据在同一个流会变成可读的。比如,
zlib.createGzip()
函数返回一个转换流,可以使用 gzip 算法对写入其中的数据进行压缩
默认情况下,流读写的是缓冲区。如果你调用了一个可读流的 setEncoding()
方法,它会返回解码后的字符串而非 Buffer 对象。Node 的流 API 也支持 对象模式
,即流会读写比缓冲区和字符串更复杂的对象。
可读流必须从某个地方读取数据,而可写流必须把数据写到某个地方。因此每个流都有两端:输入端和输出端(或称来源和目标)。使用基于流的 API,最难的地方是流的这两端几乎总是以不同的速度流动。流的实现几乎总会包含一个内部缓冲区,缓冲有助于保证在读取时有数据,而在写入时有空间保存数据。但这两点都无法绝对保证,基于流编程的本质决定了读取器有时候必须要等待数据写入(因为缓冲区空了),而写入器有时候必须等待数据读取(因为缓冲区满了)。
Node 的流 API 是基于事件和回调的。与其他 Node API 不同,本章后面描述的方法没有 同步
版。
管道
有时候,我们需要把从流中读取的数据写入另一个流。与其自己写代码来处理这里的读和写,不如把这两个接口连接为一个 管道
,让 Node 帮你实现复杂的操作。只要把可写流简单地传给可读流的 pipe()
方法即可:
1 | const fs = require("fs"); |
下面这个实用函数通过管道把一个流导向另一个流,并在完成或发生错误时调用一个回调:
1 | function pipe(readble, writable, callback) { |
转换流特别适合与管道一起使用,可以创建多个流的传输管道。如下代码实现了文件压缩:
1 | const fs = require("fs"); |
使用 pipe()
方法从可读流向可写流复制数据很容易。不过在实践中,经常要对流经程序的数据做某些处理。为此,一种方式是实现自己的 Transform 流来完成相应处理,这种方式可以让你避免手工读取和写入流。可以通过继承 stream.Transform
类来实现自定义的转换流。
异步迭代
在 Node 12 及之后,可读流是异步迭代器,这意味着在一个 async 函数中可以使用 for/await
循环从流中读取字符串或 Buffer块,而代码结构就像同步代码一样
1 | async function grep(source, destination, pattern, encoding="utf8") { |
写入流与背压处理
调用写入流的 write() 方法就可以将数据写入到 写入流
中,write()
方法以一个缓冲区或字符串作为第一个参数:
- 如果传入一个缓冲区,则该缓冲区的字节会被直接写入
- 如果传入一个字符串,则字符串在被写入前会被编码成字节缓冲区(可写流有默认编码,通常为
utf8
) - 可以同时传入一种编码的名字作为
write()
方法的第二个参数 write()
可选地接收一个回调函数作为第三个参数。这个回调会在数据已经实际写入、不再存在于可写流的内部缓冲区时被调用
这个 write() 方法有一个非常重要的返回值。在调用一个流的 write()
方法时,它始终会接收并缓冲传入的数据块。如果内部缓冲区未满,它会返回 true。如果内部缓冲区已满或太满,它会返回 false。这个返回值是建议性的,你可以忽略它。可写流会随着你不断调用 write()
而按需增大它的内部缓冲区。
write()
方法返回 false 值是一种 背压
(backpressure)的表现。背压是一种消息,表示你向流中写入数据的速度超过了它的处理能力。对这种背压的正确反应是停止调用 write(),直到流发出 drain
(耗尽)事件,表明缓冲区又有空间了。
1 | function write(stream, chunk, callback) { |
下面例子中的异步 copy() 函数演示了如何正确处理背压:
1 | function write(stream, chunk) { |
如果不能对背压作出反应,则在可写流的内部缓冲区溢出时,会导致你的程序占用过多内存,而且占用的内存会越来越多。
通过事件读取流
Node 可读流有两种模式,每种模式都有自己的读取 API。如果你不能在程序中使用管道或异步迭代,那就需要从这两种基于事件的 API 中选择一种来处理流。关键在于只能使用其中一种 API,不能两种混用。
流动模式:
在流动模式(flowing mode)下,当可读数据到达时,会立即以 data
事件的形式发送。要在这种模式下读取流,只要为 data
事件注册一个事件处理程序,流就会在可用时立即把数据块(缓冲区或字符串)推送给你:
- 注意,在流动模式下无须调用
read()
方法,只需要处理data
事件 - 新创建的流并非一开始就处于流动模式,注册
data
事件处理程序会把流切换为流动模式 - 可以调用可读流的
pause()
方法暂时停止data
事件 - 处于流动模式的流会在到达流末尾时发出一个
end
事件
暂停模式:
可读流的另一种模式是 暂停模式
。这个模式是流开始时所处的模式。如果你不注册 data
事件处理程序,也不调用pipe()
方法,那么可读流就一直处于暂停模式。
- 在暂停模式下,流不会以
data
事件的形式向你推送数据。相反,你需要显式调用其read()
方法从流中拉取数据 - 这个方法不是阻塞操作,且如果流中已经没有数据可读,它会返回 null
- 因为没有同步 API 等待数据,所以暂停模式 API 也是基于事件的
- 可读流在暂停模式下会发送
readable
事件,表示流中有可读数据。相应地,你的代码应该调用read()
方法读取该数据。而且,必须在一个循环中反复调用read()
,直到它返回 null - 只有这样才能完全耗尽流的缓冲区,从而在将来再次触发新的
readable
事件 - 如果在仍然有可读数据的情况下停止调用
read()
,那么就不会再收到下一个readable
事件,你的程序很可能会被挂起 - 处于暂停模式的流会像处于流动模式的流一样发送
end
和error
事件
如果你的程序从一个可读流读数据,向一个可写流写数据,那么暂停模式可能并非好的选择。因为我们需要一次性读取所有可读的数据,但是这些数据可能无法写入(因为需要处理写入流的背压状态),这样处理起来很麻烦,不如流动模式更简单。
如下展示了一个使用 暂停模式
的示例,它可以计算指定文件的 SHA256
散列值:
1 | const fs = require("fs"); |
进程、CPU 和操作系统细节
全局 Process 对象有很多有用的属性和函数,通常与当前运行的 Node 进程的状态相关。Node 文档中有这些属性和函数的详细说明。
1 | > process.arch |
os 模块
(与 process 不同,需要通过 require()
显式加载)提供对 Node 所在计算机和操作系统的类似的低级细节。
1 | > const os = require("os") |
操作文件
Node的 fs 模块是用于操作文件和目录的综合性 API。path 模块是 fs 模块的补充,定义了操作文件和目录名的常用函数。fs 模块定义了大量 API,主要是因为每种基本操作都有很多变体,例如非阻塞基于回调版本、同步阻塞变体、基于期约的异步变体。另外有些 API 以要操作的文件路径作为参数,有的则以文件描述符作为参数(这样的变体通常以 f
开头)。fs 模块中还有少量函数有名字前面加 l
,这个带 l
的变体与基本函数类似,但不会跟踪文件系统中的符号链接,而是直接操作符号链接本身。
路径、文件描述符和 FileHandle
文件通常都是通过路径来指定的,由于不同操作系统使用不同的字符来分隔目录名,因此处理路径可能会有点棘手。Node 的 path 模块及其他一些重要的 Node 特性可以帮我们处理路径:
1 | > let path = require("path") |
有一些 fs
函数接收文件描述符,而不是文件名。文件描述符是操作系统级的整数引用,调用 fs.pen()
(或 fs.openSync()
)函数可以得到一个指定文件的描述符。在文件描述符上调用 fs.close()
可以关闭文件。
在 fs.promises
定义的基于期约的 API 中,与 fs.open()
对应的是 fs.promises.open()
。fs.promises.open()
返回一个期约,该期约会解决为一个 FileHandle 对象。这个 FileHandle 对象与文件描述符的作用相同。操作完成后同样要调用它的 close()
方法。
读文件
Node 允许你一次性读取文件的内容,可以通过流,也可以通过低级 API:
- 如果你的文件很小,或者内存占用或性能并非主要考虑的因素,那么通过一次调用读取文件的全部内容是最简单的。这时,可以使用同步方法,也可以使用异步方法加回调,也可以使用期约
- 如果可以顺序地处理文件内容,同时不需要把文件内容全都放到内存中,那通过流来读取文件可能是最有效的方式。
- 如果需要在更低层次上控制要读取文件的哪些字节,可以打开文件取得文件描述符,然后再使用
fs.read()
、fs.readSync()
或fs.promises.read()
,从文件中指定的来源位置将指定数量的字节读取到指定目标位置的指定缓冲区 - 如果你要从文件中读取多个数据块,这个基于回调的 read() API 使用起来会很麻烦。如果可以使用同步API(或基于期约的 API 及 await),那从一个文件中读取多个数据块就简单了
写文件
-
就是通过写入一个并不存在的文件名,可以创建一个新文件
-
与读文件一样,Node中有 3 种写文件的基本方式。如果你有字符串或缓冲区中全部的文件内容
- 基于回调:fs.writeFile()
- 同步:fs.writeFileSync()
- 基于期约:fs.promises.writeFile()
-
相关函数
fs.appendFile()
、fs.appendFileSync()
和fs.promises.appendFile()
也类似,只不过它们会在指定文件存在时,把数据追加到已有数据的末尾,而不会重写已有的文件内容 -
如果要写入文件的数据并不全在一个块中,或者如果在同一时刻并不全都在内存中,那么使用可写流是个好办法
-
如果你想以多个块的形式将数据写入文件,并且想控制把每个块都写入文件中的确切位置,那么可以使用
fs.open()
、fs.openSync()
或fs.promises.open()
打开文件,然后把生成的文件描述符再传给fs.write()
或fs.writeSync()
函数 -
类似地,用
fs.promises.open()
及其产生的FileHandle
对象也可以在更低层次写入缓冲区和字符串 -
当使用
fs.open()
和fs.openSync()
打开文件来写入时,必须同时传入第二个字符串参数,用于指定你准备如何使用这个文件描述符,例如可以是"w"
、"w+"
、"a"
等。如果没有指定,则默认为"r"
,即返回只读文件描述符 -
可以通过
fs.truncate()
、fs.truncateSync()
或fs.promises.truncate()
截掉文件后面的内容 -
数据写入成功仅仅代表 Node 已经把数据交给了操作系统。如果想把数据强制写入磁盘,保证数据得到安全存储,可以使用
fs.fsync()
或fs.fsyncSync()
文件操作
fs.copyFile()
、fs.copyFileSync()
和fs.promises.copyFile()
可以用于复制文件fs.rename()
函数(以及相应的同步和基于期约的变体)可以移动或重命名文件- 函数
fs.link()
和fs.symlink()
分别创建硬链接和符号链接 fs.unlink()
、fs.unlinkSync()
和fs.promises.unlink()
是 Node 用来删除文件的函数
文件元数据
fs.stat()
、fs.statSync()
和fs.promises.stat()
函数可以让你取得指定文件或目录的元数据fs.lstat()
及其变体与fs.stat()
类似,只是在指定文件为符号链接时,Node 会返回链接本身的元数据,而不会跟踪链接- 如果你已经打开一个文件并得到其文件描述符或 FileHandle 对象,那么可以使用
fs.fstat()
或其变体取得这个打开文件的元数据 fs.chmod()
、fs.lchmod()
和fs.fchmod()
(以及相应的同步和基于期约的版本)用于设置文件或目录的模式
或权限fs.chown()
、fs.lchown()
和fs.fchown()
(以及相应的同步和基于期约的版本)用于为文件或目录设置所有者和组- 可以使用
fs.utimes()
和fs.futimes()
及其变体设置文件或目录的访问时间和修改时间
操作目录
- 可以使用
fs.mkdir()
、fs.mkdirSync()
或fs.promises.mkdir()
创建新目录 fs.mkdtemp()
及其变体接收一个传入的路径前缀,然后在后面追加一些随机字符(对于安全很重要),并以该名字创建一个目录,- 要删除一个目录,使用
fs.rmdir()
或它的变体。注意,必须是空目录才能删除 - fs 模块提供了两组不同的 API 用于列出目录的内容
fs.readdir()
、fs.readdir Sync()
和fs.promises.readdir()
一次性读取整个目录- 基于流的
fs.opendir()
及其变体返回一个Dir
对象,表示指定的目录。可以使用这个 Dir 对象的read()
或readSync()
方法每次读取一个 Dirent 对象。使用 Dir 对象最简单的方式是将其作为异步迭代器,配合for/await
循环
如下是一个示例:
1 | const fs = require("fs"); |
HTTP 客户端与服务器
Node的 http、https 和 http2 模块是功能完整但相对低级的 HTTP 协议实现。这些模块定义了实现 HTTP 客户端和服务器的所有 API。
- 发送
HTTP GET
请求的最简单方式是使用http.get()
或https.get()
。 - 这两个函数的第一个参数是要获取的 URL,第二个参数是一个回调
- 当服务器响应开始到达时这个回调会以一个 IncomingMessage 对象被调用
- 调用回调时,HTTP 状态和头部已经可以读取,但响应体尚未就绪。
IncomingMessage
对象是一个可读流
除了发送 HTTP 和 HTTPS 请求,http 和 https 模块也允许你编写响应这些请求的服务器。基本流程如下。
- 创建一个新 Server 对象
- 调用它的 listen() 方法,开始监听指定端口的请求
- 为
request
事件注册处理程序,通过这个处理程序读取客户端请求(特别是request.url
属性),然后写入你的响应
Node 的内置模块可以用来编写简单的 HTTP 和 HTTPS 服务器。不过,产品级服务器通常并不直接构建于这些模块之上。多数常用的服务器都是使用外部库(如 Express 框架)实现的,这些外部库提供 中间件
及其他后端 Web 开发者期待的高级实用特性。
非 HTTP 网络服务器及客户端
除了基于 HTTP 协议的客户端/服务器,Node 也完全支持其他类型的网络服务器和客户端。
net 模块定义了 Server
和 Socket
类。要创建服务器:
- 调用
net.createServer()
,然后调用返回对象的listen()
方法告诉服务器监听哪个端口的连接 - Server 对象会在客户端连接到该端口时生成
connection
事件,而传给事件监听器的值就是一个 Socket 对象 - 这个 Socket 对象是一个双工流,可以使用它从客户端读取数据和向客户端写入数据
- 在这个 Socket 对象上调用
end()
可以断开链接
写客户端甚至更容易,只要给 net.createConnection()
传一个端口号和主机名,就可以创建一个套接口,与该主机上监听相应端口的服务器通信。然后使用这个套接口就可以从服务器读取数据或者向服务器写入数据了。
除了支持基于 TCP 的服务器,Node 的 net 模块也支持通过 Unix 域套接口
(Unix domain socket)的进程间通信。另外还有支持 UDP 通信的 dgram 模块、支持 tls 加密通信的 tls 模块等,这里就不再详细介绍了。
操作子进程
child_process
模块定义了一些函数,用于在子进程中运行其他程序。
execSync()
与 execFileSync()
运行其他程序的最简单方式是使用 child_process.execSync()
。这个函数的第一个参数是要运行的命令,它会创建一个子进程,并在该进程中运行一个命令行解释器 shell,并使用该解释器执行你传入的命令:
- 执行命令期间会阻塞,直到命令(及命令行解释器)退出
- 如果命令中存在错误,则
execSync()
会抛出异常 - 否则,
execSync()
将返回该命令写入其标准输出流的任何内容
如果你不需要命令行的特性,可以使用 child_process.execFileSync()
来避免启动命令行的开销。这个函数直接执行程序,不调用命令行。
execSync()
和其他很多 child_process 函数都有第二或第三个可选参数对象,用于指定子进程如何运行。
exec()
与 execFile()
exec()
和 execFile()
与它们的同步变体相似,只不过会立即返回一个 ChildProcess
对象,表示正在运行的子进程,而且接收一个错误在先的回调作为最后的参数,这个回调会在子进程退出时被调用。
exec() 和 execFile() 返回的 ChildProcess 对象允许你终止子进程,向它写入数据(进而可以从其标准输入读取)。
如果你想同时执行多个子进程,那么最简单的方式可能就是使用 exec()
的期约版,它返回一个期约对象。如果子进程无错误退出,这个期约对象会解决为一个包含 stdout 和 stderr 属性的对象。
spawn()
child_process.spawn()
函数允许在子进程运行期间流式访问子进程的输出。同时,它也允许向子进程写入数据(子进程将该数据作为自己标准输入流的输入)。这意味着可以动态与子进程交互,基于它的输出向它发送输入。
spawn()
与execFile()
一样,也返回一个 ChildProcess 对象,但它不接收回调参数- 虽然不使用回调,但可以监听这个 ChildProcess 对象或它的流发出的事件
spawn()
返回的ChildProcess
对象是一个事件发送器(event emitter),可以监听子进程退出时发出的exit
事件- ChildProcess 对象也有 3 个流属性。stdout 和 stderr 是可读流:当子进程写入自己的标准输出和标准错误流时,相应的输出通过 ChildProcess 流变成可读的。stdin 属性是可写的流:写入这个流的任何数据都将进入子进程的标准输入
fork()
child_process.fork()
是一个特殊的函数,用于在一个 Node 子进程中运行一段 JavaScript 代码。fork() 接收与 spawn() 相同的参数,但第一个参数应该是 JavaScript 代码文件的路径而非可执行二进制文件的路径。
使用 fork() 创建的子进程可以通过子进程的标准输入流和标准输出流与父进程通信。另外,fork()
还在父进程和子进程之间提供了一种更简单的通信方式:
- 在使用
fork()
创建子进程后,可以使用它返回的 ChildProcess 对象的send()
方法向子进程发送一个对象的副本 - 可以监听这个 ChildProcess 的
message
事件,从子进程中接收消息 - 在子进程中运行的代码可以使用
process.send()
向父进程发送消息,也可以监听 process 的message
事件,从父进程接收消息
启动子进程的代价是相当大的,如果子进程不能完成几个大数量级的计算,那么就不值得使用 fork()
。
工作线程
Node 的并发模型是单线程、基于事件的。但 Node 从第 10 版开始支持真正的多线程编程,提供了与浏览器定义的 Web Workers API
非常相似的的一套 API。
JavaScript 的工作线程只能通过消息传递来通信:
- 主线程可以调用代表工作线程的 Worker 对象的
postMessage()
方法向工作线程发送消息,工作线程可以通过监听message
事件,从父线程接收消息 - 工作线程可以使用自己的
postMessage()
方法向主线程发送消息,而主线程可以通过自己的message
事件处理程序接收该消息
工作线程不像子进程那么重,但也不轻。除非真的有很多工作需要它去完成,否则也不值得创建工作进程。
创建工作线程及传递消息
定义工作线程的 Node 模块叫 worker_threads
,这个模块定义了 Worker 类来表示工作线程。
Worker()
构造函数的第一个参数是要在线程中运行的 JavaScript 代码文件的路径。如果传入的是相对路径,则它相对的是process.cwd()
,而非相对于当前运行的模块Worker()
构造函数还可以接收一个对象作为第二个参数,这个对象的属性为要创建的工作线程提供可选的配置。- Node会将传给
postMessage()
的对象制作一个副本,而不是直接将它与工作线程共享。这样可以防止工作线程和主线程共享内存
工作线程的执行环境
很大程度上,Node 工作线程中的 JavaScript 代码在执行时与在主线程中一样。当然也有一些需要注意的区别:
- threads.isMainThread 在主线程中是 true,但在任何工作线程中都是 false
- 在工作线程中,可以使用
threads.parentPort.postMessage()
向父线程发送消息,使用threads.parentPort.on
为来自父线程的消息注册事件处理程序。在主线程中,threads.parentPort
始终是 null - 在工作线程中,
threads.workerData
被设置为Worker()
构造函数第二个参数 workerData 属性的一个副本。在主线程中,这个属性始终是 null。可以使用这个 workerData 属性向工作线程传一条最初的消息 - 默认情况下,
process.env
在工作线程中是父线程中process.env
的一个副本。但父线程可以通过设置 Worker() 构造函数第二个参数的 env 属性指定一组自定义的环境变量 - 默认情况下,
process.stdin
流在工作线程中永远不会有任何可读数据。可以通过给Worker()
构造函数的第二个参数传stdin: true
来改变这个默认设置。如此,这个 Worker 对象的 stdin 属性就是一个可写的流。父线程写入worker.stdin
的任何数据在工作线程中的process.stdin
中都会变成可读的 - 默认情况下,
process.stdout
和process.stderr
在工作线程中都会简单地被引流到父线程中对应的流。要重写这个默认设置,可以在Worker()
构造函数的第二个参数中传入stdout: true
或stderr: true
。如此,工作线程中写入这些流的任何输出在父线程中的worker.stdout
和worker.stderr
上都会变成可读的 - 如果工作线程调用
process.exit()
,只有当前线程会退出,而不是整个进程都退出 - 工作线程不能改变它们所属进程的共享状态。例如在工作线程中调用
process.chdir()
和process.setuid()
等函数会抛出异常 - 操作系统信号(如 SIGINT 和 SIGTERM)只发送到主线程,工作线程不能接收和处理它们
通信信道与 MessagePort
创建一个新工作线程时,也会随之创建一个通信信道,以便在工作线程和父线程之间来回传递消息。创建一个新工作线程时,也会随之创建一个通信信道,以便在工作线程和父线程之间来回传递消息。
工作线程 API 也支持使用 MessageChannel API 创建自定义通信信道(类似于浏览器定义中的 MessageChannel):
- 使用
MessageChannel()
构造函数可以创建一个新消息信道 - MessageChannel 对象有两个属性:
port1
和port2
,这两个属性分别引用不同的 MessagePort 对象 - 在其中一个端口上调用 postMessage() 会导致另一个端口生成
message
事件,并接收到一个Message对象的结构化克隆的副本 - 在任何一个端口上调用
close()
都可以断开这两个端口之间的连接
转移 MessagePort 和定型数组
postMessage() 函数使用结构化克隆算法,但是它不能复制 Socket 对象和 Stream 对象。它可以处理 MessagePort 对象,但只作为特例且需要使用特殊技巧:
- postMessage() 方法接收可选的第二个参数。这个参数(名为transferList)是一个对象数组,其中的对象会在线程间转移而非复制
- MessagePort 对象不能通过结构化克隆算法复制,但它可以被转移
- 如果 postMessage() 的第一个参数已经包含了一个或多个 MessagePort(在 Message 对象中嵌套任意深度),那么这些 MessagePort 对象必须也出现在作为第二个参数的数组中,这样将告诉 Node 将这些 MessagePort 对象转交给另一个线程
- MessagePort 对象并不是唯一可以转移的对象,定型数组也可以通过类似的方法进行转移。定型数组转移给另一个线程后,当前线程就不能再使用它了,而这也是保证这种转移安全的原因
在线程间共享定型数组
除了可以在线程间转移定型数组,也可以在线程间共享它们。只要创建一个自定义大小的 SharedArrayBuffer
,然后使用该缓冲区创建一个定型数组即可。在把基于 SharedArrayBuffer
创建的定型数组传给 postMessage() 时,底层的内存会在线程间共享。此时,不应该再在 postMessage() 的第二个参数中包含这个共享缓冲区。
但是一般不应该这样做,因为 JavaScript 设计时并未考虑线程安全。一个可能适合使用 SharedArrayBuffer 的场景,是两个线程分别操作共享内存中完全独立的区域。为此,可以创建两个定型数组,作为共享缓冲区中不重叠的两个视图。然后让两个线程分别使用这两个独立的定型数组。
如果必须允许多线程同时访问共享数组的同一区域,为保证线程安全,可以使用 Atomics 对象定义的函数。