七天學不會nodejs——流

天天up啊噗發表於2018-07-22

參考文章:

poster

圖片來源 視覺中國

“流”的概念

流(stream)是一種在 Node.js 中處理流式資料的抽象介面 ——官方文件

流是資料的集合,你可以將它理解成資料連結串列或者字串的形式,區別在於流中的資料並不能立即可用,這裡又可以將其理解成水流。你無需將所有的資料一次性全部放入記憶體,相反,你可以使用流這一特有的性質,完成對大量資料的操作以及逐段處理的操作

在node非同步處理資料的基礎上,流將要傳輸的資料處理成小份資料(chunk)連續傳輸,這樣通過更少的記憶體消耗,從而帶來更多的效能提升


“流”的型別

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

  • Readable -- 可讀流 可以讀取資料的源的抽象。 eg. fs.createReadStream()
  • Writable -- 可寫流 可以寫入資料目標的抽象。 eg. fs.createWriteStream()
  • Duplex -- 雙向流(雙工流) 既是可讀的,又是可寫的。 eg. not.Socket
  • Transform -- 變換流(可變流) 讀寫過程中可以修改或者轉化資料的雙向流。 eg. zlib.createDeflate()

所有的流都是 EventEmitter 的例項,他們發出可以被讀和寫的事件,在這個基礎上,我們能夠很方便的利用 pipe 方法對這些流進行操作

readableSrc.pipe(writableDest)
複製程式碼

上面這個簡單的例子中,我們利用 readable stream 的輸出作為 writable stream 的輸入。 那麼再來想,如果我們的輸入輸出都是 Duplex 那就可以一直 pipe 下去,實現如 Linux 命令般連續的操作。 如果你有用過 gulp 進行前端資源的壓縮整合,對於此一定會印象深刻


Node.js中內建的流

下表中全部資料Node.js中原生的物件,這些物件也是可以讀寫的流,一部分是雙工流與可變流 注意:一個 HTTP 相應在客戶端是可讀流,但在服務端就是可寫流。 stdio 流(stdin, stdout, stdout)在子程式中有著與父程式中相反的型別,也正是這樣,父子通訊才變的簡單

Readable Stream Writable Stream
HTTP response (客戶端) HTTP request (客戶端)
HTTP request (服務端) HTTP response (服務端)
fs read streams fs write streams
zlib streams zlib streams
crypto streams crypto streams
TCP sockets TCP sockets
child process stdout, stderr child process stdin
process.stdin process.stdout, process.stderr

通過一個簡單例子凸顯流在實際運用中的重要性

  1. 利用可寫流建立一個大檔案,向 big.file 寫入100萬行資料,檔案大約 400M
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();
複製程式碼
  1. 執行上面的指令碼,生成檔案。執行下面的指令碼。啟動用來傳送 big.file 的 node 服務。使用 curl 連線啟動的 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);
複製程式碼

當啟動 node 服務並未連線時候,記憶體的佔用為 8.7M,屬於正常情況(下圖)

normal

當使用 curl localhost:8000 連線伺服器,可以清晰看到一次性讀取會消耗多少記憶體(下圖)

burdens

  1. Node.js 的 fs 模組提供了對於任何檔案可以使用的 createReadStram 方法,我們可以利用此方法將讀取到的流 pipe 到響應,減輕伺服器負擔。程式碼以及效果如下
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);
複製程式碼

better

可以看到,node服務對於記憶體的壓力得到了力度極大的釋放,並且在輸出速度上依然很快。這裡即很直觀的體現了此文一開始提到的,node利用流通過極少記憶體的佔用,高效完成了對大檔案的操作。最後用一個形象的例子比喻一下上面的操作: 貨運工人有一車的貨物需要搬運,他可以選擇將車上的貨物全部卸下,然後一起搬到目的地;他還可以選擇通過使用履帶,將貨物一件一件運輸到目的地。試想一下,這兩種方式操作的效率。


關於讀寫流的內部機制

