導讀:由於事件流是基於EventEmitter例項,並且也是通過fs模組來建立的,所以在學習事件流之前,最好先了解一下發布訂閱和fs檔案的操作,才可以對原始碼有更好的理解。
為什麼需要流
在node中讀取檔案的方式有來兩種,一個是利用fs模組,一個是利用流來讀取。如果讀取小檔案,我們可以使用fs讀取,fs讀取檔案的時候,是將檔案一次性讀取到本地記憶體。而如果讀取一個大檔案,一次性讀取會佔用大量記憶體,效率很低,這個時候需要用流來讀取。流是將資料分割段,一段一段的讀取,效率很高。
流的特點
流也是操作檔案的一種方式,流的特點有序(有順序的,順序不能打亂),有方向的
對檔案操作用的也是fs模組,可以用fs模組的方法來建立流
所有的流都是 EventEmitter 的例項。也就是流具有事件的能力,可以通過發射事件來反饋流的狀態。這樣我們就可以註冊監聽流的事件,來達到我們的目的。也就是我們訂閱了流的事件,這個事件觸發時,流會通知我,然後我就可以做相應的操作了。
流的分類
Readable Stream :可讀資料流
Writeable Stream :可寫資料流
Duplex Stream :雙向資料流,可以同時讀和寫
Transform Stream: 轉換資料流,可讀可寫,同時可以轉換(處理)資料
這裡我們介紹兩種:可讀流,可寫流
可讀流
可讀流的兩種模式
可讀流有兩種模式之一:flowing 和 paused 。
在 flowing 模式下, 可讀流自動從系統底層讀取資料,並通過 EventEmitter 介面的事件儘快將資料提供給應用。
在 paused 模式下,必須顯式呼叫 stream.read() 方法來從流中讀取資料片段。
兩種模式之間的轉換
所有初始工作模式為 paused 的 Readable 流,可以通過下面三種途徑切換到 flowing 模式:
-
監聽 `data` 事件。
-
呼叫 stream.resume() 方法。
-
呼叫 stream.pipe() 方法將資料傳送到 Writable。
可讀流可以通過下面途徑切換到 paused 模式:
-
如果不存在管道目標(pipe destination),可以通過呼叫 stream.pause() 方法實現。
-
如果存在管道目標,可以通過取消 `data` 事件監聽,並呼叫 stream.unpipe() 方法移除所有管道目標來實現。
注意:如果可讀流切換到流動模式,並且沒有消費者處理流中的資料,這些資料將會丟失。
常用的事件
- `open`事件
- `close`事件
- `error`事件
- `data`事件 – 資料正在傳遞時,觸發該事件(以chunk資料塊為物件)
- `end`事件 – 資料傳遞完成後,會觸發該事件。
常用的方法
- rs.pause()暫停
- rs.resume()恢復
所有這些事件都可以在官方API文件中找到例子。我們可以監聽流的這些事件,來完成相應操作。通過以下程式碼來說明:
1.txt檔案,內容如下(這是我們要操作的檔案)
1234567890
複製程式碼
js檔案:建立可讀流和常用方法
let fs = require(`fs`);
let path = require(`path`);
// let ReadStream = require(`./ReadStream`);
// 1、建立可讀流(返回一個可讀流物件) 默是非流動模式,不會讀取資料
// 讀取的時候預設讀 預設64k,encoding 讀取預設都是buffer
let rs = fs.createReadStream(path.join(__dirname,`1.txt`),{
// let rs = new ReadStream(`./2.txt`,{
flags:`r`, // 檔案的操作是讀取操作
encoding:`utf8`,// 預設是null null代表的是buffer
autoClose:true,// 讀取完畢後自動關閉
highWaterMark:3,// 預設是64k 64*1024b
start:0,
end:8 // 包前又包後 從0到8
});
// 2、可讀流常用方法
// 設定讀取的編碼格式 等價於encoding:`utf8`(建立可讀流不寫配置時,需要加上)
rs.setEncoding(`utf8`);
// 操作的是一個檔案,首先需要open開啟這個檔案
rs.on(`open`,function(){
console.log(`檔案開啟了`);
});
// 檔案關閉觸發close事件
rs.on(`close`,function(){
console.log(`關閉`);
});
// 出現錯誤
rs.on(`error`,function(){
console.log(err);
});
// 預設情況下是非流動模式,通過監聽 `data`事件,使暫停模式變成流動模式,流動模式會
// 瘋狂的觸發data事件,直到讀取完畢
rs.on(`data`,function(data){
console.log(data);
});
// 讀完後觸發end事件
rs.on(`end`,function(){
console.log(`end`);
});
複製程式碼
// 輸出
檔案開啟了
123
456
789
end
關閉
複製程式碼
在以上的程式碼基礎上新增暫停,恢復事件方法
rs.on(`open`,function(){
console.log(`檔案開啟了`);
});
rs.on(`close`,function(){
console.log(`關閉`);
});
rs.on(`error`,function(){
console.log(err);
});
rs.on(`data`,function(data){
console.log(data);
rs.pause(); // 暫停方法 表示暫停讀取,暫停data事件觸發
});
setTimeout(function(){
rs.resume(); // 恢復data事件的觸發,繼續讀取, 變為流動模式
},3000);
rs.on(`end`,function(){
console.log(`end`);
});
複製程式碼
這裡輸出123後會暫停三秒,等事件再次變為流動模式後,才會輸出456然後暫停,這裡並沒有將檔案讀取完畢,所以不會觸發end和close事件。
// 輸出
檔案開啟了
123
456
複製程式碼
將setTimeout改成setInterval,每隔三秒恢復流動模式,輸出值,直到檔案讀取完畢。
// 輸出
檔案開啟了
123
456
789
end
關閉
複製程式碼
實現一個可讀流
// ReadStream.js
let fs = require(`fs`); // 引入fs核心模組
let EventEmitter = require(`events`); // 需要依賴事件發射
// 1 建立例項繼承EventEmitter
class ReadStream extends EventEmitter{
constructor(path,options={}){
super();
this.path = path;
this.highWaterMark = options.highWaterMark || 64*1024;
this.autoClose = options.autoClose || true;
this.start = options.start || 0;
this.pos = this.start; // pos會隨著讀取的位置改變
this.end = options.end || null; // null表示沒傳遞
this.encoding = options.encoding || null;
this.flags = options.flags || `r`;
// 一些引數的問題
// 6、非流動模式 null是暫停模式
this.flowing = null;
// 7、看是否監聽了data事件,如果監聽了 就要變成流動模式(同步)
this.on(`newListener`,(eventName,callback)=>{
if(eventName === `data`){
// 相當於使用者監聽了data事件
this.flowing = true;
// 監聽了 就去讀
this.read(); // 去讀內容了
}
})
// 9、要建立一個buffer,把內容讀到這個buffer裡,讀完以後再發射出去,這個buffer就是要
// 一次讀highWaterMark個
this.buffer = Buffer.alloc(this.highWaterMark);
// 9、pos讀取到的位置是可變的,start是不變的
this.pos = this.start;
//3、 只要讀流就要先開啟檔案,開啟檔案的目的是為了獲取檔案描述符fd,有了這描述符才
// 能去讀取內容
// this指的是例項,這是一個例項上的方法 open完了需要去監聽data事件才會觸發流動模式
this.open();
}
// 8、open是非同步的,newListener是同步的,read同步,同步的先執行,所以第一次呼叫read方法時還
// 沒有獲取fd 所以不能直接讀
read(){
// console.log(this.fd) // 先列印的undefind 再去觸發的open
if(typeof this.fd !== `number`){ // 通過fd判斷此時檔案還沒開啟
return this.once(`open`,() => this.read());
// 當檔案真正開啟後會觸發open事件,此時fd肯定拿到了,拿到以後再去執行read方法
//(見10)
}
// 10、此時有fd了 開始讀取檔案 第二個引數(見9)
// buffer裡應該填多少? 計算howMuchToRead // 123 4
let howMuchToRead = this.end?Math.min(this.highWaterMark,this.end-this.pos+1)
:this.highWaterMark;
fs.read(this.fd,this.buffer,0,howMuchToRead,this.pos,(err,bytesRead)=>{
if(bytesRead>0){
// 讀到了多少個 累加
this.pos += bytesRead;
// 這裡的this.buffer長度是3 需要擷取真實讀到的bytesRead長度
let data =this.encoding?this.buffer.slice(0,bytesRead).
toString(this.encoding):this.buffer.slice(0,bytesRead);
this.emit(`data`,data);
// 當讀取的位置 大於了末尾 就是讀取完畢了
if(this.pos > this.end){
this.emit(`end`);
this.destroy();
}
if(this.flowing){ //流動模式繼續觸發
this.read();
}
}else{
this.emit(`end`);
this.destroy();
}
});
}
// 12 恢復
resume(){
this.flowing = true;
this.read();
}
// 11 暫停
pause(){
this.flowing = false
}
// 5、銷燬關閉檔案
destroy(){
// 通過fd判斷檔案是否開啟過,開啟了就關閉檔案,觸發close事件
if(typeof this.fd === `number`){
fs.close(this.fd,()=>{
this.emit(`close`);
});
return
}
this.emit(`close`);
}
// 4、 開啟檔案
open(){
fs.open(this.path,this.flags,(err,fd)=>{
// fd標識的就是當前this.path這個檔案,從3開始(number型別)
if(err){
if(this.autoClose){ // 是否自動關閉,如果需要自動關閉我再去銷燬fd
this.destroy(); // 銷燬(關閉檔案,觸發關閉事件)
}
this.emit(`error`,err); // 如果有錯誤觸發error事件
return;
}
this.fd = fd; // 沒有錯誤,將fd檔案描述符掛載到當前例項上
this.emit(`open`,this.fd);// 檔案開啟成功後監聽open事件,觸發檔案開啟的方法
});
}
}
// 2
module.exports = ReadStream;
複製程式碼
可寫流
特點
可寫流有快取區的概念
第一次寫入是真的向檔案裡寫,第二次在寫入的時候是放到了快取區裡,過一會寫完了,會去清空快取區
寫入時會返回一個boolean型別,返回為false時不要再寫入了
當記憶體和正在寫入法人內容消耗完後 會觸發一個事件 drain
常用的方法
1,write(chunk[,encoding] [,callback])方法可以把資料寫入流中。
其中,chunk是待寫入的資料,是Buffer或String物件。這個引數是必須的,其它引數都是可選的。如果chunk是String物件,encoding可以用來指定字串的編碼格式,write會根據編碼格式將chunk解碼成位元組流再來寫入。callback是資料完全重新整理到流中時會執行的回撥函式。
write方法返回布林值,表示的並不是是否寫入,表示的是能否繼續寫,但是返回false也不會丟失,就是會把內容放到記憶體中
// 程式碼一
let fs = require(`fs`);
// 建立可寫流
let ws = fs.createWriteStream(`./1.txt`,{
flags:`w`, // 預設檔案不存在會建立
highWaterMark:3, // 設定當前快取區的大小,每次寫3個 預設寫是16k,讀是64k
encoding:`utf8`,// 檔案裡存放的都是二進位制
start:0, // 從哪開始寫
autoClose:true, // 自動關閉
mode:0o666,// 可讀可寫
});
// flag代表是否能繼續寫 寫的時候有個快取區的概念,先寫到快取區裡去,快取區沒滿是true,快取區滿
// 了就是flase,但是返回false也不會丟失,就是會把內容放到記憶體中
let flag = ws.write(1+``,`utf8`,()=>{}); // 非同步的方法
console.log(flag);
flag = ws.write(1+``,`utf8`,()=>{}); // 非同步的方法
console.log(flag);
flag = ws.write(1+``,`utf8`,()=>{}); // 非同步的方法
console.log(flag);
複製程式碼
// 輸出
true
true
false
複製程式碼
寫出的內容輸出到1.txt中(可寫流沒有會自動建立,有內容會清空,1.txt是自動建立的)
// 1.txt 輸出
111
複製程式碼
注意:最後一次當快取區滿了返回false時,資料並沒有丟失,最後還是輸出的三次=>111
2,end([chunk] [,encoding][,callback])方法可以用來結束一個可寫流。
它的三個引數都是可選的。chunk和encoding的含義與write方法類似。callback是一個可選的回撥,當你提供它時,它會被關聯到Writable的finish事件上,這樣當finish事件發射時它就會被呼叫。
當寫完觸發end事件後,就不能再繼續寫了
// 接著程式碼一的
ws.end(`ok`); // 這個ok也會寫到1.txt中去 當寫完後 就不能再繼續寫了
// ws.write(`123`); // 報錯 write after end
複製程式碼
// 1.txt 輸出
111ok
複製程式碼
常用的事件
1、drain事件:抽乾方法。只有當highWaterMark快取區填滿,滿了後被清空了才會觸發drain。(當可讀流配合可寫流可以用drain事件寫一個pipe方法)
let fs = require(`fs`);
// 建立可寫流
let ws = fs.createWriteStream(`./1.txt`,{
flags:`w`, // 預設檔案不存在會建立
highWaterMark:3, // 設定當前快取區的大小,每次寫3個 預設寫是16k,讀是64k
encoding:`utf8`,// 檔案裡存放的都是二進位制
start:0, // 從哪開始寫
autoClose:true, // 自動關閉
mode:0o666,// 可讀可寫
});
let flag = ws.write(1+``,`utf8`,()=>{}); // 非同步的方法
console.log(flag);
flag = ws.write(1+``,`utf8`,()=>{}); // 非同步的方法
console.log(flag);
flag = ws.write(1+``,`utf8`,()=>{}); // 非同步的方法
console.log(flag);
flag = ws.write(1+``,`utf8`,()=>{}); // 非同步的方法
console.log(flag);
ws.on(`drain`,function(){
console.log(`drain`)
})
複製程式碼
這裡快取區highWaterMark大小是3個位元組,所以第三個flag時快取區已經滿了,所以從第三個flag開始都是false,但是資料並沒有丟失,即1.txt輸出四個1,並且觸發抽乾事件。如果這裡的write方法只寫入了兩次,快取區並沒有滿,就不會觸發這個抽乾事件。
// 輸出
true
true
false
false
drain
複製程式碼
// 1.txt 輸出
1111
複製程式碼
注意這裡每個write方法裡的數字1只佔用1個位元組,如果是11就2個位元組,一個數字佔用1個位元組,如果是一個漢字就是3個位元組。 所以如果第一個write方法寫入三個1,那第一個flag就會是false
寫個抽乾例子
let fs = require(`fs`);
// 建立可寫流
let ws = fs.createWriteStream(`./1.txt`,{
// let ws = new WS(`./2.txt`,{
flags:`w`, // 預設檔案不存在會建立
highWaterMark:3, // 設定當前快取區的大小,每次寫3個 預設寫是16k,讀是64k
encoding:`utf8`,// 檔案裡存放的都是二進位制
start:0, // 從哪開始寫
autoClose:true, // 自動關閉
mode:0o666,// 可讀可寫
});
// 寫內容的時候 必須是字串或者buffer
for(var i = 0;i < 9;i++){
let flag = ws.write(i+``); // 一次寫一個字元
console.log(flag);
}
複製程式碼
這裡highWaterMark快取大小是3位元組,所以第三次寫入時就滿了,之後都返回false
// 輸出
true
true
false
false
false
false
false
false
false
複製程式碼
// 1.txt 輸出
012345678
複製程式碼
當一個流不處在 drain 的狀態, 對 write() 的呼叫會快取資料塊, 並且返回 false。 一旦所有當前所有快取的資料塊都排空了(被作業系統接受來進行輸出), 那麼 `drain` 事件就會被觸發,程式碼如下:
// let WS = require(`./WriteStreamS`);
let fs = require(`fs`);
// 建立可寫流
let ws = fs.createWriteStream(`./1.txt`,{
// let ws = new WS(`./2.txt`,{
flags:`w`, // 預設檔案不存在會建立
highWaterMark:3, // 設定當前快取區的大小,每次寫3個 預設寫是16k,讀是64k
encoding:`utf8`,// 檔案裡存放的都是二進位制
start:0, // 從哪開始寫
autoClose:true, // 自動關閉
mode:0o666,// 可讀可寫
});
let i = 9;
function write(){
let flag = true;
while (flag && i > 0) {
// 當返回的是false時 flag識別符號為false 就不寫了
flag = ws.write(--i + ``,`utf8`,()=>{console.log(`ok`)});
console.log(i+""+flag)
}
}
write();
// 以上程式碼輸出的是true,true,false,當highWaterMark填滿了就不會繼續輸出,1.txt輸出的是876
// drain只有當快取區充滿後 並且被消費後才會觸發
ws.on(`drain`,function () {
console.log(`抽乾`);
write();
})
複製程式碼
寫完一次(一次快取區是個highWaterMark 3個位元組),清空快取區,再繼續寫
// 輸出
8true
7true
6false
抽乾
5true
4true
3false
抽乾
2true
1true
0false
抽乾
複製程式碼
// 1.txt 輸出
876543210
複製程式碼
2、finish事件:在呼叫了 stream.end() 方法,且緩衝區資料都已經傳給底層系統之後, `finish` 事件將被觸發。
實現一個可寫流
// WriteStreamS.js
let fs = require(`fs`);
let EventEmitter = require(`events`);
// 1、建立例項繼承EventEmitter
class WriteStream extends EventEmitter{
constructor(path,options = {}){
super();
this.path = path;
this.flags = options.flags || `w`;
this.encoding = options.encoding || `utf8`;
this.start = options.start || 0;
this.pos = this.start;
this.mode = options.mode || 0o666;
this.autoClose = options.autoClose || true;
this.highWaterMark = options.highWaterMark || 16*1024 // 讀64 寫16
// 3、寫檔案的時候 需要的引數有哪些
// 3-1、可寫流有快取區,第一次寫是真的往檔案裡寫,之後如果正在寫入檔案的時候,內容要
// 寫入到快取區中,寫一點放快取裡一點,寫完之後再把快取裡的取出來再去寫,這個快取區
// 在原始碼中是一個連結串列,這裡我們為了方便,我們使用陣列去存內容
// 快取用簡單的陣列來模擬一下
this.cache = [];
// 3-2、標識 是否正在寫入 預設第一次就不是正在寫入
this.writing = false;
// 3-3、是否滿足觸發drain事件(只有當快取區滿了被清空後觸發)
this.needDrain = false;
// 3-4、維護一個變數 表示快取的長度
this.len = 0;
// 3-5、記錄寫入的位置,一次寫3個
this.pos = 0;
// 4、 寫流之前要先開啟(開啟檔案目的就是為了獲取fd描述符)
this.open(); // fd 非同步的 觸發一個open事件 當觸發open事件後fd肯定就存在了
}
// 9、遞迴清空快取區 [7,6,5,4,3,2,1]
clearBuffer(){
let buffer = this.cache.shift(); // 從陣列第一個開始取出來刪除
if(buffer){ // 快取裡有(陣列裡有值)
this._write(buffer.chunk,buffer.encoding,()=>{
buffer.callback();
this.clearBuffer();
});
}else{ // 快取裡沒有了
if(this.needDrain){ // 是否需要觸發drain事件 需要就發射drain事件
this.writing = false;// 告訴下次直接寫就可以了 不需要寫到記憶體中了
this.needDrain = false;
this.emit(`drain`)
}
}
}
// 8、把chunk往檔案裡寫
_write(chunk,encoding,clearBuffer){
// 因為write方法是同步呼叫,open非同步,先調write時,此時fd還沒有獲取到(fd是從3開始的
// 數字)所以先開啟open獲取到fd後再執行write操作
if(typeof this.fd != `number`){ // fd不是數字說明不存在,觸發open將_write方法存起來
return this.once(`open`, () => this._write(chunk,encoding,clearBuffer));
}
fs.write(this.fd,chunk,0,chunk.length,this.pos,(err,byteWritten)=>{
this.pos += byteWritten;
this.len -= byteWritten; // 每次寫入後就要在記憶體中減少一下
clearBuffer();// 第一次就寫完了 去清空快取區內容
})
}
// 7、客戶呼叫的是write方法去寫入內容
write(chunk,encoding=this.encoding,callback=()=>{}){
// 要判斷 chunk必須是buffer或者字串 為了統一,如果傳遞的是字串也要轉車buffer
// 這裡用encoding編碼把chunk轉換成buffer
chunk = Buffer.isBuffer(chunk)?chunk:Buffer.from(chunk,encoding);
this.len += chunk.length; // 維護快取的長度
let ret = this.len < this.highWaterMark;
if(!ret){
this.needDrain = true; // 快取區滿了 !ret返回true 觸發drain事件
}
// 判斷是否正在寫入 如果是正在寫入 就寫入到快取區中
if(this.writing){
this.cache.push({
chunk,
encoding,
callback
}); // [7,6,5,4,3,2,1]
}else{ // 第一次往檔案裡寫
this.writing = true;
this._write(chunk,encoding,()=>{
callback();
this.clearBuffer();
}); // 專門實現寫的方法,第一次寫入8 這裡的回撥用來清空快取區
}
return ret; //能不能繼續寫了,false表示下次寫的時候就要佔用更多記憶體了
}
// 6、銷燬
destroy(){
if(typeof this.fd !=`number`){
this.emit(`close`);
}else{
fs.close(this.fd,()=>{
this.emit(`close`);
})
}
}
// 5、開啟檔案
open(){
fs.open(this.path,this.flags,this.mode,(err,fd)=>{
if(err){
this.emit(`error`,err);
if(this.autoClose){
this.destroy(); // 如果自動關閉就銷燬檔案描述符
}
return;
}
this.fd = fd;
this.emit(`open`,this.fd);
});
}
}
// 2、匯出這個例項
module.exports = WriteStream;
複製程式碼
整個思路流程
原始碼中一開始就執行了一個open方法,這個open方法主要是為了開啟檔案,獲取到描述符fd,有描述符後才可以真正的去寫去讀,因為使用者不會等你開啟檔案後再去寫,他會直接開始呼叫write方法去寫,所以當使用者調write方法時,我們通過once觸發了open事件。(注意:在open方法中,釋出了一個open事件,後面通過on/once監聽這個事件去觸發open()方法並通過回撥函式去處理業務邏輯。這個是釋出訂閱中的知識點。)
write方法中有幾個引數:chunk,encoding,callback。我們要看一看chunk是不是buffer,如果是buffer我們就用buffer,因為寫的時候要寫buffer,不是buffer, 就轉換成buffer。通過this.len算出寫的內容長度,用這個長度和highWaterMark比,如果和highWaterMark相等了,就說明當前快取區滿了,ret就為false,(ret為false時就是快取區寫滿了,ret為trun時就是快取區還沒寫滿)這時候write方法將會通過ret返回一個布林型別。只有當快取區滿了時,也就是ret為false時,並且滿了被清空後(這裡通過clearBuffer去清空的),會去觸發抽乾事件。第一次開始寫就把this.writing變成true,表示正在寫入,之後開始寫的就往快取區裡寫(這裡快取區是陣列模擬的),第一次寫入時,我們通過_write這個方法去真正的往檔案裡去寫,當這個檔案被寫入以後,再去取快取
區裡的第一項,這裡通過clearBuffer方法去取陣列的第一個值往檔案裡去寫(逐步清空這個資料),當陣列裡第一個寫完之後就再去呼叫這個clearBuffer方法再去清空快取區,直
到快取區裡的值被清空完為止。當快取區裡值清空完了需要判斷是否要觸發抽乾事件,要觸發的話就通過emit釋出抽乾事件,使用者就可以用on監聽並通過回撥函式處理業務邏輯
。
這個_write方法每次寫的時候,我們就要把快取區大小減少(陣列長度減少),寫入的位置增加,並且把this.writing變成false(表示正在寫入),每次寫完後
,通過clearBuffer清空快取區。
結論:整個思路是圍繞第一次是真正往檔案裡去寫,寫之前就要通過open拿到fd,之後再寫入的資料就放到快取區裡,當快取區滿了被清空後去觸發抽乾事件。所以我們是寫滿快取區,清空快取區,觸發抽乾事件,這樣迴圈,直到資料被寫完畢。這裡需要注意的是,如果最後一次寫入的內容沒有寫滿快取區,最後一次就不會觸發抽乾事件。抽乾事件的觸發條件有兩個:highWaterMark快取區填滿,滿了後被清空了才會觸發