这篇文章来自我们团队穆召同学的分享,介绍了Koa源码的结构。现在流行的如Egg.js等Node.js后端框架,就是基于Koa二次封装的。对服务端开发感兴趣的同学不妨来了解一下。
阅读文章前,建议提前准备好Koa源码,以便随时对照查看。
概览
Koa的源码结构非常简单,所有源码都在lib文件夹下,总共只包含4个文件:
- lib
- application.js // 入口文件,定义了Application类
- context.js // 请求上下文 ctx
- request.js // ctx.request 对象
- response.js // ctx.response 对象
下面通过一个简单的koa应用详细介绍各个文件的内容
一个简单的Koa应用
// npm i koa
const Koa = require('koa');
const app = new Koa();
app.use(async (ctx, next) => {
ctx.body = 'Hello Koa';
});
app.listen(7000);
node app.js运行上述代码,此时一个简单的web服务就启动成功了。然后在浏览器导航栏输入http://localhost:7000就可以看出到 ‘Hello, Koa’。
首先,我们通过require('koa')引入Koa包,然后new Koa()创建了Koa的实例。检查Koa文件夹中的package.json文件,可以看出实际引入的是lib/application文件。
application.js
/**
* Expose `Application` class.
* Inherits from `Emitter.prototype`.
*/
// 继承 Emitter, 拥有处理事件能力
module.exports = class Application extends Emitter {
/**
* Initialize a new `Application`.
*
* @api public
*/
/**
*
* @param {object} [options] Application options
* @param {string} [options.env='development'] Environment
* @param {string[]} [options.keys] Signed cookie keys
* @param {boolean} [options.proxy] Trust proxy headers
* @param {number} [options.subdomainOffset] Subdomain offset
* @param {string} [options.proxyIpHeader] Proxy IP header, defaults to X-Forwarded-For
* @param {number} [options.maxIpsCount] Max IPs read from proxy IP header, default to 0 (means infinity)
*
*/
constructor (options) {
super()
options = options || {}
// 代理服务有关
// X-Forwarded-Proto XFP记录客户端与代理服务器连接使用的协议(HTTP,HTTPS)
// X-Forwarded-For <client>, <proxy1>, <proxy2>
// X-Forwarded-Host 记录了客户端访问最初的host
this.proxy = options.proxy || false
this.subdomainOffset = options.subdomainOffset || 2 // 子域偏移
this.proxyIpHeader = options.proxyIpHeader || 'X-Forwarded-For' // 获取 ctx.request.ips 的 header 字段
this.maxIpsCount = options.maxIpsCount || 0 // 获取 ctx.request.ips 的最大数量
this.env = options.env || process.env.NODE_ENV || 'development' // 环境变量
if (options.keys) this.keys = options.keys // cookie签名密钥数组
this.middleware = [] // 定义了一个中间件数组,所有中间件都会push到该数组
// Object.create 实现了继承
this.context = Object.create(context) // 根据context.js创建context
this.request = Object.create(request) // 根据request.js创建request
this.response = Object.create(response) // 根据response.js创建response
// util.inspect.custom support for node 6+
/* istanbul ignore else */
if (util.inspect.custom) {
// 定义this[util.inspect.custom] 可以覆盖util.inspect的默认行为
this[util.inspect.custom] = this.inspect
}
}
...
}
其中,this.proxy 从注释中可以出为 是否信任代理头部,我们来看看和 this.proxy 相关的源码,打开 request.js 文件, 全局搜索 proxy 关键词,可以看到和proxy相关的几个方法。 当 proxy 设置为 true 时, request.host, request.protocal, request.ips会分别获取请求头的 X-Forwarded-Host, X-Forwarded-For, X-Forwarded-Proto 的值作为返回值,这三个值其实和代理服务有关,有兴趣的同学可以去mozilla官网详细了解。this.proxyIpHeader决定request.ips具体获取请求头的字段。maxIpsCount决定了request.ips返回ips的最大数量。
/**
* Parse the "Host" header field host
* and support X-Forwarded-Host when a
* proxy is enabled.
*
* @return {String} hostname:port
* @api public
*/
get host () {
const proxy = this.app.proxy
let host = proxy && this.get('X-Forwarded-Host')
if (!host) {
if (this.req.httpVersionMajor >= 2) host = this.get(':authority')
if (!host) host = this.get('Host')
}
if (!host) return ''
return host.split(/\s*,\s*/, 1)[0]
},
/**
* Return the protocol string "http" or "https"
* when requested with TLS. When the proxy setting
* is enabled the "X-Forwarded-Proto" header
* field will be trusted. If you're running behind
* a reverse proxy that supplies https for you this
* may be enabled.
*
* @return {String}
* @api public
*/
get protocol () {
if (this.socket.encrypted) return 'https'
if (!this.app.proxy) return 'http'
const proto = this.get('X-Forwarded-Proto')
return proto ? proto.split(/\s*,\s*/, 1)[0] : 'http'
},
/**
* When `app.proxy` is `true`, parse
* the "X-Forwarded-For" ip address list.
*
* For example if the value was "client, proxy1, proxy2"
* you would receive the array `["client", "proxy1", "proxy2"]`
* where "proxy2" is the furthest down-stream.
*
* @return {Array}
* @api public
*/
get ips () {
const proxy = this.app.proxy
const val = this.get(this.app.proxyIpHeader)
let ips = proxy && val
? val.split(/\s*,\s*/)
: []
if (this.app.maxIpsCount > 0) {
ips = ips.slice(-this.app.maxIpsCount)
}
return ips
},
this.subdomainOffset 表示字域偏移量,可以看以下 request.js 中的源码, 举个例子: 如果请求域名为 tobi.ferrets.example.com , 如果 app.subdomainOffset 没设置,this.subdomains 返回 ["ferrets", "tobi"], 如果 app.subdomainOffset 设置为3,则this.subdomains返回["tobi"]
/**
* Return subdomains as an array.
*
* Subdomains are the dot-separated parts of the host before the main domain
* of the app. By default, the domain of the app is assumed to be the last two
* parts of the host. This can be changed by setting `app.subdomainOffset`.
*
* For example, if the domain is "tobi.ferrets.example.com":
* If `app.subdomainOffset` is not set, this.subdomains is
* `["ferrets", "tobi"]`.
* If `app.subdomainOffset` is 3, this.subdomains is `["tobi"]`.
*
* @return {Array}
* @api public
*/
get subdomains () {
const offset = this.app.subdomainOffset
const hostname = this.hostname
if (net.isIP(hostname)) return []
return hostname
.split('.')
.reverse()
.slice(offset)
},
this.env 表示运行的环境变量,默认为development。this.keys 定义 cookie 签名密钥数组。util.inspect为返回对象的字符串表示,而this[util.inspect.custom] = function(){return 'example'}可以覆盖util.inspect的默认行为并打印出example。
this.middleware = [] // 定义了一个中间件数组,所有中间件都会push到该数组
// Object.create 实现了继承
this.context = Object.create(context) // 根据context.js创建context
this.request = Object.create(request) // 根据request.js创建request
this.response = Object.create(response) // 根据response.js创建response
this.middleware 定义了中间件数组,所有使用app.use()注册的中间件都会push到这个数组里面。
使用 Object.create() 分别实现 this.context, this.request, this.response 对context, request, response三个文件的继承。
context.js
const proto = module.exports = {
inspect () {},
toJSON () {},
assert: httpAssert,
throw (...args) {},
/**
* Default error handling.
*
* @param {Error} err
* @api private
*/
onerror (err) {},
get cookies () {},
set cookies (_cookies) {}
}
/* istanbul ignore else */
if (util.inspect.custom) {
module.exports[util.inspect.custom] = module.exports.inspect
}
/**
* Response delegation.
*/
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')
/**
* 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')
.access('accept')
.getter('origin')
.getter('href')
.getter('subdomains')
.getter('protocol')
.getter('host')
.getter('hostname')
.getter('URL')
.getter('header')
.getter('headers')
.getter('secure')
.getter('stale')
.getter('fresh')
.getter('ips')
.getter('ip')
可以看出context.js定义了一些方法和属性, 可以自行了解一下,都比较容易理解。文件末尾调用delegate方法,将proto的方法委托给response和request,所以此时调用proto的方法实际是调用了response和request的方法。
委托模式,delegates包
function Delegator(proto, target) {
if (!(this instanceof Delegator)) return new Delegator(proto, target);
this.proto = proto;
this.target = target;
this.methods = [];
this.getters = [];
this.setters = [];
this.fluents = [];
}
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;
};
Delegator.prototype.access = function(name){
return this.getter(name).setter(name);
};
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;
};
Delegator.prototype.setter = function(name){
var proto = this.proto;
var target = this.target;
this.setters.push(name);
proto.__defineSetter__(name, function(val){
return this[target][name] = val;
});
return this;
};
Delegator.prototype.fluent = function (name) {
var proto = this.proto;
var target = this.target;
this.fluents.push(name);
proto[name] = function(val){
if ('undefined' != typeof val) {
this[target][name] = val;
return this;
} else {
return this[target][name];
}
};
return this;
};
method方法将proto的方法委托给target。getter,setter将proto的属性委托给target。access方法同时定义了getter和setter。而fluent方法则是access方法的另一种写法。可以结合context代码看出,context自己并没有额外声明方法的实现,只是将方法和属性分别委托给了request和response。所以我们在koa程序中调用 ctx.body 等方法实际上是调用的对应的response或request上的方法。
request.js
module.exports = {
get header () {
return this.req.headers
},
set header (val) {
this.req.headers = val
},
...
}
response.js
module.exports = {
get socket () {
return this.res.socket
},
get status () {
return this.res.statusCode
},
...
}
request.js, response.js 是基于node原生的req和res对象封装了一些更为简单方便的方法和属性,使处理请求时能方便的调用。
Koa 中间件机制
继续看一看是那个简单的demo。Koa实例创建完成后,调用 app.use()给应用添加中间件
// application.js 源码
module.exports = class Application extends Emitter {
...
use (fn) {
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!')
debug('use %s', fn._name || fn.name || '-')
this.middleware.push(fn)
return this
}
...
}
use方法十分简单,仅仅时判断了参数fn是否为function类型,不是的话报错,否则将fn推出this.middleware数组,所以不难理解所有的koa中间件都会按顺序储存在this.middleware。
然后调用app.listen启动服务
// application.js 源码
module.exports = class Application extends Emitter {
...
listen (...args) {
debug('listen')
const server = http.createServer(this.callback())
return server.listen(...args)
}
...
}
listen方法其实就是调用了原生http模块的createServer方法创建http服务,然后调用server.listen监听端口启动服务。熟悉node原生http的应该会知道,实际上http.createServer(callback)的参数是一个回调函数,而每个http请求都会执行这个callback,也可以说会经过这个callback, 而所有的node框架中间件机制都是围绕这个callback进行实现的。而Koa这里调用了this.callback()。Koa的中间件机制就是在this.callback函数中完成。
// application.js 源码
module.exports = class Application extends Emitter {
...
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中调用了componse函数返回了一个fn,componse实际上是引入koa-componse包,这个包实现了koa中间件的核心逻辑,后面详细介绍。随后监听了'error'事件,处理错误。接下来返回了返回了handleRequest函数作为原生http.createServer的回调函数,而koa中间件机制,全部是在handleReqeust这个函数中实现。随后listen方法调用了node原生server.listen传入端口号,至此一个整koa服务已经启动成功。此时访问对应的端口号,既可以收到对象的响应。
那么来看看具体的一个请求koa做了什么,结合之前讲的node原生http模块的createServer方法需要传递一个回调函数,而这个回调函数实际上是每个http请求都需要执行的。koa框架通过调用callback方法返回这个回调函数handleRequest,所以当http请求被处理时实际上是调用了handleRequest函数。handleRequest首先会调用this.createContext(req, res)函数,将node原生的req, res传入。
// application.js 源码
module.exports = class Application extends Emitter {
...
createContext (req, res) {
// 创建独立的ctx
const context = Object.create(this.context)
// 创建独立的request赋值给context.request
const request = context.request = Object.create(this.request)
// 创建独立的response赋值给context.response
const response = context.response = Object.create(this.response)
// 将实例赋值给context.app, request.app, response.app
context.app = request.app = response.app = this
// 将原生req, res赋值给context, request, response
context.req = request.req = response.req = req
context.res = request.res = response.res = res
// 将ctx赋值给 request 和 response
request.ctx = response.ctx = context
//request, response 相互赋值
request.response = response
response.request = request
// 原生req.url赋值给context, request
context.originalUrl = request.originalUrl = req.url
// 创建state对象用作用户自定存储
context.state = {}
return context
}
...
}
createContext其实就是保证了每个请求会有自己独立的context, request, response,并且做了一系列赋值操作,使delegate委托生效。
// application.js 源码
module.exports = class Application extends Emitter {
...
handleRequest (ctx, fnMiddleware) {
const res = ctx.res
res.statusCode = 404 // 默认了statusCode 为404
const onerror = err => ctx.onerror(err)
const handleResponse = () => respond(ctx)
onFinished(res, onerror) // 是监听了res相关错误事件,onerror去捕获错误
return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}
...
}
fnMiddleware执行完成后对返回结果进行包装处理,然后catch错误,这里需要了解一下,这里onError调用了ctx.onerror,而ctx.onerror实际上调用了this.app.emit('error')触发了error事件,这块就是koa错误处理,也可以手动监听'error'事件错误处理。
洋葱模型,koa-compose 到底做了什么
koa-compose源码实际上非常简单,整个文件包含注释也就不到50行代码,不过,这50行代码却是koa框架的精华所在。 这里基于一个实例分析koa-compose做了什么
const Koa = require('koa');
const app = new Koa();
app.use(async (ctx, next) => {
console.log('first enter');
await next();
console.log('first out');
} );
app.use(async (ctx, next) => {
console.log('second enter');
await next();
console.log('second out');
})
app.use(async (ctx, next) => {
console.log('last');
ctx.body = 'Hello Koa';
});
app.listen(7000);
// first enter
// second enter
// last
// second out
// first out
假设我们启动了上述koa服务,当http处理时,进入了中间件处理环节
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!')
}
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)));
} catch (err) {
return Promise.reject(err)
}
}
}
}
compose函数返回了一个function,这个function 声明了变量index = -1,定义了一个dispatch函数,直接又返回一个dispatch(0),逐行分析dispatch函数
- 判断i是否小于等于
index, 小于等于index直接返回Promise.reject。这里0 > -1 - 将
i赋值给index。此时index = 0; - 将声明变量
fn将middleware[0]赋值给fn。此时fn就等于第一个use的中间件 - i是否登录中间件
middleware数组的长度,等于的话直接将next赋值给fn。此时middleware.length === 3 - 如果
fn不存在,直接返回Promise.resolve()。此时fn存在。 - 返回
promise,prmise结果取决于fn(context, dispatch.bind(null, i + 1)))的执行结果。此时开始执行第一个中间件。而中间件参数next又传入了dispatch(1);
接下来在示例中可以看到第一个中间件中调用了await next(), 实际上第一个中间件阻塞到了await next()处, 直接又调用了dispatch(1),继续重复上述6步,逐行分析,可以得出结果,第二个中间件也阻塞到了await next(), 并且调用dispatch(2), 开始执行第三个中间件,第三个中间件没有执行next,所以等第三个中间件执行完成之后,第二个中间件await next()后续代码开始执行,等第二个中间件代码执行完成,第一个中间件后续代码开始执行,至此示例代码中的三个中间件全部执行完成,这就是koa的洋葱模型。总结一下,简单来说koa是利用async函数通过await递归的调用中间件实现了洋葱模型。