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