策略模式详解

58 阅读7分钟

1 设计模式

在1995年GoF提出23种设计模式之后,设计模式就成为了程序员认为的”高级技巧“。其实“模式”最早诞生在20世纪70年代的建筑学,由哈弗大学建筑学博士Christopher Alexander从解决同一问题的不同设计方案中找出的相似性,并用“模式”来指代这种相似性,因此设计模式并非是只用于软件开发。设计模式的定义是:在面向对象软件设计过程中针对特定问题的简洁而优雅的解决方案。直白的说,设计模式是在某种场合下对某个问题的一种解决方案。如果再直白一点的说,设计模式就是给面向对象软件开发中的一些好的设计取个名字。下面我们说一下设计模式中的策略模式,使用的编程语言是javaScript并且尽可能的保持语法简单。

2 策略模式

俗话说,条条大路通罗马,比如我们要去某个地方旅游,有很多途径可以选择。

  1. 如果没有时间并且不在乎钱的话,可以坐飞机。
  2. 如果没有钱,可以选择坐火车。
  3. 如果在穷一点的话可以骑自行车。 在设计程序时,可以有多种方案来实现一个功能,比如我们进行对称加密,既可以选择DES算法,也可以选择RC2算法,还可以选择Blowfish等。我们可以随意在它们之间进行替换,这种解决方案就是策略模式。

    策略模式的具体定义:封装一系列算法,并且可以相互替换

3 使用策略模式计算价格

假如我们有很多品质不同的产品,B品质为基本价格的2倍,A品质为基本价格的3倍,S品质的为基本价格的4倍。现在我们来用代码实现这个功能。

3.1 初版代码

实现calculatePrice函数,参数为商品基本价格,和品质等级。

    function calculatePrice(qualityLevel, basePrice){
        if(qualityLevel === 'S') {
            return basePrice * 4
        }
        if(qualityLevel === 'A') {
            return basePrice * 3
        }
        if(qualityLevel === 'B') {
            return basePrice * 2
        }
    }

    calculatePrice('B', 100) //输出:200
    calculatePrice('S', 200) //输出:800

这段代码有着十分明显的缺点:

  1. calculatePrice函数包含了很多 if 语句,必须要包含所有逻辑分支,使函数变得庞大。
  2. calculatePrice缺乏扩展性,如果加入其它品质的商品的话,必须深入函数内部修改,这就打破了开放封闭原则。
  3. 不方便复用,只能复制粘贴。

3.2 重构代码

将计算逻辑抽离到不同的函数,使其可以进行复用。

    function qualityS(basePrice){
        return basePrice * 4
    }
    function qualityA(basePrice){
        return basePrice * 3
    }
    function qualityB(basePrice){
        return basePrice * 2
    }
    function calculatePrice(qualityLevel, basePrice){
        if(qualityLevel === 'S') {
            return qualityS(basePrice)
        }
        if(qualityLevel === 'A') {
            return qualityA(basePrice)
        }
        if(qualityLevel === 'B') {
            return qualityB(basePrice)
        }
    }

可以看出重构之后的效果还是不理想。

3.3 使用策略模式(面向对象)

设计模式的主题是将不变的部分和变化的部分分隔开来,所以策略模式需要将算法的使用和与实现分离开来,基于策略模式程序最少需要两个部分,一部分是一组策略类来实现具体的算法,另一部分是环境类来接收客户的请求,并且将请求分发给对应的策略类。

    class QualityS {
        calculate(basePrice){
            return basePrice * 4
        }
    }
    class QualityA {
        calculate(basePrice){
            return basePrice * 3
        }
    }
    class QualityB {
        calculate(basePrice){
            return basePrice * 2
        }
    }
    class Price {
        constructor(){
            this.basePrice = null
            this.strategy = null
        }
        setBasePrice(basePrice){
            this.basePrice = basePrice;
        }
        setStrategy(strategy){
            this.strategy = strategy;
        }
        getPrice(){
            return this.strategy.calculate(this.basePrice)
        }
    }

我们接着往下写,我们首先创建一个price对象,并且将基础价格和需要用到的策略对象设置上,price本身并没有计算能力,它只是将请求委托给了保存好的策略对象。

    const price = new Price()
    price.setBasePrice(100)
    price.setStrategy(new QualityS())
    console.log(price.getPrice()) // 输出:400

    price.setStrategy(new QualityA())
    console.log(price.getPrice()) // 输出:300

