三篇文章让你彻底搞懂nodejs中的stream(下)

535 阅读8分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第29天,点击查看活动详情

stream 和 buffer

前面介绍过了 stream 在网络 IO 和文件 IO 中的使用以及价值,但还有一个很重要的问题没有解决。即stream 就是将数据一点一点的流动起来,那么每次流动的数据到底是什么?

var readStream = fs.createReadStream('./file1.txt')
readStream.on('data', function (chunk) {
    // ...
})

具体一点,就是以上代码中的 chunk 到底是什么?

二进制

现代计算机之父 冯.诺依曼 提出了“冯.诺依曼结构”,其中最核心的内容之一就是:计算式使用二进制形式进行存储了计算。这种设计一直沿用至今,而且在未来也会一直用下去。

计算机内存由若干个存储单元组成,每个存储单元只能存储 0 或者 1 (因为内存是硬件,计算机硬件本质上就是一个一个的电子元件,只能识别充电和放电的状态,充电代表 1 ,放电代表 0 ,可以先这么简单理解),即二进制单元(英文叫 bit)。但是这一个单元所能存储的信息太少,因此约定将 8 个二进制单元为一个基本存储单元,叫做字节(英文是 byte)。一个字节所能存储的最大整数就是 2^8 = 256 ,也正好是 16^2 ,因此也尝尝使用两位的 16 进制数代表一个字节。例如 CSS 中常见的颜色值 #CCCCCC 就是 6 位 16 进制数字,它占用 3 个字节的空间。

二进制是计算机最底层的数据格式,也是一种通用格式。计算机中的任何数据格式,字符串、数字、视频、音频、程序、网络包等,在最底层都是用二进制来进行存储。这些高级格式和二进制之间,都可通过固定的编码格式进行相互转换

例如,C 语言中 int32 类型的十进制整数(无符号的),就占用 32 bit 即 4 byte ,十进制的 3 对应的二进制就是 00000000 00000000 00000000 00000011 。字符串也同理,可以根据 ASCII 编码规则或者 unicode 编码规则(如 utf-8)等和二进制进行相互转换

总之,计算机底层存储的数据都是二进制格式,各种高级类型都有对应的编码规则,和二进制进行相互转化。

nodejs 表示二进制

Buffer 就是 nodejs 中二进制的表述形式。

var str = '学习 nodejs stream'
var buf = Buffer.from(str, 'utf-8')
console.log(buf) 
// <Buffer e5 ad a6 e4 b9 a0 20 6e 6f 64 65 6a 73 20 73 74 72 65 61 6d>
console.log(buf.toString('utf-8'))  
// 学习 nodejs stream

以上代码中,先通过 Buffer.from 将一段字符串转换为二进制形式,其中 utf-8 是一个编码规则。二进制打印出来之后是一个类似数组的对象(但它不是数组),每个元素都是两位的 16 进制数字,即代表一个 byte ,打印出来的 buf 一共有 20 byte 。即根据 utf-8 的编码规则,这段字符串需要 20 byte 进行存储。最后,再通过 utf-8 规则将二进制转换为字符串并打印出来。

流动的数据是二进制格式

先在之前的示例中分别打印一下 chunk instanceof Buffer 和 chunk ,看看结果。

var readStream = fs.createReadStream('./file1.txt')
readStream.on('data', function (chunk) {
    console.log(chunk instanceof Buffer)
    console.log(chunk)
})

打印结果如下图:

image.png

可以看到 stream 中流动的数据就是 Buffer 类型,就是二进制。因此,在使用 stream chunk 的时候,需要将这些二进制数据转换为相应的格式。例如之前讲解 post 请求,从 request 中接收数据就是这样。

var dataStr = '';
req.on('data', function (chunk) {
    var chunkStr = chunk.toString()  // 这里,将二进制转换为字符串
    dataStr += chunkStr
});

stream 中为何要“流动”二进制格式的数据呢?

回答这个问题得再次考虑一下 stream 的设计目的 —— 为了优化 IO 操作。无论是文件 IO 还是网络 IO ,其中包含的数据格式是未可知的,如字符串、音频、视频、网络包等。即便都是字符串,其编码规则也是未可知的,如 ASC 编码、utf-8 编码。在这么多未可知的情况下,只能是以不变应万变,直接用最通用的二进制格式,谁都能认识。

而且,用二进制格式进行流动和传输,是效率最高的。

补充一点,按照上面的说法,那无论是用 stream 读取文件还是 fs.readFile 读取文件,读出来的都应该是二进制格式?—— 答案是正确的。

var fileName = path.resolve(__dirname, 'data.txt');
fs.readFile(fileName, function (err, data) {
    console.log(data instanceof Buffer)  // true
    console.log(data)  // <Buffer 7b 0a 20 20 22 72 65 71 75 69 72 65 ...>
});

Buffer 带来的性能提升

前面的文章中讲解 get 请求,用 stream 可以提升性能。下面我们抛开 stream 这个因素不谈,只看 Buffer 对 get 请求性能的影响。

在之前的文件夹中新建 buffer-test.txt 文件,多粘贴一些文字进去,让文件大小 500kb 左右。

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

var server = http.createServer(function (req, res) {
    var fileName = path.resolve(__dirname, 'buffer-test.txt');
    fs.readFile(fileName, function (err, data) {
        res.end(data)   // 测试1 :直接返回二进制数据
        // res.end(data.toString())  // 测试2 :返回字符串数据
    });
});
server.listen(8000);

对以上代码中两个需要测试的情况,使用 ab 工具运行 ab -n 100 -c 100 http://localhost:8000/ 分别进行测试。

image.png

从测试结果可以看出,无论是从吞吐量(Requests per second)还是连接时间上,返回二进制格式比返回字符串格式效率提高很多。为何字符串格式效率低?—— 因为网络请求的数据本来就是二进制格式传输,虽然代码中写的是 response 返回字符串,最终还得再转换为二进制进行传输,多了一步操作,效率当然低了。

stream 常用类型总结

再次回顾第一篇中讲解的管道换水的图,source 通过一个管道流向了 dest ,如下图。

image.png 通过之前的学习知道:这里的 source 可能是 http 请求中的 request ,也可能是读取文件的 stream 对象,也可能是 process.stdin;这里的 dest 可能是 http 请求中的 response ,也可能是写入文件的 stream 对象,也可能是 process.stdout;这里的管道就是 pipe 函数。

先不管 pipe 函数。source 和 dest 完全就是两个不同的类型,一个是读取数据的,叫做 readable stream ,一个是写入数据的,叫做 writeable stream。

除了这两种类型之外,还有一种类型叫做 duplex stream ,中文叫双工流既有读取功能又有写入功能。例如下面的代码,readStream 读取的数据直接 pipe 到 zlib.createGzip() 这个 duplex stream 就是 gzip 压缩,然后再 pipe 到 writeStream 结束操作。

var fs = require('fs')
var zlib = require('zlib')
var readStream = fs.createReadStream('./file1.txt')
var writeStream = fs.createWriteStream('./file1.txt.gz')
readStream.pipe(zlib.createGzip())
          .pipe(writeStream)

readable stream

http 请求中的 request 和读取文件的 stream 对象都是 readable stream ,即可以从中读取数据。它有两种常用的操作方式,第一种是直接将数据 pipe 到一个 writeable stream。

var stream = fs.createReadStream(fileName);
stream.pipe(res); // 直接将数据 `pipe` 到一个 writeable stream

第二种是通过监听 on end 自定义事件来获取数据再手动处理。

var dataStr = '';
req.on('data', function (chunk) {
    // 接收到数据,先存储起来
    var chunkStr = chunk.toString()
    dataStr += chunkStr
});
req.on('end', function () {
    // 接收数据完成,将数据写入文件
    var fileName = path.resolve(__dirname, 'post.txt');
    fs.writeFile(fileName, dataStr)

    res.end('OK');
});

以上说的两个例子,都是已经封装好的 readable stream 。那么它的本来面目是怎样的?先看下面代码。

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

// 构造一个 readable stream 并往里“灌入”数据
var rs = new Readable;
rs.push('学习 ');
rs.push('nodejs ');
rs.push('stream');
rs.push(null);

// pipe 到一个 writable stream
rs.pipe(process.stdout);

以上代码可以看出,node.js 提供了 readable stream 的构造函数,可以 new 出一个新的 readable stream 对象。然后通过 push 函数往里“灌入”数据,当传入的是 null 的时候表示“灌入”完成,即可输入了。最后 pipe 到了一个 writeable stream 。

writeable stream

http 请求中的 response 和写入文件的 stream 对象都是 writable stream ,它可以作为参数传入 pipe 函数,以读取上游的数据。

var writeStream = fs.createWriteStream(fileName)
req.pipe(writeStream) // writeStream 作为参数传递到 pipe 函数中

以上代码中 writeStream 是已经封装好了的 writeable stream ,下面再看看它的本来面目。

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

var ws = Writable();
ws._write = function (chunk, enc, next) {
    console.log(chunk.toString());  // 输出“流动”的数据
    next();  // 继续监听下一次输出
};

process.stdin.pipe(ws); // 作为参数传递到 pipe 函数中

pipe

之前讲解 source.pipe(dest) 模式是为了方便理解和使用,现在我们更新一个更加严谨的 pipe 用法:

  • 调用 pipe 的对象必须是 readable stream 或者 duplex stream ,即具有读取数据的功能。如 req.pipe(..)
  • 传入 pipe 的参数必须是 writeable stream 或者 duplex stream ,即具有写入数据的功能。如 req.pipe(res)
  • pipe 支持链式调用,如readStream.pipe(zlib.createGzip()).pipe(writeStream)