Koa源码解析

了解Koa

Koa

Koa是一款基于中间件理念的Nodejs框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。

中间件

中间件是koa中非常重要的概念,koa和express都根据这个概念设计而成。

用koa官方的一张图片很生动地展示中间件的概念:

Koa与Express的区别

koa和express都是基于中间件概念设计的,但是两者的风格却截然不同。koa采用更现代的ES6语法async function来实现中间件,而express而使用nodejs传统的回调函数的方式来实现中间件,因此两者在设计思想上有一些差异。

此外express原生集成了一些常用的中间件,例如路由处理、静态资源服务器等,而koa只是实现了一个中间件的框架,并不内置任何中间件,但是可以通过社区来集成这些中间件。

| Feature           | Koa | Express | Connect |
|------------------:|-----|---------|---------|
| Middleware Kernel | ✓   | ✓       | ✓       |
| Routing           |     | ✓       |         |
| Templating        |     | ✓       |         |
| Sending Files     |     | ✓       |         |
| JSONP             |     | ✓       |         |

Koa源码

概述

Koa的源码相对简单,非常简洁,总体而言由四个部分组成:

  1. Application

  2. Context

  3. Request

  4. Response

RequestResponse很容易理解,它们分别是针对HTTP requestresponse对象的封装,而context是koa上下文对象的原型(即app.ctx的原型),Application是一个继承Emitter的类。

Context

context文件中定义了app.ctx的原型。

context对象原生定义了一些实用的工具方法,比较常用的有:

  • assert: 添加断言,如果断言失败则返回服务器异常的HTTP响应,可以指定错误码和消息。 示例ctx.assert(1 === 2, 500, 'error')

  • throw: 让服务器返回异常响应(默认status=500),可以指定status和msg。 示例:ctx.throw(403, 'error')

  • onerror: 错误处理函数,当中间件函数发生错误时执行

  • cookie: 处理cookie

ctx.assert和ctx.throw

context.assert()context.throw()调用后可以返回指定HTTP响应,这是因为当在中间件函数内部调用context.throw() 或者context.assert() 断言为false时,内部会抛出了一个特殊的异常,而这一异常会被context.onerror捕获,处理后返回异常响应

app.use(async (ctx, next) => {
  try{
    //  ctx.throw(403, '111')
	  ctx.assert(1 === 2, 404, "error msg");
  } catch (err) {
      console.log(err);
  }
});

// NotFoundError: error msg

当中间件函数执行时,ctx.onerror会捕获异常:

// 假设fnMiddleware函数内部会依次调用中间件,并返回一个promise
// 那么当中间件内部抛出异常时,除非用户手动捕获异常,否则ctx.onerror将会触发执行。
fnMiddleware(ctx).catch(ctx.onerror)

ctx.conerror

ctx.onerror当中间件函数出现异常且没有被手动捕获异常时执行,以便HTTP服务器能返回正确的响应来提示客户端服务器异常。

onerror函数需要处理nodejs原生错误对象以及ctx.throw等api抛出的特殊错误对象。

onerror(err) {
    // 如果没有err,就直接退出。
    if (null == err) return;
		
		// 是否继承于Error,即判断是否是一个错误
    // 某些情况下err instanceof Error无法识别node内部的错误
    const isNativeError =
      Object.prototype.toString.call(err) === '[object Error]' ||
      err instanceof Error;
    if (!isNativeError) err = new Error(util.format('non-error thrown: %j', err));

		// ...

    let statusCode = err.status || err.statusCode;

    // ENOENT support
    if ('ENOENT' === err.code) statusCode = 404;

    // 默认500 服务器错误
    if ('number' !== typeof statusCode || !statuses[statusCode]) statusCode = 500;

    // 返回异常响应,如果err有指定状态码和消息则直接使用,否则默认状态码为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);
  },

方法别名

koa为了方便,context提供了一些方法别名,例如:ctx.header 就是ctx.request.header的映射,ctx.headerctx.request.header 指向的是同一个对象。

koa是通过delegates这个第三方库来实现别名的

/**
 * Response delegation.
 */
delegate(proto, 'response')
  .method('attachment')
  .method('redirect')
  .method('remove')
  .method('vary')
  .method('has')
  .method('set')
  .method('append')
  .method('flushHeaders')
  .access('status')
  .access('message')
  .access('body')
  .access('length')
  .access('type')
  .access('lastModified')
  .access('etag')
  .getter('headerSent')
  .getter('writable');

