基于reactorAPI的spring-statemachine的状态机实战

1,856 阅读12分钟

1.概念引言

"状态机(state machine)"用于描述一个对象在其生命周期中的动态行为。

  • 表现对象响应事件所经历的状态序列以及伴随的动作用于描述一个对象在其生命周期中的动态行为。
  • 表现对象响应事件所经历的状态序列以及伴随的动作

状态机的定义入手,StateMachine<States, Events>,其中:

  • StateMachine:状态机模型
  • state:S - 状态,一般定义为一个枚举类,如创建、待风控审核、待支付等状态
  • event:E - 事件,同样定义成一个枚举类,如订单创建、订单审核、支付等,代表一个动作。 一个状态机的定义就由这两个主要的元素组成,状态及对对应的事件(动作)。

状态机的一些组件定义:

  • Transition(过渡): 节点,是组成状态机引擎的核心
  • source:节点的当前状态
  • target:节点的目标状态
  • event:触发节点从当前状态到目标状态的动作
  • guard(守卫):起校验功能,一般用于校验是否可以执行后续 action
  • action:用于实现当前节点对应的业务逻辑处理

2.简单案例

2.1 引入依赖

 <dependency>
     <groupId>org.springframework.statemachine</groupId>
     <artifactId>spring-statemachine-starter</artifactId>
     <version>3.2.0</version>
 </dependency>

2.2 状态类和事件类枚举定义

 public enum States {
     SI, S1, S2
 }
 ​
 public enum Events {
     E1, E2
 }

2.3 状态机配置类

 @Configuration
 @EnableStateMachine
 public class StateMachineConfig extends EnumStateMachineConfigurerAdapter<States,Events> {
 ​
     @Override
     public void configure(StateMachineConfigurationConfigurer<States, Events> config) throws Exception {
         config.withConfiguration()
             .autoStartup(true)
             .listener(listener());
     }
 ​
     @Override
     public void configure(StateMachineStateConfigurer<States, Events> states) throws Exception {
         states.withStates()
             .initial(States.SI)
             .states(EnumSet.allOf(States.class));
     }
 ​
     @Override
     public void configure(StateMachineTransitionConfigurer<States, Events> transitions) throws Exception {
 ​
         transitions.withExternal()
             .source(States.SI).target(States.S1).event(Events.E1)
             .and()
             .withExternal()
             .source(States.S1).target(States.S2).event(Events.E2);
     }
     @Bean
     public StateMachineListener<States,Events> listener(){
         return new StateMachineListenerAdapter<States,Events>(){
             @Override
             public void stateChanged(State<States, Events> from, State<States, Events> to) {
                 if (Objects.isNull(from)){
                     System.out.println("State change to " + to.getId());
                 }else {
                     System.out.println("State "+from.getId()+" change to " + to.getId());
                 }
 ​
             }
         };
     }
 }

2.4 项目启动类

 @SpringBootApplication
 public class TestApplication implements CommandLineRunner{
 ​
     @Autowired
     private StateMachine<States, Events> stateMachine;
 ​
     public static void main(String[] args) {
         SpringApplication.run(TestApplication.class,args);
     }
 ​
     @Override
     public void run(String... args) throws Exception {
         stateMachine.sendEvent(Events.E1);
         stateMachine.sendEvent(Events.E2);
     }
 }

在启动类中直接继承 CommandLineRunner,并未生效。

2.5 版本不一致的报错

我本身项目的springboot版本是2.3.2.RELEASE,springcloud版本是Hoxton.SR9,启动之后报错:Caused by: java.lang.NoClassDefFoundError: reactor/core/publisher/Sinks,解决无果,发现spring-statemachine-3.2.0依赖的父pom版本是2.6.7,尝试升级版本。

 <dependencyManagement>
         <dependencies>
             <dependency>
                 <groupId>org.springframework.boot</groupId>
                 <artifactId>spring-boot-dependencies</artifactId>
                 <version>2.6.14</version>
                 <type>pom</type>
                 <scope>import</scope>
             </dependency>
             <dependency>
                 <groupId>org.springframework.cloud</groupId>
                 <artifactId>spring-cloud-dependencies</artifactId>
                 <version>2021.0.5</version>
                 <type>pom</type>
                 <scope>import</scope>
             </dependency>
     </dependencies>
 </dependencyManagement>

已上错误修复成功。执行结果如下:

 State change to S1
 State change to S2

2.6 过时的sendEvent方法

this method is now deprecated in favour of a reactive methods.

Deprecated in favor of sendEvent(Mono)

推荐使用响应式api去触发事件。

2.7 reactor api

