从策略模式看软件设计的智慧-灵活应对变化的艺术

本文涉及的产品
服务治理 MSE Sentinel/OpenSergo,Agent数量 不受限
可观测可视化 Grafana 版,10个用户账号 1个月
简介: 策略模式是一种行为设计模式,它定义了算法族,分别封装起来,让它们之间可以互相替换,使得算法的变化独立于使用算法的客户。本文深入探讨了策略模式的组成、应用场景、实现方式及其优缺点。通过实际案例,展示了策略模式在灵活处理算法和业务规则变化中的强大作用。文章还提供了最佳实践和使用注意事项,帮助开发者更有效地运用策略模式,同时比较了与其他设计模式的异同。掌握策略模式,将为您的软件设计带来更高的灵活性和可维护性。

一、案例场景

image.png

购买商品时候使用的各种类型优惠券

优惠类型
- 满减
- 直减
- 折扣
- N元购

这样的场景有时候用户用起来还是蛮爽的,但是最初这样功能的设定以及产品的不不断迭代,对于程序员开发还是不太容易的。因为这里包括了了很多的规则和优惠逻辑,所以我们模拟其中的一个计算优惠的方式。

下面我们通过不使用模式和使用策略模式的两种方式来对比实现简单购物结算系统。

## 1.1 一坨坨代码实现
public class CheckoutCounter {
    

    // 满减条件和优惠金额
    private double thresholdForDiscount;
    private double discountAmountOnThreshold;

    // 直减金额
    private double directReductionAmount;

    public CheckoutCounter(double thresholdForDiscount, double discountAmountOnThreshold, double directReductionAmount) {
    
        this.thresholdForDiscount = thresholdForDiscount;
        this.discountAmountOnThreshold = discountAmountOnThreshold;
        this.directReductionAmount = directReductionAmount;
    }

    public double calculateTotalAmount(double rawTotal) {
    
        double total = rawTotal;

        // 满减优惠
        if (total >= thresholdForDiscount) {
    
            total -= discountAmountOnThreshold;
        }

        // 直减优惠
        total -= directReductionAmount;

        // 保证总额不为负数
        if (total < 0) {
    
            total = 0;
        }

        return total;
    }

    public static void main(String[] args) {
    
        // 初始化收银台,设定满减和直减规则
        CheckoutCounter counter = new CheckoutCounter(100, 20, 5);

        // 结算购买商品的金额
        double rawTotal = 120;
        double totalAfterDiscount = counter.calculateTotalAmount(rawTotal);
        System.out.println("结算总金额:" + totalAfterDiscount);
    }
}

在这个简单的示例中,我们创建了一个 CheckoutCounter 类,它接受满减条件、满减金额与直减金额作为构建参数。calculateTotalAmount 方法按照相应的规则计算最终的总金额。

1.2 存在的问题


1. 紧耦合:

促销策略与结算类紧密耦合在一起,这意味着每次促销策略改变时,都需要修改 CheckoutCounter 类中的代码。

2. 扩展性差:

如果想要添加更多的优惠方式(比如新用户折扣、VIP用户折扣),需要在 CheckoutCounter 类中继续添加代码,这会使得类膨胀且难以维护。

3. 代码重用性低:

促销计算逻辑被限制在了 CheckoutCounter 类中,如果其他部分的代码也需要执行相似的计算,代码的重复和重用问题就会变得严重。

4. 测试困难:

由于促销逻辑与结算系统紧耦合,单元测试变得更为复杂。每次测试可能需要不同的促销条件,而且耦合性使得编写测试用例更为繁琐。

5. 违反单一职责原则:

CheckoutCounter 类承担了处理具体的促销规则和结算的责任,而这些职责在理想情况下应该是分开的。

这些问题展示了在复杂或者多变的业务场景中,不使用设计模式可能导致软件的可维护性与可拓展性受限。使用设计模式(如策略模式)能够添加额外的抽象层次,将促销策略封装在独立的类中,降低了维护成本,提升了系统的灵活性与可测试性。

