职责链模式的使用和探讨

1,236 阅读11分钟

本篇文章为了描述我在项目中使用职责链模式解决一连串弹框校验的问题。保存数据前,需要对数据做一些校验,不满足校验条件的需要弹框提示,可能会有连续多个弹框,有的弹框只有一个按钮“确认”,点击后不允许保存;有的弹框有两个按钮“确认”和“取消”,点击“确认”,进行下一步校验,点击“取消”,关闭弹框且不允许保存。只有通过所有校验后,才允许保存。

在我们的项目的业务场景中,做采购订单,某门店的业务员可以在我们的后台管理系统采购订单页面进行采购业务的操作。该业务员先选择供应商,然后选择供应商提供的商品,生成一个商品表格,表格中每个商品的价格、数量可以修改,表明用户将以什么样的价格和数量采购该商品。其中金额字段是根据(金额 = 价格 * 数量)实时计算的,用户修改商品的价格或数量,就会同步计算出采购该商品所需的金额。现在业务员经过一系列操作后,最终表格里选择了两个商品(为简化,已经忽略了商品的一些其他字段,如规格、零售价等),商品的信息用数组表示如下:

const productList = [
    {
        ItemId:10001, // 商品ID
        Name:'可口可乐', // 品名
        Unit:'瓶', // 单位
        IsFresh:false, // 是否为生鲜商品
        CostPrice:1.5, // 成本价
        PurcPrice:1.5, // 档案进价
        Price:2, // 价格
        Qty:100, // 数量
        Amount:200, // 金额
    },
    {
        ItemId:10009, // 商品ID
        Name:'猪肉', // 品名
        Unit:'斤', // 单位
        IsFresh:true, // 是否为生鲜商品
        CostPrice:15, // 成本价
        PurcPrice:15, // 档案进价
        Price:21, // 价格
        Qty:100, // 数量
        Amount:2100, // 金额
    }
]

业务员选完商品后,准备点击保存,从而生成一个采购订单。但是在保存之前,需要根据采购设置对业务员选择的商品进行一些校验,假设系统中的采购设置有如下两个选项:1、 采购价格大于成本价时是否提示 2、生鲜商品是否允许采购价格大于档案进价

image.png

并且采购设置中属性的状态也已经通过接口获取到了,用代码表示如下:

const settings = {
    // 采购价格大于成本价时是否提示
    IsHintWhilePriceHigherThanCostPrice: true, 
    // 生鲜商品是否允许采购价格大于档案进价
    FreshAllowPriceHigherThanPurcPrice: false
}

根据需求文档,保存前需要对数据进行如下的校验:

  1. 商品的金额(Amount)不能超过99999999,如超过,提示 “金额不能超过99999999” 且不允许保存

  2. 采购设置中启用"采购价格大于成本价时是否提示"时:

    a. 如果存在某一商品满足条件(Price > CostPrice),提示:“价格高于成本价,是否继续保存?” (确认、取消)

    i. 用户选择 “确认”:进行下一个步骤的校验(第3步骤)

    ii. 用户选择 “取消”:程序停止,不允许保存

  3. 采购设置中不启用 “生鲜商品是否允许采购价格大于档案进价” 时:

    a. 如果存在某一商品满足条件(IsFresh == true && Price > PurcPrice),提示 “采购设置中设置了不允许生鲜商品采购价格高于档案进价,请重新输入价格!”,程序停止,不允许保存。

  4. 如果通过了1、2、3的校验,可以进行保存

保存的方法已经写好了,如下:

async function save() {
    if (!(await validate())) return
  
    // ......调用接口对数据进行保存
}

关键在于validate方法怎么写,如果按照传统的写法,可能会写出这样的代码


