跟着文档学Node(一):Stream

378 阅读8分钟

本系列文章的目的是在结合文档的前提下深入理解Node.js的各个稳定模块。

在学习的过程中遵循what -> why -> how的思路,即了解该模块是什么、为什么要有这个模块(有什么作用)、怎么去使用该模块的api。

What: 流是什么?

先看官方定义:

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

翻译一下:Node.js中有一种数据传输方式是流式传输,而stream对象就是用于操作这些流式数据。

Why: 为什么要有流这个概念?

官方定义:

stream API 的主要目标,特别是 stream.pipe(),是为了限制数据的缓冲到可接受的程度,也就是读写速度不一致的源头与目的地不会压垮内存。

举个例子来解释下官方对于stream api的目标定义:

当我们想从服务器本地读取一个大文件内容并通过网络请求返回给客户端时,在不使用流的前提下,我们首先需要通过磁盘IO读取到整个文件的所有内容塞到程序内存中,然后再通过网络IO返回给客户端。

正常情况下,作为数据源头的磁盘IO的速度是远远大于作为传输目的地的网络IO的速度的。这时候就出现了“读写速度不一致的源头与目的地”,当并发足够高或者文件内容足够大时就会压垮程序内存。

而流式传输过程中数据读取速度可以匹配上消费者的消费速度,因此占用的内存会大大减少,避免上述问题发生。

How: 流的使用方法

stream的实现本质上也是一个对象,通过在stream对象上监听和触发各种事件以及调用其实例方法来达到流式传输数据的目的。

而Node.js将stream流分为了四种类型:

  1. 可读流 Readable
  2. 可写流 Writable
  3. 双工流 Duplex
  4. 转换流 Transform

每种类型的流对象都有各自不同的触发事件和实例方法,我们只需要逐一了解其事件和方法就能学会使用流。

学习各个类型的方法前,可以先看各类型的一些公共概念:

公共概念

缓冲池

在使用stream读取流式数据时,我们可以想象数据来源就是一个水泵,而可读流就是连接这个水泵的水管。数据从水泵中流出,不断地涌向消费者。

但生产者的数据流并不直接流向消费者,而是先流向缓存池中,然后消费者随时从缓存池中消费数据。

image.png

当然这个缓冲池并不是无限大的,它有一个阈值参数highWaterMark。

从这个参数词语的字面意思也可以看出,node.js把缓冲池想象为一个水池,生产者是连接着注入缓冲池的水管,而消费者是连接着流出缓冲池的水管。

当超过该阈值时生产者就应当停止往缓冲池中push数据,等待消费者消费完数据后再进行生产。否则超出阈值的数据将会溢出到程序内存中,直到达到 node.js 的最大内存限制。

背压

当消费速度慢于生成速度从而导致缓冲池中的数据超过阈值的这一现象称之为背压

对象模式

在stream流中默认传输的数据都是String、Buffer类型。当我们想要将传输的数据保留为其他类型时,可以通过objectMode来打开对象模式,从而传输其他对象类型的数据。

Readable 可读流

流动和暂停

可读流有两个模式:流动模式和暂停模式。

所有可读流都开始于暂停模式,可以通过以下方式切换到流动模式:

  • 调用 resume 方法
  • 添加 data 事件监听函数
  • 调用 pipe 方法

通过源码可以看到添加 data 事件监听函数时其实也是通过调用 resume 方法:

image.png 而调用 pipe 方法时会自动给可读流添加一个 data 事件监听函数。

也就是说上面三种方法最终都是通过调用 resume 方法来切换到流动模式,而 resume 方法最终调用到了下面这个 flow 函数:

image.png

可以看到切换到流动模式后,flow 函数会通过一个while循环不断地调用 read 方法去生产数据。不同类型的可读流的read方法都会从不同的来源读取数据,例如文件可读流从磁盘IO读取,http可读流从网络IO读取。具体见下文 自定义流类型。

