逐行分析Koa中间件机制
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/互动营销/数据可视化等);学习上善于沉淀、持续学习;性格上乐观开朗、活泼外向。
如有兴趣加入我们,欢迎发送简历至邮箱:[email protected]
本文作者:蚂蚁保险-体验技术组-渐臻
掘金地址:DC大锤