新手也能看懂的Koa2.0源码系列

685 阅读14分钟

koa相信大家多多少少都有所接触,即使没用过koa的人肯定也会听过洋葱模型这个概念。今天的目标就是手把手带大家实现一个koa2。

前2章的思路是从使用层面和运行结果层面倒推源码,感觉这么做的代入感会更强一些。最后一章就完全按照官方文档和源码直接实现了。

老规矩,先提几个问题

  1. koa如何启一个node服务?
  2. 洋葱模型如何实现?
  3. 常用的ctx是怎么来的?
  4. 为什么给ctx.body赋值后会自动返回对应的数据类型?

接下来我们在实现的过程中会一个个解决这些问题!

如何启动一个服务

先观察一个Koa的最简demo

const Koa = require('koa')
const app = new Koa()
app.listen(8889, '0.0.0.0', () => {
  console.log(`启动成功8889`)
})
// node koa.js -> 启动成功8889

首先观察代码结构

  • 第2行使用了new操作符,那么koa抛出的肯定是一个构造函数(function)或者类(class)
  • 第3行Koa实例调用了listen方法,并且接收了几个参数,如果先不管参数,那么Koa实例内肯定包含一个listen方法

结合上述2个结论可以推断出koa内的结构大概是这样

class Koa {
  listen(...args) {
  }
}

然后看看运行结果

  • 启动成功后会调起一个服务,那么肯定离不开node的http模块的createServercreateServer得到一个server后也拥有一个listen方法,并且完全对应demo里app.listen的参数。所以Koa的listen实现如下
const http = require('http')
class Koa {
  listen(...args) {
    const server = http.createServer()
    return server.listen(...args)
  }
}

验证一下

const Koa = require('./listen.js')
const app = new Koa()
app.listen(8889, '0.0.0.0', () => {
  console.log(`启动成功8889`)
})
// node app.js -> 启动成功8889

洋葱模型

单个中间件

首先我们先看看单个中间件的最简demo

const Koa = require('koa')
const app = new Koa()
app.use(() => {
  console.log(1)
})

app.listen(8889, '0.0.0.0', () => {
  console.log(`启动成功8889`)
})
// node koa.js - > 启动成功8889
// Postman请求 -> 1

观察一下上述代码结构

  • 根据第3行app.use,可以推断出Koa类有一个use函数,并且接收一个函数参数

就这个结论我们可以很容易的写出如下代码

class Koa {
  constructor() {
    // 初始化函数
    this.middleware = () => {}
  }
  use(cb) {
    // 保存函数
    this.middleware = cb
  },
  listen(...) {...}
}

然后再看看运行结果

  • 启动成功后log了启动成功8889,也就是上一节我们实现的内容
  • Postman请求后log了1,也就是执行了app.use中的函数参数。那么我们可以假设app.use是一个注册器,注册了一个函数(中间件),当服务接收到请求后执行这个函数,而http.createServer api的参数接收一个函数来监听请求,正好满足我们的需求

所以结合上述结论我们就可以实现刚刚写的最简demo了,代码如下

// 源码层
const http = require('http')
class Koa {
  constructor() {
    // 初始化函数
    this.middleware = () => {}
  }
  // 一个注册器
  use(cb) {
    // 保存函数
    this.middleware = cb
  }
  listen(...args) {
    // http.createServer接收一个函数参数,用于接收请求
    const server = http.createServer((req, res) => {
      // 接收到请求后执行use中注册的函数
      this.middleware()
      // 这一段是为了正常结束请求,暂时加上,可以先忽略
      res.end('1');
    })
    return server.listen(...args)
  }
}

验证一下

const Koa = require('./use1.js')
const app = new Koa()
app.use(() => {
  console.log(1)
})
app.listen(8889, '0.0.0.0', () => {
  console.log(`启动成功8889`)
})
// node app.js - > 启动成功8889
// Postman请求0.0.0.0:8889 -> 1

多个中间件

最简demo的源码是完成了,但是正常大家写Koa的时候,都会有不少的插件,自定义的各种拦截器等各种不同类型的中间件。但是抛开这些中间件的具体逻辑而言,它们的执行顺序无非为同步和异步2种。 所以区分这2种情况,分别观察一下代码结构和运行结果,然后一步步分析,最后再去推导源码。

同步
const Koa = require('koa')
const app = new Koa()
app.use(function cb1(ctx, next){
  console.log(1)
  next()
  console.log(5)
})

app.use(function cb2(ctx, next) {
  console.log(2)
  next()
  console.log(4)
}).use(function cb3(ctx, next) {
  console.log(3)
})

app.listen(8889, '0.0.0.0', () => {
  console.log(`启动成功8889`)
})
// node koa.js -> 启动成功8889
// Postman请求0.0.0.0:8889 -> 1 2 3 4 5

观察一下上述代码结构和运行结果(这一次需要结合运行结果来分析,所以不拆分了)

  1. 可以多次使用app.use注册中间件,中间件接收ctx,next两个参数,因为可以注册多个,所以我们需要用有一个地方存起来,比如用数组存储
  2. 可以使用链式写法(13行),可以推断出use内返回this。
  3. 请求后会先后打印1,2,3,4,5。也就是说next()会暂停执行当前中间件去执行下一个中间件,实际上就像js内的嵌套函数

首先,根据1,2结论,可以得出下方的代码

class Koa {
  constructor() {
    // 老代码 this.middleware = () => {}
    // 保存中间件数组
    this.middleware = []
  }
  // 注册器
  use(cb) {
    // 保存中间件
    this.middleware.push(cb)
    // 链式写法
    return this
  }
}

第3点就比较复杂了,为了更好的分析,我们可以把use这一段代码用js的写法来写一遍

这一整节暂时先不管ctx,后续会对其进行封装

function cb1(next){
  console.log(1)
  next(cb3)
  console.log(5)
}

function cb2(next) {
  console.log(2)
  next(cb3)
  console.log(4)
}
function cb3(next) {
  console.log(3)
}
cb1(cb2)
// 放控制台执行 -> 1 2 3 4 5
// cb1的next -> cb2
// cb2的next -> cb3
// cb3的next -> () => {} // 兼容

image.png 结合js代码和上图,那么我们怎么去封装这个use函数呢?

唯一的困难点就是如何获取下一个中间件。 这时我们想一下中间件注册的时机,中间件的注册发生在node服务启动的时候,执行是在请求进来时,所以请求进来的时候,我们已经用数组保存了全部的中间件,那么我们是不是就可以利用数组的下标获取下一个中间件呢

const middleware = [cb1, cb2, cb3]
// 执行cb1的时候,我们可以获取到cb2,并传给cb1
middleware[0](middleware[1])

用上述的概念实现一下最简单的洋葱,看看是否可以和Koa一样运行

const http = require('http')
class Koa {
  constructor() {
    // 初始化中间件数组,因为可能是多个
    this.middleware = []
  }
  // 注册器
  use(cb) {
    // 保存所有注册的中间件
    this.middleware.push(cb)
    return this
  }
  compose() {
    const dispatch = (i) => {
      // 从数组中取出中间件
      const fn = this.middleware[i]
      // 执行中间件,并传递执行下一个中间件的函数
      // dispatch(i + 1)会立即执行下一个中间件,所以用一个函数包起来,何时执行交给用户自己选择
      return fn(() => dispatch(i + 1))
    }
    // 执行第一个中间件
    return dispatch(0)
  }
  callback() {
    return (req, res) => {
      // 接收请求后执行compose
      this.compose()
      // 这一段是为了让Postman正常结束请求,暂时加上,可以先忽略
      res.end('111')
    }
  }
  listen(...args) {
    const server = http.createServer(this.callback())
    return server.listen(...args)
  }
}

验证一下

const Koa = require('./useSync.js')
const app = new Koa()
app.use(function cb1(next){
  console.log(1)
  next()
  console.log(5)
})

app.use(function cb2(next) {
  console.log(2)
  next()
  console.log(4)
}).use(function cb3(next) {
  console.log(3)
})

app.listen(8889, '0.0.0.0', () => {
  console.log(`启动成功8889`)
})
// node koa.js -> 启动成功8889
// Postman请求0.0.0.0:8889 -> 1 2 3 4 5
校验

基于上述实现,有2个点需要注意

  • middleware是一个数组
  • middleware内每一项都是一个函数

之前也提到过,在server启动的时候,就已经收集到了全部的中间件,所以为了代码更健壮,最好在启动阶段就进行强制校验。让我们分析一下如何做到这一步。

  • 运行时机:启动时,也就是执行callback的时候
  • 需要compose提供的功能:校验 + 执行中间件。利用闭包,执行时校验并返回一个执行中间件的函数

基于上述2点,可以推导出,callback执行时立即执行compose,请求进来时执行中间件函数

const http = require('http')
class Koa {
  constructor() {
    this.middleware = []
  }
  use(cb) {
    this.middleware.push(cb)
    return this
  }
  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!')
    }
    // 返回执行中间件的函数
    return () => {
      const dispatch = (i) => {
        // 从数组中取出中间件
        const fn = middleware[i]
        // 执行中间件,并传递执行下一个中间件的函数
        // 这里注意,dispatch(i + 1)会立即执行下一个中间件,所以用一个函数包起来,何时执行交给用户自己选择
        return fn(() => dispatch(i + 1))
      }
      // 执行第一个中间件
      return dispatch(0)
    }
  }
  callback() {
    // 启动时校验
    const fn = this.compose(this.middleware)
    return (req, res) => {
      // 请求进来时执行
      fn()
      res.end('111')
    }
  }
  listen(...args) {
    const server = http.createServer(this.callback())
    return server.listen(...args)
  }
}

校验一下

const Koa = require('./useSyncValidator.js')
const app = new Koa()
app.use(111)

app.listen(8889, '0.0.0.0', () => {
  console.log(`启动成功8889`)
})

image.png

边界

比较了解Koa的人可能会发现,我们目前实现的代码还有2个边界情况未处理

  1. Koa中规定每个中间件只能执行一次next,但是我们目前还没做到
  2. 最后一个中间件也存在next,执行next会报错。因为i >= middleware.length,用middleware[i]获取到的是undefined

先看看第1个边界 image.png 如图所示,虽然给出了警告,但是log了3和1。可以看出底层拦截了第2个next,第一个next正常执行,并且不会影响next后续逻辑的运行 那么我们怎么去判断代码里存在1个以上的next呢? 看看刚刚的核心代码compose

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!')
  }
  return () => {
    const dispatch = (i) => {
      const fn = middleware[i]
      return fn(() => dispatch(i + 1))
    }
    return dispatch(0)
  }
}

每一个next都是一个dispatch(i + 1),也就是说每次执行next的时候i都是相同的。那么我们如果在dispatch外再维护一个索引,dispatch执行的时候index = i,再执行一次dispatch的时候判断i是不是小于等于index,就可以判断当前next是不是执行2+次了。 image.png 修改一下上面的compose函数

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!')
  }
  return () => {
    let index = -1
    const dispatch = (i) => {
      if (i <= index) {
        return console.error(new Error('next() called multiple times'))
      }
      index = i
      const fn = middleware[i]
      return fn(() => dispatch(i + 1))
    }
    return dispatch(0)
  }
}

然后第2个边界就好处理了,当最后一个next的时候,默认返回一个空函数就行

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!')
  }
  return () => {
    let index = -1
    const dispatch = (i) => {
      if (i <= index) {
        return console.error(new Error('next() called multiple times'))
      }
      index = i
      // 添加了这一段
      if (i >= middleware.length) return () => {}
      const fn = middleware[i]
      return fn(() => dispatch(i + 1))
    }
    return dispatch(0)
  }
}

然后整体验证一下

const Koa = require('./useSync.js')
const app = new Koa()
app.use(function cb1(next){
  next()
  next()
  console.log(1)
})

app.use(function cb2(next) {
  next()
})

app.use(function cb3(next) {
  console.log(3)
  next()
})

app.listen(8889, '0.0.0.0', () => {
  console.log(`启动成功8889`)
})
// node app.js 

image.png 报错时机是因为底层封装不一样,并不影响。接下来我们看看异步的情况

异步

首先用上一步实现的代码试试看async await的运行结果

const Koa = require('./useSync.js')
const app = new Koa()
app.use(async (next) => {
  console.log(1)
  await next()
  console.log(4)
})

app.use(async (next) => {
  console.log(2)
  await timeout()
  console.log(3)
})

function timeout() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve()
    }, 2000)
  })
}
// node app.js -> 启动成功8889
// Postman请求0.0.0.0:8889 -> 1 2(2s后) 3 4

到这里是不是很疑惑,和Koa源码的运行结果一致,难道封装已经完成了?

答案肯定是否定的!因为log正确完全是因为async await的底层实现。一句话概括底层原理就是基于Promise实现的自执行的Generator函数,async最后会返回一个Promise,await等于yield。 所以不用async await,直接用Promise的结构来看看demo

const Koa = require('koa')
const app = new Koa()
app.use((ctx, next) => {
  console.log(1)
  next().then(() => {
    console.log(3)
  })
})

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

app.listen(8889, '0.0.0.0', () => {
  console.log(`启动成功8889`)
})
// node app.js  -> 启动成功8889
// Postman请求0.0.0.0:8889 -> 1 2 3

从上述demo可以给看出,最后一个中间件是一个普通函数,但是上一个中间件却用了next().then(),也就是认为最后一个中间件是一个Promise。在Koa中这么写确实被允许的,没有报错,也就是Koa中兼容了普通函数和Promise。那么如何去兼容呢?

Promise.resolve

我们看看Promise.resolve的源码实现

// 函数是Promise直接返回,不是就包一层Promise
Promise.resolve = function (fn) {
  if (fn instanceof Promise) {
    return fn
  } else {
    return new Promise((resolve) => {
      resolve(fn)
    })
  }
}

所以再次修改compose函数

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!')
  }
  return () => {
    let index = -1
    const dispatch = (i) => {
      if (i <= index) {
        return console.error(new Error('next() called multiple times'))
      }
      index = i
      if (i >= middleware.length) return () => {}
      const fn = middleware[i]
      // 修改了这
      return Promise.resolve(fn(() => dispatch(i + 1)))
    }
    return dispatch(0)
  }
}

再次验证一下刚刚的demo,正常log 1 2 3,然后再看看另一个情况

const Koa = require('./useAsync.js')
const app = new Koa()
app.use(next => {
  console.log(1)
  next().then(() => {
    console.log(3)
  })
})

app.use(next => {
  return next().then(() => {
    console.log(2)
  })
})

image.png 从报错可以看出2都没有被log出来,也就是最后一个中间件的next().then报错了,回想一下之前保护机制里第2个边界的处理,直接返回一个Promise.resolve,就可以解决这个问题

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!')
  }
  return () => {
    let index = -1
    const dispatch = (i) => {
      if (i <= index) {
        return console.error(new Error('next() called multiple times'))
      }
      index = i
      // 就这里
      if (i >= middleware.length) return Promise.resolve()
      const fn = middleware[i]
      return Promise.resolve(fn(() => dispatch(i + 1)))
    }
    return dispatch(0)
  }
}

最后做一下错误情况的兼容

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!')
  }
  return () => {
    let index = -1
    const dispatch = (i) => {
      // Promise.reject
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      if (i === middleware.length) return Promise.resolve()
      const fn = middleware[i]
      // try catch
      try{
        return Promise.resolve(fn(() => dispatch(i + 1)))
      } catch(err) {
        return Promise.reject(err)
      }
    }
    return dispatch(0)
  }
}

ctx封装

基础结构

这一章直接按文档来实现其原理。不再按之前的推断思路。 首先在封装前,我们先看看官方对ctx的定义,简单来说就是封装了node的response和request。 image.png 这一部分,只简单的封装一下ctx,request,response和body 首先,ctx是一个对象,request和response是ctx的一个属性并且也是一个对象,直接得出如下源码

const ctx = {

}

module.exports = ctx
module.exports = {

}
module.exports = {

}

然后上述对Context的描述里说到,每个请求都会创建一个新的Context,并在中间件中引用,也就是说每次server接收到请求后(callback内返回的函数),都会根据req和res创建一个Context。 Context中的内容根据文档内描述

  • ctx.request:根据node的request封装而来
  • ctx.response:根据node的response封装而来
  • ctx.req:node的request对象
  • ctx.res:node的response对象

中间件第一个参数为Context 首先根据上述描述,可以推导出源码如下(可以只看注释部分)

const http = require('http')
const context = require('./src/context')
const request = require('./src/request')
const response = require('./src/response')
class Koa {
  constructor() {
    this.middleware = []
    // 初始化ctx等,引用类型,避免引用
    this.context = Object.create(context)
    this.request = Object.create(request)
    this.response = Object.create(response) 
  }
  use(cb) {
    this.middleware.push(cb)
  }
  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!')
    }
    // 接收ctx
    return (ctx) => {
      let index = -1
      const dispatch = (i) => {
        if (i <= index) return Promise.reject(new Error('next() called multiple times'))
        index = i
        if (i === middleware.length) return Promise.resolve()
        const fn = middleware[i]
        try{
          // 增加第一个参数为ctx
          return Promise.resolve(fn(ctx, () => dispatch(i + 1)))
        } catch(err) {
          return Promise.reject(err)
        }
      }
      return dispatch(0)
    }
  }
  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)
    // req,res为node原生request和response
    // 给request和response也赋值req,res是为了利用this获取到原生req,res,然后做二次封装
    context.req = request.req = response.req = req
    context.res = request.res = response.res = res
    return context
  }
  callback() {
    const fn = this.compose(this.middleware)
    return (req, res) => {
      // 每个请求都创建一个新的context
      const ctx = this.createContext(req, res)
      // 传入ctx
      fn(ctx)
      res.end('111')
    }
  }
  listen(...args) {
    const server = http.createServer(this.callback())
    return server.listen(...args)
  }
}
const Koa = require('./ctx.js')
const app = new Koa()
app.use((ctx, next) => {
  next()
})

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

app.listen(8889, '0.0.0.0', () => {
  console.log(`启动成功8889`)
})

log的ctx大概如下结构

{
  request: {
    req: <node req>,
    res: <node res>
  },
  response: {
    req: <node req>,
    res: <node res>
  },
  req: <node req>,
  res: <node res>
}

response & request

上文已经给request和response赋值了,所以可以对req和res做一层有用的封装

module.exports = {
  // req: <node req>,
  // res: <node res>
  get header() {
    return this.req.headers
  },
  set header(val) {
    this.req.headers = val
  },
  get url() {
    return this.req.url
  },
  set url(val) {
    this.req.url = val
  }
  ... api内的方法
}
module.exports = {
  // req: <node req>,
  // res: <node res>
  get header() {
    const { res } = this;
    return typeof res.getHeaders === 'function'
      ? res.getHeaders()
      : res._headers || {}; // Node < 7.7
  },
  set header(val) {
    console.log(val, 222)
  },
  get body() {
    return this._body;
  },
  // 这里只是随便赋了个值,源码内做了很多判断为了适应不同的数据
  set body(val) {
    this._body = val
  }
  ... api内的方法
}

验证一下

const Koa = require('./ctx.js')
const app = new Koa()
app.use((ctx, next) => {
  next()
})

app.use(ctx => {
  console.log(ctx.request.header, 2)
  console.log(ctx.request.url, 3)
})

app.listen(8889, '0.0.0.0', () => {
  console.log(`启动成功8889`)
})

image.png

ctx别名

image.png 可以看看官方文档的别名具体都有哪些,这里就对request的header和url做一次实现,其它都同理。 源码内利用delegates包做了一层代理,其实也可以用proxy等,实现一下request的代理

const delegate = require('delegates');
const ctx = {

}

// 将ctx.request的header和url属性代理到ctx下
delegate(ctx, 'request')
  .access('header')
  .access('url')

// 代理response.body
delegate(ctx, 'response')
  .access('body')

module.exports = ctx

验证一下

const Koa = require('./ctx.js')
const app = new Koa()
app.use((ctx, next) => {
  next()
})

app.use(ctx => {
  console.log(ctx.request.url, 1)
  // 可以直接访问
  console.log(ctx.url, 2)
})

app.listen(8889, '0.0.0.0', () => {
  console.log(`启动成功8889`)
})

image.png

ctx.body

Koa中给ctx.body赋值后,请求结束会识别ctx.body的类型然后返回对应的数据

const Koa = require('koa')
const app = new Koa()
app.use((ctx, next) => {
  console.log(1)
  next()
  console.log(3)
})

app.use((ctx, next) => {
  console.log(2)
  ctx.body = '<html>111</html>'
})


app.listen(8889, '0.0.0.0', () => {
  console.log(`启动成功8889`)
})
// node koa.js -> 启动成功8889
// Postman请求0.0.0.0:8889 -> 1 2 3

image.png image.png 所以我们只需要走完全部中间件的时候,判断body类型,然后使用res.end结束请求即可,粗略版本源码如下


class Koa {
  callback() {
    const fn = this.compose(this.middleware)
    return (req, res) => {
      const ctx = this.createContext(req, res)
      // 中间件全部走完后执行
      fn(ctx).then(() => respond(ctx))
    }
  }
}

function respond(ctx) {
  const res = ctx.res
  let body = ctx.body
  // 判断body类型,自动设置Content-Type
  if (typeof body === 'string') {
    res.setHeader('Content-Type', /^\s*</.test(body) ? 'text/html' : 'text/plain')
  }
  if (typeof body === 'object' && ctx.body !== null) {
    res.setHeader('Content-Type', 'application/json')
    body = JSON.stringify(body)
  }
  // 结束请求并返回body
  res.end(body)
}
const Koa = require('./ctx.js')
const app = new Koa()
app.use((ctx, next) => {
  next()
})

app.use(ctx => {
  ctx.body = {
    msg: '成功啦'
  }
})

app.listen(8889, '0.0.0.0', () => {
  console.log(`启动成功8889`)
})

参考文章

juejin.cn/post/702262… juejin.cn/post/701658…