Qualche tempo fa, nell’articolo Automi e Gestione dello Stato, abbiamo parlato di un framework per l’implementazione di una macchina a stati finiti, ovvero di automi che possono rivelarsi molto utili quando, in un progetto, si ha la necessità di dover gestire lo stato di un oggetto di business delle possibili azioni applicabili ad esso, in base allo stato, e delle conseguenti transizioni allo stato successivo.
L’importanza dell’impiego di tali automi in sistemi reali, è maggiormente avvalorata dal fatto che un progetto open source rilevante come Spring, ha recentemente rilasciato un suo framework per la gestione di macchine a stati: Spring Statemachine. Per comprendere la robustezza e l’usabilità di tale framework, proveremo ad implementare il semplice processo di purchase order, già introdotto nell’articolo Automi e Gestione dello Stato, e che riportiamo per comodità nella figura seguente.
Naturalmente anche Spring Statemachine supporta il disegno del processo mediante un file descrittore, che in particolare, è un diagramma UML disegnato mediante l’ambiente di modellazione UML Papyrus per Eclipse. Per il nostro esempio però utilizzeremo un approccio programmatico.
Le Dipendenze
Per l’utilizzo del framework è necessaria una unica dipendenza spring-statemachine-core
, anche se esistono diversi altri moduli che gli consentono, ad esempio, di essere facilmente integrato con Spring Boot o di supportare l’accesso a database relazionali o NoSQL, o ancora di essere utilizzato con Spring Cloud, etc.
Alla dipendenza core di Statemachine aggiungiamo inoltre quelle necessarie all’utilizzo di a Spring JavaConfig e Spring core:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-core</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.springframework.statemachine</groupId> <artifactId>spring-statemachine-core</artifactId> <version>${spring.statemachine}</version> </dependency> </dependencies> |
Eventi e Stati
Gli stati che caratterizzano una macchina a stati finiti di Spring e gli eventi che determinano le transizioni da uno stato al successivo, possono essere rappresentati indifferentemente da classi String
o Enum
. In generale se si vuole caratterizzare tali elementi con informazioni aggiuntive è preferibile utilizzare gli Enum
, che essendo fondamentalmente delle classi java, possono essere arricchite con ulteriori proprietà. Per l’esempio del PO abbiamo quindi utilizzato due classi Enum
così definite.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public enum OrderStates { CREATED ("New order"), APPROVED ("Approved"), CANCELLED ("Cancelled"), DENIED ("Denied"), PROCESSED ("Processed"); private String description; private OrderStates(String description) { this.description = description; } public String getDescription() { return this.description; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public enum OrderEvents { APPROVE ("Approve order"), DENY ("Deny order"), CANCEL ("Calcel order"), PROCESS ("Process order"); private String description; private OrderEvents(String description) { this.description = description; } public String getDescription() { return this.description; } } |
Definizione dell’Automa
Il metodo scelto da Spring per definire una macchina a stati è quello di utilizzare una classe di configurazione di JavaConfig, ovvero una classe annotata con l’annotazione @Configuration
. Spring Statemachine introduce due ulteriori annotazioni con cui annotare la classe di configurazione, che abilitano le funzioni necessarie a supportare una macchina a stati finiti. Tali annotazioni sono @EnableStateMachine
e @EnableStateMachineFactory
.
La differenza tra le due annotazioni è che la prima genera una istanza della macchina a stati all’avvio dell’applicazione, che resta disponibile nel contesto di Spring (come singleton) per tutta la sua vita. La seconda invece configura l’automa ma la sua generazione è differita al momento in cui il programmatore, con una specifica classe factory, deciderà di istanziarla. Conseguentemente se si intende gestire con una macchina a stati l’evoluzione di un oggetto di business, come un ordine di acquisto, che può essere istanziato più volte nell’applicazione, deve essere utilizzata l’annotazione @EnableStateMachineFactory
.
1 2 3 4 5 |
@Configuration @EnableStateMachineFactory public class PurchaseOrderConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> { .... ] |
Il framework mette inoltre a disposizione due classi adapter EnumStateMachineConfigurerAdapter
e StateMachineConfigurerAdapter
che possono essere estese e che definiscono i metodi di callback per la configurazione della macchina a stati. In particolare devono essere sovrascritti due metodi necessari per la configurazione degli stati e delle transizioni ammesse in base agli eventi definiti:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
@Configuration @EnableStateMachineFactory public class PurchaseOrderConfig extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> { @Override public void configure(StateMachineStateConfigurer<OrderStates, OrderEvents> states) throws Exception { states .withStates() .initial(OrderStates.CREATED) .state(OrderStates.APPROVED) .end(OrderStates.DENIED) .end(OrderStates.CANCELLED) .end(OrderStates.PROCESSED); } @Override public void configure(StateMachineTransitionConfigurer<OrderStates, OrderEvents> transitions) throws Exception { transitions .withExternal() .source(OrderStates.CREATED) .target(OrderStates.APPROVED) .event(OrderEvents.APPROVE) .and() .withExternal() .source(OrderStates.CREATED) .target(OrderStates.DENIED) .event(OrderEvents.DENY) .and() .withExternal() .source(OrderStates.APPROVED) .target(OrderStates.PROCESSED) .event(OrderEvents.PROCESS) .and() .withExternal() .source(OrderStates.APPROVED) .target(OrderStates.CANCELLED) .event(OrderEvents.CANCEL); } } |
Quella mostrata è la configurazione in assoluto più semplice di un automa, in cui tutti gli stati sono allo stesso livello, e che bene si adatta al nostro problema dell’ordine di acquisto. In generale però il framework è in grado di gestire stati gerarchici, ovvero in cui ad uno stato corrispondono sotto-stati differenti. Conseguentemente anche le transizioni possono essere classificate in modo diverso in funzione del fatto che possano avere un effetto sui soli sotto-stati o anche sugli stati gerarchicamente superiori.
Action
Alle transizioni e agli eventi di ingresso ed uscita da uno stato possono essere associate delle azioni che il motore esegue al verificarsi dell’evento. Tornando all’esempio del PO, in conseguenza delle transizioni approve
e deny,
è previsto che vengano valorizzate opportunamente le proprietà dell’ordine: number
, approvalDate
e cancelDate
. A scopo nella definizione delle transazioni il framework mette a disposizione un metodo action() che riceve in ingresso l’azione da eseguire:
1 2 3 4 5 6 7 8 9 10 11 |
.source(OrderStates.CREATED) .target(OrderStates.APPROVED) .event(OrderEvents.APPROVE) .action( approveAction() ) ... .source(OrderStates.CREATED) .target(OrderStates.DENIED) .event(OrderEvents.DENY) .action( denyAction() ) |
I due metodi approceAction()
e denyAction()
restituiscono un oggetto che implementa l’interfaccia Action<OrderStates, OrderEvents>
, la quale definisce un unico metodo execute()
che è quello invocato dal framework quando la logica dell’action deve essere eseguita.
Prima di procedere con l’implementazione concreta dei due metodi dobbiamo introdurre l’interfaccia ExtendedState
. Tale interfaccia è utilizzata per arricchire lo stato dell’automa con variabili (di stato) aggiuntive. Nel nostro caso, ad esempio, l’oggetto Order
gestito dal processo PO è memorizzato nell’ExtendedState
della macchina. L’accesso a tale stato “esteso” è possibile attraverso il metodo getExtendedState()
della classe StateMachine
o StateContext
, a seconda dei casi.
Il metodo execute()
dell’interfaccia Action
prevede in input un oggetto di tipo StateContext
, per cui l’implementazione dei metodi approceAction()
e denyAction()
sarà:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
public Action<OrderStates, OrderEvents> approveAction() { return new Action<OrderStates, OrderEvents>() { @Override public void execute(StateContext<OrderStates, OrderEvents> context) { Order order = findOrder( context.getExtendedState() ); if ( order != null ) { order.setNumber( (int)(Math.random()*100000) ); order.setApprovalDate( new Date() ); } } }; } public Action<OrderStates, OrderEvents> denyAction() { return new Action<OrderStates, OrderEvents>() { @Override public void execute(StateContext<OrderStates, OrderEvents> context) { Order order = findOrder( context.getExtendedState() ); if ( order != null ) { order.setCancelDate( new Date() ); } } }; } |
findOrder()
ricerca l’ordine tra le variabili memorizzate nell’extended state:
1 2 3 4 5 6 7 8 |
private Order findOrder( ExtendedState extendedState ) { for ( Object obj : extendedState.getVariables().values() ) { if ( obj instanceof Order ) { return (Order) obj; } } return null; } |
Come detto inizialmente è anche possibile associare azioni ad uno stato, che vengono eseguite in corrispondenza degli eventi di ingresso ed uscita dallo stesso, indipendentemente dalla transizione che li hanno generati. Per completezza mostriamo un esempio di come eseguire tale associazione, fermo restando che l’implementazione dell’action segue la medesima logica. Aggiorniamo quindi la definizione degli stati nel seguente modo:
1 2 3 4 5 |
.initial(OrderStates.CREATED) .state( OrderStates.APPROVED, entryAction(), exitAction() ) .end(OrderStates.DENIED) .end(OrderStates.CANCELLED) .end(OrderStates.PROCESSED); |
dove al metodo state()
sono state aggiunte le azioni corrispondenti all’ingresso ed all’uscita dallo stato APPROVED
, e che sono così definite:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public Action<OrderStates, OrderEvents> entryAction() { return new Action<OrderStates, OrderEvents>() { @Override public void execute(StateContext<OrderStates, OrderEvents> context) { System.out.println( "Entering state " + context.getTarget().getId() ); } }; } public Action<OrderStates, OrderEvents> exitAction() { return new Action<OrderStates, OrderEvents>() { @Override public void execute(StateContext<OrderStates, OrderEvents> context) { System.out.println( "Exiting state " + context.getSource().getId() ); } }; } |
Guard
Ultima feature di cui abbiamo bisogno per implementare il processo PO è l’interfaccia Guard
, che è utilizzata per “proteggere” le transizioni tra gli stati. Tale interfaccia definisce un unico metodo evaluate()
, che restituisce un boolean
, e che è invocato prima della esecuzione della transizione a cui è associata. L’effetto è quello di bloccare la transazione nel caso in cui sia restituito il valore FALSE
.
Nel caso del processo di PO, il vincolo che deve essere implementato è quello di non consentire l’approvazione di richieste con un budget maggiore di un importo prefissato (100$). A tale scopo alla transizione dallo stato CREATED
a quello APPROVED
è associato un oggetto Guard
nel modo seguente:
1 2 3 4 5 |
.source(OrderStates.CREATED) .target(OrderStates.APPROVED) .event(OrderEvents.APPROVE) .action( approveAction() ) .guard( budgetGuard( BigDecimal.valueOf( 100 ) ) ) |
budgetGuard()
definisce una classe Guard
che restituisce FALSE
nel caso in cui il budget dell’ordine sia superiore al limite fissato:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public Guard<OrderStates, OrderEvents> budgetGuard( BigDecimal limit ) { return new Guard<OrderStates, OrderEvents>() { @Override public boolean evaluate(StateContext<OrderStates, OrderEvents> context) { Order order = findOrder( context.getExtendedState() ); if ( order != null ) { return order.getBudget().compareTo( limit ) == -1; } return true; } }; } |
Esecuzione della Macchina a Stati
L’interazione con la macchina a stati finiti che abbiamo definito per il processo di PO implica due aspetti fondamentali:
- generazione di una nuova istanza della macchina a stati per ogni richiesta di acquisto;
- interazione con la macchina a stati al fine di far procedere l’ordine, transitando tra i diversi stati previsti.
Il primo aspetto è coperto dal framework attraverso l’interfaccia StateMachineFactory
ed il metodo getStateMachine()
che genera e restituisce un’istanza della classe StateMachine
che rappresenta la macchina a stati richiesta. Introduciamo quindi, per semplicità, la classe OrderManager
per la gestione degli ordini di acquisto e definiamo il metodo newOrder()
nel seguente modo:
1 2 3 4 5 6 7 8 9 10 11 |
public class OrderManager { @Autowired StateMachineFactory<OrderStates, OrderEvents> factory; public StateMachine<OrderStates,OrderEvents> newOrder( Order order) { StateMachine<OrderStates,OrderEvents> stateMachine = factory.getStateMachine( "PO-" + order.getId() ); stateMachine.getExtendedState().getVariables().put( "order", order ); stateMachine.start(); return stateMachine; } } |
In il metodo nell’ordine:
- istanzia la macchina associandogli un
machine-id
derivato dall’id
dell’ordine; - inserisce l’ordine nell’
extended state
della macchina (importante per poter essere recuperato dalleAction
eGurd
definite); - avvia la macchina che si “posizionerà” sullo stato iniziale di
CREATED
.
L’evoluzione della macchina tra i vari stati, che copre il secondo degli aspetti considerati, si realizza attraverso l’invio di eventi o segnali alla macchina. Tali eventi, che sono evidentemente quelli definiti in fase di configurazione, possono essere inviati attraverso il metodo sendEvent()
della classe StateMachine
. Procedendo con l’implementazione della classe OrderManager introduciamo il metodo generico fire()
così definito:
1 2 3 4 5 6 |
public class OrderManager { ... public boolean fire(StateMachine<OrderStates,OrderEvents> stateMachine, OrderEvents event) { return stateMachine.sendEvent( event ); } } |
Esempi di Richieste di Acquisto
Per testare il comportamento della macchina consideriamo due richieste di acquisto, una con budget superiore al limite ed una con budget inferiore. Creiamo quindi un ordine per un laptop di importo pari a 1000 euro e procediamo approvando l’ordine:
1 2 3 4 5 6 7 8 |
// Laptop PO Order laptop = new Order(1L, "Laptop", BigDecimal.valueOf(1000)); StateMachine<OrderStates,OrderEvents> laptopStateMachine = orderManager.newOrder( laptop ); System.out.println("Laptop State: " + laptopStateMachine.getState().getId()); orderManager.fire(laptopStateMachine, OrderEvents.APPROVE); System.out.println("Laptop State: " + laptopStateMachine.getState().getId()); |
Come ci aspettiamo lo stato dell’ordine prima e dopo l’evento di approvazione rimane inalterato in CREATED
.
1 2 |
Laptop State: CREATED Laptop State: CREATED |
Procediamo eseguendo lo stesso test questa volta per l’acquisto di un mouse del valore di 50 euro:
1 2 3 4 5 6 7 8 9 |
// Mouse PO Order mouse = new Order(2L, "Mouse", BigDecimal.valueOf(50)); StateMachine<OrderStates,OrderEvents> mouseStateMachine = orderManager.newOrder( mouse ); System.out.println("Mouse State: " + mouseStateMachine.getState().getId()); orderManager.fire(mouseStateMachine, OrderEvents.APPROVE); System.out.println("Mouse State: " + mouseStateMachine.getState().getId()); System.out.println(mouseStateMachine.getExtendedState().getVariables().get("order")); |
Questa volta la condizione imposta dalla condizione di Guard
è superata e la macchina si porta nello stato APPROVED
. Inoltre le proprietà number
e approvalDate
vengono valorizzate dalla Action
associata alla transazione.
1 2 3 4 |
Mouse State: CREATED Entering state APPROVED Mouse State: APPROVED Order [id=2, item=Mouse, number=36377, note=null, budget=50, approvalDate=Mon Mar 25 15:14:52 CET 2019, cancelDate=null] |
Per completezza terminiamo il flusso di approvazione inviando il segnale PROCESS
che porta la macchina nello stato PROCESSED
. Si tratta di uno stato classificato come stato finale (end state) ed il framework ce lo segnala esponendo un metodo isCompleted()
della classe StateMachine
che, se invocato, restituirà TRUE
.
1 2 3 4 5 6 |
System.out.println( "Mouse Machine Completed:" + mouseStateMachine.isComplete() ); orderManager.fire(mouseStateMachine, OrderEvents.PROCESS); System.out.println("Mouse State: " + mouseStateMachine.getState().getId()); System.out.println( "Mouse Machine Completed:" + mouseStateMachine.isComplete() ); |
1 2 3 4 |
Mouse Machine Completed:false Exiting state APPROVED Mouse State: PROCESSED Mouse Machine Completed:true |
Codie Sorgente
Il codice sorgente del progetto è disponibile qui spring-statemachine.