从源码角度带你解读Nodejs Streams模块

688 阅读11分钟

如果你正在学习Nodejs,那么一定是一个你需要掌握的概念。如果你想成为一个Node高手,那么流一定是武功秘籍中不可缺少的一个部分。关于流这个主题,由Node高手substack带来的stream-handbook绝对是经典入门读物之一,其在Github上的star数量已经超过了4500个,足以见其权威程度。

1.为什么要使用流

在node中,I/O操作都是异步的,所以在硬盘和网络交互过程中都会与回调函数产生关系,你在读取一个文件的过程中可能会这样写:

let http = require('http');
let fs = require('fs');
let path = require('path');
let server = http.createServer((req, res) => {
    fs.readFile(path.resolve(__dirname, '/1.txt'), (err, data) => {
        res.end(data);
    })
});
server.listen(3000);

乍一看,上面这段代码似乎并没有什么问题,当时每次请求的时候都会将文件读到内存中,然后在返回给客户端。如果文件很小,并不会影响什么。如果文件超大。不仅会消耗掉大量的内存,客户端等待的时间也将大大增加。

那该如何解决上面这个问题呢?

少年, 你可知道 req, res 参数都是基于流(假设你已经知道 这个概念)的对象。因此。可以将上面代码进行如下优化:

let http = require('http');
let path = require('path');
let fs = require('fs');  //引入fs核心模块

let server = http.createServer((req, res) => {
let rs = fs.createReadStream(path.resolve(__dirname,'/1.txt')); //返回一个可读流对象
    rs.pipe( res );  //pipe 管道方法
});

画个图来示意:

在这里,.pipe()方法(管道)会自动帮助我们监听data(每次读一点)和end(读完整个文件)事件。优化后的代码码不仅简洁,而且1.txt文件中每一小段数据都将源源不断的发送到客户端。

知道这样用的好处了,那么我们不禁要发问了,什么是 "流", 什么又是 data 事件和 end事件。fs.createReadStream的内部实现的机制到底如何?, 由浅入深,一步一步带你来了解其原理。

2.流是什么

1.流(stream)在 Node.js 中是处理流数据的抽象接口(abstract interface)。 
2.Node.js 提供了多种流对象。 例如, HTTP 请求 和 process.stdout 就都是流的实例。
3.流可以是可读的、可写的,或是可读写的。所有的流都是 EventEmitter 的实例。
  • 流:有序的有方向的 流可以自己控制速率
  • 读:读是将内容读取到内存中
  • 写:写是将内存或者文件的内容写入到文件内

2.1 流的基本类型

Node.js 中有四种基本的流类型:
1. Readable - 可读的流 (例如 fs.createReadStream()).
2. Writable - 可写的流 (例如 fs.createWriteStream()).
3. Duplex - 可读写的流 (双工流)(例如 net.Socket).
4. Transform - 在读写过程中可以修改和变换数据的 Duplex 流 (例如 zlib.createDeflate()).

3 可读流Readable

1. 可读流事实上工作在下面两种模式之一:flowing 和 paused 。
2. 在 flowing 模式下, 可读流自动从系统底层读取数据,并通过 EventEmitter 接口的事件尽快将数据提供给应用。
3. 在 paused 模式下,必须显式调用 stream.read() 方法来从流中读取数据片段。
4. 所有初始工作模式为 paused 的 Readable 流,可以通过下面三种途径切换到 flowing 模式:
5. 监听 'data' 事件。
6. 调用 stream.resume() 方法。
7. 调用 stream.pipe() 方法将数据发送到 Writable。

3.1 可读流的基本用法

  • 创建可读流
let fs = require('fs'); //引入fs核心模块
let path = require('path');
//返回一个可读流
let rs = fs.createReadStream( path.resolve(__dirname, './1.txt'), {
    highWaterMark: 3, //字节, 默认是 64k,  64 * 1024个字节
    flags: 'r',       //文件的读取操作,默认是'r':读
    autoClose: true   //默认读取后自动关闭
    start: 0,         //从第几个字节开始读取
    //end: 3,           //流是闭合区间, [0, 3]
    encoding: 'utf8'  //设置编码格式,默认是null, null代表的是buffer
});

//1.txt 中的内容为 1234567890
//默认是非流动模式, 不会将内容输出,内部创建一个3个字节的buffer

  • 监听data事件

  • 必须要强调的是 流是基于事件的。默认状态下是 paused 模式。通过监听 data 事件, 将 paused 状态改成 flowing 模式。

  • 通过不停的触发 data 事件来读取内容,每次根据 hignWaterMark 来读取3个字节的内容

rs.on('data', data => {
    //会触发2两次 data 事件, 分别打印出123 和 4
    //如果不设置encoding, 则默认是buffer
    console.log( data );  
})
  • 监听end事件

读取完成所有内容后,根据end 事件来处理读取文件的所有内容。

