Koa源码解析
整体架构
核心文件只有4个,在lib文件夹下:
- application.js koa框架的入口文件
- context.js 创建网络请求的上下文对象
- request.js 用于包装koa的request对象
- response.js 用于包装koa的response对象
简单demo
const koa = require('koa');
const app = new koa();
// application文件中有一个use方法,其接收的参数是一个fn
app.use(async (ctx,next) => {
ctx.body = 'hi';
})
app.listen(3000,()=>{
})
application.js源码解析
生成 application 对象
constructor() {
super(); // 因为继承于 EventEmitter,这里需要调用 super
// 代理设置,为true时表示获取真正的客户端ip地址
this.proxy = false;
// 数组,用于存储所有的中间件函数的,所有的app.use()中调用的fn(中间件)都会被push进去
this.middleware = [];
// 子域名偏移设置
this.subdomainOffset = 2;
this.env = process.env.NODE_ENV || 'development'; // 环境变量
// 声明koa的几个核心对象
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;
}
}
上述代码中额外讲解下 subdomainOffset
配置:
该属性会改变获取 subdomain
时返回数组的值,
比如: test.page.example.com
域名,如果设置subdomainOffset为2,那么返回的数组值为 ["page","test"]
, 如果为3时,返回的数组是 ["test"]
use 与中间件
源码:
use(fn) {
// 传入的不是function报错
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
// 这一步是对koa1.x的兼容,判断当前函数是否是 generator函数,如果是就转化(将generator函数包装成Promise),如果不是则不转化。
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 || '-');
// 转化or不转化之后将该中间件函数 push 进实例对象的 middleware 数组中
this.middleware.push(fn);
return this;
}
上述use的功能主要是判断参数是否为function,再进行generator函数转化包装成Promise,用于兼容koa1.x,最后将包装好的fn(传入的参数)push进实例的中间件列表中。
所以,所谓koa的中间件函数的串联其实就是通过数组来逐个执行的。
listen原理
源码解析:
listen(...args) {
debug('listen');
const server = http.createServer(this.callback());
return server.listen(...args);
}
listen很简单,就是用node中的http模块起吊一个http的服务。这里面的 callback
是重点。
核心的 callback 函数
callback() {
// 使用koa-compose建立中间件机制
const fn = compose(this.middleware);
// 如果没有对 error 时间进行监听,那么绑定 error 事件监听处理
if (!this.listenerCount('error')) this.on('error', this.onerror);
// handleRequest相当于是 上面listen中的http.createServer 的回调函数。
// 有req和res两个参数,代表原生的 request,response对象
const handleRequest = (req, res) => {
// 每次接受一个新的请求就是生成一次全新的 context
// 创建一个新的 context 对象,建立koa中context、request、response属性之间和原生http对象的关系
// 然后将创建的ctx对象带入中间件函数们中执行
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn);
};
return handleRequest;
}
/**
* Handle request in callback.
*
* @api private
*/
handleRequest(ctx, fnMiddleware) {
const res = ctx.res;
res.statusCode = 404;
const onerror = err => ctx.onerror(err); // 错误处理
const handleResponse = () => respond(ctx); // 响应处理
// 为res 对象添加错误处理响应,当res响应结束时,执行context中的onerror函数
// 这里需要注意区分 context 与 koa 实例中的onerror
onFinished(res, onerror);
// 执行中间件数组中的所有函数,并结束时调用上面的respond函数
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
koa-compose如何解析
koa-compose模块可以将多个中间件函数合并成一个大的中间件函数,然后调用这个中间件函数就可以依次执行添加的中间件函数,执行一系列的任务。
const compose = require('koa-compose');
function one(ctx,next){
console.log('第一个');
next();
}
function two(ctx,next){
console.log('第2个');
next();
}
function three(ctx,next){
console.log('第3个');
next();
}
// 就是将koa的middlewares数组传入依次执行
const middlewares = compose([one, two, three]);
// 执行的中间件函数,函数执行后返回的是Promise对象
middlewares().then(()=>{
console.log('队列执行完毕');
})
看下compose的源码:
module.exports = compose
function compose (middleware) {
// compose接收的middleware是一个又中间件函数组成的数组,所以先检测是否为数组,不是则报错
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
// 然后检测middleware中的项是否都为function,如果不是则报错
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) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'))
index = i
let fn = middleware[i]
// 执行到最后一个中间件之后,执行next,中间件都执行完毕之后fn置为undefined
if (i === middleware.length) fn = next
// 但是next是undefined,所以此时返回Promise.resolve()
if (!fn) return Promise.resolve()
try {
// 使用递归的方式返回当前传入ctx的参数,并在中间件执行完毕之后执行的next(),next()其实就是调用自己实现递归。
return Promise.resolve(fn(context, function next () {
return dispatch(i + 1)
}))
} catch (err) {
return Promise.reject(err)
}
}
}
}
所以中间件函数在compose中是不会处理任何context的相关操作的。其只是实现一个递归去执行众多的中间件函数而已。但是难点在调用next()
上。
看一个demo:
const koa = require('koa');
const app = new koa();
app.use((ctx, next) => {
console.log('第一个中间件函数')
next();
console.log('第一个中间件函数next之后');
})
app.use(async (ctx, next) => {
console.log('第二个中间件函数')
next();
console.log('第二个中间件函数next之后');
})
app.use(ctx => {
console.log('响应');
ctx.body = 'hello'
})
app.listen(3000)
启动上述代码最后执行顺序如下:
第一个中间件函数
第二个中间件函数
响应
第二个中间件函数next之后
第一个中间件函数next之后
上述代码的执行过程是这样的:
- 执行第一个中间件函数,打印出“第一个中间件函数”
- 调用了next,不再继续向下执行
- 执行第二个中间件函数,打印出“第二个中间件函数”
- 调用了next,不再向下继续执行
... - 最后一个中间件执行后,上一个中间件函数收回控制权,继续执行,打印出“第二个中间件函数next之后”
- 倒数第二个中间件执行后,上一个中间件函数收回控制权,继续执行,打印出“第一个中间件函数next之后”
看一张图片来说明:
这个也说明了koa的洋葱模型~~
看了compose的源码解析也正好能解释了为什么app.use中间件时候的写法
app.use((ctx,next)=>{
console.log(111);
next();
console.log(2);
})
这里面的next
可以表示接着往下执行,也可以表示执行下一个中间件。
this.createContext
其作用就是生成一个新的context
对象,并建立 koa 中context、request、response属性之间和原生http对象的关系。而handleRequest函数只是负责执行。
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;
}
图表示一下:
图型解释:
koa通过创建context将node原生的req和res对象都集中到了一起。
respond函数
该函数在中间件执行之后被执行。
function respond(ctx) {
// allow bypassing koa
if (false === ctx.respond) return;
const res = ctx.res;
// writable 是原生的node的response对象上的 writable 属性,其作用是用于检查是否是可写流
if (!ctx.writable) return;
let body = ctx.body;
const code = ctx.status;
// ignore body
// statuses是一个模块方法,用于判断响应的 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为null时
if (null == body) {
// httpVersionMajor是node原生对象response上的一个属性,用于返回当前http的版本,这里是对http2版本以上做的一个兼容
if (ctx.req.httpVersionMajor >= 2) {
body = String(code);
} else {
body = ctx.message || String(code);
}
// headersSent也是原生属性,为ture表示响应头已经被发送
// 如果响应报文头还没有被发送出去,就为ctx添加一个length属性,length属性记录这当前报文主体body的字节长度
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
// 最后将为Json格式的body进行字符串处理,将其转化成字符串
// 同时添加length头部信息
body = JSON.stringify(body);
if (!res.headersSent) {
ctx.length = Buffer.byteLength(body);
}
// 最后将转化成字符串的body吐出去
res.end(body);
}
在respond
函数中,主要是运用 node http模块中的响应对象中的end方法与koa context对象中代理的属性进行最终响应对象的设置。
至此application.js
文件分析完毕。好像挺简单的样子。
request.js
request.js主要是对原生的 http 模块的request
对象进行封装,其实就是对原生request对象的某些属性和方法通过重新getter、setter函数进行代理。
referrer
这个属性用于返回当前request的来源url。
源码是这样的
get(field) {
const req = this.req;
switch (field = field.toLowerCase()) {
case 'referer':
case 'referrer':
return req.headers.referrer || req.headers.referer || '';
default:
return req.headers[field] || '';
}
}
表示当request的header中没有referrer或referer属性的之后就返回空字符串。这个不是koa特别设置的。而是真实会有referrer为空的情况。
- 修改Location对象进行页面导航
在IE下会丢失Referrerwindow.location.hostname = 'baidu.com';
- window.open方式打开新窗口
<a href="#" onclick="window.open('http://baidu.com')">百度</a>
- 鼠标拖拽打开新窗口
- 点击Flash内部链接
- HTTPS跳转到HTTP(同一网址)
referrer丢失,Web Analytics就会丢失很重要的一部分信息了,特别对于广告流量来说,就无法知道实际来源了。
所以不要下次说referrer为空是koa某些时候特定造成的了。
内容协商
request
中有很多accept
相关的方法。
response.js
response和request.js一样,也是对http模块中的response对象进行封装,通过对response对象的某些属性和方法通过重写 getter/setter 函数进行代理
Content-disposition
在 response.js中的attachment
方法中,其对HTTP头部的 Content-dispostion
字段进行了处理。
Content-dispostion
是用于说明这个返回的信息是以什么形式展示的,例如如果值是inline,那么就是以网页的一部分或者整个页面展示,如果是 attachment
的话,就是以下载附件的形式展示;
attachment(filename) {
if (filename) this.type = extname(filename);
this.set('Content-Disposition', contentDisposition(filename));
}
Content-Disposition: inline; // 网页一部分或者整个网页展示
Content-Disposition: attachment; // 下载网页附件的形式
Content-Disposition: attachment, filename="xxx.ext"; // 还可以使用 filename 来指定文件名
缓存协商
在koa中的request
中使用 fresh
字段来判断这个请求需要的内容是否是最新的,其原理也就是我们熟知的 http 缓存机制,内部通过 fresh 这个库来判断请求头中的 if-modify-since
与 if-match-since
对于此响应头部中的 last-Modified
字段与ETag
字段。
当然, 在检查这两个字段之前, 还需要检查一下请求头部的 Cache-Control 头部, 如果 Cache-Control 头部是 no-cache, 那么就代表请求信息必须是最新的。
context.js
context的核心是通过 delegates
这一个库,将request
、response
对象上的属性方法代理到context
对象上。
var delegate = require('delegates');
var proto = module.exports = {}; // 一些自身方法,被我删了
/**
* Response delegation.
*/
delegate(proto, 'response')
.method('attachment')
.method('redirect')
.method('remove')
.method('vary')
.method('set')
.method('append')
.access('status')
.access('message')
.access('body')
.access('length')
.access('type')
.access('lastModified')
.access('etag')
.getter('headerSent')
.getter('writable');
/**
* Request delegation.
*/
delegate(proto, 'request')
.method('acceptsLanguages')
.method('acceptsEncodings')
.method('acceptsCharsets')
.method('accepts')
.method('get')
.method('is')
.access('querystring')
.access('idempotent')
.access('socket')
.access('search')
.access('method')
.access('query')
.access('path')
.access('url')
.getter('origin')
.getter('href')
.getter('subdomains')
.getter('protocol')
.getter('host')
.getter('hostname')
.getter('header')
.getter('headers')
.getter('secure')
.getter('stale')
.getter('fresh')
.getter('ips')
.getter('ip');
这个看上去其实就是koa的API目录。
下面分析下delegates
这个库的源码。
// proto其实就是context对象,target是模块的name,比如request和response
function Delegator(proto, target){
// 避免this指向错误的兼容
if(!(this instanceof Delegator)) return new Delegator(proto,target);
this.proto = proto;
this.target = target;
this.methods = []; // 所有的方法数组集
this.getters = [];
this.setters = [];
this.fluents = [];
}
// 获取koa中所有的方法集
Delegator.prototype.method = function(name){
// 拿到context对象和当前模块target
var proto = this.proto;
var target = this.target;
this.methods.push(name);
// 复制request和response中的所有方法
proto[name] = function(){
// this[target][name]相当于是request.accepts方法,此时该方法已经从request上委托到了delegator实例上
return this[target][name].apply(this[target], arguments);
}
return this;
}
Delegator.prototype.getter = function(name){
// this.proto 指向原型。也就是context对象
var proto = this.proto;
// target 是指 'response' 字符串
var target = this.target;
// 将 name 加入到 delegator 实例对象的 getters 数组中
this.getters.push(name);
// 调用原生的 __defineGetter__ 方法进行 getter 代理, 那么 proto[name] 就相当于 proto[target][name]
// 而 context.response 就相当于 response 对象
// 由此实现属性代理
proto.__defineGetter__(name, function(){
return this[target][name];
});
return this;
}
Delegator.prototype.access = function(name){
return this.getter(name).setter(name);
};
错误处理
在koa中有两个错误处理:application.js中的onerror方法和context.js中的onerror方法。
application中的onerror方法:
绑定在koa实例对象上,其监听的是整个对象的error事件。
context中的onerror方法:
绑定在中间件函数数组生成的Promise的catch中与res对象的onFinished函数的回调的,其就是为了处理请求or响应中出现的error事件的。
application中的onerror
onerror(err) {
// 判断err是否是 new Error过来的实例
if (!(err instanceof Error)) throw new TypeError(util.format('non-error thrown: %j', err));
// 忽略404错误
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();
}
context中的onerror
onerror(err) {
// don't do anything if there is no error.
// this allows you to pass `this.onerror`
// to node-style callbacks.
if (null == err) return;
// 将错误转化为 Error 实例
if (!(err instanceof Error)) err = new Error(util.format('non-error thrown: %j', err));
let headerSent = false;
if (this.headerSent || !this.writable) {
headerSent = err.headerSent = true;
}
// delegate
this.app.emit('error', err, this);
// nothing we can do here other
// than delegate to the app-level
// handler and log.
if (headerSent) {
return;
}
const { res } = this;
// first unset all headers
/* istanbul ignore else */
if (typeof res.getHeaderNames === 'function') {
res.getHeaderNames().forEach(name => res.removeHeader(name));
} else {
res._headers = {}; // Node < 7.7
}
// then set those specified
this.set(err.headers);
// force text/plain
this.type = 'text';
// ENOENT support
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);
this.res.end(msg);
}
其他
1. koa 中 proxy 属性真正用途是什么?
要知道, 我们在实际运用中, 可能会使用很多的代理服务器, 包括我们常见的正向代理与反向代理, 虽然代理的用处很大, 但是无法避免地我们有时需要知晓真正的客户端的请求 ip,
而其实实际上, 服务器并不知道真正的客户端请求 ip, 即使你使用 socket.remoteAddrss 属性来查看, 因为这个请求是代理服务器转发给服务器的, 幸好代理服务器例如 nginx 提供了一个
HTTP 头部来记录每次代理服务器的源 IP 地址, 也就是 X-Forwarded-For 头部.形式如下:
X-Forwarded-For: 192.168.210.13, 210.112.40.13, 43.56.210.10
如果一个请求跳转了很多代理服务器, 那么 X-Forwarded-For 头部的 ip 地址就会越多, 第一个就是原始的客户端请求 ip, 第二个就是第一个代理服务器 ip, 以此类推.
当然, X-Forwarded-For 并不完全可信, 因为中间的代理服务器可能会"使坏"更改某些 IP. 而 koa 中 proxy 属性的设置就是如果使用 true, 那么就是使用 X-Forwarded-For 头部的第一个
ip 地址, 如果使用 false, 则使用 server 中的 socket.remoteAddress 属性值.
除了 X-Forwarded-For 之外, proxy 还会影响 X-Forwarded-proto 的使用, 和 X-Forwarded-For 一样, X-Forwarded-proto 记录最开始的请求连接使用的协议类型(HTTP/HTTPS), 因为客户端与
服务端之间可能会存在很多层代理服务器, 而代理服务器与服务端之间可能只是使用 HTTP 协议, 并没有使用 HTTPS, 所以 proxy 属性为 true 的话, koa 的 protocol 属性会去取 X-Forwarded-proto 头部
的值(koa 中 protocol 属性会先使用 tlsSocket.encrypted 属性来判断是否是 https 协议, 如果是则直接返回 ‘https’).
关于co
既然已经浏览了koa2的源码,那就再花一点点时间了解一下koa的时候中间件机制是怎么完成的吧。
先看koa中context.js的关于中间件的部分源码
app.callback = function(){
// this.experimental是用来判断是否开启ES7的,开启之后中间件就能使用async函数了
// 这部分已经在koa2中实现了,另外Tj大神还做了个generator的兼容,并提示大家koa3版本不再兼容generator了
if (this.experimental) {
console.error('Experimental ES7 Async Function support is deprecated. Please look into Koa v2 as the middleware signature has changed.')
}
// 这是第一部分,用来绑定中间件的
var fn = this.experimental
? compose_es7(this.middleware)
: co.wrap(compose(this.middleware));
var self = this;
return function(req,res){
res.statusCode = 404;
var ctx = self.createContext(req,res);
onFinished(res, ctx.onerror);
fn.call(ctx).then(function(){
respond.call(ctx);
}).catch(ctx.onerror);
}
}
代码逻辑和koa2中的差不多,重点是这个co.wrap(compose(this.middleware))
。
前面就说了compose是用来把一个个的koa中间件串联起来的
// generator组合而成的数组
this.middleware = [function *m1(){},function *m2(){},function *m3(){}];
var middleware = compose(this.middleware);
// 转换后得到的middleware其实是这样的
function *(){
yield *m1(m2(m3(noop())));
}
ES6的generator函数的特性是,第一次执行并不会执行函数中的代码,而是生成一个generator对象,这个对象有next
、throw
等方法。这样就是每一个中间件都会有一个参数,这个参数就是下一个中间件执行后,生成出来的generator对象,也就是next。这里的数据结构有点像是单链表。
再看下compose的源码
module.exports = compose;
function compose(middleware){
return function *(next){
// 执行第一个中间的时候指定next为noop函数
if(!next) next = noop();
var i = middleware.length;
// 中间件从后往前一次执行
while(i--){
// 把每一个中间件执行后得到的generator对象赋值给next,当下一次执行中间件的时候(也就是执行前一个中间件的时候)把next传递给前一个中间件。这样就保证了前中间件的参数是后中间件生成的generator对象。
// 而执行第一个中间的时候指定next为noop函数
next = middleware[i].call(this,next);
}
// 最后返回第一个中间件的generator对象
return yield *next;
}
}
function *noop(){}
上述代码可以看出来,中间件数组经过compose之后会得到一个函数,执行这个函数就与执行第一个中间件的效果是一样的。同时中间件的状态就变成了不可用的中间件是一个数组,可用的中间件是一个generator函数。
一层层的执行下去,就像剥洋葱一样~~
分析完compose之后,那这个co又是干嘛的呢?
白话文说明一下就是:把异步变成同步的模块。
co.wrap = function(fn){
createPromise.__generatorFunction__ = fn;
return createPromise;
function createPromise(){
return co.call(this, fn.apply(this, arguments));
}
}
其接收一个函数,这个函数其实就是最后返回的yeild *next(),也就是可用状态下的中间件。最后返回一个函数createPromise,而当执行createPromise
函数的时候,调用co并传入一个参数,这个参数是中间件函数执行后生成的Generator对象。
它就是一个“皮”,通过co来包装Generator和yeild。将异步的写法改成了同步。所以Tj大神已经在koa2中async/await代替了。并在koa2去除了co。
var co = require('co');
co(function *(){
var result = yield Promise.resolve(true);
}).catch(onerror);
co(function *(){
var a = Promise.resolve(1);
var b = Promise.resolve(2);
var c = Promise.resolve(3);
var res = yield [a, b, c];
console.log(res);
}).catch(onerror);
co(function *(){
try {
yield Promise.reject(new Error('boom'));
} catch (err) {
console.error(err.message); // "boom"
}
}).catch(onerror);
function onerror(err) {
// log any uncaught errors
// co will not throw any errors you do not handle!!!
// HANDLE ALL YOUR ERRORS!!!
console.error(err.stack);
}