
2.1.3 回形针一样的中间件
Koa中间件可以对请求和响应同时进行拦截,这是Web框架里少有的强大功能,其他Web框架只对请求进行拦截,不对响应进行拦截,比如Express。所以在中间件机制上,Koa是占有优势的。当然,强大也意味着复杂,中间件叠加之后,请求和响应同时拦截就会形成类似回形针一样的调用,因而大家都习惯称Koa的中间件为“回形针”。为了了解中间件的工作原理,我们先从实例看起,总结规律,进而深究其原理。
单一中间件的工作示意图如图2-3所示。

图2-3
中间件就是过滤器,Koa中间件的工作过程可以分为3部分,具体如下。
○ 处理请求前先完成一些准备工作。
○ 进行业务逻辑处理或通过next将业务交由下一个中间件处理。
○ 后面的中间件完成处理后会进行回溯,执行处理后的操作。
所以Koa的中间件可以理解为像凹槽一样的积木模块,结合HTTP,只要有一个中间件处理了请求,这次请求就算结束。如果中间件不想结束请求,那么把处理权转交给后面的中间件就可以了。
我们以Koa v1为例来看一下中间件处理请求的过程,代码如下。

下面是对这段代码的分析。
○ var start=new Date;表示处理前,边界是yield next;,在此之前的代码都算处理前的代码,无论有多少行。
○ yield next;用于将处理权转交给后面的中间件,如果没有next的话,转让处理权的操作会被停止,并开始回溯。
○ var ms=new Date-start;及console.log('%s%s-%s',this.method,this.url,ms);是处理后的代码。
多个中间件同时工作的示意图如图2-4所示。一个完整的Koa Web应用其实就是各种中间件的组装。多个中间件同时工作时,原理如下。
○ 如果中间件1不想处理请求,就把处理权转交给中间件2。
○ 如果中间件2也不想处理,就把处理权转交给中间件3。
○ 如果中间件3还不想处理,则只能报错。

图2-4
➘ 中间件执行顺序
对于Express来说,中间件只对请求进行拦截,而且是有顺序的。但对于Koa来说,可以在一个中间件里对请求和响应同时进行拦截。于是在Koa应用中,第一个中间件里的请求是1,响应是2,第二个中间件里的请求是3,响应是4,那么最终输出结果就是1342。这看起来很有趣,下面我们来分析一下。
1.从Generator说起
为了便于大家理解,先来看一下Generator的用法,示例如下。

以上代码的执行结果如下。

从这个例子可以看到,hello()生成了it1对象,由it1.next()来执行第一个yield内容。对于a这个Generator里的内容,yield之前的语句是先执行的,yield之后的语句是在b执行完成后才执行的。这就是回形针的早期体现。
2.用co简化代码
Generator的执行过程是非常烦琐的,上面的例子明明很简单,但执行起来却有点绕,还好我们有co这个Generator执行器,能够让代码执行起来更简单。代码如下。

这段代码的结果与前面例子的结果是一样的,只是调用方式更加简单。
3.具体的“1342”
为了演示中间件执行顺序,笔者创造了“1342”这个词,下面为各位读者演示一下原理,代码如下。

以上代码的执行结果如下。

中间件执行顺序是a→b→c,但最终结果的顺序是a1→b3→c→b4→a2,这其实正反映了本节的核心:当两个中间件叠加使用时,假设第一个中间件里的请求是1,响应是2,第二个中间件里的请求是3,响应是4,那么具体的执行顺序是1→3→4→2。
4.中间件的写法
先来看一个模拟的Koa中间件的示例,代码如下。

koa-compose是Koa中最核心的模块,用于组合多个中间件,最终合并成一个中间件。通过co和koa-compose模拟中间件机制再适合不过了,简单且容易理解。
通过上面的3个示例,相信大家已经能够理解Generator的yield处理权转让机制了。通过Generator嵌套next方法能够实现这种回溯功能。
➘ 探索原理
前面讲了中间件的各种用法,我们还总结了“1342”问题,那么,Koa到底是怎样实现中间件机制的呢?本节将给出精简的Koa中间件机制代码,以帮助读者更轻松地读懂Koa源码。
1.探索1
假设app.js代码如下。


测试代码如下。

探索1的代码里只依赖co模块,整体来说是非常精简和容易理解的,下面我们来详细解读。
(1)中间件是由数组保存的,代码如下。

(2)app.use使用中间件的代码如下。通过push方法给数组增加中间件,返回this,这种简单的链式写法支持app.use(fn).use(fn2)的使用。

(3)测试代码如下。

测试代码执行到callback函数之后,会打印出结果1342,即callback函数是入口,并处理完所有中间件调用,代码如下。

以上代码的要点如下。
○ 通过this.compose把this.middleware转变成一个名为fn的Generator函数。
○ 通过co来执行fn。
○ co的返回值是Promise对象,所以then后面接了两个参数,其中cb是成功的回调,后面的匿名函数是用于处理异常的。
这里的逻辑比较简单,就是通过co执行fn,获得最终结果。但问题是this.middleware是基于Generator函数的数组,怎么把它转化成Generator呢?这其实就是通过this.compose来实现的。
(4)核心是compose,代码如下。

以上代码的功能其实就是将compose([f1,f2,...,fn])转化为fn(...f2(f1(noop()))),最终的返回值是一个Generator函数。
compose返回的是Generator,因此它就需要一个Generator执行器,也就是上文出现的co,来搭配使用。compose其实就是koa-compose的核心功能,由此可见koa-compose的重要程度。
2.探索2
探索2版本的app.js代码如下。

测试代码如下。

开源的convert模块源码中的测试用例能够非常简单地体现其用法,这也是学习开源模块的常用手段。很多开源项目的文档其实没有测试用例写得那么完善。convert对应的测试代码如下。


从上面测试代码中可以看出,convert.compose函数将各种中间件写法合并执行,最终返回了Promise。掌握以上代码对于熟练掌握中间件写法是有一定帮助的。
3.探索3
探索3版本的app.js代码如下。

对应的测试代码如下。


如果想把其中的一个中间件变成Koa v1支持的Generator写法,代码如下。

➘ 回溯机制
为了了解Koa v1中间件的回溯机制,先来看一下下面这段代码。

在终端里,执行app.js启动Web服务器,结果如下。

“1”是中间件具体处理的前置位置,“2”是中间件,before中间件依次向下处理,处理完业务逻辑后,after中间件向上执行,回到回形针底部,然后再次返回中间件2。
下面给出包含两个中间件的回溯示例,如果包含3个或更多中间件呢?请大家思考如何书写包含多个中间的回溯代码。

在终端里,执行app.js启动Web服务器,结果如下。

我们稍微进行一下可视化处理,看一下极其重要的“1342”。

Express里的中间件机制描述如下,原理见图2-5。通过图2-5可以看出,Express中间件对请求进行处理,但无法对响应进行处理。


图2-5
Koa里的中间件机制描述如下,原理如图2-6所示。


图2-6
通过图2-6可以看出,Koa的中间件能对请求做出处理,也能对响应做出处理。对比Express中间件机制,Koa中间件可以同时处理请求和响应,这种能力让Koa中间件更强大,更富有表现力。
前面说过,Koa中间件叠加之后,请求和响应同时被拦截就会形成类似回形针一样的调用,因而大家都称Koa中间件为“回形针”,如图2-7所示。

图2-7
当一个请求过来的时候,会依次被各个中间件处理,中间件跳转的信号是next(),当请求到达某个中间件,且该中间件处理完请求不执行next()时,程序就会逆序执行前面的中间件剩下的逻辑。
通过以上执行过程,我们不难看出中间件的执行顺序:流程是一层层地打开,然后一层层地闭合的,就像剥洋葱一样。早期的Python为这种执行方式起了一个很好听的名字,洋葱模型,如图2-8所示。

图2-8
其实回形针模型和洋葱模型是一回事,理解了上面说的Koa中间件机制,就可以很好地理解这些模型了。
1.回形针的实现:compose
先来看一段关于compose的代码,具体如下。

上面的代码其实就是把compose([f1,f2,...,fn])转化为了f1(...f(n-1)(fn(noop()))),最终的返回值是一个function (context,next){}。
从dispatch(0)开始递归执行如下代码。

然后执行如下操作。

这是执行第一个中间件机制的代码,如果执行成功,就继续执行第二个中间件机制,依此类推,直至执行完所有的中间件机制。
我们结合这个示例来看一下下面的示例。

实际上,这个中间件写法和上面演示的fn是一样的。为了进一步说明中间件返回的是Promise,这里给出示意代码。

我们知道Promise.resolve()返回的是Promise对象,它是可以实现Promise链式调用的。
再来看一个执行多个中间件机制的例子,具体如下。

上面代码的意思是在app上挂载两个中间件,其原理类似于m1(m2()),代码如下。

如果dispatch_1_value对象中有then方法,则一定会继续执行并打印出4,然后执行return Promise.resolve(value)就可以获得返回值。此时如果还有一个then方法,则会打印出2。
通过上面的介绍,我们已经清楚知道了中间件的执行原理,下面演示基于中间件的“1342”示例,代码如下。

至此,你应该已经理解为什么app.js上挂载两个中间件会输出1342这样的结果了。
虽然koa-compose是底层核心库,不过也是可以用在开发过程中的,而且有时候会给开发带来意想不到的便利性。下面我们来看一个例子。

通过以上代码可以看出,koa-compose可以将多个中间件组合成一个中间件,如果可以这样使用,在某些场景下是非常方便的。
2.改进探索原理一节中探索3的app.js
笔者想将app.js修改为和Koa源码更接近的样子,修改后的代码如下。


上述代码与之前的app.js相比,主要变化在use函数里。

上面use函数中的核心代码如下。

如果fn是生成器函数(Koa v1中间件),则需要通过convert进行转换,转换时结合co模块,具体方法如下。经过转换,原函数变为ES6 Generator函数,这样就保证了所有compose里的中间件写法是统一的。

3.从Koa v1到Koa v2
有的读者会问,为什么上面的例子都是基于Koa v1的,而你却推荐使用Koa v2?不要着急,听我慢慢道来。
Koa v1的中间件机制其实就是将compose([f1,f2,...,fn])转化为fn(...f2(f1(noop()))),最终的返回值是一个生成器函数。而Koa v2的中间件机制是将compose([f1,f2,...,fn])转化为f1(...f(n-1)(fn(noop()))),最终的返回值是一个function (context,next){}。即使返回值是function*(next){},也会被转换成function (context,next){},这就是convert的向后兼容特性的体现。