流是什麼?
流的常見的應用場景有哪些?
流的實現機制是什麼?
流是什麼?
什麼是流呢?我畫了個汙水處理的簡化流程來表達我對流的理解(原諒我拙劣的想象力和畫技)。
這也是我早期在使用gulp的時候,對它的工作機制的理解。汙水通過進水口進入,通過汙水處理器中一系列的處理,最終會在出水口流出清水。
迴歸到程式碼中來,使用gulp時,我輸入一個less檔案,gulp會使用less外掛、自動補全外掛、壓縮外掛等一系列的處理,最終輸出我們想要的css檔案。一條龍服務,有木有?
在學習流的過程中,我還了解到了一個有趣的模型——生產者/消費者模型。理解了這個模型,那理解流就基本沒什麼壓力了。
舉個生活中的例子,幫助我們理解生產者/消費者模型。
生產者,比作生活中的工廠,不斷生成泡麵。
消費者,比作廣大人民群眾,需要不斷的買泡麵續命。
如果我直接去工廠裡買泡麵,雖然會很便宜,但人家零售就會虧本,所以只會批發給我,這足以刷爆我的小金庫。
對於工廠,如果直接面對買家,那工廠生產一天的泡麵,就要停產一個月,因為接下來一個月都需要去賣泡麵。很快,工廠就會倒閉了。
既然工廠和消費者不能直接對接,那如果引入一個第三方——超市,作為工廠的代理商。那麼,工廠就可以專注於生產泡麵了,生產的泡麵直接批發給超市。而消費者也不用去工廠批發了,雖然貴一點,但自由啊。想吃,就去超市買一包。
通過以上兩個現實小栗子,巨集觀理解一下流。
三個角色:生產者、消費者、第三方中介
下方虛線的第三方中介,與上方的第三方中介,就是一個。之所以分開表示,是為了突出消費者與生產者通過第三方中介解耦了。
流的一些應用場景
我們從Node.js官網中可以找到,Node.js有四種基本的流型別:
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
需要特別注意以下幾點:
- 只有提供消費者去消費資料,比如,新增
'data'
事件監聽器,可讀流才會去生產資料。 - 如果移除
'data'
事件監聽器,將不會自動的停止流。 - 如果呼叫了
stream.pipe(..)
,再呼叫stream.pause()
,將不會停止這個流。 - 如果可讀流被切換到了流的模式,但是卻沒有新增
'data'
事件監聽器,那麼資料將會丟失掉。比如呼叫了stream.resume()
,卻沒有新增'data'
事件監聽器,或者'data'
事件監聽器被移除了。 - 選擇一種方式去消耗可讀流生產的資料。比較推薦
stream.pipe(..)
。也可使用可控性比較強的事件機制,再配合readable.pause()/readable.resume()
APIs - 如果
readable
和data
被同時使用了,那麼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 Stream
和fs Writable Stream
分別是流的基本型別Readable Stream
和Writable Stream
的上層API。fs原始碼中,實際上是分別繼承這兩個基本流型別再加上一些fs的檔案操作,最後擴充套件成一個檔案流的。
所以流就是基於事件和狀態機去實現'生產者/消費者'這樣的一個模型。
更多關於流的更多使用,可參考官網