Русский
Русский
English
Статистика
Реклама

Машина состояний

Используем XSTATE для VueJS

14.09.2020 10:18:41 | Автор: admin


Маленький пример применения библиотеки XState от David Khourshid для декларативного описания логики компонента VueJS 2. XState это очень развитая библиотека для создания и использования конечных автоматов на JS. Неплохое подспорье в трудном деле создания веб приложений.

Предистория


В моей прошлой статье кратко описано зачем нужны машины состояний (конечные автоматы) и приведена простенькая реализация для работы с Vue. В моем велосипеде были только состояния и декларация состояний выглядела так:
{    idle: ['waitingConfirmation'],    waitingConfirmation: ['idle','waitingData'],    waitingData: ['dataReady', 'dataProblem'],    dataReady: [idle],    dataProblem: ['idle']}

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

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

Изучив теорию по роликам из Ютюба, стало понятно, что события нужны и важны. В голове родился такой вид декларации:
{  idle: {    GET: 'waitingConfirmation',  },  waitingConfirmation: {    CANCEL: 'idle',    CONFIRM: 'waitingData'  },  waitingData: {    SUCCESS: 'dataReady',    FAILURE: 'dataProblem'  },  dataReady: {    REPEAT: 'idle'  },  dataProblem: {    REPEAT: 'idle'  }}

А это уже очень напоминает то, как описывает состояния библиотека XState. Почитав внимательней доку, я решил убрать самодельный велосипед в сарай, и пересесть на фирменный.

VUE + XState


Установка очень простая, читайте доку, после установки включаем XState в компонент:
import {Machine, interpret} from xstate

Создаем машину на основе объекта-декларации:
const myMachine = Machine({    id: 'myMachineID',    context: {      /* some data */    },    initial: 'idle',    states: {        idle: {          on: {            GET: 'waitingConfirmation',          }        },        waitingConfirmation: {          on: {            CANCEL: 'idle',            CONFIRM: 'waitingData'          }        },        waitingData: {          on: {            SUCCESS: 'dataReady',            FAILURE: 'dataProblem'          },        },        dataReady: {          on: {            REPEAT: 'idle'          }        },        dataProblem: {          on: {            REPEAT: 'idle'          }        }    }})

Понятно, что есть состояния idle, waitingConfirmation' и есть события в верхнем регистре GET, CANCEL, CONFIRM .

Сама по себе машина не работает, из нее надо создать сервис с помощью функции interpret. Ссылку на этот сервис разместим в наш state, а заодно и ссылку на текущее состояние current:
data: {    toggleService: interpret(myMachine),    current: myMachine.initialState,}

Сервис надо стартануть start(), а также указать, что при переходах состояния мы обновляем значение current:
mounted() {    this.toggleService        .onTransition(state => {            this.current = state         })        .start();    }

В методы добавляем функцию send, ее и используем для управления машиной передачи ей событий:
methods: {   send(event) {      this.toggleService.send(event);   },  } 

Ну а дальше все просто. Передавать событие просто вызовом:
this.send(SUCCESS)

Узнать текущее состояние:
this.current.value

Проверить нахождение машины в определенном состоянии так:
this.current.matches(waitingData')


Cоберем все вместе:

Template
<div id="app">  <h2>XState machine with Vue</h2>  <div class="panel">    <div v-if="current.matches('idle')">      <button @click="send('GET')">        <span>Get data</span>      </button>    </div>    <div v-if="current.matches('waitingConfirmation')">      <button @click="send('CANCEL')">        <span>Cancel</span>      </button>      <button @click="getData">        <span>Confirm get data</span>      </button>    </div>    <div v-if="current.matches('waitingData')" class="blink_me">      loading ...    </div>    <div v-if="current.matches('dataReady')">      <div class='data-hoder'>        {{ text }}      </div>      <div>        <button @click="send('REPEAT')">          <span>Back</span>        </button>      </div>    </div>    <div v-if="current.matches('dataProblem')">      <div class='data-hoder'>        Data error!      </div>      <div>        <button @click="send('REPEAT')">          <span>Back</span>        </button>      </div>    </div>  </div>  <div class="state">    Current state: <span class="state-value">{{ current.value }}</span>  </div></div>


JS
const { Machine, interpret } = XStateconst myMachine = Machine({    id: 'myMachineID',    context: {      /* some data */    },    initial: 'idle',    states: {        idle: {          on: {            GET: 'waitingConfirmation',          }        },        waitingConfirmation: {          on: {            CANCEL: 'idle',            CONFIRM: 'waitingData'          }        },        waitingData: {          on: {            SUCCESS: 'dataReady',            FAILURE: 'dataProblem'          },        },        dataReady: {          on: {            REPEAT: 'idle'          }        },        dataProblem: {          on: {            REPEAT: 'idle'          }        }    }})new Vue({  el: "#app",  data: {  text: '',  toggleService: interpret(myMachine),    current: myMachine.initialState,  },  computed: {  },  mounted() {    this.toggleService        .onTransition(state => {          this.current = state        })        .start();  },  methods: {    send(event) {      this.toggleService.send(event);    },    getData() {      this.send('CONFIRM')    requestMock()      .then((data) => {             this.text = data.text         this.send('SUCCESS')      })      .catch(() => this.send('FAILURE'))    },  }})function randomInteger(min, max) {  let rand = min + Math.random() * (max + 1 - min)  return Math.floor(rand);}function requestMock() {  return new Promise((resolve, reject) => {  const randomValue = randomInteger(1,2)  if(randomValue === 2) {    let data = { text: 'Data received!!!'}      setTimeout(resolve, 3000, data)    }    else {    setTimeout(reject, 3000)    }  })}


Ну и конечно все это можно потрогать на jsfiddle.net

Visualizer


XState предоставляет замечательный инструмент Visualizer . Можно посмотреть диаграмму именно вашей машины. И не только посмотреть но и пощелкать по событиям и осуществить переходы. Вот так выглядит наш пример:


Итог


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

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

  • Guarded transitions
  • Actions (entry, exit, transition)
  • Extended state (context)
  • Orthogonal (parallel) states
  • Hierarchical (nested) states
  • History

А есть еще аналогичные библиотеки, например Robot. Вот сравнение Comparing state machines: XState vs. Robot. Так что если вас заинтересовала тема, вам будет чем заняться.
Подробнее..

Конечные автоматы на страже порядка

26.11.2020 12:17:06 | Автор: admin


При разработке сложных систем часто сталкиваешься с проблемой прозрачности кода, точным описанием бизнес-логики и масштабирования решения. Однажды нам поставили задачу: реализовать функциональность тарифов, в которой много бизнес-логики. При этом сроки были сжаты, да ещё и повышенные финансовые риски. Чтобы решить эту задачу быстро, эффективно и прозрачно, мы решили использовать конечные автоматы (state machine).

Суть задачи


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

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

Бизнес попросил добавить в мобильное приложение возможность всячески редактировать услугу тарифов. Продакт-менеджеры придумали очень крутую и гибкую схему логики, под которую пришлось бы сделать очень много экранов и переходов. Вот лишь четверть всей схемы, это только редактирование, а есть ещё создание, оплата, планирование и так далее.



Естественно, мы заподозрили, что решение получится очень громоздким. Например, с тарифами задача подразумевала 7 полноценных экранов, массу различных диалогов и уведомлений. От сервера необходимо было сразу получать определенные данные. К этому добавилась и обработка различных состояний доступности редактирования выбранных значений; предвыбранные значения, которые нам приходят с сервера; возможность выбирать значения только на увеличение (речь о возможности запланировать тариф с большими значениями относительно текущих настроек тарифа). И многое другое. С пакетами была похожая картина, но меньше масштабом.

К тому же было еще два небольших условия от бизнеса:

  • Дедлайны близко.
  • Решение точно будет расширяться. Когда мы приступали к разработке, еще не было В2В-сегмента. Но мы знали, что он появится, и расширяться будет очень интенсивно.

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

Выбор решения


Первый вариант самый очевидный: флаги. Их можно описать очень много. Например, вот небольшое условие, которое отображает шапку тарифа:

if (hasTariff) {if (hasErrorTariff) {           // Ошибка оплаты тарифа} else if (isProcessedTariff) {           // тариф ожидает оплаты} else {           //тариф активен}} else {//нет тарифа}

Увы, такой вариант тяжело расширять. Когда добавится новое условие, придётся ветвить схему еще сильнее.

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

enum class State {PROCESS, ERROR, ACTIVE}when (state) {   PROCESS -> // тариф ожидает оплаты   ERROR -> // Ошибка оплаты тарифа   ACTIVE -> //тариф активен}

Третий вариант: найти что-то более описываемое, понятное и масштабируемое. Конечно же, это конечные автоматы (машины состояний).

Конечный автомат это модель дискретного устройства, которое имеет в себе определенный набор правил, обычно один вход и один выход. И в каждый момент времени автомат находится в одном состоянии из множества описанных. У автомата есть API, по которому можно переключить состояние, и если это некорректное переключение, то мы узнаем об ошибке. Следуя этой концепции очень легко структурировать код и сделать его читаемым. Такой код проще отлаживать и контролировать на всех этапах. Простенький конечный автомат может выглядеть так, и его очень легко расширять:


Конечные автоматы


Конечные автоматы прекрасно помогают в реализации бизнес-логики. Ведь мы точно описываем поведение системы при любом событии. Поэтому мы решили использовать этот подход. Описали нашу схему:


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

Есть несколько вариантов. Первый: пишем всё сами. Второй: берём одну из своих старых узкоспециализированных реализаций и дорабатываем. И третий вариант: используем готовое решение.

У самописного решения есть очевидные достоинства и недостатки. К первым относится лёгкость изменения и язык Kotlin. Правда, на разработку требуется немало времени. К тому же могут быть баги, которые придётся исправлять.

Начали смотреть на сторонние решения. Сначала выбрали библиотеку Polidea. Но у неё оказалось довольно много недостатков на наш взгляд: она написана на Java, имеет проблемы с поддержкой и трудно дорабатывается.

Тогда мы обратили внимание на библиотеку Tinder. Достоинств у неё оказалось больше, чем недостатков, что и сыграло позднее в её пользу. Она написана на Kotlin, у неё удобная DSL, библиотеку регулярно обновляют. А её главный недостаток трудно дорабатывается. Но всё же мы остановились на Tinder.

Библиотека Tinder


Код библиотеки:

val stateMachine = StateMachine.create<State, Event, SideEffect> {    initialState(State.Solid)    state<State.Solid> {        on<Event.OnMelted> {            transitionTo(State.Liquid, SideEffect.LogMelted)        }    }    state<State.Liquid> {        on<Event.OnFroze> {            transitionTo(State.Solid, SideEffect.LogFrozen)        }        on<Event.OnVaporized> {            transitionTo(State.Gas, SideEffect.LogVaporized)        }    }    state<State.Gas> {        on<Event.OnCondensed> {            transitionTo(State.Liquid, SideEffect.LogCondensed)        }    }    onTransition {        val validTransition = it as? StateMachine.Transition.Valid ?: return@onTransition        when (validTransition.sideEffect) {            SideEffect.LogMelted -> logger.log(ON_MELTED_MESSAGE)            SideEffect.LogFrozen -> logger.log(ON_FROZEN_MESSAGE)            SideEffect.LogVaporized -> logger.log(ON_VAPORIZED_MESSAGE)            SideEffect.LogCondensed -> logger.log(ON_CONDENSED_MESSAGE)        }    }}

Здесь есть состояния, в которых можно хранить какие-то данные, если, например, надо переходить с какими-то условиями. Также есть различные события, на которые мы можем реагировать: в данном случае OnFroze. SideEffect мы не использовали, не понадобилось.

Состояния переключаются просто: передаём в Transition объекта stateMachine событие, которое хотим отправить. В stateMachine есть описание всех возможных состояний. А внутри них мы можем описать те события, которые могут произойти.

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

Реализация


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

По мере реализации схема разрослась: получилось около 30 состояний и 100 переходов. И поскольку всё содержалось в одном файле, ориентироваться стало довольно сложно. А искать баги ещё тяжелее, потому что когда из одного состояния перешел в другое, то появились какие-то данные и не можешь понять, в чём проблема.

На помощь пришла декомпозиция. Раз мы смогли сделать один конечный автомат, то сможем сделать ещё. Так мы из одного автомата сделали шесть.


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

class TariffFlowStateMachine constructor(       val selectedStateMachine: TariffSelectedStateMachine,       val presetStateMachine: TariffPresetStateMachine,       val packageStateMachine: TariffPackageStateMachine,       val tariffStateMachine: TariffStateMachine,       val paymentStateMachine: TariffPaymentStateMachine) {   private val initialState = State.Init      val state: State       get() = when (stateMachine.state) {           is State.RootsState.RootSelectedState -> selectedStateMachine.state           is State.RootsState.RootPresetState -> presetStateMachine.state           is State.RootsState.RootPackageState -> packageStateMachine.state           is State.RootsState.RootTariffState -> tariffStateMachine.state           is State.RootsState.RootPaymentState -> paymentStateMachine.state           else -> State.Init   }

У нас есть базовый автомат, который управляет несколькими маленькими. Каждый из них отвечает за свой фрагмент функциональности. И когда продакт-менеджеры просят добавить что-то ещё, нам не приходится менять все переходы большого автомата. Достаточно поменять один маленький.

Например, так выглядит автомат выбора данных:


Автомат сборки пакета:


Автомат сборки тарифа:


А это автомат оплаты:


Приятный бонус


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

stateMachine = flowStateMachine.stateMachinestateFlowable = flowStateMachine.stateMachine.state//region utilityprivate fun assertTransition(initial: State, event: Event, expected: State) {  //given  val stateMachine = givenStateIs(initial)  val stateSubscriber = stateFlowable.test()  //when  stateMachine.transition(event)  //assert  stateSubscriber.assertLast(expected)}private fun givenStateIs(state: State): StateMachine<State, Event, SideEffect> {  return stateMachine.with { initialState(state) }}private fun TestSubscriber<State>.assertLast(expected: State) {  this.assertValueAt(this.valueCount() - 1, expected)}@Testfun `given state PaidPromotion on Error should result in PaymentMethods`() {  assertTransition(        initial = State.PaidPromotion(paymentMethod = PaymentMethod.CARD),        event = Event.Error(),        expected = State.PaymentMethods(navigateBack = true, reload = false)  )}

Было очень приятно осознать, что авторы библиотеки позаботились и о простоте тестирования.

В заключение


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

Да, конечные автоматы не всегда оправданы. Поэтому к их использованию надо подходить, взвесив все за и против.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru