Spring Statemachine

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


Основна комунікація бота і юзера базується на станах юзера, які називаються стейтами. Юзер перебуває у різних стейтах і ці стейти по різному змінюються. Для того, щоб бот міг успішно відслідковувати і реагувати на поведінку юзера, зміну його стейту, потрібен окремий механізм. Цей механізм нам доводиться відтворювати у кожному боті, раз за разом придумуючи велосипед.

У Spring є окремий проект, який називається Spring Statemachine, він надає весь потрібний функціонал розробнику, щоб легко працвати з стейтами.

Глосарій Finite State Machine

State machine (скінчений автомат) є потужним інструментом, бо ще на етапі старту програми гарантує всі можливі стани в яких програма може перебувати і всі можливі переходи між цими станами. Такий підхід дозволяє легко дебажити поведінку програми, налаштовувати безпеку і моніторити стан користувача.

Ідея полягає в тому, що ваша програма може існувати в кінцевій кількості станів, і певні попередньо визначені тригери можуть переводити вашу програму з одного стану в інший. Такі тригери можуть базуватись на івентах, або таймерах.

States

Стейти - це стани в яких може перебувати сутність. На клавіатурі є нампад і він здатен перебувати в двох різних станах: numlock on & numlock off.

У програмуванні це дозволяє замість використання прапорців, вкладених розгалужень if/else/break або іншої непрактичної логіки покладатись на стан, або його зміну.

Pseudo States

Псевдостан - це особливий тип стану, який використовується для більш високорівневої логіки, надаючи стану спеціальне додаткове значення (наприклад, початковий стан, кінцевий стан). Потім машина станів може внутрішньо реагувати на ці стани. Сприймайте псевдостан, як утилітний стан необхідний для структури, або покращення координації машини.

Initial

Ініціюючий псевдостан - це стан який завжди необхідний, стан у якому машина перебуває на старті.

End

Кінцевий/Термінальний псевдостан - вказує на те, що певний автомат досягнув свого кінцевого стану. Фактично, це означає, що автомат більше не обробляє жодних подій і не переходить до жодного іншого стану. Іноді, коли машина доходить до термінального стану вона може перезапуститись, якщо це передбачено.

Choice

Ви можете використовувати псевдостан Choice для вибору динамічної умовної гілки переходу стану. Умова оцінюється так, щоб була обрана лише одна гілка.

Зазвичай використовується проста структура if/elseif/else, щоб переконатися, що буде обрано одну гілку. В іншому випадку машина станів може зайти в глухий кут. Щоб зупинки не було, необхідно створювати else стан (У Spring це .last(), на діаграмі це S3).

Junction

Псевдостан Junction функціонально схожий на choice, оскільки обидва реалізуються за допомогою структур if/elseif/else. Єдина реальна відмінність полягає в тому, що Junction дозволяє декілька вхідних переходів, тоді як вибір дозволяє лише один. Ця різниця є здебільшого академічною.

History

Історія це особливий псевдостан, який використовується для збереження останнього стану в якому була машина. Це необхідно у випадках, коли система відновлюється після збою. Є два типи псевдостану Історія: SHALLOW(Запам’ятовує тільки останній стан), DEEP(Запам’ятовує ще й вкладені стани).

Стан історії можна реалізувати ззовні, прослуховуючи івенти автомата, але це призвело б до дуже складної логіки, особливо якщо автомат містить складні вкладені структури. У Spring Statemachine є можливість створити стан історії всередині самого автомата.

Користувачеві потрібно лише створити перехід у стан історії, а машина станів обробляє необхідну логіку для повернення до останнього відомого записаного стану.

Можна використовувати дефолтний стан історії, коли автомат не встигає перейти в стан історії. Це потрібно, щоб система змогла відновитись до дефолтного стану, навіть якщо не було першого переходу в стан історії.

Regions

Регіони це інструмент який дозволяє об’жднувати машини станів таким чином, щоб працювати з однією машною станів. Вони дозволяють сутності одночасно перебувати в декількох станах і створити незалежні переходи всередині окремих регіонів.

Регіон А та Б всередині одного батьківського стану. Кожен має незалежний перехід.

Було б трохи незручно працювати з двома різними автоматами як з абсолютно окремими об'єктами, оскільки вони все одно певним чином працюють разом. Ця незалежність дозволяє ортогональним регіонам об'єднуватися разом у декількох одночасних станах у межах одного батьківського стану в одному автоматі.

Entry Point & Exit Point

Всередині автомата можуть бути різні регіони станів з визначеною між собою логікою. Точка воходу це стан через який відбувається перехід в регіон. Точка виходу є станом через який виходять з регіону.

Fork & Join

Форк використовується, щоб одночасно перейти в два регіони.

Джоїн використовується, щоб об’єдднати декілька переходів з різних регіонів у один.

