[譯] Node.js 流: 你需要知道的一切

loveky發表於2017-06-14

Node.js 流: 你需要知道的一切

[譯] Node.js 流: 你需要知道的一切

圖片來源

Node.js 中的流有著難以使用,更難以理解的名聲。現在我有一個好訊息告訴你:事情已經不再是這樣了。

很長時間以來,開發人員創造了許許多多的軟體包為的就是可以更簡單的使用流。但是在本文中,我會把重點放在原生的 Node.js 流 API上。

“流是 Node 中最棒的,同時也是最被人誤解的想法。”

— Dominic Tarr

流到底是什麼呢?

流是資料的集合 —— 就像陣列或字串一樣。區別在於流中的資料可能不會立刻就全部可用,並且你無需一次性地把這些資料全部放入記憶體。這使得流在操作大量資料或是資料從外部來源逐傳送過來的時候變得非常有用。

然而,流的作用並不僅限於操作大量資料。它還帶給我們組合程式碼的能力。就像我們可以通過管道連線幾個簡單的 Linux 命令以組合出強大的功能一樣,我們可以利用流在 Node 中做同樣的事。

[譯] Node.js 流: 你需要知道的一切

Linux 命令的組合性

const grep = ... // 一個 grep 命令輸出的 stream
const wc = ... // 一個 wc 命令輸入的 stream

grep.pipe(wc)複製程式碼

Node 中許多內建的模組都實現了流介面:

[譯] Node.js 流: 你需要知道的一切

截圖來自於我的 Pluralsight 課程 —— 高階 Node.js

上邊的列表中有一些 Node.js 原生的物件,這些物件也是可以讀寫的流。這些物件中的一部分是既可讀、又可寫的流,例如 TCP sockets,zlib 以及 crypto。

需要注意的是這些物件是緊密關聯的。雖然一個 HTTP 響應在客戶端是一個可讀流,但在伺服器端它卻是一個可寫流。這是因為在 HTTP 的情況中,我們基本上是從一個物件(http.IncomingMessage)讀取資料,向另一個物件(http.ServerResponse)寫入資料。

還需要注意的是 stdio 流(stdinstdoutstderr)在子程式中有著與父程式中相反的型別。這使得在子程式中從父程式的 stdio 流中讀取或寫入資料變得非常簡單。

一個流的真例項子

理論是偉大的,當往往沒有 100% 的說服力。下面讓我們通過一個例子來看看流在節省記憶體消耗方面可以起到的作用。

首先讓我們建立一個大檔案:

const fs = require('fs');
const file = fs.createWriteStream('./big.file');

for(let i=0; i<= 1e6; i++) {
  file.write('Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n');
}

file.end();複製程式碼

看看在建立這個大檔案時我用到了什麼。一個可寫流!

通過 fs 模組你可以使用一個流介面讀取或寫入檔案。在上面的例子中,我們通過一個可寫流向 big.file 寫入了 100 萬行資料。

執行這段指令碼會生成一個約 400MB 大小的檔案。

以下是一個用來傳送 big.file 檔案的 Node web 伺服器:

const fs = require('fs');
const server = require('http').createServer();

server.on('request', (req, res) => {
  fs.readFile('./big.file', (err, data) => {
    if (err) throw err;

    res.end(data);
  });
});

server.listen(8000);複製程式碼

當伺服器收到請求時,它會通過非同步方法 fs.readFile 讀取檔案內容傳送給客戶端。看起來我們並沒有阻塞事件迴圈。一切看起來還不錯,是吧?是嗎?

讓我們來看看真實的情況吧。我們啟動伺服器,發起連線,並監控記憶體的使用情況。

當我啟動伺服器的時候,它佔用了一個正常大小的記憶體空間,8.7MB:

[譯] Node.js 流: 你需要知道的一切

當我連線到伺服器的時候。請注意記憶體消耗的變化:

[譯] Node.js 流: 你需要知道的一切

哇 —— 記憶體消耗暴增到 434.8MB。

