ReadableStream 簡單實現

風辰月發表於2018-07-13

今天的文章需要提前瞭解一下 nodefs 模組的相關 api,不太熟悉的同學可以點這裡

眾所周知,node 中的 fs 模組功能大都與檔案相關,比如可以通過 fs.createReadStream 建立檔案可讀流,通過fs.createWriteStream 建立檔案可寫流,還可以通過監聽 opendataenderrorreadable 事件對資料進行操作。由於時間有限,今天我們先來實現一下 readable 事件功能。

開始之前,先簡單介紹一下可讀流函式 fs.createReadStream(path[, options]) 中各引數所代表的含義,如下所示:

  • path <string> | <Buffer> | <URL> 建立可讀流的路徑
  • options <string> | <Object> 可選引數
    • flags <string> 檔案讀寫標識,預設為 r
    • encoding <string> 讀取編碼格式,預設為 null
    • fd <integer> 檔案描述符,預設為 null
    • mode <integer> 檔案操作許可權,預設為 0o666
    • autoClose <boolean> 檔案是否自動關閉,預設為 true
    • start <integer> 檔案讀取開始位置,預設為 0
    • end <integer> 檔案讀取結束位置,預設為 Infinity
    • highWaterMark <integer> 水位線,每次讀取長度,預設為 64位元組(64 * 1024)

一、建立可讀流

首先我們需要實現一個可讀流的類,不防定義為 ReadableStream,該類可以通過 on 函式進行事件監聽,所以需要繼承 nodeEventEmitter 類; 當監聽 readable 函式時可讀取到檔案內容,由此得知在建構函式中除了需要定義上面的變數,還需要呼叫開啟檔案和第一次讀取檔案的功能。程式碼如下:

let fs = require('fs')
let EventEmitter = require('events')
class ReadableStream extends EventEmitter {
  constructor(path, options) {
    super()
    this.path = path
    this.flags = options.flags || 'r'
    this.encoding = options.encoding || null
    this.autoClose = options.autoClose || true
    this.highWaterMark = options.highWaterMark || 64 * 1024
    this.start = options.start || 0
    this.end = options.end || null
    this.mode = options.mode || 0o666

    // 是否正在讀取檔案
    this.reading = false
    // 當len=0時,觸發readable事件
    this.emitReadable = false
    // 快取中位元組的長度
    this.len = 0
    // 快取每次讀取的內容,格式為[<Buffer />, <Buffer />, ...]
    this.arr = []
    // 檔案讀取的位置
    this.pos = this.start
    // 是否檔案全部讀取完
    this.finished = false
    // 開啟檔案
    this.open()
    // 判斷使用者是否監聽了readable事件
    this.on('newListener', (type) => {
      if (type === 'readable') {
        // 第一次檔案讀取
        this.read()
      }
    })
  }
}
module.exports = ReadableStream
複製程式碼

建構函式中其它變數可以先忽略,到實現階段時我相信大家自然清晰其用處。 下面利用 fs.openfs.destory 實現 opendestory 功能。實現程式碼如下:

// 開啟可讀流
open() {
  fs.open(this.path, this.flags, (err, fd) => {
    if (err) {
      this.emit('error')
      if (this.autoClose) {
        this.destory()
      }
      return
    }
    this.fd = fd
    this.emit('open')
  })
}
// 關閉可讀流,引數為檔案描述符
destory() {
  if (typeof this.fd === 'number') {
    fs.close(this.fd, () => {
      this.emit('close')
    })
  }
  this.emit('close')
}
複製程式碼

接下來看下初次讀取時的 read 函式。

實現思路:在建構函式中,當觸發第一次讀取檔案時,讀取大小為 highWaterMark 個,不防我們將比較讀取長度和快取長度的方法設定為 read。然後再 read 函式中判斷,如果快取區長度為 0 時,表明可以觸發 readable 事件;如果快取區長度小於水位線時,則進行檔案讀取,此時我們將真正讀取檔案的函式命名為 _read;最後,根據編碼格式進行返回資料。

