Node.js Stream(流)

一、什么是 stream?

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

英文叫 stream 中文叫“流”,都能很形象的表述出它的本质 —— 就是让数据流动起来。我们用桶和水来做比喻还算比较恰当(其实计算机中的概念,都是数学概念,都是抽象的,都无法完全用现实事务做比喻),如下图。数据从原来的 source 流向 dest ,要像水一样,慢慢的一点一点的通过一个管道流过去。给鱼缸换水、偷汽油,都能用得上。

stream 并不是 nodejs 独有的概念,而是一个操作系统最基本的操作方式,只不过 nodejs 有 API 支持这种操作方式。linux 命令的 | 就是 stream ,因此所有 server 端语言都应该实现 stream 的 API 。

二、为何要使用 stream

暂不管编程的原因,先分析一下上图中换水的例子。如果没有中间的管道,而是直接抱起 source 水桶往 dest 水桶中倒,那肯定得需要一个力量特别大的人(或者多个人)才能完成。而有了这个管道,小孩子都可以很轻松的完成换水,而且管道粗细都可以最终完成,只不过是时间长短的问题。即,有管道换水需要的力量消耗非常少,不用管道换水消耗力量很大,这个应该很好理解。

其实这里所说的“力量”,对应到计算机编程中就是硬件的性能,这包括 CPU 的运算能力,内存的存储能力,硬盘和网络的读写速度(硬盘存储能力暂不考虑)。将上面倒水的例子对应到一个计算机的场景中,例如在线看电影,source 就是服务器端的视频,dest 就是你自己的播放器(或者浏览器中的 flash 和 h5 video)。到这里大家应该一下子能明白,目前看电影的方式就是如同用管道换水一样,一点一点的从服务端将视频流动到本地播放器,一边流动一边播放,最后流动完了也播放完了。

那播放视频为何要使用这种方式?解决这个问题不妨考虑反证法,即不用管道和流动的方式,先从服务端加载完视频文件,然后再播放。这样导致最直观的问题就是,需要加载很长一段时间才能播放视频。其实这仅仅的表面现象,还有可能是视频加载过程中,因内存占用太多而导致系统卡顿或者崩溃。因为我们的网速、内存、CPU 运算速度都是有限的(而且还要多个程序共享使用),这个视频文件可能有几个 G 那么大。

再说一个更加直观的例子,先看下面的这段代码。语法上并没有什么问题,但是如果 data.txt 文件非常大,在响应大量用户的并发请求时,程序可能会消耗大量的内存,这样很可能会造成用户连接缓慢的问题。而且,如果并发请求过大,服务器内存开销也很大。

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

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

要解决这个问题很简单 —— 用 stream ,代码改造如下。即并不是把文件全部读取了再返回,而是一边读取一边返回,一点一点的把数据流动到客户端。

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

var server = http.createServer(function (req, res) {
    var fileName = path.resolve(__dirname, 'data.txt');
    var stream = fs.createReadStream(fileName);  // 这一行有改动
    stream.pipe(res); // 这一行有改动
});
server.listen(8000);

 (注意,以上关于 stream 的 API 暂时没必要掌握,下文或者后面的章节会详细介绍)

最后总结一下,之所以用 stream ,是因为一次性读取、操作大文件,内存和网络是“吃不消”的,因此要让数据流动起来,一点一点的进行操作。这其实也符合算法中一个很重要的思想 —— 分而治之。

三、stream 流转的过程

从管道换水的例子可以看出,stream 包括 source,dest,还有中间的管道,下面将通过这三个方面来介绍 stream 的过程。其中比较关键的 API 有:

  • data 事件,用来监听 stream 数据的输入
  • end 事件,用来监听 stream 数据输入完成
  • fs.createReadStream 方法,返回一个文件读取的 stream 对象
  • fs.createWriteStream 方法,返回一个文件写入的 stream 对象
  • pipe 方法,用来做数据流转

这些 API 下文都会有介绍和代码演示,能通过代码看懂语义即可,暂时不用深究 API 细节,后续章节会有详细介绍。

四、source —— 从哪里来

stream 常见的来源方式主要有三种:

  • 从控制台输入
  • http 请求中的 request
  • 读取文件

运行如下代码,然后从控制台输入任何内容,都会被 data 事件监听到,process.stdin 就是一个 stream 对象。注意,data 就是 stream 用来监听数据传入的一个自定义函数,后续会大量用到这个方法。

process.stdin.on('data', function (chunk) {
    console.log('stream by stdin', chunk.toString())
})

http 请求中的 request 输入可以参考如下代码片段(不能直接运行,后面章节会详解)。即客户端发起了 http 请求,服务端可以通过这种方式(注意也用到了 data 事件监听)来监听到数据的传入。这种 http 请求一般是一个 post 请求,上传数据。注意,end 用来监听 stream 数据传输完毕,一般和 data 共用。 

req.on('data', function (chunk) {
    // “一点一点”接收内容
    data += chunk.toString()
})
req.on('end', function () {
    // end 表示接收数据完成
})

读取文件是用 stream 如以下代码。fs.createReadStream(...) 可以返回一个读取文件的 stream 对象,该对象可以监听 data 和 end 事件。 

var fs = require('fs')
var readStream = fs.createReadStream('./file1.txt')  // 读取文件的 Stream 对象

var length = 0
readStream.on('data', function (chunk) {
    length += chunk.toString().length
})
readStream.on('end', function () {
    console.log(length)
})

五、管道

以上 source 的三种代码示例中,都有一个共同点,就是对 stream 对象可以监听 data end 事件。 nodejs 中监听自定义事件要使用 .on 方法,例如 process.stdin.on('data', ...) req.on('data', ...) 。通过这种方式,能很直观的监听到 stream 数据的传入和结束。

根据上图管道倒水的例子,source 和 dest 之间有一个管道。我们已经介绍了 source ,在介绍 dest 之前先介绍一下这个管道 —— pipe ,其基本语法是 source.pipe(dest) ,source 上文已经介绍过三种类型,dest 下文会继续介绍三种类型,他们两者就是使用 pipe 进行连接,就是让数据从 source 流向 dest,就是管道。

六、dest —— 到哪里去

stream 常见输出方式主要有三种:

  • 输出到控制台
  • http 请求中的 response
  • 写入文件

上文讲解 source 时提到,process.stdin.on('data', ...) 可以监听控制台输入,而那仅仅是手动监听。如果让控制台输入这个 source 直接通过管道连接到控制台输入,即让数据从输入直接流向输出,使用如下代码。

process.stdin.pipe(process.stdout) // source.pipe(dest) 形式

nodejs 处理 http 请求时会用到 req 和 res ,其实这两者都是 stream 对象。其中 req 是 source ,可以 req.on('data', ...) 使用(上文已经演示过),res 是 dest ,用法如下。下面这段代码在本节文章一开始就介绍了,到这里大家应该明白,这是用 stream 的方式读取文件然后直接返回 http 请求。 

var stream = fs.createReadStream(fileName);
stream.pipe(res); // source.pipe(dest) 形式

读取文件可以用 stream ,写入文件当然也可以用 stream ,如下代码。其中,fs.createWriteStream(...) 会返回一个写入文件的 stream 对象,即 dest 。这段代码,就是将一个文件中的内容,一点一点的流动到另外的文件中,完成复制功能。跟文章一开始管道换水的例子非常像。 

var fs = require('fs')
var readStream = fs.createReadStream('./file1.txt')  // source
var writeStream = fs.createWriteStream('./file2.txt')  // dest
readStream.pipe(writeStream) // source.pipe(dest) 形式

七、stream 的常见使用场景

根据上文的介绍可以看出,stream 常见的应用场景是 http 请求和文件操作,后面的章节会根据这两个场景展开详细讲解。

总结来看,http 请求和文件操作都属于 IO ,即 stream 主要的应用场景就是处理 IO ,这就又回到了 stream 的本质 —— 由于一次性 IO 操作过大,硬件开销太多,影响软件运行效率,因此将 IO 分批分段操作,让数据一点一点的流动起来,直到操作完成。

八、总结

本节主要介绍了 stream 的基本概念和常用 API ,学完本节希望你能掌握:

stream 的基本概念,即 source -> 管道 -> dest 这个模型图。

为何要用 stream ? —— 一次性操作 IO ,内存和网络开销太大。

source pipe dest 各种部分的常用 API ,要求能通过代码看懂语义。

stream 的常见应用场景 —— IO 操作。

====================================================================

一、Node.js中的stream(流)的概念及作用?

什么是流呢?日常生活中有水流,我们很容易想得到的就是水龙头,那么水龙头流出的水是有序且有方向的(从高处往低处流)。我们在nodejs中的流也是一样的,他们也是有序且有方向的。nodejs中的流是可读的、或可写的、或可读可写的。并且流继承了EventEmitter。因此所有的流都是EventEmitter的实列。

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

1. Readable--可读的流(比如 fs.createReadStream()).

2. Writable--可写的流(比如 fs.createWriteStream()).

3. Duplex--可读写的流

4. Transform---在读写过程中可以修改和变换数据的Duplex流。

nodeJS中的流最大的作用是:读取大文件的过程中,不会一次性的读入到内存中。每次只会读取数据源的一个数据块。然后后续过程中可以立即处理该数据块(数据处理完成后会进入垃圾回收机制)。而不用等待所有的数据。

我们先来看一个简单的流的实列来理解下:

1. 首先我们来创建一个大文件,如下代码:

const fs = require('fs');
const file = fs.createWriteStream('./big.txt');
// 循环500万次
for (let i = 0; i <= 5000000; i++) {
  file.write('我是空智,我来测试一个大文件, 你看看我会有多大?');
}
 
file.end();

在项目文件里面新建一个app.js文件,然后把上面的代码放入到 app.js 里面去,可以看到循环了500万次后,写入500万次数据到 big.txt中去,因此会在文件目录下生成一个 big.txt文件,如下:

该文件在我磁盘中显示345兆。

readFile读取该文件:

下面我们使用 readFile 来读取该文件看看(readFile会一次性读入到内存中)。

我们把app.js代码改成如下:

const fs = require('fs');
const Koa = require('koa');
 
const app = new Koa();
 
app.use(async(ctx, next) => {
  const res = ctx.res;
  fs.readFile('./big.txt', (err, data) => {
    if (err) {
      throw err;
    } else {
      res.end(data);
    }
  })
});
 
app.listen(3001, () => {
  console.log('listening on 3001');
});

 当我们运行node app.js 后,我们查看下该代码占用的内存(12MB)如下:

但是当我们运行 http://localhost:3001/ 后,发现占用的内存(有338MB了)如下:

 readFile 它会把 big.txt的文件内容整个的读进以Buffer格式存入到内存中,然后再写进返回对象,那么这样的效率非常低的,并且如果该文件如果是1G或2G以上的文件,那么内存会直接被卡死掉的。或者服务器直接会奔溃掉。

下面我们使用 Node中的createReadStream方法就可以避免占用内存多的情况发生。我们把app.js 代码改成如下所示:

const fs = require('fs');
const Koa = require('koa');
 
const app = new Koa();
 
app.use(async(ctx, next) => {
  const res = ctx.res;
  const file = fs.createReadStream('./big.txt');
  file.pipe(res);
});
 
app.listen(3001, () => {
  console.log('listening on 3001');
});

然后我们继续查看内存的使用情况,如下所示:

 可以看到我们的占用的内存只有12.8兆。也就是说:createReadStream 在读取大文件的过程中,不会一次性的读入到内存中。每次只会读取数据源的一个数据块。这就是流的优点。 

二、从流中读取数据

创建 input.txt 文件,内容如下:

教程官网地址:www.xxx.com

创建 main.js 文件, 代码如下:

var fs = require("fs");
var data = '';

// 创建可读流
var readerStream = fs.createReadStream('input.txt');

// 设置编码为 utf8。
readerStream.setEncoding('UTF8');

// 处理流事件 --> data, end, and error
readerStream.on('data', function(chunk) {
   data += chunk;
});

readerStream.on('end',function(){
   console.log(data);
});

readerStream.on('error', function(err){
   console.log(err.stack);
});

console.log("程序执行完毕");

以上代码执行结果如下:

程序执行完毕

教程官网地址:www.xxx.com

三、写入流

创建 main.js 文件, 代码如下:

var fs = require("fs");
var data = '教程官网地址:www.runoob.com';

// 创建一个可以写入的流,写入到文件 output.txt 中
var writerStream = fs.createWriteStream('output.txt');

// 使用 utf8 编码写入数据
writerStream.write(data,'UTF8');

// 标记文件末尾
writerStream.end();

// 处理流事件 --> finish、error
writerStream.on('finish', function() {
    console.log("写入完成。");
});

writerStream.on('error', function(err){
   console.log(err.stack);
});

console.log("程序执行完毕");

以上程序会将 data 变量的数据写入到 output.txt 文件中。代码执行结果如下:

$ node main.js 
程序执行完毕
写入完成。

查看 output.txt 文件的内容:

$ cat output.txt 
教程官网地址:www.xxx.com

四、管道流

管道提供了一个输出流到输入流的机制。通常我们用于从一个流中获取数据并将数据传递到另外一个流中。


如上面的图片所示,我们把文件比作装水的桶,而水就是文件里的内容,我们用一根管子(pipe)连接两个桶使得水从一个桶流入另一个桶,这样就慢慢的实现了大文件的复制过程。

以下实例我们通过读取一个文件内容并将内容写入到另外一个文件中。

设置 input.txt 文件内容如下:

教程官网地址:www.xxx.com
管道流操作实例

创建 main.js 文件, 代码如下:

var fs = require("fs");

// 创建一个可读流
var readerStream = fs.createReadStream('input.txt');

// 创建一个可写流
var writerStream = fs.createWriteStream('output.txt');

// 管道读写操作
// 读取 input.txt 文件内容,并将内容写入到 output.txt 文件中
readerStream.pipe(writerStream);

console.log("程序执行完毕");

代码执行结果如下:

$ node main.js 
程序执行完毕

查看 output.txt 文件的内容:

$ cat output.txt 
教程官网地址:www.xxx.com
管道流操作实例

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值