Koa 中间件加载机制 源码解析(对比midway compose)

162 阅读4分钟

上一篇文章从0到1 讲解了express中间件的加载以及运行原理,本文再将koa从0至1根据源码来解析koa的中间件加载机制。
上一篇请参考express中间件加载原理

闲言少讲,放码过来,本次同样是以koa-generator 生成的极简项目为例,来看看koa的中间件是如何定义以及使用的。

首先不得不夸一下,koa良心了,在初始化项目时安装了nodemon,直接执行npm run dev就会使用nodemon启动项目,妈妈再也不用担心我更新文件之后忘记重启了。

  "scripts": {
    "start": "node bin/www",
    "dev": "./node_modules/.bin/nodemon bin/www",
    "prd": "pm2 start bin/www",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

首先来看最常用的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;
  }

what? 竟然这么简单?什么都没有处理?就简简单单的把传入的function传入到了middleware对象中,相比较起来,比express确实简单了很多。

那好吧,我们也不必纠结,毕竟koa是express之后的一代,相比于前一代肯定要进步很多。那么让我们来看看它在使用的时候是怎么处理的吧。

通过源码我们可知,创建server的时候,koa传入了this.callback(),那么请求来了之后,就会执行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;
  }

callback 方法中我们终于见到了著名的洋葱模型,compose函数。可以看到,callback函数的执行结果是返回handleRequest函数,而这个函数则是通过传入的req和res,生成ctx对象,在调用application的hanldeRequest方法,去处理。而handleRequest方法也很简单,就是执行我们通过compose函数生成的函数,

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

那么接下来就简单了,我们来看看compose函数,究竟都作了些什么?

   
/**
 * Compose `middleware` returning
 * a fully valid middleware comprised
 * of all those which are passed.
 *
 * @param {Array} middleware
 * @return {Function}
 * @api public
 */

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

koa-compose方法很简单,只有短短的三十行代码,通过前文可知,middleware就是用户通过use方法传入的一个一个的function。而compose方法只是简单的作了一下处理,然后就返回了一个匿名函数,在fnMiddleware(ctx)调用的时候,这个函数才真正的被调用到。

dispatch函数返回的时候使用promise.resolve处理了一下,是防止传入的函数是非promise函数,就无法进行promise的链式调用了。然后执行了取到的第0个函数,以及下一个next函数,即dispatch.bind(null, i+1)。通过bind函数特性可知,我们在调用next函数时,将会直接bind中传入的i+1作为参数。那么就不用担心用户会传入错误的参数。而且下一步调用的位置也可以有fn中的函数自己来决定,即可以先执行next或者后执行next,或者再执行的中间执行next,从而达到了洋葱模型的效果。

使用时方法如下:

app.use(function middleware1(ctx, next) {
  console.log(1);
  next();
  console.log(2);
});

app.use(function middleware2(ctx, next) {
  console.log(3);
  next();
  console.log(4);
});

在执行时的输出顺序为:1,3,4,2 。

看到源码时我突然有个疑问❓就是app。use方法,是取的入参的第一项作为function的,那么如果你传入多项还有意义么?使用koa2-generator生成的app.js,默认路由的如参数是这样的

app.use(index.routes(), index.allowedMethods())
app.use(users.routes(), users.allowedMethods())

所以其实第二项入参是无用的。这应该算是一个bug吧。

midway的compse函数做了什么处理?

midway在官方文档中讲midway对洋葱模型做了一些微调,那么微调了哪些内容呢?

image.png

const composeFn = (context: T, next?) => {
      const supportBody = isIncludeProperty(context, 'body');
      // last called middleware #
      let index = -1;
      return dispatch(0);

      function dispatch(i) {
        if (i <= index)
          return Promise.reject(
            new MidwayCommonError('next() called multiple times')
          );
        index = i;
        let fn = (newMiddlewareArr as Array<FunctionMiddleware<T, R, N>>)[i];
        if (i === newMiddlewareArr.length) fn = next;
        if (!fn) return Promise.resolve();
        try {
          if (supportBody) {
            return Promise.resolve(
              fn(context, dispatch.bind(null, i + 1), {
                index,
              } as any)
            ).then(result => {
              /**
               * 1、return 和 ctx.body,return 的优先级更高
               * 2、如果 result 有值(非 undefined),则不管什么情况,都会覆盖当前 body,注意,这里有可能赋值 null,导致 status 为 204,会在中间件处进行修正
               * 3、如果 result 没值,且 ctx.body 已经赋值,则向 result 赋值
               */
              if (result !== undefined) {
                context['body'] = result;
              } else if (context['body'] !== undefined) {
                result = context['body'];
              }
              return result;
            });
          } else {
            return Promise.resolve(
              fn(context, dispatch.bind(null, i + 1), {
                index,
              } as any)
            );
          }
        } catch (err) {
          return Promise.reject(err);
        }
      }
    };

其实就是重点改造的就是添加了中文注释的那一部分。在midway中,可以通过return方法对中间件进行传参,在实际的处理过程中,是如果result存在,那么就会将result赋值给context.body,反之如果不存在,但是context.body存在,则会将其赋值给result。这么做很大程度上方便了开发者,可以对上一个函数的处理结果进行修改。可是同样的也会存在一些问题,比如说某一个中间件返回时返回了一个全新的东西,将上一个中间件的结果遗弃了,那么后续的中间件都会以这个中间件返回的结果作为结果。这样的处理方式仁者见仁。在开发时需要多加注意。

其实洋葱模型的理解主要还是需要开发者debug,一步一步断点的方式去调试,这样才能理解的更加透彻。使用vscode的 JavaScript Debug Terminal 可以很方便的进行调试, 使用cmd+shift+p,试一下吧~。