1. Настройка проекта
Создаем новый SpringBoot проект и добалвяем в зависимостях statemachine
Вся магия в одном стартере для стэйт-машины.
<dependency>
<groupId>org.springframework.statemachine</groupId>
<artifactId>spring-statemachine-starter</artifactId>
</dependency>
2. Состояния и события
Для примера возьмем бизнес-процесс, знакомый большинству разработчиков. Если вдруг канбан прошел мимо вас, то стоит немного почитать про эту методологию разработки.
Из этого бизнес-процесса мы можем вычленить следующие состояния:
BACKLOG
,IN_PROGRESS
,TESTING
,DONE
. Собственно, это и будет набором стэйтов.
Переход из одного состояния в другое происходит по определенному событию. Spring State Machine позволяет конфигурировать стэйты и ивенты через java enum-ы.
public enum States {
BACKLOG,
IN_PROGRESS,
TESTING,
DONE
}
public enum Events {
START_FEATURE,
FINISH_FEATURE,
QA_REJECTED_UC,
QA_CHECKED_UC
}
3. Конфигурация для statemachine
Мы будем рассматривать пример использования statemachine через стандартные механизмы Spring DI, для этого нам понадобится сделать spring configuration, описывающий настройки стэйт-машины.
@Configuration
@EnableStateMachine
public class StateMachineConfig
extends EnumStateMachineConfigurerAdapter (1)
<States, Events> {
@Override
public void configure(
StateMachineConfigurationConfigurer
<States, Events> config) throws Exception {
config.withConfiguration() (2)
.autoStartup(true);
}
@Override
public void configure(
StateMachineStateConfigurer<States, Events> states)
throws Exception {
states.withStates() (3)
.initial(States.BACKLOG)
.state(States.IN_PROGRESS)
.state(States.TESTING)
.end(States.DONE);
}
@Override
public void configure(
StateMachineTransitionConfigurer<States, Events> transitions)
throws Exception {
transitions.withExternal() (4)
.source(States.BACKLOG)
.target(States.IN_PROGRESS)
.event(Events.START_FEATURE)
.and()
.withExternal()
.source(States.IN_PROGRESS)
.target(States.TESTING)
.event(Events.FINISH_FEATURE)
.and()
.withExternal()
.source(States.TESTING)
.target(States.IN_PROGRESS)
.event(Events.QA_TEAM_REJECT)
.and()
.withExternal()
.source(States.TESTING)
.target(States.DONE)
.event(Events.QA_TEAM_APPROVE);
}
1 | мы работаем с типизированной стэйт-машиной, и все наши состояния и события описаны в энумах, поэтому конфигурация тоже отражает эту типизацию |
2 | оверайдим метод конфигурации базовых настрок стэйт-машины |
3 | описываем состояния стэйт-машины |
4 | описываем переходы |
В базовой конфигурации мы задаем настройку автозапуска для стэйт-машины, это позволит нам автоматически переходить в стартовый стэйт, при создании стэйт-машины.
В настройках состояний нужно указать точку входа в стэйт-машину (initial state), при запуске стэйт-машины переход в эту точку будет первым. Тут важно понимать, что переход в состояние BACKLOG из ниоткуда - это тоже переход, далее мы увидим это более наглядно.
Так же в конфигурации состояний мы задаем точку выхода (end state), перейдя в которую стэйт-машина будет остановлена.
Настройка переходов выглядит довольно интуитивно-понятной, для каждого перехода нужно указать исходное состояние, целевое состояние и событие, которое приведет к такому переходу.
4. Используем стэйт-машину
Первым делом, протестируем, что контекст сконфигурирован корректно, и спринг инжектит нужную стэйт-машину:
@RunWith(SpringRunner.class)
@SpringBootTest
public class StatesFirstApplicationTests {
@Autowired
private StateMachine<States, Events> stateMachine;
@Test
public void initTest() {
Assertions.assertThat(stateMachine.getState().getId())
.isEqualTo(States.BACKLOG);
Assertions.assertThat(stateMachine).isNotNull();
}
}
Напишем тест для основного флоу так называемый "green way":
@Test
public void testGreenFlow() {
// Arrange & Act
stateMachine.sendEvent(Events.START_FEATURE);
stateMachine.sendEvent(Events.FINISH_FEATURE);
stateMachine.sendEvent(Events.QA_TEAM_APPROVE);
// Asserts
Assertions.assertThat(stateMachine.getState().getId())
.isEqualTo(States.DONE); (1)
}
1 | главный критерий успешного выполнения всех переходов - это достижение стэйт-машиной состояния DONE, в полноценном тесте стоило бы проверить каждый переход, а не только конечное состояние после серии переходов, но для наших исследовательских целей этого будет достаточно. |
5. Перехватываем события стэйт-машины
Добавляем listener в конфигурацию:
@Override
public void configure(
StateMachineConfigurationConfigurer
<States, Events> config) throws Exception {
config.withConfiguration()
.listener(listener()) (1)
.autoStartup(true);
}
private StateMachineListener<States, Events> listener() {
return new StateMachineListenerAdapter<States, Events>(){
@Override
public void transition(Transition<States, Events> transition) { (2)
log.warn("move from:{} to:{}",
ofNullableState(transition.getSource()),
ofNullableState(transition.getTarget()));
}
@Override
public void eventNotAccepted(Message<Events> event) { (3)
log.error("event not accepted: {}", event);
}
private Object ofNullableState(State s) {
return Optional.ofNullable(s)
.map(State::getId)
.orElse(null);
}
};
}
1 | прописываем обработчик событий для стэйт машины |
2 | переопределяем метод перехвата любого перехода |
3 | переопределяем метод, срабатывающий при попытке осуществить недопустимый переход |
После этого можно написать тест с недопустимым переходом:
@Test
public void testWrongWay() {
// Arrange
// Act
stateMachine.sendEvent(Events.START_FEATURE);
stateMachine.sendEvent(Events.QA_TEAM_APPROVE);
// Asserts
Assertions.assertThat(stateMachine.getState().getId())
.isEqualTo(States.IN_PROGRESS);
}
И в логах мы увидим, что обработчик отработал как мы этого ожидали:
Тут видно еще один нюанс, о котором я рассказывал выше, первый переход в BACKLOG
(initial стэйт)
тоже является переходом для стэйт машины и наш обработчик его отлавливает, но исходное состояние для него будет NULL
,
для этого в обработчике пришлось оборачивать стэйты полученные из transition
в ofNullableState
6. Создаем несколько экземпляров стэйт-машины
Для инстанцирования стэйт машин нужно заменить в конфигурации аннотацию @EnableStateMachine
на @EnableStateMachineFactory
.
Это позволит нам инжектить в код фабрику, создающую стэйт-машины.
Исправим конфигурацию:
@Slf4j
@Configuration
@EnableStateMachineFactory
public class StateMachineConfig
extends EnumStateMachineConfigurerAdapter
<States, Events> {
...
}
Модифицируем тесты:
@RunWith(SpringRunner.class)
@SpringBootTest
public class StatesFirstApplicationTests {
private StateMachine<States, Events> stateMachine;
@Autowired
private StateMachineFactory<States, Events> stateMachineFactory;
@Before
public void setUp() throws Exception {
stateMachine = stateMachineFactory.getStateMachine();
}
...
}
7. Добавим бизнес-логику на переход из стэйта в стэйт
Давайте добавим немного автоматизации, например,
сделаем автодеплой перед запуском стэйта TESTING
.
Мы можем сделать это следующим образом:
@Override
public void configure(StateMachineStateConfigurer<States, Events> states)
throws Exception {
states.withStates()
.initial(States.BACKLOG)
.state(States.IN_PROGRESS)
.state(States.TESTING, deployAction()) (1)
.end(States.DONE);
}
private Action<States, Events> deployAction() { (2)
return context -> {
log.warn("DEPLOYING: {}",context.getEvent());
};
}
1 | запускаем действие deployAction после входа в стейт TESTING |
2 | реализация действия deployAction |
Этот подход имеет одну проблему.
Если мы немного усложним наш бизнес-процесс, то это действие может выполняться тогда, когда нам этого не нужно.
Например, разработчик может реализовать две фичи в одном пул-реквесте, тогда нам нужно
переместить вторую задачу в TESTING
в обход состояния IN_PROGRESS
.
Будем надеяться, что разработчик так сделал от того, что он слишком эффективен
и успевает реализовать два таска в одном, еще и в два раза быстрее, чем изначально планировал.
Обычно таких супер-эффективных программистов мы называем Рок-Звездами =)
Добавим событие и переход:
transitions.withExternal()
...
.and()
.withExternal()
.source(States.BACKLOG)
.target(States.TESTING)
.event(Events.ROCK_STAR_MAKE_ALL_IN_ONE);
проверим, что все работает так как мы планировали:
@Test
public void testRockStar() {
// Arrange
// Act
stateMachine.sendEvent(Events.ROCK_STAR_MAKE_ALL_IN_ONE);
// Asserts
Assertions.assertThat(stateMachine.getState().getId())
.isEqualTo(States.TESTING);
}
результат теста:
Теперь, когда мы перемещаем таск из BACKLOG
в TESTING
, запускается процесс деплоя.
Но это не то поведение, которое нам нужно. Для решения этой проблемы мы можем переместить
вызов действия из конфигурации стейтов в конфигурацию переходов:
states.withStates()
.initial(States.BACKLOG, timeToWorkAction())
.state(States.IN_PROGRESS) (1)
.state(States.TESTING)
.end(States.DONE);
...
transitions.withExternal()
...
.source(States.IN_PROGRESS)
.target(States.TESTING)
.event(Events.FINISH_FEATURE)
.action(deployAction()) (2)
.and()
...
1 | убираем вызов действия из стэйтов |
2 | добавляем вызов действия на переход из IN_PROGRESS в FINISH_FEATURE |
8. Guards - добавляем условия на переходы
Теперь мы добавим возможность запустить деплой из стейта BACKLOG
и из стейта IN_PROGRESS
. Сделаем событие DEPLOY
так, чтобы разработчики
сами могли решить, когда он им нужен. Однако, перед входом в стейт TESTING
мы
будем проверять, был ли выполнен деплой, и если нет, то переход в стейт TESTING
будет неосуществим. Эту проверку мы сделаем при помощи guard’а на переходе в стейт TESTING
.
Добавим внутренние переходы для событий DEPLOY
из стейтов BACKLOG
и IN_PROGRESS
.
Так же добавим guard для перехода в стейт TESTING
:
transitions.withExternal()
.source(States.BACKLOG)
.target(States.IN_PROGRESS)
.event(Events.START_FEATURE)
.and()
.withExternal()
.source(States.IN_PROGRESS)
.target(States.TESTING)
.event(Events.FINISH_FEATURE)
.guard(checkDeployGuard()) (1)
.and()
.withExternal()
.source(States.TESTING)
.target(States.IN_PROGRESS)
.event(Events.QA_TEAM_REJECT)
.and()
.withExternal()
.source(States.TESTING)
.target(States.DONE)
.event(Events.QA_TEAM_APPROVE)
.and()
.withExternal()
.source(States.BACKLOG)
.target(States.TESTING)
.guard(checkDeployGuard()) (1)
.event(Events.ROCK_STAR_MAKE_ALL_IN_ONE)
.and()
.withInternal()
.source(States.BACKLOG)
.event(Events.DEPLOY) (2)
.action(deployAction())
.and()
.withInternal()
.source(States.IN_PROGRESS)
.event(Events.DEPLOY) (2)
.action(deployAction());
1 | проверяем условие доступности перехода в TESTING |
2 | внутренне событие (не меняющее стейт) DEPLOY |
Теперь сделаем реализацию guard’а с использованием переменных из контекста стейт-машины. Вы можете писать в ExtendedState переменные, которые будут расшарены между всем процессом исполнения стейт-машины. Количество этих переменных не ограничено, мы просто задаем имя переменной (строкой) и ее значение (Object).
private Guard<States, Events> checkDeployGuard() {
return context -> {
Boolean flag = (Boolean) context.getExtendedState()
.getVariables()
.get("deployed");
return flag == null ? false : flag;
};
}
Теперь нужно не забыть установить значение этой переменной в действии деплоя:
private Action<States, Events> deployAction() {
return context -> {
log.warn("DEPLOYING: {}", context.getEvent());
context.getExtendedState()
.getVariables()
.put("deployed", true);
};
}
Сделаем юнит-тест для этого кейса:
@Test
public void testGuard() {
// Arrange & act
stateMachine.sendEvent(Events.START_FEATURE);
stateMachine.sendEvent(Events.FINISH_FEATURE);
stateMachine.sendEvent(Events.QA_TEAM_APPROVE); // not accepted!
// Asserts
Assertions.assertThat(stateMachine.getState().getId())
.isEqualTo(States.IN_PROGRESS);
}
Если мы не вызываем событие DEPLOY
, то мы получим следующий результат:
9. Сохранение/восстановления состояний стейт-машины
Для сохранения/восстановления состояний стейт-машины нужно реализовать интерфейс StateMachinePersist
.
Для начала напишем простую реализацию in-memory persist:
public class InMemoryPersist
implements StateMachinePersist<States, Events, UUID> {
private HashMap<UUID, StateMachineContext<States, Events>> storage
= new HashMap<>(); (1)
@Override
public void write(StateMachineContext<States, Events> context,
UUID contextObj) throws Exception {
storage.put(contextObj, context);
}
@Override
public StateMachineContext<States, Events> read(UUID contextObj) throws Exception {
return storage.get(contextObj);
}
}
1 | в качестве хранилища будем использовать HashMap |
Обратите внимание, что сохраняется не экземпляр StateMachine
, а контекст (StateMachineContext
)
Теперь нам нужно сделать конфигурацию для нашего способа персистинга и добавить реализацию StateMachinePersister
,
работающую через наш способ хранения контекста стейт-машины.
@Bean
public StateMachinePersist<States, Events, UUID> inMemoryPersist() {
return new InMemoryPersist();
}
@Bean
public StateMachinePersister<States, Events, UUID> persister(
StateMachinePersist<States, Events, UUID> defaultPersist) {
return new DefaultStateMachinePersister<>(defaultPersist);
}
Теперь можно воспользоваться полученным механизмом:
@Autowired
private StateMachineFactory<States, Events> stateMachineFactory;
@Autowired
private StateMachinePersister<States, Events, UUID> persister;
@Test
public void testPersist() throws Exception {
// Arrange
StateMachine<States, Events> firstStateMachine =
stateMachineFactory.getStateMachine();
StateMachine<States, Events> secondStateMachine =
stateMachineFactory.getStateMachine();
firstStateMachine.sendEvent(Events.START_FEATURE);
firstStateMachine.sendEvent(Events.DEPLOY);
// precondition
Assertions.assertThat(secondStateMachine.getState().getId())
.isEqualTo(States.BACKLOG);
// Act
persister.persist(firstStateMachine, firstStateMachine.getUuid()); (1)
persister.restore(secondStateMachine, firstStateMachine.getUuid()); (2)
// Asserts
Assertions.assertThat(secondStateMachine.getState().getId())
.isEqualTo(States.IN_PROGRESS);
}
1 | сохраняем контекст первой стейт-машины |
2 | загружаем во сторую стейт-машину состояние первой |
10. Сохраняем стейт-машины в MongoDb
Для работы с MongoDb нам понадобится добавить парочку новых зависимостей. Так же мы будем использовать TestContainers для написания интеграционных тестов.
<!-- MongoDB -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.statemachine</groupId>
<artifactId>spring-statemachine-data-mongodb</artifactId>
<version>2.0.0.RELEASE</version>
</dependency>
<!-- MongoDB -->
<!-- TestContainers -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.4.3</version>
</dependency>
<!-- TestContainers -->
Теперь мы можем добавить персист для MongoDb:
@Bean
public StateMachineRuntimePersister<States, Events, UUID> mongoPersist(
MongoDbStateMachineRepository mongoRepository) {
return new MongoDbPersistingStateMachineInterceptor<States,Events,UUID>(mongoRepository);
}
Теперь можно написать интеграционный тест.
Для запуска mongodb в тесте я буду пользоваться библиотекой TestContainers, для всех интеграционных тестов mongodb я использую следующий базовый класс:
@SpringBootTest
@RunWith(SpringRunner.class)
public abstract class BaseMongoIT {
private static final Integer MONGO_PORT = 27017;
private static GenericContainer mongo =
new GenericContainer("mongo:latest")
.withExposedPorts(MONGO_PORT);
static {
mongo.start();
System.setProperty("spring.data.mongodb.host", mongo.getContainerIpAddress());
System.setProperty("spring.data.mongodb.port", mongo.getMappedPort(MONGO_PORT).toString());
}
@Autowired
protected MongoTemplate mongoTemplate;
}
в итоге тестовый кейс будет выглядеть так:
public class MongoPersistTest extends BaseMongoIT {
@Autowired
private StateMachinePersister<States, Events, UUID> persister;
@Autowired
private StateMachineFactory<States, Events> stateMachineFactory;
@Test
public void testMongoPersist() throws Exception {
// Arrange
StateMachine<States, Events> firstStateMachine = stateMachineFactory.getStateMachine();
StateMachine<States, Events> secondStateMachine = stateMachineFactory.getStateMachine();
firstStateMachine.sendEvent(Events.START_FEATURE);
firstStateMachine.sendEvent(Events.DEPLOY);
// Act
persister.persist(firstStateMachine, firstStateMachine.getUuid());
persister.persist(secondStateMachine, secondStateMachine.getUuid());
persister.restore(secondStateMachine, firstStateMachine.getUuid());
// Asserts
Assertions.assertThat(secondStateMachine.getState().getId())
.isEqualTo(States.IN_PROGRESS);
boolean deployed = (boolean) secondStateMachine.getExtendedState()
.getVariables()
.get("deployed");
Assertions.assertThat(deployed).isEqualTo(true);
// Mongo specific asserts:
Assertions.assertThat(mongoTemplate.getCollectionNames())
.isNotEmpty();
List<Document> documents = mongoTemplate.findAll(Document.class,
"MongoDbRepositoryStateMachine");
Assertions.assertThat(documents).hasSize(2);
Assertions.assertThat(documents)
.flatExtracting(Document::values)
.contains(firstStateMachine.getUuid().toString(),
secondStateMachine.getUuid().toString())
.contains(firstStateMachine.getState().getId().toString(),
secondStateMachine.getState().getId().toString());
}
}
11. Несколько стэйт-машин в одном проекте
Иногда, приходится использовать несколько различных стэйт-машин, в одном проекте. У этих машин свой набор состояний и своя конфигурация.
Для того чтобы сделать несколько конфигураций, нужно указать имя для фабрики StateMachineFactory
,
по которому потом можно будет инжектить нужную зависимость.
@Configuration
@EnableStateMachineFactory(name = "secondStateMachineFactory")
public class SecondStateMachineConfig extends EnumStateMachineConfigurerAdapter<States, Events> {
...
}
Теперь можно использовать эту стейт-машину, указав имя фабрики в Qualifier
@Autowired
@Qualifier("secondStateMachineFactory")
private StateMachineFactory<States, Events> secondStateMachineFactory;
Теперь мы можем использовать несколько стейт-машин одновременно.
12. Зачем и когда нужны стейт-машины
Без использования стэйт-машин наш пример можно было бы решить другими способами, давайте рассмотрим некоторые из них и сравним альтернативные варианты.
12.1. Первая альтернатива - захардкодить
Всю логику стэйт машины можно представить в виде обычного кода с условиями и ветвлениями:
@Slf4j
class StateMachine {
private States currentState;
private HashMap<String, Object> variables;
private boolean finished;
public void processing(Events event) {
if(!finished) {
log.error("already finished state machine");
}
switch (currentState) {
case BACKLOG:
if (event == Events.START_FEATURE) {
currentState = States.IN_PROGRESS;
// transition logic
} else if (event == Events.DEPLOY) {
variables.put("deployed", true);
// start deploy
} else {
log.error("not accepted event.");
}
break;
case IN_PROGRESS:
if (event == Events.FINISH_FEATURE) {
if (variables.get("deployed").equals(true)) {
currentState = States.TESTING;
// run business logic
} else {
log.error("unreachable state");
}
// transition logic
} else if (event == Events.DEPLOY) {
variables.put("deployed", true);
// start deploy
} else {
log.error("not accepted event.");
}
break;
case TESTING:
if(event == Events.QA_TEAM_APPROVE){
currentState = States.DONE;
finished = true;
log.info("successful done!");
} else if (event == Events.QA_TEAM_REJECT){
currentState = States.IN_PROGRESS;
log.info("reject");
} else {
log.error("not accepted event.");
}
break;
...
}
}
Конечно, можно было бы подумать над организацией этого кода, вынести повторяющиеся части, добавить какие-нибудь стратегии обработки, но в целом поддерживать это решение довольно трудно.
Использование стейт-машины будет самым простым вариантом наведения порядка и даст простоту поддержки и модификации этого кода.
12.2. Вторая альтерантива - BPM фреймворк
Чего нам будет стоить реализовать подобный бизнес-кейс на каком-нибудь BPMN движке, мы рассмотрим в другой статье, тут я лишь коротко раскрою этот вопрос. Во-первых, вам нужно будет поднимать в проекте полноценный фреймворк для BPMN процесов, со своей специфичной БД. Эти фреймворки не легковесные, далеко не все из них нативно работают в java. Во-вторых, нужно уметь пользоваться нотацией BPMN и описать процесс.
Когда стоит использовать BPM:
-
если у вас сложные бизнес-процессы
-
когда нужно много возможностей: сообщения, подпроцессы, сигналы, условные переходы и т.п.
-
если нужно версионирование процессов
-
когда есть необходимость графически визуализировать используемые процессы
-
когда нужен механизм для просмотра и отладки всех текущих процессов и их состояний
Когда не стоит использовать:
-
у вас очень простые процессы и набор состояний системы очевиден
-
мало времени на разработку системы или нет специалистов в предметной области
-
нет необходимости версионировать процессы
-
нет возможности разворачивать в продакшене полноценный BPM фреймворк
13. Проект на github
Все исходники можно посмотреть на гитхабе: