在 node 中流是最核心的一部分, 也是相对较难的一部分, node 中大量的 api 都与 流相关,通过一些学习, 对流的概念稍稍的了解了一点, 本文将梳理一下自己的理解,不对之处还望指正。
stream
流是一组有序的,有起点和终点的字节数据传输手段,
它不关心文件的整体内容,只关注是否从文件中读到了数据,以及读到数据之后的处理。
node 中的很多对象都实现了流。 比如 HTTP 服务器中的 request 和 response 对象都是流, node 的 api
process.stdin process.stdout 等
node 中 有四种基本的流类型
- Readable 可读流,用来读取数据
- Writeable 可写流, 用来写入数据
- Duplex 可读可写的流(双工流)
- Transform 在读写过程中可以修改和变换数据的 Duplex 流
除了以上四种基本的流类型之外, 在 node 环境中我们还可以自定义流
本文将阐述 可读流 与 可写流 的一些基础概念:
可读流 (createReadStream)
创建一个可读流
fs.createReadStream(path, [options]); path: 读取文件的路径 options: flags 打开文件要做的操作, 默认为'r' encoding 默认为 null start 开始读取的索引位置 end 结束读取的索引位置(包括结束位置) highWaterMark 读取缓存区默认的大小64kb
可读流分为两种模式 flowing 和 paused
- 在 flowing 模式下, 可读流自动从系统底层读取数据,并通过 EventEmitter 接口的事件尽快将数据提供给到应用
- 在 paused 模式下, 必须显示的调用 stream.read() 方法来从流中读取数据片段
所有初始工作模式为 paused 的 可读流,可以通过下面三种途径切换到 flowing 模式:
- 监听 ‘data’ 事件
- 调用 stream.resume();
- 调用 stream.pipe() 方法将数据发送到 Writeable 中
可读流可以通过下面两种途径切换到 paused 模式
- 如果不存在管道目标 ,可以通过调用 stream.pause() 方法实现。
- 如果存在管道目标,可以通过取消 ‘data’ 事件监听,并调用 stream.unpipe() 方法移除所有管道目标来实现。
可读流的事件: ‘open’, ‘data’, ‘close’, ‘error’, ‘end’, ‘readable’
代码体现let fs = require('fs'); let path = require('path'); let fs = require('fs'); let path = require('path'); let rs = fs.createReadStream(path.join(__dirname, '1.txt'), { flags: 'r', encoding: 'utf-8', // 设置读取到数据的编码格式, 默认 null 为 buffer highWaterMark: 3, // 最高水位线 默认是 64 * 1024b start: 0, // end: 2 }); rs.on('open', function (fd) { // fd 文件描述符 console.log('文件打开了', fd); } ); // rs 默认情况下为 paused 模式 , 通过调用 data 事件变为 flowing 模式, 会疯狂的出发 data 事件读取数据, 直到读取完毕 // 流动模式会疯狂的触发 data 事件 , 知道读取完毕 rs.on('data', function (data) { console.log('data', data); // 调用 rs.pause 表示暂停读取,暂停 data 事件的触发 rs.pause(); }); setInterval(() => { rs.resume(); }, 1000); rs.on('end', function () { console.log('===end==='); } ); rs.on('close', function () { console.log('===文件关闭==='); }); rs.on('error', function (err) { console.log(err); }); // or fs.createReadStream(path.join(__dirname, '1.txt')).pipe(process.stdout);
可读流中还有一个 readable 的方法
- readable 方法特点
- 当我们创建了一个流时, 会先读取 highWaterMark 的数据到当前流的缓存区中, 等待着你去消费
- 如果缓存区中的数据被清空, 会再次触发 readable 事件
- readable 事件每次触发时, 都会对比一下你消费后剩下的数据是否小于 highWaterMark,小于时 readable 事件中会再次添加 highWaterMark 这么多的数据到 缓存区中, 等待这你去消费
- readable 方法特点
let fs = require('fs'); let path = require('path'); let rs = fs.createReadStream(path.join(__dirname, '1.txt'), { flags: 'r', encoding: 'utf-8', // 设置读取到数据的编码格式, 默认 null 为 buffer highWaterMark: 3, // 最高水位线 默认是 64 * 1024b start: 0, }); rs.on('readable', function () { // rs.read 中不给参数的话, 会默认读取 highWaterMark 个数据 // 如果读取的数据超过缓存区的数据时,会更改缓存区的大小再去读取 let result = rs.read(5); console.log(result); });
Readable 和 Writable 流都会将数据存储到内部的缓冲区中
缓存区的大小取决于传递给流构造函数的 highWaterMark 选项。 对于普通的流, highWaterMark 选项指定了总共的字节数。 对于工作在对象模式的流, highWaterMark 指定了对象的总数
当可读流的实例调用 stream.push(chunk) 方法时,数据被放到缓存区中. 如果流的消费者没有调用 stream.read() 方法, 这些数据会始终存在于内部队列中, 直到被消费。
当内部缓存区的大小达到 highWaterMark 制定的阈值时, 流会暂停从底层读取资源数据, 直到当前缓存区的数据被消费(也就是说 流会在内部停止调用 readable._read() 来填充可读缓存区)。可写流 (createWriteStream)
创建一个可写流, 可写流写入的数据必须是 字符串 或者 bufferlet ws = fs.createWriteStream(path, [options]); path: 写入的文件的路径, 如果文件不存在, 会自动创建一个文件 options: { flags: 'w', // 打开文件要做的操作, 默认为 'w' mode: 0o666, // 设置文件模式,默认 0o666 autoClose: true, // 是否自动关闭 highWaterMark: 3, // 写入缓存区的大小 默认 16kb encoding: 'utf-8', // 写入的编码格式 start: 0 // 开始写入文件的位置 }
可写流通过反复调用 writeStream.write(chunk) 方法将数据放到缓存区中, 当内部可写缓存区的总大小 小于 highWaterMark 制定的阈值时, 调用 writeStream.write(chunk) 将返回 true。 一旦内部缓存区的大小达到或超过 highWaterMark, 调用 writeStream.write(chunk) 将返回 false。
let fs = require('fs'); let path = require('path'); let i = 9; let ws = fs.createWriteStream(path.join(__dirname, '1.txt'), { flags: 'w', mode: 0o666, // 设置文件模式,默认 0o666 autoClose: true, highWaterMark: 3, encoding: 'utf-8', start: 0 }); function write () { let flag = true; while ( i > 0 && flag ) { // 返回的标识符 表示的并不是是否写入,而是能否继续写,返回 false 时数据也不会丢失,会把内容放到缓存区中, 当 flag 变为 true 时, 会先将缓存区的内容写入到文件内容中去, 当缓存区的内容消费完时会出发 drain 事件 flag = ws.write(--i+'','utf8',() => { console.log('ok') }); console.log(flag); } } write(); // 抽干, 当缓存区满了之后又被清空后才会触发 drain 事件 //当一个流不处在 drain 的状态, 对 write() 的调用会缓存数据块, 并且返回 false。 一旦当前所有缓存的数据块都排空了, 那么 'drain' 事件就会被触发建议, 一旦 write() 返回 false, 在 'drain' 事件触发前, 不能写入任何数据块 ws.on('drain', function () { console.log('抽干'); write(); }); // ws.end('文件关闭'); // 当调用 end 方法后 , 文件写入结束,就不能在继续调 write 方法, 调用 end 方法后将不会触发 drain 事件
stream pipe
// readStream.pipe(writeStream); let from = fs.createWriteStream(path.join(__dirname,'1.txt')) let to = fs.createWriteStream(path.join(__dirname,'2.txt')); from.pipe(to);
pipe 方法的原理
let fs = require('fs'); let path = require('path'); let ws = fs.createWriteStream(path.join(__dirname,'2.txt')); let rs = fs.createReadStream(path.join(__dirname,'1.txt')); rs.on('data', function (data) { var flag = ws.write(data); if(!flag) rs.pause(); }); ws.on('drain', function () { rs.resume(); }); rs.on('end', function () { ws.end(); });
stream API 的关键目标, 尤其对于 stream.pipe() 方法, 就是限制缓存区的数据大小, 以达到可接受的程度。 这样, 对于读写速度不匹配的源头和目标, 就不会超出可用的内存大小。
在网上看到一张图描述的挺到位的(盗图一张),借鉴来总结一下:
当读取一个文件写入到另一个文件时的过程:
首先会创建一个可读流, 我们有两种选择,
1. 当监听 readable 事件时, 此时 readable._readableState.flowing 属性默认为 null, 当监听 readable 事件时,会自动读区 highWaterMark 这么多的数据到缓存区中等待被消费,这时流属于暂停模式, 当 手动调用
流的 read 方法后,流转变为流动模式,会读取缓存区的内容消费, 当缓存区的内容读取 highWaterMark 这么多数据之后(缓存区被清空后),会再次触发 readable 事件, 直到源文件中所有的数据被读区完毕
2. 当监听 data 事件时,此时 readable._readableState.flowing 属性为 true, 可读流会直接读取 highWaterMark 这么多的数据在 data 的回调函数中供消费,流的模式转变为为 flowing ,这时会不停的触发的 data 事件,直到源文件中的数据全部读取完毕, 这个过程中可以调用 stream.pause 暂停流的读取。
以上两种读区模式的区别, readable 可以对流读取的数据进行精准的控制, 而当我们不需要精确的操作流时,可以选择 data 的流动方式, 效率更高 。
上面的两种方式不管以哪种读取的数据,我们都可以创建一个可写流对读取到的数据进行写入的消费,
调用 write 方法会像文件中写入数据, 但是因为写入的速度较慢, 如果当前正在写入, 而同时又调用了 write 方法, node 会将你写入的内容暂存到缓存区中, 等到文件写入完毕会从缓存区中取出数据, 继续写入。
write 方法执行会返回一个布尔类型的值, 表示目前是否还可以继续调用 write 方法写入内容(到内存中), 当缓存区的大小大于 highWaterMark 时,这是会返回 false , 应当停止数据的写入,以避免消耗过多内存。 当缓存区满后,文件写入一直在进行,一会儿就把缓存区的内容全部写入,缓存区处于清空状态, 这时会触发 drain 事件, 我们可以继续写入文件
注意:如果缓存区从未满过,drain 事件永远也不会触发。
可读流/可写流 剖析代码 点我