接上次挖的坑,对koa2.x
相关的源码进行分析 第一篇。
不得不说,koa
是一个很轻量、很优雅的http框架,尤为是在2.x之后移除了co
的引入,使其代码变得更为清晰。 javascript
express
和koa
同为一批人进行开发,与express
相比,koa
显得很是的迷你。
由于express
是一个大而全的http
框架,内置了相似router
之类的中间件进行处理。
而在koa
中,则将相似功能的中间件所有摘了出来,早期koa
里边是内置了koa-compose
的,而如今也是将其分了出来。 koa
只保留一个简单的中间件的整合,http
请求的处理,做为一个功能性的中间件框架来存在,自身仅有少许的逻辑。 koa-compose
则是做为整合中间件最为关键的一个工具、洋葱模型的具体实现,因此要将二者放在一块儿来看。html
. ├── application.js ├── request.js ├── response.js └── context.js
关于koa
整个框架的实现,也只是简单的拆分为了四个文件。 前端
就象在上一篇笔记中模拟的那样,建立了一个对象用来注册中间件,监听http
服务,这个就是application.js
在作的事情。
而框架的意义呢,就是在框架内,咱们要按照框架的规矩来作事情,一样的,框架也会提供给咱们一些更易用的方式来让咱们完成需求。
针对http.createServer
回调的两个参数request
和response
进行的一次封装,简化一些经常使用的操做。
例如咱们对Header
的一些操做,在原生http
模块中可能要这样写:java
// 获取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
中能够这样处理:node
// 获取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
上。
也就是说,代理后的操做是这样子的:git
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
之类的操做。github
由application
引入context
,context
中又整合了request
和response
的功能,四个文件的做用已经很清晰了:express
file | desc |
---|---|
applicaiton | 中间件的管理、http.createServer 的回调处理,生成Context 做为本次请求的参数,并调用中间件 |
request | 针对http.createServer -> request 功能上的封装 |
response | 针对http.createServer -> response 功能上的封装 |
context | 整合request 与response 的部分功能,并提供一些额外的功能 |
而在代码结构上,只有application
对外的koa
是采用的Class
的方式,其余三个文件均是抛出一个普通的Object
。json
首先,咱们须要建立一个http
服务,在koa2.x
中建立服务与koa1.x
稍微有些区别,要求使用实例化的方式来进行建立:segmentfault
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) }
因此咱们就来看看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) } } }
因此明确了这两点之后,上边的代码就会变得很清晰:
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
建议在最外层的中间件都本身作异常处理,由于默认的错误提示有点儿太难看了(纯文本),本身处理跳转到异常处理页面会好一些,以及避免一些接口由于默认的异常信息致使解析失败。
在原生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可是,koa
依然是一个很棒的框架,很适合阅读源码来进行学习,这些都是一些小细节,无伤大雅。
总结一下koa
与koa-compose
的做用:
koa
注册中间件、注册http
服务、生成请求上下文调用中间件、处理中间件对上下文对象的操做、返回数据结束请求koa-compose
将数组中的中间件集合转换为串行调用,并提供钥匙(next
)用来跳转下一个中间件,以及监听next
获取内部中间件执行结束的通知我司如今大量招人咯,前端、Node方向都有HC 公司名:Blued,坐标帝都朝阳双井 主要技术栈是React,也会有机会玩ReactNative和Electron Node方向8.x版本+koa 新项目会以TS为主 有兴趣的小伙伴能够私聊我,或者: email: jiashunming@blued.com wechat: github_jiasm