每天学点node系列-stream

在编写代码时,我们应该有一些方法将程序像连接水管一样连接起来 -- 当我们需要获取一些数据时,可以去通过"拧"其他的部分来达到目的。这也应该是IO应有的方式。 -- Doug McIlroy. October 11, 1964

为什么应该使用stream?

在node中,I/O都是异步的,所以在和硬盘以及网络的交互过程中会涉及到传递回调函数的过程。你之前可能会写出这样的代码:

var http = require('http');
var fs = require('fs');

var server = http.createServer(function (req, res) {
    fs.readFile(__dirname + '/data.txt', function (err, data) {
        res.end(data);
    });});
server.listen(8000);

上面的这段代码并没有什么问题,但是在每次请求时,我们都会把整个data.txt文件读入到内存中,然后再把结果返回给客户端。想想看,如果data.txt文件非常大,在响应大量用户的并发请求时,程序可能会消耗大量的内存,这样很可能会造成用户连接缓慢的问题。其次,上面的代码可能会造成很不好的用户体验,因为用户在接收到任何的内容之前首先需要等待程序将文件内容完全读入到内存中。所幸的是,(req,res) 参数都是流对象,这意味着我们可以使用一种更好的方法来实现上面的需求:

var http = require('http');
var fs = require('fs');

var server = http.createServer(function (req, res) {
    var stream = fs.createReadStream(__dirname + '/data.txt');
    stream.pipe(res);
});
server.listen(8000);

在这里,.pipe()方法会自动帮助我们监听data和end事件。上面的这段代码不仅简洁,而且data.txt文件中每一小段数据都将源源不断的发送到客户端。
除此之外,使用.pipe()方法还有别的好处,比如说它可以自动控制后端压力,以便在客户端连接缓慢的时候node可以将尽可能少的缓存放到内存中。

认识NodeJS中的stream

流(stream)是 Node.js 中处理流式数据的抽象接口。·stream 模块用于构建实现了流接口的对象。

我们用到的很多核心模块都是stream的实例。 例如:http.clientRequest, process.stdout。

流可以是可读的、可写的、或者可读可写的。

所有的流都是 EventEmitter 的实例。

虽然我们平时开发过程中平常不会直接用到stream模块,但是也需要了解其运行机制。

对于想要实现自定义stream实例的开发者来说,就得好好研究stream的扩展API了,比如gulp的内部实现就大量用到了自定义的stream类型。

stream的类型

Node.js 中有四种基本的流类型:

  • Writable - 可写入数据的流(例如 fs.createWriteStream())。
  • Readable - 可读取数据的流(例如 fs.createReadStream())。
  • Duplex - 可读又可写的流(例如 net.Socket)。
  • Transform - 在读写过程中可以修改或转换数据的 Duplex 流(例如 zlib.createDeflate())。

使用Stream可实现数据的流式处理,如:

var fs = require('fs') 
// `fs.createReadStream`创建一个`Readable`对象以读取`bigFile`的内容,并输出到标准输出 
// 如果使用`fs.readFile`则可能由于文件过大而失败 
fs.createReadStream(bigFile).pipe(process.stdout)

Readable

Readable流可以产出数据,你可以将这些数据传送到一个writable,transform或者duplex流中,只需要调用pipe()方法:

创建个readable流

var Readable = require('stream').Readable;

var rs = new Readable;
rs.push('beep ');
rs.push('boop\n');
rs.push(null);

rs.pipe(process.stdout);

下面运行代码

$ node read.js
beep boop

在上面的代码中rs.push(null)的作用是告诉rs输出数据应该结束了。
需要注意的一点是我们在将数据输出到process.stdout之前已经将内容推送进readable流rs中,但是所有的数据依然是可写的。这是因为在你使用.push()将数据推进一个readable流中时,一直要到另一个东西来消耗数据之前,数据都会存在一个缓存中。然而,在更多的情况下,我们想要的是当需要数据时数据才会产生,以此来避免大量的缓存数据。

流式消耗迭代器中的数据

我们可以通过定义一个._read函数来实现按需推送数据:

const Readable = require('stream').Readable
class ToReadable extends Readable {
    constructor(iterator) {
        super()
        this.iterator = iterator
    }
    // 子类需要实现该方法
    // 这是生产数据的逻辑
    _read() {
        const res = this.iterator.next()
        if (res.done) {
            // 数据源已枯竭,调用`push(null)`通知流
            return this.push(null)
        }
        setTimeout(() => {
        // 通过`push`方法将数据添加到流中
            this.push(res.value + '\n')
        }, 0)
    }
}
module.exports = ToReadable

使用时,new ToReadable(iterator)会返回一个可读流,下游可以流式的消耗迭代器中的数据。

const iterator = function (limit) {
    return {
        next: function () {
            if (limit--) {
                return { done: false, value: limit + Math.random() }
            }
            return { done: true }
        }
    }
}(1e10)
const readable = new ToReadable(iterator)
// 监听`data`事件,一次获取一个数据
readable.on('data', data => process.stdout.write(data))
// 所有数据均已读完
readable.on('end', () => process.stdout.write('DONE'))

执行上述代码,将会有100亿个随机数源源不断地写进标准输出流。

创建可读流时,需要继承Readable,并实现_read方法。 * _read方法是从底层系统读取具体数据的逻辑,即生产数据的逻辑。 * 在_read方法中,通过调用push(data)将数据放入可读流中供下游消耗。 * 在_read方法中,可以同步调用push(data),也可以异步调用。 * 当全部数据都生产出来后,必须调用push(null)来结束可读流。 * 流一旦结束,便不能再调用push(data)添加数据。

可以通过监听data事件的方式消耗可读流。 * 在首次监听其data事件后,readable便会持续不断地调用_read(),通过触发data事件将数据输出。 * 第一次data事件会在下一个tick中触发,所以,可以安全地将数据输出前的逻辑放在事件监听后(同一个tick中)。 * 当数据全部被消耗时,会触发end事件。

上面的例子中,process.stdout代表标准输出流,实际是一个可写流。

Writable

一个writable流指的是只能流进不能流出的流:

src.pipe(writableStream)

创建一个writable流

只需要定义一个._write(chunk,enc,next)函数,你就可以将一个readable流的数据释放到其中:

const Writable = require('stream').Writable

const writable = Writable()
// 实现`_write`方法
// 这是将数据写入底层的逻辑
writable._write = function (data, enc, next) {
  // 将流中的数据写入底层
  process.stdout.write(data.toString().toUpperCase())
  // 写入完成时,调用`next()`方法通知流传入下一个数据
  process.nextTick(next)
}

// 所有数据均已写入底层
writable.on('finish', () => process.stdout.write('DONE'))

// 将一个数据写入流中
writable.write('a' + '\n')
writable.write('b' + '\n')
writable.write('c' + '\n')

// 再无数据写入流时,需要调用`end`方法
writable.end()

运行结果如下:

$ node 1.js
A
B
C
DONE
  • 上游通过调用writable.write(data)将数据写入可写流中。write()方法会调用_write()将data写入底层。
  • _write中,当数据成功写入底层后,必须调用next(err)告诉流开始处理下一个数据。
  • 在从一个readable流向一个writable流传数据的过程中,数据会自动被转换为Buffer对象,除非你在创建writable流的时候制定了decodeStrings参数为false:Writable({decodeStrings: false})
  • 如果你需要传递对象,需要指定objectMode参数为trueWritable({ objectMode: true })
  • 在end方法调用后,当所有底层的写操作均完成时,会触发finish事件。
  • 上游必须调用writable.end(data)来结束可写流,data是可选的。此后,不能再调用write新增数据。
  • next的调用既可以是同步的,也可以是异步的.

