《Node.js設計模式》使用流進行編碼

counterxing發表於2019-03-03

本系列文章為《Node.js Design Patterns Second Edition》的原文翻譯和讀書筆記,在GitHub連載更新,同步翻譯版連結

歡迎關注我的專欄,之後的博文將在專欄同步:

Coding with Streams

StreamsNode.js最重要的元件和模式之一。 社群中有一句格言“Stream all the things(Steam就是所有的)”,僅此一點就足以描述流在Node.js中的地位。 Dominic Tarr作為Node.js社群的最大貢獻者,它將流定義為Node.js最好,也是最難以理解的概念。

使Node.jsStreams如此吸引人還有其它原因; 此外,Streams不僅與效能或效率等技術特性有關,更重要的是它們的優雅性以及它們與Node.js的設計理念完美契合的方式。

在本章中,將會學到以下內容:

  • Streams對於Node.js的重要性。
  • 如何建立並使用Streams
  • Streams作為程式設計正規化,不只是對於I/O而言,在多種應用場景下它的應用和強大的功能。
  • 管道模式和在不同的配置中連線Streams

發現Streams的重要性

在基於事件的平臺(如Node.js)中,處理I / O的最有效的方法是實時處理,一旦有輸入的資訊,立馬進行處理,一旦有需要輸出的結果,也立馬輸出反饋。

在本節中,我們將首先介紹Node.jsStreams和它的優點。 請記住,這只是一個概述,因為本章後面將會詳細介紹如何使用和組合Streams

Streams和Buffer的比較

我們在本書中幾乎所有看到過的非同步API都是使用的Buffer模式。 對於輸入操作,Buffer模式會將來自資源的所有資料收集到Buffer區中; 一旦讀取完整個資源,就會把結果傳遞給回撥函式。 下圖顯示了這個範例的一個真實的例子:

《Node.js設計模式》使用流進行編碼

從上圖我們可以看到,在t1時刻,一些資料從資源接收並儲存到緩衝區。 在t2時刻,最後一段資料被接收到另一個資料塊,完成讀取操作,這時,把整個緩衝區的內容傳送給消費者。

另一方面,Streams允許你在資料到達時立即處理資料。 如下圖所示:

《Node.js設計模式》使用流進行編碼

這一張圖顯示了Streams如何從資源接收每個新的資料塊,並立即提供給消費者,消費者現在不必等待緩衝區中收集所有資料再處理每個資料塊。

但是這兩種方法有什麼區別呢? 我們可以將它們概括為兩點:

  • 空間效率
  • 時間效率

此外,Node.jsStreams具有另一個重要的優點:可組合性(composability)。 現在讓我們看看這些屬性對我們設計和編寫應用程式的方式會產生什麼影響。

空間效率

首先,Streams允許我們做一些看起來不可能的事情,通過緩衝資料並一次性處理。 例如,考慮一下我們必須讀取一個非常大的檔案,比如說數百MB甚至千MB。 顯然,等待完全讀取檔案時返回大BufferAPI不是一個好主意。 想象一下,如果併發讀取一些大檔案, 我們的應用程式很容易耗盡記憶體。 除此之外,V8中的Buffer不能大於0x3FFFFFFF位元組(小於1GB)。 所以,在耗盡實體記憶體之前,我們可能會碰壁。

使用Buffered的API進行壓縮檔案

舉一個具體的例子,讓我們考慮一個簡單的命令列介面(CLI)的應用程式,它使用Gzip格式壓縮檔案。 使用BufferedAPI,這樣的應用程式在Node.js中大概這麼編寫(為簡潔起見,省略了異常處理):

const fs = require('fs');
const zlib = require('zlib');
const file = process.argv[2];
fs.readFile(file, (err, buffer) => {
  zlib.gzip(buffer, (err, buffer) => {
    fs.writeFile(file + '.gz', buffer, err => {
      console.log('File successfully compressed');
    });
  });
});
複製程式碼

現在,我們可以嘗試將前面的程式碼放在一個叫做gzip.js的檔案中,然後執行下面的命令:

node gzip <path to file>
複製程式碼

如果我們選擇一個足夠大的檔案,比如說大於1GB的檔案,我們會收到一個錯誤資訊,說明我們要讀取的檔案大於最大允許的緩衝區大小,如下所示:

RangeError: File size is greater than possible Buffer:0x3FFFFFFF
複製程式碼

《Node.js設計模式》使用流進行編碼

上面的例子中,沒找到一個大檔案,但確實對於大檔案的讀取速率慢了許多。

正如我們所預料到的那樣,使用Buffer來進行大檔案的讀取顯然是錯誤的。

使用Streams進行壓縮檔案

我們必須修復我們的Gzip應用程式,並使其處理大檔案的最簡單方法是使用StreamsAPI。 讓我們看看如何實現這一點。 讓我們用下面的程式碼替換剛建立的模組的內容:

const fs = require('fs');
const zlib = require('zlib');
const file = process.argv[2];
fs.createReadStream(file)
  .pipe(zlib.createGzip())
  .pipe(fs.createWriteStream(file + '.gz'))
  .on('finish', () => console.log('File successfully compressed'));
複製程式碼

“是嗎?”你可能會問。是的;正如我們所說的,由於Streams的介面和可組合性,因此我們還能寫出這樣的更加簡潔,優雅和精煉的程式碼。 我們稍後會詳細地看到這一點,但是現在需要認識到的重要一點是,程式可以順暢地執行在任何大小的檔案上,理想情況是記憶體利用率不變。 嘗試一下(但考慮壓縮一個大檔案可能需要一段時間)。

時間效率

現在讓我們考慮一個壓縮檔案並將其上傳到遠端HTTP伺服器的應用程式的例子,該遠端HTTP伺服器進而將其解壓縮並儲存到檔案系統中。如果我們的客戶端是使用BufferedAPI實現的,那麼只有當整個檔案被讀取和壓縮時,上傳才會開始。 另一方面,只有在接收到所有資料的情況下,解壓縮才會在伺服器上啟動。 實現相同結果的更好的解決方案涉及使用Streams。 在客戶端機器上,Streams只要從檔案系統中讀取就可以壓縮和傳送資料塊,而在伺服器上,只要從遠端對端接收到資料塊,就可以解壓每個資料塊。 我們通過構建前面提到的應用程式來展示這一點,從伺服器端開始。

我們建立一個叫做gzipReceive.js的模組,程式碼如下:

const http = require('http');
const fs = require('fs');
const zlib = require('zlib');

const server = http.createServer((req, res) => {
  const filename = req.headers.filename;
  console.log('File request received: ' + filename);
  req
    .pipe(zlib.createGunzip())
    .pipe(fs.createWriteStream(filename))
    .on('finish', () => {
      res.writeHead(201, {
        'Content-Type': 'text/plain'
      });
      res.end('That\'s it\n');
      console.log(`File saved: ${filename}`);
    });
});

server.listen(3000, () => console.log('Listening'));
複製程式碼

伺服器從網路接收資料塊,將其解壓縮,並在接收到資料塊後立即儲存,這要歸功於Node.jsStreams

我們的應用程式的客戶端將進入一個名為gzipSend.js的模組,如下所示:

在前面的程式碼中,我們再次使用Streams從檔案中讀取資料,然後在從檔案系統中讀取的同時壓縮併傳送每個資料塊。

現在,執行這個應用程式,我們首先使用以下命令啟動伺服器:

node gzipReceive
複製程式碼

然後,我們可以通過指定要傳送的檔案和伺服器的地址(例如localhost)來啟動客戶端:

node gzipSend <path to file> localhost
複製程式碼

《Node.js設計模式》使用流進行編碼

如果我們選擇一個足夠大的檔案,我們將更容易地看到資料如何從客戶端流向伺服器,但為什麼這種模式下,我們使用Streams,比使用BufferedAPI更有效率? 下圖應該給我們一個提示:

《Node.js設計模式》使用流進行編碼

一個檔案被處理的過程,它經過以下階段:

  1. 客戶端從檔案系統中讀取
  2. 客戶端壓縮資料
  3. 客戶端將資料傳送到伺服器
  4. 服務端接收資料
  5. 服務端解壓資料
  6. 服務端將資料寫入磁碟

為了完成處理,我們必須按照流水線順序那樣經過每個階段,直到最後。在上圖中,我們可以看到,使用BufferedAPI,這個過程完全是順序的。為了壓縮資料,我們首先必須等待整個檔案被讀取完畢,然後,傳送資料,我們必須等待整個檔案被讀取和壓縮,依此類推。當我們使用Streams時,只要我們收到第一個資料塊,流水線就會被啟動,而不需要等待整個檔案的讀取。但更令人驚訝的是,當下一塊資料可用時,不需要等待上一組任務完成;相反,另一條裝配線是並行啟動的。因為我們執行的每個任務都是非同步的,這樣顯得很完美,所以可以通過Node.js來並行執行Streams的相關操作;唯一的限制就是每個階段都必須保證資料塊的到達順序。

從前面的圖可以看出,使用Streams的結果是整個過程花費的時間更少,因為我們不用等待所有資料被全部讀取完畢和處理。

組合性

到目前為止,我們已經看到的程式碼已經告訴我們如何使用pipe()方法來組裝Streams的資料塊,Streams允許我們連線不同的處理單元,每個處理單元負責單一的職責(這是符合Node.js風格的)。這是可能的,因為Streams具有統一的介面,並且就API而言,不同Streams也可以很好的進行互動。唯一的先決條件是管道的下一個Streams必須支援上一個Streams生成的資料型別,可以是二進位制,文字甚至是物件,我們將在後面的章節中看到。

為了證明Streams組合性的優勢,我們可以嘗試在我們先前構建的gzipReceive / gzipSend應用程式中新增加密功能。 為此,我們只需要通過向流水線新增另一個Streams來更新客戶端。 確切地說,由crypto.createChipher()返回的流。 由此產生的程式碼應如下所示:

const fs = require('fs');
const zlib = require('zlib');
const crypto = require('crypto');
const http = require('http');
const path = require('path');

const file = process.argv[2];
const server = process.argv[3];

const options = {
  hostname: server,
  port: 3000,
  path: '/',
  method: 'PUT',
  headers: {
    filename: path.basename(file),
    'Content-Type': 'application/octet-stream',
    'Content-Encoding': 'gzip'
  }
};

const req = http.request(options, res => {
  console.log('Server response: ' + res.statusCode);
});

fs.createReadStream(file)
  .pipe(zlib.createGzip())
  .pipe(crypto.createCipher('aes192', 'a_shared_secret'))
  .pipe(req)
  .on('finish', () => {
    console.log('File successfully sent');
  });
複製程式碼

使用相同的方式,我們更新服務端的程式碼,使得它可以在資料塊進行解壓之前先解密:

const http = require('http');
const fs = require('fs');
const zlib = require('zlib');
const crypto = require('crypto');

const server = http.createServer((req, res) => {
  const filename = req.headers.filename;
  console.log('File request received: ' + filename);
  req
    .pipe(crypto.createDecipher('aes192', 'a_shared_secret'))
    .pipe(zlib.createGunzip())
    .pipe(fs.createWriteStream(filename))
    .on('finish', () => {
      res.writeHead(201, {
        'Content-Type': 'text/plain'
      });
      res.end('That\'s it\n');
      console.log(`File saved: ${filename}`);
    });
});

server.listen(3000, () => console.log('Listening'));
複製程式碼

crypto是Node.js的核心模組之一,提供了一系列加密演算法。

只需幾行程式碼,我們就在應用程式中新增了一個加密層。 我們只需要簡單地通過把已經存在的Streams模組和加密層組合到一起,就可以。類似的,我們可以新增和合並其他Streams,如同在玩樂高積木一樣。

顯然,這種方法的主要優點是可重用性,但正如我們從目前為止所介紹的程式碼中可以看到的那樣,Streams也可以實現更清晰,更模組化,更加簡潔的程式碼。 出於這些原因,流通常不僅僅用於處理純粹的I / O,而且它還是簡化和模組化程式碼的手段。

開始使用Streams

在前面的章節中,我們瞭解了為什麼Streams如此強大,而且它在Node.js中無處不在,甚至在Node.js的核心模組中也有其身影。 例如,我們已經看到,fs模組具有用於從檔案讀取的createReadStream()和用於寫入檔案的createWriteStream()HTTP請求和響應物件本質上是Streams,並且zlib模組允許我們使用StreamsAPI壓縮和解壓縮資料塊。

現在我們知道為什麼Streams是如此重要,讓我們退後一步,開始更詳細地探索它。

Streams的結構

Node.js中的每個Streams都是Streams核心模組中可用的四個基本抽象類之一的實現:

  • stream.Readable
  • stream.Writable
  • stream.Duplex
  • stream.Transform

每個stream類也是EventEmitter的一個例項。實際上,Streams可以產生幾種型別的事件,比如end事件會在一個可讀的Streams完成讀取,或者錯誤讀取,或其過程中產生異常時觸發。

請注意,為簡潔起見,在本章介紹的例子中,我們經常會忽略適當的錯誤處理。但是,在生產環境下中,總是建議為所有Stream註冊錯誤事件偵聽器。

Streams之所以如此靈活的原因之一是它不僅能夠處理二進位制資料,而且幾乎可以處理任何JavaScript值。實際上,Streams可以支援兩種操作模式:

  • 二進位制模式:以資料塊形式(例如buffersstrings)流式傳輸資料
  • 物件模式:將流資料視為一系列離散物件(這使得我們幾乎可以使用任何JavaScript值)

這兩種操作模式使我們不僅可以使用I / O流,而且還可以作為一種工具,以函式式的風格優雅地組合處理單元,我們將在本章後面看到。

在本章中,我們將主要使用在Node.js 0.11中引入的Node.js流介面,也稱為版本3。 有關與舊介面差異的更多詳細資訊,請參閱StrongLoop在https://strongloop.com/strongblog/whats-new-io-js-beta-streams3/中的優秀部落格文章。

可讀的Streams

一個可讀的Streams表示一個資料來源,在Node.js中,它使用stream模組中的Readableabstract類實現。

從Streams中讀取資訊

從可讀Streams接收資料有兩種方式:non-flowing模式和flowing模式。 我們來更詳細地分析這些模式。

non-flowing模式(不流動模式)

從可讀的Streams中讀取資料的預設模式是為其附加一個可讀事件偵聽器,用於指示要讀取的新資料的可用性。然後,在一個迴圈中,我們讀取所有的資料,直到內部buffer被清空。這可以使用read()方法完成,該方法同步從內部緩衝區中讀取資料,並返回表示資料塊的BufferString物件。read()方法以如下使用模式:

readable.read([size]);
複製程式碼

使用這種方法,資料隨時可以直接從Streams中按需提取。

為了說明這是如何工作的,我們建立一個名為readStdin.js的新模組,它實現了一個簡單的程式,它從標準輸入(一個可讀流)中讀取資料,並將所有資料回送到標準輸出:

process.stdin
  .on('readable', () => {
    let chunk;
    console.log('New data available');
    while ((chunk = process.stdin.read()) !== null) {
      console.log(
        `Chunk read: (${chunk.length}) "${chunk.toString()}"`
      );
    }
  })
  .on('end', () => process.stdout.write('End of stream'));
複製程式碼

read()方法是一個同步操作,它從可讀Streams的內部Buffers區中提取資料塊。如果Streams在二進位制模式下工作,返回的資料塊預設為一個Buffer物件。

在以二進位制模式工作的可讀的Stream中,我們可以通過在Stream上呼叫setEncoding(encoding)來讀取字串而不是Buffer物件,並提供有效的編碼格式(例如utf8)。

資料是從可讀的偵聽器中讀取的,只要有新的資料,就會呼叫這個偵聽器。當內部緩衝區中沒有更多資料可用時,read()方法返回null;在這種情況下,我們不得不等待另一個可讀的事件被觸發,告訴我們可以再次讀取或者等待表示Streams讀取過程結束的end事件觸發。當一個流以二進位制模式工作時,我們也可以通過向read()方法傳遞一個size引數來指定我們想要讀取的資料大小。這在實現網路協議或解析特定資料格式時特別有用。

現在,我們準備執行readStdin模組並進行實驗。讓我們在控制檯中鍵入一些字元,然後按Enter鍵檢視回顯到標準輸出中的資料。要終止流並因此生成一個正常的結束事件,我們需要插入一個EOF(檔案結束)字元(在Windows上使用Ctrl + Z或在Linux上使用Ctrl + D)。

我們也可以嘗試將我們的程式與其他程式連線起來;這可以使用管道運算子(|),它將程式的標準輸出重定向到另一個程式的標準輸入。例如,我們可以執行如下命令:

cat <path to a file> | node readStdin
複製程式碼

這是流式範例是一個通用介面的一個很好的例子,它使得我們的程式能夠進行通訊,而不管它們是用什麼語言寫的。

flowing模式(流動模式)

Streams中讀取的另一種方法是將偵聽器附加到data事件;這會將Streams切換為flowing模式,其中資料不是使用read()函式來提取的,而是一旦有資料到達data監聽器就被推送到監聽器內。例如,我們之前建立的readStdin應用程式將使用流動模式:

process.stdin
  .on('data', chunk => {
    console.log('New data available');
    console.log(
      `Chunk read: (${chunk.length}) "${chunk.toString()}"`
    );
  })
  .on('end', () => process.stdout.write('End of stream'));
複製程式碼

flowing模式是舊版Streams介面(也稱為Streams1)的繼承,其靈活性較低,API較少。隨著Streams2介面的引入,flowing模式不是預設的工作模式,要啟用它,需要將偵聽器附加到data事件或顯式呼叫resume()方法。 要暫時中斷Streams觸發data事件,我們可以呼叫pause()方法,導致任何傳入資料快取在內部buffer中。

呼叫pause()不會導致Streams切換回non-flowing模式。

實現可讀的Streams

現在我們知道如何從Streams中讀取資料,下一步是學習如何實現一個新的Readable資料流。為此,有必要通過繼承stream.Readable的原型來建立一個新的類。 具體流必須提供_read()方法的實現:

readable._read(size)
複製程式碼

Readable類的內部將呼叫_read()方法,而該方法又將啟動 使用push()填充內部緩衝區:

請注意,read()是Stream消費者呼叫的方法,而_read()是一個由Stream子類實現的方法,不能直接呼叫。下劃線通常表示該方法為私有方法,不應該直接呼叫。

為了演示如何實現新的可讀Streams,我們可以嘗試實現一個生成隨機字串的Streams。 我們來建立一個名為randomStream.js的新模組,它將包含我們的字串的generator的程式碼:

const stream = require('stream');
const Chance = require('chance');

const chance = new Chance();

class RandomStream extends stream.Readable {
  constructor(options) {
    super(options);
  }

  _read(size) {
    const chunk = chance.string(); //[1]
    console.log(`Pushing chunk of size: ${chunk.length}`);
    this.push(chunk, 'utf8'); //[2]
    if (chance.bool({
        likelihood: 5
      })) { //[3]
      this.push(null);
    }
  }
}

module.exports = RandomStream;
複製程式碼

在檔案頂部,我們將載入我們的依賴關係。除了我們正在載入一個chance的npm模組之外,沒有什麼特別之處,它是一個用於生成各種隨機值的庫,從數字到字串到整個句子都能生成隨機值。

下一步是建立一個名為RandomStream的新類,並指定stream.Readable作為其父類。 在前面的程式碼中,我們呼叫父類的建構函式來初始化其內部狀態,並將收到的options引數作為輸入。通過options物件傳遞的可能引數包括以下內容:

  • 用於將Buffers轉換為Stringsencoding引數(預設值為null
  • 是否啟用物件模式(objectMode預設為false
  • 儲存在內部buffer區中的資料的上限,一旦超過這個上限,則暫停從data source讀取(highWaterMark預設為16KB

好的,現在讓我們來解釋一下我們重寫的stream.Readable類的_read()方法:

  • 該方法使用chance生成隨機字串。
  • 它將字串push內部buffer。 請注意,由於我們push的是String,此外我們還指定了編碼為utf8(如果資料塊只是一個二進位制Buffer,則不需要)。
  • 5%的概率隨機中斷stream的隨機字串產生,通過push null到內部Buffer來表示EOF,即stream的結束。

我們還可以看到在_read()函式的輸入中給出的size引數被忽略了,因為它是一個建議的引數。 我們可以簡單地把所有可用的資料都push到內部的buffer中,但是如果在同一個呼叫中有多個推送,那麼我們應該檢查push()是否返回false,因為這意味著內部buffer已經達到了highWaterMark限制,我們應該停止新增更多的資料。

以上就是RandomStream模組,我們現在準備好使用它。我們來建立一個名為generateRandom.js的新模組,在這個模組中我們例項化一個新的RandomStream物件並從中提取一些資料:

const RandomStream = require('./randomStream');
const randomStream = new RandomStream();

randomStream.on('readable', () => {
  let chunk;
  while ((chunk = randomStream.read()) !== null) {
    console.log(`Chunk received: ${chunk.toString()}`);
  }
});
複製程式碼

現在,一切都準備好了,我們嘗試新的自定義的stream。 像往常一樣簡單地執行generateRandom模組,觀察隨機的字串在螢幕上流動。

可寫的Streams

一個可寫的stream表示一個資料終點,在Node.js中,它使用stream模組中的Writable抽象類來實現。

寫入一個stream

把一些資料放在可寫入的stream中是一件簡單的事情, 我們所要做的就是使用write()方法,它具有以下格式:

writable.write(chunk, [encoding], [callback])
複製程式碼

encoding引數是可選的,其在chunkString型別時指定(預設為utf8,如果chunkBuffer,則忽略);當資料塊被重新整理到底層資源中時,callback就會被呼叫,callback引數也是可選的。

為了表示沒有更多的資料將被寫入stream中,我們必須使用end()方法:

writable.end([chunk], [encoding], [callback])
複製程式碼

我們可以通過end()方法提供最後一塊資料。在這種情況下,callbak函式相當於為finish事件註冊一個監聽器,當資料塊全部被寫入stream中時,會觸發該事件。

現在,讓我們通過建立一個輸出隨機字串序列的小型HTTP伺服器來演示這是如何工作的:

const Chance = require('chance');
const chance = new Chance();

require('http').createServer((req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/plain'
  }); //[1]
  while (chance.bool({
      likelihood: 95
    })) { //[2]
    res.write(chance.string() + '\n'); //[3]
  }
  res.end('\nThe end...\n'); //[4]
  res.on('finish', () => console.log('All data was sent')); //[5]
}).listen(8080, () => console.log('Listening on http://localhost:8080'));
複製程式碼

我們建立了一個HTTP伺服器,並把資料寫入res物件,res物件是http.ServerResponse的一個例項,也是一個可寫入的stream。下面來解釋上述程式碼發生了什麼:

  1. 我們首先寫HTTP response的頭部。請注意,writeHead()不是Writable介面的一部分,實際上,這個方法是http.ServerResponse類公開的輔助方法。
  2. 我們開始一個5%的概率終止的迴圈(進入迴圈體的概率為chance.bool()產生,其為95%)。
  3. 在迴圈內部,我們寫入一個隨機字串到stream
  4. 一旦我們不在迴圈中,我們呼叫streamend(),表示沒有更多 資料塊將被寫入。另外,我們在結束之前提供一個最終的字串寫入流中。
  5. 最後,我們註冊一個finish事件的監聽器,當所有的資料塊都被重新整理到底層socket中時,這個事件將被觸發。

我們可以呼叫這個小模組稱為entropyServer.js,然後執行它。要測試這個伺服器,我們可以在地址http:// localhost:8080開啟一個瀏覽器,或者從終端使用curl命令,如下所示:

curl localhost:8080
複製程式碼

此時,伺服器應該開始向您選擇的HTTP客戶端傳送隨機字串(請注意,某些瀏覽器可能會緩衝資料,並且流式傳輸行為可能不明顯)。

Back-pressure(反壓)

類似於在真實管道系統中流動的液體,Node.jsstream也可能遭受瓶頸,資料寫入速度可能快於stream的消耗。 解決這個問題的機制包括緩衝輸入資料;然而,如果資料stream沒有給生產者任何反饋,我們可能會產生越來越多的資料被累積到內部緩衝區的情況,導致記憶體洩露的發生。

為了防止這種情況的發生,當內部buffer超過highWaterMark限制時,writable.write()將返回false。 可寫入的stream具有highWaterMark屬性,這是write()方法開始返回false的內部Buffer區大小的限制,一旦Buffer區的大小超過這個限制,表示應用程式應該停止寫入。 當緩衝器被清空時,會觸發一個叫做drain的事件,通知再次開始寫入是安全的。 這種機制被稱為back-pressure

本節介紹的機制同樣適用於可讀的stream。事實上,在可讀stream中也存在back-pressure,並且在_read()內呼叫的push()方法返回false時觸發。 但是,這對於stream實現者來說是一個特定的問題,所以我們將不經常處理它。

我們可以通過修改之前建立的entropyServer模組來演示可寫入的streamback-pressure

const Chance = require('chance');
const chance = new Chance();

require('http').createServer((req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/plain'
  });

  function generateMore() { //[1]
    while (chance.bool({
        likelihood: 95
      })) {
      const shouldContinue = res.write(
        chance.string({
          length: (16 * 1024) - 1
        }) //[2]
      );
      if (!shouldContinue) { //[3]
        console.log('Backpressure');
        return res.once('drain', generateMore);
      }
    }
    res.end('\nThe end...\n', () => console.log('All data was sent'));
  }
  generateMore();
}).listen(8080, () => console.log('Listening on http://localhost:8080'));
複製程式碼

前面程式碼中最重要的步驟可以概括如下:

  1. 我們將主邏輯封裝在一個名為generateMore()的函式中。
  2. 為了增加獲得一些back-pressure的機會,我們將資料塊的大小增加到16KB-1Byte,這非常接近預設的highWaterMark限制。
  3. 在寫入一大塊資料之後,我們檢查res.write()的返回值。 如果它返回false,這意味著內部buffer已滿,我們應該停止傳送更多的資料。在這種情況下,我們從函式中退出,然後新註冊一個寫入事件的釋出者,當drain事件觸發時呼叫generateMore

如果我們現在嘗試再次執行伺服器,然後使用curl生成客戶端請求,則很可能會有一些back-pressure,因為伺服器以非常高的速度生成資料,速度甚至會比底層socket更快。

實現可寫入的Streams

我們可以通過繼承stream.Writable類來實現一個新的可寫入的流,併為_write()方法提供一個實現。實現一個我們自定義的可寫入的Streams類。

讓我們構建一個可寫入的stream,它接收物件的格式如下:

{
  path: <path to a file>
  content: <string or buffer>
}
複製程式碼

這個類的作用是這樣的:對於每一個物件,我們的stream必須將content部分儲存到在給定路徑中建立的檔案中。 我們可以立即看到,我們stream的輸入是物件,而不是StringsBuffers,這意味著我們的stream必須以物件模式工作。

呼叫模組toFileStream.js

const stream = require('stream');
const fs = require('fs');
const path = require('path');
const mkdirp = require('mkdirp');

class ToFileStream extends stream.Writable {
  constructor() {
    super({
      objectMode: true
    });
  }

  _write(chunk, encoding, callback) {
    mkdirp(path.dirname(chunk.path), err => {
      if (err) {
        return callback(err);
      }
      fs.writeFile(chunk.path, chunk.content, callback);
    });
  }
}
module.exports = ToFileStream;
複製程式碼

作為第一步,我們載入所有我們所需要的依賴包。注意,我們需要模組mkdirp,正如你應該從前幾章中所知道的,它應該使用npm安裝。

我們建立了一個新類,它從stream.Writable擴充套件而來。

我們不得不呼叫父建構函式來初始化其內部狀態;我們還提供了一個option物件作為引數,用於指定流在物件模式下工作(objectMode:true)。stream.Writable接受的其他選項如下:

  • highWaterMark(預設值是16KB):控制back-pressure的上限。
  • decodeStrings(預設為true):在字串傳遞給_write()方法之前,將字串自動解碼為二進位制buffer區。 在物件模式下這個引數被忽略。

最後,我們為_write()方法提供了一個實現。正如你所看到的,這個方法接受一個資料塊,一個編碼方式(只有在二進位制模式下,stream選項decodeStrings設定為false時才有意義)。

另外,該方法接受一個回撥函式,該函式在操作完成時需要呼叫;而不必要傳遞操作的結果,但是如果需要的話,我們仍然可以傳遞一個error物件,這將導致stream觸發error事件。

現在,為了嘗試我們剛剛構建的stream,我們可以建立一個名為writeToFile.js的新模組,並對該流執行一些寫操作:

const ToFileStream = require('./toFileStream.js');
const tfs = new ToFileStream();

tfs.write({path: "file1.txt", content: "Hello"});
tfs.write({path: "file2.txt", content: "Node.js"});
tfs.write({path: "file3.txt", content: "Streams"});
tfs.end(() => console.log("All files created"));
複製程式碼

有了這個,我們建立並使用了我們的第一個自定義的可寫入流。 像往常一樣執行新模組來檢查其輸出;你會看到執行後會建立三個新檔案。

雙重的Streams

雙重的stream既是可讀的,也可寫的。 當我們想描述一個既是資料來源又是資料終點的實體時(例如socket),這就顯得十分有用了。 雙工流繼承stream.Readablestream.Writable的方法,所以它對我們來說並不新鮮。這意味著我們可以read()write()資料,或者可以監聽readabledrain事件。

要建立一個自定義的雙重stream,我們必須為_read()_write()提供一個實現。傳遞給Duplex()建構函式的options物件在內部被轉發給ReadableWritable的建構函式。options引數的內容與前面討論的相同,options增加了一個名為allowHalfOpen值(預設為true),如果設定為false,則會導致只要stream的一方(ReadableWritable)結束,stream就結束了。

為了使雙重的stream在一方以物件模式工作,而在另一方以二進位制模式工作,我們需要在流構造器中手動設定以下屬性:

this._writableState.objectMode
this._readableState.objectMode
複製程式碼

轉換的Streams

轉換的Streams是專門設計用於處理資料轉換的一種特殊型別的雙重Streams

在一個簡單的雙重Streams中,從stream中讀取的資料和寫入到其中的資料之間沒有直接的關係(至少stream是不可知的)。 想想一個TCP socket,它只是向遠端節點傳送資料和從遠端節點接收資料。TCP socket自身沒有意識到輸入和輸出之間有任何關係。

下圖說明了雙重Streams中的資料流:

《Node.js設計模式》使用流進行編碼

另一方面,轉換的Streams對從可寫入端接收到的每個資料塊應用某種轉換,然後在其可讀端使轉換的資料可用。

下圖顯示了資料如何在轉換的Streams中流動:

《Node.js設計模式》使用流進行編碼

從外面看,轉換的Streams的介面與雙重Streams的介面完全相同。但是,當我們想要構建一個新的雙重Streams時,我們必須提供_read()_write()方法,而為了實現一個新的變換流,我們必須填寫另一對方法:_transform()_flush())。

我們來演示如何用一個例子來建立一個新的轉換的Streams

實現轉換的Streams

我們來實現一個轉換的Streams,它將替換給定所有出現的字串。 要做到這一點,我們必須建立一個名為replaceStream.js的新模組。 讓我們直接看怎麼實現它:

const stream = require('stream');
const util = require('util');

class ReplaceStream extends stream.Transform {
  constructor(searchString, replaceString) {
    super();
    this.searchString = searchString;
    this.replaceString = replaceString;
    this.tailPiece = '';
  }

  _transform(chunk, encoding, callback) {
    const pieces = (this.tailPiece + chunk)         //[1]
      .split(this.searchString);
    const lastPiece = pieces[pieces.length - 1];
    const tailPieceLen = this.searchString.length - 1;

    this.tailPiece = lastPiece.slice(-tailPieceLen);     //[2]
    pieces[pieces.length - 1] = lastPiece.slice(0,-tailPieceLen);

    this.push(pieces.join(this.replaceString));       //[3]
    callback();
  }

  _flush(callback) {
    this.push(this.tailPiece);
    callback();
  }
}

module.exports = ReplaceStream;
複製程式碼

與往常一樣,我們將從其依賴項開始構建模組。這次我們沒有使用第三方模組。

然後我們建立了一個從stream.Transform基類繼承的新類。該類的建構函式接受兩個引數:searchStringreplaceString。 正如你所想象的那樣,它們允許我們定義要匹配的文字以及用作替換的字串。 我們還初始化一個將由_transform()方法使用的tailPiece內部變數。

現在,我們來分析一下_transform()方法,它是我們新類的核心。_transform()方法與可寫入的stream_write()方法具有幾乎相同的格式,但不是將資料寫入底層資源,而是使用this.push()將其推入內部buffer,這與我們會在可讀流的_read()方法中執行。這顯示了轉換的Streams的雙方如何實際連線。

ReplaceStream_transform()方法實現了我們這個新類的核心。正常情況下,搜尋和替換buffer區中的字串是一件容易的事情;但是,當資料流式傳輸時,情況則完全不同,可能的匹配可能分佈在多個資料塊中。程式碼後面的程式可以解釋如下:

  1. 我們的演算法使用searchString函式作為分隔符來分割塊。
  2. 然後,它取出分隔後生成的陣列的最後一項lastPiece,並提取其最後一個字元searchString.length - 1。結果被儲存到tailPiece變數中,它將會被作為下一個資料塊的字首。
  3. 最後,所有從split()得到的片段用replaceString作為分隔符連線在一起,並推入內部buffer區。

stream結束時,我們可能仍然有最後一個tailPiece變數沒有被壓入內部緩衝區。這正是_flush()方法的用途;它在stream結束之前被呼叫,並且這是我們最終有機會完成流或者在完全結束流之前推送任何剩餘資料的地方。

_flush()方法只需要一個回撥函式作為引數,當所有的操作完成後,我們必須確保呼叫這個回撥函式。完成了這個,我們已經完成了我們的ReplaceStream類。

現在,是時候嘗試新的stream。我們可以建立另一個名為replaceStreamTest.js的模組來寫入一些資料,然後讀取轉換的結果:

const ReplaceStream = require('./replaceStream');

const rs = new ReplaceStream('World', 'Node.js');
rs.on('data', chunk => console.log(chunk.toString()));

rs.write('Hello W');
rs.write('orld!');
rs.end();
複製程式碼

為了使得這個例子更復雜一些,我們把搜尋詞分佈在兩個不同的資料塊上;然後,使用flowing模式,我們從同一個stream中讀取資料,記錄每個已轉換的塊。執行前面的程式應該產生以下輸出:

Hel
lo Node.js
!
複製程式碼

有一個值得提及是,第五種型別的stream:stream.PassThrough。 與我們介紹的其他流類不同,PassThrough不是抽象的,可以直接例項化,而不需要實現任何方法。實際上,這是一個可轉換的stream,它可以輸出每個資料塊,而不需要進行任何轉換。

使用管道連線Streams

Unix管道的概念是由Douglas Mcllroy發明的;這使程式的輸出能夠連線到下一個的輸入。看看下面的命令:

echo Hello World! | sed s/World/Node.js/g
複製程式碼

在前面的命令中,echo會將Hello World!寫入標準輸出,然後被重定向到sed命令的標準輸入(因為有管道操作符 |)。 然後sedNode.js替換任何World,並將結果列印到它的標準輸出(這次是控制檯)。

以類似的方式,可以使用可讀的Streamspipe()方法將Node.jsStreams連線在一起,它具有以下介面:

readable.pipe(writable, [options])
複製程式碼

非常直觀地,pipe()方法將從可讀的Streams中發出的資料抽取到所提供的可寫入的Streams中。 另外,當可讀的Streams發出end事件(除非我們指定{end:false}作為options)時,可寫入的Streams將自動結束。 pipe()方法返回作為引數傳遞的可寫入的Streams,如果這樣的stream也是可讀的(例如雙重或可轉換的Streams),則允許我們建立鏈式呼叫。

將兩個Streams連線到一起時,則允許資料自動流向可寫入的Streams,所以不需要呼叫read()write()方法;但最重要的是不需要控制back-pressure,因為它會自動處理。

舉個簡單的例子(將會有大量的例子),我們可以建立一個名為replace.js的新模組,它接受來自標準輸入的文字流,應用替換轉換,然後將資料返回到標準輸出:

const ReplaceStream = require('./replaceStream');
process.stdin
  .pipe(new ReplaceStream(process.argv[2], process.argv[3]))
  .pipe(process.stdout);
複製程式碼

上述程式將來自標準輸入的資料傳送到ReplaceStream,然後返回到標準輸出。 現在,為了實踐這個小應用程式,我們可以利用Unix管道將一些資料重定向到它的標準輸入,如下所示:

echo Hello World! | node replace World Node.js
複製程式碼

執行上述程式,會輸出如下結果:

Hello Node.js
複製程式碼

這個簡單的例子演示了Streams(特別是文字Streams)是一個通用介面,管道幾乎是構成和連線所有這些介面的通用方式。

error事件不會通過管道自動傳播。舉個例子,看如下程式碼片段:

stream1
  .pipe(stream2)
  .on('error', function() {});
複製程式碼

在前面的鏈式呼叫中,我們將只捕獲來自stream2的錯誤,這是由於我們給其新增了erorr事件偵聽器。這意味著,如果我們想捕獲從stream1生成的任何錯誤,我們必須直接附加另一個錯誤偵聽器。 稍後我們將看到一種可以實現共同錯誤捕獲的另一種模式(合併Streams)。 此外,我們應該注意到,如果目標Streams(讀取的Streams)發出錯誤,它將會對源Streams通知一個error,之後導致管道的中斷。

Streams如何通過管道

到目前為止,我們建立自定義Streams的方式並不完全遵循Node定義的模式;實際上,從stream基類繼承是違反small surface area的,並需要一些示例程式碼。 這並不意味著Streams設計得不好,實際上,我們不應該忘記,因為StreamsNode.js核心的一部分,所以它們必須儘可能地靈活,廣泛擴充Streams以致於使用者級模組能夠將它們充分運用。

然而,大多數情況下,我們並不需要原型繼承可以給予的所有權力和可擴充套件性,但通常我們想要的僅僅是定義新Streams的一種快速開發的模式。Node.js社群當然也為此建立了一個解決方案。 一個完美的例子是through2,一個使得我們可以簡單地建立轉換的Streams的小型庫。 通過through2,我們可以通過呼叫一個簡單的函式來建立一個新的可轉換的Streams

const transform = through2([options], [_transform], [_flush]);
複製程式碼

類似的,from2也允許我們像下面這樣建立一個可讀的Streams

const readable = from2([options], _read);
複製程式碼

接下來,我們將在本章其餘部分展示它們的用法,那時,我們會清楚使用這些小型庫的好處。

throughfrom是基於Stream1規範的頂層庫。

基於Streams的非同步控制流

通過我們已經介紹的例子,應該清楚的是,Streams不僅可以用來處理I / O,而且可以用作處理任何型別資料的優雅程式設計模式。 但優點並不止這些;還可以利用Streams來實現非同步控制流,在本節將會看到。

順序執行

預設情況下,Streams將按順序處理資料;例如,轉換的Streams_transform()函式在前一個資料塊執行callback()之後才會進行下一塊資料塊的呼叫。這是Streams的一個重要屬性,按正確順序處理每個資料塊至關重要,但是也可以利用這一屬性將Streams實現優雅的傳統控制流模式。

程式碼總是比太多的解釋要好得多,所以讓我們來演示一下如何使用流來按順序執行非同步任務的例子。讓我們建立一個函式來連線一組接收到的檔案作為輸入,確保遵守提供的順序。我們建立一個名為concatFiles.js的新模組,並從其依賴開始:

const fromArray = require('from2-array');
const through = require('through2');
const fs = require('fs');
複製程式碼

我們將使用through2來簡化轉換的Streams的建立,並使用from2-array從一個物件陣列中建立可讀的Streams。 接下來,我們可以定義concatFiles()函式:

function concatFiles(destination, files, callback) {
  const destStream = fs.createWriteStream(destination);
  fromArray.obj(files)             //[1]
    .pipe(through.obj((file, enc, done) => {   //[2]
      const src = fs.createReadStream(file);
      src.pipe(destStream, {end: false});
      src.on('end', done); //[3]
    }))
    .on('finish', () => {         //[4]
      destStream.end();
      callback();
    });
}

module.exports = concatFiles;
複製程式碼

前面的函式通過將files陣列轉換為Streams來實現對files陣列的順序迭代。 該函式所遵循的程式解釋如下:

  1. 首先,我們使用from2-arrayfiles陣列建立一個可讀的Streams
  2. 接下來,我們使用through來建立一個轉換的Streams來處理序列中的每個檔案。對於每個檔案,我們建立一個可讀的Streams,並通過管道將其輸入到表示輸出檔案的destStream中。 在原始檔完成讀取後,通過在pipe()方法的第二個引數中指定{end:false},我們確保不關閉destStream
  3. 當原始檔的所有內容都被傳送到destStream時,我們呼叫through.obj公開的done函式來傳遞當前處理已經完成,在我們的情況下這是需要觸發處理下一個檔案。
  4. 所有檔案處理完後,finish事件被觸發。我們最後可以結束destStream並呼叫concatFiles()callback()函式,這個函式表示整個操作的完成。

我們現在可以嘗試使用我們剛剛建立的小模組。讓我們建立一個名為concat.js的新檔案來完成一個示例:

const concatFiles = require('./concatFiles');

concatFiles(process.argv[2], process.argv.slice(3), () => {
  console.log('Files concatenated successfully');
});
複製程式碼

我們現在可以執行上述程式,將目標檔案作為第一個命令列引數,接著是要連線的檔案列表,例如:

node concat allTogether.txt file1.txt file2.txt
複製程式碼

執行這一條命令,會建立一個名為allTogether.txt的新檔案,其中按順序儲存file1.txtfile2.txt的內容。

使用concatFiles()函式,我們能夠僅使用Streams實現非同步操作的順序執行。正如我們在Chapter3 Asynchronous Control Flow Patters with Callbacks中看到的那樣,如果使用純JavaScript實現,或者使用async等外部庫,則需要使用或實現迭代器。我們現在提供了另外一個可以達到同樣效果的方法,正如我們所看到的,它的實現方式非常優雅且可讀性高。

模式:使用Streams或Streams的組合,可以輕鬆地按順序遍歷一組非同步任務。

無序並行執行

我們剛剛看到Streams按順序處理每個資料塊,但有時這可能並不能這麼做,因為這樣並沒有充分利用Node.js的併發性。如果我們必須對每個資料塊執行一個緩慢的非同步操作,那麼並行化執行這一組非同步任務完全是有必要的。當然,只有在每個資料塊之間沒有關係的情況下才能應用這種模式,這些資料塊可能經常發生在物件模式的Streams中,但是對於二進位制模式的Streams很少使用無序的並行執行。

注意:當處理資料的順序很重要時,不能使用無序並行執行的Streams。

為了並行化一個可轉換的Streams的執行,我們可以運用Chapter3 Asynchronous Control Flow Patters with Callbacks所講到的無序並行執行的相同模式,然後做出一些改變使它們適用於Streams。讓我們看看這是如何更改的。

實現一個無序並行的Streams

讓我們用一個例子直接說明:我們建立一個叫做parallelStream.js的模組,然後自定義一個普通的可轉換的Streams,然後給出一系列可轉換流的方法:

const stream = require('stream');

class ParallelStream extends stream.Transform {
  constructor(userTransform) {
    super({objectMode: true});
    this.userTransform = userTransform;
    this.running = 0;
    this.terminateCallback = null;
  }

  _transform(chunk, enc, done) {
    this.running++;
    this.userTransform(chunk, enc, this._onComplete.bind(this), this.push.bind(this));
    done();
  }

  _flush(done) {
    if(this.running > 0) {
      this.terminateCallback = done;
    } else {
      done();
    }
  }

  _onComplete(err) {
    this.running--;
    if(err) {
      return this.emit('error', err);
    }
    if(this.running === 0) {
      this.terminateCallback && this.terminateCallback();
    }
  }
}

module.exports = ParallelStream;
複製程式碼

我們來分析一下這個新的自定義的類。正如你所看到的一樣,建構函式接受一個userTransform()函式作為引數,然後將其另存為一個例項變數;我們也呼叫父建構函式,並且我們預設啟用物件模式。

接下來,來看_transform()方法,在這個方法中,我們執行userTransform()函式,然後增加當前正在執行的任務個數; 最後,我們通過呼叫done()來通知當前轉換步驟已經完成。_transform()方法展示瞭如何並行處理另一項任務。我們不用等待userTransform()方法執行完畢再呼叫done()。 相反,我們立即執行done()方法。另一方面,我們提供了一個特殊的回撥函式給userTransform()方法,這就是this._onComplete()方法;以便我們在userTransform()完成的時候收到通知。

Streams終止之前,會呼叫_flush()方法,所以如果仍有任務正在執行,我們可以通過不立即呼叫done()回撥函式來延遲finish事件的觸發。相反,我們將其分配給this.terminateCallback變數。為了理解Streams如何正確終止,來看_onComplete()方法。

在每組非同步任務最終完成時,_onComplete()方法會被呼叫。首先,它會檢查是否有任務正在執行,如果沒有,則呼叫this.terminateCallback()函式,這將導致Streams結束,觸發_flush()方法的finish事件。

