Русский
Русский
English
Статистика
Реклама

Из песочницы Создаем конечный автомат в 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"}}]} endend

Этот процесс начинается в состоянии :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) endend

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() endend

Теперь вы можете выполнить переход как показано:

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

Если у вас есть собственные примеры реализации конечных автоматов на вашем языке программирования, то прошу поделиться ссылкой, будет интересно изучить.
Источник: habr.com
К списку статей
Опубликовано: 20.07.2020 18:22:00
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Elixir/phoenix

Конечный автомат

Finite state machine

Ecto

Elixir

Категории

Последние комментарии

© 2006-2020, personeltest.ru