1.可写流createWriteStream的使用

1.1 创建可写流

const ws = fs.createWriteStream(path.resolve(__dirname, 'test.txt'), {
  flags: 'w', // 写流不能用r,会报错.可以用'a'表示追加
  encoding: 'utf8', // 不写默认是utf8
  autoClose: true, // 写完是否自动关闭
  // start: 0, //从第几个字节开始写
  highWaterMark: 3 // 这是安全线,默认写的水位线是16k,即16 * 1024。表示总共写入的大小在这个范围内表示安全的,因为这代表着链表缓存区的大小,如果满了,那么就需要rs.pause()暂停读取,等缓存区的内容开始写入,留出一部分空间后,再去写入。如果超过这个值,虽然不会阻止你写入,但是会告诉你已经超了这条安全线了。特别注意:超过这个水位线也没事,只是超过会ws.write()会返回给你false,不超过返回的是true
})
  • 特别注意这个highWaterMark,所代表的意思就是"水位线",默认大小是16k,那"水位线"是具体什么意思?
    • 按照它源码中的写法,这个highWaterMark的值就代表 当前写入的字节 + 缓存中的字节数 的和,如果超过这个值,虽然不会阻止你继续往缓存中去写入,但是可读流的write()方法的返回值会变为false,不超过的话,返回的是true。这样做的目的,是为了能够做到读一部分,写一部分,不会导致内存爆满。
  • 返回值ws代表文件可写流对象

1.2 可写流监听的一些列常用事件

  • open事件
  • drain事件
1.2.1 open事件
ws.on('open', (fd) => {
  console.log(fd); // 文件描述符
})
1.2.2 drain事件
  • 触发drain事件需要同时满足两个条件
    • 当正在写入的数据的字节数 + 缓存中的字节数 之和,超过highWaterMark
    • 将这些数据(正在写入和缓存中的)写入完毕
ws.on('drain', () => {
  console.log('ok')
})

1.3 可写流的常用方法

  • write()
  • close()
  • end()
1.3.1 可写流write()方法
  • 只能写入 string 或 buffer,源码中都会统一转成buffer
  • 返回值代表本次写入时,正在写入的数据字节数 + 缓存字节数 之和,是否超过highWaterMark,超过返回false,不超过返回true
let flag = ws.write('1',() => { // 返回值flag表示总共写入的大小是否超出了highWaterMark,超出了就是false,主要用来限制是否要继续读取,值为false表示当前链表缓存区满了,需要等一等再写,这时候可以调用rs.pause()暂停读取,那么也就不会写入缓存区了,等缓存区的数据真正开始写入文件了,那么这时候缓存区就有空间了,那么flag就变为true了,表示可以继续写入了
  console.log(1)
}); // 只能写入 string 或者 buffer类型
1.3.2 可写流的close()方法
  • 代表关闭可写流
ws.close()
1.3.3 可写流的end()方法
  • 该方法参数可传可不传:

    • 传了参数,等价于 先调用ws.write(内容)把内容写入,然后调用ws.close()
    • 不传参数,等价于直接调用ws.close()
  • 注意:

    • 如果连续多次使用ws.end(有参数)方法,如果连续end()方法都有参数,那么会报错,因为第一次end(有参数)就代表写入内容,并关闭文件,第二次再end(有参数),内容就无法写入了,因为已经关闭文件了,所以会报错。
    • 但是如果ws.end(有参数)后面调用没有参数的ws.end(),是没关系的,因为第一次虽然关闭了,但是第二次ws.end()没有参数,只是又做了一次关闭操作,重复关闭不会有问题。
ws.end('ok1') // 等价于 先调用ws.write('ok')把ok写入,然后调用ws.close()
// ws.end('ok2') // 这样写会报错,因为close后不能再write
ws.end() // 如果不传参数,是可以的,相当于又调用了一次ws.close()。没有调用write

1.4 可写流的特点

  • 异步并发串行写入
    • 比如:同时调用ws.write(),分别写入1、2、3、4,那么调用的时候是并发的,也就是说,会去写1,但是不能同时写2、3、4, 2、3、4要放在缓存区中,等1写完了,再从缓存区拿出2去写,写完拿出3,3写完拿出4去写,一直到缓存区写完。第一次写入是真的向文件中写,后续的操作都缓存到链表中了。
  • 缓存区是用链表来实现的
    • 因为如果数据比较多的话,假如用的是数组,取出第一个,后面每一个都会往上移一位,消耗性能比较大。
    • 所以,可以用链表来实现栈或者队列,取数据的头部的时候,性能会比数组高一些。因为链表的指针始终指向数据的头部,当头部取出的时候,只需要移动指针,移向下一位就可以了。

1.5 可写流 与 文件可写流(原理)

  • 文件可写流 是继承 Writable类的,Writable类继承了events模块来实现订阅发布。像on(‘opne’)、on(‘drain’)事件都是通过writable的this.emit()来实现的。

  • 可写流本身没有fs的操作,文件可写流在继承了可写流Writable类后,又实现了fs相关操作。

    • 比如当我们创建一个文件可写流的时候,就会调用fs.open()打开文件,拿到文件描述符fd。
    • 然后如果用户去调用write方法,那么就会其实并不是调用文件可写流这个类的write方法,其实调用的是可读流Writable类下的write方法,Writable类下的write方法再去调用文件可读流类下的_write()方法,源码中是doWrite方法。这个_write()方法内部才是真正的调用fs.write()去一部分一部分写文件。
      • 当然,在这个写文件的过程中,涉及到一个缓存区,跟highWaterMark有关,什么意思?具体就是:当我多次调用write方法的时候,虽然write是异步的,但是其实我写入的顺序是同步的,这是通过缓存区做到的。也就是说,当我多次调用write()方法的时候,会做判断,如果是第一次调用,那么就直接写入文件,后面几次调用的write()方法的内容都放到缓存区中,等第一次写完后,再去缓存区中读取并写入,这样一次次将缓存区写完。当多次调用write()方法的数据的总长度,等于或超过highWaterMark值,并且将缓存区数据全局写入文件时,会触发drain事件,代表抽干了,可以继续写入了。文件可写流就是这样一批批写入数据的。
  • 手写文件可写流这个类

const fs = require('fs');
const EventEmitter = require('events');
const Queue = require('./7.链表封装成队列')
class Writable extends EventEmitter {
  constructor(options){
    console.log(options, 111111)
    super();
    this.len = 0; // 每次写入的总长度(正在写入 + 缓存的长度),缓存空了后就为0了
  }
  write(chunk, encoding = this.encoding, cb = () => {}){ // 这个write是用户调用的
    // 这里需要判断是真的写入还是放到缓存中
    // 用户调用write时,写入的数据可能是string或者Buffer,所以统一转成buffer
    chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
    this.len += chunk.length;
    let ret = this.len < this.highWaterMark;
    if (!ret) { // 如果长度大于等于水位线,就改变返回值类型——ws.write()方法的返回值类型
      this.needDrain = true;
    }
    if (this.writing) { // 如果当前正在写,那么就放到缓存中去
      this.cache.offer({
        chunk,
        encoding,
        cb
      })
    } else { // 如果没有正在写,就代表可以去写
      this.writing = true; // 表示现在开始,正在写了
      this._write(chunk, encoding, () => { // 这里的回调函数 不是 用户ws.write()的回调函数
        cb(); // 用户的回调要执行
        this.clearBuffer(); // 清空缓存
      })
    }
    return ret;
  }
  clearBuffer(){ // 多个异步并发 可以靠队列来实现,依次清空队列
    let data = this.cache.poll().element; // 缓存中删除第一个,然后拿到第一个数据
    if (data) {
      let { chunk, encoding, cb } = data;
      this._write(chunk, encoding, () => {
        cb(); // 用户的回调要执行
        this.clearBuffer(); // 清空缓存
      });
    } else {
      this.writing = false; // 缓存中的内容也写入了 清空缓存
      if (this.needDrain) { // 当不写了,要判断下是否需要出发drain,this.needDrain代表着数据长度有没有超过highWaterMark
        this.needDrain = false; // 如果需要触发drain,那么先置为false,再触发
        this.emit('drain'); // 触发drain
      }
    }
  }
}
class WriteStream extends Writable {
  constructor(path, options){
    super(options);
    this.path = path;
    this.flags = options.flags || 'r';
    this.encoding = options.encoding || 'utf8';
    this.highWaterMark = options.highWaterMark || 16 * 1024;
    if (typeof this.autoClose === 'undefined') {
      this.autoClose = true;
    } else {
      this.autoClose = options.autoClose;
    }

    this.open();

    // 要判断是第一次写入,还是第二次写入
    this.writing = false; // 用来描述当前是否有正在写入的操作
    this.needDrain = false; // 是否触发drain事件,默认false不触发。触发条件:写入的长度大于等于highWaterMark,并且写完,也就是this.writing为false
    
    this.offset = 0; // 每次写时入的偏移量
    this.cache = new Queue(); // 缓存区,先用数组,后面再改成链表
  }
  open(){
    fs.open(this.path, this.flags, (err, fd) => {
      if (err) this.emit('error', err);
      this.fd = fd;
      this.emit('open', fd);
    })
  }
  _write(chunk, encoding, callback){ // 这个相当于fs.write 等价于 源码中的doWrite
    // debugger
    if (typeof this.fd !== 'number') {
      return this.once('open', () => {
        this._write(chunk, encoding, callback)
      })
    }
    fs.write(this.fd, chunk, 0, chunk.length, this.offset, (err, written) => {
      this.len -= written; // 写完len要减少
      this.offset += written; // 写完偏移量要增加
      callback();
    })
  }
}

module.exports = WriteStream;

上面所涉及的缓存区是链表的形式,链表的缓存区是这样实现可看我写的关于链表的文章(也就是下一篇)

  • 使用自己写的文件可写流
/*
  fs.createWriteStream方法的源码逻辑
  1. 格式化传入的数据,默认需要打开文件
  2. 用户会调用write方法,这个方法数据Writeable类实现的write方法,内部会调用_write方法,该方法内部就是fs.write方法
  3. 区分是第一次写入还是后续写入,第一次写入是真的往文件中写,后续写入是往链表缓存区中写
*/

const fs = require('fs')
const path = require('path')
const WriteStream = require('./WriteStream.js')

// const ws = fs.createWriteStream(path.resolve(__dirname, 'text.txt'), {
const ws = new WriteStream(path.resolve(__dirname, 'text.txt'), {
  flags: 'w', // 写流不能用r,会报错.可以用'a'表示追加
  encoding: 'utf8', // 不写默认是utf8
  autoClose: true, // 写完是否自动关闭
  // start: 0, //从第几个字节开始写
  highWaterMark: 1 
})

ws.on('open', function(fd){
  console.log(fd);
})
// ws.write('y');
// ws.write('h');
let flag = ws.write('y');
console.log(flag);
flag = ws.write('h');
console.log(flag);

ws.on('drain', () => {
  console.log('抽干了')
})

1.6 关于面试题

1.6.1 并发多个ws.write()方法,为什么异步并发会串行写入?(面试题
  • 因为文件可写流,写入的时候会先写到一个链表实现的缓存区中,等一个写完后,再从缓存区的头部拿出一个写入。
  • 举个例子:
    • 同时调用ws.write(),分别写入1、2、3、4,那么调用的时候是并发的,也就是说,会去写1,但是不能同时写2、3、4,
    • 2、3、4要放在缓存区中,等1写完了,再从缓存区拿出2去写,写完拿出3,3写完拿出4去写,一直到缓存区写完。
    • 第一次写入是真的向文件中写,后续的操作都缓存到链表中了
1.6.2 继续问:为什么缓存区需要链表实现,而不用数组来实现?(面试题
  • 因为如果数据比较多的话,假如用的是数组,取出第一个,后面每一个都会往上移一位,消耗性能比较大。
    • 所以,可以用链表来实现栈或者队列,取数据的头部的时候,性能会比数组高一些。因为链表的指针始终指向数据的头部,当头部取出的时候,只需要移动指针,移向下一位就可以了。
    • 当然上面说的是单向列表,链表的类型有很多种:单向、双向、环形、循环列表等等。(这个不用答,多答多问多错)
1.6.3 继续问:ws.write()这个write方法返回值是什么?有什么含义?(面试题
  • 首先,返回值是一个布尔值
  • 表示总共写入的大小是否超出了highWaterMark,主要用来表示是否要继续读取
  • 具体的意思就是:
    • 当我们写入的时候,也就是调用write()方法的时候,如果并发多次调用,第一次调用会写入文件,后面的会先放到一个链表结构的缓存区中,等文件写入完一个,再从缓存区中读取一个开始写入,一点点把缓存区读完。
    • 那么,当返回值为false表示当前链表缓存区满了,需要等一等再写,这时候可以调用rs.pause()暂停读取,那么也就不会写入缓存区了,等缓存区的数据真正开始写入文件了,那么这时候缓存区就有空间了,那么flag就变为true了,表示可以继续写入了
1.6.4 多个异步并发怎么变成串行?(面试题
  • 将并发内容放入队列,一个个执行
  • 比如文件可写流,多次同时调用write()方法写入内容,就是第一次真正将内容写入文件,后面几次都放到缓存队列中,然后等第一次写完,从缓存中取出一个写入,这样一个个取出来写入,直至写完。这个过程多次同时调用write方法就是异步并发的,但是放入缓存后,一个个写入就成了串行的了。
  • 当然,实际源码中,缓存区本质是一个链表。