阅读 508

实战设计模式——策略模式

本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

背景

以优惠券业务为例,可能存在多种优惠券,满减券,折扣券,无门槛券,减至券等。用户在购买一件商品,并且有一张优惠券时,需要计算优惠后金额,计算金额时需要判断该券的类型。假设一开始产品提出需要实现满减券,折扣券,无门槛券三种优惠券类型,得出如下代码:

初始代码

优惠券类型枚举

public enum CouponTypeEnum {

    DiscountCoupon(1, "折扣券"),
    FullCutCoupon(2, "满减券"),
    NoThresholdReducedToCoupon(3, "无门槛扣减券"),
    //ReducedToCoupon(4, "减至券"),
    ;

    private int code;

    private String desc;

    CouponTypeEnum(int code, String desc) {
        this.code = code;
        this.desc = desc;
    }

    public int getCode() {
        return this.code;
    }

    public String getDesc() {
        return this.desc;
    }

    public static CouponTypeEnum getByCode(int code) {
        for (CouponTypeEnum couponTypeEnums : values()) {
            if (code == couponTypeEnums.getCode()) {
                return couponTypeEnums;
            }
        }
        throw new IllegalArgumentException("CouponTypeEnum not exist, code=" + code);
    }
}
复制代码

业务处理service类

@Service
public class CouponService {

    @Autowired
    private CouponRepository couponRepository;

    /**
     * 计算优惠
     *
     * @param quantity
     * @param sellingPrice
     * @param couponId
     * @return
     */
    public CouponCalcResult calculate(Integer quantity, Long sellingPrice, Long couponId) {
        Coupon coupon = couponRepository.get(couponId);
        CouponTypeEnum couponTypeEnum = CouponTypeEnum.getByCode(coupon.getType());
        //获取优惠配置,例如xx折,满xx元减yy元少
        CouponConfig couponConfig = JsonUtils.fromJson(coupon.getConfig(), CouponConfig.class);
        CouponCalcParams couponCalcParams = new CouponCalcParams(sellingPrice, quantity);
        CouponCalcResult couponCalcResult;
        switch (couponTypeEnum) {
            case DiscountCoupon:
                couponCalcResult = discountCouponCalculate(couponCalcParams, couponConfig);
                break;
            case FullCutCoupon:
                couponCalcResult = fullCutCouponCalculate(couponCalcParams, couponConfig);
                break;
            case NoThresholdReducedToCoupon:
                couponCalcResult = noThresholdReduceCouponCalculate(couponCalcParams, couponConfig);
                break;
            default:
                throw new IllegalArgumentException("couponTypeEnum error");
        }
        return couponCalcResult;
    }

    /**
     * 计算原总价
     *
     * @param quantity
     * @param sellingPrice
     * @return
     */
    Long calculateTotalPrice(Integer quantity, Long sellingPrice) {
        return quantity * sellingPrice;
    }

    /**
     * 折扣券计算优惠
     */
    private CouponCalcResult discountCouponCalculate(CouponCalcParams couponCalcParams, CouponConfig couponConfig) {
        if (couponConfig == null || couponConfig.getDiscount() == null) {
            throw new IllegalArgumentException("couponConfig error");
        }
        CouponCalcResult result = new CouponCalcResult();
        // 计算总价
        Long totalPrice = calculateTotalPrice(couponCalcParams.getQuantity(), couponCalcParams.getSellingPrice());

        Long amount = totalPrice * (1000 - couponConfig.getDiscount()) / 1000;
        if (couponConfig.getMaxReductionAmount() != null && amount >= couponConfig.getMaxReductionAmount()) {
            amount = couponConfig.getMaxReductionAmount();
        }
        result.setAmount(amount);   //优惠金额
        result.setActualAmount(totalPrice - amount);    //优惠后实际金额
        return result;
    }

