Node.js Stream - 基础篇

以你之姓@ 2023-10-17 20:04 64阅读 0赞

背景

在构建较复杂的系统时,通常将其拆解为功能独立的若干部分。这些部分的接口遵循一定的规范,通过某种方式相连,以共同完成较复杂的任务。譬如,shell通过管道|连接各部分,其输入输出的规范是文本流。

在Node.js中,内置的Stream模块也实现了类似功能,各部分通过.pipe()连接。

鉴于目前国内系统性介绍Stream的文章较少,而越来越多的开源工具都使用了Stream,本系列文章将从以下几方面来介绍相关内容:

  1. 流的基本类型,以及Stream模块的基本使用方法
  2. 流式处理与back pressure的工作原理
  3. 如何开发流式程序,包括对Gulp与Browserify的剖析,以及一个实战示例。

本文为系列文章的第一篇。

流的四种类型

Stream提供了以下四种类型的流:

  1. var Stream = require('stream')
  2. var Readable = Stream.Readable
  3. var Writable = Stream.Writable
  4. var Duplex = Stream.Duplex
  5. var Transform = Stream.Transform

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

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

Readable

创建可读流。

实例:流式消耗迭代器中的数据。

  1. 'use strict'
  2. const Readable = require('stream').Readable
  3. class ToReadable extends Readable {
  4. constructor(iterator) {
  5. super()
  6. this.iterator = iterator
  7. }
  8. // 子类需要实现该方法
  9. // 这是生产数据的逻辑
  10. _read() {
  11. const res = this.iterator.next()
  12. if (res.done) {
  13. // 数据源已枯竭,调用`push(null)`通知流
  14. return this.push(null)
  15. }
  16. setTimeout(() => {
  17. // 通过`push`方法将数据添加到流中
  18. this.push(res.value + '\n')
  19. }, 0)
  20. }
  21. }
  22. module.exports = ToReadable

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

  1. const iterator = function (limit) {
  2. return {
  3. next: function () {
  4. if (limit--) {
  5. return { done: false, value: limit + Math.random() }
  6. }
  7. return { done: true }
  8. }
  9. }
  10. }(1e10)
  11. const readable = new ToReadable(iterator)
  12. // 监听`data`事件,一次获取一个数据
  13. readable.on('data', data => process.stdout.write(data))
  14. // 所有数据均已读完
  15. 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

创建可写流。

前面通过继承的方式去创建一类可读流,这种方法也适用于创建一类可写流,只是需要实现的是_write(data, enc, next)方法,而不是_read()方法。

有些简单的情况下不需要创建一类流,而只是一个流对象,可以用如下方式去做:

  1. const Writable = require('stream').Writable
  2. const writable = Writable()
  3. // 实现`_write`方法
  4. // 这是将数据写入底层的逻辑
  5. writable._write = function (data, enc, next) {
  6. // 将流中的数据写入底层
  7. process.stdout.write(data.toString().toUpperCase())
  8. // 写入完成时,调用`next()`方法通知流传入下一个数据
  9. process.nextTick(next)
  10. }
  11. // 所有数据均已写入底层
  12. writable.on('finish', () => process.stdout.write('DONE'))
  13. // 将一个数据写入流中
  14. writable.write('a' + '\n')
  15. writable.write('b' + '\n')
  16. writable.write('c' + '\n')
  17. // 再无数据写入流时,需要调用`end`方法
  18. writable.end()
  • 上游通过调用writable.write(data)将数据写入可写流中。write()方法会调用_write()data写入底层。
  • _write中,当数据成功写入底层后,必须调用next(err)告诉流开始处理下一个数据。
  • next的调用既可以是同步的,也可以是异步的。
  • 上游必须调用writable.end(data)来结束可写流,data是可选的。此后,不能再调用write新增数据。
  • end方法调用后,当所有底层的写操作均完成时,会触发finish事件。

Duplex

创建可读可写流。

Duplex实际上就是继承了ReadableWritable的一类流。
所以,一个Duplex对象既可当成可读流来使用(需要实现_read方法),也可当成可写流来使用(需要实现_write方法)。

  1. var Duplex = require('stream').Duplex
  2. var duplex = Duplex()
  3. // 可读端底层读取逻辑
  4. duplex._read = function () {
  5. this._readNum = this._readNum || 0
  6. if (this._readNum > 1) {
  7. this.push(null)
  8. } else {
  9. this.push('' + (this._readNum++))
  10. }
  11. }
  12. // 可写端底层写逻辑
  13. duplex._write = function (buf, enc, next) {
  14. // a, b
  15. process.stdout.write('_write ' + buf.toString() + '\n')
  16. next()
  17. }
  18. // 0, 1
  19. duplex.on('data', data => console.log('ondata', data.toString()))
  20. duplex.write('a')
  21. duplex.write('b')
  22. duplex.end()

上面的代码中实现了_read方法,所以可以监听data事件来消耗Duplex产生的数据。
同时,又实现了_write方法,可作为下游去消耗数据。

因为它既可读又可写,所以称它有两端:可写端和可读端。
可写端的接口与Writable一致,作为下游来使用;可读端的接口与Readable一致,作为上游来使用。

Transform

在上面的例子中,可读流中的数据(0, 1)与可写流中的数据(’a’, ‘b’)是隔离开的,但在Transform中可写端写入的数据经变换后会自动添加到可读端。
Tranform继承自Duplex,并已经实现了_read_write方法,同时要求用户实现一个_transform方法。

  1. 'use strict'
  2. const Transform = require('stream').Transform
  3. class Rotate extends Transform {
  4. constructor(n) {
  5. super()
  6. // 将字母旋转`n`个位置
  7. this.offset = (n || 13) % 26
  8. }
  9. // 将可写端写入的数据变换后添加到可读端
  10. _transform(buf, enc, next) {
  11. var res = buf.toString().split('').map(c => {
  12. var code = c.charCodeAt(0)
  13. if (c >= 'a' && c <= 'z') {
  14. code += this.offset
  15. if (code > 'z'.charCodeAt(0)) {
  16. code -= 26
  17. }
  18. } else if (c >= 'A' && c <= 'Z') {
  19. code += this.offset
  20. if (code > 'Z'.charCodeAt(0)) {
  21. code -= 26
  22. }
  23. }
  24. return String.fromCharCode(code)
  25. }).join('')
  26. // 调用push方法将变换后的数据添加到可读端
  27. this.push(res)
  28. // 调用next方法准备处理下一个
  29. next()
  30. }
  31. }
  32. var transform = new Rotate(3)
  33. transform.on('data', data => process.stdout.write(data))
  34. transform.write('hello, ')
  35. transform.write('world!')
  36. transform.end()
  37. // khoor, zruog!

objectMode

前面几节的例子中,经常看到调用data.toString()。这个toString()的调用是必需的吗?
本节介绍完如何控制流中的数据类型后,自然就有了答案。

在shell中,用管道(|)连接上下游。上游输出的是文本流(标准输出流),下游输入的也是文本流(标准输入流)。在本文介绍的流中,默认也是如此。

对于可读流来说,push(data)时,data只能是StringBuffer类型,而消耗时data事件输出的数据都是Buffer类型。对于可写流来说,write(data)时,data只能是StringBuffer类型,_write(data)调用时传进来的data都是Buffer类型。

也就是说,流中的数据默认情况下都是Buffer类型。产生的数据一放入流中,便转成Buffer被消耗;写入的数据在传给底层写逻辑时,也被转成Buffer类型。

但每个构造函数都接收一个配置对象,有一个objectMode的选项,一旦设置为true,就能出现“种瓜得瓜,种豆得豆”的效果。

Readable未设置objectMode时:

  1. const Readable = require('stream').Readable
  2. const readable = Readable()
  3. readable.push('a')
  4. readable.push('b')
  5. readable.push(null)
  6. readable.on('data', data => console.log(data))

输出:

  1. <Buffer 61>
  2. <Buffer 62>

Readable设置objectMode后:

  1. const Readable = require('stream').Readable
  2. const readable = Readable({ objectMode: true })
  3. readable.push('a')
  4. readable.push('b')
  5. readable.push({})
  6. readable.push(null)
  7. readable.on('data', data => console.log(data))

输出:

  1. a
  2. b
  3. {}

可见,设置objectMode后,push(data)的数据被原样地输出了。此时,可以生产任意类型的数据。

预告

Stream系列共三篇文章:

  • 第一部分:基础篇,介绍Stream接口的基本使用。
  • 第二部分:进阶篇,重点剖析Stream底层如何支持流式数据处理,及其back pressure机制。
  • 第三部分:实战篇。介绍如何使用Stream进行程序设计。从Browserify和Gulp总结出两种设计模式,并基于Stream构建一个为Git仓库自动生成changelog的应用作为示例。

参考文献

  • GitHub,substack/browserify-handbook
  • GitHub,zoubin/streamify-your-node-program

发现文章有错误、对内容有疑问,都可以关注美团点评技术团队微信公众号(meituantech),在后台给我们留言。我们每周会挑选出一位热心小伙伴,送上一份精美的小礼品。快来扫码关注我们吧!

公众号二维码

发表评论

表情:
评论列表 (有 0 条评论,64人围观)

还没有评论,来说两句吧...

相关阅读

    相关 Node.js Stream - 基础

    背景 在构建较复杂的系统时,通常将其拆解为功能独立的若干部分。这些部分的接口遵循一定的规范,通过某种方式相连,以共同完成较复杂的任务。譬如,shell通过管道`|`连接各

    相关 NodeJS Stream

    什么是 Stream 在 Unix 系统中流就是一个很常见也很重要的概念,从术语上讲流是对输入输出设备的抽象。 ls | grep .js 类似这样的代

    相关 nodejs 基础整合

    nodeJs 基础篇整合 > 最近有朋友也想学习nodeJs相关方面的知识,如果你是后端想接近前端,node作为一门跑在服务端的JS语言从这里入门再好不过了。如果你正好喜

    相关 nodejs stream

    今天起得晚,发现我的博客排名上升到5位数了,激动之情难以言表,只有再写一篇博客来巩固自己的地位(以上内容与本文无关,轻喷~) 之前说过,nodejs操

    相关 初探nodejsstream(流)

    什么是流? `Stream(流)` 就和它得名字一样,我们可以把它想象成像水流,从一个地方流向另外一个地方。按照一定速率,有快有慢。而Nodejs里流则是将数据以一定的速