Вступление


В проектах я встречался с тремя примерами, так или иначе связанными с теорией конечных автоматов

  • Пример 1. Занимательный говнокод код. Занимает уйму времени, на то чтоб понять что происходит. Характерной особенностью воплощения обозначенной теории в коде является довольно лютая свалка, которая местами дико напоминает процедурный код. О том что этот вариант кода лучше не трогать на проекте знает каждый технолог, методолог и продуктолог. Заходят в этот код что-то поправить в случае крайней нужды(когда совсем сломалось), о доработке каких либо фичей речи не идет. Ибо сломать страшно. Второй яркой особенностью, обосабливающий данный тип является наличие могучих таких switch, на весь экран.
    На этот счет даже есть шутеечка:
    Оптимальный размер
    На каком то из JPoint, один из спикеров, возможно Николай Алименков рассказывал о том, сколько кейсов в switch норма, сказал что топ-ответ «пока влазит в экран». Соответственно если влазить перестало и ваш switch уже как бы не норм, берете и уменьшаете размер шрифта в IDE
  • Пример 2. Pattern State. Основная идея(для тех кто не любит переходить по ссылкам) заключается в том, что некую бизнес-задачу, мы разбиваем на набор конечных состояний и описываем их кодом.
    Основной недостаток Pattern State заключается в том, что состояния знают друг про друга, знают что есть братья и вызывают друг друга. Такой код довольно сложно сделать универсальным. Например при реализации платежной системы с несколькими типами платежей вы рискуете настолько закопаться в Generic-s, что декларация ваших методов может стать примерно такой:

    private <
          T extends BaseContextPayment,
          Q extends BaseDomainPaymentRequest,
          S,
          B extends AbstractPaymentDetailBuilder<T, Q, S, B>,
          F extends AbstractPaymentBuilder<T, Q, S, B>
          > PaymentContext<T, S> build(final Q request, final Class<F> factoryClass){
    //"несложная" реализация
    }

    Резюмируя по State: реализация может вылиться в довольно непростой код.
  • Пример 3 StateMachine Основная идея Pattern-а в том что состояния ничего не знают друг о друге, управление переходами осуществляет контекст, уже лучше, меньше связанности — проще код.

Прочувствовав всю «мощь» первого типа и всю сложность второго мы решили использовать Pattern StateMachine, для нового бизнес-кейса.
Чтобы не изобретать свой велосипед, за основу было решено взять Statemachine Spring-а(это-ж Spring).

После прочтения доки я пошел на Ютуб и Хабр (чтоб понять как с этим работают люди, как это чувствует себя на проде, какие грабли и т.д.) Выяснилось что информации немного, на Ютубе — пара-тройка видео, все довольно поверхностные. На Хабре по данной теме я нашел всего одну статью, так же как и видео, довольно поверхностную.
В одной статье не получится описать все тонкости работы Spring statemachine, пройтись по всей доке и описать все кейсы, но я постараюсь рассказать самое важное и востребованное, ну и про грабельки, конкретно мне, при знакомстве с framework-ом информация, изложенная ниже, была бы очень полезна.

Основная часть


Создадим Spring Boot приложение добавим стартер Web (получаем как можно быстрее работающее web-приложение).Приложение будет абстракцией на процесс покупки. Продукт при покупке будет проходить стадии new, reserved, reserved decline и purchase complete.
Небольшая импровизация, в реальном проекте статусов было бы больше, ну да ладно, у нас тоже вполне реальный проект.
В pom.xml вновь испеченного web-приложения добавим зависимость на машину и на тесты для нее (Web Starter уже должен быть, если собирали через start.spring.io):
<dependency>
    <groupId>org.springframework.statemachine</groupId>
    <artifactId>spring-statemachine-core</artifactId>
    <version>2.1.3.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.springframework.statemachine</groupId>
    <artifactId>spring-statemachine-test</artifactId>
    <version>2.1.3.RELEASE</version>
    <scope>test</scope>
</dependency>
<cut />

Создадим структуру:

Пока не надо особо вдаваться в подробности этой структуры, все буду пояснять последовательно, а на исходники в конце статьи будет ссылка.

Итак, поехали.
У нас есть чистый проект с нужными зависимостями, для начала создадим enum, с states и events, довольно простая абстракция, сами по себе эти компоненты не несут никакой логики.
public enum PurchaseEvent {
   RESERVE, BUY, RESERVE_DECLINE
}

public enum PurchaseState {
    NEW, RESERVED, CANCEL_RESERVED, PURCHASE_COMPLETE
}

