詳解NodeJs流之一

superTerrorist發表於2019-03-01

如果你對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)

image

我們可以把 Readable Stream拆分成兩個階段:push階段和pull階段,在push階段,通過實現_read方法將資料從底層資料資源池中推送到快取池中,這是資料的生產階段,而pull階段,則是將快取池的資料拉出,供下游使用,這是資料的消費階段。

在開始進一步講解之前,我們先來介紹幾個欄位,這些欄位來源於node原始碼:

  • state.buffer: Array 快取池,每個元素對應push(data)中的data
  • state.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是一切的起點。

這是根據原始碼整理的一個簡單的流程圖,後面將對一些環節加以說明。

image

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);
複製程式碼
  1. 底層資料(資源)沒有可讀資料,此時state.endedtrue

通過呼叫pull(null)表示底層資料當前已經沒有可讀資料了

  1. 快取池中沒有可讀資料 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就會做以下處理:

  1. 將流切換到暫停模式
state.flowing = false; 
state.needReadable = true;
複製程式碼
  1. 如果快取池未消耗的資料,觸發readable
stream.emit('readable');
複製程式碼
  1. 否則,判斷當前是否正在讀取底層資料,如果不是,開始(nextTick)讀取底層資料self.read(0);
觸發條件
  1. state.flow === false當前處於暫停模式
  2. 快取池中還有資料或者本輪底層資料已經讀取完畢state.length || state.ended
return !state.ended &&
    (state.length < state.highWaterMark || state.length === 0);
複製程式碼

參考

image

相關文章