One of the common tasks when using a Statemachine is to design its runtime configuration. This chapter will focus on how Spring Statemachine is configured and how it leverages Spring’s lightweight IoC containers to simplify the application internals to make it more manageable.
Note | |
---|---|
Configuration examples in this section are not feature complete, i.e. you always need to have definitions of both states and transitions, otherwise state machine configuration would be ill-formed. We have simply made code snippets less verbose by leaving other needed parts away. |
We use familiar spring enabler annotations to ease configuration. Two annotations exists, @EnableStateMachine and @EnableStateMachineFactory. These annontations if placed in a @Configuration class will enable some basic functionality needed by a state machines.
@EnableStateMachine is used when a configuration wants to create an
instance of a StateMachine. Usually @Configuration class extends adapters
EnumStateMachineConfigurerAdapter
or StateMachineConfigurerAdapter
which
allows user to override configuration callback methods. We automatically
detect if user is using these adapter classes and modify runtime configuration
logic.
@EnableStateMachineFactory is used when a configuration wants to create an instance of a StateMachineFactory.
Note | |
---|---|
Usage examples of these are shown in below sections. |
We’ll get into more complex configuration examples a bit later but
let’s first start with a something simple. For most simple state
machine you just use EnumStateMachineConfigurerAdapter
and define
possible states, choose initial and optional end state.
@Configuration @EnableStateMachine public class Config1Enums extends EnumStateMachineConfigurerAdapter<States, Events> { @Override public void configure(StateMachineStateConfigurer<States, Events> states) throws Exception { states .withStates() .initial(States.S1) .end(States.SF) .states(EnumSet.allOf(States.class)); } }
It’s also possible to use strings instead of enums as states and
events by using StateMachineConfigurerAdapter
as shown below. Most
of a configuration examples is using enums but generally speaking
strings and enums can be just interchanged.
@Configuration @EnableStateMachine public class Config1Strings extends StateMachineConfigurerAdapter<String, String> { @Override public void configure(StateMachineStateConfigurer<String, String> states) throws Exception { states .withStates() .initial("S1") .end("SF") .states(new HashSet<String>(Arrays.asList("S1","S2","S3","S4"))); } }
Note | |
---|---|
Using enums will bring more safe set of states and event types but limits possible combinations to compile time. Strings don’t have this limitation and allows user to use more dynamic ways to build state machine configurations but doesn’t allow same level of safety. |
Hierarchical states can be defined by using multiple withStates()
calls where parent()
can be used to indicate that these
particular states are sub-states of some other state.
@Configuration @EnableStateMachine public class Config2 extends EnumStateMachineConfigurerAdapter<States, Events> { @Override public void configure(StateMachineStateConfigurer<States, Events> states) throws Exception { states .withStates() .initial(States.S1) .state(States.S1) .and() .withStates() .parent(States.S1) .initial(States.S2) .state(States.S2); } }
There are no special configuration methods to mark a collection of states to be part of an orthogonal state. To put it simple, orthogonal state is created when same hierarchical state machine has multiple set of states each having a initial state. Because an individual state machine can only have one initial state, multiple initial states must mean that a specific state must have multiple independent regions.
@Configuration @EnableStateMachine public class Config10 extends EnumStateMachineConfigurerAdapter<States2, Events> { @Override public void configure(StateMachineStateConfigurer<States2, Events> states) throws Exception { states .withStates() .initial(States2.S1) .state(States2.S2) .and() .withStates() .parent(States2.S2) .initial(States2.S2I) .state(States2.S21) .end(States2.S2F) .and() .withStates() .parent(States2.S2) .initial(States2.S3I) .state(States2.S31) .end(States2.S3F); } }
We support three different types of transitions, external
,
internal
and local
. Transitions are either triggered by a signal
which is an event sent into a state machine or a timer.
@Configuration @EnableStateMachine public class Config3 extends EnumStateMachineConfigurerAdapter<States, Events> { @Override public void configure(StateMachineStateConfigurer<States, Events> states) throws Exception { states .withStates() .initial(States.S1) .states(EnumSet.allOf(States.class)); } @Override public void configure(StateMachineTransitionConfigurer<States, Events> transitions) throws Exception { transitions .withExternal() .source(States.S1).target(States.S2) .event(Events.E1) .and() .withInternal() .source(States.S2) .event(Events.E2) .and() .withLocal() .source(States.S2).target(States.S3) .event(Events.E3); } }
Guards are used to protect state transitions. Interface Guard is used to do an evaluation where method has access to a StateContext.
@Configuration @EnableStateMachine public class Config4 extends EnumStateMachineConfigurerAdapter<States, Events> { @Override public void configure(StateMachineTransitionConfigurer<States, Events> transitions) throws Exception { transitions .withExternal() .source(States.S1).target(States.S2) .event(Events.E1) .guard(guard()) .and() .withExternal() .source(States.S2).target(States.S3) .event(Events.E2) .guardExpression("true"); } @Bean public Guard<States, Events> guard() { return new Guard<States, Events>() { @Override public boolean evaluate(StateContext<States, Events> context) { return true; } }; } }
In above two different types of guard configurations are used. Firstly a
simple Guard is created as a bean and attached to transition between
states S1
and S2
.
Secondly a simple SPeL expression can be used as a guard where
expression must return a BOOLEAN
value. Behind a scenes this
expression based guard is a SpelExpressionGuard. This was attached to
transition between states S2
and S3
. Both guard in above sample
always evaluate to true.
Actions can be defined to be executed with transitions and states itself. Action is always executed as a result of a transition which originates from a trigger.
@Configuration @EnableStateMachine public class Config51 extends EnumStateMachineConfigurerAdapter<States, Events> { @Override public void configure(StateMachineTransitionConfigurer<States, Events> transitions) throws Exception { transitions .withExternal() .source(States.S1) .target(States.S2) .event(Events.E1) .action(action()); } @Bean public Action<States, Events> action() { return new Action<States, Events>() { @Override public void execute(StateContext<States, Events> context) { // do something } }; } }
In above a single Action
is defined as bean action
and associated
with a transition from S1
to S2
.
@Configuration @EnableStateMachine public class Config52 extends EnumStateMachineConfigurerAdapter<States, Events> { @Override public void configure(StateMachineStateConfigurer<States, Events> states) throws Exception { states .withStates() .initial(States.S1, action()) .state(States.S1, action(), null) .state(States.S2, null, action()) .state(States.S3, action(), action()); } @Bean public Action<States, Events> action() { return new Action<States, Events>() { @Override public void execute(StateContext<States, Events> context) { // do something } }; } }
Note | |
---|---|
Usually you would not define same |
In above a single Action
is defined as bean action
and associated
with states S1
, S2
and S3
. There is more going on there which
needs more clarification:
S1
.
S1
and left exit action empty.
S2
and left entry action empty.
S3
.
S1
is used twice with initial()
and state()
functions. This is only needed if you want to define entry or exit
actions with initial state.
Important | |
---|---|
Defining action with |
Pseudo state configuration is usually done by configuring states and transitions. Pseudo states are automatically added to state machine as states.
Simply mark a particular state as initial state by using initial()
method. There are two methods where one takes extra argument to define
an initial action. This initial action is good for example initialize
extended state variables.
@Configuration @EnableStateMachine public class Config11 extends EnumStateMachineConfigurerAdapter<States, Events> { @Override public void configure(StateMachineStateConfigurer<States, Events> states) throws Exception { states .withStates() .initial(States.S1, initialAction()) .end(States.SF) .states(EnumSet.allOf(States.class)); } @Bean public Action<States, Events> initialAction() { return new Action<States, Events>() { @Override public void execute(StateContext<States, Events> context) { // do something initially } }; } }
Simply mark a particular state as end state by using end()
method.
This can be done max one time per individual sub-machine or region.
@Configuration @EnableStateMachine public class Config1Enums extends EnumStateMachineConfigurerAdapter<States, Events> { @Override public void configure(StateMachineStateConfigurer<States, Events> states) throws Exception { states .withStates() .initial(States.S1) .end(States.SF) .states(EnumSet.allOf(States.class)); } }
History state can be defined once for each individual state machine.
You need to choose its state identifier and History.SHALLOW
or
History.DEEP
respectively.
@Configuration @EnableStateMachine public class Config12 extends EnumStateMachineConfigurerAdapter<States3, Events> { @Override public void configure(StateMachineStateConfigurer<States3, Events> states) throws Exception { states .withStates() .initial(States3.S1) .state(States3.S2) .and() .withStates() .parent(States3.S2) .initial(States3.S2I) .state(States3.S21) .state(States3.S22) .history(States3.SH, History.SHALLOW); } }
Choice needs to be defined in both states and transitions to work
properly. Mark particular state as choice state by using choice()
method. This state needs to match source state when transition is
configured for this choice.
Transition is configured using withChoice()
where you define source
state and first/then/last
structure which is equivalent to normal
if/elseif/else
. With first
and then
you can specify a guard just
like you’d use a condition with if/elseif
clauses.
Transition needs to be able to exist so make sure last
is used.
Otherwise configuration is ill-formed.
@Configuration @EnableStateMachine public class Config13 extends EnumStateMachineConfigurerAdapter<States, Events> { @Override public void configure(StateMachineStateConfigurer<States, Events> states) throws Exception { states .withStates() .initial(States.SI) .choice(States.S1) .end(States.SF) .states(EnumSet.allOf(States.class)); } @Override public void configure(StateMachineTransitionConfigurer<States, Events> transitions) throws Exception { transitions .withChoice() .source(States.S1) .first(States.S2, s2Guard()) .then(States.S3, s3Guard()) .last(States.S4); } @Bean public Guard<States, Events> s2Guard() { return new Guard<States, Events>() { @Override public boolean evaluate(StateContext<States, Events> context) { return false; } }; } @Bean public Guard<States, Events> s3Guard() { return new Guard<States, Events>() { @Override public boolean evaluate(StateContext<States, Events> context) { return true; } }; } }
Fork needs to be defined in both states and transitions to work
properly. Mark particular state as choice state by using fork()
method. This state needs to match source state when transition is
configured for this fork.
Target state needs to be a super state or immediate states in regions. Using a super state as target will take all regions into initial states. Targeting individual state give more controlled entry into regions.
@Configuration @EnableStateMachine public class Config14 extends EnumStateMachineConfigurerAdapter<States2, Events> { @Override public void configure(StateMachineStateConfigurer<States2, Events> states) throws Exception { states .withStates() .initial(States2.S1) .fork(States2.S2) .state(States2.S3) .and() .withStates() .parent(States2.S3) .initial(States2.S2I) .state(States2.S21) .state(States2.S22) .end(States2.S2F) .and() .withStates() .parent(States2.S3) .initial(States2.S3I) .state(States2.S31) .state(States2.S32) .end(States2.S3F); } @Override public void configure(StateMachineTransitionConfigurer<States2, Events> transitions) throws Exception { transitions .withFork() .source(States2.S2) .target(States2.S22) .target(States2.S32); } }
Join needs to be defined in both states and transitions to work
properly. Mark particular state as choice state by using join()
method. This state doesn’t need to match either source states or
target state in a transition configuration.
Select one target state where transition goes when all source states has been joined. If you use state hosting regions as source, end states of a regions are used as joins. Otherwise you can pick any states from a regions.
@Configuration @EnableStateMachine public class Config15 extends EnumStateMachineConfigurerAdapter<States2, Events> { @Override public void configure(StateMachineStateConfigurer<States2, Events> states) throws Exception { states .withStates() .initial(States2.S1) .state(States2.S3) .join(States2.S4) .and() .withStates() .parent(States2.S3) .initial(States2.S2I) .state(States2.S21) .state(States2.S22) .end(States2.S2F) .and() .withStates() .parent(States2.S3) .initial(States2.S3I) .state(States2.S31) .state(States2.S32) .end(States2.S3F); } @Override public void configure(StateMachineTransitionConfigurer<States2, Events> transitions) throws Exception { transitions .withJoin() .source(States2.S2F) .source(States2.S3F) .target(States2.S5); } }
Some of a common state machine configuration can be set via a
ConfigurationConfigurer
. This allows to set BeanFactory
,
TaskExecutor
, TaskScheduler
, autostart flag for a state machine
and register StateMachineListener
instances.
@Configuration @EnableStateMachine public class Config17 extends EnumStateMachineConfigurerAdapter<States, Events> { @Override public void configure(StateMachineConfigurationConfigurer<States, Events> config) throws Exception { config .withConfiguration() .autoStartup(true) .beanFactory(new StaticListableBeanFactory()) .taskExecutor(new SyncTaskExecutor()) .taskScheduler(new ConcurrentTaskScheduler()) .listener(new StateMachineListenerAdapter<States, Events>()); } }
State machine autoStartup
flag is disabled by default because all
instances handling sub-states are controlled by a state machine itself
and cannot be started automatically. Also it is much safer to leave
this decision to a user whether a machine should be started
automatically or not. This flag will only control an autostart of a
top-level state machine.
Setting a BeanFactory
, TaskExecutor
or TaskScheduler
exist for
conveniance for a user and are also use within a framework itself.
Registering StateMachineListener
instances is also partly for
convenience but is required if user wants to catch callback during a
state machine lifecycle like getting notified of a state machine
start/stop events. Naturally it is not possible to listen a state
machine start events if autoStartup
is enabled unless listener can
be registered during a configuration phase.
DistributedStateMachine
is configured via withDistributed()
which
allows to set a StateMachineEnsemble
which if exists automatically
wraps created StateMachine
with DistributedStateMachine
and
enables distributed mode.
@Configuration @EnableStateMachine public class Config18 extends EnumStateMachineConfigurerAdapter<States, Events> { @Override public void configure(StateMachineConfigurationConfigurer<States, Events> config) throws Exception { config .withDistributed() .ensemble(stateMachineEnsemble()); } @Bean public StateMachineEnsemble<States, Events> stateMachineEnsemble() throws Exception { // naturally not null but should return ensemble instance return null; } }
More about distributed states, refer to section Chapter 23, Using Distributed States.