NodeJS —— 流的原理分析与简易实现

GPT Image 2 资源索引库:能力边界、提示词工程与API接入指南 AI图像生成正从随机抽卡走向对话式迭代,GPT Image 2通过增强的指令跟随与多轮编辑能力,让用户可以在保持构图与风格的基础上精准修改细节。然而,要真正发挥模型潜力,不仅需要理解能力边界,还需掌握结构化提示词工程与API接入方法。从基础句式到风格层级,从局部编辑到批量生成,将模型嵌入设计、电商、内容创作等工作流,能够大幅提升出图效率与一致性。本文介绍的awesome-gpt-image-2资源索引库,正是围绕能力认知、提示词技巧、API接入、行业案例与配套工具而建,提供可验证的优质资源与避坑指南,帮助不 阅读详情

在这里插入图片描述


阅读原文


前言

在之前的博客中已经了解了流的基本用法,这篇的重点在于根据可读流的用法对可读流的原理进行分析,实现简易版的 ReadStream,流的基本用法请看这里 NodeJS —— Stream 的基本使用。


可读流的实现(流动模式)

1、ReadStream 类创建

在使用 fs 的 createReadStream 创建可读流时,返回了 ReadStream 对象,上面存在着一些事件和方法,其实我们在创建这个可读流的时候创建了某一个类的实例,这个实例可以调用类原型上的方法,我们这里将这个类命名为 ReadStream。

在类原型上的方法内部可能会创建一些事件,在 NodeJS 中,事件是依赖 events 模块的,即 EventEmitter 类,同时类的方法可能会操作文件,会用到 fs 模块,所以也提前引入 fs。

// 创建 ReadStream 类
// 引入依赖模块
const EventEmitter = require("events");
const fs = require("fs");

// 创建 ReadStream 类
class ReadStream extends EventEmitter {
    constructor(path, options = {}) {
        super();
        // 创建可读流参数传入的属性
        this.path = path; // 读取文件的路径
        this.flags = options.flags || "r"; // 文件标识位
        this.encoding = options.encoding || null; // 字符编码
        this.fd = options.fd || null; // 文件描述符
        this.mode = options.mode || 0o666; // 权限位
        this.autoClose = options.autoClose || true; // 是否自动关闭
        this.start = options.start || 0; // 读取文件的起始位置
        this.end = options.end || null; // 读取文件的结束位置(包含)
        this.highWaterMark = options.highWaterMark || 64 * 1024; // 每次读取文件的字节数

        this.flowing = false; // 控制当前是否是流动状态,默认为暂停状态
        this.buffer = Buffer.alloc(this.highWaterMark); // 存储读取内容的 Buffer
        this.pos = this.start; // 下次读取文件的位置(变化的)

        // 创建可读流要打开文件
        this.open();

        // 如果监听了 data 事件,切换为流动状态
        this.on("newListener", type => {
            if (type === "data") {
                this.flowing = true;

                // 开始读取文件
                this.read();
            }
        });
    }
}

// 导出模块
module.exports = ReadStream;

使用 fs.createReadStream 时传入了两个参数,读取文件的路径和一个 options 选项,options 上有八个参数,我们在创建 ReadStream 类的时候将这些参数初始化到了 this 上。

创建可读流的时候有两种状态,流动状态和暂停状态,默认创建可读流是暂停状态,只有在触发 data 事件时才会变为流动状态,所以在 this 上挂载了 flowing 存储当前的状态是否为流动状态,值默认为 false。

注意:这里说的暂停状态不是暂停模式,暂停模式是 readable, 是可读流的另一种模式,我们这节讨论的可读流为流动模式。

在读取文件时其实是操作 Buffer 进行读取的,需要有一个 Buffer 实例用来存储每次读取的数据,所以在 this 上挂载了一个新创建的 Buffer,长度等于 highWaterMark。

当从 start 值的位置开始读取文件,下一次读取文件的位置会发生变化,所以在 this 上挂载了 pos 属性,用于存储下次读取文件的位置。

在创建 ReadStream 的实例(可读流)时,应该打开文件并进行其他操作,所以在 this 上挂载了 open 方法并执行。

创建实例的同时监听了 newListener 事件,回调在每次使用 on 监听事件时触发,回调内部逻辑是为了将默认的暂停状态切换为流动状态,因为在使用时,流动状态是通过监听 data 事件触发的,在 newListener 的回调中判断事件类型为 data 的时候将 flowing 标识的值更改为 true,并调用读取文件的 read 方法。

在使用 ES6 的类编程时,原型上的方法都是写在 class 内部,我们下面为了把原型上的方法拆分出来成为单独的代码块,都使用 ReadStream.prototype.open = function... 直接给原型添加属性的方式,但这样的方式和直接写在 class 内有一点区别,就是 class 内部的书写的原型方法都是不可遍历的,添加属性的方式创建的方法都是可遍历的,但是这点区别对我们代码的执行没有任何影响。

2、打开文件方法 open 的实现

在使用可读流时,打开时默认是暂停状态,会触发 open 事件,如果打开文件出错会触发 error 事件。

// open 方法
// 打开文件
ReadStream.prototype.open = function() {
    fs.open(this.path, this.flags, this.mode, (err, fd) => {
        if (err) {
            this.emit("error", err);

            // 如果文件打开了出错,并配置自动关闭,则关掉文件
            if (this.autoClose) {
                // 关闭文件(触发 close 事件)
                this.destroy();

                // 不再继续执行
                return;
            }
        }
        // 存储文件描述符
        this.fd = fd;

        // 成功打开文件后触发 open 事件
        this.emit("open");
    });
};

open 方法的逻辑就是在打开文件的时候,将文件描述符存储在实例上方便后面使用,并使用 EventEmitter 的原型方法 emit 触发 open 事件,如果出错就使用 emit 触发 error 事件,如果配置 autoClose 参数为 true,就关闭文件并触发 close。

