koa源码阅读[1]-koa与koa-compose

接上次挖的坑,对koa2.x相关的源码进行分析 第一篇
不得不说,koa是一个很轻量、很优雅的http框架,尤为是在2.x之后移除了co的引入,使其代码变得更为清晰。 javascript

expresskoa同为一批人进行开发,与express相比,koa显得很是的迷你。
由于express是一个大而全的http框架,内置了相似router之类的中间件进行处理。
而在koa中,则将相似功能的中间件所有摘了出来,早期koa里边是内置了koa-compose的,而如今也是将其分了出来。
koa只保留一个简单的中间件的整合,http请求的处理,做为一个功能性的中间件框架来存在,自身仅有少许的逻辑。
koa-compose则是做为整合中间件最为关键的一个工具、洋葱模型的具体实现,因此要将二者放在一块儿来看。html

koa基本结构

.
├── application.js
├── request.js
├── response.js
└── context.js

关于koa整个框架的实现,也只是简单的拆分为了四个文件。 前端

就象在上一篇笔记中模拟的那样,建立了一个对象用来注册中间件,监听http服务,这个就是application.js在作的事情。
而框架的意义呢,就是在框架内,咱们要按照框架的规矩来作事情,一样的,框架也会提供给咱们一些更易用的方式来让咱们完成需求。
针对http.createServer回调的两个参数requestresponse进行的一次封装,简化一些经常使用的操做。
例如咱们对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'
})

简化了一些针对requestresponse的操做,将这些封装在了request.jsresponse.js文件中。
但同时这会带来一个使用上的困扰,这样封装之后其实获取或者设置header变得层级更深,须要经过context找到requestresponse,而后才能进行操做。
因此,koa使用了node-delegates来进一步简化这些步骤,将request.getresponse.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.jsresponse.js的行为。
同时context.js也会提供一些其余的工具函数,例如Cookie之类的操做。github

application引入contextcontext中又整合了requestresponse的功能,四个文件的做用已经很清晰了:express

file desc
applicaiton 中间件的管理、http.createServer的回调处理,生成Context做为本次请求的参数,并调用中间件
request 针对http.createServer -> request功能上的封装
response 针对http.createServer -> response功能上的封装
context 整合requestresponse的部分功能,并提供一些额外的功能

而在代码结构上,只有application对外的koa是采用的Class的方式,其余三个文件均是抛出一个普通的Objectjson

拿一个完整的流程来解释

建立服务

首先,咱们须要建立一个http服务,在koa2.x中建立服务与koa1.x稍微有些区别,要求使用实例化的方式来进行建立:segmentfault

const app = new Koa()

而在实例化的过程当中,其实koa只作了有限的事情,建立了几个实例属性。
将引入的contextrequest以及response经过Object.create拷贝的方式放到实例中。

this.middleware = [] // 最关键的一个实例属性

// 用于在收到请求后建立上下文使用
this.context = Object.create(context)
this.request = Object.create(request)
this.response = Object.create(response)

在实例化完成后,咱们就要进行注册中间件来实现咱们的业务逻辑了,上边也提到了,koa仅用做一个中间件的整合以及请求的监听。
因此不会像express那样提供router.getrouter.post之类的操做,仅仅存在一个比较接近http.createServeruse()
接下来的步骤就是注册中间件并监听一个端口号启动服务:

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的源码时,能够看到,暴露给外部的方法,经常使用的基本上就是uselisten
一个用来加载中间件,另外一个用来监听端口并启动服务。

而这两个函数实际上并无过多的逻辑,在use中仅仅是判断了传入的参数是否为一个function,以及在2.x版本针对Generator函数的一些特殊处理,将其转换为了Promise形式的函数,并将其push到构造函数中建立的middleware数组中。
这个是从1.x过渡到2.x的一个工具,在3.x版本将直接移除Generator的支持。
其实在koa-convert内部也是引用了cokoa-compose来进行转化,因此也就再也不赘述。

而在listen中作的事情就更简单了,只是简单的调用http.createServer来建立服务,并监听对应的端口之类的操做。
有一个细节在于,createServer中传入的是koa实例的另外一个方法调用后的返回值callback,这个方法才是真正的回调处理,listen只是http模块的一个快捷方式。
这个是为了一些用socket.iohttps或者一些其余的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)
    }
  }
}

因此明确了这两点之后,上边的代码就会变得很清晰:

  1. next用来进入下一个中间件
  2. 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会拿以前提到的contextrequestresponse来建立本次请求所使用的上下文。
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建立的过程当中,将一大批的requestresponse的属性、方法代理到了自身,有兴趣的能够本身翻看源码(看着有点晕):koa.js | context.js
这个delegate的实现也算是比较简单,经过取出原始的属性,而后存一个引用,在自身的属性被触发时调用对应的引用,相似一个民间版的Proxy吧,期待后续可以使用Proxy代替它。

而后咱们会将生成好的context做为参数传入koa-compose生成的洋葱中去。
由于不管何种状况,洋葱确定会返回结果的(出错与否),因此咱们还须要在最后有一个finished的处理,作一些相似将ctx.body转换为数据进行输出之类的操做。

koa使用了大量的getset访问器来实现功能,例如最经常使用的ctx.body = 'XXX',它是来自responseset body
这应该是requestresponse中逻辑最复杂的一个方法了。
里边要处理不少东西,例如在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.bodyctx.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
}

这里就有一个koaexpress对比的劣势了,由于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(),它仅仅是添加了一个statusCodeLocation而已:

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,否则颇有可能后续的statusbody赋值极可能会致使一些诡异的问题。

app.use(ctx => {
  ctx.redirect('https://baidu.com')

  // 建议直接return

  // 后续的代码还在执行
  ctx.body = 'hello world'
  ctx.status = 200 // statusCode的改变致使redirect失效 
})

小记

koa是一个很好玩的框架,在阅读源码的过程当中,其实也发现了一些小问题:

  1. 多人合做维护一份代码,确实可以看出各人都有不一样的编码风格,例如typeof val !== 'string''number' == typeof code,很显然的两种风格。2333
  2. delegate的调用方式在属性特别多的时候并非很好看,一大长串的链式调用,若是换成循环会更好看一下

可是,koa依然是一个很棒的框架,很适合阅读源码来进行学习,这些都是一些小细节,无伤大雅。

总结一下koakoa-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

相关文章
相关标签/搜索