赶走烦人的“if-else”,使用状态模式推动业务生命周期的流转

2,057 阅读13分钟

本文正在参加「金石计划」

1.业务背景

本文借助海外互金业务的借款流程展开。业务核心是借款的生命周期,相当于是电商中的订单一样。一笔借款的整个生命周期包含了提交,审批,确认,放款,还款。一笔借款的状态对应已上的操作,同样就很多了。如图是一笔借款的生命周期:

001-payday loan简化业务流程

对于这么多状态,业务代码上有很多判断分支,什么情况下可以做什么操作,这是强校验的。业务初期快速上线,if else可以快速地将业务串联起来,在各个业务处理的节点判断并执行业务逻辑。

 if (LoanStatusConstant.CREATED.equals(oldStatus) ||
     LoanStatusConstant.NEED_MORE_INFO.equals(oldStatus)) {
     log.info("---> Loan  to Submitted");
 }
 ​
 if (LoanStatusConstant.APPROVED.equals(loan.getStatus())) {
     log.info("---> Loan confirmed");
 }
 ​
 if (!LoanStatusConstant.APPROVED.equals(loanStatus)) {
     log.info("---> Loan approved,to Fund");
 }
 //.......

2.业务代码的“坏味道”

随着运营推广的力度加大,用户蜂拥而至,风控环境更加严苛,整个产品线也陆续加入了更多的流程去迭代产品,让风险和收益能趋于平衡。

这时候在开发代码上的体现就是代码库急剧膨胀,业务扩张自然会扩招,新同事也会在已有的代码上打补丁,在这些补丁式的需求下,曾经的if else会指数级的混乱,一个简单的需求都可能挑战现有的状态分支。这种“坏味道”不加以干预,整个项目的生命力直接走向暮年。

 //审核
 public void approve(@RequestBody LoanSubmitDTO submitDTO) {
     final Loan loan = loanMapper.selectOne(new LambdaQueryWrapper<Loan>().eq(Loan::getRefId, submitDTO.getLoanRefId()));
     if (Objects.isNull(loan)){
         throw new BaseBizException("loan Not exists");
     }
     if (!SUBMITTED.getCode().equals(loan.getStatus())){
         throw new BaseBizException("loan status incorrect");
     }
 ​
     loan.setStatus(APPROVED.getCode());
     loanMapper.updateById(loan);
 }
 //确认
 public LoanDTO submit(LoanSubmitDTO submitDTO) {
     final Loan loan = loanMapper.selectOne(new LambdaQueryWrapper<Loan>().eq(Loan::getRefId, submitDTO.getLoanRefId()));
     if (Objects.isNull(loan)){
         throw new BaseBizException("loan Not exists");
     }
     if (!CREATED.getCode().equals(loan.getStatus())){
         throw new BaseBizException("loan status incorrect");
     }
 ​
     loan.setStatus(SUBMITTED.getCode());
     loanMapper.updateById(loan);
     riskService.callRisk(submitDTO);
 ​
     return BeanConvertUtil.map(loan,LoanDTO.class);
 }

随着项目不断膨胀,为了对贷款状态进行校验,if else会充斥业务层的各个地方,此时,一旦产品上对业务流程进行调整,状态也会随着修改。比如新增了风控确认机制,用户可以补充信息再提交,对于满足一定条件的贷款可以让用户补充一定的风控信息再提交,那么此时对于借款提交流程的前置状态就要发生变化了:

 //提交
 public LoanDTO submit(LoanSubmitDTO submitDTO) {
     final Loan loan = loanMapper.selectOne(new LambdaQueryWrapper<Loan>().eq(Loan::getRefId, submitDTO.getLoanRefId()));
     if (Objects.isNull(loan)){
         throw new BaseBizException("loan Not exists");
     }
     //判断条件发生变化。
     if (!CREATED.getCode().equals(loan.getStatus())&&!NEED_MORE_INFO.getCode().equals(loan.getStatus())){
         throw new BaseBizException("loan status incorrect");
     }
 ​
     loan.setStatus(SUBMITTED.getCode());
     loanMapper.updateById(loan);
     riskService.callRisk(submitDTO);
 ​
     return BeanConvertUtil.map(loan,LoanDTO.class);
 }

