阅读 141

认识美丽的“流”姑娘:Node Stream(一)


不识庐山真面目,只缘生在此山中

一、揭开流姑娘神秘面纱:什么是流?

官方标准答案:

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

我自己的观点:

我认为其实流就是在两个设备之间建立一个管道,然后通过管道将数据以流动的方式传输。如何来理解这个以流动的方式呢?

举个例子来说吧,当我们读取文件的时候,如果不使用流的方式读取的话,我们会将整个文件的内容先通过I/O设备写进内存,然后再由消费者去内存中读取。而使用流的方式是边将文件内容写入缓存边由消费者去读取,不用将整个文件先写进内存,从而节省了内存的空间。


我们需要去下载一个500M大小的文件,如果我们不使用流,就会占用500M的内存。这样就影响了响应速度。对用户的体验也是有一定的影响。


实现同样的下载功能,假如我们使用流,内存占用非常小,当然下面只是假定每次都是50M。经过测试内存使用情况流的方式平均是前者的十分之一。


其实上面流的过程我们可以这样理解


至于为什么使用流,流的好处通过我们上面的例子我们也能看到,其最大的一个好处就是节省内存,提高程序的运行速度。

在工作中合理的使用流就能大大提高程序的性能,从而提高用户体验。


二、流姑娘的四种基本类型

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

我们已经知道有四种流,但今天我们先学会使用可读流并且仿写一款可读流

可读流的用法简介

  首先创建可读流

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

如果指定utf8编码highWaterMark要大于3个字节

监听data事件

流切换到流动模式,数据会被尽可能快的读出

rs.on('data', function (data) {
    console.log(data);
});
复制代码

监听end事件

该事件会在读完数据后被触发

rs.on('end', function () {
    console.log('读取完成');
});
复制代码

监听error事件

rs.on('error', function (err) {
    console.log(err);
});
复制代码

监听open事件

rs.on('open', function () {
    console.log(err);
});
复制代码

监听close事件

rs.on('close', function () {
    console.log(err);
});
复制代码

设置编码

与指定{encoding:'utf8'}效果相同,设置编码

rs.setEncoding('utf8');
复制代码

暂停和恢复触发data

通过pause()方法和resume()方法

rs.on('data', function (data) {
    rs.pause();
    console.log(data);
});
setTimeout(function () {
    rs.resume();
},2000);
复制代码

好了,到这里我们学会了可写流的用法,那么接下来我们自己仿写一款可读流

创建ReadStream类

// ReadStream.js
const fs = require('fs');
const EventEmitter = require('events');  // 需要依赖事件发射
class ReadStream extends EventEmitter {
    constructor(path, options) {    // 需要传入path和options配置项
        super();    // 继承
        this.path = path;
        // 参照上面new出的实例,我们开始写
        this.flags = options.flags || 'r';  // 文件打开的操作,默认是'r'读取
        this.encoding = options.encoding || null;   // 读取文件编码格式,null为buffer类型
        this.autoClose = options.autoClose || true;
        this.highWaterMark = options.highWaterMark || 64 * 1024;  // 默认是读取64k
        this.start = options.start || 0;
        this.end = options.end;
        
        this.flowing = null;   // null表示非流动模式
        // 要建立一个buffer,这个buffer就是一次要读多少内容
        // Buffer.alloc(length)  是通过长度来创建buffer,这里每次读取创建highWaterMark个
        this.buffer = Buffer.alloc(this.highWaterMark);  
        this.pos = this.start;  // 记录读取的位置
        
        this.open();    // 打开文件,获取fd文件描述符
        
        // 看是否监听了data事件,如果监听了,就变成流动模式
        this.on('newListener', (eventName, callback) => {
            if (eventName === 'data') {   // 相当于用户监听了data事件
                this.flowing = true;  // 此时监听了data会疯狂的触发
                this.read();    // 监听了,就去读,要干脆,别犹豫
            }
        });
    }
}

module.exports = ReadStream;    // 导出复制代码

写到这里我们已经创建好了ReadStream类,在该类中我们继承了EventEmitter事件发射的方法

其中我们写了open和read这两个方法,从字面意思就明白了,我们的可读流要想读文件,首先需要先打开(open)文件,然后再去读内容(read)。

open方法

class ReadStream extends EventEmitter {
    constructor(path, options) {
        // 省略...
    }
    
    open() {
        // 用法: fs.open(filename,flags,[mode],callback)
        fs.open(this.path, this.flags, (err, fd) => {   // fd为文件描述符
            // 说实在的我们打开文件,主要就是为了获取fd
            // fd是个从3开始的数字,每打开一次都会累加,4->5->6...
            if (err) {
                if (this.autoClose) {  // 文件打开报错了,是否自动关闭掉
                    this.destory();    // 销毁    
                }
                this.emit('error', err);    // 发射error事件
                return;
            }
            this.fd = fd;   // 如果没有错,保存文件描述符
            this.emit('open');  // 发射open事件
        });
    }
    
    // 这里用到了一个destory销毁方法,我们也直接实现了吧
    destory() {
        // 先判断有没有fd 有就关闭文件 触发close事件
        if (typeof this.fd === 'number') {
            // 用法: fs.close(fd,[callback])
            fs.close(this.fd, () => {
                this.emit('close'); 
            });
            return;
        }
        this.emit('close');
    }
}
复制代码

read方法

class ReadStream extends EventEmitter {
    constructor(path, options) {
        // 省略...
    }
    // 监听data事件的时候,去读取
    read() {
        console.log(this.fd);   // 直接读fd为undefined,因为open事件是异步的,此时还拿不到fd
        // 此时文件还没打开
        if (typeof this.fd !== 'number') {  // 前面说过fd是个数字
            // 当文件真正打开的时候,会触发open事件
            // 触发事件后再执行read方法,此时fd肯定有了
            return this.once('open', () => this.read());  // once方法只会执行一次
        }
        // 现在有fd了,大声的读出来,不要害羞
        // 用法: fs.read(fd, buffer, offset, length, pos, callback((err, bytesRead)))
        
        // length就是一次想读几个, 不能大于buffer长度
        // 这里length不能等于highWaterMark,举个🌰
        // 文件内容是12345如果按照highWaterMark:3来读,总共读end:4个,每次读3个字节
        // 分别是123 45空,我们应该知道一共要读几个,总数-读取位置+1得到下一次要读多少个
        // 这里有点绕,大家可以多去试试体会一下
        // 我们根据源码起一个同样的名字
        let howMuchToRead = this.end ? Math.min((this.end-this.pos+1), this.highWaterMark) : this.highWaterMark;
        
        fs.read(this.fd, this.buffer, 0, howMuchToRead, this.pos, (err, bytesRead) => {
            // bytesRead为读取到的个数,每次读3个,bytesRead就是3
            if (bytesRead > 0) {
                this.pos += bytesRead; // 读到了多少个,累加,下次从该位置继续读
                
                let buf = this.buffer.slice(0, bytesRead);  // 截取buffer对应的值
                // 其实正常情况下,我们只要把buf当成data传过去即可了
                // 但是考虑到还有编码的问题,所以有可能不是buffer类型的编码
                // 这里需要判断一下是否有encoding
                let data = this.encoding ? buf.toString(this.encoding) : buf.toString(); 
                
                this.emit('data', data);    // 发射data事件,并把data传过去
                
                // 如果读取的位置 大于 结束位置 就代表读完了,触发一个end事件
                if (this.pos > this.end) {
                    this.emit('end');
                    this.destory();
                }
                // 流动模式继续触发
                if (this.flowing) {   
                    this.read();
                }
            } else {    // 如果bytesRead没有值了就说明读完了
                this.emit('end');   // 发射end事件,表示文件读完
                this.destory();     // 没有价值了,kill
            }
        });
    }
}
复制代码

pause和resume方法

class ReadStream extends EventEmitter {
    constructor(path, options) {
        // 省略...
    }
    pause() {
        this.flowing = false;
    }
    resume() {
        this.flowing = true;
        this.read();
    }
}复制代码
最后附送大家一个完整版可读流

let fs = require('fs');
let EventEmitter = require('events');
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';

    // 参数的问题
    this.flowing = null; // 非流动模式
    // 弄一个buffer读出来的数
    this.buffer = Buffer.alloc(this.highWaterMark);
    this.open(); 
    // {newListener:[fn]}
    // 次方法默认同步调用的
    this.on('newListener', (type) => { // 等待着 它监听data事件
      if (type === 'data') {
        this.flowing = true;
        this.read();// 开始读取 客户已经监听了data事件
      }
    })
  }
  pause(){
    this.flowing = false;
  }
  resume(){
    this.flowing =true;
    this.read();
  }
  read(){ // 默认第一次调用read方法时还没有获取fd,所以不能直接读
    if(typeof this.fd !== 'number'){
       return this.once('open',() => this.read()); // 等待着触发open事件后fd肯定拿到了,拿到以后再去执行read方法
    }
    // 当获取到fd时 开始读取文件了
    // 第一次应该读2个 第二次应该读2个
    // 第二次pos的值是4 end是4
    // 一共4个数 123 4
    let howMuchToRead = this.end?Math.min(this.end-this.pos+1,this.highWaterMark): this.highWaterMark;
    fs.read(this.fd, this.buffer, 0, howMuchToRead, this.pos, (error, byteRead) => { // byteRead真实的读到了几个
      // 读取完毕
      this.pos += byteRead; // 都出来两个位置就往后搓两位
      // this.buffer默认就是三个
      let b = this.encoding ? this.buffer.slice(0, byteRead).toString(this.encoding) : this.buffer.slice(0, byteRead);
      this.emit('data', b);
      if ((byteRead === this.highWaterMark)&&this.flowing){
        return this.read(); // 继续读
      }
      // 这里就是没有更多的逻辑了
      if (byteRead < this.highWaterMark){
        // 没有更多了
        this.emit('end'); // 读取完毕
        this.destroy(); // 销毁即可
      }
    });
  }
  // 打开文件用的
  destroy() {
    if (typeof this.fd != 'number') { return this.emit('close'); }
    fs.close(this.fd, () => {
      // 如果文件打开过了 那就关闭文件并且触发close事件
      this.emit('close');
    });
  }
  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; // 保存文件描述符
      this.emit('open', this.fd); // 触发文件的打开的方法
    });
  }
}
module.exports = ReadStream;
复制代码

关于“流”姑娘的故事还未完,咱们下回再续。。。各位看官看完别忘记动动小爪点个赞。。。。


文章分类
前端