async function validate() {
  const data = productList
  // 校验第一步:商品的金额(Amount)不能超过99999999
  const needTip = data.some((item) => item.Amount > 99999999)
  if (needTip) {
    return new Promise((resolve) => {
      Modal.confirm({
        title: '提示',
        icon: createVNode(ExclamationCircleOutlined),
        okText: '确定',
        content: '小计金额不能超过99999999',
        cancelText: createVNode(),
        async onOk() {
          resolve(false)
        }
      })
    })
  } else if (settings.IsHintWhilePriceHigherThanCostPrice) {
    // 校验第二步:"采购价格大于成本价时是否提示"
    const needTip = data.some((item) => item.ItemId && item.Price > item.CostPrice)
    if (needTip) {
      return new Promise((resolve) => {
        const modal = Modal.confirm({
          title: '提示',
          icon: createVNode(ExclamationCircleOutlined),
          okText: '确定',
          content: '价格高于成本价,是否继续保存?',
          cancelText: '取消',
          async onOk() {
            nextTick(() => modal.destroy())
            // 校验第三步:生鲜商品是否允许采购价格大于档案进价
            resolve(await validateFresh())
          },
          async onCancel() {
            nextTick(() => modal.destroy())
            resolve(false)
          }
        })
      })
    } else {
      return await validateFresh()
    }
  } else {
    return await validateFresh()
  }

  // 校验第三步:生鲜商品是否允许采购价格大于档案进价
  async function validateFresh() {
    if (!settings.FreshAllowPriceHigherThanPurcPrice) {
      const needTip = data.some((item) => item.ItemId && item.IsFresh && item.Price > item.PurcPrice)
      if (needTip) {
        return new Promise((resolve) => {
          const modal = Modal.confirm({
            title: '提示',
            icon: createVNode(ExclamationCircleOutlined),
            okText: '确定',
            content: '采购设置中设置了不允许生鲜商品采购价格高于档案进价,请重新输入价格!',
            cancelText: createVNode(),
            async onOk() {
              nextTick(() => modal.destroy())
              resolve(false)
            }
          })
        })
      }
    }
    return true
  }
}

仔细阅读validate的代码实现,它的确能够实现我们的功能,但是它有如下几个缺点:

a. if - else 语句看起来比较杂乱。

b. 三个校验的代码强耦合在一起,使它们难舍难分。如果需求改变了,需要改变校验的顺序,改动起来是还是挺费劲的,且容易改错,也违背了开放-封闭原则(当需要改变一个程序的功能或者给这个程序增加新功能的时候,可以使用增加代码的方式,但是不允许改动程序的源代码)。

c. 第二个校验弹框有两个选择(确认、取消),点击“确认”会继续下一个校验,点击“取消”会终止校验。这里就产生了回调嵌套的代码,好在上面的代码已经将第三个校验单独抽离成了一个函数,否则代码还将更加难看。考虑到现在只有三个校验弹框,且只有一个弹框中有两个分支,已经是非常简单的情况了。如果有六七个校验,其中有两三个校验存在两个分支,则按照上面的方式编写的代码将难以维护。而我在真实项目中就是要处理六七个校验,且有两个是双分支的情况。

下面就让我们认识下什么是职责链模式吧:

定义:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系,将这些对象连成一条链,并沿着这条链传递该请求,直到有一个对象处理它为止。 ————《javascript设计模式与开发实践》曾探

在我们这个例子中,我们需要做如下几个步骤:

a. 将每个校验先封装成一个函数

b. 再将这些校验函数封装成一个个职责链节点(其实就是一个对象)

c. 指定节点在职责链中的顺序

d. 把请求传递给第一个节点

我们的目的是让每个节点都有处理请求的机会,每个节点内部会根据具体业务决定节点是继续往下传递还是就此终止。使用职责链模式的好处显而易见:

a. 每个校验都会被封装成一个函数,成为一个独立的个体,解除了耦合关系。

b. 多个校验之间不需要使用if - else进行判断。

c. 可以灵活地拆分重组校验节点,从而轻易达到改变校验顺序的需求。

下面我们先来实现一个生成职责链节点的类Chain:

/**
 * 职责链
 */
class Chain {
    constructor(fn) {
        this.fn = fn
        this.successor = null
    }

