Node.js 設計模式 學習筆記 之 流程式設計

ꍯꇝ車格發表於2019-05-06

參考

1. 什麼是流?

不同於緩衝,流可以讀取資料的一部分同時立即提供給後序處理,這提高了記憶體的空間效率和程式執行的時間效率。

緩衝的記憶體空間問題:緩衝是一次性讀取所有資料到記憶體,然後才能交給處理程式,如果檔案資料過大比如由幾百MB甚至幾百GB,那麼讀取整個檔案到緩衝,然後再一次下返回,那麼可能會導致記憶體溢位(比如說在 V8 的快取區不能超過1GB)。

緩衝的執行時間問題:只有在檔案資料被完整讀取到記憶體才能進行後續處理,比如說 如果使用同步讀取的方法讀取一個檔案,當檔案過大就會阻塞程式執行,雖然使用非同步方法讀取檔案不會阻塞,但是仍然會把檔案整個讀到記憶體中。
複製程式碼

使用流可以緩解緩衝的以上問題:流可以實現一些無法通過快取資料並一次性處理來實現的功能(比如在處理大檔案壓縮時,壓縮之前要先讀取檔案,而檔案太大,記憶體會溢位,如果使用流,那麼可以通過流的管道在讀檔案的同時對流中的資料塊進行壓縮),除了避免記憶體溢位的空間問題,流還允許一旦讀到檔案的資料塊就可以儘快處理資料,這比緩衝一次性讀取整個檔案再進行處理執行的速度會更快。

2. 流的分類

流的分類

3. 流的特點

  1. 組合性

    流還可以通過管道 pipe() 組合起來,管道中的每個獨立單元只實現單一的功能,(像中介軟體?),管道中的上一個處理單元的輸出流作為下一個單元的輸入,前提是管道中的下一個流必須支援上一個流的輸出資料型別。流的可組合性不僅使流可以用來處理前面的純 I/O 問題,也可以用來對程式碼進行 簡化 和 模組化處理。使用 pipe() 還可以避免手動解決背壓問題,因為管道會自動處理。

  2. 流的操作模式 :

    流的操作模式有 物件模式二進位制模式 。 當建立流時,可以使用 objectMode 選項把流例項切換到物件模式。在該模式下,流中的資料可以看作是一系列獨立的物件,通過設定 objectMode 引數使得流可以接受任何 JavaScript 物件。 在二進位制模式下,流中的資料是以塊的形式存在的,比如緩衝或者字串.

  3. 流的背壓

    在可讀流中,資料寫入緩衝的速度大於被讀出的速度,那麼緩衝會積聚大量資料,佔用大量的記憶體。如果使用管道pipe()方法,那麼不用自己解決背壓問題。

4. 使用流:

首先要建立流,可以使用 Node 內建的的流介面,也可以通過繼承建立新的流類,或者直接使用第三方模組。

Node.js 設計模式 學習筆記 之 流程式設計

5. 流的使用場景:

(1)處理I/O:

    比如使用流對讀入資料立即進行壓縮和傳送,不用等待所有資料讀入快取之後才能壓縮和傳送。
    利用流的可組合性使用管道對資料進行各種變化,不僅是壓縮還可以加密。
複製程式碼

(2)使用流進行流程控制:

    如順序執行、無序並行執行、無序有限制的並行執行、順序並行執行。
複製程式碼

a. 順序執行

比如將幾個檔案的內容拼接起來,要遵循檔案的先後順序,首先定義一個目標檔案流,用 from2-array 這個模組建立一個檔案陣列,然後使用管道pipe(),在pipe()中把每一個檔案以流的模式讀入,並且用讀入目標檔案流的管道中,將每一個檔案按先後順序依次讀入目標檔案流,就把所有檔案拼接起來了。程式碼:

function concatFiles(destination,files,callback){
    const destStream = fs.createWriteStream(destination);
    fromArray.obj(files)
         .pipe(through.obj( ( file, enc, done ) => {
             const src = fs.createReadStream(file);
             src.pipe(destStream, {end:false});
             src.on('end', done)
         }));
}
    module.exports = concatFiles
複製程式碼

在 concat.js 使用此模組:

// concat.js:
const concatFiles = require('./concatFiles');
concatFiles(process.argv[2], process.argv.slice(3), ()=>{
    console.log('檔案成功連線!!')
})

複製程式碼

執行 node concat allTogether.txt file1.txt file2.txt

將 file1.txt 、file2.txt 兩個檔案的內容拼接然後輸入到 allTogether.txt 裡。

b. 無序並行執行

充分利用 Node.js 的併發性,在兩個資料塊之間沒有聯絡時,用並列執行來提高處理速度。比如有一個檔案 urlList.txt, 裡面的文字是每行一個URL,現在用流的方式並行讀入這些URL 來做一些處理。

定義一個並行執行的變換流類 :

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.push.bind(this),
            this._onComplete.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()
        }
    }
}

複製程式碼

在上面建立的變換類中,預設使用物件模式,_transform() 方法會執行傳入的變換函式,而且為了實現並行,在變換函式為執行完之前呼叫 done() 方法,變換函式中會繫結一個回撥函式 _onComplete() ;流終止時會呼叫 _flush() 方法,只要還有任務在執行中,就不呼叫done()(done 的作用是每一個流的塊完成之後才能處理下一個流,是一個流的原生API),而是將 done 賦值給 termnateCallback(), 賦值這個操作延遲了 done()執行, 也就可以延遲 finish 事件觸發;_onComplete() 方法在每一個非同步任務完成後執行, 當所有任務完成後就呼叫 termnateCallback() 也就是 done() , 並且觸發 finish 事件,執行 _flush() .

使用這個類:checkUrls.js:

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

fs.createReadStream(process.argv[2])
    .pipe(split())
    .pipe(new ParallelStream(( url, enc, push, done) => {
        if(!url) return done();
        request.head(url ,(err, respond)=>{
            push(url + 'is' + (err ? 'down' : 'up') + '\n');
            done()
        })
    }))
    .pipe(fs.createWriteStream('result.txt'))
    .on('finish', ()=> console.log(' 所有 URL 已經校驗'))
複製程式碼

執行 node checkUrls urlList.txt

urlList.text 的內容:

    http://1XXXX
    http://2XXXX
    http://3XXXX
複製程式碼

執行上面的命令後 得到一個 result.txt:

    http://3XXXX  is down
    http://1XXXX is up
    http://2XXXX id up
複製程式碼

可以發現結果是無序的。

c. 無序有限制的並行執行

同時執行過多的任務會破壞應用的可靠性,控制負載和資源的有效方法就是限制任務的併發執行,減少一次併發的數量。只要在上面無序並行執行的流類中增加一個控制並行執行的數量變數 concurrency 和一個 continueCallback(),並且修改 _transform() 和 _onComplete() 為一下即可:

_transform(chunk,enc,done){
    this.running++;
    this.userTransform(chunk,enc,this._onComplete.bind(this));
    if(this.running < this.concurrency){
        done();
    }else{
        this.continueCallback = done;
    }
}

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

在上面的 _transform() 方法裡,增加 if 判斷在執行一個任務時是否還有空閒的資源可以用於下一個任務的執行, 如果當前工作流中任務總數達到了最大限度,就只是簡單的把 done() 賦值給 continueCallback , 在 _onComplete() 裡在一個任務完成後就可以執行這個 continueCallback 也就是 done(),這樣就延遲了 done() 的執行,也就是延遲了下一批任務的執行,等 _onComplete() 裡執行了 continueCallback 表示上一批任務執行完成解除了流的阻塞,才可以執行下一批任務。這樣就實現了有限制的並行執行。

d. 順序並行執行

可以使用第三方模組 變化流 through2-parallel

const throughParallel = require('through2-parallel');

fs.createReadStream(process.argv[2])
    .pipe(split())
    .pipe(throughParallel.obj({concurrency:2},(url,enc,done)=>{
            // ....
        })
    )
    .pipe(fs.createWriteStream('result.txt'))
    .on('finish',()=>console.lpog('所有 URL 已被校驗'))
複製程式碼

(3)流的管道模式:(Node.js 中流的拼接技術)

a. 組合流

