Создаем конечный автомат в Elixir и Ecto

от автора

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

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

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

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

ссылка на оригинал статьи https://habr.com/ru/post/511780/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *