ToB复杂业务状态的可复用的解决方案

开发 架构
今天就介绍关于一个业务:状态变多、条件分支增多、状态转换规则、依赖关系变复杂,导致代码难以维护,代码可重用性和可扩展性变差这个问题,一套可复用解决方案。

年底了大家都要述职,老板都会问:你这个方案,是否沉淀形成一套可复用解决方案?

今天就介绍关于一个业务:状态变多、条件分支增多、状态转换规则、依赖关系变复杂,导致代码难以维护,代码可重用性和可扩展性变差这个问题,一套可复用解决方案。

1.问题背景

产品:来活啦,有用户反馈我们的APP没有线上退保证金功能,都在抖音上找到老板反馈了。

产品:我们商户入驻状态有:未入驻、待入驻、已入驻、清退中、已清退。清退中又有子状态 商户申请关户中、统计商户有责货损中、待商户确认有责货损、申诉商户有责货损中、提现失败、提现成功、已清退等。

我这里简单画一下关户状态流转图

图片图片

2.如何解决状态问题

在软件开发过程中,随着功能的不断迭代和添加,代码的可维护性往往会面临挑战。

我们有一个商户入驻的功能,功能也是由简单迭代到复杂,现在又要增加商户退出、重新入驻相关状态,状态转换逻辑也变得复杂,在这个迭代过程中,我们如何防止屎山代码形成?

2.1 解耦重构优化代码

项目初期,状态比较少,我们通过如下方法重构代码就可以解决问题。

图片图片

下面是我们重构优化代码的例子。

重构前:入驻方法很长,所有业务代码逻辑耦合在一起。

class A {
  //入驻方法
  public void join() { 
   if(未入驻过){
     // 实人认证
     //查询关联账号
   ...//其他业务逻辑
   }else if(曾入驻过){
   
    //查询关联账号
   //计算售后率
   ...//其他业务逻辑
   }
}

重构后:提取出实人认证、计算售后率等方法。

class A {
   //入驻方法
   public void join() { 
      if(未入驻过){
       // 实人认证
      realPersonAuthentication();
       ...//其他业务逻辑
      }else if(曾入驻过){
       //计算售后率
       calculatingAfterSalesRate();
       ...//其他业务逻辑
      }
      //查询关联账号
      associatedAccount();
    }
    public void realPersonAuthentication() { 
    // 实人认证
    }
   
    public void associatedAccount() { 
    //查询关联账号
    }
    public void calculatingAfterSalesRate() {
    //计算售后率
    }
}

2.2 使用设计模式重构优化

使用状态模式、策略模式等都能帮助我们的代码清晰易读。

下面就是个状态模式解决不同入驻状态下商户拥有不同权限的例子,如果你的业务场景有如下特点,那么可以尝试使用状态模式优化。

1)对象行为取决于状态。

比如商户是否可以送检行为取决于商户的已入驻状态。

2)状态转换逻辑复杂。

比如商户申请关户中、待确认货损状态才能撤销关户等。

3)避免大量条件语句。

这个业务场景,如果使用ifelse判断,需要写6个,代码也不好扩展维护。

业务规则:

图片图片

状态模式类图:

状态模式类图状态模式类图

2.3 使用状态机

如果你的状态很多,并且转换逻辑很复杂的时候,我们可以使用状态机,帮助我们进行状态管理。

2.3.1 思考状态机特点

看上面状态图,思考我们开发的其他场景的状态流转图,这些场景是不是有以下共同特点:1)根据不同触发条件执行不同处理动作最后转变为新的状态(状态也有可能不变)。

2)状态间转换逻辑比较复杂。如果使用ifelse进行判断,业务复杂之后很难维护。

3)各个状态转换分散在不同方法中,很难清晰知道状态全部的流转规则。

2.3.2 总结归纳状态机特点

针对状态流转过程,其实是有很多相似的地方,我们可以简单总结归纳一下。

1)我们可以考虑将状态全部从业务层抽离,统一进行收口维护。

2)抽象状态流转规则, 比如:已入驻 状态转变为 清退中 状态,需要原状态是已入驻,并要完成一些业务操作(记录申请信息,通知商户、BD等等)。