# 二、使用策略模式解决问题


用来解决上述问题的一个合理的解决方案就是策略模式

那么什么是策略模式呢?

定义
> 定义一系列的算法,把它们一个个封装起来,并且使它们可相互替换。本模式使得算法可独立于使用它的客户而变化。

解决问题思路
使用策略模式对购物结算系统进行了重构。策略模式是一种设计模式,使得算法在运行时可以选择使用。这种模式通过定义一系列算法,将每个算法封装在具有共同接口的独立的类中,并使它们可以互换。策略模式的关键就是将算法的使用和算法的实现分离开来。

重构策略的步骤

1. 定义策略接口:创建一个策略接口 DiscountStrategy,其中包含了一个抽象方法 applyDiscount。这个方法接受原始的购物总额,返回经过折扣计算后的金额。



2. 实现具体的策略类: 根据不同的优惠规则创建了两个策略类 ThresholdDiscountStrategy (满减优惠)和 DirectDiscountStrategy(直减优惠)。这些类实现了 DiscountStrategy 接口,并重写了 applyDiscount 方法,根据其各自的优惠规则进行折扣计算。



3. 结算类使用策略: 创建一个 CheckoutCounter 类来计算最终的购物总金额。它持有 DiscountStrategy 的一个列表,这样它就可以适用多个优惠策略。在结算类的 calculateTotalAmount 方法中,遍历优惠策略列表,并逐个将策略应用在初始的购物总额上。



4. 测试和使用: 在 CheckoutCounter 的 main 方法中,我们展示了如何初始化折扣策略列表,并且如何通过创建 CheckoutCounter 对象来结算商品金额,体现了优惠金额的计算。

重构的思路是使整个结算系统更加灵活和易于维护,同时也易于扩展。添加新的折扣策略不再需要修改现有的结算类代码,只需要添加一个新的策略类实现 DiscountStrategy 接口即可。这对于遵守开放-封闭原则是很有益处的;即软件实体(类、模块、函数等)应该对于扩展是开放的,但对于修改是封闭的。

2.1 使用策略模式重构代码

为了使用策略模式重构上述的购物结算系统,我们首先定义一个通用的优惠策略接口,然后创建实现该接口的具体策略类来处理满减和直减逻辑。最后,在结算类中使用策略对象而不是硬编码的优惠规则。

首先定义策略接口
它声明了一个接受原始总金额并返回经过折扣计算后的金额的 applyDiscount 方法。

// 优惠策略接口,所有的折扣策略都需要实现这个接口
interface DiscountStrategy {
      
    double applyDiscount(double rawTotal);
}

具体的策略
接下来,我们实现两个具体的策略,针对不同的优惠类型:满减优惠和直减优惠。

// 满减优惠策略
class ThresholdDiscountStrategy implements DiscountStrategy {
      

    private double thresholdForDiscount;  // 满减条件金额
    private double discountAmountOnThreshold;  // 满足条件后的折扣金额

    // 构造器初始化满减条件和折扣金额
    public ThresholdDiscountStrategy(double thresholdForDiscount, double discountAmountOnThreshold) {
      
        this.thresholdForDiscount = thresholdForDiscount;
        this.discountAmountOnThreshold = discountAmountOnThreshold;
    }

    // 应用满减折扣的方法
    @Override
    public double applyDiscount(double rawTotal) {
      
        // 如果原始总金额达到满减条件,则应用满减金额
        if (rawTotal >= thresholdForDiscount) {
      
            return rawTotal - discountAmountOnThreshold;
        }
        // 如果不满足条件,不应用折扣
        return rawTotal;
    }
}

// 直减优惠策略
class DirectDiscountStrategy implements DiscountStrategy {
      

    private double directReductionAmount;  // 直减金额

    // 构造器初始化直减金额
    public DirectDiscountStrategy(double directReductionAmount) {
      
        this.directReductionAmount = directReductionAmount;
    }

