流
什么是流(Stream)?
流(Stream)是计算机科学中的一种用于处理数据的抽象概念。
它允许你在传输数据的过程中,逐步读取和写入数据,而不是一次性将所有数据加载到内存中。
流是有方向的,根据数据的流动方向,可以将其分为以下几种类型:
- 可读流(Readable Streams):数据从外部源(如文件、网络请求等)被读取到内存中。
- 可写流(Writable Streams):数据从内存写入到外部目标(如文件、网络连接等)。
- 双向流(Duplex Streams):从本质上来说,双向流是将 可读流 和 可写流 的功能结合到一个流对象中,允许在同一个流中进行读取和写入操作
- 转换流(Transform Streams):继承自 Duplex 流,特点是可以在读取和写入过程中对数据进行转换。
流解决了什么问题?
先总结一下流的主要特点:
-
异步操作:流的操作通常是非阻塞的,也就是说,数据会按需加载,流的处理是分批次进行的。
-
数据逐步处理:通过流的方式,可以在接收到数据后立即进行处理,而不需要等待所有数据都到齐。
所以,流可以解决数据传输过程中的以下问题:
-
数据【规模】不一致:内存与其他介质(如硬盘、网络等)的数据规模差异很大,流允许我们逐步处理数据,避免一次性加载过多数据到内存中,避免内存溢出。
-
数据【处理能力】不一致:内存和其他介质的处理速度差异很大,流支持异步非阻塞 I/O,可以在等待数据的同时继续进行其他处理,提升效率。
文件流
文件流(File Streams)是 Node.js 中处理文件的一种方式,它允许我们以流的形式读取和写入文件。
概括来说,就是内存数据和磁盘文件数据之间的流动。
node 版本更新到现在,创建流的方法多种多样,比如 node12.x 之后的 Readable.from()方法,我们这里还是使用最基础的 callback 方式来讲解。
创建可读流
File system | Node.js v16.20.2 Documentation
fs.createReadStream(path[, options])创建文件可读流,用于读取内容。
参数:
path:读取的文件路径options:可选配置
| 配置项 | 描述 |
|---|---|
| flags | 文件系统标志,默认为 'r'(读取模式) |
| encoding | 编码方式,默认为 null |
| start | 起始字节 |
| end | 结束字节 |
| highWaterMark | 每次读取的字节数,默认为 64KB。 (encodin 有值,该数量表示一个字符数;encoding 为 null,该数量表示字节数) |
| autoClose | 是否自动关闭文件描述符,默认为 true |
返回:Readable 的子类 ReadStream 对象
- 事件
rs.on(事件名, () => {});
表格
| 事件名 | 描述 | 备注 |
|---|---|---|
| open | 文件打开时触发 | 被打开后触发 |
| data | 当有数据可读时触发 | 1. 反复触发;2. 回调函数参数为数据块;3.每次读取 highWaterMark 指定的数量 |
| end | 所有数据读取完毕后触发 | 在 close 之前触发 |
| error | 当发生错误时触发 | 回调函数参数为错误对象 |
| close | 当文件流关闭时触发。 | 关闭方式:1.手动关闭 rs.close;2.文件读取完成后自动关闭 |
- 常用方法
rs.pause();
暂停读取,触发 pause 事件。
rs.resume();
恢复读取,触发 resume 事件。
使用示例:
const fs = require('node:fs');
const path = require('path');
const filename = path.resolve(__dirname, './test.txt');
const rs = fs.createReadStream(filename, {
encoding: 'utf-8',
highWaterMark: 2, // 每次读取2个字符
});
rs.on('open', () => {
console.log('文件被打开了');
});
rs.on('error', () => {
console.log('出错了');
});
rs.on('close', () => {
console.log('文件关闭了');
});
rs.on('data', (chunk) => {
console.log('读到了一部分数据:', chunk);
if (chunk === 'tr') {
rs.pause(); //读到了 tr 就暂停
}
});
rs.on('pause', () => {
console.log('暂停了');
// 暂停1s后恢复
setTimeout(() => {
rs.resume();
}, 1000);
});
rs.on('resume', () => {
console.log('恢复了');
});
rs.on('end', () => {
console.log('全部数据读取完毕');
});
创建可写流
fs.createWriteStream(path[, options])创建文件可写流,用于写入内容。
File system | Node.js v16.20.2 Documentation
参数:
path:写入的文件路径options:可选配置
| 配置项 | 描述 |
|---|---|
| flags | 文件系统标志,(默认'w': 覆盖;'a':追加) |
| encoding | 编码方式,默认为 utf8 |
| start | 起始字节 |
| highWaterMark | 流的缓冲区大小,默认为 16KB。 当该内部缓冲区达到 highWaterMark 的限制时,write 操作会返回 false,表示写入被“暂停”。 |
| autoClose | 是否自动关闭文件描述符,默认为 true |
返回:Writable 的子类 WriteStream 对象
- 事件
ws.on(事件名, () => {});
与可读流类似:open、error、close...
- 常用方法
ws.end([chunk[, encoding]][, callback])
chunk:要写入的数据encoding:编码方式,默认为 utf8callback:写入完成后的回调函数
结束写入,触发 finish 事件, 并将自动关闭文件(如果 autoClose 为默认的 true),
ws.end('写入结束');
// 文件中最后的内容是“写入结束”
// ws.on('close', () => {})可以监测到
ws.write(chunk[, encoding][, callback])
chunk:要写入的数据encoding:编码方式,默认为 utf8callback:写入完成后的回调函数
写入数据
const flag = ws.write('a');
返回布尔值,表示是否可以继续写入。如果返回 true,表示可以继续写入更多数据。如果返回 false,表示缓冲区已满,需要等待事件(如 drain)才能继续写入。
返回 true 时,写入通道没有被填满,接下来的数据可以直接写入,无须排队
如果写入通道目前已被填满,接下来的数据将进入写入队列,此时 write() 返回 false。
此时,就涉及到背压的问题
背压
因为向流写入数据的速度太快,而目标(如文件、网络连接等)无法及时处理这些数据,就会出现背压现象。此时流就会进入背压状态,直到缓冲区有足够的空间接收更多的数据。
背压的目的是防止内存被耗尽或系统崩溃,确保流系统不会在处理能力不足时继续接收数据。
在 Node.js 中,背压问题可以通过 write() 方法的返回值和 drain 事件配合来处理:
const fs = require('node:fs');
const path = require('path');
const filename = path.resolve(__dirname, './test.txt');
const ws = fs.createWriteStream(filename, {
encoding: 'utf-8',
highWaterMark: 16 * 1024, // 缓冲区大小为16KB
});
let i = 0;
const maxWriteCount = 1024 * 64; // 最大写入次数
//一直写入a,直到到达上限,或无法再直接写入
function write() {
let flag = true;
//只要缓冲区还有空间,就继续写入a
while (i < maxWriteCount && flag) {
flag = ws.write('a');
i++;
}
console.log('i=', i);
if (i >= maxWriteCount) {
console.log('达到最大写入次数,结束写入');
ws.end();
}
}
write();
// 当缓冲区有足够的空间时,继续调用 write() 写入数据
ws.on('drain', () => {
console.log('可以继续写了');
write();
});
ws.on('finish', () => {
console.log('文件写入完成');
});
当写入队列清空时,会触发 drain 事件,表示可以继续写入数据。在 drain 事件触发时,可以调用 write() 方法继续写入数据。
管道 pipe
如果想要将可读流的数据写入到可写流,根据前面可读流和可写流的介绍,可以手动读取数据,再写入数据,同时需要考虑背压的问题。但这样太麻烦了,Node.js 提供了管道 pipe 来实现这个功能。
rs.pipe(ws)
Stream | Node.js v16.20.2 Documentation 将可读流的数据传输到可写流,并自动处理背压问题。
示例:将 input.txt 的内容复制到 output.txt
const fs = require('fs');
const path = require('path');
const inputPath = path.resolve(__dirname, 'input.txt');
const outputPath = path.resolve(__dirname, 'output.txt');
const rs = fs.createReadStream(inputPath, { encoding: 'utf8' });
const ws = fs.createWriteStream(outputPath);
// 读取数据并写入到 output.txt
rs.pipe(ws);
ws.on('finish', () => {
console.log('数据已经从 input.txt 写入到 output.txt');
});
rs.on('error', (err) => {
console.error('读取文件时出错:', err);
});
ws.on('error', (err) => {
console.error('写入文件时出错:', err);
});
像这样将一个流的输出传递给另一个流,这种做法称为管道操作,常用于读取文件并将内容写入另一个文件、HTTP 响应或进程等。
小结
Node.js 文件流通过逐块读取和写入,提高了大文件的处理效率,避免了内存浪费。流式操作还提供了诸如管道传输等文件操作方式,使得文件处理更加灵活和高效。
关于文件流的优势,可以总结为以下几点:
-
节省内存:流式读取将文件分成小块逐步加载,每次只在内存中存储当前数据块,显著减少内存消耗。
-
非阻塞 I/O:Node.js 的文件流操作是非阻塞的,流的工作方式允许应用程序继续执行其他任务,而文件操作则在后台进行,提高了整体性能。
-
适合大文件处理:能够将视频、音频或日志文件等大文件分块处理,避免一次性加载整个文件的性能瓶颈,尤其是需要实时处理的数据或大规模数据的传输任务。