为什么需要ctx
使用过koa的人应该都会知道koa处理请求是按照中间件的形式的。而中间件并没有返回值的。那么如果一个中间件的处理结果需要下一个中间件使用的话,该怎么把这个结果告诉下一个中间件呢。例如有一个中间件是解析token的将它解析成userId,然后后面的业务代码(也就是后面的一个中间件)需要用到userId,那么如何把它传进来呢?
其实中间件就是一个函数,给函数传递数据最简单的就是给他传入参数了。所以维护一个对象ctx,给每个中间件都传入ctx。所有中间件便能通过这个ctx来交互了。ctx便是context的简写,意义就是请求上下文,保存着这次请求的所有数据。
那么有人便会提出疑问了,request 事件的回调函数不是有两个参数:request,response吗,为什么不能把数据存放在request或者response里呢?设计模式的基本原则就是开闭原则,就是在不修改原有模块的情况下对其进行扩展。想象一下,假设我们在解析token中的中间件为request增加一个属性userId,用来保存用户id,后面的每个业务都使用了request.userId。某一天koa升级了http模块,他的request对象里有一个userId属性,那我们该怎么办?要是修改自己的代码,就要把所有的userId替换一下!要是直接修改http模块,那每次想要升级http模块,都要看一次http模块的新代码。
ctx的定义
要找到ctx定义的地方,最直接的方式就是从http.createServer(callback)里的callback中找。上一节,我们看到koa脚手架,传入的callback就是app.callback()。而app是new了koa。所以我们找到koa中的callback方法。
callback() {
const fn = compose(this.middleware);
if (!this.listeners('error').length) this.on('error', this.onerror);
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn);
};
return handleRequest;
}
上面的代码便是koa中的callback方法。返回的函数handleRequest便是我们熟悉的(req,res)=>{} 形式的。ctx是通过koa的createContext方法构建的。并将构建好的ctx传递给koa的handleRequest方法(注意不是callback方法中定义的handleRequest)。下面看下createContext方法吧
createContext(req, res) {
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.originalUrl = request.originalUrl = req.url;
context.cookies = new Cookies(req, res, {
keys: this.keys,
secure: request.secure
});
request.ip = request.ips[0] || req.socket.remoteAddress || '';
context.accept = request.accept = accepts(req);
context.state = {};
return context;
}
a = Object.create(b)可以理解为a={},a的原型为b。 从第一句可以看到,context是一个继承了this.context的对象,this为koa实例。后面主要为context赋值一些属性。这些属性主要来自this.request,this.response,以及传进来的req,res,也就是http模块request事件的两个回调参数。req,res不用说了,接下来让我们看下koa对象的request,response,和context属性吧。
const context = require('./context');
const request = require('./request');
const response = require('./response');
module.exports = class Application extends Emitter {
constructor() {
super();
//删去了部分代码
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
}
// 删去了其他方法
}
可以看到koa中的context,request,response分别继承了引入的三个模块。下面我们来看下这三个模块。
context模块
在看context模块之前,让我们先回顾下,context模块和我们平常写中间件用的ctx之间的关系。
koa的脚手架的启动文件是www文件,而www先require了app文件。app中new了koa。所以这个脚手架在进行http监听之前就先new了koa,执行了koa中的构造函数。所以koa的实例app中有一个属性context继承了context模块。当有http请求进来后,会触发app的callback方法,里面调用了createContext方法,并将请求的req,res传入。 这个函数真正构建了context,也就是我们中间件里用的ctx。context继承了app.context。还为他赋值了一些属性。ctx构建完成,就作为入参传入我们定义的中间件中了。
有人已经看到了,ctx没有我们常用的一些属性啊,我们经常用ctx.url,ctx.method,ctx.body等属性都还没有定义。剩下唯一能影响ctx的就是context模块了,因为ctx继承app.context,app.context继承了context模块。
看过context模块的源码后你会发现,里面定义了proto = module.exports。proto这个对象就是context模块暴露出的对象。这个对象仅仅定义了5个方法,还不是我们常用的。其实后面还有两句,其中一句是:
delegate(proto, 'response')
.method('attachment')
.method('redirect')
.method('remove')
.method('vary')
.method('set')
.method('append')
.method('flushHeaders')
.access('status')
.access('message')
.access('body')
.access('length')
.access('type')
.access('lastModified')
.access('etag')
.getter('headerSent')
.getter('writable');
delegate是引入的一个模块。上一句的功能就是将proto.response的一些属性方法赋给proto,下面详细看下method方法。
function Delegator(proto, target) {
if (!(this instanceof Delegator)) return new Delegator(proto, target);
this.proto = proto;
this.target = target;
this.methods = [];
this.getters = [];
this.setters = [];
this.fluents = [];
}
Delegator.prototype.method = function(name){
var proto = this.proto;
var target = this.target;
this.methods.push(name);
proto[name] = function(){
return this[target][name].apply(this[target], arguments);
};
return this;
};
Delegator这个对象有两个属性,proto和target。对应刚刚的proto和'response'。调用method便能将response的方法委托给proto。method接受一个参数name,也就是要委托的方法的key值。method方法中最重要的一句就是proto[name]=function(){},为proto新增了一个方法,方法的内容就是调用proto.target[name]。这里用了apply,这个apply的目的不是为了改变this的指向,是为了将传给proto[name]方法的参数,原封不动的传给proto.target[name]方法。
Delegator还有三个方法,getter,setter,access。getter就是调用了proto.defineGetter。setter就是调用了proto.defineGetter。具体用法不再赘述。access就是调用了getter方法和setter方法。
所以我们可以看到ctx大部分方法和属性都是委托给了response和request。那response和request是谁呢?这个要看ctx.request和ctx.response是谁了。对于我们在中间件所引用的那个ctx,通过上面的流程图可以看到,通过createContext方法,为他赋值了request和response。通过看createContext这个方法的源码,可以看到这两个对象与request模块,response模块的关系如同context模块和ctx。都是经过了两次继承。
respose模块和reqeust模块
这连个模块的功能分别就是封装了http的请求和响应。那http模块已经封装好了http的请求和响应了,为什么koa还要搞出来request模块和response模块呢?答案就是为了扩展和解耦。
先说扩展:例如koa要是直接让我们使用http封装的response,我们要想在响应体中返回一个json,就要设置header中的context-type为json,想返回buffer就要设置成bin,还要设置响应码为200。而koa封装好的ctx.body方法呢(其实调用的是response.body)只需要将响应值给他,他自己通过判断是否响应值为空来设置http状态码,通过响应值的类型来设置header中的context-type。
再说解耦:那response和request模块是如何实现的这些功能呢?其实也是通过的http封装的req,res。那为什么不能直接使用req和res呢?首先koa想要扩展他们的功能,如果直接为他们添加方法那就违法了前面说过的开闭原则。还有就是解耦。让我们(koa的用户)的代码和http模块无关。这样koa某天如果觉得http模块效率太低了,就可以换掉他。自己用socket实现一个。http.req.url变成了http.req.uri。koa就可以把自己的request模块中的url=http.req.uri。完全不影响我们对koa的使用。
其实这就是用了代理模式,当我们想扩展其他人的模块时,不如试试。
接下来让我们举例看下最常用的ctx.body吧!
set body(val) {
const original = this._body;
this._body = val;
// no content
if (null == val) {
if (!statuses.empty[this.status]) this.status = 204;
this.remove('Content-Type');
this.remove('Content-Length');
this.remove('Transfer-Encoding');
return;
}
// set the status
if (!this._explicitStatus) this.status = 200;
// set the content-type only if not yet set
const setType = !this.header['content-type'];
// string
if ('string' == typeof val) {
if (setType) this.type = /^\s*</.test(val) ? 'html' : 'text';
this.length = Buffer.byteLength(val);
return;
}
// buffer
if (Buffer.isBuffer(val)) {
if (setType) this.type = 'bin';
this.length = val.length;
return;
}
// stream
if ('function' == typeof val.pipe) {
onFinish(this.res, destroy.bind(null, val));
ensureErrorHandler(val, err => this.ctx.onerror(err));
// overwriting
if (null != original && original != val) this.remove('Content-Length');
if (setType) this.type = 'bin';
return;
}
// json
this.remove('Content-Length');
this.type = 'json';
}
这里用到了es5的语法,set body函数会在为body赋值的时候触发。也就是当我们为ctx.body赋值时,其实是调用了上面的方法。代码并不难,就是通过val的值和类型来对_body的值和res的header做一些操作。我们可以看到里面并没有调用res.end(_body)啊。其实生成响应的代码是koa的最后一个中间件。也是koa模块定义的唯一中间件(不过并没在middleWare数组里)。
handleRequest(ctx, fnMiddleware) {
const res = ctx.res;
res.statusCode = 404;
const onerror = err => ctx.onerror(err);
const handleResponse = () => respond(ctx);
onFinished(res, onerror);
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
function respond(ctx) {
// allow bypassing koa
if (false === ctx.respond) return;
const res = ctx.res;
if (!ctx.writable) return;
let body = ctx.body;
const code = ctx.status;
// ignore body
if (statuses.empty[code]) {
// strip headers
ctx.body = null;
return res.end();
}
if ('HEAD' == ctx.method) {
if (!res.headersSent && isJSON(body)) {
ctx.length = Buffer.byteLength(JSON.stringify(body));
}
return res.end();
}
// status body
if (null == body) {
body = ctx.message || String(code);
if (!res.headersSent) {
ctx.type = 'text';
ctx.length = Buffer.byteLength(body);
}
return res.end(body);
}
// responses
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);
}
handleRequest这个方法最后一句就是执行所有中间件后,再执行respond方法。而respond方法,就是生成最后的响应,按照一些判断条件来调用res.end()
总结
上图中的req对象和res对象,对应着http,request事件中的两个回调参数。app为koa实例。context为中间件中的ctx,request为ctx.request, response为ctx.response。
上图还有一个小问题,那就是app.context,为什么ctx不能直接继承context模块,个人认为这个是方便扩展ctx功能的。要为ctx赋值方法首先不能修改context模块。如果要直接修改每一个ctx,就要来一次请求,为构造的ctx添加一次方法。有了这个app.context模块,只需要app.context.demo = ()=>{},每个ctx就有demo方法了。koa-validate这个模块就是通过这种方式扩展ctx的。
本文若有问题,希望能够指正,不胜感激!