Node中的一股清流,比流川枫还要樱花木道

1,319 阅读17分钟

导读:由于事件流是基于EventEmitter实例,并且也是通过fs模块来创建的,所以在学习事件流之前,最好先了解一下发布订阅和fs文件的操作,才可以对源码有更好的理解。

为什么需要流

在node中读取文件的方式有来两种,一个是利用fs模块,一个是利用流来读取。如果读取小文件,我们可以使用fs读取,fs读取文件的时候,是将文件一次性读取到本地内存。而如果读取一个大文件,一次性读取会占用大量内存,效率很低,这个时候需要用流来读取。流是将数据分割段,一段一段的读取,效率很高。

流的特点

流也是操作文件的一种方式,流的特点有序(有顺序的,顺序不能打乱),有方向的

对文件操作用的也是fs模块,可以用fs模块的方法来创建流

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

流的分类

Readable Stream :可读数据流

Writeable Stream :可写数据流

Duplex Stream :双向数据流,可以同时读和写

Transform Stream: 转换数据流,可读可写,同时可以转换(处理)数据

这里我们介绍两种:可读流,可写流

可读流

可读流的两种模式

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

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

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

两种模式之间的转换

所有初始工作模式为 paused 的 Readable 流,可以通过下面三种途径切换到 flowing 模式:

  • 监听 'data' 事件。

  • 调用 stream.resume() 方法。

  • 调用 stream.pipe() 方法将数据发送到 Writable。

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

  • 如果不存在管道目标(pipe destination),可以通过调用 stream.pause() 方法实现。

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

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

常用的事件

  1. 'open'事件
  2. 'close'事件
  3. 'error'事件
  4. 'data'事件 - 数据正在传递时,触发该事件(以chunk数据块为对象)
  5. 'end'事件 - 数据传递完成后,会触发该事件。

常用的方法

  1. rs.pause()暂停
  2. rs.resume()恢复

所有这些事件都可以在官方API文档中找到例子。我们可以监听流的这些事件,来完成相应操作。通过以下代码来说明:

1.txt文件,内容如下(这是我们要操作的文件)

1234567890

js文件:创建可读流和常用方法

let fs = require('fs');
let path = require('path');
// let ReadStream = require('./ReadStream');

// 1、创建可读流(返回一个可读流对象)  默是非流动模式,不会读取数据
// 读取的时候默认读  默认64k,encoding 读取默认都是buffer
let rs = fs.createReadStream(path.join(__dirname,'1.txt'),{
// let rs = new ReadStream('./2.txt',{
    flags:'r', // 文件的操作是读取操作
    encoding:'utf8',// 默认是null  null代表的是buffer
    autoClose:true,// 读取完毕后自动关闭
    highWaterMark:3,// 默认是64k 64*1024b
    start:0,
    end:8 // 包前又包后 从0到8
});


// 2、可读流常用方法
// 设置读取的编码格式 等价于encoding:'utf8'(创建可读流不写配置时,需要加上)
rs.setEncoding('utf8');
// 操作的是一个文件,首先需要open打开这个文件
rs.on('open',function(){
    console.log('文件打开了');
});
// 文件关闭触发close事件
rs.on('close',function(){
    console.log('关闭');
});
// 出现错误
rs.on('error',function(){
    console.log(err);
});
// 默认情况下是非流动模式,通过监听 'data'事件,使暂停模式变成流动模式,流动模式会
// 疯狂的触发data事件,直到读取完毕
rs.on('data',function(data){
    console.log(data);
});
// 读完后触发end事件
rs.on('end',function(){
    console.log('end');
});
// 输出
文件打开了
123
456
789
end
关闭

在以上的代码基础上添加暂停,恢复事件方法

rs.on('open',function(){
    console.log('文件打开了');
});
rs.on('close',function(){
    console.log('关闭');
});
rs.on('error',function(){
    console.log(err);
});
rs.on('data',function(data){
    console.log(data);
    rs.pause(); // 暂停方法 表示暂停读取,暂停data事件触发
});
setTimeout(function(){
    rs.resume(); // 恢复data事件的触发,继续读取, 变为流动模式
},3000);
rs.on('end',function(){
    console.log('end');
});

这里输出123后会暂停三秒,等事件再次变为流动模式后,才会输出456然后暂停,这里并没有将文件读取完毕,所以不会触发end和close事件。

// 输出 
文件打开了
123
456

将setTimeout改成setInterval,每隔三秒恢复流动模式,输出值,直到文件读取完毕。

// 输出 
文件打开了
123
456
789
end
关闭

实现一个可读流

// ReadStream.js
let fs = require('fs'); // 引入fs核心模块
let EventEmitter = require('events'); // 需要依赖事件发射

// 1 创建实例继承EventEmitter
class ReadStream extends EventEmitter{
    constructor(path,options={}){
        super();
        this.path = path;
        this.highWaterMark = options.highWaterMark || 64*1024;
        this.autoClose = options.autoClose || true;
        this.start = options.start || 0;
        this.pos = this.start; // pos会随着读取的位置改变
        this.end = options.end || null; // null表示没传递
        this.encoding = options.encoding || null;
        this.flags = options.flags || 'r';


        // 一些参数的问题
        // 6、非流动模式  null是暂停模式
        this.flowing = null; 
        // 7、看是否监听了data事件,如果监听了  就要变成流动模式(同步)
        this.on('newListener',(eventName,callback)=>{
            if(eventName === 'data'){
                // 相当于用户监听了data事件
                this.flowing = true;
                // 监听了 就去读
                this.read(); // 去读内容了
            }
        })


        // 9、要建立一个buffer,把内容读到这个buffer里,读完以后再发射出去,这个buffer就是要
        //  一次读highWaterMark个  
        this.buffer = Buffer.alloc(this.highWaterMark);
        //  9、pos读取到的位置是可变的,start是不变的
        this.pos = this.start;

        //3、 只要读流就要先打开文件,打开文件的目的是为了获取文件描述符fd,有了这描述符才
        //  能去读取内容 
        //  this指的是实例,这是一个实例上的方法  open完了需要去监听data事件才会触发流动模式  
        this.open();
    }


    // 8、open是异步的,newListener是同步的,read同步,同步的先执行,所以第一次调用read方法时还
    // 没有获取fd  所以不能直接读
    read(){ 
        // console.log(this.fd) // 先打印的undefind  再去触发的open
        if(typeof this.fd !== 'number'){ // 通过fd判断此时文件还没打开
            return this.once('open',() => this.read()); 
            // 当文件真正打开后会触发open事件,此时fd肯定拿到了,拿到以后再去执行read方法
            //(见10)
        }
        
        // 10、此时有fd了  开始读取文件  第二个参数(见9) 
        // buffer里应该填多少? 计算howMuchToRead  // 123 4
        let howMuchToRead = this.end?Math.min(this.highWaterMark,this.end-this.pos+1)
        :this.highWaterMark;
        fs.read(this.fd,this.buffer,0,howMuchToRead,this.pos,(err,bytesRead)=>{
            if(bytesRead>0){
                // 读到了多少个  累加
                this.pos += bytesRead;
                // 这里的this.buffer长度是3  需要截取真实读到的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();
            }
        });
    }


    // 12 恢复
    resume(){
        this.flowing = true;
        this.read();
    }
    // 11 暂停
    pause(){
        this.flowing = false
    }


    // 5、销毁关闭文件
    destroy(){
        // 通过fd判断文件是否打开过,打开了就关闭文件,触发close事件
        if(typeof this.fd === 'number'){
            fs.close(this.fd,()=>{ 
                this.emit('close');
            });
            return
        }
        this.emit('close');
    }


    // 4、 打开文件
    open(){
        fs.open(this.path,this.flags,(err,fd)=>{ 
            // fd标识的就是当前this.path这个文件,从3开始(number类型)
            if(err){
                if(this.autoClose){ // 是否自动关闭,如果需要自动关闭我再去销毁fd
                    this.destroy(); // 销毁(关闭文件,触发关闭事件)
                }
                this.emit('error',err); // 如果有错误触发error事件
                return;
            }
            this.fd = fd; // 没有错误,将fd文件描述符挂载到当前实例上
            this.emit('open',this.fd);// 文件打开成功后监听open事件,触发文件打开的方法  
        });
    }
}


// 2 
module.exports = ReadStream;

可写流

特点

可写流有缓存区的概念

第一次写入是真的向文件里写,第二次在写入的时候是放到了缓存区里,过一会写完了,会去清空缓存区

写入时会返回一个boolean类型,返回为false时不要再写入了

当内存和正在写入法人内容消耗完后 会触发一个事件 drain

常用的方法