組合流可以對整個管道進行模組化和重用。 組合流的原理和特徵:組合流 中第一個是寫入流,最後一個是可讀流,管道中每個流的錯誤事件並不會沿著管道自動傳遞,需要為每一個流新增錯誤監聽器,而組合流應該是一個黑盒,我們不能訪問管道里的任何一個流,所以組合流必須簡化錯誤管理機制,只需要對整個組合流新增錯誤機制而不是對管道中的每一個流。 使用第三方組合流模組比自己實現要簡單多了:比如 multipipe、combine-stream

Node.js 設計模式 學習筆記 之 流程式設計

combinedStreams.js:

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.decryptAndDecoDecompress = password => {
    return combine(
        crypto.createDecipher('ase192',password),
        zlib.createGunzip()
    )
}
複製程式碼

在 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 => {
    // this error may comes from any stream in the pipeline
    console.log(err)
})

複製程式碼

執行 `node archive mypassword /path/to/a/file.txt'

b. 複製流

當想要把相同的資料傳輸到不同的目標時,或者對同樣的資料進行不同的變化或者根據不同的標準來分離的資料的時候需要複製流,將一個可讀流傳輸到多個可寫流中。

Node.js 設計模式 學習筆記 之 流程式設計

一個例子?:建立一個檔案的可讀流並將其複製為兩個不同流,分別對倆個流進行 sha1 和 md5 計算。

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

const sha1Stream = crypto.createHash('sha1');
sha1Stream.setEncoding('base64');

const md5Stream = crypto.createHash('md5');
md5Stream.setEncoding('bade64')


const inputFile = process.argv[2];
const inputStream = fs.createReadstream(inputFile);

inputFile
    .pipe(sha1Stream)
    .pipe(fs.createWriteStream(inputFile + '.sha1'));

inputFile
    .pipe(md5Stream)
    .pipe(fs.createWriteStream(inputFile + '.md5'));

複製程式碼

⚠️ 複製流需要注意:複製的兩個流會接受相同的資料,對資料的操作要小心以免影響到複製的每一個流。

c. 合併流

與複製流相反。將一組可讀流合併傳輸到一個可寫流中。需要注意的是,如果預設每一個流都是自動結束,那麼就會導致只要有一個流結束了那麼目標流也會結束,所以應該使用 { end: false }設定選項使每一個讀入流不能自動結束。

Node.js 設計模式 學習筆記 之 流程式設計

一個例子?:將2個不同目錄的內容打包為一個。使用2個 npm 包,tar(一個使用流進行打包的庫)、fstream(一個用來對檔案建立物件流的庫) mergeTar.js:

const tar = require('tar')
const fstream = require('fstream');
const path = require('path')

const destination = path.resolve(process.argv[2])
const sourceA = path.resolve(process.argv[3])
const sourceB = path.recolve(process.argv[4])

const pack = tar.Pack()
pack.pipe(fstream.Write(destination))

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)

sourceStreamA.pipe(pack,{end:false})
sourceStreamB.pipe(pack,{end:false})

複製程式碼

執行 node mergeTar dest.tar /path/to/sourceA /path/to/sourceB

合併流也可以使用一些 npm 包:merge-stream、multistream-merge

d. 複用、分解

將多個流合併到一起以便使用單一的流來傳輸資料的模式就是 複用; 相反,從共享流接收的資料重新構建原始流稱為分解。

Node.js 設計模式 學習筆記 之 流程式設計

舉一個?:實現一個小程式,複用一個TCP連線,複用此通道的資料流是子執行緒的標準輸出和標準錯誤,功能是啟動一個子執行緒將其標準輸出和標準錯誤這兩個流重定向傳送搭配一個遠端伺服器,然後伺服器將兩個資料流分別儲存到兩個獨立的檔案裡。我們使用分組交換技術,每一個組除了資料之外還有一個頭部用於標記每一個流的資料,並在分解是將不同的組路由傳輸到正確的通道。

第一次釋出文章,如有錯誤希望大佬們指出。由於文章是從自己的日常學習筆記中擷取下來的,可能在有些內容銜接上有些突兀,如果覺得筆記還可以,更多內容可以前往我的 GitHub pages

相關文章