Koa之洋葱模型分析

1,720 阅读10分钟

背景

最近在开发公司官网项目时,使用到了Eggjs+React的SSR方案,众所周知,官网等门户网站对于SEO和首屏优化非常重视,因此结合该场景考虑,最终决定使用SSR服务端渲染方案,也是在技术调研的时候发现了一个比较完善的基于eggjs封装的一个SSR框架,在初步尝试和测试了一段时间后,最终决定了这个方案。

想必大家都知道eggjs是基于koa封装实现的,而对于koa而言,最知名的莫过于他的洋葱模型中间件方案,这是一个很巧妙的设计,也是经常接触的一个知识点。因此对于其实现原理和运行逻辑也很值得我们进一步探索,做到知其然、知其所以然。

简单介绍

Koa 是一个由 Express 原班人马打造的新的 web 框架,Koa 本身并没有捆绑任何中间件,只提供了应用(Application)、上下文(Context)、请求(Request)、响应(Response)四个模块(源码中可以发现)。原本 Express 中的路由(Router)模块已经被移除,改为通过中间件的方式实现。相比较 Express,Koa 能让使用者更大程度上构建个性化的应用。

Koa 是一个中间件框架,本身没有捆绑任何中间件。本身支持的功能并不多,功能都可以通过中间件拓展实现。通过添加不同的中间件,实现不同的需求,从而构建一个 Koa 应用。

中间件的基本使用

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

// async 函数
app.use(async (ctx, next) => {
  const start = Date.now()
  await next()
  const ms = Date.now() - start
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`)
})

// 普通函数
app.use((ctx, next) => {
  const start = Date.now()
  return next().then(() => {
    const ms = Date.now() - start
    console.log(`${ctx.method} ${ctx.url} - ${ms}ms`)
  })
})

app.listen(3001, () => {
  console.log(`Server port is 3000.`)
})

通过官方示例,可以初步了解到,Koa 的中间件就是函数,可以是 async 函数,或是普通函数。而next()函数则是一个异步promise函数。

中间件的执行顺序

// 最外层的中间件
app.use(async (ctx, next) => {
  await console.log(`第 1 个执行`)
  await next()
  await console.log(`第 6 个执行`)
})

// 第二层中间件
app.use(async (ctx, next) => {
  await console.log(`第 2 个执行`)
  await next()
  await console.log(`第 5 个执行`)
})

// 最里层的中间件
app.use(async (ctx, next) => {
  await console.log(`第 3 个执行`)
  ctx.body = 'Hello world.'
  await console.log(`第 4 个执行`)
})

通过示例,可以了解到,中间件的执行顺序受 next()函数影响,以 next()为界分为上下两部分,next()上面的部分为从上到下顺序执行,直到执行到最深处 ctx上下文执行返回结果后(无next函数),再从下到上执行,直到执行到最外层。

这样看可能不太好理解,我们换种写法,把关注点集中在 next()函数和ctx上下文,再看一遍:

// 最外层的中间件
app.use(async (ctx, next) => {
  // 这里是针对ctx.request做一些处理
  ctx.request.query.name = ctx.request.query.name + '_query1'
  await next()
  // 这里是针对ctx.response做一些处理
  ctx.response.body = ctx.response.body + '_query1'

  ctx.res.end(ctx.response.body)
})

// 第二层中间件
app.use(async (ctx, next) => {
  ctx.request.query.name = ctx.request.query.name + '_query2'
  await next()
  ctx.response.body = ctx.response.body + '_query2'
})

// 最里层的中间件
app.use(async (ctx, next) => {
  const query = ctx.request.query
  // console.log(query) => { name: 'zhangsan_query1_query2' }
  ctx.response.body = 'hello world'
})

// 请求参数如下:
// http://localhost:3001?name=zhangsan
// 返回结果如下:
// hello world_query2_query1

简单分析可以发现,我们以next函数为分界线,next函数的上面部分可以理解为request请求的流程(从外到内),next函数下面的部分可以理解为response响应的流程(从内到外)。

从表现上来看,我觉得这和递归的模式还挺相似的,开始都是先一层层往里调用,直到调用到最后一层,开始执行,得到结果,返回给上一层,然后再从最后一层往回执行,直到回到第一层,得到最终的结果。

话说和JS事件流的表现也挺像的:捕获、冒泡。

洋葱模型

洋葱模型

Koa 中间件采用的是洋葱圈模型,每次执行下一个中间件传入两个参数 ctxnext,参数 ctx 是由 koa 传入的,封装了 requestresponse 对象,可以通过它访问 requestresponsenext 就是进入下一个要执行的中间件。

洋葱模型

在洋葱模型中,每一层相当于一个中间件,用来处理特定的功能,比如错误处理、Session 处理等等。其处理顺序先是 next() 前请求(Request,从外层到内层)然后执行 next() 函数,最后是 next() 后响应(Response,从内层到外层),也就是说每一个中间件都有两次处理时机。

kao为什么使用洋葱模型

按照传统逻辑分析,一个中间件函数应该是自上而下的执行,执行结束后再执行下一个中间件,即从头到尾按顺序链式调用。

但是这样会产生一些问题,比如:

  • 如果只链式执行一次,怎么能保证前面的中间件能使用之后的中间件所添加的东西呢?
  • 如何正确划分请求前和请求后的关联逻辑?

简要说明:

问题一:如果不是next分层这种执行方式,对于普通的链式调用,在执行下一个中间件并对数据做了一些特殊处理之后,怎么做到让上一个中间件获取到该特殊数据后并且再次执行呢,以及如何避免对其他中间件的影响和整个应用的执行呢?

问题二:以对一个数据库的查询时间做计算来说明,中间件以next分层,上面为开始请求逻辑部分,标记开始的时间,然后执行next函数进入下一个中间件,调用数据库查询相关的中间件功能函数,执行结束后,来到了next函数的下面部分,这里为返回结果,标记结束请求的时间,两数相减即可,非常的简单,功能划分也是很清晰。对于中间件的各种添加、拓展等等,都可以很好集成进去,并做到功能的纯净。

可以发现使用洋葱模型可以很好(优雅)的解决这些问题。

中间件的使方式

中间件的使方式非常简单,只需要在 app.use(fn) 中添加中间件函数即可。

该函数接受两个参数:ctx——上下文、next——下一个中间件函数。

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

const fn = async (ctx, next) => {
  const start = Date.now()
  await next()
  const ms = Date.now() - start
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`)
}