1,write(chunk[,encoding] [,callback])方法可以把数据写入流中。

其中,chunk是待写入的数据,是Buffer或String对象。这个参数是必须的,其它参数都是可选的。如果chunk是String对象,encoding可以用来指定字符串的编码格式,write会根据编码格式将chunk解码成字节流再来写入。callback是数据完全刷新到流中时会执行的回调函数。

write方法返回布尔值,表示的并不是是否写入,表示的是能否继续写,但是返回false也不会丢失,就是会把内容放到内存中

// 代码一
let fs = require('fs');
// 创建可写流
let ws = fs.createWriteStream('./1.txt',{
    flags:'w', // 默认文件不存在会创建
    highWaterMark:3, // 设置当前缓存区的大小,每次写3个  默认写是16k,读是64k
    encoding:'utf8',// 文件里存放的都是二进制
    start:0, // 从哪开始写
    autoClose:true, // 自动关闭
    mode:0o666,// 可读可写
});

// flag代表是否能继续写 写的时候有个缓存区的概念,先写到缓存区里去,缓存区没满是true,缓存区满
// 了就是flase,但是返回false也不会丢失,就是会把内容放到内存中
let flag = ws.write(1+'','utf8',()=>{}); // 异步的方法
console.log(flag);
flag = ws.write(1+'','utf8',()=>{}); // 异步的方法
console.log(flag);
flag = ws.write(1+'','utf8',()=>{}); // 异步的方法
console.log(flag);
// 输出
true
true
false

写出的内容输出到1.txt中(可写流没有会自动创建,有内容会清空,1.txt是自动创建的)

// 1.txt 输出
111

注意:最后一次当缓存区满了返回false时,数据并没有丢失,最后还是输出的三次=>111

2,end([chunk] [,encoding][,callback])方法可以用来结束一个可写流。

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

当写完触发end事件后,就不能再继续写了

// 接着代码一的
ws.end('ok'); // 这个ok也会写到1.txt中去  当写完后  就不能再继续写了
// ws.write('123'); // 报错  write after  end
// 1.txt 输出
111ok

常用的事件

1、drain事件:抽干方法。只有当highWaterMark缓存区填满,满了后被清空了才会触发drain。(当可读流配合可写流可以用drain事件写一个pipe方法)

let fs = require('fs');
// 创建可写流
let ws = fs.createWriteStream('./1.txt',{
    flags:'w', // 默认文件不存在会创建
    highWaterMark:3, // 设置当前缓存区的大小,每次写3个  默认写是16k,读是64k
    encoding:'utf8',// 文件里存放的都是二进制
    start:0, // 从哪开始写
    autoClose:true, // 自动关闭
    mode:0o666,// 可读可写
});


let flag = ws.write(1+'','utf8',()=>{}); // 异步的方法
console.log(flag);
flag = ws.write(1+'','utf8',()=>{}); // 异步的方法
console.log(flag);
flag = ws.write(1+'','utf8',()=>{}); // 异步的方法
console.log(flag);
flag = ws.write(1+'','utf8',()=>{}); // 异步的方法
console.log(flag);


ws.on('drain',function(){
    console.log('drain')
})

这里缓存区highWaterMark大小是3个字节,所以第三个flag时缓存区已经满了,所以从第三个flag开始都是false,但是数据并没有丢失,即1.txt输出四个1,并且触发抽干事件。如果这里的write方法只写入了两次,缓存区并没有满,就不会触发这个抽干事件。

// 输出
true
true
false
false
drain
// 1.txt 输出
1111

注意这里每个write方法里的数字1只占用1个字节,如果是11就2个字节,一个数字占用1个字节,如果是一个汉字就是3个字节。 所以如果第一个write方法写入三个1,那第一个flag就会是false

写个抽干例子

let fs = require('fs');
// 创建可写流
let ws = fs.createWriteStream('./1.txt',{
// let ws = new WS('./2.txt',{
    flags:'w', // 默认文件不存在会创建
    highWaterMark:3, // 设置当前缓存区的大小,每次写3个  默认写是16k,读是64k
    encoding:'utf8',// 文件里存放的都是二进制
    start:0, // 从哪开始写
    autoClose:true, // 自动关闭
    mode:0o666,// 可读可写
});

// 写内容的时候  必须是字符串或者buffer
for(var i = 0;i < 9;i++){
    let flag = ws.write(i+''); // 一次写一个字符
    console.log(flag);
}

