荐 超级详细的koa源码解析(看完不会我打你)
如果你想提升node水平,那么我极力推荐你看看koa源码。
koa作者是神一般的男人TJ Holowaychuk,源码设计巧妙而又短小精悍,既能领略koa的设计思想,而又避免了源码过冗长而带来的疲劳感。
这篇文章,我们从源头出发,一起领略kao的精髓,同时围绕下面三点介绍:
1.老生常谈的 洋葱模型
2. context的 委托模式
3. koa的 错误处理
目录结构
话不多说,让我们开始吧~首先请跟我一起打开命令行 输入
touch koa_learn.js && npm init && npm i koa
然后用任何一个ide打开koa_learn.js,我们看看node_modules下的koa目录结构。
除了readme、历史信息、开源许可证,真正的源码部分只有四个文件。application、context、request、response。 打开package.json文件,我们可以看到koa的入口。
"main": "lib/application.js",
application.js
好的,让我们开始写一个简单的Demo。
const Koa = require("koa");
const app = new Koa();
app.use(async (ctx, next) => {
ctx.body = "你好,YouHe";
});
app.listen(3000);
然后在终端上
curl http://localhost:3000
//终端上打印出 *你好,YouHe*
我们看到,app.js里引入了koa,然后用new来实例化一个app,之后我们使用了app.use传入一个async函数,也就是kao中间件,最后调用app.listen方法,至此一个kao应用就跑起来了。
打开application.js文件,首先看到这里暴露了一个Application类,继承于Emitter(错误处理讲到)。
constructor
module.exports = class Application extends Emitter {
constructor(options) {
super();
options = options || {}; //配置
this.proxy = options.proxy || false; //是否proxy模式
this.subdomainOffset = options.subdomainOffset || 2; //domain要忽略的偏移量
this.proxyIpHeader = options.proxyIpHeader || 'X-Forwarded-For'; //proxy自定义头部
this.maxIpsCount = options.maxIpsCount || 0; //代理服务器数量
this.env = options.env || process.env.NODE_ENV || 'development'; //环境变量
if (options.keys) this.keys = options.keys; // 自定义cookie 密钥
this.middleware = []; //中间件数组
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
if (util.inspect.custom) { //自定义检查,这里的作用是get app时,去执行this.inspect 。感兴趣可见http://nodejs.cn/api/util.html#util_util_inspect_custom
this[util.inspect.custom] = this.inspect;
}
}
...
这里是constructor里做了一些配置,这里主要是
this.middleware = []; //中间件数组
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
首先是一个中间件数组,我们用use使用的中间件都会放进这个数组中,然后分别用Object.create拷贝的context、request、response,分别对应koa目录的三个文件。这里用Object.create是因为我们在同一个应用中可能会有多个new Koa的app,为了防止这些app相互污染,用拷贝的方法让其引用不指向同一个地址。
app.use
这里首先判断use传进来的参数是不是一个函数,然后判断函数是否为generator函数,并将其转化为generator,这里用到了convert函数,本质上其实是用到co去把generator转成了一个返回promise的function(说着很绕口,可以理解转成类似async函数)
const converted = function (ctx, next) {
return co.call(ctx, mw.call(ctx, createGenerator(next)))
}
其实除了这个个人觉得很鸡肋的DEBUG,(感兴趣试试命令行输入 DEBUG=koa* node app.js),重点在于这个 this.middleware.push(fn);
,它做的只是把中间件放进了middleware数组。
app.listen
之后我们就用到listen方法
listen(...args) {
debug('listen');
const server = http.createServer(this.callback());
//从http.createServer我们猜测,this.callback()返回的是一个 (req,res)=> something 的函数
return server.listen(...args);
}
这个方法是封装了http模块提供的http.createServer和listen方法,将this.callback()传入,我们看一下这个callback
callback() {
const fn = compose(this.middleware);
//上面的compose就是koa中间件洋葱模型的核心了
if (!this.listenerCount('error')) this.on('error', this.onerror);//koa错误处理,判断app上错误监听的数量,也就是判断是否我们的代码里有自己写监听,如果没有那么走koa的 this.onerror方法
const handleRequest = (req, res) => { //koa的委托模式会在这个函数里体现
const ctx = this.createContext(req, res); //将req, res包装成一个ctx返回
return this.handleRequest(ctx, fn); //怎么又返回了自己? 别急,注意这个this,它代表的是app上的handleRequest方法,而不是它自己
};
return handleRequest;
}
现在我们知道koa的大概的运转了,那么现在我们就从这几个方法入手。
compose、this.createContext、this.createContext
洋葱模型
找到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) {//过一遍middleware,判断每个成员是否为函数
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
}
return function (context, next) { //返回一个函数
let index = -1 //index计数
return dispatch(0)//调用dispatch,传入0
function dispatch (i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times')) //i小于index,证明在中间件内调用了不止一次的next(),抛出错误
index = i //更新index的值
let fn = middleware[i] //middleware中的函数,从第1个开始
if (i === middleware.length) fn = next//如果i走到最后一个的后面,就让fn为next,此时fn为undefined
if (!fn) return Promise.resolve() //那么这时候就直接resolve
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));//将其包装成一个Promise resolve态,主要作用是区分reject
} catch (err) {
return Promise.reject(err)//catch错误,并reject
}
}
}
}
不知道上面的注释看明白没有,那么这里重新过一遍流程。
首先直接返回一个函数,函数内部返回一个dispatch函数,dispatch调用并传入0,现在我们fn取到了middleware的第一个中间件,然后返回被Promise包裹的、fn的执行结果。fn在调用时传入两个参数,
- context 也就是中间件函数里的ctx
- dispatch.bind(null, i + 1) 下一个中间件函数,用bind把this指向null,也就是中间件函数里的next
所以,调用next,就可以把函数的执行权交给下一个中间件,待其执行完,在回过头继续执行自身,这样代码形成回形针式的级联。这也就是老生常谈的洋葱模型。
在中间件自身,我们使用async函数,这样可以让异步转同步更方便,但是,async并不是koa洋葱模型的必要条件,面试的时候注意哦~
但是,如果开发者在一个中间件多次调用next。koa怎么处理呢。首先,声明一个计数器为-1,在每个中间件内部判断这个index是否小于等于现在中间件的i(也就是在middleware的index),然后将更新这个index为i。 这时如果多次调用next,i就会大于现在的index,抛出错误。
上面的this.callback里的fn便是koa-compose返回的这个函数。介绍完了,我们来看下this.createContext吧。
this.createContext
const handleRequest = (req, res) => { //验证了上面的(req,res)=>sth
const ctx = this.createContext(req, res); //将req, res包装成一个ctx返回
return this.handleRequest(ctx, fn);
};
这里传入了http模块提供的两个参数req、res,然后声明ctx为this.createContext(req, res)的返回值。看下这个this.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;
}
这里代码很长,做的事却只有一个。那就是包装出一个全局唯一的context。
刚才在上面的constructor里我们声明了
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
在这里,我们用Object.create又包装了一层
const context = Object.create(this.context); //全局唯一
const request = context.request = Object.create(this.request);
const response = context.response = Object.create(this.response);
目的就是让每次http请求都生成一个context,并且单次生成的context是全局唯一的,相互之间隔离。同样的,Object.create(this.request|response)也是同理。我们将Object.create(this.request|response)赋值给context.request|response,这样我们可以在context*问到request和response。
这里我们其实就是做了让response、this.request、context,可以共享app、res、req这些属性,并且可以互相访问。为什么要这么做呢?
一个 ctx 即可获得所有 koa 提供的数据和方法,而 koa 会继续将这些职责进行进一步的划分,比如 request 是用来进一步封装 req 的,response 是用来进一步封装 res的,这样职责得到了分散,降低了耦合,同时共享所有资源使得整个 context 具有了高内聚的性质,内部元素互相都能够访问得到。
除此之外,我个人认为,这点的好处其中之一就是可以让一个完全不懂http req、res的人直接上手一个简单的web应用。
看完了createContext,回到this.handleRequest,接下来的是 return this.handleRequest(ctx, fn);
这就是callback流程的最后一步,http.createServer传入的函数被调用时,handleRequest被调用,然后内部调用this.handleRequest,传入包装好的ctx和中间件函数。
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);
}
这里简单判断了下statusCode是否404,onFinished处理res为stream时情况。
最后调用fnMiddleware函数,传入ctx,resove则进入respond,reject则进入ctx.onerror,最终返回结果。
-
respond这个方法里内容稍微有点长,它做的是ctx返回不同情况的处理,如method为head时加上content-length字段、body为空时去除content-length等字段,返回相应状态码、body为Stream时使用pipe等,这里不再赘述。
-
ctx.onerror我们会在下面context.js里提到。
这样一个大概流程便走完了,我们只看了application,还有一下几个点没有看
context.js 、 request.js 、 response.js.
直接用到这三个包的是在constructor里,
this.context = Object.create(context);
this.request = Object.create(request);
this.response = Object.create(response);
现在我们去看看它们都做了什么。
上半部分,方法有toJson、inspect、throw、onerror、get cookies()、set cookies方法,下面依次介绍它们的作用
- toJson 同context.js一样(上面没有提到,但是context.js里也有这个方法) , 调用only这个包,返回
return {
request: this.request.toJSON(),
response: this.response.toJSON(),
app: this.app.toJSON(),
originalUrl: this.originalUrl,
req: '<original node req>',
res: '<original node res>',
socket: '<original node socket>'
};
- inspect 调用toJson()
- throw 抛出错误,将穿进的参数付给createError()包返回的函数并调用。
- onerror ctx的错误处理
- get cookies() 获取cookie,如果有直接返回,如果无立刻生成
if (!this[COOKIES]) {
this[COOKIES] = new Cookies(this.req, this.res, {
keys: this.app.keys,
secure: this.request.secure
});
}
return this[COOKIES];
- set cookies 设置cookie
错误处理
我们重点看下onerror
onerror(err) {
if (null == err) return;
const isNativeError = //判断是否为原生错误
Object.prototype.toString.call(err) === '[object Error]' ||
err instanceof Error;
if (!isNativeError) err = new Error(util.format('non-error thrown: %j', err));
let headerSent = false;
if (this.headerSent || !this.writable) { //检查是否已经发送了一个响应头
headerSent = err.headerSent = true;
}
this.app.emit('error', err, this); //emit 这个错误,而刚刚我们看到application上有监听器。app.on('error',onerror) ,转交给application的onerror处理。这里可以做到emit、on来发布订阅错误就是因为application继承了Emitter模块。这里不熟悉的可以看下我的另一篇文章,见文章末尾
if (headerSent) {//已经发送了一个响应头,return掉
return;
}
const { res } = this;
if (typeof res.getHeaderNames === 'function') {//HeaderNames为function,删除所有Header
res.getHeaderNames().forEach(name => res.removeHeader(name));
} else {
res._headers = {}; // Node < 7.7
}
//下面的就是对这个错误的ctx进行修改。如header设置成err.headers、statusCode设置成err.status ,msg设置为如err.message等等
this.set(err.headers);
this.type = 'text';
let statusCode = err.status || err.statusCode;
// ENOENT support
if ('ENOENT' === err.code) statusCode = 404;
// default to 500
if ('number' !== typeof statusCode || !statuses[statusCode]) statusCode = 500;
// respond
const code = statuses[statusCode];
const msg = err.expose ? err.message : code;
this.status = err.status = statusCode;
this.length = Buffer.byteLength(msg);
res.end(msg);
},
这里主要是处理了发生error时ctx的情况,和把err emit给application进行处理。
看下application是怎么处理这个错误的。
onerror(err) {
// When dealing with cross-globals a normal `instanceof` check doesn't work properly.
// See https://github.com/koajs/koa/issues/1466
// We can probably remove it once jest fixes https://github.com/facebook/jest/issues/2549.
const isNativeError =
Object.prototype.toString.call(err) === '[object Error]' ||
err instanceof Error;
if (!isNativeError) 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(`\n${msg.replace(/^/gm, ' ')}\n`);
}
};
其实这里可以说只是把error打印了出来。但是对于中间件内的异步错误,koa是无法捕捉的(除非转同步)。我们的应用如果需要记录这个错误可以用node的process监听
process.on("unhandledRejection", (err) => {
console.log(err);
});
委托模式
我们来看context的下半部分。
delegate(proto, 'response')
.method('attachment')
.method('redirect')
.method('remove')
.method('vary')
.method('has')
.method('set')
.method('append')
.method('flushHeaders')
.access('status')
.access('message')
.access('body')
.access('length')
.access('type')
.access('lastModified')
.access('etag')
.getter('headerSent')
.getter('writable');
可以看到这样的代码,用了一个delegate函数。我们来看看它的源码。我们主要用到了access、getter、method
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;
};
作用很明显,target.name包装一层函数赋值给proto.name,也就是将target上的函数也能让proto去调用。
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;
};
通过__defineGetter__劫持proto的 get,转而去访问 target。(目前官方建议使用Object.defineProroty或Proxy进行劫持)
Delegator.prototype.access = function(name){
return this.getter(name).setter(name);
};
劫持get和set,set与上面同理,不再赘述。
我们在使用诸如ctx.body时,这里的context将它们委托给request和respense。
我们看下request.js & respense.js,(代码较长,不放在文章上,请自行看源码)
这里所做的,其实就是将原生req、res做了一层封装。我们在需要时调用request.js而间接调用了req。(res同理)
打个比方,我们在访问ctx.header时,ctx会将其委托给reques.headert,而request又会将其委托给req.headers,最终我们拿到了header值。
Koa使用委托模式,把外层暴露的自身对象将请求委托给内部的node原生对象进行处理。 委托模式使得我们可以用 聚合 来替代 继承。
末尾
至此,本文就介绍koa源码到这里。欢迎学习交流~