你應該知道的Node.js流

spursyy發表於2019-02-27

文章翻譯自:Node.js Streams: Everything you need to know

streams.jpeg

在開發者中普遍認為Node.js流不但難以應用,而且難以理解。現在有一個好訊息,Node.js流將不在難以處理。過去幾年,為了方便操作Node.js流,開發者開發了許多第三方Node.js包。但是在這篇文章中,我將集中在Node.js原生的流介面應用的介紹。

“Streams are Node’s best and most misunderstood idea.”

— Dominic Tarr

什麼是流

流就是資料集合----諸如陣列或是字串。不同之處在於流不必一次全部使用,它們也不必適應記憶體。這兩個特點使流在處理大量資料或一次向外部返回一大塊資料時非常高效。

流程式碼的組合性,為流處理大量資料,提供了新的力量。就像把微小linux命令組合成功能豐富的組合命令一樣,Node.js流通過同樣的方式實現資料通道的功能。

linux-command.png

const grep = ... // A stream for the grep output
const wc = ... // A stream for the wc input
grep.pipe(wc)
複製程式碼

許多Node.js內建模組都實現流介面:

native-module.png

上面展示的API中,一部分原生Node.js物件既是可讀又是可寫流,諸如TCP Sockets,Zlib和Crypto流。

值得注意的是,物件的部分行為是密切相關的。例如:在客戶端HTTP物件是可讀流,在服務端HTTP物件是可寫流。這是因為在HTTP上,程式從一個物件上讀取資料(http.IncomingMessage),然後將讀取的資料寫到另外一個物件上(http.ServerResponse)。

一個關於流實際用例

理論聽起來美妙,但並不能完全傳遞流的精妙。讓我們看一個例子,通過這個例子,可以看出是否使用流對於記憶體佔用的不同影響。

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

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檔案中。

執行相應的程式碼,生成大約400兆的檔案。

下面是一個專門用來操作這個大檔案的Node伺服器程式碼:

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.7兆。

server-memory.png

然後請求這個服務,注意記憶體佔用的情況:

server-memory.gif

哇 ---- 記憶體佔用突然間跳到434.8兆。

本質上講,程式在將大資料檔案寫入到http響應物件前,會將所有的資料寫入記憶體中。這種程式碼的效率是非常低效的。

HTTP響應物件也是一個可寫流,如果我們將代表big.file內容可讀流與HTTP相應物件的可寫流在管道中連線,程式就可以通過兩個流管道,在不產生近400兆記憶體佔用的情況下,達到相同的結果。

Node.js中的fs模組通過createReadStream方法,生成讀取檔案的可讀流。然後程式可以通過管道將可讀流傳到http響應物件中:

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);
複製程式碼

當再次請求服務時,一個神奇的事情發生了(注意記憶體佔用):

server-memory-opt.gif

發生了什麼

當客戶端請求大檔案時,程式一次將一塊資料生成流檔案,這就意味著我們不需要將資料快取到記憶體中。記憶體的佔用也僅僅上升了25兆。

我們可以將這個測試用例推到極限。重新生成五百萬行而不是一百萬行的big.file檔案,重新生成的檔案將會達到2GB,這將大於Node.js預設的快取量。

使用fs.readFile實現大記憶體檔案的讀取,最好不要修改程式預設的快取空間。但是如果使用fs.createReadStream,即便請求2GB的資料流也不會有問題。使用第二種方式作為服務程式的記憶體佔用幾乎不發生變化。

流在Node.js中有四種:Readable、Writable、Duplex和Transform。

  • 可讀流(Readable Stream): 可被消費的資源的抽象,如 fs.createReadStream方法
  • 可寫流(Writable Stream):資料可被寫入目的地的抽象,如 fs.createWriteStream方法
  • 雙工流(Duplex Stream):既是可讀流,又是可寫流, 如 TCP socket
  • 轉換流(Transform Stream):以雙工流為基礎,把讀取資料或者寫入資料進行修改或者轉換。如 zlib.createGzip函式使用gzip方法實現資料的壓縮。我們可以認為轉換流的輸入是可寫流、輸出是可讀流。這就是聽說過的"通過流"的轉換流。

所有流都是EventEmitter模組的例項,觸發可讀和可寫資料的事件。但是,程式可以使用pipe函式消費流資料。

管道(pipe)函式

下面是一段你值得記憶的魔法程式碼:

readableSrc.pipe(writableDest)

在這一簡單的程式碼中,將可讀流的輸出 (資料來源) 作為可寫流的輸入 (目標) 進行管道化。源資料必須是可讀流,目標必須是可寫流。它們也可以同時是雙工流或者轉換流。事實上, 如果開發者將雙工流傳入管道中, 我們就可以像Linux那樣連結到管道呼叫:

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

