Node Stream流核心机制理解

172 阅读8分钟

流是Node内部比较重要的概念,Node很多其他模块的内部逻辑依赖流机制实现,比如读写文件模块、http模块、process.stdin/stdout模块等,这些模块是Web开发经常使用,所以深入流机制对这些模块的理解非常有帮助。本文结合stream模块源码阅读,记录流相关概念和实现机制;由于流涉及内容多,本文会结合实战经验,持续迭代和改进文章内容。

概念理解

Buffer缓存

定义:用来临时暂存未消费数据的内存区域,highWatermark指定了内存大小

作用:解决生产者和消费者速度不匹配问题引入的设计,如果生产者生产速度超过消费者速度,就会导致buffer数据溢出,可以把buffer看成一个指定大小的蓄水池,如果水流流入速度和流出速度快,水就会溢出;比如磁盘IO读数据的速度比写数据快、网络流读入数据比下游消费速度快等。

那么buffer溢出怎么解决?当然是要有种机制通知生产者暂停和恢复生产,不过不同领域处理手段不一样,比如TCP协议中不同进程间通信通过流量控制协议来解决,在stream中通过背压机制解决。

背压:就是消费者需要多少就生产多少;为什么以消费者需要为准?因为上面提到buffer内容是否溢出取决于消费者消费快慢,这里面涉及到整个消费者和生产者之间的协同,下面会提到

可读流

定义:类似蓄水池,池中水的来源从水龙头流入,从另一个出口流出。从水龙头读取的水类比可读流,蓄水池本身看成buffer,所以可读流是对(被消费的)数据源的抽象,它约定了如下核心公共接口,任何实现该接口的类都是可读流。Node原生stream模块提供Readable类实现了该接口

interface Readable extends Event.Emitter{
    read([size]) // 从buffer读取指定大小数据并返回
    _read([size]) // 由子类覆盖,从底层资源(比如水龙头)获取数据
    pipe(Writable) // 通过管道自动进入流动模式,内部管理状态使得可写流匹配可读流速度
    pause() // 处于流动模式的流停止触发data事件,读取的数据暂存到buffer中
    resume() // 恢复流动模式
    // 暴露data、readable、end、destroy等事件
}

stream模块内提供的Readable可读流实现,包括两种模式

  • 流动模式:理解成自动化获取流数据的机制,只要程序内部监听data事件就会自动开启,另外该类还提供两个方法开启该模式
    • Readable.resume方法
    • Readable.pipe方法:需要提供可写流对象,把可读流数据自动发送到可写流
  • 暂停模式:手动调用stream.read方法从buffer读取数据

那么从底层实现角度,可读流和buffer是什么关系?事实上,stream模块提供的Readable可读流类,或者下面介绍的Writable可写流类,每个流的底层都依赖buffer决定读取/写入的最多数据量,可以通过如下图帮助理解:

image.png

可读流内部的生产者就是用户自定义的_read方法,消费者是内部提供的read方法,_read由read方法驱动,即当调用read方法时,内部调用_read来填充buffer,如果buffer的大小达到阈值,就停止调用_read;read方法通过触发data事件来通知外部消费。同理,可写流和buffer关系也是类似的。

可写流

定义:可写流对数据写入目标的抽象,它约定如下核心接口,任何实现该接口的类都是可写流,Node原生stream模块提供Writable类实现了该接口

interface Writable extends Event.Emitter {
    write(chunk[,encoding][,callback]) //将数据写入目标流,写入成功后调用callback
    end() // 通知不再有流写入
    // 暴露一些公共状态变量
    //暴露drain、close、pipe、finish等事件
}

核心API:pipe理解

pipe:类似Unix命令行中“|”的作用,表示管道,管道实现了数据流动

为什么选择pipe函数分析:这个函数串联了读写流,内部包括读写流的核心实现,弄懂了该API原理对理解流的实现有帮助

使用方式:source.pipe(dest),其中source表示可读流,dest表示可写流。也可以继续通过级联方式接入更多可写流,比如source.pipe(dest1).pipe(dest2)

核心原理:由dest可写流决定source可读流的暂停和恢复,那么source和dest内部是怎么互相协同来实现数据流动?

可读流

这里分两个过程来看

1、初始化:驱动数据开始流动的逻辑,默认开启流动状态。具体做法如下

//Readable原型对象内部对on方法做二次封装 
Readable.prototype.on = function(ev, fn) {
    const res = Stream.prototype.on.call(this, ev, fn);
    const state = this._readableState;

    if (ev === 'data') {
        // 初始化时flowing=null,满足这个条件
        if (state.flowing !== false)
            this.resume(); //在这里开启流动模式
        }
    }
}
function resume() {
    // 开启流动模式
    state.flowing = true;
    process.nextTick(resume_, stream, state);
}
function resume_(stream, state) {
    //...
    // 从buffer内预读取数据
    read(0); 
    flow(stream);
}
function flow(stream) {
    // state是可读流内部维护的所有状态,包括是否处于流动模式flowing
    while(state.flowing && stream.read() != null);
}

