流(stream):可读流篇

439 阅读8分钟

node 可读流

流是Node.js最重要的组成和设计模式之一,同时也是最容易让人产生误解的地方。流,不仅因为其在技术层面表现出的良好性能和高效率,更因为他的优雅,以及能完美契合Node.js的变成思想。

流为什么很重要

我们都知道,Node.js是以事件为基础的,实时处理是处理I/O操作最高效的方法,尽快接收输入内容,并经过程序处理尽快输出结果。

那什么是流呢?

流是一组有序的,有起点和终点的字节数据传输手段 它不关心文件的整体内容,只关注是否从文件中读到了数据,以及读到数据之后的处理 流是一个抽象接口,被 Node 中的很多对象所实现。比如HTTP 服务器request和response对象都是流。

从Node.js的核心模块开始,流是随处可见的。比如,fs模块使用的createReadStream()方法读取文件,使用createWriteStream()来写文件,HTTP的request和response对象本质来说也是流。

流的分类:四种基本的流类型 每个流的实例都是stream模块提供的四个基本抽象类之一的实现:

  • Readable - 可读的流 (例如 fs.createReadStream()).
  • Writable - 可写的流 (例如 fs.createWriteStream()).
  • Duplex - 可读写的流 (例如 net.Socket).
  • Transform - 在读写过程中可以修改和变换数据的 Duplex 流 (例如 zlib.createDeflate()). 实际上,流可以产生好几类事件,比如在可读流完成读取之后会产生end事件,或者在发生错误时产生error事件。

流中的数据有两种模式,二进制模式和对象模式.

二进制模式, 每个分块都是buffer或者string对象. 对象模式, 流内部处理的是一系列普通对象.

所有使用 Node.js API 创建的流对象都只能操作 strings 和 Buffer对象。但是,通过一些第三方流的实现,你依然能够处理其它类型的 JavaScript 值 (除了 null,它在流处理中有特殊意义)。 这些流被认为是工作在 “对象模式”(object mode)。 在创建流的实例时,可以通过 objectMode 选项使流的实例切换到对象模式。试图将已经存在的流切换到对象模式是不安全的。

现在先从简单入手,先聊聊可读流~

可读流

可读流读取数据的两种模式:流动模式(flowing) 和暂停模式(paused)

在flowing模式下,从流中读取数据的方式是给data事件添加一个监听器,在该模式下数据不是通过read()来获取。而是,只要流中的数据可读,便会立即被推送到data事件的监听器。 在 paused 模式下,必须显式调用 stream.read() 方法来从流中读取数据片段。 所有初始工作模式为 paused 的 Readable 流,可以通过下面三种途径切换到 flowing 模式:

  • 监听 'data' 事件
  • 调用 stream.resume() 方法
  • 调用 stream.pipe() 方法将数据发送到 Writable

可读流可以通过下面途径切换到 paused 模式:

  • 如果不存在管道目标(pipe destination),可以通过调用 stream.pause() 方法实现。
  • 如果存在管道目标,可以通过取消 'data' 事件监听,并调用 stream.unpipe() 方法移除所有管道目标来实现。

如果 Readable 切换到 flowing 模式,且没有消费者处理流中的数据,这些数据将会丢失。 比如, 调用了 readable.resume() 方法却没有监听 'data' 事件,或是取消了 'data' 事件监听,就有可能出现这种情况。

实现可读流

现在,先实现一个可读流的例子。

/*
var rs = fs.createReadStream(path,[options]);
path读取文件的路径
options
    flags打开文件要做的操作,默认为'r'
    encoding默认为null
    start开始读取的索引位置
    end结束读取的索引位置(包括结束位置)
    highWaterMark读取缓存区默认的大小64kb
*/


let fs = require('fs');
let path = require('path');

let rs = fs.createReadStream(path.join(__dirname,'文件名'),{// 返回的是一个可读流对象
    flags:'r',// 文件的操作是读取操作
    encoding:'utf8',// 默认是null  null代表的是buffer
    autoClose:true,// 读取完毕后自动关闭
    highWaterMark:3,//最高水位线 默认是64k  64*1024b
    start:0,//文件是有序的,从哪读到哪,可以自己定,从0开始读
    end:9,//读10个数,包前又包后,0-9,既有前面又有后面
});
//rs.setEncoding('utf8');//设置读取的编码格式,一般是utf8,与指定{encoding:'utf8'}效果相同,设置编码
// 默认情况下 不会将文件中的内容输出
// 也就是说把这个流往这里一放,它不会自动读文件
// 内部会先创建一个buffer先读取3b,因为先把highWaterMark的值为3,这个文件不会全读了先放在这,什么都不动。这种模式叫非流动模式 / 暂停模式

// 那什么时候开始真正工作呢?你需要干一件事。这个流是基于事件的,所以我们要先监听一下事件,事件名是内置好的
// 操作的是一个文件,需要把文件打开
rs.on('open',function(){//监听open事件
    console.log('文件开启');
})
// 文件有打开就有关闭
rs.on('close',function(){
    console.log('文件关闭');
});
// 因为事件都是异步的,触发的时候会触发回调函数,可能会有一些错误,错误需要捕获
rs.on('error',function(err){//监听error事件
    console.log(err);
});
rs.on('data',function(data){// 监听了data事件,流就从暂停模式->流动模式,但流动模式会疯狂触发data事件,不停的触发,知读取取完毕
    console.log(data) // 读取10个数
    rs.pause();  //暂停方法 表示暂停读取,暂停data事件触发
});
// 希望过一段时间继续读取
/*setTimeout(function(){
    rs.resume();//恢复data事件触发,继续读取,变为流动模式,但又会触发pause,所以这里可以使用setInterval,每隔一段时间执行一次
},1000);*/
setInterval(function(){
    rs.resume();//恢复触发data
},1000);
// 读完以后,告知流结束,该事件会在读完数据后被触发
rs.on('end',function(){
    console.log('end')
})

以上实现的结果是;

文件开启
hel
low
orl
d
end
文件关闭
//注:我文件的数据是helloworld!

根据上面的案例,简单实现一个可读流的库(功能尚未完善):

// 可读流的实现
// 可读流是基于事件的,所以先把事件的模块先引进来
let EventEmitter = require('events');
let fs = require('fs');

// 实现一个createReadStream的类,类继承EventEmitter
class RreadStream extends EventEmitter {
    // 类需要路径 对象 两个参数
    constructor(path,options){
            // 继承了必须干一件事
            super();//固定的写法
            this.path = path;
            this.flags = options.flags || 'r';//如果没传,就给默认参数
            this.autoClose = options.autoClose || true;//默认true
            this.highWaterMark = options.highWaterMark|| 64*1024;//默认是64k
            this.start = options.start||0;//默认开始是0
            this.end = options.end;
            this.encoding = options.encoding || null;//默认null


            // 默认情况下,要先打开文件开始读,先写一个打开文件的方法
            this.open();//打开文件 目的是为了获取fd--文件描述符,有了这个描述符,我们才能去读取内容

            // 是否监听了data事件,如果监听了,就变成流动模式
            this.flowing = null; // null就是暂停模式

            // 要建立一个buffer 这个buffer就是要一次读多少
            this.buffer = Buffer.alloc(this.highWaterMark);
        
            this.pos = this.start; // pos 表示读取的位置 可变 start不变的

            // 如果有新事件绑定了,就会触发newListener
            this.on('newListener',(eventName,callback)=>{
                if(eventName === 'data'){//如果事件名==data
                    // 相当于用户监听了data事件
                    this.flowing  = true;//变成流动模式
                    // 监听了 就去读
                    this.read(); // 读内容了,需要实现一个read方法
                }
            })

            
    }
    open(){ 
        fs.open(this.path,this.flags,(err,fd)=>{//路径,读(打开文件的目的),回调函数(箭头函数防止this混乱),err表示读文件出错了,fs表示文件描述符
            if(err){
                this.emit('error',err);//文件出错,触发error事件
                if(this.autoClose){ // 是否自动关闭--防止文件出错,不能访问
                    // 如果文件可以自动关闭,那文件关掉再触发
                    this.destroy();//销毁掉,需要实现一个destroy方法
                }
                return;//报错就不往下走
            }
            this.fd = fd; // 如果文件没有出错,就把文件挂在当前的实例上 即保存文件描述符
            this.emit('open'); // 文件打开成功了,监听open
        });
    };
    // destroy方法
    destroy(){
        // 先判断有没有fd描述符 有则关闭文件 触发close事件
        if(typeof this.fd ==='number'){//有的话一定是number类型,true说明打开过要销毁
            fs.close(this.fd,()=>{//把文件先关掉,再销毁,触发close
                this.emit('close');
            });
            return;
        }
        this.emit('close'); // 销毁 , 需实现一个close方法
    };
    resume(){//恢复触发data
        this.flowing = true;
        this.read();
    }
    pause(){//暂停方法 表示暂停读取,暂停data事件触发
        this.flowing = false;
    }
    read(){
        // 先判断此时文件有没打开
        if(typeof this.fd !== 'number'){//如果文件没打开 ,触发一次open事件
            // 文件打开 会触发open事件,触发事件后再执行read,此时fd肯定有了
            return this.once('open',()=>this.read());//open走一次,就触发一次,之后不再走了,因为已经有文件了。
            // 当触发open,事件已经被触发了,当触发的时候,会走对应的回调函数
        } 
        // 此时有fd了
        // 每次读多少个。如果没有end,随便读,读多少个都可以。如果有highWaterMark,可能读到某一个就停住了
        let howMuchToRead = this.end?Math.min(this.highWaterMark,this.end-this.pos+1):this.highWaterMark;//读的时候,用结尾的进去当前位置
        // 比方说:想读4个,但写的是3,每次读3个
        // 第一次读123 下一次读一个4
        fs.read(this.fd,this.buffer,0,howMuchToRead,this.pos,(err,bytesRead)=>{
            // 读到了多少个 累加
            if(bytesRead>0){
                this.pos+= bytesRead;
                // 判断有没传encoding,如果传了,转成字符串的形式,如果没传,说明默认是null
                // this.buffer.slice(0,bytesRead)由buffer第0个开始就截,读一个就取一个,读两个就取两个,bytesRead是真实读到的位置
                let data = this.encoding?this.buffer.slice(0,bytesRead).toString(this.encoding):this.buffer.slice(0,bytesRead);
                this.emit('data',data);
                // 当读取的位置 大于了末尾 就是读取完毕了
                if(this.pos > this.end){
                    this.emit('end');
                    this.destroy();
                }
                if(this.flowing) { // 流动模式继续触发
                    this.read(); 
                }
            }else{
                this.emit('end');
                this.destroy();
            }
        });
    }
}
module.exports = RreadStream

参考: Node.js文档