前言
koa作为广泛运用的node框架,其源代码非常精简,看完之后愈发佩服TJ大神,能够用这么少的代码实现了如此强大易用的框架。下面结合源码具体分析一下其中的核心原理。
整体结构
首先看一下 koa 框架的组成结构,koa 的源码由4个部分组成,分别是 application.js(入口文件),context.js(上下文,即koa的ctx),request.js(请求对象,基于req封装),response.js(响应对象,基于res封装),其中核心代码主要都位于 application.js 中。下面会从4个文件展开具体的分析:
application
application.js 是 koa 的入口文件,也是核心所在。在该文件中引入了其他3个文件,并在构造函数中定义了一些核心属性,主要有
- middleware:这是注册的中间件的集合
- context:上下文模块,继承于context.js创建的对象
- request:请求模块,继承于request.js创建的对象
- response:响应模块,继承于response.js创建的对象
const context = require('./context');
const request = require('./request');
const response = require('./response');
....
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;
}
}
接下来看koa的使用方法 listen ,listen 方法就是对 http.createServer 对了一个简单的封装,抽离出来单独的回调函数,返回 http 服务对传入端口的监听。node 原生的 http.createServer方法中需要传入处理的回调函数,但是在实际的复杂业务逻辑中,代码不可避免不好管理,因此 koa 这里对回调函数作了单独处理。
listen(...args) {
debug('listen');
const server = http.createServer(this.callback());
return server.listen(...args);
}
下面继续看单独抽离的 callback 方法,该方法中主要做了3件事情:
- 对注册的中间件进行了统一整合处理
- 监听框架运行错误,并设置了错误处理函数
- 返回请求处理函数
下面具体来看这 3 个步骤:
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;
}
compose
该方法中的第一步就调用了 compose 方法对中间件进行了整合处理, compose方法是单独写的 koa-compose 模块,是koa中间件处理的核心所在,具体看一下处理逻辑,十分有意思:
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!')
}
/**
* @param {Object} context
* @return {Promise}
* @api public
*/
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))); // 返回的方法中的第二个参数递归调用下一个中间件方法,这就是为什么中间件中执行next()时会调用下一个中间件函数
} catch (err) {
return Promise.reject(err)
}
}
}
}
该方法中首先对传入的中间件集合入参做了判断,只能是数组,并且数组中的每个元素都必须是函数,这就要求注册的中间件必须是函数。 接下来就是核心所在,该方法返回一个函数,第一个参数 context 是请求上下文,第二个参数 next 是所有中间件执行完之后最终执行的回调函数。我们重点来看一下该方法中核心的dispatch函数的逻辑:
dispatch
dispatch函数会遍历 middleware 中间件集合,依次取出中间件进行执行,直到所有中间件都执行完成。fn(context, dispatch.bind(null, i + 1)) 这条语句是最关键的一条语句,执行当前中间件函数,将上下文context作为第一个参数传入,下一个要执行的中间件方法作为第二个参数传入。这就是为什么我们在 koa 中间件中执行next()方法(对应这里的第二个参数)时,会执行下一个中间件函数的原因,如果不调用next(),那么后面的中间件函数都会无法执行。
监听error
第二步,通过 this.on('error', this.onerror) 对框架中的 error 事件进行监听,对应的 onerror 处理函数分情况进行了相应的错误处理
onerror(err) {
if (!(err instanceof Error)) throw new TypeError(util.format('non-error thrown: %j', 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();
}
返回handleRequest
createContext
先执行了 const ctx = this.createContext(req, res),创建了当前请求的上下文对象,createContext 方法做的事情就是创建了一个 context 对象,并且将当前的this、req、res都挂载到了该对象上,这也是为什么我们在使用 koa 时能在请求的 ctx 上拿到关于 app、req 和 res 上的各种请求相关属性的原因。
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;
}
handleRequest
紧接着 return this.handleRequest(ctx, fn) 其中 ctx 就是上一步创建的请求上下文对象,fn 是 compose 返回的闭包函数。handleRequest方法最终 return fnMiddleware(ctx).then(handleResponse).catch(onerror); 即当所有中间件执行之后执行响应处理函数。
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); // 所有中间件执行之后执行响应处理函数,若抛出异常,执行默认错误回调函数
}
respond
接下来重点看一下上一步相应处理函数中用到的 respond 函数,该函数是 koa 中的又一个核心函数。主要就是针对不同的响应主体和状态,进行不同的处理,主要分为以下几种case:
- 没有响应主体时的处理
if (statuses.empty[code]) { // 返回的状态码表示没有相应主体时
// strip headers
ctx.body = null;
return res.end();
}
- HEAD请求方法,响应头已经发送,但是没有内容长度时的处理
if ('HEAD' === ctx.method) { // HEAD请求方法
if (!res.headersSent && !ctx.response.has('Content-Length')) { // 响应头已经发送,但是没有内容长度,进行设置
const { length } = ctx.response;
if (Number.isInteger(length)) ctx.length = length;
}
return res.end();
}
- 有相应主体,但是为空时的处理
if (null == body) { // 有相应主体,但是为空
if (ctx.response._explicitNullBody) {
ctx.response.remove('Content-Type');
ctx.response.remove('Transfer-Encoding');
return res.end();
}
...
- 有相应主体,不同格式的处理
// 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);
至此整个 koa 入口文件的主流程使用方法已经分析完成,除了这个主流程之外,还有个中间件的使用方法 use() 需要单独看一下。
use
use 方法是 koa 中注册中间件的方法,原理其实很简单,当调用 use 方法注册中间件时,实质上就是讲中间件函数 push 到 this.middleware 这个框架中间件集合中,所以中间件的执行是先进先出。并且函数最后返回了 this, 这么做是保证了中间件的注册可以实现链式调用。具体的代码和注释如下:
use(fn) {
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
// koa1.x版本使用Generator Function的方式写中间件,而Koa2改用ES6 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);
}
debug('use %s', fn._name || fn.name || '-');
this.middleware.push(fn);
return this; // 返回this,所以可以链式调用
}
context
接下来看 koa 的 context.js 文件,context 的核心在于:
- 封装了 koa 请求的上下文,代理了 request 和 response 这两个对象的属性和方法,用到的 delegate 方法是一个第三方库,用来代理对象的属性和方法。
delegate(proto, 'request')
.method('acceptsLanguages')
.method('acceptsEncodings')
.method('acceptsCharsets')
.method('accepts')
.method('get')
.method('is')
...
delegate(proto, 'response')
.method('attachment')
.method('redirect')
.method('remove')
.method('vary')
.method('has')
.method('set')
...
2.定义了 onerror 错误处理函数,在之前 application.js 里面的 handleRequest 就有用到。该函数主要也是根据不同的情况做不同的处理:
onerror(err) {
if (null == err) return;
if (!(err instanceof Error)) err = new Error(util.format('non-error thrown: %j', err));
...
if ('ENOENT' == err.code) err.status = 404;
// default to 500
if ('number' != typeof err.status || !statuses[err.status]) err.status = 500;
// respond
const code = statuses[err.status];
const msg = err.expose ? err.message : code;
this.status = err.status;
this.length = Buffer.byteLength(msg);
res.end(msg);
}
request && response
最后的请求和响应模块,没什么特别需要分析的,就是对请求和响应的相关属性和方法作了封装,用 set 和 get 函数的形式进行属性的读写操作。大致的封装代码如下:
get search() {
if (!this.querystring) return '';
return `?${this.querystring}`;
},
set search(str) {
this.querystring = str;
},
...
洋葱圈模型
最后着重看下 koa 中间件请求的洋葱圈模型,这是 koa 区别于 express 的一个重大特点。 用一张网上经典的图片和一个简单的小例子说明一下:
const Koa = require('koa');
const app = new Koa();
app.use(async (ctx, next) => {
console.log(1);
await next();
console.log(6);
});
app.use(async (ctx, next) => {
console.log(2);
await next();
console.log(5);
});
app.use(async (ctx, next) => {
console.log(3);
ctx.body = "last middleware";
console.log(4);
});
app.listen(3000, () => {~~~~
console.log('listenning on 3000');
});
//依次输出 1、2、3、4、5、6
为什么能实现这个效果呢,其实在前面介绍 koa-compose 源码中已经可以找到答案了。koa 中间件机制 return Promise.resolve(fn(context, dispatch.bind(null, i + 1))) 每次都返回一个promise,并在中间件方法中调用 next()时,对应执行下一个中间件。因此当 await next() 时会等待下一个中间件执行完成后,再回到当前中间件中继续执行后续代码。这也就是洋葱圈模型实现的原理。