# 从源码理解Koa洋葱模型

Koa框架的洋葱模型非常著名,但是之前只了解其概念,没有深入过代码细节。今天得空看了一下Koa中间件这部分的代码设计。不得感叹Koa的代码还真是精简。目测全部代码可能也就2000行左右的样子吧。废话不多说,先上一张经典洋葱模型图,然后再看源码。

# 中间件模型

洋葱模型

从图上可以很直观的看出,koa中间件是先进后出的处理逻辑,跟栈是类似的。可以联想到比如调用栈、递归等。

koa中间件的定义是一个函数,可以是async函数或者普通函数。中间件函数签名、或者说范式如下

async function middleWare(ctx: Object, next: Function) {
    // Do something
    await next();
    // Do something
}
1
2
3
4
5

中间件函数接受两个参数,第一个是koa在启动时创建好的上下文环境,里面包含了req,res等数据信息。第二个next函数是koa在调度中间件的时候传进来的一个函数,用来调用下一个中间件或结束中间件的递归调用。

# Koa注册中间件

中间件由koa提供的use方法来注册。

import Koa from 'koa'

const app = new Koa()

function middleWare(ctx, next) {
    next()
}

app.use(middleWare)

app.listen(8080)
1
2
3
4
5
6
7
8
9
10
11

这样我们就算是启动了koa程序,并且注册了一个中间件。当然现在什么事都没有做。

在中间件函数体内部,我们可以在next函数调用前后分别做一些逻辑处理,next方法前的逻辑,是按中间件的注册顺序执行的,反之则是中间件注册的相反顺序。这是由于栈、或者说递归的特性导致的。

个人思考

中间件函数体内部的逻辑,我认为跟树的遍历是类似的。不同之处在于,中间件做了一次流程控制,而树的遍历需要两次流程控制(左右子树)。

为了证明这一点,我们注册三个中间件mw1mw2mw3,分别在next方法前后添加一些日志。

function mw1(ctx, next) {
    console.log('mw1 start')
    next()
    console.log('mw1 end')
}

// mw2 mw3类似

app
    .use(mw1)
    .use(mw2)
    .use(mw3)

app.listen(8080)
1
2
3
4
5
6
7
8
9
10
11
12
13
14

浏览器打开本地8080端口,可以看到下面的log koa_mw

这就是整个koa中间件整个洋葱模型的运行机制,下面进入koa源码看看,这种机制是怎么实现的。

# Koa中间件处理

Koa初始化App监听端口成功后,针对中间件有如下操作:

  1. 初始化一个空数组,用来存放注册的中间件;

    const Emitter = require('Event')
    class Appliction extends Emitter {
        constructor() {
            super()
            this.middleware = []
            // ...
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8

    这个文件定义了Koa的app类,该类继承自node核心模块中的Emitter类。我们开发时常用的uselisten等方法都在这个类里定义。

    当app实例创建完毕。接下来可能会使用use方法进行中间件注册。

    use(fn) {
         if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
         if (isGeneratorFunction(fn)) {
             deprecate('Support for generators will be removed in v3. ' +
                         'See the documentation for examples of how to convert old middleware ' +
                         'https://github.com/koajs/koa/blob/master/docs/migration.md');
             fn = convert(fn);
         }
         debug('use %s', fn._name || fn.name || '-');
         this.middleware.push(fn);
         return this;
     }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12

    函数逻辑很简单。中间件必须是一个函数,且v3版本之后不再支持generator函数作为中间件,这里会自动使用convert函数将generator函数转为async函数。最后将中间件注册到实例上并且将实例返回。

  2. App.listen函数执行之后,将所有注册的中间件进行compose操作,返回一个新的函数,这个函数调用时,会起到递归调用中间件的效果;

    中间件注册完毕之后。接下来就是使用listen方法启动http服务,监听端口。

    listen(...args) {
        debug('listen');
        const server = http.createServer(this.callback());
        return server.listen(...args);
    }
    
    1
    2
    3
    4
    5

    listen使用node核心模块http起了一个http服务,并且调用了实例上的callback,其返回值作为http.createServer的参数。

    我们知道,http.createServer的回调函数即它的参数的签名如下

    function fn(req, res) => {}
    
    1

    因此。callback返回的应该也是一样的函数。下面看看callback函数做了什么。

    callback() {
        const fn = compose(this.middleware);
    
        if (!this.listenerCount('error')) this.on('error', this.onerror);
    
        const handleRequest = (req, res) => {
            const ctx = this.createContext(req, res);
            return this.handleRequest(ctx, fn);
        };
    
        return handleRequest;
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12

    首先它返回一个handleRequest函数。这个函数就是作为listen函数的参数的。callback函数首先调用compose函数处理中间件注册列表。将执行结果和上下文一起作为请求监听函数的参数。

    注意

    这里有两个handleRequest函数。一个是实例方法,该方法监听处理http请求。一个是我们自己创建的局部函数,作为http.createServer的参数使用。

    接下来再深入到compose函数中,看看它对中间件做了什么样的处理。中间件是如何递归调用的。

    function compose (middleware) {
        if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
        for (const fn of middleware) {
            if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
        }
    
        /**
        * @param {Object} context
        * @return {Promise}
        * @api public
        */
    
        return function (context, next) {
            // last called middleware #
            let index = -1
            return dispatch(0)
            function dispatch (i) {
                if (i <= index) return Promise.reject(new Error('next() called multiple times'))
                index = i
                let fn = middleware[i]
                if (i === middleware.length) fn = next
                if (!fn) return Promise.resolve()
                try {
                    return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
                } catch (err) {
                    return Promise.reject(err)
                }
            }
        }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30

    这是compose函数的全部内容。逻辑还是比较长的,我们拆解开分析一下。

    首先最上面的部分,参数必须是一个Function类型的数组。否则直接结束。

    接下来是核心的逻辑部分。compose返回了一个函数匿名函数fnMiddleware。它是一个闭包,内部记录了当前请求发生时,中间件调用的位置。首先调用dispatch(0),也就是开始调用第一个中间件。

    接下来是dispatch函数内部的逻辑。首先是参数i和外部变量index做了对比。这个放到之后再说。下面参数i的值赋给index。表示当前第i个中间件要被正式调用了。接下来将第i个中间件取出存入局部变量fn,并且对中间件读取做了越界检测。如果fn是空值,则代表中间件递归结束。接下来的try catch块中,中间件被调用,并且中间件调用的第二个参数是dispatch函数。由前面的逻辑可知,dispatch是用来执行中间件的,这里将i的值加1,表示dispatch下一个中间件。

    还记得中间件函数签名吗?

    fn middleware(ctx, next) {}
    
    1

    这里的第二个next参数,就是这里的dispatch函数。因此我们在中间件中调用next(),就等于是执行dispatch(i+1)。 如果不小心在同一个中间件中调用了两次next函数。也就是dispatch(i+1)被执行了两次。此时dispatch函数开头部分的检测就会起到作用,它保证了每个中间件在每次调用链中都只调用一次。如果中间件是幂等的,多次调用倒也没什么功能方面的影响。但是为了避免任何出错的可能,koa只允许每个中间件调用一次。

  3. compose返回的fnMiddleware函数,将作为实例上的handleRequest方法的参数。每次收到http请求时,都会调用fnMiddleware函数,即递归调用中间件。

这就是koa中间件模型中的整个流程了。源码方面还是比较简洁易懂。