上面舉了一個“不恰當”但是可意會的“履帶”例子,其實node stream在計算機中真實的運作並沒有這麼簡單
生產者消費者問題 將會有助於更好理解流的原理

讀操作

讀操作原理1
圖片來源:小鬍子哥的個人部落格

可讀流分為兩種模式

  • Flowing Mode 流動模式
  • Non-Flowing Mode 暫停模式(預設,可以通過監聽data事件或執行resume方法改變)

流動模式,可以在比喻的基礎上理解為三個節點,水泵(水源,資料來源)、水桶(快取容器,記憶體)、目的地(水流向的位置)

資源的資料流不會直接流向消費者,而會先經過 highWaterMark 的判斷,push 到快取池(記憶體)中。如果超過 highWaterMark, push操作返回 false。最後的 resume()pause() 是通向消費者的一個閥門

寫操作

寫操作原理1
圖片來源:小鬍子哥的個人部落格

原理與讀流類似,寫入速度足夠快會直接寫入資源,當寫入速度比較慢或者暫停寫入時候,資料會在快取池中快取起來,當生產者寫入過快,快取池被放滿時候,這時候應當通知生產者暫停生產(比如下文write方法返回false),當快取池被釋放空,Writable Stream 會給生產者傳送 drain 訊息,通知生產者再次開始寫入。ps:這裡的內容下文介紹 writable stream 時會有程式碼示例

FS操作中流的使用

上面整體介紹了流的概念流的型別使用流的優點,接下來通過具體的程式碼,整理一些在fs模組中流的使用方式。

  • 可讀流 ReadableStream
  • 可寫流 WritableStream

可讀流 ReadableStream

建立可讀流 fs.createReadStream(path, )

const fs = require('fs);

const rs = fs.createReadStream('text.txt'); // options

/**
    fs.createReadStream(path, {
    flags: 'r', // 讀檔案,檔案不存在報錯,預設'r'
    encoding: 'utf-8', // 以什麼編碼格式讀取檔案(可以被Buffer接收的任何格式),預設讀取buffer
    autoClose: true, // 讀取後是否自動關閉檔案,預設true
    highWarterMark: 100, // 每次讀取的位元組數,預設64k(65536)
    start: 0, // 開始讀取的位置,預設0
    end: 200 // 讀取檔案的終點索引,預設 Infinity
    })
 **/
複製程式碼

注意:
end 如果設定為100,則需要讀取的位元組數為101,即0~100,包括100
因為預設 flags'r',如果 path 指向的檔案不存在,即會報錯

監聽事件 opendataendcloseerror 事件

上文提到:所有的流都是 EventEmitrer 的例項

const fs = require('fs);

const rs = fs.createReadStream('text.txt');

rs.on('open', () => {
    console.log('open');
});

rs.on('data', (datas) => {
    console.log('file is read', datas);
})

rs.on('close', () => {
    console.log('file is closed');
});

rs.on('error', (err) => {
    console.log(err);
});

/**
   依次輸出
   open
   檔案的內容(buffer)
   file is closed
 **/
複製程式碼

注意:
data 事件可能被多次觸發,如果將 highWarterMark 設定為3,讀取寫有0123456789text.txt檔案時,會觸發四次,依次輸出012、345、678、9對應的buffer

呼叫方法 pauseresume,暫停、恢復

/**
 * text.txt檔案內容 0123456789
 */
const fs = require('fs');

const rs = fs.createReadStream('text.txt', {
    encoding: 'utf-8',
    highWaterMark: 3,
});

rs.on('data', (datas) => {
    console.log(datas);
    rs.pause();
    console.log('stream is paused now');
});

rs.on('end', () => {
    console.log('stream is end');
    clearInterval(interval); // 清除定時器,否則會一直列印stream is resumed now
});

const interval = setInterval(() => {
    rs.resume();
    console.log('stream is resumed now');
}, 1000);

/**
   輸出:
   012
   stream is paused now
   stream is resumed now
   345
   stream is paused now
   stream is resumed now
   678
   stream is paused now
   stream is resumed now
   9
   stream is paused now
   stream is end
 **/
複製程式碼

注意: 沒什麼注意的

可寫流 WritableStream

建立可寫流 fs.createWriteStream(path, )

const fs = require('fs');
fs.createWriteStream(path, options);

const ws = fs.createWriteStream('2.txt', {
    flags: 'w', // 預設'w'寫入檔案,不存在則建立
    encoding: 'utf-8'
    fd: null, // 檔案描述符
    mode: 0o666, // 檔案操作許可權,同438
    autoClose: true,
    start: 0 // 開始寫入位置
    highWarterMark: 16384 // !!! 文件沒有給出這一設定,預設 16k,文末將驗證
});
複製程式碼

注意:
options 引數與 createReadStream 不同
也可以設定 highWaterMark 選項,官方文件沒有給出,預設的寫入大小為 16k,在可寫流物件執行 write 方法的時候如果超出 highWaterMark,返回值將變成 false

呼叫方法 writeenddrainfinish

  • write 方法有返回值,返回 truefalse, 分別代表,代表當前記憶體中被寫入的資料是否超出 highWaterMark (上面剛剛提到)
  • write 方法是非同步的,執行 write 之後資料並不會立即被寫入檔案,而會在記憶體中快取,然後依次寫入
/**
 * write 方法
 * chunk      寫入資料的buffer/string
 * encoding   編碼格式,可選。且chunk為字串時有用
 * callback   寫入成功回撥函式
 **/
ws.write(chunk,[encoding],[callback]);

/**
 * end 方法,表明接下來沒有資料要被寫入
 * chunk      寫入資料的buffer/string
 * encoding   編碼格式,可選。且chunk為字串時有用
 * callback   回撥函式,如果傳入,將作為 finish 事件的回撥函式
 **/
ws.end(chunk,[encoding],[callback]);

/**
 * finish 方法,在呼叫了 stream.end() 方法,且緩衝區資料都已經傳給底層系統之後, 'finish' 事件將被觸發。
 **/
const writer = fs.createWriteStream('2.txt');
for (let i = 0; i < 100; i++) {
    writer.write(`hello, ${i}!\n`);
}
writer.end('結束\n');
writer.on('finish', () => {
    console.error('所有的寫入已經完成!');
});
複製程式碼

drain 方法

const fs = require('fs');

const ws = fs.createWriteStream('2.txt', {
    encoding: 'utf-8',
    highWaterMark: 3
});

let i = 10;
function write() {
    let flag = true;
    while(i && flag) {
        flag = ws.write('1');
        i --;
        console.log(flag);
    }
}

write();

ws.on('drain', () => {
    console.log('drain');
    write();
});
複製程式碼

注意:

  • 當一個流處在 drain 狀態,對 write 的呼叫會被快取(下面解釋),並且返回false。一旦所有快取的資料都被排空(被作業系統用來進行輸出),那麼 drain 事件將被觸發,意思為記憶體中快取的資料已經被全部寫入到檔案中,接下來可以繼續執行 write 向記憶體中寫入資料了
  • 如果你在手動控制讀寫以及快取,建議這麼做,一旦 write 方法返回false,在 drain 事件觸發前,最好不要寫入任何資料,當然這樣需要配合 createWriteStreamhighWaterMark 引數,(這一引數文件沒有給出)

pipeunpipecorkuncork 方法

pipe 方法

上面題目的幾種方法中,pipe無疑使用最多,在流一般的使用場景下,pipe能解決大部分的需要,下面一句很簡單的語義程式碼就是 pipe 的使用方式,readable 通過 pipe 將資料傳輸給 writable,正如其名,管道

readable.pipe(writable)
複製程式碼

其基本原理為:

  • 呼叫 pipe 方法,通知寫入
  • write()
  • 消費速度慢於生產速度,pause()
  • 消費者完成消費,通過 drain 觸發 resume(),繼續寫入
  • 返回 writable (因為pipe支援鏈式呼叫)

一個簡單的例子:

const from = fs.createReadStream('./1.txt');
const to = fs.createWriteStream('./2.txt');
from.pipe(to);
複製程式碼

以上的例子都是可讀流作為輸入源,可寫流作為返回結果,當然,如果我們操作的是 duplex/transform,這時候就可以很容易寫作鏈式呼叫

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

unpipe 方法

/**
 * dest   當前readable pipe 管道的目標可寫流
 **/
readable.unpipe(dest)
複製程式碼
  • 可以將之前通過stream.pipe()方法繫結的流分離
  • 如果 dest 未被指定,則 readable 繫結的所有流都將被分離

corkuncork方法

  • cork
    • 呼叫 writable.cork() 方法將強制所有寫入資料都存放到記憶體中的緩衝區裡。 直到呼叫 stream.uncork() 或 stream.end() 方法時,緩衝區裡的資料才會被輸出。
  • uncork
    • writable.uncork()將輸出在stream.cork()方法被呼叫之後緩衝在記憶體中的所有資料。
stream.cork();
stream.write('1');
stream.write('2');
process.nextTick(() => stream.uncork());
複製程式碼

關於流的一些重要事件、方法總結

Readable Stream 可讀流的事件與方法

Event Functions
data pipe()、unpipe()
end read()、unshift()
error pause()、resume()
close isPaused()
readable setEncoding()

Writable Stream 可寫流的事件與方法

Event Functions
drain write()
finish end()
error cork()
close uncork()
pipe/unpipe setDefaultEncoding()

番外:驗證可寫流預設寫入資料大小 highWaterMark

  • 由於自己不能確定,建立可寫流時,預設寫入的大小,通過以下兩種方式可以證明16k
  • 文件沒有說明 fs.createWriteStream() option 中 highWaterMark 作用,我在此文多次提到,希望可以加深印象

方式一:

const fs = require('fs');

let count = 0;
const ws = fs.createWriteStream('testInput.txt');
for (let i = 0; i < 10000; i ++) {
    count ++;
    let flag = ws.write(i.toString());
    if (!flag) { // 返回false即到達了highWaterMark
        console.log('寫入' + count + '次');
        break;
    }
}

ws.end(function() {
    console.log('檔案寫入結束,輸出的總位元組為', ws.bytesWritten);
});

// 輸出:
寫入4374次
檔案寫入結束,輸出的總位元組為 16386
16386 / 1024
// 結果:
16.001953125
複製程式碼

方式二:

function writeOneMillionTimes(writer, data, encoding, callback) {
    let i = 10000;
    write();
    function write() {
        let ok = true;
        while (i-- > 0 && ok) {
            // 寫入結束時回撥
            = writer.write(data, encoding, i === 0 ? callback : null);
        }
        if (i > 0) {
            // 這裡提前停下了,'drain' 事件觸發後才可以繼續寫入  
            console.log('drain', i);
            writer.once('drain', write);
        }
    }
}

const Writable = require('stream').Writable;

const writer = new Writable({
    write(chunk, encoding, callback) {
        // 比 process.nextTick() 稍慢
        setTimeout(() => {
            callback && callback();
        });
    }
});

writeOneMillionTimes(writer, '123456', 'utf8', () => {
    console.log('end');
});

// 輸出
drain 7268
drain 4536
drain 1804
end

// 計算:
(10000-7268) * 6 / 1024
// 結果:16.0078125

複製程式碼

文末總結與說明

總結

本文主要從檔案操作的角度探究流的原理以及使用方法,node應用中你可以使用流做很多事情,網路請求、檔案上傳、命令列工具等等。 在Node.js應用中,流隨處可見,檔案操作,網路請求,程式、socket中流無處不在。正是這樣,流的特效能讓你的node應用真正體現出“小而美”的特性,

說明

文章目的為個人筆記,本人也是Node.js初學者,文中如有不恰當描述以及說明,歡迎指正交流。 文章借鑑了學習了很多大佬的文章(文首傳送門),非常感謝 後續有時間會繼續更新,祝自己node之路順利吧?

相關文章