逐行分析Koa中间件机制

9,947 阅读15分钟

0.背景

自从koa框架发布,已经有很多前端同行们对它的源码进行了解读。在知乎、掘金、Github上,已有不少文章讲了它的ctx等API实现、中间件机制概要、错误处理等细节,但对于中间件机制中的细节做逐行分析的文章还是比较少,本文将采用详细的逐行分析的策略,来讨论Koa中间件机制的细节。

PS:本次Koa源码分析基于2.7.0版本。

1. 从入口开始

大部分情况下使用Koa,都是这样的,假定我们的demo 入口文件叫app.js

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

require在查找第三方模块时,会查找该模块下package.json文件的main字段。查看koa仓库目录下下package.json文件,可以看到模块暴露的出口是lib目录下的application.js文件

{
  "main": "lib/application.js",
}

而lib/application文件中所暴露的出口

module.exports = class Application extends Emitter {}

可以看到,在app.js 中引用koa时,变量Koa就是指向该Application类。

2.如何响应请求

(已经了解Koa如何响应请求的同学,可以跳过本节,直接看第3节)

好,现在给app.js增加一点内容:监听3004端口,打印一行日志,返回

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

const final = (ctx, next) => {
  console.log('Request-Start');
  ctx.body = { text: 'Hello World' };
}

app.use(final);

app.listen(3004);

// 启动app.js,就可以看到返回的结果

以上这段代码中,ctx.body 如何实现并不是本文的重点,只要知道它的作用是设置响应体的数据,就可以了

在本节里,需要搞清楚的问题有两个:

  • app.use 的作用是挂载中间件,它做了什么?
  • app.listen 的作用是监听端口,它做了哪些工作?

回到刚刚的lib/application文件,可以看到Application上挂载了use方法

  use(fn) {
    // 类型判断
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
    
    // 兼容v1版本的koa
    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);
    }
    // 中间省略部分无关代码
    this.middleware.push(fn);
    return this;
  }

在官方文档里,中间件的类型是函数,因此use方法的第一行完成了参数类型的检查。

而第二段代码,则判断是否为Generator函数,如果是的话,就提示开发者Generator类型的中间件即将被废弃,并通过convert方法将该中间件的类型从Generator函数转换成普通函数。

为什么会有这么一段代码呢?因为在Koa的v1版本和v0版本,使用的异步控制方案是Generator+Promise+Co,因此将中间件定义成了Generator Function。但自从Koa v2版本起,它的异步控制方案就开始支持Async/Await,因此中间件也用普通函数就可以了。

这里用到了几个函数库,只要理解它们的作用和原理概要即可,有兴趣可以自行查看(但不看也不影响你理解后面的内容)

  • isGeneratorFunction:判断是否为Generator函数,判断方法包括Object.prototype.call、Function.prototype.call、Object.getPrototypeOf等。
  • deprecate:给出API即将被弃用的提示信息。
  • convert:即koa-convert,作用是加入了一层函数嵌套,并使用Co自动执行原Generator函数

最后一段代码的作用是把传入的函数,push到this.middleware属性的尾部,而在Application对象的构造函数里,可以看到这么一行代码

this.middleware = [];

它是用来存储中间件的。

OK,中间件通过use方法存储好了,那么如何使用呢?这就要先讲一下Koa所实现的“请求响应机制”作为基础知识,来看刚刚说的app.listen方法,它也被挂载在Application类上

  listen(...args) {
    // 略去无关代码
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }

很眼熟有没有~

只要你看过任意一份Node服务端开发入门的教程,都会知道this.callback()返回的值,即http.createServer的参数,它的格式一定如下

(req, res) => {
	// Do Sth.
}

即它是一个以请求Request对象和响应Response对象为参数的函数。好,来看callback函数

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

    // 省略一些错误处理代码
    const handleRequest = (req, res) => {
      // ctx上下文对象构建代码,对理解响应机制不重要
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn);
    };

    return handleRequest;
  }

