阅读 340

说说Node.js中 流 的一些原理

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

今天我们来学习这块知识,怎么学,写源码呗。这篇文章主要是去实现一个可读流,实现一个可写流,就不介绍流的基础API和用法了。

如果你还不熟悉,建议移步到这里

1、前置知识

学习流之前,我们需要掌握事件机制。源码中采用的是events模块,你也可以自己写一个,只要有发布订阅这两个API就可以了。 发布订阅这种机制可以完成信息的交互功能,同时还能解耦模块。这种机制在好多源码中都有体现,比如:webpack源码中的事件流、vueMVVM模式也用了发布订阅机制。

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 )是同时实现了 ReadableWritable 接口的流。有了双工流,我们可以在同一个对象上同时实现可读和可写,就好像同时继承这两个接口。 重要的是双工流的可读性和可写性操作完全独立于彼此。这仅仅是将两个特性组合成一个对象。

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、转换流

对于转换流,我们不必实现readwrite的方法,我们只需要实现一个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);
复制代码
文章分类
阅读