Существует много полезных шаблонов проектирования и концепция конечного автомата входит в число полезных шаблонов проектирования.
Конечный автомат отлично подходит в случаях, когда когда вы моделируете сложный бизнес-процесс, в котором происходит переход состояний из предопределенного набора состояний и каждое состояние должно обладать своим предопределенным поведением.
В этой публикации вы узнаете, как реализовать этот шаблон с помощью Elixir и Ecto.
Конечный автомат может быть отличным выбором когда вы моделируете сложный, многошаговый бизнес-процесс, и где к каждому шагу предъявляются определенные требования.
Примеры:
Приведем небольшой учебный пример, иллюстрирующий работу конечного автомата: работа двери.
Дверь может быть заблокирована или разблокирована. Она также может быть открыта или закрыта. Если она разблокирована, то её можно открыть.
Мы можем смоделировать это как конечный автомат:
![image](https://habrastorage.org/r/w1560/webt/o9/e4/gs/o9e4gsgd6aao2ga5bqtsixuey3u.png)
Этот конечный автомат имеет:
Из диаграммы можно сделать вывод, что невозможно перейти от заблокирована к открыта. Или простыми словами: сначала нужно разблокировать дверь, а уже потом открыть. Данная диаграмма описывает поведение, но как реализовать это?
Начиная с OTP 19, Erlang предоставляет модуль :gen_statem, который позволяет реализовывать процессы, подобные gen_server, которые ведут себя как конечные автоматы (в которых текущее состояние влияет на их внутреннее поведение). Давайте посмотрим, как это будет выглядеть для нашего примера с дверью:
Этот процесс начинается в состоянии :locked (заблокировано). Отправляя соответствующие события, мы можем сопоставить текущее состояние с запрошенным переходом и выполнить необходимые преобразования. Дополнительный аргумент data сохраняется для любого другого дополнительного состояния, но в этом примере мы его не используем.
Мы можем вызвать его с нужным нам переходом состояния. Если текущее состояние позволяет этот переход, то он отработает. В противном случае будет возвращена ошибка (из-за последнего обработчика события, которое отлавливает всё, что не соответствует допустимым событиям).
Если наш конечный автомат в большей степени ориентирован на данные, чем на процессы, то мы можем использовать другой подход.
Есть несколько пакетов Elixir, которые решают эту проблему. В этом посте я буду использовать Fsmx, но другие пакеты, например, Machinery, также предоставляют аналогичные функции.
Этот пакет позволяет нам моделировать точно такие же состояния и переходы, но в существующей модели Ecto:
Как мы увидеть, Fsmx.Struct получает все возможные переходы в качестве аргумента. Это позволяет ему проверять нежелательные переходы и предотвращать их возникновение. Теперь мы можем изменить состояние, используя традиционный, не-Ecto подход:
Но мы можем также попросить то же самое в форме Ecto changeset (используемое в Elixir слово, означающее “набор изменений”):
Этот changeset только обновляет поле :state. Но мы можем расширить его, чтобы включить дополнительные поля и проверки. Допустим, чтобы открыть дверь, нам нужно принять ее условия:
Fsmx ищет в вашей схеме необязательную функцию transition_changeset/4 и вызывает ее как с предыдущим состоянием, так и со следующим. Вы можете сопоставить их по шаблону, чтобы добавить определенные условия для каждого переход.
Перевести конечный автомат из одного состояния в другое это общая задача для конечных автоматов. Но Еще одним большим преимуществом конечных автоматов является способность справляться с побочными эффектами, которые могут иметь место в каждом состоянии.
Допустим, мы хотим получать уведомления каждый раз, когда кто-то открывает нашу дверь. Возможно, мы захотим отправить электронную почту, когда это произойдет. Но мы хотим, чтобы эти две операции были одной атомарной операцией.
Ecto работает с атомарностью через пакет Ecto.Multi, которая группирует несколько операций внутри транзакции базы данных. Ecto также имеет функцию Ecto.Multi.run/3, которая позволяет запускать произвольный код в рамках той же транзакции.
Fsmx, в свою очередь, интегрируется с Ecto.Multi, предоставляя вам возможность выполнять переходы состояний как часть Ecto.Multi, а также предоставляет дополнительный обратный вызов, который выполняется в этом случае:
Теперь вы можете выполнить переход как показано:
Эта транзакция будет использовать тот же transition_changeset/4, как было описано выше, для вычисления необходимых изменений в Ecto модели. И будет включать новый обратный вызов в качестве вызова Ecto.Multi.run. В результате электронное письмо отправляется (асинхронно, с использованием Bamboo, чтобы не запускаться внутри самой транзакции).
Если changeset (набор изменений) по какой-либо причине признан недействительным, электронное письмо никогда не будет отправлено, в результате атомарного выполнения обеих операций.
В следующий раз, когда вы моделируете какое-то поведение с состоянием, подумайте о подходе с использование шаблона конечного автомата (машины конечных состояний), для вас этот шаблон может стать хорошим помощником. Он одновременно и прост и эффективен. Этот шаблон позволит смоделированную диаграмму переходов состояния легко выразить в виде программного кода, что ускорит разработку.
Сделаю оговорку, возможно акторная модель способствует простоте реализации конечного автомата в Elixir\Erlang, каждый актор имеет своё состояние и очередь входящих сообщений, которые последовательно изменяют его состояние. В книге “Проектирование масштабируемых систем в Erlang/ОТР” про конечные автоматы очень хорошо написано, в разрезе акторной модели.
Если у вас есть собственные примеры реализации конечных автоматов на вашем языке программирования, то прошу поделиться ссылкой, будет интересно изучить.
Конечный автомат отлично подходит в случаях, когда когда вы моделируете сложный бизнес-процесс, в котором происходит переход состояний из предопределенного набора состояний и каждое состояние должно обладать своим предопределенным поведением.
В этой публикации вы узнаете, как реализовать этот шаблон с помощью Elixir и Ecto.
Случаи использования
Конечный автомат может быть отличным выбором когда вы моделируете сложный, многошаговый бизнес-процесс, и где к каждому шагу предъявляются определенные требования.
Примеры:
- Регистрация в личном кабинете. В этом процессе пользователь сначала регистрируется, потом добавляет некоторую дополнительную информацию, затем подтверждает свою электронную почту, затем включает 2FA, и только после этого получает доступ в систему.
- Корзина для покупок. Сперва она пустая, потом в неё можно добавить товары и после чего пользователь может перейти к оплате и доставке.
- Конвейер задач в системах управления проектами. Например: изначально задачи в статусе "создана", потом задача может быть "назначена" исполнителю, затем статус изменится на "в процессе", а затем в "выполнено".
Пример конечного автомата
Приведем небольшой учебный пример, иллюстрирующий работу конечного автомата: работа двери.
Дверь может быть заблокирована или разблокирована. Она также может быть открыта или закрыта. Если она разблокирована, то её можно открыть.
Мы можем смоделировать это как конечный автомат:
![image](https://habrastorage.org/webt/o9/e4/gs/o9e4gsgd6aao2ga5bqtsixuey3u.png)
Этот конечный автомат имеет:
- 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/ОТР” про конечные автоматы очень хорошо написано, в разрезе акторной модели.
Если у вас есть собственные примеры реализации конечных автоматов на вашем языке программирования, то прошу поделиться ссылкой, будет интересно изучить.