不识庐山真面目,只缘生在此山中
一、揭开流姑娘神秘面纱:什么是流?
官方标准答案:
- 流是一组有序的,有起点和终点的字节数据传输手段
- 它不关心文件的整体内容,只关注是否从文件中读到了数据,以及读到数据之后的处理
- 流是一个抽象接口,被 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]);
复制代码
- path读取文件的路径
- 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;
复制代码
关于“流”姑娘的故事还未完,咱们下回再续。。。各位看官看完别忘记动动小爪点个赞。。。。