为什么说网上99%的策略模式都有问题?带你设计一个工程上可用的策略模式

3,384 阅读11分钟

常见的策略模式分析

当你在网上搜索策略模式的时候,你能看到很多的教程。前几天还有一个冲上热度榜第一的文章,juejin.cn/post/727904…

榜单.png

但是你发现当你用了他们的模式之后,放在你的项目中,反而使得你的项目变得更加复杂和臃肿了。甚至于我之前的leader跟我说策略模式不适合用于深层嵌套的语句中。那么他们为什么会出现这种认为用这玩意不如不用的想法呢,在我看来,他们的用法和设计是有大问题的

误区 | 弊端 | 为什么说网上99%的策略模式都有问题

好了我们来看先来看一下什么是策略模式吧,策略模式的定义:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。 简单来说你在用 if else 的时候,你可以用一个key value 数组来进行 替代。我们来看看上面热度榜第一给出的最终优化后的版本吧。

  const strategies = {
      "high": function (workHours) {
          return workHours * 25
      },
      "middle": function (workHours) {
          return workHours * 20
      },
      "low": function (workHours) {
          return workHours * 15
      },
  }
  
  const calculateSalary = function (workerLevel, workHours) {
      return strategies[workerLevel](workHours)
  }
  console.log(calculateSalary('high', 10)) // 250
  console.log(calculateSalary('middle', 10)) // 200
  

先简单讲解一下这段代码吧。strategies 如果是 定义了 3个状态,在3个状态之下,我们对他传入的第二个参数做三个状态做出分别的处理。为什么说这种的策略模式做法用了还不如不用呢?

  • 第一点:可扩展性语义:你看看现在的策略基本是属于一种根本无法扩展的状态。你如果需要添加一个策略,你需要去到strategies这个地方地方去手动添加。

    • 你也许会说,可扩展性那还不简单,搞一个class,设置一个类似于addfunction之类的属性动态添加这个策略里面的策略。不就解决扩展这个问题了,的确,这样子能够解决扩展的问题。但你要知道,我们在使用calculateSalary 的时候,我们需要判断第一个参数是high还是middle还是low呢。诶,这个时候你会发现,tm的我还要判断这个 Salary 是 high还是 middle还是 low呢

      也就是说,你需要做如下的判断

      if(你的薪水>100){
          calculateSalary('high', 10)
      }else if(你的薪水>30){
          calculateSalary('middle', 10)
      }
      

      额。。。你会发现怎么还是逃不过这个if else。哈哈,我们用设计模式用了一个寂寞(当然聪明的你可能想到了把high | middle | low的判断逻辑抽离出来接着直接传入calculateSalary,这点我们后面再谈)。并且关于这个high | middle | low,万一以后我们需要扩展的话,我们需要怎么命名呢,Much_High 还是 Much_Much_High,这个命名也是一个大问题

  • 第二点:复杂逻辑:你也许会说,即使你说了这么多,那你看,我在工程的确使得这种if/else的判断更加简单了。并且你刚才说的 if else 对于状态的判断,我可以把状态抽离出来。那么还有什么问题吗,有的,那就是对于复杂逻辑的判断

    举一个简单的例子:你的calculateSalary这个函数的第一个参数可能是由 age | region | school 三个属性决定

    那么我们写一个代码

    let age = 20;
    let region = "cn"
    let school = "A"
    let weacher = "rainy"
    let status = ""
    if (age == 20) {
        if (school == "A") {
            if (region == "cn") {
                status = "high";
                if(weacher=="rainy"){
                    status = "middle"
                }
            }
        } else if(school == "B"){
            status = "middle"
        }
    } else if(age == 22) {
        if (school == "A") {
            status = "middle"
        } else if(school == "B") {
            status = "low"
        }
    }
    

    类似于这种形式你先判断出status后,你直接调用

    calculateSalary(status, 10)
    

    有没有用到你说的策略模式?用了。这样子行不行?em......if else仍然满天飞,人多少也麻了。这样子跟咱们把方法封装一下塞进去不是一样吗。也就没必要用到我们的策略模式了

  • 第三点:完全没有默认情况的处理:可以看到这类文章普遍没有一个事件兜底的意识,假如该事件没有命中任何一个事件那么我们要怎么处理呢?

  • 第四点:完全没有特殊情况的处理: 我们看到现在我们可以看到,基本上这些函数都是key-value的数值对,假如说我们有一个需求,需要在 value 数值对的函数中 进行错误的处理 或者 在这个函数中的某一个阶段需要向外面的数据进行 交互(进度条 | promise的数组对外进行组装等等)。这些网上的策略模式都是有大问题的

总结 | 改进方向

因此我们可以从哪几个方面去改进这几个点呢,

  • 首先可扩展性,必然是使用class的工厂模式,内部通过缓存 key value 数组和添加 类似于 addfunction这类的方法动态的添加function。也就是说,我们需要把策略的执行和定义分离出来

  • 然后怎么处理我们的复杂逻辑。在这一点上,我的想法是通过重新设计key-value来进行解决。常规的策略模式的key是status 这样的一维字符串,这就导致了我们对status进行判断的时候,实际上需要前置的做很多工作,例如我们上面的 关于 calculateSalary这个函数的第一个参数 的 判断,if/else 写了一堆,但是如果我们的key设计成这样呢

    {
        age:"20",
        school:"A",
        region:"cn",
        "weacher":"rainy"
    }
    

    这种情况下咱们的 value 也就是 status = "middle"。这样子是不是很清晰。别的情况我们也可以用这个object进行判断就可以了

  • 然后是我上面说的第三点和第四点,也就是我们关于特殊情况和默认情况的处理。这个时候又要用到我们的发布者/订阅者设计模式了,这里我们可以在内部向外面传递一个事件出来。这样我们就有能力去自定义我们的特殊事件

实现策略模式(全等状态)

碎碎念一些需要注意的地方

key的设计

我们把object作为key,那么我们取出这个key的时候,各个object都是引用类型的,那么就导致这样一个情况

  let a = {
      id:1
  }
  let b = {
      id:1
  }
  console.log(a==b) // false

为了解决这一点我们可以将这个object 进行json.stringify

  JSON.stringify(a) ==  JSON.stringify(b)  // true 

但是这样你又发现有一点问题

  let a = {
      name:"xiaoming",
      id:1
  }
  let b = {
      id:1,
      name:"xiaoming",
  }
  console.log(JSON.stringify(a) ==  JSON.stringify(b)) // false

我们key的顺序进行换位又有问题,可以看到我们的值其实是一样的但是比较起来又不对了。因此这里可以考虑对这个 object进行排序,但是object本身是没有顺序的,为了解决这一点,我们会采用map这个数据结构进行处理(因为map的数据结构是有序的),存数据示例如下

  /**
       * @des 属性 和 方法
       * @param HashKey 属性object
       * @param HashValue 方法
  */ 
  const orderedMap = new Map();
  const sortedKeys = Object.keys(HashKey).sort();
  for (const key of sortedKeys) {
      orderedMap.set(key, HashKey[key]);
  }
  let Key = JSON.stringify(Object.fromEntries(orderedMap))
  this.MapHash.set(Key, HashValue)
    

取数据示例如下

  /**
       * @des 属性 和 方法
       * @param HashKey 属性object
  */ 
  const orderedMap = new Map();
  const sortedKeys = Object.keys(HashKey).sort();
  for (const key of sortedKeys) {
      orderedMap.set(key, HashKey[key]);
  }
  let Key =  JSON.stringify(Object.fromEntries(orderedMap));
  if (!this.MapHash.get(Key)) {
      this.emit("default","触发默认方法")
      return 
  }
  let Fn = this.MapHash.get(Key)!

这样子我们在存取数据用object即使key的顺序不一样也能够取出数据了

发布者订阅者模式的实现

一个标准的订阅者发布者模式应该包含一个eventbus的调度中心,两个角色(发布者和订阅者)和两个事件on(注册事件) emit(触发事件)

这里我们的eventbus 设计是让用户传入,我们在工具方法中也只有一个emit方法。你也许会问on事件在我们这个示例中是由用户提前定义的,也就是说,触发的事件是eventbus传入的名字.例如下方就是我们会emit 一个default事件给我们的外部,你在外部可以对这个事件做出一系列操作

  new IfElse({
      eventBus: {
          default: [(e: any) => {
              console.log("触发默认方法:", e)
          }]
      }
  })

在举一个例子,如果你需要对error事件进行监听,你需要在这个class的 内部try catch 捕获异常然后this.emit("error").接着用户传入如下

  new IfElse({
      eventBus: {
          default: [(e: any) => {
              console.log("触发默认方法:", e)
          }],
          error:[(e: any) => {
              console.log("触发error方法:", e)
          }]
      }
  })

这样子我们就可以轻松处理当所有策略没有命中的事件和处理报错的事件

源码地址:github.com/electroluxc…

优化(非全等状态)

但是直到这里,仍然有问题,上面的策略只能解决 全等的情况,但是 类似于 > < 这种大于等于以及根据两种情况的复杂的大于等于的情况我们并不能解决。在这个情况中。我们可以思考我们的key 是不是还有更加可以优化的地方。我们之前是 根据 object 和 对应的 value 做一个映射。我们现在可以舍弃掉key,取而代之,我们通过传入一个 function 进行判断,先得到 策略名称后在 执行对应的策略

let judge = (param1) => {
    if (param1 > 1000) {
        return "fly"
    }
    if (param1 < 30) {
        return "run"
    }
}

然后我们重新造一个 class类的 JudgeFnSet 方法

JudgeFnSet(config: Function) {
    this.JudgeFn = config
}

执行的逻辑我们也改一下

Execute<k extends  EventBusType>(JudgeData: JudgeType) {
    let StrategyName
    let StrategyFn
    try{
        StrategyName = this.JudgeFn(JudgeData)
    }catch{
        this.emit("error" as k,"utilmonorepo提示:judge判断报错")
        return
    }
    StrategyFn = this.StrategyObject[StrategyName]
    if (!this.StrategyObject[StrategyName]) {
        this.emit("default" as k , "utilmonorepo默认提示:似乎并没有")
        return
    }
}

对应的 我们要重新 设置 策略传入的 方法

private StrategyObject: Record<any,any>
private JudgeFn:Function;
private config: DeepShowType<StrategyEnanceType<EventBusType>>;
constructor() {
    this.StrategyObject = {}
}

/**
 * @des 策略的添加
 */
StrategyAdd<k extends  StrategyType>(StrategyName: k, HashFn: Function) {
    this.StrategyObject[StrategyName] = HashFn
}

最后简单写一下我们最终的调用方式

import { StrategyEnance } from "./StrategyEnance.js"
let lk = new StrategyEnance()
let judge = (param1)=>{
    if(param1>1000){
        return "fly"
    }
    if(param1<500){
        return "run"
    }
}

let lk = new StrategyEnance<"run" | "fly",{id:number},any>()
lk.EventBusSet({
    eventBus:{
        default:[()=>{
            console.log("触发defalut")
        }],error:[()=>{
            console.log("触发了error")
        }]
    }
})
lk.JudgeFnSet(judge)

lk.StrategyAdd("fly",()=>{
    console.log("触发了fly",)
})
lk.StrategyAdd("run",()=>{
    console.log("触发了run",)
})
lk.Execute(
    56
)

在最终我们只需要 执行 lk.Execute(56) 就可以 执行整条链路

回顾一下,应该是解决了一些传统策略模式存在的问题

  • 第一点:可扩展性和 语义
  • 第二点:复杂逻辑
  • 第三点:自定义事件

完整代码见下方链接

github.com/electroluxc…

针对于评论区的一些质疑(2023.9.26)

不知道为啥,评论区环境那么差,很多人一上来就甩个什么四字词语给程序定性,我建议是拿出你的论点来,都是成年人了,不要跟小孩子吵架一样。首先跟朋友们说明一点,这个设计已经用到了我们研究院项目中去。虽然给出的源码有点不同,但是基本原理是相通的。也是经受起几次工程迭代和考验的设计模式。接下来针对评论区的问题给予一些回应

  • 过度设计:首先 我代码加起来一共80行,用的都是基本的程序设计理念。你如果非要说所谓的过度设计,那我觉得你要么做的都是那种外包crud的项目,要么你本人也缺乏程序基本的边界,鲁棒性和功能扩展意识。并且居然还有人说看不懂的,我想说假如80行代码你还说看不懂,那是不是你应该反思一下自己的水平。
  • key的设计:评论区有人说这个玩意只用执行就好了,识别并不是他的的职责。那你传入一维的object是不是一样的效果 例如直接把key置为{strategies:"high"} 。并且我不太理解识别为什么不是他的职责,你在编程中是不是不需要if/else对于状态的判断?
  • 关于eventbus:我在文章中说过这一块的设计是为了扩展性。简单举个例子,我们研究院目前有一个场景是分文件后缀上传, xml上传一个地方 | dwg上传一个地方 | img/mp4上传一个地方. 上传后做optimistic的处理,直接message.success("请到后台查看")。本来到这里就是1.0的版本,后来新增了几个需求
    • 上传过程中返回 进度条数据
    • 上传失败后 告知用户
    • 错误重试机制(区分文件)
    • 上传过程中拿到自定义参数组装后树结构返回后端
    • 对这几个文件上传的表单的名字/大小/文件指纹做分别校验
    eventbus允许你自定义事件然后针对于这些需求创造事件,接着在源码中emit给主处理函数就可以了。如果你说你就是要针对于 xml | dwg | img/mp4 的 写 m * n 个事件来分别处理这些需求,而弃用这个设计。那我也无话可说

好了,就这样吧。有问题评论区见,希望能进行理性的探讨