koa洋葱结构解析

351 阅读4分钟

image.png

经常在使用 koa 的时候,通过 .use 的形式来注册各种中间件,例如下面一段代码

app.use(async (ctx, next) => {
  console.log(1);
  await next();
  console.log(2);
});
app.use(async (ctx, next) => {
  console.log(3);
  await next();
  console.log(4);
});

这里会输出 1,3,4,2,下面就来翻看一下源码看看这个中间件实现的具体原理。

在看具体代码之前,先温习一下,使用 koa 的最小运行代码是什么样的

const Koa = require("koa");
const app = new Koa();

// response
app.use((ctx) => {
  ctx.body = "Hello Koa";
});

app.listen(3000);

可以看到,最后通过 listen 方法来启动服务,那我们重点先看下 use 和 listen 做了什么事情。

use

  use (fn) {
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!')
    debug('use %s', fn._name || fn.name || '-')
    this.middleware.push(fn)
    return this
  }

代码比较少,这里直接贴上去了,use 的主要作用就是给 middleware 添加相对应的 fn。

listen

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

这里 server 是 http 的库的方法,我们先不管,主要看一下 this.callback 做了什么事情。

  callback () {
    const fn = this.compose(this.middleware)

    if (!this.listenerCount('error')) this.on('error', this.onerror)

    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res)
      if (!this.ctxStorage) {
        return this.handleRequest(ctx, fn)
      }
      return this.ctxStorage.run(ctx, async () => {
        return await this.handleRequest(ctx, fn)
      })
    }

    return handleRequest
  }

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

可以看到,最终是把 this.compose 返回的 fn 进行了调用,那么由此可以知道 this.compose 就是具体中间件调度的具体实现。

compose

this.compose 可以在 constructor 中看到,默认情况下就是 koa-compose,这个库也非常精简只有 50 行代码,下面会通过注释的形式来对源码进行一个说明。

// 省略部分注释和部分代码

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!");
  }
  // 这里默认返回一个函数,handleRequest函数会调用这个返回的函数,并且传递 context
  return function (context, next) {
    let index = -1;
    // 默认情况下执行一次 dispatch ,dispatch因为是函数声明所以会提升到做作用域顶部
    return dispatch(0);
    function dispatch(i) {
      // 通常情况下不会遇到,但是如果执行两次就会抛出异常,例如第一次调用index为-1,i为0,第二次执行则变成index为0,i也为0则抛出错误
      if (i <= index)
        return Promise.reject(new Error("next() called multiple times"));
      index = i;
      let fn = middleware[i];
      // 这里从koa的实现可以看到,是没有传递next的,所以这行代码可以跳过
      if (i === middleware.length) fn = next;
      // 执行到最后一项的时候直接返回不再继续递归下去
      if (!fn) return Promise.resolve();
      try {
        // 这里实现很巧妙,利用了bind的原理,bind的第一个参数为this,之后的参数为函数的预设值,最后返回一个函数
        // 然后根据Promise.resolve的实现规范,如果传递的Promise.resolve是一个Promise要等待新的Promise执行完成之后决定状态
        // 这里推荐看下PromiseA+规范实现
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err);
      }
    }
  };
}

ok,这里基本上就讲完了,还是对照最初的示例来看

app.use(async (ctx, next) => {
  console.log(1);
  await next();
  console.log(2);
});
app.use(async (ctx, next) => {
  console.log(3);
  await next();
  console.log(4);
});

这里传递给 compose([fn1, fn2]),之后 i 为 0,返回

const fn = async (ctx, next) => {
  console.log(1);
  await next();
  console.log(2);
};
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));

之后 i 为 1 的时候

const fn = async (ctx, next) => {
  console.log(3);
  await next();
  console.log(4);
};
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));

此时 i 为 2,发现数组取不到值了,执行

if (!fn) return Promise.resolve();

回到最最后一步,还没有解释为什么会洋葱结构这样来执行代码

app.use(async (ctx, next) => {
  console.log(1);
  await next();
  console.log(2);
});
app.use(async (ctx, next) => {
  console.log(3);
  await next();
  console.log(4);
});

这里我们知道先执行第一个函数

  1. 输出 console.log(1);
  2. 执行的过程中遇到 await next() 会执行 next,而从源码 dispatch.bind(null, i + 1) 可以知道下一项就是第二个函数
  3. 执行第二个函数
  4. 输出 console.log(3)
  5. 继续执行 await next() 从源码知道 i 为 2 的时候,返回的是 if (!fn) return Promise.resolve();
  6. 执行 conosle.log(4),返回 Promise 状态为已完成,结果为 undefined
  7. 执行 console.log(2)

上面可能有点绕,但是其实 koa-compose 利用了事件循环的机制,对于微任务每次执行都会放到微任务队列,等待主线程执行栈调用,而栈的特点就是先进后出,所以这也是为什么会输出 1,3,4,2 的原因了。

最后

如果文章有说的不对地方欢迎指出,最后本人正在找工作,有相关 hc 岗位欢迎滴滴。