Node.js Readable Stream的實現簡析

菠蘿小蘿蔔發表於2018-03-23

作者:肖磊

個人主頁:github

Readable Stream是對資料來源的一種抽象。它提供了從資料來源獲取資料並快取,以及將資料提供給資料消費者的能力。

接下來分別通過Readable Stream的2種模式來學習下可讀流是如何獲取資料以及將資料提供給消費者的。

Flowing模式

node-stream-flowing

flowing模式下,可讀流自動從系統的底層讀取資料,並通過EventEmitter介面的事件提供給消費者。如果不是開發者需要自己去實現可讀流,大家可使用最為簡單的readable.pipe()方法去消費資料。

接下來我們就通過一個簡單的例項去具體分析下flowing模式下,可讀流是如何工作的。

const { Readable } = require('stream')

let c = 97 - 1
// 例項化一個可讀流
const rs = new Readable({
  read () {
    if (c >= 'z'.charCodeAt(0)) return rs.push(null)

    setTimeout(() => {
      // 向可讀流中推送資料
      rs.push(String.fromCharCode(++c))
    }, 100)
  }
})

// 將可讀流的資料pipe到標準輸出並列印出來
rs.pipe(process.stdout)

process.on('exit', () => {
  console.error('\n_read() called ' + (c - 97) + ' times')
})
複製程式碼

首先我們先來看下Readable建構函式的實現:

function Readable(options) {
  if (!(this instanceof Readable))
    return new Readable(options);

  // _readableState裡面儲存了關於可讀流的不同階段的狀態值,下面會具體的分析
  this._readableState = new ReadableState(options, this);

  // legacy
  this.readable = true;

  if (options) {
    // 重寫內部的_read方法,用以自定義從資料來源獲取資料
    if (typeof options.read === 'function')
      this._read = options.read;

    if (typeof options.destroy === 'function')
    // 重寫內部的_destory方法
      this._destroy = options.destroy;
  }

  Stream.call(this);
}
複製程式碼

在我們建立可讀流例項時,傳入了一個read方法,用以自定義從資料來源獲取資料的方法,如果是開發者需要自己去實現可讀流,那麼這個方法一定需要去自定義,否則在程式的執行過程中會報錯ReadableState建構函式中定義了很多關於可讀流的不同階段的狀態值:

function ReadableState(options, stream) {
  options = options || {};

  ...

  // object stream flag. Used to make read(n) ignore n and to
  // make all the buffer merging and length checks go away
  // 是否為物件模式,如果是的話,那麼從緩衝區獲得的資料為物件
  this.objectMode = !!options.objectMode;

  if (isDuplex)
    this.objectMode = this.objectMode || !!options.readableObjectMode;

  // the point at which it stops calling _read() to fill the buffer
  // Note: 0 is a valid value, means "don't call _read preemptively ever"
  // 高水位線,一旦buffer緩衝區的資料量大於hwm時,就會停止呼叫從資料來源再獲取資料
  var hwm = options.highWaterMark;
  var readableHwm = options.readableHighWaterMark;
  var defaultHwm = this.objectMode ? 16 : 16 * 1024;  // 預設值

  if (hwm || hwm === 0)
    this.highWaterMark = hwm;
  else if (isDuplex && (readableHwm || readableHwm === 0))
    this.highWaterMark = readableHwm;
  else
    this.highWaterMark = defaultHwm;

  // cast to ints.
  this.highWaterMark = Math.floor(this.highWaterMark);

  // A linked list is used to store data chunks instead of an array because the
  // linked list can remove elements from the beginning faster than
  // array.shift()
  // readable可讀流內部的緩衝區
  this.buffer = new BufferList();
  // 緩衝區資料長度
  this.length = 0;
  this.pipes = null;
  this.pipesCount = 0;
  // flowing模式的初始值
  this.flowing = null;
  // 是否已將源資料全部讀取完畢
  this.ended = false;
  // 是否觸發了end事件
  this.endEmitted = false;
  // 是否正在從源資料處讀取資料到緩衝區
  this.reading = false;

  // a flag to be able to tell if the event 'readable'/'data' is emitted
  // immediately, or on a later tick.  We set this to true at first, because
  // any actions that shouldn't happen until "later" should generally also
  // not happen before the first read call.
  this.sync = true;

  // whenever we return null, then we set a flag to say
  // that we're awaiting a 'readable' event emission.
  this.needReadable = false;
  this.emittedReadable = false;
  this.readableListening = false;
  this.resumeScheduled = false;

  // has it been destroyed
  this.destroyed = false;

  // Crypto is kind of old and crusty.  Historically, its default string
  // encoding is 'binary' so we have to make this configurable.
  // Everything else in the universe uses 'utf8', though.
  // 編碼方式
  this.defaultEncoding = options.defaultEncoding || 'utf8';

  // 在pipe管道當中正在等待drain事件的寫入流
  // the number of writers that are awaiting a drain event in .pipe()s
  this.awaitDrain = 0;

  // if true, a maybeReadMore has been scheduled
  this.readingMore = false;

  this.decoder = null;
  this.encoding = null;
  if (options.encoding) {
    if (!StringDecoder)
      StringDecoder = require('string_decoder').StringDecoder;
    this.decoder = new StringDecoder(options.encoding);
    this.encoding = options.encoding;
  }
}
複製程式碼

