Construyendo una máquina de estado en Elixir y Ecto

Hay muchos patrones de diseño útiles y el concepto de máquina de estados es uno de los patrones de diseño útiles.



Una máquina de estados es excelente cuando se modela un proceso empresarial complejo en el que los estados pasan de un conjunto predefinido de estados y cada estado debe tener su propio comportamiento predefinido.



En esta publicación, aprenderá cómo implementar este patrón usando Elixir y Ecto.



Casos de uso



Una máquina de estado puede ser una gran opción cuando se modela un proceso empresarial complejo de varios pasos y donde existen requisitos específicos para cada paso.



Ejemplos:



  • Registro en su cuenta personal. En este proceso, el usuario primero se registra, luego agrega información adicional, luego confirma su correo electrónico, luego enciende 2FA y solo después de eso obtiene acceso al sistema.
  • Cesta de la compra. Al principio, está vacío, luego puede agregarle productos y luego el usuario puede proceder al pago y la entrega.
  • Una cartera de tareas en los sistemas de gestión de proyectos. Por ejemplo: inicialmente la tarea tiene el estado " creada ", luego la tarea puede ser " asignada " al ejecutor, luego el estado cambiará a " en curso " y luego a " completada ".


Ejemplo de máquina de estado



Aquí hay un pequeño estudio de caso para ilustrar cómo funciona una máquina de estado: operación de puerta.



La puerta se puede bloquear o desbloquear . También se puede abrir o cerrar . Si está desbloqueado, se puede abrir.



Podemos modelar esto como una máquina de estado:



imagen



esta máquina de estado tiene:



  • 3 estados posibles: bloqueado, desbloqueado, abierto
  • 4 posibles transiciones de estado: desbloquear, abrir, cerrar, bloquear


Del diagrama, podemos concluir que es imposible pasar de bloqueado a abierto. O en palabras simples: primero debe desbloquear la puerta y solo luego abrirla. Este diagrama describe el comportamiento, pero ¿cómo lo implementa?



Máquinas de estado como procesos de Elixir



Desde OTP 19, Erlang proporciona un módulo : gen_statem que le permite implementar procesos similares a gen_server que se comportan como máquinas de estado (en las que el estado actual afecta su comportamiento interno). Veamos cómo se verá nuestro ejemplo de puerta:



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


Este proceso comienza en el estado : bloqueado . Al enviar los eventos apropiados, podemos hacer coincidir el estado actual con la transición solicitada y realizar las transformaciones necesarias. El argumento de datos adicionales se guarda para cualquier otro estado adicional, pero no lo usamos en este ejemplo.



Podemos llamarlo con la transición de estado que queremos. Si el estado actual permite esta transición, funcionará. De lo contrario, se devolverá un error (debido a que el último controlador de eventos detecta algo que no coincide con eventos válidos).



{: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 nuestra máquina de estados se basa más en datos que en procesos, entonces podemos adoptar un enfoque diferente.



Máquinas de estados finitos como modelos ecto



Existen varios paquetes de Elixir que resuelven este problema. Usaré Fsmx en esta publicación , pero otros paquetes como Machinery también brindan una funcionalidad similar.



Este paquete nos permite simular exactamente los mismos estados y transiciones, pero en el modelo Ecto existente:



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


Como podemos ver, Fsmx.Struct toma todas las ramas posibles como argumento. Esto le permite buscar transiciones no deseadas y evitar que ocurran. Ahora podemos cambiar el estado usando el enfoque tradicional, no Ecto:



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


Pero también podemos pedir lo mismo en forma de conjunto de cambios Ecto (utilizado en Elixir para "conjunto de cambios"):



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


Este conjunto de cambios solo actualiza el campo : estado. Pero podemos expandirlo para incluir campos y validaciones adicionales. Digamos que para abrir la puerta, debemos aceptar sus términos:



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


Fsmx busca la función opcional transition_changeset / 4 en su esquema y la llama tanto con el estado anterior como con el siguiente. Puede crear un patrón para agregar condiciones específicas para cada transición.



Lidiando con los efectos secundarios



Mover una máquina de estado de un estado a otro es una tarea común para las máquinas de estado. Pero otra gran ventaja de las máquinas de estado es la capacidad de lidiar con los efectos secundarios que pueden ocurrir en todos los estados.

Digamos que queremos ser notificados cada vez que alguien abre nuestra puerta. Es posible que deseemos enviar un correo electrónico cuando esto suceda. Pero queremos que estas dos operaciones sean una operación atómica.



Ecto trabaja con atomicidad a través del paquete Ecto.Multi , que agrupa múltiples operaciones dentro de una transacción de base de datos. Ecto también tiene una función llamada Ecto.Multi.run/3 que permite que se ejecute código arbitrario dentro de la misma transacción.



FSMXa su vez se integra con Ecto.Multi, lo que le brinda la capacidad de realizar transiciones de estado como parte de Ecto.Multi, y también proporciona una devolución de llamada adicional que se ejecuta en este caso:



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


Ahora puede hacer la transición como se muestra:



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


Esta transacción utilizará el mismo conjunto de cambios de transición / 4 que se describe anteriormente para calcular los cambios necesarios en el modelo Ecto. E incluirá una nueva devolución de llamada como una llamada a Ecto.Multi.run . Como resultado, el correo electrónico se envía (de forma asincrónica, utilizando Bamboo para evitar que se active dentro de la transacción en sí).



Si un conjunto de cambios se invalida por cualquier motivo, el correo electrónico nunca se enviará, como resultado de la ejecución atómica de ambas operaciones.



Conclusión



La próxima vez que modele algún comportamiento con estado, piense en el enfoque utilizando un patrón de máquina de estado (máquina de estado), este patrón puede ser una buena ayuda para usted. Es simple y eficaz. Esta plantilla permitirá que el diagrama de transición de estado modelado se exprese fácilmente en código, lo que acelerará el desarrollo.



Haré una reserva, quizás el modelo de actor contribuya a la simplicidad de la implementación de la máquina de estados en Elixir \ Erlang, cada actor tiene su propio estado y una cola de mensajes entrantes, que cambian secuencialmente su estado. En el libro " Diseñar sistemas escalables en Erlang / OTP " está muy bien escrito sobre máquinas de estados finitos, en el contexto del modelo de actor.



Si tiene sus propios ejemplos de la implementación de máquinas de estados finitos en su lenguaje de programación, por favor comparta un enlace, será interesante estudiarlo.



All Articles