При разработке сложных систем часто сталкиваешься с проблемой прозрачности кода, точным описанием бизнес-логики и масштабирования решения. Однажды нам поставили задачу: реализовать функциональность тарифов, в которой много бизнес-логики. При этом сроки были сжаты, да ещё и повышенные финансовые риски. Чтобы решить эту задачу быстро, эффективно и прозрачно, мы решили использовать конечные автоматы (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.stateMachine
stateFlowable = flowStateMachine.stateMachine.state
//region utility
private 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)
}
@Test
fun `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)
  )
}

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

В заключение


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

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