在上面的例子中,當例項化一個可讀流rs後,呼叫可讀流例項的pipe方法。這正式開始了可讀流在flowing模式下從資料來源開始獲取資料,以及process.stdout對資料的消費。

Readable.prototype.pipe = function (dest, pipeOpts) {
  var src = this
  var state = this._readableState
  ...

  // 可讀流例項監聽data,可讀流會從資料來源獲取資料,同時資料被傳遞到了消費者
  src.on('data', ondata)
  function ondata (chunk) {
    ...
    var ret = dest.write(chunk)
    ...
  }

  ...
}
複製程式碼

Node提供的可讀流有3種方式可以將初始態flowing = null的可讀流轉化為flowing = true

  • 監聽data事件
  • 呼叫stream.resume()方法
  • 呼叫stream.pipe()方法

事實上這3種方式都回歸到了一種方式上:strean.resume(),通過呼叫這個方法,將可讀流的模式改變為flowing態。繼續回到上面的例子當中,在呼叫了rs.pipe()方法後,實際上內部是呼叫了src.on('data', ondata)監聽data事件,那麼我們就來看下這個方法當中做了哪些工作。

Readable.prototype.on = function (ev, fn) {
  ...
  // 監聽data事件
  if (ev === 'data') {
    // 可讀流一開始的flowing狀態是null
    // Start flowing on next tick if stream isn't explicitly paused
    if (this._readableState.flowing !== false)
      this.resume();
  } else if (ev === 'readable') {
    ...
  }

  return res;
}
複製程式碼

可讀流監聽data事件,並呼叫resume方法:

Readable.prototype.resume = function() {
  var state = this._readableState;
  if (!state.flowing) {
    debug('resume');
    // 置為flowing狀態
    state.flowing = true;
    resume(this, state);
  }
  return this;
};

function resume(stream, state) {
  if (!state.resumeScheduled) {
    state.resumeScheduled = true;
    process.nextTick(resume_, stream, state);
  }
}

function resume_(stream, state) {
  if (!state.reading) {
    debug('resume read 0');
    // 開始從資料來源中獲取資料
    stream.read(0);
  }

  state.resumeScheduled = false;
  // 如果是flowing狀態的話,那麼將awaitDrain置為0
  state.awaitDrain = 0;
  stream.emit('resume');
  flow(stream);
  if (state.flowing && !state.reading)
    stream.read(0);
}
複製程式碼

resume方法會判斷這個可讀流是否處於flowing模式下,同時在內部呼叫stream.read(0)開始從資料來源中獲取資料(其中stream.read()方法根據所接受到的引數會有不同的行為):

TODO: 這個地方可說明stream.read(size)方法接收到的不同的引數

Readable.prototype.read = function (n) {
  ...
  
  if (n === 0 &&
      state.needReadable &&
      (state.length >= state.highWaterMark || state.ended)) {
    debug('read: emitReadable', state.length, state.ended);
    // 如果快取中沒有資料且處於end狀態
    if (state.length === 0 && state.ended)
    // 流狀態結束
      endReadable(this);
    else
    // 觸發readable事件
      emitReadable(this);
    return null;
  }

  ...

  // 從快取中可以讀取的資料
  n = howMuchToRead(n, state);

  // 判斷是否應該從資料來源中獲取資料
  // if we need a readable event, then we need to do some reading.
  var doRead = state.needReadable;
  debug('need readable', doRead);

  // if we currently have less than the highWaterMark, then also read some
  // 如果buffer的長度為0或者buffer的長度減去需要讀取的資料的長度 < hwm 的時候,那麼這個時候還需要繼續讀取資料
  // state.length - n 即表示當前buffer已有的資料長度減去需要讀取的資料長度後,如果還小於hwm話,那麼doRead仍然置為true
  if (state.length === 0 || state.length - n < state.highWaterMark) {
    // 繼續read資料
    doRead = true;
    debug('length less than watermark', doRead);
  }

  // however, if we've ended, then there's no point, and if we're already
  // reading, then it's unnecessary.
  // 如果資料已經讀取完畢,或者處於正在讀取的狀態,那麼doRead置為false表明不需要讀取資料
  if (state.ended || state.reading) {
    doRead = false;
    debug('reading or ended', doRead);
  } else if (doRead) {
    debug('do read');
    state.reading = true;
    state.sync = true;
    // if the length is currently zero, then we *need* a readable event.
    // 如果當前緩衝區的長度為0,首先將needReadable置為true,那麼再當緩衝區有資料的時候就觸發readable事件
    if (state.length === 0)
      state.needReadable = true;
    // call internal read method
    // 從資料來源獲取資料,可能是同步也可能是非同步的狀態,這個取決於自定義_read方法的內部實現,可參見study裡面的示例程式碼
    this._read(state.highWaterMark);
    state.sync = false;
    // If _read pushed data synchronously, then `reading` will be false,
    // and we need to re-evaluate how much data we can return to the user.
    // 如果_read方法是同步,那麼reading欄位將會為false。這個時候需要重新計算有多少資料需要重新返回給消費者
    if (!state.reading)
      n = howMuchToRead(nOrig, state);
  }

  // ret為輸出給消費者的資料
  var ret;
  if (n > 0)
    ret = fromList(n, state);
  else
    ret = null;

  if (ret === null) {
    state.needReadable = true;
    n = 0;
  } else {
    state.length -= n;
  }

  if (state.length === 0) {
    // If we have nothing in the buffer, then we want to know
    // as soon as we *do* get something into the buffer.
    if (!state.ended)
      state.needReadable = true;

    // If we tried to read() past the EOF, then emit end on the next tick.
    if (nOrig !== n && state.ended)
      endReadable(this);
  }

  // 只要從資料來源獲取的資料不為null,即未EOF時,那麼每次讀取資料都會觸發data事件
  if (ret !== null)
    this.emit('data', ret);

  return ret;
}
複製程式碼

這個時候可讀流從資料來源開始獲取資料,呼叫this._read(state.highWaterMark)方法,對應著例子當中實現的read()方法:

const rs = new Readable({
  read () {
    if (c >= 'z'.charCodeAt(0)) return rs.push(null)

    setTimeout(() => {
      // 向可讀流中推送資料
      rs.push(String.fromCharCode(++c))
    }, 100)
  }
})
複製程式碼

read方法當中有一個非常中的方法需要開發者自己去呼叫,就是stream.push方法,這個方法即完成從資料來源獲取資料,並供消費者去呼叫。

Readable.prototype.push = function (chunk, encoding) {
  ....
  // 對從資料來源拿到的資料做處理
  return readableAddChunk(this, chunk, encoding, false, skipChunkCheck);
}

function readableAddChunk (stream, chunk, encoding, addToFront, skipChunkCheck) {
  ... 
  // 是否新增資料到頭部
      if (addToFront) {
        // 如果不能在寫入資料
        if (state.endEmitted)
          stream.emit('error',
                      new errors.Error('ERR_STREAM_UNSHIFT_AFTER_END_EVENT'));
        else
          addChunk(stream, state, chunk, true);
      } else if (state.ended) { // 已經EOF,但是仍然還在推送資料,這個時候會報錯
        stream.emit('error', new errors.Error('ERR_STREAM_PUSH_AFTER_EOF'));
      } else {
        // 完成一次讀取後,立即將reading的狀態置為false
        state.reading = false;
        if (state.decoder && !encoding) {
          chunk = state.decoder.write(chunk);
          if (state.objectMode || chunk.length !== 0)
            // 新增資料到尾部
            addChunk(stream, state, chunk, false);
          else
            maybeReadMore(stream, state);
        } else {
          // 新增資料到尾部
          addChunk(stream, state, chunk, false);
        }
      }
  ...

  return needMoreData(state);
}

// 根據stream的狀態來對資料做處理
function addChunk(stream, state, chunk, addToFront) {
  // flowing為readable stream的狀態,length為buffer的長度
  // flowing模式下且為非同步讀取資料的過程時,可讀流的緩衝區並不儲存資料,而是直接獲取資料後觸發data事件供消費者使用
  if (state.flowing && state.length === 0 && !state.sync) {
    // 對於flowing模式的Reabable,可讀流自動從系統底層讀取資料,直接觸發data事件,且繼續從資料來源讀取資料stream.read(0)
    stream.emit('data', chunk);
    // 繼續從快取池中獲取資料
    stream.read(0);
  } else {
    // update the buffer info.
    // 資料的長度
    state.length += state.objectMode ? 1 : chunk.length;
    // 將資料新增到頭部
    if (addToFront)
      state.buffer.unshift(chunk);
    else
    // 將資料新增到尾部
      state.buffer.push(chunk);

    // 觸發readable事件,即通知快取當中現在有資料可讀
    if (state.needReadable)
      emitReadable(stream);
  }
  maybeReadMore(stream, state);
}
複製程式碼

addChunk方法中完成對資料的處理,這裡需要注意的就是,在flowing態下,資料被消耗的途徑可能還不一樣:

  1. 從資料來源獲取的資料可能進入可讀流的緩衝區,然後被消費者使用;
  2. 不進入可讀流的緩衝區,直接被消費者使用。

這2種情況到底使用哪一種還要看開發者的是同步還是非同步的去呼叫push方法,對應著state.sync的狀態值。

push方法被非同步呼叫時,即state.syncfalse:這個時候對於從資料來源獲取到的資料是直接通過觸發data事件以供消費者來使用,而不用存放到緩衝區。然後呼叫stream.read(0)方法重複讀取資料並供消費者使用。

push方法是同步時,即state.synctrue:這個時候從資料來源獲取資料後,就不是直接通過觸發data事件來供消費者直接使用,而是首先上資料緩衝到可讀流的緩衝區。這個時候你看程式碼可能會疑惑,將資料快取起來後,那麼在flowing模式下,是如何流動起來的呢?事實上在一開始呼叫resume_方法時:

function resume_() {
  ...
  // 
  flow(stream);
  if (state.flowing && !state.reading)
    stream.read(0); // 繼續從資料來源獲取資料
}

function flow(stream) {
  ...
  // 如果處理flowing狀態,那麼呼叫stream.read()方法用以從stream的緩衝區中獲取資料並供消費者來使用
  while (state.flowing && stream.read() !== null);
}
複製程式碼

flow方法內部呼叫stream.read()方法取出可讀流緩衝區的資料供消費者使用,同時繼續呼叫stream.read(0)來繼續從資料來源獲取資料。

以上就是在flowing模式下,可讀流是如何完成從資料來源獲取資料並提供給消費者使用的大致流程。

paused模式

pasued模式下,消費者如果要獲取資料需要手動呼叫stream.read()方法去獲取資料。

舉個例子:

const { Readable } = require('stream')

let c = 97 - 1

const rs = new Readable({
  highWaterMark: 3,
  read () {
    if (c >= 'f'.charCodeAt(0)) return rs.push(null)
    setTimeout(() => {
      rs.push(String.fromCharCode(++c))
    }, 1000)
  }
})

rs.setEncoding('utf8')
rs.on('readable', () => {
  // console.log(rs._readableState.length)
  console.log('get the data from readable: ', rs.read())
})
複製程式碼

通過監聽readable事件,開始出發可讀流從資料來源獲取資料。

