NodeJS stream 流 原理分析(附原始碼)

煎蛋面__cq發表於2018-09-18

前言

在之前的部落格中已經瞭解了流的基本用法(請看我之前的部落格),這篇的重點在於根據可讀流的用法對可讀流的原理進行分析,實現簡易版的 ReadStream

可讀流的實現(流動模式)

1、ReadStream 類建立

在使用 fscreateReadStream 建立可讀流時,返回了 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 在讀取完成後不斷的觸發 endclose 事件。

驗證可讀流(流動模式)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 事件,讀取一次後暫停,每隔一秒恢復讀取一次,再讀取完成後觸發 endclose 事件,通過執行程式碼結果和我們希望的一樣。

可讀流的實現(暫停模式)

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

fs 模組中用 createReadStream 建立的可讀流中通過監聽 readable 事件觸發暫停模式(監聽 data 事件觸發流動模式),通過下面例子感受暫停模式與流動模式的不同,現在讀取檔案 1.txt,內容為 0~9 十個數字。

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

// 建立可讀流
let rs = fs.createReadStream("1.txt", {
    encoding: "utf8",
    start: 0,
    hithWaterMark: 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 事件後就像建立了一個 “容器”,容量為 hithWaterMark,檔案中的資料會自動把容器注滿,呼叫可讀流的 read 方法讀取時,會從容器中取出資料,如果 read 方法讀取的資料小於 hithWaterMark,則直接暫停,不再繼續讀取,如果大於 hithWaterMark ,說明 “容器” 空了,則會觸發 readable 事件,無論讀取位元組數與 hithWaterMark 關係如何,只要 “容器” 內容量剩餘小於 hithWaterMark 就會進行 “續杯”,再次向 “容器” 中填入 hithWaterMark個,所以有些時候真實的容量會大於 hithWaterMark

read 方法讀取的內容會返回 null 是因為容器內真實的資料數小於了讀取數,如果不是最後一次讀取,會在多次讀取後將值一併返回,如果是最後一次讀取,會把剩餘不足的資料返回。

1、readable 事件的觸發條件:“容器” 空了;

2、“續杯” 條件:讀取後 “容器” 內剩餘量小於 hithWaterMark

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 設定為當前讀取位元組個數距離最接近的 2n 次方的數值,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 例項存入讀取的資料後儲存在快取區中,如果讀取完成 bytesRead0,則觸發 end 事件。

注意:在 NodeJS 原始碼中,可讀流的兩種模式程式碼都是混合在一起的,只是使用 fs.createReadStream建立一個可讀流,通過監聽 datareadable 兩種不同的事件來觸發兩種不同的模式,而我們為了模擬,把兩種模式拆開成了兩個類來實現的,在測試時需要建立不同類的例項。

驗證可讀流(暫停模式)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 類建立

在使用 fscreateWriteStream 建立可寫流時,返回了 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 事件並關閉檔案,開啟和關閉檔案的方法 opendetroyReadStreamopendetroy 方法的邏輯如出一轍,所以這裡直接拿過來用了。

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 事件,而 needDrainwrite 的返回值正好相反,所以我們用 needDrain 取反來作為 write 方法的返回值。

在寫入的邏輯中第一次是直接通過記憶體寫入到檔案,但是再次寫入就需要將資料存入快取,將資料寫入到檔案中寫入狀態 writing 預設為 false,通過快取再寫入證明應該正在寫入中,所以在第一次寫入後應更改 writing的狀態為 true,寫入快取其實就是把轉換的 Buffer、編碼以及寫入成功後要執行的回撥掛在一個物件上存入快取的陣列 buffer 中。

我們把真正寫入檔案的邏輯抽取成一個單獨的方法 _write,並傳入 chunk(要寫入的內容,已經處理成 Buffer)、encoding(字元編碼)、回撥函式,在回撥函式中執行了原型方法 clearBuffer,接下來就來實現 _writeclearBuffer

注意:方法使用 `
` 開頭代表私有方法,輕易不要在外部呼叫或修改,這是一個開發者之間約定俗成的不成文規定。_

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 事件,最後更改了 writingneedDrain 的狀態。

更正 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.createWriteStreamWriteStream 類分別執行上面的程式碼,對比結果,看看是否相同。

可讀流和可寫流的橋樑 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 原始碼中,可讀流和可寫流的內容要比本篇內容多很多,本篇是將原始碼精簡,抽出核心邏輯並針對流的使用方式進行實現,主要目的是幫助理解流的原理和使用,爭取做到 “知其然知其所以然”,瞭解了一些底層再對流使用時,也能遊刃有餘。


原文出自:https://www.pandashen.com


相關文章