Koa.js 源码解析

Koa.js 源码解析

中间件常用中间件的实现 章节当中,我们简单的介绍了一下中间件和洋葱模型的概念,然后我们手动实现了一个最基本的 Koa.js 框架,最后又看了一些比较常用的中间件,所以在本章当中,我们就来深入的了解一下 Koa.js 的源码,看看它与我们手动实现的版本有什么区别

使用

在分析源码之前,我们先来看看如何使用 Koa.js 来创建一个 server 的大体流程

1
2
3
4
5
6
7
8
const Koa = require('koa')
const app = new Koa()

app.use(async ctx => {
ctx.body = 'hello world'
})

app.listen(3000)

下面是一张来源于网络关于 Koa.js 架构的示意图,我们可以对比着进行了解

入口文件

一般我们都是从入口文件开始找起,如果你看了 Koa.js 的源码,会发现 Koa.js 源码其实很简单,一共就四个文件

1
2
3
4
5
6
// https://github.com/koajs/koa/tree/master/lib
── lib
├── application.js
├── context.js
├── request.js
└── response.js

其实这四个文件分别对应着 Koa.js 当中的四个对象

1
2
3
4
5
── lib
├── application.js ==> new Koa() || ctx.app
├── context.js ==> ctx
├── request.js ==> ctx.req || ctx.request
└── response.js ==> ctx.res || ctx.response

对比使用可以发现,其实总的来说就三个步骤

  • 实例化一个对象(new Koa()
  • 注册一个或多个中间件(app.use(async ctx => { ... })
  • 调用 listen() 方法启动一个服务器

下面我们就按照步骤一个一个来了解

构造函数

通过查看 package.jsonmain 字段中可以发现 application.js 是入口文件,下面是入口文件的部分源码,只罗列了一些比较核心的内容,详细见 lib/application.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 依赖模块,我们主要看下面这几个
const response = require('./response')
const compose = require('koa-compose')
const context = require('./context')
const request = require('./request')
const Emitter = require('events')
const convert = require('koa-convert')

// 可以发现 Application 类是继承于 EventEmitter 的
// 所以我们在 koa 实例对象上可以使用 on,emit 等方法进行事件监听
module.exports = class Application extends Emitter {
constructor() {
super() // 因为继承于 EventEmitter,这里需要调用 super
this.middleware = [] // 该数组存放所有通过 use 函数的引入的中间件函数

// 这两个见下方
this.proxy = false // 代理设置
this.subdomainOffset = 2

// 下面这三个是我们重点需要关注的
// 分别通过 context.js、request.js、response.js 来创建对应的 context、request、response
this.context = Object.create(context)
this.request = Object.create(request)
this.response = Object.create(response)
}
}

相关内容都已经写在注释当中了,这里我们主要要提及两个属性

subdomainOffset

subdomainOffset 属性会改变获取 subdomain 时返回数组的值,比如 test.page.example.com 域名,如果设置 subdomainOffset2,那么返回的数组值为 ['page', 'test'],如果设置为 3,那么返回数组值为 ['test']

proxy

顾名思义,指的是代理,属性值是 true 或者 false,它的作用在于是否获取真正的客户端 IP 地址,在我们实际运用当中,可能会使用很多的代理服务器,包括我们常见的正向代理与反向代理,虽然代理的用处很大,但是无法避免地我们有时需要知晓真正的客户端的请求 IP

而其实实际上,服务器并不知道真正的客户端请求 IP,即使你使用 socket.remoteAddrss 属性来查看,因为这个请求是代理服务器转发给服务器的,幸好代理服务器例如 nginx 提供了一个 HTTP 头部来记录每次代理服务器的源 IP 地址,也就是 X-Forwarded-For 头部,形式如下

1
X-Forwarded-For: client, proxy1, proxy2

如果一个请求跳转了很多代理服务器,那么 X-Forwarded-For 头部的 IP 地址就会越多,第一个就是原始的客户端请求 IP,第二个就是第一个代理服务器 IP,以此类推,当然,X-Forwarded-For 并不完全可信,因为中间的代理服务器可能会更改某些 IP(也有可能直接手动设定),所以

  • Koa.jsproxy 属性的设置就是如果使用 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.jsprotocol 属性会去取 X-Forwarded-proto 头部的值
  • Koa.jsprotocol 属性会先使用 tlsSocket.encrypted 属性来判断是否是 HTTPS 协议,如果是则直接返回 HTTPS

关于此部分内容想了解更多的可以参考下面两个链接

接下来我们再来看注册中间件使用的 use() 方法

注册中间件

在实例化一个对象以后,接下来使用 use() 方法来注册一个中间件,其实就是简单的 push 到自身的 mideware 这个数组中

1
2
3
4
5
6
7
8
use(fn) {
if (isGeneratorFunction(fn)) {
// 兼容 koa v1 的 generator 写法
fn = convert(fn)
}
this.middleware.push(fn)
return this
}

这其中有一个 convert() 的方法,简单来说就是将 koa v1 当中使用的 generator 函数转换成 koa v2 中的 async 函数,更准确的说是将 generator 函数转换成使用 co 包装成的 Promise 对象,然后执行对应的代码,这里就不详细展开了,详细可以参考这篇博文 koa-convert 源码分析

启动服务

最后调用 listen() 方法来启动服务

1
2
3
4
listen(...args) {
const server = http.createServer(this.callback())
return server.listen(...args)
}

使用了 Node.js 原生的 http.createServer() 来创建服务器,并把 this.callback() 作为参数传递进去,下面我们就来看一下这个核心的 callback() 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
callback() {

// 使用 koa-compose 来组合 middleware 的运行方式(可以参考之前我们手动实现的 compose 方法)
const fn = compose(this.middleware)

if (!this.listenerCount('error')) this.on('error', this.onerror)

// 这里的 req, res 两个参数,代表原生的 request, response 对象
const handleRequest = (req, res) => {
// 每次接受一个新的请求就是生成一次全新的 context
const ctx = this.createContext(req, res)
return this.handleRequest(ctx, fn)
}

return handleRequest
}

这里主要涉及到三个方法

compose(this.middleware)

洋葱模型实现的核心

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* Compose `middleware` returning a fully valid middleware comprised of all those which are passed.
*
* @param {Array} middleware
* @return {Function}
*/
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!')
}

/**
* @param {Object} context
* @return {Promise}
*/
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 {
// 执行下一个中间件逻辑,并将 next 参数设置为 dispatch(i + 1)
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
return Promise.reject(err)
}
}
}
}

