年底了大家都要述职,老板都会问:你这个方案,是否沉淀形成一套可复用解决方案?
今天就介绍关于一个业务:状态变多、条件分支增多、状态转换规则、依赖关系变复杂,导致代码难以维护,代码可重用性和可扩展性变差这个问题,一套可复用解决方案。
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