Guard Conditions

Обмежувальні умови - це вирази, які визначені як TRUE або FALSE на основі розширених змінних стану та параметрів івентів. Ці умови використовуються, щоб визначати, яка дія, або перехід повинен виконатись.

Ви можете зробити Choice з одним входом і виходом, якщо створите два стейти, перехід між якими буде захищений обмежувальною умовою.

Events

Івенти це один з способів створити тригер для переходу стану (Інший це таймер). Іноді івенти називають сигналами.

Transitions

Транзиції, або переходи - це зв’язки між станами в системі, які визначають від якого до якого стану може переходити автомат.

Internal Transition

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

External versus Local Transitions

У більшості випадків зовнішні та локальні переходи функціонально еквівалентні, за винятком випадків, коли перехід відбувається між над- та підстанами. Локальні переходи не спричиняють виходу та входу у вихідний стан, якщо цільовий стан є підстаном вихідного стану. І навпаки, локальні переходи не викликають виходу і входу в цільовий стан, якщо цільовий стан є надстаном вихідного стану. Наступне зображення показує різницю між локальними та зовнішніми переходами з дуже спрощеними супер- та підстанами:

Triggers

Тригери починають транзицію між станами. Тригери репрезентуються івентами, або таймерами.

Actions

Дії це код, який виконується при переході до іншого стану. Якщо стан змінився з numlock off на numlock on, то повинна відбутись дія, яка розблокує ці numpad кнопки на клавіатурі.

Дії зазвичай мають доступ до контексту станів, який дає можливість коду взаємодіяти з машиною станів різними способами. Дія знає про івент, про те звідки він прийшов, яку інформацію приніс і в який стан змусив перейти.

Hierarchical State Machines

Концепція ієрархічного автомата станів використовується для спрощення проектування станів, коли певні стани повинні існувати разом. Ви об’єднуєте декілька станів у один стан, як абстрактний клас та його реалізації, раніше у пості я називав це батьківським станом.

Приклад програми

Діаграма

ESTART - це івент із значенням, яке валідують різні guards, що прив’язані до різних переходів. Всі інші переходи в автоматі здійснюються по таймеру.

Код

Maven

                <dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.springframework.statemachine</groupId>
			<artifactId>spring-statemachine-core</artifactId>
			<version>4.0.0</version>
		</dependency>
		<dependency>
			<groupId>com.guicedee.services</groupId>
			<artifactId>sl4j</artifactId>
			<version>1.0.13.5</version>
		</dependency>

StateMachineConfig

@Configuration
@Slf4j
@EnableStateMachineFactory
public class StateMachineConfig extends StateMachineConfigurerAdapter<States, Events> {
    @Bean
    public StateMachine<States, Events> stateMachine(StateMachineListener<States, Events> listener) throws Exception {
        StateMachineBuilder.Builder<States, Events> builder = StateMachineBuilder.builder();
        int twoSeconds = 2000;

        builder.configureConfiguration()
                .withConfiguration()
                .autoStartup(true)
                .listener(listener);

        builder.configureStates()
                .withStates()
                .initial(States.INIT_STATE)
                .choice(CHOOSING_STATE)
                .end(S1)
                .state(States.S2)
                .state(States.S3)
                .state(States.SS45)
                .and()
                .withStates()
                .parent(States.SS45)
                    .initial(States.S4)
                    .state(States.S4)
                    .state(States.S5)
                .and()
                .withStates()
                .history(States.HISTORY, StateConfigurer.History.SHALLOW);

        builder.configureTransitions()
                .withExternal()
                .source(States.INIT_STATE).target(CHOOSING_STATE).event(Events.ESTART)
                .and()
                .withChoice()
                    .source(CHOOSING_STATE)
                    .first(S1, context -> context.getEvent().getValue() == 2)
                    .then(States.S2, context -> context.getEvent().getValue() == 0, context -> log.info("Action function have been performed!. Random number {}", ThreadLocalRandom.current().nextInt(1000)))
                    .then(States.S3, context -> context.getEvent().getValue() == 1)
                    .last(States.S5)
                .and()
                .withExternal()
                .source(States.S2).target(States.INIT_STATE).timer(twoSeconds)
                .and()
                .withExternal()
                .source(States.S3).target(States.S4).timer(twoSeconds)
                .and()
                .withExternal()
                .source(States.S4).target(States.S5).timer(twoSeconds)
                .and()
                .withExternal()
                .source(States.S5).target(States.INIT_STATE).timer(twoSeconds);

        StateMachine<States, Events> stateMachine = builder.build();

        stateMachine.addStateListener(listener);
        return stateMachine;
    }

    @Override
    public void configure(StateMachineConfigurationConfigurer<States, Events> config) throws Exception {
        config.withConfiguration()
                .autoStartup(true)
                .listener(listener());
    }

