Stream -- Node.js中最好的却最容易被误解的部分

1,593 阅读8分钟

Stream是什么?

Streams 是一个数据集——和数组、字符串一样。不同的是streams的数据可能不能马上全部获取到,他们不需要在内存中。这样使得streams在处理大数据集或者来自外部的数据源的数据块上能发挥很大的作用。

然而,streams不仅是能用在大数据上,也给我们在代码中的可组合的能力。就像通过发送其他较小的Linux命令组成强大的Linux命令一样,我们可以在Node中用streams做同样的事情。

流(stream)在 Node.js 中是处理流数据的抽象接口(abstract interface)。 stream 模块提供了基础的 API 。使用这些 API 可以很容易地来构建实现流接口的对象。

Node.js 提供了多种流对象。 例如, HTTP 请求 和 process.stdout 就都是流的实例。

流可以是可读的、可写的,或是可读写的。所有流的对象都是EventEmitter的实例,都实现了EventEmitter的接口。

也就是流具有事件的能力,可以通过发射事件来反馈流的状态。这样我们就可以注册监听流的事件,来达到我们的目的。也就是我们订阅了流的事件,这个事件触发时,流会通知我,然后我就可以做相应的操作了。

流的类型

Node.js 中有四种基本的流类型:

  • Readable - 可读的流 (例如 fs.createReadStream()).
  • Writable - 可写的流 (例如 fs.createWriteStream()).
  • Duplex - 可读写的流 (例如 net.Socket).
  • Transform - 在读写过程中可以修改和变换数据的 Duplex 流 (例如 zlib.createDeflate()).

可读流(stream.Readable)

可读流有两种模式:flowing和paused

1)在流动模式下,可读流自动从系统底层读取数据,并通过EventEmitter接口的事件尽快将数据提供给应用。

2)在暂停模式下,必须显示调用stream.read()方法来从流中读取数据片段。

注意:如果可读流切换到流动模式,并且没有消费者处理流中的数据,这些数据将会丢失。

可读流(Readable streams)是对提供数据的 源头(source)的抽象

可读流的例子包括

  • HTTP responses, on the client :客户端请求
  • HTTP requests, on the server :服务端请求
  • fs read streams :读文件
  • zlib streams :压缩
  • crypto streams :加密
  • TCP sockets :TCP协议
  • child process stdout and stderr :子进程标准输出和错误输出
  • process.stdin :标准输入

所有的 Readable 都实现了 stream.Readable 类定义的接口

通过流读取数据

  • 用Readable创建对象readable后,便得到了一个可读流
  • 如果实现_read方法,就将流连接到一个底层数据源
  • 流通过调用_read向底层请求数据,底层再调用流的push方法将需要的数据传递过来
  • 当readable连接了数据源后,下游便可以调用readable.read(n)向流请求数据,同时监听readable的data事件来接收取到的数据

下面简单举个可读流的例子:

  • 监听可读流的data事件,当你一旦开始监听data事件的时候,流就可以读文件的内容并且发射data,读一点发射一点读一点发射一点
  • 默认情况下,当你监听data事件之后,会不停的读数据,然后触发data事件,触发完data事件后再次读数据
  • 读的时候不是把文件整体内容读出来再发射出来的,而且设置一个缓冲区,大小默认是64K,比如文件是128K,先读64K发射出来,再读64K在发射出来,会发射两次
  • 缓冲区的大小可以通过highWaterMark来设置
let fs = require('fs');
//通过创建一个可读流
let rs = fs.createReadStream('./1.txt',{
    flags:'r',//我们要对文件进行何种操作
    mode:0o666,//权限位
    encoding:'utf8',//不传默认为buffer,显示为字符串
    start:3,//从索引为3的位置开始读
    //这是我的见过唯一一个包括结束索引的
    end:8,//读到索引为8结束
    highWaterMark:3//缓冲区大小
});
rs.on('open',function () {
    console.log('文件打开');
});
rs.setEncoding('utf8');//显示为字符串
//希望流有一个暂停和恢复触发的机制
rs.on('data',function (data) {
    console.log(data);
    rs.pause();//暂停读取和发射data事件
    setTimeout(function(){
        rs.resume();//恢复读取并触发data事件
    },2000);
});
//如果读取文件出错了,会触发error事件
rs.on('error',function () {
    console.log("error");
});
//如果文件的内容读完了,会触发end事件
rs.on('end',function () {
    console.log('读完了');
});
rs.on('close',function () {
    console.log('文件关闭');
});

/**
文件打开
334
455
读完了
文件关闭
**/

可写流(stream.Writable)

1.Writable流的write()方法可以把数据写入流中。

其中,chunk是待写入的数据,是Buffer或String对象。这个参数是必须的,其它参数都是可选的。如果chunk是String对象,encoding可以用来指定字符串的编码格式,write会根据编码格式将chunk解码成字节流再来写入。callback是数据完全刷新到流中时会执行的回调函数。write方法返回布尔值,当数据被完全处理后返回true(不一定是完全写入设备哦)。