可读流可以通过两个方式切换到暂停模式:

  • 在没有管道目标时(没有调用过pipe连接),则调用 pause方法。
  • 在有管道目标时,则调用 unpipe 方法移出管道目标。

需要注意的是,调用 pause 方法只是停止了数据的流动,并没有停止数据的生成。因此开发者在暂停后应尽快调用 resume 方法恢复流动。

可读流事件

除了以上几个操作可读流模式的方法外,可读流还有几个比较重要的事件:data事件、end事件、error事件、readable事件。

在较新的 node.js 版本中我们可以通过多种方式来消费流数据:

  • 监听data事件
  • 监听readable事件
  • pipe方法

通常情况下, readable.pipe() 和 'data' 事件的机制比 'readable' 事件更容易理解。 处理 'readable' 事件可能造成吞吐量升高。

对于大多数用户,建议使用 readable.pipe(),因为它是消费流数据最简单的方式。 如果开发者需要精细地控制数据的传递与产生,可以使用 EventEmitter、 readable.on('readable')/readable.read() 或 readable.pause()/readable.resume()。

对于大部分场景,上述官方文档推荐开发者使用 data 事件和pipe来消费数据。在有管道目标的情况下使用pipe,没有则使用data事件。

而通过监听readable事件来消费数据,需要我们手动处理较多的一些情况,但好处也是能更精细化地处理数据。

所以只有在需要更加精细化控制数据传递的场景下才建议通过监听readable事件来消费数据。

小结

可读流在切换到流动模式后会源源不断地调用read方法来从数据源获取到数据push到缓存池中,消费者再通过data等事件来消费缓存池中的数据。

可写流

比起可读流来说,可写流的概念比较少。我们常用的方法就是创建一个可写流,然后不断地调用write方法来写入数据,写完后调用end方法来结束流。

在写入过程中,如果缓存池的消费速度比push速度慢,那么池子中的数据量会达到highWaterMark阈值限制。

此时write方法会返回false,提醒生产者暂停写入数据,直至drain事件触发。drain事件代表着缓冲池中的数据已被消费完。

我们可以通过源码来看下这个过程:

image.png

write方法的返回值是writeOrBuffer函数的返回值,可以看到当缓冲池的数据长度小于highWaterMark时,writeOrBuffer函数返回true,表示可以继续写入。

当超过highWaterMark时,writeOrBuffer函数返回false,并且把needDrain置为true。

image.png

接下来在每次write方法执行后调用的afterWrite检查函数中可以看到当needDrain == true并且state.length === 0(缓冲池数据已消费完)时会触发drain事件。

小结

可写流不断地调用write方法往缓存池中写入数据,写满后等待drain事件后再继续写入。所有数据写入完毕后调用end方法结束流。

双工流

双工流(Duplex)是同时实现了 Readable 和 Writable 接口的流。

image.png

可以看到Duplex类是同时实现了Readable和Writable,因此它可以调用可读流和可写流的方法。

转换流

转换流(Transform)实现了双工流:

image.png

在双工流的基础上,转换流新增了transform参数,因此在写入时可以通过transform函数来对数据进行转换:

image.png

实现自定义流

自定义流继承了四种基本流之一,在继承基础上重新实现了一些方法。

在node.js中我们大部分使用的流都是自定义流,例如fs的文件读取流、http响应流等。

node.js规定了新建自定义流类型时必须实现其继承流对应的一个或多个方法:

image.png

为什么node.js要求必须实现这些方法呢?

举个例子,上面提到可读流在切换到流动模式时,会不停地调用read方法去读取数据。而read方法里就是调用了_read方法。不同的自定义流在_read方法里就可以从不同的地方去读取数据。

例如fs.createReadStream 实现的_read方法中就会使用fs.read去读取文件内容数据:

image.png

场景

Stream 模块的使用场景不少,例如代理转发的pipe、压缩zlib流、fs流读取大文件等。

一句话总结就是,数据传输过程中大量占用内存的场景我们都可以考虑使用 Stream 模块来优化。