窺探Node.js裡的Stream

panda080發表於2018-04-07

圖片來源網路

流是什麼?

流的常見的應用場景有哪些?

流的實現機制是什麼?

流是什麼?

什麼是流呢?我畫了個汙水處理的簡化流程來表達我對流的理解(原諒我拙劣的想象力和畫技)。

汙水處理模型

這也是我早期在使用gulp的時候,對它的工作機制的理解。汙水通過進水口進入,通過汙水處理器中一系列的處理,最終會在出水口流出清水。

迴歸到程式碼中來,使用gulp時,我輸入一個less檔案,gulp會使用less外掛、自動補全外掛、壓縮外掛等一系列的處理,最終輸出我們想要的css檔案。一條龍服務,有木有?

在學習流的過程中,我還了解到了一個有趣的模型——生產者/消費者模型。理解了這個模型,那理解流就基本沒什麼壓力了。

舉個生活中的例子,幫助我們理解生產者/消費者模型。

生產者,比作生活中的工廠,不斷生成泡麵。

消費者,比作廣大人民群眾,需要不斷的買泡麵續命。

如果我直接去工廠裡買泡麵,雖然會很便宜,但人家零售就會虧本,所以只會批發給我,這足以刷爆我的小金庫。

對於工廠,如果直接面對買家,那工廠生產一天的泡麵,就要停產一個月,因為接下來一個月都需要去賣泡麵。很快,工廠就會倒閉了。

既然工廠和消費者不能直接對接,那如果引入一個第三方——超市,作為工廠的代理商。那麼,工廠就可以專注於生產泡麵了,生產的泡麵直接批發給超市。而消費者也不用去工廠批發了,雖然貴一點,但自由啊。想吃,就去超市買一包。

通過以上兩個現實小栗子,巨集觀理解一下流。

三個角色:生產者、消費者、第三方中介

生產者/消費者模型

下方虛線的第三方中介,與上方的第三方中介,就是一個。之所以分開表示,是為了突出消費者與生產者通過第三方中介解耦了。

流的一些應用場景

我們從Node.js官網中可以找到,Node.js有四種基本的流型別:

  • Readable——可讀流
  • Writable——可寫流
  • Duplex——可讀寫的流,也叫雙工流
  • Transform——在讀寫過程中可以修改和變換資料的一種特殊的 Duplex 流

Node.js中很多內建/核心模組都是基於流實現的:

流的應用場景

由此圖可看出,流在Node.js中的地位。可以說理解了流,將會很大程度的幫助我們去理解和使用上圖列舉的內建模組。

流的實現機制

瞭解流內部機制的最佳方式除了看 Node.js 官方文件,還可以去看看 Node.js 的 原始碼:

從原始碼中我們可以看出,這四種基本型別的流都是流的一種抽象,提供給開發者去擴充套件使用的,所以原始碼看起來得有一定的使用基礎。接下來我將另闢蹊徑,不借助這四種基本型別的流,去實現 fs readable stream 的主要邏輯,管中窺豹,加深對流的理解

fs Readable Stream

Readable stream有兩種模式:

  • flowing:在該模式下,會盡快獲取資料向外輸出。因此如果沒有事件監聽,也沒有pipe()來引導資料流向,資料可能會丟失。
  • paused:預設模式。在該模式下,需要手動呼叫stream.read(..)來獲取資料。

可以通過以下幾種方法切換到flowing模式:

  • 新增'data'事件監聽器
  • 呼叫stream.resume(..)方法
  • 呼叫stream.pipe()方法將資料傳送給消費者Writable

可以通過以下幾種方法切換到paused模式:

  • 如果沒有呼叫stream.pipe(..),則呼叫stream.pause(..)即可
  • 如果有呼叫stream.pipe(..),那麼需要通過stream.unpipe(..)移除所有的pipe

需要特別注意以下幾點:

  1. 只有提供消費者去消費資料,比如,新增'data'事件監聽器,可讀流才會去生產資料。
  2. 如果移除'data'事件監聽器,將不會自動的停止流。
  3. 如果呼叫了stream.pipe(..),再呼叫stream.pause(),將不會停止這個流。
  4. 如果可讀流被切換到了流的模式,但是卻沒有新增'data'事件監聽器,那麼資料將會丟失掉。比如呼叫了stream.resume(),卻沒有新增'data'事件監聽器,或者'data'事件監聽器被移除了。
  5. 選擇一種方式去消耗可讀流生產的資料。比較推薦stream.pipe(..)。也可使用可控性比較強的事件機制,再配合readable.pause()/readable.resume()APIs
  6. 如果readabledata被同時使用了,那麼readable事件的優先順序會比data事件高。此時,必須在readable事件內顯示呼叫stream.read(..)才能讀到資料。

