«1. Введение

Эта статья посвящена проекту Spring State Machine, который можно использовать для представления рабочих процессов или любых других задач представления автоматов с конечным числом состояний.

2. Зависимость Maven

Для начала нам нужно добавить основную зависимость Maven:

<dependency>
    <groupId>org.springframework.statemachine</groupId>
    <artifactId>spring-statemachine-core</artifactId>
    <version>1.2.3.RELEASE</version>
</dependency>

Последнюю версию этой зависимости можно найти здесь.

3. Конфигурация конечного автомата

Теперь давайте начнем с определения простого конечного автомата:

@Configuration
@EnableStateMachine
public class SimpleStateMachineConfiguration 
  extends StateMachineConfigurerAdapter<String, String> {

    @Override
    public void configure(StateMachineStateConfigurer<String, String> states) 
      throws Exception {
 
        states
          .withStates()
          .initial("SI")
          .end("SF")
          .states(
            new HashSet<String>(Arrays.asList("S1", "S2", "S3")));

    }

    @Override
    public void configure(
      StateMachineTransitionConfigurer<String, String> transitions) 
      throws Exception {
 
        transitions.withExternal()
          .source("SI").target("S1").event("E1").and()
          .withExternal()
          .source("S1").target("S2").event("E2").and()
          .withExternal()
          .source("S2").target("SF").event("end");
    }
}

Обратите внимание, что этот класс аннотирован как обычная конфигурация Spring, а также как конечный автомат. Также необходимо расширить StateMachineConfigurerAdapter, чтобы можно было вызывать различные методы инициализации. В одном из методов настройки мы определяем все возможные состояния конечного автомата, в другом — как события меняют текущее состояние.

В приведенной выше конфигурации показан довольно простой прямолинейный автомат переходных состояний, которому должно быть достаточно легко следовать.

Теперь нам нужно запустить контекст Spring и получить ссылку на конечный автомат, определенный нашей конфигурацией:

@Autowired
private StateMachine<String, String> stateMachine;

Когда у нас есть конечный автомат, его нужно запустить:

stateMachine.start();

Теперь что наша машина находится в начальном состоянии, мы можем отправлять события и тем самым запускать переходы:

stateMachine.sendEvent("E1");

Мы всегда можем проверить текущее состояние конечной машины:

stateMachine.getState();

4. Действия

Давайте добавить некоторые действия, которые будут выполняться при переходах состояний. Сначала мы определяем наше действие как компонент Spring в том же файле конфигурации:

@Bean
public Action<String, String> initAction() {
    return ctx -> System.out.println(ctx.getTarget().getId());
}

Затем мы можем зарегистрировать созданное выше действие при переходе в нашем классе конфигурации:

@Override
public void configure(
  StateMachineTransitionConfigurer<String, String> transitions)
  throws Exception {
 
    transitions.withExternal()
      transitions.withExternal()
      .source("SI").target("S1")
      .event("E1").action(initAction())

Это действие будет выполнено когда происходит переход от SI к S1 через событие E1. Действия могут быть присоединены к самим состояниям:

@Bean
public Action<String, String> executeAction() {
    return ctx -> System.out.println("Do" + ctx.getTarget().getId());
}

states
  .withStates()
  .state("S3", executeAction(), errorAction());

Эта функция определения состояния принимает операцию, которая будет выполняться, когда машина находится в целевом состоянии, и, опционально, обработчик действия ошибки.

Обработчик действия при ошибке мало чем отличается от любого другого действия, но он будет вызываться, если в любой момент во время оценки действий состояния возникнет исключение:

@Bean
public Action<String, String> errorAction() {
    return ctx -> System.out.println(
      "Error " + ctx.getSource().getId() + ctx.getException());
}

Также можно зарегистрировать отдельные действия для переходы состояний входа, выполнения и выхода:

@Bean
public Action<String, String> entryAction() {
    return ctx -> System.out.println(
      "Entry " + ctx.getTarget().getId());
}

@Bean
public Action<String, String> executeAction() {
    return ctx -> 
      System.out.println("Do " + ctx.getTarget().getId());
}

@Bean
public Action<String, String> exitAction() {
    return ctx -> System.out.println(
      "Exit " + ctx.getSource().getId() + " -> " + ctx.getTarget().getId());
}
states
  .withStates()
  .stateEntry("S3", entryAction())
  .stateDo("S3", executeAction())
  .stateExit("S3", exitAction());

Соответствующие действия будут выполняться при соответствующих переходах состояний. Например, мы можем захотеть проверить некоторые предварительные условия во время входа или инициировать некоторые отчеты во время выхода.

5. Глобальные прослушиватели

Для конечного автомата можно определить глобальные прослушиватели событий. Эти прослушиватели будут вызываться каждый раз, когда происходит переход состояния, и их можно использовать для таких вещей, как ведение журнала или безопасность.

Во-первых, нам нужно добавить еще один метод конфигурации — тот, который имеет дело не с состояниями или переходами, а с конфигурацией самого конечного автомата.

public class StateMachineListener extends StateMachineListenerAdapter {
 
    @Override
    public void stateChanged(State from, State to) {
        System.out.printf("Transitioned from %s to %s%n", from == null ? 
          "none" : from.getId(), to.getId());
    }
}

Нам нужно определить слушателя, расширив StateMachineListenerAdapter:

Здесь мы только переопределили stateChanged, хотя доступно множество других четных перехватчиков.

6. Расширенное состояние

Spring State Machine отслеживает свое состояние, но для отслеживания состояния нашего приложения, будь то некоторые вычисленные значения, записи от администраторов или ответы от вызовов внешних систем, нам нужно использовать то, что есть называется расширенным состоянием.

@Bean
public Action<String, String> executeAction() {
    return ctx -> {
        int approvals = (int) ctx.getExtendedState().getVariables()
          .getOrDefault("approvalCount", 0);
        approvals++;
        ctx.getExtendedState().getVariables()
          .put("approvalCount", approvals);
    };
}

Предположим, мы хотим убедиться, что заявка на учетную запись проходит два уровня утверждения. Мы можем отслеживать количество утверждений, используя целое число, хранящееся в расширенном состоянии:

7. Guards

@Bean
public Guard<String, String> simpleGuard() {
    return ctx -> (int) ctx.getExtendedState()
      .getVariables()
      .getOrDefault("approvalCount", 0) > 0;
}

Guard можно использовать для проверки некоторых данных перед выполнением перехода в состояние. Guard выглядит очень похоже на действие:

Заметное отличие здесь состоит в том, что Guard возвращает значение true или false, которое информирует конечный автомат о том, следует ли разрешить переход.

.guardExpression("extendedState.variables.approvalCount > 0")

Также существует поддержка выражений SPeL в качестве охранников. Приведенный выше пример также можно было бы написать так:

8. Конечный автомат из конструктора

StateMachineBuilder.Builder<String, String> builder 
  = StateMachineBuilder.builder();
builder.configureStates().withStates()
  .initial("SI")
  .state("S1")
  .end("SF");

builder.configureTransitions()
  .withExternal()
  .source("SI").target("S1").event("E1")
  .and().withExternal()
  .source("S1").target("SF").event("E2");

StateMachine<String, String> machine = builder.build();

StateMachineBuilder можно использовать для создания конечного автомата без использования аннотаций Spring или создания контекста Spring:

~~ ~ 9. Иерархические состояния

states
  .withStates()
    .initial("SI")
    .state("SI")
    .end("SF")
    .and()
  .withStates()
    .parent("SI")
    .initial("SUB1")
    .state("SUB2")
    .end("SUBEND");

«Иерархические состояния можно настроить, используя несколько withStates() в сочетании с parent():

stateMachine.getState().getIds()
["SI", "SUB1"]

Такая установка позволяет конечному автомату иметь несколько состояний, поэтому вызов getState() создаст несколько идентификаторов. Например, сразу после запуска следующее выражение дает результат:

10. Соединения (выборы)

До сих пор мы создавали переходы между состояниями, которые были линейными по своей природе. Это не только довольно неинтересно, но и не отражает реальных вариантов использования, которые разработчику будет предложено реализовать. Скорее всего, условные пути должны быть реализованы, и соединения (или выборы) конечного автомата Spring позволяют нам сделать именно это.

states
  .withStates()
  .junction("SJ")

Во-первых, нам нужно пометить состояние как соединение (выбор) в определении состояния:

.withJunction()
  .source("SJ")
  .first("high", highGuard())
  .then("medium", mediumGuard())
  .last("low")

Затем в переходах мы определяем опции first/then/last, которые соответствуют структуре if-then-else :

@Bean
public Guard<String, String> mediumGuard() {
    return ctx -> false;
}

@Bean
public Guard<String, String> highGuard() {
    return ctx -> false;
}

сначала, а затем возьмите второй аргумент, который является обычным сторожем, который будет вызываться, чтобы узнать, какой путь выбрать:

Обратите внимание, что переход не останавливается на узле соединения, но будет немедленно казните определенных охранников и идите по одному из назначенных маршрутов.

В приведенном выше примере указание конечному автомату перейти в SJ приведет к тому, что фактическое состояние станет низким, поскольку оба охранника просто возвращают false.

Последнее замечание: API предоставляет как переходы, так и выбор. Однако функционально они идентичны во всех аспектах.

11. Форк

Иногда возникает необходимость разделить выполнение на несколько независимых путей выполнения. Этого можно добиться с помощью функции fork.

states
  .withStates()
  .initial("SI")
  .fork("SFork")
  .and()
  .withStates()
    .parent("SFork")
    .initial("Sub1-1")
    .end("Sub1-2")
  .and()
  .withStates()
    .parent("SFork")
    .initial("Sub2-1")
    .end("Sub2-2");

Во-первых, нам нужно обозначить узел как узел разветвления и создать иерархические области, в которые конечный автомат будет выполнять разбиение:

.withFork()
  .source("SFork")
  .target("Sub1-1")
  .target("Sub2-1");

Затем определить переход разветвления:

12. Присоединиться ~ ~~ Дополнением операции fork является соединение. Это позволяет нам установить состояние, переход в которое зависит от завершения некоторых других состояний:

Как и в случае разветвления, нам нужно указать узел соединения в определении состояния:

states
  .withStates()
  .join("SJoin")

Затем в переходах мы определяем, какой состояния должны быть завершены, чтобы активировать наше состояние соединения:

transitions
  .withJoin()
    .source("Sub1-2")
    .source("Sub2-2")
    .target("SJoin");

Вот и все! С этой конфигурацией, когда оба Sub1-2 и Sub2-2 достигнуты, конечный автомат перейдет к SJoin

13. Перечисления вместо строк

В приведенных выше примерах мы использовали строковые константы для определения состояний и событий для ясность и простота. В реальной производственной системе, вероятно, захочется использовать перечисления Java, чтобы избежать орфографических ошибок и повысить безопасность типов.

Во-первых, нам нужно определить все возможные состояния и события в нашей системе:

public enum ApplicationReviewStates {
    PEER_REVIEW, PRINCIPAL_REVIEW, APPROVED, REJECTED
}

public enum ApplicationReviewEvents {
    APPROVE, REJECT
}

Нам также нужно передать наши перечисления как общие параметры при расширении конфигурации:

public class SimpleEnumStateMachineConfiguration 
  extends StateMachineConfigurerAdapter
  <ApplicationReviewStates, ApplicationReviewEvents>

После определения мы можно использовать наши константы перечисления вместо строк. Например, чтобы определить переход:

transitions.withExternal()
  .source(ApplicationReviewStates.PEER_REVIEW)
  .target(ApplicationReviewStates.PRINCIPAL_REVIEW)
  .event(ApplicationReviewEvents.APPROVE)

14. Заключение

В этой статье были рассмотрены некоторые особенности конечного автомата Spring.

Как всегда, вы можете найти исходный код примера на GitHub.