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

浙公网安备 33010602011771号