Node.js流,這樣的開啟方式對不對!

亦曾執著過不後悔發表於2018-08-20

Node.js流,這樣的開啟方式對不對!

Node.js流,這樣的開啟方式對不對!

俗話說的好:“人往高處走,水往低處流”;古語有云:“落花有意,流水無情”。(吃瓜群眾:what?你特麼這是要弄啥哩!二營長?)哎呀?,各位大佬,這點小事用不著驚動二營長的義大利炮了吧,進錯頻道了,馬上開始正題!

(一)流到底是個什麼東西?

Node.js的檔案系統(fs核心模組)在我們的開發中應該經常用到,在沒有深入瞭解學習之前,如果有人問我Node.js流到底是個什麼東西呢?我當時的表情一定是這樣曬的:

Node.js流,這樣的開啟方式對不對!

流到底是個啥?不給提示讓我怎麼說的出嘴?這種問題,哎呀腦殼痛啊!看來只能去google了(哼~我程式猿早已戒了百度)。

翻了好幾篇大佬寫的文章,不能說完全解惑吧,也算是收穫滿滿哦?!但是 呢,還是覺的它有那麼點抽象,又有那麼點難以理解!痛定思痛下,決定把大佬們的理解在小本本上記下來慢慢理解。(麻麻再也不用擔心我下次被問到還蒙圈了)

  • 流是資料的集合——就像陣列或字串一樣。區別在於流中的資料可能不會立刻就全部可用,並且無需一次性地把這些資料全部放入記憶體中。這使得流在操作大量資料或者一次來自外部源的資料時變得非常強大。

來自於:Node.js Streams: Everything you need to know

  • 流是一組有序的,有起點和終點的位元組資料傳輸手段。在應用程式中各種物件之間交換與傳輸資料的時候,總是先將該物件中所包含的資料轉換為各種形式的流資料(即位元組資料),在通過流的傳輸,到達目的物件後再將流資料轉換為該物件中可以使用的資料。

來自於:《Node.js權威指南》

(二)Node.js中流的型別

知道了流是什麼,我們還需要了解一下在Node.js中流的型別。流是一個很抽象介面,但是卻被Node.js中的很多物件所實現。比如HTTP伺服器request和response物件都是流。那我們就先來了解一下Node.js有四種基本的流型別:

  • Readable:可讀流。如Node.js的檔案系統(fs)中的fs.createReadStream(path,options)就是一個可讀流的例子;
  • Writable:可寫流。如Node.js的檔案系統(fs)中的fs.createWriteStream(path,options)就是一個可寫流的例子;
  • Duplex:可讀寫的流,又稱雙向流。如Node.js的網路(net)中的net.Socket類;
  • Transform:在讀寫過程中可以修改和變換資料的 Duplex 流,又稱變換流。如Node.js的壓縮(zlib)中的zlib.createDeflate(options)。

在Node.js中,所有的流的實現都繼承了EventEmitter(用於實現各種事件處理的event模組)這個類,因此,在讀取或者寫入資料的過程中,可能會觸發各種事件。

(三)createReadStream和createWriteStream

現在我們瞭解了流的含義和型別,那麼Node.js中的流是怎麼實現的呢?我們都知道fs.createReadStream(path,options)建立一個可讀流,fs.createWriteStream(path,options)建立一個可寫流,path是讀取檔案的路徑,options是配置引數。(這配置引數也忒多了點,?努力記.....)

  • flags: 可讀流預設是'r',可寫流預設是'w';
  • encoding: 編碼格式。可讀流預設是null(其實就是buffer啦),可寫流預設是'utf8';
  • autoClose:是否自動關閉。預設都是true(就是讀完檔案或者寫入完之後自動把檔案關上啦);
  • mode: 讀取和寫入的模式。預設都是0o666(可讀可寫,八進位制);
  • highWaterMark: 最高水位線。可讀流預設是64 kb(每次最多讀取位元組數),可寫流預設是16 kb(每次最多寫入的位元組數,也可以理解為佔用的最大記憶體);
  • start:開始讀取或者開始寫入的位置。預設是從0開始的(單位是位元組數);
  • fd:檔案識別符號。是Number型別的
  • end:是可讀流獨有的,讀取檔案的最終位置,預設是Infinity(單位是位元組數)。

其實,引數雖然比較多,但是都很容易理解。這裡需要特別注意的是讀取檔案的時候,如果start設定為0,end設定為5,那麼實際上最終讀取的結果是6個字元,即相當於包前又包後!(有點小霸道哦?)

在Node.js中,使用fs.createReadStream(path,options)建立可讀流和fs.createWriteStream(path,options)建立可寫流兩個方法很簡單,難道我們就甘心僅僅停留在能用、會用的層面嗎?No、No、No!我們不僅要會用,還要知道其中的原理,他們是如何實現的?先來建立一個可讀流感受一下其用法:

// 'a.txt'存放十個數字--> 1234567890 
let rs = fs.createReadStream('a.txt', {
  flags: 'r',
  encoding: 'utf8',
  autoClose: true,
  mode: 0o666,
  start: 0, 
  end:5,
  highWaterMark: 2 
});
rs.on('data',function (data) {
  console.log(data);
  rs.pause();
});
setInterval(() => {
  rs.resume();
}, 1000);
// 最後輸出的結果是 01 23 45 
複製程式碼

監聽到'data'事件之後,流切換到流動模式,資料會被儘可能快的讀出。pause、resume事件是用來暫停和恢復觸發'data'事件的(意味著讀取檔案的操作停止了)。當然還可以監聽'end'事件、'open'事件、'close'事件、'error'事件。

相比較於可讀流,我們還應該知道可寫流的以下幾個特點:

  • 可寫流是有快取區的概念的,第一次會真的往檔案裡寫,後面的內容會先寫到快取中
  • 可寫流寫入時會返回一個boolean型別,當返回為false時,就不要在寫入了。(但如果返回false之後還有寫入操作,還是會寫入檔案中,因為超過的部分會放到快取裡,這樣可能會導致記憶體的浪費);
  • 正在寫入的內容和快取中的內容都消耗完後,會觸發drain事件
// 如果'a.txt'中有內容,會被寫入的內容覆蓋掉
let ws = fs.createWriteStream('a.txt',{
  flags: 'w',
  mode: 0o666,
  encoding: 'utf8',
  autoClose: true,
  start: 0,
  highWaterMark: 3
})
let flag = ws.write('1');
console.log(flag); // true
flag = ws.write('1');
console.log(flag); // true
flag = ws.write('1');
console.log(flag); // false
複製程式碼

上面例子中,最後一次返回的是false,其實跟設定的highWaterMark最高水位線(設定的當前快取區大小)有關係了。當寫入的內容大於等於highWaterMark時,就會返回flase

那我們如何去控制寫入的時機,從而不造成記憶體的浪費呢?請看下面的例子。

// 複用上個例子中的可寫流例項,寫入時只佔用三個位元組的記憶體
let i = 0;
function write(){ // 每次寫入三個位元組,然後停住,寫入完成後再繼續寫入
  let flag = true;
  while(i < 9 && flag){
    flag = ws.write(i + '');
    i++
  }
}
ws.on('drain',function(){ // 達到highWaterMark觸發該事件
  console.log('寫入成功');
  write();
})
write(); // a.txt檔案中--> 012345678
複製程式碼

實現createReadStream和createWriteStream

上面大致瞭解了createReadStream和createWriteStream的用法和特點,如果我們能自己實現一下可讀流和可寫流,無疑能加深我們對其的理解。翻看Node.js的原始碼,可讀流fs.createReadStream()執行後返回的是ReadStream類的例項,可寫流也是一樣的邏輯,程式碼如下:

fs.createReadStream = function(path, options) {
  return new ReadStream(path, options);
};
fs.createWriteStream = function(path, options) {
  return new WriteStream(path, options);
};
複製程式碼

是不是感覺一下子明朗了許多,只要我們能夠封裝ReadStream和WriteStream類就可以了。為了能夠更好的理解,基本每句程式碼都有註釋哦?!為減少篇幅,這裡只貼出來核心read方法和write方法的實現,全部程式碼請移步stream下載:

class ReadStream extends EventEmitter{
  read(){ // 讀取檔案
    if (this.finished) { // 讀完之後就不再讀了
      return;
    }
    // open開啟檔案是非同步的,當我們讀取的時候可能檔案還沒有開啟
    if(typeof this.fd !== 'number'){
      this.once('open',()=>this.read());
      return;
    }
    // length代表每次讀取的位元組數
    let length = this.end ? Math.min(this.highWaterMark, this.end - this.pos + 1) : this.highWaterMark;
    fs.read(this.fd,this.buffer,0,length,this.pos,(err,bytesRead)=>{
      if(err){
        this.emit('error',err);
        this.destroy();
        return;
      }
      if(bytesRead > 0){ // 讀到的位元組數 
        this.pos += bytesRead;
        let res = this.buffer.slice(0, bytesRead); // 真實讀取到的bytesRead可能不能填滿this.buffer,需要擷取,保留有用的
        res = this.encoding ? res.toString(this.encoding) : res;
        this.emit('data', res);
        if (this.flowing) { // 如果是流動模式,就繼續呼叫read方法讀取
          this.read();
        }
      }else {
        this.finished = true; // 讀完的標識
        this.emit('end');
        this.destroy();
      }
    })
  }
}
複製程式碼

可讀流ReadStream類的封裝,最主要的就是理解read方法的實現,其他的方法都比較簡單好理解。read方法中最難理解的就是length變數(要讀取的位元組數),因為讀到最後,可能檔案中的位元組數小於了highWaterMark最高水位線,所以要取Math.min()最小值。打個比方:如果this.end = 4;說明總共需要讀取5個位元組,this.highWaterMark= 3;說明每次讀取3個位元組,第一次讀完後this.pos = 3;此時還需要在讀取2個位元組就夠了。

class WriteStream extends EventEmitter {
  // chunk:寫入的內容;encoding:編碼格式;callback:寫入完成後的回撥
  write(chunk,encoding=this.encoding,callback){ // 寫入的時候呼叫的方法
     // 為了統一,如果傳遞的是字串也要轉成buffer
    chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk,encoding);
    this.len += chunk.length; // 維護快取的長度
    let ret = this.highWaterMark > this.len;
    if(!ret){ 
      this.needDrain = true; // 表示需要觸發drain事件
    }
    if(this.writing){ // true表示正在寫入,應該放在快取中
      this.buffer.push({
        chunk,
        encoding,
        callback
      });
    }else{ // 第一次寫入
      this.writing = true;
      this._write(chunk,encoding,()=>this.clearBuffer()); // 實現一個寫入的方法
    }
    return ret; // write的返回值必須是true/false
  }
  _write(chunk,encoding,callback){ // 因為write方法是同步呼叫的,此時fd可能還沒有獲取到
    if(typeof this.fd !== 'number'){ // 判斷如果檔案還沒有開啟
      return this.once('open',()=>this._write(chunk,encoding,callback));
    }
    // 引數:fd 檔案描述符; chunk是資料; 0:寫入的buffer開始的位置; chunk.length寫入的位元組數; this.pos檔案開始寫入資料的位置的偏移量
    fs.write(this.fd,chunk,0,chunk.length,this.pos,(err,bytesWritten)=>{
      this.pos += bytesWritten;
      this.len -= bytesWritten; // 每次寫入後,記憶體中的也要相應的減少
      callback();
    })
  }
  clearBuffer(){ // 清除快取中的
    let buf = this.buffer.shift();
    if(buf){
      this._write(buf.chunk,buf.encoding,()=>this.clearBuffer());
    }else{
      if(this.needDrain){ // 如果需要觸發drain
        this.writing = false;
        this.needDrain = false;// 觸發一次drain 再置回false 方便下次繼續判斷
        this.emit('drain');
      }
    }
  }
}
複製程式碼

可寫流中最主要的就是write方法,其又依賴_write方法clearBuffer方法。全部程式碼請移步stream下載,更好理解哦b( ̄▽ ̄)d!

(四)pipe方法

其實啊,說了這麼多,又是理解含義,又是封裝程式碼,都是為了突出pipe方法導流的重要性啊。pipe方法怎麼使用呢,請注意(前方高能):

let fs = require('fs');
let rs = fs.createReadStream('a.txt', { 
  highWaterMark: 4
});
let ws = fs.createWriteStream('b.txt', { 
  highWaterMark: 1
});
rs.pipe(ws);
複製程式碼

用法是不是很簡單,很直接呀!雖然僅僅這一行的程式碼,但這正是其神奇之處啊。(說好的高能呢?小板凳都準備好了,你告訴我這些)。 那我們怎麼去實現一個pipe方法呢?其實基於上面可讀流createReadStream和可寫流createWriteStream的封裝,pipe的實現就顯得很簡單了,在ReadStream類的原型上封裝pipe方法,程式碼如下:

class ReadStream extends EventEmitter{
  pipe(dest){
    this.on('data',(data)=>{
      let flag = dest.write(data);
      if(!flag){
        this.pause(); // 不能繼續讀取了,等寫入完成後再繼續讀取
      }
    });
    dest.on('drain',()=>{
      this.resume();
    })
  }
}
複製程式碼

再次友情提示:為了更好的理解,可以移步這裡stream下載全部程式碼哦( ̄▽ ̄)~*!

  • pipe方法又叫管道方法,最大的優點就是可以控制速率(防止淹沒可用記憶體);
  • pipe方法的實現原理:可讀流例項rs監聽on('data')方法,將讀取到的內容呼叫ws.write方法(ws是可寫流例項),其方法會返回一個boolean型別,如果返回false會呼叫rs.pause()暫停讀取,等待可寫流寫入完畢後,呼叫ws.on('drain')在恢復讀取

總結:Node.js流到這裡就告一段落了,感謝大家的閱讀!如果有問題歡迎指出,共同進步哦!如果感覺文章有點晦澀難懂,可以先收藏,方便以後閱讀哦❤️!

參考文章:

相關文章