    // 应用直减折扣的方法
    @Override
    public double applyDiscount(double rawTotal) {
      
        // 直接从原始总金额中减去直减金额
        double result = rawTotal - directReductionAmount;
        // 确保最终金额不为负数,如果为负数则将其设置为0
        return result > 0 ? result : 0;
    }
}

结算类
现在我们的策略类已经定义好了,可以实现结算类 CheckoutCounter,它将接收一个策略列表,并在计算总金额时遍历这些策略。

import java.util.List;
// 结算类
public class CheckoutCounter {
      
    private List<DiscountStrategy> discountStrategies; // 一个包含所有优惠策略的列表

    // 构造器接收优惠策略列表
    public CheckoutCounter(List<DiscountStrategy> discountStrategies) {
      
        this.discountStrategies = discountStrategies;
    }

    // 计算应用所有策略后的最终总金额
    public double calculateTotalAmount(double rawTotal) {
      
        double total = rawTotal;
        // 遍历策略列表,依次应用每个策略到总金额上
        for (DiscountStrategy strategy : discountStrategies) {
      
            total = strategy.applyDiscount(total);
        }
        return total;
    }
    // 主方法演示如何使用结算类
    public static void main(String[] args) {
      
        // 创建优惠策略列表,可以在这里添加任意多的策略
        List<DiscountStrategy> strategies = List.of(
            new ThresholdDiscountStrategy(100, 20), // 满100减20
            new DirectDiscountStrategy(5)           // 直减5元
        );

        // 初始化收银台,并将策略列表传入
        CheckoutCounter counter = new CheckoutCounter(strategies);

        // 结算购买商品的金额,假设原始金额为120
        double rawTotal = 120;
        // 使用结算类计算最终金额
        double totalAfterDiscount = counter.calculateTotalAmount(rawTotal);
        // 打印出最终结算金额
        System.out.println("结算总金额:" + totalAfterDiscount);
    }
}

运行结果

结算总金额:95.0

此重构版本利用了策略模式,增加了代码的灵活性和可维护性。每个具体策略类都封装了自己的折扣计算规则,CheckoutCounter 只负责将这些策略应用于商品总金额。通过以这种方式来分解问题,我们使得每个部分都容易理解和测试,并且,如果将来需要添加更多的折扣类型,我们只需实现新的 DiscountStrategy,而无需更改现有的代码。这就是策略模式的力量。

2.2 克服了问题


1. 低耦合:

优惠策略的实现与结算类分离,促进了责任分离。

2. 高扩展性:

现在可以轻松地添加新的优惠策略,而无需修改 CheckoutCounter 的代码。

3. 代码重用性:

可以在不同的上下文中复用相同的优惠策略。

4. 易于测试:

每个策略都是独立的,可以单独测试各自的逻辑。

4. 符合单一职责:

每个策略类只关心一种优惠规则,这有利于维护。



提示

策略模式的重心不是如何来实现算法,而是如何组织、调用这些算法,从而提示让程序结构更灵活,具有更好的维护性和扩展性

三、模式讲解

核心思想

策略模式的核心思想可以概括为 “分离算法,选择实现”


1. 分离算法: 将一组特定的行为(算法)从其使用环境中提取出来,并将它们封装在独立的策略类中。每个策略类代表一种算法或一种行为实现。


2. 封装变化: 将算法的变化封闭在独立的策略类中,使得改变或添加新算法不影响那些依赖于算法的类(上下文)。


3. 提供选择: 通过定义一个公共的策略接口,上下文(Context)类可以在运行时选择使用任何实现了该接口的策略,而无需修改代码。


4. 动态替换: 策略接口为上下文类与策略类提供了一个共同的通信协议,允许在运行时动态地替换当前策略,从而改变上下文的行为。


5. 动态替换: 上下文和算法可以独立演化,而不互相影响。开发者可以独立地修改或扩展策略,而不会破坏客户代码。

3.1 结构图及说明