app.use(fn)

源码分析

首先,我们按照使用kao的过程来分析源码。

创建kao实例

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

既然是通过 new的方式,那肯定是一个构造函数。可以发现源码中是一个class类。

class Application extends Emitter {
  constructor (options) {
    super()
    this.middleware = []
    // ...
  }

  // ...
}

koa内中间件的管理方式是通过维护一个数组队列来实现的。

添加中间件

app.use(fn)
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
}

核心代码,通过把fn中间件函数按顺序pushthis.middleware数组队列中。

再看listen

app.listen(3001, () => {
  console.log(`Server port is 3000.`)
})

如下:

listen (...args) {
  debug('listen')
  // 使用node的http模块的createServer创建服务
  const server = http.createServer(this.callback())
  return server.listen(...args)
}

创建服务的时候,传入了callback函数的返回值,看下callback函数

// const compose = require('koa-compose')

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
}

重点为第一行,使用了compose函数,处理中间件的核心代码,它返回的是一个promise函数。

然后把执行上下文ctxcompose处理中间件函数返回的promise函数fn传入handleRequest函数,并调用执行。

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

可以发现fnMiddleware函数(即compose处理中间件函数后返回的promise函数)是接受了一个ctx执行上下文作为参数并执行的。

OK,进入主题,compose函数~

compose猜想

在此之前,我们先简单思考下compose函数的作用,并尝试自己实现一个compose函数。

  • 要把上下文ctx对象下一个中间件next传给当前的中间件
  • 必须要等待下一个中间件执行完再执行当前中间件的后续逻辑
const middleware = []

const fn1 = async (ctx, next) => {
  console.log('fn1-next前')
  await next()
  console.log('fn1-next后')
}
const fn2 = async (ctx, next) => {
  console.log('fn2-next前')
  await next()
  console.log('fn2-next后')
}
const fn3 = async (ctx, next) => {
  console.log('fn3-next前')
  await next()
  console.log('fn3-next后')
}
const fn4 = async (ctx, next) => {
  console.log('fn4')
}

function use(fn) {
  middleware.push(fn)
}

use(fn1)
use(fn2)
use(fn3)
use(fn4)

let i = 0

function run(ctx) {
  let current = middleware[i]
  current(ctx, middleware[++i])
}

run({})

// 执行结果如下:
// fn1-next前
// fn2-next前
// TypeError: next is not a function

可以看到,代码执行报错了——TypeError: next is not a function

简要分析:

  1. i=0时,middleware[i]fn1,即current函数,执行current函数相当于fn1(ctx, middleware[++i]),遇到++i自增1,middleware[i]fn2,此时函数为fn1(ctx, fn2)
  2. fn1执行next时,其实执行的是fn2,这时可以发现fn2是没有参数传入的,即ctxnext都为undefined,所以next函数报错。
const fn2 = async (ctx, next) => {
  console.log('fn2-next前', ctx, next)
  await next()
  console.log('fn2-next后')
}
// fn2-next前 undefined undefined

因此,我们只要保证之后的中间件函数调用时,ctxnext都有值,就可以正常执行。

这里我们可以通过bind绑定上下文,使用bind绑定函数的上下文时,并不会立即调用执行该函数(callapply是立即调用执行),其返回的是一个可执行函数,所以不影响函数的正常调用执行。

function run(ctx) {
  let current = middleware[i]
  current(ctx, middleware[++i].bind(null, ctx, middleware[i + 1]))
}

// 执行结果如下:
// fn1-next前
// fn2-next前
// fn3-next前
// TypeError: next is not a function

可以看到,代码执行依旧报错了——TypeError: next is not a function,这是因为在fn3中调用next的时候,此时next也是未传入的。

可以发现还存在的一个问题就是:该方法无法根据中间件的数量进行自动调用并传递参数。

当前我们是在外部手动触发一次调用执行的,能否考虑把执行逻辑交给中间件控制调用呢?并自动管理调用的顺序?

以此实现,中间件函数如果还存在就继续调用,不存在就结束返回。

再改造一下:

function run(ctx) {
  // 通过包装一个dispatch函数,再结合bind
  // 使得该函数可以被中间件自动调用并传递参数
  function dispatch(i) {
    let current = middleware[i]
    if (!current) return
    return current(ctx, dispatch.bind(null, i + 1))
  }

  // 默认从第一个中间件开始
  return dispatch(0)
}
// 执行结果如下:
// fn1-next前
// fn2-next前
// fn3-next前
// fn4
// fn3-next后
// fn2-next后
// fn1-next后

这样就解决了next作为第二个参数传入的问题,并同时做为调用下一个中间件的执行函数。

最后就是包装一个Promise了,也比较简单,直接看看koa-compose源码是怎么实现的。

koa-compose

compose 函数引用的是 koa-compose 这个库。

function compose (middleware) {
  // ...
  return function (context, next) {
    // last called middleware #
    let index = -1
    // 一开始的时候传入为 0,后续会递增
    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
      // 当 fn 为空的时候,就会开始执行 next() 后面部分的代码
      if (!fn) return Promise.resolve()
      try {
        // 执行中间件,留意这两个参数,都是中间件的传参,第一个是上下文,第二个是 next 函数
        // 也就是说执行 next 的时候也就是调用 dispatch 函数的时候
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

代码不多,重在思想。(可以结合官方测试用例并debugger理解)

其实看了koakoa-compose源码后,你会发现其核心代码量确实不算多,很多代码也并不是很复杂的,但是其有些设计思想在某些地方是有点复杂的、很巧妙,需要仔细思考一番,如:洋葱模型的compose函数这里。

这让我想到了redux-thunk,核心代码也很少,实现起来也很简单,但是功能却很强大。

主要在于编程思想,属于那种代码十几行,文档几百行的(“十行代码,百行思想”)。

总结

Koa 的洋葱模型指的是以 next() 函数为分割点,先由外到内执行 Request 的逻辑,再由内到外执行 Response 的逻辑。通过洋葱模型,将多个中间件之间通信等变得更加可行和简单。其实现的原理并不是很复杂,主要是 compose 方法。

kao的洋葱模型让我深深体会到什么叫“编程思想”,编程思想可以很复杂,但是实现可能并不复杂,但是却非常有用。

资料