koa2 compose理解及模拟实现

介绍

Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。 通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。 Koa 并没有捆绑任何中间件, 而是提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序。

学习koa-compose之前,先看一下这两张图

基本使用

const Koa = require('../../lib/application');

// const Koa = require('koa');
const app = new Koa();

// x-response-time

app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  ctx.set('X-Response-Time', `${ms}ms`);
});

// logger

app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  console.log(`${ctx.method} ${ctx.url} - ${ms}`);
});

// response

app.use(async ctx => {
  ctx.body = 'Hello World';
});

app.listen(3000);

  • 创建一个跟踪响应时间的日期
  • 等待下一个中间件的控制
  • 创建另一个日期跟踪持续时间
  • 等待下一个中间件的控制
  • 将响应主体设置为“Hello World”
  • 计算持续时间
  • 输出日志行
  • 计算响应时间
  • 设置 X-Response-Time 头字段
  • 交给 Koa 处理响应

看完这个gif图,也可以思考下如何实现的。根据表现,可以猜测是next是一个函数,而且返回的可能是一个promise,被await调用。

阅读koa-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];

      // next的值为undefined,当没有中间件的时候直接结束
      // 其实这里可以去掉next参数,直接在下面fn = void 0,和之前的代码效果一样
      // if (i === middleware.length) fn = void 0;
      if (i === middleware.length) fn = next;

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

      try {
        // fn就是中间件函数, dipatch(i)调用的就是第i个中间件函数
        // eg :                app.use((ctx,next) => { next()})

        // 第 1 次 reduce 的返回值,下一次将作为 a
        // arg => fn1(() => fn2(arg));

        // 第 2 次 reduce 的返回值,下一次将作为 a
        //         arg => (arg => fn1(() => fn2(arg)))(() => fn3(arg));
        // 等价于...
        //         arg => fn1(() => fn2(() => fn3(arg)));

        // 执行最后返回的函数连接中间件,返回值等价于...
        // fn1(() => fn2(() => fn3(() => {})));
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err);
      }
    }
  };
}

上面的代码等价于

// 这样就可能更好理解了。
// simpleKoaCompose
const [fn1, fn2, fn3] = this.middleware;
const fnMiddleware = function(context){
    return Promise.resolve(
      fn1(context, function next(){
        return Promise.resolve(
          fn2(context, function next(){
              return Promise.resolve(
                  fn3(context, function next(){
                    return Promise.resolve();
                  })
              )
          })
        )
    })
  );
};


fnMiddleware(ctx).then(handleResponse).catch(onerror);

也就是说koa-compose返回的是一个PromisePromise中取出第一个函数(app.use添加的中间件),传入context和第一个next函数来执行。

第一个next函数里也是返回的是一个PromisePromise中取出第二个函数(app.use添加的中间件),传入context和第二个next函数来执行。

第二个next函数里也是返回的是一个PromisePromise中取出第三个函数(app.use添加的中间件),传入context和第三个next函数来执行。

第三个...

以此类推。最后一个中间件中有调用next函数,则返回Promise.resolve。如果没有,则不执行next函数。
这样就把所有中间件串联起来了。这也就是我们常说的洋葱模型。

模拟实现

同步实现

文件app.js

// 模拟 Koa 创建的实例
class app {
    constructor(){
        this.middlewares = []
    }

    use(fn){
        this.middlewares.push(fn)
    }

    compose() {
        // 递归函数
        let self = this;
        function dispatch(index) {
             // 如果所有中间件都执行完跳出
             if (index === self.middlewares.length) return;
    
             // 取出第 index 个中间件并执行
             const midFn = self.middlewares[index];
             return midFn(() => dispatch(index + 1));
         }

         取出第一个中间件函数执行
         dispatch(0);
    }
};

module.exports = new app();

上面是同步的实现,通过递归函数 dispatch 的执行取出了数组中的第一个中间件函数并执行,在执行时传入了一个函数,并递归执行了 dispatch,传入的参数 +1,这样就执行了下一个中间件函数,依次类推,直到所有中间件都执行完毕,不满足中间件执行条件时,会跳出,这样就按照上面案例中 1 3 5 6 4 2 的情况执行,测试例子如下(同步上、异步下)。

文件sync-test.js

const app = require("./app");

app.use(next => {
    console.log(1);
    next();
    console.log(2);
});

app.use(next => {
    console.log(3);
    next();
    console.log(4);
});

app.use(next => {
    console.log(5);
    next();
    console.log(6);
});

app.compose();
// 1
// 3
// 5
// 6
// 4
// 2

文件async-test.js

const app = require("./app");

// 异步函数
function fn() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve();
            console.log("hello");
        }, 3000);
    });
}

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

app.use(async next => {
    console.log(3);
    await fn(); // 调用异步函数
    await next();
    console.log(4);
});

app.use(async next => {
    console.log(5);
    await next();
    console.log(6);
});

app.compose();
// 1
// 3
// hello
// 5
// 6
// 4
// 2

我们发现如果案例中按照 Koa 的推荐写法,即使用 async 函数,都会通过,但是在给 use 传参时可能会传入普通函数或 async 函数,我们要将所有中间件的返回值都包装成 Promise 来兼容两种情况,其实在 Koacompose 最后返回的也是 Promise,是为了后续的逻辑的编写,但是现在并不支持,下面来解决这两个问题。

注意:后面 compose 的其他实现方式中,都是使用 sync-test.js 和 async-test.js 验证,所以后面就不再重复了。

升级为异步,其实就是koa-compose的实现(简化版)

compose() {
        // 递归函数
        let self = this;
        function dispatch(index) {
            // 异步实现
            // 如果所有中间件都执行完跳出,并返回一个 Promise
            if (index === self.middlewares.length) return Promise.resolve();

            // 取出第 index 个中间件并执行
            const route = self.middlewares[index];

            // 执行后返回成功态的 Promise
            return Promise.resolve(route(() => dispatch(index + 1)));
        }

        // 取出第一个中间件函数执行
        dispatch(0);
}

我们知道 async 函数中 await 后面执行的异步代码要实现等待,待异步执行后继续向下执行,需要等待 Promise,所以我们将每一个中间件函数在调用时最后都返回了一个成功态的 Promise,使用 async-test.js进行测试,发现结果为 1 3 hello(3s后) 5 6 4 2

reduceRight实现(Redux旧版使用逆序归并)

  • 同步实现
compose () {
    return self.middlewares.reduceRight((a, b) => () => b(a), () => {})();
};

上面的代码看起来不太好理解,我们不妨根据案例把这段代码拆解开,假设 middlewares 中存储的三个中间件函数分别为 fn1fn2fn3
由于使用的是 reduceRight 方法,所以是逆序归并,第一次 a 代表初始值(空函数),b代表fn3,而执行 fn3 返回了一个函数,这个函数再作为下一次归并的 a,而 fn2作为b`,依次类推,过程如下:

// 第 1 次 reduceRight 的返回值,下一次将作为 a
() => fn3(() => {});

// 第 2 次 reduceRight 的返回值,下一次将作为 a
() => fn2(() => fn3(() => {}));

// 第 3 次 reduceRight 的返回值,下一次将作为 a
() => fn1(() => fn2(() => fn3(() => {})));

由上面的拆解过程可以看出,如果我们调用了这个函数会先执行 fn1,如果调用 next 则会执行 fn2,如果同样调用 next 则会执行 fn3fn3 已经是最后一个中间件函数了,再次调 next 会执行我们最初传入的空函数,这也是为什么要将 reduceRight初始值设置成一个空函数,就是防止最后一个中间件调用 next 而报错。经过测试上面的代码不会出现顺序错乱的情况,但是在 compose 执行后,我们希望进行一些后续的操作,所以希望返回的是 Promise,而我们又希望传入给 use 的中间件函数既可以是普通函数,又可以是 async 函数,这就要我们的 compose 完全支持异步

  • 异步实现
compose() {
    // reduceRight, 逆序归并
    return Promise.resolve(
        self.middlewares.reduceRight(
            (a, b) => () => Promise.resolve(b(a)),
            () => Promise.resolve()
        )()
    )
}

参考同步的分析过程,由于最后一个中间件执行后执行的空函数内一定没有任何逻辑,但为遇到异步代码可以继续执行(比如执行 next 后又调用了 then),都处理成了 Promise,保证了 reduceRight 每一次归并的时候返回的函数内都返回了一个 Promise,这样就完全兼容了 async普通函数,当所有中间件执行完毕,也返回了一个 Promise,这样 compose 就可以调用 then 方法执行后续逻辑。

reduce(Redux新版使用正序归并)

  • 同步实现
compose () {
    return self.middlewares.reduce((a, b) => arg => a(() => b(arg)))(() => {});
};

Redux 新版本中将 compose 的逻辑做了些改动,将原本的 reduceRight 换成 reduce,也就是说将逆序归并改为了正序,我们不一定和 Redux 源码完全相同,
是根据相同的思路来实现串行中间件的需求。个人觉得改成正序归并后更难理解,所以还是将上面代码结合案例进行拆分,中间件依然是 fn1fn2fn3,由于reduce并没有传入初始值,所以此时 afn1,b 为 fn2

// 第 1 次 reduce 的返回值,下一次将作为 a
arg => fn1(() => fn2(arg));

// 第 2 次 reduce 的返回值,下一次将作为 a
arg => (arg => fn1(() => fn2(arg)))(() => fn3(arg));

// 等价于...
arg => fn1(() => fn2(() => fn3(arg)));

// 执行最后返回的函数连接中间件,返回值等价于...
fn1(() => fn2(() => fn3(() => {})));

所以在调用 reduce 最后返回的函数时,传入了一个空函数作为参数,其实这个参数最后传递给了 fn3,也就是第三个中间件,这样保证了在最后一个中间件调用 next 时不会报错。

  • 异步实现
compose() {
      // reduce版本
      return Promise.resolve(
          self.middlewares.reduce((a, b) => arg =>
              Promise.resolve(a(() => b(arg)))
          )(() => Promise.resolve())
      );
}

使用async函数实现(仅记录)

compose() {
     return (async function () {
    // 定义默认的 next,最后一个中间件内执行的 next
    let next = async () => Promise.resolve();

    // middleware 为每一个中间件函数,oldNext 为每个中间件函数中的 next
    // 函数返回一个 async 作为新的 next,async 执行返回 Promise,解决异步问题
    function createNext(middleware, oldNext) {
        return async () => {
            await middleware(oldNext);
        }
    }

    // 反向遍历中间件数组,先把 next 传给最后一个中间件函数
    // 将新的中间件函数存入 next 变量
    // 调用下一个中间件函数,将新生成的 next 传入
    for (let i = self.middlewares.length - 1; i >= 0; i--) {
        next = createNext(self.middlewares[i], next);
    }

    await next();
})();
}

参考文章

KOA2 compose 串联中间件实现(洋葱模型)
学习 koa 源码的整体架构,浅析koa洋葱模型原理和co原理

posted @ 2020-08-14 11:26  yatolk  阅读(1077)  评论(0编辑  收藏  举报