博客原文地址 欢迎交流、star
上一章把koa实例创建和处理请求的流程梳理了一遍,其中很多细节没有分析,比如生成洋葱中间件的compose函数,context、request和responed对象是怎么构建的等。这一章就来梳理一下这些细节,学习koa的思想和编程技巧。
洋葱模型中间件
在源码中,是引入了koa-compose工具函数来处理中间件的,最终合并成一个。
const fn = compose(this.middleware);
先看一下compose的简单结构:
function compose (middleware) {
...
return function (context, next) {
...
}
}
可以看到compose函数是一个接受middleware中间数组并返回一个入参为context和next的函数。这里在koa源码中把这个返回的函数称作为fnMiddleware,它的外部调用形式为:
fnMiddleware(ctx).then(handleResponse).catch(onerror);
在koa中调用的时候传入一个ctx,next并没有传入。可以看到这个函数的返回值是一个promise。接下来来看看它的内部实现。
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
let fn = middleware[i]
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err)
}
}
}
}
- 首先声明一个变量
index用来记录当前准备执行的中间件。 - 声明
dispatch函数,接受一个下标,用来执行对象下标的中间件。 - 执行第一个中间件。
dispatch(0)
函数内部的核心在于dispatch函数,这个函数的主要几个步骤如下:
-
根据传入的下标获取对应的中间件函数,这里会对下标的边界做一些兼容和查错。
-
使用
Promise.resolve决策一个promise,这个promise就是中间件执行后的返回值。例如我们有个中间件如下:
app.use(async function (ctx, next) {
return null
})
一个async函数已经会返回一个promise,那么在dispatch函数内部为什么还需要使用Promise.resolve去包裹呢?因为我们传入的中间件有可能就是普通的函数,所有这里是做了一个兼容。
-
重点放在
fn(context, dispatch.bind(null, i + 1)),被执行的中间件,传入了context对象,这里是直接传入,这就是为什么所有的中间件用到的ctx是全局唯一同一个引用。第二个参数就是next函数,这里把dispatch绑定了下一个下标作为参数传入。如果我们在我们的中间件中不执行next函数,也就是没有调用dispatch(i+1),下一个中间件也就不会被执行了。 -
使用
try/catch来包裹中间件的执行,有错误就直接返回一个Promise.reject。
兼容生成器函数
在看koa的源码的时候,可以看到在使用use添加中间件的时候,会先对函数进行判断,目前2.*的版本下,会将生成器函数转成async/await函数。
// use
if (isGeneratorFunction(fn)) {
deprecate('Support for generators will be removed in v3. ' +
'See the documentation for examples of how to convert old middleware ' +
'https://github.com/koajs/koa/blob/master/docs/migration.md');
fn = convert(fn);
}
首先简单的了解一下generator函数。
特点:一是,function关键字与函数名之间有一个星号;二是,函数体内部使用yield表达式,定义不同的内部状态(yield在英语里的意思就是“产出”)。(引用自《ECMAScript 6 入门》) 当执行生产器的时候,会获取一个遍历对象,通过调用对象的
next()方法就会返回一个有着value和done两个属性的对象{ value: x, done: true/false }。value属性表示当前的内部状态的值,是yield表达式后面那个表达式的值;done属性是一个布尔值,表示是否遍历结束。
简单的介绍之后,可以看到generator的执行,需要通过不停的调用next()函数,但是async/await是可以自动执行的。所以想要将generator转成类async/await就需要有个辅助方法来自动执行generator的next函数。
这里举这样的例子(来自可能是目前最全的koa源码解析指南)。
function* gen() {
yield new Promise(function (resolve, reject) {
// 做一些异步操作
if (true) { // 成功
resolve()
} else {
reject()
}
})
yield new Promise(function (resolve, reject) {
// 做一些异步操作
if (true) { // 成功
resolve()
} else {
reject()
}
})
}
let g = gen()
let ret = g.next() // 拿到第一个`promise`
怎么让next()继续执行下去呢?看看如下代码。
let p = ret.value // 第一个 promise
p.then(() => { g.next() })
如上,只需要使用一定的方式在每一个promise的决策中再次调用next方法,直到生成器被执行完。
如果想通过上面的方式去实现转换,最终要的一步就是使每一个yield后面返回都应该是一个promise。
koa里面的convert函数,最终是调用的是co这库来进行转换的,所以来看看它是怎么处理的。
function co(gen) {
var ctx = this;
var args = slice.call(arguments, 1);
...
return new Promise(function(resolve, reject) {
if (typeof gen === 'function') gen = gen.apply(ctx, args);
if (!gen || typeof gen.next !== 'function') return resolve(gen);
onFulfilled();
...
function onFulfilled(res) {
var ret;
try {
ret = gen.next(res);
} catch (e) {
return reject(e);
}
next(ret);
return null;
}
...
function onRejected(err) {
var ret;
try {
ret = gen.throw(err);
} catch (e) {
return reject(e);
}
next(ret);
}
...
function next(ret) {
if (ret.done) return resolve(ret.value);
var value = toPromise.call(ctx, ret.value);
if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, '
+ 'but the following object was passed: "' + String(ret.value) + '"'));
}
});
}
- 整个函数返回一个
promise对象,这与async/await一致。 - 在返回的
promise中,首先判断是否为可执行的生成器函数,然后调用函数,获取到遍历对象。 - 然后第一次手动执行
onFulfilled函数,这个函数就是来调用next()方法的。 - 声明
onRejected函数,主要用来调用生成器的throw()方法来结束执行和报错的。 - 在3、4步骤中,只要调用了
g.next()方法,最终都会调用co自己声明的next函数,这个函数的主要工作就是将给promise的value转成promise,然后再在promise的下一个异步去调用onFulfilled和onRejected函数,以此往复。
以上就是如何把generator函数转为类async的逻辑了。
每个请求的独立context
在阅读源码的过程中,在处理请求的回调函数中, 都会调用createContext函数来创建一个独立且整个处理过程中唯一的context对象。
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn);
}
在中间件处理请求的时候,上一章说过,他们是共享这个context对象的,在处理完之后统一交给response对象去将结果响应给请求方。
// 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.state = {};
return context;
}
createContext函数通过Objectet.create方法创建了一个原型为从context.js文件导出的对象的一个新对象,然后在将request和response这两个koa自己扩展的对象和http原生的req和res挂载上去了。所以我们在中间件处理响应的时候就可以直接通过ctx访问到原生的req和res对象了,并且能访问到request和response上的扩展方法了。
委托模式
在处理请求的的时候,可以直接通过访问ctx.header、ctx.url和ctx.method来获取一些请求头或者去设置响应头等。能够这样操作,主要是因为ctx.request和ctx.response的一些属性和方法被委托到了ctx这个对象上。如果对vue比较熟悉的人,也会感受到vue里面的一些data和method可以直接在vue的对象中访问到,这些也是委托模式,将其他对象的属性委托在了最上层的对象属性上。
看一下,context.js里面委托的实现,这里截取其中几行代码
delegate(proto, 'request')
.method('acceptsLanguages')
.method('get')
.method('is')
.access('querystring')
.access('idempotent')
...
这里通过delegate函数,将proto对象里面的request属性下面的方法委托到proto对象上。这里的delegate函数是引入的一个库const delegate = require('delegates');。源码
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.prototype.getter = function(name){
var proto = this.proto;
var target = this.target;
this.getters.push(name);
proto.__defineGetter__(name, function(){
return this[target][name];
});
return this;
};
通过构造函数初始化委托的对象和被委托的目标对象,这里的method和getter方法都是很简单的,当访问委托对象上的属性时,就是去调用了target上的属性,只是需要注意this的执行。然后getter的调用也就是直接访问了相应的值。当然这个库还有其他一些方法,有兴趣的可以去了解。
小结
通过分析,对koa源码里面的一些细节更加的清晰了,中间件的合并和co函数的实现思想有了足够的认识。分析源码还是收益不少,委托模式的使用可以应用在一些通用的库上面,让使用者能够最直接的访问到对象的属性值,适当的减少了使用难度。
参考文章: