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。这样做的目的,是为了能够做到读一部分,写一部分,不会导致内存爆满。
- 按照它源码中的写法,这个highWaterMark的值就代表 当前写入的字节 + 缓存中的字节数 的和,如果超过这个值,虽然不会阻止你继续往缓存中去写入,但是
- 返回值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方法就是异步并发的,但是放入缓存后,一个个写入就成了串行的了。
- 当然,实际源码中,缓存区本质是一个链表。