小帅の技术博客 小帅の技术博客
首页
  • 前端
  • 服务
  • Node
  • 技术
  • 服务器
  • 程序猿
关于
友链
  • 网站
  • 资源
  • Vue资源
  • 优秀技术文章
  • 分类
  • 标签
  • 归档
GitHub

前端小帅

学而不思则罔,思而不学则殆
首页
  • 前端
  • 服务
  • Node
  • 技术
  • 服务器
  • 程序猿
关于
友链
  • 网站
  • 资源
  • Vue资源
  • 优秀技术文章
  • 分类
  • 标签
  • 归档
GitHub
  • 前端

  • NodeJs

    • puppeteer无头浏览器
    • 新一代ORM-Prisma
    • NodeJs获取照片信息并分类
    • 微信每天给女朋友发早安
    • Koa之洋葱模型分析
      • 背景
      • 简单介绍
        • 中间件的基本使用
        • 中间件的执行顺序
      • 洋葱模型
        • kao为什么使用洋葱模型
        • 中间件的使方式
      • 源码分析
        • 创建kao实例
        • 添加中间件
        • 再看listen
        • compose猜想
        • koa-compose
      • 总结
      • 资料
  • 服务

  • 其他

  • 2021年终总结
  • 文章
  • NodeJs
sunss
2022-04-30

Koa之洋葱模型分析

# 背景

最近在开发公司官网项目时,使用到了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.`)
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

通过官方示例,可以初步了解到,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 个执行`)
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

通过示例,可以了解到,中间件的执行顺序受 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

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

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

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

# 洋葱模型

洋葱模型

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

洋葱模型

在洋葱模型中,每一层相当于一个中间件,用来处理特定的功能,比如错误处理、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)
1
2
3
4
5
6
7
8
9
10
11

# 源码分析

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

# 创建kao实例

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

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

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

  // ...
}
1
2
3
4
5
6
7
8
9

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

# 添加中间件

app.use(fn)
1
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
}
1
2
3
4
5
6

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

# 再看listen

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

如下:

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

创建服务的时候,传入了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
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

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

然后把执行上下文ctx和compose处理中间件函数返回的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)
}
1
2
3
4
5
6
7
8

可以发现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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

可以看到,代码执行报错了——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是没有参数传入的,即ctx和next都为undefined,所以next函数报错。
const fn2 = async (ctx, next) => {
  console.log('fn2-next前', ctx, next)
  await next()
  console.log('fn2-next后')
}
// fn2-next前 undefined undefined
1
2
3
4
5
6

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

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

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
1
2
3
4
5
6
7
8
9
10

可以看到,代码执行依旧报错了——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后
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

这样就解决了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)
      }
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

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

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

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

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

# 总结

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

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

# 资料

  • koa
  • koa-compose
  • Egg官方文档
  • Koa 系列 — 如何编写属于自己的 Koa 中间件
  • 【Node】深入浅出 Koa 的洋葱模型
  • Koa洋葱模型 从理解到实现
  • 如何更好地理解中间件和洋葱模型
编辑
#koa
上次更新: 2022/12/07, 14:14:50
微信每天给女朋友发早安
webhook实现服务器自动部署

← 微信每天给女朋友发早安 webhook实现服务器自动部署→

最近更新
01
vue编译为js的研究
12-07
02
【配置文件分析】——json、yaml、toml
09-06
03
【前端组件化】系列第二篇——monorepo方案实战
09-05
更多文章>
sunss | © 2020.08-2022.12 浙ICP备2022002957号-1
载入天数... 载入时分秒...  |  总访问量 次
提供CDN加速/云存储服务
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式