Preliminary support for Spring Data persist

- Add new repository model for storing StateMachineContext
  via a new StateMachineRepository.
- New StateMachineRuntimePersister interface to abstract needed
  functionality to do a runtime machine persistence.
- As runtime persistence, as of now, is done via interceptors, define
  JpaRepositoryStateMachinePersist and JpaPersistingStateMachineInterceptor
  to define StateMachineRuntimePersister logic.
- Add new datajpapersist sample demonstrating new concepts.
- Keep tests related to jpa as there's not redis/mongo integration
  implemented in this first iteration.
- As this is going to be WIP until features around this issues
  are completed, docs, etc are not yet added. Also, interfaces and impls
  are subject to change during a process.
- Relates to #423
- Relates to #426
- Relates to #427
This commit is contained in:
Janne Valkealahti
2017-11-18 16:38:49 +00:00
parent d82bca1280
commit 33a5370d01
29 changed files with 1442 additions and 13 deletions

View File

@@ -121,6 +121,18 @@ project('spring-statemachine-samples-datajpa') {
}
}
project('spring-statemachine-samples-datajpapersist') {
description = 'Spring State Machine Data Jpa Persist Sample'
dependencies {
compile project(":spring-statemachine-boot")
compile project(":spring-statemachine-data-common:spring-statemachine-data-jpa")
compile("org.springframework.boot:spring-boot-starter-thymeleaf:$springBootVersion")
compile("org.springframework.boot:spring-boot-starter-data-jpa:$springBootVersion")
compile("org.springframework.boot:spring-boot-devtools:$springBootVersion")
compile("com.h2database:h2:$h2Version")
}
}
project('spring-statemachine-samples-monitoring') {
description = 'Spring State Machine Monitoring Sample'
dependencies {

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package demo.datajpapersist;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
//tag::snippetA[]
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
//end::snippetA[]

View File

@@ -0,0 +1,105 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package demo.datajpapersist;
import java.util.EnumSet;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.statemachine.config.EnableStateMachineFactory;
import org.springframework.statemachine.config.StateMachineConfigurerAdapter;
import org.springframework.statemachine.config.builders.StateMachineConfigurationConfigurer;
import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;
import org.springframework.statemachine.data.jpa.JpaPersistingStateMachineInterceptor;
import org.springframework.statemachine.data.jpa.JpaStateMachineRepository;
import org.springframework.statemachine.persist.StateMachineRuntimePersister;
@Configuration
public class StateMachineConfig {
//tag::snippetA[]
@Configuration
@EnableStateMachineFactory
public static class Config extends StateMachineConfigurerAdapter<States, Events> {
@Autowired
private JpaStateMachineRepository jpaStateMachineRepository;
@Override
public void configure(StateMachineConfigurationConfigurer<States, Events> config)
throws Exception {
config
.withPersistence()
.runtimePersister(stateMachineruntimePersister());
}
@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()
.withExternal()
.source(States.S2).target(States.S3)
.event(Events.E2)
.and()
.withExternal()
.source(States.S3).target(States.S4)
.event(Events.E3)
.and()
.withExternal()
.source(States.S4).target(States.S5)
.event(Events.E4)
.and()
.withExternal()
.source(States.S5).target(States.S6)
.event(Events.E5)
.and()
.withExternal()
.source(States.S6).target(States.S1)
.event(Events.E6);
}
@Bean
public StateMachineRuntimePersister<States, Events> stateMachineruntimePersister() {
return new JpaPersistingStateMachineInterceptor<>(jpaStateMachineRepository);
}
}
//end::snippetA[]
//tag::snippetB[]
public enum States {
S1, S2, S3, S4, S5, S6;
}
public enum Events {
E1, E2, E3, E4, E5, E6;
}
//end::snippetB[]
}

View File

@@ -0,0 +1,128 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package demo.datajpapersist;
import java.util.EnumSet;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.statemachine.StateMachine;
import org.springframework.statemachine.StateMachineContext;
import org.springframework.statemachine.StateMachinePersist;
import org.springframework.statemachine.access.StateMachineAccess;
import org.springframework.statemachine.access.StateMachineFunction;
import org.springframework.statemachine.config.StateMachineFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.ObjectUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import demo.datajpapersist.StateMachineConfig.Events;
import demo.datajpapersist.StateMachineConfig.States;
@Controller
public class StateMachineController {
public final static String MACHINE_ID_1 = "datajpapersist1";
public final static String MACHINE_ID_2 = "datajpapersist2";
private final static String[] MACHINES = new String[] { MACHINE_ID_1, MACHINE_ID_2 };
@Autowired
private StateMachineFactory<States, Events> stateMachineFactory;
@Autowired
private StateMachinePersist<States, Events, String> stateMachinePersist;
private StateMachine<States, Events> cachedStateMachine;
private final StateMachineLogListener listener = new StateMachineLogListener();
@RequestMapping("/")
public String home() {
return "redirect:/state";
}
@RequestMapping("/state")
public String feedAndGetStates(
@RequestParam(value = "events", required = false) List<Events> events,
@RequestParam(value = "machine", required = false, defaultValue = MACHINE_ID_1) String machine,
Model model) throws Exception {
StateMachine<States, Events> stateMachine = getStateMachine(machine);
if (events != null) {
for (Events event : events) {
stateMachine.sendEvent(event);
}
}
StateMachineContext<States, Events> stateMachineContext = stateMachinePersist.read(machine);
model.addAttribute("allMachines", MACHINES);
model.addAttribute("machine", machine);
model.addAttribute("allEvents", getEvents());
model.addAttribute("messages", createMessages(listener.getMessages()));
model.addAttribute("context", stateMachineContext != null ? stateMachineContext.toString() : "");
return "states";
}
private synchronized StateMachine<States, Events> getStateMachine(String machineId) throws Exception {
if (cachedStateMachine == null) {
cachedStateMachine = buildStateMachine(machineId);
cachedStateMachine.start();
} else {
if (!ObjectUtils.nullSafeEquals(cachedStateMachine.getId(), machineId)) {
cachedStateMachine.stop();
cachedStateMachine = buildStateMachine(machineId);
cachedStateMachine.start();
}
}
return cachedStateMachine;
}
private StateMachine<States, Events> buildStateMachine(String machineId) throws Exception {
StateMachine<States, Events> stateMachine = stateMachineFactory.getStateMachine(machineId);
stateMachine.addStateListener(listener);
listener.resetMessages();
return restoreStateMachine(stateMachine, stateMachinePersist.read(machineId));
}
private StateMachine<States, Events> restoreStateMachine(StateMachine<States, Events> stateMachine,
StateMachineContext<States, Events> stateMachineContext) {
if (stateMachineContext == null) {
return stateMachine;
}
stateMachine.stop();
stateMachine.getStateMachineAccessor().doWithAllRegions(new StateMachineFunction<StateMachineAccess<States, Events>>() {
@Override
public void apply(StateMachineAccess<States, Events> function) {
function.resetStateMachine(stateMachineContext);
}
});
return stateMachine;
}
private Events[] getEvents() {
return EnumSet.allOf(Events.class).toArray(new Events[0]);
}
private String createMessages(List<String> messages) {
StringBuilder buf = new StringBuilder();
for (String message : messages) {
buf.append(message);
buf.append("\n");
}
return buf.toString();
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package demo.datajpapersist;
import java.util.LinkedList;
import java.util.List;
import org.springframework.statemachine.StateContext;
import org.springframework.statemachine.StateContext.Stage;
import org.springframework.statemachine.listener.StateMachineListenerAdapter;
import demo.datajpapersist.StateMachineConfig.Events;
import demo.datajpapersist.StateMachineConfig.States;
public class StateMachineLogListener extends StateMachineListenerAdapter<States, Events> {
private final LinkedList<String> messages = new LinkedList<String>();
public List<String> getMessages() {
return messages;
}
public void resetMessages() {
messages.clear();
}
@Override
public void stateContext(StateContext<States, Events> stateContext) {
if (stateContext.getStage() == Stage.STATE_ENTRY) {
messages.addFirst("Enter " + stateContext.getTarget().getId());
} else if (stateContext.getStage() == Stage.STATE_EXIT) {
messages.addFirst("Exit " + stateContext.getSource().getId());
} else if (stateContext.getStage() == Stage.STATEMACHINE_START) {
messages.addLast("Machine started");
} else if (stateContext.getStage() == Stage.STATEMACHINE_STOP) {
messages.addFirst("Machine stopped");
}
}
}

View File

@@ -0,0 +1,6 @@
logging:
level:
root: INFO
security:
basic:
enabled: false

View File

@@ -0,0 +1,45 @@
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Spring Statemachine Demo</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<div>
<a href="/h2-console" target="_blank">h2 console</a>
</div>
<form action="#" data-th-action="@{/state}" data-th-object="${model}" method="post">
<div>
<p th:text="'Choose machine'"/>
<ul>
<li th:each="ty : ${allMachines}">
<input type="radio" name="machine" th:value="${ty}" th:checked="${ty} == ${machine}"/>
<label th:text="${ty}">replaced</label>
</li>
</ul>
</div>
<div>
<p th:text="'Choose events'"/>
<ul>
<li th:each="ty : ${allEvents}">
<input type="checkbox" name="events" th:value="${ty}" />
<label th:text="${ty}">replaced</label>
</li>
</ul>
</div>
<button type="submit">Send Events</button>
</form>
<div>
<h3>Events</h3>
</div>
<div>
<textarea th:text="${messages}" rows="15" cols="100"/>
</div>
<div>
<h3>StateMachineContext</h3>
</div>
<div>
<textarea th:text="${context}" rows="15" cols="100"/>
</div>
</body>
</html>

View File

@@ -0,0 +1,107 @@
/*
* Copyright 2017 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package demo.datajpapersist;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.annotation.DirtiesContext.ClassMode;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import demo.datajpapersist.Application;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = { Application.class })
@WebAppConfiguration
@DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD)
public class DataJpaPersistTests {
private MockMvc mvc;
@Autowired
private WebApplicationContext context;
@Test
public void testHome() throws Exception {
mvc.
perform(get("/state")).
andExpect(status().isOk());
}
@Test
public void testSendEventE1() throws Exception {
mvc.
perform(get("/state").param("events", "E1")).
andExpect(status().isOk()).
andExpect(content().string(containsString("Exit S1")));
}
@Test
public void testSendEventsE1E2() throws Exception {
mvc.
perform(get("/state").param("events", "E1").param("events", "E2")).
andExpect(status().isOk()).
andExpect(content().string(allOf(
containsString("Exit S1"),
containsString("Exit S2"))));
}
@Test
public void testSendEventE4() throws Exception {
mvc.
perform(get("/state").param("events", "E1").param("machine", StateMachineController.MACHINE_ID_2)).
andExpect(status().isOk()).
andExpect(content().string(containsString("Exit S1")));
}
@Test
public void testChangeMachineRestores() throws Exception {
mvc.
perform(get("/state").param("events", "E1")).
andExpect(status().isOk()).
andExpect(content().string(containsString("Exit S1")));
mvc.
perform(get("/state").param("events", "E1").param("machine", StateMachineController.MACHINE_ID_2)).
andExpect(status().isOk()).
andExpect(content().string(containsString("Exit S1")));
mvc.
perform(get("/state").param("events", "E2").param("machine", StateMachineController.MACHINE_ID_1)).
andExpect(status().isOk()).
andExpect(content().string(containsString("Exit S2")));
mvc.
perform(get("/state").param("events", "E2").param("machine", StateMachineController.MACHINE_ID_2)).
andExpect(status().isOk()).
andExpect(content().string(containsString("Exit S2")));
}
@Before
public void setup() throws Exception {
mvc = MockMvcBuilders.webAppContextSetup(context).build();
}
}