likes
comments
collection
share

手把手带你用 128 行代码实现一个简易版 Koa 框架

作者站长头像
站长
· 阅读数 3

Koa 是由 Express 背后的团队设计的一个新的 Web 框架,不过比 Express 更精简,更现代。具体来说:

一、中间件机制

Koa 中通过异步函数,实现中间件控制权传递到“下游”后,依然能够流回“上游”,而非 Express 那样仅仅实现了控制权传递,无法追踪,实现了真正意义上的中间件级联调用。

二、上下文对象 context

中间件调用时接收的是上下文对象 context,而非像 Express 中间件那样接收原生 reqres 对象;另外,context 对象上提供了一些快捷属性和方法,帮助开发者完成相关操作,使用更便捷。

下面,我们先来学习一下 Koa 的常用使用。

基本使用

先来看一下 Koa 的“hello world”程序。

const Koa = require('koa')
const app = new Koa()

app.use(async ctx => {
  ctx.body = 'Hello World'
});

app.listen(3000)

跟 Express 相比,写起来更加简单了。与 Express 有 2 点不一样。

  1. Koa 应用是采用类实例方式创建的(new Koa()),而非 Express 的工厂函数方式创建的(express()
  2. 中间件依然是采用 app.use() 方法收集,不过中间件接收的第 1 个参数变成了上下文对象 ctx,而非原生 reqres 对象,原生 reqres 对象可以通过 ctx.reqctx.res 获取

Koa 禁止通过直接修改 ctx.res 的方式返回响应,包括:

  • res.statusCode
  • res.writeHead()
  • res.write()
  • res.end()

而必须使用 Koa 自己包装的 Response/Request 对象(通过 ctx.responsectx.request 获取)或是借助 ctx.bodyctx.statusctx.messagectx.typectx.set() 等这些快捷方式设置,快捷方式本质上是对 ctx.response, ctx.request 的高层抽象,通常会使用快捷方式进行设置context 上的快捷方式分布可以查看这里(Request Alias)这里(Response Alias)

还有,跟 Express 一样,app.listen(3000) 实际上是 http.createServer(app.callback()).listen(3000) 的语法糖。

const Koa = require('koa')
const app = new Koa()

app.listen(3000);
// 等同于
// http.createServer(app.callback()).listen(3000);

当你还需要同时启动 https 服务时,要这样写。

const http = require('http')
const https = require('https')
const Koa = require('koa')
const app = new Koa()
http.createServer(app.callback()).listen(3000)
https.createServer(app.callback()).listen(3001)

当然,你可以同时注册多个中间件。

const Koa = require('koa')
const app = new Koa()

// logger

app.use(async (ctx, next) => {
  await next()
  const rt = ctx.response.get('X-Response-Time')
  console.log(`${ctx.method} ${ctx.url} - ${rt}`)
});

// x-response-time

app.use(async (ctx, next) => {
  const start = Date.now()
  await next()
  const ms = Date.now() - start
  ctx.set('X-Response-Time', `${ms}ms`)
});

// response

app.use(async ctx => {
  ctx.body = 'Hello World'
});

app.listen(3000);

与 Express 类似,中间件系统也是通过 next() 参数连接的。不过不同的是,next() 函数调用后会始终返回一个 Promise 对象,你可以通过 async 函数 + await next() 的方式,在后续中间件执行完成后,做一些操作,这是 Express 中间件系统无法实现的。

拿上面的案例来说:

首先,我们在第 2 个中间件通过 await next() 方式等待响应体设置后(ctx.body = 'Hello World'),通过 ctx.set() 设置了响应头 X-Response-Time

同时,我们在第 1 个中间件中通过 await next() 方式监听第 2 个中间件完成执行,获取并打印响应头 X-Response-Time 的值。

app 上还提供了一个 .context 属性,它是传递给中间件 ctx 参数的原型对象。你可以通过编辑 app.contextctx 添加其他属性,并对所有中间件可见。

app.context.db = db();

app.use(async ctx => {
  console.log(ctx.db)
});

最后,Koa 没有专门的错误中间件。它会通过 app 上的 error 事件,捕获错误。

app.on('error', err => {
  log.error('server error', err)
});

要说明但是,Koa 实例本身也是 EventEmitter 对象,可以触发(.emit(eventName))和监听事件(.on(eventName))。

以上我们就介绍完了 Koa 的基本使用。下面来看如何实现它。

代码实现

依据 Koa v2.15.0 版本代码

查看 Koa 仓库源码,一共 4 个文件。

手把手带你用 128 行代码实现一个简易版 Koa 框架

application.js 存放 Koa 和核心实现逻辑(包括 app.use()app.callback(), app.listen()),context.js 中存储上下文对象中的一些方法和属性,request.jsresponse.js 则分别是 Koa 基于 reqres 封装的对象。

我们的实现不会存放在不同的文件中,而是放在一个文件 koa.js 里面,也不会引入任何外部依赖。

Koa 类及实例属性

首先,Koa 是一个类,并且继承了 EventEmitter

const EventEmitter = require('events')

module.exports = class Application extends EventEmitter {
  // TODO
}

初始化 app 实例上,有 3 个属性,contextrequestresponse

const context = {}
const response = {}
const request = {}

module.exports = class Application extends EventEmitter {
  constructor() {
    super()

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

.context.request.response 属性分别基于 3 个原型对象创建,内容会随着我们的实现慢慢填充。

app.use()

Koa 我们先来实现 app.use() 方法,它是用来收集存储中间件的。

module.exports = class Application extends EventEmitter {
  constructor() {
    super()

    this.middleware = []
    // ...
  }

  use(fn) {
    this.middleware.push(fn)
    return this
  }
}

实现比较简单,就是把传入进来中间件存储在内部的 middleware 数组属性上,为了实现链接调用,返回了 this

app.listen()

再来实现 app.listen() 方法。

module.exports = class Application extends EventEmitter {
  // ...

  listen(...args) {
    return require('node:http').createServer(/* ? */).listen(...args)
  }
}

通过“基本使用”一节的介绍,我们知道 /* ? */ 中的 ? 对应的是 this.callback() 方法。

app.callback()

.callback() 包含 Koa 业务处理的核心逻辑,处理中间件数组的链式调用,并能正常处理返回。

module.exports = class Application extends EventEmitter {
  // ...

  listen(...args) {
    return require('node:http').createServer(this.callback()).listen(...args)
  }

  callback() {
    return (req, res) => {
      // TODO
    }
  }
}

那该如何来实现呢?首先,我们会写一个 compose 函数来正确处理中间件的前端调用顺序。

compose() 函数

compose 函数类似 compose(middleware)(ctx),也就是说实际执行中间件逻辑的时候会传入上文对象 ctx

function compose(middleware) {
  return function (context) {
    // TODO
  }
}

另外,内部 dispatch 函数专门用于执行中间件,你只要传入对应中间件的索引值就行。

function compose(middleware) {
  return function (context) {
    function dispatch(index) {
      const fn = middleware[index]
      // TODO
    }
    return dispatch(0)
  }
}

在调用当前中间件时,除了会传递上下文对象,还会传递下一个中间件的触发函数 dispatch(index + 1) 作为 next() 参数,用于将控制权传递给下一个中间件。

function compose(middleware) {
  return function (context) {
    function dispatch(index) {
      const fn = middleware[index]
+     return fn(context, dispatch.bind(null, index + 1))
    }
    return dispatch(0)
  }
}

如果不调用 next(),那么执行流程会在当前中间件结束后就返回了。

同时,为了确保每个中间件是异步函数,返回一个 Promise,还要额外加一个 Promise.resolve() 包装。

function compose(middleware) {
  return function (context) {
    function dispatch(index) {
      const fn = middleware[index]
-     return fn(context, dispatch.bind(null, index + 1))
+     return Promise.resolve(fn(context, dispatch.bind(null, index + 1)))
    }
    return dispatch(0)
  }
}

如果 fn() 调用抛错,还要能获取异常。

function compose(middleware) {
  return function (context) {
    function dispatch(index) {
      const fn = middleware[index]
+     try {
        return Promise.resolve(fn(context, dispatch.bind(null, index + 1)))
+     } catch (err) {
+       return Promise.reject(err)
+     }
    }
    return dispatch(0)
  }
}

当然,中间件都执行完了的时候,fnundefined,也要处理。

function compose(middleware) {
  return function (context) {
    function dispatch(index) {
      const fn = middleware[index]
+     if (!fn) {
+       return Promise.resolve()
+     }
      
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, index + 1)))
      } catch (err) {
        return Promise.reject(err)
      }
    }
    return dispatch(0)
  }
}

