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

Юла

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

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