_write的参数:

  • 第一个参数,chunk表写进来的数据。
  • 第二个参数 enc 代表编码的字符串,但是只有在opts.decodeStringfalse的时候你才可以写一个字符串。
  • 第三个参数,next(err)是一个回调函数,使用这个回调函数你可以告诉数据消耗者可以写更多的数据。你可以有选择性的传递一个错误对象error,这时会在流实体上触发一个emit事件。

向一个writable流中写东西

如果你需要向一个writable流中写东西,只需要调用.write(data)即可。

    process.stdout.write('beep boop\n');

为了告诉一个writable流你已经写完毕了,只需要调用.end()方法。你也可以使用.end(data)在结束前再写一些数据。

var fs = require('fs');
var ws = fs.createWriteStream('message.txt');

ws.write('beep ');

setTimeout(function () {
    ws.end('boop\n');
},1000);

运行结果如下所示:

$ node writing.js 
$ cat message.txt
beep boop

如果你在创建writable流时指定了highWaterMark参数,那么当没有更多数据写入时,调用.write()方法将会返回false。如果你想要等待缓存情况,可以监听drain事件。

Duplex

Duplex流是一个可读也可写的流,就好像一个电话,可以接收也可以发送语音。一个rpc交换是一个duplex流的最好的例子。如果你看到过下面这样的代码:

a.pipe(b).pipe(a)

那么你需要处理的就是一个duplex流对象。

实现一个Duplex

var Duplex = require('stream').Duplex
var duplex = Duplex()
// 可读端底层读取逻辑
duplex._read = function () {
    this._readNum = this._readNum || 0
    if (this._readNum > 1) {
        this.push(null)
    } else {
        this.push('' + (this._readNum++))
    }
}
// 可写端底层写逻辑
duplex._write = function (buf, enc, next) {
    // a, b
    process.stdout.write('_write ' + buf.toString() + '\n')
    next()
}
// 0, 1
duplex.on('data', data => console.log('ondata', data.toString()))
duplex.write('a')
duplex.write('b')
duplex.end()

上面的代码中实现了_read方法,所以可以监听data事件来消耗Duplex产生的数据。 同时,又实现了_write方法,可作为下游去消耗数据。
因为它既可读又可写,所以称它有两端:可写端和可读端。 可写端的接口与Writable一致,作为下游来使用;可读端的接口与Readable一致,作为上游来使用。

Transform

Transform stream是Duplex stream的特例,也就是说,Transform stream也同时可读可写。跟Duplex stream的区别点在于,Transform stream的输出与输入是存在相关性的。

const Transform = require('stream').Transform
class Rotate extends Transform {
    constructor(n) {
        super()
        // 将字母旋转`n`个位置
        this.offset = (n || 13) % 26
    }
    // 将可写端写入的数据变换后添加到可读端
    _transform (buf, enc, next) {
        var res = buf.toString().split('').map(c => {
            var code = c.charCodeAt(0)
            if (c >= 'a' && c <= 'z') {
                code += this.offset
                if (code > 'z'.charCodeAt(0)) {
                    code -= 26
                }
            } else if (c >= 'A' && c <= 'Z') {
                code += this.offset
                if (code > 'Z'.charCodeAt(0)) {
                    code -= 26
                }
            }
            return String.fromCharCode(code)
        }).join('')
        // 调用push方法将变换后的数据添加到可读端
        this.push(res)
        // 调用next方法准备处理下一个
        next()
    }
}
var transform = new Rotate(3)
transform.on('data', data => process.stdout.write(data))
transform.write('hello, ')
transform.write('world!')
transform.end()

执行结果如下:

$ node 1.js
khoor, zruog!

Tranform继承自Duplex,并已经实现了_read和_write方法,同时要求用户实现一个_transform方法。

相关链接

https://nodejs.org/api/stream.html

posted @ 2019-07-05 20:59  一半水一半冰  阅读(648)  评论(0编辑  收藏  举报
Fork me on GitHub