Start Spring StateMachine

Introduction



In the projects I met with three examples, one way or another connected with the theory of finite automata





Having experienced all the ā€œpowerā€ of the first type and all the complexity of the second, we decided to use Pattern StateMachine for the new business case.

In order not to reinvent his bicycle, it was decided to take Statemachine Spring as the basis (this is Spring).



After reading the docks, I went to YouTube and Habr (to understand how people work with it, how it feels on the prod, what kind of rake, etc.) It turned out that there is little information, on YouTube there are a couple of videos, all are pretty superficial. On HabrƩ on this subject I found only one article, as well as the video, quite superficial.

In one article, itā€™s impossible to describe all the subtleties of Spring statemachineā€™s work, to go around the dock and describe all the cases, but Iā€™ll try to tell the most important and demanded, and about rake, specifically to me, when I became acquainted with the framework, the information described below was would be very helpful.



Main part



We will create a Spring Boot application and add a Web starter (we will get a web application running as fast as possible). The application will be an abstraction of the purchase process. The product at purchase will go through the stages of new, reserved, reserved decline and purchase complete.

A little improvisation, there would be more statuses in a real project, but oh well, we also have a very real project.

In the pom.xml of the newly baked web application, add the dependency to the machine and to the tests for it (Web Starter should already be, if collected via start.spring.io ):

 <dependency> <groupId>org.springframework.statemachine</groupId> <artifactId>spring-statemachine-core</artifactId> <version>2.1.3.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.statemachine</groupId> <artifactId>spring-statemachine-test</artifactId> <version>2.1.3.RELEASE</version> <scope>test</scope> </dependency> <cut />
      
      





Create the structure:



I donā€™t have to go into details of this structure yet, Iā€™ll explain everything sequentially, and there will be a link to the source at the end of the article.



So let's go.

We have a clean project with the necessary dependencies, for a start we will create enum, with states and events, a rather simple abstraction, these components themselves do not carry any logic.

 public enum PurchaseEvent { RESERVE, BUY, RESERVE_DECLINE }
      
      





 public enum PurchaseState { NEW, RESERVED, CANCEL_RESERVED, PURCHASE_COMPLETE }
      
      





Although formally, you can add fields to these enum, and hardcode something in them that is typical of, for example, a particular state, which is quite logical (we did this by solving our case, quite conveniently).

We will configure the machine through the java config, create the config file and, for the extends class EnumStateMachineConfigurerAdapter <PurchaseState, PurchaseEvent>. Since our state and event are enum, the interface is appropriate, but it is not necessary, any type of object can be used as generic (we will not consider other examples in the article, since EnumStateMachineConfigurerAdapter is more than enough in my opinion).



The next important point is whether one machine will live in the context of the application: in a single instance of @EnableStateMachine, or each time a new @EnableStateMachineFactory will be created. If this is a multi-user web application with a bunch of users, then the first option is hardly suitable for you, so we will use the second one as the more popular one. StateMachine can also be created via builder as a regular bean (see the documentation), which is convenient in some cases (for example, you need the machine to be explicitly declared as a bean), and if it is a separate bean, then we can tell it our scope e.g. session or request. In our project, wrapper was implemented over the statemachine bean (features of our business logic), wrapper was singleton, and the prototype machine itself

Rake
How to implement prototype in singlton?

Essentially, all you need to do is get a new bean from the applicationContext each time you access the object. It is a sin to inject applicationContext into business logic, therefore, a bean statemachine must either implement an interface with at least one method, or an abstract method (method injection), when creating a java config, it will be necessary to implement the indicated abstract method, and in the implementation we will pull from applicationContext new bean. It is normal practice to have a link to the applicationContext in the config class, and through the abstract method we will call .getBean ();



The EnumStateMachineConfigurerAdapter class has several methods, overriding which we configure the machine.

To begin, register the statuses:

  @Override public void configure(final StateMachineStateConfigurer<PurchaseState, PurchaseEvent> states) throws Exception { states .withStates() .initial(NEW) .end(PURCHASE_COMPLETE) .states(EnumSet.allOf(PurchaseState.class)); }
      
      





