之前看别人用 Koa 实现文件下载功能,直接将 Readable Stream 传给 ctx.body
:
const fs = require('fs');
const Koa = require('koa');
const app = new Koa();
app.use(async ctx => {
try {
ctx.body = fs.createReadStream(resolve(__dirname, 'test.json'));
} catch (err) {
ctx.body = err
}
});
app.listen(3000);
当时觉得这样做很不可思议,因为我之前理解流的概念,就是监听 data
事件,然后拼接 chunk
:
const http = require('http');
http.createServer((req, res) => {
const readable = fs.createReadStream(resolve(__dirname, 'test.json'));
let data = '';
readable.on('data', (chunk) => {
data += chunk.toString();
})
readable.on('end', () => {
res.end(data);
})
}).listen(3000);
但是后来看了下 Koa 源码,发现原来是框架进行了封装:
// https://github.com/koajs/koa/blob/master/lib/application.js:256
function respond(ctx) {
...
let body = ctx.body;
if (body instanceof Stream) return body.pipe(res);
...
}
框架在返回的时候做了层判断,因为 res 是一个可写流对象,如果 body 也是一个 Stream 对象(此时的 Body 是一个可读流),则使用 body.pipe(res) 以流的方式进行响应
那么 pipe
方法的作用是什么,解决了什么问题,Stream 相比 fs.readFile
的优点在哪里?
fs.readFile 的弊端
如果使用 fs.readFile
读取文件,看似没问题,但是存在一个隐患,因为它是将数据一次性读入内存,当数据文件很大的时候会占用大量内存,导致内存泄漏,因此不推荐使用:
const http = require('http');
const path = require('path');
const fs = require('fs').promises;
http.createServer(async (req, res) => {
// 将数据一次性读入内存
// 数据文件很大会导致内存泄漏
const file = await fs.readFile(path.resolve(__dirname, 'test.json'));
res.end(file);
}).listen(3000);
Stream 的优点
在 Node.js 中有四种基本的流:
Writable
:可以写入数据的流,例如fs.createWriteStream()
;Readable
:可以从中读取数据的流。例如fs.createReadStream()
;Duplex
:可以同时作为Writable
和Readable
的流;Transform
:在Duplex
写入和读取数据时修改或转换数据的流;
这边主要介绍 Readable
和 Writable
。
流在 Node.js 中几乎随处可见,例如下面是一个简单的 HTTP 服务:
const http = require('http');
http.createServer((req, res) => {
let body = '';
req.setEncoding('utf-8');
req.on('data', (chunk) => {
body += chunk;
})
req.on('end', () => {
res.write(body);
res.end();
})
}).listen(3000);
在上面的代码中,req
就是一个 Readable
,通过监听 data
事件,读取请求体数据。而 res
则是 Writable
,负责写入数据并响应,write()
和 end()
都是 Writable
提供的 API 。
为什么说用 Stream 会更好呢?原因是 Stream 处理的是 chunk 而不是全部数据,这些 chunk 存储在内部的缓冲队列中,直到被消费,如果缓冲区的大小达到了指定阈值,Stream 将暂停读取或写入数据,直到可以消耗当前缓冲的数据。
下面的代码是一个从 text.txt
复制内容到 dest-text.txt
的例子,可以看到我们并没有一次性全部读取 text.txt
到内存中,而是读取一部分就写入一部分,不断重复这个过程,直到数据复制完毕。这样可以更加高效地利用内存,避免出现内存泄漏。
const fs = require('fs');
const path = require('path');
const readable = fs.createReadStream(path.resolve(__dirname, 'text.txt'));
const writable = fs.createWriteStream(path.resolve(__dirname, 'dest-text.txt'));
readable.on('data', (chunk) => {
writable.write(chunk);
})
readable.on('end', () => {
writable.end();
})
Stream 背压及解决方案
前面介绍了 Stream 的优点,避免了一次性加载大量数据导致内存泄漏。
但是上面的代码还是存在一个问题。我们知道数据是以流的形式从 Readable
流向 Writable
,不会全部读入内存,而通常写入磁盘速度是低于读取磁盘速度的,这样上游流速过快下游来不及消费造成数据积压,即“背压”。这个时候就需要一种平衡机制,使得数据平滑流畅的从一端流向另一端。
我们知道 Writable
对象的 write(chunk)
方法可以接受一些数据写入流,当内部缓冲区小于创建 Writable
对象时配置的 highWaterMark
则返回 true
,否则返回 false
表示内部缓冲区已满或溢出,此时就是背压的一种表现。下面我们就来处理背压:
const fs = require('fs');
const path = require('path');
function _write(dest, chunk) {
return new Promise(resolve => {
if (dest.write(chunk)) {
// 缓冲区未满,继续写入
return resolve(null);
}
// 缓冲区空间已满,暂停向流中写入数据
// 直到 drain 事件触发,表示缓冲区中的数据已经排空,可以继续写入
dest.once('drain', resolve);
})
}
function myCopy(src, dest) {
return new Promise(async (resolve, reject) => {
dest.on('error', reject);
try {
for await (const chunk of src) {
// 结合异步迭代器,不用监听 data 和 end 事件
// 如果缓冲区空间已满,等待 drain 事件触发调用 resolve
// 当缓冲区有空间可以继续写了,再次进行读取和写入
await _write(dest, chunk);
}
resolve();
} catch (err) {
reject(err);
}
})
}
const readable = fs.createReadStream(path.resolve(__dirname, 'text.txt'));
const writable = fs.createWriteStream(path.resolve(__dirname, 'dest-text.txt'));
myCopy(readable, writable);
如果我们使用 write()
方法写入数据,而没有正确的处理背压,会导致缓冲区数据溢出,后面来不及消费的数据不得不驻留在内存中,直到程序处理完毕才会被清除。整个数据积压的过程中,进程会不断消耗系统内存,对其他进程任务也会产生很大影响。
Node.js 的 Stream 模块提供的一些方法 pipe()
、pipeline()
已经为我们做了这些处理,使用这些 API 方法我们不需要自己考虑去处理“背压”的问题:
const fs = require('fs');
const path = require('path');
const stream = require('stream');
const util = require('util');
// 将 API 转换为 Promise 形式
const pipeline = util.promisify(stream.pipeline);
// readable.pipe() 方法
const readable = fs.createReadStream(path.resolve(__dirname, 'text.txt'));
const writeable = fs.createWriteStream(path.resolve(__dirname, 'dest-text.txt'));
readable.pipe(writeable);
// stream.pipeline() 方法
(async () => {
await pipeline(
fs.createReadStream(path.resolve(__dirname, 'text.txt')),
fs.createWriteStream(path.resolve(__dirname, 'dest-text.txt'))
)
console.log('Pipeline succeeded');
})()
上面的代码实现了从 Readable
到 Writable
的复制。此外如果后端需要响应一个文本内容,只需要这样写:
const http = require('http');
const fs = require('fs');
const path = require('path');
http.createServer((req, res) => {
const readable = fs.createReadStream(path.resolve(__dirname, 'text.txt'));
readable.pipe(res);
// 使用 pipe() 方法之后就不需要调用 res.end() 了
// res.end();
}).listen(3000);