可以看到这段代码就做了两件事:

  • 用compose函数对middleware数组做处理。
  • 返回handleRequest给http.createServer作为参数,因此每次请求发过来的时候,内部会执行this.handleRequest

compose的实现涉及到中间件的执行流程,这里先记住,它返回的是一个函数,该函数的执行结果是一个Promise对象,具体实现在下一节会说明。我们先看this.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);
  }

这段代码完成了三件事情:

  • 错误处理:onerror函数
  • onFinished监听response执行完成,以用来做一些资源清理工作。
  • 执行传入的fnMiddleware

前两者本文暂时不讨论,因为并不影响对于中间件执行机制的理解,所以只谈最后这件事。

fnMiddleware是什么呢?回顾刚刚的分析过程,可以意识到fnMiddleware,就是被compose处理过得到的fn函数

const fn = compose(this.middleware);

它的返回结果是一个Promise,在resolved之后,就开始执行handleResponse函数,开始组织响应。

好,响应机制到这里就分析完毕了(后面响应如何具体实现暂时不需要在意),开始介绍中间件的执行流程。

3.中间件如何执行

3.1 基本执行逻辑

刚才说到,compose函数对this.middleware,也就是中间件数组做了处理工作,返回了一个fnMiddleware函数。好,来看看这个compose到底是什么

const compose = require('koa-compose');

找到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!')
  }

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

好,我们从头开始看。

先是一段类型检查

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

检查数组类型及数组里每个元素的类型(PS:个人觉得,这里最好给提示一下究竟是第几个中间件类型错了)

接下来返回了一个函数,这个函数就是之前提到的fnMiddleware函数。

  return function (context, next) {
    // last called middleware #
    let index = -1
    return dispatch(0)

    // i表示预期想要执行哪个中间件
    function dispatch (i) {
			// 暂时先省略
    }
  }

fnMiddleware两个参数的含义,也很好理解,看刚才fnMiddleware被执行的位置就可以知道:

  • context:上下文对象,被Application对象实例上的this.createContext方法创造出来,表示是一次请求的上下文,但koa-compose只对它进行了透传,不详细理解也没关系,
  • next:目前是undefined,后面会说明,它是用来表示所有中间件走完之后,最后执行的一个函数。

好,刚刚说到,每次请求的时候,fnMiddleware都会被执行,那么来看它的执行过程。

首先,标识了一个变量index,等下讲dispatch函数的时候会看到它的作用 —— 用于标识「上一次执行到了哪个中间件」。

其次,以0为参数,执行了dispatch函数,它的代码如下:

   function dispatch (i) {
     
      // 校验预期执行的中间件,其索引是否在已经执行的中间件之后
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
     
      // 通过校验,将「已执行的中间件的索引」标记为新的「预期执行的中间件的索引」
      index = i
     
      // 取预期执行的中间件函数
      let fn = middleware[i]
      
      // 预期执行的中间件索引,已经超出了middleware边界,说明中间件已经全部执行完毕,开始准备执行之前传入的next
      if (i === middleware.length) fn = next
     
      // 没有fn的话,直接返回一个已经reolved的Promise对象
      if (!fn) return Promise.resolve()
      try {
        // 对中间件的执行结果包裹一层Promise.resolve
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }

上面的注释看不太懂也没关系,我们一行一行来看,并配上一个Demo来理解,等看完了逐行解析,再回过头来看也来得及。

先放Demo代码:

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

const one = (ctx, next) => {
  console.log('1-Start');
  next();
  console.log('1-End');
}

const two = (ctx, next) => {
  console.log('2-Start');
  next();
  console.log('2-End');
}

const final = (ctx, next) => {
  console.log('final-Start');
  ctx.body = { text: 'Hello World' };
  next();
  console.log('final-End');
}

app.use(one);
app.use(two);
app.use(final);

app.listen(3004);

可以看到,这段代码中有三个中间件,每个中间件都是同步方法,都调用了next函数。

刚才说到,首先执行的是dipatch(i),且i为0,而变量i的作用是“标识即将执行哪个中间件”,那么第一行代码如下:

if (i <= index) return Promise.reject(new Error('next() called multiple times'))

它对比了「“即将执行的中间件”索引」和「“上一次执行的中间件”的索引」,如果后者大,或者相等,就抛出一个错误,告诉调用者,next函数被执行了多次。

这什么意思呢?用刚刚的Demo举个例子,如果我执行到了第2个中间件,即two函数,即index为1,这时候我发现传入的i是1,这意思是让我再执行一遍当前的中间件,这当然不行。同理,如果传入的i是0,这是让我去执行one中间件啊,。这显然不合理啊!one中间件已经被执行过了,中间件就不该再执行了!

可是这关next函数被执行了多次有什么关系?请保持这个疑问,先继续看下去。

现在i是0,index是-1。

index = i
let fn = middleware[i]

刚刚说,index用于标识上次执行到了哪个中间件(-1表示第0个),i用于标识即将执行哪个中间件(0表示第1个),那现在校验通过了,就说明要执行的确实是下一个中间件,这时候要修改一下index这个“已执行标识”,以说明“刚刚这个「即将被执行」的中间件,现在正式被执行了”。

并且,用fn变量来保存这个「即将执行」的中间件。

接下来的两句代码:

if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()

目前的变量i还是0,而middleware长度是3,fn是第一个中间件one,所以两句都不会执行,先行跳过。

try {
  // 原代码是一行,为了方便理解被我拆成了三行
  const next = dispatch.bind(null, i + 1);
  const fnResult = fn(context, next);
  return Promise.resolve(fnResult);
} catch (err) {
  return Promise.reject(err)
}

可以看到这段代码做了三件小事:

  • 一是定义了next函数,且绑定了执行上下文和第一个参数为i+1,它的含义是“即将执行下一个函数”
  • 二是执行了fn函数,在i为0的情况下,即one中间件
  • 三是对one中间件执行的结果进行了Promise包装,确保返回值是Promise对象,并完成了错误的处理。

而我们知道,one中间件的格式如下:

const one = (ctx, next) => {
  console.log('1-Start');
  next();
  console.log('1-End');
}

所以, 对于one中间件来说,执行next,就相当于执行dispatch(1),所以每个中间件函数所传入的next变量,都是对“下一个中间件执行行为”的封装。

那么现在dispatch开始了第二次执行,传入的i值成了1,这个过程请各位自己分析。

而当final中间执行的时候,以下语句中,i+1成了3。

dispatch.bind(null, i + 1)

所以若final中间件中执行了next函数,就会开始执行dispatch(3)

// 上次执行到第3个中间件final,所以index是2, i 是3,校验通过
if (i <= index) return Promise.reject(new Error('next() called multiple times'))

// 改index 为 3
index = i
let fn = middleware[i]
// i为3,middleware长度为3,fn赋值为next,而next是fnMiddleware执行时所传入的第二个参数
if (i === middleware.length) fn = next

// fn是undefined,直接返回Promise
if (!fn) return Promise.resolve()

所以,当fnMiddleware执行时设置的then回调执行的时候,所有的中间件已经执行完毕了。

3.2 next多次调用问题

把Demo改一改

const one = (ctx, next) => {
  console.log('1-Start');
  next();
  next();
  console.log('1-End');
}

前面说到,one中间件里的next,相当于dispatch.bind(null, 1),所以两次next调用,相当于执行了两次dispatch(1):

  • 第一次调用时:i为1,index为0,i <= index 不成立,校验通过。
  • 第二次调用时:i为1,index为1,i <= index 成立,抛错提示。

所以这一层i <= index和它所抛出的next() called multiple times错误,就是为了防止在当前中间件里多次执行next,从而产生重复调用行为。

3.3 提前终止

把one中间件恢复原状,修改two中间件:

const two = (ctx, next) => {
  console.log('2-Start');
  // next()
  console.log('2-End');
}

所以在下列代码语句中,dispatch.bind(null,  i+1)(i为1)虽然传给了two函数,但two函数并没有调用它

return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));