在on监听器内部,判断event是否为data,且flowing状态不为false,调用resume来切换到流动模式。resume代码内部执行真正的读逻辑

2、从数据源读取数据过程:主要调用read和flow两个方法,该方法内部逻辑复杂,通过如下图显示流动模式相关的调用逻辑:

image.png 结合上述代码和上图逻辑可以看出resume_和flow内部调用read方法,但二者调用read方法的参数不一致:

  • 参数为0表示调用_read从外部数据源读取数据,写入到内部buffer,但不触发data事件
  • flow内部调用的read参数为空,此时内部会读取外部数据源数据并触发data事件。pipe函数内,注册了data事件的回调函数ondata,ondata函数内真正实现数据缓冲限制在可接受的水平,以匹配不同读写流的速度,代码如下
function ondata(stream){
    // 调用dest可写流的write方法
    const ret = dest.write(chunk);
    // 如果写入流返回失败
    if (ret === false) {
        src.pause(); // 停止流动
        if (!ondrain) {
            // 注册过了就不用再注册
            ondrain = pipeOnDrain(src, dest);
            // 注册可写流的drain事件
            dest.on('drain', ondrain);
        }
    }
}
function pipeOnDrain(src, dest) {
    return function pipeOnDrainFunctionResult() {
        //...
        state.flowing = true;
        flow(src);
    }
}

可写流

write是可写流内部的核心方法,整个调用图如下:

image.png write的返回值由writeOrBuffer决定,看下该函数的主要逻辑

Writable.prototype.write(chunk, encoding, cb) {
    //...
    ret = writeOrBuffer(this, state, chunk, encoding, cb);
    return ret;
}
function writeOrBuffer(stream, state, chunk, encoding, cb) {
    state.length += chunk.length;
    // write的返回值如下逻辑决定:只要写入chunk超过阈值就返回false
    const ret = state.length < state.highWaterMark;
    // 记录状态,表示是否要触发drain事件
    if (!ret)
        state.needDrain = true;
    // 还没写完成,或者业务手动调用corked方法
    if (state.writing || state.corked) {
        // 把chunk缓存到bufferedRequest队列里,等到doWrite完成后再从bufferedRequest队列中获取数据并写入流
    } else {
        doWrite(stream, state, false, len, chunk, encoding, cb);
    }
    return ret;
}

显然,只要写入长度超过阈值返回false,数据将停止写入流。数据是否继续写入流的逻辑由onwrite内部逻辑决定,onwrite是用户自定义函数_write执行成功后的callback函数,_write函数职责是将数据写入流,这和业务需求有关,所以需要业务层面自定义;onwrite核心实现如下:

function onwrite(stream, er) {
    // 写完后更新内部状态
    // 如果判断有chunk缓存,那么先调用clearBuffer函数继续写入并清空缓存
    // bufferedRequest就是缓存队列
    if (!finished && !state.corked && !state.bufferedRequest){
        clearBuffer(stream, state);
    }
    if (sync) {
        // 忽略中间步骤,最后调用afterWrite函数
        afterWrite(stream, state);
    }
}

function afterWrite(stream, state) {
    // state.needDrain状态在writeOrBuffer函数内设置过
    const needDrain = state.length === 0 && state.needDrain;
    // 如果数据都被写入成功了,并且state.needDrain设置过true,说明可写流没有数据可写了
    if (needDrain) {
        state.needDrain = false;
        // 触发drain事件,说明数据适合继续写入流了,流动模式下可读流根据该事件获知可以继续读取流数据
        stream.emit('drain');
    }
}

基于可读流read和可写流和write方法内部调用关系图,pipe内部通过串联二者实现管道功能的语义就比较清晰,如下图,由write方法决定可读流的暂停和恢复逻辑。 image.png

实际上读写流内部维护了多个状态,状态流转过程比较复杂,建议读者有兴趣可以进一步深入源码研究。从设计角度,stream模块的API也值得学习,比如

  • 每个模块的职责明确,目的单一:Readable和Writable类各自暴露公共的接口给开发者使用,内部的私有状态独立成一个函数单独管理,私有状态只对类内部可更新
  • 提供足够的状态记录和对外接口查询,状态记录通过内部函数管理,为了防止调用者修改状态,对外通过Object.defineProperty定义状态的get操作来感知当前读写流的状态