Node.js Stream 流的使用及實現總結

張文夢發表於2019-03-01

流的概念

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

Node.js 中有四種基本的流型別

  1. Readable - 可讀的流 (例如 fs.createReadStream())。
  2. Writable - 可寫的流 (例如 fs.createWriteStream()).
  3. Duplex - 可讀寫的流(雙工流) (例如 net.Socket).
  4. Transform - 轉換流 在讀寫過程中可以修改和變換資料的 Duplex 流 (例如 zlib.createDeflate())

為什麼使用流

如果讀取一個檔案,使用fs.readFileSync同步讀取,程式會被阻塞,然後所有資料被寫到記憶體中。使用fs.readFile讀取,程式不會阻塞,但是所有資料依舊會一次性全被寫到記憶體,然後再讓消費者去讀取。如果檔案很大,記憶體使用便會成為問題。 這種情況下流就比較有優勢。流相比一次性寫到記憶體中,它會先寫到到一個緩衝區,然後再由消費者去讀取,不用將整個檔案寫進記憶體,節省了記憶體空間。

1.不使用流時檔案會全部寫入記憶體,再又記憶體寫入目標檔案

Node.js Stream 流的使用及實現總結
2.使用流時可以控制流的讀取及寫入速率

Node.js Stream 流的使用及實現總結

流的使用及實現

可讀流createReadStream

可讀流的使用

  1. 建立可讀流

    var rs = fs.createReadStream(path,[options]);
    複製程式碼

    1.)path讀取檔案的路徑

    2.)options

    • flags開啟檔案要做的操作,預設為'r'
    • encoding預設為null
    • start開始讀取的索引位置
    • end結束讀取的索引位置(包括結束位置)
    • highWaterMark讀取快取區預設的大小64kb

    如果指定utf8編碼highWaterMark要大於3個位元組

  2. 監聽data事件

    流切換到流動模式,資料會被儘可能快的讀出

    rs.on('data', function (data) {
        console.log(data);
    });
    複製程式碼
  3. 監聽end事件

    該事件會在讀完資料後被觸發

    rs.on('end', function () {
        console.log('讀取完成');
    });
    複製程式碼
  4. 監聽error事件

    rs.on('error', function (err) {
        console.log(err);
    });
    複製程式碼
  5. 監聽close事件

    與指定{encoding:'utf8'}效果相同,設定編碼

    rs.setEncoding('utf8');
    複製程式碼
  6. 暫停和恢復觸發data

    通過pause()方法和resume()方法

    rs.on('data', function (data) {
        rs.pause();
        console.log(data);
    });
    setTimeout(function () {
        rs.resume();
    },2000);
    複製程式碼