我们将关闭文件的逻辑抽取出来封装在了 ReadStream 类的 destroy 方法中,下面来实现 destroy。

3、关闭文件方法 destroy 的实现

文件出错分为两种,第一种文件打开出错,第二种是文件不存在出错(没打开),第二种系统是没有分配文件描述符的。

// detroy 方法
// 关闭文件
ReadStream.prototype.detroy = function() {
    // 判断是否存在文件描述符
    if (typeof this.fd === "number") {
        // 存在则关闭文件并触发 close 事件
        fs.close(fd, () => {
            this.emit("close");
        });
        return;
    }

    // 不存在文件描述符直接触发 close 事件
    this.emit("close");
};

如果是打开文件后出错需要关闭文件,并触发 close 事件,如果是没打开文件,则直接触发 close 事件,所以上面通过文件描述符来判断该如何处理。

4、读取文件方法 read 的实现

还记得在 ReadStream 类中,监听的 newListener 事件的回调中如果监听了 data 事件则会执行 read 读取文件,接下来就实现读取文件的核心逻辑。

// read 方法
// 读取文件
ReadStream.prototype.read = function() {
    // 由于 open 异步执行,read 是在创建实例时同步执行
    // read 执行可能早于 open,此时不存在文件描述符
    if (typeof this.fd !== "number") {
        // 因为 open 用 emit 触发了 open 事件,所以在这是重新执行 read
        return this.once("open", () => this.read());
    }

    // 如过设置了结束位置,读到结束为止就不能再读了
    // 如果最后一次读取真实读取数应该小于 highWaterMark
    // 所以每次读取的字节数应该和 highWaterMark 取最小值
    let howMuchToRead = this.end
        ? Math.min(this.highWaterMark, this.end - this.pos + 1)
        : this.highWaterMark;

    // 读取文件
    fs.read(
        this.fd,
        this.buffer,
        0,
        howMuchToRead,
        this.pos,
        (err, bytesRead) => {
            // 如果读到内容执行下面代码,读不到则触发 end 事件并关闭文件
            if (bytesRead > 0) {
                // 维护下次读取文件位置
                this.pos += bytesRead;

                // 保留有效的 Buffer
                let realBuf = this.buffer.slice(0, bytesRead);

                // 根据编码处理 data 回调返回的数据
                realBuf = this.encoding
                    ? realBuf.toString(this.encoding)
                    : realBuf;

                // 触发 data 事件并传递数据
                this.emit("data", realBuf);

                // 递归读取
                if (this.flowing) {
                    this.read();
                }
            } else {
                this.isEnd = true;
                this.emit("end"); // 触发 end 事件
                this.detroy(); // 关闭文件
            }
        }
    );
};

创建 ReadStream 的实例时,执行的 open 方法内部是使用 fs.open 打开文件的,是异步操作,而读取文件方法 read 是在 newListener 回调中同步执行的,这样很可能触发 read 的时候文件还没有被打开(不存在文件描述符),所以在 read 方法中判断了文件描述符是否存在,并在不存在时候使用 once 添加了 open 事件,回调中重新执行了 read。

由于在 open 方法中使用 emit 触发了 open 事件,所以 read 内用 once 添加的 open 事件的回调也会跟着执行一次,并在回调中重新调用了 read 方法,保证了 read 读取文件的逻辑在文件真正打开后才执行,为了文件打开前执行 read 而不执行读取文件的逻辑,用 once 添加 open 事件时别忘记 return。

在使用 fs.read 读取文件的时候有一个参数为本次读取几个字符到 Buffer 中,如果在创建可读流的时候设置了读取文件的结束位置 end 参数,则读到 end 位置就不应该再继续读取了,所以在存在 end 参数的时候每次都计算一下读取个数和 highWaterMark 取最小值,保证读取内容小于 highWaterMark 的时候不会多读,因为读取时是包括 end 值作为 Buffer 的索引这一项的,所以计算时多减去的要 +1 加回来,再一次读取这个读取个数计算结果变成了 0,也就结束了读取。

因为 end 参数的情况,所以在内部读取逻辑前判断了 bytesRead (实际读取字节数)是否大于 0,如果不满足条件则在实例添加是否读取结束标识 isEnd(后面使用),触发 end 事件并关闭文件,如果满足条件,也是通过 bytesRead 对 Buffer 进行截取,保留了有用的 Buffer,并且通过 encoding 编码对 Buffer 进行处理后,触发 data 事件,并将处理后的数据传递给 data 事件的回调。

5、暂停、恢复读取 pause 和 resume

pause 的目的就是暂停读取,其实就是阻止 read 方法在读取时进行递归,所以只需要更改 flowing 的值即可。

// pause 方法
// 暂停读取
ReadStream.prototype.pause = function() {
    this.flowing = false;
};

resume 的目的是恢复读取,在更改 flowing 值得基础上重新执行 read 方法,由于在 pause 调用时 read 内部还是执行得读取文件得分支,文件并没有关闭,读取文件位置的参数也是通过实例上的当前的属性值进行计算的,所以重新执行 read 会继续上一次的位置读取。

// resume 方法
// 恢复读取
ReadStream.prototype.resume = function() {
    this.flowing = true;
    if (!this.isEnd) this.read();
};

上面在重新执行 read 之前使用 isEnd 标识做了判断,防止在 setInterval 中调用 resume 在读取完成后不断的触发 end 和 close 事件。


验证可读流(流动模式)ReadStream

接下来我们使用自己实现的 ReadStream 类来创建可读流,并按照 fs.createReadStream 的用法进行使用并验证。

// 验证 ReadStream
// 文件 1.txt 内容为 0123456789
const fs = require("fs");
const ReadStream = require("./ReadStream");

// 创建可读流
let rs = new ReadStream("1.txt", {
    encoding: "utf8",
    start: 0,
    end: 5,
    highWaterMark: 2
});

rs.on("open", () => console.log("open"));

rs.on("data", data => {
    console.log(data, new Date());
    rs.pause();
});

rs.on("end", () => console.log("end"));
rs.on("close", () => console.log("close"));
rs.on("error", err => console.log(err));

setInterval(() => rs.resume(), 1000);

// open
// 01 2018-07-04T10:44:20.384Z
// 23 2018-07-04T10:44:21.384Z
// 45 2018-07-04T10:44:22.384Z
// end
// close

执行上面的代码正常的执行逻辑是先触发 open 事件,然后触发 data 事件,读取一次后暂停,每隔一秒恢复读取一次,再读取完成后触发 end 和 close 事件,通过运行代码结果和我们希望的一样。


可读流的实现(暂停模式)

1、在 fs 中的暂停模式的真正用法

在 fs 模块中用 createReadStream 创建的可读流中通过监听 readable 事件触发暂停模式(监听 data 事件触发流动模式),通过下面例子感受暂停模式与流动模式的不同,现在读取文件 1.txt,内容为 0~9 十个数字。

// 暂停模式的用法
// 读取的
const fs = require("fs");

// 创建可读流
let rs = fs.createReadStream("1.txt", {
    encoding: "utf8",
    start: 0,
    highWaterMark: 3
});

rs.on("readable", () => {
    // read 参数为本次读取的个数
    let r = rs.read(3);
    // 打印读取的数据
    console.log(r);
    // 打印容器剩余空间
    console.log(rs._readableState.length);
});

// 012
// 0
// 345
// 0
// 678
// 0
// null
// 1
// 90
// 0

通俗的解释,暂停模式的 readable 事件默认会触发一次,监听 readable 事件后就像创建了一个 “容器”,容量为 highWaterMark,文件中的数据会自动把容器注满,调用可读流的 read 方法读取时,会从容器中取出数据,如果 read 方法读取的数据小于 highWaterMark,则直接暂停,不再继续读取,如果大于 highWaterMark ,说明 “容器” 空了,则会触发 readable 事件,无论读取字节数与 highWaterMark 关系如何,只要 “容器” 内容量剩余小于 highWaterMark 就会进行 “续杯”,再次向 “容器” 中填入 highWaterMark 个,所以有些时候真实的容量会大于 highWaterMark。

read 方法读取的内容会返回 null 是因为容器内真实的数据数小于了读取数,如果不是最后一次读取,会在多次读取后将值一并返回,如果是最后一次读取,会把剩余不足的数据返回。

1、readable 事件的触发条件:“容器” 空了;
2、“续杯” 条件:读取后 “容器” 内剩余量小于 highWaterMark。
3、read 返回 null:“容器” 容器内可悲读取数据无法满足一次读取字节数。

2、ReadableStream 类的实现

同为可读流,暂停模式与流动模式相同,都依赖 fs 模块和 events 模块的 EventEmitter 类,参数依然为读取文件的路径和 options。

// 创建 ReadableStream 类
// 引入依赖
const EventEmitter = require("events");
const fs = require("fs");

class ReadableStream extends EventEmitter {
    constructor(path, options = {}) {
        super();
        this.path = path; // 读取文件的路径
        this.flags = options.flags || "r"; // 文件标识位
        this.encoding = options.encoding || null; // 字符编码
        this.fd = options.fd || null; // 文件描述符
        this.mode = options.mode || 0o666; // 权限位
        this.autoClose = options.autoClose || true; // 是否自动关闭
        this.start = options.start || 0; // 读取文件的起始位置
        this.highWaterMark = options.highWaterMark || 64 * 1024; // 每次读取文件的字节数

        this.reading = false; // 如果正在读取,则不再读取
        this.emitReadable = false; // 当缓存区的长度等于 0 的时候, 触发 readable
        this.arr = []; // 缓存区
        this.len = 0; // 缓存区的长度
        this.pos = this.start; // 下次读取文件的位置(变化的)

        // 创建可读流要打开文件
        this.open();

        this.on("newListener", type => {
            if (type === "readable") {
                this.read(); // 监听readable就开始读取
            }
        });
    }
}

// 导出模块
module.exports = ReadableStream;

在类的添加了 newListener 事件,在回调中判断是否监听了 readable 事件,如果监听了开始从 “容器” 中读取。

3、打开、关闭文件 open 和 detroy

打开和关闭文件的方法和流动模式的套路基本相似。

// open 方法
// 打开文件
ReadableStream.prototype.open = function() {
    fs.open(this.path, this.flags, this.mode, (err, fd) => {
        if (err) {
            this.emit("error", err);
            if (this.autoClose) {
                this.destroy();
                return;
            }
        }
        this.fd = fd;
        this.emit("open");
    });
};
// detroy 方法
// 关闭文件
ReadableStream.prototype.detroy = function() {
    if (typeof this.fd === "number") {
        fs.close(fd, () => {
            this.emit("close");
        });
        return;
    }
    this.emit("close");
};

4、从 “容器” 中读取 read 方法的实现

read 方法的参数不传时就相当于从 “容器” 读取 highWaterMart 个字节,如果传参表示读取参数数量的字节数。

