使用策略模式重构代码

2,008 阅读6分钟

前言

前端时间重构项目,于是......没错,我又想吐槽了,重构真的比开发新功能累的多,首先要去理解原来的代码逻辑,然后才能动手,更重要的是还得保证你重构的代码不能错,最重要的是原来的屎山代码......原来的项目很少有运用设计模式,今天借着重构经历,给大家介绍一个运用很广泛的设计模式 —— 策略模式,希望对大家有帮助。

策略模式简介

在策略模式 Strategy Pattern 中,一个类的行为或其算法可以在运行时更改。我们创建表示各种策略的对象和一个行为随着策略对象改变而改变的上下文对象。策略对象改变上下文对象的执行算法。通常在面对符合多个行为的 if/else、switch 语句时,我们可以考虑使用策略模式来重构。

image.png

简单来说就是你的 if/else、switch 里面全都是干的相同的一件事情,只不过具体干这件事的过程略微有差异,那么这时候就可以考虑使用策略模式来代替。

策略模式改造创建虚拟账户

场景

项目有个业务是根据用户选择的还款渠道创建属于他的虚拟账户,先来看下原来的代码

switch (channel) {
  case FASPAY:
    //...创建虚拟账户
    break;
  case FASPAY_V2:
    //...创建虚拟账户
    break;
  case BNI:
    //...创建虚拟账户
    break;
  case INSTAMONEY:
    //...创建虚拟账户
    break;
  case INSTAMONEY_V2:
    //...创建虚拟账户
    break;
  case BCA:
    //...创建虚拟账户
    break;
  default:
    break;
}

可以看到如果以后再接入其他渠道还得再加 case 代码块,(其实如果原来的代码真的把 generateVirtualAccount() 封装好,公共代码抽取好的话我觉得问题也不大,但是......你懂得)可以看到所有的 case 代码块都是创建虚拟账户,只不过不同的渠道创建的细节略微有差异。所以我们可以使用策略模式将其改造,如果你不明白策略模式相比 switchif/else 好在哪,可以直接跳转 策略模式的优势

定义策略接口及其实现

首先需要一个顶层策略接口 VirtualAccountGenerateStrategy

/** 虚拟账户生成策略 */
public interface VirtualAccountGenerateStrategy {
  /** 是否支持当前渠道 */
  boolean support(DepositChannel channel);

  /** 生成方法 */
  VirtualAccount generate(VirtualAccount virtualAccount);
}

下面是针对不同渠道定义的具体策略类,实现 VirtualAccountGenerateStrategy 接口重写 gernerate()、support() 方法即可

  • BcaVirtualAccountGenerateStrategyBCA 渠道生成策略
  • FaspayVirtualAccountGenerateStrategyFASPAY 渠道生成策略
  • InstamoneyVirtualAccountGenerateStrategyINSTAMONEY 渠道生成策略
  • InstamoneyV2VirtualAccountGenerateStrategyINSTAMONEY_V2 渠道生成策略
  • BniVirtualAccountGenerateStrategyBNI 渠道生成策略

INSTAMONEY 渠道为例,观察其源码

/** Instamoney V1 生成虚拟账户策略 */
@Component
public class InstamoneyVirtualAccountGenerateStrategy implements VirtualAccountGenerateStrategy {

  @Override
  public boolean support(DepositChannel channel) {
    return channel == DepositChannel.INSTAMONEY;
  }

  @Override
  public VirtualAccount generate(VirtualAccount virtualAccount) {
    //... todo 发送 HTTP 请求调用第三方
    virtualAccount.setAccountNumber(response.getAccountNumber());
    virtualAccount.setUniqueId(response.getId());
    return virtualAccount;
  }
}

这里发送 HTTP 调用第三方的处理业务代码很多,观察到由于对于 INSTAMONEY、INSTAMONEY_V2 来说它们都需要发送 HTTP 请求调用第三方,这是一块公共代码,虽然处理逻辑很复杂,但只是入参的 key、secret 不同,所以我们可以将其抽象一个公共代码块共用

引入抽象策略

定义一个 AbstractInstamoneyVirtualAccountGenerateStrategy 实现 VirtualAccountGenerateStrategy 接口,将 INSTAMONEY、INSTAMONEY_V2 两种策略类继承该抽象策略,公共代码提取到父类中。

public abstract class AbstractInstamoneyVirtualAccountGenerateStrategy implements VirtualAccountGenerateStrategy {

  @Autowired protected VirtualAccountService accountService;
  @Autowired protected InstamoneyProperties properties;//注意,父类里面不能用 private 修饰,否则子类用不了
  @Autowired protected OkHttpClient client;

  @Override
  public VirtualAccount generate(VirtualAccount virtualAccount) {
    accountService.createVirtualAccount(actualGenerate(virtualAccount));
    return virtualAccount;
  }

  /** 调用 Instamoney 创建虚拟账户,由子类实现 */
  public abstract VirtualAccount actualGenerate(VirtualAccount virtualAccount);

  /**
  * 根据不同版本,调用第三方创建虚拟账户,提供给子类调用
  *
  * @param url 第三方接口 URL
  * @param authorization 第三方接口访问的 authorization
  */
  public InstamoneyVirtualAccountResponse callInstamoneyCreateVirtualAccount(VirtualAccount virtualAccount, String url, String authorization) {
    //... todo 发送 Http 请求调用第三方
    return response;
  }
}

然后 INSTAMONEY、INSTAMONEY_V2 两种渠道的策略就可以继承该抽象类共用公共代码。如果对于 INSTAMONEY 的抽象策略和其他策略还有可抽取的代码,我们也可以继续往上抽象一个策略类出来。将封装、继承、多态发挥到极致!

将策略添加到上下文

仿照 Spring 的策略模式,定义 VirtualAccountStrategyComposite 当做策略上下文,其源码

@Component
@Slf4j
public class VirtualAccountStrategyComposite {
  //存放所有策略
  private static final Map<DepositChannel, VirtualAccountGenerateStrategy> STRATEGY_HOLDER = new ConcurrentHashMap<>();
 
  public static void addStrategy(DepositChannel channel, VirtualAccountGenerateStrategy strategy) {
    STRATEGY_HOLDER.put(channel, strategy);
  }
  /** 根据不同的 DepositChannel 调用不同生成策略 */
  public VirtualAccount generate(VirtualAccount virtualAccount) {
    DepositChannel channel = virtualAccount.getChannel();
    VirtualAccountGenerateStrategy strategy = STRATEGY_HOLDER.get(channel);
    if (Objects.isNull(strategy) || !strategy.support(channel)) {
      log.error("暂无该方式: {} 的虚拟账户生成策略", channel);
      throw new ClientException("暂无该方式的生成策略");
    }
    return strategy.generate(virtualAccount);
  }
}

使用 @PostConstruct 添加策略到 VirtualAccountStrategyComposite.STRATEGY_HOLDER

@PostConstruct
public void init(){
  VirtualAccountStrategyComposite.addStrategy(DepositChannel.INSTAMONEY, this);
}

使用 VirtualAccountStrategyComposite

在业务代码中直接注入 VirtualAccountStrategyComposite 使用即可

@Autowired private VirtualAccountStrategyComposite virtualAccountStrategyComposite;
/** 获取用户虚拟账户,如果没有则创建一个 */
public VirtualAccountResponse virtualAccount(DepositChannel channel,AccountType type, long customerId) {
  VirtualAccount virtualAccount = findVirtualAccount(customerId, channel, type);
  if (Objects.isNull(virtualAccount)) {
    virtualAccount = virtualAccountStrategyComposite.generate(VirtualAccount.builder().channel(channel).bankCode(depositMethod).type(type).customerId(customerId).build());
  }
  return virtualAccount.toDto();
}

上述步骤已经基本完成了使用策略模式改造虚拟账户的创建,代码可读性大大提高,方法复杂度也大大降低,但你可能并没有发现这里隐藏着一个巨大的问题:事务失效

解决策略模式事务失效

失效原因

大家应该都知道 Spring 事务是基于代理实现的,当 Service 类中存在 @Transactional 注解时,注入到 Spring 容器的其实是一个代理对象。Spring 对这个代理对象添加了事务支持,只有调用 @Transactional 注解的方法是代理对象时,事务才会生效,而上面我们在 @PostConstruct 注解的 init() 方法中使用

VirtualAccountStrategyComposite.addStrategy(DepositChannel.INSTAMONEY, this);

这里的 this 并不是代理对象,所以在策略类中使用 @Transactional 事务将不会生效。当然解决这个问题也很简单,既然放进策略的不是代理对象,那我们把代理对象放进去就可以了。

拿到代理对象

参考 Spring 官方文档,我们可以把策略类实现 BeanNameAware 接口,此接口是一个通知接口,当 Bean 工厂创建代理对象完成之后会调用这个接口的 setBeanName() 方法,我们可以在这个方法中拿到代理对象的名字,再使用 ApplicationContext 根据 Bean 的名字拿到容器中真正的代理对象。

InstamoneyVirtualAccountGenerateStrategy 实现 BeanNameAware 接口,重写 setBeanName()

@Override
public void setBeanName(@NotNull String name) {
  VirtualAccountStrategyComposite.addStrategy(DepositChannel.INSTAMONEY, name);//将 Bean 的名字放入集合
}

这样在项目启动之后,VirtualAccountStrategyComposite.STRATEGY_HOLDER 中就存储了所有策略 Bean 的名字,然后再用 ApplicationContext 根据名称从容器中拿代理对象即可。再参考 Spring 官网我们可以监听 ContextRefreshedEvent 事件来拿到 ApplicationContext 实例,源码:

@Component
@Slf4j
public class VirtualAccountStrategyComposite {

  private static ApplicationContext CONTEXT;

