koa
是当下非常流行的
node
框架,相比笨重的
express
,
koa
只专注于中间件模型的建立,以及请求和响应控制权的转移。本文将以
koa2
为例,深入源码分析框架的实现细节。
koa2
的源码位于
lib
目录,结构非常简单和清晰,只有四个文件,如下:
根据
package.json
中的
main
字段,可以知道入口文件是
lib/application.js
,
application.js
定义了
koa
的构造函数以及实例拥有的方法,如下图:
构造函数
首先看一下构造函数的代码
constructor() {
super();
this.proxy = false;
this.middleware = [];
this.subdomainOffset = 2;
this.env = process.env.NODE_ENV || 'development';
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;
}
}
复制代码
这里定义了实例的8个属性,各自的含义如下:
属性 | 含义 |
---|---|
proxy |
表示是否开启代理,默认为
false
,如果开启代理,对于获取
request
请求中的
host
,
protocol
,
ip
分别优先从
Header
字段中的
X-Forwarded-Host
,
X-Forwarded-Proto
,
X-Forwarded-For
获取。
|
middleware | 最重要的一个属性,存放所有的中间件,存放和执行的过程后文细说。 |
subdomainOffset |
子域名的偏移量,默认值为2,这个参数决定了
request.subdomains
的返回结果。
|
env |
node
的执行环境, 默认是
development
。
|
context |
中间件第一个实参
ctx
的原型, 具体在讲
context.js
时会说到。
|
request |
ctx.request的原型,定义在
request.js
中。
|
response |
ctx.response的原型,定义在
response.js
中。
|
[util.inspect.custom] |
util.inspect
这个方法用于将对象转换为字符串, 在
node v6.6.0
及以上版本中
util.inspect.custom
是一个
Symbol
类型的值,通过定义对象的
[util.inspect.custom]
属性为一个函数,可以覆盖
util.inspect
的默认行为。
|
use()
use
方法很简单,接受一个函数作为参数,并加入
middleware
数组。由于
koa
最开始支持使用
generator
函数作为中间件使用,但将在
3.x
的版本中放弃这项支持,因此
koa2
中对于使用
generator
函数作为中间件的行为给与未来将被废弃的警告,但会将
generator
函数转化为
async
函数。返回
this
便于链式调用。
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;
}
复制代码
listen()
下面是
listen
方法,可以看到内部是通过原生的
http
模块创建服务器并监听的,请求的回调函数是
callback
函数的返回值。
listen(...args) {
debug('listen');
const server = http.createServer(this.callback());
return server.listen(...args);
}
复制代码
callback()
下面是
callback
的代码,
compose
函数将中间件数组转换成执行链函数
fn
,
compose
的实现是重点,下文会分析。
koa
继承自
Emitter
,因此可以通过
listenerCount
属性判断监听了多少个
error
事件, 如果外部没有进行监听,框架将自动监听一个
error
事件。
callback
函数返回一个
handleRequest
函数,因此真正的请求处理回调函数是
handleRequest
。在
handleRequest
函数内部,通过
createContext
创建了上下文
ctx
,并交给
koa
实例的
handleRequest
方法去处理回调逻辑。
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;
}
复制代码
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;
}
复制代码
上面是
createContext
的代码, 从这里我们可以知道,通过
ctx.req
和
ctx.res
可以访问到
node
原生的请求对象和响应对象, 通过修改
ctx.state
可以让中间件共享状态。可以用一张图描述这个函数中定义的关系,如下:
接下来我们分析细节,
this.context
、
this.request
、
this.response
分别通过
context
、
request
、
response
三个对象的原型创建, 我们先看一下
request
的定义,它位于
request.js
文件中。
request.js
request.js
定义了
ctx.request
的原型对象的原型对象,因此该对象的任意属性都可以通过
ctx.request
获取。这个对象一共有20多个属性和若干方法。其中属性多数都定义了
get
和
set
方法,截取一小部分代码如下:
module.exports = {
get header() {
return this.req.headers;
},
set header(val) {
this.req.headers = val;
},
...
}
复制代码
上面代码中定义了
header
属性,根据前面的关系图可知,
this.req
指向的是原生的
req
,因此
ctx.request.header
等于原生
req
的
headers
属性,修改
ctx.request.header
就是修改
req
的
headers
。
request
对象中所有的属性和方法列举如下:
属性/方法 | 含义 |
---|---|
header |
原生
req
对象的
headers
|
headers |
原生
req
对象的
headers
, 同上
|
url |
原生
req
对象的
url
|
origin |
protocol://host
|
href |
请求的完整
url
|
method |
原生
req
对象的
method
|
path |
请求
url
的
pathname
|
query |
请求
url
的
query
,对象形式
|
queryString |
请求
url
的
query
,字符串形式
|
search |
?queryString
|
hostname |
hostname
|
URL | 完整的URL对象 |
fresh |
判断缓存是否新鲜,只针对
HEAD
和
GET
方法,其余请求方法均返回
false
|
stale |
fresh
取反
|
idempotent |
检查请求是否幂等,符合幂等性的请求有
GET
,
HEAD
,
PUT
,
DELETE
,
OPTIONS
,
TRACE
6个方法
|
socket |
原生
req
对象的套接字
|
charset | 请求字符集 |
type |
获取请求头的
Content-Type
不含参数
charset
。
|
length |
请求的
Content-Length
|
secure |
判断是不是
https
请求
|
ips |
当
X-Forwarded-For
存在并且
app.proxy
被启用时,这些
ips
的数组被返回,从上游到下游排序。 禁用时返回一个空数组。
|
ip |
请求远程地址。 当
app.proxy
是
true
时支持
X-Forwarded-Proto
|
protocol |
返回请求协议,
https
或
http
。当
app.proxy
是
true
时支持
X-Forwarded-Proto
|
host |
获取当前主机
(hostname:port)
。当
app.proxy
是
true
时支持
X-Forwarded-Host
,否则使用
Host
|
subdomains |
根据
app.subdomainOffset
设置的偏移量,将子域返回为数组
|
get(...args) | 获取请求头字段 |
accepts(...args) |
检查给定的
type(s)
是否可以接受,如果
true
,返回最佳匹配,否则为
false
|
acceptsEncodings(...args) |
检查
encodings
是否可以接受,返回最佳匹配为
true
,否则为
false
|
acceptsCharsets(...args) |
检查
charsets
是否可以接受,在
true
时返回最佳匹配,否则为
false
。
|
acceptsLanguages(...args) |
检查
langs
是否可以接受,如果为
true
,返回最佳匹配,否则为
false
。
|
[util.inspect.custom] |
自定义的
util.inspect
|
response.js
response.js
定义了
ctx.response
的原型对象的原型对象,因此该对象的任意属性都可以通过
ctx.response
获取。和
request
类似,
response
的属性多数也定义了
get
和
set
方法。
response
的属性和方法如下:
属性/方法 | 含义 |
---|---|
header |
原生
res
对象的
headers
|
headers |
原生
res
对象的
headers
, 同上
|
status |
响应状态码, 原生
res
对象的
statusCode
|
message |
响应的状态消息. 默认情况下,
response.message
与
response.status
关联
|
socket |
套接字,原生
res
对象的socket
|
type |
获取响应头的
Content-Type
不含参数
charset
|
body |
响应体,支持
string
,
buffer
、
stream
、
json
|
lastModified |
将
Last-Modified
标头返回为
Date
, 如果存在
|
etag |
响应头的
ETag
|
length |
数字返回响应的
Content-Length
,使用
Buffer.byteLength
对
body
进行计算
|
headerSent | 检查是否已经发送了一个响应头, 用于查看客户端是否可能会收到错误通知 |
vary(field) |
在
field
上变化。
|
redirect(url, alt) | 执行重定向 |
attachment(filename, options) |
将
Content-Disposition
设置为 “附件” 以指示客户端提示下载。(可选)指定下载的
filename
|
get(field) | 返回指定的响应头部 |
set(field, val) | 设置响应头部 |
is(type) | 响应类型是否是所提供的类型之一 |
append(field, val) | 设置规范之外的响应头 |
remove(field) | 删除指定的响应头 |
flushHeaders() | 刷新所有响应头 |
writable() |
判断响应是否可写,原生
res
对象的
finished
为
true
,则返回
false
, 否则判断原生
res
对象是否建立套接字
socket
, 如果没有返回
false
, 有则返回
socket.writable
|
request
和
response
中每个属性
get
和
set
的定义以及方法的实现多数比较简单直观,如果对每个进行单独分析会导致篇幅过长,而且这些不是理解
koa
运行机制的核心所在,因此本文只罗列属性和方法的用途,这些大部分也可以在
koa
的官方文档中找到。关心细节的朋友可以直接阅读
request.js
和
response.js
这两个文件,如果你熟悉
http
协议,相信这些代码对你并没有障碍。接下来我们的重点是
context.js
。
context.js
context.js
定义了
ctx
的原型对象的原型对象, 因此这个对象中所有属性都可以通过
ctx
访问到。
context.js
中除了定义
[util.inspect.custom]
这个不是很重要的属性外,只直接定义了一个属性
cookies
,也定义了几个方法,这里分别进行介绍:
cookies
get cookies() {
if (!this[COOKIES]) {
this[COOKIES] = new Cookies(this.req, this.res, {
keys: this.app.keys,
secure: this.request.secure
});
}
return this[COOKIES];
},
set cookies(_cookies) {
this[COOKIES] = _cookies;
}
复制代码
上面的代码中定义了
cookies
属性的
set
和
get
方法。
set
方法很简单,
COOKIES
是一个
Symbol
类型的私有变量。需要注意的是我们一般不通过
ctx.cookies
来直接设置
cookies
,官方文档推荐使用
ctx.cookies.set(name, value, options)
来设置,可是这里并没有
cookies.set
呀,其实这里稍微一看就明白,
cookies
的值是
this[COOKIES]
,它是
Cookies
的一个实例,在
Cookie
这个
npm
包中是定义了实例的
get
和
set
方法的。
throw()
throw(...args) {
throw createError(...args);
},
复制代码
当我们调用
ctx.throw
抛出一个错误时,内部是抛出了一个有状态码和信息的错误,
createError
的实现在
http-errors
这个
npm
包中。
onerror()
下面是
onerror
方法的代码,发生错误时首先会触发
koa
实例上的
error
事件来打印一个错误日志,
headerSent
变量表示响应头是否发送,如果响应头已经发送,或者响应处于不可写状态,将无法在响应中添加错误信息,直接退出该函数,否则需要将之前写入的响应头部信息清空。
onerror(err) {
// 没有错误时什么也不做
if (null == err) return;
// err不是Error实例时,使用err创建一个Error实例
if (!(err instanceof Error)) err = new Error(util.format('non-error thrown: %j', err));
let headerSent = false;
// 如果res不可写或者请求头已发出
if (this.headerSent || !this.writable) {
headerSent = err.headerSent = true;
}
// 触发koa实例app的error事件
this.app.emit('error', err, this);
if (headerSent) {
return;
}
const { res } = this;
// 移除所有设置过的响应头
if (typeof res.getHeaderNames === 'function') {
res.getHeaderNames().forEach(name => res.removeHeader(name));
} else {
res._headers = {}; // Node < 7.7
}
// 设置错误头部
this.set(err.headers);
// 设置错误时的Content-Type
this.type = 'text';
// 找不到文件错误码设为404
if ('ENOENT' == err.code) err.status = 404;
// 不能被识别的错误将错误码设为500
if ('number' != typeof err.status || !statuses[err.status]) err.status = 500;
const code = statuses[err.status];
const msg = err.expose ? err.message : code;
// 设置错误码
this.status = err.status;
this.length = Buffer.byteLength(msg);
// 结束响应
res.end(msg);
},
复制代码
从上面代码中会有疑问,
this.set
、
this.type
等是哪里来的?
context
并没有定义这些属性。我们知道,
ctx
中其实是代理了很多
response
和
resquest
的属性和方法的,
this.set
、
this.type
其实就是
response.set
和
response.type
。那么
koa
中对象属性和方法的代理是如何实现的呢,答案是
delegate
,
context
中代码的最后就是使用
delegate
来代理一些本来只存在于
request
和
response上
的属性。接下来我们看一下
delegete
是如何实现代理的,
delegete
的实现代码在
delegetes
这个npm包中。
delegate
delegate
方法本质上是一个构造函数,接受两个参数,第一个参数是代理对象,第二个参数是被代理的对象,下面是它的定义,
Delegator
就是
delegate
。可以看到,不管是否使用
new
关键字,该函数总是会返回一个实例。
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
构造函数的原型上,定义了几个方法,
koa
中用到了
Delegator.prototype.method
、
Delegator.prototype.accsess
以及
Delegator.prototype.getter
,这些都是代理方法, 分别代理
set
和
get
方法。下面是代码,其中
get
和
set
方法的代理主要使用了对象的
__defineGetter__
以及
__defineSetter__
方法。
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