Node.js中不可不精的Stream

楊陽本尊發表於2019-03-04

一、什麼是Stream(流)

  • 流(stream)在 Node.js 中是處理流資料的抽象介面(abstract interface)。 stream 模組提供了基礎的API。使用這些API可以很容易地來構建實現流介面的物件。例如, HTTP 請求 和 process.stdout 就都是流的例項。
  • 流可以是可讀的、可寫的,或是可讀寫的。注意,所有的流都是 EventEmitter 的例項

二、流的型別

Node.js 中有四種基本的流型別:

  1. Readable – 可讀的流 (例如 fs.createReadStream())。
  2. Writable – 可寫的流 (例如 fs.createWriteStream())。
  3. Duplex – 可讀寫的流(雙工流) (例如 net.Socket)。
  4. Transform – 在讀寫過程中可以修改和變換資料的 Duplex 流 (例如 zlib.createDeflate())。
var Stream = require(`stream`) //stream 模組引入方式

var Readable = Stream.Readable //可讀的流
var Writable = Stream.Writable //可寫的流
var Duplex = Stream.Duplex //可讀寫的流
var Transform = Stream.Transform //在讀寫過程中可以修改和變換資料的 Duplex 流
複製程式碼

Node.js中關於流的操作被封裝到了Stream模組中,這個模組也被多個核心模組所引用。例如在fs.createReadStream()和fs.createWriteStream()的原始碼實現裡,都呼叫了Stream模組提供的抽象介面來實現對流資料的操作。

三、為什麼使用Stream?

我們通過兩個例子,瞭解一下為什麼要使用Stream。

Exp1:

下面是一個讀取檔案內容的例子:

const fs = require(`fs`)

fs.readFile(file, function (err, content) { //讀出來的content是Buffer
  console.log(content)
  console.log(content.toString())
})
複製程式碼

但如果檔案內容較大,譬如在500M時,執行上述程式碼的輸出為:

<Buffer 64 74 09 75 61 09 63 6f 75 6e 74 0a 0a 64 74 09 75 61 09 63 6f 75 6e 74 0a 32 30 31 35 31 32 30 38 09 4d 6f 7a 69 6c 6c 61 2f 35 2e 30 20 28 63 6f 6d ... >
buffer.js:382
    throw new Error(`toString failed`);
    ^

Error: toString failed
    at Buffer.toString (buffer.js:382:11)
複製程式碼

報錯的原因是content這個Buffer物件的長度過大,導致toString方法失敗。
可見,這種一次獲取全部內容的做法,不適合操作大檔案。

可以考慮使用流來讀取檔案內容。

var fs = require(`fs`)

fs.createReadStream(bigFile).pipe(process.stdout) 
複製程式碼

fs.createReadStream建立一個可讀流,連線了源頭(上游,檔案)和消耗方(下游,標準輸出)。

執行上面程式碼時,流會逐次呼叫fs.read(ReadStream這個類的原始碼裡有一個_read方法,這個_read方法在內部呼叫了fs.read來實現對檔案的讀取),將檔案中的內容分批取出傳給下游。

在檔案看來,它的內容被分塊地連續取走了。

在下游看來,它收到的是一個先後到達的資料序列。

如果不需要一次操作全部內容,它可以處理完一個資料便丟掉。

在流看來,任一時刻它都只儲存了檔案中的一部分資料,只是內容在變化而已。

這種情況就像是用水管去取池子中的水。

每當用掉一點水,水管便會從池子中再取出一點。

無論水池有多大,都只儲存了與水管容積等量的水。

Exp2:

下面是一個線上看視訊的例子,假定我們通過HTTP請求返回視訊內容給使用者

const http = require(`http`);
const fs = require(`fs`);
 
http.createServer((req, res) => {
    fs.readFile(videoPath, (err, data) => {
    res.end(data);
});
}).listen(8080);
複製程式碼

但這樣有兩個明顯的問題

  1. 視訊檔案需要全部讀取完,才能返回給使用者,這樣等待時間會很長。
  2. 視訊檔案一次全放入記憶體中,記憶體吃不消。

用流可以將視訊檔案一點一點讀到記憶體中,再一點一點返回給使用者,讀一部分,寫一部分。(利用了 HTTP 協議的 Transfer-Encoding: chunked 分段傳輸特性),使用者體驗得到優化,同時對記憶體的開銷明顯下降。

const http = require(`http`);
const fs = require(`fs`);
 
http.createServer((req, res) => {
    fs.createReadStream(videoPath).pipe(res);
}).listen(8080);
複製程式碼

通過上述兩個例子,我們知道,在大資料情況下必須使用流式處理。

四、可讀流(Readable Stream)

可讀流(Readable streams)是對提供資料的源頭(source)的抽象。

常見的可讀流:

  • HTTP responses, on the client
  • HTTP requests, on the server
  • fs read streams
  • TCP sockets //sockets是一個雙工流,即可讀可寫的流
  • process.stdin //標準輸入

所有的 Readable Stream 都實現了 stream.Readable 類定義的介面。

可讀流的兩種模式(flowing 和 paused)

  1. 在 flowing 模式下,可讀流自動從系統底層讀取資料,並通過 EventEmitter 介面的事件儘快將資料提供給應用(所有的流都是 EventEmitter 的例項)。

  2. 在 paused 模式下,必須顯式呼叫 stream.read()方法來從流中讀取資料片段。

建立流的Readable流,預設是非流動模式(paused模式),預設不會讀取資料。所有初始工作模式為paused的Readable流,可以通過下面三種途徑切換為flowing模式:

  • 監聽’data’事件
  • 呼叫stream.resume()方法
  • 呼叫stream.pipe()方法將資料傳送到Writable

fs.createReadStream(path[, options])原始碼實現

//檔名 ReadStream.js
let fs = require(`fs`);//讀取檔案
let EventEmitter = require(`events`);
class ReadStream extends EventEmitter {//流操作都是基於事件的
  constructor(path, options = {}) {
    super();
    //需要的引數
    this.path = path;//讀取檔案的路徑
    this.highWaterMark = options.highWaterMark || 64 * 1024;//緩衝區大小,預設64KB
    this.autoClose = options.autoClose || true;//是否需要自動關閉檔案描述符,預設為true
    this.start = options.start || 0; //options 可以包括 start 和 end 值,使其可以從檔案讀取一定範圍的位元組而不是整個檔案
    this.pos = this.start; // 從檔案的那個位置開始讀取內容,pos會隨著讀取的位置而改變
    this.end = options.end || null; // null表示沒傳遞
    this.encoding = options.encoding || null;
    this.flags = options.flags || `r`;//以何種方式操作檔案

    // 引數的問題
    this.flowing = null; // 預設為非流動模式
    // 建一個buffer存放讀出來的資料
    this.buffer = Buffer.alloc(this.highWaterMark);
    this.open(); 
    // {newListener:[fn]}
    // 次方法預設同步呼叫的
    this.on(`newListener`, (type) => { // 等待著 它監聽data事件
      if (type === `data`) {//當監聽到data事件時,把流設定為流動模式
        this.flowing = true;
        this.read();// 開始讀取 客戶已經監聽了data事件
      }
    })
  }
  pause(){//將流從flowing模式切換為paused模式
    this.flowing = false;
  }
  resume(){//將流從paused模式切換為flowing模式
    this.flowing =true;
    this.read();//將流從paused模式切換為flowing模式後,繼續讀取檔案內容
  }
  read(){ // 預設第一次呼叫read方法時還沒有獲取fd,檔案的開啟是非同步的,所以不能直接讀
    if(typeof this.fd !== `number`){ //如果fd不是number型別,證明檔案還沒有開啟,此時需要監聽一次open事件,因為檔案一開啟,就會觸發open事件,這個在this.open()裡寫了
       return this.once(`open`,() => this.read()); // 等待著觸發open事件後fd肯定拿到了,拿到以後再去執行read方法
    }
    // 當獲取到fd時 開始讀取檔案了
    // 第一次應該讀2個 第二次應該讀2個
    // 第二次pos的值是4 end是4
    // 讀取檔案裡一共4有個數為123 4,我們讀取裡面的123 4
    let howMuchToRead = this.end?Math.min(this.end-this.pos+1,this.highWaterMark): this.highWaterMark;//規定每次讀取多少個位元組
    fs.read(this.fd, this.buffer, 0, howMuchToRead, this.pos, (error, byteRead) => { // byteRead為真實的讀到了幾個位元組的內容
      // 讀取完畢
      this.pos += byteRead; // 讀出來兩個,pos位置就往後移兩位
      // this.buffer預設就是三個
      let b = this.encoding ? this.buffer.slice(0, byteRead).toString(this.encoding) : this.buffer.slice(0, byteRead);//對讀出來的內容進行編碼
      this.emit(`data`, b);//觸發data事件,將讀到的內容輸出給使用者
      if ((byteRead === this.highWaterMark)&&this.flowing){
        return this.read(); // 繼續讀
      }
      // 這裡就是沒有更多的邏輯了
      if (byteRead < this.highWaterMark){
        // 沒有更多了
        this.emit(`end`); // 讀取完畢
        this.destroy(); // 銷燬即可
      }
    });
  }
  // 開啟檔案用的
  destroy() {
    if (typeof this.fd != `number`) { return this.emit(`close`); } //如果檔案還沒開啟,直接觸發close事件
    fs.close(this.fd, () => {
      // 如果檔案開啟過了 那就關閉檔案並且觸發close事件
      this.emit(`close`);
    });
  }
  open() {
    fs.open(this.path, this.flags, (err, fd) => { //fd是檔案描述符,它標識的就是當前this.path這個檔案,從3開始(number型別)
      if (err) {
        if (this.autoClose) { // 如果需要自動關閉我再去銷燬fd
          this.destroy(); // 銷燬(關閉檔案,觸發關閉事件)
        }
        this.emit(`error`, err); // 如果有錯誤觸發error事件
        return;
      }
      this.fd = fd; // 儲存檔案描述符
      this.emit(`open`, this.fd); // 檔案被開啟了,觸發檔案被開啟的方法
    });
  }
  pipe(dest){//管道流的實現 pipe()方法是ReadStream下的方法,它裡面的引數是WritableStream
    this.on(`data`,(data)=>{
      let flag = dest.write(data);
      if(!flag){//這個flag就是每次呼叫ws.write()後返回的讀狀態值
        this.pause();// 已經不能繼續寫了,等他寫完了再恢復
      }
    });
    dest.on(`drain`,()=>{//當讀取快取區清空後
      console.log(`寫一下停一下`)
      this.resume();//繼續往dest寫入資料
    });
  }
}
module.exports = ReadStream;//匯出可讀流
複製程式碼

使用fs.createReadStream()

// 流:有序的有方向的,可以自己控制速率
// 讀:讀是將內容讀取到記憶體中 
// 寫:寫是將記憶體或者檔案的內容寫入到檔案內
// 讀取的時候預設讀 預設一次讀取64k,encoding 讀取出來的內容預設都是buffer
//let fs = require(`fs`);
//let rs = fs.createReadStream({...});//原生實現可讀流
let ReadStream = require(`./ReadStream`);
let rs = new ReadStream(`./2.txt`, {
  highWaterMark: 3, // 位元組
  flags:`r`,//讀檔案
  autoClose:true, // 預設讀取完畢後自動關閉檔案描述符
  start:0,
  //end:3,// 流是閉合區間 包start也包end
  encoding:`utf8`
});
// 預設建立一個流 是非流動模式(上述原始碼中有寫的),預設不會讀取資料
// 如果我們需要接收資料,那我們要監聽data事件,這樣資料會自動的流出來
rs.on(`error`,function (err) {// 通常,這會在底層系統內部出錯從而不能產生資料,或當流的實現試圖傳遞錯誤資料時發生。
  console.log(err)
});
rs.on(`open`,function () {//檔案被開啟了,獲取到了fd。內部會自動的觸發這個事件 rs.emit(`data`); 
  console.log(`檔案開啟了`);
});
rs.on(`data`,function (data) {//有資料流出來了
  console.log(data);
  rs.pause(); // 暫停觸發on(`data`)事件,將流動模式又轉化成了非流動模式
});
setTimeout(()=>{rs.resume()},3000);//三秒鐘之後再將非流動模式轉化為流動模式
rs.on(`end`,function () {// 讀取完畢
  console.log(`讀取完畢了`);
});
rs.on(`close`,function () {//close 事件將在流或其底層資源(比如一個檔案)關閉後觸發。close 事件觸發後,該流將不會再觸發任何事件。
  //console.log(`關閉`)
});
複製程式碼

四、可寫流(Writable Stream)

可寫流是對資料流向裝置的抽象,用來消費上游流過來的資料,通過可寫流程式可以把資料寫入裝置,常見的是本地磁碟檔案或者 TCP、HTTP 等網路響應。

常見的可寫流:

  • HTTP requests, on the client
  • HTTP responses, on the server
  • fs write streams
  • zlib streams
  • crypto streams
  • TCP sockets
  • child process stdin
  • process.stdout, process.stderr

所有 Writable 流都實現了 stream.Writable 類定義的介面。

可寫流的使用

呼叫可寫流例項的 write() 方法就可以把資料寫入可寫流

const fs = require(`fs`);
const rs = fs.createReadStream(sourcePath);
const ws = fs.createWriteStream(destPath);
 
rs.setEncoding(`utf-8`); // 設定編碼格式
rs.on(`data`, chunk => {
ws.write(chunk); // 寫入資料
});
複製程式碼

監聽了可讀流的data事件就會使可讀流進入流動模式,我們在回撥事件裡呼叫了可寫流的 write() 方法,這樣資料就被寫入了可寫流抽象的裝置destPath中。

write() 方法有三個引數

  • chunk {String| Buffer},表示要寫入的資料
  • encoding 當寫入的資料是字串的時候可以設定編碼
  • callback 資料被寫入之後的回撥函式

drain事件

如果呼叫 stream.write(chunk)方法返回false,表示當前快取區已滿,流將在適當的時機(快取區清空後)觸發drain事件。

const fs = require(`fs`);
const rs = fs.createReadStream(sourcePath);
const ws = fs.createWriteStream(destPath);
 
rs.setEncoding(`utf-8`); // 設定編碼格式
rs.on(`data`, chunk => {
let flag = ws.write(chunk); // 寫入資料
if (!flag) { // 如果快取區已滿暫停讀取
rs.pause();
}
});
 
ws.on(`drain`, () => {
rs.resume(); // 快取區已清空 繼續讀取寫入
});
複製程式碼

fs.createWriteStream(path[, options])原始碼實現

