Ми з другом часто пишемо чат ботів з різним функціоналом, деякі з них комунікують між собою в мережі, деякі знайомлять людей, деякі агрегують новини.
Основна комунікація бота і юзера базується на станах юзера, які називаються стейтами. Юзер перебуває у різних стейтах і ці стейти по різному змінюються. Для того, щоб бот міг успішно відслідковувати і реагувати на поведінку юзера, зміну його стейту, потрібен окремий механізм. Цей механізм нам доводиться відтворювати у кожному боті, раз за разом придумуючи велосипед.
У 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) {
}
};
}
}