流(stream):可讀流篇

MoTong發表於2018-04-06

node 可讀流

流是Node.js最重要的組成和設計模式之一,同時也是最容易讓人產生誤解的地方。流,不僅因為其在技術層面表現出的良好效能和高效率,更因為他的優雅,以及能完美契合Node.js的變成思想。

流為什麼很重要

我們都知道,Node.js是以事件為基礎的,實時處理是處理I/O操作最高效的方法,儘快接收輸入內容,並經過程式處理儘快輸出結果。

那什麼是流呢?

流是一組有序的,有起點和終點的位元組資料傳輸手段 它不關心檔案的整體內容,只關注是否從檔案中讀到了資料,以及讀到資料之後的處理 流是一個抽象介面,被 Node 中的很多物件所實現。比如HTTP 伺服器request和response物件都是流。

從Node.js的核心模組開始,流是隨處可見的。比如,fs模組使用的createReadStream()方法讀取檔案,使用createWriteStream()來寫檔案,HTTP的request和response物件本質來說也是流。

流的分類:四種基本的流型別 每個流的例項都是stream模組提供的四個基本抽象類之一的實現:

  • Readable - 可讀的流 (例如 fs.createReadStream()).
  • Writable - 可寫的流 (例如 fs.createWriteStream()).
  • Duplex - 可讀寫的流 (例如 net.Socket).
  • Transform - 在讀寫過程中可以修改和變換資料的 Duplex 流 (例如 zlib.createDeflate()). 實際上,流可以產生好幾類事件,比如在可讀流完成讀取之後會產生end事件,或者在發生錯誤時產生error事件。

流中的資料有兩種模式,二進位制模式和物件模式.

二進位制模式, 每個分塊都是buffer或者string物件. 物件模式, 流內部處理的是一系列普通物件.

所有使用 Node.js API 建立的流物件都只能操作 strings 和 Buffer物件。但是,通過一些第三方流的實現,你依然能夠處理其它型別的 JavaScript 值 (除了 null,它在流處理中有特殊意義)。 這些流被認為是工作在 “物件模式”(object mode)。 在建立流的例項時,可以通過 objectMode 選項使流的例項切換到物件模式。試圖將已經存在的流切換到物件模式是不安全的。

現在先從簡單入手,先聊聊可讀流~

可讀流

可讀流讀取資料的兩種模式:流動模式(flowing) 和暫停模式(paused)

在flowing模式下,從流中讀取資料的方式是給data事件新增一個監聽器,在該模式下資料不是通過read()來獲取。而是,只要流中的資料可讀,便會立即被推送到data事件的監聽器。 在 paused 模式下,必須顯式呼叫 stream.read() 方法來從流中讀取資料片段。 所有初始工作模式為 paused 的 Readable 流,可以通過下面三種途徑切換到 flowing 模式:

  • 監聽 'data' 事件
  • 呼叫 stream.resume() 方法
  • 呼叫 stream.pipe() 方法將資料傳送到 Writable

可讀流可以通過下面途徑切換到 paused 模式:

  • 如果不存在管道目標(pipe destination),可以通過呼叫 stream.pause() 方法實現。
  • 如果存在管道目標,可以通過取消 'data' 事件監聽,並呼叫 stream.unpipe() 方法移除所有管道目標來實現。

如果 Readable 切換到 flowing 模式,且沒有消費者處理流中的資料,這些資料將會丟失。 比如, 呼叫了 readable.resume() 方法卻沒有監聽 'data' 事件,或是取消了 'data' 事件監聽,就有可能出現這種情況。

實現可讀流

現在,先實現一個可讀流的例子。

/*
var rs = fs.createReadStream(path,[options]);
path讀取檔案的路徑
options
    flags開啟檔案要做的操作,預設為'r'
    encoding預設為null
    start開始讀取的索引位置
    end結束讀取的索引位置(包括結束位置)
    highWaterMark讀取快取區預設的大小64kb
*/


let fs = require('fs');
let path = require('path');

let rs = fs.createReadStream(path.join(__dirname,'檔名'),{// 返回的是一個可讀流物件
    flags:'r',// 檔案的操作是讀取操作
    encoding:'utf8',// 預設是null  null代表的是buffer
    autoClose:true,// 讀取完畢後自動關閉
    highWaterMark:3,//最高水位線 預設是64k  64*1024b
    start:0,//檔案是有序的,從哪讀到哪,可以自己定,從0開始讀
    end:9,//讀10個數,包前又包後,0-9,既有前面又有後面
});
//rs.setEncoding('utf8');//設定讀取的編碼格式,一般是utf8,與指定{encoding:'utf8'}效果相同,設定編碼
// 預設情況下 不會將檔案中的內容輸出
// 也就是說把這個流往這裡一放,它不會自動讀檔案
// 內部會先建立一個buffer先讀取3b,因為先把highWaterMark的值為3,這個檔案不會全讀了先放在這,什麼都不動。這種模式叫非流動模式 / 暫停模式

