- 本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
- 这是源码共读的第5期,链接:koa-compose。
说明
本文主要是通过剖析koa-compose的部分源码,去分析在koa中,中间件的实现原理。
我们可以思考几个问题:
- 什么是中间件?
- 为什么我们需要中间件?
- 实际开发中是否有遇到过需要调用多个相互依赖的请求?或者需要阻塞执行?又是如何实现功能的?
目的
通过本文阅读,你将学习到如下内容:
- Koa中间件实现原理;
- 从零实现自己的中间件;
下面我们会先就Koa的compose部分源码,去分析Koa中间件是如何实现的,如果搭建洋葱模型的,然后就会从零根据实际需求,去完善自己的洋葱模型。
源码分析
环境准备
这边依旧直接拉川哥的项目地址了:
git clone https://github.com/lxchuan12/koa-compose-analysis.git
(大家也去koa-compose的github地址获取:https://github.com/koajs/compose)
调试
这边直接使用jest的测试案例去调试compose源码了,比较简单,不再赘述调试的过程了。
源码分析
下面就主体代码进行分析。
compose
其余代码省略,可看上图。
await compose(stack)({})
- stack是数组,收集了多个async 函数,即返回值是promise,内部有异步,总之就是收集了所有的异步操作;
- compose是核心代码,函数接收stack数组返回一个匿名函数(这里采用了闭包);
- 核心代码就是执行这个匿名函数;
我们来看下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!')
}
/**
* @param {Object} context
* @return {Promise}
* @api public
*/
// 核心匿名函数:接收上下文对象context和自定义next函数;
return function (context, next) {
// last called middleware #
let index = -1
return dispatch(0) //调用第一个元素函数
function dispatch (i) {
// 防止连续执行两次next
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
let fn = middleware[i]
if (i === middleware.length) fn = next //此处i对应的fn是undefined,也支持自定义next
if (!fn) return Promise.resolve() // 最后一个直接返回
try {
// 递归去执行dispatch,本质是获取下一个fn元素
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
return Promise.reject(err)
}
}
}
}
说实话,这块的逻辑,不太好组织语言解释,下面我就主要展示一下逻辑图,具体思路稍后我们会从零搭建洋葱模型、实现中间件时,再详细分析。
通过上图,比较清晰的知道,各中间件的串行,本质上每一个异步函数内执行的next函数就是在执行下一个异步函数,只不过为了方便递归嵌套,所以异步函数的执行是在dispach函数内。
自定义洋葱模型
从上面简单的分析koa-compose的代码过程中,我们对洋葱模型有了大概的认知,也明白了这种设计的妙处,那么如果我们需要从零搭建这么一个洋葱模型,去实现自己的需求,又该如何下手?
我们看到了上面koa-compose的实现结果,但是毕竟这是一个结果,我们固然能通过代码分析,知道它在干嘛,不过那并不是我们从自己的思想上去构思出、演变成这个结果的呈现。
那么,下面我们就从零去分析、推导这么一个洋葱模型实现的过程。
1、需求设定和分析
我们目前有如下需求:有三个异步的操作,假定是接口请求,而后一个需要前一个执行完再去执行。
- 从上面的需求,我们首先排除直接使用Promise.all方法,因为该方法是并行处理各异步操作;
- 我们可以在第一个请求拿到结果后,去执行第二个请求,第三个类似,但是这并不友好,虽然避免了回调函数嵌套,但依旧行成了promise的嵌套,如果请求很多呢?
2、async+await
于是乎我们想到了async和await,然后我们写下了下面的代码: 注意:
// 假定接口(模拟请求)
function wait(message, time) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(message);
resolve(message);
}, time);
});
}
// 工厂函数(用于生成三个异步请求的函数)
function factory(message, time) {
return async function (content, next) {
await wait(message, time);
};
}
// 生成3个异步请求函数
const foo1 = factory("foo1", 1000);
const foo2 = factory("foo2", 2000);
const foo3 = factory("foo3", 3000);
// 实现阻塞执行3个异步
async function compose() {
let res1 = await foo1();
let res2 = await foo2();
let res3 = await foo3();
}
compose();
从上面的代码,我们实现了基本的需求,这个实现过程是非常基础的,相信大家都能做到。
但是,我们发现:
- 这种情况下,我们需要把所有的异步都在这里执行一下,使用await阻塞,如果有n个请求呢?显然都需要明确的写出执行过程;
- 其次,如果有需要共享的参数,也必然得通过拿到await等到的返回值,并处理后传递给下一个异步,代码会很混乱;
于是,我们开始优化。
3、数组收集异步
- 为了收集所有异步操作,我们设置数组汇总所有异步函数;
- 为了共享数据,我们使用闭包形式,在compose函数返回新的函数,并接收content上下文对象,作为参数共享对象;
const arr = [foo1, foo2, foo3];
function compose(middle) {
return function (content) {
// 执行异步数组函数的逻辑
};
}
compose(arr)({});
上面代码结构很清晰,也是我们能直接想到的设计思路。
那么我们现在面临的问题,就是该怎么实现内部的异步函数的串行执行呢?
for循环数组?不行,这是并行的。
于是乎,我们想到了回到函数,为什么不把下一个异步函数作为参数传给上一个异步函数呢,等上一个异步函数拿到结果后,调用这个参数函数,不就实现串行了吗?
4、实现递归
于是,我们随后的就写了下面代码:
// 给工厂函数加next参数
function factory(message, time) {
return async function (content, next) {
await wait(message, time);
next();
};
}
// ...其余代码省略,可至上方查看
const arr = [foo1, foo2, foo3];
function compose(middle) {
return function (content) {
// 执行异步数组函数的逻辑
let fn = middle[0];
fn(content, middle[1])
};
}
compose(arr)({});
可是,才加了几行代码,我们就无从下手了,因为实际我们并不知道middle的长度,不可能一个个把元素列举出来,并手动传递下一个函数作为参数。于是我们知道,这里需要递归,因此,我们又加了一个函数作为递归函数,代码如下:
// 部分代码省略
function factory(message, time) {
return async function (content, next) {
await wait(message, time);
next();
};
}
const arr = [foo1, foo2, foo3];
function compose(middle) {
return function (content) {
function dispatch(i) {
let fn = middle[i];
if (!fn) return false;
// 注意此处的dispatch并不执行,只是作为参数传递
fn(content, dispatch.bind(null, i + 1));
}
// 第一个元素的函数需要手动执行
dispatch(0);
};
}
compose(arr)({});
从上面的代码实现中,其实各个异步函数已经实现了串行的效果了,数组中每一个异步函数都属于中间件,只有上一个中间件内部调用next函数,才会执行下一个中间件的代码;同时,由于闭包的存在,content参数并不会被销毁,因此可以作为多个中间件共享参数的对象,可以放心使用。
5、实现compose阻塞和自定义next
我们再扩展一下自定义next,从上面代码中可以看到,由于最后一次进入dispatch时,i等于middle的length,此时fn自然为undefined,因此if (!fn) return false;必然执行退出。于是我们可以自定义最后一个时指定的next,代码如下:
const arr = [foo1, foo2, foo3];
function compose(middle) {
return function (content, next) {
function dispatch(i) {
let fn = middle[i];
if (i === middle.length) fn = next;
if (!fn) return false;
// 注意此处的dispatch并不执行,只是作为参数传递
fn(content, dispatch.bind(null, i + 1));
}
dispatch(0);
};
}
compose(arr)({}, () => console.log("最后了"));
我们继续优化:我们知道,这种情况下其实compose(arr)({})本身是异步的,虽然内部各个中间件是串行嵌套的,保证代码阻塞执行,但是于外面而言,compose(arr)({})并不会阻塞该代码下面的执行,如:
compose(arr)({}, () => console.log("最后了"));
console.log('不会被阻塞') //该代码会先被执行
那么有没有办法去控制按需阻塞呢?很显然,我们只需要把compose(arr)的返回值改成返回promise对象,那么当执行await compose(arr)({})时就能阻塞下面代码了。
我们可以看看前面代码,我们compose(arr)函数并没有指定返回值,默认undefined,那么为了不影响前面的功能,又有promise对象返回值,很显然我们可以手动使用Promise.resolve()执行微任务,代码如下:
// 其余代码省略
function factory(message, time) {
return async function (content, next) {
await wait(message, time);
// next前面必须加await
await next();
};
}
const arr = [foo1, foo2, foo3];
function compose(middle) {
return function (content, next) {
function dispatch(i) {
let fn = middle[i];
if (i === middle.length) fn = next;
if (!fn) return Promise.resolve();
try {
// 注意此处的dispatch并不执行,只是作为参数传递
return Promise.resolve(fn(content, dispatch.bind(null, i + 1)));
} catch (error) {
return Promise.reject(error);
}
}
// 注意此处必须return出去
return dispatch(0);
};
}
(async function () {
await compose(arr)({}, () => console.log("最后了"));
console.log("被阻塞了");
})();
以上就是我们从零实现自己的洋葱模型过程,从需求出发,一步步去推敲和完善设计思路,最后得到结果。
对比一下koa-comppose的代码,你会发现我们自己实现的洋葱模型基本和koa-compose的实现基本一致,只不过我们没有对next的二次执行进行报错处理,读者有兴趣可以自行添加。
下面附上本次演练的最终完整代码:
function wait(message, time) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(message);
resolve(message);
}, time);
});
}
function factory(message, time) {
return async function (content, next) {
await wait(message, time);
// next前面必须加await
await next();
};
}
const foo1 = factory("foo1", 1000);
const foo2 = factory("foo2", 2000);
const foo3 = factory("foo3", 3000);
const arr = [foo1, foo2, foo3];
function compose(middle) {
return function (content, next) {
function dispatch(i) {
let fn = middle[i];
if (i === middle.length) fn = next;
if (!fn) return Promise.resolve();
try {
// 注意此处的dispatch并不执行,只是作为参数传递
return Promise.resolve(fn(content, dispatch.bind(null, i + 1)));
} catch (error) {
return Promise.reject(error);
}
}
// 注意此处必须return出去
return dispatch(0);
};
}
(async function () {
await compose(arr)({}, () => console.log("最后了"));
console.log("被阻塞了");
})();
总结和收获
其实直接从koa-compose的代码阅读来看,代码量虽然不多,但是设计思路很妙,因此虽然打了断点去调试分析,但是依旧很难用语言去表达和总结整个koa-compose的实现过程,因此我只能用简单的几句寥寥概括,并附上了一张图,去总结大概的中间件串行嵌套层级。
后面,我们自己从零实现了洋葱模型,我想这种方式更容易去理解所谓的洋葱模型的设计和意义,并且有助于大家遇到其他需求时,去推敲对应的设计思维。
有时候自己不会,看别人代码却看得懂,等到下一次遇到时,又不会了,因为看的时候,我们是按照原本已经设计好的过程去走,但是没有思考过自己遇到这种需求,应当如何设计,因此要学会多思考,每一句代码的存在必定是有其存在的理由的。