项目迭代过程中每一次上线前测试同学都会进行严格地测试。以上这种变动可能会修改多个地方的代码,测试同学就不得不进行大面积的回归测试,上线风险会大大增加;而我们开发同学这种新逻辑上线就硬改原有代码的行为,违背了开闭原则,随着业务的迭代,项目代码的可读性会越来越差(if-else越来越多,测试用例不清晰),可能很多人觉得就改了个判断语句没什么大不了的,但实际上很多生产事故都是因为这种频繁的小改动导致的。

如何去规避已上这种 “坏味道” 呢?

3.OCP原则(开放封闭原则)

3.1 定义

我们先来看看什么是【开放封闭原则】:

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

--Bertrand Meyer

翻译过来的意思是:一个软件实体(类、模块、函数等)应对扩展开放,但对修改封闭。也就是说当我们设计一个模块,一个实体对象时,应该在不修改自身源代码的情况下,能够扩展新的行为。

以上这句话读完,不能修改和能够扩展是自相矛盾的,如何才能实现并满足开闭原则呢?

抽象化是关键,我们抽象出共性的基类,并且通过基类衍生出来的实现类去实现新行为的扩展。【抽象层保持不变,实现层实现扩展】。

3.2 对比说明

举个简单的例子来看,我们要做支付,支付方式必须要支持微信支付,支付宝支付等多种支付方式。此时我们的类设计如下:

002-违反开闭原则

此时产品要接入新的支付方式,要支持银联支付,我们就不得不去修改switch语句块的逻辑,新增银联支付方式,并新增银联支付的channel类。这就破坏了开闭原则。其实开闭原则是我们做面向对象开发的基础原则,所有导致原有代码修改的行为都会破坏开闭原则。

这就考验我们在对象设计时的要做到敏锐性和合理性,及时发现关键领域中具有相同行为的对象,使用抽象类或者接口,将相同的行为抽象到抽象类或者接口中,将不同的行为封装到子类实现类上面。

这样处理之后,系统需要扩展功能时,我们只要扩展新的子类就可以。对于子类的修改我们也可以重新实现一个新的子类。

比如,我们把所有的支付渠道抽象出统一的支付行为,并且针对不同的支付渠道去扩展不同的子类,并通过工厂模式去管理所有的支付渠道,把支付渠道的选择逻辑也封装到工厂中去,此时对于PayService这类业务对象来说,就不会因为支付方式的修改而去变动代码。这样就做到了,对扩展开放,对对更改关闭

此时的类设计如下:

003-开闭原则-支付类图

4.状态模式

4.1 定义

Allow an object to alter its behavior when its internal state changes.The object will appear to change its class.

  ——《设计模式:可复用面向对象软件的基础》

状态模式(State Pattern):状态模式是一个行为型设计模式。允许一个对象在其内部状态改变时改变它的行为。该对象将看起来改变了它的类。

状态模式的使用场景:用于解决系统中复杂对象的状态转换以及不同状态下行为的封装问题。对有状态的对象,把复杂多样的状态从对象中抽离出来,封装到专门的状态类中,这样就可以让对象的状态灵活变化。

4.2 UML图

状态模式的参与者:

  • 环境类Context角色:可以认为是状态上下文,内部维护了对象当前的ConcreteState,对客户方提供接口,可以负责具体状态的切换。
  • 抽象状态State角色:这是一个接口,用来封装环境类对象中的一个特定状态相关的行为。
  • 具体状态ConcreteState角色:是State的子类实现类,具体状态子类,每一个子类都封装了一个ConcreteState相关的行为。

004-状态模式类图 (1)

4.3 简单示例

stateDiagram
	[*] -->A
	A-->B
	A-->C
	B-->C:X

抽象状态类:

 public abstract class State {
 ​
     protected void a2b(Context context) {
         throw new BaseBizException(context.getState().getClass().getSimpleName()+" not support this transition to B state" );
     }
 ​
     protected void b2c(Context context) {
         throw new BaseBizException(context.getState().getClass().getSimpleName()+" not support this transition to C state" );
     }
 ​
     protected void a2c(Context context) {
         throw new BaseBizException("current state is "+context.getState().getClass().getSimpleName()+" not support  transition" );
     }
 }

