如果你正在学习Nodejs,那么流一定是一个你需要掌握的概念。如果你想成为一个Node高手,那么流一定是武功秘籍中不可缺少的一个部分。关于流这个主题,由Node高手substack带来的stream-handbook绝对是经典入门读物之一,其在Github上的star数量已经超过了4500个,足以见其权威程度。
1.为什么要使用流
在node中,I/O操作都是异步的,所以在硬盘和网络交互过程中都会与回调函数产生关系,你在读取一个文件的过程中可能会这样写:
let http = require('http');
let fs = require('fs');
let path = require('path');
let server = http.createServer((req, res) => {
fs.readFile(path.resolve(__dirname, '/1.txt'), (err, data) => {
res.end(data);
})
});
server.listen(3000);
乍一看,上面这段代码似乎并没有什么问题,当时每次请求的时候都会将文件读到内存中,然后在返回给客户端。如果文件很小,并不会影响什么。如果文件超大。不仅会消耗掉大量的内存,客户端等待的时间也将大大增加。
那该如何解决上面这个问题呢?
少年, 你可知道 req, res 参数都是基于流(假设你已经知道 流 这个概念)的对象。因此。可以将上面代码进行如下优化:
let http = require('http');
let path = require('path');
let fs = require('fs'); //引入fs核心模块
let server = http.createServer((req, res) => {
let rs = fs.createReadStream(path.resolve(__dirname,'/1.txt')); //返回一个可读流对象
rs.pipe( res ); //pipe 管道方法
});
画个图来示意:
在这里,.pipe()方法(管道)会自动帮助我们监听data(每次读一点)和end(读完整个文件)事件。优化后的代码码不仅简洁,而且1.txt文件中每一小段数据都将源源不断的发送到客户端。
知道这样用的好处了,那么我们不禁要发问了,什么是 "流", 什么又是 data 事件和 end事件。fs.createReadStream的内部实现的机制到底如何?, 由浅入深,一步一步带你来了解其原理。
2.流是什么
1.流(stream)在 Node.js 中是处理流数据的抽象接口(abstract interface)。
2.Node.js 提供了多种流对象。 例如, HTTP 请求 和 process.stdout 就都是流的实例。
3.流可以是可读的、可写的,或是可读写的。所有的流都是 EventEmitter 的实例。
- 流:有序的有方向的 流可以自己控制速率
- 读:读是将内容读取到内存中
- 写:写是将内存或者文件的内容写入到文件内
2.1 流的基本类型
Node.js 中有四种基本的流类型:
1. Readable - 可读的流 (例如 fs.createReadStream()).
2. Writable - 可写的流 (例如 fs.createWriteStream()).
3. Duplex - 可读写的流 (双工流)(例如 net.Socket).
4. Transform - 在读写过程中可以修改和变换数据的 Duplex 流 (例如 zlib.createDeflate()).
3 可读流Readable
1. 可读流事实上工作在下面两种模式之一:flowing 和 paused 。
2. 在 flowing 模式下, 可读流自动从系统底层读取数据,并通过 EventEmitter 接口的事件尽快将数据提供给应用。
3. 在 paused 模式下,必须显式调用 stream.read() 方法来从流中读取数据片段。
4. 所有初始工作模式为 paused 的 Readable 流,可以通过下面三种途径切换到 flowing 模式:
5. 监听 'data' 事件。
6. 调用 stream.resume() 方法。
7. 调用 stream.pipe() 方法将数据发送到 Writable。
3.1 可读流的基本用法
- 创建可读流
let fs = require('fs'); //引入fs核心模块
let path = require('path');
//返回一个可读流
let rs = fs.createReadStream( path.resolve(__dirname, './1.txt'), {
highWaterMark: 3, //字节, 默认是 64k, 64 * 1024个字节
flags: 'r', //文件的读取操作,默认是'r':读
autoClose: true //默认读取后自动关闭
start: 0, //从第几个字节开始读取
//end: 3, //流是闭合区间, [0, 3]
encoding: 'utf8' //设置编码格式,默认是null, null代表的是buffer
});
//1.txt 中的内容为 1234567890
//默认是非流动模式, 不会将内容输出,内部创建一个3个字节的buffer
-
监听data事件
-
必须要强调的是 流是基于事件的。默认状态下是 paused 模式。通过监听 data 事件, 将 paused 状态改成 flowing 模式。
-
通过不停的触发 data 事件来读取内容,每次根据 hignWaterMark 来读取3个字节的内容
rs.on('data', data => {
//会触发2两次 data 事件, 分别打印出123 和 4
//如果不设置encoding, 则默认是buffer
console.log( data );
})
- 监听end事件
读取完成所有内容后,根据end 事件来处理读取文件的所有内容。
rs.on('end', () => {
console.log('读取完成'); //读取完成
});
- 监听error事件
rs.on('error', err => {
console.log(err);
})
- 监听open事件
rs.on('open', err => {
console.log(err);
})
- 监听close事件
rs.on('close', err => {
console.log(err);
})
设置编码
默认不设置编码,读出来的内容是buffer格式
rs.setEncoding('utf8')
暂停和恢复触发data事件
rs.on('data', data => {
console.log('data');
rs.pause();
})
setTimeout( () => {
rs.resume();
}, 1000)
- 了解了可读流的基本概念和调用步骤,是不是有点意犹未尽的赶脚哇~, 下面就带小伙伴们一起来实现一把可读流
3.2 实现可读流
话说有这样的一段业务代码:
// readstream.js
const ReadStream = require('./ReadStream'); // 引入实现的可读流
const rs = new ReadStream('1.txt', {
flags: 'r',
// encoding: 'utf8',
autoClose: true,
highWaterMark: 3,
start: 0,
end: 3
});
rs.on('data', data => {
console.log(data);
rs.pause();
});
rs.on('end', () => {
console.log('end');
});
setTimeout(() => {
rs.resume();
}, 1000);
嘿,哥们是时候见真章了~
创建自定义ReadStream模块(类)
思路分析
- 从业务代码反推, 我们可以知道ReadStream构造函数中应该有如下参数options中包含的,而通过new 创建的实例。所以都在构造函数中来定义。
- 可读流实例的默认模式是非流动模式: flowing: false
- 流是基于事件的,所以继承自EventEmittter
let fs = require('fs');
let EventEmitter = require('events');
class ReadStream extends EventEmitter {
constructor(path, options = {}) { //path: 文件路径, options: 可读流的参数
//code1
}
}
module.exports = ReadStream;
为了阅读的方便,将constructor方法中的代码放在这里: code1
super(); //实现继承EventEmitter类
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 = false; //默认为非流动模式
// 弄一个buffer读出来的数, 分配可用的内存空间
//想想文章开始的那个问题。这样的操作对于优化性能是不是杠杠滴呢?
this.buffer = Buffer.alloc(this.highWaterMark);
this.open(); //打开文件,获取文件描述符 fd
// {newListener:[fn]}
// 监听方法默认同步调用的,当用户(rs, 可读流的实例)在监听data事件的时候,
//同时会触发 newListener 事件
this.on('newListener', (type) => {
if (type === 'data') { //事件类型
this.flowing = true; //讲paused 模式转换成 flowing 模式
this.read();// 开始读取,用户已经监听了data事件
}
})
如果你读到这里,对于ReadStream类中存在疑问的估计就是 open() 和 read() 方法了。
- 我们先来实现open方法
let fs = require('fs');
let EventEmitter = require('events');
class ReadStream extends EventEmitter {
constructor(path, options = {}) { //path: 文件路径, options: 可读流的参数
//code1
}
destroy() {
if (typeof this.fd != 'number') { return this.emit('close'); }
fs.close(this.fd, () => {
// 如果文件打开过了 那就关闭文件并且触发close事件
this.emit('close');
});
}
// 打开文件用的
open(){
//fd标识的就是当前this.path这个文件,从3开始(number类型)
fs.open(this.path, this.flags, (err, fd) => {
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;
再来看看read方法,一定要耐住性子和寂寞,代码中含有大量的注释来解释逻辑,不要怂,一鼓作气读完它:
let fs = require('fs');
let EventEmitter = require('events');
class ReadStream extends EventEmitter {
constructor(path, options = {}) { //path: 文件路径, options: 可读流的参数
//参考上文代码部分
}
//open方法部分省略
read(){
// 等待着触发open事件后fd肯定拿到了,拿到以后再去执行read方法
// 文件打开后,就可以去读取了
if(typeof this.fd !== 'number'){
return this.once('open',() => this.read());
}
//每次读取的数量:
//1: 当没有设置end值的时候,每次读取的数量就是 highWaterMark的值,即每次读固定数量3个,直到读完整个文件,这里有个问题就是当读到最后一次,不够3个时候,需要留意。
//2. 当设置end的时候, 当前可读数量就需要计算了。
//假设:end为3, 那就是读取1,2,3,4总共4个字节,而每次可读的初始化设置highWaterMark为3,那就分两次读取
//第一次读1,2,3
//第二次读4
let howMuchToRead = this.end?Math.min(this.end-this.pos+1,this.highWaterMark): this.highWaterMark;
// 对于 fs.read方法参数的理解就是:
//讲 this.fd这个文件读到 this.buffer中,
//并且是this.buffer的第0个位置, 每次读howMuchToRead个,
//当前读到了文件的第this.pos个
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);
//将每次读到的内容发射出去,也就是业务代码中的data事件部分
this.emit('data', b);
if ((byteRead === this.highWaterMark)&&this.flowing){
return this.read(); // 继续读
}
// 这里就是没有更多的逻辑了
if (byteRead < this.highWaterMark){
// 没有更多了
this.emit('end'); // 读取完毕
this.destroy(); // 销毁即可
}
});
}
}
module.exports = ReadStream;
补上pause 和 resume 方法,就大功告成了:
pause(){
this.flowing = false;
}
resume(){
this.flowing =true;
this.read();
}
至此,基本完成了对可读流的实现。似乎也没有想象中的那么难。不妨,再努力努力,达到文武双全(可读可写)的境界。
4.1 可写流的基本用法
先调用原生fs.createWriteStream来看看效果:
let fs = require('fs');
let ws = fs.createWriteStream('2.txt',{
flags:'w',
encoding:'utf8',
start:0,
highWaterMark:3 // 一次能写三个
});
let i = 9;
function write() {
let flag = true; // 表示是否能写入
while (flag&&i>=0) { // 9 - 0
// 第一次是真的往文件里写,后面的都会放到缓存区中,过一会写完了,
// 会去清空缓存区
//写入的内容必须是buffer或者string,并且是异步的方法
flag = ws.write(i--+'');
}
}
// drain的触发时机,只有当highWaterMark填满时,才可能触发drain
// 或者说 drain缓存区内容被消费完后就触发该事件
ws.on('drain',()=>{
console.log('把缓存的中全部写完'); //按照这样的模式,这里会打印三次
write();
})
write();
下面我们来看看fs.createWriteStream背后的实现过程吧,相比于可读流。我们需要多关注 drain事件。同样也包含了打开文件open方法。
4.2 实现自定义WriteStream模块(类)
let fs = require('fs');
let EventEmitter = require('events');
class WriteStream extends EventEmitter {
constructor(path, options = {}) {
super();
this.path = path;
this.flags = options.flags || 'w';
this.encoding = options.encoding || 'utf8';
this.start = options.start || 0;
this.pos = this.start;
this.mode = options.mode || 0o666;
this.autoClose = options.autoClose || true;
this.highWaterMark = options.highWaterMark || 16 * 1024;
// fd 异步的 触发一个open事件当触发open事件后fd肯定就存在了
this.open();
// 写文件的时候 需要的参数有哪些
// 第一次写入是真的往文件里写
this.writing = false; // 默认第一次就不是正在写入
// 缓存我用简单的数组来模拟一下,源码中是采用的链表来实现的
this.cache = [];
// 维护一个变量 表示缓存的长度,数组的遍历,出于对性能的考虑
this.len = 0;
// 是否触发drain事件
this.needDrain = false;
}
}
module.exports = WriteStream;
定义open方法 需要知道往哪个文件中写内容: this.fd
let fs = require('fs');
let EventEmitter = require('events');
class WriteStream extends EventEmitter {
constructor(path, options = {}) {
//见上文
}
destroy() {
if (typeof this.fd != 'number') {
this.emit('close');
} else {
fs.close(this.fd, () => {
this.emit('close');
});
}
}
open() {
fs.open(this.path, this.flags, this.mode, (err, fd) => {
if (err) {
this.emit('error', err);
if (this.autoClose) {
this.destroy(); // 如果自动关闭就销毁文件描述符
}
return;
}
this.fd = fd;
this.emit('open', this.fd);
});
}
}
同样的思路,定义write方法
let fs = require('fs');
let EventEmitter = require('events');
class WriteStream extends EventEmitter {
constructor(path, options = {}) {
//参考上文
}
destroy() {
//参考上文
}
open() {
//参考上文
}
// 因为write方法是同步调用的此时fd还没有获取到,所以等待获取到再执行write操作
_write(chunk, encoding, clearBuffer) {
if (typeof this.fd != 'number') {
return this.once('open', () => this._write(chunk, encoding, clearBuffer));
}
fs.write(this.fd, chunk, 0, chunk.length, this.pos, (err, byteWritten) => {
this.pos += byteWritten;
this.len -= byteWritten; // 每次写入后就要再内存中减少一下
clearBuffer(); // 第一次就写完了
})
}
// 客户调用的是write方法去写入内容
// 要判断 chunk必须是buffer或者字符串 为了统一,如果传递的是字符串也要转成buffer
write(chunk, encoding = this.encoding) {
chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
this.len += chunk.length; // 维护缓存的长度 3
let ret = this.len < this.highWaterMark;
if (!ret) {
this.needDrain = true; // 表示需要触发drain事件
}
if (this.writing) { // 正在写入应该放到内存中
this.cache.push({
chunk,
encoding,
});
} else { // 第一次
this.writing = true;
this._write(chunk, encoding, () => this.clearBuffer()); // 专门实现写的方法
}
return ret; // 能不能继续写了,false表示下次的写的时候就要占用更多内存了
}
}
对drain事件实现
clearBuffer() {
let buffer = this.cache.shift();
if (buffer) { // 缓存里有
this._write(buffer.chunk, buffer.encoding, () => this.clearBuffer());
} else {// 缓存里没有了
if (this.needDrain) { // 需要触发drain事件
this.writing = false; // 告诉下次直接写就可以了 不需要写到内存中了
this.needDrain = false;
this.emit('drain');
}
}
}
是不是很简单?对于**_write**方法中几个重点稍作解。