之前,我一直在使用express做简单的后台server,写一些api,给自己做的前端来提供服务,觉着吧挺好用的,虽然koa也出来挺久的,但是我一直没有更换过,直到今天看到一个项目中别人是使用koa来做后端代理的,所以,我才想,是否需要了解一下koa的源码呢。其实,我并不是一个喜欢尝鲜的人,因为我总觉得新鲜的事物一般有他没有考虑到的地方,或许会有很多大大的坑等着我们。但是突然发现,koa其实已经好几年的历史了,沉淀的也差不多了,是时候了解一下,并切换到koa上来了。
了解一个框架最好的方式莫过于直接下载他的源码,然后,跑他最简单的例子,找到入口位置,一步一步的跟踪下去。
首先,koa的入口文件在lib/application.js
中,这个是他的package.json
文件中告诉我的,node的工程就是这点好,打开package.json
文件,大概就知道入口健在在哪了,很方便跟踪源代码。
我们在package.json中可以看到scripts下面配置了这样一些命令,
代码语言:txt复制 "scripts": {
"test": "egg-bin test test",
"test-cov": "egg-bin cov test",
"lint": "eslint benchmarks lib test",
"bench": "make -C benchmarks",
"authors": "git log --format='%aN <�>' | sort -u > AUTHORS"
},
test
,test-cov
分别就是做测试,和覆盖率测试用的,简单的说,test就是测试你工程目录test中的文件,挨个挨个挨个拿出来盘一遍,而test-cov
是会出一个报告的,你通过率是多少,当然我试了下,100%通过,这说明koa质量确实杠杠的,可以考虑切换。
之前自己写项目,从来就没有考虑写过测试,大佬就是大佬,我们不妨随便看一个测试用例先。就说说 /test/application/context.js
这个吧,这里面代码是:
'use strict';
const request = require('supertest');
const assert = require('assert');
const Koa = require('../..');
describe('app.context', () => {
const app1 = new Koa();
app1.context.msg = 'hello';
const app2 = new Koa();
it('should merge properties', () => {
app1.use((ctx, next) => {
assert.equal(ctx.msg, 'hello');
ctx.status = 204;
});
return request(app1.listen())
.get('/')
.expect(204);
});
it('should not affect the original prototype', () => {
app2.use((ctx, next) => {
assert.equal(ctx.msg, undefined);
ctx.status = 204;
});
return request(app2.listen())
.get('/')
.expect(204);
});
});
他是为了说明,两个实例的context是互相不会影响的。
这个测试是通过的,其他就就不一一过了,因为本文毕竟是将源码分析的,其实我还是想啰嗦一句:
test
目录下的测试用例都看过一遍之后,你其实对koa的特性就等于基本了解了一遍,以后遇到什么问题,其实都不用上Google或许都可以解决,直接到真的个目录搜索关键字,通过测试用例,就能发现也许是自己某些配置导致的,我也是近期才发现,原来还可以这样定位问题。
那就废话不多说了,我们还是聊聊源码吧,首先,我们看到koa官网给我们的那个极致简单的例子:
代码语言:txt复制const Koa = require('koa');
const app = new Koa();
// response
app.use(ctx => {
ctx.body = 'Hello Koa';
});
app.listen(3000);
可以分解为3步:
- New 了一个koa实例
- 给实例use了一个中间件
- 把这个server绑定到3000端口并启动。
首先,我们看一下new Koa做了些什么:
代码语言:txt复制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;
}
}
options中哪些key就不一一介绍,这里有一个keys,表示使用签名的cookie,这样方式被篡改。
然后,这里初始化了一个中间件的数组,用来存储一会用use注册的中间件
,等会我们来看这里,先打一个记号。
然后,对context,request,response,但是这里使用的是Ojbect.create
,可以了解一下,既:
Object.create()
方法创建一个新对象,使用现有的对象来提供新创建的对象的proto。 (请打开浏览器控制台以查看运行结果。)
那,这就意味着this.context的原型其实就是我们import进来的那个context,同理,this.request,this.response也是如此。这种做法明显就比较省内存,同时将context,request,response独立出来,做到了解耦,复用,感觉完美至极。
好吧,koa实例实际上就这么初始化了,其实,我们记得,主要是绑了context,request,response给这个实例,然后做了一个装中间件的数组容器。
那么,接下来,我们看看中间件是如何注册的。
代码语言:txt复制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;
}
还是贴源码比价过瘾一点,中间件就是通过这个函数注册的,这里他已经不建议注册那种迭代器函数了,至于神马是迭代器函数,可以参考这里。
这里是注册了,那么,哪里执行的中间件呢?
代码语言:txt复制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;
}
我们可以看到是在callback函数中,而callback又是在启动httpServer时注册的回调函数,这就意味着来一个请求就会触发这个回调,进而会调用到我们的callback,然而。这里有一个问题:
那就是我们注册了一堆的中间件,他是以怎么样的方式来执行呢?
可以看到中间件数组被compose了一下,这个compose是干啥的呢,一开始我没有看出个所以然,不过,看了这篇文章之后,我大概就明白了。然来Koa.js
的中间件通过这个工具函数组合后,按 app.use()
的顺序同步执行,也就是形成了 洋葱圈 式的调用。如图所示
部分比较重要的代码看下面,所有源码都在这
代码语言:txt复制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, function next () {
return dispatch(i 1)
}))
} catch (err) {
return Promise.reject(err)
}
}
}
}
可以看到,中间件需要实现next方法,否则,你会将这个链路断开,到时其他注册的走不了。
至于怎么理解洋葱圈式的调用,可以参考我们的测试用例
。
describe('app.use(fn)', () => {
it('should compose middleware', async() => {
const app = new Koa();
const calls = [];
app.use((ctx, next) => {
calls.push(1);
return next().then(() => {
calls.push(6);
});
});
app.use((ctx, next) => {
calls.push(2);
return next().then(() => {
calls.push(5);
});
});
app.use((ctx, next) => {
calls.push(3);
return next().then(() => {
calls.push(4);
});
});
const server = app.listen();
await request(server)
.get('/')
.expect(404);
assert.deepEqual(calls, [1, 2, 3, 4, 5, 6]);
});
it('should compose mixed middleware', async() => {
process.once('deprecation', () => {}); // silence deprecation message
const app = new Koa();
const calls = [];
app.use((ctx, next) => {
calls.push(1);
return next().then(() => {
calls.push(6);
});
});
app.use(function * (next){
calls.push(2);
yield next;
calls.push(5);
});
app.use((ctx, next) => {
calls.push(3);
return next().then(() => {
calls.push(4);
});
});
const server = app.listen();
await request(server)
.get('/')
.expect(404);
assert.deepEqual(calls, [1, 2, 3, 4, 5, 6]);
});
所以,我们知道,上面的测试用例输出的是1,2,3,4,5,6了。
最后,绑定3000端口,启动起来就不用怎么解释了,这个是node原生代码,理解起来并无难度。
那么,这就玩了么,有我不是进场用express做静态代理吗?同样的道理,koa也可以,那么使用的中间件就是这个啦。
我们看下他的源码关键部分:
代码语言:txt复制if (!opts.defer) {
return async function serve (ctx, next) {
let done = false
if (ctx.method === 'HEAD' || ctx.method === 'GET') {
try {
done = await send(ctx, ctx.path, opts)
} catch (err) {
if (err.status !== 404) {
throw err
}
}
}
if (!done) {
await next()
}
}
}
return async function serve (ctx, next) {
await next()
if (ctx.method !== 'HEAD' && ctx.method !== 'GET') return
// response is already handled
if (ctx.body != null || ctx.status !== 404) return // eslint-disable-line
try {
await send(ctx, ctx.path, opts)
} catch (err) {
if (err.status !== 404) {
throw err
}
}
}
总共也没有多少行源码,这里的逻辑是指,静态代理是否需要推迟执行,如果不推迟执行,那就在next执行之前就执行,如果defer执行,那么先让给其他中间件先处理,处理完回来之后,我在处理。
其实,还有一个中间件,甚至是非常重要的一个,那就是路由中间件,那么他实现的大概原理是啥呢?
代码太多,就看看关键部分
代码语言:txt复制Router.prototype.routes = Router.prototype.middleware = function () {
const router = this;
let dispatch = function dispatch(ctx, next) {
debug('%s %s', ctx.method, ctx.path);
const path = router.opts.routerPath || ctx.routerPath || ctx.path;
const matched = router.match(path, ctx.method);
let layerChain;
if (ctx.matched) {
ctx.matched.push.apply(ctx.matched, matched.path);
} else {
ctx.matched = matched.path;
}
ctx.router = router;
if (!matched.route) return next();
const matchedLayers = matched.pathAndMethod
const mostSpecificLayer = matchedLayers[matchedLayers.length - 1]
ctx._matchedRoute = mostSpecificLayer.path;
if (mostSpecificLayer.name) {
ctx._matchedRouteName = mostSpecificLayer.name;
}
layerChain = matchedLayers.reduce(function(memo, layer) {
memo.push(function(ctx, next) {
ctx.captures = layer.captures(path, ctx.captures);
ctx.params = layer.params(path, ctx.captures, ctx.params);
ctx.routerName = layer.name;
return next();
});
return memo.concat(layer.stack);
}, []);
return compose(layerChain)(ctx, next);
};
dispatch.router = this;
return dispatch;
};
可以看到最终return的那个方法dispatch ,参数签名包括ctx,next两者,这其实和我们之前看到的中间件定义的方式是一致的。
其实就是去匹配method和path,如果找到就处理,否则直接调用next,交给其他中间件处理,注意,路由本身是中间件。
总结,这里其实可以看到,koa框架本身非常简洁,核心上来看,就是处理了context,request,response,然后所有的事情都交给了中间件处理,这就极大的提升了灵活性,把这部分开放出来交给开发者,可以玩出无限多的可能。