實現程式碼如下:

class ReadableStream extends EventEmitter {

  // 此處如上,省略...
  
  // 可讀流例項呼叫的方法
  read () {
    let buffer = null

    // 如果快取區長度為0時,表明可以觸發readable事件
    if (this.len === 0) {
      this.emitReadable = true
    }

    // 如果快取區長度小於水位線時,則進行檔案讀取
    if (this.len < this.highWaterMark) {
      if (!this.reading) {
        this.reading = true
        this._read()
      }
    }

    // 根據編碼方式處理資料
    if (buffer) {
      buffer = this.encoding ? buffer.toString(this.encoding) : buffer
    }
    return buffer
  }
  // 真實讀取檔案的方法
  _read () {
    // 因為開啟檔案為非同步操作,當讀取時檔案未開啟,可以通過註冊一次open事件,開啟後執行回撥即可拿到this.fd
    if (typeof this.fd !== 'number') {
      this.once('open', () => this._read())
      return
    }

    let howMuchToRead = this.end ? Math.min(this.highWaterMark, this.end - this.pos + 1) : this.highWaterMark
    let buffer = Buffer.alloc(howMuchToRead)
    fs.read(this.fd, buffer, 0, this.howMuchToRead, this.pos, (err, bytesRead) => {
      // bytesRead 為檔案讀取到的長度
      if (bytesRead > 0) {
        // 將讀取的內容快取到arr陣列中
        this.arr.push(buffer)
        // 相關變數更新
        this.len += bytesRead
        this.pos += bytesRead
        this.reading = false
        // 快取後觸發例項上使用者呼叫的read函式
        if (this.emitReadable) {
          this.emitReadable = false
          this.emit('readable')
        }
      }
    })
  }

  // 此處如上,省略...

}
複製程式碼

當前 1.txt 檔案中的內容為 1234567890。呼叫方式如下:

let fs = require('fs')
let ReadableStream = require('./ReadableStream')
let rs = new ReadableStream('./1.txt', {
  autoClose: true,
  start: 0,
  flags: 'r',
  encoding: 'utf8',
  highWaterMark: 3
})
rs.on('readable', () => {
})
複製程式碼

接下來從快取區中讀取資料。

二、讀取長度小於水位線

當讀取長度小於水位線時,使用原生方式呼叫,可以得到如下結果:

let fs = require('fs')
let rs = fs.createReadStream('./1.txt', {
  autoClose: true,
  start: 0,
  flags: 'r',
  encoding: 'utf8',
  highWaterMark: 3
})
rs.on('readable', () => {
  let r = rs.read(2)
  // 輸出結果為 12
  console.log(r)
})
複製程式碼

由此可知,如果緩衝區內容夠讀,則返回結果結束讀取。實現程式碼如下:

class ReadableStream extends EventEmitter {

  // 此處如上,省略...

  // 可讀流例項呼叫的方法
  read (n) {
    // 如果引數為空且不是在建構函式中呼叫此函式,n 預設按highWaterMark處理
    if (typeof n === 'undefined' && this.pos > this.start) {
      n = this.highWaterMark
    }

    // 如果讀取長度小於快取區長度,this.read(2) highWaterMark=3
    if (n > 0 && n <= this.len) {
      buffer = Buffer.alloc(n)
      let current
      let index = 0
      let flag = true
      while (flag && (current = this.arr.shift())) {
        for (let i = 0; i < current.length; i++) {
          buffer[index++] = current[i]
          if (index === n) {
            flag = false
            let other = current.slice(i + 1)
            if (other.length > 0) {
              this.arr.unshift(other)
            }
            this.len -= n
            break
          }
        }
      }
    }

    // 如果快取區長度為0時,表明可以觸發readable事件
    if (this.len === 0) {
      this.emitReadable = true
    }

    // 如果快取區長度小於水位線時,則進行檔案讀取
    if (this.len < this.highWaterMark) {
      if (!this.reading) {
        this.reading = true
        this._read()
      }
    }

    // 根據編碼方式處理資料
    if (buffer) {
      buffer = this.encoding ? buffer.toString(this.encoding) : buffer
    }
    return buffer
  }

