在SF平台潜水很久了,这也是我的第一篇文章,之前一直以学习为主。希望日后能通过写技术文章和回答问题的方式来做一些输出^ ^ 之前没有这方面的习惯,所以语言组织方面可能会有点混乱.
最近面试两次被问到KOA的洋葱圈模型(因为之前学校的几个项目我都是用KOA2来写的),但是自己没有深挖过洋葱圈的原理,感觉答得不是很满意。所以这次特地翻出KOA的源码看了一下。
目录结构
├── lib
│ ├── application.js
│ ├── context.js
│ ├── request.js
│ └── response.js
└── package.json
目前我们下载的node_modules/koa
包中的源文件结构就是如此。而koa处理请求的核心也就是以上四个文件,其中
-
application.js
是整个KOA2的入口。最重要的中间件逻辑也是在此进行处理。本次学习的也就是这个文件。 -
context.js
负责处理应用上下文 -
request.js
处理http请求 -
response.js
处理http响应
简单看看KOA
类中封装了哪些方法,顺藤摸瓜看看
构造函数
constructor(options) {
super();
options = options || {};
this.proxy = options.proxy || false;
this.subdomainOffset = options.subdomainOffset || 2;
this.proxyIpHeader = options.proxyIpHeader || 'X-Forwarded-For';
this.maxIpsCount = options.maxIpsCount || 0;
this.env = options.env || process.env.NODE_ENV || 'development'; // 环境变量
if (options.keys) this.keys = options.keys;
this.middleware = []; // **中间件队列**
this.context = Object.create(context); // 上下文
this.request = Object.create(request); // 请求对象格式
this.response = Object.create(response); // 响应对象格式
if (util.inspect.custom) {
this[util.inspect.custom] = this.inspect;
}
}
listen
listen(...args) {
debug('listen');
const server = http.createServer(this.callback());
return server.listen(...args);
}
KOA
的监听函数是对原生的createServer
做的简单封装,传入的参数也会直接被传给原生的server.listen
。只不过这里通过KOA类中的callback
方法生成了一个配置对象传入server中,从这里来看,KOA实际的执行逻辑其实是通过callback
函数来暴露的。
callback
callback() {
const fn = compose(this.middleware);
if (!this.listenerCount('error')) this.on('error', this.onerror);
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn);
};
return handleRequest;
}
首先查看第一句
const fn = compose(this.middleware);
this.midlleware
显然是实例中的中间件队列。
众所周知,compose
是组成的意思,以前我们学过的一个短语就是be composed of
-> 由…所组成
。在代码中的表现形式则大概为组合函数
g() + h() => g(h())
这里的compose变量来自koa-compose
这个包,他的作用是将所有的koa中间件进行合并执行。可以理解为之前的middleware
数组只是一些零散的洋葱圈层级,是通过koa-compose
处理过后才成为了一个完整的洋葱(文章末尾会附上koa-compose
的原理)
回到上面的方法,获得了中间件合体后的组合函数fn
后,声明了一个最终用于输出的handleRequest
函数,在函数中先通过this.createContext
初始化报文,可以理解为生成了一个完整的请求报文和响应报文,而初始化报文的逻辑就写在了lib/request.js
和lib/response.js
之中。最后将他们通过this.handleRequest
方法对报文进行处理。
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);
}
这里就是将处理后的报文传给我们之前生成的组合函数fn
(也就是洋葱圈),在完成了整个洋葱圈的处理逻辑之后去做一些后续处理(返回客户端/错误处理)。
众所周知,洋葱圈模型的每一层都是一个Promise对象,只有当上游(官方文档的说法)的洋葱圈进入resolved
状态后,线程的使用权才会向下游传递。(请注意,此时上一层洋葱圈并没有执行完毕)之后当内层的Promise成为resolved
状态后,JS线程的使用权才会继续向上冒泡,去处理外层洋葱圈resolve之后的逻辑。
洋葱圈函数接收的两个参数ctx
、next
中的next方法就是一个异步方法,代表将当前这层洋葱圈强制resolve
,控制权指向下游,当前的函数将被阻塞,直到下游所有逻辑处理完之后,才能继续执行。
看到这里我们已经可以对目前看到的部分做一个梳理。
- KOA实例收集了一些需要的中间件(
use
方法) - 在
this.callback
函数中通过koa-compose
将中间件进行组合生成了一个洋葱圈处理器fn
(处理谁呢,当然是处理报文对象啦) - 引用报文格式化方法
- 将报文格式化方法和洋葱圈处理器封装成一个工厂函数
handleRequest
。这个工厂函数只负责接受报文,返回结果。
上面说到的use
方法其实也是洋葱圈模型的核心,也就是字面上的注册中间件方法。
use
use(fn) {
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
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);
}
debug('use %s', fn._name || fn.name || '-');
this.middleware.push(fn);
return this;
}
use
接受的必须是一个函数(接受两个参数,一个是ctx上下文,一个是next函数),如果这个函数是生成器(*generator
),(KOA1中的中间件只接收迭代器方法,KOA2中则全部用async await来实现),那么就需要做一个优雅降级处理,通过koa-convert
函数去进行转换。(这一块儿我也还没有看= =)
最后就很简单了,将这个方法push到KOA实例的middleware
队列中
看到这里,application
文件的内容就结束了,这样看来KOA的源码还是比较少的,但是因为中间件的存在,可扩展性变得非常强,这应该也是为什么Koa目前这么火的原因。
PS: koa-compose
koa-compose
的代码量非常少,只有50行不到(49行)。但是设计非常精妙。
function compose (middleware) {
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
}
return function (context, next) {
// 最后一次被调用的中间件下标
let index = -1
return dispatch(0)
function dispatch (i) {
// i <= index 说明有中间件被重复调用了,抛出错误
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
let fn = middleware[i]
// fn取对应层的中间件,如果传入了next函数,那么next函数的优先级更高
if (i === middleware.length) fn = next
// 此处fn指向的就是第i层的中间件函数
// 如果没有了,那么直接resolve
if (!fn) return Promise.resolve()
try {
// 实际的执行步骤在这,执行fn方法,同时将下一层中间件的执行函数也传递过去
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err)
}
}
}
}
笼统的概括这个语法就是每个中间件执行到await next()
语句的时候,都会调用下一层的中间件。你也可以将代码理解为
// 前半部分处理逻辑
await next()
// 后半部分处理逻辑
/* ================= 等价于 ================= */
// 前半部分处理逻辑
await new Promise([下一层中间件的逻辑])
// 后半部分处理逻辑
而这样的插入逻辑在下一层中间件的逻辑中又在递归的发生着,洋葱圈的执行逻辑其实存储在数组koa.middleware
中,只有执行到洋葱圈的第n
层的时候,才会通过下标n+1
去取下一层的处理逻辑,并且生成Promise插入到上层洋葱圈的函数体中,形成了一个不断重叠的函数作用域。
END