春风十里不如Node中的一股清流

手写实现Node.js流
本文详细介绍Node.js中流的概念及使用方式,并手写实现可读流与可写流,帮助理解流的工作原理。

一股清流

清明时节雨纷纷,果然每逢清明是会下雨的。在这个雨夹雪,不方便外出的日子,宅在家里一起来相互学习分享吧!不然还能怎样呢!哈哈

友情提示:本文可能会涉及到一些Api的内容,会很乏味,很枯燥,很没劲

But我们后面的精彩也会超乎你的想象,因为我们要手写实现一下,不亲自上马怎么知道马跑的有多慢呢,Hurry Up Go Go Go!

用过node的朋友们都知道流的作用非常之厉害,可读可写,无所不能。

相比于fs模块,流更适用于读取一个大文件,一次性读取会占用大量内存,效率很低,而流是将数据分割成段,会一段一段的读取,效率会很高。说了一堆,先上概念,一起看看它是谁

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

node中很多内容都应用到了流,比如http模块的req就是可读流,res是可写流,而socket是可读可写流,看起来屌屌的,那么我们今天就都不讲他们,只来讲一下可读流和可写流这对兄弟

可读流和可写流对文件的操作用的也是fs模块

那么让我们从可读流讲起,先来看下都有哪些方法(Api)

可读流

首先要会用,重点在会用
创建可读流
const fs = require('fs');   // 引入fs核心模块

// fs.createReadStream(path, options)
// 返回的是一个可读流对象
let rs = fs.createReadStream('1.txt', {
    flags: 'r',         // 文件的读取操作,默认是'r':读取
    encoding: 'utf8',   // 设置编码格式,默认是null, null代表的是buffer
    autoClose: true,    // 读取完毕后自动关闭
    highWaterMark: 3,   // 默认是读取64k    64 * 1024字节
    start: 0,
    end: 3              // 文件结束位置索引,和正常的截取slice有所不同,包前又包后(包括自己结束的位置)
});

// 默认情况下,不会将文件中的内容输出
// 内部会先创建一个buffer先读取3字节

// 1.txt文件内容为 123456789
复制代码

以上代码写了如何创建可读流,看起来要记那么多options项,真是头疼,其实一般情况下,配置项是不用我们写的,这下大家满足了吧

知道了如何创建,我们就看看rs这个可读流对象上有哪些监听事件啊

监听data事件

可读流这种模式它默认情况下是非流动模式(暂停模式),它什么也不做,就在这等着

大家知道流是基于事件的,所以我们可以去监听事件,监听了data事件的话,就可以将非流动模式转换为流动模式

// 流动模式会疯狂的触发data事件,直到读取完毕
// 根据上面设置的highWaterMark一次读3个字节
rs.on('data', data => { // 非流动模式 -> 流动模式
    console.log(data);  // 触发2次data事件, 分别打出123和4  从0到3共4个(包括末尾)
});

// 题外话:
// 监听data事件的时候,如果没有设置编码格式,data返回的是buffer类型
// so我们可以为data设置encoding为utf8
rs.setEncoding('utf8');     // 等同于options里的encoding: 'utf8'
复制代码

当我们把想要读取的内容都读完后,还可以监听一个end事件,去判断何时读完

监听end事件
rs.on('end', () => {
    console.log('完毕了'); 
});

// 此时除了会打印data事件里的123, 4之外还会打印 完毕了
// 如下表示:
// 123
// 4
// 完毕了
复制代码

除了data和end两个事件之外,可读流中还可以监听到error、open以及close事件,由于用处没有前两位大,就委屈一下放在一起写吧

监听error/open/close事件
// error
rs.on('error', err => {
    console.log(err);
});
// open
rs.on('open', () => {
    console.log('文件打开了');
});
// close
rs.on('close', () => {
    console.log('文件关闭了');
});

// 根据上面监听data、end事件,下面打印的内容是
/*
*   文件打开了
    123
    4
    end
    文件关闭了
* */
复制代码

各类监听事件都知道怎么写了,最后再看两个方法,他们是pause和resume,暂停和恢复触发data

暂停和恢复
// pause
rs.on('data', data => { 
    console.log(data);  // 只会读取一次就暂停了,此时只读到了123
    rs.pause();     // 暂停读取,会暂停data事件触发
});
// resume
setInterval(() => {
    rs.resume();    // 恢复data事件, 继续读取,变为流动模式
                    // 恢复data事件后,还会调用rs.pause,要想再继续触发,把setTimeout换成setInterval持续触发
}, 3000);
// 打印如下:
/*
*   文件打开了
    123
    4   // 隔了3秒后打印
    end
    文件关闭了
* */
复制代码

说完了可读流的用法,让我们再接再厉(不得不)去看下它的兄弟可写流吧,毕竟对于作为世界第一大群体的程序猿来说,总得有个从入门到精通(放弃)的深层次提升嘛!加了个油的,各位走起。

可写流

废话不多说,上来就是干

