前言

项目中有这样一个场景,在公园放置了用来拍摄人像的识别杆,根据用户在不同识别杆之间采集的图象来计算用户的运动距离。由于涉及到许多公园,每个公园的布局不同,识别杆之间距离不同,算法也不同。但代码中每个不同的公园的算法区别都采用ifelse来进行判断处理。

这样的写法你能看得下去吗?肯定不能。所以,就用策略模式对此进行了重构。项目采用SpringBoot架构,于是对不同的策略模式写法又进行了一次升级。现在就以实战的角度带领大家来学习策略模式,以及如何将ifelse重构为基于SpringBoot的策略模式。

ifelse的伪代码

由于业务逻辑比较复杂,这里以最简单的简化模型来为大家展示一段伪代码。

public int getDistance(int parkId, int count) {
    // 如果是公园x,1个间距为y(比如:10)米
    if (parkId == 1) {
        return 10 * count;
    } else if (parkId == 2) {
        return 5 * count;
    } else if (parkId == 3) {
        return 50 * count;
    } else {
        // 默认 20米
        return 20 * count;
    }
}

假设上面的代码是用来计算不同识别杆之间的距离的。不同的公园识别杆距离不同,根据parkId来计算多个识别杆之间的距离。当然真实业务不可能这么简单的相乘。

首先对照一下设计模式的开闭原则:面对扩展开放,面对修改关闭。

上述代码,如果某个公园的计算算法改变了,那么这段代码就要进行修改,或者如果新增了一个公园,这段代码同样需要修改。一旦修改必然会影响到其他公园的业务逻辑。完全不符合开闭原则,同时代码中还充斥着大量的ifelse,如果业务复杂,代码会急速膨胀。

那么,下面我们就针对以上实例,用策略模式来进行重新设计。

什么是策略模式

策略模式属于对象的行为模式,是针对算法的包装。通常场景为,对象有某个行为,但是在不同的场景中,该行为有不同的实现算法。

那么针对这一组算法,将每一个实现封装为抽象策略类的子类,从而使得它们可以相互替换。策略模式使得算法可以在不影响到客户端的情况下发生变化。

策略模式的结构

如果用类图来表示,策略模式的结构可以展示如下:

image

策略类简单的来说,就是定义一个接口或抽象类,在其中定义公共算法方法,然后不同的子类实现该接口的方法。同时针对接口的操作和获取又有一个环境(Context)类来进行辅助或封装。

在上图中各部分的角色功能如下:

  • 环境(Context)角色:持有Strategy的引用,通常会封装一些获取实现类的方法,或策略类的调用。相对来说定义比较灵活。
  • 抽象策略(Strategy)角色:抽象角色,通常是一个接口或抽象类。所有的具体策略类都需要实现该接口。如果是抽象类,可实现一些公共的方法。
  • 具体策略(ConcreteStrategy)角色:具体算法或行为的实现,也就是抽象策略类中定义的方法的不同实现。

简单代码重构

首先定义一个计算距离的抽象类AbstractParkStrategy。实战过程中可根据具体情况采用接口或抽象类。

/**
 * 抽象类,此处也可以使用接口,根据具体情况定义
 **/
public abstract class AbstractParkStrategy {

    /**
     * 计算距离的抽象方法
     * @param count 节点数
     * @return 总距离
     */
    public abstract int calcDistance(int count);
}

在抽象类中提供一个抽象方法,也就是所有公园带实现的距离计算算法。这里因为是抽象类,所以就定义了abstract生命的抽象方法。下面便是针对此抽象类的具体实现,不同的公园有不同的算法实现。

人民公园的实现类PeopleParkStrategy。其中@Slf4j为Lombok的注解,可根据自己项目情况进行修改。

/**
 * 人民公园的实现
 **/
@Slf4j
public class PeopleParkStrategy extends AbstractParkStrategy {

    @Override
    public int calcDistance(int count) {
        log.info("处理【人民公园】距离计算:count={}", count);
        // 默认 10米
        return 10 * count;
    }
}

北海公园的实现类BeiHaiParkStrategy:

/**
 * 北海公园的实现
 **/
@Slf4j
public class BeiHaiParkStrategy extends AbstractParkStrategy {

    @Override
    public int calcDistance(int count) {
        log.info("处理【北海公园】距离计算:count={}",count);
        // 默认 10米
        return 50 * count;
    }
}

最后再提供一个默认的通用公园的实现类DefaultParkStrategy:

/**
 * 提供一个默认,通用的计算实现
 **/
@Slf4j
public class DefaultParkStrategy extends AbstractParkStrategy {

    @Override
    public int calcDistance(int count) {
        log.info("处理【通用公园】距离计算:count={}", count);
        // 默认 20米
        return 20 * count;
    }
}

最后,我们来实现一个持有抽象类AbstractParkStrategy的环境角色类DistanceContext:

/**
 * 环境角色类
 **/
public class DistanceContext {

    /**
     * 持有策略抽象类
     */
    private AbstractParkStrategy abstractParkStrategy;

    // 通过构造方法注入,也可以通过其他方式注入
    public DistanceContext(AbstractParkStrategy parkStrategy) {
        this.abstractParkStrategy = parkStrategy;
    }

    public int calcDistance(int count) {
        return abstractParkStrategy.calcDistance(count);
    }

}

在该类中定义AbstractParkStrategy为其属性,通过构造方法传入实例化对象,并且提供了一个调用策略类的方法。

最后,我们来看一下如何调用该策略类,这里通过单元测试来完成:

@Slf4j
public class ParkTest {

    @Test
    public void testStrategy() {
        DistanceContext distanceContext = new DistanceContext(new PeopleParkStrategy());
        int distance = distanceContext.calcDistance(2);
        log.info("获得距离:{}", distance);
    }
}

单元测试模拟客户端的调用,当客户端实例化的是人民公园时,调用对应算法获得的便是人民公园的距离。如果创建的是其他公园的对象,则返回其他公园的距离。

策略模式的优缺点

从示例可以看出,策略模式仅仅封装算法,并不决定在何时使用何种算法。同时,在什么时候使用什么算法也是由客户端决定的。

同时策略模式有以下优缺点。优点:

  • 算法可以自由切换。
  • 使用策略模式可以避免使用多重条件(if-else)语句。多重条件语句不易维护,它把采取哪一种算法或采取哪一种行为的逻辑与算法或行为的逻辑混合在一起,统统列在一个多重条件语句里面,比使用继承的办法还要原始和落后。
  • 扩展性良好。增加新策略,只需实现接口的具体逻辑即可。当旧策略不需要时,直接剔除就行。
  • 良好的封装性。策略的入口封装在Context封装类中,客户端只要知道使用哪种策略对象就可以了。

缺点:

  • 客户端必须知道具体的策略,并且决定使用哪一个策略,也就是说各个策略需要暴露给客户端。
  • 如果策略增多,策略类的数量就会增加。对比上面的ifelse会发现增加了很多类。

策略工厂类改进

上面我们也看到了一个问题,就是客户端必须知道具体的策略实现类才能进行具体算法的调用。那么,是否可以将实现类进行封装,客户端只需传入对应的类型,然后直接调用方法就可以了?

此时,我们可以对策略模式的环境角色类进行改造,参考一下工厂模式的元素。修改之后的环境角色类变为FactoryContext:

public class FactoryContext {

    /**
     * 持有策略抽象类
     */
    private AbstractParkStrategy parkStrategy;

    // 把创建策略放在封装角色内,客户端只需要知道结果
    public void factory(int strategyType) {
        if (strategyType == 1) {
            parkStrategy = new PeopleParkStrategy();
        } else if (strategyType == 2) {
            parkStrategy = new BeiHaiParkStrategy();
        } else {
            parkStrategy = new DefaultParkStrategy();
        }
    }

    public int calcDistance(int count) {
        return parkStrategy.calcDistance(count);
    }

}

对应的单元测试类改为如下:

@Test
public void testFactoryStrategy() {
    FactoryContext factoryContext = new FactoryContext();
    factoryContext.factory(1);
    int distance = factoryContext.calcDistance(5);
    log.info("获得距离:{}", distance);
}

执行单元测试,发现可以正常输出结果。此时,客户端只用传入它自身的类型,不再关系具体类的创建了。

基于SpringBoot的策略模式

上面的改进已经更方便实践中的使用了,此时如果项目使用的是SpringBoot项目,那么还可以更进一步的封装和改进。请注意此步骤作为讲解验证的过度步骤,便于大家理解,此方式实现为非线程安全的,不建议在生产中使用。

此时主要利用Spring的@Autowired注解来将实例化的策略实现类注入到一个Map当中,然后通过key可以方便的拿到服务。这里使用concurrentHashMap是防止多线程操作的时候出现问题。

首先将策略实现类通过@Service注解进行实例化,并指定实例化的名称。以人民公园的实现为例:

@Slf4j
@Service("peopleParkStrategy")
public class PeopleParkStrategy extends AbstractParkStrategy {
    // ...
}

其他策略实现类与上相同,依次实例化。

最后改造环境角色类为DistanceSpringContext:

@Component("distanceSpringContext")
public class DistanceSpringContext {

    @Autowired
    private final Map<String, AbstractParkStrategy> strategyMap = new ConcurrentHashMap<>(3);

    private AbstractParkStrategy strategy;

    public void factory(String serviceName) {
        strategy = strategyMap.get(serviceName);
    }

    public int calcDistance(int count) {
        return strategy.calcDistance(count);
    }
}

首先通过@Component注解将DistanceSpringContext的实例化也交由Spring来管理了。而@Autowired注解会将容器中AbstractParkStrategy的实现类(注解了@Service)注入到该Map中。其中key就是@Service中指定的实例化服务的名称,value值便是对应的对象。此时我们有三个策略实现类。因此,strategyMap中会被注入三个值。

既然其中有值存在,此时可通过serviceName来获得对应的服务,并调用相应的方法。

最后看一下单元测试的方法:

@Slf4j
@SpringBootTest
@RunWith(SpringRunner.class)
public class ParkSpringTest {

    @Resource
    private DistanceSpringContext distanceSpringContext;

    @Test
    public void testSpringStrategy() {
        distanceSpringContext.factory("defaultParkStrategy");
        int distance = distanceSpringContext.calcDistance(10);
        log.info("获得距离:{}", distance);
    }
}

直接在使用的地方注入DistanceSpringContext,然后根据具体的服务名来调用即可。不过,在最开始我们已经说过了,此种方法非线程安全,因为DistanceSpringContext是单例的,在factory方法设置的时候,其他线程也有可能修改。而且还需要传递Service的名称,使用起来还是不够方便。

进一步改进

针对SpringBoot集成的问题,我们再进一步改进。我们不再在策略角色类中调用策略类的方法了,只让策略角色类作为工厂的角色,返回对应的服务。而相关服务方法的调用由客户端直接调用实现类的方法。

同时,针对服务的名称和公园的Id我们通过枚举进行映射。先来定义一个枚举类:

public enum ParkEnum {

    DEFAULT_PARK(0, "defaultParkStrategy", "默认公园"),
    PERSON_PARK(1, "peopleParkStrategy", "人民公园"),
    BEI_HAI_PARK(2, "beiHaiParkStrategy", "北海公园"),

    ;

    ParkEnum(int parkId, String serviceName, String desc) {
        this.parkId = parkId;
        this.serviceName = serviceName;
        this.desc = desc;
    }

    public static ParkEnum valueOf(int parkId) {
        for (ParkEnum parkEnum : ParkEnum.values()) {
            if (parkEnum.getParkId() == parkId) {
                return parkEnum;
            }
        }
        return DEFAULT_PARK;
    }

    private int parkId;

    private String serviceName;

    private String desc;

    public int getParkId() {
        return parkId;
    }

    public String getServiceName() {
        return serviceName;
    }

    public String getDesc() {
        return desc;
    }
}

在上述枚举类中,我们将公园的ID和对应的服务进行绑定,并提供了通过公园ID获得对应枚举的方法。

然后改造环境角色类:

@Component("distanceSpringV2Context")
public class DistanceSpringV2Context {

    @Autowired
    private final Map<String, AbstractParkStrategy> strategyMap = new ConcurrentHashMap<>(3);

    public AbstractParkStrategy getService(int parkId) {
        ParkEnum parkEnum = ParkEnum.valueOf(parkId);
        return strategyMap.get(parkEnum.getServiceName());
    }

}

通过枚举的转换,我们只需要接收公园ID这样一个参数便可以返回对应的具体策略实现类,在也中直接调用便可。而parkId每个公园都有,根本不需要进行业务判断。

下面看单元测试类:

@Slf4j
@SpringBootTest
@RunWith(SpringRunner.class)
public class ParkSpringV2Test {

    @Resource
    private DistanceSpringV2Context distanceSpringV2Context;

    @Test
    public void testSpringStrategy() {
        AbstractParkStrategy parkStrategy = distanceSpringV2Context.getService(1);
        int distance = parkStrategy.calcDistance(10);
        log.info("获得距离:{}", distance);
    }

}

此时,只用传递对应的公园ID,然后获得对应的服务,然后直接调用服务的方法便可进行使用。是不是比最开始方便多了。此时,如果新添加算法,只用创建对应算法的服务,然后在枚举类中映射一下关系,便可在不影响客户端调用的情况进行扩展。当然,根据具体的业务场景还可以进行进一步的改造。

小结

最开始我们在讲策略模式,但演变到最后,发现它已经变得不那么像最初的策略模式了,并且有了工厂模式的身影。这也正是学习与实践的不同。单纯理论学习策略模式,是很难运用的好的。只有结合实践场景,根据需要进行改进。毕竟适合的最好的,就像,我们使用了SpringBoot便可根据SpringBoot的特性进行融合。回头看看你的项目,有没有类似的可改造的地方?行动起来吧。

相关源码,公众号“程序新视界”中回复“1001”即可获得链接。



SpringBoot下的策略模式,消灭了大量的ifelse,真香!插图1

关注公众号:程序新视界,一个让你软实力、硬技术同步提升的平台

除非注明,否则均为程序新视界原创文章,转载必须以链接形式标明本文链接

本文链接:https://choupangxia.com/2020/10/23/springboot-strategy/