启动类

 @SpringBootApplication
 public class TestApplication {
 ​
     @Autowired
     private StateMachine<States, Events> stateMachine;
 ​
     public static void main(String[] args) {
         SpringApplication.run(TestApplication.class,args);
     }
 }
 ​
 @Component
 public class Runner implements CommandLineRunner {
 ​
     @Autowired
     private StateMachine<States, Events> stateMachine;
 ​
     @Override
     public void run(String... args) throws Exception {
         //stateMachine.sendEvent(Events.E1);
         //stateMachine.sendEvent(Events.E2);
         GenericMessage<Events> e1=new GenericMessage(Events.E1);
         GenericMessage<Events> e2=new GenericMessage(Events.E2);
         final Flux<Message<Events>> messageFlux = Flux.just(e1, e2);
 ​
         //stateMachine.sendEvents(messageFlux);
         stateMachine.sendEvent(Mono.just(e1));
         stateMachine.sendEvent(Mono.just(e2));
     }
 }
 ​

输出:

 State change to SI

使用新版本的reactor api之后,以上并没有我们预想的结果:从SI->S1,S1->S2。是什么导致了这种情况呢?

sendEvent方法返回的是Flux<StateMachineEventResult<States, Events>>,对于Flux,在订阅它之前,什么都不会发生,数据不会流动,也不会有实质的状态传递。StateMachineEventResult包含了事件发送的结果。

所以我们做如下修改:

 @Component
 public class Runner implements CommandLineRunner {
 ​
     @Autowired
     private StateMachine<States, Events> stateMachine;
 ​
     @Override
     public void run(String... args) throws Exception {
         //stateMachine.sendEvent(Events.E1);
         //stateMachine.sendEvent(Events.E2);
         //GenericMessage<Events> e1=new GenericMessage(Events.E1);
         //GenericMessage<Events> e2=new GenericMessage(Events.E2);
         final Message<Events> e1 = MessageBuilder.withPayload(Events.E1).build();
         final Message<Events> e2 = MessageBuilder.withPayload(Events.E2).build();
         final Flux<Message<Events>> messageFlux = Flux.just(e1, e2);
 ​
         final Disposable meeage_has_sent = stateMachine.sendEvents(messageFlux).doOnComplete(() -> {
             System.out.println("meeage has sent");
         }).doOnNext(r->{
             System.out.println(r.getMessage().getPayload()+" 事件 发送结果:"+r.getResultType());
         }).subscribe();
     }
 }

返回结果:

 State change to SI
 ------省略部分系统日志------
 State SI change to S1
 E1 事件 发送结果:ACCEPTED
 State S1 change to S2
 E2 事件 发送结果:ACCEPTED
 meeage has sent

至此,我们实现了简单的状态机的状态迁移。

3.复合案例

3.1 分层子状态

使用withStates()定义分层状态,并且通过parent()方法去指定状态的父状态。示例如下:

 states.withStates()
     .initial(States.SI)
     .state(States.S1).state(States.S2)
     .and()
     .withStates()
     .parent(States.S1)
     .initial(States.S1_1)
     .state(States.S1_2);

在同一层中也可能出现多个组的状态,每一组都有自己的初始状态。所以抽象出区域的概念,每个区域内去设置初始状态和状态组。而对于不同的区域,我们可以设置区域id(RegionId),默认值为UUID。示例如下:

 states.withStates()
     .initial(States.SI)
     .state(States.S1)
     .state(States.S2)
     .and()
     .withStates()
     .parent(States.S1)
     .region("S1-1")
     .initial(States.S1_1)
     .state(States.S1_2)
     .and()
     .withStates()
     .parent(States.S1)
     .region("S1-2")
     .initial(States.S1_3)
     .state(States.S1_4);

3.2 状态转换的三种类型

三种类型分别是:externalinternallocal,通过向状态机传递事件或者使用定时器都会触发转换。

  • internal:内部转换,source和target的状态是相同的,可以定义entry actionexit action
  • external:外部转换,从source状态转移到target状态。
  • local:本地转换和外部转换大致相同,唯一的区别就是存在super-sub状态转换时:如果目标状态是源状态的子状态,则local转换不会导致退出和进入源状态。 相反,如果目标是源状态的超状态,则local转换不会导致退出和进入目标状态。如下图所示:

statechart4

3.3 案例:CD Player

现实世界中使用CD播放器,我们通过机子上的按键去使用各个功能。比如eject(出仓,关仓),play(播放),stop(关闭),pause(暂停/开始),rewind(倒带),backward(上一曲),forward(下一曲)。

我们主要的操作流程:首先打开CD机的仓门,放入CD,关闭仓门,按播放键,CD机开始播放。播放期间可以暂停,可以选择上一曲,下一曲播放。

