这是我参与8月更文挑战的第13天,活动详情查看: 8月更文挑战”juejin.cn/post/698796…
介绍
概述
与Express相比,Koa的体量极小。原因在于Express框架是一个大而全的框架,内置了很多的中间件在里面;而Koa框架则没有捆绑任何中间件,本身只有一个简单的中间件的整合逻辑和http请求的处理,所以作为一个功能性的中间件框架来存在。
内容
Koa的整个框架被分成了四部分内容,即为四个文件:
- application.js
- context.js
- request.js
- response.js
基础示例
首先根据下面的一个基础示例,开始进行分析。
const Koa = require('koa');
const app = new Koa();
app.use((ctx,next)=>{
ctx.body = 'hello worlds';
})
app.listen(3000)
综合以往的知识,可以分析得出以下几个情况或问题:
-
Koa框架最后被导出的是一个class类,在这个类里有use和listen方法
- use方法的参数是一个函数
- 根据http模块的使用规则,isten方法的参数只有一个端口号吗?
-
ctx的由来以及ctx.body的由来?
-
next是什么东西?
以下,就让我们带着问题开始解读Koa的源码。
Application
Application对象
Application.js中导出的Application对象是Koa的核心对象,也即为上文我们所说的Koa框架最后被导出的那个类。并且该对象通过use方法来注册中间件和通过listen方法创建http服务。
代码分析
为节约时间,已省略部分代码。
module.exports = class Application extends Emitter {
constructor (options) {
...
// 用来收集存放中间件内容
this.middleware = []
// 用于封装Request和Respnse对象以及创建上下文
this.context = Object.create(context)
this.request = Object.create(request)
this.response = Object.create(response)
...
}
// 用于创建http服务,监听端口并启动服务
listen (...args) {
...
const server = http.createServer(this.callback())
return server.listen(...args)
}
// 用于注册中间件
use (fn) {
this.middleware.push(fn)
return this
}
}
上下文封装
在Application类的构造函数里,可以看到对于上下文对象和request、response等实例属性的创建,通过Object.create浅拷贝的方式放到实例中。但这一部分的重点不在这一部分,留后再述
listen方法
listen方法的参数为...args,则表示参数不止一个。依照http模块的用法,参数可以为端口号和启动监听事件。如下:
server.lsiten(3000,()=>{
....
})
use方法
use方法的主要作用在于注册中间件,将中间件函数存储到middleware数组中,并且在注册中间件之后返回当前实例。
callback解释
callback的由来
依据正常的原生Node写法,this.callback应当是一个正常执行逻辑的回调函数。
const http = require('http');
const server = http.createServer((req,res)=>{
...
// 正常逻辑内容
})
this.callback正是代码中的正常逻辑部分。
而在Koa框架中,逻辑部分变为对中间件的处理逻辑。对于callback方法的描述在下文:
callback () {
// 处理数组中的中间件,通过compose()将其转换为洋葱模型的格式
const fn = compose(this.middleware)
// 定义请求处理回调,也就是原生Node里的逻辑部分
const handleRequest = (req, res) => {
// 创建上下文对象,留后再说
const ctx = this.createContext(req, res)
// 返回通过handleRequest方法处理过的中间件执行结果
return this.handleRequest(ctx, fn)
}
return handleRequest
}
compose
在Koa中,compose的功能是由koa-compose所支持的。
function compose(middleware){
...
return function(context,next){
let index = -1
return dispatch(0)
function dispatch(i){
// 一个中间件里不可多次调用next,如果多次调用next,
// 就会导致下一个中间件的多次执行,这样就破坏了洋葱模型。
if (i <= index) {
return Promise.reject(
new Error('next() called multiple times')
)
}
index = i
// fn就是当前的中间件
let fn = middleware[i]
if (i === middleware.length) {
fn = next
// 最后一个中间件如果也next时进入(一般最后一个中间件是直接
// 操作ctx.body,并不需要next了)
}
if (!fn) {
return Promise.resolve() // 没有中间件,直接返回成功
}
try {
/*
* 使用了bind函数返回新的函数,类似下面的代码
return Promise.resolve(fn(context, function next () {
return dispatch(i + 1)
}))
*/
// dispatch.bind(null, i + 1)就是中间件里的next参数,
// 调用它就可以进入下一个中间件
// fn如果返回的是Promise对象,Promise.resolve直接把这个对象返回
// fn如果返回的是普通对象,Promise.resovle把它Promise化
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
// 中间件是async的函数,报错不会走这里,
// 直接在fnMiddleware的catch中捕获
// 捕获中间件是普通函数时的报错,Promise化,
// 这样才能走到fnMiddleware的catch方法
return Promise.reject(err)
}
}
}
}
}
- 在dispatch函数的开头,需要判断当前中间件的下标来防止一个中间件多次调用next。因为如果多次调用next,会使得下一个中间件多次执行,破坏了洋葱模型的结构。
- 经过层层的return,实际上compose最终提供的是一个中间件全部执行后结果的回调。
- next是进入下一个中间件的钥匙。
Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
在上面的代码中,可以看到dispatch以递归的形式将自身绑定index参数后作为本次中间件的第二个参数传入,相当于调用执行了下一个中间件。以此类推,直到最后一个中间件执行完毕。
同时,在当前中间件执行完毕后,通过返回的Promise.resolve()又会将结果返回到上一个中间件,也就是达到洋葱模型先入后出的效果。
Req和Res封装
在源码的request.js和response.js中,都使用了大量的get和set代理属性。
代理形式
header get\set
get header () {
return this.req.headers
},
set header (val) {
this.req.headers = val
},
以此形式的还有很多:
resquest.js
| get/set | 方法 | get/set | 方法 | |
|---|---|---|---|---|
| get/set | headers | get/set | url | |
| get | origin | get | href | |
| get/set | method | get/set | path | |
| get/set | query | get/set | querystring | |
| get/set | search | get | host | |
| get | hostname | get | URL | |
| get | fresh | get | stale | |
| get | idempotent | get | socket | |
| get | charset | get | length | |
| get | protocol | get | secure | |
| get | ips | get/set | ip | |
| get | subdomains | get/set | accept |
response.js
| get/set | 方法 | get/set | 方法 | |
|---|---|---|---|---|
| get | socket | get | header | |
| get | headers | get/set | status | |
| get/set | message | get/set | body | |
| get/set | length | get | headerSent | |
| get/set | type | get/set | lastModified | |
| get/set | etag |
Context
context.js里重要的有三部分:
- onerror方法的错误处理
- 对cookies的get和set处理
- 通过delegate对request和response的代理
onerror的错误处理
因为中间件经过compose处理后的函数返回的依旧是Promise对象,所以在处理过程中的错误可以在catch中捕捉到。
onerror方法的触发在application.js中
handleRequest (ctx, fnMiddleware) {
const res = ctx.res
const onerror = err => ctx.onerror(err)
const handleResponse = () => respond(ctx)
onFinished(res, onerror)
return fnMiddleware(ctx).then(handleResponse).catch(onerror)
}
通过catch捕获到错误之后,执行onerror的错误处理操作
onerror的实现在context.js中
onerror (err) {
if (err == null) return
// 用于处理框架层面的错误
this.app.emit('error', err, this)
const { res } = this
if (err.code === 'ENOENT') statusCode = 404
if (typeof statusCode !== 'number' || !statuses[statusCode]) {
statusCode = 500
}
const code = statuses[statusCode]
const msg = err.expose ? err.message : code
this.status = err.status = statusCode
this.length = Buffer.byteLength(msg)
res.end(msg)
}
onerror处理的是中间件里的错误,对于Koa框架本身的错误则是采用对原生Emitter的继承,从而实现error监听。
const Emitter = require('events');
// 继承Emitter
class Application extends Emitter {
constructor() {
// 调用super
super();
...
}
}
Koa异常捕获方式
- 中间件捕获(Promise catch)
- 框架捕获(Emitter error)
// 捕获全局异常的中间件
app.use(async (ctx, next) => {
try {
await next()
} catch (error) {
return ctx.body = 'error'
}
})
// 事件监听
app.on('error', err => {
console.log('error happends: ', err.stack);
});
cookies处理
主要封存了环境的上下文信息
get cookies () {
if (!this[COOKIES]) {
// 创建cookie实例
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
}
delegate数据代理
根据文章《数据代理方法》,对代理方式已经有所了解。
delegate方法的原理就是defineGetter__和__defineSetter方式
delegate(proto, 'response')
.access('status')
.access('body')
delegate(proto, 'request')
.access('url')
.getter('header')
代理问题比较
- set和get方法中可以加入自己的逻辑处理
- delegate只能代理属性,不能额外添加操作
{
get length() {
const len = this.get('Content-Length');
if (len == '') return;
return ~~len;
},
}
delegate(proto, 'response')
.access('length')
context的创建
contxt对象和response、request等对象的联系建立是在application.js中的createContext方法中
createContext (req, res) {
// 对于每个请求,都会创建一个ctx
const context = Object.create(this.context)
// 创建request、response并挂载到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
}
使用Object.create创建一个全新的对象,通过原型链继承原来的属性。这样可以有效的防止污染原来的对象
总结
Koa的整个流程分为初始化阶段、请求阶段、响应阶段
- 初始化阶段
new初始化一个实例,use注册中间件到middleware数组,listen 合成中间件fnMiddleware,返回一个callback函数给http.createServer,开启服务器,等待http请求。
- 请求阶段
每次请求,createContext生成一个新的ctx,传给fnMiddleware,触发中间件的整个流程
- 响应阶段
整个中间件完成后,调用respond方法,对请求做最后的处理,返回响应给客户端。