Featured image of post 浅谈 Koa 和 Express 的中间件设计模式

浅谈 Koa 和 Express 的中间件设计模式

异步任务与洋葱圈模型

前言

面试腾讯的时候,面试官问到了我 KoaExpress 中间件模型的区别,提到了 Koa 是洋葱圈而 Express 不是洋葱圈,并问到了我洋葱圈模型是如何实现的,我之前并没有仔细地研究过两者的异同,只是粗略地讲了执行 next 函数之前是从外层向内层,而在 next 函数之后则是从内层返回外层,这样的回答显然是难以满足面试官要求的,因此这次好好地整理一下关于中间件设计模式和洋葱圈模型的知识。

正文

中间件设计模式

后端开发的过程中,常常需要对请求进行许多复杂的处理,比如需要记录执行时间、路由匹配、用户鉴权等,全部写在一起显然是不太现实的,因此需要把任务拆分成一个个小任务,然后按照顺序调用,比如收到一次请求后我先记录时间,然后进行路由匹配,然后进行用户鉴权,然后处理业务,最后返回响应。这样依次执行的小任务实际上就是中间件模型,后端通过中间件模型可以非常轻松地完成代码的抽象和复用。

常见的中间件设计模式有如下几种:

  1. 链式调用模式:该模式将多个处理器串联起来形成一个处理链,每个处理器都可以对请求进行处理或者转发给下一个处理器。Spring Boot 中的过滤器链就是一种链式调用模式的实现。
  2. 洋葱圈模型:该模型将多个中间件以洋葱圈的形式层层包裹,每个中间件都可以在请求进入时进行一些处理,也可以在请求离开时进行一些处理。Koa 框架就是一个使用洋葱圈模型的框架。
  3. 责任链模式:该模式将多个处理器形成一个链式结构,每个处理器都可以处理请求或者将请求转发给下一个处理器。与链式调用模式不同的是,责任链模式中的处理器可以决定是否将请求继续传递给下一个处理器。
  4. 代理模式:该模式将请求的处理交给一个代理对象来完成,代理对象可以在请求执行前后进行一些处理,比如记录日志、缓存数据等。Spring AOP 就是一种代理模式的实现。

如题,本文主要讨论的就是 洋葱圈模型

洋葱圈模型

要介绍洋葱圈模型,我们可以先看看老牌后端框架 Spring Boot 的中间件模型,如上文提到的,Spring Boot 采用 过滤器链模式 模式

对于Spring 来说,中间件执行完成后会直接转发请求给内层的中间件,不会再返回到外层的中间件。正如其名“过滤器链”,因为请求会依次经过多个过滤器,每个过滤器可以对请求进行处理或者拦截请求,不让其继续向下执行。

我们可以简单地理解为这只是一个链式调用函数的过程,而洋葱圈模型则与其不同,它是一个从外到内,然后再从内到外的过程。请求先经过外层中间件,然后依次通过内层中间件,最后再返回到外层中间件。在这个过程中,每个中间件都可以对请求进行处理或者将请求传递给下一个中间件,就像穿出一个洋葱一样,下图就是一个非常经典的例子

洋葱圈模型

我们可以以一个非常简单的 logger 中间件来理解它的运行过程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import { Context } from 'koa';

export default function logger() {
  return async (ctx: Context, next: () => Promise<void>) => {
    const start = Date.now();
    await next();
    const ms = Date.now() - start;
    console.log(`${ctx.method} ${ctx.url} ${ctx.status} - ${ms}ms`);
  };
}

这个中间件一般作为最外层的中间件,当请求进入时,我们记录当前时间,然后执行 next 函数,由于使用了 await,所以这个函数会阻塞当前函数的运行,并把控制权转交给下一个中间件,等到所有中间件都执行完成后,就会继续执行下面的逻辑,计算时间差并输出,这样一层一层地走下去,最后一层一层返回,就是一个非常简单明了的洋葱圈模型了

但是,回到最开头面试官问的问题:

难道只要有 next 就是洋葱圈吗?

我们知道,Express 的中间件也和 Koa 类似,也有一个 next 函数实现中间件的切换,但是严格来说,Express 并不是中间件的结构,比如下面是一个 Koa 的中间件代码示例:

 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
new Koa()
  .use(async (ctx, next) => {
    console.log('start 1');
    await next();
    console.log('end 1');
  })
  .use(async (ctx, next) => {
    console.log('start 2');
    await next();
    console.log('end 2');
  })
  .use(async (ctx, next) => {
    console.log('async');
    await next();
    await new Promise((res) => {
      setTimeout(() => {
        console.log('async end in 3s');
        res();
      }, 3000);
    });
  })
  .use(async (ctx, next) => {
    console.log('start 3');
    await next();
    console.log('end 3');
  });