3)我们将状态流转规则也进行统一维护(配置文件配置、代码里写死配置、数据库里配置等),这样便能清晰看到全部流转规则,简化状态管理。

3.状态机介绍

状态机其实很简单,不然也不会这么流行。由上面抽象总结出来的方法,其实就是状态机。状态机的概念很早就提出了,而且比Java语言本身还要早。(详见百度百科)

3.1 状态机的四大概念

状态机核心概念状态机核心概念

第一个是State,状态。一个状态机至少要包含两个状态。例如上面例子,有 未入驻、已入驻、清退中、已清退等多个状态。

第二个是Event,事件。事件就是执行某个操作的触发条件。对于关户流程,”申请关户” 就是一个事件。

第三个是Action,动作。事件发生以后要执行的动作。例如事件是“申请关户”,动作是“记录申请信息、通知BD联系商户等”。编程的时候,一个Action一般就对应一个函数或者类。

第四个是Transition,描述了一个状态机如何从一个状态转换到另一个状态相关规则、条件。当特定的 Event事件发生时,状态机会根据Transition规则执行状态转换。这个过程中,可能伴随着动作(Action)的执行,这些动作可以是状态进入前执行的预处理、状态转换时执行的中间操作或状态退出后执行的清理工作。例如 “商户操作入驻,状态从未入驻转到已入驻” 就是一个变换。

3.2 状态机的优点

参考网上各种描述,这里就简单提一下

1)清晰的状态管理:其实就是统一在一个地方配置转态流转规则,这有助于确保系统的行为符合预期(可以在配置文件配置,代码写死,数据库配置流转规则),减少错误和不确定性。

2)易于维护和扩展:状态机的结构使得对系统的修改和扩展变得更加容易。由于状态机的各个部分相对独立,因此可以在不干扰其他部分的情况下添加新状态或修改现有状态

3)促进团队沟通:比如前面的状态流转图,状态机提供了一种图形化的方式来描述系统行为,这对于团队成员之间的沟通非常有帮助。非技术团队成员也能理解状态图,从而更好地参与项目讨论

4.状态机对比与选型

下面我们就介绍几种状态机的核心实现,大家可以作为参考,搭建符合自己业务场景的状态机。

4.1 转转状态机

转转状态机主要看这个图。

转转状态机流程图转转状态机流程图

1)项目启动,从Mysql数据库读取状态机配置的状态转换规则Transition,并在内存使用Map保存。

2)Event事件发生后,根据当前状态、事件到Map获取对应的Action执行。

3)这是init()方法加载转换规则,转换为Map,Map<String, List> fsmNodeMap,key是事件+当前状态,value是要执行的FsmNode类。

public class FsmNode {
    private Integer opType;
    private Integer role;
    private Integer sourceStatus;
    private Integer targetStatus;
    private NodeType nodeType;
    private FsmAction action;
    private HashSet<JobConfig> jobConfigs;
    private HashSet<TransactionConfig> transactionConfigs;
}

总结起来有如下特点

1)把状态机流转条件、执行的业务类名(Transition)配置到数据库。如果你的状态机、状态转换条件特别多,那么存储在数据库就很方便查找。

2)通过定时任务轮训数据表,重试执行失败的Action。

3)无状态设计,每次状态流转无需生成状态机实例,只要根据当前状态、事件到Map里找到需要执行的Action及job来执行就好。

4)支持每次状态转换的时候发送事务消息(结合数据库事务实现)适用场景:状态机转换条件配置特别多,需要定时任务补偿执行,需要在状态转换时候发送事务消息的场景。

以上就是宙斯状态机的核心功能,总结来说就是:把事件、当前状态、目标状态、要执行的Action类配置数据库里,执行时候根据这个配置来查找Action并执行。

4.2 Cola状态机

参考Cola的官方文档。阿里团队觉得开源状态机不好用,太复杂且性能差,就自己写了一个简洁版状态机。详见博客

Cola状态机是一个轻量级的开源框架,相比Spring Statemachine和squirrel-foundation,它更加简单、轻量且性能极高。

