初识洋葱模型,分析中间件执行过程,浅析koa中间件源码

2/27/2022 Node洋葱模型

# 前言

作为洋葱模型的第一篇文章,这里仅介绍了一些入门级知识,比如

  • 了解洋葱模型执行顺序
  • 分析部分 koa 中间件的源码来加深对中间件的认识

为第二篇文章:分析洋葱模型实现原理,在自己项目中接入洋葱模型 打下基础

# 什么是洋葱模型

洋葱模型 一图胜前言

图中的一层一层的“方法”称为 中间件,比如 Cache Middleware ,Routers Middleware 就是一个个中间件

他们像流水线一样,按指定顺序执行中间件的方法

下面的案例都以 koa 作为演示的基础

# 搭建一个 koa 环境

由于只是简单的一个环境,所以就不用脚手架了,直接自己需要什么就安装什么

  • 新建文件夹 koa-middleware(按自己喜好命名就好)
  • 进入新建的文件夹,打开终端执行 npm init -y
  • 再次运行 npm install koa koa-router nodemon
  • 新建 src/app.js 文件
  • 打开 package.json 文件,在 “script” 中添加一句启动命令 "dev": "nodemon src/app.js"

运行 npm run dev 看到上图的效果,就算是 OK 了

然后在 app.js 中写入 koa 开启服务的基础代码

const Koa = require('koa')
const Router = require('koa-router')
const app = new Koa()
const router = new Router()

router.get('/', async function(ctx) {
  ctx.body = 'hello world'
})

app.use(router.routes())