更多API使用可參考官網stream_readable_streams

flowing模式

下面嘗試實現檔案可讀流的流動模式,一觀它的內部機制。

// 原始碼參見 https://github.com/nodejs/node/blob/master/lib/fs.js
const fs = require('fs');
const EventEmitter = require('events');
const util = require('util');

// 使用Node.js內部工具模組,讓檔案可讀流繼承事件的很多事件方法
// 由此可看出,流就是基於事件機制實現的
util.inherits(FsReadableStream, EventEmitter);

// 宣告一個檔案可讀流的建構函式,並初始化相關引數
function FsReadableStream(path, options) {
    const self = this; // 防止this指標的指向混亂

    // 這裡為了主要說明實現流程,省略引數的邊界限制。
    self.path = path;

    // 開啟檔案時的引數
    // 參見 https://nodejs.org/api/fs.html#fs_fs_open_path_flags_mode_callback
    self.flags = options.flags || 'r';
    self.mode = options.mode || 0o66;

    // 檔案內容的讀取起止位置
    self.start = options.start || 0;
    self.end = options.end;
    // 每次讀取內容的水位線,即一次讀取,最大快取
    self.highWaterMark = options.highWaterMark || 64 * 1024;
    // 內容讀取完畢之後,是否自動關閉檔案
    self.autoClose = options.autoClose === undefined ? true : options.autoClose;
    // 最後輸出的資料將以何種編碼方式解碼
    self.encoding = options.encoding || 'utf8';

    // 檔案有效的描述符
    self.fd = null;

    // 檔案讀取的實時起始位置。第一次從0開始,第二次就是從 0 + 第一次讀的內容長度
    self.pos = self.start;

    // 申請水位線大小的空間作為buffer快取
    self.buffer = Buffer.alloc(self.highWaterMark);

    // new這個建構函式時,就開啟檔案,為後續做準備
    self.open();

    // 模式  初始為暫停模式
    self.flowing = null;

    // 一旦有新的事件被監聽,且,是'data'事件,
    // 就將模式切換至流動模式,並讀取資料
    self.on('newListener', function (eventName) {
        if (eventName === 'data') {
            self.flowing = true;
            self.read();
        }
    });
}

// 在對檔案操作之前,需要先開啟檔案,獲取檔案的有效描述符
FsReadableStream.prototype.open = function () {
    const self = this;
    fs.open(self.path, self.flags, self.mode, function (err, fd) {
        if (err) {
            self.emit('error', err);
            if (self.autoClose) {
                self.destroy();
            }
            return;
        }
        self.fd = fd;
        self.emit('open', fd);
    });
};

// 讀取檔案裡的內容
FsReadableStream.prototype.read = function () {
    const self = this;
    // self.open()是非同步方法,此時,需判斷檔案是否被開啟了
    if (typeof self.fd !== 'number') {
        // 檔案未開啟,可以新增open事件監聽器
        self.once('open', self.read);
        return;
    }
    // 需計算每次需要讀取多少資料。
    const howMuchToRead = self.end ? Math.min(self.highWaterMark, self.end - self.pos + 1) : self.highWaterMark;
    fs.read(self.fd, self.buffer, 0, howMuchToRead, self.pos, function (err, bytesRead) {
        if (err) {
            self.emit('error', err);
            if (self.autoClose) {
                self.destroy();
            }
            return;
        }
        if (bytesRead > 0) {
            // 更新讀取位置
            self.pos = self.pos + bytesRead;
            // 有可能讀取的內容長度比buffer快取長度小,就必須擷取出來,防止亂碼情況
            const data = self.encoding ? self.buffer.slice(0, bytesRead).toString(self.encoding) : self.buffer.slice(0, bytesRead);
            self.emit('data', data);
            // 如果下一次讀取的起始位置比結束位置要大,則表明已經讀完了
            if (self.pos > self.end) {
                self.emit('end');
                if (self.autoClose) {
                    self.destroy();
                }
            }
            // 如果仍然處於流動模式,將會繼續讀取資料
            if (self.flowing) {
                self.read();
            }
        } else {
            // 檔案內容已經讀取完畢了
            self.emit('end');
            if (self.autoClose) {
                self.destroy();
            }
        }
    });
};

// 將流動模式切換到暫停模式
FsReadableStream.prototype.pause = function () {
    if (this.flowing !== false) {
        this.flowing = false;
    }
};

// 將暫停模式切換到流動模式
FsReadableStream.prototype.resume = function () {
    // 如果直接呼叫resume,卻沒有新增data監聽器
    // 資料則會丟失
    if (!this.flowing) {
        this.flowing = true;
        this.read();
    }
};

// 關閉檔案
FsReadableStream.prototype.destroy = function () {
    const self = this;
    if (typeof self.fd === 'number') {
        fs.close(self.fd, function (err) {
            if (err) {
                self.emit('error', err);
                return;
            }
            self.fd = null;
            self.emit('close');
        });
        return;
    }
    this.emit('close');
};
複製程式碼

我們會發現流動模式,是會源源不斷的生成資料的,直到資料來源枯竭為止。當然,也可以通過stream.pause(..)/stream.resume去精準控制。這種模式的控制權在於開發者,開發者必須熟悉這種模式的執行機制,謹慎運用,否則很容易出現,消費者被撐爆或者資料中途丟失的情況。

paused模式

由於流動模式和暫停模式是互斥的,所以採用分開實現可讀流的兩種模式。暫停模式下,我們需要監聽另外一個事件——'readable',並顯示呼叫stream.read(n)才能讀到資料。

// 原始碼參見 https://github.com/nodejs/node/blob/master/lib/fs.js
// 檔案可讀流的暫停模式
const fs = require('fs');
const EventEmitter = require('events');
const util = require('util');

// 使用Node.js內部工具模組,讓檔案可讀流繼承事件的很多事件方法
// 由此可看出,流就是基於事件機制實現的
util.inherits(FsReadableStream, EventEmitter);

// 宣告一個檔案可讀流的建構函式,並初始化相關引數
function FsReadableStream(path, options) {
    const self = this; // 防止this指標的指向混亂

    // 這裡為了主要說明實現流程,省略引數的邊界限制。
    self.path = path;

    // 開啟檔案時的引數
    // 參見 https://nodejs.org/api/fs.html#fs_fs_open_path_flags_mode_callback
    self.flags = options.flags || 'r';
    self.mode = options.mode || 0o66;

    // 檔案內容的讀取起止位置
    self.start = options.start || 0;
    self.end = options.end;
    // 每次讀取內容的水位線,即一次讀取,最大快取
    self.highWaterMark = options.highWaterMark || 64 * 1024;
    // 內容讀取完畢之後,是否自動關閉檔案
    self.autoClose = options.autoClose === undefined ? true : options.autoClose;
    // 最後輸出的資料將以何種編碼方式解碼
    self.encoding = options.encoding || 'utf8';

    // 檔案有效的描述符
    self.fd = null;

    // 檔案讀取的實時起始位置。第一次從0開始,第二次就是從 0 + 第一次讀的內容長度
    self.pos = self.start;

    // 作為快取存放每次讀到的資料  [Buffer, Buffer, Buffer...]
    self.buffers = [];
    // 當前快取的長度   self.buffers.length 只能讀到陣列有多少個元素。
    // 這裡是快取 buffers 每一項的長度之和
    self.length = 0;

    // 因為讀取檔案是非同步操作,所以這裡需要一個標記
    // 如果處於正在讀狀態,則將資料存放在快取中
    this.reading = false;
    // 是否達到 傳送 'readable' 事件的條件
    // 'readable' 事件表明流有了新的動態:要麼是有了新的資料,要麼是到了流的尾部。
    // 對於前者, stream.read() 將返回可用的資料。而對於後者, stream.read() 將返回 null
    this.emittedReadable = false;

    // new這個建構函式時,就開啟檔案,為後續做準備
    self.open();

    // 一旦有新的事件被監聽,且,是'readable'事件,
    // 開啟 emittedReadable
    self.on('newListener', function (eventName) {
        if (eventName === 'readable') {
            self.read();
        }
    });
}

FsReadableStream.prototype.read = function (n) {
    const self = this;
    let buffer = null;
    // 當索要的資料長度  大於  快取區的長度
    if (n > self.length) {
        // 此時要索要的資料,超過了快取大小
        // 就會提升水位線,來適應這種需求量
        // computeNewHighWaterMark 是node原始碼中提高水位線的方法
        self.highWaterMark = computeNewHighWaterMark(n);
        self.emitReadable = true;
        self._read();
    }
    // 當索要的資料長度  大於  0  且  小於等於  快取區的長度
    if (n > 0 && n <= self.length) {
        // 先申請Buffer記憶體
        buffer = Buffer.alloc(n);
        let index = 0; // 迴圈次數
        let flag = true; // 控制while的標記
        let b;
        while (flag && (b = self.buffers.shift())) {
            for (let i = 0; i < b.length; i++) {
                buffer[index++] = b[i]; // 賦值
                if (n === index) {
                    let arr = b.slice(index);
                    if (arr.length) {
                        // 不要的 再塞回快取
                        self.buffers.unshift(arr);
                    }
                    self.length = self.length - n;
                    flag = false;
                }
            }
        }
    }
    // 如果當期快取區沒有資料
    if (self.length === 0) {
        self.emittedReadable = true;
    }
    // 當快取區的資料長度  小於  水位線了   就去生成資料,繼續放在快取區
    if (self.length < self.highWaterMark) {
        if (!self.reading) {
            self.reading = true;
            self._read();
        }
    }
    // 返回讀到的資料
    return buffer;
};

FsReadableStream.prototype._read = function () {
    const self = this;
    if (typeof self.fd !== 'number') {
        return self.once('open', self._read);
    }
    const buffer = Buffer.alloc(self.highWaterMark);
    fs.read(self.fd, buffer, 0, self.highWaterMark, self.pos, function (err, bytesRead) {
        if (bytesRead > 0) {
            // 預設將讀取的內容放到快取區中
            self.buffers.push(buffer.slice(0, bytesRead));
            self.pos = self.pos + bytesRead; // 維護讀取的索引
            self.length = self.length + bytesRead; // 維護快取區的大小
            self.reading = false; // 讀取完成
            // 是否需要觸發readable事件
            if (self.emittedReadable) {
                self.emittedReadable = false; // 下次預設不觸發
                self.emit('readable');
            }
        } else {
            self.emit('end');
            if (self.autoClose) {
                self.destroy();
            }
        }
    });
};

// 在對檔案操作之前,需要先開啟檔案,獲取檔案的有效描述符
FsReadableStream.prototype.open = function () {
    const self = this;
    fs.open(self.path, self.flags, self.mode, function (err, fd) {
        if (err) {
            self.emit('error', err);
            if (self.autoClose) {
                self.destroy();
            }
            return;
        }
        self.fd = fd;
        self.emit('open', fd);
    });
};

// 關閉檔案
FsReadableStream.prototype.destroy = function () {
    const self = this;
    if (typeof self.fd === 'number') {
        fs.close(self.fd, function (err) {
            if (err) {
                self.emit('error', err);
                return;
            }
            self.fd = null;
            self.emit('close');
        });
        return;
    }
    this.emit('close');
};
複製程式碼

這種模式,我們會發現,'readable'事件是告訴我們什麼時候可以去索取資料了。如果直接去調stream.read(n)的話,會因為fs.read(..)非同步操作,還沒將資料讀出並放至快取區,導致結果將返回null。

只要快取區內容被消耗至水位線以下,就會自動續杯,生成水位線大小的資料放到快取中。

那什麼時候會觸發'readable'事件呢?快取區為空,然後生產了水位線大小的資料放在快取區之後,便會觸發。下一次觸發的時機,仍然是快取區被消耗乾淨了,再次續滿杯之後。

stream.read(n)想要多少資料,就傳入多長。可以通過stream.length檢視當前快取區資料的長度,再決定索取多少資料。實際場景運用中則需要使用stream._readableState.length

fs Writable Stream

fs writable stream它的機制和可讀流有些相似。話不多說,先上程式碼:

// 原始碼參見 https://github.com/nodejs/node/blob/master/lib/fs.js
const fs = require('fs');
const EventEmitter = require('events');
const util = require('util');

// 使用Node.js內部工具模組,讓檔案可寫流繼承事件的很多事件方法
// 由此可看出,流就是基於事件機制實現的
util.inherits(FsWritableStream, EventEmitter);

// 宣告一個檔案可寫流的建構函式,並初始化相關引數
function FsWritableStream(path, options) {
    const self = this; // 防止this指標的指向混亂

    // 這裡為了主要說明實現流程,省略引數的邊界限制。
    self.path = path;

    // 開啟檔案時的引數
    // 參見 https://nodejs.org/api/fs.html#fs_fs_open_path_flags_mode_callback
    self.flags = options.flags || 'r';
    self.mode = options.mode || 0o66;

    // 檔案內容的寫入開始位置
    self.start = options.start || 0;
    // 每次寫入內容的水位線,即最大快取
    self.highWaterMark = options.highWaterMark || 64 * 1024;
    // 內容寫入完畢之後,是否自動關閉檔案
    self.autoClose = options.autoClose === undefined ? true : options.autoClose;
    // 告訴程式寫入的資料將以何種編碼方式解碼
    self.encoding = options.encoding || 'utf8';

    // 檔案有效的描述符
    self.fd = null;

    // 檔案寫入的實時起始位置。第一次從0開始,第二次就是從 0 + 第一次寫入的內容長度
    self.pos = self.start;

    // 作為快取,存放來不及寫入檔案的資料  [Buffer, Buffer, Buffer...]
    self.buffers = [];
    // 當前快取的長度   self.buffers.length 只能讀到陣列有多少個元素。
    // 這裡是快取 buffers 每一項的長度之和
    self.length = 0;

    // 因為讀取檔案是非同步操作,所以這裡需要一個標記
    // 如果處於正在讀狀態,則將資料存放在快取中
    this.writing = false;

    // 控制是否通知'drain'事件監聽器,表示,快取區從滿狀態,被消耗空了
    self.needDrain = false;

    // new這個建構函式時,就開啟檔案,為後續做準備
    self.open();
}

FsWriteStream.prototype.open = function () {
    const self = this;
    fs.open(self.path, self.flags, self.mode, function (err, fd) {
        if (err) {
            self.emit('error', err);
            if (self.autoClose) {
                self.destroy();
            }
            return;
        }
        self.fd = fd;
        self.emit('open', fd);
    });
};

FsWriteStream.prototype.destroy = function () {
    const self = this;
    if (typeof self.fd === 'number') {
        fs.close(self.fd, function (err) {
            if (err) {
                self.emit('error', err);
                return;
            }
            self.emit('close');
        });
    } else {
        self.emit('close');
    }
};

// 主動發起呼叫,這裡預設三個引數都會傳
FsWriteStream.prototype.write = function (chunk, encoding, callback) {
    const self = this;
    const bufferChunk = Buffer.isBuffer(chunk) && chunk || Buffer.from(chunk, encoding);
    self.length = self.length + bufferChunk.length;
    // 如果當前快取長度 小於 水位線,表示快取區未滿
    const ret = self.length < self.highWaterMark;
    // 當快取區滿了,則開啟 向drain事件 發通知的開關
    self.needDrain = !ret;
    if (self.writing) {
        // 正在 寫入/消費 資料,所以先存到快取中
        self.buffers.push({
            chunk,
            encoding,
            callback,
        });
    } else {
        // 相當於去發ajax,這裡先來一個loading
        self.writing = true;
        self._write(chunk, encoding, function () {
            callback();
            // 當寫入完成時,清除快取中的內容
            self.clearBuffer();
        });
    }
    // 每次呼叫write時,到會返回當前快取區是否滿了
    return ret;
};

FsWriteStream.prototype._write = function (chunk, encoding, callback) {
    const self = this;
    if (typeof self.fd !== 'number') {
        self.once('open', function () {
            self._write(chunk, encoding, callback);
        });
        return;
    }
    fs.write(self.fd, chunk, 0, chunk.length, self.pos, function (err, writtenBytes) {
        if (err) {
            self.emit('error', err);
            self.writing = null;
            if (self.autoClose) {
                self.destroy();
            }
            return false;
        }
        // 長度 減掉已經消費成功的資料長度
        self.length = self.length - writtenBytes;
        // 更新下一次寫入的開始位置
        self.pos = self.pos + writtenBytes;
        callback();
    });
};

FsWriteStream.prototype.clearBuffer = function () {
    const self = this;
    const buffer = self.buffers.shift();
    if (buffer) {
        // 如果快取區仍有資料,則繼續消費資料
        self._write(buffer.chunk, buffer.encoding, function () {
            buffer.callback();
            self.clearBuffer();
        });
    } else {
        // 如果快取區空了,則重置寫入狀態
        self.writing = false;
        if (self.needDrain) {
            // 傳送 drain 事件,告知快取區已經消耗完了,可以進行下一波資料寫入了
            self.needDrain = false;
            self.emit('drain');
        }
    }
};
複製程式碼

我們會發現,當資料流來的時候,可寫流會直接去消費資料。當 消費/寫入檔案 速度過於緩慢的時候,資料流會被送入快取區快取起來。

當生產者傳來的資料速度過快,把快取塞滿了之後,就會出現「背壓」(fs.write(..)返回的結果),這個時候是需要告訴生產者暫停生產的,當快取區被消耗完之後,可寫流會給生產者傳送一個 drain 訊息,這樣就可以恢復生產了。

總結

以上 fs Readable Streamfs Writable Stream分別是流的基本型別Readable StreamWritable Stream的上層API。fs原始碼中,實際上是分別繼承這兩個基本流型別再加上一些fs的檔案操作,最後擴充套件成一個檔案流的。

所以流就是基於事件和狀態機去實現'生產者/消費者'這樣的一個模型。

更多關於流的更多使用,可參考官網

參考

相關文章