深入理解nodejs Stream模組

changqingniubi發表於2018-02-03

為什麼應該使用流

你可能看過這樣的程式碼。

var http = require('http');  
var fs = require('fs')  
var server = http.createServer(function (req, res) {  
    fs.readFile(__dirname + '/data.txt', function (err, dat  
        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,生產者不斷在快取中製造產品,而消費者則不斷地從快取中消費產品

readableStream

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

深入理解nodejs Stream模組

資源的資料流並不是直接流向消費者,而是先 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模式的流程圖如下

深入理解nodejs Stream模組

資源池會不斷地往快取池輸送資料,直到 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' 事件將在流中再沒有資料可供消費時觸發
  • 'end' 事件將在流中再沒有資料可供消費時觸發
  • 'readable'(從字面上看:“可以讀的”)事件將在流中有資料可供讀取時觸發。在某些情況下,為 'readable' 事件新增回撥將會導致一些資料被讀取到內部快取中。'readable' 事件表明 流有了新的動態:要麼是有了新的資料,要麼是到了流的尾部。 對於前者, stream.read() 將返回可用的資料。而對於後者, stream.read() 將返回 null。
  • 'setEncoding'設定編碼會使得該流資料返回指定編碼的字串而不是Buffer物件。
  • 'pipe' 事件放到後面詳談。

writableStream

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

readableStream.pipe(writableStream) 

複製程式碼

深入理解nodejs Stream模組

資料流過來的時候,會直接寫入到資源池,當寫入速度比較緩慢或者寫入暫停時,資料流會進入佇列池快取起來,當生產者寫入速度過快,把佇列池裝滿了之後,就會出現「背壓」(backpressure),這個時候是需要告訴生產者暫停生產的,當佇列釋放之後,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,支援鏈式呼叫

相關文章