Хотя формально, можно добавить в эти enum поля, и захардкодить в них что-нибудь, свойственное например, конкретному state, что довольно логично(мы так и поступили решая свой кейс, довольно удобно).
Конфигурить машину будем через java-конфиг, создадим конфиг-файл и за-extends-им класс EnumStateMachineConfigurerAdapter<PurchaseState, PurchaseEvent>. Так как наши state и event есть enum, то и интерфейс соответствующий, но это не обязательно, может быть использован совершенно любой тип объекта в качестве generic-ов(не рассматриваем другие примеры в статье, так как EnumStateMachineConfigurerAdapter на мой взгляд более чем достаточно).

Следующий важный момент одна ли машина будет жить в контексте приложения: в единственном экземпляре @EnableStateMachine, или каждый раз будет создаваться новая @EnableStateMachineFactory. Если это многопользовательское веб-приложение, с кучей пользователей, то едва ли первый вариант вам подойдет, поэтому мы будем использовать второй, как более популярный. StateMachine так же может быть создана через builder как обычный bean (см.документацию), что бывает удобно в отдельных случаях(например вам нужно чтобы машина обязательно была явно объявлена как bean), и если это отдельный бин, то мы можем указать ему свой scope, например session или request. В нашем проекте, над бином statemachine был реализован wrapper(особенности нашей бизнес-логики), wrapper был singleton, а сама машина prototype
Грабли
Как реализовать prototype в singlton-е?
По сути все что требуется сделать это получать каждый раз при обращении к объекту новый bean из applicationContext. Inject-ать applicationContext в бизнес-логику грех, поэтому bean statemachine должен либо реализовывать интерфейс с хотя бы одним методом, либо абстрактный метод, при создании в java — конфиге потребуется реализовать обозначенный абстрактный метод, а в реализации мы будем дергать из applicationContext новый bean. Иметь в config классе ссылку на applicationContext нормальная практика, и через абстрактный метод мы будет вызвать из контекста .getBean();

У класса EnumStateMachineConfigurerAdapter есть несколько методов, переопределяя которые мы настраиваем машинку.
Для начала зарегистрируем статусы:
    @Override
    public void configure(final StateMachineStateConfigurer<PurchaseState, PurchaseEvent> states) throws Exception {
        states
                .withStates()
                .initial(NEW)
                .end(PURCHASE_COMPLETE)
                .states(EnumSet.allOf(PurchaseState.class));

    }

