【转载】跨入Koa2.0,从Compose开始

417 阅读11分钟
原文链接: cnodejs.org

什么是Koa

在当前的http1.1大时代下,web应用早已经远远超出于网站和html的范畴了,尤其在node出现之后,结合ioredis,mysql,momgodb等值的信赖的npm模块,就算是使用node搭建纯api的rpc框架,也不是什么让人惊讶的事情。整个web应用体系就是一个标准的异构体系,异构体系之间想完成某种程度的协作,这就依赖协议了,那么在web应用体系里面,无疑http1.1协议正是承载异构系统间通信的重任。这种前提下,一个好的web应用架构,首先需要的就是方便使用的http请求的路由模块,以及尽量精简的其余内建模块,并且拥有便捷的预处理中间件注册机制。针所以整个的koa,在我看来其实是一个面向纯web应用的框架,其极致瘦身的框架源码,优秀的中间件兼容机制,可以让不同项目很方便的编写适用的中间件,从而让web应用很方便的搭建起来。 很多人开始接触Koa的时候会不由自主将express和koa进行对比,在我看来,中间件加载模式、流程控制甚至完全的去回调其实都不是两者最本质的区别。我觉得koa和express最最大的区别在于,express是一个面向网站和html的框架,对于一些SAAS或者RPC服务来说,并不关心模板引擎不关心页面渲染,那么此时express就稍显臃肿;而koa,已经剔除了几乎所有除了搭建web应用所需的最基本功能的所有冗余功能,而这,带给开发者以简洁之美,纵观koa框架源码,无论是koa1.x还是koa2.x,处处都是简洁优雅的实现,虽然理解起来可能不是那么容易。任何行业抽象到最后,就是艺术,从express到koa,便是nodejs的web框架从工具到艺术的升华。

初识Koa中间件

熟悉Express的小伙伴都知道,express的中间件加载是一个串行的顺序,依靠next进入下一个中间件; 对于Koa来说,中间件加载却是截然不同的呈现出V形: 从http请求进入服务器开始,经历了:

 Request——中间件1第一部分代码——await/yield next()——中间件2第一部分代码—
 —await/yield next()——中间件3全部代码——中间件2第二部分代码—
 —中间件1第二部分代码——Response

这样的一个类似级联的完整流程。 那么为什么Koa的中间件加载这样加载呢,这就到了本文的主题,Koa框架设计者TJ大神区别凡人的神思鬼算,同时也是导致Koa1.x项目和Koa2.x项目无法直接兼容的罪魁祸首,位于koa框架源码(1.x和2.x均是)中lib目录下的application.js里面引入的compose模块。

隐藏的Compose

如果说Koa框架暴露给开发者的app.use()方法就像是沐浴在光明中的树叶,那么Compose模块就可以说是深藏于黑暗中的根。 Compose模块是Koa框架中间件执行的发动机,下面我们就从源码来理解一下为什么Compose模块如此重要,以及,Koa框架中间件V形加载机制的原因。 首先是Koa1.x

function compose(middleware){
	return function *(next){
		if (!next) next = noop();
		var i = middleware.length;
		while (i--) {
  			next = middleware[i].call(this, next);
			}
	 	return yield *next;
		}
	}

上面是Koa1.x的Compose源码,看起来有点复杂是不是,下面我把它变成短短三行来方便理解:

this.middleware = this.middleware.reverse();
this.middleware.forEach(item=>next=item.call(this,next));
yield * next;

我们逐行来解析,首先 第一行:

this.middleware = this.middleware.reverse();

很清晰,middleware参数是一个数组,存储了开发者使用app.use(generator function)注册的中间件,此句话仅仅是把这个数组倒叙排列了一遍。

第二行:

this.middleware.forEach(item=>next=item.call(this,next));

不用说大家也知道,核心逻辑就是这一行代码。那么这一行代码做了哪些事情呢,其实也很简单,forEach对中间件数组进行遍历: 第一次时,next=最后一个中间件.call(this, next), 此时next为默认的空generator函数; 第二次时,next=倒数第二个中间件.call(this,next), 此时next为最后一个中间件执行后的generator函数; 第三次时,next=倒数第三个中间件.call(this,next), 此时next为倒数第二个中间件执行后的generator函数; … 第N-1次时,next=正数第二个中间件.call(this,next), 此时next为正数第三个中间件执行后的generator函数; 第N次时,next=第一个中间件.call(this,next), 此时next为正数第二个中间件执行后的generator函数; 到了这里其实就很清晰了,第二段代码将每一个中间件的下一个中间件作为next入参传入,所以在每一个中间件中调用yield next,其实就代表中断本中间件执行代码,跳转到下一个generator中间件执行。 当然能这样执行的原因除了ES6的generator函数,还依赖于TJ大神的co库,这个库简单的说就是用来自动执行generator函数的。源码也是写的相当精妙,以后有机会我会再写一点深入解析co源码的文章,目前大家只需要知道,形如:

function * _gen  (){
	let body = yield promiseGet();//一个包装成promise的异步操作
	console.log(body);
}
co(_gen).catch(err=>console.error(err));

可以按照代码顺序“同步”执行就行了。

第三行:

yield * next;

理解了第二行,这行也就相当简单了,此时的next经过一系列for循环赋值,内容其实就是第一个中间件,那么这里yield * 第一个中间件,相当于开始执行第一个中间件里面的代码。 至此,中间件自动开始加载执行,并且从第一个中间件开始,遇到yield next,就中断本中间件的代码执行,跳转到对应的下一个generator中间件执行期内的代码…一直到最后一个中间件,然后逆序回退到倒数第二个中间件yield next下部分的代码执行,完成后继续会退…一直会退到第一个中间件yield next下部分的代码执行完成,中间件全部执行结束。