创建可写流
const fs = require('fs');
// fs.createWriteStream(path, options);
const ws = fs.createWriteStream('2.txt', {
    flags: 'w',         // 文件的操作, 'w'写入文件,不存在则创建
    mode: 0o666,
    autoClose: true,
    highWaterMark: 3,   // 默认写是16k
    encoding: 'utf8'
});
复制代码

可写流就有两个方法,分别是write和end方法,直接看下如何使用

write方法
// ws.write(chunk, encoding(可选), callback);
// 写入的chunk数据必须是字符串或者buffer
let flag = ws.write('1', 'utf8', () => {});     // 异步的方法 有返回值

console.log(flag);  // true
flag = ws.write('22', 'utf8', () => {});    
console.log(flag);  // false    超过了highWaterMark的3个字节,不能再写了
flag = ws.write('3', 'utf8', () => {}); 
console.log(flag);  // false

// 2.txt -> 写入了 1223
复制代码

flag标识符表示的并不是是否写入,而是能否继续写,true为可以继续写入。但是返回false,也不会丢失,还会写到文件内的

接下来再介绍下end方法

end方法
// 可以传入chunk值
ws.end('完毕');   // 当写完后 就不能再写了

// 此时2.txt -> 写入了 1223完毕
复制代码

讲完了write和end方法,可写流还有一个on监听事件,它可以监听drain(抽干)事件

监听drain事件
// drain方法
// 抽干方法 当都写入后触发drain事件
ws.on('drain', () => {
    console.log('已经抽干了');
});
复制代码

重头戏来了

  • 前面罗里吧嗦都在写如何使用,Api着实让大家看的昏昏欲睡了。
  • 但是各位观众,现在才是最最值得高兴的时刻,对于流的操作,我们不仅仅要会用,还应该简单的去实现一下。
  • 这样才能满足我们庞大的求知欲并且get到新技能,老样子,直接上代码,从代码中去深入分析一番
  • 如果读的疲惫了,那就歇歇吧,当一个佛系青年,看空一切也是一种痛的领悟啊
实现可读流

先来个栗子

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

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

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

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

setTimeout(() => {
    rs.resume();
}, 2000);
复制代码

前方高能,开启敲击模式,如果还不知道node中的buffer和events的话,千万别捉急。大家都是一条船上的人,我会在之后的文章里给大家分享,且先暂且继续看下去啊!坚持住,兄弟姐妹们!

创建ReadStream类
// ReadStream.js
const fs = require('fs');
const EventEmitter = require('events');  // 需要依赖事件发射
// 这里用ES6提供的class写法,大家也一起来看看是怎么写的吧

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这两个方法,从字面意思就明白了,我们的可读流要想读文件,the first就需要先打开(open),after我们再去读内容(read)

这就是实习可读流的主要方法,我们接下来先从open方法写起

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
            }
        });
    }
}
复制代码

以上就是read方法的主要实现了,其实思路上并不是很难,去除掉注释的话也会更精简些。大家上面也了解了可读流的用法,知道它还有两个方法,那就是pause(暂停)和resumt(恢复),那么我们择日不如撞日直接写完吧,简单到令人发指的实现,看看不会吃亏的,哈哈

pause和resume方法
class ReadStream extends EventEmitter {
    constructor(path, options) {
        // 省略...
    }
    pause() {
        this.flowing = false;
    }
    resume() {
        this.flowing = true;
        this.read();
    }
}
复制代码

完事,就是这么so easy,我们实现了自己的可读流了,可喜可贺,可喜可贺。

实现可写流

先看下测试数据

let WriteStream = require('./WriteStream'); // 引入我们实现的可写流

let ws = new WriteStream('3.txt', {
    flags: 'w',
    highWaterMark: 3,
    autoClose: true,
    encoding: 'utf8',
    mode: 0o666,
    start: 0
});

// ws.write('你d好', 'utf8', () => {});

let i = 9;

function write() {
    let flag = true;
    while (i >= 0 && flag) {
        flag = ws.write(--i + '', 'utf8', () => {});
        console.log(flag);
    }
}

write();
// drain只有当缓存区充满后 并且被消费后出发
ws.on('drain', () => {
    console.log('抽干');
    write();
});
复制代码

可写流的实现前面部分和可读流基本一致,不过可写流是有drain(抽干)事件的,所以在编写的时候也会对这一点进行处理的

创建可写流
let fs = require('fs');
let EventEmitter = require('events');   // 需要事件发射

// 继承事件发射EventEmitter
class WriteStream extends EventEmitter {
    constructor(path, options) {
        super();    // 继承
        this.path = path;
        this.highWaterMark = options.highWaterMark || 16 * 1024;    // 默认一次写入16k
        this.autoClose = options.autoClose || true;
        this.encoding = options.encoding || null;
        this.mode = options.mode;
        this.start = options.start || 0;
        this.flags = options.flags || 'w';  // 默认'w'为写入操作
        
        this.buffers = [];
        this.writing = false;   // 标识 是否正在写入
        this.needDrain = false;     // 是否满足触发drain事件
        this.pos = 0;   // 记录写入的位置
        this.length = 0;
        
        this.open();    // 首先还是打开文件获取到fd文件描述符
    }
}

