逐步解析 koa2 核心实现原理及代码实践

1,524 阅读16分钟

引言

作为一个前端,工作大部分时间都是在和页面打交道,但如果有一天你有一个很好的产品 idea,前后端都需要,就尴尬了,一般这个时候有 3 条路走:

  • 放弃,心里想着这产品哪怕做出来可能也没人用,还要消耗自己大部分时间和人力,白费力气。
  • 找一个认识的后端,说动他(她)并一起实践这个伟大的想法。
  • 前后端都自己做,不过要花大量时间去涉猎服务端相关知识,并且一个人做两个人的活儿,累死累活最终实现这个伟大的想法。

我的建议还是首先尝试一下第三条路(如果你觉得这个想法再不快点实现就亏了一个亿,你也可以首先选第二条路),先别想着放弃,一方面哪怕最后不成功没人用,但是从技术角度讲,这不是刚好扩展了自己的知识面吗?而且万一咱的产品🔥了呢?

想搞服务端,前端最好的选择无非就是 NodeJS 了,语法和我们写前端时的 JavaScript 是一样的,只需要了解相关的 node 模块即可上手编写服务端代码。而我们最先应该了解的就是 http 模块,它能让我们快速启动一个服务。但是因为历史包袱以及考虑到广泛的适用性,原生的模块多少是需要二次封装一下才能很好地服务于我们开发者。

Koa2 原理实现

Koa2 就是这么个封装了原始 http 模块,拥有更好的心智模型的框架,接下来我会从原理实现入手,讲清楚如何实现一个基本的 Koa2 ,再到后面介绍如何基于 Koa2 开始我们的服务端开发!一个快乐的 SQL Boy!🎉

koa2 与 http 简单对比

我们先使用 node 原生模块 http 起一个本地服务:

// server-http.js
const http = require('http')

const server = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/html' })
  res.end('<h1>Hello World</h1>')
})

server.listen(3000, () => {
  console.log('server is running on http://localhost:3000')
})

执行一下 node server-http.js ,在浏览器打开 http://localhost:3000 ,即可看到效果。

现在使用对 http 进行了封装的 Koa2 起一个类似的本地服务,当然了,这需要我们先安装一下这个包,控制台执行下 npm i koa ,然后在新建的文件中写入以下代码:

// server-koa.js
const Koa = require('koa')

const app = new Koa()

app.use(async ctx => {
  ctx.response.res.writeHead(200, { 'Content-Type': 'text/html' })
  ctx.body = '<h1>Hello World</h1>'
})

app.listen(3001, () => {
  console.log('server is running on http://localhost:3001')
})

执行一下 node server-koa.js ,在浏览器打开 http://localhost:3001 ,即可看到一样的效果。

我们对比下两者书写上的区别,可以很直观地发现:

  • koa 中导出的是一个类 class ,没有暴露创建服务的方法 http.createServer ,可以猜想到是在 app.listen 中执行了此方法。
  • app.use 的第一个参数是一个回调函数,与 http.createServer 类似,不过,回调参数 reqres 被封装到了一个参数 ctx 中。
  • 在原生 http 中将内容响应到客户端是使用 res.end ,在 koa2 中却是 ctx.body ,咋回事呢?

现在我们对两者先有直观上的区别感受,接下来大家逐步跟着我的思路阅读,会对这种区别产生的原因理解地透透的,大家记住一句话,本质上 koa2 就是对 http 的扩展,使其有更多的常用功能和更好用而已,我们学习的就是 koa2 的封装思路。

koa2 源码文件的结构

koa2 的源码文件就只有 4 个,很简洁明了。

|- lib
    |-- application.js
    |-- context.js
    |-- request.js
    |-- response.js

各个文件的名字很直接展示了其主要作用:

  • application.js 为导出 Koa 类的主入口文件,内部实现将其它模块串联起来的逻辑。
  • context.js 主要作用是代理 request.jsresponse.js 中的方法。
  • request.js 封装了 http 的请求,扩展了一些功能。
  • response.js 封装了 http 的响应,扩展了一些功能。

