Maszyna stanów w Spring Boot


Maszyna stanów w Spring Boot

Maszyna stanów pozwala zilustrować w postaci diagramu przejścia obiektu przez różne jej stany. Jest to bardzo użyteczne narzędzie w projektowaniu aplikacji. Przykładem może być projekt gry komputerowej. Schemat opisuje wtedy w jakim stanie może znaleźć się gracz oraz jakie warunki muszą być spełnione aby w tym stanie mógł się znaleźć. Poniżej dwa przykłady diagramów prostej maszyny stanów:

Partsoffsm.gif

[źródło] https://www.trccompsci.online/mediawiki/index.php/Finite_State_Machines

Znalezione obrazy dla zapytania maszyna stanów[źródło] https://ucgosu.pl/2019/08/implementacja-maszyn-stanu-na-tablicach/

Spring Boot udostępnia zależność która realizuje założenia maszyny stanów:

<dependency>
    <groupId>org.springframework.statemachine</groupId>
    <artifactId>spring-statemachine-core</artifactId>
    <version>1.2.3.RELEASE</version>
</dependency>

Do dzieła! Tworzymy nowy projekt Spring Boota – plik pom.xml – niezbędne zależności:

<dependencies>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-web</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.statemachine</groupId>
		<artifactId>spring-statemachine-core</artifactId>
		<version>1.2.3.RELEASE</version>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-test</artifactId>
		<scope>test</scope>
		<exclusions>
			<exclusion>
				<groupId>org.junit.vintage</groupId>
				<artifactId>junit-vintage-engine</artifactId>
			</exclusion>
		</exclusions>
	</dependency>
</dependencies>

Klasa konfiguracyjna w której zawarte są informacje dotyczące stanów w jakich maszyna może się znajdować:

@Configuration
@EnableStateMachine
public class SimpleStateMachineConfiguration 
  extends StateMachineConfigurerAdapter<String, String> {
 
    @Override
    public void configure(StateMachineStateConfigurer<String, String> states)
      throws Exception {
  
        states
          .withStates()
          .initial("SI")
          .end("SF")
          .states(
            new HashSet<String>(Arrays.asList("S1", "S2")));
 
    }
 
    @Override
    public void configure(
      StateMachineTransitionConfigurer<String, String> transitions)
      throws Exception {
  
        transitions.withExternal()
          .source("SI").target("S1").event("E1").and()
          .withExternal()
          .source("S1").target("S2").event("E2").and()
          .withExternal()
          .source("S2").target("SF").event("end");
    }
}

RestController:

@RestController
public class StateMachineController {

    @Autowired
    private StateMachine<String, String> stateMachine;

    @GetMapping("/sentEvent/{event}")
    public String sentEvent(@PathVariable("event") String event) {
        stateMachine.start();
        stateMachine.sendEvent(event);
        return stateMachine.getState().getId();
    }

}

Początkowy stan to SI, końcowy stan to SF

przejścia pomiędzy stanami (tranzycje):

SI -> S1 -> S2 -> SF

eventy które wyzwalają przejścia pomiędzy stanami:

event E1: SI -> S1

event E2: S1 -> S2

event end: S2 ->SF

Testy:

http://localhost:8080/sentEvent/E2

wynik:

SI

ponownie:

http://localhost:8080/sentEvent/E2

wynik:

SI

Wniosek:

Jeśli maszyna stanów będzie w stanie początkowym SI (z ang. state initial) to nie jest możliwe przejście np. do stanu S2 bez przejścia kolejno przez inne stany prowadzące do stanu S2.

Akcje

Dodajmy akcje do klasy konfiguracyjnej która będzie wykonana w momencie przejścia ze stanu S1 to stanu S2. Akcja ta wyświetli na konsoli informacje o aktualnym stanie w jakim maszyna się znajduje:

@Bean
public Action<String, String> printStateIdAction() {
    return ctx -> System.out.println(ctx.getTarget().getId());
}
@Override
public void configure(
  StateMachineTransitionConfigurer<String, String> transitions)
  throws Exception {

    transitions.withExternal()
      .source("SI").target("S1").event("E1").and()
      .withExternal()
      .source("S1").target("S2").event("E2").action(printStateIdAction()).and()
      .withExternal()
      .source("S2").target("SF").event("end");
}

Słuchacze – StateMachineListener

Logowanie informacji o aktualnych przejściach pomiędzy stanami skonfigurujemy w następujący oto sposób z użyciem klasy StateMachineListener:

public class StateMachineListener extends StateMachineListenerAdapter {
    @Override
    public void stateChanged(State from, State to) {
        System.out.printf("[LOG] Transitioned from %s to %s%n", from == null ? "none" : from.getId(), to.getId());
    }
}

trzeba również dodać metodę do klasy konfiguracyjnej która rejestruję klasę Listenera:

@Override
public void configure(StateMachineConfigurationConfigurer<String, String> config) throws Exception {
    config
            .withConfiguration()
            .autoStartup(true)
            .listener(new StateMachineListener());
}

Wynik:

[LOG] Transitioned from S1 to S2

[LOG] Transitioned from S2 to SF

Stan rozszerzony

Stan rozszerzony pozwala nam śledzić ile razy dany stan został wywołany:

@Bean
public Action<String, String> countNumbersOfInvokeStateS1() {
    return ctx -> {
        int approvals = (int) ctx.getExtendedState().getVariables()
                .getOrDefault("counter", 0);
        approvals++;
        ctx.getExtendedState().getVariables()
                .put("counter", approvals);

        System.out.println("counter " + approvals);
    };
}

rozszerzamy konfigurację o nową akcję związaną ze stanem rozszerzonym:

@Override
public void configure(StateMachineStateConfigurer<String, String> states)
  throws Exception {

    states
      .withStates()
      .initial("SI")
      .end("SF")
      .states(new HashSet<String>(Arrays.asList("S1", "S2"))).state("S1", countNumbersOfInvokeStateS1());
}

Przykład – wywołujemy kolejno:

localhost:8080/sentEvent/E1
localhost:8080/sentEvent/E2
localhost:8080/sentEvent/end

Wynik:

counter 1

localhost:8080/sentEvent/E1
localhost:8080/sentEvent/E2
localhost:8080/sentEvent/end

Wynik:

counter 2

localhost:8080/sentEvent/E1
localhost:8080/sentEvent/E2
localhost:8080/sentEvent/end

Wynik:

counter 3

Walidacja przejścia – Guard

Zanim nastąpi przejście z jednego stanu do drugiego być może potrzebna będzie weryfikacja danych, można to osiągnąć z użyciem konstrukcji – Guard:

@Bean
public Guard<String, String> guard() {
    return ctx -> (int) ctx.getExtendedState()
            .getVariables()
            .getOrDefault("counter", 0) >= 0;
}
@Override
public void configure(
  StateMachineTransitionConfigurer<String, String> transitions)
  throws Exception {

    transitions.withExternal()
      .source("SI").target("S1").event("E1").guard(guard()).and()
      .withExternal()
      .source("S1").target("S2").event("E2").action(printStateIdAction()).and()
      .withExternal()
      .source("S2").target("SF").event("end");
}

Instrukcje warunkowe – junction

Warunkowe przejścia między stanami realizujemy w następujący sposób z użyciem metody junction():

@Override
public void configure(StateMachineStateConfigurer<String, String> states)
  throws Exception {
    states
      .withStates()
      .initial("SI")
      .end("SF").junction("J")
      .states(new HashSet<String>(Arrays.asList("S1", "S2"))).state("S1", countNumbersOfInvokeStateS1());
}
@Override
public void configure(
  StateMachineTransitionConfigurer<String, String> transitions)
  throws Exception {
    transitions.withExternal()
      .source("SI").target("S1").event("E1").guard(guard()).and()
      .withExternal()
      .source("S1").target("J").event("E2").action(printStateIdAction()).and()
      .withExternal().and()
            .withJunction()
            .source("J")
            .first("S2", firstGuard())
            .then("SF",  secondGuard())
            .last("SF")
            .and()
            .withExternal()
            .source("S2").target("SF").event("E3");
}

Po przejściu do stanu J następuje instrukcja warunkowa – jeśli firstGuard() zwróci true to przejdź do stanu S2, w przeciwnym wypadku przejdź do stanu SF jeśli secondGuard() zwróci true. Jeśli warunki te są niespełnione przejdź do stanu koncowego SF.

Oddzielne gałęzie

W momencie kiedy zależy nam na tym aby rozdzielić wykonywanie zadań na dwie oddzielne i niezależne ścieżki to należy zastosować mechanizm Fork (rozdzielanie) wraz z mechanizmem Join (łączenie). Przykładowo:

Fork

Activity-ForkJoin2

Join

[źródło] https://sparxsystems.com/enterprise_architect_user_guide/10/standard_uml_models/forkjoin.html

Activity-ForkJoin1

[źródło] https://sparxsystems.com/enterprise_architect_user_guide/10/standard_uml_models/forkjoin.html

 

@Override
public void configure(StateMachineStateConfigurer<String, String> states)
  throws Exception {
    states
      .withStates()
      .initial("SI")
      .fork("SFork")
      .join("SJoin")
      .end("SF").junction("J")
      .states(new HashSet<String>(Arrays.asList("S1", "S2"))).state("S1", countNumbersOfInvokeStateS1())
            .and()
            .withStates()
                .parent("SFork")
                .initial("Sub1-1")
                .end("Sub1-2")
            .and()
            .withStates()
                .parent("SFork")
                .initial("Sub2-1")
                .end("Sub2-2");
}
@Override
public void configure(
  StateMachineTransitionConfigurer<String, String> transitions)
  throws Exception {
    transitions.withExternal()
      .source("SI").target("S1").event("E1").guard(guard()).and()
      .withExternal()
      .source("S1").target("J").event("E2").action(printStateIdAction()).and()
      .withExternal().and()
            .withJunction()
            .source("J")
            .first("S2"  , firstGuard())
            .then("SFork", secondGuard())
            .last("SF")
            .and()
            .withExternal()
            .source("S2").target("SF").event("E3").and()
            .withFork()
                .source("SFork")
                .target("Sub1-1")
                .target("Sub2-1")
            .and()
            .withJoin()
                .source("Sub1-2")
                .source("Sub2-2")
                .target("SJoin").and().withExternal()
            .source("SI").target("SFork").event("E1")
            .and().withExternal()
            .source("Sub1-1").target("Sub1-2").event("sub1")
            .and().withExternal()
            .source("Sub2-1").target("Sub2-2").event("sub2");
}

Testujemy:

http://localhost:8081/sentEvent/E1

wynik:

SFork

http://localhost:8081/sentEvent/sub1

wynik:

SFork

http://localhost:8081/sentEvent/sub2

wynik:

SJoin

Dopóki nie zakończą się zadania Sub1-1 oraz Sub2-1 nie zostanie wykonane przejście do stanu join.

Wyliczenia zamiast typu String

Zamiast na sztywno używać typu String co powoduje błędy powinniśmy używać wyliczeń, np. dla definiowania stanów:

public enum States {
    S1, S2, SI, SF,SFork
}

wtedy w konfiguracji klasy należy podać jako typ parametryczny powyższego enuma:

@Configuration
@EnableStateMachine
public class SimpleStateMachineConfiguration 
  extends StateMachineConfigurerAdapter<States, String> {

...

od teraz można używać wyliczeń do definiowania stanów:

@Override
public void configure(StateMachineStateConfigurer<State, String> states)
  throws Exception {
    states
      .withStates()
      .initial(States.S1)

...


Leave a comment

Your email address will not be published.


*