app.listen('3000', function() {
  console.log(`koa 服务已启动: http://localhost:3000`)
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14

可以看到 nodemon 已经起作用了,我们无须重启终端也可以立刻看到代码修改后的运行效果~

由于 koa 开启的是一个网络服务,我们每次都得打开http://localhost:3000 才能触发相关的事件,太麻烦了

所以接下来在安装一个 axios 用于自动触发任务,方便我们探究中间件运行过程

  • 安装 axios npm install axios
  • 修改 app.js 代码如下:
const Koa = require('koa')
const axios = require('axios')
const Router = require('koa-router')
const app = new Koa()
const router = new Router()

router.get('/', async function(ctx) {
  ctx.body = 'hello world'
})

app.use(router.routes())

app.listen('3000', function() {
  console.log(`koa 服务已启动: http://localhost:3000`)

  axios('http://localhost:3000/')
    .then(res => {
      console.log('服务端返回的内容:', res.data)
    })
    .catch(res => {
      // catch 方法在这里是很有必要的,
      // 因为 node 程序如果有报错可能整个程序都会挂掉
      // 及时catch 住错误,不要影响主进程的运行
      console.log('服务端报错~', res.message)
    })
})
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

就这样一个简单的中间件探索运行环境都搭建好了

# 先了解中间件的写法

下面的写法仅针对 koa ,当然我也会介绍一下 express 的写法,不过都是大同小异的

  • 相同的地方:

koa 和 express 都是通过 app.use() 方法传入回调函数来挂载中间件
都有 next 方法用于执行下一个中间件

  • 不同的地方

use 方法中 koa 回调函数参数有 2 个 ctxnext,而 express 有 3 个 req,res,next

koa 的 ctx 可以理解为 reqres 的结合体了

  • koa 写法
app.use(async function(ctx, next) {
  // ...
})
1
2
3
  • express 写法
app.use(function(req, res, next) {
  // ...
})
1
2
3

很多中间件插件的写法,其实效果都是一样的,用一个闭合函数的目的是为了可以为中间件传入自己需要的参数

// 定义 log 中间件函数
function logMiddleare() {
  return async function(ctx, next) {
    // ...
  }
}

// 使用的时候
app.use(logMiddleare())
1
2
3
4
5
6
7
8
9

# 中间件执行顺序

以 app.use 调用顺序为准:越早调用执行越早,包尾越晚

怎么理解?

const Koa = require('koa')
const axios = require('axios')
const Router = require('koa-router')
const app = new Koa()
const router = new Router()

router.get('/', async function(ctx) {
  ctx.body = 'hello world'
})

app.use(async (ctx, next) => {
  console.log('1. 第一个执行')
  await next()
  console.log('1. 包尾执行')
})

app.use(async (ctx, next) => {
  console.log('2. 第2个执行')
  await next()
  console.log('2. 2执行结束')
})

app.use(router.routes())

app.listen('3000', function() {
  console.log(`koa 服务已启动: http://localhost:3000`)

  axios('http://localhost:3000/')
    .then(res => {
      console.log('服务端返回的内容:', res.data)
    })
    .catch(res => {
      console.log('服务端报错~', res.message)
    })
})
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

终端输出内容如下:

可以看到 1 方法最早执行包尾结束。结合开头的洋葱图,能理解何为“洋葱模型”了把

如果留意到 router.get 方法,其实他也是一个中间件,也遵循上面说的规则,他是由 app.use(router.routes()) 位于第三位注册进去的,虽然没打印内容,不过可以试下他应该是第三个打印内容的中间件

# 中间件一定会执行吗?

不一定,这得看中间件 上家 给不给执行,如果上家一家独大包揽了,不调用 next 那“洋葱”就闭环结束了

如何理解?上代码(就是把 await next()注释掉):

// ...
app.use(async (ctx, next) => {
  console.log('1. 第一个执行')
  // await next()
  console.log('1. 包尾执行')
})
// ...
1
2
3
4
5
6
7

看到返回值:

接口直接 404;方法 2 也没被执行了。为什么?

因为接口的路由注册app.use(router.routes()) 位于第三位才注册,方法 2 也位于第二位。只要有一个 app.use 的方法不调用 next 后面的方法都不能执行

同理,如果把 方法 1 中的 next 放开了,把方法 2 的注释掉,那么得到的结果就是:

# 中间件能相互传递数据吗?

在 koa 框架中,所有东西都挂载在 ctx/app 上,如果是公共的方法,参数,那就挂载在 app 上,如果只是和这次请求相关的内容,挂载在 ctx 上即可

当然也可以通过 return 来返回数据,不过前提是上一个中间件有接收你的值

比如下面的代码

  1. 方法 1 中,挂载了一个 ctx.name;同时想获取 ctx.desc 拿到的是 undefined
  2. 在方法 2 中才挂载 ctx.desc;并且接收 next 执行的返回值;方法 2 没有返回值
  3. 在最后的方法中,成功拿到了 ${ctx.name} ${ctx.desc},同时 return 了一个 {age:18} 回去
const Koa = require('koa')
const axios = require('axios')
const Router = require('koa-router')
const app = new Koa()
const router = new Router()

router.get('/', async function(ctx, next) {
  console.log(`${ctx.name} ${ctx.desc}`)
  ctx.body = 'hello world'
  await next()
  return { age: 18 }
})

app.use(async (ctx, next) => {
  console.log('1. 第一个执行')
  ctx.name = 'Jioho'
  console.log('方法1中获取desc:', ctx.desc)
  let res = await next()
  console.log('1. 包尾执行', res)
})

app.use(async (ctx, next) => {
  console.log('2. 第2个执行')
  ctx.desc = '前端小菜鸡'
  let res = await next()
  console.log('2. 2执行结束', res)
})

app.use(router.routes())

app.listen('3000', function() {
  console.log(`koa 服务已启动: http://localhost:3000`)

  axios('http://localhost:3000/')
    .then(res => {
      console.log('服务端返回的内容:', res.data)
    })
    .catch(res => {
      console.log('服务端报错~', res.message)
    })
})
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

执行结果如下:

这也说明了,中间件之间传递参数,最好还是通过 ctx 挂载,因为如果单纯的靠 return 来返回内容,万一中间某一个中间件没返回,那参数就丢失了

# koa 节选中间件源码分析

前面也有说到,koa 中间件写法有 2 种,上面一直都是简单的 console 代码观察中间件运行顺序,下面来几个实际的中间件看看中间件到底能做些什么~

下面的均为 koajs (opens new window) 官方的中间库,我挑了几个一个简单的库来分析源码(实在是找不到简单,又有代表性的库了)

# koa-response-time

koa-response-time (opens new window) 统计接口响应时间的中间件

用法和简单的描述:

这是一个统计接口响应时间的库。最下面还有一句没截图到就是

Best to .use() at the top before any other middleware, to wrap all subsequent middleware. 最好在任何其他中间件之前的顶部 .use() ,以包装所有后续中间件。

这样才能更好的统计接口整个响应的时间

可以看到这个库源码非常简短 https://github.com/koajs/response-time/blob/master/index.js (opens new window)

/**
 * Expose `responseTime()`.
 */

module.exports = responseTime

/**
 * Add X-Response-Time header field.
 * @param {Dictionary} options options dictionary. { hrtime }
 *
 *        hrtime: boolean.
 *          `true` to use time in nanoseconds.
 *          `false` to use time in milliseconds.
 *          Default is `false` to keep back compatible.
 * @return {Function}
 * @api public
 */

function responseTime(options) {
  let hrtime = options && options.hrtime
  return function responseTime(ctx, next) {
    let start = ctx[Symbol.for('request-received.startAt')]
      ? ctx[Symbol.for('request-received.startAt')]
      : process.hrtime()
    return next().then(() => {
      let delta = process.hrtime(start)

      // Format to high resolution time with nano time
      delta = delta[0] * 1000 + delta[1] / 1000000
      if (!hrtime) {
        // truncate to milliseconds.
        delta = Math.round(delta)
      }
      ctx.set('X-Response-Time', delta + 'ms')
    })
  }
}
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

可以看到 responseTime 函数里面接收了 options 参数,就是为了接收 hrtime ,用于判断时候要输入小数时间

闭包方法和我们写的一样

唯一的区别就是他并没有用 async 关键字,因为他在 next() 方法后面使用了 .then() 完全使用 Promise 的方式,就不需要 async 和 await 了。

剩下的代码就是

  1. 先记录请求开始时间
  2. 然后调用 next 方法,执行后面所有的中间件
  3. 执行完成后在记录一次时间,并且设置到到返回值的 header 的 'X-Response-Time' 中

# 小结

  1. 中间件执行顺序取决于 app.use 调用时间
  2. 越早调用,执行越早,收尾越晚
  3. 传参得靠 ctx
  4. 必须调用 next 方法后面的方法才会执行,否则洋葱模型就结束了

摸清楚中间件挂载和执行顺序后,就可以继续下一篇:分析洋葱模型实现原理,在自己项目中接入洋葱模型

Last Updated: 1/7/2024, 5:51:59 PM