initial (NEW) is the status the machine will be in after the bean is created, end (PURCHASE_COMPLETE) is the status by going to which the machine will execute statemachine.stop (), for a non-deterministic machine (most of which) is irrelevant, but something needs to be specified . .states (EnumSet.allOf (PurchaseState.class) list of all statuses, you can shove in bulk.



Configure global machine settings

  @Override public void configure(final StateMachineConfigurationConfigurer<PurchaseState, PurchaseEvent> config) throws Exception { config .withConfiguration() .autoStartup(true) .listener(new PurchaseStateMachineApplicationListener()); }
      
      





Here autoStartup determines whether the machine will be started immediately after creation by default, in other words - whether it will automatically switch to the NEW status (false by default). Here we register the listener for the machine context (about it a little later), in the same config you can set a separate TaskExecutor, which is convenient when a long Action is performed on some of their transitions, and the application should go further.

Well, the transitions themselves:

  @Override public void configure(final StateMachineTransitionConfigurer<PurchaseState, PurchaseEvent> transitions) throws Exception { transitions .withExternal() .source(NEW) .target(RESERVED) .event(RESERVE) .action(reservedAction(), errorAction()) .and() .withExternal() .source(RESERVED) .target(CANCEL_RESERVED) .event(RESERVE_DECLINE) .action(cancelAction(), errorAction()) .and() .withExternal() .source(RESERVED) .target(PURCHASE_COMPLETE) .event(BUY) .guard(hideGuard()) .action(buyAction(), errorAction()); }
      
      





All logic of transitions or transitions is set here, Guard can be hung on transitions, a component that always returns boolean, what exactly you will check on the transition from one status to another at your discretion, any logic can be perfect in Guard, this is a completely ordinary component but he must return boolean. Within the framework of our project, for example, HideGuard can check a certain setting that the user could set (do not show this product) and, in accordance with it, not let the machine into the state protected by Guard. I note that Guard, only one can be added to one transition in the config, such a design will not work:

  .withExternal() .source(RESERVED) .target(PURCHASE_COMPLETE) .event(BUY) .guard(hideGuard()) .guard(veryHideGuard())
      
      





More precisely it will work, but only the first guard (hideGuard ())

But you can add several Actions (now we are talking about Action, which we prescribe in the transitions configuration), I personally tried to add three Actions to one transition.

  .withExternal() .source(NEW) .target(RESERVED) .event(RESERVE) .action(reservedAction(), errorAction())
      
      





the second argument is ErrorAction, control will get to it if ReservedAction throws an exception (throw ).

Rake
Keep in mind that if in your Action you still handle the error via try / catch, then you wonā€™t go into ErrorAction, if you need to process and go into ErrorAction then you should throw RuntimeException () from catch, for example (you yourself said that it is very necessary).



In addition to ā€œhangingā€ Action in transitions, you can also ā€œhangingā€ them in the configure method for state, something like this:

  @Override public void configure(final StateMachineStateConfigurer<PurchaseState, PurchaseEvent> states) throws Exception { states .withStates() .initial(NEW) .end(PURCHASE_COMPLETE) .stateEntry() .stateExit() .state() .states(EnumSet.allOf(PurchaseState.class)); }
      
      





It all depends on how you want to run the action.

Rake
Note that if you specify action when configuring state (), like so

  states .withStates() .initial(NEW) .end(PURCHASE_COMPLETE) .state(randomAction())
      
      





it will be executed asynchronously, it is assumed that if you say .stateEntry (), for example, the Action should be executed directly at the entrance, but if you say .state () then the Action should be executed in the target state, but it is not so important when.

In our project, we configured all the Actions on the transition config, since you can hang them several at a time.



The final version of the config will look like this:

 @Configuration @EnableStateMachineFactory public class StateMachineConfig extends EnumStateMachineConfigurerAdapter<PurchaseState, PurchaseEvent> { @Override public void configure(final StateMachineConfigurationConfigurer<PurchaseState, PurchaseEvent> config) throws Exception { config .withConfiguration() .autoStartup(true) .listener(new PurchaseStateMachineApplicationListener()); } @Override public void configure(final StateMachineStateConfigurer<PurchaseState, PurchaseEvent> states) throws Exception { states .withStates() .initial(NEW) .end(PURCHASE_COMPLETE) .stateEntry() .stateExit() .state() .states(EnumSet.allOf(PurchaseState.class)); } @Override public void configure(final StateMachineTransitionConfigurer<PurchaseState, PurchaseEvent> transitions) throws Exception { transitions .withExternal() .source(NEW) .target(RESERVED) .event(RESERVE) .action(reservedAction(), errorAction()) .and() .withExternal() .source(RESERVED) .target(CANCEL_RESERVED) .event(RESERVE_DECLINE) .action(cancelAction(), errorAction()) .and() .withExternal() .source(RESERVED) .target(PURCHASE_COMPLETE) .event(BUY) .guard(hideGuard()) .action(buyAction(), errorAction()); } @Bean public Action<PurchaseState, PurchaseEvent> reservedAction() { return new ReservedAction(); } @Bean public Action<PurchaseState, PurchaseEvent> cancelAction() { return new CancelAction(); } @Bean public Action<PurchaseState, PurchaseEvent> buyAction() { return new BuyAction(); } @Bean public Action<PurchaseState, PurchaseEvent> errorAction() { return new ErrorAction(); } @Bean public Guard<PurchaseState, PurchaseEvent> hideGuard() { return new HideGuard(); } @Bean public StateMachinePersister<PurchaseState, PurchaseEvent, String> persister() { return new DefaultStateMachinePersister<>(new PurchaseStateMachinePersister()); }
      
      





Pay attention to the scheme of the machine, it is very clearly visible on what exactly we encoded (which transitions on which events are valid, which Guard protects the status and what will be performed when the status is switched, which Action).



Let's make the controller:

 @RestController @SuppressWarnings("unused") public class PurchaseController { private final PurchaseService purchaseService; public PurchaseController(PurchaseService purchaseService) { this.purchaseService = purchaseService; } @RequestMapping(path = "/reserve") public boolean reserve(final String userId, final String productId) { return purchaseService.reserved(userId, productId); } @RequestMapping(path = "/cancel") public boolean cancelReserve(final String userId) { return purchaseService.cancelReserve(userId); } @RequestMapping(path = "/buy") public boolean buyReserve(final String userId) { return purchaseService.buy(userId); } }
      
      







service interface

 public interface PurchaseService { /** *    ,          * * @param userId id ,    ,      id  *    http- * @param productId id ,     * @return /  ,             *      . */ boolean reserved(String userId, String productId); /** *   /    * * @param userId id ,    ,      id  *    http- * @return /  ,             *      . */ boolean cancelReserve(String userId); /** *     * * @param userId id ,    ,      id  *    http- * @return /  ,             *      . */ boolean buy(String userId); }
      
      





Rake
Do you know why it is important to create bean through the interface when working with Spring? Faced this problem (well, yes, yes, and Zhenya Borisov spoke in the ripper), when once in the controller they tried to implement an improvised non-empty interface. Spring creates a proxy for components, and if a component does not implement any interface, then it will do it through CGLIB, but as soon as you implement some interface - Spring will try to create a proxy through a dynamic proxy, as a result you will get an incomprehensible object type and NoSuchBeanDefinitionException .



The next important point is how you will restore the state of your machine, because for each call a new bean will be created that does not know anything about your previous statuses of the machine and its context.

For these purposes, spring statemachine has a Persistens mechanism:

 public class PurchaseStateMachinePersister implements StateMachinePersist<PurchaseState, PurchaseEvent, String> { private final HashMap<String, StateMachineContext<PurchaseState, PurchaseEvent>> contexts = new HashMap<>(); @Override public void write(final StateMachineContext<PurchaseState, PurchaseEvent> context, String contextObj) { contexts.put(contextObj, context); } @Override public StateMachineContext<PurchaseState, PurchaseEvent> read(final String contextObj) { return contexts.get(contextObj); } }
      
      





For our naive implementation, we use the usual Map as a state store, in a non-naive implementation it will be some kind of database, pay attention to the third generic type String, this is the key by which the state of your machine will be saved, with all statuses, variables in the context, id etc. In my example, I used the user id for the save key, which can be absolutely any key (user session_id, unique login, etc.).

Rake
In our project, the mechanism for saving and restoring states from the box did not suit us, since we stored the statuses of the machine in the database and could be changed by a job that knew nothing about the machine.

I had to fasten on the status received from the database, do some InitAction which, when the machine starts, received the status from the database, and set it forcibly, and only then threw event, an example of code that fulfills the above:

 stateMachine .getStateMachineAccessor() .doWithAllRegions(access -> { access.resetStateMachine(new DefaultStateMachineContext<>({ResetState}, null, null, null, null)); }); stateMachine.start(); stateMachine.sendEvent({NewEventFromResetState});
      
      







We will consider the implementation of the service in each method:

  @Override public boolean reserved(final String userId, final String productId) { final StateMachine<PurchaseState, PurchaseEvent> stateMachine = stateMachineFactory.getStateMachine(); stateMachine.getExtendedState().getVariables().put("PRODUCT_ID", productId); stateMachine.sendEvent(RESERVE); try { persister.persist(stateMachine, userId); } catch (final Exception e) { e.printStackTrace(); return false; } return true; }
      
      





We get the car from the factory, put the parameter in the machine context, in our case it is a productId, the context is a kind of box into which you can put everything you need, wherever there is access to the statemachine bean or its context, since the machine starts automatically when the context starts , then after the start, our car will be in the NEW status, throw the event on the reservation of goods.



The remaining two methods are similar:

  @Override public boolean cancelReserve(final String userId) { final StateMachine<PurchaseState, PurchaseEvent> stateMachine = stateMachineFactory.getStateMachine(); try { persister.restore(stateMachine, userId); stateMachine.sendEvent(RESERVE_DECLINE); } catch (Exception e) { e.printStackTrace(); return false; } return true; } @Override public boolean buy(final String userId) { final StateMachine<PurchaseState, PurchaseEvent> stateMachine = stateMachineFactory.getStateMachine(); try { persister.restore(stateMachine, userId); stateMachine.sendEvent(BUY); } catch (Exception e) { e.printStackTrace(); return false; } return true; }
      
      





Here we first restore the state of the machine for the userId of a particular user, and then throw an event that corresponds to the api method.

Note that productId doesnā€™t appear in the method anymore, we added it to the machineā€™s context and will get it after restoring the machine from its backup.

In the Action implementation, we will get the product id from the machine context and display a message corresponding to the transition in the log, for example, I will give the code ReservedAction:

 public class ReservedAction implements Action<PurchaseState, PurchaseEvent> { @Override public void execute(StateContext<PurchaseState, PurchaseEvent> context) { final String productId = context.getExtendedState().get("PRODUCT_ID", String.class); System.out.println("   " + productId + " ."); } }
      
      





We cannot but mention the listener, which out of the box offers quite a few scripts that you can hang on, see for yourself:

 public class PurchaseStateMachineApplicationListener implements StateMachineListener<PurchaseState, PurchaseEvent> { @Override public void stateChanged(State<PurchaseState, PurchaseEvent> from, State<PurchaseState, PurchaseEvent> to) { if (from.getId() != null) { System.out.println("   " + from.getId() + "   " + to.getId()); } } @Override public void stateEntered(State<PurchaseState, PurchaseEvent> state) { } @Override public void stateExited(State<PurchaseState, PurchaseEvent> state) { } @Override public void eventNotAccepted(Message<PurchaseEvent> event) { System.out.println("   " + event); } @Override public void transition(Transition<PurchaseState, PurchaseEvent> transition) { } @Override public void transitionStarted(Transition<PurchaseState, PurchaseEvent> transition) { } @Override public void transitionEnded(Transition<PurchaseState, PurchaseEvent> transition) { } @Override public void stateMachineStarted(StateMachine<PurchaseState, PurchaseEvent> stateMachine) { System.out.println("Machine started"); } @Override public void stateMachineStopped(StateMachine<PurchaseState, PurchaseEvent> stateMachine) { } @Override public void stateMachineError(StateMachine<PurchaseState, PurchaseEvent> stateMachine, Exception exception) { } @Override public void extendedStateChanged(Object key, Object value) { } @Override public void stateContext(StateContext<PurchaseState, PurchaseEvent> stateContext) { } }
      
      





The only problem is that this is an interface, which means that you need to implement all these methods, but since youā€™re unlikely to need them all, some of them will hang empty, which coverage will say that the methods are not covered by tests.

Here in lisener we can hang absolutely any metrics on completely different events of the machine (for example, payments do not go through, the machine often goes into some kind of PAYMENT_FAIL status, we listen to the transitions, and if the machine went into an erroneous status - we write, in the separate log or base or call the police, whatever).

Rake
There is an event stateMachineError in lisener-e, but with a nuance, when you have an exception and you handle it in catch, the machine does not consider that there was an error, you need to speak explicitly in catch

stateMachine.setStateMachineError (exception) and pass an error.



As a check of what we have done, we will execute two cases:



Conclusion



In conclusion, I will give an example of testing the framework, I think everything will become clear from the code, you just need a dependency on the test machine, and you can check the configuration declaratively.

  @Test public void testWhenReservedCancel() throws Exception { StateMachine<PurchaseState, PurchaseEvent> machine = factory.getStateMachine(); StateMachineTestPlan<PurchaseState, PurchaseEvent> plan = StateMachineTestPlanBuilder.<PurchaseState, PurchaseEvent>builder() .defaultAwaitTime(2) .stateMachine(machine) .step() .expectStates(NEW) .expectStateChanged(0) .and() .step() .sendEvent(RESERVE) .expectState(RESERVED) .expectStateChanged(1) .and() .step() .sendEvent(RESERVE_DECLINE) .expectState(CANCEL_RESERVED) .expectStateChanged(1) .and() .build(); plan.test(); } @Test public void testWhenPurchaseComplete() throws Exception { StateMachine<PurchaseState, PurchaseEvent> machine = factory.getStateMachine(); StateMachineTestPlan<PurchaseState, PurchaseEvent> plan = StateMachineTestPlanBuilder.<PurchaseState, PurchaseEvent>builder() .defaultAwaitTime(2) .stateMachine(machine) .step() .expectStates(NEW) .expectStateChanged(0) .and() .step() .sendEvent(RESERVE) .expectState(RESERVED) .expectStateChanged(1) .and() .step() .sendEvent(BUY) .expectState(PURCHASE_COMPLETE) .expectStateChanged(1) .and() .build(); plan.test(); }
      
      





Rake
If you suddenly want to test your machine without raising the context with the usual unit tests, you can create a machine through builder (partially discussed above), create an instance of the class with a config and get action and guard from there, it will work without context, you can write a small test The framework is on mock-ahs, it will be a plus in it to check which Actions were called, which are not, how many times, etc., on different cases.



PS



Our car is working on a productive basis, so far we have not encountered any operational problems, a feature is coming in which we can use the vast majority of the components of the current machine when implementing a new one (Guard and some Actions are just perfect)



Note



I didnā€™t consider it in the article, but I want to mention such opportunities as choice, this is a kind of trigger that works according to the switch principle, where Guards are hung on cases, and the machine alternately tries to go to that state, which is described in the choice config and where Guard will let it go, without some Events, itā€™s convenient when, when initializing the machine, we need to automatically switch to some kind of pseudo-host.



References



Doca

Sources



All Articles