之前我们已经手动的实现了一个简化版本的了,这里就不详细展开了,可以参考之前的洋葱模型的实现章节,这里主要介绍其中两行代码

1
2
if (i === middleware.length) fn = next
if (!fn) return Promise.resolve()

在我们调用 fnMiddleware 是可以传入两个参数的,第二个可选参数表示最终的回调函数,比如

1
2
3
fnMiddleware(ctx, () => {
console.log(`done`, ctx)
})

i === middleware.length 成立时,实际上所有传入的 middleware 已经执行完,这个时候我们的 fn = next 表示 fn 被赋值给了这个传入的最终回调,接下来判断如果没有传入最终回调,那么整个中间件执行流程就到此结束

createContext(req, res)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
}

根据 reqres 封装中间件所需要的 ctx,简单来说就是将变量挂到 Context 上面,然后最后返回,但是这里需要注意区分

  • request.reqresponse.req 指向的是 HTTP 模块原生的 IncomingMessage 对象
  • request.responseresponse.request 指向的都是 Koa.js 封装后的对象
    • ctx.reqctx.res 是原生的 reqres 对象
    • ctx.requestctx.response 则是 Koa.js 自己封装的 requestresponse 对象

这里有一个小问题,这里明明只是将原生的 reqres 赋值给相应的属性,但是 ctx 上不是暴露出来很多属性吗?它们在哪里?其实这些东西我们可以通过 request.jsresponse.js 的源码来了解,通过源码可以发现,经过原型链的形式,我们 ctx.request 所能访问属性和方法绝大部分都在其对应的 request 这个简单的对象上面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// https://github.com/koajs/koa/blob/master/lib/request.js
module.exports = {

/**
* Return request header.
*
* @return {Object}
* @api public
*/

get header() {
return this.req.headers
},

/**
* Set request header.
*
* @api public
*/

set header(val) {
this.req.headers = val
},

// ...
}

所以当你操作 ctx.request.xx 的时候,其实访问的都是 resquest 这个对象上的属性的赋值器(setter)和取值器(getter

handleRequest(ctx, fnMiddleware)

1
2
3
4
5
6
7
8
9
// fnMiddleware 是经过 compose 包装后的函数
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)
}

这个函数简单来说只是负责执行中间件所有的函数, 并在中间件函数执行结束的时候调用 respond(ctx),本质上,在执行 fnMiddleware(ctx) 的时候其实就会调用 compose() 方法当中的那个 dispatch(0),然后开始不断递归,直到中间件流程执行结束,触发 handleResponse,也就是我们这里的 respond(ctx)

对请求的响应处理 respond

对于 respond() 函数, 其核心就是根据不同类型的数据对 HTTP 的响应头部与响应体 body 做对应的处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
function respond(ctx) {
// allow bypassing koa
// 用于设置自定义的 response 策略
if (false === ctx.respond) return

// writable 是原生的 response 对象的 writeable 属性,检查是否是可写流
if (!ctx.writable) return

const res = ctx.res
let body = ctx.body
const code = ctx.status

// ignore body
// 如果响应的 statusCode 是属于 body 为空的类型,例如 204,205,304,将 body 置为 null
if (statuses.empty[code]) {
// strip headers
ctx.body = null
return res.end()
}

// 如果是 HEAD 方法
// 需要注意,HEAD 请求不返回 body
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 值为空
if (null == body) {
// body 值为 context 中的 message 属性或 code
body = ctx.message || String(code)
// 修改头部的 type 与 length 属性
if (!res.headersSent) {
ctx.type = 'text'
ctx.length = Buffer.byteLength(body)
}
return res.end(body)
}

// responses
if (Buffer.isBuffer(body)) return res.end(body) // 对 body 为 buffer 类型的进行处理
if ('string' == typeof body) return res.end(body) // 对 body 为字符串类型的进行处理
if (body instanceof Stream) return body.pipe(res) // 对 body 为流形式的进行处理,流式响应使用 pipe,更好的利用缓存

// body: json
// 对 body 为 json 格式的数据进行处理,(转化为 json 字符串,添加 length 头部信息)
body = JSON.stringify(body)
if (!res.headersSent) {
ctx.length = Buffer.byteLength(body)
}
res.end(body)
}

可以发现,respond() 函数主要用于将中间件处理后的结果通过 res.end 返回给客户端

错误处理

Koa.js 中, 错误处理分为在 application.js 中的 onerror 处理函数与在 context.js 中的 onerror 处理函数

  • Contextonerror 函数是绑定在中间函数数组生成的 Promisecatch 中与 res 对象的 onFinished 函数的回调的(为了处理请求或响应中出现的 error 事件)
  • application.js 中的 onerror 函数是绑定在 Koa.js 实例对象上的, 它监听的是整个对象的 error 事件

这里,我们主要看 Context 中的的 onerror() 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
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
// 触发 koa 实例对象的 error 事件, application 上的 onerror 函数会执行
this.app.emit('error', err, this)

// nothing we can do here other
// than delegate to the app-level
// handler and log.
// 如果响应头部已经发送(或者 socket 不可写), 那么退出函数
if (headerSent) {
return
}
// 获取 http 原生 res 对象
const { res } = this

// first unset all headers
// 根据文档 res.getHeaderNames 函数是 7.7.0 版本后添加的, 这里为了兼容做了一个判断
// 如果出错那么之前中间件或者其他地方设置的 HTTP 头部就无效了, 应该清空设置
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
// 出错后响应类型为 text/plain
this.type = 'text'

// ENOENT support
// 对 ENOENT 错误进行处理, ENOENT 的错误 message 是文件或者路径不存在, 所以状态码应该是 404
if ('ENOENT' == err.code) err.status = 404

// default to 500
// 默认设置状态码为 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
// 设置响应 body 长度
this.length = Buffer.byteLength(msg)
// 返回 message
this.res.end(msg)
}

在之前的 callback() 中的源码我们可以看到,App 会默认注册一个错误处理函数

1
if (!this.listenerCount('error')) this.on('error', this.onerror)

但是我们每次 HTTP 请求的错误其实是交给 ctx.onerror 处理的

1
2
3
4
const onerror = err => ctx.onerror(err)
const handleResponse = () => respond(ctx)
onFinished(res, onerror)
return fnMiddleware(ctx).then(handleResponse).catch(onerror)

onFinished 是确保一个流在关闭、完成和报错时都会执行相应的回调函数

ctx.onerror 这个函数在参数为空或者 null 的时候,直接返回,不会做任何操作,就是上面源码当中的

1
2
3
4
// don't do anything if there is no error.
// this allows you to pass `this.onerror`
// to node-style callbacks.
if (null == err) return

否则,则会触发 App 产生一个错误事件,如下

1
2
3
// delegate
// 触发 koa 实例对象的 error 事件, application 上的 onerror 函数会执行
this.app.emit('error', err, this)

然后如果判断该请求处理依旧没有结束,也就是 App 注册的 onerror 事件没有结束该请求,则会尝试向客户端产生一个 500 的错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let headerSent = false
if (this.headerSent || !this.writable) {
headerSent = err.headerSent = true
}

// nothing we can do here other
// than delegate to the app-level
// handler and log.
if (headerSent) {
return
}

// ...

// default to 500
if ('number' != typeof err.status || !statuses[err.status]) err.status = 500

// ...

this.res.end(msg)

总结起来,我们可以在不同的抽象层次上处理错误,比如我们可以在顶层的中间件将所有中间件产生的错误捕获并处理了,这样错误就不会被上层捕获,我们也可以覆盖 ctx.onerror 的方式来捕获所有的异常,而且可以不触发 Apperror 事件,最后我们也可以直接监听 Apperror 事件的方式来处理错误

参考

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×