Node.js 流以难以使用,甚至更难理解而著称。好吧,我有好消息要告诉你——情况已经不同了。
多年来,开发人员创建了许多包,其唯一目的是简化流的处理。但在本文中,我将重点关注原生Node.js 流 API。
究竟什么是流?
流是数据的集合——就像数组或字符串一样。不同之处在于流可能不会同时全部可用,并且它们不必适合内存。这使得流在处理大量数据或一次一个块地来自外部源的数据时非常强大。
然而,流不仅仅是处理大数据。它们还赋予我们代码可组合性的力量。就像我们可以通过管道传输其他较小的 Linux 命令来组合强大的 linux 命令一样,我们可以在 Node 中使用流来做同样的事情。
与 Linux 命令的可组合性
const grep = ... // grep 输出流
const wc = ... // wc 输入流
grep.pipe(wc)
Node 中的许多内置模块都实现了流接口:
上面的列表包含一些原生 Node.js 对象的示例,这些对象也是可读可写流。其中一些对象既是可读流又是可写流,例如 TCP 套接字、zlib 和加密流。
请注意,这些对象也密切相关。虽然 HTTP 响应在客户端是可读流,但在服务器上是可写流。这是因为在 HTTP 情况下,我们基本上从一个对象 ( http.IncomingMessage
) 读取并写入另一个对象 ( http.ServerResponse
)。
stdio
还要注意流 ( stdin
, stdout
, stderr
) 在涉及子进程时如何具有反向流类型。这允许一种非常简单的方法来从主要流程流中传输到这些流和从这些流中流出stdio
。
一个流的实际例子
理论很棒,但往往不能 100% 令人信服。让我们看一个示例,演示流在内存消耗方面可以在代码中产生的差异。
让我们先创建一个大文件:
const fs = require('fs');
const file = fs.createWriteStream('./big.file');
for(let i=0; i<= 1e6; i++) {
file.write('Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat。Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n' );
}
file.end();
看看我用什么创建了那个大文件。可写流!
该fs
模块可用于使用流接口读取和写入文件。在上面的例子中,我们big.file
通过一个循环的可写流 100 万行写入它。
运行上面的脚本会生成一个大约 400 MB 的文件。
这是一个简单的 Node Web 服务器,专为以下服务而设计big.file
:
const fs = require('fs');
const server = require('http').createServer();
server.on('request', (req, res) => {
fs.readFile('./big.file', (err, data) => {
if (err) throw err;
res.end(data);
});
});
server.listen(8000);
当服务器收到请求时,它将使用异步方法为大文件提供服务,fs.readFile
. 但是,嘿,我们并不是在阻塞事件循环或其他任何东西。每件事都很棒,对吧?正确的?
好吧,让我们看看当我们运行服务器、连接到它并同时监视内存时会发生什么。
当我运行服务器时,它以正常的内存量开始,8.7 MB:
然后我连接到服务器。注意消耗的内存发生了什么:
内存消耗跃升至 434.8 MB。
big.file
在我们将其写出到响应对象之前,我们基本上将整个内容放在内存中。这是非常低效的。
HTTP 响应对象(res
在上面的代码中)也是一个可写流。这意味着如果我们有一个表示 的内容的可读流big.file
,我们可以将这两个流相互连接起来,并在不消耗约 400 MB 内存的情况下获得几乎相同的结果。
Node 的fs
模块可以使用该方法为我们提供任何文件的可读流createReadStream
。我们可以将其通过管道传递给响应对象:
const fs = require('fs');
const server = require('http').createServer();
server.on('request', (req, res) => {
const src = fs.createReadStream('./big.file');
src.pipe(res);
});
server.listen(8000);
现在当你连接到这个服务器时,神奇的事情发生了(看看内存消耗):
发生了什么?
当客户端请求那个大文件时,我们一次将它传输一个块,这意味着我们根本不会在内存中缓冲它。内存使用量增加了大约 25 MB,仅此而已。
您可以将此示例推向极限。重新生成big.file
500 万行而不是 100 万行,这将使文件超过 2 GB,这实际上大于 Node.js 中的默认缓冲区限制。
如果您尝试使用 提供该文件fs.readFile
,则默认情况下您根本做不到(您可以更改限制)。但是对于fs.createReadStream
,向请求者流式传输 2 GB 数据完全没有问题,最重要的是,进程内存使用量大致相同。
Stream
Node.js 中有四种基本流类型:Readable、Writable、Duplex 和 Transform 流。
- 可读流是对可以使用数据的源的抽象。一个例子就是方法
fs.createReadStream
。 - 可写流是对可写入数据的目的地的抽象。一个例子就是方法
fs.createWriteStream
。 - 双工流是可读和可写的。一个例子是 TCP 套接字。
- 转换流基本上是一个双工流,可用于在写入和读取数据时修改或转换数据。一个例子是
zlib.createGzip
使用 gzip 压缩数据的流。您可以将转换流视为一个函数,其中输入是可写流部分,输出是可读流部分。您可能还会听到称为“通过流”的转换流。
所有流都是EventEmitter
. 它们发出可用于读取和写入数据的事件。但是,我们可以使用该pipe
方法以更简单的方式使用流数据。
管道法
这是您需要记住的:
readableSrc.pipe(writableDest)可读源
在这一行简单的代码中,我们将可读流的输出(数据源)作为可写流的输入(目标)进行管道传输。源必须是可读流,目标必须是可写流。当然,它们也可以都是双工/转换流。事实上,如果我们通过管道进入双工流,我们可以像在 Linux 中一样链接管道调用:
readableSrc
.pipe(transformStream1)
.pipe(transformStream2)
.pipe(finalWrtitableDest)
该pipe
方法返回目标流,这使我们能够执行上面的链接。对于流a
(可读)、b
(c
双工)和d
(可写),我们可以:
a.管道(b).管道(c).管道(d)
# 相当于:
a.pipe(b)
b.pipe(c)
c.pipe(d)
# 在 Linux 中相当于:
$ a | b | c | d
该pipe
方法是消费流的最简单方法。通常建议使用该pipe
方法或使用带有事件的流,但要避免混合使用这两者。通常,当您使用该pipe
方法时,您不需要使用事件,但如果您需要以更多自定义方式使用流,那么事件将是您的不二之选。
Stream events
除了从可读流源读取和写入可写目标之外,该pipe
方法还自动管理一些事情。例如,它处理 错误、文件结尾以及一个流比另一个流慢或快的情况。
但是,流也可以直接与事件一起使用。pipe
下面是该方法主要用来读写数据的简化事件等效代码:
# 可读管道(可写)
readable.on('data', (chunk) => {
writable.write(chunk);
});
readable.on('end', () => {
writable.end();
});
以下是可用于可读和可写流的重要事件和函数的列表:
事件和函数在某种程度上是相关的,因为它们通常一起使用。
可读流中最重要的事件是:
data
每当流将一大块数据传递给消费者时就会发出该事件- 事件
end
,当没有更多数据可从流中使用时发出。
可写流上最重要的事件是:
- 事件
drain
,这是可写流可以接收更多数据的信号。 finish
当所有数据都已刷新到底层系统时发出的事件。
可以组合事件和函数来自定义和优化流的使用。要使用可读流,我们可以使用pipe
/unpipe
方法或read
//方法。要使用可写流,我们可以将它作为/的目的地,或者只使用该方法写入它并在完成后调用该方法。unshift/
resume/
pipe/
unpipe/
write/
end
可读流的暂停和流动模式
可读流有两种主要模式会影响我们使用它们的方式:
- 它们可以处于暂停模式
- 或者在流动模式
这些模式有时被称为拉和推模式。
默认情况下,所有可读流都以暂停模式启动,但它们可以很容易地切换到流动模式,并在需要时返回暂停模式。有时,切换会自动发生。
当可读流处于暂停模式时,我们可以使用该read()
方法按需从流中读取,但是对于流动模式的可读流,数据是不断流动的,我们必须监听事件来消费它。
在流动模式下,如果没有消费者可以处理数据,数据实际上可能会丢失。这就是为什么当我们有一个处于流动模式的可读流时,我们需要一个data
事件处理程序。事实上,只需添加data
事件处理程序即可将暂停的流切换为流动模式,删除data
事件处理程序可将流切换回暂停模式。其中一些是为了向后兼容旧的 Node 流接口。
要在这两种流模式之间手动切换,可以使用resume()
和pause()
方法。
实现流
当我们谈论 Node.js 中的流时,有两个主要的不同任务:
- 实现流的任务。
- 消耗它们的任务。
到目前为止,我们一直在谈论只消费流。让我们实施一些!
require
流实现者通常是模块的实现者stream
。
实现可写流
要实现可写流,我们需要使用Writable
流模块中的构造函数。
const { Writable } = require('stream');
我们可以通过多种方式实现可写流。例如,Writable
如果需要,我们可以扩展构造函数
我更喜欢更简单的构造函数方法。我们只是从构造函数创建一个对象Writable
并向它传递一些选项。唯一需要的选项是一个write
公开要写入的数据块的函数。
const { Writable } = require('stream');
const outStream = new Writable({
write (chunk, encoding, callback) {
console.log(chunk.toString());
callback();
}
});
process.stdin.pipe(outStream);
此写入方法采用三个参数。
- 除非我们以不同方式配置流,否则块通常是缓冲区。
- 在这种情况下需要编码参数,但通常我们可以忽略它。
- 回调是我们在处理完数据块后需要调用的函数。这是写入是否成功的信号。要发出失败信号,请使用错误对象调用回调。
在 中outStream
,我们简单地console.log
将块作为一个字符串,然后callback
在没有错误的情况下调用它来指示成功。这是一个非常简单但可能不是很有用的回声流。它会回显它收到的任何东西。
要使用这个流,我们可以简单地将它与 一起使用process.stdin
,它是一个可读流,因此我们可以通过管道输入process.stdin
我们的outStream
.
当我们运行上面的代码时,我们输入的任何内容都process.stdin
将使用该outStream
console.log
行回显。
这不是一个非常有用的实现流,因为它实际上已经实现并内置了。这非常等同于process.stdout
. 我们可以通过管道stdin
输入stdout
,我们将通过这一行获得完全相同的回声功能:
process.stdin.pipe(process.stdout);
实现可读流
要实现可读流,我们需要接口Readable
,并从中构造一个对象,并read()
在流的配置参数中实现一个方法:
const { Readable } = require('stream');
const inStream = new Readable({
read() {}
});
有一种简单的方法可以实现可读流。我们可以直接将push
我们希望消费者消费的数据:
const { Readable } = require('stream');
const inStream = new Readable({
read() {}
});
inStream.push('ABCDEFGHIJKLM');
inStream.push('NOPQRSTUVWXYZ');
inStream.push(null); // No more data
inStream.pipe(process.stdout);
当我们是push
一个null
对象时,这意味着我们想要发出信号,表明该流没有更多数据。
要使用这个简单的可读流,我们可以简单地将其通过管道传输到可写流中process.stdout
。
当我们运行上面的代码时,我们将从中读取所有数据inStream
并将其回显到标准输出。非常简单,但效率也不是很高。
我们基本上是在将所有数据通过管道传输到process.stdout
. 更好的方法是在消费者要求时按需推送数据。read()
我们可以通过在配置对象中实现该方法来做到这一点:
const inStream = new Readable({
read (size) {
// 数据有需求……有人要读。
}
});
当在可读流上调用 read 方法时,实现可以将部分数据推送到队列。例如,我们可以一次推送一个字母,从字符代码 65(代表 A)开始,并在每次推送时递增:
const inStream = new Readable({
read(size) {
this.push (String.fromCharCode(this.currentCharCode++));
if (this.currentCharCode > 90) {
this.push (null);
}
}
});
inStream.currentCharCode = 65;
inStream.pipe(process.stdout);
当消费者正在阅读可读流时,该read
方法将继续触发,我们将推送更多字母。我们需要在某个地方停止这个循环,这就是为什么 if 语句在 currentCharCode 大于 90(代表 Z)时压入 null 的原因。
此代码等同于我们开始使用的更简单的代码,但现在我们在消费者请求时按需推送数据。你应该总是那样做。
流对象模式
默认情况下,流需要缓冲区/字符串值。我们可以设置一个objectMode
标志让流接受任何 JavaScript 对象。
这是一个简单的例子来证明这一点。以下转换流组合实现了将逗号分隔值字符串映射到 JavaScript 对象的功能。于是就“a,b,c,d”
变成了{a: b, c: d}
。
const { Transform } = require('stream');
const commaSplitter = new Transform({
readableObjectMode: true,
transform(chunk, encoding, callback) {
this.push(chunk.toString().trim().split(','));
callback();
}
});
const arrayToObject = new Transform({
readableObjectMode: true,
writableObjectMode: true,
transform(chunk, encoding, callback) {
const obj = {};
for(let i=0; i < chunk.length; i+=2) {
obj[chunk[i]] = chunk[i+1];
}
this.push(obj);
callback();
}
});
const objectToString = new Transform({
writableObjectMode: true,
transform(chunk, encoding, callback) {
this.push(JSON.stringify(chunk) + '\n');
callback();
}
});
process.stdin
.pipe(commaSplitter)
.pipe(arrayToObject)
.pipe(objectToString)
.pipe(process.stdout)
我们传递输入字符串(例如“a,b,c,d”
),通过commaSplitter
它推送数组作为其可读数据 ( [“a”, “b”, “c”, “d”]
)。在该流上添加readableObjectMode
标志是必要的,因为我们将一个对象推到那里,而不是一个字符串。
然后我们获取数组并将其通过管道传输到arrayToObject
流中。我们需要一个writableObjectMode
标志来使该流接受一个对象。它还会推送一个对象(输入数组映射到一个对象),这就是为什么我们也需要readableObjectMode
那里的标志。最后一个objectToString
流接受一个对象但推出一个字符串,这就是我们只需要一个writableObjectMode
标志的原因。可读部分是普通字符串(字符串化对象)。