2.Writable流的end()方法可以用来结束一个可写流。它的三个参数都是可选的。chunk和encoding的含义与write方法类似。callback是一个可选的回调,当你提供它时,它会被关联到Writable的finish事件上,这样当finish事件发射时它就会被调用。

常用的事件:

drain事件:当一个流不处在 drain 的状态, 对 write() 的调用会缓存数据块, 并且返回 false。 一旦所有当前所有缓存的数据块都排空了(被操作系统接受来进行输出), 那么 'drain' 事件就会被触发

finish事件:在调用了 stream.end() 方法,且缓冲区数据都已经传给底层系统之后, 'finish' 事件将被触发。

可写流是对数据写入'目的地'的一种抽象 可写流的例子包括了:

  • HTTP requests, on the client 客户端请求
  • HTTP responses, on the server 服务器响应
  • fs write streams 文件
  • zlib streams 压缩
  • crypto streams 加密
  • TCP sockets TCP服务器
  • child process stdin 子进程标准输入
  • process.stdout, process.stderr 标准输出,错误输出

下面举个可写流的简单例子(当你往可写流里写数据的时候,不是会立刻写入文件的,而是会很写入缓存区,缓存区的大小就是highWaterMark,默认值是16K。然后等缓存区满了之后再次真正的写入文件里)

let fs = require('fs');
let ws = fs.createWriteStream('./2.txt',{
   flags:'w',
   mode:0o666,
   start:3,
   highWaterMark:3//默认是16K
});
  • 如果缓存区已满 ,返回false,如果缓存区未满,返回true
  • 如果能接着写,返回true,如果不能接着写,返回false
  • 按理说如果返回了false,就不能再往里面写了,但是如果你真写了,如果也不会丢失,会缓存在内存里。等缓存区清空之后再从内存里读出来
let flag = ws.write('1');
console.log(flag);//true
flag =ws.write('2');
console.log(flag);//true
flag =ws.write('3');
console.log(flag);//false
flag =ws.write('4');
console.log(flag);//false

'drain' 事件

如果调用 stream.write(chunk) 方法返回 false,流将在适当的时机触发 ‘drain’ 事件,这时才可以继续向流中写入数据

当一个流不处在 drain 的状态, 对 write() 的调用会缓存数据块, 并且返回 false。 一旦所有当前所有缓存的数据块都排空了(被操作系统接受来进行输出), 那么 ‘drain’ 事件就会被触发

建议, 一旦 write() 返回 false, 在 ‘drain’ 事件触发前, 不能写入任何数据块

举个简单的例子说明一下:

let fs = require('fs');
let ws = fs.createWriteStream('2.txt',{
    flags:'w',
    mode:0o666,
    start:0,
    highWaterMark:3
});
let count = 9;
function write(){
 let flag = true;//缓存区未满
//写入方法是同步的,但是写入文件的过程是异步的。
//在真正写入文件后还会执行我们的回调函数
 while(flag && count>0){
     console.log('before',count);
     flag = ws.write((count)+'','utf8',(function (i) {
         return ()=>console.log('after',i);
     })(count));
     count--;
 }
}
write();//987
//监听缓存区清空事件
ws.on('drain',function () {
    console.log('drain');
    write();//654 321
});
ws.on('error',function (err) {
    console.log(err);
});
/**
before 9
before 8
before 7
after 9
after 8
after 7
**/

如果已经不再需要写入了,可以调用end方法关闭写入流,一旦调用end方法之后则不能再写入

比如在ws.end();后写ws.write('x');,会报错write after end

'pipe'事件

  • linux精典的管道的概念,前者的输出是后者的输入
  • pipe是一种最简单直接的方法连接两个stream,内部实现了数据传递的整个过程,在开发的时候不需要关注内部数据的流动
  • 这个方法从可读流拉取所有数据, 并将数据写入到提供的目标中 自动管理流量,将数据的滞留量限制到一个可接受的水平,以使得不同速度的来源和目标不会淹没可用内存 默认情况下,当源数据流触发 end的时候调用end(),所以写入数据的目标不可再写。传 { end:false }作为options,可以保持目标流打开状态
pipe方法的原理
var fs = require('fs');
var ws = fs.createWriteStream('./2.txt');
var rs = fs.createReadStream('./1.txt');
rs.on('data', function (data) {
    var flag = ws.write(data);
    if(!flag)
    rs.pause();
});
ws.on('drain', function () {
    rs.resume();
});
rs.on('end', function () {
    ws.end();
});
pipe的用法:
let fs = require('fs');
let rs = fs.createReadStream('./1.txt',{
  highWaterMark:3
});
let ws = fs.createWriteStream('./2.txt',{
    highWaterMark:3
});
rs.pipe(ws);
//移除目标可写流
rs.unpipe(ws);
  • 当监听可读流data事件的时候会触发回调函数的执行
  • 可以实现数据的生产者和消费者速度的均衡
rs.on('data',function (data) {
    console.log(data);
    let flag = ws.write(data);
   if(!flag){
       rs.pause();
   }
});