koa2 compose理解及模拟实现
介绍
Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。 通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。 Koa 并没有捆绑任何中间件, 而是提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序。
学习koa-compose
之前,先看一下这两张图
基本使用
const Koa = require('../../lib/application');
// const Koa = require('koa');
const app = new Koa();
// x-response-time
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
ctx.set('X-Response-Time', `${ms}ms`);
});
// logger
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}`);
});
// response
app.use(async ctx => {
ctx.body = 'Hello World';
});
app.listen(3000);
- 创建一个跟踪响应时间的日期
- 等待下一个中间件的控制
- 创建另一个日期跟踪持续时间
- 等待下一个中间件的控制
- 将响应主体设置为“Hello World”
- 计算持续时间
- 输出日志行
- 计算响应时间
- 设置 X-Response-Time 头字段
- 交给 Koa 处理响应
看完这个gif图,也可以思考下如何实现的。根据表现,可以猜测是next是一个函数,而且返回的可能是一个promise,被await调用。
阅读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!');
}
/**
* @param {Object} context
* @return {Promise}
* @api public
*/
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];
// next的值为undefined,当没有中间件的时候直接结束
// 其实这里可以去掉next参数,直接在下面fn = void 0,和之前的代码效果一样
// if (i === middleware.length) fn = void 0;
if (i === middleware.length) fn = next;
if (!fn) return Promise.resolve();
try {
// fn就是中间件函数, dipatch(i)调用的就是第i个中间件函数
// eg : app.use((ctx,next) => { next()})
// 第 1 次 reduce 的返回值,下一次将作为 a
// arg => fn1(() => fn2(arg));
// 第 2 次 reduce 的返回值,下一次将作为 a
// arg => (arg => fn1(() => fn2(arg)))(() => fn3(arg));
// 等价于...
// arg => fn1(() => fn2(() => fn3(arg)));
// 执行最后返回的函数连接中间件,返回值等价于...
// fn1(() => fn2(() => fn3(() => {})));
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err);
}
}
};
}
上面的代码等价于
// 这样就可能更好理解了。
// simpleKoaCompose
const [fn1, fn2, fn3] = this.middleware;
const fnMiddleware = function(context){
return Promise.resolve(
fn1(context, function next(){
return Promise.resolve(
fn2(context, function next(){
return Promise.resolve(
fn3(context, function next(){
return Promise.resolve();
})
)
})
)
})
);
};
fnMiddleware(ctx).then(handleResponse).catch(onerror);
也就是说koa-compose
返回的是一个Promise
,Promise
中取出第一个函数(app.use
添加的中间件),传入context
和第一个next
函数来执行。
第一个next函数
里也是返回的是一个Promise
,Promise
中取出第二个函数(app.use
添加的中间件),传入context
和第二个next
函数来执行。
第二个next函数
里也是返回的是一个Promise
,Promise
中取出第三个函数(app.use
添加的中间件),传入context
和第三个next
函数来执行。
第三个...
以此类推。最后一个中间件中有调用next
函数,则返回Promise.resolve
。如果没有,则不执行next
函数。
这样就把所有中间件串联起来了。这也就是我们常说的洋葱模型。
模拟实现
同步实现
文件app.js
// 模拟 Koa 创建的实例
class app {
constructor(){
this.middlewares = []
}
use(fn){
this.middlewares.push(fn)
}
compose() {
// 递归函数
let self = this;
function dispatch(index) {
// 如果所有中间件都执行完跳出
if (index === self.middlewares.length) return;
// 取出第 index 个中间件并执行
const midFn = self.middlewares[index];
return midFn(() => dispatch(index + 1));
}
取出第一个中间件函数执行
dispatch(0);
}
};
module.exports = new app();
上面是同步的实现,通过递归函数 dispatch
的执行取出了数组中的第一个中间件函数并执行,在执行时传入了一个函数,并递归执行了 dispatch
,传入的参数 +1
,这样就执行了下一个中间件函数,依次类推,直到所有中间件都执行完毕,不满足中间件执行条件时,会跳出,这样就按照上面案例中 1 3 5 6 4 2
的情况执行,测试例子如下(同步上、异步下)。
文件sync-test.js
const app = require("./app");
app.use(next => {
console.log(1);
next();
console.log(2);
});
app.use(next => {
console.log(3);
next();
console.log(4);
});
app.use(next => {
console.log(5);
next();
console.log(6);
});
app.compose();
// 1
// 3
// 5
// 6
// 4
// 2
文件async-test.js
const app = require("./app");
// 异步函数
function fn() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
console.log("hello");
}, 3000);
});
}
app.use(async next => {
console.log(1);
await next();
console.log(2);
});
app.use(async next => {
console.log(3);
await fn(); // 调用异步函数
await next();
console.log(4);
});
app.use(async next => {
console.log(5);
await next();
console.log(6);
});
app.compose();
// 1
// 3
// hello
// 5
// 6
// 4
// 2
我们发现如果案例中按照 Koa
的推荐写法,即使用 async
函数,都会通过,但是在给 use
传参时可能会传入普通函数或 async
函数,我们要将所有中间件的返回值都包装成 Promise
来兼容两种情况,其实在 Koa
中 compose
最后返回的也是 Promise
,是为了后续的逻辑的编写,但是现在并不支持,下面来解决这两个问题。
注意:后面 compose 的其他实现方式中,都是使用 sync-test.js 和 async-test.js 验证,所以后面就不再重复了。
升级为异步,其实就是koa-compose的实现(简化版)
compose() {
// 递归函数
let self = this;
function dispatch(index) {
// 异步实现
// 如果所有中间件都执行完跳出,并返回一个 Promise
if (index === self.middlewares.length) return Promise.resolve();
// 取出第 index 个中间件并执行
const route = self.middlewares[index];
// 执行后返回成功态的 Promise
return Promise.resolve(route(() => dispatch(index + 1)));
}
// 取出第一个中间件函数执行
dispatch(0);
}
我们知道 async
函数中 await
后面执行的异步代码要实现等待,待异步执行后继续向下执行,需要等待 Promise
,所以我们将每一个中间件函数在调用时最后都返回了一个成功态的 Promise
,使用 async-test.js
进行测试,发现结果为 1 3 hello(3s后) 5 6 4 2
。
reduceRight实现(Redux旧版使用逆序归并)
- 同步实现
compose () {
return self.middlewares.reduceRight((a, b) => () => b(a), () => {})();
};
上面的代码看起来不太好理解,我们不妨根据案例把这段代码拆解开,假设 middlewares
中存储的三个中间件函数分别为 fn1
、fn2
和 fn3
,
由于使用的是 reduceRight
方法,所以是逆序归并
,第一次 a
代表初始值(空函数),
b代表
fn3,而执行
fn3 返回了一个函数,这个函数再作为下一次归并的
a,而
fn2作为
b`,依次类推,过程如下:
// 第 1 次 reduceRight 的返回值,下一次将作为 a
() => fn3(() => {});
// 第 2 次 reduceRight 的返回值,下一次将作为 a
() => fn2(() => fn3(() => {}));
// 第 3 次 reduceRight 的返回值,下一次将作为 a
() => fn1(() => fn2(() => fn3(() => {})));
由上面的拆解过程可以看出,如果我们调用了这个函数会先执行 fn1
,如果调用 next
则会执行 fn2
,如果同样调用 next
则会执行 fn3
,fn3
已经是最后一个中间件函数了,再次调 next
会执行我们最初传入的空函数
,这也是为什么要将 reduceRight
的初始值设置成一个空函数
,就是防止最后一个中间件调用 next 而报错
。经过测试上面的代码不会出现顺序错乱的情况,但是在 compose
执行后,我们希望进行一些后续的操作,所以希望返回的是 Promise
,而我们又希望传入给 use
的中间件函数既可以是普通函数
,又可以是 async 函数
,这就要我们的 compose 完全支持异步
。
- 异步实现
compose() {
// reduceRight, 逆序归并
return Promise.resolve(
self.middlewares.reduceRight(
(a, b) => () => Promise.resolve(b(a)),
() => Promise.resolve()
)()
)
}
参考同步的分析过程,由于最后一个中间件执行后执行的空函数内一定没有任何逻辑,但为遇到异步代码可以继续执行(比如执行 next
后又调用了 then
),都处理成了 Promise
,保证了 reduceRight
每一次归并的时候返回的函数内都返回了一个 Promise
,这样就完全兼容了 async
和普通函数
,当所有中间件执行完毕,也返回了一个 Promise
,这样 compose
就可以调用 then
方法执行后续逻辑。
reduce(Redux新版使用正序归并)
- 同步实现
compose () {
return self.middlewares.reduce((a, b) => arg => a(() => b(arg)))(() => {});
};
Redux
新版本中将 compose
的逻辑做了些改动,将原本的 reduceRight
换成 reduce
,也就是说将逆序归并改为了正序,我们不一定和 Redux 源码完全相同,
是根据相同的思路来实现串行中间件的需求。个人觉得改成正序归并后更难理解,所以还是将上面代码结合案例进行拆分,中间件依然是 fn1
、fn2
和 fn3
,由于reduce
并没有传入初始值,所以此时 a
为 fn1
,b 为 fn2
。
// 第 1 次 reduce 的返回值,下一次将作为 a
arg => fn1(() => fn2(arg));
// 第 2 次 reduce 的返回值,下一次将作为 a
arg => (arg => fn1(() => fn2(arg)))(() => fn3(arg));
// 等价于...
arg => fn1(() => fn2(() => fn3(arg)));
// 执行最后返回的函数连接中间件,返回值等价于...
fn1(() => fn2(() => fn3(() => {})));
所以在调用 reduce
最后返回的函数时,传入了一个空函数
作为参数
,其实这个参数
最后传递给了 fn3
,也就是第三个中间件
,这样保证了在最后一个中间件调用 next 时不会报错。
- 异步实现
compose() {
// reduce版本
return Promise.resolve(
self.middlewares.reduce((a, b) => arg =>
Promise.resolve(a(() => b(arg)))
)(() => Promise.resolve())
);
}
使用async函数实现(仅记录)
compose() {
return (async function () {
// 定义默认的 next,最后一个中间件内执行的 next
let next = async () => Promise.resolve();
// middleware 为每一个中间件函数,oldNext 为每个中间件函数中的 next
// 函数返回一个 async 作为新的 next,async 执行返回 Promise,解决异步问题
function createNext(middleware, oldNext) {
return async () => {
await middleware(oldNext);
}
}
// 反向遍历中间件数组,先把 next 传给最后一个中间件函数
// 将新的中间件函数存入 next 变量
// 调用下一个中间件函数,将新生成的 next 传入
for (let i = self.middlewares.length - 1; i >= 0; i--) {
next = createNext(self.middlewares[i], next);
}
await next();
})();
}