根据 koa2 的简单 demo 和上面的文件结构,我们就可以顺着思路一步步实现基本的 koa2 了,这里的实现不是完全照搬 koa2 的源码,而是利用其实现思路,写一个相对来说更容易理解的版本。

封装 http 服务

新建 application.js ,直接创建 Application 类,并实现对 node 中 http 的封装:

const http = require('http')

class Application {
  constructor() {
    this.fn = null
  }

  use(fn) {
    this.fn = fn
  }

  handleRequestCallback() {
    return (req, res) => {
      this.fn(req, res)
    }
  }

  listen(...args) {
    const server = http.createServer(this.handleRequestCallback())
    server.listen(...args)
  }
}

module.exports = Application

上面的代码就是对 http 的一个简单封装,利用 app.use 注册回调函数,通过 app.listen 监听 server 并传入参数。

值得注意的是 handleRequestCallback 返回的是一个箭头函数,这里是为了让 this 指向的是实例,毕竟 fn 就是挂在实例上的。如果这里不这样写,而是直接执行 this.fn(req, res) ,其中 this 将会指向我们创建的 server ,显然是不正确的。

此时在同目录新建一个 test.js ,写入以下代码:

const Koa = require('./koa')

const app = new Koa()

app.use((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/html' })
  res.end('<h1>Hello World</h1>')
})

app.listen(8888, () => {
  console.log('server is running on http://localhost:8888')
})

控制台使用 node 命令执行该文件,随后打开 http://localhost:8888 ,会发现 Hello World 被正确地返回。

但是我们使用 koa2 时,app.use 中回调函数的第一个参数是 ctx ,而不是现在的 node 原生的 requestresponse 对象,所以我们要将其封装成如下这样:

app.use(ctx => {
  ctx.response.res.writeHead(200, { 'Content-Type': 'text/html' })
  ctx.body = '<h1>Hello World</h1>'
})

如果你将代码替换成上面这种写法,数据是不会被正确返回的,我们后续会再继续完善!

接下来就需要我们编写 context.jsrequest.jsresponse.js 中的内容了,并将它们在 application.js 中串联起来。

创建上下文 context

使用 koa 时,在上下文中能访问到封装的请求对象 ctx.request ,原生模块的请求对象 ctx.req ,封装的响应对象 ctx.response ,原生模块的响应对象 ctx.res ,以及原生的请求、响应对象都被附加到了封装的请求、响应对象中,即 ctx.request.reqctx.response.res

先在各个文件写入最简单的代码,先实现这些对象存储结构。

context.js

const context = {}

module.exports = context

request.js

const request = {}

module.exports = request

response.js

const response = {}

module.exports = response

然后在 application.js 中导入这些模块:

const context = require('./context')
const request = require('./request')
const response = require('./response')

接下来我们思考以下几个问题:

  • 如何做到避免用户直接操作我们的 contextrequestresponse 对象?
  • 每新建一个应用,即 new Koa ,如何保持各个应用中对于这 3 个模块的独立性?
  • 每次通过 app.use 注册回调函数时,这些回调函数内的上下文都是独立的?

koa 通过在构造函数内分别创建三个对应的对象,并将原型分别指向 contextrequestresponse ,解决前两个问题:

class Application {
  constructor() {
    this.context = Object.create(context)
    this.request = Object.create(request)
    this.response = Object.create(response)

    this.fn = null
  }
}

因为 http 请求无状态,使用 app.use 注册回调函数时,其上下文也要保持独立,所以需要再进行一次类似上面的原型操作:

class Application {
  createContext(req, res) {
    const context = Object.create(this.context)
    const request = Object.create(this.request)
    const response = Object.create(this.response)

    return context
  }

  handleRequestCallback() {
    return (req, res) => {
      const ctx = this.createContext(req, res)
      this.fn(ctx)
    }
  }

再然后就是将各个原生对象挂在我们自己封装的对象,以下是这一步骤 application.js 完整代码:

const http = require('http')
const context = require('./context')
const request = require('./request')
const response = require('./response')

class Application {
  constructor() {
    this.context = Object.create(context)
    this.request = Object.create(request)
    this.response = Object.create(response)

    this.fn = null
  }

  use(fn) {
    this.fn = fn
  }

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

    context.req = req // 原生的
    context.request = request // 自己封装的
    context.request.req = req // 原生的

    context.res = res // 原生的
    context.response = response // 自己封装的
    context.response.res = res // 原生的

    return context
  }

  handleRequestCallback() {
    return (req, res) => {
      const ctx = this.createContext(req, res)
      this.fn(ctx)
    }
  }

  listen(...args) {
    const server = http.createServer(this.handleRequestCallback())
    server.listen(...args)
  }
}

module.exports = Application

自定义 request 和 response 扩展

我们上面说到过,ctx 上挂载的 requestresponse 是自定义的请求和响应对象的扩展,接下来举个例子说明这两个自定义对象的目的。

第一种情况,代理原生请求或响应对象本来就有的能力:

const request = {
  get url() {
    return this.req.url
  },
  set url(val) {
    this.req.url = val
  },
}

module.exports = request

通过 getter/setter 函数对原生的 url 进行代理,可方便进行赋值和取值操作。

我们在 test.js 中通过以下写法来获取 url ,这样写:

app.use(ctx => {
  ctx.response.res.writeHead(200, { 'Content-Type': 'text/html' })
  console.log(ctx.request.url)
  ctx.body = '<h1>Hello World</h1>'
})

控制台使用 node 命令重新执行该文件,随后打开 http://localhost:8888/a/b ,在控制台会发现打印了 /a/b ,说明我们自定义的 request 扩展对象代理 url 成功。

现在考虑下,为什么在执行 this.req.url 时能够正确访问原生 req 对象?因为在 context 中我们将原生 req 对象挂载到了自定义扩展的 request 对象上了,即以下这行代码:

context.request.req = req

于是访问 this.req.url 时,实际上这里的 this 就是 ctx.request

第二种情况,新增原生对象上没有的能力:

const response = {
  _body: undefined,
  get body() {
    return this._body
  },
  set body(val) {
    this._body = val
    this.res.statusCode = 200
  }
}

module.exports = response

到目前为止,我们的服务并没有返回任何东西,如果你开着浏览器访问,会一直转圈圈,因为我们实际上并没有返回任何东西,原生的 http 服务通过 res.end('xxx') 来返回数据并关闭连接。而现在我们是通过ctx.body 来模拟这个操作。

有了 response 模块这部分代码,当我们执行 ctx.body = '<h1>Hello World</h1>' 时,相当于给 _body 赋值存了起来。

诶,不对,给 ctx.body 赋值,关我 response 什么事?还记得一开始我们说过,context.js 主要作用是代理 request.jsresponse.js 中的方法。

比如 ctx.body 其实就相当于 ctx.response.body ,回到 context 模块,以下代码就是实现这种代理的方式:

const context = {}

function defineGetter(target, key) {
  context.__defineGetter__(key, function() {
    return this[target][key]
  })
}

function defineSetter(target, key) {
  context.__defineSetter__(key, function(value) {
    return this[target][key] = value
  })
}

defineGetter('request', 'url')
defineSetter('request', 'url')

defineGetter('response', 'body')
defineSetter('response', 'body')

module.exports = context

实际上 __defineGetter____defineSetter 一直都是非标准方法,但是其兼容性却特别好。koa2 中使用的 delegates 模块也一直是用的这两个非标准方法。或许大家可以考虑下用 Object.defineProperty 来实现。

回到 application.js ,在 handleRequestCallback 函数中添加以下代码:

class Application {
  handleRequestCallback() {
    return (req, res) => {
      const ctx = this.createContext(req, res)
      res.statusCode = 404
      this.fn(ctx)

      const content = ctx.body
      if (content) {
        res.end(content)
      } else {
        res.end('Not Found')
      }
    }
  }
}

module.exports = Application

重启服务,再看看浏览器,即可看到正确返回了 Hello World

中间件机制 - 洋葱模型

目前在我们的测试文件 test.js 中只使用了一次 app.use ,注册了一个回调函数,然而在实际应用时,我们必然会使用多次的。

现在我们使用 koa2 做个测试,新建一个 test-koa.js 写入以下代码:

// test-koa.js
const Koa = require('koa')

const app = new Koa()

app.use((ctx, next) => {
  console.log(1)
  next()
  console.log(2)
})

app.use((ctx, next) => {
  console.log(3)
  next()
  console.log(4)
})

app.use((ctx, next) => {
  console.log(5)
  next()
  console.log(6)
})

app.listen(7777, () => {
  console.log('server is running on http://localhost:7777')
})

控制台执行 node test-koa.js 后,打开 http://localhost:7777 ,再回到控制台会看到打印的顺序如下:

1 3 5 6 4 2

koa2 中注册函数的第一个参数 ctx 大家很熟悉了,第二个参数 next 代表下一个要被执行的注册函数。其实上面的代码可以这样来理解:

app.use((ctx, next) => {
  console.log(1)
  (ctx, next) => {
    console.log(3)
    (ctx, next) => {
      console.log(5)
      // empty
      console.log(6)
    }()
    console.log(4)
  }()
  console.log(2)
})

每一次执行 next() 函数就相当于将下一个注册函数执行,也就是说执行下一个中间件函数,这就是所谓的洋葱模型,用一张图来解释:

上面的代码中全是同步逻辑,如果我们的中间件函数里有异步逻辑,也就是我们使用 koa2 时经常用到的 async/await ,我们考虑下面一段代码会在浏览器显示什么,以及控制台的打印顺序:

const Koa = require('koa')

const app = new Koa()

const sleep = (time) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log('sleeping')
      resolve()
    }, time)
  })
}

app.use((ctx, next) => {
  console.log(1)
  ctx.body = '1'
  next()
  console.log(2)
  ctx.body = '2'
})

app.use(async (ctx, next) => {
  console.log(3)
  ctx.body = '3'
  await sleep(2000)
  next()
  console.log(4)
  ctx.body = '4'
})

app.use((ctx, next) => {
  console.log(5)
  ctx.body = '5'
  next()
  console.log(6)
  ctx.body = '6'
})

app.listen(7777, () => {
  console.log('server is running on http://localhost:7777')
})

结果是浏览器访问 http://localhost:7777 显示的是 2 ,控制台打印的是顺序是:

1 3 2 (延迟 2000 ms 后) sleeping 5 6 4

koa2 中代表所有中间件函数执行完毕的标志是最外圈的“洋葱皮”被“刀”都切到了,也就是说最外层的代码都被执行完毕了,知道这个我们再来分析上面代码的输出结果。

在第一个(也就是最外层)中间件函数中,执行到的 next 中有异步逻辑 但我们没有等待 next 执行完毕,只是进入了第二个中间件函数中开始执行代码,所以直接打印出了 1 3 2 ,至此其实已经判定为中间件函数执行完毕了,开始响应逻辑,所以在浏览器页面上看到的是 2

但后续的代码还在执行,在第二个中间件函数中使用了 await ,于是 2000 ms 后继续走后续逻辑,所以在控制台的打印顺序如上。

所以咱们在 koa2 的使用中,一定要在 next() 前加上 await ,不然大概率结果会不如预期,大多数中间件都是有异步逻辑的。

实现中间件机制

koa2 中是如何实现上述的中间件机制的呢?通过上述代码和结果演示,能够看出每一个中间件函数的执行顺序是和 app.use 的使用顺序一致的,这不难想到也许 koa2 中使用了一个数组来保存每一个中间件函数,并依次执行。

回到我们的 application.js 模块,接下来就是重头戏了,也是 koa2 中最核心最精华的部分,我将演示如何一步步实现中间件机制。

初始化一个 middlewares 数组

在构造函数 constructor 中把我们原来定义的 this.fn 删掉,定义一个数组 this.middlewares = [] ,其目的是保存所有 app.use 注册的中间件函数。

class Application {
  constructor() {
    this.context = Object.create(context)
    this.request = Object.create(request)
    this.response = Object.create(response)

    this.middlewares = []
  }
}

添加中间件函数

use 函数内部,删除 this.fn = fn ,而是将 fn 添加至 this.middlewares 数组中。

class Application {
  use(fn) {
    this.middlewares.push(fn)
  }
}

新建组合函数 compose

之前只注册一个函数时,我们直接执行 this.fn(ctx) ,但现在我们需要一个新的组合函数 compose 来执行所有注册的有异步逻辑的中间件函数,并且返回一个 Promise

class Application {
  compose(ctx) {
    // 执行所有中间件函数并返回一个 Promise
  }

  handleRequestCallback() {
    return (req, res) => {
      const ctx = this.createContext(req, res)
      res.statusCode = 404

      this.compose(ctx)
        .then(() => {
          const content = ctx.body
          if (content) {
            res.end(content)
          } else {
            res.end('Not Found')
          }
        })
    }
  }
}

⚠️ 在 koa2 源码中使用了 koa-compose 模块,该模块导出的 compose 为一个函数,该函数执行后返回一个新的内联函数,我们上述的实现相当于直接把这个内联函数抽出来执行了,相信大家看源码的时候会理解的。

实现 compose 逻辑

我们在 compose 内部的代码主要实现以下逻辑:

  • 没有注册中间件函数时,直接返回 Promise.resolve()
  • 从第一个中间件函数执行开始,遇到执行 next 就意味着要执行第二个中间件函数,相当于递归调用。
  • 所有返回结果都要包装成 Promise
  • 一个中间件函数不能被调用两次,否则抛错。

于是我们根据上面思路,就可以写出以下代码:

class Application {
  compose(ctx) {
    let index = -1

    const dispatch = (i) => {
      // 一个中间件函数不能被调用两次,否则抛错
      if (i <= index) {
        return Promise.reject('[Error] next() called multiples times')
      }

      index = i

      // 没有注册中间件函数时,直接返回 Promise.resolve()
      if (this.middlewares.length === i) {
        return Promise.resolve()
      }

      const fn = this.middlewares[i]
      try {
        // 遇到执行 next 就意味着要执行第二个中间件函数,相当于递归调用
        // 这里 () => dispatch(i + 1) 就是 next
        return Promise.resolve(fn(ctx, () => dispatch(i + 1)))
      } catch (err) {
        return Promise.reject(err)
      }
    }

    // 从第一个中间件函数执行开始
    return dispatch(0)
  }
}

大家思考一下,为什么我在一个中间件函数里执行两次或以上 next ,就会导致报错?原因在于我们执行无论多少次,i + 1i 都是同一个值,但是第一次执行 dispatch(i + 1) 时,index 已经赋值为 i + 1 了,这样第二次执行时,i + 1 <= index 就会成立,反之就代表只执行了一次。

就是那么简单的几行代码就实现了 koa2 的核心功能,不过我们只是阅读别人的代码时候觉得简单,真的要自己想出来估计也是需要不少脑细胞的。

接下来你可以拿刚才写好的 test-koa 来测试这段逻辑了,别忘了引入的是我们自己的 koa 哦~

错误捕获与处理

一个优秀的框架或 SDK,良好的错误或异常捕获是很有必要的,不至于因为代码执行错误导致后续逻辑中断,这可以给开发者更多的信息,也能有更多选择,比如降级逻辑。

koa2 中某个中间件函数发生错误时,可以通过 app.on('error', () => {}) 拿到错误信息,这需要 node 的原生模块 events 默认导出的 EventEmitter 支持,如果有用过 Vue 的同学,对这个一定很熟悉了。不熟悉的同学可以搜索下发布订阅模式~