在我們將其寫入響應物件之前,我們基本上把 big.file 的全部內容都載入到記憶體中了。這是非常低效的。

HTTP 響應物件也是一個可寫流。這意味著如果我們有一個代表了 big.file 內容的可讀流,我們就可以通過將兩個流連線起來以實現相同的功能而不必消耗約 400MB 的記憶體。

Node fs 模組中的 createReadStream 方法可以針對任何檔案給我們返回一個可讀流。我們可以把它和響應物件連線起來:

const fs = require('fs');
const server = require('http').createServer();

server.on('request', (req, res) => {
  const src = fs.createReadStream('./big.file');
  src.pipe(res);
});

server.listen(8000);複製程式碼

現在,當你再次連線到伺服器時,神奇的事情發生了(請注意記憶體消耗):

[譯] Node.js 流: 你需要知道的一切

發生了什麼?

當客戶端請求這個大檔案時,我們通過流逐塊的傳送資料。這意味著我們不需要把檔案的全部內容快取到記憶體中。記憶體消耗只增長了大約 25MB。

你可以把這個例子推向極端。重新生成一個 500 萬行而不是 100 萬行的 big.file 檔案。它大概有 2GB 那麼大。這已經超過了 Node 中預設的緩衝區大小的上限。

如果你嘗試通過 fs.readFile 讀取那個檔案,預設情況下會失敗(當然你可以修改緩衝區大小上限)。但是通過使用 fs.createReadStream,向客戶端傳送一個 2GB 的檔案就沒有任何問題。更棒的是,程式的記憶體消耗並不會因檔案增大而增長。

準備好學習流了嗎?

這篇文章是我的 Pluralsight 課堂上 Node.js 課程中的一部分。你可以通過這個連結找到這部分內容的視訊版。

流快速入門

在 Node.js 中有四種基本型別的流:可讀流,可寫流,雙向流以及變換流。

  • 可讀流是對一個可以讀取資料的源的抽象。fs.createReadStream 方法是一個可讀流的例子。
  • 可寫流是對一個可以寫入資料的目標的抽象。fs.createWriteStream 方法是一個可寫流的例子。
  • 雙向流既是可讀的,又是可寫的。TCP socket 就屬於這種。
  • 變換流是一種特殊的雙向流,它會基於寫入的資料生成可供讀取的資料。例如使用 zlib.createGzip 來壓縮資料。你可以把一個變換流想象成一個函式,這個函式的輸入部分對應可寫流,輸出部分對應可讀流。你也可能聽說過變換流有時被稱為 “thought streams”。

所有的流都是 EventEmitter 的例項。它們發出可用於讀取或寫入資料的事件。然而,我們可以利用 pipe 方法以一種更簡單的方式使用流中的資料。

pipe 方法

以下這行程式碼就是你要記住的魔法:

readableSrc.pipe(writableDest)複製程式碼

在這行簡單的程式碼中,我們以管道的方式把一個可讀流的輸出連線到了一個可寫流的輸入。管道的上游(source)必須是一個可讀流,下游(destination)必須是一個可寫流。當然,它們也可以是雙向流/變換流。事實上,如果我們使用管道連線的是雙向流,我們就可以像 Linux 系統裡那樣連線多個流:

readableSrc
  .pipe(transformStream1)
  .pipe(transformStream2)
  .pipe(finalWrtitableDest)複製程式碼

pipe 方法會返回最後一個流,這使得我們可以串聯多個流。對於流 a (可讀),bc (雙向),以及 d(可寫)。我們可以這樣:

a.pipe(b).pipe(c).pipe(d)

# 等價於:
a.pipe(b)
b.pipe(c)
c.pipe(d)

# 在 Linux 中,等價於:
$ a | b | c | d複製程式碼

pipe 方法是使用流最簡單的方式。通常的建議是要麼使用 pipe 方法、要麼使用事件來讀取流,要避免混合使用兩者。一般情況下使用 pipe 方法時你就不必再使用事件了。但如果你想以一種更加自定義的方式使用流,就要用到事件了。

流事件

除了從可讀流中讀取資料寫入可寫流以外,pipe 方法還自動幫你處理了一些其他情況。例如,錯誤處理,檔案結尾,以及兩個流讀取/寫入速度不一致的情況。

然而,流也可以直接通過事件讀取。以下是一段簡化的使用事件來模擬 pipe 讀取、寫入資料的程式碼:

# readable.pipe(writable)

readable.on('data', (chunk) => {
  writable.write(chunk);
});

readable.on('end', () => {
  writable.end();
});複製程式碼

以下是一些使用可讀流或可寫流時用到的事件和方法:

[譯] Node.js 流: 你需要知道的一切

截圖來自於我的 Pluralsight 課程 - 高階 Node.js

這些事件和函式是相關的,因為我們總是把它們組合在一起使用。

一個可讀流上最重要的兩個事件是:

  • data 事件,任何時候當可讀流傳送資料給它的消費者時,會觸發此事件
  • end 事件,當可讀流沒有更多的資料要傳送給消費者時,會觸發此事件

一個可寫流上最重要的兩個事件是:

  • drain 事件,這是一個表示可寫流可以接受更多資料的訊號.
  • finish 事件,當所有資料都被寫入底層系統後會觸發此事件。

事件和函式可以組合起來使用,以更加定製,優化的方式使用流。對於可讀流,我們可以使用 pipe/unpipe 方法,或是 readunshiftresume方法。對於可寫流,我們可以把它設定為 pipe/unpipe 方法的下游,亦或是使用 write 方法寫入資料並在寫入完成後呼叫 end 方法。

可讀流的暫停和流動模式

可讀流有兩種主要的模式,影響我們使用它的方式:

  • 它要麼處於暫停模式
  • 要麼就是處於流動模式

這些模式有時也被成為拉取和推送模式。

所有的可讀流預設都處於暫停模式。但它們可以按需在流動模式和暫停模式間切換。這種切換有時會自動發生。

當一個可讀流處於暫停模式時,我們可以使用 read() 方法按需的讀取資料。而對於一個處於流動模式的可讀流,資料會源源不斷的流動,我們需要通過事件監聽來處理資料。

在流動模式中,如果沒有消費者監聽事件那麼資料就會丟失。這就是為何在處理流動模式的可讀流時我們需要一個 data 事件回撥函式。事實上,通過增加一個 data 事件回撥就可以把處於暫停模式的流切換到流動模式;同樣的,移除 data 事件回撥會把流切回到暫停模式。這麼做的一部分原因是為了和舊的 Node 流介面相容。

要手動在這兩個模式間切換,你可以使用 resume()pause() 方法。

[譯] Node.js 流: 你需要知道的一切

截圖來自於我的 Pluralsight 課程 - 高階 Node.js

當使用 pipe 方法時,它會自動幫你處理好這些模式之間的切換,因此你無須關心這些細節。

實現流介面

當我們討論 Node.js 中的流時,主要是討論兩項任務:

  • 一個是實現流。
  • 一個是使用流。

到目前為止,我們只討論瞭如何使用流。接下來讓我們看看如何實現它!

流的實現者通常都會 require stream 模組。

實現一個可寫流

要實現一個可寫流,我們需要使用來自 stream 模組的 Writable 類。

const { Writable } = require('streams');複製程式碼

實現一個可寫流有很多種方法。例如,我們可以繼承 Writable 類:

class myWritableStream extends Writable {
}複製程式碼

然而,我傾向於更簡單的構造方法。我們可以直接給 Writable 建構函式傳入配置項來建立一個物件。唯一必須的配置項是一個 write 函式,它用於暴露一個寫入資料的介面。

const { Writable } = require('stream');
const outStream = new Writable({
  write(chunk, encoding, callback) {
    console.log(chunk.toString());
    callback();
  }
});

process.stdin.pipe(outStream);複製程式碼

write 方法接受三個引數。

  • chunk 通常是一個 buffer,除非我們對流進行了特殊配置。
  • encoding 通常可以忽略。除非 chunk 被配置為不是 buffer。
  • callback 方法是一個在我們完成資料處理後要執行的回撥函式。它用來表示資料是否成功寫入。若是寫入失敗,在執行該回撥函式時需要傳入一個錯誤物件。

outStream 中,我們只是單純的把收到的資料當做字串 console.log 出來,並通過執行 callback 時不傳入錯誤物件以表示寫入成功。這是一個非常簡單且沒什麼用處的回傳流。它會回傳任何收到的資料。

要使用這個流,我們可以把它和可讀流 process.stdin 配合使用。只需把 process.stdin 通過管道連線到 outStream

當我們執行上面的程式碼時,任何輸入到 process.stdin 中的字元都會被 outStream 中的 console.log 輸出回來。

這不是一個非常實用的流實現,因為 Node 已經內建了它的實現。它幾乎等同於 process.stdout。通過把 stdinstdout 連線起來,我們就可以通過一行程式碼得到完全相同的回傳效果:

process.stdin.pipe(process.stdout);複製程式碼

實現一個可讀流

要實現可讀流,我們需要引入 Readable 介面並通過它建立物件:

const { Readable } = require('stream');

const inStream = new Readable({});複製程式碼

這是一個非常簡單的可讀流實現。我們可以通過 push 方法向下遊推送資料。

const { Readable } = require('stream');  

const inStream = new Readable();

inStream.push('ABCDEFGHIJKLM');
inStream.push('NOPQRSTUVWXYZ');

inStream.push(null); // 沒有更多資料了

inStream.pipe(process.stdout);複製程式碼

當我們 push 一個 null 值,這表示該流後續不會再有任何資料了。

要使用這個可讀流,我們可以把它連線到可寫流 process.stdout

當我們執行以上程式碼時,所有讀取自 inStream 的資料都會被顯示到標準輸出上。非常簡單,但並不高效。

在把該流連線到 process.stdout 之前,我們就已經推送了所有資料。更好的方式是隻在使用者要求時按需推送資料。我們可以通過在可讀流配置中實現 read() 方法來達成這一目的:

const inStream = new Readable({
  read(size) {
    // 某人想要讀取資料
  }
});複製程式碼

當可讀流上的 read 方法被呼叫時,流實現可以向佇列中推送部分資料。例如,我們可以從字元編碼 65(表示字母 A) 開始,一次推送一個字母,每次都把字元編碼加 1:

const inStream = new Readable({
  read(size) {
    this.push(String.fromCharCode(this.currentCharCode++));
    if (this.currentCharCode > 90) {
      this.push(null);
    }
  }
});

inStream.currentCharCode = 65

inStream.pipe(process.stdout);複製程式碼

當使用者讀取該可讀流時,read 方法會持續被觸發,我們不斷推送字母。我們需要在某處停止該迴圈,這就是為何我們放置了一個 if 語句以便在 currentCharCode 大於 90(代表 Z) 時推送一個 null 值。

這段程式碼等價於之前的我們開始時編寫的那段簡單程式碼,但我們已改為在使用者需要時推送資料。你始終應該這樣做。

實現雙向/變換流

對於雙向流,我們要在同一個物件上同時現實可讀流和可寫流。就好像是我們繼承了兩個介面。

以下的例子實現了一個綜合了前面提到的可讀流與可寫流功能的雙向流:

const { Duplex } = require('stream');

const inoutStream = new Duplex({
  write(chunk, encoding, callback) {
    console.log(chunk.toString());
    callback();
  },

  read(size) {
    this.push(String.fromCharCode(this.currentCharCode++));
    if (this.currentCharCode > 90) {
      this.push(null);
    }
  }
});

inoutStream.currentCharCode = 65;

process.stdin.pipe(inoutStream).pipe(process.stdout);複製程式碼

通過組合這些方法,我們可以通過該雙向流讀取從 A 到 Z 的字母還可以利用它的回傳特性。我們把可讀的 stdin 流接入這個雙向流以利用它的回傳特性同時又把它接入可寫的 stdout 流以檢視字母 A 到 Z。