    setNextSuccessor(successor) {
        return (this.successor = successor)
    }

    async passRequest() {
        const res = await this.fn.apply(this, arguments)
        if (res === 'nextSuccessor' && this.successor) {
            return await this.successor.passRequest.apply(this.successor, arguments)
        }
        return !!res
    }
}

接着我们重构之前的代码(使用vue中组合式api的风格将校验函数封装在一个单独的函数useValidate中):


	// (步骤a)将每个校验封装成一个函数
  function useValidate(data) {
    // 金额不能大于99999999
    async function validateAmount() {
      const needTip = data.some((item) => item.Amount > 99999999)
      if (needTip) {
        return new Promise((resolve) => {
          Modal.confirm({
            title: '提示',
            icon: createVNode(ExclamationCircleOutlined),
            okText: '确定',
            content: '小计金额不能超过99999999',
            cancelText: createVNode(),
            async onOk() {
              resolve(false)
            }
          })
        })
      }
      return 'nextSuccessor'
    }

    // 采购价格大于成本价时是否提示
    async function validateIsHintWhilePriceHigherThanCostPrice() {
      if (settings.IsHintWhilePriceHigherThanCostPrice) {
        const needTip = data.some((item) => item.ItemId && item.Price > item.CostPrice)
        if (needTip) {
          return new Promise((resolve) => {
            const modal = Modal.confirm({
              title: '提示',
              icon: createVNode(ExclamationCircleOutlined),
              okText: '确定',
              content: '价格高于成本价,是否继续保存?',
              cancelText: '取消',
              async onOk() {
                nextTick(() => modal.destroy())
                resolve('nextSuccessor')
              },
              async onCancel() {
                nextTick(() => modal.destroy())
                resolve(false)
              }
            })
          })
        }
      }
      return 'nextSuccessor'
    }

    // 生鲜商品是否允许采购价格大于档案进价
    async function validateFreshAllowPriceHigherThanPurcPrice() {
      if (!settings.FreshAllowPriceHigherThanPurcPrice) {
        const needTip = data.some((item) => item.ItemId && item.IsFresh && item.Price > item.PurcPrice)
        if (needTip) {
          return new Promise((resolve) => {
            const modal = Modal.confirm({
              title: '提示',
              icon: createVNode(ExclamationCircleOutlined),
              okText: '确定',
              content: '采购设置中设置了不允许生鲜商品采购价格高于档案进价,请重新输入价格!',
              cancelText: createVNode(),
              async onOk() {
                nextTick(() => modal.destroy())
                resolve(false)
              }
            })
          })
        }
      }
      return 'nextSuccessor'
    }

    return {
      validateAmount,
      validateIsHintWhilePriceHigherThanCostPrice,
      validateFreshAllowPriceHigherThanPurcPrice
    }
  }

async function validate() {
    const { validateAmount, validateIsHintWhilePriceHigherThanCostPrice, validateFreshAllowPriceHigherThanPurcPrice } = useValidate(productList)
    
    // (步骤b)将校验函数封装成一个个职责链节点
    const nodeAmount = new Chain(validateAmount)
    const nodeCostPrice = new Chain(validateIsHintWhilePriceHigherThanCostPrice)
    const nodeFresh = new Chain(validateFreshAllowPriceHigherThanPurcPrice)

    // (步骤c)指定节点在职责链中的顺序
    nodeAmount.setNextSuccessor(nodeCostPrice).setNextSuccessor(nodeFresh)
    
    // (步骤d)把请求传递给第一个节点
    return await nodeAmount.passRequest()
 }

上面这段代码可以实现和之前同样的效果,同时解决了之前存在的一些问题。当我们想要修改校验顺序时,只需改动步骤c那一行代码即可;当我们想要添加校验时,只需在useValidate中添加一个校验函数,然后再在步骤b生成校验节点,在步骤c中指定顺序即可;当我们想要删除一个校验时也同样简单。我们删除和添加校验规则以及修改顺序的代价都很小,这段代码比之前好维护多了。

接着我们可以对现有代码做一些简单的优化,我们发现每个校验函数中弹框的逻辑都类似,可以提取为一个函数createModal:


  /**
   * 创建弹框
   * @param {Boolean} withCancel 控制弹框是否显示 '取消' 按钮
   * true => 显示 => 点击 '确定' 按钮时返回 'nextSuccessor'
   * false => 不显示 => 点击 '确定' 按钮时返回 false
   * @param {String} content 弹框的内容
   */
  function createModal({ withCancel = false, content = '' }) {
    return new Promise((resolve) => {
      const modal = Modal.confirm({
        title: '提示',
        icon: createVNode(ExclamationCircleOutlined),
        okText: '确定',
        content,
        cancelText: withCancel ? '取消' : createVNode(),
        async onOk() {
          nextTick(() => modal.destroy())
          resolve(withCancel ? 'nextSuccessor' : false)
        },
        async onCancel() {
          nextTick(() => modal.destroy())
          resolve(false)
        }
      })
    })
  }

下面是完整的代码:

const productList = [
    {
        ItemId:10001, // 商品ID
        Name:'可口可乐', // 品名
        Unit:'瓶', // 单位
        IsFresh:false, // 是否为生鲜商品
        CostPrice:1.5, // 成本价
        PurcPrice:1.5, // 档案进价
        Price:2, // 价格
        Qty:100, // 数量
        Amount:200, // 金额
    },
    {
        ItemId:10009, // 商品ID
        Name:'猪肉', // 品名
        Unit:'斤', // 单位
        IsFresh:true, // 是否为生鲜商品
        CostPrice:15, // 成本价
        PurcPrice:15, // 档案进价
        Price:21, // 价格
        Qty:100, // 数量
        Amount:210, // 金额
    }
]

const settings = {
    // 采购价格大于成本价时是否提示
    IsHintWhilePriceHigherThanCostPrice: true, 
    // 生鲜商品是否允许采购价格大于档案进价
    FreshAllowPriceHigherThanPurcPrice: false
}

/**
* 职责链
*/
class Chain {
  constructor(fn) {
      this.fn = fn
      this.successor = null
  }

  setNextSuccessor(successor) {
      return (this.successor = successor)
  }

  async passRequest() {
      const res = await this.fn.apply(this, arguments)
      if (res === 'nextSuccessor' && this.successor) {
          return await this.successor.passRequest.apply(this.successor, arguments)
      }
      return !!res
  }
}

/**
* 创建弹框
* @param {Boolean} withCancel 控制弹框是否显示 '取消' 按钮
* true => 显示 => 点击 '确定' 按钮时返回 'nextSuccessor'
* false => 不显示 => 点击 '确定' 按钮时返回 false
* @param {String} content 弹框的内容
*/
function createModal({ withCancel = false, content = '' }) {
    return new Promise((resolve) => {
      const modal = Modal.confirm({
        title: '提示',
        icon: createVNode(ExclamationCircleOutlined),
        okText: '确定',
        content,
        cancelText: withCancel ? '取消' : createVNode(),
        async onOk() {
          nextTick(() => modal.destroy())
          resolve(withCancel ? 'nextSuccessor' : false)
        },
        async onCancel() {
          nextTick(() => modal.destroy())
          resolve(false)
        }
      })
    })
}

