【译】Nodejs Stream你需要知道的一切

Nodejs的流以难以使用,甚至更难理解而著称。好吧,我有个好消息要告诉你——不再是这样了。

过去几年里,开发者实现了许多工具包来简化流操作。但是在这篇文章中,我将着重介绍Nodejs原生的 stream API

流到底是什么?

流是数据的集合,就像字符串型数组中的单个字符串一样。不同的是,流可能不会一下子要用到它的所有内容,并且不需要把数据存储到内存中。这使得流在处理大量数据,以及从外部读取数据块时非常强大。

然而,流不仅仅是用来处理大数据的。它也让我们写的代码更灵活。就像在Linux系统中灵活使用命令一样,通过几个简单的命令输出组合(这里的输出组合,是指可以将上一个命令的输出当作下一个命令的输入,例如 ps -ef | grep 8080,将所有进程的信息输出当作下一个命令的输入,查找8080的匹配 ),完成一个强大的功能,我们也可以将同样的道理运用到Nodejs中。

const grep = ... // A stream for the grep output
const wc = ... // A stream for the wc input

grep.pipe(wc)

上述Linux命令行等价于js代码,Linux中使用“|”,nodejs中使用“pipe”。

Nodejs中内置的实现Stream的接口:

上面列出了一些内置的例子,一些是可读可写的,例如TCP sockets、zlib、和加密流。

需要注意,他们都是密切关联的。HTTP响应在客户端是一个可读的流,而在服务端是一个可写的流。这是因为在HTTP的中,我们是从http.IncomingMessage中读取,写到http.ServerResponse中。

还需要注意到,标准输入输出流(stdin,stdout,stderr)向子进程发送时,是有反向类型的,这使得主进程的标准输入流很容易pipe到子进程中。

一个练习的例子

理论看上去不错,但是不能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模块可以用来读和写文件。上面这个例子,我们通过写入流循环100万次,输出了一个名称为big.file的文件。

运行上述脚本,生成的文件大概400MB。

然后写一个简单的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),但是,并没有阻塞类似事件循环之类的东西。看上去还不错,是吧?

 

当我启动这个Node Web服务时,大概消耗了9.3MB内存:

 

然后在浏览器敲localhost:8000,访问服务后,再看一下内存消耗(使用360浏览器直接崩溃了):

 内存消耗达一下跳到了430MB!

实际上我们是把big.file整个文件的内容都放到了内存中,之后再把内存中的数据块输出。这是非常低效的。

HTTP 响应对象也是一个输出流。这就意味着如果我们能用一个输入流来读取big.file,就可以使用pipe来传输数据了,就不会消耗400多MB的内存。

Nodejs的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);

 现在我们再访问服务,一个神奇的事情发生了,内存消耗只有20几MB:

发生了什么?

当客户端请求时,我们使用流进行传输,每次传一个数据块,这就意味着不需要把全部数据缓存到内存中。因此内存最多也就使用25MB而已。

这很酷,在加载图片或者大量文本时,对客户端的体验是不同的。不使用流的情况下,对机器内存压力很大,而且客户端体验会变差。因为它需要所有chunk先缓存到缓冲区,再返回;而流模式下是分批多次的返回,客户端体验是不停的在返回(虽然每次只是返回一点点,例如,一张图片从上到下慢慢刷出来的,而不是等很久之后Duang的一下扔出来),而不是长时间没有响应。

你可以把这个测试推向极限,使用5百万次循环重新生成一个big.file,那么就要超过2GB的大小,这就会超出nodejs的默认分配内存大小。如果还使用fs.readFile的方式就会失败(当然你可以去手动修改内存限制),但是如果使用fs.createReadStream就没有这个问题,并且内存消耗和之前400多MB的情况一样。

准备好开始学习流了吗?

Streams 101

在nodejs中,有四种基本类型的流:可读型(readable)、可写型(writable)、双向型(duplex)、转换型(transform streams)。

  • 可读型的流,是资源可以被消费的一个抽象。例如,fs.createReadStream方法。
  • 可写型的流,是数据可以被写到目的地的一个抽象。例如,fs.createWriteStream方法。
  • 双向型的流,既可读又可写。例如,TCP socket。
  • 转换型的流是基于双向型流的,可以在读取或者写入时用来修改或者转化数据。例如,zlib.createGzip流压缩数据。你可以把转换型的流认为是一个函数,输入的是可写型流部分,输出是可读型流的部分。你可能也听过转换型流也被称为“through streams”