具体状态类:

 public class AState extends State {
 ​
 ​
     @Override
     protected void a2b(Context context) {
         System.out.println("状态流转:a State ->b State");
         context.setState(new BState());
     }
 ​
     @Override
     protected void a2c(Context context) {
         System.out.println("状态流转:a State ->c State");
         context.setState(new BState());
     }
 }
 ​
 public class BState extends State {
 ​
     @Override
     protected void b2c(Context context) {
         System.out.println("状态流转:b State ->c State");
         context.setState(new CState());
     }
 }
 public class CState extends State {
 ​
 }

环境上下文类:

 public class Context {
     private State state;
 ​
     public Context(State initState) {
         this.state = initState;
     }
     public State getState() {
         return state;
     }
     public void setState(State state) {
         this.state = state;
     }
     public void a2b() {
         state.a2b(this);
     }
     public void b2c() {
         state.b2c(this);
     }
     public void a2c() {
         state.a2c(this);
     }
 }

测试类:

 public class ClientInvoker {
 ​
     public static void main(String[] args) {
         Context context = new Context(StateFactory.getState(AState.class.getSimpleName()));
         context.a2b();
         context.a2c();
     }
 }

测试结果:

 状态流转:a State ->b State
 Exception in thread "main" cn.ev.common.Exception.BaseBizException: current state is BState not support  transition
     at state.State.a2c(State.java:16)
     at state.Context.a2c(Context.java:22)
     at state.ClientInvoker.main(ClientInvoker.java:8)

4.4 状态模式的使用场景和优缺点

使用场景:

  • 对象有多个状态,并且不同状态需要处理不同的行为。
  • 对象需要根据自身变量的当前值改变行为,不期望使用大量 if-else 语句。
  • 对于某些确定的状态和行为,不想使用重复代码。

优点:

  • 符合开闭原则,可以方便地扩展新的状态和对应的行为,只需要改变对象状态即可改变对象的行为。
  • 状态转换逻辑与状态对象合成一体,避免了大量的分支判断语句和超大的条件语句块。

缺点:

  • 一个状态一个子类,增加了系统类和对象的个数。如果使用不当将导致程序结构和代码的混乱。
  • 一定程度上满足了开闭原则,不过对于控制状态流转的职责类,添加新的状态类需要修改。

5.优化借款流程

5.1 抽象状态类

首先我们定义抽象状态类AbstractLoanState

 public  abstract class AbstractLoanState {
 ​
     @Resource
     protected LoanMapper loanMapper;
     /**
      * 获取State
      * @return
      */
     abstract Integer getState();
     protected Loan getLoanDTO(String loanRefId) {
         final Loan loan = loanMapper.selectOne(new LambdaQueryWrapper<Loan>().eq(Loan::getRefId, loanRefId));
         if (Objects.isNull(loan)){
             throw new BaseBizException("loan Not exists");
         }else {
 ​
             return loan;
         }
     }
     /**
      * 借款提交
      * @param loanRefId 借款id
      * @param currentState 当前状态
      * @return
      */
     protected  LoanDTO submit(String loanRefId, Enum<LoanStateEnum> currentState) {
         throw new BaseBizException("状态不正确,请勿操作");
     }
     /**
      * 借款审批通过
      * @param loanRefId 借款id
      * @param currentState 当前状态
      * @return
      */
     protected LoanDTO approve(String loanRefId, Enum<LoanStateEnum> currentState) {
         throw new BaseBizException("状态不正确,请勿操作");
     }
     /**
      * 借款确认
      * @param loanRefId 借款id
      * @param currentState 当前状态
      * @return
      */
     protected LoanDTO confirm(String loanRefId, Enum<LoanStateEnum> currentState){
         throw new BaseBizException("状态不正确,请勿操作");
     }
     /**
      * 借款审批拒绝
      * @param loanRefId 借款id
      * @param currentState 当前状态
      * @return
      */
     protected LoanDTO reject(String loanRefId, Enum<LoanStateEnum> currentState){
         throw new BaseBizException("状态不正确,请勿操作");
     }
     /**
      * 放款
      * @param loanRefId 借款id
      * @param currentState 当前状态
      * @return
      */
     protected LoanDTO tofund(String loanRefId, Enum<LoanStateEnum> currentState) {
         throw new BaseBizException("状态不正确,请勿操作");
     }
     /**
      * 还款
      * @param loanRefId 借款id
      * @param currentState 当前状态
      * @return
      */
     protected LoanDTO repay(String loanRefId, Enum<LoanStateEnum> currentState) {
         throw new BaseBizException("状态不正确,请勿操作");
     }
     /**
      * 补充信息
      * @param loanRefId 借款id
      * @param currentState 当前状态
      * @return
      */
     protected LoanDTO needMoreInfo(String loanRefId, Enum<LoanStateEnum> currentState) {
         throw new BaseBizException("状态不正确,请勿操作");
     }
 }