    /**
     * 满减券计算优惠
     */
    private CouponCalcResult fullCutCouponCalculate(CouponCalcParams couponCalcParams, CouponConfig couponConfig) {
        if (couponConfig == null || couponConfig.getThresholdAmount() == null || couponConfig.getReductionAmount() == null) {
            throw new IllegalArgumentException("couponConfig error");
        }
        CouponCalcResult result = new CouponCalcResult();
        // 计算总价
        Long totalPrice = calculateTotalPrice(couponCalcParams.getQuantity(), couponCalcParams.getSellingPrice());

        Long actualAmount = totalPrice;
        if (totalPrice >= couponConfig.getThresholdAmount()) {
            actualAmount -= couponConfig.getReductionAmount();
        }
        result.setAmount(totalPrice - actualAmount);
        result.setActualAmount(actualAmount);
        return result;
    }

    /**
     * 无门槛扣减券计算优惠
     */
    private CouponCalcResult noThresholdReduceCouponCalculate(CouponCalcParams couponCalcParams, CouponConfig couponConfig) {
        if (couponConfig == null || couponConfig.getThresholdAmount() == null) {
            throw new IllegalArgumentException("couponConfig error");
        }
        CouponCalcResult result = new CouponCalcResult();
        // 计算总价
        Long totalPrice = calculateTotalPrice(couponCalcParams.getQuantity(), couponCalcParams.getSellingPrice());

        //计算实际应付金额
        long actualAmount = totalPrice - couponConfig.getReductionAmount();

        // actualAmount 取值到角
        actualAmount = actualAmount < 0 ? 0 : actualAmount;
        result.setActualAmount(actualAmount);
        result.setAmount( totalPrice - actualAmount);
        return result;
    }
}
复制代码

其中couponConfig目前有如下属性,

@Data
public class CouponConfig {

    // 折扣保留了小数点后两位,用整数表示时要乘以1000
    private Integer discount;
    // 最多减多少(单位 分)
    private Long maxReductionAmount;
    //总价满多少(单位分)
    private Long thresholdAmount;
    //总价减多少(单位分)
    private Long reductionAmount;
    // 单价减至多少元
    private Long unitReduceToAmount;
}
复制代码

比如是折扣券,只关心discount和maxReductionAmount两个字段,存在数据库中可能为如下配置,表示打9折,最多减100元。

{"discount":900,"maxReductionAmount":10000}
复制代码

迭代代码

随着业务的迭代,新增了优惠券类型减至券,那CouponService类中需要做如下更改:

@Service
public class CouponService {

    @Autowired
    private CouponRepository couponRepository;

    /**
     * 计算优惠
     *
     * @param quantity
     * @param sellingPrice
     * @param couponId
     * @return
     */
    public CouponCalcResult calculate(Integer quantity, Long sellingPrice, Long couponId) {
        Coupon coupon = couponRepository.get(couponId);
        CouponTypeEnum couponTypeEnum = CouponTypeEnum.getByCode(coupon.getType());
        //获取优惠配置,例如xx折,满xx元减yy元少
        CouponConfig couponConfig = JsonUtils.fromJson(coupon.getConfig(), CouponConfig.class);
        CouponCalcParams couponCalcParams = new CouponCalcParams(sellingPrice, quantity);
        CouponCalcResult couponCalcResult;
        switch (couponTypeEnum) {
            case DiscountCoupon:
                couponCalcResult = discountCouponCalculate(quantity, sellingPrice, couponConfig);
                break;
            case FullCutCoupon:
                couponCalcResult = fullCutCouponCalculate(quantity, sellingPrice, couponConfig);
                break;
            case NoThresholdReducedToCoupon:
                couponCalcResult = noThresholdReduceCouponCalculate(quantity, sellingPrice, couponConfig);
                break;
            case ReducedToCoupon:	//新增
                couponCalcResult = reduceToCouponCalculate(quantity, sellingPrice, couponConfig);
                break;
            default:
                throw new IllegalArgumentException("couponTypeEnum error");
        }
        return couponCalcResult;
    }

    /**
     * 计算原总价
     * 折扣券计算优惠
     * 满减券计算优惠
     * 无门槛扣减券计算优惠
     * 代码一致
     */

