- 什麼是流?
- 可讀流於可寫流
- 雙工流於轉換流
- 背壓機制與檔案流模擬實現
一、什麼是流?
關於流的概念早在1964年就有記錄被提出了,簡單的說“流”就是控制資料傳輸過程的程式,比如在那篇記錄中有這樣的描述:
“在編寫程式碼時,我們應該有一些方法將程式像連線水管一樣連線起來 -- 當我們需要獲取一些資料時,可以去通過"擰"其他的部分來達到目的。這也應該是IO應有的方式”。
——Doug McIlroy. October 11, 1964
在nodejs中將這種“流”的概念抽象成一個模型,可以實現有序的、可控制大小的、可按需獲取的方式實現資料傳輸,該模型在nodejs中實現的JavaScript層就是tream模組。
1.流的基本模型:
在nodejs中基於流的基本模型又實現了四種基本流型別:
Writable(可寫流):可以理解為將資料儲存到指定的地方中,例如fs.crateWriteStream()
Readable(可讀流):可以理解為將資料從指定的地方取出來,例如fs.crateReadStream()
Duplex(雙工流):例如net.Socket
Transform(轉換流):例如zlib.crateDeflate()
這個四個流的基本型別在stream模組上都以類的形式作為匯出API,我們可以基於這四個基本型別自定義自己的流的具體實現,比如下面通過可讀流(Readable)作為示例:
自定義可讀流的思考:資料來源、如何獲取資料?
資料來源採用一個陣列模擬,獲取資料的方式流程上stream已經實現了,但具體的獲取功能它在內部提供了定義介面,具體功能並沒有實現,這就是接下來我們需要實現的部分(假設每次從陣列中讀取一個陣列):
1 //自定義可讀流 2 const {Readable} = require('stream'); 3 //模擬資料來源 4 let source = ['a','b','c']; 5 //自定義繼承Readable 6 class MyReadable extends Readable{ 7 constructor(source){ 8 super(); 9 this.source = source; 10 } 11 _read(){ 12 let data = this.source.shift() || null; 13 this.push(data); 14 } 15 };
在Readable上有一個read()方法可以獲取指定量資料,先不討論它的具體實現過程,看下面的幾個測試結果:
測試一:
//例項化自定義讀取流 let myReadable = new MyReadable(source); console.log(myReadable.read(1)); //<Buffer 61> console.log(myReadable.read(1)); //<Buffer 62> console.log(myReadable.read(1)); //<Buffer 63>
測試二:
let myReadable = new MyReadable(source); console.log(myReadable.read()); //<Buffer 61> console.log(myReadable.read(1)); //<Buffer 62> console.log(myReadable.read(1)); //<Buffer 63>
測試三:
//測試三 console.log(myReadable.read(2)); //null console.log(myReadable.read(1)); //<Buffer 61> console.log(myReadable.read(1)); //<Buffer 62>
測試四:
//測試四 console.log(myReadable.read(1)); //<Buffer 61> console.log(myReadable.read(2)); //<null> console.log(myReadable.read(1)); //<Buffer 62>
首先通過列印結果來看,讀取流並不是將陣列中的元素值直接返回,而返回的是Buffer。這是因為在流內部使用了Buffer作為資料中轉容器,通過push傳入的資料如果不是Buffer則會使用Buffer.from()轉換後再快取到這個Buffer內。
至於為什麼read(size)在前面為什麼讀出的null,首先要理解size的意思是從快取內讀出指定位元組長度的資料,當中轉Buffer內沒有足夠未讀的資料就不會返回資料,而是返回null。然後就是要了解Buffer內的資料是什麼時候被傳入的,當我們每呼叫一次read()本質上內部就會執行一次_read(),也就是實現向Buffer快取資料的操作,當Buffer中快取足夠指定讀出位元組長度時就會成功返回資料,否則就會返回null,比如下面的示例:
console.log(myReadable.read(2)); //null--這次一次向Buffer記憶體了一個位元組,但不夠要讀的位元組長度是2,所以返回null console.log(myReadable.read(2)); //<Buffer 61 62>--這次又向Buffer記憶體了一個位元組,加上次存的位元組剛好滿足要讀的位元組長度2,所以成功返回兩個位元組的資料
通過read()返回資料肯定不是符合流的模式,根據前面前面的基本模型來看,它應該是一種可以自動的將資料來源中的資料來源源不斷的返回,nodejs的流tream底層是通過事件的機制來實現的,所以正確獲取資料的方式是通過data、readable事件來獲取資料:
myReadable.on('data',(chunk)=>{ console.log(chunk); }); //列印結果 //<Buffer 61> //<Buffer 62> //<Buffer 63>
可讀流中data事件獲取資料的機制:每一次_read()實現一次資料快取時,就會觸發一次data事件,並將當前_read()快取到Buffer內的資料全部傳遞給data事件回撥函式作為實參chunk。
可讀流readable事件獲取資料的機制:該事件會在啟動流操作快取空間寫入資料觸發一次(也就是_read()執行一次後),然後再是在資料來源的資料全部被寫入到Buffer後觸發一次(但需要注意這個全部寫入描述並不準確,因為它是在當_read()向Buffer寫入null時觸發)。因為任務回撥是非同步操,當它觸發後回撥執行時Buffer內的資料量是不確定的,從nodejs流的設計機制來說readable並不是為了獲取資料而存在,而僅僅是作為傳遞流操作的狀態資訊,但也可以通過這個事件配合read()方法獲取資料:
myReadable.on('readable',()=>{ let data = null; while((data = myReadable.read(1)) !== null){ console.log(data); } });
測試在資料來源上出現一個null元素對readable事件的影響:
//更新模擬資料來源 let source = ['a','b','c',null,'d','e']; //測試readable事件(還是使用上一個示例的輸出程式碼) <Buffer 61> <Buffer 62> <Buffer 63>
通過輸出結果可以看到,在陣列中新增的null,'d','e'沒有被成功輸出,這是因為當push()向Buffer內快取資料時寫入的是null,表示不在為流提供消費資料,類似終止資料來源向快取中提供資料,流操作隨之結束。所以,push(null)可以理解為流操作結束的最終閥門,並且無法重啟,所以在定義流時push(null)用來表示流的資料來源傳輸資料到達終點。
然後就是控制器(閥門的兩個操作):
pause()暫停:這個方法會暫停_read()方法向快取區寫資料時觸發data事件,也就是說呼叫這個方法只能中斷向消費者輸出資料,而不會中斷_read()從資料來源中讀取資料寫入快取區。並且這個方法呼叫會觸發pause事件,既暫停消費資料事件。
resume()啟動:這個方法可以重啟被pause()暫停的資料消費,重新啟動data事件從快取區讀出資料。但需要注意這個方法會觸發resume事件,但不是在重啟時觸發這個事件,而是在表示啟動時觸發resume事件,關於這個事件的詳細內容在下一節解析,這裡只需要瞭解resume()可以重啟被pause()暫停的data事件。
1 myReadable.on('data',(chunk)=>{ 2 //消費資料 3 console.log(chunk); 4 if(chunk.toString() === "a"){ 5 console.log("暫停資料消費") 6 myReadable.pause(); 7 } 8 }); 9 myReadable.on("pause",()=>{ 10 //暫停消費資料時觸發 11 console.log("重啟資料消費"); 12 myReadable.resume(); //重啟消費資料 13 }); 14 //測試結果 15 <Buffer 61> 16 暫停資料消費 17 重啟資料消費 18 <Buffer 62> 19 <Buffer 63>
通過上面對可讀流的簡單測試和解析,可以瞭解到流就是將資料從一個地方源源不斷的傳輸到另一個地方,並且可以通過控制傳輸單個資料節點的大小來控制傳輸速度,也可以隨時暫停和啟動來實現傳輸的可控性。但關於中轉快取部分沒有做任何解析,而這部分直接關係到記憶體的分配和使用,這也是影響I/O效能的核心部分,接下來逐個解析可讀流、可寫流、雙工流、轉換流。
二、可讀流與寫入流
2.1可寫流:
1 //自定義可寫流 2 const {Writable} = require('stream'); 3 let buf = Buffer.alloc(10,0,'utf-8'); //資料寫入的目的地 4 class MyWritable extends Writable { 5 constructor(options){ 6 super(options); 7 this.buf = buf; 8 this.offset = 0; 9 } 10 _write(chunk, en, done){ 11 setTimeout(()=>{ 12 this.buf.write(chunk.toString(),this.offset); //因為中間快取寫入將資料轉換成了Buffer,這裡buf接收資料的編碼是utf-8,所以又要轉換成字串 13 this.offset++; 14 done(); 15 }); 16 } 17 }; 18 //構造寫入流例項 19 let myWritable = new MyWritable({ 20 highWaterMark:1 21 }); 22 //寫入資料 23 myWritable.write('a',()=>{ 24 console.log(buf); //<Buffer 61 00 00 00 00 00 00 00 00 00> 25 }); 26 myWritable.write('b',()=>{ 27 console.log(buf); //<Buffer 61 62 00 00 00 00 00 00 00 00> 28 }); 29 myWritable.write('c',()=>{ 30 console.log(buf); //<Buffer 61 62 63 00 00 00 00 00 00 00> 31 });
根可讀流一樣,在寫入資料流中包含資料來源(write()寫入的資料)、中間快取、目的地(最後資料儲存的地方,示例中用buffer作為這個資料的寫入目的地),同樣也可以實現源源不斷的向資料儲存資源中寫入資料。
然後就是關於寫入流如何控制,這個控制涉及兩個方面:一是單次向程式寫入的資料量如何控制,二是什麼時候應該停止寫入資料。
關於寫入流的單次寫入量可以通過兩種方式:
第一種方式可以在write()寫入時控制寫入資料塊的大小,但這不能實現對記憶體消耗的精準把控,單個資料塊寫入多少是合適的,寫入的頻率如何掌控都只能從大概的推測上來實現。
第二種方式是基於流的drain事件配合highwaterMark設定中間快取大小來實現控制,當使用write()寫入資料時,如果當前寫入到中間快取的資料到達highwaterMark設定的最大值就會返回false,這時候就意味著中間快取空間已經佔滿,但中間快取依然還在內部持續的向目標空間寫入,如果寫入完畢中間快取清空就會觸發drain事件。基於流的這樣實現就可以在write()寫入返回false時停止寫入,等待drain事件觸發再寫入資料,這樣就實現了控制資料寫入的啟停操作,當然單個資料塊大小也可以基於highwaterMark的設定來考慮。
更具前面的解析和流程圖再來看下面的具體示例:
1 //自定義可寫流(實現可控的資料寫入操作) 2 const {Writable} = require('stream'); 3 let buf = Buffer.alloc(10,0,'utf-8'); 4 class MyWritable extends Writable { 5 constructor(options){ 6 super(options); 7 this.buf = buf; 8 this.offset = 0; 9 } 10 _write(chunk, en, done){ 11 setTimeout(()=>{ 12 this.buf.write(chunk.toString(),this.offset); 13 this.offset++; 14 done(); 15 }); 16 } 17 }; 18 19 20 let myWritable = new MyWritable({ 21 highWaterMark:1 22 }); 23 let source = 'abc'.split(""); 24 let flag = true; 25 let num = 0; 26 27 function fun(){ 28 while(flag && num < source.length){ 29 flag = myWritable.write('a',()=>{ 30 console.log(buf); 31 }); 32 num ++; 33 } 34 } 35 36 myWritable.on('drain',()=>{ 37 console.log("drain 執行了"); //表示中間快取資料已經完全寫入到目的地,可以重啟資料寫入操作 38 flag = true; 39 fun(); 40 }); 41 fun();
最後介紹一下關於寫入流的其他方法、事件、配置屬性:
//方法、例項化配置 write():實現資料寫入操作。 cork():該方法用於強制所有寫入資料快取在中間快取中,只有呼叫uncork()、end()方法後才會重新啟動向目的地寫入資料 uncork():重新啟動向目的地寫入資料,用於關閉之前cork()的操作。 end():表示不在寫入資料,但這個方法依然還可以像write()方法一樣寫入最後一個資料塊。這個方法執行後,當中間快取的資料全部寫入到目的地後觸發finish事件。 destroy():銷燬流,用於強制關閉流操作。如果此時中間快取還有資料沒寫入到目的地也不會再寫入,並且會報ERR_STREAM_DESTROYED錯誤。 new Writable([options]):例項話可寫流,這裡重點來關注options引數,該引數的屬性可以用來定製流的具體操作,同等與繼承stream.Writable然後在構造內定義具體操作,比如前面示例中定義_write()方法,也可通過這個引數的_write掛載實現,但這裡重點來關注一些配置屬性: --highWaterMark:設定中間快取的容量,預設16KB --decodeStrings:用於設定write()向中間快取寫入字串時是否使用write的編碼轉換成Buffer,預設為true,這時候_write()接收到的chunk就是Buffer,除String型別以外其他型別不會被轉換;如果設定為false,_write()接收到的chunk還是字串。 --defaultEncoding:指定預設編碼格式,如果write()沒有指定編碼就會使用該預設編碼,預設utf8。 --objectMode:是否支援寫入字串、Buffer、Uint8Array以外的JavaScript值,即使為true也不能支援null值。 --emitClose:被銷燬後是否觸發close事件,預設為true,即銷燬流會觸發close事件。 --autoDestroy:在結束流操作(即end()指定)以後是否自動使用destroy()銷燬流,預設為true。 //事件 dirin:當被寫滿的中間快取重新被清空後觸發該事件,但第一次write寫入之前也會觸發這個事件。 finish:在呼叫end()方法之後,並且所有資料都重新整理到底層系統之後觸發該事件 colose:當流及其任何底層資源已關閉時,觸發該事件,意味者所有流操作結束(如果寫入流使用emitClose建立,則始終觸發該事件) error:在寫入或管道資料時發生錯誤會觸發該事件 pipe:當可讀流上呼叫pipe方法將此寫入流新增到其目標集時,觸發該事件 unpipe:當可讀流通過unpipe()取消該寫入流的源流,觸發該事件,也就是從可讀流的目標集合中刪除這個目標時觸發事件
2.2可讀流:
可讀流的兩種模式:流動模式、暫停模式。
--流動模式:基於data事件實現的不中斷式的提供資料,資料從源到中間快取,再到資料讀出到消費層不做任何控制處理,資料來源源不斷流向消費層。
--暫停模式:基於pause事件實現可中斷式的提供資料模式,資料流中間採用pause()暫停/pause事件/resume()重啟控制可讀流,資料傳輸過程中根據需要可控的往消費層傳輸。
可讀流的三種狀態:readableFlowing-->null(不提供資料狀態)、false(暫停提供資料狀態)、true(持續提供資料狀態)。
--不提供資料狀態:通過繫結data事件、pipe()、resume()、readableFlowing=true,可以使可讀流開始主動觸發資料讀出事件。
--暫停提供資料狀態:該狀態由pause()、readableFlowing=false、unpipe()觸發,該狀態只阻止中間快取向資料消費層提供資料,並不停止資料來源向中間快取寫入資料。
--持續提供資料狀態:即由data事件、pipe()、resume()、readableFlowing=true實現資料持續流向消費層,該狀態下流持續通過data事件、read()向消費層提供資料。
關於可讀流在第一節中有詳細的示例,這裡就不重複了,下面介紹一下可讀流相關事件、方法、配置屬性:
//方法 destroy():銷燬流,用於強制關閉流操作。如果此時中間快取區有未向外讀出的資料也不會再讀出了。 isPaused():用於判斷當前流是否處於暫停狀態,返回boolean值。 pause():用於暫停當前流操作,但不會暫停底層向中間快取區寫入資料。 pipe():管道,該方法用於將可寫流繫結到當前的可讀流上, read():用於向外讀取指定大小資料塊的方法,一般用於暫停模式,不建議與流動模式的data事件讀取資料混合使用。 resume():重啟當前流操作,用於重啟被pause()暫停的流操作。 setEncoding():用於設定讀取資料的編碼操作。 unpipe():用於分離先前使用pipe()繫結的可寫流。 unshift():將已讀取出來的資料推回到中間快取區中,用於解決已經被消耗的資料重新被其他資料消費者使用。 wrap():用於相容舊的nodejs可讀流,建立使用舊流作為其資料來源的Readable流。 readable[Symbol.asyncIterator]():返回<AsyncIterator>以完全消費流,流將以大小等於highWaterMark的讀取塊往外讀取。 iterator():返回<AsyncIterator>消費流。用於實現迭代方式往外提供資料,該方法用於迭代器的方式為使用者提供取消流銷燬的選項。(16版以上新增) //類上的靜態方法(16版以上新增) map(fn):返回<Readable>使用函式fn對映的流(promise) filter(fn):此方法允許過濾流,對於流中每個條目,都會呼叫fn函式,如果其返回真值,則該條目將被傳給結果流。(可以實現promise) new Readable([options):例項化可讀流。這裡重點來關注options引數,該引數的屬性可以用來定製流的具體操作: --highWaterMark:用於配置可讀流中間快取容量,預設16KB。 --encoding:用於配置快取區中使用指定的編碼解碼為字串,預設null。 --objectMode:是否表現為物件流,預設false。如果該配置為false,read(n)返回單個大小為n的Buffer。 --emitClose:被銷燬後是否觸發close事件,預設true。 --autoDestroy:是否在流操作結束後自動呼叫destroy()銷燬流,預設為true。 --signal:表示可能取消的訊號 //事件 close:當流及其任何底層資源已關閉時,觸發該事件,意味者流操作結束。如果寫入流基於emitClose建立的,則始終觸發該事件。也就是當end事件觸發後並且快取區的資料全部讀出後觸發該事件。 data:當流將資料塊移交給消費者時,則觸發該事件。也就是當快取區向外提供資料時通過觸發該事件來實現。 end:當流中沒有更多資料可供消費,則觸發該事件。也就是當read()向快取區寫入null時觸發該事件。 error:如果底層流內部故障無法生成資料時,或者當流嘗試推送無效資料塊時,可能觸發該事件。 pause:通過pause()暫停流動模式時,觸發該事件,任何可用資料都將保留在內部緩衝區。 readable:當有從流中讀取的資料或以到達末尾時,觸發該事件。也就是每次向中間快取區快取資料後觸發該事件,快取區沒有資料觸發該事件是為了實現read()向快取區寫入null來觸發end事件,實現結束可讀流操作。 resume:當呼叫resume()重啟從中間快取中向外提供資料時觸發該事件
三、雙工流與轉換流
3.1雙工流Duplex:
所謂雙工流就是在一個流上同時實現可讀流和可寫流。
//雙工流 const {Duplex} = require('stream'); let buf = Buffer.alloc(10,0,'utf-8');//開闢一個記憶體空間,作為雙工流的底層資料儲存裝置 buf.write("abc");//初識化一些資料 class MyDuplex extends Duplex{ constructor(options){ super(options); this.buf = buf; this.offset = 3; //設定寫入偏移到初識化資料的末尾處 this.readStart = 0; //初識化可讀流的起始位置 this.readEnd = 1; //初識化可讀流的結束位置 } _write(chunk, en, done){ process.nextTick(()=>{ this.buf.write(chunk.toString(),this.offset); this.offset += chunk.length; done(); }); } _read(){ let data = this.readStart > this.buf.length || this.readStart >= this.offset ? null : this.buf.subarray(this.readStart,this.readEnd); this.push(data,'utf-8'); this.readStart ++; this.readEnd ++; } }; let myDuplex = new MyDuplex(); //測試讀資料 // myDuplex.on('data',(chunk)=>{ // console.log(chunk); // }); //測試寫入再讀資料 myDuplex.write("defghijk",()=>{ myDuplex.on('data',(chunk)=>{ console.log(chunk.toString()); }); });
雙工流的特點就是sources、destination共用一個資源,雖然它底層依然管理兩個中間快取(讀取流的中間快取區、寫入流的中間快取區),但它不能兩個操作同時進行,因為任意一個寫入流還是讀取流關閉後就不能重啟,而且中途有寫有讀那就不是流模式了,違背了流源源不斷的往一個方向傳輸資料的原則。所以雙工流本質上就是實現讀寫流兩個操作各消費一次,也就是說要麼執行完讀再執行寫,要麼執行完寫再執行讀。
最典型的雙工流具體功能實現,在nodejs中就是TCP套位元組socket的接收與響應資料操作,程式先將網路資源全部讀出然後再將響應資料全部寫入,
3.2轉換流Transform:
轉換流是在雙工流的基礎上實現的,基於先執行寫入流,再執行讀取流的方式,實現將寫入的資料在內部做一些轉換,再將轉換過的資料通過讀取流的data事件響應回來。
轉換流不會單獨執行資料讀出,而是必須先執行寫入操作,才會觸發data事件將轉換後的資料傳輸出來。
1 const {Transform} = require('stream'); 2 let buf = Buffer.alloc(10,0,'utf-8'); 3 class MyTransform extends Transform{ 4 constructor(options){ 5 super(options); 6 this.buf = buf; 7 this.offset = 0; //初識化寫入流的ishi位置 8 } 9 _transform(chunk, en, cb){ 10 let str = chunk.toString().toUpperCase(); 11 process.nextTick(()=>{ 12 this.buf.write(str,this.offset); 13 this.offset += chunk.length; 14 }); 15 this.push(str); 16 cb(); 17 } 18 } 19 20 let myTransform = new MyTransform(); 21 myTransform.on('data',(chunk)=>{ 22 console.log(chunk.toString()); //ABCDEFG 23 }); 24 myTransform.write('abcdefg');
基於轉換流,nodejs內建實現了壓縮流(zlib)和加密流(crypto)。
四、背壓機制與檔案流模擬實現
4.1背壓機制:
在很多業務中都有這樣的需求,先從一個地方讀出一些資料,然後再將這些資料寫入到另一個地方,典型的操作就是檔案拷貝。由於資料讀取速度遠遠大於資料寫入速度,這就可能導致讀出到快取中的資料超過設定快取限制的最大值,從而造成記憶體溢位報錯、垃圾回收器(GC)頻繁呼叫、導致其他程式變慢,為了解決這種需求可能存在的潛在風險提出了一種解決方案,這個解決方案就是背壓機制。
背壓機制的原理:
基於流的I/O操作背壓機制,在nodejs的實現中就是可讀流的pipe(),其內部同時管理兩個流模型:可讀流、可寫流,可讀流的目的地(destination)是可寫流的資料來源(sources);可讀流有自己的資料來源(sources),可寫流有自己的流目的地(destination)。在這兩個流模型中都有自己獨立的中間快取區,當可讀流的讀取速度大於可寫流的寫入速度時,可讀流會先將來不及寫入目的地(destination)的資料快取到可寫流的中間快取空間中,當可讀流的中間快取空間也寫滿以後就會通知可讀流暫停向可寫流提供資料,這時候可讀流就會將自身從自己的資料來源中讀出的資料寫到可讀流自己的中間快取空間中。
當可寫流將自己中間快取中的資料全部寫入到自己的目的地以後,可寫流又開始通知可讀流向自己提供資料。如果這個過程中可讀流自身的中間快取空間都寫滿了,可讀流還沒等到可寫流通知向它提供資料,這時候可讀流會停止從自己的資料來源中讀取資料的操作,直到等到可寫流通知向它提供資料,可讀流將自身中間快取中的資料讀出傳輸給可寫流,直到可讀流將自身中間快取中的資料全部清空然後,然後可讀流再開始從自己的資料來源中讀取資料提供給可寫流。
迴圈以上操作,直到資料傳輸完成,這就是流操作的背壓機制。上面的描述看起會比較複雜,下面提供一個簡單的流程圖:
下面是基於EventEmitter和 fs.read/fs.write模擬實現的檔案流原始碼:
1 //自定義檔案讀取流readFile.js 2 const fs = require('fs'); 3 const EventEmitter = require('events').EventEmitter; 4 const {Queue} = require('./linked.js'); 5 6 class MyFileReadStream extends EventEmitter { 7 constructor(path, optons={}){ 8 super(); 9 this.path = path; //繫結要讀取的檔案 10 this.flags = optons.flags || 'r'; //檔案件操作模式:讀取模式 11 this.mode = optons.mode || 438; //檔案操作許可權 12 this.autoClose = optons.autoClose || true; //是否關閉(銷燬)當前檔案 13 this.start = optons.start || 0; //開始讀取的位置 14 this.end = optons.end; //結束讀取的位置 15 this.highWaterMark = optons.highWaterMark || 64 * 1024; //可讀流最大可快取的位元組數 16 17 this.readOffset = 0; //從什麼位置讀出 18 this.cache = new Queue(); //用於快取資料的佇列 19 this.readableFlowing = null; //當前流的狀態:null(不提供資料狀態)、true(持續輸出資料)、false(暫停輸出資料) 20 21 this.open(); 22 this.on('newListener',(type)=>{ //當在MyFileReadStream例項上新增監聽事件時觸發該事件,並將當前監聽事件的名稱傳遞給回撥 23 if(type === 'data'){ 24 this.read(); 25 } 26 }); 27 } 28 open(){ 29 //原生open方法開啟指定位置上的檔案 30 fs.open(this.path, this.flags, this.mode, (err, fd)=>{ 31 if(err){ 32 this.emit('error', err); 33 } 34 this.fd = fd; 35 this.readableFlowing = true; //當檔案正常開啟時將流的狀態置為持續資料資料的狀態 36 this.emit('open',fd); 37 }); 38 } 39 read(){ 40 //負責將要讀取的資料通過data事件輸出 41 if(typeof this.fd !== 'number'){ 42 //當第一次觸發事件 43 return this.once('open',this.read); //在通過open事件獲取到檔案識別符號後,重新呼叫資料讀取操作 44 } 45 if(!this.readableFlowing){ //當流處於暫停模式時,需要通過resume()重啟流 46 return ; 47 } 48 if(this.cache.size > 0){ //如果可讀流的中間快取中有資料,就從中間快取中拿資料通過data事件響應出去 49 this.emit("data",this.cache.deQueue()); 50 return this.read(); 51 } 52 //實現資料讀取操作: 53 let buf = Buffer.alloc(this.highWaterMark); 54 let howMuchToRead ; 55 if(this.end){ 56 howMuchToRead = Math.min(this.end - this.readOffset + 1, this.highWaterMark); 57 }else{ 58 howMuchToRead = this.highWaterMark; 59 } 60 //呼叫原生檔案讀取方法fs.read獲取資料 61 fs.read(this.fd, buf, 0, howMuchToRead, this.readOffset,(err,readBytes)=>{ 62 if(readBytes){ 63 this.readOffset += readBytes; 64 //這裡需要判斷是否通過data事件輸出資料,還是將資料快取到中間快取中 65 if(this.readableFlowing){ 66 //當流處於資料持續輸出狀態,直接將資料通過data事件輸出,並繼續呼叫read()讀取資料 67 this.emit('data',buf.slice(0,readBytes)); 68 this.read(); 69 }else{ 70 //當流處於資料暫停輸出狀態,將資料快取到中間快取(即快取佇列) 71 this.cache.enQueue(buf.slice(0,readBytes)); 72 } 73 }else{ 74 //當沒有資料可讀時觸發end事件,並關閉檔案識別符號 75 this.emit('end'); 76 this.close(); 77 } 78 }); 79 } 80 close(){ 81 //關閉檔案流操作 82 fs.close(this.fd,()=>{ 83 this.autoClose= false; //表示檔案已被關閉 84 this.emit('close'); 85 }); 86 } 87 pause(){ 88 //暫停流操作 89 this.readableFlowing = false; 90 } 91 resume(){ 92 //重啟流操作 93 this.readableFlowing = true; 94 this.read(); 95 } 96 pipe(ws){ 97 this.on('data',(data)=>{ 98 let flag = ws.write(data); 99 if(!flag){ 100 this.pause(); 101 } 102 }); 103 this.on("end",()=>{ 104 ws.end(); 105 }); 106 this.on("close",()=>{ 107 console.log("檔案讀取完成,正常關閉"); 108 }); 109 ws.on('drain',()=>{ 110 this.resume(); 111 }); 112 } 113 } 114 115 module.exports = MyFileReadStream;
1 //自定檔案寫入流writeFile.js 2 const fs = require('fs'); 3 const {EventEmitter} = require('events'); 4 const {Queue} = require('./linked.js'); 5 6 class MyFileWriteStream extends EventEmitter{ 7 constructor(path,options={}){ 8 super(); 9 this.path = path; 10 this.flags = options.flags || 'w'; 11 this.mode = options.mode || 438; 12 this.autoClose = options.autoClose || true; 13 this.start = options.start || 0; 14 this.encoding = options.encoding || 'utf8'; 15 this.highWaterMark = options.highWaterMark || 16*1024; 16 17 this.writeoffset = this.start; //從什麼位置寫入 18 this.writing = false; //是否正在寫入 19 this.writLen = 0; //當前MyWriteStream例項要寫入的位元組長度,也就在當的寫入操作中間快取上有多少個位元組的資料 20 this.needDrain = false; //中間快取是否排空了,如果排空了就觸發drain事件 21 this.cache = new Queue(); 22 this.upstream = false; //上游狀態是否關閉。比如由上可讀流通過pipe傳輸到當前可寫流上的資料已經到達末尾,可讀流讀取的檔案要關閉被開啟的檔案時,通過可寫流的end()通知可寫流它已經關閉 23 24 this.open(); 25 }; 26 open(){ 27 //原生fs.open 28 fs.open(this.path, this.flags, (err, fd)=>{ 29 if(err){ 30 this.emit('error', err); 31 } 32 //正常開啟檔案 33 this.fd = fd; 34 this.emit('open', fd); 35 }); 36 }; 37 //寫入操作的對外介面 38 write(chunk, encoding, cb){ 39 if(chunk !== null){ 40 chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); 41 this.writLen += chunk.length; //累計當前要寫入的位元組數(即表示寫入流快取中的資料位元組長度) 42 let flag = this.writLen < this.highWaterMark; //檢查快取中的資料節點長度是否超出流的最大快取空間。flag為tuer時表示快取沒有超出,反之則超出了 43 this.needDrain = !flag; 44 if(this.writing){ 45 //當前正在執行寫入,即將內容快取到佇列 46 this.cache.enQueue({ 47 chunk:chunk, 48 encoding:encoding, 49 cb:cb 50 }); 51 }else{ 52 //當前不是正在寫入,即立可以即執行寫入操作 53 this._write(chunk,encoding,()=>{ 54 cb && cb(); //執行寫入回撥 55 //清空排隊的內容 56 this._clearBuffer(); 57 }); 58 } 59 return flag; 60 }else{ 61 cb && cb(); 62 //清空排隊的內容 63 this._clearBuffer(); 64 } 65 }; 66 //實現寫入 67 _write(chunk, encoding, cb){ 68 this.writing = true; //將寫入狀態置為正在寫入 69 if(typeof this.fd !== 'number'){ 70 //第一次寫入時,writeFile 物件上沒有fd,這事因為_write是同步任務,而this.fd獲取檔案描述符的邏輯在open()執行後的非同步回撥上 71 //在open()的非同步回撥任務中繫結this.fd以後會觸發'open'事件,也就是說這個當'open'事件觸發時就會fd了 72 //所以在第一次寫入時在'open'事件上新增一個一次性的任務,在這個任務內真正的實現寫入操作。 73 return this.once('open',()=>{ 74 this._write(chunk, encoding, cb); 75 }); 76 } 77 fs.write(this.fd, chunk, this.start, chunk.length, this.writeoffset,(err,writen)=>{ 78 this.writeoffset += writen; //將寫入的位元組數累計到寫入位置上,為下一次寫入提供寫入位置定位 79 this.writLen -= writen; //將快取位元組數記錄減掉寫入的位元組數 80 this.writing = false; 81 cb && cb(); 82 }); 83 }; 84 _clearBuffer(){ 85 let data = this.cache.deQueue(); 86 if(data){ 87 //當有資料時持續迭代連結串列節點中的快取,從而實現將快取中的資料寫入到磁碟中 88 this._write(data.element.chunk, data.element.encoding, ()=>{ 89 data.element.cb && data.element.cb(); 90 this._clearBuffer(); 91 }); 92 }else{ 93 //當快取中沒有資料 94 if(this.upstream){ //當上遊呼叫可寫流的程式已經關閉,說明不會再有資料傳入,這時候應該關閉當前的可寫流 95 return this.close(); 96 } 97 if(this.needDrain){ 98 this.needDrain = false; 99 this.emit('drain'); 100 } 101 } 102 } 103 end(){ 104 this.write(null); 105 this.upstream = true; 106 } 107 close(){ 108 fs.close(this.fd,()=>{ 109 this.emit("close"); 110 console.log("檔案寫入完成,正常關閉") 111 }); 112 } 113 } 114 module.exports = MyFileWriteStream;
模擬流操作需要一個實現連結串列結構的基礎佇列模組,下面是具體實現原始碼:
1 //連結串列結構 2 //node節點 + head + null 3 //head頭 -> null 4 //size連結串列長度 5 //next下一個節點 element 6 //增加、刪除、修改、查詢、清空 7 8 //構造節點 9 class Node{ 10 constructor(element, next){ 11 this.element = element; 12 this.next = next; 13 } 14 } 15 //構造連結串列 16 class LinkedList{ 17 constructor(head, size){ 18 this.head = null; 19 this.size = 0; 20 } 21 //獲取指定節點 22 _getNode(index){ 23 if(index < 0 || index >= this.size){ 24 throw new Error('cross the border'); //丟擲越界錯誤 25 } 26 let currentNode = this.head; 27 for(let i = 0; i < index; i++){ 28 currentNode = currentNode.next; 29 } 30 return currentNode; 31 } 32 //新增節點:可以在指定的位置新增(即插入節點:傳入索引+節點元素兩個引數),如果只傳入節點元素就預設在連結串列的末尾新增節點 33 add(index, element){ 34 if(arguments.length === 1){ 35 element = index; 36 index = this.size; //當沒有傳入插入位置時,將插入位置預設未連結串列的末尾 37 } 38 if(index < 0 || index > this.size){ 39 throw new Error('cross the border'); //丟擲越界錯誤 40 } 41 if(index === 0){ 42 let head = this.head; 43 this.head = new Node(element,head); 44 }else{ 45 let prevNode = this._getNode(index -1); 46 prevNode.next = new Node(element,prevNode.next); 47 } 48 this.size++; 49 } 50 //刪除節點 51 remove(index){ 52 let rmNode = null; //刪除的節點 53 if(index === 0){ 54 rmNode = this.head; 55 if(!rmNode){ 56 return undefined; 57 } 58 this.head = rmNode.next; 59 }else{ 60 let prevNode = this._getNode(index-1); 61 rmNode = prevNode.next; 62 prevNode.next = prevNode.next.next; 63 } 64 this.size --; 65 return rmNode; 66 } 67 //修改連結串列節點 68 set(index,element){ 69 let node = this._getNode(index); 70 node.element = element; 71 } 72 //查詢連結串列節點 73 get(index){ 74 return this._getNode(index); 75 } 76 //清空連結串列 77 clear(){ 78 this.head = null; 79 this.size = 0; 80 } 81 } 82 83 //構造連結串列佇列 84 class Queue{ 85 constructor(){ 86 this.linkedList = new LinkedList(); 87 } 88 //入佇列 89 enQueue(data){ 90 this.linkedList.add(data); 91 } 92 //出佇列 93 deQueue(){ 94 return this.linkedList.remove(0); 95 } 96 } 97 98 module.exports = { 99 Node:Node, 100 LinkedList:LinkedList, 101 Queue:Queue 102 };
最後測試程式碼:
1 const fs = require('fs'); 2 const myFileReadStream = require('./readFile.js'); 3 const myWriteStream = require('./writeFile.js'); 4 5 // const rs = fs.createReadStream('./筆記(副本).txt',{ 6 // highWaterMark:4 7 // }); 8 // const rs = fs.createReadStream('./tst.txt',{ 9 // highWaterMark:1 10 // }); 11 const rs = new myFileReadStream('./筆記(副本).txt',{ 12 highWaterMark:4 13 }); 14 15 // const ws = fs.createWriteStream('./筆記.txt',{ 16 // highWaterMark:12 17 // }); 18 const ws = new myWriteStream('./筆記.txt',{ 19 highWaterMark:12 20 }); 21 rs.pipe(ws);
測試的txt檔案可以自己修改,
自定義實現的可讀流和可寫流可以與node原生的檔案流API實現互動,
可以基於註釋的測試程式碼測試:
比如使用fs的createReadStream與自定義的可寫流模組實現檔案流操作
也可以使用fs的createWriteStream與自定義的可讀流模組實現檔案流操作