可讀流的簡單實現

  1. 仿寫可讀流
    let fs = require('fs');
    let EventEmitter = require('events');
    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';
    
        // 引數的問題
        this.flowing = null; // 非流動模式
        // 弄一個buffer讀出來的數
        this.buffer = Buffer.alloc(this.highWaterMark);
        this.open(); 
        // {newListener:[fn]}
        // 次方法預設同步呼叫的
        this.on('newListener', (type) => { // 等待著 它監聽data事件
          if (type === 'data') {
            this.flowing = true;
            this.read();// 開始讀取 客戶已經監聽了data事件
          }
        })
      }
      pause(){
        this.flowing = false;
      }
      resume(){
        this.flowing =true;
        this.read();
      }
      read(){ // 預設第一次呼叫read方法時還沒有獲取fd,所以不能直接讀
        if(typeof this.fd !== 'number'){
           return this.once('open',() => this.read()); // 等待著觸發open事件後fd肯定拿到了,拿到以後再去執行read方法
        }
        // 當獲取到fd時 開始讀取檔案了
        // 第一次應該讀2個 第二次應該讀2個
        // 第二次pos的值是4 end是4
        // 一共4個數 123 4
        let howMuchToRead = this.end?Math.min(this.end-this.pos+1,this.highWaterMark): this.highWaterMark;
        fs.read(this.fd, this.buffer, 0, howMuchToRead, this.pos, (error, byteRead) => { // byteRead真實的讀到了幾個
          // 讀取完畢
          this.pos += byteRead; // 都出來兩個位置就往後搓兩位
          // this.buffer預設就是三個
          let b = this.encoding ? this.buffer.slice(0, byteRead).toString(this.encoding) : this.buffer.slice(0, byteRead);
          this.emit('data', b);
          if ((byteRead === this.highWaterMark)&&this.flowing){
            return this.read(); // 繼續讀
          }
          // 這裡就是沒有更多的邏輯了
          if (byteRead < this.highWaterMark){
            // 沒有更多了
            this.emit('end'); // 讀取完畢
            this.destroy(); // 銷燬即可
          }
        });
      }
      // 開啟檔案用的
      destroy() {
        if (typeof this.fd != 'number') { return this.emit('close'); }
        fs.close(this.fd, () => {
          // 如果檔案開啟過了 那就關閉檔案並且觸發close事件
          this.emit('close');
        });
      }
      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; // 儲存檔案描述符
          this.emit('open', this.fd); // 觸發檔案的開啟的方法
        });
      }
    }
    module.exports = ReadStream;
    複製程式碼
  2. 驗證
    let ReadStream = require('./ReadStream');
    let rs = new ReadStream('./2.txt', {
      highWaterMark: 3, // 位元組
      flags:'r',
      autoClose:true, // 預設讀取完畢後自動關閉
      start:0,
      //end:3,// 流是閉合區間 包start也包end
      encoding:'utf8'
    });
    // 預設建立一個流 是非流動模式,預設不會讀取資料
    // 我們需要接收資料 我們要監聽data事件,資料會總動的流出來
    rs.on('error',function (err) {
      console.log(err)
    });
    rs.on('open',function () {
      console.log('檔案開啟了');
    });
    // 內部會自動的觸發這個事件 rs.emit('data');
    rs.on('data',function (data) {
      console.log(data);
      rs.pause(); // 暫停觸發on('data')事件,將流動模式又轉化成了非流動模式
    });
    setTimeout(()=>{rs.resume()},5000)
    rs.on('end',function () {
      console.log('讀取完畢了');
    });
    rs.on('close',function () {
      console.log('關閉')
    });
    複製程式碼

可寫流createWriteStream

可寫流的使用

  1. 建立可寫流

    var ws = fs.createWriteStream(path,[options]);
    複製程式碼

    1.)path寫入的檔案路徑

    2.)options

    • flags開啟檔案要做的操作,預設為'w'
    • encoding預設為utf8
    • highWaterMark寫入快取區的預設大小16kb
  2. write方法

    ws.write(chunk,[encoding],[callback]);
    複製程式碼

    1.)chunk寫入的資料buffer/string

    2.)encoding編碼格式chunk為字串時有用,可選

    3.)callback 寫入成功後的回撥

    返回值為布林值,系統快取區滿時為false,未滿時為true

  3. end方法

    ws.end(chunk,[encoding],[callback]);
    複製程式碼

    表明接下來沒有資料要被寫入 Writable 通過傳入可選的 chunk 和 encoding 引數,可以在關閉流之前再寫入一段資料 如果傳入了可選的 callback 函式,它將作為 'finish' 事件的回撥函式

  4. drain方法

    • 當一個流不處在 drain 的狀態, 對 write() 的呼叫會快取資料塊, 並且返回 false。 一旦所有當前所有快取的資料塊都排空了(被作業系統接受來進行輸出), 那麼 'drain' 事件就會被觸發

    • 建議, 一旦 write() 返回 false, 在 'drain' 事件觸發前, 不能寫入任何資料塊

    let fs = require('fs');
    let ws = fs.createWriteStream('./2.txt',{
      flags:'w',
      encoding:'utf8',
      highWaterMark:3
    });
    let i = 10;
    function write(){
     let  flag = true;
     while(i&&flag){
          flag = ws.write("1");
          i--;
         console.log(flag);
     }
    }
    write();
    ws.on('drain',()=>{
      console.log("drain");
      write();
    });
    複製程式碼
  5. finish方法

    在呼叫了 stream.end() 方法,且緩衝區資料都已經傳給底層系統之後, 'finish' 事件將被觸發

    var writer = fs.createWriteStream('./2.txt');
    for (let i = 0; i < 100; i++) {
      writer.write(`hello, ${i}!\n`);
    }
    writer.end('結束\n');
    writer.on('finish', () => {
      console.error('所有的寫入已經完成!');
    });
    複製程式碼

可寫流的簡單實現

  1. 仿寫可寫流
    let fs = require('fs');
    let EventEmitter = require('events');
    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;
        this.open(); // fd 非同步的  觸發一個open事件當觸發open事件後fd肯定就存在了
    
        // 寫檔案的時候 需要的引數有哪些
        // 第一次寫入是真的往檔案裡寫
        this.writing = false; // 預設第一次就不是正在寫入
        // 快取我用簡單的陣列來模擬一下
        this.cache = [];
        // 維護一個變數 表示快取的長度
        this.len = 0;
        // 是否觸發drain事件
        this.needDrain = false;
      }
      clearBuffer() {
        let buffer = this.cache.shift();
        if (buffer) { // 快取裡有
          this._write(buffer.chunk, buffer.encoding, () => this.clearBuffer());
        } else {// 快取裡沒有了
          if (this.needDrain) { // 需要觸發drain事件
            this.writing = false; // 告訴下次直接寫就可以了 不需要寫到記憶體中了
            this.needDrain = false;
            this.emit('drain');
          }
        }
      }
      _write(chunk, encoding, clearBuffer) { // 因為write方法是同步呼叫的此時fd還沒有獲取到,所以等待獲取到再執行write操作
        if (typeof this.fd != 'number') {
          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(); // 第一次就寫完了
        })
      }
      write(chunk, encoding = this.encoding) { // 客戶呼叫的是write方法去寫入內容
        // 要判斷 chunk必須是buffer或者字串 為了統一,如果傳遞的是字串也要轉成buffer
        chunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
        this.len += chunk.length; // 維護快取的長度 3
        let ret = this.len < this.highWaterMark;
        if (!ret) {
          this.needDrain = true; // 表示需要觸發drain事件
        }
        if (this.writing) { // 正在寫入應該放到記憶體中
          this.cache.push({
            chunk,
            encoding,
          });
        } else { // 第一次
          this.writing = true;
          this._write(chunk, encoding, () => this.clearBuffer()); // 專門實現寫的方法
        }
        return ret; // 能不能繼續寫了,false表示下次的寫的時候就要佔用更多記憶體了
      }
      destroy() {
        if (typeof this.fd != 'number') {
          this.emit('close');
        } else {
          fs.close(this.fd, () => {
            this.emit('close');
          });
        }
      }
      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);
        });
      }
    }
    module.exports = WriteStream;
    複製程式碼
  2. 驗證
    let WS = require('./WriteStream')
    let ws = new WS('./2.txt', {
      flags: 'w', // 預設檔案不存在會建立
      highWaterMark: 1, // 設定當前快取區的大小
      encoding: 'utf8', // 檔案裡存放的都是二進位制
      start: 0,
      autoClose: true, // 自動關閉
      mode: 0o666, // 可讀可寫
    });
    // drain的觸發時機,只有當highWaterMark填滿時,才可能觸發drain
    // 當嘴裡的和地下的都吃完了,就會觸發drain方法
    let i = 9;
    function write() {
      let flag = true;
      while (flag && i >= 0) {
        i--;
        flag = ws.write('111'); // 987 // 654 // 321 // 0
        console.log(flag)
      }
    }
    write();
    ws.on('drain', function () {
      console.log('幹了');
      write();
    });
    複製程式碼

pipe方法

pipe方法是管道的意思,可以控制速率

  • 會監聽rs的on('data'),將讀取到的內容呼叫ws.write方法
  • 呼叫寫的方法會返回一個boolean型別
  • 如果返回了false就呼叫rs.pause()暫停讀取
  • 等待可寫流寫入完畢後 on('drain')在恢復讀取 pipe方法的使用
let fs = require('fs');
let rs = fs.createReadStream('./2.txt',{
  highWaterMark:1
});
let ws = fs.createWriteStream('./1.txt',{
  highWaterMark:3
});
rs.pipe(ws); // 會控制速率(防止淹沒可用記憶體)
複製程式碼

相關文章