所以final中间件就不会执行,所以浏览器访问该服务器时,会展示Not Found错误。

所以在koa的中间件的第二个参数,实际上表示该中间件对下一个中间件的执行权。

3.4 异步机制

我们修改一下代码,来模拟一个异步场景

const one = async (ctx, next) => {
  console.log('1-Start');
  await next();
  console.log('1-End');
}

const final = (ctx, next) => {
  return new Promise(resolve => {
    setTimeout(() => {
      ctx.body = { text: 'Hello World' };
      resolve();
    }, 400);
  })
}

app.use(one);
app.use(final);

当one中间件执行next,也就是执行dispatch(1)时

try {
  // 原代码是一行,为了方便理解被我拆成了三行,i是1,
  const next = dispatch.bind(null, i + 1);
  
  // 这儿的fn是final中间件函数
  const fnResult = fn(context, next);
  // fnResult是个400ms之后状态变成resolved的Promise
  return Promise.resolve(fnResult);
} catch (err) {
  return Promise.reject(err)
}

因此,中间件的one执行过程可以简化成下列伪代码

const one = async (ctx, next) => {
  console.log('1-Start');
  await (
    // 这个Promise.resolve是在dispatch(1)中被执行的
    Promise.resolve(
      // 这个Promise是final中间件返回的
      new Promise(resolve => {
        setTimeout(() => {
          ctx.body = { text: 'Hello World' };
          resolve();
        }, 400);
      })
    )
  );
  console.log('1-End');
}

而Promise有个特性,如果Promise.resolve接受的参数,也是个Promise,那么外部的Promise会等待该内部的Promise变成resolved之后,才变成resolved。可以拿着下面这段代码在浏览器控制台里跑一跑,就能理解这段

Promise.resolve(new Promise((resolve => {
	setTimeout(() => { 
    console.log('Inner Resolved');
    resolve()
  }, 1000);
})))
  .then(() => { console.log('Out Resolved')})

// 先输出:Inner Resolved
// 后输出:Out Resolved

回到上面的中间件执行过程,也就是one中间件函数代码中间的await语句,会等待final中间件执行完毕之后再继续执行,而在其中,Promise.resolve方法起了至关重要的作用。

而这正是的中间件模型,即洋葱圈模型的实现

4.总结

至此,我可以概括v2版本的中间件执行机制的特点:

  • 存储:以数组形式存储中间件。
  • 状态管理:所有的状态变更,都交给ctx对象,无需跨中间件传递参数。
  • 流程控制:以递归的方式进行中间件的执行,将下一个中间件的执行权交给正在执行的中间件,即洋葱圈模型。
  • 异步方案:用Promise包裹中间件的返回结果,以支持在上一个中间件内部实现Await逻辑。

所以Koa的中间件的格式非常统一

async function mw(ctx, next){
	// Do sth.
  await next();
  // Do something else
}

但是它的缺点也比较明显:流程控制方案较弱

在Koa体系下,因为当前中间件只能掌握下一个中间件的执行权,因此无法在运行时根据状态来动态决定中间件的执行顺序,只能通过静态路由,或者把部分服务封装成工具函数并在中间件文件中引入来解决。


关于我们

我们是蚂蚁保险体验技术团队,来自蚂蚁金服保险事业群(杭州/上海)。我们是一个年轻的团队(没有历史技术栈包袱),目前平均年龄92年(去除一个最高分8x年-团队leader,去除一个最低分97年-实习小老弟)。我们支持了阿里集团几乎所有的保险业务。18年我们产出的相互宝轰动保险界,19年我们更有多个重量级项目筹备动员中。现伴随着事业群的高速发展,团队也在迅速扩张,欢迎各位前端高手加入我们~

我们希望你是:技术上基础扎实、某领域深入(Node/互动营销/数据可视化等);学习上善于沉淀、持续学习;性格上乐观开朗、活泼外向。

如有兴趣加入我们,欢迎发送简历至邮箱:shuzhe.wsz@alipay.com


本文作者:蚂蚁保险-体验技术组-渐臻

掘金地址:DC大锤