  // 此處如上,省略...

}
複製程式碼

三、讀取長度等於水位線

當讀取長度等於水位線時,使用原生方式呼叫,可以得到如下結果:

let fs = require('fs')
let rs = fs.createReadStream('./1.txt', {
  autoClose: true,
  start: 0,
  flags: 'r',
  encoding: 'utf8',
  highWaterMark: 2
})
rs.on('readable', () => {
  let r = rs.read(2)
  // 輸出結果為 12 34 56 78 90 null
  console.log(r)
})
複製程式碼

由此可知,如果緩衝區內容讀完為空,則返回結果繼續讀取。實現程式碼如下:

class ReadableStream extends EventEmitter {

  // 此處如上,省略...

  read (n) {

    // 此處如上,省略...
    
    // 如果讀取長度等於水位線,this.len 等於 0,表明可以觸發readable事件,固_read後會觸發readable函式
    if (this.len === 0) {
      this.emitReadable = true
    }

    // 如果快取區長度小於水位線時,則進行檔案讀取
    if (this.len < this.highWaterMark) {
      if (!this.reading) {
        this.reading = true
        this._read()
      }
    }

    // 此處如上,省略...

    return buffer
  }

  // 此處如上,省略...

}
複製程式碼

四、讀取長度大於水位線

當讀取長度大於水位線時,使用原生方式呼叫,可以得到如下結果:

let fs = require('fs')
let rs = fs.createReadStream('./1.txt', {
  autoClose: true,
  start: 0,
  flags: 'r',
  encoding: 'utf8',
  highWaterMark: 3
})
rs.on('readable', () => {
  let r = rs.read(8)
  // 輸出結果為 null 12345678 90
  console.log(r)
})
複製程式碼

由此可知,如果緩衝區內容不夠讀,初次會返回 null,然後修改 highWaterMark 值繼續讀取返回,即為 12345678。此時,this.len 不等於 0 且小於 this.highWaterMark,會再次呼叫 _read 方法,如果讀取檔案為空,則需要手動觸發一下 readable 事件。實現程式碼如下:

class ReadableStream extends EventEmitter {

  // 此處如上,省略...

  // 可讀流例項呼叫的方法
  read (n) {

    // 此處如上,省略...

    // 如果this.read(8) highWaterMark=3 
    if (n > this.len) {
      // 不夠讀時且檔案沒有讀取完,修改highWaterMark繼續讀取
      if (!this.finished) {
        this.highWaterMark = computeNewHighWaterMark(n)
        this.reading = true
        this.emitReadable = true
        this._read()
      } else {
        // 否則直接返回快取資料
        buffer = this.arr.shift()
      }
    }

    // 此處如上,省略...

    return buffer
  }
  // 真實讀取檔案的方法
  _read () {

    // 此處如上,省略...

    fs.read(this.fd, buffer, 0, howMuchToRead, this.pos, (err, bytesRead) => {
      // bytesRead 為檔案讀取到的長度
      if (bytesRead > 0) {

        // 此處如上,省略...

      } else {
        // this.len不等於0且小於this.highWaterMark,需要手動觸發一下readable事件
        this.finished = true
        if (this.len) {
          this.emit('readable')
        } else {
          this.emit('end')
        }
      }
    })
  }

  // 此處如上,省略...

}
複製程式碼

計算 highWaterMark 的函式如下:

function computeNewHighWaterMark (n) {
  n--;
  n |= n >>> 1;
  n |= n >>> 2;
  n |= n >>> 4;
  n |= n >>> 8;
  n |= n >>> 16;
  n++;
  return n;
}
複製程式碼

原始碼

???,好了,全部功能已經實現,就到此結束吧!