背景
其实在没深入了解stream之前,我只是把stream简单理解为将数据分段传输的api。但后面在接触一些工具,e.g. sofa-rpc-node,发现其实Stream可以通过管道模式实现比较便捷/性能更好的数据处理方式。
为什么Stream这么难理解呢?个人以为主要是以下三点:
1、在创建流的同时,会自动触发数据流转,而不需要手动触发,跟我们平时开发的思维模式不太一样;
2、你可以非常自由便捷的在管道的中间添加处理,但同时也会增加调试难度(流动方向,流动分支);
3、数据的流动一定是从可读流到可写流(input -> output);
本文主要是总结下stream的常用api,跟非流数据对比,使用场景等,希望帮大家提高对stream的认识;
多种流数据
一、可读流Readable;
const {Readable} = require('stream')
let flag = 0
class ToReadable extends Readable {
_read() {
++flag
if(flag < 10) {
this.push(JSON.stringify(flag + Math.random()))
} else {
this.push(null)
}
}
}
let rs = new ToReadable()
rs.on('data', (data) => {
console.log(data.toString())
})
rs.on('end', () => {
console.log('done')
})
// turn's out
// 1.2162690954254562
// 2.8249601828140705
// 3.846741942997312
// 4.328607484660268
// 5.89713387631011
// 6.0026266207450325
// 7.975351346988425
// 8.084137311479894
// 9.845946694122443
// done
二、可写流Writable;
const { Writable } = require('stream')
const { appendFile } = require('fs')
const wa = new Writable()
let flag = 10
wa._write = (data, enc, next) => {
appendFile('./sample.txt', data, () => {
next()
})
}
for(let i = 0; i < flag; i += 1) {
wa.write((i + Math.random()).toString() + '\n')
}
// result
0.9969171047545802
1.4246013281755485
2.0487701445729503
3.9630777343462302
4.240175220904482
5.3005839022865215
6.7384640930773525
7.132276155120592
8.437246793517344
9.249792390390194
三、可写/可读流Duplex, Duplex对象根据需要既可以当做可读流,也可以当做可写流处理。
四、转换流Transform;
// 将可写流数据经过转换后添加到可读流,功能是将小写转换为大写
const { Transform } = require('stream')
class ToTransform extends Transform {
_transform(buf, encoding, next) {
let _result = buf.toString().toUpperCase()
this.push(_result)
next()
}
}
const tf = new ToTransform()
tf.on('data', (data) => {
console.log(`可读流接受到数据: ${data}`)
})
tf.write('hello, ')
tf.write('world!')
tf.end()
// result
// 可读流接受到数据: HELLO,
// 可读流接受到数据: WORLD!
不管是可读流,可写流还是在这之上拓展的Duplex/TransForm,在我们使用Stream之前,我们需要封装好对应的read, write, tranform等方法提供给调用方。例如'fs'模块封装的createReadStream, createWriteStream等。
pipe
pipe是stream中重要的方法,是数据流动的关键。但在使用pipe时,有几个需要注意的问题:
一、只有可读流才有pipe方法(包括继承继承可读流pipe方法的类型Duplex和Tranform), 可写流在继承Stream类后对pipe方法做了处理;
// Otherwise people can pipe Writable streams, which is just wrong.
Writable.prototype.pipe = function() {
errorOrDestroy(this, new ERR_STREAM_CANNOT_PIPE());
};
二、流的处理过程是异步的,在实际应用中,node作为服务端处理数据时候需要保证数据独立性;e.g.
// server side
const net = require('net')
const {Readable} = require('stream')
const _data = ['vb', 'fool', 'fish']
const readStream = new Readable({
read() {
setTimeout(() => {
this.push(_data.shift())
}, 1000)
}
})
net.createServer(server => {
readStream.pipe(server)
}).listen(3003)
// client side
// 第一次请求结果
recieve data: vb
recieve data: fool
recieve data: fish
// 第二次请求
第二次请求没有结果,因为数据已被第一个请求消耗了。
三、异常处理。在数据流动过程中如果出现问题,可读流的背压不会销毁会导致内存泄漏。Stream的异常处理可以通过pipeline进行处理。
pipeline提供了Stream的异常处理方案,当出现异常时,会emit('end')事件给Stream,结束数据流转。避免背压数据导致内存溢出。
背压问题
一般两种情况下会导致背压问题:
1、数据流转中读数据会比写数据效率更高(e.g. 一般情况下磁盘读性能比磁盘写性能高),所以在数据流转中会出现部分流积压在前面的情况;
2、在数据流转中有手动限制流大小,异步处理流数据等导致流的积压;
出现背压问题会导致内存消耗增大,如果是服务端用户数量大的话,很容易出现内存溢出问题。可以通过以下例子看下(一个1.3g的大文件拷贝):
const fs = require('fs')
const {Transform, Readable, Duplex, pipeline} = require('stream')
const readStream = fs.createReadStream('./input.zip', {
highWaterMark: 1024 * 1024 * 100
})
const writeStream = fs.createWriteStream('./test.zip')
const middleStream = new Transform({
transform(buf, encoding, next) {
setTimeout(() => {
this.push(buf)
next()
}, 20000)
}
})
middleStream.read(1)
pipeline(readStream, middleStream, writeStream, () => {
console.log('done')
})
延迟了middleStream的流动时间和流动数据大小(白话一点就是在中间的水管加了障碍物和缩小水管大小),导致内存飙升:
PID COMMAND %CPU TIME #TH #WQ #POR MEM PURG CMPR PGRP PPID
43787 node 0.0 00:01.07 11 0 25 405M+ 0B 0B 43787 26180
这只是一个文件处理,如果很多用户的话内存马上就爆了,这种情况一般两种处理方式:
1、检查限制和异步处理是否合适,进行调整;
2、限制流入的数据大小和间隔(以时间换空间);
参考文章
1、Node.js Streams: Everything you need to know: www.freecodecamp.org/news/node-j…
2、Node.js Stream - 实战篇: tech.meituan.com/2016/07/22/…
3、数据流中的积压问题: nodejs.org/zh-cn/docs/…
4、cloud.tencent.com/developer/a…