為什么應該使用流
你可能看過這樣的代碼。
var http = require('http');
var fs = require('fs');
var server = http.createServer(function (req, res) {
fs.readFile(__dirname + '/data.txt', function (err, data) {
res.end(data);
});
});
server.listen(8000);
這段代碼中,服務器每收到一次請求,就會先把data.txt讀入到內存中,然后再從內存取出返回給客戶端。尷尬的是,如果data.txt非常的大,而每次請求都需要先把它全部存到內存,再全部取出,不僅會消耗服務器的內存,也可能造成用戶等待時間過長。
幸好,HTTP請求中的request對象和response對象都是流對象,于是我們可以換一種更好的方法:
var http = require('http');
var fs = require('fs');
var server = http.createServer(function (req, res) {
let stream = fs.createReadStream(__dirname + '/data.txt');//創造可讀流
stream.pipe(res);//將可讀流寫入response
});
server.listen(8000);
pipe方法如同stream和response之間的一個管道,將data.txt文件一小段一小段地發送到客戶端,減小了服務器的內存壓力。
比喻理解Stream
在node中,一共有五種類型的流:readable,writable,transform,duplex以及"classic"。其中最核心的是可讀流和可寫流。我們舉個栗子來生動形象地理解它們。
可讀流可理解為:從一個裝滿了水的水桶中一點一點把水抽取出來的過程
可寫流可理解為:把從可讀流抽出來的水一點一點倒入一個空的桶中的過程
腦補一下,如圖所示
也可以以經典的生產者和消費者的問題來理解Stream,生產者不斷在緩存中制造產品,而消費者則不斷地從緩存中消費產品
readableStream
可讀流(Readable streams)是對提供數據的 源頭 (source)的抽象
可讀流的流程如圖所示
資源的數據流并不是直接流向消費者,而是先 push 到緩存池,緩存池有一個水位標記 highWatermark,超過這個標記閾值,push 的時候會返回 false,從而控制讀取數據流的速度,如同水管上的閥門,當水管面裝滿了水,就暫時關上閥門,不再從資源里“抽水”出來。什么場景下會出現這種情況呢?
- 消費者主動執行了 .pause()
- 消費速度比數據 push 到緩存池的生產速度慢
可讀流有兩種模式,flowing和pause
- flowing模式下 可讀流可自動從資源讀取數據
- pause模式下 需要顯式調用stream.read()方法來讀取數據
緩存池就像一個空的水桶,消費者通過管口接水,同時,資源池就像一個水泵,不斷地往水桶中泵水,而 highWaterMark 是水桶的浮標,達到閾值就停止蓄水。下面是一個簡單的flowing模式 Demo:
const Readable = require('stream').Readable
class MyReadable extends Readable{
constructor(dataSource, options){
super(options)
this.dataSource = dataSource
}
//_read表示需要從MyReadable類內部調用該方法
_read(){
const data = this.dataSource.makeData()
this.push(data)
}
}
//模擬資源池
const dataSource = {
data: new Array('abcdefghijklmnopqrstuvwxyz'),
makeData: function(){
if(!this.data.length) return null
return this.data.pop()
}
}
const myReadable = new MyReadable(dataSource);
myReadable.setEncoding('utf8');
myReadable.on('data', (chunk) => {
console.log(chunk);
});
另外一種模式是pause模式,這種模式下可讀流有三種狀態
- readable._readableState.flowing = null 目前沒有數據消費者,所以不會從資源庫中讀取數據
- readable._readableState.flowing = false 暫停從資源庫讀取數據,但 不會 暫停數據生成,主動觸發了 readable.pause() 方法, readable.unpipe() 方法, 或者接收 “背壓”(back pressure)可達到此狀態
- readable._readableState.flowing = true 正在從資源庫中讀取數據,監聽 'data' 事件,調用 readable.pipe() 方法,或者調用 readable.resume() 方法可達到此狀態
一個簡單的切換狀態的demo:
const myReadable = new MyReadable(dataSource);
myReadable.setEncoding('utf8');
myReadable.on('data', (chunk) => {
console.log(`Received ${chunk.length} bytes of data.`);
myReadable.pause()
console.log('pausing for 1 second')
setTimeout(()=>{
console.log('now restart')
myReadable.resume()
}, 1000)
});
pause模式的流程圖如下
資源池會不斷地往緩存池輸送數據,直到 highWaterMark 閾值,消費者需要主動調用 .read([size]) 函數才會從緩存池取出,并且可以帶上 size 參數,用多少就取多少:
const myReadable = new MyReadable(dataSource);
myReadable.setEncoding('utf8');
myReadable.on('readable', () => {
let chunk;
while (null !== (chunk = myReadable.read(5))) {//每次讀5個字節
console.log(`Received ${chunk.length} bytes of data.`);
}
});
這里值得注意的是,readable事件的回調函數沒有參數。因為 'readable' 事件將在流中有數據可供讀取時就會觸發,而在pause模式下讀取數據需要顯式調用read()才會消費數據
輸出為:
Received 5 bytes of data.
Received 5 bytes of data.
Received 5 bytes of data.
Received 5 bytes of data.
Received 5 bytes of data.
Received 1 bytes of data.
readableStream一些需要注意的事件
- 'data' 事件會在流將數據傳遞給消費者時觸發
- 'end' 事件將在流中再沒有數據可供消費時觸發
- 'readable' (從字面上看:“可以讀的”) 事件將在流中有數據可供讀取時觸發。在某些情況下,為 'readable' 事件添加回調將會導致一些數據被讀取到內部緩存中。
'readable' 事件表明流有了新的動態:要么是有了新的數據,要么是到了流的尾部。 對于前者, stream.read() 將返回可用的數據。而對于后者, stream.read() 將返回 null。 - 'setEncoding' 設置編碼會使得該流數據返回指定編碼的字符串而不是Buffer對象。
- ‘pipe’ 事件放到后面詳談。
writableStream
Writable streams 是 destination 的一種抽象,一個writable流指的是只能流進不能流出的流:
readableStream.pipe(writableStream)
數據流過來的時候,會直接寫入到資源池,當寫入速度比較緩慢或者寫入暫停時,數據流會進入隊列池緩存起來,當生產者寫入速度過快,把隊列池裝滿了之后,就會出現「背壓」(back pressure),這個時候是需要告訴生產者暫停生產的,當隊列釋放之后,Writable Stream 會給生產者發送一個 drain 消息,讓它恢復生產.
writable.write() 方法向流中寫入數據,并在數據處理完成后調用 callback。在確認了 chunk 后,如果內部緩沖區的大小小于創建流時設定的 highWaterMark 閾值,函數將返回 true 。 如果返回值為 false (即隊列池已經裝滿),應該停止向流中寫入數據,直到 'drain' 事件被觸發。
構造一個可寫流需要重寫_write方法
const Writable = require('stream').writable
class MyWritableStream extends Writable{
constructor(options){
super(options)
}
_write(chunk, encoding, callback){
console.log(chunk)
}
}
一個寫入數據10000次的demo,其中可以加深對write方法和drain方法的認識
function writeOneMillionTimes(writer, data, encoding, callback) {
let i = 10000;
write();
function write() {
let ok = true;
while(i-- > 0 && ok) {
// 寫入結束時回調
if(i===0){
writer.write(data, encoding, callback)//當最后一次寫入數據即將結束時,再調用callback
}else{
ok = writer.write(data, encoding)//寫數據還沒有結束,不能調用callback
}
}
if (i > 0) {
// 這里提前停下了,'drain' 事件觸發后才可以繼續寫入
console.log('drain', i);
writer.once('drain', write);
}
}
}
const Writable = require('stream').Writable;
class MyWritableStream extends Writable{
constructor(options){
super(options)
}
_write(chunk, encoding, callback){
setTimeout(()=>{
callback(null)
},0)
}
}
let writer = new MyWritableStream()
writeOneMillionTimes(writer, 'simple', 'utf8', () => {
console.log('end');
});
輸出是
drain 7268
drain 4536
drain 1804
end
輸出結果說明程序遇到了三次「背壓」,如果我們沒有在上面綁定 writer.once('drain'),那么最后的結果就是 Stream 將第一次獲取的數據消耗完就結束了程序,即只輸出drain 7268
pipe
readable.pipe(writable);
readable 通過 pipe(管道)傳輸給 writable
Readable.prototype.pipe = function(writable, options) {
this.on('data', (chunk) => {
let ok = writable.write(chunk);
if(!ok) this.pause();// 背壓,暫停
});
writable.on('drain', () => {
// 恢復
this.resume();
});
// 告訴 writable 有流要導入
writable.emit('pipe', this);
// 支持鏈式調用
return writable;
};
核心有5點:
- emit(pipe),通知寫入
- .write(),新數據過來,寫入
- .pause(),消費者消費速度慢,暫停寫入
- .resume(),消費者完成消費,繼續寫入
- return writable,支持鏈式調用
參考:
http://www.barretlee.com/blog/2017/06/06/dive-to-nodejs-at-stream-module/
http://nodejs.cn/api/stream.html
https://github.com/substack/stream-handbook