Introduction
In the projects I met with three examples, one way or another connected with the
theory of finite automata
- Example 1. An entertaining
govnokod code . It takes a lot of time to understand what is happening. A characteristic feature of the embodiment of the indicated theory in the code is a rather fierce dump, which in some places wildly resembles a procedural code. The fact that this version of the code is better not to touch on the project knows every technologist, methodologist and product specialist. They go into this code to fix something in case of emergency (when itās completely broken), thereās no question of finalizing any features. For itās scary to break. The second striking feature that isolates this type is the presence of such powerful switches, full screen.
There is even a joke on this score:
Optimal size On one of the JPoint, one of the speakers, perhaps Nikolai Alimenkov, talked about how many cases in the switch are normal, said that the top answer is "so far fits into the screen." Accordingly, if you interfere and your switch is already not normal, take and reduce the font size in the IDE
- Example 2. Pattern State . The main idea (for those who do not like to follow links) is that we break down a certain business task into a set of final states and describe them with code.
The main drawback of Pattern State is that the states know about each other, they know that there are brothers and call each other. Such code is quite difficult to make universal. For example, when implementing a payment system with several types of payments, you run the risk of digging into Generic-s so much that the declaration of your methods may become something like this:
private < T extends BaseContextPayment, Q extends BaseDomainPaymentRequest, S, B extends AbstractPaymentDetailBuilder<T, Q, S, B>, F extends AbstractPaymentBuilder<T, Q, S, B> > PaymentContext<T, S> build(final Q request, final Class<F> factoryClass){
Summarizing State: an implementation can result in rather complicated code. - Example 3 StateMachine The main idea of āāthe Pattern is that states do not know anything about each other, transition control is carried out by the context, itās better, less connectivity - the code is simpler.
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 { boolean reserved(String userId, String productId); boolean cancelReserve(String userId); 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:
- 1. Reservation and subsequent rejection of the purchase. We will send the application a request for the URI "/ reserve", with the parameters userId = 007, productId = 10001, and after it the request "/ cancel" with the parameter userId = 007 the console output will be as follows:
Machine started
10001 .
NEW RESERVED
Machine started
10001
RESERVED CANCEL_RESERVED
- 2. Reservation and successful purchase:
Machine started
10001 .
NEW RESERVED
Machine started
10001
RESERVED PURCHASE_COMPLETE
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