Node.js 诞生是为了解决 I/O 密集的 Web 性能问题,最常使用的两个模块就是文件系统和网络,而这两个模块都是 stream 的重度用户,stream 是 Node.js 从入门到进阶的必经之路

:::info Node.js 对 stream 是这样解释的
A stream is an abstract interface for working with streaming data in Node.js. The stream module provides an API for implementing the stream interface. :::
翻译过来流是 Node.js 中处理流式数据的抽象接口。stream模块提供了用于实现流接口的对象。
基本就是用 stream 解释自己,第一次使用肯定不理解,但其实我们平时经常用到流

  1. ls | grep *.js

命令的含义是 list 当前目录文件,把结果交给 grep 命令,按行筛选出拓展名是 js 的内容

使用 | 连接两条命令,把前一个命令的结果作为后一个命令的参数传入,这样数据像是水流在管道中传递,每个命令类似一个处理器,对数据做一些加工,因此 | 被称为“管道符”,这个处理过程就是流

从术语上讲流是对输入输出设备的抽象,是一组有序的、有起点和终点的字节数据传输手段
image.png

Node 的 stream 类型

从程序角度而言流是有方向的数据,以程序为第一视角,按照流动方向可以分为三种流

  1. 设备流向程序:readable
  2. 程序流向设备:writable
  3. 双向流动:duplex、transform

Node 关于流的操作被封装到了 Stream 模块,这个模块也被多个核心模块所引用。
按照 Unix 的哲学:一切皆文件

  1. 普通文件(txt、jpg、mp4)
  2. 设备文件(stdin、stdout)
  3. 网络文件(http、net)

在 Node.js 中对文件的处理多数使用流来完成

小试牛刀

假设写程序某个功能需要读取某个配置文件 config.json,这时候简单分析一下

  • 数据:config.json 的内容
  • 方向:设备(物理磁盘文件) ——> NodeJS 程序

应该使用 readable 流来做此事(后面章节会具体介绍 API)

  1. const fs = require('fs');
  2. const filePath = '...';
  3. const rs = fs.createReadStream(filePath);

通过 fs 模块提供的 createReadStream() 方法创建了一个可读的流,这时候 config.json 的内容从设备流向程序。示例并没有直接使用 Stream 模块,因为 fs 内部已经引用了 Stream 模块,并做了封装

读取到数据后可以对数据进行处理,比如需要写到某个路径 dest,这时候需要一个 writable 的流,让数据从程序流向设备

  1. const ws = fs.createWriteStream(dist);

两种流都有了,也就是两个数据加工器,那么如何通过类似 Unix 的管道符 | 来连接流呢?
在 Node.js 中管道符号就是 pipe() 方法

  1. const fs = require('fs');
  2. const filePath = 'DEST_PATH_IN_YOUR_DISK';
  3. const rs = fs.createReadStream(filePath);
  4. const ws = fs.createWriteStream(dist);
  5. rs.pipe(ws);

这样利用流实现了简单的文件复制功能,关于 pipe() 方法的实现原理后面会提到,但有个值得注意地方:
数据必须是从上游 pipe 到下游,也就是从一个 readable 流 pipe 到 writable 流

加工数据

如果有个需求,把本地一个 package.json 文件中的所有字母都改为大写,并保存到同目录下的 package-upper.json 文件下

这时候就需要用到双向的流了,假定有个专门处理字符转大写的流 toUppercase ,那么代码写出来是这样的

  1. const fs = require('fs')
  2. const rs = fs.createReadStream('./package.json')
  3. const ws = fs.createWriteStream('./package-upper.json')
  4. rs.pipe(toUpperCase).pipe(ws)

根据上面说的,必须从一个 readable 流 pipe 到 writable 流:

  • rs -> uppercase:uppercase 在下游,所以 lower 需要是个 writable 流
  • uppercase -> ws:相对而言,uppercase 又在上游,所以 upper 需要是个 readable 流

因此能够满足需求的 uppercase 必须是双向的流,具体使用 duplex 还是 transform 后面章节会提到。

当然如果需求还有额外一些处理,比如字母还需要转成 ASCII 码,假定有一个双工流 ascii,那么代码简单改写

  1. rs.pipe(uppercase).pipe(acsii).pipe(ws);

这样就完成了 package.json 文件字母转大写后处理成 acsii 码的需求

为什么要使用 stream

有个用户 Web 在线看视频的场景,假定我们通过 HTTP 请求返回给用户电影内容,那么代码可能写成这样

  1. const http = require('http');
  2. const fs = require('fs');
  3. http.createServer((req, res) => {
  4. fs.readFile(moviePath, (err, data) => {
  5. res.end(data);
  6. });
  7. }).listen(8080);

这样的代码又两个明显的问题

  1. 电影文件需要读完之后才能返回给客户,等待时间超长
  2. 电影文件需要一次放入内存中,内存吃不消

使用 stream 可以把电影文件一点点的放入内存中,然后一点点的返回给客户(利用了 HTTP 协议的 Transfer-Encoding: chunked 分段传输特性),用户体验得到优化,同时对内存的开销明显下降

  1. const http = require('http');
  2. const fs = require('fs');
  3. http.createServer((req, res) => {
  4. fs.createReadStream(moviePath).pipe(res);
  5. }).listen(8080);

除了上述好处,stream 还让代码优雅了很多,功能逻辑独立,拓展也比较简单。
比如需要对视频内容压缩,我们可以引入一个专门做此事的流,这个流不用关心其它部分做了什么,只要是接入管道中就可以了

  1. const http = require('http');
  2. const fs = require('fs');
  3. const oppressor = require(oppressor);
  4. http.createServer((req, res) => {
  5. fs.createReadStream(moviePath)
  6. .pipe(oppressor)
  7. .pipe(res);
  8. }).listen(8080);

可以看出来使用流后代码逻辑变得相对独立,可维护性也会有一定的改善