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

Pasted image 20240515003946

责任链模式介绍

责任链模式(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()
    
    resChain.add('order500', (_, next) => {
        if (orderType === 1 && pay === true) {
            console.log('500元定金预购,拿到100元优惠券');
            return;
        }
        next(); // 这里将会调用order200对应的回调函数
    })

    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
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      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:

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

  add(key: string, 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);
  }
}

以上就是我参考koa实现的责任链工具函数:ResChain

有些人会说,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函数代表下一个环的异步函数。

有人可能还注意到了,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就能够往下一个节点走,不调用的话就可以终止。

总结

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

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

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

工具链接

参考

https://juejin.cn/post/6844903855348514829