所有流都是EventEmitter的实例,它们发送事件,用来读和写数据。然而,我们可以使用一个简单的pipe方法来使用流数据。

pipe 方法

这是一行充满魔力的代码,需要记住:

readableSrc.pipe(writableDest)

在这简单的一行里,我们将可读流(数据的来源)的输出管道化为可写流(目标)的输入。数据源必须是一个可读流,目的地必须是一个可写流。当然,它们也可以是 双工流(duplex)或者转换流(transform)。事实上,如果使用双工流pipe,就像Linux操作一样:

readableSrc
  .pipe(transformStream1)
  .pipe(transformStream2)
  .pipe(finalWrtitableDest)

pipe方法返回了目标流,这样就可以向上面那样,使用链式调用。对于流: a(readable),b和c(duplex),d(writable)我们可以:

a.pipe(b).pipe(c).pipe(d)

# Which is equivalent to:
a.pipe(b)
b.pipe(c)
c.pipe(d)

# Which, in Linux, is equivalent to:
$ a | b | c | d

pipe方法是最简单的使用流的方式。一般来说,会建议使用pipe方法或者event来使用流。但是要避免同时使用pipe和event。通常使用了pipe方法就不需要使用event,但是如果你需要以自定义的方式使用流,那就选择使用event。

Stream events

除了从一个可读流中读取数据并输出到一个可写流中,pipe方法在传输过程中会自动管理一些事情。例如,它处理了 errors、end-of-files、调用链中的某个流明显比其它流慢或者快的问题等。

流可以被event直接使用。下面是一个简单的event和pipe等效的示例:

# readable.pipe(writable)

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方法。

可读流的暂停(Pause)模式和流动(Flow)模式

我们使用可读流主要有两种模式:

  • 暂停模式(在暂停模式中,必须显式调用 stream.read() 读取数据块)
  • 流动模式(在流动模式中,数据自动从底层系统读取,并通过 EventEmitter 接口的事件尽可能快地被提供给应用程序)

这些模式有时也被称作,推、拉模式。

所有的可读流都是以暂停模式开始,但是他们可以很容易的转换为流动模式,并且当需要变回暂停模式时再变回去。转换过程有时是自动的。

当可读流是暂停模式,我们可以使用read()方法按需读取数据,然而,如果是流动模式时,就需要使用事件监听的方式来使用可读流了。

在流动模式下,如果没有消费者来处理数据,数据是很容易丢失的。这就是为什么当我们使用流模式的可读流时,需要一个data事件来处理。事实上,仅仅添加一个data事件处理程序,就可以将暂停的流切换到流模式,并删除数据事件处理程序,就可以将流切换回暂停模式。这样做的部分原因是为了向后兼容旧版本的Nodejs stream接口。

你可以使用resume()方法和pause()方法,来手动切换这两种模式。

当在可读流中使用pipe方法时,我们不需要担心这些模式,pipe方法会自动管理它们。

实现流

我们讨论Nodejs中的流,主要有两个不同的任务:

  • 实现一个流
  • 使用流

目前为止,我们已经谈了使用流,现在来实现一个流!

流的实现通常需要引入stream模块。

实现一个可写流(writableStream)

实现一个可写的流,我们需要使用stream模块中的Writable构造器。

const { Writable } = require('stream');

我们可以多种方式实现。例如,继承Writable构造器。

class myWritableStream extends Writable {
}

然而,我想以一种更简单的方式使用。只需要使用Writable创建一个对象,并传入一些选项。唯一需要的选项就是一个write函数,用于暴露写数据块的方法。

const { Writable } = require('stream');

const outStream = new Writable({
  write(chunk, encoding, callback) {
    console.log(chunk.toString());
    callback();
  }
});

process.stdin.pipe(outStream);

write方法有三个参数。

  • chunk通常是一个缓冲区,除非我们使用不同的方式配置流
  • encoding参数通常可以省略
  • callback是当我们处理完数据块之后需要回调的函数,用来表示是否写成功,当前数据被完全消费之后,必须调用callback函数。 当处理输入的过程中发生出错时,callback的第一个参数传入 Error对象,否则传入 null。 如果 callback传入了第二个参数,则它会被转发到 readable.push()。 就像下面的例子:
transform.prototype._transform = function(data, encoding, callback) {
  this.push(data);
  callback();
};

transform.prototype._transform = function(data, encoding, callback) {
  callback(null, data);
};

在输出流中,我们简单地使用console.log将数据块记录为一个字符串,并在此之后调用回调函数(不带任何参数,相当于传入了两个null),来表示成功。这非常简单但并没什么用。它仅仅返回了接收到的内容。

要使用这个流,我们可以简单的和process.stdin连用,这是一个可读流,因此可以把process.stdin和我们的输出流用pipe连起来。

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'); // 等效于在read方法中使用this.push("ABCDEFGHIJKLM");
inStream.push('NOPQRSTUVWXYZ'); // 等效于在read方法中使用this.push("NOPQRSTUVWXYZ");

inStream.push(null); // No more data 等效于在read方法中使用this.push(null);

inStream.pipe(process.stdout);

当push一个null对象时,意味着我们想向流发送一个信号,表示已经没有数据要继续推送了。

要使用这个流时,我们可以直接把这个流pipe到一个可写的流中,例如process.stdout。

运行上面代码,就会把输入流中的数据输出到控制台。非常简单,但是效率并不高。

上面的逻辑是先把所有的数据都push到输入流中,再输出到输出流中。更好的方式是按需push数据,当有人请求数据了再push。我们可以通过实现read方法来做到:

const inStream = new Readable({
  read(size) {
    // there is a demand on the data... Someone wants to read it.
  }
});

当read方法再可读流中被调用,就可以push一部分数据到队列中。例如,我们可以一次push一个字母,从65开始(代表A),然后每次push后自增:

const { Readable } = require('stream'); 

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方法会一直触发,因此判断code大于90(代表Z)时push一个null,用于停止循环。(readableStream,是需要一个结束标识的,如果没有会一直调用read方法,不会终止)

这段代码等同于一个简单版的,请求来了,按需push数据,而非一次全扔进去的模型。

实现一个双向/转换(Duplex/Transform)流

使用双向流,可以实现统一对象既可读又可写。类似继承了这两种接口。

下面是一个双向流的实现,结合了可读、可写的特性:

const { Duplex } = require('stream');

const inoutStream = new Duplex({
  write(chunk, encoding, callback) {
    console.log(chunk.toString()); //在这添加处理数据的逻辑
    callback();
  },

  read(size) {
    this.push(String.fromCharCode(this.currentCharCode++));
    if (this.currentCharCode > 90) {
      this.push(null);
    }
  }
});

inoutStream.currentCharCode = 65;

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

通过组合read和write我们可以使用duplex流实现A-Z的输出,并能输出实时输入。我们将可读的stdin流导入这个双工流显示输出A-Z,并将duplex流本身导入可写的stdout流以查看字母A到Z。

需要理解很重要的一点,duplex中的可读流和可写流是互相独立的。它仅仅是将两种特性加入到一个对象中。

转换流是更有趣的双工流(它继承自双工流),因为它的输出是从它的输入计算出来的。

对于转换流,我们不必实现read或write方法,我们只需要实现一个transform方法,它将两者结合起来。它具有write方法的签名(相同的方法名和参数类型),也可以使用它来推送数据(即readableStream才有的push)。

下面是一个简单的转换流,它可以将你输入的任何内容转换成大写格式后返回:

const { Transform } = require('stream');

const upperCaseTr = new Transform({
  transform(chunk, encoding, callback) {
    this.push(chunk.toString().toUpperCase());
    callback();
  }
});

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

在这个transform流中,我们使用的方式与前面的duplex流示例完全相同,只实现了transform()方法。在该方法中,我们将数据块转换为它的大写版本,然后将修改后的数据块返回。

流对象模型(Streams Object Mode)

默认情况下,流需要buffer类型或者字符串类型的值。有一个objectMode标志,我们可以将其设置为true,让流接受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)

