likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 8

在 http get 请求中使用 stream

response 常用的 API 有 send end 等,如上面代码中的 res.end(data) ,但是 response 也是一个 stream 对象。通过上一章管道换水的图,以及 source.pipe(dest) 模型,response 就是一个 dest 。因此,之前的代码可以做出如下改动。

var server = http.createServer(function (req, res) {
    var method = req.method; // 获取请求方法
    if (method === 'GET') { // 暂只关注 get 请求
        var fileName = path.resolve(__dirname, 'data.txt');
        var stream = fs.createReadStream(fileName);
        stream.pipe(res); // 将 res 作为 stream 的 dest
    }
    // 其他 method 暂时忽略
});

使用 stream 对性能的提升

现在都知道使用 stream 比直接一次性操作 IO 性能更好,但是具体好多少,不妨做一个压力测试。首先,把现有的 data.txt 搞大一点,拷贝一些文字进去,然后多复制粘贴几次,让它大概有 5M 大小。然后用 ab 工具对两种方式做一个压力测试。

ab 全称 Apache bench ,是 Apache 自带的一个工具,因此使用 ab 必须要安装 Apache 。mac os 系统自带 Apache ,windows 用户视自己的情况进行安装。运行 ab 之前先启动 Apache ,mac os 启动方式是 sudo apachectl start 。

安装启动 Apache 完成之后,运行 ab -n 100 -c 100 http://localhost:8000/ 命令,即可进行测试。其中 -n 100 表示先后发送 100 次请求,-c 100 表示一次性发送的请求数目是 100 个。

在测试结果中,拿最重要的吞吐率(Requests per second)来说,从 118.23 变为 188.40 ,这可谓是一个非常大的性能提升 —— 别忘了,我们的成本仅仅是改了两行代码而已。而且,操作的文件体积越大,越能体现 stream 的价值。

实际应用

从上面例子可以看出,对 response 使用 stream 特性能提高性能。因此,在 nodejs 中如果要返回的数据是经过 IO 操作得来的,例如上面例子中读取文件内容,可以直接使用 stream.pipe(res); 这种方式,而不要再用 res.end(data); 了。

这种应用的实例应该比较多,主要有两种场景:

  • 使用 node.js 作为服务代理,即客户端通过 node.js 服务作为跳板去请求其他服务,返回请求的内容
  • 使用 node.js 做静态文件服务器,直接返回静态文件

在 http post 请求中使用stream

web server 接收 http 请求肯定是通过 request ,而 request 接收数据的本质其实就是 stream 。虽然看似是 request 接收数据,但是在 nodejs 服务本身而言,request 是产生数据的(即 source),它产生的数据就是它接收到客户端发送来的数据。

source 类型的 stream 对象都可以对其监听 data end 事件,分别触发数据接收和数据接收完成的通知。如下代码演示:

var server = http.createServer(function (req, res) {
    var method = req.method; // 获取请求方法
    if (method === 'POST') { // 暂只关注 post 请求
        req.on('data', function (chunk) {
            // 接收到部分数据
            console.log('chunk', chunk.toString().length);
        });
        req.on('end', function () {
            // 接收数据完成
            console.log('end');
            res.end('OK');
        });
    }
    // 其他请求方法暂不关心
});

通过 req.on('data', ...) 接收流转的数据,因为数据是一点一点“流”进来的,因此每次“流”进来的那一点,都可以通过 chunk 读取。最后,通过 req.on('end', ...) 来监听数据传输完成,此时请求可以结束了。

request 和 response 一样,本身也是一个 stream 对象,可以用 stream 的特性,那肯定也能提高性能。两者的区别就在于,request 是 source 类型的、是 stream 的源头,而 response 是 dest 类型的、是 stream 的目的地

使用 stream 对性能的提升

为了完成测试,要做一个简单的 demo ,即 nodejs 接收到 post 请求的数据,然后将其写入文件。

不使用 stream 的 demo 代码如下。即通过 req.on('data', ...) 获取数据,然后暂存下来,最后在 req.on('end', ...) 中将数据完成的写入到 post.txt 文件中。当然,你需要先创建 post.txt 文件。

var server = http.createServer(function (req, res) {
    var method = req.method; // 获取请求方法
    if (method === 'POST') { // 暂只关注 post 请求
        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');
        });
    }
});