    @Bean
    public StateMachineListener<States, Events> listener() {
        return new StateMachineListenerAdapter<>() {
            @Override
            public void stateChanged(State<States, Events> from, State<States, Events> to) {
                log.info("State changed to {}", to.getId());
            }
        };
    }

    public class BaseGuard implements Guard<States, Events> {

        @Override
        public boolean evaluate(StateContext<States, Events> context) {
            return false;
        }
    }
}

EventController

@RestController
@RequiredArgsConstructor
@Slf4j
public class EventController {
    private final StateMachine<States, Events> stateMachineb;

    @GetMapping("/{id}")
    public void sendEventId(@PathVariable("id") int id) {
        stateMachineb.sendEvent(Events.getById(id));
    }

    public States getCurrentState() {
        State<States, Events> currentState = stateMachineb.getState();
        if (currentState != null) {
            return currentState.getId();
        } else {
            return null;
        }
    }
}

Events

Для того, щоб змінити поведінку проходження по автомату змініть значення для ESTART з 1 на 0, або 2.
@Getter
@RequiredArgsConstructor
public enum Events {
    // Configure here value
    ESTART(0, 1);

    private final int id;
    private final int value;

    public static Events getById(int id) {
        for (Events event : Events.values()) {
            if (event.getId() == id) {
                return event;
            }
        }
        throw new IllegalArgumentException("No event found with ID: " + id);
    }
}

States


public enum States {
    INIT_STATE,
    CHOOSING_STATE,
    S1,
    S2,
    S3,
    S4,
    S5,
    SS45,
    HISTORY
}

Наш лісенер логуватиме кожну зміну стейту, том в логах ви будете бачити щось схоже на:

2024-05-01T23:41:24.896+03:00  INFO 16920 --- [demo] [           main] c.e.s.demo.config.StateMachineConfig     : State changed to INIT_STATE
2024-05-01T23:41:26.890+03:00  INFO 16920 --- [demo] [nio-8081-exec-2] c.e.s.demo.config.StateMachineConfig     : State changed to S3
2024-05-01T23:41:28.875+03:00  INFO 16920 --- [demo] [     parallel-4] c.e.s.demo.config.StateMachineConfig     : State changed to S4
2024-05-01T23:41:30.863+03:00  INFO 16920 --- [demo] [     parallel-5] c.e.s.demo.config.StateMachineConfig     : State changed to S5
2024-05-01T23:41:32.875+03:00  INFO 16920 --- [demo] [     parallel-3] c.e.s.demo.config.StateMachineConfig     : State changed to INIT_STATE

UML

У Spring Statemachine є модуль spring-statemachine-uml, який дозволяє створювати автомат на основі UML файла. Це можна робити через спеціальний фреймвор Eclypse Papyrus.

Необхідно написати просту конфігурацію для підтягування UML файла з ресурсів і створити сам файл.

Такий формат створення автомату спрощує візуалізацію вашої системи, але змушують редагувати усі ваші зміни в UML файлі.

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

    @Override
    public void configure(StateMachineModelConfigurer<String, String> model) throws Exception {
        model
            .withModel()
                .factory(modelFactory());
    }

    @Bean
    public StateMachineModelFactory<String, String> modelFactory() {
        return new UmlStateMachineModelFactory("classpath:mypath/statemachine.uml");
    }
}

Моніторинг

Для моніторингу можна викорситовувати StateMachineMonitor . Через нього можна отримати більше інформації про тривалість переходів і стейтів.

public class TestStateMachineMonitor extends AbstractStateMachineMonitor<String, String> {

	@Override
	public void transition(StateMachine<String, String> stateMachine, Transition<String, String> transition,
			long duration) {
	}

	@Override
	public void action(StateMachine<String, String> stateMachine,
			Function<StateContext<String, String>, Mono<Void>> action, long duration) {
	}
}
@Configuration
@EnableStateMachine
public class Config1 extends StateMachineConfigurerAdapter<String, String> {

	@Override
	public void configure(StateMachineConfigurationConfigurer<String, String> config)
			throws Exception {
		config
			.withMonitoring()
				.monitor(stateMachineMonitor());
	}

	@Override
	public void configure(StateMachineStateConfigurer<String, String> states) throws Exception {
		states
			.withStates()
				.initial("S1")
				.state("S2");
	}

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

	@Bean
	public StateMachineMonitor<String, String> stateMachineMonitor() {
		return new TestStateMachineMonitor();
	}
}

Сек’юріті

Є спеціальний конфігуратор, щоб налаштовувати доступи до ваших стейтів - StateMachineConfigurationConfigurer . По дефолту сек’юріті виключений.

Включити сек’юріті

@Configuration
@EnableStateMachine
static class Config4 extends StateMachineConfigurerAdapter<String, String> {

