一文搞定 Koa 中间件实现原理

9,349 阅读6分钟

该博客是个人学习 Koa 总结篇,如理解有误,请留言或在 GitHub 提交 issue 纠正。

转载请标明出处。

Koa 简介


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

Koa 中间件作用


中间件功能是可以访问请求对象(request),响应对象(response)和应用程序的请求-响应周期中通过 next 对下一个中间件函数的调用。通俗来讲,利用这个特性在 next 之前对 request 进行处理,在 next 函数之后对 response 处理。Koa 的中间件模型可以非常方便的实现后置处理逻辑。

图为洋葱模型:


应用程序


以下案例来源(Koa官网: hello world 应用 )

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

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

app.listen(3000);

现在,一起深入刨析中间件的运行原理。

中间件应用案例


中间件应用案例(简称: demo),下面是 demo 代码:

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

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

app.use(async (ctx, next) => {
  ctx.body = 'Hello, Koa';
});

app.listen(3001);

开启服务器,端口为 3001。每接受一条客户端请求时,服务器依次输出 1, 3, 4, 2 打印结果。结合 demo 逐步刨析中间件原理。

中间件原理

注册中间件函数


demo 示例使用 use 注册中间件函数, 看下 Koa 源码内部的 use 实现。

use(fn) {
  // 省略部分代码...
  this.middleware.push(fn);
  return this;
}

省略了部分校验和转换的代码,use 函数最核心的就是 this.middleware.push(fn)  这句。将注册的中间件函数都缓存到 middleware 栈中,并且返回了 this 自身,方便进行链式调用。 demo 注册了三个中间件函数,具体这些中间件函数什么时候执行以及如何执行,下文有详细解释。

创建 server 服务

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

内部使用了 Node 原生的 http 模块,通过 createServer 创建 Server 实例并监听指定的端口号。http.createServer(RequestListener) 接受请求侦听器函数作为参数。

所以,this.callback() 函数的调用返回 RequestListener 函数。RequestListener 函数接受请求对象(request)和 响应对象(response)两个参数。

callback 创建 RequestListener 请求侦听器函数

callback() {
  // compose 为中间件运行的核心
  const fn = compose(this.middleware);

  // handleRequest 就是 callback 函数返回的函数
  const handleRequest = (req, res) => {
    const ctx = this.createContext(req, res);
    return this.handleRequest(ctx, fn);
  };
  return handleRequest;
}


callback 函数主要做了两件事情:

  1. 使用 compose  函数对缓存中间件函数的栈做了一层校验,并 **返回了一个函数。**后面会详细讲解 compose 的实现。

  2. 创建一个 RequestListener 请求侦听器函数返回。如果客户端发送请求时,会先触发请求侦听器函数(handleRequest)执行,接受每次请求的 request 和 response 对象。

handleRequest 函数中的 const ctx = this.createContext(req, res) 将每次请求的 request(简称:req)和 response(简称:res) 对象结合创建一个 context(简称:ctx)上下文对象,并且创建三者的互相引用关系。当然,这不是本篇文章重点,只是简单带过。

然后将 ctx 和 fn 交给 handleRequest 进行处理,在讲解 handleRequest 之前,先看下通过 compose 生成的 fn 有什么作用。

compose


compose 是一个 koa-compose npm 包,内部核心代码也就20来行,它提供了中间件 next 函数调用的核心承载,看一下内部实现。下面为简化后的代码,方便理清运行逻辑。

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} ctx
   * @return {Promise}
   * @api public
   */
  return function fn (ctx, next) {
    // 简化了部分代码
    return dispatch(0)
    function dispatch (i) {
      let middlewareFn = middleware[i]
      try {
        return Promise.resolve(middlewareFn(ctx, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

代码量不多,但实现的很巧妙。

**const fn = compose(this.middleware) **的调用主要对已注册的中间件列表(middleware)栈内每一个中间件函数的校验,并返回 fn  函数。

所以,只要记住传递给 handleRequest 函数的 fn 就是如下代码:

return function fn (ctx, next) {
  // 简化了部分代码
  return dispatch(0)
  function dispatch (i) {
    let middlewareFn = middleware[i]
    try {
      return Promise.resolve(middlewareFn(ctx, dispatch.bind(null, i + 1)));
    } catch (err) {
      return Promise.reject(err)
    }
  }
}

handleRequest


客户端每次请求时,都会调用 RequestListener 请求侦听器函数,并创建请求响应上下文对象后传递 ctx  和 fn  到 handleRequest 函数处理。

因此,每次请求都需要根据中间件注册的顺序进行中间件函数的执行。

看下 Koa handleRequest 函数内部实现:

handleRequest(ctx, fn) {
  // 省略无关代码...
  const onerror = err => ctx.onerror(err);
  const handleResponse = () => respond(ctx);
  // 省略无关代码...
  return fn(ctx).then(handleResponse).catch(onerror);
}

将上下文对象(ctx)作为参数传入 fn 函数,在 fn 内部所有中间件全部执行完毕后,即调用 resolve 通知外部的 handleResponse 函数进行后续响应数据的处理。fn 内部首次调用 dispatch(0) 根据自定义下标 i 取出 middleware 栈中的第一个中间件函数 middlewareFn 。

demo 中第一个注册的中间件函数:

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


执行第一个中间件函数,将 **上下文对象(ctx) dispatch.bind(null, i+1) **作为参数传递给中间件函数。首先执行 console.log(1) 打印 1,然后执行 await next() 将当前中间件函数的执行权转交给 next(即: dispatch(1) )执行,则继续取出第二个中间件函数执行,如此类推,直到所有中间件都执行完毕。

所有中间件函数依次执行完毕后,最后执行的中间件函数出栈后,将执行权转交给前一个中间件函数的 await 出,继续代码的执行。

结合 demo 代码,通过一张中间件函数入栈-出栈的图,来深刻的体会一下。


当 dispatch(0) 出栈后表示所有的中间件函数依次执行完毕了,如果某个中间件执行过程出现错误,会抛出 reject 由外部 onerror 错误处理函数进行处理,如果没有出现错误则调用 handleResponse 转交给 respond 函数处理 body 的数据格式。

总结

  • 中间件函数的 next 不是直接调用的下一个中间件函数,而是调用的 fn 内部的 dispatch 函数,由它来调用下一个中间件函数并传递上下文对象(ctx)和自身(dispatch)。

  • 中间件函数的执行顺序和注册顺序一致, 先 use 的中间件函数先执行。

  • 一个中间件函数执行完毕,相应的 dispatch 函数将执行权转交给上一个中间件函数的 **await next(),**执行该中间件函数 await 后续的代码。