小狗的Node笔记—— 文件流基础

338 阅读7分钟

什么是流(Stream)?

image.png

流(Stream)是计算机科学中的一种用于处理数据的抽象概念。

它允许你在传输数据的过程中,逐步读取和写入数据,而不是一次性将所有数据加载到内存中。

流是有方向的,根据数据的流动方向,可以将其分为以下几种类型:

  • 可读流(Readable Streams):数据从外部源(如文件、网络请求等)被读取到内存中。
  • 可写流(Writable Streams):数据从内存写入到外部目标(如文件、网络连接等)。
  • 双向流(Duplex Streams):从本质上来说,双向流是将 可读流 和 可写流 的功能结合到一个流对象中,允许在同一个流中进行读取和写入操作
  • 转换流(Transform Streams):继承自 Duplex 流,特点是可以在读取和写入过程中对数据进行转换。

流解决了什么问题?

先总结一下流的主要特点:

  1. 异步操作:流的操作通常是非阻塞的,也就是说,数据会按需加载,流的处理是分批次进行的。

  2. 数据逐步处理:通过流的方式,可以在接收到数据后立即进行处理,而不需要等待所有数据都到齐。

所以,流可以解决数据传输过程中的以下问题:

  1. 数据【规模】不一致:内存与其他介质(如硬盘、网络等)的数据规模差异很大,流允许我们逐步处理数据,避免一次性加载过多数据到内存中,避免内存溢出。

  2. 数据【处理能力】不一致:内存和其他介质的处理速度差异很大,流支持异步非阻塞 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 对象
  1. 事件
rs.on(事件名, () => {});

表格

事件名描述备注
open文件打开时触发被打开后触发
data当有数据可读时触发1. 反复触发;2. 回调函数参数为数据块;3.每次读取 highWaterMark 指定的数量
end所有数据读取完毕后触发在 close 之前触发
error当发生错误时触发回调函数参数为错误对象
close当文件流关闭时触发。关闭方式:1.手动关闭 rs.close;2.文件读取完成后自动关闭
  1. 常用方法
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 对象
  1. 事件
ws.on(事件名, () => {});

与可读流类似:open、error、close...

  1. 常用方法

ws.end([chunk[, encoding]][, callback])

  • chunk:要写入的数据
  • encoding:编码方式,默认为 utf8
  • callback:写入完成后的回调函数

结束写入,触发 finish 事件, 并将自动关闭文件(如果 autoClose 为默认的 true),

ws.end('写入结束');
// 文件中最后的内容是“写入结束”
// ws.on('close', () => {})可以监测到

ws.write(chunk[, encoding][, callback])

  • chunk:要写入的数据
  • encoding:编码方式,默认为 utf8
  • callback:写入完成后的回调函数

写入数据

const flag = ws.write('a');

返回布尔值,表示是否可以继续写入。如果返回 true,表示可以继续写入更多数据。如果返回 false,表示缓冲区已满,需要等待事件(如 drain)才能继续写入。

返回 true 时,写入通道没有被填满,接下来的数据可以直接写入,无须排队

image.png

如果写入通道目前已被填满,接下来的数据将进入写入队列,此时 write() 返回 false。

image.png

此时,就涉及到背压的问题

背压

因为向流写入数据的速度太快,而目标(如文件、网络连接等)无法及时处理这些数据,就会出现背压现象。此时流就会进入背压状态,直到缓冲区有足够的空间接收更多的数据。

背压的目的是防止内存被耗尽或系统崩溃,确保流系统不会在处理能力不足时继续接收数据。

在 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 文件流通过逐块读取和写入,提高了大文件的处理效率,避免了内存浪费。流式操作还提供了诸如管道传输等文件操作方式,使得文件处理更加灵活和高效。

关于文件流的优势,可以总结为以下几点:

  1. 节省内存:流式读取将文件分成小块逐步加载,每次只在内存中存储当前数据块,显著减少内存消耗。

  2. 非阻塞 I/O:Node.js 的文件流操作是非阻塞的,流的工作方式允许应用程序继续执行其他任务,而文件操作则在后台进行,提高了整体性能。

  3. 适合大文件处理:能够将视频、音频或日志文件等大文件分块处理,避免一次性加载整个文件的性能瓶颈,尤其是需要实时处理的数据或大规模数据的传输任务。