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}!
    `);
    }
    writer.end(`結束
    `);
    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); // 會控制速率(防止淹沒可用記憶體)
複製程式碼

相關文章