    /**
     * 减至券计算优惠
     */
    private CouponCalcResult reduceToCouponCalculate(CouponCalcParams couponCalcParams, CouponConfig couponConfig) {
        if (couponConfig == null || couponConfig.getUnitReduceToAmount() == null) {
            throw new IllegalArgumentException("couponConfig error");
        }
        CouponCalcResult result = new CouponCalcResult();
        // 计算总价
        Long totalPrice = calculateTotalPrice(couponCalcParams.getQuantity(), couponCalcParams.getSellingPrice());

        //计算实际应付金额
        long actualAmount = couponConfig.getUnitReduceToAmount() * couponCalcParams.getQuantity();

        result.setActualAmount(actualAmount);
        result.setAmount( totalPrice - actualAmount);
        return result;
    }
}
复制代码

可以看出,这里我们对switch case进行了更改,违背了开闭原则,最好对这块代码进行回归测试。并且在当前类上增加了减至券的计算方法,导致该类变得更加复杂。但其实只要客户端知道当前是折扣券之后,其实只需要关心折扣券计算方法而已。根据单一职责原则与里氏替换原则的指导,我们考虑使用策略模式对其进行优化。

定义

策略(Strategy)模式的定义:定义了一系列算法,并将每个算法封装起来,使它们可以相互替换,且算法的变化不会影响使用算法的客户。

模式的结构

策略模式的主要角色如下。

  • 抽象策略(Strategy)类:定义了一个公共接口,各种不同的算法以不同的方式实现这个接口,环境角色使用这个接口调用不同的算法,一般使用接口或抽象类实现。
  • 具体策略(Concrete Strategy)类:实现了抽象策略定义的接口,提供具体的算法实现。
  • 环境(Context)类:持有一个策略类的引用,最终给客户端调用。

UML图

策略模式的结构图

模式基本实现

上下文类

public class Context {
    private Strategy strategy;

    public Context(Strategy strategy) {
        this.strategy = strategy;
    }

    public Strategy getStrategy() {
        return strategy;
    }

    public void setStrategy(Strategy state) {
        this.strategy = state;
    }

    public void handle() {
        strategy.handle();
    }
}
复制代码

抽象策略类

public interface Strategy {
    void handle();
}
复制代码

具体策略类A

public class AStrategy implements Strategy{
    @Override
    public void handle() {
        System.out.println("AStrategy");
    }
}
复制代码

具体策略类B

public class BStrategy implements Strategy{
    @Override
    public void handle() {
        System.out.println("BStrategy");
    }
}
复制代码

测试Client类

public class ClientTest {
    public static void main(String[] args) {
        AStrategy aStrategy = new AStrategy();
        Context context = new Context(aStrategy);
        context.handle();
        BStrategy bStrategy = new BStrategy();
        context.setStrategy(bStrategy);
        context.handle();
    }
}
复制代码

执行结果

AStrategy
BStrategy
复制代码

在Context不主动set最新的Strategy时,handle可重复执行。

上面的基本代码中有两个问题,一是一般客户端无需感知Strategy的继承簇,即无需感知到AStrategy和BStrategy,二是在使用之前依靠客户端自己new一个实例出来,并且set到context中使用,其实没有必要,因为各个具体策略之间没有像状态模式那样的耦合关系,可以不需要维护这个上下文关系。为了解决这两个问题,对策略类的管理可以利用工厂来实现。

策略工厂类

public class StrategyFactory {
    private static final Map<String, Strategy> strategyMap = new HashMap<>();

    //如果是spring环境下可以通过@PostConstruct完成注册
    static {
        register("A", new AStrategy());
        register("B", new BStrategy());
    }

    public static void register(String code, Strategy strategy) {
        strategyMap.put(code, strategy);
    }

    public static Strategy get(String code) {
        return strategyMap.get(code);
    }
}
复制代码

客户端实现变为

public class ClientTest {
    public static void main(String[] args) {
        Strategy strategy = StrategyFactory.get("A");
        strategy.handle();
        strategy = StrategyFactory.get("B");
        strategy.handle();
    }
}
复制代码

优化优惠券计算

基于此我们对优惠券计算的代码进行优化,由于目前项目一般都是使用springboot进行开发,下面给出优惠券计算在springboot中实现的代码。

定义抽象优惠券类

public abstract class AbstractCouponCalculator {

    abstract CouponTypeEnum getCouponTypeEnum();

    @PostConstruct
    void register() {
        CouponCalculateFactory.register(getCouponTypeEnum(), this);
    }

