Construire une machine d'état dans Elixir et Ecto

Il existe de nombreux modèles de conception utiles, et le concept de machine à états est l'un des modèles de conception utiles.



Une machine à états est idéale lorsque vous modélisez un processus métier complexe dans lequel les états passent d'un ensemble d'états prédéfini et chaque état doit avoir son propre comportement prédéfini.



Dans cet article, vous apprendrez comment implémenter ce modèle en utilisant Elixir et Ecto.



Cas d'utilisation



Une machine à états peut être un excellent choix lorsque vous modélisez un processus métier complexe en plusieurs étapes et où des exigences spécifiques sont placées à chaque étape.



Exemples:



  • Inscription dans votre compte personnel. Dans ce processus, l'utilisateur s'inscrit d'abord, puis ajoute des informations supplémentaires, puis confirme son e-mail, puis active 2FA, et ce n'est qu'après avoir accès au système.
  • Panier. Au début, il est vide, vous pouvez ensuite y ajouter des produits, puis l'utilisateur peut procéder au paiement et à la livraison.
  • Un pipeline de tâches dans les systèmes de gestion de projet. Par exemple: au départ, la tâche a le statut « créée », puis la tâche peut être « affectée » à l'exécuteur, puis le statut passe à « en cours », puis à « terminé ».


Exemple de machine d'état



Voici une petite étude de cas pour illustrer le fonctionnement d'une machine à états: fonctionnement de la porte.



La porte peut être verrouillée ou déverrouillée . Il peut également être ouvert ou fermé . S'il est déverrouillé, il peut être ouvert.



Nous pouvons modéliser cela comme une machine à états:



image



Cette machine à états a:



  • 3 états possibles: verrouillé, déverrouillé, ouvert
  • 4 transitions d'état possibles: déverrouiller, ouvrir, fermer, verrouiller


À partir du schéma, nous pouvons conclure qu'il est impossible de passer de verrouillé à ouvert. Ou en termes simples: vous devez d'abord déverrouiller la porte, puis seulement l'ouvrir. Ce diagramme décrit le comportement, mais comment l'implémentez-vous?



État des machines en tant que processus Elixir



Depuis OTP 19, Erlang fournit un module : gen_statem qui vous permet d'implémenter des processus de type gen_server qui se comportent comme des machines à états (dans lesquelles l'état actuel affecte leur comportement interne). Voyons à quoi cela ressemblera pour notre exemple de porte:



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


Ce processus démarre dans l'état : verrouillé . En distribuant les événements appropriés, nous pouvons faire correspondre l'état actuel avec la transition demandée et effectuer les transformations nécessaires. L'argument de données supplémentaires est enregistré pour tout autre état supplémentaire, mais nous ne l'utilisons pas dans cet exemple.



Nous pouvons l'appeler avec la transition d'état que nous voulons. Si l'état actuel permet cette transition, cela fonctionnera. Sinon, une erreur sera renvoyée (en raison du dernier gestionnaire d'événements interceptant tout ce qui ne correspond pas aux événements valides).



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


Si notre machine à états est davantage axée sur les données que sur les processus, nous pouvons adopter une approche différente.



Machines à états finis comme modèles Ecto



Il existe plusieurs packages Elixir qui résolvent ce problème. J'utiliserai Fsmx dans cet article , mais d'autres packages comme Machinery fournissent également des fonctionnalités similaires.



Ce package nous permet de simuler exactement les mêmes états et transitions, mais dans le modèle Ecto existant:



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


Comme nous pouvons le voir, Fsmx.Struct prend toutes les branches possibles comme argument. Cela lui permet de vérifier les transitions indésirables et d'éviter qu'elles ne se produisent. Nous pouvons maintenant changer d'état en utilisant l'approche traditionnelle, non-Ecto:



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


Mais nous pouvons également demander la même chose sous la forme du changeset Ecto (utilisé dans Elixir pour «changeset»):



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


Cet ensemble de modifications met uniquement à jour le champ : state. Mais nous pouvons l'étendre pour inclure des champs et des validations supplémentaires. Disons que pour ouvrir la porte, nous devons accepter ses conditions:



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


Fsmx recherche la fonction optionnelle transition_changeset / 4 dans votre schéma et l'appelle à la fois avec l'état précédent et le suivant. Vous pouvez les modéliser pour ajouter des conditions spécifiques pour chaque transition.



Gérer les effets secondaires



Le déplacement d'une machine d'état d'un état à un autre est une tâche courante pour les machines d'état. Mais un autre grand avantage des machines à états est la capacité à gérer les effets secondaires qui peuvent survenir dans chaque état.

Disons que nous voulons être avertis chaque fois que quelqu'un ouvre notre porte. Nous pouvons souhaiter envoyer un e-mail lorsque cela se produit. Mais nous voulons que ces deux opérations soient une seule opération atomique.



Ecto fonctionne avec atomicité via le package Ecto.Multi , qui regroupe plusieurs opérations au sein d'une transaction de base de données. Ecto a également une fonctionnalité appelée Ecto.Multi.run/3 qui permet à du code arbitraire de s'exécuter dans la même transaction.



Fsmxà son tour s'intègre avec Ecto.Multi, vous donnant la possibilité d'effectuer des transitions d'état dans le cadre d'Ecto.Multi, et fournit également un rappel supplémentaire qui est exécuté dans ce cas:



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


Vous pouvez maintenant effectuer la transition comme indiqué:



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


Cette transaction utilisera le même transition_changeset / 4 que celui décrit ci-dessus pour calculer les changements requis dans le modèle Ecto. Et inclura un nouveau rappel comme appel à Ecto.Multi.run . En conséquence, l'e-mail est envoyé (de manière asynchrone, en utilisant Bamboo pour éviter d'être déclenché dans la transaction elle-même).



Si un ensemble de modifications est invalidé pour une raison quelconque, l'e-mail ne sera jamais envoyé, à la suite de l'exécution atomique des deux opérations.



Conclusion



La prochaine fois que vous modéliserez un comportement avec un état, pensez à l'approche utilisant un modèle de machine à états (machine à états), ce modèle peut être une bonne aide pour vous. C'est à la fois simple et efficace. Ce modèle permet au diagramme de transition d'état modélisé d'être facilement exprimé dans le code, ce qui accélérera le développement.



Je vais faire une réservation, peut-être que le modèle d'acteur contribue à la simplicité de l'implémentation de la machine d'état dans Elixir \ Erlang, chaque acteur a son propre état et une file d'attente de messages entrants, qui changent séquentiellement son état. Dans le livre " Designing scalable systems in Erlang / OTP " sur les machines à états finis est très bien écrit, dans le contexte du modèle d'acteur.



Si vous avez vos propres exemples d'implémentation de machines à états finis dans votre langage de programmation, merci de partager un lien, il sera intéressant à étudier.



All Articles