责任链模式最强工具res-chain🚀

1,446 阅读11分钟
image.png

上面的logo是由ai生成

责任链模式介绍

责任链模式(Chain of Responsibility Pattern)是一种行为型设计模式,它通过把请求的发送者和接收者解耦,将多个对象连接成一个链,并沿着这条链传递请求,直到有一个对象能够处理它为止,从而避免了请求的发送者和接收者之间的直接耦合

在责任链模式中,每个处理者都持有对下一个处理者的引用,即构成一个链表结构。当请求从链头开始流经链上的每个处理者时,如果某个处理者能够处理该请求,就直接处理,否则将请求发送给下一个处理者,直到有一个处理者能够处理为止。

这种方式可以灵活地动态添加或修改请求的处理流程,同时也避免了由于请求类型过多而导致类的爆炸性增长的问题。

看完以上责任链的描述,有没有发现跟Node.js的某些库特别的像,没错,就是koa。什么?没用过koa?那我建议你立马学起来,因为它用起来特别的简单。

下面来一个简单使用koa的例子:

const Koa = require('koa');
const app = new Koa();

app.use(async (ctx, next) => {
  if (ctx.request.url === '/') {
    ctx.body = 'home';
    return;
  }
  
  next(); // 执行下面的回调函数
});

app.use(async (ctx, next) => {
  if (ctx.request.url === '/hello') {
    ctx.body = 'hello world';
    return;
  }
});

app.listen(3000);

通过node运行上面的代码,在浏览器请求localhost:3000,接口就会返回home,当我们请求localhost:3000/hello,接口就会返回hello world

上面对请求的处理过程就很符合职责链模式的思想,我们可以清楚的知道每个链做的工作,并且链条的顺序流程也很清晰。

有人就会问,只在一个回调里面也能处理呀,比如下面的代码:

app.use(async (ctx, next) => {
  if (ctx.request.url === '/') {
    ctx.body = 'home';
    return;
  } else if (ctx.request.url === '/home') {
	ctx.body = 'hello world';
	return
  }
});

是的,上面的代码确实可以实现,但是这就要回到我们使用责任链模式的初衷了:为了逻辑解耦。

责任链解决的问题

我们继续接着聊上一节的问题,使用if确实可以实现相同效果,但是在某些场景中,if并没有职责链那么好用,为什么这么说呢。

我们找一个应用案例举个例子:

假设我们负责一个售卖手机的网站,需求的定义是:经过分别缴纳500元定金和200元定金的两轮预订,现在到了正式购买阶段。公司对于交了定金的用户有一定的优惠政策,规则如下:

  • 缴纳500元定金的用户可以收到100元优惠券;
  • 缴纳200元定金的用户可以收到50元优惠券;
  • 没有缴纳定金的用户进入普通购买模式,没有优惠券。
  • 而且在库存不足的情况下,不一定能保证买得到。

下面开始设计几个字段,解释它们的含义:

  • orderType:表示订单类型,值为1表示500元定金用户,值为2表示200元定金用户,值为3表示普通用户。
  • pay:表示用户是否支付定金,值为布尔值true和false,就算用户下了500元定金的订单,但是如果没有支付定金,那也会降级为普通用户购买模式。
  • stock:表示当前用户普通购买的手机库存数量,已经支付过定金的用户不受限制。

下面我们分别用if和职责链模式来实现:

使用if:

const order = function (orderType, pay, stock) {
  if (orderType === 1) {
    if (pay === true) {
      console.log('500元定金预购,得到100元优惠券')
    } else {
      if (stock > 0) {
        console.log('普通用户购买,无优惠券')
      } else {
        console.log('手机库存不足')
      }
    } else if (orderType === 2) {
      if (pay === true) {
        console.log('200元定金预购,得到50元优惠券')
      } else {
        if (stock > 0) {
          console.log('普通用户购买,无优惠券')
        } else {
          console.log('手机库存不足')
        }
      }
    } else if (orderType === 3) {
      if (stock > 0) {
          console.log('普通用户购买,无优惠券')
        } else {
          console.log('手机库存不足')
      } 
  }
}

order(1, true, 500)  // 输出:500元定金预购,得到100元优惠券'

虽然上面的代码也可以实现需求,但是代码实在是难以阅读,维护起来更是困难,如果继续在这个代码上开发,未来肯定会成为一座很大的屎山。

下面我们使用责任链模式来实现:

function printResult(orderType, pay, stock) {
  // 这里ResChain类是模拟koa的写法,后面会讲如何实现ResChain
  // 请先耐心看完它是如何处理的
  const resChain = new ResChain();
  // 针对500元定金的情况
  resChain.add('order500', (_, next) => {
    if (orderType === 1 && pay === true) {
      console.log('500元定金预购,拿到100元优惠券');
      return;
    }
    next(); // 这里将会调用order200对应的回调函数
  });
  // 针对200元定金的情况
  resChain.add('order200', (_, next) => {
    if (orderType === 2 && pay === true) {
      console.log('200元定金预购,拿到50元优惠券');
      return;
    }
    next(); // 这里会调用noOrder对应回调函数
  });
  // 针对普通用户购买的情况
  resChain.add('noOrder', (_, next) => {
    if (stock > 0) {
      console.log('普通用户购买,无优惠券');
    } else {
      console.log('手机库存不足');
    }
  });

  resChain.run(); // 开始执行order500对应的回调函数
}

// 测试
printResult(1, true, 500); // 500元定金预购,得到100元优惠券
printResult(1, false, 500); // 普通用户购买,无优惠券
printResult(2, true, 500); // 200元定金预购,得到50元优惠券
printResult(3, false, 500); // 普通用户购买,无优惠券
printResult(3, false, 0); // 手机库存不足

以上的代码经过责任链处理之后特别的清晰,并且减少了大量的if-else嵌套,每个链的职责分,我们可以看出责任链模式存在的优点:

  1. 降低了代码之间的耦合,很好的对每个处理逻辑进行封装。在每个链条内,只需要关注自身的逻辑实现。
  2. 增强了代码的可维护性。我们可以很轻易在原有链条内的任何位置添加新的节点,或者对链条内的节点进行替换或者删除。

责任链还特别的灵活,如果说后面pm找我们加需求,需要加多一个预付定金400,返回80元优惠券,处理起来也是易如反掌,只需要怼回去,只需要在order500下面加多一个节点处理即可:

... 
resChain.add('order500', (_, next) => {
  if (orderType === 1 && pay === true) {
    console.log('500元定金预购,拿到100元优惠券');
    return;
  }
  next();
})
+ // 加上这一块
+ resChain.add('order400', (_, next) => {
+  if (orderType === 3 && pay === true) {
+    console.log('400元定金预购,拿80元优惠券');
+    return;
+  }
+  next();
+ })

resChain.add('order200', (_, next) => {
  if (orderType === 2 && pay === true) {
    console.log('200元定金预购,拿到50元优惠券');
    return;
  }
  next();
})
...

就是这么简单。那这个ResChain是如何实现的呢?

封装ResChain

先别急,首先我们来了解一下koa是如何实现:在链节点的回调函数内调用next就可以跳到下一个节点的呢?

话不多说,直接看源码,参考的库是koa-compose,代码也是特别的简洁:

function compose (middleware) {
  // 这里传入的middleware是函数数组,例如: [fn1, fn2, fn3, fn4, ...]
  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) {
    // last called middleware #
    let index = -1
    return dispatch(0);
    
    // 这里利用了函数申明提升的特性
    function dispatch (i) {
	  // 这里是防止重复调用next
      if (i <= index) return Promise.reject(new Error('next() called multiple times'))
      index = i
      
      // 从middleware中取出回调函数
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      
      // 如果fn为空了,则结束运行
      if (!fn) return Promise.resolve()
      
      try {
        // next函数其实就是middleware的下一个函数,执行next就是执行下一个函数
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)))
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

看完源代码,我们接着来实现ResChain类,首先整理一下应该要有的方法:

  • add方法。可以添加回调函数,并按添加的顺序执行。
  • run方法。开始按顺序执行责任链。

add方法执行的时候,把回调函数按顺序push进一个数组中。

export class ResChain {
  
  /**
   * 按顺序存放链的key
   */
  keyOrder = [];
  /**
   * key对应的函数
   */
  key2FnMap = new Map();
  /**
   * 每个节点都可以拿到的对象
   */
  ctx = {}
  constructor(ctx) {
	this.ctx = ctx;
  }

  // 这里用key来标识当前callback的唯一性,后面重复添加可以区分。
  add(key, callback) {
    if (this.key2FnMap.has(key)) {
      throw new Error(`Chain ${key} already exists`);
    }

    this.keyOrder.push(key);
    this.key2FnMap.set(key, callback);
    return this;
  }

  async run() {
    let index = -1;
    const dispatch = (i) => {
      if (i <= index) {
        return Promise.reject(new Error('next() called multiple times'));
      }

      index = i;
      const fn = this.key2FnMap.get(this.keyOrder[i]);
      if (!fn) {
        return Promise.resolve(void 0);
      }

      return fn(this.ctx, dispatch.bind(null, i + 1));
    };

	return dispatch(0);
  }
}

add方法的第一个参数key可以用来判断是否已经添加过相同的回调。

有人会说,koa的中间件是异步函数的,你这个行不行?

当然可以,接下来看个异步的例子:

const resChain = new ResChain();

resChain.add('async1', async (_, next) => {
  console.log('async1');
  await next();
});


resChain.add('async2', async (_, next) => {
  console.log('async2')
  // 这里可以执行一些异步处理函数
  await new Promise((resolve, reject) => {
    setTimeOut(() => {
      resolve();
    }, 1000)
  });

  await next();
});


resChain.add('key3', async (_, next) => {
  console.log('key3');
  await next();
});


// 执行责任链
await resChain.run();

console.log('finished');

// 先输出 async1 async2 然后停顿了1秒钟之后,才输出async3 finished

🚧 需要注意:如果是异步模式,则链上的每个回调函数必须要 await next(),因为next函数代表下一个环的异步函数。

koa的中间件方式简直一毛一样。

有人可能还注意到了,ResChain实例化的时候可以传入对象,比如下面的代码:

const resChain = new ResChain({ interrupt: false });

传入对象具体有什么用法呢?可以用来获取一些在链中处理好的数据,来实现发送者和处理者的解耦。可能比较抽象,我们来举个例子。

比如需要进行数据校验的场景,如果不通过,则中断提交:

const ctx = {
  // 表单项
  model: {
    name: '',
    phone: '',
  },
  // 错误提示
  error: '',
  // 是否中断
  interrupt: false,
}
const resChain = new ResChain(ctx);

resChain.add('校验name', (ctx, next) => {
  const { name = '' } = ctx;
  if (name === '') {
    ctx.error = '请填写name';
    ctx.interrupt = true;
    return;
  }

  next();
})

resChain.add('校验phone', (ctx, next) => {
  const { phone = '' } = ctx;
  if (phone === '') {
    ctx.error = '请填写手机号';
    ctx.interrupt = true;
    return;
  }

  next();
})

// 执行责任链
resChain.run();

// 如果需要中断,则提示
if (resChain.ctx.interrupt) {
  alert(resChain.ctx.error);
  return;
}

如果是使用if来实现:

const ctx = {
  // 表单项
  model: {
    name: '',
    phone: '',
  },
  // 错误提示
  error: '',
  // 是否中断
  interrupt: false,
}

if(ctx.model.name === '') {
  ctx.error = '请填写用户名';
  ctx.interrupt = true;
}

if (!ctx.interrupt && ctx.model.phone === '') {
  ctx.error = '请填写手机号';
  ctx.interrupt = true;
}

// 如果需要中断,则提示
if (resChain.ctx.interrupt) {
  alert(resChain.ctx.error);
  return;
}

可以发现,对phone的判断逻辑,就要先判断interrupt是否为false才能继续,而且如果下面还有其他的字段校验,那必须都走一遍if。

这也是责任链的一个优势,可以在某个环节按自己的想法停止,不用继续走后面的节点。

目前我已经把这个工具上传到npm了,如果想要在自己的项目中使用,直接安装: res-chain即可使用:

npm install res-chain

# 或者
# yarn add res-chain

引入:

import { ResChain } from 'res-chain';
// CommonJS方式的引入也是支持的
// const { ResChain } = 'res-chain';

const resChain = new ResChain();

resChain.add('key1', (_, next) => {
  console.log('key1');
  next();
});

resChain.add('key2', (_, next) => {
  console.log('key2');
  // 这里没有调用next,则不会执行key3
});

resChain.add('key3', (_, next) => {
  console.log('key3');
  next();
});

// 执行职责链
resChain.run(); // => 将会按顺序输出 key1 key2

芜湖起飞🚀。

有了这个工具函数,我们就可以视场景去优化项目中的一大坨if-else嵌套,或者直接使用它来实现一些业务中比较复杂的逻辑。

起源

这个工具诞生的过程还挺巧合的,某一天周六我在公司加班赶需求,发现需要在一堆旧逻辑if-else中添加新的逻辑,强迫症的我实在是无法忍受在💩山上继续堆💩。。。

我陷入沉思,用什么方式去优化呢?看了网上责任链模式的实现,感觉还是不够优雅。

无意中翻到了之前用koa写的项目,突然灵光乍现💡,koa的中间件不就是一个很棒的实践。调用next就能够往下一个节点走,不调用的话就可以终止。

于是立即动工,三下五除二就完成了。我还推荐给部门的其他前端小伙伴,他们也在一些需求的复杂逻辑中有运用。

总结

过去无意学到的某个知识,或者某个概念,在未来也许会发挥作用,你只需要做的就是等待。

没有koa这么棒的库,估计也不会有这工具了,所以还是得感谢它的作者如此聪明。😁

如果你也喜欢这个工具,欢迎去github里给个🌟,感谢。

如果有什么更好的建议,在底下留言,一起探讨。

工具链接

res-chain

参考