Заводим Spring StateMachine

в 13:57, , рубрики: java, patterns, spring, statemachine, web

Вступление

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

  • Пример 1. Занимательный говнокод код. Занимает уйму времени, на то чтоб понять что происходит. Характерной особенностью воплощения обозначенной теории в коде является довольно лютая свалка, которая местами дико напоминает процедурный код. О том что этот вариант кода лучше не трогать на проекте знает каждый технолог, методолог и продуктолог. Заходят в этот код что-то поправить в случае крайней нужды(когда совсем сломалось), о доработке каких либо фичей речи не идет. Ибо сломать страшно. Второй яркой особенностью, обосабливающий данный тип является наличие могучих таких switch, на весь экран. На этот счет даже есть шутеечка:

    Оптимальный размер
    На каком то из JPoint, один из спикеров, возможно Николай Алименков рассказывал о том, сколько кейсов в switch норма, сказал что топ-ответ «пока влазит в экран». Соответственно если влазить перестало и ваш switch уже как бы не норм, берете и уменьшаете размер шрифта

  • Пример 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, но я постараюсь рассказать самое важное и востребованное, ну и про грабельки.

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

Создадим 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 />

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

Заводим Spring StateMachine - 1

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

Итак, поехали.

У нас есть чистый проект с нужными зависимостями, для начала создадим 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 машины в java-конфиг), и если это отдельный бин, то мы можем указать ему свой scope, например session или request. В нашем проекте, над бином statemachine был реализован wrapper(особенности нашей бизнес-логики), wrapper был singleton, а сама машина prototype

Грабли

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

По сути все что требуется сделать это получать каждый раз при обращении к объекту новый bean из applicationContext. Inject-ать applicationContext в бизнес-логику грех, поэтому bean statemachine должен либо реализовывать интерфейс с хотя бы одним методом, либо абстрактный метод, который при объявлении в java — конфиге будет реализован, а в реализации мы запросим у applicationContext новый bean, а иметь в config классе ссылку на applicationContext нормальная практика, и через абстрактный метод мы будет вызвать из контекста .getBean();

У класса EnumStateMachineConfigurerAdapter есть несколько методов, которые мы можем переопределить согласно своим states и events.

Для начала зарегистрируем статусы:

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

    }

Initial это статус в котором будет находиться машина после создания bean-а, end — статус зайдя в который машина будет считать что она отработала, для не детерминированной машины(коих большинство) неактуально, но что то указать надо. States список всех статусов, можно пихать скопом.

Сконфигурим глобальный настройки машины

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

Здесь autoStartup определяет будет ли запущена ваша машина сразу после создания по умолчанию как и все boolean флаг выставлен в false. Тут же мы можем зарегистрировать слушателя для контекста машины(о нем чуть позже), задать отдельный 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());
    }

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

   .withExternal()
                .source(RESERVED)
                .target(PURCHASE_COMPLETE)
                .event(BUY)
                .guard(hideGuard())
                .guard(veryHideGuard())

Точнее сработает, но только первый guard(hideGuard()).

А вот action() можно добавлять несколько(сейчас речь об action которые мы прописываем в конфигурации transitions), лично я пробовал 3 и все работало. Action могут быть навешены непосредственно при осуществлении перехода:

                .withExternal()
                .source(NEW)
                .target(RESERVED)
                .event(RESERVE)
                .action(reservedAction(), 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 конфиге, благо навешивать их можно по нескольку на один переход.

Все Action и Guard обычные bean-ы, реализующие интерфейсы Action и Guard соответственно.

Окончательная версия конфига будет выглядеть так:

@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());
    }

Сделаем контроллер:

@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;
    }

Получаем из фабрики машину(фабрика за-inject-ана в класс, bean ее мы объявили и настроили в конфиге), кладем в контекст машины параметр, в нашем случае это некий productId, так как машина при старте контекста запускается автоматически и инит статус 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.

В качестве реализации 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-е мы можем навесить совершенно любые метрики, на совершенно разные события машины.

Грабли

В 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, будет работать и без контекста, можно написать небольшой тестовый фреймворк на моках, в нем плюсом можно будет проверить какие action вызывались, какие нет, на разные кейсы

P.S

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

Ссылки

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

Автор: Дмитрий Быков

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js