Конечный автомат отлично подходит в случаях, когда когда вы моделируете сложный бизнес-процесс, в котором происходит переход состояний из предопределенного набора состояний и каждое состояние должно обладать своим предопределенным поведением.
В этой публикации вы узнаете, как реализовать этот шаблон с помощью Elixir и Ecto.
Случаи использования
Конечный автомат может быть отличным выбором когда вы моделируете сложный, многошаговый бизнес-процесс, и где к каждому шагу предъявляются определенные требования.
Примеры:
- Регистрация в личном кабинете. В этом процессе пользователь сначала регистрируется, потом добавляет некоторую дополнительную информацию, затем подтверждает свою электронную почту, затем включает 2FA, и только после этого получает доступ в систему.
- Корзина для покупок. Сперва она пустая, потом в неё можно добавить товары и после чего пользователь может перейти к оплате и доставке.
- Конвейер задач в системах управления проектами. Например: изначально задачи в статусе "создана", потом задача может быть "назначена" исполнителю, затем статус изменится на "в процессе", а затем в "выполнено".
Пример конечного автомата
Приведем небольшой учебный пример, иллюстрирующий работу конечного автомата: работа двери.
Дверь может быть заблокирована или разблокирована. Она также может быть открыта или закрыта. Если она разблокирована, то её можно открыть.
Мы можем смоделировать это как конечный автомат:
Этот конечный автомат имеет:
- 3 возможных состояния: заблокирована, разблокирована, открыта
- 4 возможных перехода состояния: разблокировать, открыть, закрыть, заблокировать
Из диаграммы можно сделать вывод, что невозможно перейти от заблокирована к открыта. Или простыми словами: сначала нужно разблокировать дверь, а уже потом открыть. Данная диаграмма описывает поведение, но как реализовать это?
Конечные автоматы как Elixir процессы
Начиная с OTP 19, Erlang предоставляет модуль :gen_statem, который позволяет реализовывать процессы, подобные gen_server, которые ведут себя как конечные автоматы (в которых текущее состояние влияет на их внутреннее поведение). Давайте посмотрим, как это будет выглядеть для нашего примера с дверью:
defmodule Door do
@behaviour :gen_statem
# Стартуем сервис
def start_link do
:gen_statem.start_link(__MODULE__, :ok, [])
end
# начальное состояние, вызываемое при старте, locked - заблокировано
@impl :gen_statem
def init(_), do: {:ok, :locked, nil}
@impl :gen_statem
def callback_mode, do: :handle_event_function
# обработка приходящего события: разблокируем заблокированную дверь
# next_state - новое состояние - дверь разблокирована
@impl :gen_statem
def handle_event({:call, from}, :unlock, :locked, data) do
{:next_state, :unlocked, data, [{:reply, from, {:ok, :unlocked}}]}
end
# блокировка разблокированной двери
def handle_event({:call, from}, :lock, :unlocked, data) do
{:next_state, :locked, data, [{:reply, from, {:ok, :locked}}]}
end
# открытие разблокированной двери
def handle_event({:call, from}, :open, :unlocked, data) do
{:next_state, :opened, data, [{:reply, from, {:ok, :opened}}]}
end
# закрытие открытой двери
def handle_event({:call, from}, :close, :opened, data) do
{:next_state, :unlocked, data, [{:reply, from, {:ok, :unlocked}}]}
end
# возвращение ошибки при неопределеном поведении
def handle_event({:call, from}, _event, _content, data) do
{:keep_state, data, [{:reply, from, {:error, "invalid transition"}}]}
end
end
Этот процесс начинается в состоянии :locked (заблокировано). Отправляя соответствующие события, мы можем сопоставить текущее состояние с запрошенным переходом и выполнить необходимые преобразования. Дополнительный аргумент data сохраняется для любого другого дополнительного состояния, но в этом примере мы его не используем.
Мы можем вызвать его с нужным нам переходом состояния. Если текущее состояние позволяет этот переход, то он отработает. В противном случае будет возвращена ошибка (из-за последнего обработчика события, которое отлавливает всё, что не соответствует допустимым событиям).
{:ok, pid} = Door.start_link()
:gen_statem.call(pid, :unlock)
# {:ok, :unlocked}
:gen_statem.call(pid, :open)
# {:ok, :opened}
:gen_statem.call(pid, :close)
# {:ok, :closed}
:gen_statem.call(pid, :lock)
# {:ok, :locked}
:gen_statem.call(pid, :open)
# {:error, "invalid transition"}
Если наш конечный автомат в большей степени ориентирован на данные, чем на процессы, то мы можем использовать другой подход.
Конечные автоматы как Ecto модели
Есть несколько пакетов Elixir, которые решают эту проблему. В этом посте я буду использовать Fsmx, но другие пакеты, например, Machinery, также предоставляют аналогичные функции.
Этот пакет позволяет нам моделировать точно такие же состояния и переходы, но в существующей модели Ecto:
defmodule PersistedDoor do
use Ecto.Schema
schema "doors" do
field(:state, :string, default: "locked")
field(:terms_and_conditions, :boolean)
end
use Fsmx.Struct,
transitions: %{
"locked" => "unlocked",
"unlocked" => ["locked", "opened"],
"opened" => "unlocked"
}
end
Как мы увидеть, Fsmx.Struct получает все возможные переходы в качестве аргумента. Это позволяет ему проверять нежелательные переходы и предотвращать их возникновение. Теперь мы можем изменить состояние, используя традиционный, не-Ecto подход:
door = %PersistedDoor{state: "locked"}
Fsmx.transition(door, "unlocked")
# {:ok, %PersistedDoor{state: "unlocked", color: nil}}
Но мы можем также попросить то же самое в форме Ecto changeset (используемое в Elixir слово, означающее “набор изменений”):
door = PersistedDoor |> Repo.one()
Fsmx.transition_changeset(door, "unlocked")
|> Repo.update()
Этот changeset только обновляет поле :state. Но мы можем расширить его, чтобы включить дополнительные поля и проверки. Допустим, чтобы открыть дверь, нам нужно принять ее условия:
defmodule PersistedDoor do
# ...
def transition(changeset, _from, "opened", params) do
changeset
|> cast(params, [:terms_and_conditions])
|> validate_acceptance(:terms_and_conditions)
end
end
Fsmx ищет в вашей схеме необязательную функцию transition_changeset/4 и вызывает ее как с предыдущим состоянием, так и со следующим. Вы можете сопоставить их по шаблону, чтобы добавить определенные условия для каждого переход.
Работа с побочными эффектами
Перевести конечный автомат из одного состояния в другое это общая задача для конечных автоматов. Но Еще одним большим преимуществом конечных автоматов является способность справляться с побочными эффектами, которые могут иметь место в каждом состоянии.
Допустим, мы хотим получать уведомления каждый раз, когда кто-то открывает нашу дверь. Возможно, мы захотим отправить электронную почту, когда это произойдет. Но мы хотим, чтобы эти две операции были одной атомарной операцией.
Ecto работает с атомарностью через пакет Ecto.Multi, которая группирует несколько операций внутри транзакции базы данных. Ecto также имеет функцию Ecto.Multi.run/3, которая позволяет запускать произвольный код в рамках той же транзакции.
Fsmx, в свою очередь, интегрируется с Ecto.Multi, предоставляя вам возможность выполнять переходы состояний как часть Ecto.Multi, а также предоставляет дополнительный обратный вызов, который выполняется в этом случае:
defmodule PersistedDoor do
# ...
def after_transaction_multi(changeset, _from, "unlocked", params) do
Emails.door_unlocked()
|> Mailer.deliver_later()
end
end
Теперь вы можете выполнить переход как показано:
door = PersistedDoor |> Repo.one()
Ecto.Multi.new()
|> Fsmx.transition_multi(schema, "transition-id", "unlocked")
|> Repo.transaction()
Эта транзакция будет использовать тот же transition_changeset/4, как было описано выше, для вычисления необходимых изменений в Ecto модели. И будет включать новый обратный вызов в качестве вызова Ecto.Multi.run. В результате электронное письмо отправляется (асинхронно, с использованием Bamboo, чтобы не запускаться внутри самой транзакции).
Если changeset (набор изменений) по какой-либо причине признан недействительным, электронное письмо никогда не будет отправлено, в результате атомарного выполнения обеих операций.
Заключение
В следующий раз, когда вы моделируете какое-то поведение с состоянием, подумайте о подходе с использование шаблона конечного автомата (машины конечных состояний), для вас этот шаблон может стать хорошим помощником. Он одновременно и прост и эффективен. Этот шаблон позволит смоделированную диаграмму переходов состояния легко выразить в виде программного кода, что ускорит разработку.
Сделаю оговорку, возможно акторная модель способствует простоте реализации конечного автомата в Elixir\Erlang, каждый актор имеет своё состояние и очередь входящих сообщений, которые последовательно изменяют его состояние. В книге “Проектирование масштабируемых систем в Erlang/ОТР” про конечные автоматы очень хорошо написано, в разрезе акторной модели.
Если у вас есть собственные примеры реализации конечных автоматов на вашем языке программирования, то прошу поделиться ссылкой, будет интересно изучить.
chapuza
Саша Юрич, автор блога erlangelist.com, одной из лучших книжек по эликсиру Elixir in Action, автор лучшей полностью функциональной библиотеки реализующей конечные автоматы (да и вообще умнейший человек), прямо в аннотации к своей библиотеке пишет:
И я с ним полностью согласен. OTP не нужны дополнительные библиотеки для имплементации FSM: паттерн-матчинг и сохранение только валидных состояний (что Ecto умеет из коробки) сделает все за нас. Приносить посторонние библиотеки для реализации того, что может быть средствами языка записано в две строки — очень порочная практика.