这里highWaterMark缓存大小是3字节,所以第三次写入时就满了,之后都返回false

// 输出
true
true
false
false
false
false
false
false
false
// 1.txt 输出
012345678

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

// let WS = require('./WriteStreamS');
let fs = require('fs');
// 创建可写流
let ws = fs.createWriteStream('./1.txt',{
// let ws = new WS('./2.txt',{
    flags:'w', // 默认文件不存在会创建
    highWaterMark:3, // 设置当前缓存区的大小,每次写3个  默认写是16k,读是64k
    encoding:'utf8',// 文件里存放的都是二进制
    start:0, // 从哪开始写
    autoClose:true, // 自动关闭
    mode:0o666,// 可读可写
});


let i = 9;
function write(){
    let flag = true;
    while (flag && i > 0) {
        // 当返回的是false时 flag标识符为false  就不写了
         flag = ws.write(--i + '','utf8',()=>{console.log('ok')});   
        console.log(i+""+flag)
    }
}
write();
// 以上代码输出的是truetruefalse,当highWaterMark填满了就不会继续输出,1.txt输出的是876 


// drain只有当缓存区充满后  并且被消费后才会触发
ws.on('drain',function () {
    console.log('抽干');
    write();
})

写完一次(一次缓存区是个highWaterMark 3个字节),清空缓存区,再继续写

// 输出
8true
7true
6false
抽干
5true
4true
3false
抽干
2true
1true
0false
抽干
// 1.txt 输出
876543210

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

实现一个可写流

// WriteStreamS.js
let fs = require('fs');
let EventEmitter = require('events');

// 1、创建实例继承EventEmitter
class WriteStream extends EventEmitter{
    constructor(path,options = {}){
        super();
        this.path = path;
        this.flags = options.flags || 'w';
        this.encoding = options.encoding || 'utf8';
        this.start = options.start || 0;
        this.pos = this.start;
        this.mode = options.mode || 0o666;
        this.autoClose = options.autoClose || true;
        this.highWaterMark = options.highWaterMark || 16*1024 // 读64 写16

       

        // 3、写文件的时候  需要的参数有哪些
        // 3-1、可写流有缓存区,第一次写是真的往文件里写,之后如果正在写入文件的时候,内容要
        // 写入到缓存区中,写一点放缓存里一点,写完之后再把缓存里的取出来再去写,这个缓存区
        // 在源码中是一个链表,这里我们为了方便,我们使用数组去存内容
        // 缓存用简单的数组来模拟一下
        this.cache = [];
        // 3-2、标识  是否正在写入  默认第一次就不是正在写入
        this.writing = false; 
        // 3-3、是否满足触发drain事件(只有当缓存区满了被清空后触发)
        this.needDrain = false;
        // 3-4、维护一个变量  表示缓存的长度
        this.len = 0;
         // 3-5、记录写入的位置,一次写3个
         this.pos = 0;
         

        // 4、 写流之前要先打开(打开文件目的就是为了获取fd描述符)
        this.open(); // fd 异步的  触发一个open事件  当触发open事件后fd肯定就存在了
        
    }
    
    
    // 9、递归清空缓存区  [7,6,5,4,3,2,1]
    clearBuffer(){
        let buffer = this.cache.shift(); // 从数组第一个开始取出来删除
        if(buffer){ // 缓存里有(数组里有值)
            this._write(buffer.chunk,buffer.encoding,()=>{
                buffer.callback();
                this.clearBuffer();
            });
        }else{ // 缓存里没有了
            if(this.needDrain){ // 是否需要触发drain事件  需要就发射drain事件
                this.writing = false;// 告诉下次直接写就可以了  不需要写到内存中了
                this.needDrain = false;
                this.emit('drain')
            }
        }
    }