3.3.1 状态分析

将CD播放器的状态分为2大类,空闲中和工作中。

  • 工作状态

    • 播放中,暂停中
    • 播放中,按暂停按钮变暂停中
    • 暂停中,按播放按钮变播放中
  • 空闲状态

    • 关仓状态,出仓状态
    • 关仓状态,按出仓按钮变出仓状态
    • 出仓状态,按出仓按钮变关仓状态
  • 工作状态,按停止按钮变空闲状态

  • 空闲状态,按播放按钮变工作状态

3.3.2 状态图

状态机启动,

  • 初始状态为IDLE,CLOSED,当我们操作开仓操作时,状态流转到OPEN

  • 此时我们可以根据放入的CD做load的操作,将CD的信息都记录到状态机的扩展变量中。

  • 当我们操作关仓或者点击播放时,仓门关闭,CLOSED的,当操作是播放并且已经loadCD时状态流转到BUSY,PLAYING,CD机开始播放

    当我们按下上一曲,下一曲 键,此时保持BUSY,PLAYING,CD开始切换曲目并开始播放。

  • 当我们按下pause键,此时状态变成BUSY,PAUSED,播放暂停。

  • 当我们按下stop键,此时状态变成IDLE,CLOSED,播放立即停止。

3.3.3 代码实现

事件定义and状态定义

事件代表用户可以按下的按钮以及用户是否将光盘加载到播放器中

 public enum States {
     BUSY,PLAYING,PAUSED,IDLE,CLOSED,OPEN
 }
 public enum Events {
     PLAY,STOP,PAUSE,EJECT,LOAD,FORWARD,BACK
 }
 public enum Headers {
     TRACKSHIFT
 }
 ​
 public enum Variables {
     CD,TRACK,ELAPSEDTIME
 }

状态转换配置类:定义状态,状态转移的触发事件,以及状态转移的监听,还有状态守卫去判断是否可以进行转移。这些都可以配置在这个类中。

在前面的配置中:

  • EnumStateMachineConfigurerAdapter配置状态和转换。

  • 设置IDLE的子状态CLOSEDOPEN状态,BUSY的子状态PLAYINGPAUSED

  • 对于CLOSED状态,我们添加了进入状态的动作 closedEntryAction

  • 状态转换,其中EJECT关闭和打开仓门、PLAYSTOPPAUSE做简单的转换。对于其他转换,我们执行了以下操作:

    • 对于源状态PLAYING,我们添加了一个定时器触发器,自动跟踪播放曲目中的经过时间,并有一个判断会决定何时切换到下一曲目。
    • 对于PLAY事件,如果源状态是IDLE,目标状态是 BUSY,我们定义了一个名为 的动作playAction和一个名为 的守卫playGuard
    • 对于LOAD事件和OPEN状态,我们使用名为 的动作定义了一个内部转换loadAction,它跟踪插入带有扩展状态变量的光盘。
    • 对于PLAYING状态定义了三个内部转换。一个由计时器触发,该操作playingAction更新扩展状态变量。其他两个转换使用trackAction 不同的事件(分别为BACKFORWARD)来处理用户想要在轨道中后退或前进的情况。
 @Configuration
 @EnableStateMachine
 public class CDPlayerStateMachineConfig extends EnumStateMachineConfigurerAdapter<States, Events> {
     @Override
     public void configure(StateMachineStateConfigurer<States, Events> states) throws Exception {
         states.withStates()
             .initial(States.IDLE)
             .state(States.IDLE)
             .and()
             .withStates()
             .parent(States.IDLE)
             .initial(States.CLOSED)
             .state(States.CLOSED,closedEntryAction(),null)
             .state(States.OPEN)
             .and()
             .withStates()
             .state(States.BUSY)
             .and()
             .withStates()
             .parent(States.BUSY)
             .initial(States.PLAYING)
             .state(States.PLAYING)
             .state(States.PAUSED);
     }
 ​
     @Override
     public void configure(StateMachineTransitionConfigurer<States, Events> transitions) throws Exception {
         transitions
             .withExternal()
             .source(States.CLOSED).target(States.OPEN).event(Events.EJECT)
             .and()
             .withExternal()
             .source(States.OPEN).target(States.CLOSED).event(Events.EJECT)
             .and()
             .withExternal()
             .source(States.OPEN).target(States.CLOSED).event(Events.PLAY)
             .and()
             .withExternal()
             .source(States.PLAYING).target(States.PAUSED).event(Events.PAUSE)
             .and()
             .withInternal()
             .source(States.PLAYING)
             .action(playingAction())
             .timer(1000)
             .and()
             .withInternal()
             .source(States.PLAYING).event(Events.BACK)
             .action(trackAction())
             .and()
             .withInternal()
             .source(States.PLAYING).event(Events.FORWARD)
             .action(trackAction())
             .and()
             .withExternal()
             .source(States.PAUSED).target(States.PLAYING)
             .event(Events.PAUSE)
             .and()
             .withExternal()
             .source(States.BUSY).target(States.IDLE).event(Events.STOP)
             .and()
             .withExternal()
             .source(States.IDLE).target(States.BUSY).event(Events.PLAY)
             .action(playAction())
             .guard(playGuard())
             .and()
             .withInternal()
             .source(States.OPEN).event(Events.LOAD).action(loadAction());
 ​
     }
 }