按照先后顺序有三个中间件分别在执行 next 前后打印内容,再在中间插入一个异步函数。使得 next 函数执行完成后等待一段时间再打印内容,它的运行结果如下:

Koa 运行结果

这里很符合直觉,异步的函数阻塞了运行,使得整个流程都严格地按照洋葱圈模型处理数据。但是如果我们在 Express 中编写相同的代码,会发生什么呢?

 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
express()
  .use(async (req, res, next) => {
    console.log('start 1');
    await next();
    console.log('end 1');
  })
  .use(async (req, res, next) => {
    console.log('start 2');
    await next();
    console.log('end 2');
  })
  .use(async (req, res, next) => {
    console.log('async');
    await next();
    await new Promise((res) => {
      setTimeout(() => {
        console.log('async end in 3s');
        res();
      }, 3000);
    });
  })
  .use(async (req, res, next) => {
    console.log('start 3');
    await next();
    console.log('end 3');
  });

终端输出如下:

Express 运行结果

可以看到,确实有了不同,我们的异步任务是在最后输出的,这并不是洋葱圈模型应该出现的情况。实际上,两者的差距还有很多,比如我们处理完成返回响应的时候,Express 调用 res.send 方法后,直接就会结束流程,不会继续运行后面的中间件,而 Koa 只是设置 ctx.body 等参数,在中间件都完成执行并返回后才返回响应。

为了搞清楚原理,我们接下来分别简单实现一下两者的逻辑

Koa

Koa 的实现非常符合直觉,我们只需要将中间件函数存入一个数组,然后在调用 next 方法时执行下一个函数,代码实现如下:

 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
class MyKoa {
  private index: number
  public middleWares: KoaMiddleWare[]
  public ctx: Context
  constructor() {
    this.index = 0
    this.middleWares = []
    this.ctx = {}
  }

  public use(middleWare: KoaMiddleWare) {
    this.middleWares.push(middleWare)
    return this
  }

  public async next() {
    if (this.index < this.middleWares.length) {
      // 这里使用 await 就可以保证等到所有中间件都执行完后再结束 next 方法,从而实现洋葱圈式的调用顺序
      await this.middleWares[this.index++](this.ctx, this.next.bind(this))
    }
  }

  public async start() {
    await this.next()
    console.log(this.ctx)
  }
}

尝试调用后,我们的输出和 Koa 的输出是一致的

实际上,Koa 源码中使用的是 koa-compose 这个库实现的,它的核心是一个 compose 函数,使用闭包来隐藏 index 变量,并且做了很多边界控制来提高代码健壮性,比如限制了传入参数必须是一个函数数组,以及 next 函数不能在同一个中间件函数中调用两次等

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function 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 function (context, next) {
    let index = -1
    function dispatch (i) {
      // 如果在一个中间件内多次调用 next 函数,第二次执行的时候实际上已经把其内部的所有中间件都执行了,因此此时的index一定会大于等于定义时候的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
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
        // 使用 bind 指定下一次执行的参数,也可以写作箭头函数 () => dispatch(i + 1)
      } catch (err) {
        return Promise.reject(err)
      }
    }
    return dispatch(0)
  }
}

那我们只需要把逻辑改成这样就可以了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class MyKoa {
  public middleWares: KoaMiddleWare[]
  public ctx: any
  constructor() {
    this.middleWares = []
    this.ctx = {}
  }

  public use(middleWare: KoaMiddleWare) {
    this.middleWares.push(middleWare)
    return this
  }

  public async start() {
    await compose(this.middleWares)(this.ctx)
    console.log(this.ctx)
  }
}

更加地简洁明了

Express

Express 的实现比较简单,和 Koa 不同的是,Express 中间件的 next 函数是同步的

 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
class MyExpress {
  private index: number
  public middleWares: ExpressMiddleWare[]
  public req: any
  public res: any
  constructor() {
    this.index = 0
    this.middleWares = []
    this.req = {}
    this.res = {}
  }
  public use(middleWare: ExpressMiddleWare) {
    this.middleWares.push(middleWare)
    return this
  }

  public next() {
    if (this.index < this.middleWares.length) {
      this.middleWares[this.index++](this.req, this.res, this.next.bind(this))
    }
  }

  public async start() {
    this.next()
  }
}

尝试调用后,也输出了和 Express 相同的结果,进一步验证了我们的结论

总结

Express 使用同步函数处理异步请求,使得中间件的执行不能严格按照洋葱圈模型的顺序调用,并且存在 callback hell 等问题

Koa2 通过 async/awaitnext 函数转化为异步函数,使得中间件的执行顺序严格符合洋葱圈模型,且天然支持 async/await 异步编程,极大地减轻了开发者的心智负担

Built with Hugo
主题 StackJimmy 设计