Koa2中为什么不要使用普通中间件

Koa2里支持两种中间件的写法,一种是使用ES7async/await语法的异步函数,这里我们称为异步中间件。一种是使用普通函数语法的普通中间件。尽管说官方支持两种写法,但是在实际应用中,我们可能不大常见普通中间件的写法。为什么呢?

tl;dr

  1. 普通中间件可以有,但没必要;
  2. 错误的普通中间件写法可能破坏洋葱模型;
  3. 正确的普通中间件的写法可能和想象中的有点不太一样。

Koa2的中间件洋葱模型

大家都知道Koa2的中间件的执行流类似洋葱,即请求会从第一个中间件开始然后暂停在next,接着执行第二个中间件,一直到最后一个中间件暂停在next。接着最后一个中间件从next恢复执行,执行完之后倒数第二个中间件从next的地方恢复执行,一直到第一个中间件从next恢复执行到完毕。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const Application = require('koa')

const middleware_1 = async (ctx, next) => {
console.log('middleware_1 before next')
await next()
console.log('middleware_1 after next')
}

const middleware_2 = async (ctx, next) => {
console.log('middleware_2 before next')
await next()
console.log('middleware_2 after next')
}

const app = new Application()
app.use(middleware_1)
app.use(middleware_2)

app.listen(3000)

// 当有请求经过的时候,console会打印
// middleware_1 before next
// middleware_2 before next
// middleware_2 after next
// middleware_1 after next

Koa2中间件洋葱模型的实现

这一部分我们简单介绍一下Koa2中间件洋葱模型的实现。当我们使用app.use(middleware)时,其实内部会将middleware推入一个数组保存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// koa/lib/application.js

module.exports = class Application extends Emitter {
//...
use(fn) {
// if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
// 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);
// }
// debug('use %s', fn._name || fn.name || '-');
this.middleware.push(fn);
return this;
}
//...
}

忽略掉大量检查代码,use就做了一件事情,this.middleware.push(fn)this.middleware是一个数组。

接下来,在app.listen(3000)的时候,app.listen会调用原生的http模块,然后使用this.callback()产生一个回调函数在请求到来的时候调用。接下来就是重头戏了,

1
2
3
4
5
6
7
8
9
10
11
12
callback() {
const fn = compose(this.middleware);

// if (!this.listenerCount('error')) this.on('error', this.onerror);

const handleRequest = (req, res) => {
// const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn);
};

return handleRequest;
}

注意到compose(this.middleware),它将中间件数组组合起来,然后返回一个执行函数fn,当调用执行函数时,如fn(ctx)时,ctx就会以洋葱模型被各个中间件所处理。this.handleRequest干的就是这么一件事,可以看源码:

1
2
3
4
5
6
7
8
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);
}

这里我们重点来看一下compose函数,它是实现洋葱模型的核心函数。

compose函数

compose函数并不在koa包里,而是在koa-compose包中,是一个非常短小精悍的函数。源码地址在这里。我们只保留函数的核心部分如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
module.exports = compose

function compose (middleware) {

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

从上面可以看到我们的中间件是如何调用的,比如上面第一个中间件其实是以middleware_1(context, dispatch(1))的形式调用的。函数中使用到了Promise来保证各个中间件按照洋葱模型执行。具体原理可以自行推导一下。

一种错误的普通中间件写法

下面是一种很流行的错误的普通中间件写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const one = (ctx, next) => {
console.log('>> one');
next();
console.log('<< one');
}

const two = (ctx, next) => {
console.log('>> two');
next();
console.log('<< two');
}

const three = (ctx, next) => {
console.log('>> three');
next();
console.log('<< three');
}

app.use(one);
app.use(two);
app.use(three);

代码出自阮一峰老师的Koa 框架教程。上面的代码是说明中间件栈(和洋葱模型差不多)这个概念。但是,代码成立的条件是所有中间件都是同步的。当其中有任意一个中间件是async的时候,代码就可能不是按照洋葱模型执行了。阮一峰老师提到**如果有异步操作(比如读取数据库),中间件就必须写成 async 函数。**可能就是因为这一原因。

下面是一个例子,在响应请求的过程中,我们需要记录响应请求所需要的时间,然后对用户传过来的密码进行哈希之后保存。对密码加盐哈希是一个非常耗时的操作,一般使用异步,这里为了模拟耗时,使用setTimeout延时1s。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const Koa = require('koa')

const logger = (ctx, next) => {
console.log('logger starts')
const entryTime = new Date()
next()
const msUsed = new Date() - entryTime
console.log(`response takes ${msUsed}ms.`)
}

const hashPassword = async (ctx, next) => {
console.log('Hashing user password')
await new Promise(resolve => {
setTimeout(() => {
console.log('password has been hashed')
resolve()
}, 1000)
})
ctx.body = "password secure"
await next()
console.log('Hashing password after next')
}

const app = new Koa()
app.use(logger)
app.use(hashPassword)

app.listen(3000)

当在浏览器里请求localhost:3000时,console输出如下:

1
2
3
4
5
logger starts
Hashing user password
response takes 1ms.
password has been hashed
Hashing password after next

可以看到loggernext之下的部分先于hashPasswordnext之下的部分执行,这破坏了洋葱模型。

这种写法问题出现在哪?

注意到,next()返回的是一个Promise,在上面的写法中没有对这个Promise做任何处理,直觉告诉我们,这里极有可能会出现异步调用的顺序问题。事实上也是如此,我们来一步一步的分析问题产生的原因。

首先,logger函数被调用,传入的next参数是dispatch(1)。当logger执行next(),实际上dispatch(1)被执行。注意到dispatch(i)的返回值:

1
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));

这个Promise.resolve是什么东西呢?

Promise.resolve(value)方法返回一个以给定值解析后的Promise 对象。但如果这个值是个thenable(即带有then方法),返回的promise会“跟随”这个thenable的对象,采用它的最终状态(指resolved/rejected/pending/settled);如果传入的value本身就是promise对象,则该对象作为Promise.resolve方法的返回值返回;否则以该值为成功状态返回promise对象。

很不巧,接下来的fn,即hashPassword是一个async函数,当运行async函数时,遇见await,函数会暂停执行并立即返回一个状态为pendingPromise。也就是说,hashPassword在第一个await的时候就返回了一个pendingPromise

1
2
3
4
5
6
7
8
9
10
11
12
const hashPassword = async (ctx, next) => {
console.log('Hashing user password')
>> await new Promise((resolve) => {
setTimeout(() => {
console.log('password has been hashed')
resolve()
}, 1000)
})
ctx.body = "password secure"
await next()
console.log('Hashing password after next')
}

然后问题就大了,Promise.resolve(fn(context, dispatch.bind(null, i + 1)))一看到fn返回了一个Promise,接着就把这个Promise再返回给上层。此时dispatch(1)结束。logger看到next()返回,然后兴高采烈地执行接下来的语句。此时hashPassword仍然在紧张地哈希用户的密码,甚至还没有调用next()函数。洋葱模型就此打乱。

那么正确的普通中间件的写法是?

既然官方支持普通中间件,那么正确的写法是什么呢?根据文档一个正确的普通中间件的写法应该是:

1
2
3
4
5
6
7
8
9
10
// Middleware normally takes two parameters (ctx, next), ctx is the context for one request,
// next is a function that is invoked to execute the downstream middleware. It returns a Promise with a then function for running code after completion.

app.use((ctx, next) => {
const start = Date.now();
return next().then(() => {
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});
});

可以看到正确处理next()返回的Promise才能够保证洋葱模型的正确性。。。

既然要处理Promise的话…为什么不直接使用async/await呢?

1
2
3
4
5
6
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});

上面的代码明显要比普通中间件的写法要好看一点。

总结

文章可能标题党了一点,Koa里面的中间件非得是async函数吗?当然不是。但是普通中间件的写法,第一可能和你想象中的不一样,第二还要略懂中间件实现的原理才能够正确实现普通中间件。如果坚信自己会用普通中间件,just do it!

参考资料

  1. Promise.resolve() - MDN