使用 stream 的代码如下。即为 post.txt 文件创建 stream 对象,然后直接 req.pipe(writeStream) 将 request 数据流转到文件中。

var fileName = path.resolve(__dirname, 'post.txt');
var writeStream = fs.createWriteStream(fileName)
req.pipe(writeStream)
req.on('end', function () {
    // 接收数据完成
    res.end('OK');
});

还是借助于 ab 工具继续测试,从测试结果中可以看出,吞吐量(Requests per second)使用 stream 是 684.18 ,而未使用 stream 是 165.41 。有非常大的差异,而且操作的文件体积越大,这种差异就越大。

实际应用

和 get 请求使用 stream 的场景类似,post 请求使用 stream 的场景,主要是用于将接收的数据直接进行 IO 操作,例如:

  • 将接收的数据直接存储为文件
  • 将接收的数据直接 post 给其他的 web server

node.js 文件操作中使用 stream

node.js 读写文件

node.js 提供了非常清晰易懂的读写文件的 API ,读取一个文件代码如下。通过 fs.readFile 读取文件,然后再回调函数中返回文件内容。

var fs = require('fs')
var path = require('path')
var fileName = path.resolve(__dirname, 'data.txt');

// 读取文件内容
fs.readFile(fileName, function (err, data) {
    if (err) {
        console.log(err.message)
        return
    }
    console.log(data.toString())
})

写入文件操作代码如下。使用 fs.writeFile 写入文件内容,然后在回调函数中返回操作状态。

fs.writeFile(fileName, 'xxxxxx', function (err) {
    if (err) {
        console.log(err.message)
        return
    }
    console.log('写入成功')
})

根据以上读写操作,可以简单做一个文件拷贝的程序,将 data.txt 中的内容拷贝到 data-bak.txt 中。代码如下:

var fileName1 = path.resolve(__dirname, 'data.txt')
fs.readFile(fileName1, function (err, data) {
    if (err) {
        console.log(err.message)
        return
    }
    // 得到文件内容
    var dataStr = data.toString()

    // 写入文件
    var fileName2 = path.resolve(__dirname, 'data-bak.txt')
    fs.writeFile(fileName2, dataStr, function (err) {
        if (err) {
            console.log(err.message)
            return
        }
        console.log('拷贝成功')
    })
})

使用 stream 读写文件

用 stream 读写文件:

  • 可以使用 fs.createReadStream(fileName) 来创建读取文件的 stream 对象;
  • 使用 fs.createWriteStream(fileName) 来创建写入文件的 stream 对象;
// 两个文件名
var fileName1 = path.resolve(__dirname, 'data.txt')
var fileName2 = path.resolve(__dirname, 'data-bak.txt')
// 读取文件的 stream 对象
var readStream = fs.createReadStream(fileName1)
// 写入文件的 stream 对象
var writeStream = fs.createWriteStream(fileName2)
// 执行拷贝,通过 pipe
readStream.pipe(writeStream)
// 数据读取完成,即拷贝完成
readStream.on('end', function () {
    console.log('拷贝完成')
})

使用 stream 来带的性能提升

上文分别用基本的文件操作 API 和 stream API 写了两个拷贝文件的程序,两者都能实现功能,但是性能上却有巨大的差异。这个程序并不是 web server ,因此我们不再用 ab 工具进行性能测试,而是看下两者对内存的占用情况,文件操作中内存往往是一个瓶颈

先介绍一个监控 node.js 内存的轻量级小工具 —— memeye ,它能很方便很直观的监控 node.js 内存占用的情况。安装和使用也都非常简单。

var memeye = require('memeye')
memeye()

命令行运行 node test3.js ,然后用浏览器打开 http://localhost:23333/ 即可看到这个 node.js 进程的内存占用情况,如下图。其中我们最需要关心的就是页面中 Process Memory Usage 这部分的 heapUsed 内存大小,即 nodejs 的堆内存(可以简单理解为 JS 对象所占用的内存空间)。

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

再继续完善一下 test.js ,为了方便测试我们还需要做两个工作。第一,让拷贝操作延迟执行,程序运行之后还得切换到浏览器刷新统计界面。第二,连续执行 100 次拷贝操作,以便明显的看出差异。这样 test.js 需要改进为:

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

// 开始监控内存
var memeye = require('memeye')
memeye()

// 将拷贝操作封装到一个函数中
function copy() {
    // 这里自行补充上文的拷贝代码
    // 测试一,使用 readFile 和 writeFile 编写的拷贝代码
    // 测试二,使用 stream 编写的拷贝代码
}

// 延迟 5s 执行拷贝
setTimeout(function () {
    // 连续执行 100 次拷贝
    var i
    for (i = 0; i < 100; i++) {
        copy()
    }
}, 5000)

以上代码中,把上文使用 readFile 和 writeFile 编写的拷贝代码粘贴到 copy 函数中,然后运行 node test.js ,立刻切换到浏览器刷新页面,结果如下图。图中 heapUsed 从 5M 左右一下子飙升到了 60M 左右。

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

再把上文用 stream 编写的拷贝代码粘贴到 copy 函数中,然后重新运行 node test3.js 并立刻切换到浏览器刷新页面,结果如下图。图中 heapUsed 从 5M 左右仅仅增长到了 6M 左右。

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

对比两种情况,5M -> 60M VS 5M -> 6M 非常巨大的差异,而且文件体积越大、操作数量越多,这种差异就越明显。由此可见,使用 stream 操作文件对性能带来了非常大的提升,因此大家以后一旦遇到文件操作,应该第一时间想到 stream 并评估是否需要使用。

应用场景

所有执行文件操作的场景,都应该尝试使用 stream ,例如文件的读写、拷贝、压缩、解压、格式转换等。除非是体积很小的文件,而且读写次数很少,性能上被忽略。

近几年前端的打包构建工具都是用 node.js 编写的,而打包和构建其实就是频繁的文件操作,这其中会用到大量的 stream 。

逐行读取的最佳方案 readline

上一章讲解了用 stream 操作文件,会来带很大的性能提升。但是原生的 stream 却对"行"无能为力,它只是把文件当做一个数据流、简单粗暴的流动。很多文件格式都是分行的,例如 csv 文件、日志文件,以及其他一些自定义的文件格式。

node.js 提供了非常简单的按行读取的 API —— readline ,它本质上也是 stream ,只不过是以"行"作为数据流动的单位。

readline 的使用

相比于 stream 的 data 和 end 自定义事件, readline 需要监听 line 和 close 两个自定义事件。readline 的基本使用示例如下:

var fs = require('fs')
var path = require('path')
var readline = require('readline') // 引用 readline
// 文件名
var fileName = path.resolve(__dirname, 'readline-data.txt')
var readStream = fs.createReadStream(fileName)

// 创建 readline 对象
var rl = readline.createInterface({
    // 输入,依赖于 stream 对象
    input: readStream
})

// 监听逐行读取的内容
rl.on('line', function (lineData) {
    console.log(lineData)
})
// 监听读取完成
rl.on('close', function () {
    console.log('readline end')
})

应用场景

对于处理按行为单位的文件,如日志文件,使用 readline 是最佳选择。接下来会使用 readline 演示一个日志文件的分析,最终得出在 2018-10-23 14:00 这一分钟内,访问 user.html 的日志数量。日志格式和上文的示例一样,但是日志的内容我制造了 10w 行(文件体积 11.4M ),为了能更加真实的演示效果。

var fs = require('fs')
var path = require('path')
var readline = require('readline') // 引用 readline

var memeye = require('memeye')
memeye()

function doReadLine() {
    var fileName = path.resolve(__dirname, 'readline-data.txt')
    var readStream = fs.createReadStream(fileName)
    var rl = readline.createInterface({
        input: readStream
    })
    var num = 0

    // 监听逐行读取的内容
    rl.on('line', function (lineData) {
        if (lineData.indexOf('2018-10-30 14:00') >= 0 && lineData.indexOf('user.html') >= 0) {
            num++
        }
    })
    // 监听读取完成
    rl.on('close', function () {
        console.log('num', num)
    })
}

setTimeout(doReadLine, 5000);

使用上面的方法看一下内存占用,发现统计这个 11.4M 的日志文件,堆内存占用从 5M 仅仅上升到了 8.78M ,内存占用非常少,如下图。如果是全部把文件读取再做分析,那内存就不会这么少了。