Una guida al progetto Spring State Machine

1. Introduzione

Questo articolo è incentrato sul progetto State Machine di Spring, che può essere utilizzato per rappresentare flussi di lavoro o qualsiasi altro tipo di problemi di rappresentazione di automi a stati finiti.

2. Dipendenza da Maven

Per iniziare, dobbiamo aggiungere la dipendenza principale di Maven:

 org.springframework.statemachine spring-statemachine-core 1.2.3.RELEASE 

L'ultima versione di questa dipendenza può essere trovata qui.

3. Configurazione della macchina a stati

Ora, iniziamo definendo una semplice macchina a stati:

@Configuration @EnableStateMachine public class SimpleStateMachineConfiguration extends StateMachineConfigurerAdapter { @Override public void configure(StateMachineStateConfigurer states) throws Exception { states .withStates() .initial("SI") .end("SF") .states( new HashSet(Arrays.asList("S1", "S2", "S3"))); } @Override public void configure( StateMachineTransitionConfigurer 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"); } }

Si noti che questa classe è annotata come una configurazione Spring convenzionale e come una macchina a stati. È inoltre necessario estendere StateMachineConfigurerAdapter in modo che possano essere richiamati vari metodi di inizializzazione. In uno dei metodi di configurazione definiamo tutti i possibili stati della macchina a stati, nell'altro come gli eventi cambiano lo stato corrente.

La configurazione sopra mostra una macchina a stati di transizione abbastanza semplice e lineare che dovrebbe essere abbastanza facile da seguire.

Ora dobbiamo avviare un contesto Spring e ottenere un riferimento alla macchina a stati definita dalla nostra configurazione:

@Autowired private StateMachine stateMachine;

Una volta che abbiamo la macchina a stati, deve essere avviata:

stateMachine.start();

Ora che la nostra macchina è nello stato iniziale, possiamo inviare eventi e quindi attivare transizioni:

stateMachine.sendEvent("E1");

Possiamo sempre controllare lo stato corrente della macchina a stati:

stateMachine.getState();

4. Azioni

Aggiungiamo alcune azioni da eseguire intorno alle transizioni di stato. Per prima cosa, definiamo la nostra azione come un bean Spring nello stesso file di configurazione:

@Bean public Action initAction() { return ctx -> System.out.println(ctx.getTarget().getId()); }

Quindi possiamo registrare l'azione sopra creata sulla transizione nella nostra classe di configurazione:

@Override public void configure( StateMachineTransitionConfigurer transitions) throws Exception { transitions.withExternal() transitions.withExternal() .source("SI").target("S1") .event("E1").action(initAction())

Questa azione verrà eseguita quando si verifica la transizione da SI a S1 tramite l'evento E1 . Le azioni possono essere associate agli stati stessi:

@Bean public Action executeAction() { return ctx -> System.out.println("Do" + ctx.getTarget().getId()); } states .withStates() .state("S3", executeAction(), errorAction());

Questa funzione di definizione dello stato accetta un'operazione da eseguire quando la macchina è nello stato di destinazione e, facoltativamente, un gestore di azioni di errore.

Un gestore di azioni di errore non è molto diverso da qualsiasi altra azione, ma verrà richiamato se viene generata un'eccezione in qualsiasi momento durante la valutazione delle azioni dello stato:

@Bean public Action errorAction() { return ctx -> System.out.println( "Error " + ctx.getSource().getId() + ctx.getException()); }

È anche possibile registrare singole azioni per le transizioni di stato di ingresso , esecuzione e uscita :

@Bean public Action entryAction() { return ctx -> System.out.println( "Entry " + ctx.getTarget().getId()); } @Bean public Action executeAction() { return ctx -> System.out.println("Do " + ctx.getTarget().getId()); } @Bean public Action exitAction() { return ctx -> System.out.println( "Exit " + ctx.getSource().getId() + " -> " + ctx.getTarget().getId()); }
states .withStates() .stateEntry("S3", entryAction()) .stateDo("S3", executeAction()) .stateExit("S3", exitAction());

Le rispettive azioni verranno eseguite sulle transizioni di stato corrispondenti. Ad esempio, potremmo voler verificare alcune pre-condizioni al momento dell'ingresso o attivare alcune segnalazioni al momento dell'uscita.

5. Ascoltatori globali

I listener di eventi globali possono essere definiti per la macchina a stati. Questi listener verranno richiamati ogni volta che si verifica una transizione di stato e possono essere utilizzati per cose come la registrazione o la sicurezza.

Innanzitutto, dobbiamo aggiungere un altro metodo di configurazione, uno che non si occupa di stati o transizioni ma con la configurazione per la macchina a stati stessa.

Dobbiamo definire un listener estendendo StateMachineListenerAdapter :

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

Qui abbiamo solo sovrascritto stateChanged sebbene siano disponibili molti altri hook pari.

6. Stato esteso

Spring State Machine tiene traccia del suo stato, ma per tenere traccia dello stato della nostra applicazione , che si tratti di alcuni valori calcolati, voci di amministratori o risposte da chiamate a sistemi esterni, dobbiamo usare quello che viene chiamato uno stato esteso .

Supponiamo di voler essere sicuri che una richiesta di account passi attraverso due livelli di approvazione. Possiamo tenere traccia del conteggio delle approvazioni utilizzando un numero intero memorizzato nello stato esteso:

@Bean public Action executeAction() { return ctx -> { int approvals = (int) ctx.getExtendedState().getVariables() .getOrDefault("approvalCount", 0); approvals++; ctx.getExtendedState().getVariables() .put("approvalCount", approvals); }; }

7. Guardie

È possibile utilizzare una guardia per convalidare alcuni dati prima che venga eseguita una transizione a uno stato. Una guardia sembra molto simile a un'azione:

@Bean public Guard simpleGuard() { return ctx -> (int) ctx.getExtendedState() .getVariables() .getOrDefault("approvalCount", 0) > 0; }

The noticeable difference here is that a guard returns a true or false which will inform the state machine whether the transition should be allowed to occur.

Support for SPeL expressions as guards also exists. The example above could also have been written as:

.guardExpression("extendedState.variables.approvalCount > 0")

8. State Machine from a Builder

StateMachineBuilder can be used to create a state machine without using Spring annotations or creating a Spring context:

StateMachineBuilder.Builder builder = StateMachineBuilder.builder(); builder.configureStates().withStates() .initial("SI") .state("S1") .end("SF"); builder.configureTransitions() .withExternal() .source("SI").target("S1").event("E1") .and().withExternal() .source("S1").target("SF").event("E2"); StateMachine machine = builder.build();

9. Hierarchical States

Hierarchical states can be configured by using multiple withStates() in conjunction with parent():

states .withStates() .initial("SI") .state("SI") .end("SF") .and() .withStates() .parent("SI") .initial("SUB1") .state("SUB2") .end("SUBEND");

This kind of setup allows the state machine to have multiple states, so a call to getState() will produce multiple IDs. For example, immediately after startup the following expression results in:

stateMachine.getState().getIds() ["SI", "SUB1"]

10. Junctions (Choices)

So far, we've created state transitions which were linear by nature. Not only is this rather uninteresting, but it also does not reflect real-life use-cases that a developer will be asked to implement either. The odds are conditional paths will need to be implemented, and Spring state machine's junctions (or choices) allow us to do just that.

First, we need to mark a state a junction (choice) in the state definition:

states .withStates() .junction("SJ")

Then in the transitions, we define first/then/last options which correspond to an if-then-else structure:

.withJunction() .source("SJ") .first("high", highGuard()) .then("medium", mediumGuard()) .last("low")

first and then take a second argument which is a regular guard which will be invoked to find out which path to take:

@Bean public Guard mediumGuard() { return ctx -> false; } @Bean public Guard highGuard() { return ctx -> false; }

Note that a transition does not stop at a junction node but will immediately execute defined guards and go to one of the designated routes.

In the example above, instructing state machine to transition to SJ will result in the actual state to become low as the both guards just return false.

A final note is that the API provides both junctions and choices. However, functionally they are identical in every aspect.

11. Fork

Sometimes it becomes necessary to split the execution into multiple independent execution paths. This can be achieved using the fork functionality.

First, we need to designate a node as a fork node and create hierarchical regions into which the state machine will perform the split:

states .withStates() .initial("SI") .fork("SFork") .and() .withStates() .parent("SFork") .initial("Sub1-1") .end("Sub1-2") .and() .withStates() .parent("SFork") .initial("Sub2-1") .end("Sub2-2");

Then define fork transition:

.withFork() .source("SFork") .target("Sub1-1") .target("Sub2-1");

12. Join

The complement of the fork operation is the join. It allows us to set a state transitioning to which is dependent on completing some other states:

As with forking, we need to designate a join node in the state definition:

states .withStates() .join("SJoin")

Then in transitions, we define which states need to complete to enable our join state:

transitions .withJoin() .source("Sub1-2") .source("Sub2-2") .target("SJoin");

That's it! With this configuration, when both Sub1-2 and Sub2-2 are achieved, the state machine will transition to SJoin

13. Enums Instead of Strings

Negli esempi precedenti abbiamo usato costanti stringa per definire stati ed eventi per chiarezza e semplicità. Su un sistema di produzione del mondo reale, si vorrebbe probabilmente utilizzare le enumerazioni di Java per evitare errori di ortografia e ottenere una maggiore sicurezza dai tipi.

Innanzitutto, dobbiamo definire tutti i possibili stati ed eventi nel nostro sistema:

public enum ApplicationReviewStates { PEER_REVIEW, PRINCIPAL_REVIEW, APPROVED, REJECTED } public enum ApplicationReviewEvents { APPROVE, REJECT }

Abbiamo anche bisogno di passare le nostre enumerazioni come parametri generici quando estendiamo la configurazione:

public class SimpleEnumStateMachineConfiguration extends StateMachineConfigurerAdapter 

Una volta definiti, possiamo usare le nostre costanti enum invece delle stringhe. Ad esempio per definire una transizione:

transitions.withExternal() .source(ApplicationReviewStates.PEER_REVIEW) .target(ApplicationReviewStates.PRINCIPAL_REVIEW) .event(ApplicationReviewEvents.APPROVE)

14. Conclusione

Questo articolo ha esplorato alcune delle caratteristiche della macchina a stati Spring.

Come sempre puoi trovare il codice sorgente di esempio su GitHub.