Koa2.0应用开发初探及核心源码解析
发布于 12 分钟前 作者 hyj1991 21 次浏览 最后一次编辑是 3 分钟前 来自 分享

在当前的http1.1时代下,web应用早已经远远超出于网站和html了范畴,尤其在node出现之后,结合ioredis,mysql,momgodb等值的信赖的npm模块,就算是使用node搭建纯api的rpc框架,也不是什么让人惊讶的事情,所以koa2.0框架,在我看来其实是一个面向纯web应用的框架,其极致瘦身的框架源码,优秀的中间件兼容机制,可以让不同项目很方便的编写适用的中间件,从而让web应用很方便的搭建起来。关于koa1.xx和koa2.0的一些区别,可以看下附录1。 那么相比较而言,express依旧是一个比较传统的面向网站搭建的综合性框架。所以对于express向koa2.0的迁移,还是需要付出一定的工作量的。但是凡事有利有弊嘛,站在nodejs服务器端角度而言,我个人很喜欢koa的流程控制以及对ES6甚至未来ES7的新语法支持,所以我把手里一个面向内部用户的项目从express迁移到koa2.0了。 当然最近对koa2.0和koa1.x的源码都研究了下,有一点收获想和大家分享下。首先是koa2.0网站搭建常用中间件的选择: 1.ejs模板引擎,可以使用koa-ejs,样例:

const koaEjsOpt = {
	root: require('path').join(__dirname, '../views'),
	layout: false,
	viewExt: 'ejs',
	cache: false,
	debug: false
}
koaEjs(app, koaEjsOpt);

当然这里我想插一段对比koa-ejs和express使用ejs的优势。哈哈,详情可见附录1,对源码分析有兴趣的可以看下。

2.静态资源的目录设置,可以使用koa-static,样例:

app.use(koaConvert(koaStatic(path.join(__dirname, 'public'))));

需要注意的是,koa-staic需要使用koa-convert模块进行转化,原因就是koa-staic是一个纯generator中间件,从koa3.0开始,官方就不再支持完全的generator函数中间件了,但是我们可以对已开发的纯generator中间件使用koa-convert模块进行转化,由于篇幅有限,关于koa-convert模块的源码解析我会从后续文章继续和大家深入探讨下,该模块从我目前的阅读解析来看,基本可以兼容所有的koa1开发的纯generator中间件,有兴趣的可以看下。

3.Body解析,可以使用koa-bodyparser模块,这个模块已经提供了直接对koa2.0的支持,样例: app.use(koaBodyParser()); 注意点是安装支持koa2.0版本需要使用npm install koa-bodyparser@next —save,估计官方也想等koa2.0正式发布后升级吧。 具体使用方法和express类似: a./userid/:userid?:ctx.params[‘userid’]; b./userid?userid=1:ctx.query[‘userid’]; c.post {userid:1}:ctx.request.body[‘userid]; 需要注意的是,post请求提交数据,必须使用ctx.request.body获取!原因就是ctx.body已经被用作返回响应数据的存储了,所以这个点是express升级到koa非常需要注意的一处!

4.Session设置,可以使用koa-session,同样需要转化,样例:

app.keys = ['username', 'password'];
app.use(koaConvert(koaSession(app)));

其中app.keys是允许存储的Session字段,用作安全控制使用直接使用ctx.session.username即可赋值和取值。

5.路由设置,可以使用koa-router,样例:

app.use(router['routes']());
global.router = router;

router.get.post方法用法和express一致,需要注意的是: router.get.post方法用法和express一致,next表示跳转到路由中间件之后的下一个中间件,如果没有的话,从路由中间件开始回源,所以以前express的next(err)的异常捕获方式不再适用。下面一节就是koa2.0下的统一异常处理方法介绍。

6.个人推荐的koa2.0写法以及异常处理(500和404错误) 我个人对于尚未正式集成到js标准库里面的特性和用法是持保留态度的,那么目前nodejs稳定版本还处理4.x(发文时为4.4.7),以nodejs4.x为平台样本的话,ES7里面的async函数和await关键是不原生支持的(V8引擎解释层面的支持),所以要通过转换器来兼容,并且等ES7的标准正式落地到V8引擎在js解释层面支持,到底会发生什么样的变化谁也不知道,所以我不推荐目前在node上开发服务使用async函数以及await关键字。 所以koa2.0上,由于compose方法基于的是Promise.resolve,那么普通函数也可以写成中间件和路由,这是相比koa1.0进步的地方,同时这也是未来的大趋势。但是如果把中间件和路由函数都是用原来的普通函数写法,体现不出koa2.0在流程控制上优势以及它所提倡的“No callback”理念。我推荐的目前koa2.0中间件和路由函数写法如下:

const co = require('co');
const Koa = require('koa');
const path = require('path');
const koaConvert = require('koa-convert');
const koaEjs = require('koa-ejs');
const koaRouter = require('koa-router');
const koaStatic = require('koa-static');
const koaBodyParser = require('koa-bodyparser');
const koaSession = require('koa-session');
const app = new Koa();
const router = koaRouter();
//use ejs middleware
//{}内容为koa-ejs配置参数,根据项目自定义
koaEjs(app,{});
//error handle middleware
app.use(co.wrap(function * (ctx,next) {
	try {
    	yield next();
		//如果最后是404,返回404页面
    	if(ctx.status == 404)
        	yield ctx.render(‘404Page')
		} catch (e) {
    		e = e instanceof Error ? e : new Error(e);
    		console.error('capture: ' + e.stack);
			//更新statusCode为500
    		ctx.status = 500; 
		 	//返回500页面
    		yield ctx.render(‘500Page’);
		}
	}));
//use static middleware
app.use(koaConvert(koaStatic(path.join(__dirname, 'public'))));
//use bodyParser middleware
app.use(koaBodyParser());
//use session middleware
app.keys = ['username', ‘password’];//此处根据项目自定义
app.use(koaConvert(koaSession(app)));
//usr router middleware
app.use(router['routes']());
router.get(‘/index’,co.wrap(function * (ctx,next){//…}));//路由函数的处理
app.listen(4000, ()=>console.log('[app]', 'Koa start at 4000...'));

7.最后给大家献上koa2.0的核心js文件源码解析(application.js): 此部分源码大框架由koa1.x的构造函数修改为了ES6的class,结构如下:

//ES6的继承,继承自事件类
class Application extends Emitter {
	//Application构造函数
	constructor() { //… }
	//Application的listen方法,实现了一个http.createServer(handle).listen(port,[cb])的语法糖
	listen() { //…}
	//筛选器
	toJSON() { //… }
	//打印输出对象内容
	inspect() { //… }
	//中间件存储函数
	use(fn) { //… }
	//上述listen语法糖的真正的回调处理函数,http请求开始处理到返回响应整个过程在这里实现
	callback() { //… }
	//创建了一个上下文对象context,http请求围绕这个context展开
	createContext(req, res) { //… }
	//默认的error处理
	onerror(err) { //… };
}

下面是针对Application类里面的每一个成员函数的源码解析: a.

constructor() { 
	//继承父类的构造函数以及原型方法,返回一个处理过的this,在ES6的Class中如果存在继承,则super必须首先执行不然子类的		this对象无法取得无法进行下面的赋值操作。
	super();
	//proxy作用暂时不清楚
	this.proxy = false;
	//构造函数初始化middleware属性,为数组,放置Application.prototype.use()方法push进来的中间件
	this.middleware = [];
	//subdomainOffset作用暂时不清楚 
	this.subdomainOffset = 2;
	//项目环境变量设置
	this.env = process.env.NODE_ENV || 'development';
	//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);
	}

b.

listen() {
debug('listen');
//一个node最简单的饿http服务器启动的过程
const server = http.createServer(this.callback());
return server.listen.apply(server, 	arguments);
}

c.

toJSON() {
	//筛选器,筛选出this对象中包含的subdomainOffset,proxy以及env为键对应的value
	return only(this, [    
		'subdomainOffset',
		'proxy',   
		'env']);
	}

d.

 //打印输出内容,nodejs重写的console.log里面打印对象,如果有inspect键,且值为函数,则会打印出里面的内容(一个大坑,最后去翻nodejs源码才明白,详情可看源码lib下console.js里面的log方法以及util.js里面的inspect方法)
inspect() {
	return this.toJSON();
}

e.

use(fn) {
	//如果不是函数,抛出异常,异常内容见下
	if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
	//如果中间件注册的函数是纯generator函数,抛出警告提示,将来的koa3.0+版本将完全抛弃纯generator函数的自动兼容,并且使用上述提到的koa-convert模块将generator函数转化为普通函数
	if (isGeneratorFunction(fn)) {
		deprecate('Support for generators will been removed in v3. ' +
		'See the documentation for examples of how to convert old middleware ' +
		'https://github.com/koajs/koa/tree/v2.x#old-signature-middleware-v1x');
		fn = convert(fn);
	}
	debug('use %s', fn._name || fn.name || '-');
//往构造函数初始化的middleware属性中,将此GeneratorFunction放入数组中;
this.middleware.push(fn);
//返回当前this对象,其实就是可以允许你链式调用use方法,形如app.use(中间件1).use(中间件2)...
return this;
}

f. //下一个成员函数分析之前,必须先分析下核心方法compose,也正是这个方法的实现原理不同,构成了koa1.x和koa2.0一样的中间件加载逻辑顺序,以及完全不一样的中间件加载实现逻辑。 function compose(middleware){ return function (context, next) { //参数index保存在处理中间件函数的同级js活动对象中,如果一个中间件中只执行一次next,则index永远会小于dispatch函数的内部参数i,当一个中间件中的next调用两次或者两次以上时,会由于函数作用域的问题,出现index>=i(2次next()时出现等于的情况,大于2次出现大于的情况)。此时抛出异常,提示用户一个中间件不能调用两次及以上的next() let index = -1 //启动中间件加载的执行入口,0表示第一个中间件 return dispatch(0) function dispatch (i) { //此处判断说明见上 if (i <= index) return Promise.reject(new Error(‘next() called multiple times’)) //next正常调用一次,则对index进行+1的操作 index = i //fn为取出来的第i个中间件 const fn = middleware[i] || next //如果fn不存在,表示中间件执行【顺】序完成,返回Promise.resolve(),开始【逆】序执行中间件剩余的部分,此处仅当最后一个中间件调用next()时会触发。 if (!fn) return Promise.resolve() try { //compose函数的核心逻辑,最终形成的类似于 Promise.resolve(function(){ //中间件1代码第2部分 await/yield Promise.resolve(function(){ //中间件2代码第2部分 await/yield Promise.resolve(function(){ //中间件3… }()); //中间件2代码第2部分 }()); //中间件1代码第2部分 }()) 这样的结构,文字来表述就是中间件1代码开始执行(dispatch(0)),执行到第二个中间件处(yield/await next())暂停,开始执行第二个中间件代码,第二个中间件代码执行到第三个中间件处暂停(yield/await next()),第三个中间件执行完成后,回源开始执行第二个中间件剩余代码,第二个中间件剩余代码执行完成后开始执行第一个中间件剩余代码,至此,所有中间件加载完毕,由最外层的Promise.resolve返回最终的promise对象。compose方法有一点绕,但是能比较明显的看到其返回了一个promise对象(Promise.resolve);其逻辑类似实现了一个递归。 return Promise.resolve(fn(context, function next () { //开始执行下一个中间件(middleWare[i+1]) return dispatch(i + 1) })) } catch (err) { //执行过程出出现的异常返回reject return Promise.reject(err) } } } }

g. callback() { //compose函数详细解释见上面,此处理解了,整个koa2.0的中间件回流加载原理也就清楚了 const fn = compose(this.middleware); //如果用户没有设置error事件处理,则启用默认的error事件处理 if (!this.listeners(‘error’).length) this.on(‘error’, this.onerror); //返回的匿名函数,对应的就是上述http.createServer(handle).listen(port)里面的handle方法 return (req, res) => { //设置默认的statusCode为404 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上,具体作用使用中体现 const ctx = this.createContext(req, res); //对http的结束事件进行侦听处理,进行了例如给ctx的挂载status属性赋值等操作 onFinished(res, ctx.onerror); //上述的compose方法最终返回的函数,执行后返回一个promise对象,是第一个中间件执行结束后得到的promise对象,then方法调用时表示所有中间件执行完毕,由于koa2.0框架中所有http请求操作都是围绕上面生成的ctx上下文进行的,所以最后在里面进行最终的响应处理(respond函数)。 fn(ctx).then(() => respond(ctx)).catch(ctx.onerror); }; }

h. //说明见上文 createContext(req, res) { //此处值得注意,由于context的原型链为:context.proto.proto = this.context.proto = require(‘./context’),并且lib下的context.js中对request和response的属性做了委托,所以在中间件和路由函数中可以使用下面的方法等价调用:ctx.path 等价于 cox.request.path; ctx.query 等价于 ctx.request.query,等等。 const context = Object.create(this.context); const request = context.request = Object.create(this.request); const 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; }

i. //默认的错误处理 onerror(err) { assert(err instanceof Error, non-error thrown: ${err});

if (404 == err.status || err.expose) return; if (this.silent) return;

const msg = err.stack || err.toString(); console.error(); console.error(msg.replace(/^/gm, ’ ')); console.error(); }

j. function respond(ctx) { //koa给了用户不使用此处自带respond返回的方式,具体就是const app = new require(‘koa’)();代码中设置app.context.respond = false即可开启此处验证 if (false === ctx.respond) return; //如果res.writable提示不可写,直接返回。因为http是无状态单次c-s通信的方式,所以如果S端已经返回过一个响应给C端,因为某些处理异常又进行了返回了一次响应给C端,会造成进程异常退出(未try catch的话),所以这里的headersSent字段为false,表示从未响应过C端;为true,表示已经响应过C端,所以直接return const res = ctx.res; if (!ctx.writable) return; //得到body和status的值,body为响应内容体,status为响应的http statusCode let body = ctx.body; const code = ctx.status; //如果statusCode为204,205或者304,则直接返回 if (statuses.empty[code]) { ctx.body = null; return res.end(); } //TODO,此处能看明白逻辑,但是不了解应用场景,待补充 if (‘HEAD’ == ctx.method) { if (!res.headersSent && isJSON(body)) { ctx.length = Buffer.byteLength(JSON.stringify(body)); } return res.end(); }

//body为空,将status code转化为字符串返回 if (null == body) { body = ctx.message || String(code); if (!res.headersSent) { ctx.type = ‘text’; ctx.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); if (!res.headersSent) { ctx.length = Buffer.byteLength(body); } res.end(body); }

附录1: koa2.0和1.xx最大区别在于,2.0的compose函数是基于promise的(koa1.0的consose函数基于纯粹的generator函数,并且借用了co库的黑魔法),准确的说是Promise.resoleve,这样每一个next返回的都是一个promise,那么对于ES7中的async函数和await关键字就有了比较好的支持(await next());并且这样koa中间件就不一定需要generator函数,又由于generator函数可以使用koa-convert或者索性是co.wrap转化成普通函数,故而koa2.0实现了对普通函数、ES7的async函数以及generator函数写法三种的兼容,这也是koa2.0不向下兼容koa1.0的原因,以及koa2.0的优势所在吧。 下面对koa2.0的核心文件application.js做一个解析,以及谈谈我将应用从express迁移到koa2.0的一些收获,以及,理所当然遇到的一些坑。

附录2: koa-ejs的源码比较简单,相当于对ejs的Template.prototype.compile方法的一个封装,对比express的ejs引擎,koa-ejs的一个好处是,可以开启词法缓存,这个缓存的作用是ejs文件解析成对应的js代码函数可以缓存下来,对于那些线上部署方式为只要node项目中任意文件更改都会pm2 reload服务的项目而言,可以完全节省下线上pv峰值时js解析ejs文件内容耗费的cpu(此处解析ejs文件,查看源码可以知道本质是一个字符串处理,ejs语法替换成js语法的一个过程,所以render峰值时,对于cpu压力比较大)。 此处的核心源码为: var fn = ejs.compile(tpl, { filename: viewPath, _with: settings._with, compileDebug: settings.debug, delimiter: settings.delimiter }); if (settings.cache) { cache[viewPath] = fn; }

相比较而言,express实现的缓存就比较low了, express的app.render方法中opt参数里面的cache,仅仅是缓存了express的View类生成的实例,唯一的作用是在view.render方法中调用this.engine方法时直接映射到ejs的renderFile方法,少了一个require(‘ejs’)的操作,然而,node的模块都是第一次加载后缓存到内存中的(原因可以查看node源码lib目录下的module.js文件),所以这里的减少一个ejs的require实在没什么卵用。并且最遗憾的是,express中app.render方法,最终调用的是this.engine(this.path, options, callback),可以看到,这个方法调用ejs.renderFile方法时只传了三个参数,对应ejs.renderFile的第1,2,4个参数,具体见如下代码段: exports.renderFile = function () { var args = Array.prototype.slice.call(arguments) , path = args.shift() , cb = args.pop() , data = args.shift() || {} , opts = args.pop() || {}//这是开启词法缓存的参数,很容易看出,上述this.engine(this.path, options, callback)传入三个参数的方式会导致这个opt = {}

//… } 偏偏开启ejs词法缓存的参数位于ejs.renderFile第三个参数!,所以很尴尬,如果不修改express源码,是无法使用到ejs的词法缓存功能的。

回到顶部