Readable.prototype.on = function (env) {
  if (env === 'data') {
    ...
  } else if (env === 'readable') {
    // 監聽readable事件
    const state = this._readableState;
    if (!state.endEmitted && !state.readableListening) {
      state.readableListening = state.needReadable = true;
      state.emittedReadable = false;
      if (!state.reading) {
        process.nextTick(nReadingNextTick, this);
      } else if (state.length) {
        emitReadable(this);
      }
    }
  }
}

function nReadingNextTick(self) {
  debug('readable nexttick read 0');
  // 開始從資料來源獲取資料
  self.read(0);
}
複製程式碼

nReadingNextTick當中呼叫self.read(0)方法後,後面的流程和上面分析的flowing模式的可讀流從資料來源獲取資料的流程相似,最後都要呼叫addChunk方法,將資料獲取到後推入可讀流的緩衝區:

function addChunk(stream, state, chunk, addToFront) {
  if (state.flowing && state.length === 0 && !state.sync) {
    ...
  } else {
    // update the buffer info.
    // 資料的長度
    state.length += state.objectMode ? 1 : chunk.length;
    // 將資料新增到頭部
    if (addToFront)
      state.buffer.unshift(chunk);
    else
    // 將資料新增到尾部
      state.buffer.push(chunk);

    // 觸發readable事件,即通知快取當中現在有資料可讀
    if (state.needReadable)
      emitReadable(stream);
  }
  maybeReadMore(stream, state);
}
複製程式碼

一旦有資料被加入到了緩衝區,且needReadable(這個欄位表示是否需要觸發readable事件用以通知消費者來消費資料)為true,這個時候會觸發readable告訴消費者有新的資料被push進了可讀流的緩衝區。此外還會呼叫maybeReadMore方法,非同步的從資料來源獲取更多的資料:

function maybeReadMore(stream, state) {
  if (!state.readingMore) {
    state.readingMore = true;
    process.nextTick(maybeReadMore_, stream, state);
  }
}

function maybeReadMore_(stream, state) {
  var len = state.length;
  // 在非flowing的模式下,且緩衝區的資料長度小於hwm
  while (!state.reading && !state.flowing && !state.ended &&
         state.length < state.highWaterMark) {
    debug('maybeReadMore read 0');
    stream.read(0);
    // 獲取不到資料後
    if (len === state.length)
      // didn't get any data, stop spinning.
      break;
    else
      len = state.length;
  }
  state.readingMore = false;
}
複製程式碼

每當可讀流有新的資料被推進緩衝區,觸發readable事件後,消費者通過呼叫stream.read()方法來從可讀流中獲取資料。

背壓

當資料消費消費資料的速度慢於可寫流提供給消費者的資料後會產生背壓。

還是通過pipe管道來看:



Readable.prototype.pipe = function () {
  ...
  
  // 監聽drain事件
  var ondrain = pipeOnDrain(src);
  dest.on('drain', ondrain);

  ...

  src.on('data', ondata)
  function ondata () {
    increasedAwaitDrain = false;
    // 向writable中寫入資料
    var ret = dest.write(chunk);
    if (false === ret && !increasedAwaitDrain) {
      ...     
      src.pause();
    }
  }
  ...
}

function pipeOnDrain(src) {
  return function() {
    var state = src._readableState;
    debug('pipeOnDrain', state.awaitDrain);
    // 減少pipes中awaitDrain的數量
    if (state.awaitDrain)
      state.awaitDrain--;
    // 如果awaitDrain的數量為0,且readable上繫結了data事件時(EE.listenerCount返回繫結的事件回撥數量)
    if (state.awaitDrain === 0 && EE.listenerCount(src, 'data')) {
      // 重新開啟flowing模式
      state.flowing = true;
      flow(src);
    }
  };
}
複製程式碼

dest.write(chunk)返回false的時候,即代表可讀流給可寫流提供的資料過快,這個時候呼叫src.pause方法,暫停flowing狀態,同步也暫停可寫流從資料來源獲取資料以及向可寫流輸入資料。這個時候只有當可寫流觸發drain事件時,會呼叫ondrain來恢復flowing,同時可讀流繼續向可寫流輸入資料。關於可寫流的背壓可參見關於Writable_stream的原始碼分析。

以上就是通過可讀流的2種模式分析了下可讀流的內部工作機制。當然還有一些細節處大家有興趣的話可以閱讀相關的原始碼。

相關文章