在Elixir和Ecto中构建状态机

有许多有用的设计模式,状态机的概念是有用的设计模式之一。



当您对复杂的业务流程进行建模时,状态机非常有用,在该流程中,状态从一组预定义的状态过渡,并且每个状态必须具有自己的预定义行为。



在本文中,您将学习如何使用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


此过程开始于状态:锁定(锁定)。通过调度适当的事件,我们可以将当前状态与请求的转换匹配,并执行必要的转换。额外数据参数将保存为其他任何额外状态,但是在此示例中我们不使用它。



我们可以用所需的状态转换来调用它。如果当前状态允许此转换,则它将起作用。否则,将返回错误(由于最后一个事件处理程序捕获了与有效事件不匹配的任何内容)。



{: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将所有可能的分支作为参数。这使它可以检查是否有不需要的过渡并防止它们发生。现在,我们可以使用传统的非节能方式更改状态:



door = %PersistedDoor{state: "locked"}
 
Fsmx.transition(door, "unlocked")
# {:ok, %PersistedDoor{state: "unlocked", color: nil}}


但是我们也可以以Ecto变更集(在Elixir中使用“变更集”的形式)的形式要求相同:



door = PersistedDoor |> Repo.one()
Fsmx.transition_changeset(door, "unlocked")
|> Repo.update()


此变更集仅更新: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的功能该功能允许任意代码在同一事务中运行。



s进而与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避免在事务本身中被触发)。



如果变更集由于任何原因而无效,则由于两个操作的原子执行,电子邮件将永远不会发送。



结论



下次使用状态对某些行为建模时,请考虑使用状态机(状态机)模式的方法,该模式可以为您提供良好的帮助。它既简单又有效。该模板将允许在代码中轻松地表达建模的状态转换图,从而加快开发速度。



我会保留一下,也许actor模型有助于简化Elixir \ Erlang中状态机的实现,每个actor都有自己的状态和传入消息队列,这些消息依次更改其状态。在参与者模型的上下文中,关于有限状态机的书《关于在Erlang / OTP中设计可伸缩系统的设计》非常出色。



如果您有自己的示例以编程语言实现有限状态机,那么请共享一个链接,这将很有趣。



All Articles