洋葱模型—从理解到实践

2022-10-05 16:02:14 浏览数 (2)

本文主要针对项目中遇到的问题,引申到koa-compose原理解析。通过学习洋葱模式来解决我们实际项目中的问题

前言

先来听听一个故事吧,今天产品提了一个业务需求:用户在一个编辑页面,此时用户点击退出登录,应用需要提示用户当前有编辑内容未保存,是否保存;当用户操作完毕后再提示用户是否退出登录。

流程如下:

因为退出登录是属于公共部分由另一位同学维护,此时和他交流后“善良”的把需求仍给了他。并告知他可以通过某某方法获取我当前是否有编辑内容。然后我继续摸鱼,他开始疯狂输出

代码语言:javascript复制
const handlerLogout = async () => {
    if (window.location.href === 'xxx') {
        if (getEditState() === 'xxx') {
            await editConfirm()
        }
    }
    await logoutConfirm();
}

功能如约上线,新需求也如约到达:产品期望用户在VIP充值页面退出登录的时候,先弹出一个VIP充值广告,当用户关闭广告后再提示用户是否退出登录。

流程如下:

然后熟悉的场景、熟悉的人,在一番交流过后,那位同学略微暴躁的又开始疯狂输出,然后我继续摸鱼

代码语言:javascript复制

const pages = {
    editPage: async () => {
        if (getEditState() === 'xxx') {
            await editConfirm()
        }
    },
    vipPage: async () => {
        if (getUserVipState() === 'xxx') {
            await vipConfirm()
        }
    }
}

const handlerLogout = async () => {
    const curPage = getPage();
    await pages[curPage];
    await logoutConfirm();
}

然后的然后功能又如约上线,然后需求又来了,一个场景中有多个弹窗业务,优先级不同,如果弹窗1不满足弹出条件,就使用弹窗2依此类推。众所周知产品的需求怎么做的完,他终于受不了了,开始思考怎么样自己才能摸摸鱼。与似乎邪恶的想法油然而生,如果自己维护的退出登录就只关注处理退出登录的业务,而其他业务的各种弹窗让业务方自己去处理那我就可以摸鱼啦。想法有了,拆解一下逻辑,底层逻辑就是在触发时需要有很多中间层的处理,等中间层处理完成后再处理自己的。那这不就像是洋葱模型吗。

洋葱模型

提到洋葱模型,koa的实现简单且优雅。koa中主要使用koa-compose来实现该模式。核心内容只有十几行,但是却涉及到高阶函数、闭包、递归、尾调用优化等知识,不得不说非常惊艳没有一行是多余的。简单来说,koa-compose暴露出一个compose方法,该方法接受一个中间件数组,并返回一个Promise函数。源码如下

代码语言:javascript复制
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)
    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
      if (!fn) return Promise.resolve()
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i   1)))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

源码中compose主要做了三件事

第一步:进行入参校验

第二步:返回一个函数,并利用闭包保存middleware和index的值

第三步:调用时,执行dispatch(0),默认从第一个中间件执行

dispatch函数的作用(dispatch其实就是next函数)

第一步:通过i <= index来避免在同一个中间件中连续next调用

第二步:设置index的值为当前中间件位置的值,并且拿到当前中间件函数

第三步:判断当前是否还有中间件,没有返回Promise.resolve()

第四步:返回Promise.resolve并把当前中间件执行结果做为返回,且传入context和next(dispatch)方法。这里利用尾调优化,避免了fn重新创建新的栈帧,同时提升了速度和节省了内存(大佬就是大佬)

我们可以通过其测试用例了解到执行的过程,有条件的读者可以通过下载源码进行断点调试,更能理解每一步的过程

代码语言:javascript复制
it('should work', async () => {
  const arr = []
  const stack = []

  stack.push(async (context, next) => {
    arr.push(1) // 步骤1
    await wait(1) // 步骤2
    await next() //  步骤3
    await wait(1) // 步骤14
    arr.push(6) // 步骤15
  })

  stack.push(async (context, next) => {
    arr.push(2) // 步骤4
    await wait(1) // 步骤5
    await next() // 步骤6
    await wait(1) // 步骤12
    arr.push(5) // 步骤13
  })

  stack.push(async (context, next) => {
    arr.push(3) // 步骤7
    await wait(1) // 步骤8
    await next() // 步骤9
    await wait(1) // 步骤10
    arr.push(4) // 步骤11
  })

  await compose(stack)({})
  expect(arr).toEqual(expect.arrayContaining([1, 2, 3, 4, 5, 6]))
})

compose接收一个参数,该参数是一个Promise数组,注入中间件后返回了一个执行函数并执行。此时会按照上诉我标记的步骤进行执行。配置koa文档中的gif示例和流程图更好理解。通过不断的递归加上Promise链式调用完成了整个中间件的执行

实践

已经了解到洋葱模型的设计,按照当前摸鱼的诉求,期望stack.push这部分内容由业务方自己去注入,而退出登录只需要执行compose(stack)({})即可,额外诉求是项目中期望对弹窗有优先级的处理,那就是不是谁先进入谁先执行。对此改造一下middleware定义,新增level表示优先级后续它进行排序,优先级越高设置level值越高即可。

代码语言:javascript复制
type Middleware<T = unknown> = {
  level: number;
  middleware: (context: T | undefined, next: () => Promise<any>) => void;
};

因为我们需要提供给业务方一个接口来添加中间件,这里使用类来实现,通过暴露出add和remove方法对中间件进行添加和删除,利用add方法在添加时利用level对中间件进行排序,使用stack来保存已经排序好的中间件。dispatch通过CV大法实现

代码语言:javascript复制
class Scheduler<T> {
  stack: Middleware<T>[] = [];

  add(middleware: Middleware<T>) {
    const index = this.stack.findIndex((it) => it.level <= middleware.level);
    this.stack.splice(index === -1 ? this.stack.length : index, 0, middleware);
    return () => {
      this.remove(middleware);
    };
  }

  remove(middleware: Middleware<T>) {
    const index = this.stack.findIndex((it) => it === middleware);
    index > -1 && this.stack.splice(index, 1);
  }
  
  dispatch(context?: T) {
    // eslint-disable-next-line
    const that = this;
    let index = -1;
    return mutate(0);
    function mutate(i: number): Promise<void> {
      if (i <= index) return Promise.reject(new Error('next() called multiple times'));
      index = i;
      const fn = that.stack[i];
      if (index === that.stack.length) return Promise.resolve();
      try {
        return Promise.resolve(fn.middleware(context, mutate.bind(null, i   1)));
      } catch (error) {
        return Promise.reject(error);
      }
    }
  }
}

export default Scheduler;

然后修改业务中的处理,之后再加类似需求就可以摸鱼了。

代码语言:javascript复制
// 暴露一个logoutScheduler方法
export const logoutScheduler = new Scheduler();

const handleLogout = () => {
    logoutScheduler.dispatch().then(() => {
        logoutConfirm();
    })
}

// 编辑页面
logoutScheduler.add({
    level: 2,
    middleware: async (_, next) => {
        if (getEditState() === 'xxx') {
          await editConfirm()
        }
        await next();
    }
})

// vip页面
logoutScheduler.add({
    level: 2,
    middleware: async (_, next) => {
        if (getUserVipState() === 'xxx') {
            await vipConfirm()
        }
        await next();
    }
})

总结

一个好的设计能在实际开发中更好的去解耦业务,而好的设计需要我们去阅读那些优秀的源码去学习和理解才能为我们所用。

0 人点赞