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

东郭弘方
2023-12-01

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

expresskoa同为一批人进行开发,与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回调的两个参数requestresponse进行的一次封装,简化一些常用的操作。
例如我们对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'
})
复制代码

简化了一些针对requestresponse的操作,将这些封装在了request.jsresponse.js文件中。
但同时这会带来一个使用上的困扰,这样封装以后其实获取或者设置header变得层级更深,需要通过context找到requestresponse,然后才能进行操作。
所以,koa使用了node-delegates来进一步简化这些步骤,将request.getresponse.set通通代理到context上。
也就是说,代理后的操作是这样子的:

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之类的操作。

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

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

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

拿一个完整的流程来解释

创建服务

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

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获取内部中间件执行结束的通知
 类似资料: