在 中间件 和 常用中间件的实现 章节当中,我们简单的介绍了一下中间件和洋葱模型的概念,然后我们手动实现了一个最基本的 Koa.js
框架,最后又看了一些比较常用的中间件,所以在本章当中,我们就来深入的了解一下 Koa.js
的源码,看看它与我们手动实现的版本有什么区别
使用
在分析源码之前,我们先来看看如何使用 Koa.js
来创建一个 server
的大体流程
1 | const Koa = require('koa') |
下面是一张来源于网络关于 Koa.js
架构的示意图,我们可以对比着进行了解
入口文件
一般我们都是从入口文件开始找起,如果你看了 Koa.js
的源码,会发现 Koa.js
源码其实很简单,一共就四个文件
1 | // https://github.com/koajs/koa/tree/master/lib |
其实这四个文件分别对应着 Koa.js
当中的四个对象
1 | ── lib |
对比使用可以发现,其实总的来说就三个步骤
- 实例化一个对象(
new Koa()
) - 注册一个或多个中间件(
app.use(async ctx => { ... })
) - 调用
listen()
方法启动一个服务器
下面我们就按照步骤一个一个来了解
构造函数
通过查看 package.json
的 main
字段中可以发现 application.js
是入口文件,下面是入口文件的部分源码,只罗列了一些比较核心的内容,详细见 lib/application.js
1 | // 依赖模块,我们主要看下面这几个 |
相关内容都已经写在注释当中了,这里我们主要要提及两个属性
subdomainOffset
subdomainOffset
属性会改变获取 subdomain
时返回数组的值,比如 test.page.example.com
域名,如果设置 subdomainOffset
为 2
,那么返回的数组值为 ['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.js
中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.js
的protocol
属性会去取X-Forwarded-proto
头部的值Koa.js
中protocol
属性会先使用tlsSocket.encrypted
属性来判断是否是HTTPS
协议,如果是则直接返回HTTPS
关于此部分内容想了解更多的可以参考下面两个链接
- 科普文:如何伪造和获取用户真实 IP ?
egg.js
提供的 前置代理模式
接下来我们再来看注册中间件使用的 use()
方法
注册中间件
在实例化一个对象以后,接下来使用 use()
方法来注册一个中间件,其实就是简单的 push
到自身的 mideware
这个数组中
1 | use(fn) { |
这其中有一个 convert()
的方法,简单来说就是将 koa v1
当中使用的 generator
函数转换成 koa v2
中的 async
函数,更准确的说是将 generator
函数转换成使用 co
包装成的 Promise
对象,然后执行对应的代码,这里就不详细展开了,详细可以参考这篇博文 koa-convert 源码分析
启动服务
最后调用 listen()
方法来启动服务
1 | listen(...args) { |
使用了 Node.js
原生的 http.createServer()
来创建服务器,并把 this.callback()
作为参数传递进去,下面我们就来看一下这个核心的 callback()
函数
1 | callback() { |
这里主要涉及到三个方法
compose(this.middleware)
洋葱模型实现的核心
1 | /** |
之前我们已经手动的实现了一个简化版本的了,这里就不详细展开了,可以参考之前的洋葱模型的实现章节,这里主要介绍其中两行代码
1 | if (i === middleware.length) fn = next |
在我们调用 fnMiddleware
是可以传入两个参数的,第二个可选参数表示最终的回调函数,比如
1 | fnMiddleware(ctx, () => { |
当 i === middleware.length
成立时,实际上所有传入的 middleware
已经执行完,这个时候我们的 fn = next
表示 fn
被赋值给了这个传入的最终回调,接下来判断如果没有传入最终回调,那么整个中间件执行流程就到此结束
createContext(req, res)
1 | createContext(req, res) { |
根据 req
和 res
封装中间件所需要的 ctx
,简单来说就是将变量挂到 Context
上面,然后最后返回,但是这里需要注意区分
request.req
、response.req
指向的是HTTP
模块原生的 IncomingMessage 对象- 而
request.response
、response.request
指向的都是Koa.js
封装后的对象- 即
ctx.req
和ctx.res
是原生的req
和res
对象 - 而
ctx.request
和ctx.response
则是Koa.js
自己封装的request
和response
对象
- 即
这里有一个小问题,这里明明只是将原生的 req
和 res
赋值给相应的属性,但是 ctx
上不是暴露出来很多属性吗?它们在哪里?其实这些东西我们可以通过 request.js
和 response.js
的源码来了解,通过源码可以发现,经过原型链的形式,我们 ctx.request
所能访问属性和方法绝大部分都在其对应的 request
这个简单的对象上面
1 | // https://github.com/koajs/koa/blob/master/lib/request.js |
所以当你操作 ctx.request.xx
的时候,其实访问的都是 resquest
这个对象上的属性的赋值器(setter
)和取值器(getter
)
handleRequest(ctx, fnMiddleware)
1 | // fnMiddleware 是经过 compose 包装后的函数 |
这个函数简单来说只是负责执行中间件所有的函数, 并在中间件函数执行结束的时候调用 respond(ctx)
,本质上,在执行 fnMiddleware(ctx)
的时候其实就会调用 compose()
方法当中的那个 dispatch(0)
,然后开始不断递归,直到中间件流程执行结束,触发 handleResponse
,也就是我们这里的 respond(ctx)
对请求的响应处理 respond
对于 respond()
函数, 其核心就是根据不同类型的数据对 HTTP
的响应头部与响应体 body
做对应的处理
1 | function respond(ctx) { |
可以发现,respond()
函数主要用于将中间件处理后的结果通过 res.end
返回给客户端
错误处理
在 Koa.js
中, 错误处理分为在 application.js
中的 onerror
处理函数与在 context.js
中的 onerror
处理函数
Context
的onerror
函数是绑定在中间函数数组生成的Promise
的catch
中与res
对象的onFinished
函数的回调的(为了处理请求或响应中出现的error
事件)application.js
中的onerror
函数是绑定在Koa.js
实例对象上的, 它监听的是整个对象的error
事件
这里,我们主要看 Context
中的的 onerror()
函数
1 | onerror(err) { |
在之前的 callback()
中的源码我们可以看到,App
会默认注册一个错误处理函数
1 | if (!this.listenerCount('error')) this.on('error', this.onerror) |
但是我们每次 HTTP
请求的错误其实是交给 ctx.onerror
处理的
1 | const onerror = err => ctx.onerror(err) |
onFinished
是确保一个流在关闭、完成和报错时都会执行相应的回调函数
ctx.onerror
这个函数在参数为空或者 null
的时候,直接返回,不会做任何操作,就是上面源码当中的
1 | // don't do anything if there is no error. |
否则,则会触发 App
产生一个错误事件,如下
1 | // delegate |
然后如果判断该请求处理依旧没有结束,也就是 App
注册的 onerror
事件没有结束该请求,则会尝试向客户端产生一个 500
的错误
1 | let headerSent = false |
总结起来,我们可以在不同的抽象层次上处理错误,比如我们可以在顶层的中间件将所有中间件产生的错误捕获并处理了,这样错误就不会被上层捕获,我们也可以覆盖 ctx.onerror
的方式来捕获所有的异常,而且可以不触发 App
的 error
事件,最后我们也可以直接监听 App
的 error
事件的方式来处理错误