Существует много полезных шаблонов проектирования и концепция конечного автомата входит в число полезных шаблонов проектирования.

Конечный автомат отлично подходит в случаях, когда когда вы моделируете сложный бизнес-процесс, в котором происходит переход состояний из предопределенного набора состояний и каждое состояние должно обладать своим предопределенным поведением.

В этой публикации вы узнаете, как реализовать этот шаблон с помощью Elixir и Ecto.

Случаи использования


Конечный автомат может быть отличным выбором когда вы моделируете сложный, многошаговый бизнес-процесс, и где к каждому шагу предъявляются определенные требования.

Примеры:

  • Регистрация в личном кабинете. В этом процессе пользователь сначала регистрируется, потом добавляет некоторую дополнительную информацию, затем подтверждает свою электронную почту, затем включает 2FA, и только после этого получает доступ в систему.
  • Корзина для покупок. Сперва она пустая, потом в неё можно добавить товары и после чего пользователь может перейти к оплате и доставке.
  • Конвейер задач в системах управления проектами. Например: изначально задачи в статусе "создана", потом задача может быть "назначена" исполнителю, затем статус изменится на "в процессе", а затем в "выполнено".

Пример конечного автомата


Приведем небольшой учебный пример, иллюстрирующий работу конечного автомата: работа двери.

Дверь может быть заблокирована или разблокирована. Она также может быть открыта или закрыта. Если она разблокирована, то её можно открыть.

Мы можем смоделировать это как конечный автомат:

image

Этот конечный автомат имеет:

  • 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/ОТР” про конечные автоматы очень хорошо написано, в разрезе акторной модели.

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