从 koa-compose 源码中学习如何实现 Promise 链式调用和洋葱模型

2,072 阅读7分钟

本文参加了由公众号@若川视野发起的每周源码共读活动,点击了解详情一起参与。 **这是源码共读的第5期,链接:juejin.cn/post/708498…

前言

面试官:了解过 koa 洋葱模型吗?用到了什么设计模式?他是怎么实现的?
本文直接带你看 koa-compose 源码,带你回答这些问题~

阅读仓库

github.com/koajs/compo…

你能学到

  • 了解 koa-compose 作用,应对面试官提问koa中间件
  • 职责链模式~
  • 等等

本文思维导图

洋葱模型

图解

先看看中间件的经典洋葱模型~
image.png
看这个图可以理解中间件到底做了什么,那么它具体代码执行是怎么样的呢?

代码执行顺序

koa 官方blog上有个非常代表性的中间件 gif 图

这张图可以配合下面官方测试样例一起看

compose就是koa中的一个工具函数,Koa.js 的中间件通过这个工具函数组合后,按 app.use() 的顺序同步执行,也就是形成了 洋葱圈 式的调用。

compose 函数干了什么

它主要干这两件事:

  1. 接收一个数组middleware作为参数,且数组中的每一项都是函数

用TS表示的话就是这样:

middleware:function[]

他会对其中每一项进行校验

  1. 返回一个函数,该函数接收contextnext两个参数,并返回一个Promise
    1. Promise就是dispatch函数的返回值,dispatch具体干了什么下面再说
    2. context:就是koa中的ctx
    3. next:下面再细说

只是说传参和返回好像有点抽象

效果

我们可以来看看官方仓库的测试例子来理解一下效果

const compose = require('..')
const assert = require('assert')

function wait (ms) {
  return new Promise((resolve) => setTimeout(resolve, ms || 1))
}
// 分组
describe('Koa Compose', function () {
  it.only('should work', async () => {
    const arr = []
    const stack = []

    stack.push(async (context, next) => {
      arr.push(1)		//1
      await wait(1)
      await next()
      await wait(1)
      arr.push(6)		//6
    })

    stack.push(async (context, next) => {
      arr.push(2) 	//2
      await wait(1)
      await next()
      await wait(1)
      arr.push(5)		//5
    })

    stack.push(async (context, next) => {
      arr.push(3)		//3
      await wait(1)
      await next()
      await wait(1)
      arr.push(4)		//4
    })

    await compose(stack)({})
    // 最后输出数组是 [1,2,3,4,5,6]
    expect(arr).toEqual(expect.arrayContaining([1, 2, 3, 4, 5, 6]))
  })
}

好像就是传入一个函数数组,他会依次执行里面的函数,并且里面的函数调用了next这个方法的话,他就会跳到数组的下一个函数中
我们再结合应用场景看看

compose 应用场景

在日常的开发中,现在有这样一个场景:我们从三个异步请求中获取数据再聚合成一个东西,如果三个服务提供的 service 没有依赖的话,这种情况比较简单,用 Promise.all() 就可以实现。但如果 service2 的请求参数依赖 service1 返回的结果, service3 的请求参数又依赖于 Service2 返回的结果,那就得将一系列的异步请求转成同步请求compose 就可以发挥其作用了

当然,直接Promise链式调用也是可以做到该效果,就像后文中简化的流程一样。但是会如你所见:

  • 回调地狱
  • 代码耦合度极高
  • 不利于维护与更新,比如三个服务的依赖顺序变化,你需要改动多少?
  • 也不利于单元测试

compose 函数 源码

/**
 * Compose `middleware` returning
 * a fully valid middleware comprised
 * of all those which are passed.
 *
 * @param {Array} middleware 
 * @return {Function}
 * @api public
 */

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

	/**
   * @param {Object} context
   * @return {Promise}
   * @api public
   */

	return function (context, next) {
		// last called middleware #
		let index = -1
		return dispatch(0) //返回 dispatch 调用的结果 一个 Promise
		function dispatch (i){
			//...下文说
		}
	}
}

接收中间件数组作为参数,检验之后,返回一个新的函数,新函数接收context、next,新函数调用后返回dispatch(),返回值是一个Promise

其中的 dispatch 函数源码