rs.on('end', () => {
    console.log('读取完成');   //读取完成
});

  • 监听error事件
rs.on('error', err => {
    console.log(err);
})
  • 监听open事件
rs.on('open', err => {
    console.log(err);
})
  • 监听close事件
rs.on('close', err => {
    console.log(err);
})

设置编码

默认不设置编码,读出来的内容是buffer格式

rs.setEncoding('utf8')

暂停和恢复触发data事件

rs.on('data', data => {
    console.log('data');
    rs.pause();
})

setTimeout( () => {
    rs.resume();
}, 1000)
  • 了解了可读流的基本概念和调用步骤,是不是有点意犹未尽的赶脚哇~, 下面就带小伙伴们一起来实现一把可读流

3.2 实现可读流

话说有这样的一段业务代码


// readstream.js
const ReadStream = require('./ReadStream'); // 引入实现的可读流

const rs = new ReadStream('1.txt', {
    flags: 'r',
    // encoding: 'utf8',
    autoClose: true,
    highWaterMark: 3,
    start: 0,
    end: 3
});

rs.on('data', data => {
    console.log(data);
    rs.pause();
});

rs.on('end', () => {
   console.log('end');
});

setTimeout(() => {
    rs.resume();
}, 1000);

嘿,哥们是时候见真章了~

创建自定义ReadStream模块(类)

思路分析

  • 从业务代码反推, 我们可以知道ReadStream构造函数中应该有如下参数options中包含的,而通过new 创建的实例。所以都在构造函数中来定义。
  • 可读流实例的默认模式是非流动模式: flowing: false
  • 流是基于事件的,所以继承自EventEmittter
let fs = require('fs');
let EventEmitter = require('events');
class ReadStream extends EventEmitter {
  constructor(path, options = {}) {  //path: 文件路径, options: 可读流的参数
      //code1
  }
}
module.exports = ReadStream;

为了阅读的方便,将constructor方法中的代码放在这里: code1

    super();  //实现继承EventEmitter类
    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 = false; //默认为非流动模式
    
    // 弄一个buffer读出来的数, 分配可用的内存空间
    //想想文章开始的那个问题。这样的操作对于优化性能是不是杠杠滴呢?
    this.buffer = Buffer.alloc(this.highWaterMark);
    this.open();  //打开文件,获取文件描述符 fd   
    
    // {newListener:[fn]}
    // 监听方法默认同步调用的,当用户(rs, 可读流的实例)在监听data事件的时候,
    //同时会触发 newListener 事件
    this.on('newListener', (type) => {
      if (type === 'data') { //事件类型
        this.flowing = true;  //讲paused 模式转换成 flowing 模式
        this.read();// 开始读取,用户已经监听了data事件
      }
    })

如果你读到这里,对于ReadStream类中存在疑问的估计就是 open() 和 read() 方法了。

  • 我们先来实现open方法
let fs = require('fs');
let EventEmitter = require('events');
class ReadStream extends EventEmitter {
  constructor(path, options = {}) {  //path: 文件路径, options: 可读流的参数
     //code1
  }
  
  destroy() {
    if (typeof this.fd != 'number') { return this.emit('close'); }
    fs.close(this.fd, () => {
      // 如果文件打开过了 那就关闭文件并且触发close事件
      this.emit('close');
    });
  }

  // 打开文件用的
  open(){
    //fd标识的就是当前this.path这个文件,从3开始(number类型)
    fs.open(this.path, this.flags, (err, fd) => {
      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;

再来看看read方法,一定要耐住性子和寂寞,代码中含有大量的注释来解释逻辑,不要怂,一鼓作气读完它:

let fs = require('fs');
let EventEmitter = require('events');
class ReadStream extends EventEmitter {
  constructor(path, options = {}) {  //path: 文件路径, options: 可读流的参数
     //参考上文代码部分 
  }
  
  //open方法部分省略
  
  read(){
  
    // 等待着触发open事件后fd肯定拿到了,拿到以后再去执行read方法
    // 文件打开后,就可以去读取了
    if(typeof this.fd !== 'number'){
        return this.once('open',() => this.read()); 
    }
    
    //每次读取的数量:
    //1: 当没有设置end值的时候,每次读取的数量就是 highWaterMark的值,即每次读固定数量3个,直到读完整个文件,这里有个问题就是当读到最后一次,不够3个时候,需要留意。
    //2. 当设置end的时候, 当前可读数量就需要计算了。
    //假设:end为3, 那就是读取1,2,3,4总共4个字节,而每次可读的初始化设置highWaterMark为3,那就分两次读取
    //第一次读1,2,3
    //第二次读4
    let howMuchToRead = this.end?Math.min(this.end-this.pos+1,this.highWaterMark): this.highWaterMark;
    
    // 对于 fs.read方法参数的理解就是:
    //讲 this.fd这个文件读到 this.buffer中,
    //并且是this.buffer的第0个位置, 每次读howMuchToRead个,
    //当前读到了文件的第this.pos个
    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);
    
      //将每次读到的内容发射出去,也就是业务代码中的data事件部分
      this.emit('data', b);
      
      if ((byteRead === this.highWaterMark)&&this.flowing){
        return this.read(); // 继续读
      }
      
      // 这里就是没有更多的逻辑了
      if (byteRead < this.highWaterMark){
        // 没有更多了
        this.emit('end'); // 读取完毕
        this.destroy(); // 销毁即可
      }
    });
  }
}
module.exports = ReadStream;

补上pause 和 resume 方法,就大功告成了:

  pause(){
    this.flowing = false;
  }
  resume(){
    this.flowing =true;
    this.read();
  }

至此,基本完成了对可读流的实现。似乎也没有想象中的那么难。不妨,再努力努力,达到文武双全(可读可写)的境界。

4.1 可写流的基本用法

先调用原生fs.createWriteStream来看看效果:

let fs = require('fs');
let ws = fs.createWriteStream('2.txt',{
  flags:'w',
  encoding:'utf8',
  start:0,
  highWaterMark:3 // 一次能写三个
});
let i = 9;
function write() {
  let flag = true; // 表示是否能写入
  while (flag&&i>=0) { // 9 - 0 
  // 第一次是真的往文件里写,后面的都会放到缓存区中,过一会写完了,
  // 会去清空缓存区
  //写入的内容必须是buffer或者string,并且是异步的方法
    flag = ws.write(i--+'');
  }
}

// drain的触发时机,只有当highWaterMark填满时,才可能触发drain
// 或者说 drain缓存区内容被消费完后就触发该事件
ws.on('drain',()=>{
  console.log('把缓存的中全部写完'); //按照这样的模式,这里会打印三次
  write();
})

write();

下面我们来看看fs.createWriteStream背后的实现过程吧,相比于可读流。我们需要多关注 drain事件。同样也包含了打开文件open方法。

4.2 实现自定义WriteStream模块(类)

let fs = require('fs');
let EventEmitter = require('events');
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;
    
    // fd 异步的  触发一个open事件当触发open事件后fd肯定就存在了
    this.open(); 

    // 写文件的时候 需要的参数有哪些
    // 第一次写入是真的往文件里写
    this.writing = false; // 默认第一次就不是正在写入
    
    // 缓存我用简单的数组来模拟一下,源码中是采用的链表来实现的
    this.cache = [];
    
    // 维护一个变量 表示缓存的长度,数组的遍历,出于对性能的考虑
    this.len = 0;
    
    // 是否触发drain事件
    this.needDrain = false;
  }
}
module.exports = WriteStream;

定义open方法 需要知道往哪个文件中写内容: this.fd

let fs = require('fs');
let EventEmitter = require('events');
class WriteStream extends EventEmitter {
  constructor(path, options = {}) {
      //见上文
  }
  
  destroy() {
    if (typeof this.fd != 'number') {
      this.emit('close');
    } else {
      fs.close(this.fd, () => {
        this.emit('close');
      });
    }
  }
  
  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);
    });
  }
}

同样的思路,定义write方法

let fs = require('fs');
let EventEmitter = require('events');
class WriteStream extends EventEmitter {
  constructor(path, options = {}) {
      //参考上文
  }
  
  destroy() {
     //参考上文
  }
  
  open() {
      //参考上文
  }
  
  // 因为write方法是同步调用的此时fd还没有获取到,所以等待获取到再执行write操作
  _write(chunk, encoding, clearBuffer) { 
    if (typeof this.fd != 'number') {
      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(); // 第一次就写完了
    })
  }
  
  // 客户调用的是write方法去写入内容
  // 要判断 chunk必须是buffer或者字符串 为了统一,如果传递的是字符串也要转成buffer
  write(chunk, encoding = this.encoding) {
    chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
    this.len += chunk.length; // 维护缓存的长度 3
    let ret = this.len < this.highWaterMark;
    if (!ret) {
      this.needDrain = true; // 表示需要触发drain事件
    }
    if (this.writing) { // 正在写入应该放到内存中
      this.cache.push({
        chunk,
        encoding,
      });
    } else { // 第一次
      this.writing = true;
      this._write(chunk, encoding, () => this.clearBuffer()); // 专门实现写的方法
    }
    return ret; // 能不能继续写了,false表示下次的写的时候就要占用更多内存了
  }
}

drain事件实现

  clearBuffer() {
    let buffer = this.cache.shift();
    if (buffer) { // 缓存里有
      this._write(buffer.chunk, buffer.encoding, () => this.clearBuffer());
    } else {// 缓存里没有了
      if (this.needDrain) { // 需要触发drain事件
        this.writing = false; // 告诉下次直接写就可以了 不需要写到内存中了
        this.needDrain = false;
        this.emit('drain');
      }
    }
  }

是不是很简单?对于**_write**方法中几个重点稍作解。