孔乙己“茴”字四种写法引起我对策略模式实现的思考

关于策略模式的文章,其实网上实在是太多了,自己的又没有啥文采,肯定也是写不出来什么花!但是我突然想起了孔乙己,对没错就是那个知道“茴”字有四种写法的孔乙己。当时,语文老师说这个场景是为了表现孔乙己的一种迂腐封建的性格。可是我对孔乙己的“茴”字有四种写法,一直有着不同的看法。我倒是觉得从某种意思上知道四种写法反倒是好事,比如你老板安排你做一件事情,你有四种不同的方案可以解决,这难道不是一件好事情嘛!于是,我还是觉得这篇文章还是有写的必要,让大家看看“茴”字有不同的写法。

茴字四种写法

假设目前有个抽奖的业务场景,奖品共有现金、优惠券、积分和谢谢参与四类,后续很大可能增加新的奖品类型如赠送抽奖次数。用户抽中则实时发送给用户。一般的小伙伴如何实现这个奖品发放的逻辑,我来写一下伪代码:

1
2
3
4
5
6
7
8
9
10
11
public void sendReward(Byte rewardType, String reward) {
if (rewardType.equals("积分")) {
log.info("发送积分奖励[{}]", reward);
} else if (rewardType.equals("优惠券")) {
log.info("发送优惠券奖励[{}]", reward);
} else if (rewardType.equals("现金")) {
log.info("发送现金奖励[{}]", reward);
} else if (rewardType.equals("谢谢参与")) {
log.info("对不起,谢谢参与[{}]", reward);
}
}

大家看一看这种代码,是不是感觉似曾相似,心里是不是在想“这不就是我写的么”!写过这种代码也没有什么不好意思的,毕竟大部分人都是普通人不是那种一个顶十个的大牛,笔者之前也是经常写这种代码,毕竟那个时候也不了解什么是好代码、如何写出好代码,此处必须提大牛Martin Fowler(马丁.福勒),他的著作《重构》大家可以看看。在此,我想强调的是过去写不出优秀的代码不重要,重要的是现在你要知道什么是优秀的代码和如何写出优秀的代码,不然你多年工作经验的沉淀在哪里呢?

任何一个傻瓜都能写出计算机可以理解的代码。唯有写出人类容易理解的代码,才是优秀的程序员。—— Martin Fowler(马丁.福勒)

问题在哪里

现在我们来说说这段伪代码到底有哪些不好的地方:

  1. NPE的问题。 在没有对入参进行校验的情况下,直接使用 rewardType.equals("积分")进行判断,若rewardType值是null,这个地方就会出现空指针异常。
  2. 硬编码问题。奖励类型的魔法值后期多处拷贝,有可能出现拼写错误,也无法很好展示到底有多少奖励类型。
  3. 过多的if else会导致嵌套过深和逻辑表达式复杂。
  4. 违反开闭原则(OCP)和单一职责原则(SRP)。需求明确提出后续可能会增加“赠送抽奖次数”的奖品类型,按照目前代码的结构要增加发送新型的奖品,则势必需要修改sendReward()代码,增加一个新的分支,这样就修改了原来的代码,就会需要对此处的代码进行回归测试。

如何改进

根据上面提到的问题我们给出针对性的优化意见。

  1. 调换equals双方的位置 "积分".equals(rewardType)或者使用java.util.Objects.equals()等等方法替代。

  2. 定义一个奖励类型枚举。

  3. 使用卫语句优化判断。

使用上面三个优化意见我们来修改伪代码,最终伪代码呈现如下:

定义一个奖励类型枚举

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Getter
public enum RewardTypeEnum {
/**
* 现金奖励
*/
CASH("1","现金"),
/**
* 积分
*/
POINT("2","积分"),
/**
* 优惠券
*/
COUPON("3","优惠券"),
/**
* 谢谢参与
*/
THANK_YOU("4","谢谢参与"),
;

private String code;

private String desc;

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

修改后的发送奖励伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void sendReward(String rewardType, String reward) {
if (RewardTypeEnum.POINT.getCode().equals(rewardType)) {
log.info("发送积分奖励[{}]", reward);
return;
}
if (RewardTypeEnum.COUPON.getCode().equals(rewardType)) {
log.info("发送优惠券奖励[{}]", reward);
return;
}
if (RewardTypeEnum.CASH.getCode().equals(rewardType)) {
log.info("发送现金奖励[{}]", reward);
return;
}
if (RewardTypeEnum.THANK_YOU.getCode().equals(rewardType)) {
log.info("对不起,谢谢参与[{}]", reward);
return;
}
}

对于问题4的解决,我们先需要思考一下。奖励的种类目前有四种,后续很有可能增加奖励种类,不同的奖励发放给用户肯定是涉及了不同的模块,调用不同的发放接口,最终的数据肯定也是写到不同的数据表中,这种情况明显是不同奖励有着不同的发放策略,很明显大家都能想到符合策略模式,本文的主角终于出场了!

策略模式属于设计模式中行为模式的一种,它主要是将算法封装起来,并且可以相互的替换。在看一些关于设计模式的书时,策略模式首先会定义一个接口,然后具体的策略类去实现策略接口,最后再定义一个Context上下文类来持有一个具体的策略以供调用者使用。这里其实有个问题好多书籍或者博客并没有说明,如何更优雅的让上下文类来持有一个具体的策略,这里我先搭建一下代码的框架,这个问题后面再细说。

定义策略接口

1
2
3
4
5
6
7
8
9

public interface RewardSendStrategy {
/**
* 发送奖励
* @param memberId 会员id
*/
void sendReward(Long memberId);

}
发送优惠券的策略
1
2
3
4
5
6
7
8
9
@Slf4j
@Service
public class CouponRewardSendStrategy implements RewardSendStrategy {

@Override
public void sendReward(Long memberId) {
log.info("给[{}]发送优惠券奖品[{}]", memberId);
}
}
发送积分的策略
1
2
3
4
5
6
7
8
9
@Slf4j
@Service
public class PointRewardSendStrategy implements RewardSendStrategy {

@Override
public void sendReward(Long memberId) {
log.info("给[{}]发送积分奖品[{}]", memberId);
}
}
谢谢参与的策略
1
2
3
4
5
6
7
8
9
@Slf4j
@Service
public class ThankYouRewardSendStrategy implements RewardSendStrategy {

@Override
public void sendReward(Long memberId) {
log.info("[{}],对不起,谢谢参与", memberId);
}
}
发送现金策略
1
2
3
4
5
6
7
8
9
@Slf4j
@Service
public class CashRewardSendStrategy implements RewardSendStrategy {

@Override
public void sendReward(Long memberId) {
log.info("给[{}]发送现金奖品", memberId);
}
}
定义Context上下文类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class RewardSendStrategyContext {

/**
* Context持有具体策略
*/
private RewardSendStrategy sendStrategy;

public void setSendStrategy(RewardSendStrategy sendStrategy) {
this.sendStrategy = sendStrategy;
}

public void excute(Long memberId) {
sendStrategy.sendReward(memberId);
}
}
单元测试,试试如何使用
1
2
3
4
5
6
7
8
9
10
11
@ContextConfiguration(locations = {"classpath:spring/spring-dao.xml", "classpath:spring/spring-service.xml"})
@RunWith(value = SpringJUnit4ClassRunner.class)
public class StrategyTest {

@Test
public void test() {
RewardSendStrategyContext context = new RewardSendStrategyContext();
context.setSendStrategy(new CouponRewardSendStrategy());
context.excute(11L);
}
}

单元测试的执行结果不用怀疑,肯定打印出了 给[11]发送优惠券奖品,但是这种调用方式我感觉并没有解决掉if else多级逻辑判断,为什么这么说,因为这个地方我知道我调用的是发放优惠券的策略,但是在实际业务调用中,我并不知道要发送什么类型奖品,我还是要根据传入的奖品类型去判断使用不同的奖品发送策略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public void test2() {
String rewardType = RewardTypeEnum.THANK_YOU.getCode();
RewardSendStrategyContext strategyContext = new RewardSendStrategyContext();
if (RewardTypeEnum.POINT.getCode().equals(rewardType)) {
strategyContext.setSendStrategy(new PointRewardSendStrategy());
strategyContext.excute(11L);
return;
}
if (RewardTypeEnum.COUPON.getCode().equals(rewardType)) {
strategyContext.setSendStrategy(new CouponRewardSendStrategy());
strategyContext.excute(11L);
return;
}
if (RewardTypeEnum.CASH.getCode().equals(rewardType)) {
strategyContext.setSendStrategy(new CashRewardSendStrategy());
strategyContext.excute(11L);
return;
}
if (RewardTypeEnum.THANK_YOU.getCode().equals(rewardType)) {
strategyContext.setSendStrategy(new ThankYouRewardSendStrategy());
strategyContext.excute(11L);
return;
}
}

按照我们前面给的建议进行优化,代码貌似又回到了起点。上面的代码明显还是不符合开闭原则和单一原则,看来我们还是需要进一步的优化。

使用Spring框架的特性改进

前面我们已经把发送不同奖品的逻辑分散到了各个单独的类中,这些操作符合单一职责原则。但是没有统一入口,导致在调用策略的地方还是出现了大片的if判断,使得代码还是没有满足开闭原则。所以接下来我们要做的就是干掉这些if判断,提供一个易用的调用接口。

那怎么样我们才能减少或者直接消除上述代码中存在的 if 判断呢?回想之前的一篇文章船新版本的策略模式,你一定没有见过 ,在这个文章中我用注解标识每个策略方法,以注解的值为key,策略方法为value保存在Map中,然后通过Map的 get()获取具体的策略方法,省去了 if 判断。这个其实就是表驱动法,是一种很经典的编程技巧。那在这里就继续用这种方式来解决我们目前遇到的问题。当然此时我们不需要自定义注解,也不需要反射调用,不需要那么大费周章。Spring是一个很优秀的Java框架,一般做Java开发都是基于Spring框架的,Spring框架里有很多特性、技巧能帮助我们写出更优秀的代码。接下来我就展示一下如何使用Spring框架让我们的代码质量更上一个层次。

在此之前我需要修改一下之前的接口,我们在接口中再定义两个方法,其中一个方法是标识当前类的奖励类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public interface RewardSendStrategy {

/**
* 发放奖励的类型,通过这个方法来标示不同的策略
* @return
*/
String type();
/**
* 是否匹配
* @param type 奖励类型
* @return
*/
boolean isTypeMatch(String type);
/**
* 发送奖励
* @param memberId 会员id
*/
void sendReward(Long memberId);

}

按照这种接口定义,我们在相应的策略类里实现相关方法,这里只给出策略类一个作为示范。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Slf4j
@Service
public class CouponRewardSendStrategy implements RewardSendStrategy {

@Override
public String type() {
return RewardTypeEnum.COUPON.getCode();
}

@Override
public boolean isTypeMatch(String type) {
return Objects.equals(type, type());
}

@Override
public void sendReward(Long memberId) {
log.info("给[{}]发送优惠券奖品", memberId);
}
}

好了,准备工作做的差不多了,接下来来显示Spring框架的威力吧!让它来帮组装我们需要的Map!

方案一@Autowired注解

这个注解大家肯定用过很多次了,我们常用它来注入其他的Bean,但是大家可能还有人不知道它有这种技能:

1
2
3
4
* <p>In case of a {@link java.util.Collection} or {@link java.util.Map}
* dependency type, the container will autowire all beans matching the
* declared value type. In case of a Map, the keys must be declared as
* type String and will be resolved to the corresponding bean names.

这个是@Autowired注解源码上的注释,大致意思我举例说明

1
2
3
4
5
6
7
# 这样定义属性的时候,Spring 会自动的将这个接口的实现类bean全都自动添加到这个 rewardSendStrategyList 中
@Autowired
private List<RewardSendStrategy> rewardSendStrategyList;

# 这样定义属性的时候,Spring 会自动的以实现类 beanName 作为 key,bean 作为 value 添加到这个 Map 中
@Autowired
private Map<String, RewardSendStrategy> strategyMap;

666,就差一点我就要给你打一百分了,借此特性我们可以很容易的实现我们的Map,之前的Context类我们稍加修改,再借助一下Lamda表达式,我们得到了如下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Slf4j
@Component
public class RewardSendStrategyFactory {

@Autowired
private List<RewardSendStrategy> strategyList;

/**
* 获取策略实例
* @param type
* @return
*/
public RewardSendStrategy getImpl(String type) {
return strategyList.stream()
.filter(strategy -> strategy.isTypeMatch(type))
.findAny()
.orElseThrow(() -> new RuntimeException("没有找到策略实现"));
}
}

这样一来我们调用就方便多了,而且很丝滑哦!

1
2
3
4
5
6
7
8
9
10
11
12
13
@ContextConfiguration(locations = {"classpath:spring/spring-dao.xml", "classpath:spring/spring-service.xml"})
@RunWith(value = SpringJUnit4ClassRunner.class)
public class StrategyTest {

@Autowired
private RewardSendStrategyFactory factory;

@Test
public void test() {
RewardSendStrategy strategy = factory.getImpl(RewardTypeEnum.POINT.getCode());
strategy.sendReward(11L);
}
}

看着单元测试输出的结果,我感觉终于找到了完美实现策略模式的方案了。哈哈,其实还有好几种,这种方式我觉得是最简便的,也是我最常用的。所以我写在最前面,后面两种让我娓娓道来。

方案二使用Spring框架的ApplicationContextAware扩展点

实现org.springframework.context.ApplicationContextAware接口,可以拿到ApplicationContext也就意味着可以拿到Spring容器中的所有Bean,既然拿到了所有的Bean,那我们就可以遍历其中的Bean,将是我们需要的放入到Map中。Context类进行修改,类名我也改成了RewardSendStrategyFactory,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Slf4j
@Component
public class RewardSendStrategyFactory implements ApplicationContextAware {

/**
* 保存所有策略
*/
private final static Map<String, RewardSendStrategy> STRATEGY_MAP = new LinkedHashMap<>();


@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
Map<String, RewardSendStrategy> beans = applicationContext.getBeansOfType(RewardSendStrategy.class);
if (MapUtils.isEmpty(beans)) {
return;
}
beans.values().forEach(bean -> STRATEGY_MAP.put(bean.type(), bean));
}

/**
* 获取策略实例
*
* @param type 奖励类型
* @return
*/
public static RewardSendStrategy getStrategyInstance(String type) {
log.info("策略实例[{}]", STRATEGY_MAP);
return STRATEGY_MAP.get(type);
}
}

调用方如何使用:

1
2
3
4
5
6
7
8
9
10
11
12
@ContextConfiguration(locations = {"classpath:spring/spring-dao.xml","classpath:spring/spring-service.xml"})
@RunWith(value = SpringJUnit4ClassRunner.class)
public class StrategyTest {

@Test
public void test(){
String code = RewardTypeEnum.CASH.getCode();
RewardSendStrategy strategyInstance = RewardSendStrategyFactory2.getStrategyInstance(code);

strategyInstance.sendReward(12L);
}
}
方案三使用Spring框架的InitializingBean扩展点

具体策略要实现org.springframework.beans.factory.InitializingBean接口。凡是将类交给Spring管理并且实现该接口,Spring在初始化bean的时候会自动执行afterPropertiesSet()方法,此时我们将这个Bean放入一个Map即可。这里我就只展示一个具体策略了,其他都是差不多的,具体的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Slf4j
@Service
public class PointRewardSendStrategy implements RewardSendStrategy, InitializingBean {
@Override
public String type() {
return RewardTypeEnum.POINT.getCode();
}

@Override
public boolean isTypeMatch(String type) {
return Objects.equals(type, type());
}

@Override
public void sendReward(Long memberId) {
log.info("给[{}]发送积分奖品", memberId);
}

@Override
public void afterPropertiesSet() throws Exception {
RewardSendStrategyFactory.registerStrategyInstance(type(), this);
}
}

细心的小伙伴可能发现了一些问题,如果每个具体策略类都实现 InitializingBean 接口,那么重写的afterPropertiesSet()方法明显是重复代码块,当然这个问题也是可以解决的,这里先按下不表!(不然下篇水文我怎么写)

相应的Context上下文类代码修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Slf4j
@Component
public class RewardSendStrategyFactory {

/**
* 保存策略集合
*/
private final static Map<String, RewardSendStrategy> STRATEGY_MAP = new ConcurrentHashMap<>(16);

/**
* 添加策略实例
*
* @param type
* @param strategy
*/
public static void registerStrategyInstance(String type, RewardSendStrategy strategy) {
STRATEGY_MAP.put(type, strategy);
}

/**
* 获取策略实例
* @param type
* @return
*/
public static RewardSendStrategy getStrategyInstance(String type) {
log.info("策略实例[{}]", STRATEGY_MAP);
return STRATEGY_MAP.get(type);
}

}

调用方式如单元测试所示:

1
2
3
4
5
6
@Test
public void test() {
String rewardType = RewardTypeEnum.POINT.getCode();
RewardSendStrategy strategy = RewardSendStrategyFactory.getStrategyInstance(rewardType);
strategy.sendReward(12L);
}
其他方案

方案其实还有很多,例如前文中也说到了基于注解的形式,Spring容器启动的时候扫描指定的注解。这个看自己的想法了,只要符合优质代码的标准,而且能实现功能又没有bug,那都是顶呱呱的方案。

总结

一千个观众眼中有一千个哈姆雷特。每个人对待任何事物都有自己的看法,一千人就有可能有一千种想法。有些事情看上去是件坏事,可换个角度去解读,反而可能会得到一种不一样思想。

善恶本有人相、我相、众生相,即是文化。

  • 作者: Sam
  • 发布时间: 2021-02-23 20:29:36
  • 最后更新: 2021-02-23 20:59:30
  • 文章链接: https://ydstudios.gitee.io/post/d06ff354.html
  • 版权声明: 本网所有文章除特别声明外, 禁止未经授权转载,违者依法追究相关法律责任!