探秘NodeJs·NodeJs的IO

91 阅读7分钟

探秘NodeJs·NodeJs的IO

什么是IO

IOInput/Output,也就是输入和输出,可以理解为把数据从一个地方拷贝到另一个地方

  • 文件IO:文件的读写就是数据在磁盘内存和磁盘之间的拷贝
  • 网络IO:网络请求是数据在网卡和内存间的拷贝

我们上一篇文章《探秘NodeJs·NodeJs进程之谜》中说过,NodeJs最擅长的场景就是I/O密集型的操作。而进行I/O(将数据从一个地方拷贝到另一个地方),最好的抽象模型就是流模型

流(Steam)

流代表着随着时间产生的数据,如我们常说的视频流,由于一个视频可能很大,如果一次性下载完才能播放很影响体验,如果播放器支持流视频的播放,那么便可以把视频切成若干个较小的片段,一部分一部分的下载播放,实现边下边播的效果。

流的抽象是:数据从A拷贝到B的过程

  • 读取流抽象的是数据的读取过程(即从目标读取数据)
    • 读取文件
    • HtmlJsCss等资源
    • 访问网络图片
    • 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);