koa源码解析
kekobin opened this issue · 0 comments
简介
与express既当爹又当妈相比,koa不要太简洁。因为它只实现了基础核心,需要其他功能时额外引入即可。
它有多简介呢?查看下它的源码就知道了:
整个的实现就4个文件,对比下express:
简直不能太友好呀!
从一个简单的例子开始
const koa = require('koa');
const app = new koa();
app.use(async (ctx, next) => {
console.log(1)
next();
console.log(2)
})
app.use(async (ctx, next) => {
console.log(3)
next();
console.log(4)
})
app.listen(3000)
大家觉得会输出什么呢?
是 1234? 或者 1324 ?
都不是,答案是 1 3 4 2。
很多新手都会觉得没法理解,那么接下来通过这个例子来解析koa的源码,顺便解答为什么会这样输出。
application.js
从koa源码package.json的main入口可以看到,它指向的是lib/application.js。即整个应用的入口。
构造函数
const app = new koa()时,会处理构造函数的逻辑:
constructor(options) {
super();
options = options || {};
this.proxy = options.proxy || false;
this.subdomainOffset = options.subdomainOffset || 2;
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;
}
}
构造函数主要的功能是初始化了中间件的容器(可看出koa中间件就是用数组处理的),从context,request,response创建koa proto的相同功能属性。
中间件添加
app.use(xxx) 对应的逻辑如下:
use(fn) {
...
this.middleware.push(fn);
return this;
}
很简单,就是添加到this.middleware数组里。
创建服务器并监听
app.listen(3000) 对应的逻辑如下:
listen(...args) {
debug('listen');
const server = http.createServer(this.callback());
return server.listen(...args);
}
可以看到,里面使用的是http.createServer来创建。重点是里面的callbak逻辑。
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;
}
很明显,http.createServer的回调代理给了这里的handleRequest。同时可以看到里面处理了中间件逻辑、每次请求的上下文、请求的最终处理等,那么问题来了:
- 问题一:都说koa中间件是洋葱模型,那么这里是如何实现的呢?
- 问题二:每次请求的上下文是如何处理的?
- 问题三:每次请求的最终回调处理是怎样的?
问题一:都说koa中间件是洋葱模型,那么这里是如何实现的呢?
对应上面的逻辑代码:
const fn = compose(this.middleware);
其中的compose是koa-compose。让我们来看看它的源码实现:
function compose (middleware) {
// 传入的 middleware 参数必须是数组
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
// middleware 数组的元素必须是函数
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
}
// 返回一个函数闭包, 保持对 middleware 的引用
return function (context, next) {
// 这里的 context 参数是作为一个全局的设置, 所有中间件的第一个参数就是传入的 context, 这样可以
// 在 context 中对某个值或者某些值做"洋葱处理"
// 解释一下传入的 next, 这个传入的 next 函数是在所有中间件执行后的"最后"一个函数, 这里的"最后"并不是真正的最后,
// 而是像上面那个图中的圆心, 执行完圆心之后, 会返回去执行上一个中间件函数(middleware[length - 1])剩下的逻辑
// index 是用来记录中间件函数运行到了哪一个函数
let index = -1
// 执行第一个中间件函数
return dispatch(0)
function dispatch (i) {
// i 是洋葱模型的记录已经运行的函数中间件的下标, 如果一个中间件里面运行两次 next, 那么 i 是会比 index 小的.
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
let fn = middleware[i]
if (i === middleware.length) {
// 这里的 next 就是一开始 compose 传入的 next, 意味着当中间件函数数列执行完后, 执行这个 next 函数, 即圆心
fn = next
}
// 如果没有函数, 直接返回空值的 Promise
if (!fn) return Promise.resolve()
try {
// next 函数是固定的, 可以执行下一个函数
return Promise.resolve(fn(context, function next () {
return dispatch(i + 1)
}))
} catch (err) {
return Promise.reject(err)
}
}
}
}
整个中间件的处理注释里面写的很清楚了,总结几点:
1.洋葱模型(即先入后出)是基于中间件中使用 next()实现的,如果中间件没有使用next(),或者某些中间件没有使用,则它及它后面的中间件就会被截断掉,执行不到了。
2.洋葱模型实现的关键点在于下面代码:
function next () {
return dispatch(i + 1)
}
即当前中间件中执行nex(),便会递归处理后面的中间件,等待后面的中间件执行完,才会再回到当前中间件,实现洋葱的效果。
3.洋葱模型并不是绝对的,可以在中间件的nex()前后执行需要的逻辑,实现AOP的效果。比如接口的权限验证,必须是在next()之前进行验证,只有验证通过了才会去执行后面的中间件。
问题二:每次请求的上下文是如何处理的?
对应上面的 const ctx = this.createContext(req, res)。从这句代码中,可以看出来,每次请求都会根据req和res创建一个全新的上下文ctx,那么是如何实现的呢?这里的ctx中包含哪些东西呢?
createContext(req, res) {
const context = Object.create(this.context);// 创建一个对象,使之拥有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;
}
从上面可以看到,app、req、res等等全部赋给了context一个对象上面。所以我们才能够访问ctx.req.url、ctx.res.body这些属性。那为什么app、req、res、ctx也存放在了request、和response对象中呢?
使它们同时共享一个app、req、res、ctx,是为了将处理职责进行转移,当用户访问时,只需要ctx就可以获取koa提供的所有数据和方法,而koa会继续将这些职责进行划分,比如request是进一步封装req的,response是进一步封装res的,这样职责得到了分散,降低了耦合度,同时共享所有资源使context具有高内聚的性质,内部元素互相能访问到。
问题三:每次请求的最终回调处理是怎样的?
对应上面的this.handleRequest(ctx, fn),源码如下:
handleRequest(ctx, fnMiddleware) {
const res = ctx.res;
res.statusCode = 404;
// application.js也有onerror函数,但这里使用了context的onerror,
const onerror = err => ctx.onerror(err);
const handleResponse = () => respond(ctx);
onFinished(res, onerror);
// 这里是中间件如果执行出错的话,都能执行到onerror的关键!!!
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
这里可以看出,会先执行所有的中间件,如果出错去执行onerror,如果成功回去执行handleResponse。而handleResponse的respond的逻辑如下:
function respond(ctx) {
// allow bypassing koa
if (false === ctx.respond) return;
const res = ctx.res;
// writable 是原生的 response 对象的 writeable 属性, 检查是否是可写流
if (!ctx.writable) return;
let body = ctx.body;
const code = ctx.status;
// ignore body
// 如果响应的 statusCode 是属于 body 为空的类型, 例如 204, 205, 304, 将 body 置为 null
if (statuses.empty[code]) {
// strip headers
ctx.body = null;
return res.end();
}
// 如果是 HEAD 方法
if ('HEAD' == ctx.method) {
// headersSent 属性 Node 原生的 response 对象上的, 用于检查 http 响应头部是否已经被发送
// 如果头部未被发送, 那么添加 length 头部
if (!res.headersSent && isJSON(body)) {
ctx.length = Buffer.byteLength(JSON.stringify(body));
}
return res.end();
}
// status body
// 如果 body 值为空
if (null == body) {
// body 值为 context 中的 message 属性或 code
body = ctx.message || String(code);
// 修改头部的 type 与 length 属性
if (!res.headersSent) {
ctx.type = 'text';
ctx.length = Buffer.byteLength(body);
}
return res.end(body);
}
// responses
// 对 body 为 buffer 类型的进行处理
if (Buffer.isBuffer(body)) return res.end(body);
// 对 body 为字符串类型的进行处理
if ('string' == typeof body) return res.end(body);
// 对 body 为流形式的进行处理
if (body instanceof Stream) return body.pipe(res);
// body: json
// 对 body 为 json 格式的数据进行处理, 1: 将 body 转化为 json 字符串, 2: 添加 length 头部信息
body = JSON.stringify(body);
if (!res.headersSent) {
ctx.length = Buffer.byteLength(body);
}
res.end(body);
}
其核心就是根据不同类型的数据对 http 的响应头部与响应体 body 做对应的处理.运用 node http 模块中的响应对象中的 end 方法与 koa context 对象中代理的属性进行最终响应对象的设置.
至此,整个appllication.js的核心实现基本分析完了。
context.js
这个js主要实现的是koa的上下文。它主要实现两个核心功能:
- 异步函数的统一错误处理机制
上面分析application代码时,有这么一段:
const onerror = err => ctx.onerror(err);
onFinished(res, onerror);
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
这里处理的是所有中间件,如果出错则用onerror去处理,里面的实现逻辑使用的ctx.onerror。而ctx.onerror的源码如下:
onerror(err) {
...
// delegate
this.app.emit('error', err, this);
...
}
可见,最终会将err还是代理回app上,所以可以通过如下的方式监听整个的错误进行处理:
app.on('error', err => {
log.error('server error', err)
});
context中还有如下两端代码,使用的是依靠delegates库通过委托模式,将node内部的request和response委托到了context上:
delegate(proto, 'response')
...
.method('redirect')
.method('remove')
...
.access('status')
.access('message')
.access('body')
...
.access('lastModified')
.access('etag')
...
/**
* Request delegation.
*/
delegate(proto, 'request')
...
.method('accepts')
.method('get')
.method('is')
...
.access('search')
.access('method')
.access('query')
.access('path')
.access('url')
...
.getter('ip');
所以,我们可以通过如下访问:
ctx.header
ctx.method
ctx.query