如果你對NodeJs系列感興趣,歡迎關注微信公眾號:前端神盾局或 github NodeJs系列文章
流從早先的unix初出茅廬,在過去的幾十年的時間裡,它被證明是一種可依賴的程式設計方式,它可以將一個大型的系統拆成一些很小的部分,並且讓這些部分之間完美地進行合作。
在node中,流的身影幾乎無處不在,無論是操作檔案、建立本地伺服器還是簡單的console
,都極有可能涉及到流。
Node.js 中有四種基本的流型別:
- Readable - 可讀取資料的流(例如 fs.createReadStream())。
- Writable - 可寫入資料的流(例如 fs.createWriteStream())。
- Duplex - 可讀又可寫的流(例如 net.Socket)。
- Transform - 在讀寫過程中可以修改或轉換資料的 Duplex 流(例如 zlib.createDeflate())
為什麼使用流
假設我們需要使用node來實現一個簡單的靜態檔案伺服器:
const http = require('http');
const fs = require('fs');
http.createServer((req,res)=>{
fs.readFile('./test.html',function(err,data){
if(err){
res.statusCode = 500;
res.end();
}else{
res.end(data);
}
})
}).listen(3000)
複製程式碼
上述程式碼簡單實現了靜態檔案的讀取和傳送,邏輯上是完全可行的。但是由於readFile
是一次性將讀取的檔案存放在記憶體中的,假設test.html
檔案非常大或者訪問量增多的情況下,伺服器記憶體很有可能耗盡。這時我們就需要使用流的方式進行改進:
const http = require('http');
const fs = require('fs');
http.createServer((req,res)=>{
fs.createReadStream('./test.html').pipe(res);
}).listen(3000);
複製程式碼
fs.createReadStream
建立一個可讀流,逐次讀取檔案內容供給下游消費,這種逐步讀取和消費的方式,有效減緩了記憶體的消耗。
可讀流(Readable Stream)
我們可以把 Readable Stream拆分成兩個階段:push階段和pull階段,在push階段,通過實現_read
方法將資料從底層資料資源池中推送到快取池中,這是資料的生產階段,而pull階段,則是將快取池的資料拉出,供下游使用,這是資料的消費階段。
在開始進一步講解之前,我們先來介紹幾個欄位,這些欄位來源於node原始碼:
state.buffer
:Array
快取池,每個元素對應push(data)中的datastate.length
:Number
快取池中的資料量,在objectMode
模式下,state.length === state.buffer.length
,否則,其值是state.buffer
中資料位元組數的總和state.ended
:Boolean
表示底層資料池沒有可讀資料了(this.pull(null)
)state.flowing
:Null|Boolean
表示當前流的模式,其值有三種情況:null
(初始狀態)、true
(流動模式)、false
(暫停模式)state.needReadable
:Boolean
是否需要觸發readable
事件state.reading
:Boolean
是否正在讀取底層資料state.sync
:Boolean
是否立即觸發data
/readable
事件,false
為立即觸發、true
下一個tick再觸發(process.nextTick
)
兩種模式
可讀流存在兩種模式:流動模式(flowing)和暫停模式(paused),在原始碼中使用state.flowing
來標識。
兩種模式其基本流程都遵循上圖中的push和pull階段,區別在於pull階段的自主性。對於流動模式而言,只要快取池還有未消耗的資料,那麼資料便會不斷的被提取,我們可以把它想象成一個自動的水泵,只要通電了,不抽乾水池的水它是不會停下來的。而對於暫停模式,它更像是打水桶,需要的時候再從水池裡面打點水出來。
所有可讀流都開始於暫停模式,可以通過以下方式切換到流動模式:
- 新增
data
事件控制程式碼(前提是state.flowing === null
) - 呼叫
stream.resume()
- 呼叫
stream.pipe()
可讀流也可以通過以下方式切換回暫停模式:
- 新增
readable
事件控制程式碼 - 如果沒有管道目標,則呼叫
stream.pause()
。 - 如果有管道目標,則移除所有管道目標。呼叫
stream.unpipe()
可以移除多個管道目標。
一切從read
開始
對於可讀流而言,消費驅動生產,只有通過呼叫pull階段的read
函式,才能喚醒push階段的資料產生,從而帶動整個流的運動。所以對於可讀流而言read
是一切的起點。
這是根據原始碼整理的一個簡單的流程圖,後面將對一些環節加以說明。
howMuchToRead
呼叫read(n)
過程中,node會根據實際情況調整讀取的數量,實際值由howMuchRead
決定
function howMuchToRead(n,state){
// 如果size <= 0或者不存在可讀資料
if (n <= 0 || (state.length === 0 && state.ended))
return 0;
// objectMode模式下 每次制度一個單位長度的資料
if (state.objectMode)
return 1;
// 如果size沒有指定
if (Number.isNaN(n)) {
// 執行read()時,由於流動模式下資料會不斷輸出,
// 所以每次只輸出快取中第一個元素輸出,而非流動模式則會將快取讀空
if (state.flowing && state.length)
return state.buffer.head.data.length;
else
return state.length;
}
if (n > state.highWaterMark)
// 更新highWaterMark
state.highWaterMark = computeNewHighWaterMark(n);
// 如果快取中的資料量夠用
if (n <= state.length)
return n;
// 如果快取中的資料不夠用,
// 且資源池還有可讀取的資料,那麼這一次先不讀取快取資料
// 留著下一次資料量足夠的時候再讀取
// 否則讀空快取池
if (!state.ended) {
state.needReadable = true;
return 0;
}
return state.length;
}
複製程式碼
end
事件
在read
函式呼叫過程中,node會擇機判定是否觸發end
事件,判定標準主要是以下兩個條件:
if (state.length === 0 && state.ended) endReadable(this);
複製程式碼
- 底層資料(資源)沒有可讀資料,此時
state.ended
為true
,
通過呼叫
pull(null)
表示底層資料當前已經沒有可讀資料了
- 快取池中沒有可讀資料
state.length === 0
本事件在呼叫read([size])
時觸發(滿足上述條件時)
doRead
doRead
用於判斷是否讀取底層資料
// 如果當前是暫停模式`state.needReadable`
var doRead = state.needReadable;
// 如果當前快取池是空的或者沒有足夠的快取
if (state.length === 0 || state.length - n < state.highWaterMark){
doRead = true;
}
if (state.ended || state.reading) {
doRead = false;
} else if (doRead) {
// ...
this._read(state.highWaterMark);
// ...
}
複製程式碼
state.reading
標誌上次從底層取資料的操作是否已完成,一旦push
方法被呼叫,就會設定為false
,表示此次_read()
結束
data
事件
在官方文件中提到:新增data
事件控制程式碼,可以使Readable Stream的模式切換到流動模式,但官方沒有提到的是這一結果成立的條件-state.flowing
的值不為null
,即只有在初始狀態下,監聽data事件,會使流進入流動模式。舉個例子:
const { Readable } = require('stream');
class ExampleReadable extends Readable{
constructor(opt){
super(opt);
this._time = 0;
}
_read(){
this.push(String(++this._time));
}
}
const exampleReadable = new ExampleReadable();
// 暫停 state.flowing === false
exampleReadable.pause();
exampleReadable.on('data',(chunk)=>{
console.log(`Received ${chunk.length} bytes of data.`);
});
複製程式碼
執行這個例子,我們發現終端沒有任何輸出,為什麼會這樣呢?原因我們可以從原始碼中看出端倪
if (state.flowing !== false)
this.resume();
複製程式碼
由此我們可以把官方表述再完善一些:在可讀流初始化狀態下(state.flowing === null
),新增data
事件控制程式碼會使流進入流動模式。
push
只能被可讀流的實現呼叫,且只能在 readable._read() 方法中呼叫。
push是資料生產的核心,消費方通過呼叫read(n)
促使流輸出資料,而流通過_read()使底層呼叫push方法將資料傳給流。
在這個過程中,push方法有可能將資料存放在快取池內,也有可能直接通過data
事件輸出。下面我們一一分析。
如果當前流是流動的(state.flowing === true
),且快取池內沒有可讀資料,
那麼資料將直接由事件data
輸出
// node 原始碼
if (state.flowing && state.length === 0 && !state.sync){
state.awaitDrain = 0;
stream.emit('data', chunk);
}
複製程式碼
我們舉個例子:
const { Readable } = require('stream');
class ExampleReadable extends Readable{
constructor(opt){
super(opt);
this.max = 100;
this.time = 0;
}
_read(){
const seed = setTimeout(()=>{
if(this.time > 100){
this.push(null);
}else{
this.push(String(++this.time));
}
clearTimeout(seed);
},0)
}
}
const exampleReadable = new ExampleReadable({ });
exampleReadable.on('data',(data)=>{
console.log('from data',data);
});
複製程式碼
readable
事件
exampleReadable.on('readable',()=>{
....
});
複製程式碼
當我們註冊一個readable
事件後,node就會做以下處理:
- 將流切換到暫停模式
state.flowing = false;
state.needReadable = true;
複製程式碼
- 如果快取池未消耗的資料,觸發
readable
,
stream.emit('readable');
複製程式碼
- 否則,判斷當前是否正在讀取底層資料,如果不是,開始(nextTick)讀取底層資料
self.read(0);
觸發條件
state.flow === false
當前處於暫停模式- 快取池中還有資料或者本輪底層資料已經讀取完畢
state.length || state.ended
return !state.ended &&
(state.length < state.highWaterMark || state.length === 0);
複製程式碼