管道函式返回的是目標流,它可以允許程式做上面的鏈式呼叫。下面的程式碼: 流a是可讀流、流b與c是雙工流、流c是可寫流。

a.pipe(b).pipe(c).pipe(d)
# Which is equivalent to:
a.pipe(b)
b.pipe(c)
c.pipe(d)
# Which, in Linux, is equivalent to:
$ a | b | c | d
複製程式碼

管道(pipe)方法是實現流消費的最簡單方式。通常建議使用管道函式(pipe)或者事件消費流,但是避免將它們混合使用。通常當你使用管道(pipe)函式時,你就不需要使用事件。但是如果程式需要定製流的消費,事件可以是一個不錯的選擇。

流事件

除了讀取可讀流源,並把讀取的資料寫入到可寫的目的地上。管道(pipe)還可以自動管理一些事情。例如:它可以處理異常,當一個流比其它流更快或更慢時結束檔案。

但是,流可以通過事件被直接消費。下面是一段等效於管道(pipe)方法的程式,它通過簡化的、與事件等效的程式碼實現資料的讀取或寫入。

# readable.pipe(writable)
readable.on('data', (chunk) => {
  writable.write(chunk);
});
readable.on('end', () => {
  writable.end();
});
複製程式碼

這裡有一系列可用於可讀、可寫流的事件或函式。

event-function.png

這些事件或函式通常以某種方式相關聯,關於可讀流的事件有:

  • data事件,當流傳遞給消費者時觸發
  • end事件,當流中沒有資料被消費時觸發

關於可寫流的重要事件有: -drain事件,可寫流接受資料時的訊號 -finish事件,所有的資料已經重新整理到系統底層時觸發

通過事件和函式可以組合在一起後自定義流或流的優化。為了消費可讀流,程式可以使用pipe/unpipe方法,或是read/unshift/resume方法。為了消費可寫流,程式可以將它作為pipe/unpipe的目的地,或是通過write方法寫入資料,在寫入完成後呼叫end方法。

可讀流中的暫停(Paused)和流動(Flowing)模式

可讀流中存在兩種模式影響程式對可讀流的使用:

  • 可讀要麼處在暫停(paused)模式
  • 要麼處在流動(flowing)模式

這些模式又是被認為是拉和推模式。

所有的可讀流在預設情況下都是從暫停模式開始,在程式需要時,轉換成流動模式或者暫停模式。有時這種轉換是自發的。

當可讀流在暫停(paused)模式時,我們可以使用read方法按需讀取流資料。但是,對於處在流動(flowing)模式下的可讀流,我們必須通過監聽事件來消費資料。

在流動(flowing)模式下,如果資料沒有被消費,資料可能會丟失。這就是當程式中有流動的可讀流時,需要data事件處理資料的原因。事實上,只需要新增data事件就可以將流從暫停轉換為流動模式和解除程式與事件監聽器的繫結、將流從流動模式轉換為暫停模式。其中的一些是為了向後相容老版本Node流的介面。

開發者可以使用resume方法和pause方法,手動實現兩種流模式的轉換。

mode-transform.png

當程式使用管道(pipe)方法消費可讀流時,開發這就不必關心流模式的轉換了,因為管道(pipe)會自動實現。

##實現流

當我們Node.js中的流時,有兩種不同的任務:

  • 繼承流的任務
  • 消費流的任務

到目前為止,我們僅僅討論著消費流。讓我們實現一些例子吧!

實現流需要我們在程式中引入流模組

實現可寫流

開發者可以使用流模組中的Writeable構造器,實現可寫流。

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

開發者實現可寫流有很多種方式。例如:通過繼承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引數在測試用例中是需要的,但是開發者通常可以忽略
  • callback是程式處理資料塊後,開發者呼叫的回撥函式。通常是寫入操作成功與否的訊號。如果是寫入異常的訊號,呼叫出現異常的回撥函式。

在outStream類中,程式僅僅將資料轉換為字串型別列印出來,並在沒有出現異常時呼叫回撥函式,以此來標誌程式的成功執行。這是一個簡單但不是特別有效的回聲流,程式會輸出任何輸入的資料。

要使用這個流,我們可以將它與process.stdin一起使用,這是一個可讀的流,將process.stdin傳輸到outStream。

當程式執行時,任何通過process.stdin輸入的資料都會被outStream中的console.log函式列印出來。

但是這個功能可以通過Node.js內建模組實現,因此這並不是一個非常實用的流。它與process.stdout的功能非常類似,我們使用下面的程式碼可以實現相同的功能:

process.stdin.pipe(process.stdout);

實現可讀流

為了實現一個可讀流,開發者需要引入Readable的介面後通過這個介面構建物件:

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

這是實現可讀流的最簡單方式,開發者可以直接推送資料以供消費使用。

const { Readable } = require('stream'); 
const inStream = new Readable();
inStream.push('ABCDEFGHIJKLM');
inStream.push('NOPQRSTUVWXYZ');
inStream.push(null); // No more data
inStream.pipe(process.stdout);
複製程式碼

當程式中推送一個空物件時,這就意味著不再有資料供給可讀流。

開發者可以將可讀流通過管道傳給process.stdout的方式,供其消費可讀流。

執行這段程式碼,程式可以讀取來自可讀流的資料,並將資料列印出來。非常簡單,但是並不高效。

上段程式碼的本質是:把資料推送給流,然後將流通過管道傳給process.stdout消費。其實程式可以在消費者請求流時,按需推送資料,這種方式比上一種更高效。通過實現readable流中的read函式實現:

const inStream = new Readable({
  read(size) {
    // there is a demand on the data... Someone wants to read it.
  }
});
複製程式碼

在readable流中呼叫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函式就會被啟用,程式就會推送更多的字母。通過向佇列些推送空物件,終止迴圈。如上程式碼中,當字母的序號超過90時,終止迴圈。

這段程式碼的功能與之前實現的程式碼是等效的,但是當消費者要讀流時,程式可以按需推送資料的效率更優。因此建議使用這種實現方式。

實現雙工、轉換流

雙工流:在同一物件上分別實現可讀流和可寫流,就像物件繼承了兩個可讀流和可寫流介面。

下面是一個雙工流,它結合了上面已經實現的可讀流、可寫流的例子:

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字母。

在雙工流中的可讀流與可寫流是完全獨立的,雙工流僅僅是一個物件同時具有可讀流和可寫流的功能。理解這一點至關重要。

轉換流比雙工流更有趣,因為它的結果是根據輸入流計算出來的。

對於雙工流,並不需要實現read和write函式,開發者僅僅需要實現transform函式,因為transform函式已經實現了read函式和write函式。

下面是將輸入的字母轉換為大寫格式後,然後把轉換後的資料傳給可寫流:

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函式,就實現了向上面雙工流的功能。在transform函式中,程式將資料轉換為大寫後推送到可寫流中。

流的物件模式

預設情況下,流只接受Buffer和String的資料。但是開發者可以通過設定objectMode標識的值,可以使流接受任何Javascript資料。

下面的例子可以證明這一點。通過一組流將以逗號分隔的字串轉換為Javscript物件,於是"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”])。設定writeObjectMode標識,因為在transform函式中推的資料是物件而不是字串。

然後將commaSplitter輸出的可讀流傳輸到轉換流arrayToObject中。由於接收的是物件,同樣需要在arrayToObject中需要設定writableObjectMode標識。由於需要在程式中推送物件(將傳入的陣列轉換為物件),這也是程式中設定readableObjectMode標識的原因。最後,轉換流objectToString接收物件,但是輸出字串。這就是程式中只設定writableObjectModel標識的原因。輸出的可讀流時正常的字串(序列化後的陣列)。

transform-result.png

Node的內建轉換流

Node有許多內建轉換流,如:lib和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'));
複製程式碼

程式將讀取檔案的可讀流傳輸進Node內建的轉換流zlib中,最後傳輸到建立壓縮檔案的可寫流中。因此開發者只要將需要壓縮的檔案路徑作為引數傳程式序中,就可以實現任何檔案的壓縮。

開發者可以將管道函式與事件結合使用,這是選擇管道函式另一個原因。例如:開發者讓程式通過列印出標記符顯示指令碼正在執行,並在指令碼執行完畢後列印出"Done"資訊。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函式處理後的目標流做一些定製互動。

管道函式的強大之處在於,使用易理解的方式將多個管道函式聯合在一起。例如:不同於上個示例,開發者可以通過傳入一個轉換流,標識指令碼正在執行。

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只是一個轉換流,在這個流中標識指令碼正在執行。值得注意的是,程式碼中使用callback函式推送transform中的資料。這與先前示例中this.push()的功能是等效的。

組合流的應用場景還有很多。例如:開發者要先加密檔案,然後壓縮檔案或是先壓縮後加密。如果要完成這個功能,程式只要將檔案按照順序傳入流中,使用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()流中,最後寫入到檔案中。

上面就是我對這個主題的總結,感謝您的閱讀,期待下次與你相遇。

相關文章