image.png

  • 1. 上下文(Context):
    上下文是策略模式中的一个核心类,它是客户端直接交互对象。上下文通常定义了一种需要多样化算法或行为的操作。为了实现这些行为的动态变化,上下文维护了一个对策略接口的引用。它不执行策略算法,而是将工作委托给连接到的策略对象。上下文可能提供一个设置器(setter)或构造器,以便在运行时切换策略对象。
  • 策略接口(Strategy Interface):
    策略接口定义了所有支持算法或行为的公共接口。通常,它是由一个抽象类或一个接口表示,它声明了一个或多个方法供具体策略类实现。这个接口为上下文类和具体策略类之间提供了一个契约。所有的算法都遵循相同的接口,这让它们在上下文中可以互换使用。
  • 具体策略类(Concrete Strategy classes):
    一组策略类实现了策略接口,提供具体的行为实现。每个具体策略类都封装了一种特定的算法或行为逻辑。这些类是策略模式的实际执行者,上下文通过策略接口与它们通信,而不必了解具体的策略实现。当上下文的行为需求发生变化时,可以替换使用不同的具体策略对象,而无需更改上下文类的代码。

    3.2 实现步骤和注意事项

    实现步骤
    • 定义策略接口: 设计一个公共的接口,该接口为策略类定义了一个方法用来执行具体算法。例如,在折扣案例中,就是 DiscountStrategy 接口的 applyDiscount 方法。

    • 实现具体策略类: 为每种策略实现一个类,这些类都实现了策略接口中的方法。每个策略类都封装了具体的算法或行为。

    • 上下文环境: 创建上下文环境(Context)类,该类含有策略接口的引用。这个上下文将从客户端接收到的请求委托给策略对象进行处理。在购物结算的例子中,CheckoutCounter 就是一个上下文环境类。

    • 客户端选择策略: 客户端根据需要选择合适的具体策略,并将它赋值给上下文。上下文使用这个策略,不知道具体实现细节。

    • 执行策略: 上下文调用策略接口的方法来执行策略。具体执行哪个策略,取决于上下文类中所持有的具体策略对象。


    注意事项

    1. 接口一致性:

    所有策略类都应实现相同的接口,这个接口定义了策略的方法。这样上下文环境可以通过同一个接口访问所有策略。

    2. 策略与上下文分离:

    策略应当完全独立于上下文,这样才能保证它们可以自由互换,不会互相干扰。

    3. 组合优于继承:

    策略模式使用组合(即策略对象被组合进上下文中)而不是继承,这提供了更高的灵活性。

    4. 封装算法变化:

    策略模式的目的在于封装算法的变化部分。如果算法或行为变化不频繁,那么可能没有必要使用策略模式。

    5. 策略对象的创建:

    注意策略对象的创建和管理。如果策略对象无状态,可以考虑使用共享或者静态实例,减少对象创建成本。如果有状态,则需要每次使用时创建新的实例。

    6. 理解代价:

    使用策略模式可能会增加代码的复杂性。确保这样的抽象是合理且有价值的,否则可能不如简单的条件语句清晰。

    7. 策略的选择:

    需要一种机制来选择所需的具体策略。这可以在客户端代码中硬编码选择逻辑,也可以更灵活地使用工厂模式等来动态选择策略。


    ## 3.3 适用场景


    策略模式是一种适用于算法家族和业务规则需要灵活变化场景的设计模式。它特别有用在以下情形中:



    1. 多种类似算法的选择:当我们的系统中存在多种算法,并且它们仅在细节上有所不同,而又服务于相同的任务时,策略模式可以非常方便地允许在运行时根据条件选择正确的算法。


    2. 算法的透明性需求:策略模式通过定义算法族,将算法的实现和使用进行分离,使得算法的变化独立于使用它们的客户端。客户端只需关心策略接口,不必了解内部实现细节,从而使得算法实现能够自由切换,提升了算法的透明性。


    3. 避免使用多重条件选择语句:在没有策略模式的场景下,算法的选择可能依赖于多个条件判断语句(如 if-else 或 switch-case)。这种方式不仅使得代码臃肿难维,也使得新增或更改一个算法成为一项复杂且容易出错的任务。策略模式通过封装算法替代这些条件判断,简化了代码的管理。


    4. 当一个类定义了多种行为,并且这些行为在类的操作中以多个条件语句的形式出现:此时,将相关的条件分支移至它们各自的策略类中可以避免代码冗余,增强代码的可读性。


    5. 需要动态地改变方法的行为:在实际的业务场景中,随着产品的发展,业务规则可能频繁变化。策略模式允许在不改变客户端代码的基础上,通过切换不同的策略实现来适应业务规则的改变或扩展。


    6. 稳定算法类库的场合:在开发过程中有时会面临创建稳定的算法类库的需求。通过策略模式,可以轻松组织和维护大量具有共同接口的算法类,确保它们的替换和扩展都不会影响到使用它们的客户端。


    7. 提供一个类的操作时,多个算法也许从概念上具备不同的行为。比如,不同的排序算法(冒泡排序、快速排序等)或者不同的路径寻找算法(Dijkstra、A*等)。


    将策略模式应用到适合的场景中,可以显著提升代码的灵活性和可维护性,同时有助于形成清晰和可扩展的架构。在实现过程中,只需通过实现新的策略并向上下文注入即可应对需求变化,而大量的条件选择代码或是分支逻辑则可得到清除,保证了代码结构的整洁和稳定。



    # 四、优势和局限性
    > 策略模式是一种软件设计模式,它使得一系列相关的算法可以互相替换。其主要目的是定义一组算法,将每个算法都封装起来,并使它们之间可以互换。该模式让算法的变化独立于使用算法的客户。

    ## 4.1 优势

    1. 提高灵活性:

    策略模式提供了管理相关算法族的手段,增强了系统的灵活性,因为可以动态更改对象的行为。

    2. 代码复用:

    相似操作的不同变体可以共享算法的代码,避免了代码冗余。

    3. 隔离实现:

    客户端代码只需知道策略接口而不是具体类,算法与使用它的客户代码之间以及不同算法之间解耦。

    4. 扩展性:

    新的算法或行为可以很容易地添加进系统,无需修改现有的代码。

    5. 代替多重条件选择语句:

    使用策略模式可以避免多重条件选择语句,代码结构更加清晰。

    6. 开闭原则:

    策略模式支持开放-封闭原则,因为可以在不修改源代码的情况下引入新的策略。



    ## 4.2 局限性

    1. 客户端知识要求:

    客户端必须了解所有策略的差异以便正确选择,这增加了客户端使用的复杂性。

    2. 策略数量膨胀:

    如果算法数量非常多,可能会导致系统中存在许多的策略类,增大维护难度。

    3. 对象数量增多:

    每一个策略都要产生一个新的对象,可能在一定程度上增加系统的对象数量。

    4. 系统开销:

    每次在运行时选择策略都可能需要时间进行决策,特别是在工厂模式与策略模式结合时,可能引入新的复杂性。

    5. 默认策略难以决定:

    如不提供默认选项,客户端必须显式选择一个策略,这有时可能被视为过度设计。

    6. 高内聚性策略的挑战:

    当策略大量依赖于上下文数据时,策略和上下文之间的边界可能会变得模糊,违背了策略模式的初衷。

    因此,策略模式的选择应当基于具体问题的需求进行评估。如果问题域中各种算法的变化和独立性显著,且需要灵活地相互替换,则策略模式可以带来显著的好处。但如果场景不适合,可能会带来不必要的复杂性和开销。

    五、总结

    策略模式是一种使得算法能在运行时透明地互换的行为型设计模式。它通过定义一系列算法,并将每一个算法封装到具有共同接口的独立的类中,使得算法可以独立于使用它的客户而变化。这种模式特别有助于管理和维护与算法相关的代码,尤其是在算法选项繁多且经常变更的系统中。

    5.1 要点回顾


    1. 策略模式的核心思想是针对一系列算法定义一个公共接口,使得这些算法在客户类中能够互换使用。

    2. 通过使用策略模式,可以避免在客户代码中使用大量的条件语句,从而降低其复杂性。

    3. 它有助于代码复用和隔离,算法的变更或添加新算法不会影响到使用算法的客户。

    4. 策略模式提升了算法的可测试性,并且可以提供更灵活的扩展性。

    5.2 实战价值


    在现实世界的软件设计中,策略模式常被应用于以下几个方面:

    · 不同类型的排序或搜索操作,在不同上下文中需要不同的算法。

    · 业务规则的动态选择,比如税率计算、折扣计算等,因场景不同而变化的逻辑部分。

    · 在游戏开发中,不同类型的角色或敌人可能有不同的行为模式。

    · 图形渲染或数据压缩领域,针对不同场景选择不同的优化算法。


    在未来的软件设计工作中,可以考虑以下几点来利用策略模式:

    · 当面对多种算法或行为,并需要在运行时便捷地切换它们时,策略模式是一个理想的选择。

    · 如果你预见需要经常更换算法或逻辑,策略模式提供了一个良好的框架,以便于这些更改,并且不影响到已有的客户代码。

    · 对于测试驱动开发(TDD)的环境,策略模式使得针对每一种算法编写单独的测试变得更加容易。

    · 在构建需要高度可配置化行为的系统时,策略模式为将行为封装为可交换的部件提供了便捷方式,从而增强了系统的灵活性。

    总之,策略模式是面对多种具有相同目标但实现不同的算法时提供可扩展,易于维护,且高度解耦的解决方案。在未来软件设计的实践中,它可以作为一个强大的工具来管理和优化代码结构,确保软件系统具有更好的应对变化的能力。

