ElixirとEctoでステートマシンを構築する

多くの有用な設計パターンがあり、ステートマシンの概念は有用な設計パターンの1つです。



状態マシンは、状態が事前定義された状態のセットから遷移し、各状態が独自の事前定義された動作を持つ必要がある複雑なビジネスプロセスをモデル化する場合に最適です。



この投稿では、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はすべての可能な分岐を引数として取ります。これにより、不要な遷移をチェックして、発生を防ぐことができます。これで、従来の非Ectoアプローチを使用して状態を変更できます。



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関数を探し、前の状態と次の状態の両方で呼び出しますそれらをパターン化して、各遷移に特定の条件を追加できます。



副作用への対処



ステートマシンをある状態から別の状態に移動することは、ステートマシンの一般的なタスクです。しかし、ステートマシンのもう1つの大きな利点は、すべてのステートで発生する可能性のある副作用に対処できることです。

誰かが私たちのドアを開けるたびに通知を受け取りたいとしましょう。これが発生した場合は、メールを送信することをお勧めします。ただし、これら2つの操作を1つのアトミック操作にする必要があります。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使用します)。



何らかの理由で変更セットが無効になった場合、両方の操作がアトミックに実行された結果として、電子メールが送信されることはありません。



結論



次回、ステートフルな動作をモデル化するときは、ステートマシン(ステートマシン)パターンを使用したアプローチを検討してください。このパターンは、非常に役立ちます。シンプルで効果的です。このテンプレートを使用すると、モデル化された状態遷移図をコードで簡単に表現できるため、開発がスピードアップします。



予約をします。おそらくアクターモデルは、Elixir \ Erlangでのステートマシンの実装の簡素化に貢献します。各アクターには独自の状態と、状態を順次変更する着信メッセージのキューがあります。有限状態マシンに関する本「Erlang / OTPでのスケーラブルなシステムの設計」には、アクターモデルのコンテキストで非常によく書かれています。



プログラミング言語での有限状態マシンの実装の独自の例がある場合は、リンクを共有してください。勉強するのは興味深いことです。



All Articles