/**
* 封装校验函数
*/
function useValidate(data) {
    // 金额不能大于99999999
    async function validateAmount() {
      const needTip = data.some((item) => item.Amount > 99999999)
      if (needTip) return await createModal({ content: '小计金额不能超过99999999' })
      return 'nextSuccessor'
    }

    // 采购价格大于成本价时是否提示
    async function validateIsHintWhilePriceHigherThanCostPrice() {
      if (settings.IsHintWhilePriceHigherThanCostPrice) {
        const needTip = data.some((item) => item.ItemId && item.Price > item.CostPrice)
        if (needTip) return await createModal({ withCancel: true, content: '价格高于成本价,是否继续保存?' })
      }
      return 'nextSuccessor'
    }

    // 生鲜商品是否允许采购价格大于档案进价
    async function validateFreshAllowPriceHigherThanPurcPrice() {
      if (!settings.FreshAllowPriceHigherThanPurcPrice) {
        const needTip = data.some((item) => item.ItemId && item.IsFresh && item.Price > item.PurcPrice)
        if (needTip) return await createModal({ content: '采购设置中设置了不允许生鲜商品采购价格高于档案进价,请重新输入价格!' })
      }
      return 'nextSuccessor'
    }

    return {
      validateAmount,
      validateIsHintWhilePriceHigherThanCostPrice,
      validateFreshAllowPriceHigherThanPurcPrice
    }
}

/**
* 校验
*/
async function validate() {
    const { validateAmount, validateIsHintWhilePriceHigherThanCostPrice, validateFreshAllowPriceHigherThanPurcPrice } = useValidate(productList)

    // (步骤b)将校验函数封装成一个个职责链节点
    const nodeAmount = new Chain(validateAmount)
    const nodeCostPrice = new Chain(validateIsHintWhilePriceHigherThanCostPrice)
    const nodeFresh = new Chain(validateFreshAllowPriceHigherThanPurcPrice)

    // (步骤c)指定节点在职责链中的顺序
    nodeAmount.setNextSuccessor(nodeCostPrice).setNextSuccessor(nodeFresh)

    // (步骤d)把请求传递给第一个节点
    return await nodeAmount.passRequest()
}

/**
* 保存
*/
async function save() {
    if (!(await validate())) return

    console.log('通过校验')
    // ......调用接口对数据进行保存
}

看下效果:

331471902311541322281891912311481682.gif

本来我以为写到这里,职责链模式真是非常适合这个案例。直到我又对validate函数进行仔细分析,发现,其实无需用到职责链模式。代码如下:

async function validate() {
    const data = productList
    async function validateAmount() {
      const needTip = data.some((item) => item.Amount > 99999999)
      if (needTip) {
        return new Promise((resolve) => {
          Modal.confirm({
            title: '提示',
            icon: createVNode(ExclamationCircleOutlined),
            okText: '确定',
            content: '小计金额不能超过99999999',
            cancelText: createVNode(),
            async onOk() {
              resolve(false)
            }
          })
        })
      }
      return true
    }

    async function validateIsHintWhilePriceHigherThanCostPrice() {
      const needTip = data.some((item) => item.ItemId && item.Price > item.CostPrice)
      if (needTip) {
        return new Promise((resolve) => {
          const modal = Modal.confirm({
            title: '提示',
            icon: createVNode(ExclamationCircleOutlined),
            okText: '确定',
            content: '价格高于成本价,是否继续保存?',
            cancelText: '取消',
            async onOk() {
              nextTick(() => modal.destroy())
              resolve(true)
            },
            async onCancel() {
              nextTick(() => modal.destroy())
              resolve(false)
            }
          })
        })
      }
      return true
    }

    async function validateFreshAllowPriceHigherThanPurcPrice() {
      if (!settings.FreshAllowPriceHigherThanPurcPrice) {
        const needTip = data.some((item) => item.ItemId && item.IsFresh && item.Price > item.PurcPrice)
        if (needTip) {
          return new Promise((resolve) => {
            const modal = Modal.confirm({
              title: '提示',
              icon: createVNode(ExclamationCircleOutlined),
              okText: '确定',
              content: '采购设置中设置了不允许生鲜商品采购价格高于档案进价,请重新输入价格!',
              cancelText: createVNode(),
              async onOk() {
                nextTick(() => modal.destroy())
                resolve(false)
              }
            })
          })
        }
      }
      return true
    }

    if (!await validateAmount()) return false
    if (!await validateIsHintWhilePriceHigherThanCostPrice()) return false
    return await validateFreshAllowPriceHigherThanPurcPrice()
}