回到 application.js ,我们引入并继承这个类,构造函数中要加上 super()

const EventEmitter = require('events')

class Application extends EventEmitter {
  constructor() {
    super()

    this.context = Object.create(context)
    this.request = Object.create(request)
    this.response = Object.create(response)

    this.middlewares = []
  }
}

在执行 compose 方法的地方,我们已经写了 then ,把 catch 也补上:

class Application {
  handleRequestCallback() {
    return (req, res) => {
      // ...
      this.on('error', this.onerror)
      const onerror = err => ctx.onerror(err)

      this.compose(ctx)
        .then(() => {
          // ...
        })
        .catch(onerror)
    }
  }

  onerror(err) {
    const msg = err.stack || err.toString()
    console.error('[Inner Error]', `\n${msg.replace(/^/gm, '  ')}\n`)
  }
}

在上面代码我们总共做了两件事:

  • 添加 this.on('error', this.onerror) 并创建了一个方法 onerror ,该方法用于处理捕获到的错误,并处理后在控制台输出(我这里为了演示简便,只是很简单处理)。
  • 添加 const onerror = err => ctx.onerror(err) ,并在 catch 中将 err 传递给 onerror

上面的两个 onerror 作用是完全不一样的,第一个是用于 koa2 内部错误打印,如果用了社区的 koa-logger 还能用于收集错误日志。第二个是用于返回给用户的原始错误。

但是给用户的错误是通过 ctx.onerror 去做的,所以我们要来到 context 模块,为其添加一个 onerror 方法:

const context = {
  onerror(err) {
    if (null == err) return
    this.app.emit('error', err, this)
  },
}

同样地,我为了演示,在该方法只是将错误抛出去,可以看到是通过 this.app.emit 进行抛出的,那么问题来了,context 上面怎么会有一个 app 属性,并且它上面还有继承了 EventEmitter 才有的方法 emit ,不难想到,其实我们只需要在 application 模块中 createContext 时,将 this 赋值给 ctx.app 就可以了:

class Application {
  createContext(req, res) {
    const context = Object.create(this.context)
    const request = Object.create(this.request)
    const response = Object.create(this.response)

    context.app = this
    // ...

    return context
  } 
}

接下来新建一个 test-koa-error.js 文件,输入以下故意有错误的代码,执行后看看控制台是不是正确打印了错误:

const Koa = require('./koa')

const app = new Koa()

app.use((ctx) => {
  ctx.response.res.writeHead(200, { 'Content-Type': 'text/html' })
  str += '<h1>Hello World</h1>' // 变量未声明,应该报错
  ctx.body = str
});

app.on('error', (err, ctx) => {
  console.error('[Outer Error]', err)
});

app.listen(8888, () => {
  console.log('server is running on http://localhost:8888')
})

启动服务后,打开 http://localhost:8888 再回到控制台,出现[Outer Error][Inner Error] ,说明我们的错误捕获成功了!

2

当然,这两个提示只是我为了区分才故意这样写的哦~

参考代码

以上就是 koa2 框架实现的基本原理,因为是文章的展现形式,可能做不到每行代码都解释清清楚楚,也不可能每一行代码都演示如何去写,我已经尽量将整段代码切割成一块一块,如果大家还是有不理解的地方,可以参考下本文的所有演示代码:

koa2 实现思路源码

结语

写这篇文章的目的一方面在于强制驱动自己去学习了解 koa2 的源码,以便于在使用 koa2 进行开发时遇到问题能快速定位问题,也能学习到其封装思路,之后自己写代码时能借鉴其思想;另一方面希望能帮助和我有一样想法的同学建立起源码阅读思路。总而言之,我认为源码的阅读不是为了读而读,而是阅读理解它之后或许能让我们在实践时有指导思路。

另外,如果对大家有所帮助,给我的 blog 赏个 star🌟 哦~