Add redis persist sample

- New sample app eventservice which is used to
  demonstrate how redis is used as a repository
  for StateMachineContext.
- Uses pooled request scope machine instances so
  that with every request a machine is reseted from
  a redis.
- Provides UI to play around.
- Provides rest endpoint to send 'pageview' events
  for processing. For example using curl command like:
  curl http://localhost:8080/feed -H "Content-Type: application/json" --data '{"user":"joe","id":"VIEW_I"}'
- Fixes #160
This commit is contained in:
Janne Valkealahti
2016-01-26 13:11:17 +00:00
parent a952714857
commit 294430c9fb
13 changed files with 638 additions and 1 deletions

View File

@@ -226,7 +226,8 @@ configure(sampleProjects()) {
configurations.archives.artifacts.removeAll { it.archiveTask.is jar }
tasks.findByPath("artifactoryPublish")?.enabled = false
dependencies {
compile project(":spring-statemachine-samples-common")
// compile project(":spring-statemachine-samples-common")
compile project(":spring-statemachine-core")
compile "org.springframework:spring-context-support:$springVersion"
testCompile "org.springframework:spring-test:$springVersion"
testCompile "org.hamcrest:hamcrest-core:$hamcrestVersion"

View File

@@ -19,6 +19,7 @@ include 'spring-statemachine-samples:persist'
include 'spring-statemachine-samples:web'
include 'spring-statemachine-samples:scope'
include 'spring-statemachine-samples:security'
include 'spring-statemachine-samples:eventservice'
rootProject.children.find {
if (it.name == 'spring-statemachine-recipes') {

View File

@@ -132,6 +132,11 @@ public class ObservableMap<K, V> implements Map<K, V> {
return delegate.entrySet();
}
@Override
public String toString() {
return delegate.toString();
}
/**
* Gets the delegating map instance.
*

View File

@@ -2,27 +2,43 @@ description = 'Spring State Machine Samples Common'
project('spring-statemachine-samples-turnstile') {
description = 'Spring State Machine Turnstile Sample'
dependencies {
compile project(":spring-statemachine-samples-common")
}
}
project('spring-statemachine-samples-showcase') {
description = 'Spring State Machine Showcase Sample'
dependencies {
compile project(":spring-statemachine-samples-common")
}
}
project('spring-statemachine-samples-cdplayer') {
description = 'Spring State Machine CD Player Sample'
dependencies {
compile project(":spring-statemachine-samples-common")
}
}
project('spring-statemachine-samples-tasks') {
description = 'Spring State Machine Parallel Regions Sample'
dependencies {
compile project(":spring-statemachine-samples-common")
}
}
project('spring-statemachine-samples-washer') {
description = 'Spring State Machine History State Sample'
dependencies {
compile project(":spring-statemachine-samples-common")
}
}
project('spring-statemachine-samples-zookeeper') {
description = 'Spring State Machine Distributed Sample'
dependencies {
compile project(":spring-statemachine-samples-common")
compile project(":spring-statemachine-zookeeper")
}
}
@@ -30,6 +46,7 @@ project('spring-statemachine-samples-zookeeper') {
project('spring-statemachine-samples-persist') {
description = 'Spring State Machine Persist Sample'
dependencies {
compile project(":spring-statemachine-samples-common")
compile project(":spring-statemachine-recipes-common")
compile ("org.hsqldb:hsqldb:2.3.1")
compile ("org.springframework:spring-jdbc:$springVersion")
@@ -66,3 +83,13 @@ project('spring-statemachine-samples-security') {
compile("org.springframework.security:spring-security-web:$springSecurityVersion")
}
}
project('spring-statemachine-samples-eventservice') {
description = 'Spring State Machine Event Service Sample'
dependencies {
compile project(":spring-statemachine-redis")
compile("org.springframework.boot:spring-boot-starter-thymeleaf:$springBootVersion")
// compile("org.springframework.boot:spring-boot-starter-redis:$springBootVersion")
compile("org.apache.commons:commons-pool2:2.4.2")
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright 2016 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.eventservice;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2016 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.eventservice;
import demo.eventservice.StateMachineConfig.Events;
public class Pageview {
private String user;
private Events id;
public String getUser() {
return user;
}
public void setUser(String user) {
this.user = user;
}
public Events getId() {
return id;
}
public void setId(Events id) {
this.id = id;
}
@Override
public String toString() {
return "Pageview [user=" + user + ", id=" + id + "]";
}
}

View File

@@ -0,0 +1,235 @@
/*
* Copyright 2016 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.eventservice;
import java.io.IOException;
import java.io.InputStream;
import java.util.EnumSet;
import java.util.Scanner;
import org.springframework.aop.framework.ProxyFactoryBean;
import org.springframework.aop.target.CommonsPool2TargetSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.statemachine.StateContext;
import org.springframework.statemachine.StateMachine;
import org.springframework.statemachine.StateMachinePersist;
import org.springframework.statemachine.action.Action;
import org.springframework.statemachine.config.StateMachineBuilder;
import org.springframework.statemachine.config.StateMachineBuilder.Builder;
import org.springframework.statemachine.redis.RedisStateMachineContextRepository;
import org.springframework.statemachine.support.RepositoryStateMachinePersist;
@Configuration
public class StateMachineConfig {
@Bean
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public ProxyFactoryBean stateMachine() {
ProxyFactoryBean pfb = new ProxyFactoryBean();
pfb.setTargetSource(poolTargetSource());
return pfb;
}
@Bean
public CommonsPool2TargetSource poolTargetSource() {
CommonsPool2TargetSource pool = new CommonsPool2TargetSource();
pool.setMaxSize(3);
pool.setTargetBeanName("stateMachineTarget");
return pool;
}
@Bean(name = "stateMachineTarget")
@Scope(scopeName="prototype")
public StateMachine<States, Events> stateMachineTarget() throws Exception {
Builder<States, Events> builder = StateMachineBuilder.<States, Events>builder();
builder.configureConfiguration()
.withConfiguration()
.autoStartup(true);
builder.configureStates()
.withStates()
.initial(States.HOME)
.states(EnumSet.allOf(States.class));
builder.configureTransitions()
.withInternal()
.source(States.ITEMS).event(Events.ADD)
.action(addAction())
.and()
.withInternal()
.source(States.CART).event(Events.DEL)
.action(delAction())
.and()
.withInternal()
.source(States.PAYMENT).event(Events.PAY)
.action(payAction())
.and()
.withExternal()
.source(States.HOME).target(States.ITEMS)
.action(pageviewAction())
.event(Events.VIEW_I)
.and()
.withExternal()
.source(States.CART).target(States.ITEMS)
.action(pageviewAction())
.event(Events.VIEW_I)
.and()
.withExternal()
.source(States.ITEMS).target(States.CART)
.action(pageviewAction())
.event(Events.VIEW_C)
.and()
.withExternal()
.source(States.PAYMENT).target(States.CART)
.action(pageviewAction())
.event(Events.VIEW_C)
.and()
.withExternal()
.source(States.CART).target(States.PAYMENT)
.action(pageviewAction())
.event(Events.VIEW_P)
.and()
.withExternal()
.source(States.ITEMS).target(States.HOME)
.action(resetAction())
.event(Events.RESET)
.and()
.withExternal()
.source(States.CART).target(States.HOME)
.action(resetAction())
.event(Events.RESET)
.and()
.withExternal()
.source(States.PAYMENT).target(States.HOME)
.action(resetAction())
.event(Events.RESET);
return builder.build();
}
@Bean
public Action<States, Events> pageviewAction() {
return new Action<States, Events>() {
@Override
public void execute(StateContext<States, Events> context) {
String variable = context.getTarget().getId().toString();
Integer count = context.getExtendedState().get(variable, Integer.class);
if (count == null) {
context.getExtendedState().getVariables().put(variable, 1);
} else {
context.getExtendedState().getVariables().put(variable, (count + 1));
}
}
};
}
@Bean
public Action<States, Events> addAction() {
return new Action<States, Events>() {
@Override
public void execute(StateContext<States, Events> context) {
Integer count = context.getExtendedState().get("COUNT", Integer.class);
if (count == null) {
context.getExtendedState().getVariables().put("COUNT", 1);
} else {
context.getExtendedState().getVariables().put("COUNT", (count + 1));
}
}
};
}
@Bean
public Action<States, Events> delAction() {
return new Action<States, Events>() {
@Override
public void execute(StateContext<States, Events> context) {
Integer count = context.getExtendedState().get("COUNT", Integer.class);
if (count != null && count > 0) {
context.getExtendedState().getVariables().put("COUNT", (count - 1));
}
}
};
}
@Bean
public Action<States, Events> payAction() {
return new Action<States, Events>() {
@Override
public void execute(StateContext<States, Events> context) {
context.getExtendedState().getVariables().put("PAYED", true);
}
};
}
@Bean
public Action<States, Events> resetAction() {
return new Action<States, Events>() {
@Override
public void execute(StateContext<States, Events> context) {
context.getExtendedState().getVariables().clear();
}
};
}
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new JedisConnectionFactory();
}
@Bean
public StateMachinePersist<States, Events, String> stateMachinePersist(RedisConnectionFactory connectionFactory) {
return new RepositoryStateMachinePersist<States, Events>(new RedisStateMachineContextRepository<States, Events>(connectionFactory));
}
@Bean
public String stateChartModel() throws IOException {
ClassPathResource model = new ClassPathResource("statechartmodel.txt");
InputStream inputStream = model.getInputStream();
Scanner scanner = new Scanner(inputStream);
String content = scanner.useDelimiter("\\Z").next();
scanner.close();
return content;
}
public enum States {
HOME,
ITEMS,
CART,
PAYMENT
}
public enum Events {
VIEW_I,
VIEW_C,
VIEW_P,
RESET,
ADD,
DEL,
PAY
}
}

View File

@@ -0,0 +1,104 @@
/*
* Copyright 2016 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.eventservice;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
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.support.DefaultStateMachineContext;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import demo.eventservice.StateMachineConfig.Events;
import demo.eventservice.StateMachineConfig.States;
@Controller
public class StateMachineController {
@Autowired
private StateMachine<States, Events> stateMachine;
@Autowired
private StateMachinePersist<States, Events, String> stateMachinePersist;
@Autowired
private String stateChartModel;
@RequestMapping("/")
public String home() {
return "redirect:/state";
}
@RequestMapping("/state")
public String feedAndGetState(@RequestParam(value = "user", required = false) String user,
@RequestParam(value = "id", required = false) Events id, Model model) throws Exception {
model.addAttribute("user", user);
model.addAttribute("allTypes", Events.values());
model.addAttribute("stateChartModel", stateChartModel);
// we may get into this page without a user so
// do nothing with a state machine
if (StringUtils.hasText(user)) {
resetStateMachineFromStore(user);
if (id != null) {
feedMachine(user, id);
}
model.addAttribute("states", stateMachine.getState().getIds());
model.addAttribute("extendedState", stateMachine.getExtendedState().getVariables());
}
return "states";
}
@RequestMapping(value = "/feed",method= RequestMethod.POST)
@ResponseStatus(HttpStatus.OK)
public void feedPageview(@RequestBody(required = true) Pageview event) throws Exception {
Assert.notNull(event.getUser(), "User must be set");
Assert.notNull(event.getId(), "Id must be set");
resetStateMachineFromStore(event.getUser());
feedMachine(event.getUser(), event.getId());
}
private void feedMachine(String user, Events id) throws Exception {
stateMachine.sendEvent(id);
stateMachinePersist.write(new DefaultStateMachineContext<States, Events>(stateMachine.getState().getId(), null, null,
stateMachine.getExtendedState()), "testprefix:" + user);
}
private StateMachine<States, Events> resetStateMachineFromStore(String user) throws Exception {
final StateMachineContext<States, Events> context = stateMachinePersist.read("testprefix:" + user);
stateMachine.stop();
stateMachine.getStateMachineAccessor()
.doWithAllRegions(new StateMachineFunction<StateMachineAccess<States, Events>>() {
@Override
public void apply(StateMachineAccess<States, Events> function) {
function.resetStateMachine(context);
}
});
stateMachine.start();
return stateMachine;
}
}

View File

@@ -0,0 +1,3 @@
security:
basic:
enabled: false

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/base.xml"/>
<logger name="org.springframework" level="INFO"/>
<logger name="org.springframework.statemachine" level="DEBUG"/>
</configuration>

View File

@@ -0,0 +1,20 @@
+---------------------------------------------------------------------------------------------------------------------+
| SM |
+---------------------------------------------------------------------------------------------------------------------+
| |
| +------------------+ VIEW_I +------------------+ VIEW_C +------------------+ VIEW_P +------------------+ |
| *-->| HOME |--------->| ITEMS |--------->| CART |--------->| PAYMENT | |
| | | | | | | | | |
| | | | ADD | | DEL | | PAY | |
| | | | +------------+ | | +------------+ | | +------------+ | |
| | | RESET | | | | VIEW_I | | | | VIEW_C | | | | |
| | |<---------| | v |<---------| | v |<---------| | v | |
| +------------------+ +------------------+ +------------------+ +------------------+ |
| ^ ^ ^ | | | |
| | | RESET | | | | |
| | +----------------------------------------------------+ | | |
| | | VIEW_I | | |
| | RESET +---------------------------------------------------+ | |
| +--------------------------------------------------------------------------------------------------+ |
| |
+---------------------------------------------------------------------------------------------------------------------+

View File

@@ -0,0 +1,28 @@
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Spring Statemachine Event Service Demo</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
</head>
<body>
<div th:if="${user != null}">
<p th:text="'User: ' + ${user}" />
<p th:text="'States: ' + ${states}" />
<p th:text="'Extended State: ' + ${extendedState}" />
</div>
<form action="#" data-th-action="@{/state}" data-th-object="${model}" method="post">
<ul>
<li th:each="ty : ${allTypes}">
<input type="radio" name="id" th:value="${ty}" />
<label th:text="${ty}">replaced</label>
</li>
</ul>
<button type="submit" name="user" value="joe">joe</button>
<button type="submit" name="user" value="bob">bob</button>
<button type="submit" name="user" value="dave">dave</button>
</form>
<pre th:text="${stateChartModel}"/>
</body>
</html>

View File

@@ -0,0 +1,131 @@
/*
* Copyright 2016 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.eventservice;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.HashMap;
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.SpringApplicationConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.statemachine.StateMachineContext;
import org.springframework.statemachine.StateMachinePersist;
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.eventservice.EventServiceTests.Config;
import demo.eventservice.StateMachineConfig.Events;
import demo.eventservice.StateMachineConfig.States;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = { Application.class, Config.class })
@WebAppConfiguration
@DirtiesContext(classMode = ClassMode.AFTER_EACH_TEST_METHOD)
public class EventServiceTests {
private MockMvc mvc;
@Autowired
private WebApplicationContext context;
@Test
public void testHome() throws Exception {
mvc.
perform(get("/state")).
andExpect(status().isOk());
}
@Test
public void testSendEvent() throws Exception {
mvc.
perform(get("/state").param("user", "joe").param("id", "VIEW_I")).
andExpect(status().isOk()).
andExpect(content().string(containsString("States: [ITEMS]")));
}
@Test
public void testAdd() throws Exception {
mvc.
perform(get("/state").param("user", "joe").param("id", "VIEW_I")).
andExpect(status().isOk()).
andExpect(content().string(containsString("States: [ITEMS]")));
mvc.
perform(get("/state").param("user", "joe").param("id", "ADD")).
andExpect(status().isOk()).
andExpect(content().string(containsString("States: [ITEMS]"))).
andExpect(content().string(containsString("Extended State: {COUNT=1, ITEMS=1}")));
}
@Test
public void testFeed() throws Exception {
mvc.
perform(post("/feed").content("{\"user\":\"joe\",\"id\":\"VIEW_I\"}").contentType(MediaType.APPLICATION_JSON)).
andExpect(status().isOk());
mvc.
perform(post("/feed").content("{\"user\":\"joe\",\"id\":\"ADD\"}").contentType(MediaType.APPLICATION_JSON)).
andExpect(status().isOk());
mvc.
perform(get("/state").param("user", "joe")).
andExpect(status().isOk()).
andExpect(content().string(containsString("States: [ITEMS]"))).
andExpect(content().string(containsString("Extended State: {COUNT=1, ITEMS=1}")));
}
@Before
public void setup() throws Exception {
mvc = MockMvcBuilders.webAppContextSetup(context).build();
}
@Configuration
static class Config {
@Bean
public StateMachinePersist<States, Events, String> stateMachinePersist() {
// use stateMachinePersist without redis to ease testing
return new InMemoryStateMachinePersist();
}
}
static class InMemoryStateMachinePersist implements StateMachinePersist<States, Events, String> {
private final HashMap<String, StateMachineContext<States, Events>> contexts = new HashMap<>();
@Override
public void write(StateMachineContext<States, Events> context, String contextOjb) throws Exception {
contexts.put(contextOjb, context);
}
@Override
public StateMachineContext<States, Events> read(String contextOjb) throws Exception {
return contexts.get(contextOjb);
}
}
}