Koa2中间件原理浅析与实现

2,070 阅读6分钟

前言

Koa是由Express团队打造的新的web server框架。相比于ExpressKoa的特点如下:

  1. 体积更小。由于Koa不涉及路由以及其他中间件的捆绑,都通过额外的插件实现,使得Koa本身的体积更小,更能体现“按需加载”的原则。
  2. 放弃回调Koa2使用了ES6的aync函数,可以通过await来获取Promise函数resolve()的数据,不需要通过一个个回调才能进行异步处理,所以Koa2可以用同步的写法实现异步的操作,并大大提高了错误处理的能力。
  3. 洋葱圈模型。与express按顺序执行中间件方式不同,Koa采用洋葱圈模式处理中间件间的嵌套和执行顺序。请求会先进入到更外层的中间件,然后外层的中间件会等到内层的中间件执行完才会执行自己后续的逻辑,所以响应反而是从更内层的中间件开始发出,这种一层层嵌套的关系类似于洋葱层层包裹,所以称之为“洋葱圈模型”。

下面对Koa2的分析,会和express一起对比,从两者的差异点分析出Koa2的特点和优点。

Koa2功能分析

首先通过Koa2代码示例来分析一下Koa2提供的功能:

Koa官网Hello world示例

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

// 1、日志打印
app.use(async (ctx, next) => {
  await next();
  const rt = ctx.response.get('X-Response-Time');
  console.log(`${ctx.method} ${ctx.url} - ${rt}`);
});

// 2、计算响应时间
app.use(async (ctx, next) => {
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  ctx.set('X-Response-Time', `${ms}ms`);
});

// 3、请求response
app.use(async ctx => {
  ctx.body = 'Hello World';
});

app.listen(3000);

上述代码打印结果会是怎么样的呢? // TODO 由上述代码和打印结果以及和Express对比可看出,Koa2实例主要有2个核心特点:

  1. koa2不涉及路由,所以和Express不同的是,koa2没有path和method处理的逻辑
  2. koa2重点是中间件注册收集机制洋葱圈next机制实现

代码实现

基于以上核心特点,下面实现一个简易的LikeKoa2类。

1、类的基本结构

先确定这个类要实现的主要方法:

  • use():实现通用的中间件注册
  • listen():实际上就是httpServer的listen()函数,所以在这个类的listen()函数里创建httpServer,透传server参数,监听请求,并执行回调函数(req, res) => {} 所以LikeKoa2类基本结构如下:
class LikeKoa2 {
  constructor() {
    // 中间件存储的地方
    this.middlewaraes = [];
  }

  // 注册中间件
  use(fn) {
    this.middlewaraes.push(fn);
    return this; // 链式调用
  }

  callback() {
    return (req, res) => {
      // ...
    }
  }

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

2、洋葱圈next机制实现

koa2的中间件函数是async函数,它的参数为:ctx, next。我们先实现koa2最核心的洋葱圈next机制
next参数是一个async函数,只有调用它才可以一层又一层地优先调用下一个中间件函数,因此next函数需要:

  1. 返回的必须是Promise函数
  2. 从中间件数组中取出第一个中间件,然后迭代地取出下一个中间件,直到里层的中间件一层一层从里向外执行完,从而实现洋葱圈执行的效果
// 组合中间件
function compose(middlewaraeList) {
  return function (ctx) {
    // 洋葱圈next机制核心方法
    function dispatch(i) {
      const fn = middlewaraeList[i];
      try {
        // 防止fn不是Promise
        return Promise.resolve(
          fn(ctx, dispatch.bind(null, i + 1)) // 实现next机制
        );
      } catch (e) {
        return Promise.reject(e);
      }
    }
    // 从第一个中间件开始从里向外执行
    return dispatch(0);
  }
}

3、创建上下文context

Koa Context 将 node 的 request 和 response 对象封装到单个对象中,为编写 Web 应用程序和 API 提供了许多有用的方法。 这些操作在 HTTP 服务器开发中频繁使用,它们被添加到此级别而不是更高级别的框架,这将强制中间件重新实现此通用功能。 每个请求都将创建一个 Context,并在中间件中作为接收器引用,或者 ctx 标识符。

createContext(req, res) {
    const ctx = {
      req,
      res,
    };
    // ...more
    // koa2的创建上下文的函数会更丰富一点,这里为了简化实现只封装了req和res
    return ctx;
  }

4、将上下文对象传入中间件函数中

上文我们已经通过compose组合中间件函数返回了一个接收ctx上下文参数的函数,所以只需要再建立一个handleRequest函数,把创建好的上下文对象传入此函数即可。

// 把上下文传入中间件函数
  handleRequest(ctx, fn) {
    return fn(ctx);
  }

因此,在callback函数中:

callback() {
    // 组合中间件
    const fn = compose(this.middlewaraes);
    return (req, res) => {
      // 创建上下文对象
      const ctx = this.createContext(req, res);
      // 将上下文传入中间件
      return this.handleRequest(ctx, fn);
    }
  }

测试

为了验证上述LikeKoa2类是否实现了中间件注册收集以及洋葱圈next机制的功能,用一段代码验证:

const Koa = require('./like-koa2');
const app = new Koa();

// logger
app.use(async (ctx, next) => {
  console.log("第一层洋葱--开始")
  await next();
  const rt = ctx['X-Response-Time'];
  console.log(`${ctx['method']} ${ctx['url']} - ${rt}`);
  console.log("第一层洋葱--结束")
});

// x-response-time
app.use(async (ctx, next) => {
  console.log("第二层洋葱--开始")
  const start = Date.now();
  await next();
  const ms = Date.now() - start;
  ctx['X-Response-Time'] = `${ms}ms`;
  console.log("第二层洋葱--结束")
});

// response
app.use(async ctx => {
  console.log("第三层洋葱--开始")
  ctx['body'] = 'Hello World';
  console.log("第三层洋葱--结束")
});

app.listen(3010);

访问http://localhost:3010/,预期结果应该是开始是一、二、三层,结束是三、二、一层,实际结果:

可以看到,实际结果与预期结果相同,证明我们的实现是正确的。

完整代码

const http = require('http');

// 组合中间件
function compose(middlewaraeList) {
  return function (ctx) {
    function dispatch(i) {
      const fn = middlewaraeList[i];
      try {
        // 防止fn不是Promise
        return Promise.resolve(
          fn(ctx, dispatch.bind(null, i + 1)) // 实现next机制
        );
      } catch (e) {
        return Promise.reject(e);
      }
    }
    return dispatch(0);
  }
}

class LikeKoa2 {
  constructor() {
    // 中间件存储的地方
    this.middlewaraes = [];
  }

  // 注册中间件
  use(fn) {
    this.middlewaraes.push(fn);
    return this; // 链式调用
  }

  createContext(req, res) {
    const ctx = {
      req,
      res,
    };
    return ctx;
  }

  // 把上下文传入中间件函数
  handleRequest(ctx, fn) {
    return fn(ctx);
  }

  callback() {
    // 组合中间件
    const fn = compose(this.middlewaraes);
    return (req, res) => {
      // 创建上下文对象
      const ctx = this.createContext(req, res);
      // 将上下文传入中间件
      return this.handleRequest(ctx, fn);
    }
  }

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

module.exports = LikeKoa2;

Koa2与Express对比

从上文的分析已知Koa2Express的3个区别:

  • 体积:Koa2不涉及路由以及其他中间件的捆绑,体积比Express
  • 写法:Koa2使用 async函数 ,Express使用 Promise回调 ,因此Koa2可以避免回调,而且可以使用try catch更方便地去处理错误异常
  • 中间件机制:
    • Koa2使用 洋葱圈模式 ,其核心实现思想是使用函数调用栈,先调用的后执行,直到里层函数一层一层由里向外执行完
    • Express核心思想是使用任务队列,先进队列的先取出执行,后面的任务进队等待,直到前面的任务都执行完后再执行 举个例子,加入我们要实现一个获取列表的接口,要求是更新一篇文章,更新成功后再发起其他动作。 使用Express:
router.post('/update', function (req, res, next) {
  const id = req.query.id;
  const data = req.body;
  const result = updateArticle(id, data);
  return result.then(val => {
      if (val) {
        res.json(new SuccessModel());
      } else {
        res.json(ErrorModel("更新文章失败"));
      }
    }).catch(e => {
      console.error(e);
    }).then(() => { /** 其他动作 */ })
    .then(() => {
      //...
    })
});

使用Koa2:

router.post('/update', loginCheck, async function (ctx, next) {
  const id = ctx.query.id;
  const data = ctx.request.body;
  try {
    const result = await updateArticle(id, data);
    if (result) {
      ctx.body = new SuccessModel();
       /** 其他动作 */ 
    } else {
      ctx.body = new ErrorModel("更新文章失败");
    }
  } catch (e) {
    console.error(e);
  }
})

可以看出:

  • 对于结果获取:Express通过Promiseresolve的回调,获取resolve得到的结果;而Koa2通过await一个async函数,使用同步的写法实现异步的效果,写法更清晰
  • 对于错误捕捉:Express对于每个callback都要做错误捕捉,然后一层一层向外传递;而Koa2可以使用一个try catch就可以实现所有错误的捕捉