    //  8、把chunk往文件里写
    _write(chunk,encoding,clearBuffer){ 
        // 因为write方法是同步调用,open异步,先调write时,此时fd还没有获取到(fd是从3开始的
        // 数字)所以先打开open获取到fd后再执行write操作
        if(typeof this.fd != 'number'){ // fd不是数字说明不存在,触发open将_write方法存起来
            return this.once('open', () => this._write(chunk,encoding,clearBuffer));
        }
        fs.write(this.fd,chunk,0,chunk.length,this.pos,(err,byteWritten)=>{
            this.pos += byteWritten;
            this.len -= byteWritten; // 每次写入后就要在内存中减少一下
            clearBuffer();// 第一次就写完了  去清空缓存区内容
        })
    }
    
    
    // 7、客户调用的是write方法去写入内容
    write(chunk,encoding=this.encoding,callback=()=>{}){
        // 要判断  chunk必须是buffer或者字符串  为了统一,如果传递的是字符串也要转车buffer
        // 这里用encoding编码把chunk转换成buffer
        chunk = Buffer.isBuffer(chunk)?chunk:Buffer.from(chunk,encoding);
        this.len += chunk.length; // 维护缓存的长度
        let ret = this.len < this.highWaterMark;
        if(!ret){
            this.needDrain = true; // 缓存区满了 !ret返回true  触发drain事件
        }
        // 判断是否正在写入  如果是正在写入  就写入到缓存区中  
        if(this.writing){ 
            this.cache.push({
                chunk,
                encoding,
                callback
            }); // [7,6,5,4,3,2,1]
        }else{ // 第一次往文件里写
            this.writing = true;
            this._write(chunk,encoding,()=>{
                callback();
                this.clearBuffer();
            }); // 专门实现写的方法,第一次写入8  这里的回调用来清空缓存区
        }
        return ret; //能不能继续写了,false表示下次写的时候就要占用更多内存了
    }



    // 6、销毁
    destroy(){
        if(typeof this.fd !='number'){
            this.emit('close');
        }else{
            fs.close(this.fd,()=>{
                this.emit('close'); 
            })
        }
    }
    
    
    // 5、打开文件
    open(){
        fs.open(this.path,this.flags,this.mode,(err,fd)=>{
            if(err){
                this.emit('error',err);
                if(this.autoClose){
                    this.destroy(); // 如果自动关闭就销毁文件描述符
                }
                return;
            }
            this.fd = fd;
            this.emit('open',this.fd);
        });
    }
}


// 2、导出这个实例
module.exports = WriteStream;

整个思路流程

源码中一开始就执行了一个open方法,这个open方法主要是为了打开文件,获取到描述符fd,有描述符后才可以真正的去写去读,因为用户不会等你打开文件后再去写,他会直接开始调用write方法去写,所以当用户调write方法时,我们通过once触发了open事件。(注意:在open方法中,发布了一个open事件,后面通过on/once监听这个事件去触发open()方法并通过回调函数去处理业务逻辑。这个是发布订阅中的知识点。)

write方法中有几个参数:chunk,encoding,callback。我们要看一看chunk是不是buffer,如果是buffer我们就用buffer,因为写的时候要写buffer,不是buffer, 就转换成buffer。通过this.len算出写的内容长度,用这个长度和highWaterMark比,如果和highWaterMark相等了,就说明当前缓存区满了,ret就为false,(ret为false时就是缓存区写满了,ret为trun时就是缓存区还没写满)这时候write方法将会通过ret返回一个布尔类型。只有当缓存区满了时,也就是ret为false时,并且满了被清空后(这里通过clearBuffer去清空的),会去触发抽干事件。第一次开始写就把this.writing变成true,表示正在写入,之后开始写的就往缓存区里写(这里缓存区是数组模拟的),第一次写入时,我们通过_write这个方法去真正的往文件里去写,当这个文件被写入以后,再去取缓存 区里的第一项,这里通过clearBuffer方法去取数组的第一个值往文件里去写(逐步清空这个数据),当数组里第一个写完之后就再去调用这个clearBuffer方法再去清空缓存区,直 到缓存区里的值被清空完为止。当缓存区里值清空完了需要判断是否要触发抽干事件,要触发的话就通过emit发布抽干事件,用户就可以用on监听并通过回调函数处理业务逻辑 。

这个_write方法每次写的时候,我们就要把缓存区大小减少(数组长度减少),写入的位置增加,并且把this.writing变成false(表示正在写入),每次写完后 ,通过clearBuffer清空缓存区。

结论:整个思路是围绕第一次是真正往文件里去写,写之前就要通过open拿到fd,之后再写入的数据就放到缓存区里,当缓存区满了被清空后去触发抽干事件。所以我们是写满缓存区,清空缓存区,触发抽干事件,这样循环,直到数据被写完毕。这里需要注意的是,如果最后一次写入的内容没有写满缓存区,最后一次就不会触发抽干事件。抽干事件的触发条件有两个:highWaterMark缓存区填满,满了后被清空了才会触发