Koa2中为什么不要使用普通中间件
Koa2里支持两种中间件的写法,一种是使用ES7async/await
语法的异步函数,这里我们称为异步中间件。一种是使用普通函数语法的普通中间件。尽管说官方支持两种写法,但是在实际应用中,我们可能不大常见普通中间件的写法。为什么呢?
tl;dr
- 普通中间件可以有,但没必要;
- 错误的普通中间件写法可能破坏洋葱模型;
- 正确的普通中间件的写法可能和想象中的有点不太一样。
¶Koa2的中间件洋葱模型
大家都知道Koa2的中间件的执行流类似洋葱,即请求会从第一个中间件开始然后暂停在next
,接着执行第二个中间件,一直到最后一个中间件暂停在next
。接着最后一个中间件从next
恢复执行,执行完之后倒数第二个中间件从next
的地方恢复执行,一直到第一个中间件从next
恢复执行到完毕。
1 | const Application = require('koa') |
¶Koa2中间件洋葱模型的实现
这一部分我们简单介绍一下Koa2中间件洋葱模型的实现。当我们使用app.use(middleware)
时,其实内部会将middleware
推入一个数组保存:
1 | // koa/lib/application.js |
忽略掉大量检查代码,use
就做了一件事情,this.middleware.push(fn)
。this.middleware
是一个数组。
接下来,在app.listen(3000)
的时候,app.listen
会调用原生的http
模块,然后使用this.callback()
产生一个回调函数在请求到来的时候调用。接下来就是重头戏了,
1 | callback() { |
注意到compose(this.middleware)
,它将中间件数组组合起来,然后返回一个执行函数fn
,当调用执行函数时,如fn(ctx)
时,ctx
就会以洋葱模型被各个中间件所处理。this.handleRequest
干的就是这么一件事,可以看源码:
1 | handleRequest(ctx, fnMiddleware) { |
这里我们重点来看一下compose
函数,它是实现洋葱模型的核心函数。
¶compose函数
compose
函数并不在koa
包里,而是在koa-compose
包中,是一个非常短小精悍的函数。源码地址在这里。我们只保留函数的核心部分如下:
1 | module.exports = compose |
从上面可以看到我们的中间件是如何调用的,比如上面第一个中间件其实是以middleware_1(context, dispatch(1))
的形式调用的。函数中使用到了Promise
来保证各个中间件按照洋葱模型执行。具体原理可以自行推导一下。
¶一种错误的普通中间件写法
下面是一种很流行的错误的普通中间件写法:
1 | const one = (ctx, next) => { |
代码出自阮一峰老师的Koa 框架教程。上面的代码是说明中间件栈(和洋葱模型差不多)这个概念。但是,代码成立的条件是所有中间件都是同步的。当其中有任意一个中间件是async
的时候,代码就可能不是按照洋葱模型执行了。阮一峰老师提到**如果有异步操作(比如读取数据库),中间件就必须写成 async 函数。**可能就是因为这一原因。
下面是一个例子,在响应请求的过程中,我们需要记录响应请求所需要的时间,然后对用户传过来的密码进行哈希之后保存。对密码加盐哈希是一个非常耗时的操作,一般使用异步,这里为了模拟耗时,使用setTimeout
延时1s。
1 | const Koa = require('koa') |
当在浏览器里请求localhost:3000
时,console输出如下:
1 | logger starts |
可以看到logger
的next
之下的部分先于hashPassword
的next
之下的部分执行,这破坏了洋葱模型。
¶这种写法问题出现在哪?
注意到,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
,函数会暂停执行并立即返回一个状态为pending
的Promise
。也就是说,hashPassword
在第一个await
的时候就返回了一个pending
的Promise
。
1 | const hashPassword = async (ctx, next) => { |
然后问题就大了,Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
一看到fn
返回了一个Promise
,接着就把这个Promise
再返回给上层。此时dispatch(1)
结束。logger
看到next()
返回,然后兴高采烈地执行接下来的语句。此时hashPassword
仍然在紧张地哈希用户的密码,甚至还没有调用next()
函数。洋葱模型就此打乱。
¶那么正确的普通中间件的写法是?
既然官方支持普通中间件,那么正确的写法是什么呢?根据文档一个正确的普通中间件的写法应该是:
1 | // Middleware normally takes two parameters (ctx, next), ctx is the context for one request, |
可以看到正确处理next()
返回的Promise
才能够保证洋葱模型的正确性。。。
既然要处理Promise
的话…为什么不直接使用async/await
呢?
1 | app.use(async (ctx, next) => { |
上面的代码明显要比普通中间件的写法要好看一点。
¶总结
文章可能标题党了一点,Koa里面的中间件非得是async
函数吗?当然不是。但是普通中间件的写法,第一可能和你想象中的不一样,第二还要略懂中间件实现的原理才能够正确实现普通中间件。如果坚信自己会用普通中间件,just do it!