逐行分析Koa中间件机制

栏目: Node.js · 发布时间: 7年前

内容简介:自从koa框架发布,已经有很多前端同行们对它的源码进行了解读。在知乎、掘金、Github上,已有不少文章讲了它的ctx等API实现、中间件机制概要、错误处理等细节,但对于中间件机制中的细节做逐行分析的文章还是比较少,本文将采用详细的逐行分析的策略,来讨论Koa中间件机制的细节。PS:本次Koa源码分析基于2.7.0版本。大部分情况下使用Koa,都是这样的,假定我们的demo 入口文件叫app.js

自从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方法起了至关重要的作用。

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

逐行分析Koa中间件机制

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大锤


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Practical Vim, Second Edition

Practical Vim, Second Edition

Drew Neil / The Pragmatic Bookshelf / 2015-10-31 / USD 29.00

Vim is a fast and efficient text editor that will make you a faster and more efficient developer. It’s available on almost every OS, and if you master the techniques in this book, you’ll never need an......一起来看看 《Practical Vim, Second Edition》 这本书的介绍吧!

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

MD5 加密
MD5 加密

MD5 加密工具

HSV CMYK 转换工具
HSV CMYK 转换工具

HSV CMYK互换工具