Cola状态机主要有如下特点:

1)简单、轻量,仅支持状态流转的状态机,不支持嵌套、并行等高级玩法。

2)无状态设计。可以使用一个状态机实例来响应所有的请求。

适用场景:Cola状态机适用于需要高并发支持且希望简化状态管理的场景。

4.2.1 Cola状态机核心源码解析

Cola 状态机本质上是状态模式+两个Map。

核心类核心类

第一个Map,Map<S, State< S, E, C>> stateMap,key为当前状态,value为State对象。

State对象类似状态模式,内部存储了状态转移transitions map,即第二个Map。

第二个Map,HashMap<E, Transition<S, E,C>> transitions,在State对象内部,key是Event事件,value是Transition对象,Transition内有要执行的Action、初始状态、目标状态、驱动事件(Event)等。

执行过程

1)业务代码执行入口,根据当前状态、事件传参执行。

StateMachine<States, Events, Context> stateMachine = builder.build(MACHINE_ID+"1");
        //触发一次事件代码
        States target = stateMachine.fireEvent(States.STATE2, Events.EVENT1, new Context());

2)根据当前状态找到map里的State。

public S fireEvent(S sourceStateId, E event, C ctx){
        isReady();
        //找到当前状态对应的State
        State sourceState = getState(sourceStateId);
        //执行
        return doTransition(sourceState, event, ctx).getId();
    }

3)从State里找到另一个Map,transitions,找到map配置的value,转换规则。

private State<S, E, C> doTransition(State sourceState, E event, C ctx) {
        //找到对应的Transition
        Optional<Transition<S,E,C>> transition = sourceState.getTransition(event);
        if(transition.isPresent()){
          //执行
            return transition.get().transit(ctx);
        }
        Debugger.debug("There is no Transition for " + event);
        return sourceState;
    }

4)找到Transition里的action执行。

@Override
    public State<S, E, C> transit(C ctx) {
        Debugger.debug("Do transition: "+this);
        this.verify();
        if(condition == null || condition.isSatisfied(ctx)){
            if(action != null){
            //执行具体的action
                action.execute(source.getId(), target.getId(), event, ctx);
            }
            return target;
        }

        Debugger.debug("Condition is not satisfied, stay at the "+source+" state ");
        return source;
    }

Cola的源码很简单,就是把当前状态、触发的事件、目标状态、执行的业务Action都配置两个Map里,执行时候根据当前状态,事件分别去两个map里拿Action出来执行就好了。

4.3 Spring Statemachine

参考Spring Statemachine的官方文档。Spring官方提供的一个状态机框架,支持状态的嵌套(substate)、状态的并行(parallel, fork, join)、子状态机等高级特性。

1)简单易用

2)状态机结构层次化,有助于简化状态控制的开发过程。

3、功能完备。与Cola状态机类似,也包括状态(State)、事件(Event)、转换(Transition)、动作(Action)等要素。并提供了更丰富的状态类型配置,如choice、join、fork、history等。

适用场景:适用于需要将复杂逻辑拆分为较小可管理任务、状态嵌套或需要循环遍历if-else结构并进行异常处理的场景。

4.3.1 部分源码解析

1)我们看看状态转换时,sendEvent都进行了哪些操作。

Region.sendEvent() 向状态机发布事件。

private Flux<StateMachineEventResult<S, E>> handleEvent(Message<E> message) {
  
  //检查状态机错误 
//如果状态机存在错误(通过hasStateMachineError()方法检查),则立即返回一个包含单个StateMachineEventResult对象的Flux流。
  //这个对象表示事件被拒绝(ResultType.DENIED),并包含了当前状态机实例、原始消息和拒绝结果类型。
  if (hasStateMachineError()) {
   return Flux.just(StateMachineEventResult.<S, E>from(this, message, ResultType.DENIED));
  }
  //处理正常事件流:
  return Mono.just(message)
   .map(m -> getStateMachineInterceptors().preEvent(m, this))
   .flatMapMany(m -> acceptEvent(m))
   .onErrorResume(error -> Flux.just(StateMachineEventResult.<S, E>from(this, message, ResultType.DENIED)))
   .doOnNext(notifyOnDenied());
 }

AbstractStateMachine.acceptEvent()接收事件。

//发送事件并收集结果:
    return cs.sendEvent(message)
     .collectList()
  //处理发送事件的结果:
使用collectList().flatMapMany(l -> {...})来收集sendEvent的结果,并将它们转换为一个新的Flux流。
     .flatMapMany(l -> {
      Flux<StateMachineEventResult<S, E>> ret = Flux.fromIterable(l);
      if (!l.stream().anyMatch(er -> er.getResultType() == ResultType.ACCEPTED)) {
       Mono<StateMachineEventResult<S, E>> result = Flux.fromIterable(transitions)
        .filter(transition -> cs != null && transition.getTrigger() != null)
        .filter(transition -> StateMachineUtils.containsAtleastOne(transition.getSource().getIds(), cs.getIds()))
        .flatMap(transition -> {
  //查找可接受的触发条件:
遍历状态转换(transitions),找到与当前状态和事件负载匹配的触发条件。
对每个匹配的触发条件,评估其是否应该触发(通过transition.getTrigger().evaluate(triggerContext))。
如果触发条件评估为真,则使用stateMachineExecutor.queueEvent将事件加入队列,并设置一个回调来处理执行结果。
  
         return Mono.from(transition.getTrigger().evaluate(triggerContext))
          .flatMap(
  ...
      
      return ret;

StateMachineExecutor.queueEvent()将事件添加到状态机的队列。

public Mono<Void> queueEvent(Mono<Message<E>> message, StateMachineExecutorCallback callback) {
  //这行代码将传入的事件消息(message)和一个延迟事件列表(deferList)合并成一个单一的Flux<Message<E>>流。这意味着所有这些事件都将按顺序被处理。
  Flux<Message<E>> messages = Flux.merge(message, Flux.fromIterable(deferList));

  MonoSinkStateMachineExecutorCallback triggerCallback = new MonoSinkStateMachineExecutorCallback();
  Mono<Void> triggerCallbackSink = Mono.create(triggerCallback);

  return messages
   .flatMap(m -> handleEvent(m, callback, triggerCallback))
   .flatMap(tqi -> Mono.fromRunnable(() -> {
     triggerSink.emitNext(tqi, EmitFailureHandler.FAIL_FAST);
    })
    .retryWhen(Retry.fixedDelay(10, Duration.ofMillis(10))))
   .then()
   .and(triggerCallbackSink);
 }

从上面的源码我们大概可以看出 Spring状态机内部通过事件发布、订阅和线程池、阻塞队列实现了整个状态的流转。到这里介绍完毕Spring状态机,详细使用建议参考官方文档,里面有很多高级用法,这里只是进行简单功能介绍。

5.选型总结

特性

Cola状态机

Spring状态机

转转状态机

集成性

较为独立,但易于集成到其他Java项目中

与Spring框架紧密集成,方便使用Spring的各种特性(如依赖注入、AOP等)

依赖数据库,易于集成到Java项目

功能丰富性

支持基本的状态定义和转换

提供了完整的状态机功能,包括状态定义、事件触发、动作执行、状态转换和守卫条件等,并发状态、子状态机

支持基本的状态定义和转换,同时提供失败重试、事务消息等功能

学习成本

低,源码较简单,同步API,使用更为直观

较高,使用Reactive的Mono、Flux等响应式编程模型

较低,源码较简单易理解

使用场景

适用于中小型项目、需要轻量级状态机管理的场景

适用于需要管理复杂状态转换逻辑、与Spring框架紧密结合的业务场景

需要轻量级状态机管理,状态机配置较多场景

5.1 业务简单用Cola状态机

从前面的源码分析来看,Cola状态机通过两个map来实现状态流转,源码简单易懂,扩展也方便。更加简洁和直观的状态管理方式。如果你的项目比较简单,开发人员也比较少,仅使用状态机辅助状态流转,在满足业务需求的前提下,可以考虑使用Cola状态机。

5.2 业务复杂用Spring Statemachine

Spring Statemachine源码、功能相对复杂,使用起来不够直观;同时不是无状态的,因此在线程安全性方面需要额外的处理,但是功能完备,提供了丰富的状态和事件处理机制,与Spring框架紧密集成,可以利用Spring的依赖注入、面向切面编程等特性,方便地进行扩展和集成。如果你的状态转换比较复杂,涉及到层次化状态、并发状态、子状态机,同时研发人员较多,研发实力较强,后续考虑扩展功能,可以考虑使用Spring的状态机。

5.3 个性化诉求自研

如果你考虑自研状态机,并且你的状态转换比较多,或者转换后,Action有重试需求,也可以参考转转宙斯状态机,将状态转换逻辑配置到数据,方便查找状态转换配置及从数据库获取Action数据进行重试处理。

5.4 B2B复杂业务场景选型

下面是我把B2B整个保卖业务简化之后的流程图,大家可以看到整个链路流程是很长很复杂。

保卖业务流程简图保卖业务流程简图

分而治之。我们把长链路划分多为个阶段,每个阶段有自己的状态,使用状态机来管理每个阶段状态流转,这样就能把复杂问题简化为每个小问题解决。

回收段:

回收段状态图回收段状态图


销售段:

图片图片

售后段:

图片图片

篇幅有限,就不列出详细代码了。

我们在保卖业务、关户退保证金项目中使用Spring状态机,主要考虑是我们业务场景特别复杂。同时,我们根据保卖业务特点,在Spring状态机基础功能上进行一些扩展。

调研发现B2B行业友商也使用了Spring状态机,他们考虑点也是状态机对于复杂的业务场景的支持。

1)性能方面,我们B2B业务对并发性能要求不高,但是业务复杂度高,Spring状态机性能完全可以满足我们需求。

2)功能方面,Spring状态机功能比较丰富,支持子状态等,而我们业务需求比较复杂,同时有子状态等业务场景,它可以满足我们的业务需求。

3)Spring状态机提供状态机事件监听器,允许开发者监听状态机的各种事件,如状态变化、转换触发等,从而执行相应的业务逻辑。我们的关户需求,也需要在关户申请、申诉通过等节点触发时进行一些通用业务逻辑。

4)我们在Spring状态机基础上扩展出事务消息,主要是基于Mysql数据库表的事务实现。

5)扩展出失败重试功能,主要基于XXL-JOB定时任务扫描失败数据并进行重试。

其实架构选型,没有银弹,也难有通用解决方案,大家还是需要结合自己的业务做选型及改造。

参考部分网上资料,如有侵权,联系我们。

关于作者黄培祖,采货侠开发工程师

参考资料

[1] https://github.com/alibaba/COLA

[2] https://spring.io/projects/spring-statemachine

[3] https://blog.csdn.net/significantfrank/article/details/104996419

责任编辑:武晓燕 来源: 转转技术
相关推荐

2023-07-17 18:39:27

业务系统架构

2022-04-07 17:30:31

Flutter携程火车票渲染

2014-09-11 15:05:40

驱动设计驱动开发

2020-09-27 14:24:58

if-else cod业务

2021-01-12 11:02:56

云计算云存储技术云开发

2021-01-12 11:13:11

云备份云原生云平台

2019-02-12 05:34:25

2013-08-15 09:00:49

云解决方案云服务

2023-06-28 06:33:37

2022-07-04 19:02:06

系统业务思考

2012-01-11 13:38:15

移动BI解决方案

2022-03-10 16:01:58

NTT思科网络

2023-05-30 07:56:23

代码软件开发

2011-01-21 09:53:12

2021-02-25 11:15:38

备份解决方案存储

2011-02-15 13:21:17

业务连续性安全威胁

2022-05-28 16:08:04

前端

2011-11-30 13:08:55

企业防毒防毒方案拯救三

2023-12-13 10:06:28

六边形架构系统测试

2013-07-17 09:09:47

IAMEMCAveksa
点赞
收藏

51CTO技术栈公众号