При разработке сложных систем часто сталкиваешься с проблемой прозрачности кода, точным описанием бизнес-логики и масштабирования решения. Однажды нам поставили задачу: реализовать функциональность тарифов, в которой много бизнес-логики. При этом сроки были сжаты, да ещё и повышенные финансовые риски. Чтобы решить эту задачу быстро, эффективно и прозрачно, мы решили использовать конечные автоматы (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)
)
}
Было очень приятно осознать, что авторы библиотеки позаботились и о простоте тестирования.
В заключение
Если вы уже сталкивались с конечными автоматами и не понимали, зачем их использовать, то надеюсь, что мой рассказ помог вам разобраться в этом. Иногда они действительно нужны, и с ними очень приятно работать.
Да, конечные автоматы не всегда оправданы. Поэтому к их использованию надо подходить, взвесив все «за» и «против».