通过重构之后代码变得各司其职,结构十分清晰,这段代码是用面向对象的思维写的,下面使用javaScript的面向过程来实现一下。

3.4 使用策略模式(面向过程)

在javaScript中使用面向过程的方式可以更简单。

    const strategies = {
        S: function(basePrice){
             return basePrice * 4
        },
        A: function(basePrice){
             return basePrice * 3
        },
        B: function(basePrice){
             return basePrice * 2
        }
    }
    function calculatePrice(level, basePrice){
        return strategies[level](basePrice)
    }

    console.log(calculatePrice('S', 100)) // 输出:400
    console.log(calculatePrice('B', 100)) // 输出:200

4 策略模式在表单校验中的应用

无论是在前端项目还是后端项目,只要有登录、注册功能就需要提交表单,那么就需要对表单数据进行校验,我们以web端校验表单为例,假设我们的注册页面需要有以下几条校验:

  1. 用户名不能为空;
  2. 密码不得少于6位;
  3. 手机号必须合规;

4.1 初版代码

    <html>
        <body>
            <form action="https://xxx/register" id="registerForm" method="post">
                请输入用户名:<input type="text" name="userName"/>
                请输入密码:<input type="password" name="password"/>
                请输入手机号:<input type="tel" name="phoneNumber"/>
                <button>提交</button>
            </form>
            <script>
            // 获取表单元素
            const registerForm = document.getElementById('registerForm')
            // 提交表单时触发
            registerForm.onsubmit = function(){
                if(registerForm.userName.value === ''){
                    alert('用户名不能为空')
                    return false
                }
                if(registerForm.password.value.length < 6){
                    alert('密码长度不能少于6位')
                    return false
                }
                if(!/^1[3578]\d{9}$/.test(registerForm.phoneNumber.value)){
                    alert('手机号格式不正确')
                    return false
                }
            }
            </script>
        </body>
    </html>

这是一种很常见的代码编写方式,但是也有着很多的缺点。

  1. 如果表单项较多的话registerForm.onsubmit会变得十分庞大,并且里面的if语句需要覆盖到所有分支,不利于维护。
  2. 如果将密码长度改为12的话,我们必须深入registerForm.onsubmit内部进行更改,缺乏扩展性。
  3. 如果其它地方也需要一个表单的话,我们只能复制粘贴,缺乏复用性。

4.2 使用策略模式重构

首先我们需要把逻辑封装成策略对象:

const strategies = {
    isNonEmpty(value, errorMsg){
        if(value === ''){
            return errorMsg
        }
    },
    minLength(value, length, errorMsg){
        if(value.length < length){
            return errorMsg
        }
    },
    isMobile(value, errorMsg){
        if(!/^1[3578]\d{9}$/.test(value)){
            return errorMsg
        }
    }
}

我们接着来实现Validator类,Validator类负责将用户的请求委托给策略对象,在写Validator类之前,我们需要了解用户如何发送请求给Validator类:

    function validateFn(){
        // 创建一个validator对象
        const validator = new Validator()
        // 添加一些校验规则
        validator.add(registerForm.userName , 'isNonEmpty', '用户名不能为空')
        validator.add(registerForm.password , 'minLength:6', '密码长度不能少于6位')
        validator.add(registerForm.phoneNumber , 'isMobile', '手机号格式不正确')
        // 获得结果
        const errorMsg = validator.start()
        return errorMsg
    }
    const registerForm = document.getElementById('registerForm')
    registerForm.onsubmit = function(){
        const errorMsg = validateFn()
        if(errorMsg){
            alert(errorMsg)
            // 阻止表单提交
            return false
        }
    }

我们首先创建一个validator对象,通过validator.add添加规则,validator.add接收三个参数,以添加密码规则为例:

  1. registerForm.userName:输入框元素;
  2. 'minLength:6':冒号前面的为策略对象,冒号后面的为校验过程中需要的一些参数,'minLength:6'的意思就是校验registerForm.userName.value的值最小长度为6;
  3. 第三个参数为校验不通过返回的错误信息; 添加完规则后我们需要调用validator.start()进行校验,如果有errorMsg的话就阻止表单提交,下面是Validator类的具体实现:
class Validator {
    constructor(){
        // 保存校验规则
        this.cache = []
    }
    add(dom, rule, errorMsg){
        // 把策略和参数分开
        const arr = rule.split(':')
        // 把校验的步骤用空函数包装,并放入cache
        this.cache.push(function(){
            // 用户挑选的策略
            const strategy = arr.shift()
            // 把输入框的值加入参数列表
            arr.unshift(dom.value)
            // 把errorMsg加入参数列表
            array.push(errorMsg)
            // 调用对应的策略,并传入所需参数
            return strategies[strategy].apply(dom, arr)
        })
    }
    start(){
        for(let i = 0, validateFn; validateFn = this.cache[i++];){
           // 开始校验并获得返回信息
           const msg = validateFn()
           if(msg){
               return msg
           }
        }
    }
}

我们可以发现在我们使用了策略模式之后,我们可以用配置的方式对表单进行校验,复用十分方便,也便于扩展,假如我们需要校验用户名的长度不得少于4为的话,可以直接改成:

validator.add(registerForm.userName , 'minLength:4', '用户名不能少于6位')

4.3 支持使用多个规则校验同一表单项

前面为了方便大家理解,我们的表单还没有支持多个规则校验同一表单项,其实只需要改动两个地方,下面我将改动的地方列举出来:

class Validator {
    '...'
    add(dom, rules){
        rules.forEach((rule)=>{
            const strategyArr = rule.strategy.split(':')
            const errorMsg = rule.errorMsg
            this.cache.push(function(){
                const strategy = strategyArr.shift()
                strategyArr.unshift(dom.value)
                strategyArr.push(errorMsg)
                return strategies[strategy].apply(dom, strategyArr)
            })
        })
    }
    '...'
}
// 客户端添加校验规则
'...'
validator.add(registerForm.userName , [
    {
        strategy: 'isNonEmpty',
        errorMsg: '用户名不能为空'
    },{
        strategy: 'minLength:10',
        errorMsg: '用户名不能少于10位'
    }
])
'...'

5 策略模式的应用场景及优缺点

5.1 策略模式的应用场景

  1. 多个类只区别在表现行为不同,可以使用策略模式,在运行时动态选择具体要执行的行为。
  2. 需要在不同情况下使用不同的策略,或者策略还可能在未来用其它方式来实现。
  3. 对客户隐藏具体策略的实现细节,彼此完全独立。

5.2 策略模式的优缺点

策略模式是一种常用的设计模式,我们通过两个例子来加深大家对设计模式的理解从其中我们可以总结出一些优点:

  1. 使用策略模式可以避免使用多重条件(if-else)语句。多重条件语句不易维护,它把采取哪一种算法或采取哪一种行为的逻辑与算法或行为的逻辑混合在一起。
  2. 策略模式提供了对开放-封闭原则的完美支持,将算法封装在独立的策略中,使得它们易于切换,易于理解,易于扩展。
  3. 策略模式提供了可以替换继承关系的办法,因为策略模式可以使环境类(Context)拥有执行算法的能力。
  4. 方便在其它地方复用,减少了复制粘贴。

有优点就一定会有缺点,但是我认为缺点并没那么严重,我们总结一下:

  1. 策略类数量会增多,每个策略都是一个类或者对象,但是这也会比将逻辑都写在Context中要好很多。
  2. 所有的策略类都需要对外暴露,因为我们需要了解每个策略,并且知道它们之间的不同点,比如我们在选择出行方案的时候就需要知道美中方案的具体细节,但是这也违背了最少知识原则。

6 总结

我们通过用对代码进行重构方式来体现出策略模式的优点,使我们加深了对策略模式的理解,我们的第一个例子中既有模拟面向对象语言的版本,又有针对javaScript语言的实现,可以总结出:在函数作为一等对象的语言中,策略模式是隐形的。javaScript就是将函数作为一等对象的语言,策略就是值为函数的变量,”算法“的具体实现可以被封装到函数中并且进行传递,也就是我们平时所说的”高阶函数“,所以说策略模式已经融入了语言本身之中,这就使得函数对象的多态性来的更加简单。