  private static final Map<DepositChannel, String> STRATEGY_HOLDER = new ConcurrentHashMap<>();

  public static void addStrategy(DepositChannel channel, String name) {
    STRATEGY_HOLDER.put(channel, name);
  }

  /**
   * 监听Spring容器初始化事件,拿到 ApplicationContext
   */
  @EventListener(ContextRefreshedEvent.class)
  public void registerRequestHandleBeanMethod(ContextRefreshedEvent event) {
    CONTEXT = event.getApplicationContext();
  }

  /**
   * 根据不同的 DepositChannel 调用不同生成策略
   */
  public VirtualAccount generate(VirtualAccount virtualAccount) {
    DepositChannel channel = virtualAccount.getChannel();
    String strategyName = STRATEGY_HOLDER.get(channel);
    if (Objects.isNull(strategyName)) {
      log.error("暂无该方式: {} 的虚拟账户生成策略", channel);
      throw new ClientException("暂无该方式的生成策略");
    }
    //拿到 Spring 容器中的代理对象
    VirtualAccountGenerateStrategy strategy = CONTEXT.getBean(strategyName, VirtualAccountGenerateStrategy.class);
    if (strategy.support(channel)) { 
      return strategy.generate(virtualAccount); //如果策略支持就执行
    }
    return null;
  }
}

奇怪的现象

起初我是直接使用实现 BeanNameAware 的方式拿到代理对象,但这样也可以看出每次执行策略都需要执行一遍下面这行代码来从 Spring 容器获取策略对象

VirtualAccountGenerateStrategy strategy = CONTEXT.getBean(strategyName, VirtualAccountGenerateStrategy.class);

但我的领导总觉得这样不好。后来他误解了别人的意思,给我推荐了 @PostConstruct 才出现了上面事务失效的一幕。于是本着好奇心我稍微尝试了一下,并且打印出 @PostConstruct 注解的方法里面的 this 和从 Spring 容器拿到的代理对象。

log.info("PostConstruct-Bean:"+this);//InstamoneyVirtualAccountGenerateStrategy@7b7bfa82
log.info("Spring-Bean:"+bean);       //InstamoneyVirtualAccountGenerateStrategy@7b7bfa82

结果惊人的发现冒号后面的东西是一样的,我误以为打印出的是地址(不知道曾经看了谁的视频或者问题被误导至今......),还疑惑了很久,既然打印的地址相同,说明应该是同一个对象,既然都是代理对象为什么一种方式事务生效,一种方式事务失效呢?后来看 Object.toStirng() 源码才发现这打印的结果其实主要是对象的 hashcode

public String toString() {
    return getClass().getName() + "@" + Integer.toHexString(hashCode());
}

于是我又尝试用 == 来比较 this 和代理对象,果然他们并不是同一个地址

log.info("==:"+(this == bean));   //false
log.info("==:"+(this.equals(bean));//false

其实这似乎是我们刚毕业的时候面试会被问的问题,还记得那句话么?两个对象 equals 相同,hashcode一定相同,反之两个对象的 hashcode 相同, equals 不一定相同。 那么问题来了,为什么代理对象和原对象有相同的 hashcode

为什么代理对象和原对象 hashcode 相同

这个问题就得清楚 Spring 的生命周期以及代理对象的创建过程了,等以后更吧......

策略模式的优势

我相信你可能会有疑问,使用策略模式真的比 if/else、switch 要好吗?因为突然的思维转变可能会让你觉得,使用策略模式之后会多了很多类甚至有可能会多写很多代码。从应用层面看,好像由传统的 if/else 改为策略模式似乎作用不大,反而增加了类的个数,策略模式就是变相的 if/else 而已。

然而我们更应该要从扩展性和设计原则上去看这个问题。以刚刚重构的为例,如果说以后新来了一种 channel,我们又得去改 switch 代码,首先这违背了开闭原则:一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。

其次我们平时都是写的业务代码,假如以后这个策略被封装在 jar 包里,或者以后让你写框架,我用策略模式只要写一个类实现策略接口即可。如果还是用 if/else 去写,这时候怎么去扩展呢?要扩展功能,自定义逻辑,总不能去改框架源码吧!参考 Spring 参数解析器策略,扩展性就很强,想要自己定义参数解析器,直接实现 HandlerMethodArgumentResolver 即可 SpringMVC 参数解析器 和 Spring 类型转换

最后更重要的是,别管对不对,反正用了设计模式你有没有感觉看起来就很高大上有逼格?这特么才会让领导和同事觉得你牛逼啊......

结语

本篇文章简单了使用了策略模式,相对于 Spring 框架中的策略模式还相差甚远,包括前置处理器、后置处理器等这里都没有体现,不过也算是完成了重构的初步尝试,后面有涉及的话再更新。大家有兴趣也可以参考 Spring 源码中对于策略模式的应用,如 InstantiationStrategyHandlerMethodArgumentResolver 等。

如果这篇文章对你有帮助,记得点赞加关注!你的支持就是我继续创作的动力!