Koa框架学习之旅之回源中间件加载机制源码解析
发布于 8 年前 作者 hyj1991 4501 次浏览 来自 分享

Koa框架学习之旅之回源中间件加载机制源码解析 由于目前使用的是express,我个人又比较喜欢用ES6的generator函数+co+promise,来实现完全的去回调,那么在express框架里,经常把路由函数写成形如:

function _genHandle(req,res,next){
	yiled 异步操作
	....
}

function handle(req,res,next){
	co(_genHandle,req,res,next).catch(err=>next(err));
}
app.get('/test',handle);

的形式。对于Koa框架,早有耳闻,但是一直没有详细的去了解,上周正好有空大致看了介绍的文档,似乎中间件是可以直接使用generator函数去写的,一下子感觉好熟悉,哈哈,正好项目又有进一步瘦身的需求,所以现在开始学习研究下Koa框架。我个人觉得,一个开源框架的使用,可以通过阅读源码,然后再看API文档来提高对项目本身的掌控力度。 其实Koa对比express,在中间件加载上最大的不同是express是纯粹的串行执行而Koa是部分顺序串行而后逆序串行回到最初,类似于一个V型的回源,即: express:中间件1—>中间件2—中间件3 Koa:中间件1(yield 前)—>中间件2(yield 前)—>中间件3—>中间件2(yield 后)—>中间件1(yield 后) 看起来很神奇是吧,哈哈,其实这样加载的原因在读完lib下的application.js后,还是很清晰的。下面是application.js的源码解析,基本上是按行解析的,当然还是有些不清楚的地方,欢迎大家讨论哈~

一.

//Koa的构造函数,let app = require('koa')()后得到的既是Application的一个实例
function Application() {
	//本行是可以形如let app = Application()的方式生成Application的实例,免去new的过程
	if (!(this instanceof Application)) return new Application;		
	//下面两行env应该是项目环境变量设置,subdomainOffset作用暂时不清楚
	this.env = process.env.NODE_ENV || 'development';
	this.subdomainOffset = 2;
	//构造函数初始化middleware属性,为数组,放置Application.prototype.use()方法push进来的Generator
	this.middleware = [];
	//proxy作用暂时不清楚
	this.proxy = false;
	//Object.create(obj),即将:
	//this.context.__proto__ = {inspect:func,toJSON:func,assert:func,throw:func,onerror:func}
	this.context = Object.create(context);
	//Object.create(obj),作用方法类似context
	this.request = Object.create(request);
	//Object.create(obj),作用方法类似context
	this.response = Object.create(response);
	}

二.

Object.setPrototypeOf(Application.prototype, Emitter.prototype);
//这句是一种继承方式,是的Application.prototype.__proto__ === Emitter.prototype, 这样Application的实例以及Application.protocol中的this即可直接调用Emitter事件方法;那么在这里,由于继承发生在prototype之前,所以等同于Application.prototype = Object.create(Emitter.prototype,{constructor: {value: Application,enumerable: false,writable: true,configurable: true}})

三.

//Application的原型方法listen定义,本质是实现了一个http.createServer(handle).listen(port,[cb])的语法糖
app.listen = function ()
	debug('listen');
	var server = http.createServer(this.callback());
	return server.listen.apply(server, arguments);
};

四.

//Application的原型方法inspect和toJSON定义,经查看only的源码此处本质是实现了一个筛选器,筛选出this对象中包含的subdomainOffset,proxy以及env对应 的value
//PS: only源码的核心方法是array.reduce
app.inspect =
	app.toJSON = function () {
    	return only(this, [
        	'subdomainOffset',
        	'proxy',
       		'env'
   		]);
	};

五.

app.use = function (fn) {
//第一步判断是否存在experimental属性(实验属性),如果不存在,调用nodejs自带的断言库assert(默认为assert.ok()) 来进行传入函数的constructor.name属性比较,如果不是GeneratorFunction,则assert.ok抛出异常,异常内容见字符串;
if (!this.experimental) {
    assert(fn && 'GeneratorFunction' == fn.constructor.name, 'app.use() requires a generator function');
}
debug('use %s', fn._name || fn.name || '-');
//第二步判断后,往构造函数初始化的middleware属性中,将此GeneratorFunction放入数组中;
this.middleware.push(fn);
//将this对象return出去,此处return作用不明,似乎没有太大意义
return this;
};

六.

//Application的原型方法核心之一的callback方法,其实就是http.createServer(handle).listen(port)里面的handle返回的是一个http句柄处理函数function(req,res){...}
//里面在ES6下使用了co对generator函数的黑魔法封装,详细剖析见函数内注释
app.callback = function () {
//依旧是判断experimental实现属性是否存在,如果存在,打印一条stderr流的信息提示用户,如果你想使用高大上的ES7黑魔法之async函数,请关注koa@2.00
//如果你是low比的ES6用户之一,那么请继续往下看
if (this.experimental) {
           console.error('Experimental ES7 Async Function support is deprecated. Please look into Koa v2 as the middleware signature has changed.')
//核心co包装方法之一,fn此处需要重点关注,整个koa实现的next中间件回形流加载机制(简单一点说,就是所有app.use()里面的按正向顺序执行到yield next之前,等没有next后再回流按照反向顺序执行每一个yield next之后的部分,注意此处的正向和反向关键字,听起来有点不可思议,但是详细理解了下面的compose函数之后,这个理解就顺利成章了)。
//此处,由于在ES6下,所以我们只需关注fn=co.wrap(compose(this.middleware))即可,compose库只返回了一个generator函数,可以改写成如下形式方便理解:
//this.middleware = this.middleware.reverse(); 
//this.middleware.forEach(item=>next=item.call(this,next));
//yield *next;
//可以看到执行入口在最后一个next(yield * next对应的是this.middleware数组的最后一个元素,由于一开始执行了reverse,所以这里的最后一个元素对应的是第一个加载的中间件),此处有一个神来之笔,是item.call(this,next),看到了没,这里强制传入了next,而这个next对应的就是上一个元素的generator实例!明白了此处,就可以轻易理解,从第一个中间件开始,因为遇到yiled next,中间件执行控制权跳转到下一个中间件...一直到没有next,开始回流,并且是从倒数第二个中间件的yield next后面的代码处开始回流(最后一个中间件的next可带可不带,compose方法提供了一个默认的空generator函数)。这边需要掌握的是在generator函数里调用generator函数,使用的是yield * otherGenerator的形式。
//这部分理解了剩下的就简单了,fn = co.wrap仅仅是利用了js的偏函数特性,返回一个执行了generator内容的常规函数而已,此处仅仅是方便对experimental属性的判断,如果没有这个判断,完全可以直接在最终返回的匿名函数里写成co.apply(ctx,compose(this.middleware))的形式。
var fn = this.experimental
  	? compose_es7(this.middleware)
    : co.wrap(compose(this.middleware));
var self = this;
//如果用户没有设置error事件处理,则启用默认的error事件处理
if (!this.listeners('error').length) this.on('error', this.onerror);
//返回的匿名函数,对应的就是上述http.createServer(handle).listen(port)里面的handle方法
return function (req, res) {
    //提供默认的statusCode(http响应码)
    res.statusCode = 404;
    //创建了一个上下文对象ctx(上一级原型为lib下的context对象),在我看来,主要做了:
    //1.把nodejs的http处理句柄获取到的req和res挂载在到ctx.req和ctx.res上;
    //2.把koa的request和response对象(lib下的request和response队形)挂载ctx.request和ctx.response上
    //3.把Application的原型方法this对象挂载到ctx.app属性下,方便随时调用
    //4.剩余的一些包含原始url,error处理函数以及cookie属性等挂载到ctx上,具体作用使用中体现
    var ctx = self.createContext(req, res);
    //对http的结束事件进行侦听处理,进行了例如给ctx的挂载status属性赋值等操作
    onFinished(res, ctx.onerror);
    //这里就是上述的co.wrap后的常规函数执行,具体处理逻辑见上述“核心co包装方法之一”段落
    fn.call(ctx).then(function () {
        respond.call(ctx);
        respond.call(ctx);
    }).catch(ctx.onerror);
}
};

七.

//Application的原型方法核心之一的createContext方法,大致说明见上一个原型函数
app.createContext = function (req, res) {
	var context = Object.create(this.context);
	var request = context.request = Object.create(this.request);
	var response = context.response = Object.create(this.response);
	context.app = request.app = response.app = this;
	context.req = request.req = response.req = req;
	context.res = request.res = response.res = res;
	request.ctx = response.ctx = context;
	request.response = response;
	response.request = request;
	context.onerror = context.onerror.bind(context);
	context.originalUrl = request.originalUrl = req.url;
	context.cookies = new Cookies(req, res, {
    keys: this.keys,
    secure: request.secure
		});
	context.accept = request.accept = accepts(req);
	context.state = {};
	return context;
};

八.

//Application的原型方法核心之一的onerror方法,错误处理
app.onerror = function (err) {
	assert(err instanceof Error, 'non-error thrown: ' + err);
	if (404 == err.status || err.expose) return;
	if (this.silent) return;
	// DEPRECATE env-specific logging in v2
	if ('test' == this.env) return;
	var msg = err.stack || err.toString();
	console.error();
	console.error(msg.replace(/^/gm, '  '));
	console.error();
};

九.

//封装的http响应方法,在app.callback()中调用,由于使用了respond.call(ctx),故此处的this指向的是app.callback()生成的ctx
function respond() {
//koa给了用户不使用此处自带respond返回的方式,具体就是const app = require('koa')();代码中设置app.context.respond = false即可开启此处验证
if (false === this.respond) return;
//此处的headersSent判断的作用,因为http是无状态单次c-s通信的方式,所以如果S端已经返回过一个响应给C端,因为某些处理异常又进行了返回了一次响应给C端,会造成进程异常退出(未try catch的话),所以这里的headersSent字段为false,表示从未响应过C端;为true,表示已经响应过C端,直接return
var res = this.res;
if (res.headersSent || !this.writable) return;
//this.body是上面app.callback()中执行fn.call(ctx)时挂载到ctx的body属性上的,即koa中间件中this.body的赋值的内容: this.stauts是ctx的onError方法中赋值的(this.status=err.staus)
var body = this.body;
var code = this.status;
//如果statusCode为204,205或者304,则直接返回
if (statuses.empty[code]) {
    // strip headers
    this.body = null;
    return res.end();
}

//TODO,此处能看明白逻辑,但是不了解应用场景,待补充
if ('HEAD' == this.method) {
    if (isJSON(body)) this.length = Buffer.byteLength(JSON.stringify(body));
    return res.end();
}

//body为空,将status code转化为字符串返回
if (null == body) {
    this.type = 'text';
    body = this.message || String(code);
    this.length = Buffer.byteLength(body);
    return res.end(body);
}

//如果body为Buffer,字符串或者Stream,直接使用对应方式返回
if (Buffer.isBuffer(body)) return res.end(body);
if ('string' == typeof body) return res.end(body);
if (body instanceof Stream) return body.pipe(res);

//如果body为对象字面量,转化为json字符串后返回
body = JSON.stringify(body);
this.length = Buffer.byteLength(body);
res.end(body);
}

PS:看到这边的都是真爱了,哈哈。本身是今年3月份开始真正学习js基础(之前做了好多业务堆积,各种cbcbcbcb),ES6的一些新特性学习也是这两个月开始学习的,确实给我耳目一醒的感觉,这个js大爆发的时代,各种新技术层出不穷,NW.js连桌面端跨平台软件都能做了,react native更是大有一统app江湖的趋势,但是万变不离其宗,js语言本身的学习就是一件很有意思的事情,欢迎大家一起讨论哈~

8 回复

我擦,第一次发帖,怎么编辑的页面如此诡异,我来看看…

为什么现在发的关于koa的 都是1的呢

@yuu2lee4 并非2就一定比1好,2基于的是ES7的await和async,现在ES6正式标准刚落地,也算是刚普及开;要真正等ES7正式落地还有好几年吧,所以我个人不喜欢用还不是标准的东西,这也是我选择Koa1.2.0的原因;另外,如果npm安装不指定@2.0.0,默认安装最新的就是1.2.0,官方估计也是这个态度吧。等ES6特性吃透了,ES7再出来那再研究下吧,还是那句话,万变不离其宗,包括ES6里面的class和extend等关键字,其实还不是js原型链和原型方法的语法糖,反正我个人来说,等新标准正式落地,再真正用到生产,不然就是有空研究做技术储备,哈哈

@hyj1991 这话说的不客观吧,koa 2.x支持3种中间件写法,只有async函数算es7-stage-3特性。。。

  • 1.x支持的2.x里都支持
  • 性能更好(一点点)
  • 和es特性无关,node 4以上随便写

@i5ting 可能吧,对于2没有比较详细的了解,因为看到里面有async,并且npm install koa默认是1.x,但是既然这样的话,我找个时间详细理一下koa@2.0.0的application.js逻辑,到时候如果有收获再来分享,哈哈

@hyj1991 加油,挺好的习惯,看好你

@i5ting ,其实我觉得,按照ES6对于generator函数调用generator函数的形式,真正比较好的写法是

app.use(function * (next) {
	//中间件1
	yield * next
});
app.use(function * () {
	//中间件2
});	

现在之所以yield next也能调通下一个中间件,纯粹是因为TJ在co库的toPromise方法里里面做一个黑魔法:

  if (isGeneratorFunction(obj) || isGenerator(obj)) return co.call(this, obj);

说穿了也简单,如果是generator函数,则继续使用co执行该函数; 所以在koa的中间件里,书写yield next和yield * next都是可以的,但是两者可以执行的原理却完全不同

回到顶部