Stream流

1,233 阅读4分钟

一、三个例子了解Stream流

流的好处就是每次可以只传一小段数据(chunk,传的是二进制,而不是字符串),这样就可以使服务器内存占用变得非常低(一点一点的读和写)

第一个Stream例子

cosnt fs = require('fs')
const stream = fs.createWriteStream('./big_file.txt')
for(let i=0; i<100000;i++){
  stream.write(`这是第${i}行内容,我们需要很多狠毒内容,要不停的写文件啊啊啊\n`)
}
stream.end()  // 别忘了关掉 stream
console.log('done')

image.png

使用ls -lh 可以查看当前目录下文件的大小

分析

  • 打开流,多次往里面塞内容(每次塞是不会覆盖之前的东西的,也就是说是分开的),关闭流
  • 看起开就是可以多次写,没什么大不了
  • 最终我们得到一个文件

Sream流

image.png

  • stream是水流,但默认没有水 (图中的虚线部分)
  • stream.write 可以让水流中有水(数据)
  • 每次写的小数据叫做 chunk (块)
  • 产生数据的一段叫做 source (源头)
  • 得到数据的一段叫做 sink (水池)

备注

chunk一般表示不完整的数据;data表示完整的数据

第二个例子

如果用readFile读一个很大的文件,将消耗很大的服务器内存

// 先引入fs 和 http
const server = http.createServer()
server.on('request',(request,response)=> {
 fs.readFile('./big_file.txt',(error,data)=>{
   if(error) throw error
   response.end(data)
   console.log('done')
 })
})

server.listen(8888)

分析

  • 用任务管理器看看 Node.js 内存占用35M

Snipaste_2023-07-05_16-35-44.png

Snipaste_2023-07-05_16-36-03.png

一个用户请求过来就用了35M内存,如果10个、100个用户请求过来呢?服务器有多少内存够用??

我们需要借助Stream来解决这个问题

第三个例子

如果使用Stream的方式改写将会消耗更小的内存

用Stream改写第二个例子

const fs = require('fs')
const http = require('http')

const server = http.createServer();
server.on('request',(request,response)=>{
    const stream = fs.createReadStream('./big_file.txt')
    stream.pipe(response)
    stream.on('end',()=> console.log('done'))
})

server.listen(8888)

Snipaste_2023-07-05_16-47-06.png

可以看出使用stream流的方式传数据,时间会稍微长一点(因为是一点一点的传),但是占用的内存明显小了很多

分析

  • 查看Node.js内存占用,基本不会高于15M
  • 文件 streamresponse stream 通过管道相连,也就是说response也是一个流

备注

  • 流(stream) 通过管道(pipe) 流向 response

管道

管道可以把2个以上流通过管道连起来,连起来之后就可以实现数据在不同地方的转化,比如:从文件转换为网络

管道就是监听一个流,把它的数据塞给另外一个流

image.png

备注

  • stream1 是文件流,读bigfile文件
  • stream2 是http流,即response
  • 它们之间本来没有关系,但是如果用管子(pipe)把它们接起来,那么他们就有关系了。stream1中所有的数据都会自动的流向stream2,即读一段bigfile文件的内容,就会传给http,http就会传给用户...

释义

两个流可以用一个管道相连

stream1 的末尾连接上 stream2 的开端

只要stream1 有数据,就会流到stream2

常用代码

stream1.pipe(stream2)

备注

  • stream1是一个会发出数据的stream
  • stream2是一个需要接收数据的stream
  • 即一个可读的流通过一个管道(pipe)连接一个可写的流

链式操作

a.pipe(b).pipe(c)
// 等价于
a.pipe(b)
b.pipe(c)

管道续

管道可以通过事件实现