// read 方法
ReadableStream.prototype.read = function(n) {
    // 如果读取大于了 highWaterMark,重新计算 highWaterMark,并重新读取
    if (n > this.len) {
        // 计算新的 highWaterMark,方法摘自 NodeJS 源码
        this.highWaterMark = computeNewHighWaterMark(n);
        this.reading = true;
        this._read();
    }

    // 将要返回的数据
    let buffer;

    // 如果读取的字节数大于 0 小于等于当前缓存 Buffer 的总长度
    if (n > 0 && n <= this.len) {
        // 则从缓存中取出
        buffer = Buffer.alloc(n);

        let current; // 存储每次从缓存区读出的第一个 Buffer
        let index = 0; // 每次读取缓存 Buffer 的索引
        let flag = true; // 是否结束整个 while 循环的标识

        // 开始读取
        while ((current = this.arr.shift()) && flag) {
            for (let i = 0; i < current.length; i++) {
                // 将缓存中取到的 Buffer 的内容读到自己定义的 Buffer 中
                buffer[index++] = current[i];

                // 如果当前索引值已经等于了读取个数,结束 for 循环
                if (index === n) {
                    flag = false;

                    // 取出当前 Buffer 没有消耗的
                    let residue = current.slice(i + 1);

                    // 在读取后维护缓存的长度
                    this.len -= n;

                    // 如果 BUffer 真的有剩下的就给塞回到缓存中
                    if (residue.length) {
                        this.arr.unshift(residue);
                    }

                    break;
                }
            }
        }
    }

    // 如果当前 读取的 Buffer 为 0,将触发 readable 事件
    if (this.len === 0) {
        this.emitReadable = true;
    }

    // 如果当前的缓存区大小小于 highWaterMark,就要读取
    if (this.len < this.highWaterMark) {
        // 如果不是正在读取才开始读取
        if (!this.read) {
            this.reading = true;
            this._read(); // 正真读取的方法
        }
    }

    // 将 buffer 转回创建可读流设置成的编码格式
    if (buffer) {
        buffer = this.encoding ? buffer.toString(this.encoding) : buffer;
    }

    return buffer;
};

上面的 read 方法的参数大小对比缓存区中取出的 Buffer 长度有两种情况,一种是小于当前缓存区内取出 Buffer 的长度,一种是大于了真个缓存区的 len 的长度。

小于当前缓存区总长度通过循环取出需要的 Buffer 存储了我们要返回创建的 Buffer 中,剩余的 Buffer 会丢失,所以我们做了一个小小的处理,将剩下的 Buffer 作为第一个 Buffer 塞回到了缓存区中,在处理这个问题时与流动模式不相同,流动模式处理后直接跳出了,而暂停模式相当于从 “容器” 中读取,如果第一次读取后还有剩余还要接着从容器中继续读取。

大于 len 属性时,规定需要重新计算 highWaterMark,遵循的原则是将当前 highWaterMark 设定为当前读取字节个数距离最接近的 2 的 n 次方的数值,NodeJS 源码中方法名称为 computeNewHighWaterMark,为了提高性能是使用位运算的方式进行计算的,源码如下。

// 重新计算 highWaterMark
function computeNewHighWaterMark(n) {
    n--;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    n++;
    return n;
}

在调用该方法重新计算 highWaterMark 后更改正在读取状态,重新读取,由于读取逻辑的重复,所以真正读取文件的逻辑抽取成一个 _read 方法来实现,下面呢就来看看 _read 内部都做了什么。

5、真正读取文件的 _read

对比可读流(流动模式)的 read 方法,在调用 _read 方法读取时,是在 newListener 中同步执行 _read,所以为了保证 _read 的逻辑是在 open 方法打开文件以后执行,使用了与 read 相同的处理方式。

// _read 方法
ReadableStream.prototype._read = function() {
    if (typeof this.fd !== "number") {
        return this.once("open", () => _read());
    }

    // 创建本次读取的 Buffer
    let buffer = Buffer.alloc(this.highWaterMark);

    // 读取文件
    fs.read(
        this.fd,
        buffer,
        0,
        this.highWaterMark,
        this.pos,
        (err, bytesRead) => {
            if (bytesRead > 0) {
                this.arr.push(buffer); // 缓存
                this.len += bytesRead; // 维护缓存区长度
                this.pos += bytesRead; // 维护下一次读取位置
                this.reading = false; // 读取完毕

                // 触发 readable 事件
                if (this.emitReadable) {
                    // 触发后更改触发状态为 false
                    this.emitReadable = false;
                    this.emit("readable");
                }
            } else {
                // 如果读完触发结束事件
                this.emit("end");
            }
        }
    );
};

由于缓存区是一个数组,存储的每一个 Buffer 是独立存在的,所以不能挂载在实例上共用,如果挂在实例上则引用相同,一动全动,这不是我们想要的,所以每一次执行 _read 方法时都创建新的 Buffer 实例存入读取的数据后存储在缓存区中,如果读取完成 bytesRead 为 0,则触发 end 事件。

注意:在 NodeJS 源码中,可读流的两种模式代码都是混合在一起的,只是使用 fs.createReadStream 创建一个可读流,通过监听 data 和 readable 两种不同的事件来触发两种不同的模式,而我们为了模拟,把两种模式拆开成了两个类来实现的,在测试时需要创建不同类的实例。


验证可读流(暂停模式)ReadableStream

为了统一我们依然读取真正用法中 1.txt 文件,内容为 0~9 十个数字。

// 验证 ReadableStream
// 引入依赖
const fs = require("fs");
const ReadableStream = require("./ReadableStream");

let rs = new ReadableStream("1.txt", {
    encoding: "utf8",
    start: 0,
    highWaterMark: 3
});

rs.on("readable", () => {
    let r = rs.read(3);
    console.log(r);
    console.log(rs.len);
});

在打印 “容器” 剩余容量时,我们使用在 ReadableStream 上构造的 len 属性。

流动模式和暂停模式分别有不同的应用场景,如果只是希望读取一个文件,并最快的获得结果使用流动模式是很好的选择,如果希望了解读取文件的具体内容,并进行精细的处理,使用暂停模式更好一些。


可写流的实现

1、WriteStream 类创建

在使用 fs 的 createWriteStream 创建可写流时,返回了 WriteStream 对象,上面也存在事件和方法,创建可写流的时也是创建类的实例,我们将这个类命名为 WriteStream。事件同样依赖 events 模块的 EventEmitter 类,文件操作同样依赖 fs 模块,所以需提前引入。

// 创建 WriteStream 类
// 引入依赖模块
const EventEmitter = require("events");
const fs = require("fs");

