写在开始之前:
本系列是笔者目前学习nodejs过程中对各个模块知识的整理和思索。注意标题,这个系列叫学习备忘录,而非应用指南。正因如此,这个系列的文章可能并不会出现诸如:如何应用这些知识,如何解决生产问题等相关内容。
本系列的重点在于对nodejs各部分知识的总结。 如果您也是正在学习nodejs的一员,欢迎在留言中与我探讨相关的知识技术。如果您是nodejs高手,那么本文在您眼中可能存在诸多问题,希望您能不吝赐教,在评论中将问题指出。我会尽量回复有价值的评论,并随时修改文章的错误。最后,希望这系列文章能够让所有愿意花费时间阅读的朋友有所收获!
前言
在nodejs中,流(Stream)是一个值得深入研究与反复揣摩的核心模块,同时也是nodejs中的一大难点。许多nodejs的工具和模块都是基于流来实现的如常用的 http请求(HTTP requests),http响应(HTTP responses),tcp套接字(TCP sockets),标准输出(process.stdout)等等。通过学习掌握流的特点与原理,可以帮助我们更加了解这些工具和模块背后的运作原理,从而更好的使用。或者在使用中出现一些未知错误的时候帮助我们快速定位问题的关键。
基于流的内容十分复杂,同时笔者也还在学习流的过程中,因此关于流的文章分为了几篇来完成。这篇文章是第一篇,旨在介绍关于流的一些基本知识和一些基础内容的分析。更多更深入的内容欢迎期待接下来的文章。
注意:下文中会介绍流的一些基本api。然而这篇文章毕竟不是api文档,因此关于api的部分只介绍一些基本常用的方法或事件,更多更详细的api请至官方文档查看,这里就不过多赘述。
正文
什么是流
相信很多像我一样刚刚接触nodejs的菜鸟在学习流的时候最大的疑问莫过于此。“流是什么?”这个问题看起来简单,却又直指核心,搞不清流是个什么东西,学习它的api乃至原理如同盲人摸象。让我们先看看nodejs文档中是如何描述流的概念。
流(stream)在 Node.js 中是处理流数据的抽象接口(abstract interface)。
文档中对流的定义,用通俗的话来讲,流就是一个处理数据从起点到终点过程的单元。它管理着数据从起点到终点的过程中的状态控制。
如果你觉得这个描述依然有些抽象,那么通过阅读下面的内容,相信你会加深对流的概念的理解。
注意:所有的流都继承自eventEmitter。
流的两种模式
在nodejs中,流的类型实际上有两种:一种是默认的类型,我们称之为二进制类型。另一种是对象类型(object mode)。
- 二进制类型的流是nodejs的默认类型,它只能处理
string
类型或buffer
(包括uint8Array
)类型的数据,我们所使用到的大部分流的实现都是二进制类型。 - 对象类型的流顾名思义,它是一种专门用来处理对象类型数据的流。默认情况下,流是不支持对象类型数据处理的,但是继承并自定义实现流的一些方法,我们可以达到处理对象类型数据的目的。在流的事例中,我们可以通过传入
objectMode:true
属性来使流切换到对象模式。需要注意的是,切换流的类型需要慎重。将一个已经存在的默认类型的流切换到对象模式会造成一些无法预期的问题。
因为对象模式一般是由第三方来实现的特殊模式,这篇文章主要是为了介绍流的基本特性,因此下文中关于流的介绍默认是二进制的流。
流的四种类型
流根据其功能和特点分为四种模式,分别为:
- 可读流(readable) : 负责从数据源读取数据的流。fs模块中的
fs.createReadStream()
方法创建的便是一个可读流的实例。 - 可写流(writable) :向文件或其他接收数据的下游写入数据的流。fs模块中的
fs.createWriteStream()
方法创建的便是一个可读流的实例。 - 双工流(duplex) : 同时具备读写能力的流,但是读和写两方面的功能是独立的。
net.Socket
就是双工流的实例 - 变换流(transform) : 变换流继承自双工流,同样具备读写的能力。不同的是变换流读出的数据可以在方法内处理后使用可写流的能力写到下游去。ndoe中的压缩模块中使用的就是变换流。
四种类型的详解
在正式详细介绍几种流的类型之前,我们需要先引入一个缓冲(buffer)的概念。了解过node中buffer模块的朋友应该知道,buffer就是一小块内存空间。流中因为要针对数据的各种状态做出必要的流程控制,所以使用了缓冲器来将数据缓存起来,以便应对不同的场景。缓冲器的大小取决于创建流实例时传入的一个属性:highWaterMark(后面用hwm来指代)。对于默认的流来说,hwm指定了缓冲器默认的总字节数。而对于对象模式下的流来说,hwm指定了存放对象的总数。无论是可读流还是可写流,他们的状态变化都是围绕着缓冲器的大小来改变的。因此了解缓冲器的概念,对我们理解流的特性至关重要。
因为可读流处理数据的过程和特性相对复杂,所以我们先从可写流开始介绍。
可写流(writable stream)
基本使用
可写流的作用是将传入的数据写入下游。它继承自 stream.Writable
模块,并实现了它的定义的接口。下面我们通过一段代码来看一下一个可读流的实例时如何工作的。
const fs = require('fs');
let ws = fs.createWriteStream('write.txt',{
highWaterMark:3,
encoding:'utf8'
})
let index = 9;
function write(){
let flag = true;
while(flag && index > 0){
flag = ws.write(index-- + "");
}
if(index == 0){
ws.end('0',function(){
console.log('finished');
})
}
}
write();
ws.on('drain',function(){
write();
console.log('drain');
})
ws.on('error',function(err){
console.log(err);
})
ws.on('finish',function(){
console.log('finish');
})
ws.on('close',function(){
console.log('close');
})
上面这段代码展示了一个可写流实例的几个基本的事件和方法,下面我们来逐一介绍:
writable.write(chunk,[encoding,callback])
这个方法的作用是向流中写入数据,是可读流实例的核心方法。参数 chunk
即我们要向流中写入的数据,它的类型必须是 string
, Buffer
或者 Uint8Array
。当数据被切实写入下游后传入的callback会被触发。同时,write
方法有一个返回值,这个返回值是一个 boolean
的值,默认为true。它代表着当前缓冲区的状态。 当调用 write
方法时,可读流会立刻调用底层的写入方法,同时将自身的writing属性改为true,表示正在写入中。这之后再次调用 write
方法,传入的数据会被送到缓冲器中缓存起来。当缓冲器的大小超过了创建实例时设定的hwm的大小后,write
方法会返回false。一旦我们确认方法返回了false后,应该立刻停止调用 write
方法,直到缓冲器中的数据被清空为止。当然,即使方法返回了false,你实际上也可以继续使用 write
方法写入数据。node会将你写入的数据全部缓存起来,直到超过了能使用的最大内存。当然,这种做法是非常不推荐的。
writable.end(chunk,[encoding,callback])
end
方法的作用是关闭流,它可以传入三个可选的参数。chunk
和 encoding
是在关闭可写流前希望最后写入的数据及其对应的编码。如果传入 callback
,这个 callback
会作为 finish
事件的回调函数触发
drain事件
在可写流的缓冲区超过hwm的条件下,当缓冲区内的数据被全部写入至下游时,会触发 drain
事件,提示使用者可以继续向可写流中写入数据。注意这个事件触发的前提,即 writable.write
方法返回了false后清空缓冲区才会触发 drain
事件。
注意:建议在 write
方法返回false时停止写入数据,在 drain
事件的回调中再次开始写入,这样可以更好的控制缓冲区的大小,避免发生内存泄漏问题。
finish事件
当调用 end
方法后,最后的数据被写入至下游后会触发这个事件。
close事件
close
方法触发在下游(如文件系统)被关闭时。当 close
事件被触发后,可写流将不会再触发其他事件。值得注意的是,不是所有可写流都会触发 close
事件。
基本流程
向可写流中写入数据可以用下面这张图来简单表示:
基本实现
根据上面的流程,我写了一些代码用来简单实现一个可写流。当然实际代码要比我的代码复杂的多,想了解node如何实现的朋友请用调试工具查看源代码,这里的代码仅供各位作为学习流程的参考。
const EventEmitter = require('events');
const fs = require('fs');
class MyWriteableStream extends EventEmitter {
//继承event模块,绑定参数
constructor(path, options) {
super(path, options);
this.path = path;
this.flag = options.flag || 'w'; //要执行的文件操作
this.autoClose = options.autoClose || true;//结束流后时候自动关闭文件
this.highWaterMark = options.highWaterMark || 16 * 1024;
this.start = options.start || 0;
this.encoding = options.encoding || 'utf8';
this.mode = options.mode || 0o666;//文件权限位
this.pos = this.start;
this.writing = false;//是否在写数据
this.needDrain = false;//是否需要出发drain时间
this.isEnd = false;
this.buffers = {};//缓冲区
this.buffersLen = 0;//缓冲区长度
this.open();
}
//打开底层文件
open() {
fs.open(this.path, this.flag, this.mode, (err, fd) => {
if (err)
fs.emit('error', err);
this.fd = fd;
this.emit('open');
})
this.once('open', () => {
this.clearBuffers(this.buffers);
})
}
//向流中写数据
write(chunk, encoding, cb) {
let wet = true;
if (this.isEnd) return this.emit('error', new Error('you can not write after end!'));
if (typeof encoding === 'function') {
cb = encoding;
encoding = this.encoding
} else {
encoding = encoding ? encoding : this.encoding;
}
if (chunk && !Buffer.isBuffer(chunk)) {
chunk = Buffer.from(chunk, encoding);
}
this.buffersLen += chunk.length;
if (this.buffersLen >= this.highWaterMark) {
this.needDrain = true;
wet = false;
}
if (this.writing) {
this._push(chunk, encoding, cb, this.buffers);
} else {
if (typeof this.fd !== 'number') { //如果此时文件还未打开,将数据缓存起来
this._push(chunk, encoding, cb, this.buffers);
if (this.buffersLen >= this.highWaterMark) {
this.needDrain = true;
wet = false;
}
return wet;
}
this._write(chunk);
}
return wet; //缓冲区是否超过最高水位线
}
//向缓冲区加入数据
_push(chunk, encoding, cb, buffers) {
if (buffers.next) {
this._push(chunk, encoding, cb, buffers.next);
} else {
buffers['chunk'] = chunk;
buffers['encoding'] = encoding;
buffers['next'] = null;
}
}
//清空缓冲区数据
clearBuffers(buff) {
if (buff.next) {
if (buff.isEnd) {
this._write(buff.chunk, null, buff.isEnd);
} else {
this._write(buff.chunk, buff);
}
} else {
this.buffers = {};
if (this.needDrain) {
this.needDrain = false;
this.emit('drain');
}
}
}
//结束流
end(chunk, cb) {
this.write(chunk);
this.isEnd = true;
if (this.autoClose) {
this.once('_end', () => {
this.destroy();
})
this.once('finish', () => {
cb && cb.call(this);
})
}
}
//销毁流
destroy(error) {
if (typeof this.fd !== 'number') {
return this.once('open', () => {
this.destroy(error);
})
}
fs.close(this.fd, (err) => {
if (err) return this.emit(err);
this.emit('close');
if (error) {
this.emit('error', new Error(error));
}
})
}
//向底层实际写入数据
_write(chunk, buff, isEnd) {
this.writing = true;
if (chunk && !Buffer.isBuffer(chunk)) {
chunk = Buffer.from(chunk, buff.encoding);
}
fs.write(this.fd, chunk, 0, chunk.length, this.pos, (err, bytes) => {
if (err) {
this.emit('error', err);
return this.destroy();
}
this.pos += bytes;
this.writing = false;
let nextBuff = buff ? buff.next : this.buffers;
this.buffersLen -= bytes;
buff && buff.cb && buff.cb();
if (this.buffersLen == 0 && this.isEnd) {
this.emit('finish');
return this.emit('_end');
}
this.clearBuffers(nextBuff);
})
}
}
module.exports = MyWriteableStream;
注意,这段代码中的一些操作的实现方法与node源码并不相同,如前面所言,仅供参考学习。
可读流(readable stream)
相比可写流,可读流要复杂许多。原因在于可读流实际上有两个工作模式:暂停模式(pause)和流动模式(flowing)。
下面我们来分别介绍一下两种模式的特点:
暂停模式
暂停模式是一个可读流实例被创建时的默认模式。当一个可读流被实例化之后,内部方法会先从数据源中读取hwm大小的数据放入缓冲区中,以备接下来使用。
在暂停模式中,我们通过 readable
事件监听是否有数据可以读取,通过 read()
方法来消费数据。下面是一个简单的例子
let fs = require('fs');
let rs = fs.createReadStream('1.txt',{
highWaterMark:3,
encoding:'utf8'
});
rs.on('readable',function () {
let char = rs.read(1);
console.log(char);
});
read(n)
read方法是可读流中的一个核心方法,用于向可读流中请求读取n个字节的数据。根据n值的不同,可读流会有不同的处理方式
- n = undefined : 即不传参数,此时可读流会不断的读取hwm字节数据并且不断触发
readable
事件,直到读到文件结束位置。相当于进入了流动模式。 - n = 0 : 当read方法传入0作为参数时,可读流会返回一个null,并且不消费任何数据。
- 0 < n < hwm : 在这种情况下,可读流会从缓冲区取出对应大小的数据,并且作为
read
方法返回值返回。同时判断此时缓冲区大小是否小于hwm,如果小于,那么将执行底层的_read
方法,从数据源中读取hwm大小的数据填充到缓冲区内。 - n > hwm : 在这种情况下,
read
方法会先返回null,然后可读流会自动将hwm增加到最接近n的2的x次方的值。然后从数据源处读取新的hwm大小的数据加入缓冲区,此时readable事件会再次触发,read方法会将大小为n的数据从缓冲区中取出并返回。
我们需要知道,read
方法是从可读流消费数据的方法。如果在暂停模式下没有显式地调用它,那么数据将一直存在缓冲区中。反之,一旦调用了 read
方法,那么对应大小的数据即被认为已经消费,在不适用其他方法的前提下,无法重新获得这段数据。
注意:向 read
方法中传入n后并不代表一定能获取到对应字节的数据。当数据源中的数据已经不够n字节时,只会返回剩余大小的字节数。当数据源中的数据已经读取完毕后,调用read
方法将返回null。
readable事件
readable
事件表示流中有数据可以用来读取,这个事件在以下两种情况下会被触发
- 当缓冲区大小为0或缓冲区大小减去要读的大小后小于hwm时,会再次触发
readable
事件 - 当文件读完时,会自动触发
readable
事件
当我们想在暂停模式中读取数据时,我们应该在 readable
事件的回调中调用 read
方法,这样才能保证大部分情况下我们需要的数据能够取到。
流动模式
流动模式不是可写流的默认模式,需要通过一些手段来开启。
开启流动模式的常用方法分为两种:监听data事件或者使用管道(pipe)。下面我们通过两个例子来了解对应的方法:
let fs = require('fs');
let rs = fs.createReadStream('1.txt',{
highWaterMark:3,
encoding:'utf8'
});
rs.on('data',function (char) {
console.log(char);
});
当我们主动监听了 data
事件后,可读流会从数据源不断地取出hwm大小的数据,并向 data
事件发送这些数据。除非我们使用了诸如 stream.pause()
这样的方法,手动将流切换到暂停模式,否则该过程将持续下去,直到读到数据源的结束位置。
值得注意的是,无论你是否使用,这些被发送来的数据都被视为已消费。因此流动模式下是没有缓冲区的。这里就存在一个问题,即数据被消费的速度是否能与数据生产的速度匹配。如果数据消费的速度小于数据生产的速度的话,无法被及时消费的数据将消失掉。这意味着我们需要手动精细地控制消费与生产的速度。为了避免因此带来的各方面麻烦,一般情况下我们更应该使用下面的管道方法来读取数据。
let fs = require('fs');
let rs = fs.createReadStream('1.txt',{
encoding:'utf8'
});
let ws = fs.createWriteStream('2.txt',{
encoding:'utf8'
});
rs.pipe(ws);
pipe
方法将可读流和可写流连接在一起,在方法内部有一套精细的状态控制体系,用来保持数据的生产和消费速度的平衡。pipe
方法返回一个 readable
对象,这意味着我们可以使用链式操作将数个流连接在一起。
管道一旦被接上,数据将持续不断的从可读流写入可写流。想要终止这个过程,只能使用 stream.unpipe()
来取消管道连接。
注意:无论是暂停模式,还是流动模式,实际上读取数据的方法都是使用read()来进行的。区别只是流动模式下可读流会自动循环调用read方法持续读取数据。
两种模式的切换方法
所有初始工作模式为暂停模式的可读流,可以通过下面三种途径切换到流动模式:
- 监听
data
事件 - 调用
stream.resume()
方法 - 调用
stream.pipe()
方法将数据发送到可写流中
可读流可以通过下面途径切换到暂停模式:
- 如果不存在管道目标(pipe destination),可以通过调用
stream.pause()
方法实现。 - 如果存在管道目标,可以通过取消
data
事件监听,并调用stream.unpipe()
方法移除所有管道目标来实现。
read方法流程
前面提到,无论是哪种模式,实际上都是通过调用 read
方法来读取数据,因此,了解了 read
方法的流程,就意味着了解了两种模式下数据读取的全过程。
下面这张图展示了 read
方法的基本流程。
基本实现
与可读流相同,我写了一份代码简单实现了可读流的一些基础流程和方法。通过这些代码,我们可以更直观的感受可读流的面貌。
let fs = require('fs');
let EventEmitter = require('events');
class ReadStream extends EventEmitter {
constructor(path, options) {
super(path, options);
this.path = path;
this.highWaterMark = options.highWaterMark || 64 * 1024;
this.buffer = Buffer.alloc(this.highWaterMark);
this.flags = options.flags || 'r';
this.encoding = options.encoding;
this.mode = options.mode || 0o666;
this.start = options.start || 0;
this.end = options.end;
this.pos = this.start;
this.autoClose = options.autoClose || true;
this.bytesRead = 0;
this.closed = false;
this.flowing;
this.needReadable = false;
this.length = 0;
this.buffers = [];
this.on('end', function () {
if (this.autoClose) {
this.destroy();
}
});
//当有新的事件被监听时,触发回调
this.on('newListener', (type) => {
if (type == 'data') {
this.flowing = true;
this.read();
}
if (type == 'readable') {
this.read(0);
}
});
this.open();
}
open() {
fs.open(this.path, this.flags, this.mode, (err, fd) => {
if (err) {
if (this.autoClose) {
this.destroy();
return this.emit('error', err);
}
}
this.fd = fd;
this.emit('open');
});
}
read(n) {
if (typeof this.fd != 'number') {
return this.once('open', () => this.read());
}
n = parseInt(n, 10);
if (n != n) {
n = this.length;
}
if (this.length == 0)
this.needReadable = true;
let ret;
if (0 < n < this.length) {
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);
}
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;
this.length += 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');
}
})
}
if (this.length == 0 || (this.length < this.highWaterMark)) {
_read(0);
}
return ret;
}
destroy() {
fs.close(this.fd, (err) => {
this.emit('close');
});
}
pause() {
this.flowing = false;
}
resume() {
this.flowing = true;
this.read();
}
pipe(dest) {
this.on('data', (data) => {
let flag = dest.write(data);
if (!flag) this.pause();
});
dest.on('drain', () => {
this.resume();
});
this.on('end', () => {
dest.end();
});
}
}
module.exports = ReadStream;
尾声
碍于篇幅和时间限制,就不再继续介绍下去。在这篇文章中,我们详细介绍了流的一些基础概念和四种类型中可读流与可写流的特点及使用方法。如果通过阅读本文,使你对流有了一个基本的概念,那么这篇文章就算达到了目的。如我在文章开头所言,本文仅介绍了一些流的基本使用方法,在阅读了本文后如果有一些地方觉得不太深入,可以去看一下官方文档,哪里有更加全面和详细的介绍。
关于流的内容,还剩下双工流和变换流以及如何自定义四种类型的流等相关知识。这些内容我会放到下一篇文章中来介绍,敬请期待。