接下来分析下Koa2.x:

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
  		const fn = middleware[i] || next
  		if (!fn) return Promise.resolve()
  		try {
    		return Promise.resolve(fn(context, function next () {
      		return dispatch(i + 1)
    	}))
  		} catch (err) {
    		return Promise.reject(err)
  		}}}
	}

老规矩,先上源码。那么在Koa2.x里面,compose方法对比1.x已经变得相当复杂了,很遗憾,逻辑也确实复杂了一些,但是中心思想是没有变的。更加遗憾的是,这个代码我想了很久也没办法把它变得简单一些方便大家理解。这个方法里面的核心就是dispatch函数(废话,整个compose方法就返回了一个函数)。没有办法简写,但是我们可以将dispatch函数类似递归的调用展开,还是以三个中间件为例: 第一次,此时第一个中间件被调用,dispatch(0),展开:

Promise.resolve(function(context, next){
	//中间件一第一部分代码
	await/yield next();
	//中间件一第二部分代码
}());

很明显这里的next指向dispatch(1),那么就进入了第二个中间件;

第二次,此时第二个中间件被调用,dispatch(1),展开:

Promise.resolve(function(context, 中间件2){
	//中间件一第一部分代码
	await/yield Promise.resolve(function(context, next){
		//中间件二第一部分代码
		await/yield next();
		//中间件二第二部分代码
	}())
	//中间件一第二部分代码
}());

很明显这里的next指向dispatch(2),那么就进入了第三个中间件;

第三次,此时第二个中间件被调用,dispatch(2),展开:

Promise.resolve(function(context, 中间件2){
	//中间件一第一部分代码
	await/yield Promise.resolve(function(context, 中间件3){
		//中间件二第一部分代码
		await/yield Promise(function(context){
			//中间件三代码
		}());
		//中间件二第二部分代码
	})
	//中间件一第二部分代码
}());

此时中间件三代码执行完毕,开始执行中间件二第二部分代码,执行完毕,开始执行中间一第二部分代码,执行完毕,所有中间件加载完毕。 可以看到,Koa2.x的compose方法虽然从纯generator函数执行修改成了基于Promise.all,但是中间件加载的中心思想没有发生改变,依旧是从第一个中间件开始,遇到await/yield next,就中断本中间件的代码执行,跳转到对应的下一个中间件执行期内的代码…一直到最后一个中间件,然后逆序回退到倒数第二个中间件await/yield next下部分的代码执行,完成后继续会退…一直会退到第一个中间件await/yield next下部分的代码执行完成,中间件全部执行结束。

为什么不兼容

看完上面的核心章节,大家应该就比较容易理解了。Koa1.x中的中间件,是纯粹的generator,compose函数的返回结果也是使用了co.wrap包装后统一执行的; 那么在2.x中,每一个中间件的加载都是Promise.resolve(function(){}())的形式,对于generator函数来说,直接function * generation(){}()的形式是无法得到执行结果的。所以对于1.x中纯粹的generator函数形式的中间件,2.x无法做到直接的向下兼容。 但是难道就没有办法解决了吗?当然不是,Koa的团队为了吸引更多的开发者从1.x转到2.x,提供了koa-convert模块,对于1.x中团队里面自己编写的纯generator中间件,只要使用:

const mw = convert(generator中间件)

的形式,就可以直接在2.x里面使用啦。 对于这个这个Koa2.x的convert模块,其源码也是非常简单,逻辑容易理解的,如果大家有兴趣,可以自行阅读下其实现。很多时候对于源码的阅读,其实是让自己对项目采用技术的掌控力度更高一些,有很多开源的npm包文档或许写的不是那么全,甚至你在项目中的使用可能会因为自己使用不当造成的一些问题,而这些问题其实都可以在源码里面找到答案。所以非常建议大家使用新技术新开源模块,先去阅读下源码,不一样要每一行都理解,但是对于核心的模块,一定要去看下其实现逻辑,这样才能做到心中有数,coding不慌。

如何选择

那么现在Koa1.x和Koa2.x并存于世的现象估计还会在将来存在很长一段时间。这真是逼死强迫症的节奏,下面我们来探讨下如何选择。 其实2.x最重要的优势要等ES7的async函数和await关键字集成到js标准库里面之后才能体现出来。目前2.x的三种写法:

1.普通函数
2.co.wrap或者koa convert后的generator函数
3.async函数

对于1,我建议选择1的小伙伴去使用express,因为纯普通函数写中间件完全体现不出koa框架异步流程控制的优势,这个迁移到koa是毫无价值的,当然追赶下潮流忽悠忽悠别人不算,哈哈。 那么对于2,其实也是我现在推荐的写法,原因很简单,generator函数在ES6已经原生支持了,并且目前nodejs稳定版本(截止发文为4.4.7)徘徊在4.x,而4.x的node版本里面嵌入的V8引擎也是在解释层面完美支持generator函数。 对于3,尝鲜的写法,目前写完后,需要各种转码工具转码后才能运行,适合技术激进党。在目前ES6还在大力推广普及的情况下,对于超前使用ES7的新特性的行为,我即不赞成也不反对。

写在最后

我很庆幸自己真正开始接触到javascript的一些技术细节是在这个js大爆发的时代;js的意义和作用已经远远超脱于浏览器本身,现在用node去写服务端已经是再正常不过的事情了。以前想都不敢想的app全端开发,现在react native的支持下也似乎不是那么遥不可及的事情;甚至于,使用NW.js等第三方库,我们都可以直接使用js开发跨平台的桌面工具,这里的平台已经覆盖到Windows和*nix这样的和人们息息相关的OS上。这是最好的时代,也是最坏的时代