// stream1 一有数据就塞给 stream2
stream1.on('data',(chunk)=>{
    const flag = stream2.write(chunk)
    if(flag === false){ // 不写文件了 }
    stream2.on('drain',()=>{
      // 继续写文件
    })
})
// stream1 停了,就停掉 stream2
stream1.on('end',()=>{
    stream2.end()
})

一般不这么写,写这么多代码,其实就是一个pipe搞定

如果flag为true表示『没堵车』;如果flag为false表示『堵车了』

image.png

堵车的意思就是图中stream1流的速度太快了,stream2的管道宽度有限,就“堵车了”

如果“堵车了” stream1就不再写文件了

那不能“堵车了”就一直不走了或者说一直不写文件了吧

什么时候开始继续写文件呢?

drain的意思是流干了意思是当stream不“堵车了”,stream1就可以继续写文件了

drain的事件就是发现,之前“湍急的河流”现在已经“干涸了”

二、Stream 对象的原型、事件

尝试打印这个对象

新建4.js

const fs  = require('fs')

const s = fs.createReadStream('./big_file.txt')

console.log(s);

如果运行node 4.js 会发现:打印出来的对象只有一层

如果我们想详细的了解这个对象的结构就需要运行:

node --inspect-brk 4.js,意思就是打断点

然后随便打开一个网页,打开控制台,过一会就会出现Node的调试工具

image.png

接着,打断点,执行

Snipaste_2023-07-06_10-54-36.png

在控制台中打印这个对象

Snipaste_2023-07-06_10-56-55.png

image.png

image.png

image.png

Stream 对象的原型链

s = fs.createReadStream(path)

  • 从上图可以看出
  • 它的对象层级为
  • 自身属性(由fs.ReadStream这一构造函数构造出来的)
  • 原型: stream.Readable.prototype
  • 二级原型: stream.Stream.prototype
  • 三级原型: events.EventEmitter.prototype,所有的Stream对象都继承了EventEmitter,它拥有pipe这一API
  • 四级原型:Object.prototype

Stream 对象都继承了 EventEmitter

备注:

除了可以在浏览器中访问一个地址外,也可以通过命令curl访问一个地址

image.png

支持的事件和方法

image.png

const fs = require('fs')
const http = require('http')

const server = http.createServer();
server.on('request',(request,response)=>{
    const stream = fs.createReadStream('./big_file.txt')
    stream.pipe(response)
    stream.on('data',(chunk)=>{
        console.log('读取了一次数据')
        console.log(chunk.toString())
    })
    stream.on('end',()=> console.log('全部读完了'))
})

server.listen(8888)

备注:

  • 比较重要的事件就是上面红色标注的事件
  • drain的意思是这一次写完了
  • finish的意思是整个全部写完了

三、Stream 的分类共4种

image.png

image.png

备注

  • 可读的文件说明它会产生数据,因此我们可以读它
  • 可写的文件说明它可以处理数据,所以我们可以往它里面写东西
  • 可读或可写相当于单向的马路,duplextransform是双向的马路
  • duplex中你不会读到你写的内容,不会产生交叉
  • transformduplex 则不同,transform相当于一个处理器 自己读自己写的内容,中间有一层转换器,比如:webpack中的bable就相当于一个transform,它可以将ES6转成浏览器能识别的JS,我们在写入ES6的时候,我们在另外一边可以读到ES5,中间的问号“?”,就是把ES6转为ES5,因此这种流是非常有用的,甚至是比duplex还有用,因为它可以把我们流的数据,变成另外一种形式的数据,如:把scss变成css、把TS变成JS。也好比是一个黑色的车进去了,经过清洁喷漆处理后,就变成了白色的车(上图所示)

四、Readable 和 Writable 的特点

Readable Stream

静止态 paused 和流动态flowing

  • 默认处于paused 态
  • 添加data事件监听,它就变成 flowing 态,流动flowing态发出数据的间隔不是人为控制的,而是它自己控制的
  • 删掉data事件监听,它就变成paused态
  • pause()可以将它变为 paused
  • resume() 可以将它变成 flowing

备注

可读的流可以认为是一个『内容生产者』,比如我们读书,书就相当于内容生产者,因为书中有内容我们才去读。那么可读的Stream也是,因为它里面有内容,我们采取读它

它里面的内容会一点一点的发出来,但是有可能它就不发了,一旦不发了,它就处于静止态paused

如果它又恢复了,发内容的状态,它就变成了流动态flowing

就像下面的代码,如果不写stream.pipe(response),那么就意味着不使用管道pipestream流向response

那么此时它有没有读呢?

如果有,那么就存在一个问题了,如果我们还没做任何事情它就开始读了,那么它读的文件流向何处?没人监听它,是不是就意味着自然的消失了

const fs = require('fs')
const http = require('http')

const server = http.createServer();
server.on('request',(request,response)=>{
    const stream = fs.createReadStream('./big_file.txt')
    // stream.pipe(response)
})

server.listen(8888)

那么什么时候开始读呢?

当我们添加data事件,它就变成flowing

pipe就是监听第一个流的data事件,然后把数据写入到第二个流中,也就是说虽然写的是pipe但是实际上写的是监听data

一旦流意识到“有人监听我了”,那么它就开始往外发内容了,你就可以读数据了

除了通过添加或删除data事件监听,让它变成flowingpaused态来控制它往外发内容

我们还可以主动的调用pause()resume()方法来控制它往外发内容

当然,对于可写的流就没有这两种状态,你自己不写不就可以了?

Writable Stream

drain 『流干了』事件

  • 表示可以加点水了
  • 我们调用 stream.write(chunk)的时候,可能会得到false
  • false的意思是你写的太快了,数据积压了
  • 这个时候我们就不能再write了,要监听drain
  • drain事件触发了,我们才能继续write
  • 看文档中的代码就懂了
const fs = require('fs')

function writeOneMillionTimes(writer, data) {
    let i = 1000000;
    write();
    function write() {
        let ok = true;
        do {
            i--;
            if (i === 0) {
                // Last time!
                writer.write(data);
            } else {
                // See if we should continue, or wait.
                // Don't pass the callback, because we're not done yet.
                ok = writer.write(data);
                if(ok === false){
                    console.log('写的太快了,不能再写了')
                }
            }
        } while (i > 0 && ok);
        if (i > 0) {
            // Had to stop early!
            // Write some more once it drains.
            writer.once('drain', ()=>{
                console.log('干涸了')
                write()
            });
        }
    }
}
const writer = fs.createWriteStream('./big_file.txt')
writeOneMillionTimes(writer,'hello world')

image.png

image.png

如果写的太快了,就相当于stram1中的车流流的太快了,stream2管道宽度有限,快要“堵车了”,ok为false的时候,此时就不能再写了

这里的ok就相当于下面的flag

const flag = stream2.write(chunk):往stream2里写数据

stream2中的车流越来越少,接近“干涸的”时候,通过监听drain事件,stram1就可以继续写了

finish 事件

  • 调用stream.end()之后,而且
  • 缓冲区数据都已经传给底层系统之后
  • 触发finish事件

备注

什么是『缓冲区数据』?

当我们往文件里写东西的时候,并不是将文件直接写在硬盘上,而是有一个缓冲,到了一定程度之后才会写到硬盘

之前讲的内容都是在使用Stream

比如:创建一个可读的stream,是通过文件系统创建的const stream = fs.createReadStream('./big_file.txt'),那是文件系统的Stream

比如: 创建一个可写的stream,是通过文件系统创建的const stream = fs.createWriteStream('./big_file.txt');

我们用的Stream都是别人提供的,

如何创建自己的流,给别人用呢?

比如: 创建一个可读的流,让别人来读我;创建一个可写的流,让别人来写我?

五、创建自己的流

创建一个 Writable Stream

让别人来给我们提供数据,我们只需要处理数据

const {Writable} = require('stream')

const outStream = new Writable({
    write(chunk, encoding, callback) {
        console.log(chunk.toString())
        callback()
    }
})

process.stdin.pipe(outStream)

// 如果不理解pipe可以写成监听data

process.stdin.on('data',(chunk)=>{
   outStream.write(chunk)
})

你输入什么,就将什么输出出来

image.png

备注

  • callback()必须调用,只有调用了才会进入下一次,否则就一直在那卡住
  • process.stdin就是一个用于输入的Stream,通过管道pipe,将数据流向outStream

创建一个 Readable Stream

我们向别人提供数据

const { Readable } = require('stream')

const inStream = new Readable()

inStream.push('ABCDEFGHIJKLM')
inStream.push('NOPQRSTUVWXYZ')
inStream.push(null)      // no more data

//inStream.pipe(process.stdout)
inStream.on('data',(chunk)=>{
    process.stdout.write(chunk)
    console.log('写了一次数据')
})

保存为文件为readable.js 然后用node运行

我们先把所有数据都push进去了,然后pipe(或者监听data)

备注

  • inStream.push(null)的意思就是已经把所有的数据提供完了
  • 当我们提供数据的时候,默认数据是没有流出去的,默认可读的流是pause暂停状态,它不会把数据发出去,当监听它的data事件或者使用pipe的时候,才把数据流出去
  • inStream.pipe(process.stdout)的意思就是把数据通过管道流向用户的输出
  • 由于推数据的时候是分2次推的,因此读的时候也是分2次读
  • 另外值的注意的是,数据是在我们推完之后,再去读的,不是一边推一边读

image.png

上面的例子是把所有数据都push进去了,然后再监听data事件,这样不太好

我们尝试把它改为:默认不push数据,当你找我要数据的时候,我再推送数据,而不是像之前的那样先把数据都push完了,你再找我要数据

const { Readable } = require('stream')

// 输出 A-Z
const inStream = new Readable({
    read(size){
        const char = String.fromCharCode(this.currentCharCode++)
        this.push(char)
        console.log(`推了${char}`)
        if(this.currentCharCode > 90){  // "Z"
            this.push(null)   // 不再推了
        }
    }
})

inStream.currentCharCode = 65  // “A”

inStream.pipe(process.stdout)

保存文件为readable2.js 然后用node 运行

这次的数据是按需供给的,对方调用read 我们才会给一次数据

image.png

如果想要做一个可读的流,最好等别人调用你的read再去push

而不是先push完了,再等别人去调

两个方法都没错,只是更加推荐第二种方法

创建一个双向流 - Duplex Stream

其实只要同时实现write() 方法和 read() 方法就可以了

const { Duplex } = require('stream')

const inoutStream = new Duplex({
    write(chunk, encoding, callback) {
        console.log(chunk.toString())
        callback()
    },
    read(size){
        const char = String.fromCharCode(this.currentCharCode++)
        this.push(char)
        if(this.currentCharCode > 90){  // "Z"
            this.push(null)   // 数据推送结束
        }
    }
})

inoutStream.currentCharCode = 65 // 'A'

process.stdin.pipe(inoutStream).pipe(process.stdout)

image.png

创建一个Transform 流

这个要实现transform方法

const { Transform } = require('stream')

const upperCaseTransform = new Transform({
    transform(chunk, encoding, callback) {
        this.push(chunk.toString().toUpperCase())
        callback()
    }
})

process.stdin.pipe(upperCaseTransform).pipe(process.stdout)

我们从用户的标准输入里监听它的data事件(pipe本质就是监听data事件)

只要用户输入任意字符,就会调用我们的transform流,这个transform流就会把这个字符变成大写

变成大写之后又会将结果输出给用户的标准输出

这样,用户就会看到自己输入的字变成大写了

image.png

备注

transform就是通过chunk.toString()读到数据

然后将数据变化形式,通过.toUpperCase()将数据大写

再通过push方法将数据写进去

就像bable一样

bable读我们写的ES6代码

将ES6代码变成ES5

最后再写给你

六、Transform 流举例

内置的 Transform Stream

const fs = require('fs')
const zlib = require('zlib')
const file = process.argv[2]

fs.createReadStream(file)
    .pipe(zlib.createGzip())
    .pipe(fs.createWriteStream(file + '.gz'))

image.png

将原本10M的文件压缩到了21kb

备注

  • file就是用户传的路径
  • 它要做的事是读这个可能很大的文件,那就一点一点的读,每读一点,就传给Gzip压缩,数据经过Gzip流压缩之后又传给一个可写的流,这个可写的流会把数据保存在以.gz结尾的文件中

续1

如果文件很大,希望有个进度条,用点表示

如果压缩完成,希望打印“压缩完成”

const fs = require('fs')
const zlib = require('zlib')
const file = process.argv[2]

fs.createReadStream(file)
    .pipe(zlib.createGzip())
    .on('data',()=>process.stdout.write("."))
    .pipe(fs.createWriteStream(file + '.gz'))
    .on('finish',()=>{console.log('压缩完成')})

image.png

image.png

打印出了5个点,说明传了5次数据给Gzip

备注

这句话的意思就是,当你在Gzip的过程中,就监听你的数据的情况,只要你的Gzip出一点数据,就打印出一个点

如果压缩完成,如何打印压缩完成这几个字呢?监听它的finish事件

续2

用变换流对数据进行处理,每次收到数据就打印进度“.”

const fs = require('fs')
const zlib = require('zlib')
const file = process.argv[2]

const { Transform } = require('stream')

const reportProgress = new Transform({
    // 每次收到数据打一个点
    transform(chunk, encoding, callback) {
        process.stdout.write(".")
        callback(null,chunk)  // 等价于 this.push(chunk)
    }
})

fs.createReadStream(file)
    .pipe(zlib.createGzip())
    .pipe(reportProgress)
    .pipe(fs.createWriteStream(file + '.gz'))
    .on('finish',()=>{console.log('压缩完成')})

image.png

效果不是一样吗,这样写有什么用呢?

很有用!!!这样我们就可以对数据进行无限的处理,如果想把数据变成大写的,就变成大写的;如果想给数据加分号,就加分号,这就已经很像webpack中的loader

比如在webpakck中

使用vue-loader加载vue的文件 => sass-loader 处理 => css-loader处理 => postcss-loader => style-loader

其实它们的原理就是变换流 transform

给我一个数据,可以将这个数据变换一下传给后面,后面可以读也可以写

续3

对数据进行压缩和加密

使用Node.js自带的crypto加密模块

const fs = require('fs')
const zlib = require('zlib')
const file = process.argv[2]
const crypto = require('crypto')

const { Transform } = require('stream')

const reportProgress = new Transform({
    // 每次收到数据打一个点
    transform(chunk, encoding, callback) {
        process.stdout.write(".")
        callback(null,chunk)  // 等价于 this.push(chunk)
    }
})

fs.createReadStream(file)
    .pipe(crypto.createCipher('aes192','123456'))
    .pipe(zlib.createGzip())
    .pipe(reportProgress)
    .pipe(fs.createWriteStream(file + '.gz'))
    .on('finish',()=>console.log('压缩完成'))

打开加密后的文件里面就是乱码

image.png

知道了密码,也可以对文件进行解密

备注

  • 打开a.txt文件并展示前100行,可以用head -n 100 a.txt
  • 因为Gzip只能解密它自己压缩的文件,因此我们要先加密最后才压缩

Stream的用途非常广

Node.js中的Stream

image.png

数据流中的挤压问题

Node.js官方文章

背压 back pressure

就是之前说的,如果我们往一个流里面写数据写的太多了,就会堵住

如果堵住了,怎么处理这个积压,变得流畅起来

这篇文章就介绍了

推荐阅读

文章链接

Node.js Stream 文档

中文文档

英文文档

文档里有想要的所有的细节