module.exports = WriteStream;
复制代码
  • 可写流要有一个缓存区,当正在写入文件时,内容要写入到缓存区里,在源码中是一个链表 => 我们就直接用个[]来实现,这就是this.buffers的作用

  • 再有就是用buffers计算的话,每增加一项都需要遍历一遍,维护起来性能太高了,所以用this.length来记录缓存区的大小

下面我们直接写open方法打开文件拿到fd文件描述符

open方法
class WriteStream extends EventEmitter {
    constructor(path, options) {
        // 省略...
        this.open();
    }
    
    open() {
        // 用法: fs.open(filename,flags,[mode],callback)
        fs.open(this.path, this.flags, this.mode, (err, fd) => {
            if (err) {
                this.emit('error', err);
                // 看一下是否会自动关闭
                if (this.autoClose) {
                    this.destory();     // 销毁
                }
                return;
            }
            this.fd = fd;
            this.emit('open');  // 触发open事件,表示当前文件打开了
        });
    }
    
    destory() {
        if (typeof this.fd !== 'number') {  // 如果不是fd的话直接返回一个close事件
            return this.emit('close');
        }
        fs.close(this.fd, () => {
            this.emit('close');
        });
    }
}
复制代码

通过open方法获取到了fd文件描述符后,对于流来说就成功了一半。下面乘胜追击,直捣黄龙完成可写流的两个方法吧!!!

write和end方法
class WriteStream extends EventEmitter {
    constructor(path, options) {
        // 省略...
    }
    // 用法:ws.write(chunk,[encoding],[callback])
    write(chunk, encoding = this.encoding, callback) {
        // 通过fs.write()写入时,chunk需要改成buffer类型
        // 并且要用我们指定的编码格式去转换
        chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
        // chunk.length就是要写入的长度
        this.length += chunk.length;
        // 比较是否达到了缓存区的大小
        let result = this.length < this.highWaterMark;
        this.needDrain = !result;   // 是否需要触发drain事件
        
        if (this.writing) {
            this.buffers.push({
                chunk,
                encoding,
                callback
            });
        } else {
            this.writing = true;
            this._write(chunk, encoding, () => {
                callback();
                this.clearBuffer();
            });
        }
        
        return result;  // write方法 返回一个布尔值
    }
    _write(chunk, encoding, callback) {
        if (typeof this.fd !== 'number') {
            return this.once('open', () => this._write(chunk, encoding, callback));
        }
        // fs.write写入文件
        // 用法: fs.write(fd, buffer[, offset[, length[, position]]], callback)
        
        fs.write(this.fd, chunk, 0, chunk.length, this.pos, (err, bytesWritten) => {
            // this.length记录缓存区大小,写入后length需要再减掉写入的个数
            this.length -= bytesWritten;    
            // this.pos下次写入的位置
            this.pos += bytesWritten;
            this.writing = false;
            callback();     // 清空缓存区内容
        });
    }
    
    clearBuffer() {
        let buf = this.buffers.shift(); // 每次把最先放入缓存区的取出
        if (buf) {  // 如果有值,接着写
            this._write(buf.chunk, buf.encoding, () => {
                buf.callback();
                this.clearBuffer(); // 每次写完都清空一次缓存区
            });
        } else {    // 缓存区已经空了
            if (this.needDrain) {   // 是否需要触发drain 需要就发射drain事件
                this.needDrain = false;
                this.emit('drain');
            }
        }
    }
    
    end() {
        if (this.autoClose) {
            this.emit('end');
            this.destory();
        }
    }
}
复制代码
  • 以上就完成了可写流的实现了,各位可能会有一些疑惑,在此我先把普遍的疑惑说一下吧
  • write方法里的条件判断先解释一下
    • if条件
      • 如果是正在写入,就先把内容放到缓存区里,就是this.buffers里
      • 给数组里存入一个对象,分别对应chunk, encoding, callback
      • 这样方便在清空缓存区的时候取缓存区里对应的内容
    • else条件
      • 专门用来将内容,写入到文件内
      • 每一次写完后都需要把buffers(缓存区)里的内容清空掉
      • 当缓存区buffers数组里是空的时候就会触发drain事件了
  • _write方法里typeof那里的判断来说明一下
    • 判断是否有fd文件描述符,只有在打开文件成功的时候才会有fd
    • 所以如果没有的话,需要触发一次open事件,拿到fd都再调_write方法
  • end方法就比较简单了
    • 判断是否会自动关闭,发射end事件并销毁即可了

写在最后

终于都搞定了,其实说实话,这些基于Api的东西说起来还是很让人枯燥无聊的,大家都是拒绝无聊主义者。但我还是坚持写下来了,也是想让大家和我一起去感受一下大师们是怎么实现怎么思考的过程

谢谢大家的观看了,能坚持下来的都不是折翼的天使啊!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值