写完 compose,让我们重新回到 callback(),继续写逻辑。

重回 app.callback()

app.callback() 中,我们首先使用 compose() 处理中间件数组,让这些中间件实现串行调用。

module.exports = class Application extends EventEmitter {
  // ...

  callback() {
    return (req、res) => {
      const fnMiddleware = compose(this.middleware)
      const ctx = this.createContext(req、res)
      return fnMiddleware(ctx).then(/* ? */).catch(/* ? */)
    }
  }
}

接下来调用 fnMiddleware 传入上下文对象,传入的 ctx 需要我们基于 reqres 创建,创建的逻辑封装在 app.createContext 中,我们来看一下。

app.createContext()

app.createContext() 逻辑如下所示。

module.exports = class Application extends EventEmitter {
  // ...

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

    context.request = request
    context.response = response

    context.req = request.req = response.req = req
    context.res = request.res = response.res = res
    context.app = request.app = response.app = this

    return context
  }
}

每次请求,我们都会重新创建包括 requestresponsecontext 在内的 3 个对象,同时:

  • context 会部署 requestresopnse 属性,用于设置快捷属性。另外
  • contextrequestresponse 会部署 resreqapp 属性,方便对原生对象进行代理访问,并能与应用实例进行交互

理解了 createContext(),我们再来看 callback()

module.exports = class Application extends EventEmitter {
  // ...

  callback() {
    return (req、res) => {
      const fnMiddleware = compose(this.middleware)
      const ctx = this.createContext(req、res)
      return fnMiddleware(ctx).then(/* ? */).catch(/* ? */)
    }
  }
}

因为 Koa 禁止我们直接使用 ctx.res 对象设置响应信息,那么在所有中间件处理结束后,在 .then(/* ? */) 里就需要我们处理请求响应。

respond()

我们把响应逻辑抽象在 respond() 中,并传入 ctx

return fnMiddleware(ctx).then(() => respond(ctx)).catch(/* ? */)

respond 方法中,为避免复杂,我们只处理字符串类型响应和 JSON 对象响应。

function respond(ctx) {
  let body = ctx.body

  // body: string
  if (typeof body === 'string') {
    return ctx.res.end(body)
  }

  // body: json
  body = JSON.stringify(body)
  return ctx.res.end(body)
}

如你所见,内部通过 ctx.body 获取请求体数据(等同于 ctx.reposnse.body,这块实现稍后说明),再对 body 值的类型进行检查,最后通过 ctx.res.end() 返回响应、终止请求。

ctx.onerror()

.catch(/* ? */) 里我们处理异常,这块逻辑(onerror)定义在原型对象 context 中。

return fnMiddleware(ctx).then(() => respond(ctx)).catch((err) => ctx.onerror(err))
const context = {
  onerror(err) {
    this.app.emit('error', err, this)
    let statusCode = err.status || err.statusCode || 500
    const msg = err.message ?? require('node:http').STATUS_CODES[statusCode]
    this.res.end(msg)
  }
}
const request = {}
const response = {}

module.exports = class Application extends EventEmitter {
  // ...
}

首先,在 app 实例上触发一个 error 事件,供外部监听。其次,使用异常消息作为响应数据返回。

接下来,再讲一讲 ctx.body 的实现。

ctx.body

ctx.body 其实是对 ctx.reposnse.body 属性的代理,我们写一个简单的实现。

const context = {
  // ...
  set body(val) {
    this.response.body = val
  },
  get body() {
    return this.response.body
  }
}

response.body 属性实现如下。

