接上次挖的坑,对koa2.x
相关的源码进行分析 第一篇。
不得不说,koa
是一个很轻量、很优雅的http框架,尤其是在2.x以后移除了co
的引入,使其代码变得更为清晰。
express
和koa
同为一批人进行开发,与express
相比,koa
显得非常的迷你。
因为express
是一个大而全的http
框架,内置了类似router
之类的中间件进行处理。
而在koa
中,则将类似功能的中间件全部摘了出来,早期koa
里边是内置了koa-compose
的,而现在也是将其分了出来。koa
只保留一个简单的中间件的整合,http
请求的处理,作为一个功能性的中间件框架来存在,自身仅有少量的逻辑。koa-compose
则是作为整合中间件最为关键的一个工具、洋葱模型的具体实现,所以要将两者放在一起来看。
koa基本结构
.
├── application.js
├── request.js
├── response.js
└── context.js
关于koa
整个框架的实现,也只是简单的拆分为了四个文件。
就象在上一篇笔记中模拟的那样,创建了一个对象用来注册中间件,监听http
服务,这个就是application.js
在做的事情。
而框架的意义呢,就是在框架内,我们要按照框架的规矩来做事情,同样的,框架也会提供给我们一些更易用的方式来让我们完成需求。
针对http.createServer
回调的两个参数request
和response
进行的一次封装,简化一些常用的操作。
例如我们对Header
的一些操作,在原生http
模块中可能要这样写:
// 获取Content-Type request.getHeader('Content-Type') // 设置Content-Type response.setHeader('Content-Type', 'application/json') response.setHeader('Content-Length', '18') // 或者,忽略前边的statusCode,设置多个Header response.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '18' })
而在koa
中可以这样处理:
// 获取Content-Type context.request.get('Content-Type') // 设置Content-Type context.response.set({ 'Content-Type': 'application/json', 'Content-Length': '18' })
简化了一些针对request
与response
的操作,将这些封装在了request.js
和response.js
文件中。
但同时这会带来一个使用上的困扰,这样封装以后其实获取或者设置header
变得层级更深,需要通过context
找到request
、response
,然后才能进行操作。
所以,koa
使用了node-delegates来进一步简化这些步骤,将request.get
、response.set
通通代理到context
上。
也就是说,代理后的操作是这样子的:
context.get('Content-Type') // 设置Content-Type context.set({ 'Content-Type': 'application/json', 'Content-Length': '18' })
这样就变得很清晰了,获取Header
,设置Header
,再也不会担心写成request.setHeader
了,一气呵成,通过context.js
来整合request.js
与response.js
的行为。
同时context.js
也会提供一些其他的工具函数,例如Cookie
之类的操作。
由application
引入context
,context
中又整合了request
和response
的功能,四个文件的作用已经很清晰了:
file | desc |
---|---|
applicaiton | 中间件的管理、http.createServer 的回调处理,生成Context 作为本次请求的参数,并调用中间件 |
request | 针对http.createServer -> request 功能上的封装 |
response | 针对http.createServer -> response 功能上的封装 |
context | 整合request 与response 的部分功能,并提供一些额外的功能 |
而在代码结构上,只有application
对外的koa
是采用的Class
的方式,其他三个文件均是抛出一个普通的Object
。
拿一个完整的流程来解释
创建服务
首先,我们需要创建一个http
服务,在koa2.x
中创建服务与koa1.x
稍微有些区别,要求使用实例化的方式来进行创建:
const app = new Koa()
而在实例化的过程中,其实koa
只做了有限的事情,创建了几个实例属性。
将引入的context
、request
以及response
通过Object.create
拷贝的方式放到实例中。
this.middleware = [] // 最关键的一个实例属性 // 用于在收到请求后创建上下文使用 this.context = Object.create(context) this.request = Object.create(request) this.response = Object.create(response)
在实例化完成后,我们就要进行注册中间件来实现我们的业务逻辑了,上边也提到了,koa
仅用作一个中间件的整合以及请求的监听。
所以不会像express
那样提供router.get
、router.post
之类的操作,仅仅存在一个比较接近http.createServer
的use()
。
接下来的步骤就是注册中间件并监听一个端口号启动服务:
const port = 8000 app.use(async (ctx, next) => { console.time('request') await next() console.timeEnd('request') }) app.use(async (ctx, next) => { await next() ctx.body = ctx.body.toUpperCase() }) app.use(ctx => { ctx.body = 'Hello World' }) app.use(ctx => { console.log('never output') }) app.listen(port, () => console.log(`Server run as http://127.0.0.1:${port}`))
在翻看application.js
的源码时,可以看到,暴露给外部的方法,常用的基本上就是use
和listen
。
一个用来加载中间件,另一个用来监听端口并启动服务。
而这两个函数实际上并没有过多的逻辑,在use
中仅仅是判断了传入的参数是否为一个function
,以及在2.x版本针对Generator
函数的一些特殊处理,将其转换为了Promise
形式的函数,并将其push
到构造函数中创建的middleware
数组中。
这个是从1.x
过渡到2.x
的一个工具,在3.x
版本将直接移除Generator
的支持。
其实在koa-convert
内部也是引用了co
和koa-compose
来进行转化,所以也就不再赘述。
而在listen
中做的事情就更简单了,只是简单的调用http.createServer
来创建服务,并监听对应的端口之类的操作。
有一个细节在于,createServer
中传入的是koa
实例的另一个方法调用后的返回值callback
,这个方法才是真正的回调处理,listen
只是http
模块的一个快捷方式。
这个是为了一些用socket.io
、https
或者一些其他的http
模块来进行使用的。
也就意味着,只要是可以提供与http
模块一致的行为,koa
都可以很方便的接入。
listen(...args) { debug('listen') const server = http.createServer(this.callback()) return server.listen(...args) }
使用koa-compose合并中间件
所以我们就来看看callback
的实现:
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 }
在函数内部的第一步,就是要处理中间件,将一个数组中的中间件转换为我们想要的洋葱模型格式的。
这里就用到了比较核心的koa-compose
其实它的功能上与co
类似,只不过把co
处理Generator
函数那部分逻辑全部去掉了,本身co
的代码也就是一两百行,所以精简后的koa-compose
代码仅有48行。
我们知道,async
函数实际上剥开它的语法糖以后是长这个样子的:
async function func () { return 123 } // ==> function func () { return Promise.resolve(123) } // or function func () { return new Promise(resolve => resolve(123)) }
所以拿上述use
的代码举例,实际上koa-compose
拿到的是这样的参数:
[ function (ctx, next) { return new Promise(resolve => { console.time('request') next().then(() => { console.timeEnd('request') resolve() }) }) }, function (ctx, next) { return new Promise(resolve => { next().then(() => { ctx.body = ctx.body.toUpperCase() resolve() }) }) }, function (ctx, next) { return new Promise(resolve => { ctx.body = 'Hello World' resolve() }) }, function (ctx, next) { return new Promise(resolve => { console.log('never output') resolve() }) } ]
就像在第四个函数中输出表示的那样,第四个中间件不会被执行,因为第三个中间件并没有调用next
,所以实现类似这样的一个洋葱模型是很有意思的一件事情。
首先抛开不变的ctx
不谈,洋葱模型的实现核心在于next
的处理。
因为next
是你进入下一层中间件的钥匙,只有手动触发以后才会进入下一层中间件。
然后我们还需要保证next
要在中间件执行完毕后进行resolve
,返回到上一层中间件:
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) } } }
所以明确了这两点以后,上边的代码就会变得很清晰:
- next用来进入下一个中间件
- next在当前中间件执行完成后会触发回调通知上一个中间件,而完成的前提是内部的中间件已经执行完成(
resolved
)
可以看到在调用koa-compose
以后实际上会返回一个自执行函数。
在执行函数的开头部分,判断当前中间件的下标来防止在一个中间件中多次调用next
。
因为如果多次调用next
,就会导致下一个中间件的多次执行,这样就破坏了洋葱模型。
其次就是compose
实际上提供了一个在洋葱模型全部执行完毕后的回调,一个可选的参数,实际上作用与调用compose
后边的then
处理没有太大区别。
以及上边提到的,next
是进入下一个中间件的钥匙,可以在这一个柯里化函数的应用上看出来:
Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
将自身绑定了index
参数后传入本次中间件,作为调用函数的第二个参数,也就是next
,效果就像调用了dispatch(1)
,这样就是一个洋葱模型的实现。
而fn
的调用如果是一个async function
,那么外层的Promise.resolve
会等到内部的async
执行resolve
以后才会触发resolve
,例如这样:
Promise.resolve(new Promise(resolve => setTimeout(resolve, 500))).then(console.log) // 500ms以后才会触发 console.log
P.S. 一个从koa1.x
切换到koa2.x
的暗坑,co
会对数组进行特殊处理,使用Promise.all
进行包装,但是koa2.x
没有这样的操作。
所以如果在中间件中要针对一个数组进行异步操作,一定要手动添加Promise.all
,或者说等草案中的await*
。
// koa1.x yield [Promise.resolve(1), Promise.resolve(2)] // [1, 2] // koa2.x await [Promise.resolve(1), Promise.resolve(2)] // [<Promise>, <Promise>] // ==> await Promise.all([Promise.resolve(1), Promise.resolve(2)]) // [1, 2] await* [Promise.resolve(1), Promise.resolve(2)] // [1, 2]
接收请求,处理返回值
经过上边的代码,一个koa
服务已经算是运行起来了,接下来就是访问看效果了。
在接收到一个请求后,koa
会拿之前提到的context
与request
、response
来创建本次请求所使用的上下文。
在koa1.x
中,上下文是绑定在this
上的,而在koa2.x
是作为第一个参数传入进来的。
个人猜测可能是因为Generator
不能使用箭头函数,而async
函数可以使用箭头函数导致的吧:) 纯属个人YY
总之,我们通过上边提到的三个模块创建了一个请求所需的上下文,基本上是一通儿赋值,代码就不贴了,没有太多逻辑,就是有一个小细节比较有意思:
request.response = response
response.request = request
让两者之间产生了一个引用关系,既可以通过request
获取到response
,也可以通过response
获取到request
。
而且这是一个递归的引用,类似这样的操作:
let obj = {} obj.obj = obj obj.obj.obj.obj === obj // true
同时如上文提到的,在context
创建的过程中,将一大批的request
和response
的属性、方法代理到了自身,有兴趣的可以自己翻看源码(看着有点晕):koa.js | context.js
这个delegate的实现也算是比较简单,通过取出原始的属性,然后存一个引用,在自身的属性被触发时调用对应的引用,类似一个民间版的Proxy
吧,期待后续能够使用Proxy
代替它。
然后我们会将生成好的context
作为参数传入koa-compose
生成的洋葱中去。
因为无论何种情况,洋葱肯定会返回结果的(出错与否),所以我们还需要在最后有一个finished
的处理,做一些类似将ctx.body
转换为数据进行输出之类的操作。
koa
使用了大量的get
、set
访问器来实现功能,例如最常用的ctx.body = 'XXX'
,它是来自response
的set body
。
这应该是request
、response
中逻辑最复杂的一个方法了。
里边要处理很多东西,例如在body
内容为空时帮助你修改请求的status code
为204,并移除无用的headers
。
以及如果没有手动指定status code
,会默认指定为200
。
甚至还会根据当前传入的参数来判断content-type
应该是html
还是普通的text
:
// string if ('string' == typeof val) { if (setType) this.type = /^s*</.test(val) ? 'html' : 'text' this.length = Buffer.byteLength(val) return }
以及还包含针对流(Stream
)的特殊处理,例如如果要用koa
实现静态资源下载的功能,也是可以直接调用ctx.body
进行赋值的,所有的东西都已经在response.js
中帮你处理好了:
// stream if ('function' == typeof val.pipe) { onFinish(this.res, destroy.bind(null, val)) ensureErrorHandler(val, err => this.ctx.onerror(err)) // overwriting if (null != original && original != val) this.remove('Content-Length') if (setType) this.type = 'bin' return } // 可以理解为是这样的代码 let stream = fs.createReadStream('package.json') ctx.body = stream // set body中的处理 onFinish(res, () => { destory(stream) }) stream.pipe(res) // 使response接收流是在洋葱模型完全执行完以后再进行的
onFinish用来监听流是否结束、destory用来关闭流
其余的访问器基本上就是一些常见操作的封装,例如针对querystring
的封装。
在使用原生http
模块的情况下,处理URL中的参数,是需要自己引入额外的包进行处理的,最常见的是querystring
。koa
也是在内部引入的该模块。
所以对外抛出的query
大致是这个样子的:
get query() { let query = parse(this.req).query return qs.parse(query) } // use let { id, name } = ctx.query // 因为 get query也被代理到了context上,所以可以直接引用
parse为parseurl库,用来从request中提出query参数
亦或者针对cookies
的封装,也是内置了最流行的cookies
。
在第一次触发get cookies
时才去实例化Cookie
对象,将这些繁琐的操作挡在用户看不到的地方:
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 }
所以在koa
中使用Cookie
就像这样就可以了:
this.cookies.get('uid') this.cookies.set('name', 'Niko') // 如果不想用cookies模块,完全可以自己赋值为自己想用的cookie this.cookies = CustomeCookie this.cookies.mget(['uid', 'name'])
这是因为在get cookies
里边有判断,如果没有一个可用的Cookie实例,才会默认去实例化。
洋葱模型执行完成后的一些操作
koa
的一个请求流程是这样的,先执行洋葱里边的所有中间件,在执行完成以后,还会有一个回调函数。
该回调用来根据中间件执行过程中所做的事情来决定返回给客户端什么数据。
拿到ctx.body
、ctx.status
这些参数进行处理。
包括前边提到的流(Stream
)的处理都在这里:
if (body instanceof Stream) return body.pipe(res) // 等到这里结束后才会调用我们上边`set body`中对应的`onFinish`的处理
同时上边还有一个特殊的处理,如果为false则不做任何处理,直接返回:
if (!ctx.writable) return
其实这个也是response
提供的一个访问器,这里边用来判断当前请求是否已经调用过end
给客户端返回了数据,如果已经触发了response.end()
以后,则response.finished
会被置为true
,也就是说,本次请求已经结束了,同时访问器中还处理了一个bug
,请求已经返回结果了,但是依然没有关闭套接字:
get writable() { // can't write any more after response finished if (this.res.finished) return false const socket = this.res.socket // There are already pending outgoing res, but still writable // https://github.com/nodejs/node/blob/v4.4.7/lib/_http_server.js#L486 if (!socket) return true return socket.writable }
这里就有一个koa
与express
对比的劣势了,因为koa
采用的是一个洋葱模型,对于返回值,如果是使用ctx.body = 'XXX'
来进行赋值,这会导致最终调用response.end
时在洋葱全部执行完成后再进行的,也就是上边所描述的回调中,而express
就是在中间件中就可以自由控制何时返回数据:
// express.js router.get('/', function (req, res) { res.send('hello world') // 在发送数据后做一些其他处理 appendLog() }) // koa.js app.use(ctx => { ctx.body = 'hello world' // 然而依然发生在发送数据之前 appendLog() })
不过好在还是可以通过直接调用原生的response
对象来进行发送数据的,当我们手动调用了response.end
以后(response.finished === true
),就意味着最终的回调会直接跳过,不做任何处理。
app.use(ctx => { ctx.res.end('hello world') // 在发送数据后做一些其他处理 appendLog() })
异常处理
koa的整个请求,实际上还是一个Promise
,所以在洋葱模型后边的监听不仅仅有resolve
,对reject
也同样是有处理的。
期间任何一环出bug都会导致后续的中间件以及前边等待回调的中间件终止,直接跳转到最近的一个异常处理模块。
所以,如果有类似接口耗时统计的中间件,一定要记得在try-catch
中执行next
的操作:
app.use(async (ctx, next) => { try { await next() } catch (e) { console.error(e) ctx.body = 'error' // 因为内部的中间件并没有catch 捕获异常,所以抛出到了这里 } }) app.use(async (ctx, next) => { let startTime = new Date() try { await next() } finally { let endTime = new Date() // 抛出异常,但是不影响这里的正常输出 } }) app.use(ctx => Promise.reject(new Error('test')))
P.S. 如果异常被捕获,则会继续执行后续的response
:
app.use(async (ctx, next) => { try { throw new Error('test') } catch (e) { await next() } }) app.use(ctx => { ctx.body = 'hello' }) // curl 127.0.0.1 // > hello
如果自己的中间件没有捕获异常,就会走到默认的异常处理模块中。
在默认的异常模块中,基本上是针对statusCode的一些处理,以及一些默认的错误显示:
const code = statuses[err.status] const msg = err.expose ? err.message : code this.status = err.status this.length = Buffer.byteLength(msg) this.res.end(msg)
statuses是一个第三方模块,包括各种http code的信息: statuses
建议在最外层的中间件都自己做异常处理,因为默认的错误提示有点儿太难看了(纯文本),自己处理跳转到异常处理页面会好一些,以及避免一些接口因为默认的异常信息导致解析失败。
redirect的注意事项
在原生http
模块中进行302
的操作(俗称重定向),需要这么做:
response.writeHead(302, { 'Location': 'redirect.html' }) response.end() // or response.statusCode = 302 response.setHeader('Location', 'redirect.html') response.end()
而在koa
中也有redirect
的封装,可以通过直接调用redirect
函数来完成重定向,但是需要注意的是,调用完redirect
之后并没有直接触发response.end()
,它仅仅是添加了一个statusCode
及Location
而已:
redirect(url, alt) { // location if ('back' == url) url = this.ctx.get('Referrer') || alt || '/' this.set('Location', url) // status if (!statuses.redirect[this.status]) this.status = 302 // html if (this.ctx.accepts('html')) { url = escape(url) this.type = 'text/html charset=utf-8' this.body = `Redirecting to <a href="${url}">${url}</a>.` return } // text this.type = 'text/plain charset=utf-8' this.body = `Redirecting to ${url}.` }
后续的代码还会继续执行,所以建议在redirect
之后手动结束当前的请求,也就是直接return
,不然很有可能后续的status
、body
赋值很可能会导致一些诡异的问题。
app.use(ctx => { ctx.redirect('https://baidu.com') // 建议直接return // 后续的代码还在执行 ctx.body = 'hello world' ctx.status = 200 // statusCode的改变导致redirect失效 })
小记
koa
是一个很好玩的框架,在阅读源码的过程中,其实也发现了一些小问题:
- 多人合作维护一份代码,确实能够看出各人都有不同的编码风格,例如
typeof val !== 'string'
和'number' == typeof code
,很显然的两种风格。2333 - delegate的调用方式在属性特别多的时候并不是很好看,一大长串的链式调用,如果换成循环会更好看一下
但是,koa
依然是一个很棒的框架,很适合阅读源码来进行学习,这些都是一些小细节,无伤大雅。
总结一下koa
与koa-compose
的作用:
koa
注册中间件、注册http
服务、生成请求上下文调用中间件、处理中间件对上下文对象的操作、返回数据结束请求koa-compose
将数组中的中间件集合转换为串行调用,并提供钥匙(next
)用来跳转下一个中间件,以及监听next
获取内部中间件执行结束的通知