// 那什麼時候開始真正工作呢?你需要幹一件事。這個流是基於事件的,所以我們要先監聽一下事件,事件名是內建好的
// 操作的是一個檔案,需要把檔案開啟
rs.on('open',function(){//監聽open事件
    console.log('檔案開啟');
})
// 檔案有開啟就有關閉
rs.on('close',function(){
    console.log('檔案關閉');
});
// 因為事件都是非同步的,觸發的時候會觸發回撥函式,可能會有一些錯誤,錯誤需要捕獲
rs.on('error',function(err){//監聽error事件
    console.log(err);
});
rs.on('data',function(data){// 監聽了data事件,流就從暫停模式->流動模式,但流動模式會瘋狂觸發data事件,不停的觸發,知讀取取完畢
    console.log(data) // 讀取10個數
    rs.pause();  //暫停方法 表示暫停讀取,暫停data事件觸發
});
// 希望過一段時間繼續讀取
/*setTimeout(function(){
    rs.resume();//恢復data事件觸發,繼續讀取,變為流動模式,但又會觸發pause,所以這裡可以使用setInterval,每隔一段時間執行一次
},1000);*/
setInterval(function(){
    rs.resume();//恢復觸發data
},1000);
// 讀完以後,告知流結束,該事件會在讀完資料後被觸發
rs.on('end',function(){
    console.log('end')
})
複製程式碼

以上實現的結果是;

檔案開啟
hel
low
orl
d
end
檔案關閉
//注:我檔案的資料是helloworld!
複製程式碼

根據上面的案例,簡單實現一個可讀流的庫(功能尚未完善):

// 可讀流的實現
// 可讀流是基於事件的,所以先把事件的模組先引進來
let EventEmitter = require('events');
let fs = require('fs');

// 實現一個createReadStream的類,類繼承EventEmitter
class RreadStream extends EventEmitter {
    // 類需要路徑 物件 兩個引數
    constructor(path,options){
            // 繼承了必須幹一件事
            super();//固定的寫法
            this.path = path;
            this.flags = options.flags || 'r';//如果沒傳,就給預設引數
            this.autoClose = options.autoClose || true;//預設true
            this.highWaterMark = options.highWaterMark|| 64*1024;//預設是64k
            this.start = options.start||0;//預設開始是0
            this.end = options.end;
            this.encoding = options.encoding || null;//預設null


            // 預設情況下,要先開啟檔案開始讀,先寫一個開啟檔案的方法
            this.open();//開啟檔案 目的是為了獲取fd--檔案描述符,有了這個描述符,我們才能去讀取內容

            // 是否監聽了data事件,如果監聽了,就變成流動模式
            this.flowing = null; // null就是暫停模式

            // 要建立一個buffer 這個buffer就是要一次讀多少
            this.buffer = Buffer.alloc(this.highWaterMark);
        
            this.pos = this.start; // pos 表示讀取的位置 可變 start不變的

            // 如果有新事件繫結了,就會觸發newListener
            this.on('newListener',(eventName,callback)=>{
                if(eventName === 'data'){//如果事件名==data
                    // 相當於使用者監聽了data事件
                    this.flowing  = true;//變成流動模式
                    // 監聽了 就去讀
                    this.read(); // 讀內容了,需要實現一個read方法
                }
            })

            
    }
    open(){ 
        fs.open(this.path,this.flags,(err,fd)=>{//路徑,讀(開啟檔案的目的),回撥函式(箭頭函式防止this混亂),err表示讀檔案出錯了,fs表示檔案描述符
            if(err){
                this.emit('error',err);//檔案出錯,觸發error事件
                if(this.autoClose){ // 是否自動關閉--防止檔案出錯,不能訪問
                    // 如果檔案可以自動關閉,那檔案關掉再觸發
                    this.destroy();//銷燬掉,需要實現一個destroy方法
                }
                return;//報錯就不往下走
            }
            this.fd = fd; // 如果檔案沒有出錯,就把檔案掛在當前的例項上 即儲存檔案描述符
            this.emit('open'); // 檔案開啟成功了,監聽open
        });
    };
    // destroy方法
    destroy(){
        // 先判斷有沒有fd描述符 有則關閉檔案 觸發close事件
        if(typeof this.fd ==='number'){//有的話一定是number型別,true說明開啟過要銷燬
            fs.close(this.fd,()=>{//把檔案先關掉,再銷燬,觸發close
                this.emit('close');
            });
            return;
        }
        this.emit('close'); // 銷燬 , 需實現一個close方法
    };
    resume(){//恢復觸發data
        this.flowing = true;
        this.read();
    }
    pause(){//暫停方法 表示暫停讀取,暫停data事件觸發
        this.flowing = false;
    }
    read(){
        // 先判斷此時檔案有沒開啟
        if(typeof this.fd !== 'number'){//如果檔案沒開啟 ,觸發一次open事件
            // 檔案開啟 會觸發open事件,觸發事件後再執行read,此時fd肯定有了
            return this.once('open',()=>this.read());//open走一次,就觸發一次,之後不再走了,因為已經有檔案了。
            // 當觸發open,事件已經被觸發了,當觸發的時候,會走對應的回撥函式
        } 
        // 此時有fd了
        // 每次讀多少個。如果沒有end,隨便讀,讀多少個都可以。如果有highWaterMark,可能讀到某一個就停住了
        let howMuchToRead = this.end?Math.min(this.highWaterMark,this.end-this.pos+1):this.highWaterMark;//讀的時候,用結尾的進去當前位置
        // 比方說:想讀4個,但寫的是3,每次讀3個
        // 第一次讀123 下一次讀一個4
        fs.read(this.fd,this.buffer,0,howMuchToRead,this.pos,(err,bytesRead)=>{
            // 讀到了多少個 累加
            if(bytesRead>0){
                this.pos+= bytesRead;
                // 判斷有沒傳encoding,如果傳了,轉成字串的形式,如果沒傳,說明預設是null
                // this.buffer.slice(0,bytesRead)由buffer第0個開始就截,讀一個就取一個,讀兩個就取兩個,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();
            }
        });
    }
}
module.exports = RreadStream
複製程式碼

參考: Node.js文件

相關文章