相关文章
|
2月前
|
设计模式 API 数据安全/隐私保护
探索设计模式的魅力:外观模式简化术-隐藏复杂性,提供简洁接口的设计秘密
外观模式是一种关键的设计模式,旨在通过提供一个简洁的接口来简化复杂子系统的访问。其核心价值在于将复杂的内部实现细节封装起来,仅通过一个统一的外观对象与客户端交互,从而降低了系统的使用难度和耦合度。在软件开发中,外观模式的重要性不言而喻。它不仅能够提高代码的可读性、可维护性和可扩展性,还能促进团队间的协作和沟通。此外,随着业务需求和技术的发展,外观模式能够适应变化,通过修改外观对象来灵活调整客户端与子系统之间的交互方式。总之,外观模式在软件设计中扮演着举足轻重的角色,是构建高效、稳定且易于维护的软件系统的关键
66 1
探索设计模式的魅力:外观模式简化术-隐藏复杂性,提供简洁接口的设计秘密
|
7月前
|
设计模式 算法 Java
设计模式第十五讲:重构 - 改善既有代码的设计(下)
设计模式第十五讲:重构 - 改善既有代码的设计
239 0
|
2月前
|
算法 程序员 C语言
C++设计哲学:构建高效和灵活代码的艺术
C++设计哲学:构建高效和灵活代码的艺术
61 1
|
4月前
|
设计模式 算法
23种设计模式分类
23种设计模式分类
35 0
|
5月前
|
设计模式 存储 缓存
二十三种设计模式全面解析-探索解释器模式如何应对性能挑战
二十三种设计模式全面解析-探索解释器模式如何应对性能挑战
|
7月前
|
设计模式 Java 测试技术
设计模式第十五讲:重构 - 改善既有代码的设计(上)
设计模式第十五讲:重构 - 改善既有代码的设计
259 0
|
12月前
|
设计模式 缓存 监控
【软件架构】支持大规模系统的设计模式和原则
【软件架构】支持大规模系统的设计模式和原则
论述系统架构中软件质量属性
ISO25010质量模型中8各方面的质量属性理解
328 0
|
Java
复杂性应对之道——抽象
写本文的原因是,抽象是软件设计中最重要的概念。但抽象这个概念本身又很抽象,我们有必要花一些时间深入理解抽象、抽象的层次性,以及不遗余力的不断提升我们抽象能力。
2697 0