initial(NEW) — это статус в котором будет находиться машина после создания bean-а, end(PURCHASE_COMPLETE) — статус зайдя в который машина выполнит statemachine.stop(), для недетерминированной машины(коих большинство) неактуально, но что-то указать надо. .states(EnumSet.allOf(PurchaseState.class) список всех статусов, можно пихать скопом.

Сконфигурим глобальный настройки машины
    @Override
    public void configure(final StateMachineConfigurationConfigurer<PurchaseState, PurchaseEvent> config) throws Exception {
        config
                .withConfiguration()
                .autoStartup(true)
                .listener(new PurchaseStateMachineApplicationListener());
    }

Здесь autoStartup определяет будет ли запущена машина сразу после создания по-умолчанию, иными словами — перейдет ли она автоматически в статус NEW(по умолчанию false). Тут же мы регистрируем listener для контекста машины(о нем чуть позже), в этом же конфиге можно задать отдельный TaskExecutor, что удобно тогда, когда на каком-то их переходов выполняется долгий Action, а приложение должно идти дальше.
Ну и сами переходы:
    @Override
    public void configure(final StateMachineTransitionConfigurer<PurchaseState, PurchaseEvent> transitions) throws Exception {
        transitions
                .withExternal()
                .source(NEW)
                .target(RESERVED)
                .event(RESERVE)
                .action(reservedAction(), errorAction())

                .and()
                .withExternal()
                .source(RESERVED)
                .target(CANCEL_RESERVED)
                .event(RESERVE_DECLINE)
                .action(cancelAction(), errorAction())

                .and()
                .withExternal()
                .source(RESERVED)
                .target(PURCHASE_COMPLETE)
                .event(BUY)
                .guard(hideGuard())
                .action(buyAction(), errorAction());
    }

Вся логика переходов или transitions задается тут, на переходы можно навешивать Guard, компонент который всегда возвращает boolean, что именно вы будете проверять на переходе из одного статуса в другой на ваше усмотрение, в Guard-е может быть совершенна любая логика, это совершенно обычный компонент, но вернуть он должен boolean. В рамках нашего проекта, например, HideGuard может проверять некую настройку, которую мог выставить пользователь(не показывать данный товар)и в соответствии с ней не пропускать машину в state защищенный Guard-ом. Отмечу что Guard на один переход в конфиге может быть добавлен только один, вот такая конструкция не сработает:
   .withExternal()
                .source(RESERVED)
                .target(PURCHASE_COMPLETE)
                .event(BUY)
                .guard(hideGuard())
                .guard(veryHideGuard())

Точнее сработает, но только первый guard(hideGuard())
А вот Action можно добавлять несколько(сейчас речь об Action, которые мы прописываем в конфигурации transitions), лично я пробовал добавлять три Action на один переход.
                .withExternal()
                .source(NEW)
                .target(RESERVED)
                .event(RESERVE)
                .action(reservedAction(), errorAction())

вторым аргументом идет ErrorAction, управление попадет к нему в случае если ReservedAction бросит исключение (throw е).
Грабли
Имейте в виду, что если в вашем Action, вы все-таки обработаете ошибку, через try/catch, то в ErrorAction вы уже не зайдете, если надо и обработать и зайти-таки в ErrorAction то следет бросить из catch RuntimeException(), например(вы же сами сказали что очень надо).

Помимо «навешивания» Action в transitions можно также «навешивать» их в методе configure для state, примерно в таком виде:
    @Override
    public void configure(final StateMachineStateConfigurer<PurchaseState, PurchaseEvent> states) throws Exception {
        states
                .withStates()
                .initial(NEW)
                .end(PURCHASE_COMPLETE)
                .stateEntry()
                .stateExit()
                .state()
                .states(EnumSet.allOf(PurchaseState.class));
    }

Все в зависимости от того как именно вы хотите запускать action
Грабли
Учтите, что если вы укажете action при конфигурировании state(), например так
        states
                .withStates()
                .initial(NEW)
                .end(PURCHASE_COMPLETE)
                .state(randomAction())

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

Окончательная версия конфига будет выглядеть так:
@Configuration
@EnableStateMachineFactory
public class StateMachineConfig extends EnumStateMachineConfigurerAdapter<PurchaseState, PurchaseEvent> {

    @Override
    public void configure(final StateMachineConfigurationConfigurer<PurchaseState, PurchaseEvent> config) throws Exception {
        config
                .withConfiguration()
                .autoStartup(true)
                .listener(new PurchaseStateMachineApplicationListener());
    }

    @Override
    public void configure(final StateMachineStateConfigurer<PurchaseState, PurchaseEvent> states) throws Exception {
        states
                .withStates()
                .initial(NEW)
                .end(PURCHASE_COMPLETE)
                .stateEntry()
                .stateExit()
                .state()
                .states(EnumSet.allOf(PurchaseState.class));
    }

    @Override
    public void configure(final StateMachineTransitionConfigurer<PurchaseState, PurchaseEvent> transitions) throws Exception {
        transitions
                .withExternal()
                .source(NEW)
                .target(RESERVED)
                .event(RESERVE)
                .action(reservedAction(), errorAction())

                .and()
                .withExternal()
                .source(RESERVED)
                .target(CANCEL_RESERVED)
                .event(RESERVE_DECLINE)
                .action(cancelAction(), errorAction())

                .and()
                .withExternal()
                .source(RESERVED)
                .target(PURCHASE_COMPLETE)
                .event(BUY)
                .guard(hideGuard())
                .action(buyAction(), errorAction());
    }

    @Bean
    public Action<PurchaseState, PurchaseEvent> reservedAction() {
        return new ReservedAction();
    }

    @Bean
    public Action<PurchaseState, PurchaseEvent> cancelAction() {
        return new CancelAction();
    }

    @Bean
    public Action<PurchaseState, PurchaseEvent> buyAction() {
        return new BuyAction();
    }

    @Bean
    public Action<PurchaseState, PurchaseEvent> errorAction() {
        return new ErrorAction();
    }

    @Bean
    public Guard<PurchaseState, PurchaseEvent> hideGuard() {
        return new HideGuard();
    }

    @Bean
    public StateMachinePersister<PurchaseState, PurchaseEvent, String> persister() {
        return new DefaultStateMachinePersister<>(new PurchaseStateMachinePersister());
    }

Обратите внимание на схему автомата, на ней очень хорошо видно что именно мы закодировали (какие переходы по каким event допустимы, какой Guard защищает статус и что будет выполнено при переходе в статус, какой Action).

Сделаем контроллер:
@RestController
@SuppressWarnings("unused")
public class PurchaseController {

    private final PurchaseService purchaseService;

    public PurchaseController(PurchaseService purchaseService) {
        this.purchaseService = purchaseService;
    }

    @RequestMapping(path = "/reserve")
    public boolean reserve(final String userId, final String productId) {
        return purchaseService.reserved(userId, productId);
    }

    @RequestMapping(path = "/cancel")
    public boolean cancelReserve(final String userId) {
        return purchaseService.cancelReserve(userId);
    }

    @RequestMapping(path = "/buy")
    public boolean buyReserve(final String userId) {
        return purchaseService.buy(userId);
    }

}


интерфейс сервиса
public interface PurchaseService {
    /**
     * Резервирование товара перед покупкой, зарезервированный товар может находиться в корзине сколько угодно долго
     *
     * @param userId    id пользователя, так как приложение простое, для того чтоб различать пользователей id будем
     *                  принимать прямо в http-запросе
     * @param productId id продукта, который начинает процедуру покупки
     * @return успешная/не успешная операция, в нашем примере операция может стать не успешной если при попытке восстановить
     * машину их импровизированного репозитория произойдет ошибка.
     */
    boolean reserved(String userId, String productId);

    /**
     * Отмена резервирования товара/удаление из пользовательской корзины
     *
     * @param userId id пользователя, так как приложение простое, для того чтоб различать пользователей id будем
     *               принимать прямо в http-запросе
     * @return успешная/не успешная операция, в нашем примере операция может стать не успешной если при попытке восстановить
     * машину их импровизированного репозитория произойдет ошибка.
     */
    boolean cancelReserve(String userId);

    /**
     * Покупка ранее зарезервированного товара
     *
     * @param userId id пользователя, так как приложение простое, для того чтоб различать пользователей id будем
     *               принимать прямо в http-запросе
     * @return успешная/не успешная операция, в нашем примере операция может стать не успешной если при попытке восстановить
     * машину их импровизированного репозитория произойдет ошибка.
     */
    boolean buy(String userId);
}

Грабли
А вы знаете, почему работая со Spring важно создавать bean через интерфейс? Столкнулись с этой проблемой(ну да-да и Женя Борисов рассказывал в потрошителе), когда однажды в контроллере попытались за-implement-ить самодельный не пустой интерфейс. Spring создает прокси на компоненты, и если компонент не реализует ни один интерфейс, то он сделает это через CGLIB, но как только вы реализуете какой-то интерфейс — Spring попытается создать прокси через dynamic-прокси, в результате вы получите непонятный тип объекта и NoSuchBeanDefinitionException.

Следующий важный момент, это то как вы будете восстанавливать состояние своей машины, ведь на каждое обращение будет создан новый bean, который ничего про ваши предыдущие статусы машины и ее контекст не знает.
Для этих целей в spring statemachine есть механизм Persistens:
public class PurchaseStateMachinePersister implements StateMachinePersist<PurchaseState, PurchaseEvent, String> {

    private final HashMap<String, StateMachineContext<PurchaseState, PurchaseEvent>> contexts = new HashMap<>();

    @Override
    public void write(final StateMachineContext<PurchaseState, PurchaseEvent> context, String contextObj) {
        contexts.put(contextObj, context);
    }

    @Override
    public StateMachineContext<PurchaseState, PurchaseEvent> read(final String contextObj) {
        return contexts.get(contextObj);
    }
}

Для нашей наивной реализации мы используем в качестве хранилища состояний обычную Map, в ненаивной реализации это будет какая-то БД, обратите внимание на третий generic типа String, это ключ по которому будет сохраняться состояние вашей машины, со всеми статусами, переменными в контексте, id и тд. В своей примере я использовал id пользователя для ключа сохранения, что может быть указан совершенно любой ключ(session_id пользователя, уникальный логин и т.д.).
Грабли
В нашем проекте механизм сохранения и восстановления состояний из коробки нам не подошел, так как статусы машины мы хранили в БД и их мог менять job, который ничего не знал о машине.
Пришлось завязываться на статус полученный из БД, делать некий InitAction который при старте машины получал статус из БД, и выставлял его принудительно, и только потом бросал event, пример кода который выполняет вышесказанное:
stateMachine
                .getStateMachineAccessor()
                .doWithAllRegions(access -> {
                    access.resetStateMachine(new DefaultStateMachineContext<>({ResetState}, null, null, null, null));
                });
stateMachine.start();
stateMachine.sendEvent({NewEventFromResetState});


Реализацию сервиса рассмотрим в каждом методе:
    @Override
    public boolean reserved(final String userId, final String productId) {
        final StateMachine<PurchaseState, PurchaseEvent> stateMachine = stateMachineFactory.getStateMachine();
        stateMachine.getExtendedState().getVariables().put("PRODUCT_ID", productId);
        stateMachine.sendEvent(RESERVE);
        try {
            persister.persist(stateMachine, userId);
        } catch (final Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

Получаем из фабрики машину, кладем в контекст машины параметр, в нашем случае это некий productId, контекст представялет собой своеобразную коробку в которую можно складывать все что требуется, везде где есть доступ к бину statemachine или ее контексту, так как машина при старте контекста запускается автоматически, то после старта наша машина будет в статусе NEW, бросаем event на резервирование товара.

Оставшиеся два метода похожи:
    @Override
    public boolean cancelReserve(final String userId) {
        final StateMachine<PurchaseState, PurchaseEvent> stateMachine = stateMachineFactory.getStateMachine();
        try {
            persister.restore(stateMachine, userId);
            stateMachine.sendEvent(RESERVE_DECLINE);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

    @Override
    public boolean buy(final String userId) {
        final StateMachine<PurchaseState, PurchaseEvent> stateMachine = stateMachineFactory.getStateMachine();
        try {
            persister.restore(stateMachine, userId);
            stateMachine.sendEvent(BUY);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

Здесь мы сначала восстанавливаем состояние машины для userId конкретного пользователя, а затем бросаем event, который соответствует методу api.
Заметьте, что productId в методе уже не фигурирует, мы добавили его в контекст машины и достанем после восстановления машины из ее бэкапа.
В реализации Action мы получим id продукта из контекста машины и выведем в лог сообщение, соответствующее переходу, для примера приведу код ReservedAction:
public class ReservedAction implements Action<PurchaseState, PurchaseEvent> {
    @Override
    public void execute(StateContext<PurchaseState, PurchaseEvent> context) {
        final String productId = context.getExtendedState().get("PRODUCT_ID", String.class);
        System.out.println("Товар с номером " + productId + " зарезервирован.");
    }
}

Нельзя не упомянуть про listener, который из коробки предлагает довольно много сценариев, на которые его можно навесить, посмотрите сами:
public class PurchaseStateMachineApplicationListener implements StateMachineListener<PurchaseState, PurchaseEvent> {
    @Override
    public void stateChanged(State<PurchaseState, PurchaseEvent> from, State<PurchaseState, PurchaseEvent> to) {
        if (from.getId() != null) {
            System.out.println("Переход из статуса " + from.getId() + " в статус " + to.getId());
        }
    }

    @Override
    public void stateEntered(State<PurchaseState, PurchaseEvent> state) {

    }

    @Override
    public void stateExited(State<PurchaseState, PurchaseEvent> state) {

    }

    @Override
    public void eventNotAccepted(Message<PurchaseEvent> event) {
        System.out.println("Евент не принят " + event);
    }

    @Override
    public void transition(Transition<PurchaseState, PurchaseEvent> transition) {

    }

    @Override
    public void transitionStarted(Transition<PurchaseState, PurchaseEvent> transition) {

    }

    @Override
    public void transitionEnded(Transition<PurchaseState, PurchaseEvent> transition) {

    }

    @Override
    public void stateMachineStarted(StateMachine<PurchaseState, PurchaseEvent> stateMachine) {
        System.out.println("Machine started");
    }

    @Override
    public void stateMachineStopped(StateMachine<PurchaseState, PurchaseEvent> stateMachine) {

    }

    @Override
    public void stateMachineError(StateMachine<PurchaseState, PurchaseEvent> stateMachine, Exception exception) {
    }

    @Override
    public void extendedStateChanged(Object key, Object value) {

    }

    @Override
    public void stateContext(StateContext<PurchaseState, PurchaseEvent> stateContext) {

    }
}

Единственная проблема в том, что это интерфейс, а значит нужно все эти методы реализовать, но так как они вряд ли понадобятся вам все, часть из них будет висеть пустыми, на что coverage скажет что методы не покрыты тестами.
Тут в lisener-е мы можем навесить совершенно любые метрики, на совершенно разные события машины(Например не проходят платежи, машина часто уходит в какой-то статус PAYMENT_FAIL, слушаем переходы, и если машина зашла в ошибочный статус — пишем, в одтельный лог, или базу или зовем милицию, как угодно).
Грабли
В lisener-e есть event stateMachineError, но он с нюансом, когда у вас случается исключение и вы обрабатываете его в catch, машина не считает что была ошибка, в catch нужно говорить явно
stateMachine.setStateMachineError(exception) и передавать ошибку.

В качестве проверки того что мы сделали выполним два кейса:
  • 1. Резервирование и последующий отказ от покупки. Отправим приложению запрос на URI "/reserve", с параметрами userId=007, productId=10001, а следом за ним запрос "/cancel" c параметром userId=007 вывод консоли будет следующим:
    Machine started
    Товар с номером 10001 зарезервирован.
    Переход из статуса NEW в статус RESERVED
    Machine started
    Резервирование товара 10001 отменено
    Переход из статуса RESERVED в статус CANCEL_RESERVED
  • 2. Резервирование и успешная покупка:
    Machine started
    Товар с номером 10001 зарезервирован.
    Переход из статуса NEW в статус RESERVED
    Machine started
    Товар с номером 10001 успешно куплен
    Переход из статуса RESERVED в статус PURCHASE_COMPLETE

Заключение


В заключении приведу пример тестирования фреймфорка, думаю из кода все станет понятно, необходима лишь зависимость на тест машины, и можно проверять конфигурацию декларативно.
   @Test
    public void testWhenReservedCancel() throws Exception {
        StateMachine<PurchaseState, PurchaseEvent> machine = factory.getStateMachine();
        StateMachineTestPlan<PurchaseState, PurchaseEvent> plan =
                StateMachineTestPlanBuilder.<PurchaseState, PurchaseEvent>builder()
                        .defaultAwaitTime(2)
                        .stateMachine(machine)
                        .step()
                        .expectStates(NEW)
                        .expectStateChanged(0)
                        .and()
                        .step()
                        .sendEvent(RESERVE)
                        .expectState(RESERVED)
                        .expectStateChanged(1)
                        .and()
                        .step()
                        .sendEvent(RESERVE_DECLINE)
                        .expectState(CANCEL_RESERVED)
                        .expectStateChanged(1)
                        .and()
                        .build();
        plan.test();
    }

    @Test
    public void testWhenPurchaseComplete() throws Exception {
        StateMachine<PurchaseState, PurchaseEvent> machine = factory.getStateMachine();
        StateMachineTestPlan<PurchaseState, PurchaseEvent> plan =
                StateMachineTestPlanBuilder.<PurchaseState, PurchaseEvent>builder()
                        .defaultAwaitTime(2)
                        .stateMachine(machine)
                        .step()
                        .expectStates(NEW)
                        .expectStateChanged(0)
                        .and()
                        .step()
                        .sendEvent(RESERVE)
                        .expectState(RESERVED)
                        .expectStateChanged(1)
                        .and()
                        .step()
                        .sendEvent(BUY)
                        .expectState(PURCHASE_COMPLETE)
                        .expectStateChanged(1)
                        .and()
                        .build();
        plan.test();
    }

Грабли
Если вам вдруг захочется протестировать вашу машину без поднятия контекста, обычными unit-тестами, то можно создать машину через builder(частично рассматривалоcь выше), создать экземпляр класса с конфигом и получить оттуда action и guard, будет работать и без контекста, можно написать небольшой тестовый фреймворк на mock-ах, в нем плюсом можно будет проверить какие Action вызывались, какие нет, сколько раз и тд, на разные кейсы.

P.S


Наша машина работает на продуктиве, пока что никаких проблем с эксплуатацией мы не встретили, в будущем грядет фича, в которой мы сможем использовать подавляющее большинство компонентов текущей машины, при реализации новой(Guard-ы и некоторые Action-ы подходят просто идеально)

Примечание


Не рассматривал в статье, но хочется упомянуть про такие возможности как choice, это своеобразный триггер, который работает по принципу switch, где на кейсы навешиваются Guards, и машина поочередно пытается пойти в тот стейт, который описан в choice конфиге и куда его пустит Guard, без какие то Events, это удобно, когда при инициализации машины, нам необходимо автоматически перейти в какой то псевдостейт.

Ссылки


Дока
Исходники

Комментарии (22)


  1. dididididi
    04.08.2019 11:36
    -2

    Заменить примитивные if на конфигурирование еще одной библиотечки? В чем плюсы то, ну кроме того, что вас не уволят, потому что никто кроме вас в этом коде не разберется?


    1. nshipyakov
      04.08.2019 13:30

      Спорное утверждение. Данный инструмент должен уменьшить объем кода и как следствие проект меньше завязан на разработчика. Инфу же по этой либе любой может легко в доках прочитать.
      Запутанная в ifах логика же наоборот разработчика делает более «бесценным» так как только он понимает, что там происходит


  1. nshipyakov
    04.08.2019 13:06
    +1

    Спасибо за статью. Скажите а данная машина состояний персистентна? Что будет если приложение остановили, когда процесс был в каком — то промежуточном статусе(не в начальном и не в end), а затем включим: процесс будет потерян, продолжится или как?
    PS: если это было в статье и я не заметил ответ на мой вопрос — прошу прощения


    1. mypanacea87 Автор
      04.08.2019 13:13
      +1

      Добрый день. Тут будет зависеть от scope машины, если она singleton, и она находится в каком то промежуточном статусу, то она будет находиться в нем до тех пор пока не остановится спринговый контекст, либо пока вы явно не скажете ей .stop()(после этого она перейдет в конечное состояние, которое вы закодировали). В случае если вы получаете машину из фабрики, то при каждом обращении к сервису будет создаваться новая машина, соответственно при каждом запросе вы сохраняете ее состояние в базу или в Map, как у меня в примере, а при повторном запросе восстанавливаете ее, то есть если машина в промежуточном состоянии и у вас настроен персистинг, то вы вернетесь в то же самое промежуточное состояние.


      1. Jhumper
        05.08.2019 14:23

        Добрый день! Спасибо за статью. У меня продолжение для предыдущего вопроса. Если машина была остановлена когда уже получила новый евент, но ещё не успела обработать его можно ли как-то зареплаить этот евент при восстановлении чтобы не перепосылать (тут вопрос наверно про транзакционность и DR)? И есть ли возможность каких-то STP. Например для мы кинули евент RESERVED, а машина дальше допинала через BUY по определённому условию?


        1. mypanacea87 Автор
          05.08.2019 14:25

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


          1. Jhumper
            05.08.2019 14:38

            Ок. Вопрос про STP остаётся актуален. По первому вопросу пример такой. Допустим машина получила RESERVED, записала в БД состояние, после клиент кинул BUY, машина его получила, прошёл платёж, а дальше сервис упал. После восстановления будем иметь неконсистентный заказ. Если клиент ещё раз кинет BUY, то деньги спишутся ещё раз, но и служба доставки его не получила. Как всё таки допинать до терминального статуса такой кейс?


            1. PrinceKorwin
              05.08.2019 15:16

              В вашем случае можно (нужно) декомпозировать модель состояний. Т.е. «деньги списаны» и «запрос доставлен в службу доставки» должны быть разными состояниями со своими переходами и логикой.
              Если я правильно вас понял, то на одно состояние у вас завязаны две независимые логики, что не есть хорошо.


              1. Jhumper
                05.08.2019 15:37

                А здесь уже возникает опять вопрос STP. Так как из «деньги списаны» в «запрос в доставке» переход должен проходить автоматически, т.е. сервис после восстановления работы должен сам решать такие кейсы, но есть много примеров, в которых не имеет смыслка декомпозиция иначе получится «Запрос на резервирование получен» -> «Запрос на резервирование доставлен в обработчик» -> «Запрос на резервирование в обработке» -> «Запрос на резервирование обработан» -> «Товар зарезервирован». И эти степы никакого отношения к бизнес процессу иметь не будут


                1. PrinceKorwin
                  05.08.2019 16:03

                  Согласен. Не стоит всё доводить до абсурда, в том числе и дробление модели состояний.
                  В вашем примере уже фигурирует before, after, in_progress что само по себе уже явно намекает, что так дробить состояния не нужно и пора бы уже остановиться.
                  В примере выше — две независимые бизнес-логики тем более общающиеся (скорее всего) с разными внешними системами.
                  Тут можно решение обыграть через бронирование.Т.е. перед оплатой бронируется позиция на складе, идет оплата, бронь подтверждается. При таком подходе у системы шансов больше выжать и не потерять заказ (и товар).

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

                  Если такое требование есть — то, возможно, лучшим решением будет использовать BPMN движки или Саги.

                  Бывает, что достаточно фонового процесса следящего за «подвисшими» сущностями и запускающий альтернативную цепочку состояний.


                1. mypanacea87 Автор
                  05.08.2019 17:14

                  Допустим вы находитесь в статусе RESERVED, совешаете покупку, летит Event по которому должно произойти списание денег, и переход в статус BUY, логика на списание заложена в неком Action, вы делаете метод на списание транзакционным, навешиваете Action в конфигурации States, я в статье писал про этонемного, так вот, там сработка Action будет немного по другому, вы сначала зайдете в статус, а уже потому выполнится ваш транзакционный метод, если вдруг в нем случится ошибка машина вернется в предыдущее состояние, и списания не произойдет. Там разные комбинации на который можно навешивать ваши Action, stateEntry, stateExit, state, то есть можно настроить так, чтоб перестраховаться от того что вы упали сразу после списания


  1. usharik
    04.08.2019 13:13

    А является ли Spring State Machine альтернативой для Spring WebFlow?


    1. Raspy
      04.08.2019 14:03

      Они немного в параллельных плоскостях лежат. Спринт ВебФлоу скорее про переходы между страничками в браузере и имеет большое количество завязок на http-протокол и веб сервер в целом.


  1. Avvero
    04.08.2019 15:35

    Интересная статья, не знал что в spring и такое уже завезли.
    А для решения подобных задач вы не смотрели в сторону bpmn или drools? Если в первой, как мне кажется, сложные графы переходов РИСОВАТЬ проще, то вторая дюже удобна, когда много мелкой логики.


    1. mypanacea87 Автор
      04.08.2019 15:42

      у нас в проекте был реализован паттерн State, сложновато конечно выглит(много дженериков), но работает как часы и добавление новой логики на разных этапах нашего бизнес кейса получилось довольно простое и прозрачное. Когда пришла очередь добавлять StateMachine для другого кейса, я точно с таким же удивлением как и вы обнаружил что есть решение от Spring. Ну и на нашу логику все просто идеально легко, так что по сторонам уже даже и не смотрели, у нас намечается третий кейс в проекте, который так же будет решаться Spring машиной, и вот огромным плюсом будет то, что вы сможем часть компонентов из предыдущей машины задействовать. Машина по сути это именно некий каркас в вашей бизнес-логике, вы можете в нее заинжектать любые сервисы, которые дергали бы из if-ов.


  1. victor_2004
    04.08.2019 18:05
    +1

    Скорее всего статья на хабре была моя :-) (StateMachine в протоколе РОСЭУ).
    У нас логика перехода состояний документа в документообороте сейчас на ней сделана. Работой вполне довольны. Единственно что не сразу поняли — в экшны лучше по минимуму логики вкладывать и просто больше экшнов делать.

    Удобно, что через пару замен в коде можно UML диаграмму получить. И наоборот — UML диаграмму легко в код перевести.

    Кстати, в sendEvent можно отправить не только ивент, но и Message с ивентом. В хедерах которого можно протащить доп информацию и использовать ее в экшенах.

    А так в целом — спасибо за более подробный разбор библиотеки. На мой взгляд стейт машина на много лучше, чем куча if/else и switch.


    1. mypanacea87 Автор
      04.08.2019 18:11

      Доброго времени суток. Про Message совершенно верно подметили, есть такая возможность, но нам показалось более удобным передавать информацию через контекст. По поводу Action у нас появилось понимание довольно быстро, как только стало ясно что в конфигурации tarnsition можно навешивать много экшенов на один переход.
      Статья была Ваша, извиняюсь если чем то обидел, поверьте, даже в мыслях не было. А так, ваш проект получается третий из тех что я знаю(наш, ваш, и еще в Nexign) где спринг стейт машина работает на проде.)


  1. Danik-ik
    04.08.2019 18:29

    Смотрю, и вижу прямо то, что буквально вчера думал, как и где найти. Сейчас как раз размышляю над стейт-машиной для обеспечения воркфлоу взаимодействия учётной системы предприятия с государственными учётными системами, типа ФГИС Меркурий. На первый взгляд — ровно то, что надо, и даже сохранение состояний в базу. И всё же терзают сомнения…

    Насколько оправданным может быть использование данной библиотеки вне Спринга, в проекте на Java EE? Не подтянет ли тонну лишних спринговых зависимостей?


  1. aleksandy
    05.08.2019 11:32

    Как реализовать prototype в singlton-е?

    ObjectFactory. Да, сервис придётся завязывать на спринговый интерфейс, но это лучше, чем завязка на контекст.

    Плюсом идёт отсутствие необходимости в поднятии спрингового контекста для тестирования.

    Ещё можно через BeanFactoryAware запилить, но это, по-сути, та же завязка на контекст приложения.


  1. mypanacea87 Автор
    05.08.2019 12:30

    Основное преимущество описанного мной подхода в том что это будет возможность протестировать машину через unit-тест, а не через интеграционный или функциональный, если у вас в интеграционном тесте перед каждым тестом в тестовом профиле накатываются схемы в БД, таблицы и прочее прочее прочее, то ваши интеграционные тесты со временем нормально так затягиваются, и вы точно предпочтете тестировать через Unit, если будет такая возможность


  1. iboltaev
    05.08.2019 13:12

    для таких задач лучше использовать Akka. Имхо.


    1. time2rfc
      05.08.2019 14:16

      Тоже подумал про FSM но есть ощушение что затаскивание без всей akka инфраструкту?ры не лучшая идея.