关于策略模式的文章,其实网上实在是太多了,自己的又没有啥文采,肯定也是写不出来什么花!但是我突然想起了孔乙己,对没错就是那个知道“茴”字有四种写法的孔乙己。当时,语文老师说这个场景是为了表现孔乙己的一种迂腐封建的性格。可是我对孔乙己的“茴”字有四种写法,一直有着不同的看法。我倒是觉得从某种意思上知道四种写法反倒是好事,比如你老板安排你做一件事情,你有四种不同的方案可以解决,这难道不是一件好事情嘛!于是,我还是觉得这篇文章还是有写的必要,让大家看看“茴”字有不同的写法。
假设目前有个抽奖的业务场景,奖品共有现金、优惠券、积分和谢谢参与四类,后续很大可能增加新的奖品类型如赠送抽奖次数。用户抽中则实时发送给用户。一般的小伙伴如何实现这个奖品发放的逻辑,我来写一下伪代码:
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(马丁.福勒)
问题在哪里 现在我们来说说这段伪代码到底有哪些不好的地方:
NPE的问题。 在没有对入参进行校验的情况下,直接使用 rewardType.equals("积分")
进行判断,若rewardType值是null,这个地方就会出现空指针异常。
硬编码问题。奖励类型的魔法值后期多处拷贝,有可能出现拼写错误,也无法很好展示到底有多少奖励类型。
过多的if else会导致嵌套过深和逻辑表达式复杂。
违反开闭原则(OCP)和单一职责原则(SRP)。需求明确提出后续可能会增加“赠送抽奖次数”的奖品类型,按照目前代码的结构要增加发送新型的奖品,则势必需要修改sendReward()
代码,增加一个新的分支,这样就修改了原来的代码,就会需要对此处的代码进行回归测试。
如何改进 根据上面提到的问题我们给出针对性的优化意见。
调换equals双方的位置 "积分".equals(rewardType)
或者使用java.util.Objects.equals()
等等方法替代。
定义一个奖励类型枚举。
使用卫语句优化判断。
使用上面三个优化意见我们来修改伪代码,最终伪代码呈现如下:
定义一个奖励类型枚举
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 { void sendReward (Long memberId) ; }
发送优惠券的策略 1 2 3 4 5 6 7 8 9 @Slf 4j@Service public class CouponRewardSendStrategy implements RewardSendStrategy { @Override public void sendReward (Long memberId) { log.info("给[{}]发送优惠券奖品[{}]" , memberId); } }
发送积分的策略 1 2 3 4 5 6 7 8 9 @Slf 4j@Service public class PointRewardSendStrategy implements RewardSendStrategy { @Override public void sendReward (Long memberId) { log.info("给[{}]发送积分奖品[{}]" , memberId); } }
谢谢参与的策略 1 2 3 4 5 6 7 8 9 @Slf 4j@Service public class ThankYouRewardSendStrategy implements RewardSendStrategy { @Override public void sendReward (Long memberId) { log.info("[{}],对不起,谢谢参与" , memberId); } }
发送现金策略 1 2 3 4 5 6 7 8 9 @Slf 4j@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 { 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 { String type () ; boolean isTypeMatch (String type) ; void sendReward (Long memberId) ; }
按照这种接口定义,我们在相应的策略类里实现相关方法,这里只给出策略类一个作为示范。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 @Slf 4j@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 @Slf 4j@Component public class RewardSendStrategyFactory { @Autowired private List<RewardSendStrategy> strategyList; 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 @Slf 4j@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)); } 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 @Slf 4j@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 @Slf 4j@Component public class RewardSendStrategyFactory { private final static Map<String, RewardSendStrategy> STRATEGY_MAP = new ConcurrentHashMap<>(16 ); public static void registerStrategyInstance (String type, RewardSendStrategy strategy) { STRATEGY_MAP.put(type, strategy); } 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,那都是顶呱呱的方案。
总结 一千个观众眼中有一千个哈姆雷特。每个人对待任何事物都有自己的看法,一千人就有可能有一千种想法。有些事情看上去是件坏事,可换个角度去解读,反而可能会得到一种不一样思想。
善恶本有人相、我相、众生相,即是文化。