利用剛剛構建的ParallelStream類可以輕鬆地建立一個無序並行執行的可轉換的Streams例項,但是有個注意:它不會保留專案接收的順序。實際上,非同步操作可以在任何時候都有可能完成並推送資料,而跟它們開始的時刻並沒有必然的聯絡。因此我們知道,對於二進位制模式的Streams並不適用,因為二進位制的Streams對順序要求較高。

實現一個URL監控應用程式

現在,讓我們使用ParallelStream模組實現一個具體的例子。讓我們想象以下我們想要構建一個簡單的服務來監控一個大URL列表的狀態,讓我們想象以下,所有的這些URL包含在一個單獨的檔案中,並且每一個URL佔據一個空行。

Streams能夠為這個場景提供一個高效且優雅的解決方案。特別是當我們使用我們剛剛寫的ParallelStream類來無序地稽核這些URL

接下來,讓我們建立一個簡單的放在checkUrls.js模組的應用程式。

const fs = require('fs');
const split = require('split');
const request = require('request');
const ParallelStream = require('./parallelStream');

fs.createReadStream(process.argv[2])         //[1]
  .pipe(split())                             //[2]
  .pipe(new ParallelStream((url, enc, done, push) => {     //[3]
    if(!url) return done();
    request.head(url, (err, response) => {
      push(url + ' is ' + (err ? 'down' : 'up') + '\n');
      done();
    });
  }))
  .pipe(fs.createWriteStream('results.txt'))   //[4]
  .on('finish', () => console.log('All urls were checked'))
;
複製程式碼

正如我們所看到的,通過流,我們的程式碼看起來非常優雅,直觀。 讓我們看看它是如何工作的:

  1. 首先,我們通過給定的檔案引數建立一個可讀的Streams,便於接下來讀取檔案。
  2. 我們通過split將輸入的檔案的Streams的內容輸出一個可轉換的Streams到管道中,並且分隔了資料塊的每一行。
  3. 然後,是時候使用我們的ParallelStream來檢查URL了,我們傳送一個HEAD請求然後等待請求的response。當請求返回時,我們把請求的結果pushstream中。
  4. 最後,通過管道把結果儲存到results.txt檔案中。
node checkUrls urlList.txt
複製程式碼

這裡的檔案urlList.txt包含一組URL,例如:

  • http://www.mariocasciaro.me/
  • http://loige.co/
  • http://thiswillbedownforsure.com/

當應用執行完成後,我們可以看到一個檔案results.txt被建立,裡面包含有操作的結果,例如:

  • http://thiswillbedownforsure.com is down
  • http://loige.co is up
  • http://www.mariocasciaro.me is up

輸出的結果的順序很有可能與輸入檔案中指定URL的順序不同。這是Streams無序並行執行任務的明顯特徵。

出於好奇,我們可能想嘗試用一個正常的through2流替換ParallelStream,並比較兩者的行為和效能(你可能想這樣做的一個練習)。我們將會看到,使用through2的方式會比較慢,因為每個URL都將按順序進行檢查,而且檔案results.txt中結果的順序也會被保留。

無序限制並行執行

如果執行包含數千或數百萬個URL的檔案的checkUrls應用程式,我們肯定會遇到麻煩。我們的應用程式將同時建立不受控制的連線數量,並行傳送大量資料,並可能破壞應用程式的穩定性和整個系統的可用性。我們已經知道,控制負載的無序限制並行執行是一個極好的解決方案。

讓我們通過建立一個limitedParallelStream.js模組來看看它是如何工作的,這個模組是改編自上一節中建立的parallelStream.js模組。

讓我們看看它的建構函式:

class LimitedParallelStream extends stream.Transform {
  constructor(concurrency, userTransform) {
    super({objectMode: true});
    this.concurrency = concurrency;
    this.userTransform = userTransform;
    this.running = 0;
    this.terminateCallback = null;
    this.continueCallback = null;
  }
// ...
}
複製程式碼

我們需要一個concurrency變數作為輸入來限制併發量,這次我們要儲存兩個回撥函式,continueCallback用於任何掛起的_transform方法,terminateCallback用於_flush方法的回撥。 接下來看_transform()方法:

_transform(chunk, enc, done) {
  this.running++;
  this.userTransform(chunk, enc,  this.push.bind(this), this._onComplete.bind(this));
  if(this.running < this.concurrency) {
    done();
  } else {
    this.continueCallback = done;
  }
}
複製程式碼

這次在_transform()方法中,我們必須在呼叫done()之前檢查是否達到了最大並行數量的限制,如果沒有達到了限制,才能觸發下一個專案的處理。如果我們已經達到最大並行數量的限制,我們可以簡單地將done()回撥儲存到continueCallback變數中,以便在任務完成後立即呼叫它。

_flush()方法與ParallelStream類保持完全一樣,所以我們直接轉到實現_onComplete()方法:

_onComplete(err) {
  this.running--;
  if(err) {
    return this.emit('error', err);
  }
  const tmpCallback = this.continueCallback;
  this.continueCallback = null;
  tmpCallback && tmpCallback();
  if(this.running === 0) {
    this.terminateCallback && this.terminateCallback();
  }
}
複製程式碼

每當任務完成,我們呼叫任何已儲存的continueCallback()將導致 stream解鎖,觸發下一個專案的處理。

這就是limitedParallelStream模組。 我們現在可以在checkUrls模組中使用它來代替parallelStream,並且將我們的任務的併發限制在我們設定的值上。

順序並行執行

我們以前建立的並行Streams可能會使得資料的順序混亂,但是在某些情況下這是不可接受的。有時,實際上,有那種需要每個資料塊都以接收到的相同順序發出的業務場景。我們仍然可以並行執行transform函式。我們所要做的就是對每個任務發出的資料進行排序,使其遵循與接收資料相同的順序。

這種技術涉及使用buffer,在每個正在執行的任務發出時重新排序塊。為簡潔起見,我們不打算提供這樣一個stream的實現,因為這本書的範圍是相當冗長的;我們要做的就是重用為了這個特定目的而構建的npm上的一個可用包,例如through2-parallel

我們可以通過修改現有的checkUrls模組來快速檢查一個有序的並行執行的行為。 假設我們希望我們的結果按照與輸入檔案中的URL相同的順序編寫。 我們可以使用通過through2-parallel來實現:

const fs = require('fs');
const split = require('split');
const request = require('request');
const throughParallel = require('through2-parallel');

fs.createReadStream(process.argv[2])
  .pipe(split())
  .pipe(throughParallel.obj({concurrency: 2}, function (url, enc, done) {
    if(!url) return done();
    request.head(url, (err, response) => {
      this.push(url + ' is ' + (err ? 'down' : 'up') + '\n');
      done();
    });
  }))
  .pipe(fs.createWriteStream('results.txt'))
  .on('finish', () => console.log('All urls were checked'))
;
複製程式碼

正如我們所看到的,through2-parallel的介面與through2的介面非常相似;唯一的不同是在through2-parallel還可以為我們提供的transform函式指定一個併發限制。如果我們嘗試執行這個新版本的checkUrls,我們會看到results.txt檔案列出結果的順序與輸入檔案中 URLs的出現順序是一樣的。

通過這個,我們總結了使用Streams實現非同步控制流的分析;接下來,我們研究管道模式。

管道模式

就像在現實生活中一樣,Node.jsStreams也可以按照不同的模式進行管道連線。事實上,我們可以將兩個不同的Streams合併成一個Streams,將一個Streams分成兩個或更多的管道,或者根據條件重定向流。 在本節中,我們將探討可應用於Node.jsStreams最重要的管道技術。

組合的Streams

在本章中,我們強調Streams提供了一個簡單的基礎結構來模組化和重用我們的程式碼,但是卻漏掉了一個重要的部分:如果我們想要模組化和重用整個流水線?如果我們想要合併多個Streams,使它們看起來像外部的Streams,那該怎麼辦?下圖顯示了這是什麼意思:

《Node.js設計模式》使用流進行編碼

從上圖中,我們看到了如何組合幾個流的了:

  • 當我們寫入組合的Streams的時候,實際上我們是寫入組合的Streams的第一個單元,即StreamA
  • 當我們從組合的Streams中讀取資訊時,實際上我們從組合的Streams的最後一個單元中讀取。

一個組合的Streams通常是一個多重的Streams,通過連線第一個單元的寫入端和連線最後一個單元的讀取端。

要從兩個不同的Streams(一個可讀的Streams和一個可寫入的Streams)中建立一個多重的Streams,我們可以使用一個npm模組,例如duplexer2

但上述這麼做並不完整。實際上,組合的Streams還應該做到捕獲到管道中任意一段Streams單元產生的錯誤。我們已經說過,任何錯誤都不會自動傳播到管道中。 所以,我們必須有適當的錯誤管理,我們將不得不顯式附加一個錯誤監聽器到每個Streams。但是,組合的Streams實際上是一個黑盒,這意味著我們無法訪問管道中間的任何單元,所以對於管道中任意單元的異常捕獲,組合的Streams也充當聚合器的角色。

總而言之,組合的Streams具有兩個主要優點:

  • 管道內部是一個黑盒,對使用者不可見。
  • 簡化了錯誤管理,因為我們不必為管道中的每個單元附加一個錯誤偵聽器,而只需要給組合的Streams自身附加上就可以了。

組合的Streams是一個非常通用和普遍的做法,所以如果我們沒有任何特殊的需要,我們可能只想重用現有的解決方案,如multipipecombine-stream

實現一個組合的Streams

為了說明一個簡單的例子,我們來考慮下面兩個組合的Streams的情況:

  • 壓縮和加密資料
  • 解壓和解密資料

使用諸如multipipe之類的庫,我們可以通過組合一些核心庫中已有的Streams(檔案combinedStreams.js)來輕鬆地構建組合的Streams

const zlib = require('zlib');
const crypto = require('crypto');
const combine = require('multipipe');
module.exports.compressAndEncrypt = password => {
  return combine(
    zlib.createGzip(),
    crypto.createCipher('aes192', password)
  );
};
module.exports.decryptAndDecompress = password => {
  return combine(
    crypto.createDecipher('aes192', password),
    zlib.createGunzip()
  );
};
複製程式碼

例如,我們現在可以使用這些組合的資料流,如同黑盒,這些對我們均是不可見的,可以建立一個小型應用程式,通過壓縮和加密來歸檔檔案。 讓我們在一個名為archive.js的新模組中做這件事:

const fs = require('fs');
const compressAndEncryptStream = require('./combinedStreams').compressAndEncrypt;
fs.createReadStream(process.argv[3])
  .pipe(compressAndEncryptStream(process.argv[2]))
  .pipe(fs.createWriteStream(process.argv[3] + ".gz.enc"));
複製程式碼

我們可以通過從我們建立的流水線中構建一個組合的Stream來進一步改進前面的程式碼,但這次並不只是為了獲得對外不可見的黑盒,而是為了進行異常捕獲。 實際上,正如我們已經提到過的那樣,寫下如下的程式碼只會捕獲最後一個Stream單元發出的錯誤:

fs.createReadStream(process.argv[3])
  .pipe(compressAndEncryptStream(process.argv[2]))
  .pipe(fs.createWriteStream(process.argv[3] + ".gz.enc"))
  .on('error', function(err) {
    // 只會捕獲最後一個單元的錯誤
    console.log(err);
  });
複製程式碼

但是,通過把所有的Streams結合在一起,我們可以優雅地解決這個問題。重構後的archive.js如下:

const combine = require('multipipe');
   const fs = require('fs');
   const compressAndEncryptStream =
     require('./combinedStreams').compressAndEncrypt;
   combine(
     fs.createReadStream(process.argv[3])
     .pipe(compressAndEncryptStream(process.argv[2]))
     .pipe(fs.createWriteStream(process.argv[3] + ".gz.enc"))
   ).on('error', err => {
     // 使用組合的Stream可以捕獲任意位置的錯誤
     console.log(err);
   });
複製程式碼

正如我們所看到的,我們現在可以將一個錯誤偵聽器直接附加到組合的Streams,它將接收任何內部流發出的任何error事件。 現在,要執行archive模組,只需在命令列引數中指定passwordfile引數,即壓縮模組的引數:

node archive mypassword /path/to/a/file.text
複製程式碼

通過這個例子,我們已經清楚地證明了組合的Stream是多麼重要; 從一個方面來說,它允許我們建立流的可重用組合,從另一方面來說,它簡化了管道的錯誤管理。

分開的Streams

我們可以通過將單個可讀的Stream管道化為多個可寫入的Stream來執行Stream的分支。當我們想要將相同的資料傳送到不同的目的地時,這便體現其作用了,例如,兩個不同的套接字或兩個不同的檔案。當我們想要對相同的資料執行不同的轉換時,或者當我們想要根據一些標準拆分資料時,也可以使用它。如圖所示:

《Node.js設計模式》使用流進行編碼

Node.js中分開的Stream是一件小事。舉例說明。

實現一個多重校驗和的生成器

讓我們建立一個輸出給定檔案的sha1md5雜湊的小工具。我們來呼叫這個新模組generateHashes.js,看如下的程式碼:

const fs = require('fs');
const crypto = require('crypto');
const sha1Stream = crypto.createHash('sha1');
sha1Stream.setEncoding('base64');
const md5Stream = crypto.createHash('md5');
md5Stream.setEncoding('base64');
複製程式碼

目前為止沒什麼特別的 該模組的下一個部分實際上是我們將從檔案建立一個可讀的Stream,並將其分叉到兩個不同的流,以獲得另外兩個檔案,其中一個包含sha1雜湊,另一個包含md5校驗和:

const inputFile = process.argv[2];
const inputStream = fs.createReadStream(inputFile);
inputStream
  .pipe(sha1Stream)
  .pipe(fs.createWriteStream(inputFile + '.sha1'));
inputStream
  .pipe(md5Stream)
  .pipe(fs.createWriteStream(inputFile + '.md5'));
複製程式碼

這很簡單:inputStream變數通過管道一邊輸入到sha1Stream,另一邊輸入到md5Stream。但是要注意:

  • inputStream結束時,md5Streamsha1Stream會自動結束,除非當呼叫pipe()時指定了end選項為false

  • Stream的兩個分支會接受相同的資料塊,因此當對資料執行一些副作用的操作時我們必須非常謹慎,因為那樣會影響另外一個分支。

  • 黑盒外會產生背壓,來自inputStream的資料流的流速會根據接收最慢的分支的流速作出調整。

合併的Streams

合併與分開相對,通過把一組可讀的Streams合併到一個單獨的可寫的Stream裡,如圖所示:

《Node.js設計模式》使用流進行編碼

將多個Streams合併為一個通常是一個簡單的操作; 然而,我們必須注意我們處理end事件的方式,因為使用自動結束選項的管道系統會在一個源結束時立即結束目標流。 這通常會導致錯誤,因為其他還未結束的源將繼續寫入已終止的Stream。 解決此問題的方法是在將多個源傳輸到單個目標時使用選項{end:false},並且只有在所有源完成讀取後才在目標Stream上呼叫end()

用多個原始檔壓縮為一個壓縮包

舉一個簡單的例子,我們來實現一個小程式,它根據兩個不同目錄的內容建立一個壓縮包。 為此,我們將介紹兩個新的npm模組:

  • tar用來建立壓縮包
  • fstream從檔案系統檔案建立物件streams的庫

我們建立一個新模組mergeTar.js,如下開始初始化:

var tar = require('tar');
var fstream = require('fstream');
var path = require('path');
var destination = path.resolve(process.argv[2]);
var sourceA = path.resolve(process.argv[3]);
var sourceB = path.resolve(process.argv[4]);
複製程式碼

在前面的程式碼中,我們只載入全部依賴包和初始化包含目標檔案和兩個源目錄(sourceAsourceB)的變數。

接下來,我們建立tarStream並通過管道輸出到一個可寫入的Stream

const pack = tar.Pack();
pack.pipe(fstream.Writer(destination));
複製程式碼

現在,我們開始初始化源Stream

let endCount = 0;

function onEnd() {
  if (++endCount === 2) {
    pack.end();
  }
}

const sourceStreamA = fstream.Reader({
    type: "Directory",
    path: sourceA
  })
  .on('end', onEnd);

const sourceStreamB = fstream.Reader({
    type: "Directory",
    path: sourceB
  })
  .on('end', onEnd);
複製程式碼

在前面的程式碼中,我們建立了從兩個源目錄(sourceStreamAsourceStreamB)中讀取的Stream那麼對於每個源Stream,我們附加一個end事件訂閱者,只有當這兩個目錄被完全讀取時,才會觸發packend事件。

最後,合併兩個Stream

sourceStreamA.pipe(pack, {end: false});
sourceStreamB.pipe(pack, {end: false});
複製程式碼

我們將兩個原始檔都壓縮到pack這個Stream中,並通過設定pipe()option引數為{end:false}配置終點Stream的自動觸發end事件。

這樣,我們已經完成了我們簡單的TAR程式。我們可以通過提供目標檔案作為第一個命令列引數,然後是兩個源目錄來嘗試執行這個實用程式:

node mergeTar dest.tar /path/to/sourceA /path/to/sourceB
複製程式碼

npm中我們可以找到一些可以簡化Stream的合併的模組:

要注意,流入目標Stream的資料是隨機混合的,這是一個在某些型別的物件流中可以接受的屬性(正如我們在上一個例子中看到的那樣),但是在處理二進位制Stream時通常是一個不希望這樣。

然而,我們可以通過一種模式按順序合併Stream; 它包含一個接一個地合併源Stream,當前一個結束時,開始傳送第二段資料塊(就像連線所有源Stream的輸出一樣)。在npm上,我們可以找到一些也處理這種情況的軟體包。其中之一是multistream

多路複用和多路分解

合併Stream模式有一個特殊的模式,我們並不是真的只想將多個Stream合併在一起,而是使用一個共享通道來傳送一組資料Stream。與之前的不一樣,因為源資料Stream在共享通道內保持邏輯分離,這使得一旦資料到達共享通道的另一端,我們就可以再次分離資料Stream。如圖所示:

《Node.js設計模式》使用流進行編碼

將多個Stream組合在單個Stream上傳輸的操作被稱為多路複用,而相反的操作(即,從共享Stream接收資料重構原始的Stream)則被稱為多路分用。執行這些操作的裝置分別稱為多路複用器和多路分解器(。 這是一個在電腦科學和電信領域廣泛研究的話題,因為它是幾乎任何型別的通訊媒體,如電話,廣播,電視,當然還有網際網路本身的基礎之一。 對於本書的範圍,我們不會過多解釋,因為這是一個很大的話題。

我們想在本節中演示的是,如何使用共享的Node.js Streams來傳送多個邏輯上分離的Stream,然後在共享Stream的另一端再次分離,即實現一次多路複用和多路分解。

建立一個遠端logger日誌記錄

舉例說明,我們希望有一個小程式來啟動子程式,並將其標準輸出和標準錯誤都重定向到遠端伺服器,伺服器接受它們然後儲存為兩個單獨的檔案。因此,在這種情況下,共享介質是TCP連線,而要複用的兩個通道是子程式的stdoutstderr。 我們將利用分組交換的技術,這種技術與IPTCPUDP等協議所使用的技術相同,包括將資料封裝在資料包中,允許我們指定各種源資訊,這對多路複用,路由,控制 流程,檢查損壞的資料都十分有幫助。

如圖所示,這個例子的協議大概是這樣,資料被封裝成具有以下結構的資料包:

《Node.js設計模式》使用流進行編碼

在客戶端實現多路複用

先說客戶端,建立一個名為client.js的模組,這是我們這個應用程式的一部分,它負責啟動一個子程式並實現Stream多路複用。

開始定義模組,首先載入依賴:

const child_process = require('child_process');
const net = require('net');
複製程式碼

然後開始實現多路複用的函式:

function multiplexChannels(sources, destination) {
  let totalChannels = sources.length;

  for(let i = 0; i < sources.length; i++) {
    sources[i]
      .on('readable', function() { // [1]
        let chunk;
        while ((chunk = this.read()) !== null) {
          const outBuff = new Buffer(1 + 4 + chunk.length); // [2]
          outBuff.writeUInt8(i, 0);
          outBuff.writeUInt32BE(chunk.length, 1);
          chunk.copy(outBuff, 5);
          console.log('Sending packet to channel: ' + i);
          destination.write(outBuff); // [3]
        }
      })
      .on('end', () => { //[4]
        if (--totalChannels === 0) {
          destination.end();
        }
      });
  }
}
複製程式碼

multiplexChannels()函式接受要複用的源Stream作為輸入 和複用介面作為引數,然後執行以下步驟:

  1. 對於每個源Stream,它會註冊一個readable事件偵聽器,我們使用non-flowing模式從流中讀取資料。

  2. 每讀取一個資料塊,我們將其封裝到一個首部中,首部的順序為:channel ID為1位元組(UInt8),資料包大小為4位元組(UInt32BE),然後為實際資料。

  3. 資料包準備好後,我們將其寫入目標Stream

  4. 我們為end事件註冊一個監聽器,以便當所有源Stream結束時,end事件觸發,通知目標Stream觸發end事件。

注意,我們的協議最多能夠複用多達256個不同的源流,因為我們只有1個位元組來標識channel

const socket = net.connect(3000, () => { // [1]
  const child = child_process.fork( // [2]
    process.argv[2],
    process.argv.slice(3), {
      silent: true
    }
  );
  multiplexChannels([child.stdout, child.stderr], socket); // [3]
});
複製程式碼

在最後,我們執行以下操作:

  1. 我們建立一個新的TCP客戶端連線到地址localhost:3000
  2. 我們通過使用第一個命令列引數作為路徑來啟動子程式,同時我們提供剩餘的process.argv陣列作為子程式的引數。我們指定選項{silent:true},以便子程式不會繼承父級的stdoutstderr
  3. 我們使用mutiplexChannels()函式將stdoutstderr多路複用到socket裡。
在服務端實現多路分解

現在來看服務端,建立server.js模組,在這裡我們將來自遠端連線的Stream多路分解,並將它們傳送到兩個不同的檔案中。

首先建立一個名為demultiplexChannel()的函式:

function demultiplexChannel(source, destinations) {
  let currentChannel = null;
  let currentLength = null;
  source
    .on('readable', () => { //[1]
      let chunk;
      if(currentChannel === null) {          //[2]
        chunk = source.read(1);
        currentChannel = chunk && chunk.readUInt8(0);
      }
    
      if(currentLength === null) {          //[3]
        chunk = source.read(4);
        currentLength = chunk && chunk.readUInt32BE(0);
        if(currentLength === null) {
          return;
        }
      }
    
      chunk = source.read(currentLength);        //[4]
      if(chunk === null) {
        return;
      }
    
      console.log('Received packet from: ' + currentChannel);
    
      destinations[currentChannel].write(chunk);      //[5]
      currentChannel = null;
      currentLength = null;
    })
    .on('end', () => {            //[6]
      destinations.forEach(destination => destination.end());
      console.log('Source channel closed');
    })
  ;
}
複製程式碼

上面的程式碼可能看起來很複雜,仔細閱讀並非如此;由於Node.js可讀的Stream的拉動特性,我們可以很容易地實現我們的小協議的多路分解,如下所示:

  1. 我們開始使用non-flowing模式從流中讀取資料。
  2. 首先,如果我們還沒有讀取channel ID,我們嘗試從流中讀取1個位元組,然後將其轉換為數字。
  3. 下一步是讀取首部的長度。我們需要讀取4個位元組,所以有可能在內部Buffer還沒有足夠的資料,這將導致this.read()呼叫返回null。在這種情況下,我們只是中斷解析,然後重試下一個readable事件。
  4. 當我們最終還可以讀取資料大小時,我們知道從內部Buffer中拉出多少資料,所以我們嘗試讀取所有資料。
  5. 當我們讀取所有的資料時,我們可以把它寫到正確的目標通道,一定要記得重置currentChannelcurrentLength變數(這些變數將被用來解析下一個資料包)。
  6. 最後,當源channel結束時,一定不要忘記呼叫目標Streamend()方法。

既然我們可以多路分解源Stream,進行如下呼叫:

net.createServer(socket => {
  const stdoutStream = fs.createWriteStream('stdout.log');
  const stderrStream = fs.createWriteStream('stderr.log');
  demultiplexChannel(socket, [stdoutStream, stderrStream]);
})
  .listen(3000, () => console.log('Server started'))
;
複製程式碼

在上面的程式碼中,我們首先在3000埠上啟動一個TCP伺服器,然後對於我們接收到的每個連線,我們將建立兩個可寫入的Stream,指向兩個不同的檔案,一個用於標準輸出,另一個用於標準錯誤; 這些是我們的目標channel。 最後,我們使用demultiplexChannel()將套接字流解複用為stdoutStreamstderrStream

執行多路複用和多路分解應用程式

現在,我們準備嘗試執行我們的新的多路複用/多路分解應用程式,但首先讓我們建立一個小的Node.js程式來產生一些示例輸出; 我們把它叫做generateData.js

console.log("out1");
console.log("out2");
console.error("err1");
console.log("out3");
console.error("err2");
複製程式碼

首先,讓我們開始執行服務端:

node server
複製程式碼

然後執行客戶端,需要提供作為子程式的檔案引數:

node client generateData.js
複製程式碼

《Node.js設計模式》使用流進行編碼

客戶端幾乎立馬執行,但是程式結束時,generateData應用程式的標準輸入和標準輸出經過一個TCP連線,然後在伺服器端,被多路分解成兩個檔案。

注意,當我們使用child_process.fork()時,我們的客戶端能夠啟動別的Node.js模組。

物件Streams的多路複用和多路分解

我們剛剛展示的例子演示瞭如何複用和解複用二進位制/文字Stream,但值得一提的是,相同的規則也適用於物件Stream。 最大的區別是,使用物件,我們已經有了使用原子訊息(物件)傳輸資料的方法,所以多路複用就像設定一個屬性channel ID到每個物件一樣簡單,而多路分解只需要讀·channel ID屬性,並將每個物件路由到正確的目標Stream

還有一種模式是取一個物件上的幾個屬性並分發到多個目的Stream的模式 通過這種模式,我們可以實現複雜的流程,如下圖所示:

《Node.js設計模式》使用流進行編碼

如上圖所示,取一個物件Stream表示animals,然後根據動物型別:reptilesamphibiansmammals,然後分發到正確的目標Stream中。

總結

在本章中,我們已經對Node.js Streams及其使用案例進行了闡述,但同時也應該為程式設計正規化開啟一扇大門,幾乎具有無限的可能性。我們瞭解了為什麼StreamNode.js社群讚譽,並且我們掌握了它們的基本功能,使我們能夠利用它做更多有趣的事情。我們分析了一些先進的模式,並開始瞭解如何將不同配置的Streams連線在一起,掌握這些特性,從而使流如此多才多藝,功能強大。

如果我們遇到不能用一個Stream來實現的功能,我們可以通過將其他Streams連線在一起來實現,這是Node.js的一個很好的特性;Streams在處理二進位制資料,字串和物件都十分有用,並具有鮮明的特點。

在下一章中,我們將重點介紹傳統的物件導向的設計模式。儘管JavaScript在某種程度上是物件導向的語言,但在Node.js中,函式式或混合方法通常是首選。在閱讀下一章便揭曉答案。

相關文章