理解雙向流的讀取和寫入部分是完全獨立的這一點非常重要。它只不過是把兩種特性在同一個物件上實現罷了。

變換流是一種更有趣的雙向流,因為它的輸出是基於輸入運算得到的。

對於一個變換流,我們不需要實現 readwrite 方法,而是隻需要實現一個 transform 方法即可,它結合了二者的功能。它的函式簽名和 write 方法一致,我們也可以通過它 push 資料。

以下是一個把你輸入的任何內容轉換為大寫字母的變換流:

const { Transform } = require('stream');

const upperCaseTr = new Transform({
  transform(chunk, encoding, callback) {
    this.push(chunk.toString().toUpperCase());
    callback();
  }
});

process.stdin.pipe(upperCaseTr).pipe(process.stdout);複製程式碼

在這個變換流中,我們只實現了 transform() 方法,卻達到了前面雙向流例子的效果。在該方法中,我們把 chunk 轉換為大寫然後通過 push 方法傳遞給下游。

流物件模式

預設情況下,流接收的引數型別為 Buffer/String。我們可以通過設定 objectMode 引數使得流可以接受任何 JavaScript 物件。

以下是一個簡單的演示。以下變換流的組合用於把一個逗號分割的字串轉變成為一個 JavaScript 物件。傳入 "a,b,c,d" 就變成了 {a: b, c: d}

const { Transform } = require('stream');
const commaSplitter = new Transform({
  readableObjectMode: true,
  transform(chunk, encoding, callback) {
    this.push(chunk.toString().trim().split(','));
    callback();
  }
});
const arrayToObject = new Transform({
  readableObjectMode: true,
  writableObjectMode: true,
  transform(chunk, encoding, callback) {
    const obj = {};
    for(let i=0; i < chunk.length; i+=2) {
      obj[chunk[i]] = chunk[i+1];
    }
    this.push(obj);
    callback();
  }
});
const objectToString = new Transform({
  writableObjectMode: true,
  transform(chunk, encoding, callback) {
    this.push(JSON.stringify(chunk) + '\n');
    callback();
  }
});
process.stdin
  .pipe(commaSplitter)
  .pipe(arrayToObject)
  .pipe(objectToString)
  .pipe(process.stdout)複製程式碼

我們給 commaSplitter 傳入一個字串(假設是 "a,b,c,d"),它會輸出一個陣列作為可讀資料([“a”, “b”, “c”, “d”])。在該流上增加 readableObjectMode 標記是必須的,因為我們在給下游推送一個物件,而不是字串。

我們接著把 commaSplitter 輸出的陣列傳遞給了 arrayToObject 流。我們需要設定 writableObjectModel 以便讓該流可以接收一個物件。它還會往下游推送一個物件(輸入的資料被轉換成物件),這就是為什麼我們還需要配置 readableObjectMode 標誌位。最後的 objectToString 流接收一個物件但卻輸出一個字串,因此我們只需配置 writableObjectMode 即可。傳遞給下游的只是一個普通字串。

[譯] Node.js 流: 你需要知道的一切

以上例項程式碼的使用方法

Node 內建的變換流

Node 內建了一些非常有用的變換流。這就是 zlib 和 crypto 流。

下面是一個組合了 zlib.createGzip()fs 可讀/可寫流來壓縮檔案的指令碼:

const fs = require('fs');
const zlib = require('zlib');
const file = process.argv[2];

fs.createReadStream(file)
  .pipe(zlib.createGzip())
  .pipe(fs.createWriteStream(file + '.gz'));複製程式碼

你可以通過該指令碼給任何引數中傳入的檔案進行 gzip 壓縮。我們通過可讀流讀取檔案內容傳遞給 zlib 內建的變換流,然後通過一個可寫流來寫入新檔案。很簡單吧。

