流是一组有序的,有起点和终点的字节数据传输手段,它不关心文件的整体内容,只关注是否从文件中读到了数据,以及读到数据之后的处理。 流是一个抽象接口,被 Node
中的很多对象所实现。比如HTTP 服务器request
和response
对象都是流。
今天我们来学习这块知识,怎么学,写源码呗。这篇文章主要是去实现一个可读流,实现一个可写流,就不介绍流的基础API
和用法了。
如果你还不熟悉,建议移步到这里
1、前置知识
学习流之前,我们需要掌握事件机制
。源码中采用的是events
模块,你也可以自己写一个,只要有发布、订阅这两个API
就可以了。 发布订阅这种机制可以完成信息的交互功能,同时还能解耦模块。这种机制在好多源码中都有体现,比如:webpack
源码中的事件流、vue
中MVVM
模式也用了发布订阅机制。
2、可读流
可读流是一个实现了stream.Readable
接口的对象,将对象数据读取为流数据。 如何创建一个可读流呢,非常简单,看以下代码
//引入fs模块
let fs = require('fs');
//调用api获得一个可读流rs。msg.text是一个文件
let rs = fs.createReadStream('./msg.txt');
复制代码
rs
就是一个可读流,它身上有一些方法和事件。比如:
rs.pause();
rs.resume();
rs.on('data', function () { })
...
复制代码
rs
可读流身上的方法以及事件我们一会儿会都去实现的,这里就是简单回忆一下。
2-1、可读流的两种模式
2-1-1、flowing
模式
当可读流处在 flowing
模式下, 可读流自动从系统底层读取数据,并通过EventEmitter
接口的事件尽快将数据提供给应用。也就是说,当我们监听可读流的data
事件时,底层接口就开始不停的读取数据,触发data
事件,直到数据被读取完毕。 看以下代码:
let fs = require('fs');
let rs = fs.createReadStream('./msg.txt');
//只要以监听data事件,底层接口就会去读取数据并且不停的触发回调函数,将数据返回
rs.on('data', function (data) { })
复制代码
那么如何切换当前可读流到flowing
模式下呢。有三种途径切换到 flowing
模式:
- 监听
data
事件 - 调用
stream.resume()
方法 - 调用
stream.pipe()
方法将数据发送到Writable
注意: 如果 Readable 切换到 flowing 模式,且没有消费者处理流中的数据,这些数据将会丢失。 比如, 调用了 readable.resume() 方法却没有监听 'data' 事件,或是取消了 'data' 事件监听,就有可能出现这种情况
2-1-2、paused
模式
当可读流处在paused
模式下,必须显式调用rs.read()
方法来从流中读取数据片段。 看下面的代码:
let fs = require('fs');
let rs = fs.createReadStream('./msg.txt');
rs.on('readable', function () {
let result = rs.read(5);
})
复制代码
当监听readable
事件时,底层接口会读取数据并将缓存区填满,然后暂停读取数据,等待缓存区的数据被消费。代码中let result = rs.read(5);
就是消费了5个数据。
如何切换可读流到paused
模式呢,可通过下面途径切换到 paused
模式:
- 如果不存在管道目标(
pipe destination
),可以通过调用rs.pause()
方法实现。 - 如果存在管道目标,可以通过取消
data
事件监听,并调用rs.unpipe()
方法移除所有管道目标来实现。
2-2、实现一个可读流
源码当中,fs.createReadStream()
是ReadStream
的一个实例,而ReadStream
是继承了stream.Readable
接口。摘取源码中的部分代码,方便我们理解。
const { Readable, Writable } = require('stream');
function ReadStream() { }
util.inherits(ReadStream, Readable);
fs.createReadStream = function (path, options) {
return new ReadStream(path, options);
};
复制代码
了解了这几个类之间的关系,那我们就开始实现一个自己的ReadStream
类。
2-2-1、flowing
模式的实现
这种模式下,我们需要做到事情是,当可读流监听data
事件后,就开始读取数据,并且不停的触发data
事件,并将数据返回。 我们先把ReadStream
类的骨架画出来,如下
let fs = require('fs');
let EventEmitter = require('events');
class ReadStream extends EventEmitter {
constructor(path, options) {
this.path = path;
this.flowing = false;
...
}
read() { }
open() { }
end() { }
destroy() { }
pipe() { }
pause() { }
resume() { }
}
复制代码
往this
上挂载的参数比较多,现单独列举出来:
属性 | 作用 |
---|---|
path | 记录要读取文件的路径 |
fd | 文件描述符 |
flowing | flowing模式的标志 |
encoding | 编码 |
flag | 文件操作权限 |
mode | 文件模式,默认为0o666 |
start | 开始读取位置,默认为0 |
pos | 当前读取位置 |
end | 结束读取位置 |
highWaterMark | 最高水位线,默认64*1024 |
buffer | 数据存放区 |
autoClose | 自动关闭 |
length | 数据存放区的长度 |
构造函数里还应该有这几部分:
this.on('newListener', (type, listener) => {
if (type === 'data') {
this.flowing = true;
this.read();
}
});
this.on('end', () => {
if (this.autoClose) {
this.destroy();;
}
});
this.open();
复制代码
着重看第一个监听事件,它实现了,只要用户监听data
事件,我们就开始调用this.read()
方法,也就是去读取数据了。
接下来,我们写主要的read()
方法,该方法主要作用是读取数据,发射data
事件。依赖一个方法,fs.read()
,不熟悉的同学点这里
read() {
//当文件描述符没有回去到时的处理
if (typeof this.fd !== 'number') {
return this.once('open', () => this.read())
}
//处理边界值
let n = this.end ? Math.min(this.end - this.pos, this.highWaterMark) : this.highWaterMark;
//开始读取数据
fs.read(this.fd, this.buffer, 0, n, this.pos, (err, bytesRead) => {
if (err) return;
if (bytesRead) {
let data = this.buffer.slice(0, bytesRead);
data = this.encoding ? data.toString(this.encoding) : data;
//发射事件,将读取到的数据返回。
this.emit('data', data);
this.pos += bytesRead;
if (this.end && this.pos > this.end) {
return this.emit('end');
}
//flowing模式下,不停的读取数据
if (this.flowing) {
this.read();
}
} else {
this.emit('end');
}
})
}
复制代码
实现open
方法,该方法就是获取文件描述符的。比较简单
open() {
//打开文件
fs.open(this.path, this.flag, this.mode, (err, fd) => {
//如果打开文件失败,发射error事件
if (err) {
if (this.autoClose) {
this.destroy();
return this.emit('error', err);
}
}
//获取到文件描述符
this.fd = fd;
this.emit('open', fd);
})
}
复制代码
实现pipe
方法,该方法的思路如下:
- 监听
data
事件,拿到数据 - 将数据写入可写流,当缓存区满时,就暂停写入。未满时,恢复写入
- 写完数据,触发
end
事件
pipe(des) {
//监听data事件,拿到数据
this.on('data', (data) => {
//flag为true时表示缓存区未满,可以继续写入。
let flag = des.write(data);
if (!flag) {
this.pause();
}
});
//drain事件表示缓存区的数据已经全部写入,可以继续读取数据了
des.on('drain', () => {
this.resume();
});
this.on('end', () => {
des.end();
})
}
复制代码
其他方法,实现比较简单。
end() {
if (this.autoClose) {
this.destroy();
}
}
destroy() {
fs.close(this.fd, () => {
this.emit('close');
})
}
pause() {
this.flowing = fasle;
}
resume() {
this.flowing = true;
this.read();
}
复制代码
至此,一个flowing
模式的可读流就实现了。
2-2-2、paused
模式的实现
paused
模式的可读流和flowing
模式的可读流的区别是,当流处在paused
模式时,底层接口不会一口气把数据读完并返回,它会先将缓存区填满,然后就不读了,当缓存区数据为空时,或者低于最高水位线了,才会再次去读取数据
paused
模式下,我们重点关注的是read()
方法的实现,此时我们不再是尽快的将数据读取出来,通过触发data
事件将数据返回给消费者。而是,当用户监听readable
事件时,我们将缓存区填满,然后就不再读取数据了。直到缓存区的数据被消费了,并且数据小于highWaterMark
时,再去读取数据将缓存区填满,如此周而复始,直到数据全部读取完毕。这种模式使得读取数据这个过程变的可控制,按需读取。
来看看read()
方法如何实现。
read(n){
let ret;
//边界值检测
if (n > 0 && n < this.length) {
//创建buffer,read方法的返回值
ret = Buffer.alloc(n);
let b;
let index = 0;
while (null != (b = this.buffers.shift())) {
for (let i = 0; i < b.length; i++) {
//取出要消费的数据
ret[index++] = b[i];
if (index === ret.length) {
this.length -= n;
b = b.slice(i + 1);
//将没有消费的数据放回缓存区
this.buffers.unshift(b);
break;
}
}
}
//处理编码问题
if (this.encoding) {
ret = ret.toString(this.encoding);
}
}
//当缓存区小于highWaterMark时,就去读取数据,将缓存区填满
if (this.length === 0 || (this.length < this.highWaterMark)) {
_read(0);
}
return ret;
}
复制代码
这里,我把主要代码贴出来了,大家可以看看,只是抛砖引玉。read()
方法主要是操作缓存区的,而_read()
方法是真正去文件中读取数据的。来看看_read()
方法。
let _read = () => {
let m = this.end ? Math.min(this.end - this.pos + 1, this.highWaterMark) : this.highWaterMark;
fs.read(this.fd, this.buffer, 0, m, this.pos, (err, bytesRead) => {
if (err) return
let data;
if (bytesRead > 0) {
data = this.buffer.slice(0, bytesRead);
this.pos += bytesRead;
if (this.end && this.pos > this.end) {
if (this.needReadable) {
this.emit('readable');
}
this.emit('end');
} else {
this.buffers.push(data);
if (this.needReadable) {
this.emit('readable');
this.needReadable = false;
}
}
} else {
if (this.needReadable) {
this.emit('readable');
}
return this.emit('end');
}
})
}
复制代码
至此,paused
模式的可读流模式就完成了。
3、可写流
实现了stream.Writable
接口的对象来将流数据写入到对象中。相比较可读流来说,可写流简单一些。可写流主要是write()
、_write()
、clearBuffer()
这三个方法。
3-1、实现一个可写流
write()
方法的实现
write(chunk, encoding, cb) {
//参数判断
if (typeof encoding === 'function') {
cb = encoding;
encoding = null;
}
//处理传入的数据
chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, this.encoding || 'utf8');
let len = chunk.length;
this.length += len;
let ret = this.length < this.highWaterMark;
//当数据正在写入时,将新任务添加到任务队列中
if (this.writing) {
this.buffers.push({
chunk,
encoding,
cb
})
//写入数据
} else {
this.writing = true;
this._write(chunk, encoding, this.clearBuffer.bind(this));
}
return ret;
}
复制代码
_write()
方法的实现
_write()
方法的主要功能是调用底层API
来将数据写入到文件中
_write(chunk, encoding, cb) {
//当文件描述符没有拿到时的处理
if (typeof this.fd !== 'number') {
return this.once('open', () => this._write(chunk, encoding, cb));
}
//写入数据,执行回调函数
fs.write(this.fd, chunk, 0, chunk.length, this.pos, (err, written) => {
if (err) {
if (this.autoClose) {
this.destroy();
}
return this.emit('error', err);
}
this.length -= written;
//更新写入数据后,下一次该从哪个位置写入的变量
this.pos += written;
//执行回调函数
cb && cb();
})
}
复制代码
clearBuffer()
方法的实现
clearBuffer(cb) {
//从任务队列中拿出一个任务
let data = this.buffers.shift();
//如果任务有值,那么就将数据写入文件中
if (data) {
this._write(data.chunk, data.encoding, this.clearBuffer.bind(this));
} else {
this.writing = false;
this.emit('drain');
}
}
复制代码
至此,一个可写流就实现了。
4、双工流
双工流( Duplex
)是同时实现了 Readable
和 Writable
接口的流。有了双工流,我们可以在同一个对象上同时实现可读和可写,就好像同时继承这两个接口。 重要的是双工流的可读性和可写性操作完全独立于彼此。这仅仅是将两个特性组合成一个对象。
const {Duplex} = require('stream');
const inoutStream = new Duplex({
//实现一个write方法
write(chunk, encoding, callback) {
console.log(chunk.toString());
callback();
},
//实现一个read方法
read(size) {
this.push((++this.index)+'');
if (this.index > 3) {
this.push(null);
}
}
});
复制代码
5、转换流
对于转换流,我们不必实现read
或write
的方法,我们只需要实现一个transform
方法,将两者结合起来。它有write
方法的意思,我们也可以用它来push
数据。
const {Transform} = require('stream');
const upperCase = new Transform({
//实现一个transform方法
transform(chunk, encoding, callback) {
this.push(chunk.toString().toUpperCase());
callback();
}
});
process.stdin.pipe(upperCase).pipe(process.stdout);
复制代码