function dispatch (i) {
	//禁止在一个函数中重复调用next():当next()重复调用,i就会小于index,就会抛出错误
	if (i <= index) return Promise.reject(new Error('next() called multiple times'))
	index = i //更新对应的index
	//取出对应的函数
	let fn = middleware[i]
	if (i === middleware.length) fn = next// middleware 为空的话就将 next 赋值给 fn
	//没有下一个了的话直接返回 Promise.resolve()
	if (!fn) return Promise.resolve()
	try {
		//i+1就是取下一个函数
		return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
	} catch (err) {// fn 执行报错就直接返回失败
		return Promise.reject(err)
	}
}

大部分情况下都是返回成功的Promise并且其中接收 fn执行的结果,fn接收contextdispatch函数本身,测试样例中的next就是这个东西

bind

bind返回一个新的函数,第一个参数就是新函数的this,这里不需要指定this就传入nul。并且传入i+1使得let fn = middleware[i]取到middleware中下一个函数。

next 参数 & 将多个异步函数转为同步的方式

你可能早就猜到next是什么了,是的,他就是下一个中间件中的函数。
或者详细点说 是所有中间件执行完后,框架使用者来最后处理请求和返回的回调函数。同时函数是一个闭包函数,存储了所有的中间件,通过递归的方式不断的运行中间件。

如果中间的某个中间件中没有调用next,后面的中间件就不会执行 —— 一个将多个异步函数转为同步的方式~

聚焦简化 compose 流程

现在看了一遍源码,感觉还是很抽象,似懂非懂的话,我们就再来简化一下看看。
它实际上大概就是这样,疯狂回调

// 这样就可能更好理解了。
// simpleKoaCompose
const [fn1, fn2, fn3] = stack;
const fnMiddleware = function(context){
    return Promise.resolve(
      fn1(context, function next(){
        return Promise.resolve(
          fn2(context, function next(){
              return Promise.resolve(
                  fn3(context, function next(){
                    return Promise.resolve();
                  })
              )
          })
        )
    })
  );
};

也就是说koa-compose返回的是一个Promise,从中间件(传入的数组)中取出第一个函数fn1,传入context和第一个next函数来执行。 第一个next函数里也是返回的是一个Promise,从中间件(传入的数组)中取出第二个函数fn2,传入context和第二个next函数来执行。 第二个next函数里也是返回的是一个Promise,从中间件(传入的数组)中取出第三个函数fn3,传入context和第三个next函数来执行。 第三个... 以此类推。最后一个中间件中有调用next函数,则返回Promise.resolve。如果没有,则不执行next函数。

最终就这样把所有中间件串联起来了,实现了洋葱模型~

compose 错误处理

测试样例:

it('should catch downstream errors', async () => {
  const arr = []
  const stack = []

  stack.push(async (ctx, next) => {
    arr.push(1)
    try {
      arr.push(6)
      await next()
      arr.push(7)
    } catch (err) {
      arr.push(2)
    }
    arr.push(3)
  })

  stack.push(async (ctx, next) => {
    arr.push(4)
    throw new Error()
  })

  await compose(stack)({})
  // 输出顺序 是 [ 1, 6, 4, 2, 3 ]
  expect(arr).toEqual([1, 6, 4, 2, 3])
})

对应的源码是try catch那一段:

try {
    return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
} catch (err) {
  return Promise.reject(err) //出去后就给catch,就进入 push(2),不会再去 push(7)了
}

stackthrow new Error()后就给源码中捕捉err,出去后就给catch,执行 push(2),不会再去 push(7)

责任链模式(职责链模式)

实际上,这 koa-compose 还涉及了该设计模式
定义:

为了避免请求发送者与多个请求处理者耦合在一起,将所有请求的处理者通过前一对象记住其下一个对象的引用而连成一条链;当有请求发生时,可将请求沿着这条链传递,直到有对象处理它为止。

在 koa-compose 中,这里的下一个引用就是next,一个函数,调用下一个

学习资源

总结 & 收获

一句话总结的话就是:利用递归实现了 Promise 的链式执行,不管中间件中是同步还是异步都通过 Promise 转成异步链式执行,再将异步流程同步化,解决回调地狱等问题

  • 如果是之前读源码时的调试,只是单纯的熟悉或者练习调试,那么这个算是真的上了强度,真的要靠断点跟着执行来搞明白它到底是怎么把中间件串联的。真的非常的妙~
  • 洋葱模型
  • 责任链模式
  • 复习了一些知识点:
    • 高阶函数
    • 闭包
    • Promise
    • bind
  • 通过测试样例来了解效果也是相当不错的方式~

🌊如果有所帮助,欢迎点赞关注,一起进步⛵