使用管道很棒的一點在於,如果有必要,我們可以把它和事件組合使用。例如,我希望在指令碼執行過程中給使用者一些進度提示,在指令碼執行完成後顯示一條完成訊息。既然 pipe 方法會返回下游流,我們就可以把註冊事件回撥的操作級聯在一起:

const fs = require('fs');
const zlib = require('zlib');
const file = process.argv[2];

fs.createReadStream(file)
  .pipe(zlib.createGzip())
  .on('data', () => process.stdout.write('.'))
  .pipe(fs.createWriteStream(file + '.zz'))
  .on('finish', () => console.log('Done'));複製程式碼

所以使用 pipe 方法,我們可以很簡單的使用流。當需要時,我們還可以通過事件來進一步定製和流的互動。

pipe 方法的好處在於,我們可以用一種更加可讀的方式通過若干片段組合我們的程式。例如,我們可以通過建立一個變換流來顯示進度,而不是直接監聽 data 事件。把 .on() 呼叫換成另一個 .pipe() 呼叫:

const fs = require('fs');
const zlib = require('zlib');
const file = process.argv[2];

const { Transform } = require('stream');

const reportProgress = new Transform({
  transform(chunk, encoding, callback) {
    process.stdout.write('.');
    callback(null, chunk);
  }
});

fs.createReadStream(file)
  .pipe(zlib.createGzip())
  .pipe(reportProgress)
  .pipe(fs.createWriteStream(file + '.zz'))
  .on('finish', () => console.log('Done'));複製程式碼

這個 reportProgress 流是一個簡單的直通流,但同時報告了進度資訊。請注意我是如何在 transform() 方法中利用 callback() 的第二個引數傳遞資料的。它等價於使用 push 方法推送資料。

組合流的應用是無止境的。例如,假設我們需要在壓縮檔案之前或之後加密它,我們要做的只不過是在正確的位置引入一個新的變換流。我們可以使用 Node 內建的 crypto 模組:

**const crypto = require('crypto');
**// ...複製程式碼
const crypto = require('crypto');
// ...
fs.createReadStream(file)
  .pipe(zlib.createGzip())
  .pipe(crypto.createCipher('aes192', 'a_secret'))
  .pipe(reportProgress)
  .pipe(fs.createWriteStream(file + '.zz'))
  .on('finish', () => console.log('Done'));複製程式碼

以上的指令碼對給定的檔案先壓縮再加密,只有知道祕鑰的人才能利用生成的檔案。我們不能利用普通的解壓工具解壓該檔案,因為它被加密了。

要能真正的解壓任何使用以上指令碼壓縮過的檔案,我們需要以相反的順序利用 crypto 和 zlib:

fs.createReadStream(file)
  .pipe(crypto.createDecipher('aes192', 'a_secret'))
  .pipe(zlib.createGunzip())
  .pipe(reportProgress)
  .pipe(fs.createWriteStream(file.slice(0, -3)))
  .on('finish', () => console.log('Done'));複製程式碼

假設傳入的檔案是壓縮後的版本,以上的指令碼會建立一個針對該檔案的讀取流,連線到一個 crypto 模組的 createDecipher() 流(使用相同的金鑰),之後將輸出傳遞給一個 zlib 模組的 createGunzip() 流,最後將得到的資料寫入一個沒有壓縮副檔名的檔案。

以上就是我關於本主題要討論的全部內容了。感謝閱讀!下次再見!

如果你認為這篇檔案對你有幫助,請點選下方的?。關注我以獲取更多關於 Node.js 和 JavaScript 的文章。

我為 PluralsightLynda 製作線上課程。我最近的課程是 React.js 入門, 高階 Node.js, 和學習全棧 JavaScript

我還進行線上與現場培訓,內容涵蓋 JavaScript,Node.js,React.js 和 GraphQL 從初級到高階的全部範圍。如果你在尋找一名講師,請聯絡我。我將在今年七月份的 Foward.js 上進行 6 場現場講習班,其中一場是 Node.js 進階

如果關於本文或任何我的其他文章有疑問,你可以通過這個 slack 賬號找到我並在 #questions 房間裡提問。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章