nodejs之koa中间件源码解析

 阅读约 13 分钟

前言

上一篇《nodejs之express中间件》已经对express中间件的实现做了详细的讲解,同时也对实现中间件的框架Connect的源码做了简单的分析。并且也提到了express的中间件是直线型,koa的中间件是洋葱型。本篇就来说说koa的中间件。

koa介绍

koa和express是同一个团队开发的。与express很像,也是一个自身功能极简的框架,所以在一个项目中所需要的东西大多是以中间件的形式引入。

目前koa有1.x和2.x版本,1.x版本基于generator,2.x版本基于
generator/async、await。由于generator的语法相比async又很明显的劣势,所以后续的版本中会去掉generator的使用,而是全部采用async的方式。不过在2.x这个过渡版中依然兼容generator,需要注意的是在使用generator或者使用了依赖generator的第三方库时,会报出一个警告,大致意思“generator在当前版本还可以正常使用,但是会在后续的版本中移除”。
注:本文所有koa的写法都是koa2版本的。并且兼容koa3。

koa中间件

相比express的直线型中间件,koa的中间件就不是那么直观了。先看一张图
在这里插入图片描述
把洋葱的一圈看做是一个中间件,直线型就是从第一个中间件走到最后一个,但是洋葱型就很特殊了,最早use的中间件在洋葱的最外层,开始的时候会按顺序走到所有中间件,然后按照倒序再走一遍所有的中间件,相当于每个中间件都会进入两次。这就给了我们更多的操作空间。
看下面一段代码

const koa = require('koa');

let server = new koa();

server.use(async (ctx, next) => {
    console.log('a-1');
    next();
    console.log('a-2');
})

server.use(async (ctx, next) => {
    console.log('b-1');
    next();
    console.log('b-2');
})

server.use(async (ctx, next) => {
    console.log('c');
})

server.listen(3000);

代码执行后命令行输出顺序为
在这里插入图片描述

  • koa官方文档上把外层的中间件称为"上游",内层的中间件为"下游"。

一般的中间件都会执行两次,调用next之前为第一次,调用next时把控制按顺序传递给下游的中间件。当下游不再有中间件或者中间件没有执行next函数时,就将依次恢复上游中间件的行为,让上游中间件执行next之后的代码。

  • 如果某一个中间件中有异步代码,最好使用async/await处理异步。

使用方式我们已经搞清了,下面就看看内部实现原理

源码解析

在github上找到koa的源码,核心的函数都在application.js中。
经过上一篇connect源码的学习,这部分源码读起来应该也是压力不大。全部代码也就200多行,这里我只截取其中比较重要的部分。

'use strict';
module.exports = class Application extends Emitter {

  constructor() {
    super();

    this.proxy = false;
    this.middleware = [];
    this.subdomainOffset = 2;
    this.env = process.env.NODE_ENV || 'development';
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
    if (util.inspect.custom) {
      this[util.inspect.custom] = this.inspect;
    }
  }

  listen(...args) {
    debug('listen');
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }

  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;
  }

  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;
  }

  handleRequest(ctx, fnMiddleware) {
    const res = ctx.res;
    res.statusCode = 404;
    const onerror = err => ctx.onerror(err);
    const handleResponse = () => respond(ctx);
    onFinished(res, onerror);
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
  }
};

1.先来重点看下use函数
第一个if是用来做校验的,第二个if是用来兼容generator函数的。
use的最后执行了this.middleware.push(fn);就是将中间件放到middleware数组中,类似connect中的stack数组。

2.再来看下callback函数
callback函数的返回值就是listen函数中执行createServer函数的回调,也就是handleRequest函数,也可以理解成是用来响应request事件的函数。

3.注意在callback中执行this.handleRequest(ctx, fn);的时候这个fn是中间件数组通过compose函数处理后返回的值。那在看handleRequest函数中最后就调用了这个函数,那么在这之前就要知道compose函数对中间件数组做了什么?

找到koa-compose这个包,截取其中的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!')
    }

    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)
            }
        }
    }
}

前面两段很明显是在验证数组和函数的类型。然后就是返回一个函数,这个函数就是callback函数中的fn,也是在传入handleRequest函数中最后调用的那个函数。
先总结下上面的内容:

  1. 在use函数中先把中间件添加到this.middleware数组中。
  2. callback函数中先通过compose函数对this.middleware数组进行操作,将数组转换为一个函数赋值给fn(注意这个函数比较特殊,下面会详细讲解),然后再通过handleRequest函数去调用这个fn函数,显而易见这个函数一定返回Promise对象。

这个流程如果搞清楚后,再来看看这个compose到底做了什么。
实际上compose就是返回一个函数,函数内利用闭包实现了对中间件数组的遍历,返回一个Promise对象,并且resolve回调就是执行当前索引对应的中间件函数,并且将参数next赋值为内部闭包函数的调用,调用的同时将索引值+1,这样在中间件中执行next()就相当于执行了dispatch(i+1),自然就找到了下一个中间件。如果中间件没有执行next或者遍历了数组中全部的中间件后,自然就开始一级一级向上执行每个中间件next后的代码。这就是洋葱圈的实现。

这里的逻辑稍微有点饶,如果还是不懂也没关系,我将代码流程简化,通过图片再来说下整个实现过程。

在这里插入图片描述
整个核心还是compose函数。如果将这个函数简化到极致,就是如下的代码

function a(){
    console.log('a-1');
    next(b);
    console.log('a-2');
}
function b(){
    console.log('b-1');
    next(c);
    console.log('b-2');
}
function c(){
    console.log('c');
}
function next(fn){
    return Promise.resolve(fn());
}

// 输出结果为
//  a-1
//  b-1
//  c
//  b-2
//  a-2

总结

到此我们已经分析了express中间件和koa中间件的实现原理。
二者的源码都是非常简洁,并且设计巧妙、可读性高。很多细节处理都值得借鉴。
希望自己在源码阅读的道路上越走越远。

阅读 149更新于 2019-12-10

推荐阅读
目录