// 创建 WriteStream 类
class WriteStream extends EventEmitter {
    constructor(path, options = {}) {
        super();
        // 创建可写流参数传入的属性
        this.path = path; // 写入文件的路径
        this.flags = options.flags || "w"; // 文件标识位
        this.encoding = options.encoding || "utf8"; // 字符编码
        this.fd = options.fd || null; // 文件描述符
        this.mode = options.mode || 0o666; // 权限位
        this.autoClose = options.autoClose || true; // 是否自动关闭
        this.start = options.start || 0; // 写入文件的起始位置
        this.highWaterMark = options.highWaterMark || 16 * 1024; // 对比写入字节数的标识

        this.writing = false; // 是否正在写入
        this.needDrain = false; // 是否需要触发 drain 事件
        this.buffer = []; // 缓存,正在写入就存入缓存中
        this.len = 0; // 当前缓存的个数
        this.pos = this.start; // 下次写入文件的位置(变化的)

        // 创建可写流要打开文件
        this.open();
    }
}

// 导出模块
module.exports = WriteStream;

使用 fs.createWriteStream 创建可写流时传入了两个参数,写入的文件路径和一个 options 选项,options 上有七个参数,我们在创建 ReadStream 类的时候将这些参数初始化到了 this 上。

创建可写流后需要使用 write 方法进行写入,写入时第一次会真的通过内存写入到文件中,而再次写入则会将内容写到缓存中,注意这里的 “内存” 和 “缓存”,内存是写入文件是的系统内存,缓存是我们自己创建的数组,第一次写入以后要写入文件的 Buffer 都会先存入这个数组中,这个数组名为 buffer,挂载在实例上,实例上同时挂载了 len 属性用来存储当前缓存中 Buffer 总共的字节数(长度)。

我们在可读流上挂载了是否正在写入的状态 writing 属性,只要缓存区中存在未写入的 Buffer,writing 的状态就是正在写入,当写入的字节数大于了 highWaterMark 需要触发 drain 事件,所以又挂载了是否需要触发 drain 事件的标识 needDrain 属性。

当从文件的 start 值对应的位置开始写入,下一次写入文件的位置会发生变化,所以在 this 上挂载了 pos 属性,用于存储下次写入文件的位置。

在 NodeJS 流的源码中缓存是用链表实现的,通过指针来操作缓存中的 Buffer,而我们为了简化逻辑就使用数组来作为缓存,虽然性能相对链表要差。

2、打开、关闭文件 open 和 detroy

在 WriteStream 中,写入文件之前也应该打开文件,在打开文件过程中出错时也应该触发 error 事件并关闭文件,打开和关闭文件的方法 open 和 detroy 与 ReadStream 的 open 和 detroy 方法的逻辑如出一辙,所以这里直接拿过来用了。

// open 方法
// 打开文件
WriteStream.prototype.open = function() {
    fs.open(this.path, this.flags, this.mode, (err, fd) => {
        if (err) {
            this.emit("error", err);
            if (this.autoClose) {
                this.destroy();
                return;
            }
        }
        this.fd = fd;
        this.emit("open");
    });
};
// detroy 方法
// 关闭文件
WriteStream.prototype.detroy = function() {
    if (typeof this.fd === "number") {
        fs.close(fd, () => {
            this.emit("close");
        });
        return;
    }
    this.emit("close");
};

3、写入文件方法 write 的实现

write 方法默认支持传入三个参数:

  • chunk:写入文件的内容;
  • encoding:写入文件的编码格式;
  • callback:写入成功后执行的回调。
// write 方法
// 写入文件的方法,只要逻辑为写入前的处理
WriteStream.prototype.write = function(
    chunk,
    encoding = this.encoding,
    callback
) {
    // 为了方便操作将要写入的数据转换成 Buffer
    chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);

    // 维护缓存的长度
    this.len += chunk.lenth;

    // 维护是否触发 drain 事件的标识
    this.needDrain = this.highWaterMark <= this.len;

    // 如果正在写入
    if (this.writing) {
        this.buffer.push({
            chunk,
            encoding,
            callback
        });
    } else {
        // 更改标识为正在写入,再次写入的时候走缓存
        this.writing = true;
        // 如果已经写入清空缓存区的内容
        this._write(chunk, encoding, () => this.clearBuffer());
    }

    return !this.needDrain;
};

与可写流的 read 一样,我们在使用 write 方法将数据写入文件时,也是操作 Buffer,在 write 方法中,首先将接收到的要写入的数据转换成了 Buffer,因为是多次写入,要知道缓存中 Buffer 字节数的总长度,所以维护了 len 变量。

我们的 WriteStream 构造函数中,this 挂载了 needDrain 属性,在使用 fs.createWriteStream 创建的可读流时,是写入的字节长度超过 highWaterMark 才会触发 drain 事件,而 needDrain 与 write 的返回值正好相反,所以我们用 needDrain 取反来作为 write 方法的返回值。

在写入的逻辑中第一次是直接通过内存写入到文件,但是再次写入就需要将数据存入缓存,将数据写入到文件中写入状态 writing 默认为 false,通过缓存再写入证明应该正在写入中,所以在第一次写入后应更改 writing 的状态为 true,写入缓存其实就是把转换的 Buffer、编码以及写入成功后要执行的回调挂在一个对象上存入缓存的数组 buffer 中。

我们把真正写入文件的逻辑抽取成一个单独的方法 _write,并传入 chunk(要写入的内容,已经处理成 Buffer)、encoding(字符编码)、回调函数,在回调函数中执行了原型方法 clearBuffer,接下来就来实现 _write 和 clearBuffer。

_注意:方法使用 _ 开头代表私有方法,轻易不要在外部调用或修改,这是一个开发者之间约定俗成的不成文规定。_

4、真正的文件操作 _write

对比可读流(流动模式)的 read 方法,在调用 _write 方法写入时,是在创建可写流之后的同步代码中执行的,与可读流在 newListener 中同步执行 read 的情况类似,所以为了保证 _write 的逻辑是在 open 方法打开文件以后执行,使用了与 read 相同的处理方式。

// _write 方法
// 真正的写入文件操作的方法
WriteStream.prototype._write = function(chunk, encoding, callback) {
    // 由于 open 异步执行,write 是在创建实例时同步执行
    // write 执行可能早于 open,此时不存在文件描述符
    if (typeof this.fd !== "number") {
        // 因为 open 用 emit 触发了 open 事件,所以在这是重新执行 write
        return this.once("open", () => this._write(chunk, encoding, callback));
    }

    // 读取文件
    fs.write(this.fd, chunk, 0, chunk.length, this.pos, (err, bytesWritten) => {
        // 维护下次写入的位置和缓存区 Buffer 的总字节数
        this.pos += bytesWritten;
        this.len -= bytesWritten;
        callback();
    });
};

在打开文件并写入的时候需要维护两个变量,下次写入的位置 pos 和当前缓存区内 Buffer 所占总字节数 len,本次写入了多少个字节,下次写入需要在写入位置的基础上加多少个字节,而 len 恰恰相反,本次写入了多少个字节,缓存区中的总长度应该对应的减少多少个字节。

在维护两个变量的值以后调用 callback,其实 callback 内执行的是 clearBuffer 方法,就如方法名,译为 “清空缓存”,其实就是一次一次的将数据写入文件并从缓存中移除,很明显需要递归调用 _write 方法,我们将这个递归的逻辑统一放在 clearBuffer 方法中实现。

5、清空缓存操作 clearBuffer

// clearBuffer 方法
// 清空缓存方法
WriteStream.prototype.clearBuffer = function() {
    // 先写入的在数组前面,从前面取出缓存中的 Buffer
    let buf = this.buffer.shift();

    // 如果存在 buf,证明缓存还有 Buffer 需要写入
    if (buf) {
        // 递归 _write 按照编码将数据写入文件
        this._write(buf.chunk, buf.encoding, () => this.clearBuffer);
    } else {
        // 如果没有 buf,说明缓存内的内容已经完全写入文件并清空,需要触发 drain 事件
        this.emit("drain");

        // 更改正在写入状态
        this.writing = false;

        // 更改是否需要触发 drain 事件状态
        this.needDrain = false;
    }
};

clearBuffer 方法中获取了缓存区数组的最前面的 Buffer(最前面的是先写入缓存的,也应该先取出来写入文件),存在这个 Buffer 时,递归 _write 方法按照编码将数据写入文件,如果不存在说明缓存区已经清空了,代表内容完全写入文件中,所以触发 drain 事件,最后更改了 writing 和 needDrain 的状态。

更正 writing 是为了 WriteStream 创建的可读流在下次调用 write 方法时默认第一次真正写入文件,而更正 needDrain 的状态是在缓存区要清空的最后一个 Buffer 的长度小于了 highWaterMark 时,保证 write 方法的返回值是正确的。

第一次是真正写入,其他的都写入缓存,再一个一个的将缓存中存储的 Buffer 写入并从缓存清空,之所以这样设计是为了把写入的内容排成一个队列,假如有 3 个人同时操作一个文件写入内容,只有第一个人是真的写入,其他的人都写在缓存中,再按照写入缓存的顺序依次写入文件,避免冲突和写入顺序出错。


验证可写流 WriteStream

接下来我们使用自己实现的 WriteStream 类来创建可写流,并按照 fs.createWriteStream 的用法进行使用并验证。

// 验证 WriteStream
// 向 1.txt 文件中写入 012345
const fs = require("fs");
const WriteStream = require("./WriteStream");

// 创建可写流
let ws = new WriteStream("2.txt", {
    highWaterMark: 3
});

let i = 0;

function write() {
    let flag = true;
    while (i <= 6 && flag) {
        i++;
        flag = ws.write(i + "", "utf8");
    }
}

ws.on("drain", function() {
    console.log("写入成功");
    write();
});
write();

// true
// true
// false
// 写入成功
// true
// true
// false
// 写入成功

可以使用 fs.createWriteStream 和 WriteStream 类分别执行上面的代码,对比结果,看看是否相同。


可读流和可写流的桥梁 pipe

可写流和可读流一般是通过 pipe 配合来使用的,pipe 方法是可读流 ReadStream 的原型方法,参数为一个可写流。

// pipe 方法
// 连接可读流和可写流的方法 pipe
ReadStream.prototype.pipe = function(dest) {
    // 开始读取
    this.on("data", data => {
        // 如果超出可写流的 highWaterMark,暂停读取
        let flag = dest.write(data);
        if (!flag) this.pause();
    });

    dest.on("drain", () => {
        // 当可写流清空内存时恢复读取
        this.resume();
    });

    this.on("end", () => {
        // 在读取完毕后关闭文件
        this.destroy();
    });
};

pipe 方法其实就是通过可读流的 data 事件触发流动状态,并用可写流接收读出的数据进行写入,当写入数据超出 highWaterMark,则暂停可读流的读取,直到可写流的缓存被清空并把内容写进文件后,恢复可读流的读取,当读取结束后关闭文件。

下面我们实现一个将 1.txt 的内容拷贝 2.txt 中的例子。

// 验证 pipe
// pipe 的使用
const fs = require("fs");

// 引入自己的 ReadStream 类和 WriteStream 类
const ReadStream = rquire("./ReadStream");
const WriteStream = rquire("./WriteStream");

// 创建可读流和可写流
let rs = new ReadStream("1.txt", {
    highWaterMark: 3
});
let ws = new WriteStream("2.txt", {
    highWaterMark: 2
});

// 使用 pipe 实现文件内容复制
rs.pipe(ws);

总结

在 NodeJS 源码中,可读流和可写流的内容要比本篇内容多很多,本篇是将源码精简,抽出核心逻辑并针对流的使用方式进行实现,主要目的是帮助理解流的原理和使用,争取做到 “知其然知其所以然”,了解了一些底层再对流使用时,也能游刃有余。


Express 源码分析及简易封装 原文出自:https://www.pandashen.com 前言 Express 是 NodeJS 的 Web 框架,与 Koa 的轻量相比,功能要更多一些,依然是当前使用最广泛的 NodeJS 框架,本篇参考 Express 的核心逻辑来实现一个简易版,Express 源码较多,逻辑复杂,看一周可能也看不完,如果你已经使用过 Express,又想快速的了解 Express 常用功能的原理... 阅读详情

相关推荐

《跨界杂谈》华为印象(五):高度集权的矩阵式架构

高度集权的矩阵式架构 “高度集权的矩阵式架构”不是官方术语。是笔者的一位同事非常经典的总结。解释这个句话还是有点困难的,笔者入职华为三年也没彻底搞明白,在这里只能尝试性的解释一下。 矩阵 我们先看一下“矩阵”。华为有两套部门职能体系,华为内部术语,叫实线部门和虚线部门。由实线与虚线部门交叉构成矩阵。大部分(但不是所有)华为员工都由两个部门(两个领导)进行管理。对员工而言,所谓的实线部

《云计算架构技术与实践》 2995

NodeJS stream 流 原理分析(附源码)

前言在之前的博客中已经了解了流的基本用法(请看我之前的博客),这篇的重点在于根据可读流的用法对可读流的原理进行分析,实现简易版的 ReadStream可读流的实现(流动模式)1、ReadStream 类创建在使用 fs 的 createReadStream 创建可读流时,返回了 ReadStream 对象,上面存在着一些事件和方法,其实我们在创建这个可读流的时候创建了某一个类的实例,这个实例可以调...

weixin_34293141的博客 1258

AMEsim不连续处理机制:仿真精度与效率的平衡艺术

本文深入解析AMEsim不连续处理机制在系统仿真中的应用,探讨如何平衡仿真精度与效率。通过液压系统仿真案例,详细介绍了事件检测算法、变步长求解器自适应策略及典型场景优化方法,帮助工程师有效应对仿真中的不连续性问题,提升仿真效率和准确性。

v4w5x6的博客 441

read stream 特别注意

read 特别注意事项

HongDaYu的搬砖之路 792

200_matlab处理NCEP数据并画全球彩色的风场图

200_matlab处理NCEP数据并画全球彩色的风场图。

miaobo0的博客 1240

数据分析必问:Pandas面试题及参考答案

DataFrame是Pandas库中的一个核心数据结构,它类似于Excel中的表格,可以存储不同类型的数据。在Pandas中,抽样操作通常很简单,但是需要根据数据的特点和分析的需求来选择合适的抽样方法。数据标准化和归一化是数据预处理中常用的技术,目的是将数据调整到一个合适的范围内,以消除不同特征量纲和数值范围的差异,从而保证模型训练的有效性。CSV文件是一种文本文件,其中的数据以逗号分隔。不平衡数据集指的是数据集中各类别样本的数量差异较大的情况,这可能导致模型训练出现偏差,影响模型的泛化能力。

大模型大数据攻城狮的专栏 3009

NodeJs ReadStream机制分析

读了一下午源代码,作者写的代码太难懂了,洋洋洒洒,勉强看懂。 ReadStream有两个模式,即flow模式和readable模式,一旦设定成一种模式,最好不要切换成另一种。 1.当一个stream被调用了on("data",..)后,即进入flow模式。 2.stream.flow被设置成true,并且调用resume方法,下图是resume方法的截图 function resume(s

qiucheng55的专栏 4664

增益可控放大电路-电路与电子技术课程设计

增益可控放大电路-电路与电子技术课程设计

Yeats_Liao的博客 1万+

Node 中的 ReadStream 和 WriteStream

之前看别人用 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) {

weixin_43487782的博客 1614

【图像增强】论文精读:Correlation Matching Transformation Transformers for UHD Image Restoration(UHDformer)

论文题目:Correlation Matching Transformation Transformers for UHD Image Restoration —— 用于UHD图像恢复的相关匹配变换变压器AAAI 2024!本文提出了一种用于超高清(UHD)图像恢复的通用变压器UHDformer。UHDformer包含两个学习空间:(a) 高分辨率空间中的学习,(b)低分辨率空间中的学习。前者学习多层次的高分辨率特征,融合低层特征,重建残差图像,后者从高分辨率特征中学习更多的代表性特征,以促进更好的恢复。

主要更新底层视觉(去噪、超分等)相关的科研内容,形式为【论文精读】+【论文复现】 291

一个人干完整个运营团队!浙大开源「Easel」全链路拆解:从热点发现到自动发布,一个 Agent 直接做出来

一个人干完整个运营团队?浙大 REAL 实验室开源的 Easel,把 OpenClaw Agent、账号画像、114 个内容技能和真实媒体工具接在一起:从热点发现、选题策划、图文视频创作,到七平台适配发布、数据归因复盘,全链路一个 Agent 闭环完成,产物按项目归档。本文完整拆解其五层工作流、四层 Prompt Stack 架构、画像内联机制、Skill 三层加载与产物契约,附安装部署、.env 关键配置、Embedding 避坑、已知问题修复等实战干货,全程无虚构、可复现。

program哲学 286

记录一次更新npm i之后,原来兼容IE的程序,又报语法错误,app.js包语法错误: “exports.destroy = (() => { ... }); const let”等未被转义

最近开发项目中,项目重新npm install之后,IE浏览器报了语法错误,但是之前是好的,查看报错的位置发现有好多箭头函数和const 没有被转义,但是babel配置都是问问题的。的sockjs-client.js,而1.6.1的版本在我的项目中,无法被转义,导致了更新依赖包后,再编译app.js中存在ES6语法,最终导致IE中报错。webpack-dev-server中默认会下载最近的。这个问题真的很坑,谷歌百度了很久都没有找到同样问题的。记录一下,希望帮助到遇到相同问题的人。ES6语法没有被转义的。

蓝with黑的博客 1605

老版QQ原生表情gif动态

这是老版的QQ表情,不过全是动态的的,Windows照片查看器是不会动的,可以右击用浏览器打开查看,再说一遍是老版的,只是分享给宁愿用老版也要动态的人。

js实现审批流_Nodejs Stream pipe 的使用与实现原理分析

通过流我们可以将一大块数据拆分为一小部分一点一点的流动起来,而无需一次性全部读入,在 Linux 下我们可以通过 | 符号实现,类似的在 Nodejs 的 Stream 模块中同样也为我们提供了 pipe() 方法来实现。1. Nodejs Stream pipe 基本示例选择 Koa 来实现这个简单的 Demo,因为之前有人在 “Nodejs技术栈” 交流群问过一个问题,怎么在 Koa 中返回一...

weixin_34365489的博客 1209

大中型超市收银系统(可直接运行)

光步前台收银系统系统功能:商品销售、商品退货、挂单、收银员权限、会员销售、单品取消、整单取消、商品查询、脱机销售、脱机数据上传,基本资料下载,开钱箱、小票打印、收款结算,支持多币种付款方式(现金、储值卡、礼券、微信等),支持多种促销。运行环境:.net 4.0登陆信息用户名:1001 密码:123456内置商品信息,商品编码可输入00001000 - 00010000,测试效果相关功能键F5 结算 F9挂单 F11商品查询 F8退货 Insert单品取消 Del整单取消 F12开钱箱F2 会员 Ctrl+Z退出系统 Home 锁屏 相关源码待公开

VC中sqlite的使用

在VC中对sqlite的使用,实现数据库大批数据的快速插入,读取数据库中的数据在ListCtrl中显示

神奇的nodejs:Buffer---二进制文件流和TCP流的文件写入和转码读取,以及输出方式

TCP是基于流式传输的 如果对TCP流式传输不太明白的可以先看上面的解释链接 我们此次以nodejs获取到vcenter认证信息里面的session-id为例,上代码: const https = require('https'); var session_id = ''; const fs = require('fs'); function getsession_id(params) { const options = { host: '172.17.xx.xxx',

shunzi2016的博客 6146

LinePhone3.2.7(android端最新版本) sip客户端

SIP客户端For android:LinePhone3.2.7(最新版本),官网地址:http://www.linphone.org/

NodeJS模块原理分析

NodeJS模块原理分析

太阳与星辰的博客 891

Nodejs Stream pipe 的使用与实现原理分析

今日文章由 “Node.js技术栈@五月君” 授权分享,正文从下面开始~通过流我们可以将一大块数据拆分为一小部分一点一点的流动起来,而无需一次性全部读入,在 Linux 下我们可以通过 ...

程序员成长指北 2223

CommonJS模块规范与NodeJS的模块系统底层原理

但在了解NodeJS模块之前 首先来科普一下什么是CommonJSCommonJS规范它为JavaScript制定一套规范——希望JavaScript能在任何地方运行 使其具备开发大型应用的能力出发点便是为了弥补当时JavaScript语言自身的缺点: 无模块系统 现在ES6弥补了这个缺点 没有包管理胸痛 导致js应用没有自加载和安装依赖能力 无标准接口 没有定义过像Web服务器一类的标准

尐轩web前端网络日志 4193

NodeJS实现JWT原理

这里写目录标题1.会话管理2.session和cookies3.JWT定义4.JWT原理5.JWT认证流程6.代码实现(Vue+NodeJS+MySql) 1.会话管理 我们用nodejs为前端或者其他服务提供resful接口时,http协议他是一个无状态的协议,有时候我们需要根据这个请求的上下文获取具体的用户是否有权限,针对用户的上下文进行操作。所以出现了cookies session还有jwt这几种技术的出现, 都是对HTTP协议的一个补充。使得我们可以用HTTP协议+状态管理构建一个的面向用户的WEB

qq_41179562的博客 2825

详解NodeJs流之一

如果你对NodeJs系列感兴趣,欢迎关注微信公众号:前端神盾局或 github NodeJs系列文章 流从早先的unix初出茅庐,在过去的几十年的时间里,它被证明是一种可依赖的编程方式,它可以将一个大型的系统拆成一些很小的部分,并且让这些部分之间完美地进行合作。 在node中,流的身影几乎无处不在,无论是操作文件、创建本地服务器还是简单的...

weixin_34303897的博客 93

nodejs流基类源码分析

流是对数据生产,消费的一种抽象,今天先分析一下流基类的实现 const EE = require('events'); const util = require('util'); // 流的基类 function Stream() { EE.call(this); } // 继承事件订阅分发的能力 util.inherits(Stream, EE); 流的基类只提供了一个函数就是pipe。用于实现管道化。这个方法代码比较多,分开说。 1 处理数据事件 function ondata(chunk) {

标子 的专栏 223

nodejs流

流数据是指一组顺序、大量、快速、连续到达的数据序列,一般情况下数据流可被视为一个随时间延续而无限增长的动态数据集合。流数据应用于网络监控、传感器网络、航空航天、气象测控和金融服务等领域。与静态数据不同,流数据是实时生成的,需要实时处理和分析。Nodejs 中的流(Stream)是用来比喻数据传输的一种形式,数据传输的起点就是流的源头,数据传输的终点就是流的终点。

CodePrincess的博客 1114

EventEmitter 源码分析与简易实现

原文出自:https://www.pandashen.com EventEmitter 简介 EventEmitter 是 NodeJS 的核心模块 events 中的类,用于对 NodeJS 中的事件进行统一管理,用 events 特定的 API 对事件进行添加、触发和移除等等,核心方法的模式类似于发布订阅。 实现 EventEmitter 1、EventEmitter 构造函数的实现 ...

github_38140984的博客 1096
上一篇: NodeJS —— Stream 的基本使用
下一篇: NodeJS —— 自定义流的实现
Panda Shen
博客等级 码龄10年 18粉丝 50原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值