load操作时触发的动作:从header中获取加载哪张cd,并设置到扩展变量中。(扩展变量是其他状态都可以访问的变量),我们后面的一些操作中可以根据这些扩展变量来判断是否已经加载过cd。

 public class LoadAction implements Action<States, Events> {
     @Override
     public void execute(StateContext<States, Events> context) {
         //从header中获取加载哪张cd,并设置到扩展变量中。(扩展变量是其他状态都可以访问的变量)
         final Object cd = context.getMessageHeader(Variables.CD);
         // 设置扩展变量,也意味着cd播放机已经加载过cd
         context.getExtendedState().getVariables().put(Variables.CD,cd);
     }
 }

播放中时通过timer(1000)定时器来触发播放时长的更新和曲目自动切换。如果当前播放时长大于曲目的最大时长,则设置forward下一曲换挡

 public class PlayingAction implements Action<States, Events> {
     @Override
     public void execute(StateContext<States, Events> context) {
         final Map<Object, Object> variables = context.getExtendedState().getVariables();
         //当前播放时长
         final Object elapsed = variables.get(Variables.ELAPSEDTIME);
         //哪张CD
         Object cd = variables.get(Variables.CD);
         // 哪首曲目
         Object track = variables.get(Variables.TRACK);
         // 简单判断数据类型是否正确
         if (elapsed instanceof Long){
             //当前时间加1s
             long e=(Long)elapsed+1000L;
             //如果当前播放时长大于曲目的最大时长,则设置forward下一曲换挡
             if (e > ((Cd)cd).getTracks()[(Integer) track].getLength()*1000){
                 context.getStateMachine().sendEvent(Mono.just(MessageBuilder
                         //下一曲
                         .withPayload(Events.FORWARD)
                         //换挡
                         .setHeader(Headers.TRACKSHIFT.toString(),1).build())
                 ).subscribe();
             }else {
                 variables.put(Variables.ELAPSEDTIME,e);
             }
 ​
         }
     }
 }

CD状态机实现类,定义状态机行为和状态机的转移。对应各个按键操作,内部通过发送事件来触发状态转移,并且可以通过传递消息头来实现上下文传递。当然了,状态机的扩展变量也可以作为上下文传递的手段。

 @WithStateMachine
 public class CdPlayer {
 ​
 ​
     @OnTransition
     public void busy(ExtendedState extendedState){
         final Object cd = extendedState.getVariables().get(Variables.CD);
         if (cd !=null){
             cdStatus=((Cd)cd).getName();
         }
     }
     //每隔1s中转移一次Playing,触发是修正播放时长
     @StateOnTransition(target = States.PLAYING)
     public void playing(ExtendedState extendedState){
         //获取播放时长
         final Object elapsed = extendedState.getVariables().get(Variables.ELAPSEDTIME);
         final Object cd = extendedState.getVariables().get(Variables.CD);
         final Object track = extendedState.getVariables().get(Variables.TRACK);
         if (elapsed instanceof Long && track instanceof Integer && cd instanceof Cd){
             SimpleDateFormat sdf=new SimpleDateFormat("mm:ss");
             trackStatus=((Cd)cd).getTracks()[(Integer)track]+" "+sdf.format(new Date((Long)elapsed));
         }
     }
 ​
     /**
      * 仓门打开时更新CD状态为open
      * @param extendedState
      */
     @StateOnTransition(target = States.OPEN)
     public void open(ExtendedState extendedState) {
         cdStatus = "Open";
     }
     @StateOnTransition(target = {States.CLOSED,States.IDLE})
     public void closed(ExtendedState extendedState){
         final Object cd = extendedState.getVariables().get(Variables.CD);
         if (cd !=null){
             cdStatus=((Cd)cd).getName();
         }else {
             cdStatus="No CD";
         }
         trackStatus="";
     }
 ​
 ​
 }

CD机操作restful接口:抽象cd机的按键操作,通过RESTful接口对外提供操作内部状态机的能力。

 @RestController(value = "/")
 public class CDPlayerController {
 ​
     @Autowired
     private CdPlayer cdPlayer;
 ​
     @Autowired
     private Library library;
 ​
     @PostMapping("/cdplayer/load/{index}")
     public String load(@PathVariable int index) {
         StringBuilder buf = new StringBuilder();
         try {
             Cd cd = library.getCollection().get(index);
             cdPlayer.load(cd);
             buf.append("Loading cd " + cd);
         } catch (Exception e) {
             buf.append("Cd with index " + index + " not found, check library");
         }
         return buf.toString();
     }
     @PostMapping("/cdplayer/play")
     public void play() {
         cdPlayer.play();
     }
     @PostMapping("/cdplayer/stop")
     public void stop() {
         cdPlayer.stop();
     }
     @PostMapping("/cdplayer/pause")
     public void pause() {
         cdPlayer.pause();
     }
     @PostMapping("/cdplayer/eject")
     public void eject() {
         cdPlayer.eject();
     }
     @PostMapping("/cdplayer/forward")
     public void forward() {
         cdPlayer.forward();
     }
     @PostMapping("/cdplayer/back")
     public void back() {
         cdPlayer.back();
     }
 }

3.3.4 测试

通过启动服务之后,开始接口调用测试结果如下:

 //  cdplayer/eject
 2023-06-02 17:58:26.861 [http-nio-8080-exec-1] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:22 [] - Entry state IDLE
 2023-06-02 17:58:26.911 [http-nio-8080-exec-1] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:22 [] - Entry state CLOSED
 // cdplayer/eject
 2023-06-02 18:46:19.360 [http-nio-8080-exec-7] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:25 [] - exit state CLOSED
 2023-06-02 18:46:19.386 [http-nio-8080-exec-7] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:22 [] - Entry state OPEN
 // cdplayer/load   
 2023-06-02 18:46:28.217 [http-nio-8080-exec-8] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:29 [] - Internal transition source= OPEN
 // cdplayer/play    
 2023-06-02 18:46:50.791 [http-nio-8080-exec-9] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:25 [] - exit state CLOSED
 2023-06-02 18:46:50.791 [http-nio-8080-exec-9] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:25 [] - exit state IDLE
 2023-06-02 18:46:50.816 [http-nio-8080-exec-9] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:22 [] - Entry state BUSY
 2023-06-02 18:46:50.828 [http-nio-8080-exec-9] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:22 [] - Entry state PLAYING
 2023-06-02 18:46:57.851 [parallel-1] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:29 [] - Internal transition source= PLAYING
 2023-06-02 18:46:57.854 [parallel-1] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:29 [] - Internal transition source= PLAYING
 2023-06-02 18:46:57.858 [parallel-1] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:29 [] - Internal transition source= PLAYING
 2023-06-02 18:46:57.860 [parallel-1] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:29 [] - Internal transition source= PLAYING
 2023-06-02 18:46:57.867 [parallel-1] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:29 [] - Internal transition source= PLAYING
 2023-06-02 18:46:57.871 [parallel-1] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:29 [] - Internal transition source= PLAYING
     
     
 //  cdplayer/pause   
 2023-06-02 18:47:39.445 [http-nio-8080-exec-10] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:25 [] - exit state PLAYING
 2023-06-02 18:47:39.447 [http-nio-8080-exec-10] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:22 [] - Entry state PAUSED
 //  cdplayer/stop  
 2023-06-02 18:58:12.695 [http-nio-8080-exec-4] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:25 [] - exit state PAUSED
 2023-06-02 18:58:12.698 [http-nio-8080-exec-4] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:25 [] - exit state BUSY
 2023-06-02 18:58:12.700 [http-nio-8080-exec-4] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:22 [] - Entry state IDLE
 2023-06-02 18:58:12.705 [http-nio-8080-exec-4] [INFO ] c.e.s.c.p.b.TestEventListener.onApplicationEvent:22 [] - Entry state CLOSED

我们可以看到,状态流转时,存在进入和退出状态的动作,在这些动作中我们可以定义一些特殊行为。在这个cd机的案例中可以看出,所有状态有序运转,维护着cd播放状态和曲目信息,对外部来说,只要几个简单的按键就运转了复杂的状态流转。

所以,在我们真实的业务代码中,一单遇到复杂的业务场景,需要处理多个复杂状态流转时,此时我们就可以考虑用状态机来实现。其中,必须要注意的是,一定要设计好状态机的各个状态的流转行为,避免出现逻辑漏洞。