const response = {
  set body(val) {
    this._body = val

    // set the content-type only if not yet set
    const setType = !this.has('Content-Type');

    // string
    if (typeof val === 'string') {
      if (setType) this.type = /^\s*</.test(val) ? 'text/html' : 'text/plain'
      return
    }

    // json
    this.type = 'application/json'
  },
  get body() {
    return this._body
  },
  set(field, val) {
    this.res.setHeader(field, val)
  },
  remove(field) {
    this.res.removeHeader(field)
  },
  has(field) {
    return this.res.hasHeader(field)
  },
  set type(type) {
    if (type) {
      this.set('Content-Type', type)
    } else {
      this.remove('Content-Type')
    }
  }
}

观察可知:

  • 我们将设置给 response.body 的值存储在了内部的 ._body
  • 在设置 response.body 的时候,我们根据 response.body 值类型,调整了 Content-Type 响应头字段的值
  • 继续在 response 上抽象了一连串针对底层 res 对象的操作

以上,我们就差不多完成了所有 Koa 核心逻辑的编写。

整体代码

现在来回顾一下整体代码。

const EventEmitter = require('events')

const context = {
  onerror(err) {
    this.app.emit('error', err, this)
    let statusCode = err.status || err.statusCode || 500
    const msg = err.message ?? require('node:http').STATUS_CODES[statusCode]
    this.res.end(msg)
  },
  set body(val) {
    this.response.body = val
  },
  get body() {
    return this.response.body
  }
}
const response = {
  set body(val) {
    this._body = val

    // set the content-type only if not yet set
    const setType = !this.has('Content-Type');

    // string
    if (typeof val === 'string') {
      if (setType) this.type = /^\s*</.test(val) ? 'text/html' : 'text/plain'
      return
    }

    // json
    this.type = 'application/json'
  },
  get body() {
    return this._body
  },
  set(field, val) {
    this.res.setHeader(field, val)
  },
  remove(field) {
    this.res.removeHeader(field)
  },
  has(field) {
    return this.res.hasHeader(field)
  },
  set type(type) {
    if (type) {
      this.set('Content-Type', type)
    } else {
      this.remove('Content-Type')
    }
  }
}
const request = {}

module.exports = class Application extends EventEmitter {
  constructor() {
    super()

    this.middleware = []

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

  use(fn) {
    this.middleware.push(fn)
    return this
  }

  listen(...args) {
    return require('node:http').createServer(this.callback()).listen(...args)
  }

  callback() {
     return (req、res) => {
      const fnMiddleware = compose(this.middleware)
      const ctx = this.createContext(req、res)
      return fnMiddleware(ctx).then(() => respond(ctx)).catch((err) => ctx.onerror(err))
    }
  }

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

    context.request = request
    context.response = response

    context.req = request.req = response.req = req
    context.res = request.res = response.res = res
    context.app = request.app = response.app = this

    return context
  }
}

function compose(middleware) {
  return function (context) {
    function dispatch(index) {
      const fn = middleware[index]
      if (!fn) {
        return Promise.resolve()
      }
      
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, index + 1)))
      } catch (err) {
        return Promise.reject(err)
      }
    }
    return dispatch(0)
  }
}

function respond(ctx) {
  let body = ctx.body

  // body: string
  if (typeof body === 'string') {
    return ctx.res.end(body)
  }

  // body: json
  body = JSON.stringify(body)
  return ctx.res.end(body)
}

连上注释加空格,一个 128 行代码,不过移除原型对象部分的(context、request、response)代码,核心逻辑也只有 80 行代码不到而已。

总结

本来我们讲述了轻量级 Web 框架 Koa 的基本使用及实现过程。细心的你可能会发现 Koa 核心库并未内路由支持,这部分实现被放在了 koa-router 包中。

相比较于 Express 的中间件机制,Koa 利用异步函数实现了可回流的中间件调用机制,而不是简单的控制权传递;另一个优势的地方在于基于原生 reqres 对象进行了一层操作封装,也就是 Koa request、response 对象,让使用更加简单。

当然,本文只是对 Koa 框架核心功能的一个简单实现。其他像 options 支持、Koa Context/Request/Response 对象完整封装,大家有兴趣的话,可以参考源码进行学习。

本文就写到这里,感谢你的阅读,再见!