/**
 * Request delegation.
 */
// ... Request也类似

通过Object.getOwnPropertyDescriptor 其实可以发现,这只不过是利用对象描述符来实现的映射而已:

// access
Object.getOwnPropertyDescriptor(proto, 'body')
/*
{
	configurable:true
	enumerable:true
	get:ƒ (){\n    return this[target][name];\n  }
	set:ƒ (val){\n    return this[target][name] = val;\n  }
}
*/

// getter
Object.getOwnPropertyDescriptor(proto, 'writable')
/*
{
	configurable:true
	enumerable:true
	get:ƒ (){\n    return this[target][name];\n  }
	set:ƒ undefined
}
*/

// method
Object.getOwnPropertyDescriptor(proto, 'set')
/*
{
	configurable: true
	enumerable: true
	value:  ƒ (){\n    return this[target][name].apply(this[target], arguments);\n  }
	writable: true
}
*/

从开始到现在最关键的两个属性ctx.response和ctx.request还没有被定义,这是因为这两个属性其实是在Application.prototype.listen()执行的时候才被挂载到ctx中的。

Request和Response

request对象和response对象

Request和Response文件分别会导出的是一个对象,而这两个对象分别是ctx.requestctx.response对象的原型对象,并且这两个对象会被挂载到context对象上,用户通过ctx.request和ctx.response访问。

const response = require('./response')
const compose = require('koa-compose')
const context = require('./context')
const request = require('./request')

module.exports = class Application extends Emitter {
	// ... 省略了无关代码
	constructor (options) {
    // ... 

this.middleware = []
		// 基于request对象和response为原型创建对象
    this.context = Object.create(context)
		// 基于request对象和response为原型创建对象
    this.request = Object.create(request)
    this.response = Object.create(response)
		
		this.request = Object.create(request)
    this.response = Object.create(response)
	}

  // 创建context
	createContext (req, res) {
		// ...
		const context = Object.create(this.context)
		const request = context.request = Object.create(this.request)
		const response = context.response = Object.create(this.response)
	}
}

Application

Application上挂载了以下原型方法,每个原型方法各司其职,其中最重要的几个方法有:

  • linsten: http.createServer(this.callback()).linsten()的语法糖

  • use: 将中间件函数添加到middleware队列中

  • callback:返回一个供http.createServer使用的回调函数。

  • createContext:初始化app.ctx

  • handleRequest:处理HTTP请求

  • respond: 处理HTTP响应,内部最终会调用res.end(body)来返回响应。

初始化

Application类实例化时会初始化一些成员变量,其中值得关注的有:

  • this.context

  • this.request

  • this.response

  • this.middleware

const response = require('./response')
const compose = require('koa-compose')
const context = require('./context')
const request = require('./request')

module.exports = class Application extends Emitter {
	// ... 省略了无关代码
	constructor (options) {
    // ... 

		this.middleware = []
		// 基于request对象和response为原型创建对象
    this.context = Object.create(context)
		// 基于request对象和response为原型创建对象
    this.request = Object.create(request)
    this.response = Object.create(response)
		
		this.request = Object.create(request)
    this.response = Object.create(response)
	}
}

use

app.use函数其实就是将中间件函数添加到一个队列中。

use (fn) {
    if (typeof fn !== 'function') {
      throw new TypeError('middleware must be a function!')
    }
    debug('use %s', fn._name || fn.name || '-')
    this.middleware.push(fn)
    return this
  }l

listen

app.listen()其实是http.createServer(this.callback()).linsten()的语法糖

listen (...args) {
    debug('listen')
    const server = http.createServer(this.callback())
    return server.listen(...args)
  }

这里可以看到其实koa本质上也是借助nodejs原生HTTP模块实现的服务器,只是在上层进行了处理,以便用户可以更方便地使用。

createContext

app.createContext内部初始化context并返回,也是在这时初始化的ctx.requestctx.response

 // 创建context
	createContext (req, res) {
		// ...
		const context = Object.create(this.context)
		const request = context.request = Object.create(this.request)
		const response = context.response = Object.create(this.response)
	}

callback

app.callback返回一个供http.createServer()消费的回调函数。在内部其实做了两件事:

  1. 利用koa-compose库将middleware中间件队列转变为一个能够依次调用中间件函数的函数。

  2. 返回handleRequest函数

当用户请求HTTP时就会触发handleRequest函数的执行,而在handleRequest函数内部又会执行app.handleRequest()

callback () {
    const fn = this.compose(this.middleware)

    if (!this.listenerCount('error')) this.on('error', this.onerror)

    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res)
      if (!this.ctxStorage) {
        return this.handleRequest(ctx, fn)
      }
      return this.ctxStorage.run(ctx, async () => {
        return await this.handleRequest(ctx, fn)
      })
    }

    return handleRequest
  }

compose

默认情况下,app.compose是调用koa-compose库的compose函数来将middleware队列转变为执行函数的。

假设有这三个中间件函数

app.use(async fn1(ctx, next){
	await next()
})

app.use(async fn2(ctx, next){
	await next()
})

app.use(async fn3(ctx, next){
	ctx.body = 1
})

那么app.middleware中应当是: [fn1, fn2, fn3]

我们最终需要转变为一个能让这几个中间件函数按顺序依次执行,且返回后能够执行上一个中间件函数next()后面代码的函数。

很明显这不能用遍历的方式,如果遍历middleware来执行的话虽然能达到按顺序执行的效果,但是没办法做到中间件函数执行完毕后返回到上一个函数继续执行next()后面代码的要求。

要实现这个目的,我们要用递归来实现,并且需要将下一个中间件函数作为参数next传入给当前执行的中间件函数。

如果能够达到这种形式: fn1(ctx, fn2.bind(null, ctx, fn3.bind(null))) ,那么js就会依次进入每个中间件函数执行,但是我们不能直接这些写,可以封装成一个函数,在函数中判断需要执行哪个中间件函数,并在内部判断递归结束条件。

我们封装一个dispatch函数,并接受一个参数i表示要执行第几个中间件函数。dispatch会递归执行,dispatch(0)内部会执行middleware[0],并且将ctx和dispatch(1)作为参数传入,当第一个中间件函数执行next()函数时就会调用dispatch(1),依次类推,直到执行完所有的中间件函数。

另外我们需要设置好结束条件,那就是i≥middleware.length时,此时不应该再继续递归。

完整代码如下:

function compose (middleware) {
  if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!')
  for (const fn of middleware) {
    if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!')
  }

  /**
   * @param {Object} context
   * @return {Promise}
   * @api public
   */

  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)
      }
    }
  }
}

handleRequest

app.handleRequest()函数会调用之前compose生成的*fnMiddleware这会依次执行中间件函数,在中间件函数全部执行完毕后,通过app.respond和ctx.onerror来处理fulfilled和reject两种场景,即fnMiddleware* 函数执行成功和触发异常这两种情况。

fnMiddleware(*ctx*).then(handleResponse).catch(onerror)

代码:

handleRequest (ctx, fnMiddleware) {
    const res = ctx.res
    res.statusCode = 404
    const onerror = (err) => ctx.onerror(err)
    const handleResponse = () => respond(ctx)
    onFinished(res, onerror)
    return fnMiddleware(ctx).then(handleResponse).catch(onerror)
  }

respond

app.response是一次HTTP请求处理的最后的处理函数,它最终会通过res.end(body) 来返回HTTP响应。

完整的执行顺序

  1. 当调用app.use(fn)其实就是将这个中间件函数fn添加到middleware这个队列中。

  2. 当调用app.linsten(3000)其实就是调用了http.createServer(this.callback()).linsten(3000) ,因此在这个过程中又会调用app.callback()来创建一个供HttpServer消费的回调函数

  3. app.callback()函数内部依次执行

    1. 利用koa-compose库将middleware中间件队列转变为一个依次执行所有中间件的fn函数。

    2. 返回一个名为handleRequest 的函数作为http.createServer 的回调函数。

  4. 当用户访问服务器时,HttpServer执行handleRequest 函数来处理请求和响应。

    1. handleRequest函数将原生reqres对象传递给app.createContext来创建conext对象。

    2. 将context上下文对象和fn函数传递给app.handleRequest 函数执行。

    3. fnMiddleware(*ctx*).then(() => respond(*ctx*)).catch((*err*) => *ctx*.onerror(*err*))

      1. fulfilled: 执行app.respond() 函数,处理响应对象,最终会调用node原生语法res.end(body)返回响应。

      2. reject: 执行ctx.onerror函数处理异常。

最后更新于