探秘NodeJs·NodeJs的IO
什么是IO
IO即Input/Output,也就是输入和输出,可以理解为把数据从一个地方拷贝到另一个地方
- 文件IO:文件的读写就是数据在磁盘内存和磁盘之间的拷贝
- 网络IO:网络请求是数据在网卡和内存间的拷贝
我们上一篇文章《探秘NodeJs·NodeJs进程之谜》中说过,NodeJs最擅长的场景就是I/O密集型的操作。而进行I/O(将数据从一个地方拷贝到另一个地方),最好的抽象模型就是流模型。
流(Steam)
流代表着随着时间产生的数据,如我们常说的视频流,由于一个视频可能很大,如果一次性下载完才能播放很影响体验,如果播放器支持流视频的播放,那么便可以把视频切成若干个较小的片段,一部分一部分的下载播放,实现边下边播的效果。
流的抽象是:数据从A拷贝到B的过程
- 读取流抽象的是数据的读取过程(即从目标读取数据)
- 读取文件
Html、Js、Css等资源- 访问网络图片
- get请求接口数据
- ...
- 写入流抽象的是讲数据写入目标的过程
- 写入文件
- 发送网络数据
- 用
Post接口提交数据 - ...
NodeJs的输入流
import { Readable } from "stream";
class SteamDemo extends Readable {
// 重写父类 Readable 中的_read方法,并往流中不断地添加随机数
_read(): void {
this.push(String(Math.random()));
}
}
const steam = new SteamDemo();
// 利用流的管道模型将一个程序的输入作为另一个程序的输出
steam.pipe(process.stdout);
// 运行上述程序将会不断地在当前进程的标准输出流(控制台)中不断输出随机数
I/O的重定向
Linux中的I/O重定向
- 进程将输入给文件:
cat test.txt > test2.txt - 进程将输入给管道:
ps | grep "nginx"
NodeJs中的I/O重定向
- 将一个流的数据给另一个流:
steam.pipe(process.stdout)
进程的3个标准文件
- 标准输入流:
stdin用于向进程输入数据 - 标准输出流:
stdout作为进程的输出数据 - 标准错误流:
stderr如果进程发生了错误,会把自己的错误写入其中。
NodeJs种的三个标准文件
# 标准输入流
process.stdin
# 标准输出流
process.stdout
# 标准错误流
process.stderr
简单实现一个文件读入流
import { Readable } from "stream";
import fs from "fs";
import path from "path";
class FileReadSteam extends Readable {
/**
* 文件描述符:File Descriptor
* 当我们访问一个文件时,操作系统会给这个文件分配一个整数作为文件描述符,利用这个描述符,可以对文件进行读取、写入、删除等操作,
* 可以把它看成文件的一个id
*/
private fd: number = 0;
/**
* 用于暂存文件流的缓冲区
*/
private buf: Buffer;
/**
* 缓冲区的大小,单位是字节
*/
private readonly bufferSize = 128;
/**
* 当前读取文件块索引
*/
private i: number = 0;
/**
* 文件是否被打开了
*/
private isOpen: boolean = false;
constructor(private file: string) {
super();
this.buf = Buffer.alloc(1024 * 4);
}
_read(): void {
if(!this.fd) {
// 没有获取到文件操作符时先获取一下文件操作符
fs.open(this.file, 'r', (err, fd: number) => {
if(err) throw err;
this.isOpen = true;
// 记录文件操作符
this.fd = fd;
// 重新执行读入逻辑
this._read();
// 由于后面在没有文件描述符时先暂停了读入过程,因此,在我们获取到了文件描述符之后,需要重新唤醒读入,让其继续工作
this.resume();
});
// 由于没有文件描述符,需要先暂停读入,否则外界不断读入,一直都是没有文件描述符,就陷入死循环了
this.pause();
return;
}
fs.read(
this.fd,
this.buf,
0,
this.bufferSize,
this.bufferSize * (this.i++),
(err, bytesRead: number, buffer: Buffer) => {
if(err) throw err;
if(bytesRead === 0) {
// 如果本次读取的字节数为0,说明文件读取完成了,关闭文件
fs.close(this.fd, () => {});
this.destroy();
return;
}
this.push(buffer);
}
)
}
}
const filepath = path.resolve(__dirname, "output.json");
const frs = new FileReadSteam(filepath);
frs.pipe(process.stdout);
// [
// {"name": "kiner"},
// {"name": "kiner1"},
// {"name": "kiner2"},
// {"name": "kiner3"},
// {"name": "kiner4"}
// ]
// output.json
[
{"name": "kiner"},
{"name": "kiner1"},
{"name": "kiner2"},
{"name": "kiner3"},
{"name": "kiner4"}
]
简单实现一个文件输出流
import { Writable } from "stream";
import fs from "fs";
import path from "path";
class AppendFileSteam extends Writable {
constructor(private file: string) {
super();
}
_write(chunk: any, encoding: string, callback: (error?: Error | null | undefined) => void): void {
fs.appendFileSync(this.file, chunk, {encoding: 'utf-8'});
callback();
}
}
const afs = new AppendFileSteam(path.resolve(__dirname, "log.txt"));
const input = fs.createReadStream(path.resolve(__dirname, 'output.json'));
input.pipe(afs);
// 执行后在 log.txt 中就会出现 output.json 的内容
既支持读取又支持写入的流:双工流
流是对数据拷贝的一个封装,读取流可以作为另一个程序的输入,而写入流可以作为使用它的程序的输出。
那么,是否存在一种既支持读取又支持写入的流呢?这就有点像我们的TCP协议这种双工协议。既可以接收信息,也可以发送信息。
在NodeJs中提供了一个Duplex双工流的类,如果开发者想要实现一个既支持读取又支持写入的流的话,可以继承这个类。
那么,双工流都有哪些场景呢?
- 类似TCP协议这种,既支持接受信息,也可以发送信息的场景
- 操作一个管道文件,从头部读取,从尾部追加
注意:在实现双工流时,一定要避免读取和写入的流程不相互干扰。
缓冲区Buffer
缓冲区的作用
在流的设计中无论是读取还是写入都会用到缓冲区来存储临时数据。
之所以用缓冲区有以下原因:
- 提速:例如你在发送一个请求的时候,如果如果发送一个字节给服务端,等待服务端返回后在发送一个字节给服务端,这样明显是非常低效的,我们一般把一组数据发送过去,这样效率会更高。同理,在进行流操作时也是一样的,如果来一个字节我们就对他进行处理,处理的流程有可能很复杂或耗时,如文件存储或网络通信,这样效率就变得极低了。但我们利用一个
Buffer的缓冲区,当读取到一定的数据后在进行处理,这样就会快很多了。 - 节省内存:比如我们需要对一个
1GB的大文件进行敏感词的检索,如果通过流数据读取过来,不用缓冲区的话,我们得把整个文件都加载到内存当中才能进行检索,但如果将其加入到缓冲区,我们就可以不部分一部分的检索了,可以节省很多的内存空间。
缓冲区的编码
缓冲区中的数据是以二进制的形式存在的,可以简单的把缓冲区内部看做是一个byte[]
相同的字符串,在使用不同的字符集时数据编码的格式是不一样的。尤其是中文。因此,我们在讲缓冲区的数据跟字符串之间进行相互转换时,需要明确指定目标编码,以免出现解析乱码的情况。
// 编码
const buff = new Buffer("你好", "utf-8");
// 解码
const str = buff.toString("utf-8");
需要注意的是,上面实例中指定的"utf-8"只是告诉Buffer我传的字符串就是utf-8编码的。但Buffer并不会去校验你传入的是否真的是utf-8编码的数据。如果你传入的字符串不是utf-8,但后面标注又是utf-8,就会出现乱码的情况。举一个简单的例子:你的代码编辑器,如VSCode所使用的编码是GBK的,你在写代码时输出的“你好”自然就是GBK编码的,此时你告诉Buffer传入的是utf-8编码的字符串,当你用utf-8编码解析的时候,解析出来的就是一串乱码。
缓冲区的操作
创建缓冲区
- Buffer.from()
- Buffer.alloc()
- Buffer.allocUnsafe()
// 第一种
const buff1 = Buffer.from("你好")
// 第二种(初始时每个字节都是0)
const buff2 = Buffer.alloc(1024*4)
// 第三种(每个字节的值是不确定的)
const buff3 = Buffer.allocUnsafe(1024*4);