5.2 具体状态类

定义具体的状态类,每种不同的状态我们都依次定义专属的状态类,并赋予它特有的行为。例如LoanSubmitState

 @Component
 @Slf4j
 public class LoanSubmitState extends AbstractLoanState{
 ​
     @Override
     Integer getState() {
         return SUBMITTED.getCode();
     }
 ​
     @Override
     protected LoanDTO approve(String loanRefId, Enum<LoanStateEnum> currentState) {
         final Loan loan = getLoanDTO(loanRefId);
         loan.setStatus(APPROVED.getCode());
         loanMapper.updateById(loan);
         log.info("loan {} 从 {} 状态流转到 {}",loanRefId,currentState,APPROVED);
         return BeanConvertUtil.map(loan,LoanDTO.class);
     }
 ​
     @Override
     protected LoanDTO reject(String loanRefId, Enum<LoanStateEnum> currentState) {
         final Loan loan = getLoanDTO(loanRefId);
         loan.setStatus(REJECTED.getCode());
         loanMapper.updateById(loan);
         log.info("loan {} 从 {} 状态流转到 {}",loanRefId,currentState,REJECTED);
         return BeanConvertUtil.map(loan,LoanDTO.class);
     }
 ​
     @Override
     protected LoanDTO needMoreInfo(String loanRefId, Enum<LoanStateEnum> currentState) {
         final Loan loan = getLoanDTO(loanRefId);
         loan.setStatus(NEED_MORE_INFO.getCode());
         loanMapper.updateById(loan);
         log.info("loan {} 从 {} 状态流转到 {}",loanRefId,currentState,NEED_MORE_INFO);
         return BeanConvertUtil.map(loan,LoanDTO.class);
     }
 }

LoanConfirmState,通过这些独立的状态类,我们可以做到避免写大块的if-else语句,避免在业务的多个角落去维护这些分支语句。

 @Component
 @Slf4j
 public class LoanConfirmState extends AbstractLoanState{
     @Resource
     private FundService fundService;
 ​
     @Override
     Integer getState() {
         return CONFIRMED.getCode();
     }
 ​
     @Override
     protected LoanDTO tofund(String loanRefId, Enum<LoanStateEnum> currentState) {
         final Loan loan = getLoanDTO(loanRefId);
         loan.setStatus(FUNDED.getCode());
         loanMapper.updateById(loan);
         final LoanFundDTO loanFundDTO = BeanConvertUtil.map(loan, LoanFundDTO.class);
         fundService.sendMoney(loanFundDTO);
 ​
         log.info("loan {} 从 {} 状态流转到 {}",loanRefId,currentState,FUNDED);
         return BeanConvertUtil.map(loan,LoanDTO.class);
     }
 }

5.3 环境上下文类

