Bau einer Zustandsmaschine in Elixir und Ecto

Es gibt viele nützliche Entwurfsmuster, und das Konzept einer Zustandsmaschine ist eines der nützlichen Entwurfsmuster.



Eine Zustandsmaschine eignet sich hervorragend, wenn Sie einen komplexen Geschäftsprozess modellieren, bei dem Zustände von einer vordefinierten Gruppe von Zuständen übergehen und jeder Zustand sein eigenes vordefiniertes Verhalten haben muss.



In diesem Beitrag erfahren Sie, wie Sie dieses Muster mit Elixir und Ecto implementieren.



Anwendungsfälle



Eine Zustandsmaschine kann eine gute Wahl sein, wenn Sie einen komplexen, mehrstufigen Geschäftsprozess modellieren und für jeden Schritt spezifische Anforderungen gestellt werden.



Beispiele:



  • Registrierung in Ihrem persönlichen Konto. In diesem Prozess meldet sich der Benutzer zuerst an, fügt dann einige zusätzliche Informationen hinzu, bestätigt dann seine E-Mail-Adresse, aktiviert dann 2FA und erhält erst danach Zugriff auf das System.
  • Einkaufskorb. Zuerst ist es leer, dann können Sie Produkte hinzufügen, und dann kann der Benutzer mit Zahlung und Lieferung fortfahren.
  • Eine Pipeline von Aufgaben in Projektmanagementsystemen. Beispiel: Anfangs haben die Aufgaben den Status " Erstellt ", dann kann die Aufgabe dem Executor " zugewiesen " werden, dann ändert sich der Status in " In Bearbeitung " und dann in " Abgeschlossen ".


Beispiel einer Zustandsmaschine



Hier ist eine kleine Fallstudie, um zu veranschaulichen, wie eine Zustandsmaschine funktioniert: Türbetrieb.



Die Tür kann verriegelt oder entriegelt werden . Es kann auch geöffnet oder geschlossen werden . Wenn es entsperrt ist, kann es geöffnet werden.



Wir können dies als Zustandsmaschine modellieren:



Bild



Diese Zustandsmaschine hat:



  • 3 mögliche Zustände: gesperrt, entsperrt, offen
  • 4 mögliche Zustandsübergänge: entsperren, öffnen, schließen, sperren


Aus dem Diagramm können wir schließen, dass es unmöglich ist, von gesperrt zu offen zu wechseln. Oder in einfachen Worten: Zuerst müssen Sie die Tür entriegeln und erst dann öffnen. Dieses Diagramm beschreibt das Verhalten, aber wie implementieren Sie es?



Zustandsautomaten als Elixier-Prozesse



Seit OTP 19 bietet Erlang ein Modul : gen_statem , mit dem Sie gen_server-ähnliche Prozesse implementieren können, die sich wie Zustandsmaschinen verhalten (in denen der aktuelle Status das interne Verhalten beeinflusst). Mal sehen, wie es für unser Türbeispiel aussehen wird:



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


Dieser Vorgang beginnt im Status : gesperrt . Durch Auslösen der entsprechenden Ereignisse können wir den aktuellen Status mit dem angeforderten Übergang abgleichen und die erforderlichen Transformationen durchführen. Das Argument für zusätzliche Daten wird für jeden anderen zusätzlichen Status gespeichert, in diesem Beispiel wird es jedoch nicht verwendet.



Wir können es mit dem Staatsübergang nennen, den wir wollen. Wenn der aktuelle Status diesen Übergang zulässt, funktioniert er. Andernfalls wird ein Fehler zurückgegeben (da der letzte Ereignishandler alles abfängt, was nicht mit gültigen Ereignissen übereinstimmt).



{: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"}


Wenn unsere Zustandsmaschine mehr datengesteuert als prozessgesteuert ist, können wir einen anderen Ansatz verfolgen.



Finite-State-Maschinen als Ecto-Modelle



Es gibt mehrere Elixir-Pakete, die dieses Problem lösen. Ich werde Fsmx in diesem Beitrag verwenden , aber auch andere Pakete wie Machinery bieten ähnliche Funktionen.



Mit diesem Paket können wir genau dieselben Zustände und Übergänge simulieren, jedoch im vorhandenen Ecto-Modell:



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


Wie wir sehen können, nimmt Fsmx.Struct alle möglichen Zweige als Argument. Auf diese Weise kann nach unerwünschten Übergängen gesucht und deren Auftreten verhindert werden. Wir können jetzt den Zustand mit dem traditionellen Nicht-Ecto-Ansatz ändern:



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


Wir können dies aber auch in Form eines Ecto-Änderungssatzes verlangen (der in Elixir für „Änderungssatz“ verwendet wird):



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


Dieser Änderungssatz aktualisiert nur das Feld : state. Wir können es jedoch um zusätzliche Felder und Validierungen erweitern. Sagen wir, um die Tür zu öffnen, müssen wir ihre Bedingungen akzeptieren:



defmodule PersistedDoor do
 # ...
 
 def transition(changeset, _from, "opened", params) do
   changeset
   |> cast(params, [:terms_and_conditions])
   |> validate_acceptance(:terms_and_conditions)
 end
end


Fsmx sucht in Ihrem Schema nach der optionalen Funktion Transition_changeset / 4 und ruft sie sowohl mit dem vorherigen als auch mit dem nächsten Status auf. Sie können sie strukturieren, um bestimmte Bedingungen für jeden Übergang hinzuzufügen.



Umgang mit Nebenwirkungen



Das Verschieben einer Zustandsmaschine von einem Zustand in einen anderen ist eine häufige Aufgabe für Zustandsmaschinen. Ein weiterer großer Vorteil von Zustandsautomaten ist die Fähigkeit, mit Nebenwirkungen umzugehen, die in jedem Zustand auftreten können.

Nehmen wir an, wir möchten jedes Mal benachrichtigt werden, wenn jemand unsere Tür öffnet. In diesem Fall möchten wir möglicherweise eine E-Mail senden. Wir wollen aber, dass diese beiden Operationen eine atomare Operation sind.



Ecto arbeitet mit Atomizität über das Ecto.Multi- Paket , das mehrere Operationen innerhalb einer Datenbanktransaktion gruppiert. Ecto verfügt außerdem über eine Funktion namens Ecto.Multi.run/3 , mit der beliebiger Code innerhalb derselben Transaktion ausgeführt werden kann.



FsmxIntegriert sich wiederum in Ecto.Multi und bietet Ihnen die Möglichkeit, Statusübergänge als Teil von Ecto.Multi durchzuführen. Außerdem wird ein zusätzlicher Rückruf bereitgestellt, der in diesem Fall ausgeführt wird:



defmodule PersistedDoor do
 # ...
 
 def after_transaction_multi(changeset, _from, "unlocked", params) do
   Emails.door_unlocked()
   |> Mailer.deliver_later()
 end
end


Jetzt können Sie den Übergang wie folgt durchführen:



door = PersistedDoor |> Repo.one()
 
Ecto.Multi.new()
|> Fsmx.transition_multi(schema, "transition-id", "unlocked")
|> Repo.transaction()


Diese Transaktion verwendet dasselbe Transition_changeset / 4 wie oben beschrieben, um die erforderlichen Änderungen im Ecto-Modell zu berechnen. Und wird einen neuen Rückruf als Aufruf von Ecto.Multi.run enthalten . Infolgedessen wird die E-Mail gesendet (asynchron mit Bamboo , um zu vermeiden, dass sie innerhalb der Transaktion selbst ausgelöst wird).



Wenn ein Änderungssatz aus irgendeinem Grund ungültig wird, wird die E-Mail aufgrund der atomaren Ausführung beider Vorgänge niemals gesendet.



Fazit



Wenn Sie das nächste Mal ein zustandsbehaftetes Verhalten modellieren, ziehen Sie einen Ansatz in Betracht, der ein Muster der endlichen Zustandsmaschine (endliche Zustandsmaschine) verwendet. Dieses Muster kann ein guter Helfer für Sie sein. Es ist sowohl einfach als auch effektiv. Mit dieser Vorlage kann das simulierte Zustandsübergangsdiagramm leicht in Code ausgedrückt werden, was die Entwicklung beschleunigt.



Ich werde eine Reservierung vornehmen, vielleicht trägt das Akteurmodell zur Einfachheit der Implementierung der Zustandsmaschine in Elixir \ Erlang bei, jeder Akteur hat seinen eigenen Zustand und eine Warteschlange eingehender Nachrichten, die nacheinander seinen Zustand ändern. In dem Buch " Entwerfen skalierbarer Systeme in Erlang / OTP " über endliche Zustandsmaschinen wird im Kontext des Akteurmodells sehr gut geschrieben.



Wenn Sie eigene Beispiele für die Implementierung von Finite-State-Maschinen in Ihrer Programmiersprache haben, teilen Sie uns bitte einen Link mit. Es wird interessant sein, diese zu studieren.



All Articles