    /**
     * 计算原总价
     * @param params
     * @return
     */
    Long calculateTotalPrice(CouponCalcParams params) {
        return params.getSellingPrice() * params.getQuantity();
    }

    /**
     * 计算金额
     * @param params
     * @return
     */
    public abstract CouponCalcResult calculate(CouponCalcParams params, CouponConfig couponConfig);
}
复制代码

定义具体优惠券类

以折扣券为例

@Component
public class DiscountCouponCalculator extends AbstractCouponCalculator {

    @Override
    CouponTypeEnum getCouponTypeEnum() {
        return CouponTypeEnum.DiscountCoupon;
    }

    @Override
    public CouponCalcResult calculate(CouponCalcParams params, CouponConfig couponConfig) {
        CouponCalcResult result = new CouponCalcResult();
        // 计算总价
        Long totalPrice = calculateTotalPrice(params);

        Long amount = totalPrice * (1000 - couponConfig.getDiscount()) / 1000;
        if (couponConfig.getMaxReductionAmount() != null && amount >= couponConfig.getMaxReductionAmount()) {
            amount = couponConfig.getMaxReductionAmount();
        }
        result.setAmount(amount);
        result.setActualAmount( totalPrice - amount );
        return result;
    }
}
复制代码

定义优惠券工厂

public class CouponCalculateFactory {

    private static final Map<CouponTypeEnum, AbstractCouponCalculator> calculatorMap = new HashMap<>();

    public static void register(CouponTypeEnum couponTypeEnum, AbstractCouponCalculator couponCalculator) {
        calculatorMap.put(couponTypeEnum, couponCalculator);
    }

    public static AbstractCouponCalculator get(CouponTypeEnum couponTypeEnum) {
        return calculatorMap.get(couponTypeEnum);
    }
}
复制代码

优化后的CouponService

@Service
public class CouponService {

    @Autowired
    private CouponRepository couponRepository;

    /**
     * 计算优惠
     *
     * @param quantity
     * @param sellingPrice
     * @param couponId
     * @return
     */
    public CouponCalcResult calculate(Integer quantity, Long sellingPrice, Long couponId) {
        Coupon coupon = couponRepository.get(couponId);
        CouponConfig couponConfig = JsonUtils.fromJson( coupon.getConfig(), CouponConfig.class);
        AbstractCouponCalculator couponCalculator = CouponCalculateFactory.get(CouponTypeEnum.getByCode(coupon.getType()));

        CouponCalcParams couponCalcParams = new CouponCalcParams(sellingPrice, quantity);
        return couponCalculator.calculate(couponCalcParams, couponConfig);
    }
}
复制代码

可以看出当前的CouponService变得简约了很多,可读性自然也提高了很多。如果策略类中不止包含了一个方法,比如当前只有calculate方法,如果还有display方法(用于展示最后计算出来的优惠效果文案,例如xx折,低至xx元,满xx减yy元)的话,优化效果会更加明显。例如下图中不仅计算了实际价格,还展示了优惠文案。

image.png

完整代码见:...待补充

优缺点

优点

  1. 有效避免了if-else与switch-case过多的情况,通过定义新的子类很容易增加新的策略和转换,适应了开闭原则
  2. 策略模式提供了一系列的可供重用的算法族,恰当使用继承可以把算法族的公共代码转移到父类里面,从而避免重复的代码,如上面的计算总价方法calculateTotalPrice。
  3. 通过结合工厂模式,可以避免让客户感知到具体策略类,通常客户只需要感知抽象策略类即可。

缺点

  1. 策略模式会造成很多的策略类,增加维护难度,一般建议算法族放到一个包下单独维护较好。
  2. 如果不结合工厂模式,那客户端必须自己来选择合适的策略,必须清楚各个策略的功能和不同,这样才能做出正确的选择,但是这暴露了策略的具体实现。

总结

当if-else或者switch-case较少,且未来也不怎么会变化时,其实一般不一定需要使用策略模式来优化,少许的if-else看起来也很清晰,否则我认为就属于过度设计了。一般情况,策略模式都是结合工厂模式使用,可以更好的对策略类进行管理,降低客户端的使用成本。策略模式良好的践行了开闭原则,单一职责原则,里氏替换原则。


参考

策略模式(策略设计模式)详解

文章分类
后端