我们通过commaSplitter传递输入字符串(例如,“a,b,c,d”),commaSplitter将数组作为readableStream的数据([" a ", " b ", " c ", " d "])。在流上添加readableObjectMode=true标志,因为我们将一个对象推到那里,而不是一个字符串(或者buffer)。

然后我们获取数组并将其导入arrayToObject流。我们需要一个writableObjectMode=true标志来使流接受对象。它还将push一个对象(将输入数组映射到一个对象),这就是为什么我们还需要设置readableObjectMode=true。最后一个objectToString流接收一个对象,但输出一个字符串,这就是为什么我们只需要设置writableObjectMode=true。可读部分是一个普通的字符串(stringified对象)。

Node 内置的转换流

Node内置一些非常有用的转换流。zlib、crypto流。

下面是一个使用zlib.createGzip()流与fs的可读/可写流相结合来创建文件压缩脚本的示例:

const fs = require('fs');
const zlib = require('zlib');
const file = process.argv[2];

fs.createReadStream(file)
  .pipe(zlib.createGzip())
  .pipe(fs.createWriteStream(file + '.gz'));

可以使用此脚本对作为参数传递的任何文件进行gzip压缩。我们将该文件的可读流导入zlib内置的转换流,然后将其导入新的gzip压缩文件的可写流。

使用管道最酷的地方在于,如果需要,我们可以将它们与事件结合起来。例如,我希望用户在脚本工作时看到进度条,在脚本完成时看到“Done”消息。由于管道方法返回目标流,因此我们可以把事件注册连起来:

const fs = require('fs');
const zlib = require('zlib');
const file = process.argv[2];

fs.createReadStream(file)
  .pipe(zlib.createGzip())
  .on('data', () => process.stdout.write('.'))
  .pipe(fs.createWriteStream(file + '.zz'))
  .on('finish', () => console.log('Done'));

因此,使用管道方法,我们可以轻松地使用流,但是我们仍然可以在需要的地方使用事件进一步定制与这些流的交互。

管道方法厉害之处在于,我们可以以一种可读性很强的方式使用它,一块一块地编写程序。例如,我们可以简单地创建一个转换流来反应进度,而不是监听上面的数据事件,并将.on()调用替换为另一个.pipe()调用:

const fs = require('fs');
const zlib = require('zlib');
const file = process.argv[2];

const { Transform } = require('stream');

const reportProgress = new Transform({
  transform(chunk, encoding, callback) {
    process.stdout.write('.');
    callback(null, chunk);
  }
});

fs.createReadStream(file)
  .pipe(zlib.createGzip())
  .pipe(reportProgress)
  .pipe(fs.createWriteStream(file + '.zz'))
  .on('finish', () => console.log('Done'));

这个reportProgress流是一个简单的传递流,但它也将进度报告给标准输出。注意我是如何使用callback()函数中的第二个参数将数据推入transform()方法中的。这相当于先传数据。

组合流的应用是无止境的。例如,如果我们需要在gzip之前或之后对文件进行加密,我们所需要做的就是按照我们需要的确切顺序对另一个转换流进行管道传输。我们可以使用Node的密码模块:

const crypto = require('crypto');
// ...

fs.createReadStream(file)
  .pipe(zlib.createGzip())
  .pipe(crypto.createCipher('aes192', 'a_secret'))
  .pipe(reportProgress)
  .pipe(fs.createWriteStream(file + '.zz'))
  .on('finish', () => console.log('Done'));

上面的脚本压缩并加密传递的文件,只有拥有该秘钥的人才可以使用输出的文件。我们不能用普通的解压工具解压这个文件,因为它是加密的。

为了实际能够解压任何压缩与上述脚本,我们需要使用相反的流加密和zlib在一个反向的顺序,这很简单:

fs.createReadStream(file)
  .pipe(crypto.createDecipher('aes192', 'a_secret'))
  .pipe(zlib.createGunzip())
  .pipe(reportProgress)
  .pipe(fs.createWriteStream(file.slice(0, -3)))
  .on('finish', () => console.log('Done'));

假设通过文件是压缩的,上面的代码将创建一个读取流,传输到createDecipher()流(使用相同的秘钥),输出到zlib createGunzip()流,然后把东西写出来回到一个文件没有扩展的部分。

这就是这个主题的全部内容。感谢你的阅读!下次见!

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值