// 檔案 WriteStream.js
let fs = require(`fs`);
let EventEmitter = require(`events`);
class WriteStream extends EventEmitter {
  constructor(path, options = {}) {
    super();
    this.path = path;
    this.flags = options.flags || `w`;
    this.encoding = options.encoding || `utf8`;
    this.start = options.start || 0;
    this.pos = this.start;
    this.mode = options.mode || 0o666;
    this.autoClose = options.autoClose || true;
    this.highWaterMark = options.highWaterMark || 16 * 1024;
    this.open(); // fd 非同步的  //觸發一個open事件,當觸發open事件後fd肯定就存在了

    // 寫檔案的時候 需要的引數有哪些
    // 第一次寫入是真的往檔案裡寫
    this.writing = false; // 預設第一次就不是正在寫入
    // 用簡單的陣列來模擬一下快取
    this.cache = [];
    // 維護一個變數,表示快取的長度
    this.len = 0;
    // 是否觸發drain事件
    this.needDrain = false;
  }
  clearBuffer() {
    let buffer = this.cache.shift();
    if (buffer) { // 如果快取裡有
      this._write(buffer.chunk, buffer.encoding, () => this.clearBuffer());
    } else {// 如果快取裡沒有了
      if (this.needDrain) { // 需要觸發drain事件
        this.writing = false; // 告訴下次直接寫就可以了 不需要寫到記憶體中了
        this.needDrain = false;
        this.emit(`drain`);
      }
    }
  }
  _write(chunk, encoding, clearBuffer) { // 因為write方法是同步呼叫的此時fd還沒有獲取到,所以等待獲取到再執行write操作
    if (typeof this.fd != `number`) {
      return this.once(`open`, () => this._write(chunk, encoding, clearBuffer));
    }
    fs.write(this.fd, chunk, 0, chunk.length, this.pos, (err, byteWritten) => {
      this.pos += byteWritten;
      this.len -= byteWritten; // 每次寫入後就要在記憶體中減少一下
      clearBuffer(); // 第一次就寫完了
    })
  }
  write(chunk, encoding = this.encoding) { // 客戶呼叫的是write方法去寫入內容
    // 要判斷 chunk必須是buffer或者字串 為了統一,如果傳遞的是字串也要轉成buffer
    chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
    this.len += chunk.length; // 維護快取的長度 3
    let ret = this.len < this.highWaterMark;
    if (!ret) {
      this.needDrain = true; // 表示需要觸發drain事件
    }
    if (this.writing) { // 表示正在寫入,應該放到記憶體中
      this.cache.push({
        chunk,
        encoding,
      });
    } else { // 第一次
      this.writing = true;
      this._write(chunk, encoding, () => this.clearBuffer()); // 專門實現寫的方法
    }
    return ret; // 能不能繼續寫了,false表示下次的寫的時候就要佔用更多記憶體了
  }
  destroy() {
    if (typeof this.fd != `number`) {
      this.emit(`close`);
    } else {
      fs.close(this.fd, () => {
        this.emit(`close`);
      });
    }
  }
  open() {
    fs.open(this.path, this.flags, this.mode, (err, fd) => {
      if (err) {
        this.emit(`error`, err);
        if (this.autoClose) {
          this.destroy(); // 如果自動關閉就銷燬檔案描述符
        }
        return;
      }
      this.fd = fd;
      this.emit(`open`, this.fd);
    });
  }
}
module.exports = WriteStream;
複製程式碼

使用fs.createWriteStream()

// 可寫流有快取區的概念
// 1.第一次寫入是真的向檔案裡寫,第二次在寫入的時候是放到了快取區裡
// 2.寫入時會返回一個boolean型別,返回為false時表示快取區滿了,不要再寫入了
// 3.當記憶體和正在寫入的內容消耗完後,會觸發一個drain事件
//let fs = require(`fs`);
//let rs = fs.createWriteStream({...});//原生實現可寫流
let WS = require(`./WriteStream`)
let ws = new WS(`./2.txt`, {
  flags: `w`, // 寫入檔案,預設檔案不存在會建立
  highWaterMark: 1, // 設定當前快取區的大小
  encoding: `utf8`, // 檔案裡存放的都是二進位制
  start: 0,
  autoClose: true, // 自動關閉檔案描述符
  mode: 0o666, // 可讀可寫
});
// drain的觸發時機,只有當highWaterMark填滿時,才可能觸發drain
// 當嘴裡的和地下的都吃完了,就會觸發drain方法
let i = 9;
function write() {
  let flag = true;
  while (flag && i >= 0) {
    i--;
    flag = ws.write(`111`); // 987 // 654 // 321 // 0
    console.log(flag)
  }
}
write();
ws.on(`drain`, function () {
  console.log(`dry`);
  write();
});
複製程式碼

總結

stream(流)分為可讀流(flowing mode和paused mode)、可寫流、可讀寫流,Node.js 提供了多種流物件。 例如, HTTP 請求 和 process.stdout 就都是流的例項。stream 模組提供了基礎的 API 。使用這些 API 可以很容易地來構建實現流介面的物件。它們底層都呼叫了stream模組並進行封裝。

相關文章