深入理解nodejs Stream模塊

為什么應該使用流

你可能看過這樣的代碼。

var http = require('http');
var fs = require('fs');

var server = http.createServer(function (req, res) {
    fs.readFile(__dirname + '/data.txt', function (err, data) {
        res.end(data);
    });
});
server.listen(8000);

這段代碼中,服務器每收到一次請求,就會先把data.txt讀入到內存中,然后再從內存取出返回給客戶端。尷尬的是,如果data.txt非常的大,而每次請求都需要先把它全部存到內存,再全部取出,不僅會消耗服務器的內存,也可能造成用戶等待時間過長。

幸好,HTTP請求中的request對象和response對象都是流對象,于是我們可以換一種更好的方法:

var http = require('http');
var fs = require('fs');

var server = http.createServer(function (req, res) {
    let stream = fs.createReadStream(__dirname + '/data.txt');//創造可讀流
    stream.pipe(res);//將可讀流寫入response
});
server.listen(8000);

pipe方法如同stream和response之間的一個管道,將data.txt文件一小段一小段地發送到客戶端,減小了服務器的內存壓力。

比喻理解Stream

在node中,一共有五種類型的流:readable,writable,transform,duplex以及"classic"。其中最核心的是可讀流和可寫流。我們舉個栗子來生動形象地理解它們。
可讀流可理解為:從一個裝滿了水的水桶中一點一點把水抽取出來的過程
可寫流可理解為:把從可讀流抽出來的水一點一點倒入一個空的桶中的過程
腦補一下,如圖所示

Stream

也可以以經典的生產者和消費者的問題來理解Stream,生產者不斷在緩存中制造產品,而消費者則不斷地從緩存中消費產品

readableStream

可讀流(Readable streams)是對提供數據的 源頭 (source)的抽象
可讀流的流程如圖所示


可讀流

資源的數據流并不是直接流向消費者,而是先 push 到緩存池,緩存池有一個水位標記 highWatermark,超過這個標記閾值,push 的時候會返回 false,從而控制讀取數據流的速度,如同水管上的閥門,當水管面裝滿了水,就暫時關上閥門,不再從資源里“抽水”出來。什么場景下會出現這種情況呢?

  • 消費者主動執行了 .pause()
  • 消費速度比數據 push 到緩存池的生產速度慢

可讀流有兩種模式,flowing和pause

  • flowing模式下 可讀流可自動從資源讀取數據
  • pause模式下 需要顯式調用stream.read()方法來讀取數據

緩存池就像一個空的水桶,消費者通過管口接水,同時,資源池就像一個水泵,不斷地往水桶中泵水,而 highWaterMark 是水桶的浮標,達到閾值就停止蓄水。下面是一個簡單的flowing模式 Demo:

const Readable = require('stream').Readable
class MyReadable extends Readable{
    constructor(dataSource, options){
        super(options)
        this.dataSource = dataSource
    }
    //_read表示需要從MyReadable類內部調用該方法
    _read(){
        const data = this.dataSource.makeData()
        this.push(data)
    }
}
//模擬資源池
const dataSource = {
    data: new Array('abcdefghijklmnopqrstuvwxyz'),
    makeData: function(){
        if(!this.data.length) return null
        return this.data.pop()

    }
}

const myReadable = new MyReadable(dataSource);
myReadable.setEncoding('utf8');
myReadable.on('data', (chunk) => {
  console.log(chunk);
});

另外一種模式是pause模式,這種模式下可讀流有三種狀態

  • readable._readableState.flowing = null 目前沒有數據消費者,所以不會從資源庫中讀取數據
  • readable._readableState.flowing = false 暫停從資源庫讀取數據,但 不會 暫停數據生成,主動觸發了 readable.pause() 方法, readable.unpipe() 方法, 或者接收 “背壓”(back pressure)可達到此狀態
  • readable._readableState.flowing = true 正在從資源庫中讀取數據,監聽 'data' 事件,調用 readable.pipe() 方法,或者調用 readable.resume() 方法可達到此狀態

一個簡單的切換狀態的demo:

const myReadable = new MyReadable(dataSource);
myReadable.setEncoding('utf8');
myReadable.on('data', (chunk) => {
  console.log(`Received ${chunk.length} bytes of data.`);
  myReadable.pause()
  console.log('pausing for 1 second')
  setTimeout(()=>{
      console.log('now restart')
      myReadable.resume()
  }, 1000)
});

pause模式的流程圖如下


pause模式

資源池會不斷地往緩存池輸送數據,直到 highWaterMark 閾值,消費者需要主動調用 .read([size]) 函數才會從緩存池取出,并且可以帶上 size 參數,用多少就取多少:

const myReadable = new MyReadable(dataSource);
myReadable.setEncoding('utf8');
myReadable.on('readable', () => {
  let chunk;
  while (null !== (chunk = myReadable.read(5))) {//每次讀5個字節
    console.log(`Received ${chunk.length} bytes of data.`);
  }
});

這里值得注意的是,readable事件的回調函數沒有參數。因為 'readable' 事件將在流中有數據可供讀取時就會觸發,而在pause模式下讀取數據需要顯式調用read()才會消費數據
輸出為:

Received 5 bytes of data.
Received 5 bytes of data.
Received 5 bytes of data.
Received 5 bytes of data.
Received 5 bytes of data.
Received 1 bytes of data.

readableStream一些需要注意的事件

  • 'data' 事件會在流將數據傳遞給消費者時觸發
  • 'end' 事件將在流中再沒有數據可供消費時觸發
  • 'readable' (從字面上看:“可以讀的”) 事件將在流中有數據可供讀取時觸發。在某些情況下,為 'readable' 事件添加回調將會導致一些數據被讀取到內部緩存中。
    'readable' 事件表明流有了新的動態:要么是有了新的數據,要么是到了流的尾部。 對于前者, stream.read() 將返回可用的數據。而對于后者, stream.read() 將返回 null。
  • 'setEncoding' 設置編碼會使得該流數據返回指定編碼的字符串而不是Buffer對象。
  • ‘pipe’ 事件放到后面詳談。

writableStream

Writable streams 是 destination 的一種抽象,一個writable流指的是只能流進不能流出的流:

readableStream.pipe(writableStream)
writable stream

數據流過來的時候,會直接寫入到資源池,當寫入速度比較緩慢或者寫入暫停時,數據流會進入隊列池緩存起來,當生產者寫入速度過快,把隊列池裝滿了之后,就會出現「背壓」(back pressure),這個時候是需要告訴生產者暫停生產的,當隊列釋放之后,Writable Stream 會給生產者發送一個 drain 消息,讓它恢復生產.

writable.write() 方法向流中寫入數據,并在數據處理完成后調用 callback。在確認了 chunk 后,如果內部緩沖區的大小小于創建流時設定的 highWaterMark 閾值,函數將返回 true 。 如果返回值為 false (即隊列池已經裝滿),應該停止向流中寫入數據,直到 'drain' 事件被觸發。

構造一個可寫流需要重寫_write方法

const Writable = require('stream').writable
class MyWritableStream extends Writable{
    constructor(options){
        super(options)
    }

    _write(chunk, encoding, callback){
        console.log(chunk)
    }
}

一個寫入數據10000次的demo,其中可以加深對write方法和drain方法的認識

function writeOneMillionTimes(writer, data, encoding, callback) {
  let i = 10000;
  write();
  function write() {
    let ok = true;
    while(i-- > 0 && ok) {
      // 寫入結束時回調
      if(i===0){
          writer.write(data, encoding, callback)//當最后一次寫入數據即將結束時,再調用callback
      }else{
          ok = writer.write(data, encoding)//寫數據還沒有結束,不能調用callback
      }
     
    }
    if (i > 0) {
      // 這里提前停下了,'drain' 事件觸發后才可以繼續寫入  
      console.log('drain', i);
      writer.once('drain', write);
    }
  }
}

const Writable = require('stream').Writable;
class MyWritableStream extends Writable{
    constructor(options){
        super(options)
    }

    _write(chunk, encoding, callback){
        setTimeout(()=>{
            callback(null)
        },0)
        
    }
}
let writer = new MyWritableStream()
writeOneMillionTimes(writer, 'simple', 'utf8', () => {
  console.log('end');
});

輸出是

drain 7268
drain 4536
drain 1804
end

輸出結果說明程序遇到了三次「背壓」,如果我們沒有在上面綁定 writer.once('drain'),那么最后的結果就是 Stream 將第一次獲取的數據消耗完就結束了程序,即只輸出drain 7268

pipe

readable.pipe(writable);

readable 通過 pipe(管道)傳輸給 writable

Readable.prototype.pipe = function(writable, options) {
  this.on('data', (chunk) => {
    let ok = writable.write(chunk);
    if(!ok) this.pause();// 背壓,暫停
    
  });
  writable.on('drain', () => {
    // 恢復
    this.resume();
  });
  // 告訴 writable 有流要導入
  writable.emit('pipe', this);
  // 支持鏈式調用
  return writable;
};

核心有5點:

  • emit(pipe),通知寫入
  • .write(),新數據過來,寫入
  • .pause(),消費者消費速度慢,暫停寫入
  • .resume(),消費者完成消費,繼續寫入
  • return writable,支持鏈式調用

pipe的源碼

參考:
http://www.barretlee.com/blog/2017/06/06/dive-to-nodejs-at-stream-module/
http://nodejs.cn/api/stream.html
https://github.com/substack/stream-handbook

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,825評論 6 546
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,814評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,980評論 0 384
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 64,064評論 1 319
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,779評論 6 414
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,109評論 1 330
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,099評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,287評論 0 291
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,799評論 1 338
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,515評論 3 361
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,750評論 1 375
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,221評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,933評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,327評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,667評論 1 296
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,492評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,703評論 2 380

推薦閱讀更多精彩內容