	@Override
	public void configure(StateMachineConfigurationConfigurer<String, String> config)
			throws Exception {
		config
			.withSecurity()
				.enabled(true)
				.transitionAccessDecisionManager(null)
				.eventAccessDecisionManager(null);
	}
}

Захистиити івенти

@Configuration
@EnableStateMachine
static class Config1 extends StateMachineConfigurerAdapter<String, String> {

	@Override
	public void configure(StateMachineConfigurationConfigurer<String, String> config)
			throws Exception {
		config
			.withSecurity()
				.enabled(true)
				.event("true")
				.event("ROLE_ANONYMOUS", ComparisonType.ANY);
	}
}

Захистити переходи

@Configuration
@EnableStateMachine
static class Config6 extends StateMachineConfigurerAdapter<String, String> {

	@Override
	public void configure(StateMachineConfigurationConfigurer<String, String> config)
			throws Exception {
		config
			.withSecurity()
				.enabled(true)
				.transition("true")
				.transition("ROLE_ANONYMOUS", ComparisonType.ANY);
	}
}

Захистити дії

@Configuration
@EnableStateMachine
static class Config3 extends StateMachineConfigurerAdapter<String, String> {

	@Override
	public void configure(StateMachineConfigurationConfigurer<String, String> config)
			throws Exception {
		config
			.withSecurity()
				.enabled(true);
	}

	@Override
	public void configure(StateMachineStateConfigurer<String, String> states)
			throws Exception {
		states
			.withStates()
				.initial("S0")
				.state("S1");
	}

	@Override
	public void configure(StateMachineTransitionConfigurer<String, String> transitions)
			throws Exception {
		transitions
			.withExternal()
				.source("S0")
				.target("S1")
				.action(securedAction())
				.event("A");
	}

	@Scope(proxyMode = ScopedProxyMode.TARGET_CLASS)
	@Bean
	public Action<String, String> securedAction() {
		return new Action<String, String>() {

			@Secured("ROLE_ANONYMOUS")
			@Override
			public void execute(StateContext<String, String> context) {
			}
		};
	}

}
Поділись своїми ідеями в новій публікації.
Ми чекаємо саме на твій довгочит!
Yaroslav Kutsela
Yaroslav Kutsela@penrose

Java Software Engineer

3.2KПрочитань
1Автори
50Читачі
На Друкарні з 26 квітня

Більше від автора

  • Рівні ізоляції транзакцій у БД

    Доволі детальний огляд аномалій у БД, рівнів ізоляції, які дозволяються уникнути аномалії, та імплементації цих рівнів. Багато використовую джерела та свої коментарі, в кінці декілька чит-шитів.

    Теми цього довгочиту:

    Бази Даних
  • Функціональна залежність у БД

    Пост про функціональну залежність в реляційних множинах. Визначення. Повторення значень в атрибуті. Приклад з п'ятьма атрибутами. Тривіальна залежність. Замикання. залежностей та атрибутів. Незвідні множини. Використання

    Теми цього довгочиту:

    Програмування

Вам також сподобається

  • Розробляю свій застосунок (частина 1)

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

    Теми цього довгочиту:

    Стартап
  • Flutter state management. Порівняння Riverpod та Provider

    Вивчіть основи state management у Flutter: порівняйте Provider та Riverpod. Зрозумійте ключові концепції, переваги, недоліки та кращі сценарії використання кожної бібліотеки. Початок з Flutter ніколи не був простішим!

    Теми цього довгочиту:

    Програмування
  • RayLib — чудовий спосіб почати програмувати ігри.

    Хороший, але спартанський метод писати ігри багатьма мовами програмування. Якщо колись цікавила ця тема, то це чудовий спосіб почати шлях розробника ігор. Я розібрав сильні сторони цієї бібліотеки та пояснив чому з неї можна почати свій шлях. І не обов'язково починати на Unity.

    Теми цього довгочиту:

    Програмування

Коментарі (0)

Підтримайте автора першим.
Напишіть коментар!

Вам також сподобається

  • Розробляю свій застосунок (частина 1)

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

    Теми цього довгочиту:

    Стартап
  • Flutter state management. Порівняння Riverpod та Provider

    Вивчіть основи state management у Flutter: порівняйте Provider та Riverpod. Зрозумійте ключові концепції, переваги, недоліки та кращі сценарії використання кожної бібліотеки. Початок з Flutter ніколи не був простішим!

    Теми цього довгочиту:

    Програмування
  • RayLib — чудовий спосіб почати програмувати ігри.

    Хороший, але спартанський метод писати ігри багатьма мовами програмування. Якщо колись цікавила ця тема, то це чудовий спосіб почати шлях розробника ігор. Я розібрав сильні сторони цієї бібліотеки та пояснив чому з неї можна почати свій шлях. І не обов'язково починати на Unity.

    Теми цього довгочиту:

    Програмування