LoanStatusHandler封装了借款生命周期中的所有操作接口,对于外部客户调用方只需要和他交互就可以对借款状态进行流转,完全不需要和具体的状态类进行耦合,这样对于后期的状态类扩展就很方便了。

 @Service
 public class LoanStatusHandler {
 ​
 ​
     @Resource
     protected LoanMapper loanMapper;
     public Loan getLoan(String loanRefId) {
         final Loan loan = loanMapper.selectOne(new LambdaQueryWrapper<Loan>().eq(Loan::getRefId, loanRefId));
         if (Objects.isNull(loan)){
             throw new BaseBizException("loan Not exists");
         }else {
 ​
             return loan;
         }
     }
     /**
      * 借款提交
      * @param loanRefId 借款id
      * @return
      */
     public  LoanDTO submit(String loanRefId) {
         final Loan loan = getLoan(loanRefId);
         final AbstractLoanState currentState = LoanStateFactory.chooseLoanState(loan.getStatus());
         return currentState.submit(loanRefId,LoanStateEnum.getEnumByCode(loan.getStatus()));
     }
     /**
      * 借款审批通过
      * @param loanRefId 借款id
      * @return
      */
     public LoanDTO approve(String loanRefId) {
         final Loan loan = getLoan(loanRefId);
         final AbstractLoanState currentState = LoanStateFactory.chooseLoanState(loan.getStatus());
         return currentState.approve(loanRefId,LoanStateEnum.getEnumByCode(loan.getStatus()));
     }
     /**
      * 借款确认
      * @param loanRefId 借款id
      * @return
      */
     public LoanDTO confirm(String loanRefId){
         final Loan loan = getLoan(loanRefId);
         final AbstractLoanState currentState = LoanStateFactory.chooseLoanState(loan.getStatus());
         return currentState.confirm(loanRefId,LoanStateEnum.getEnumByCode(loan.getStatus()));
     }
     /**
      * 借款审批拒绝
      * @param loanRefId 借款id
      * @return
      */
     public LoanDTO reject(String loanRefId){
         final Loan loan = getLoan(loanRefId);
         final AbstractLoanState currentState = LoanStateFactory.chooseLoanState(loan.getStatus());
         return currentState.reject(loanRefId,LoanStateEnum.getEnumByCode(loan.getStatus()));
     }
     /**
      * 放款
      * @param loanRefId 借款id
      * @return
      */
     public LoanDTO tofund(String loanRefId) {
         final Loan loan = getLoan(loanRefId);
         final AbstractLoanState currentState = LoanStateFactory.chooseLoanState(loan.getStatus());
         return currentState.tofund(loanRefId,LoanStateEnum.getEnumByCode(loan.getStatus()));
     }
 ​
     /**
      * 还款
      * @param loanRefId 借款id
      * @return
      */
     public LoanDTO repay(String loanRefId) {
         final Loan loan = getLoan(loanRefId);
         final AbstractLoanState currentState = LoanStateFactory.chooseLoanState(loan.getStatus());
         return currentState.repay(loanRefId,LoanStateEnum.getEnumByCode(loan.getStatus()));
     }
     /**
      * 补充信息
      * @param loanRefId 借款id
      * @return
      */
     public LoanDTO needMoreInfo(String loanRefId) {
         final Loan loan = getLoan(loanRefId);
         final AbstractLoanState currentState = LoanStateFactory.chooseLoanState(loan.getStatus());
         return currentState.needMoreInfo(loanRefId,LoanStateEnum.getEnumByCode(loan.getStatus()));
     }
 }

5.4 状态工厂类

封装了一个持有所有状态实例的工厂,这样就可以根据借款的状态去获取单例的状态类,既不浪费内存,共享了所有的状态实例,也可以很好地结合了借款领域对象的状态去推动我们封装的状态模式流转业务。

 @Component
 public class LoanStateFactory implements ApplicationContextAware {
 ​
     private final static Map<Integer, AbstractLoanState> loanStateMap =new LinkedHashMap<>();
     @Override
     public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
         Map<String, AbstractLoanState> map = applicationContext.getBeansOfType(AbstractLoanState.class);
         map.forEach((key,value)->loanStateMap.put(value.getState(),value));
     }
 ​
     public static AbstractLoanState chooseLoanState(Integer currentState){
         return loanStateMap.get(currentState);
     }
 }

5.5 单元测试

首先创建一笔借款,然后依次通过LoanStatusHandler状态处理类去推动借款的生命周期,如果状态变化可以从Created-->PaidOff,那么就说明状态模式的流转没有问题,测试通过。

 @Test
 public void testStateCreatedToPaidOff() {
     LoanCreateDTO loanCreateDTO = createLoan();
 ​
     LoanDTO loanDTO = loanService.create(loanCreateDTO);
 ​
     String loanRefId = loanDTO.getRefId();
 ​
     loanStatusHandler.submit(loanRefId);
 ​
     loanStatusHandler.needMoreInfo(loanRefId);
 ​
     loanStatusHandler.submit(loanRefId);
 ​
     loanStatusHandler.approve(loanRefId);
 ​
     loanStatusHandler.confirm(loanRefId);
 ​
     loanStatusHandler.tofund(loanRefId);
     loanStatusHandler.repay(loanRefId);
 }

5.6 测试结果

以下输出结果说明了整个借款的正向流程的状态变化,单元测试通过。

 2023-03-17 17:35:14.907 [main] [INFO ] c.e.p.t.fsm.ifElse.LoanService.create:52 [] - W3p6qS_loan loan created
 2023-03-17 17:35:15.230 [main] [INFO ] c.e.p.t.fsm.ifElse.RiskService.callRisk:17 [] - null callRisk
 2023-03-17 17:35:15.231 [main] [INFO ] c.e.p.t.f.s.LoanCreateState.submit:36 [] - loan W3p6qS_loan 从 CREATED 状态流转到 SUBMITTED,调用风控
 2023-03-17 17:35:15.370 [main] [INFO ] c.e.p.t.f.s.LoanSubmitState.needMoreInfo:49 [] - loan W3p6qS_loan 从 SUBMITTED 状态流转到 NEED_MORE_INFO
 2023-03-17 17:35:15.501 [main] [INFO ] c.e.p.t.fsm.ifElse.RiskService.callRisk:17 [] - null callRisk
 2023-03-17 17:35:15.501 [main] [INFO ] c.e.p.t.f.s.LoanNeedMoreInfoState.submit:35 [] - loan W3p6qS_loan 从 NEED_MORE_INFO 状态流转到 SUBMITTED,调用风控
 2023-03-17 17:35:15.639 [main] [INFO ] c.e.p.t.f.s.LoanSubmitState.approve:31 [] - loan W3p6qS_loan 从 SUBMITTED 状态流转到 APPROVED
 2023-03-17 17:35:15.772 [main] [INFO ] c.e.p.t.f.s.LoanApproveState.confirm:30 [] - loan W3p6qS_loan 从 APPROVED 状态流转到 CONFIRMED
 2023-03-17 17:35:15.915 [main] [INFO ] c.e.p.t.fsm.ifElse.FundService.sendMoney:17 [] - null sendMoney
 2023-03-17 17:35:15.915 [main] [INFO ] c.e.p.t.f.s.LoanConfirmState.tofund:36 [] - loan W3p6qS_loan 从 CONFIRMED 状态流转到 FUNDED
 2023-03-17 17:35:16.051 [main] [INFO ] c.e.p.t.f.ifElse.RepayService.rapay:17 [] - W3p6qS_loan sendMoney
 2023-03-17 17:35:16.051 [main] [INFO ] c.e.p.t.f.s.LoanFundState.repay:36 [] - loan W3p6qS_loan 从 FUNDED 状态流转到 PAID_OFF

6.更多思考

实际项目中使用状态模式去改造业务流程会有这些情况发生:

  1. 扩展状态需增加状态类,状态多了会出现很多状态类,随着状态的不断增多,导致抽象状态类和上下文类中的方法定义可能会变得很多。
  2. 状态模式虽然让状态独立,通过定义新的子类很容易地增加新的状态和转换,较好的适应了开闭原则。但是并没有完全实现状态与业务解耦。比如上文中具体状态类中还有对领域对象的DB操作。

对于复杂的业务状态流转,其实可以有一种优雅的实现方法:状态机。在Java项目中,比较常用的有Spring StatemachineSquirrel-foundation。通过状态机去推动业务流程,状态转移和业务逻辑基于事件完全解耦,整个系统状态可以更好地维护和扩展。