Construindo uma máquina de estado em Elixir e Ecto

Existem muitos padrões de projeto úteis, e o conceito de máquina de estado é um dos padrões de projeto úteis.



Uma máquina de estado é ótima quando você está modelando um processo de negócios complexo no qual os estados fazem a transição de um conjunto predefinido de estados e cada estado deve ter seu próprio comportamento predefinido.



Neste post, você aprenderá como implementar esse padrão com Elixir e Ecto.



Casos de uso



Uma máquina de estado pode ser uma ótima opção quando você está modelando um processo de negócios complexo de várias etapas e onde requisitos específicos são impostos a cada etapa.



Exemplos:



  • Registro em sua conta pessoal. Nesse processo, o usuário primeiro se inscreve, depois adiciona algumas informações adicionais, depois confirma seu e-mail, liga o 2FA e só depois obtém acesso ao sistema.
  • Cesta de compras. A princípio ele está vazio, depois você pode adicionar produtos a ele, e então o usuário pode prosseguir com o pagamento e entrega.
  • Um pipeline de tarefas em sistemas de gerenciamento de projetos. Por exemplo: inicialmente as tarefas têm o status " criado ", depois a tarefa pode ser " atribuída " ao executor, depois o status muda para " em andamento " e depois para " concluído ".


Exemplo de máquina de estado



Aqui está um pequeno estudo de caso para ilustrar como funciona uma máquina de estado: operação de porta.



A porta pode ser trancada ou destrancada . Também pode ser aberto ou fechado . Se estiver desbloqueado, pode ser aberto.



Podemos modelar isso como uma máquina de estado:



imagem



Esta máquina de estado tem:



  • 3 estados possíveis: bloqueado, desbloqueado, aberto
  • 4 transições de estado possíveis: desbloquear, abrir, fechar, bloquear


A partir do diagrama, podemos concluir que é impossível passar de bloqueado para aberto. Ou em palavras simples: primeiro você precisa destrancar a porta e só depois abri-la. Este diagrama descreve o comportamento, mas como você o implementa?



Máquinas de estado como processos Elixir



Desde OTP 19, Erlang fornece um módulo : gen_statem que permite a você implementar processos do tipo gen_server que se comportam como máquinas de estado (nas quais o estado atual afeta seu comportamento interno). Vamos ver como ficará o nosso exemplo de porta:



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 processo começa no estado : bloqueado . Ao despachar os eventos apropriados, podemos combinar o estado atual com a transição solicitada e realizar as transformações necessárias. O argumento de dados extras é salvo para qualquer outro estado extra, mas não o usamos neste exemplo.



Podemos chamá-lo com a transição de estado que desejamos. Se o estado atual permitir essa transição, ela funcionará. Caso contrário, um erro será retornado (devido ao último manipulador de eventos capturar qualquer coisa que não corresponda a 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"}


Se nossa máquina de estado for mais orientada por dados do que por processo, podemos adotar uma abordagem diferente.



Máquinas de estado finito como modelos Ecto



Existem vários pacotes Elixir que resolvem este problema. Vou usar o Fsmx neste artigo , mas outros pacotes como o Machinery também oferecem funcionalidade semelhante.



Este pacote nos permite simular exatamente os mesmos estados e transições, mas no 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 leva todas as ramificações possíveis como argumento. Isso permite que ele verifique se há transições indesejadas e evite que ocorram. Agora podemos alterar o estado usando a abordagem tradicional, não Ecto:



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


Mas também podemos pedir o mesmo na forma de changeset Ecto (usado em Elixir para "changeset"):



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


Este conjunto de alterações atualiza apenas o campo : state. Mas podemos expandi-lo para incluir campos adicionais e validações. Digamos que para abrir a porta, precisamos aceitar seus termos:



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


Fsmx procura a função opcional transaction_changeset / 4 em seu esquema e a chama com o estado anterior e o próximo. Você pode padronizá-los para adicionar condições específicas para cada transição.



Lidando com efeitos colaterais



Mover uma máquina de estado de um estado para outro é uma tarefa comum para máquinas de estado. Mas outra grande vantagem das máquinas de estado é a capacidade de lidar com os efeitos colaterais que podem ocorrer em todos os estados.

Digamos que queremos ser notificados sempre que alguém abre nossa porta. Podemos enviar um e-mail quando isso acontecer. Mas queremos que essas duas operações sejam uma operação atômica.



Ecto trabalha com atomicidade por meio do pacote Ecto.Multi , que agrupa várias operações em uma transação de banco de dados. O Ecto também possui um recurso chamado Ecto.Multi.run/3 que permite a execução de código arbitrário na mesma transação.



Fsmxpor sua vez, integra-se ao Ecto.Multi, dando a você a capacidade de realizar transições de estado como parte do Ecto.Multi, e também fornece um retorno de chamada adicional que é executado neste caso:



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


Agora você pode fazer a transição conforme mostrado:



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


Esta transação usará o mesmo transaction_changeset / 4 descrito acima para calcular as mudanças necessárias no modelo Ecto. E incluirá um novo retorno de chamada como uma chamada para Ecto.Multi.run . Como resultado, o e-mail é enviado (de forma assíncrona, usando o Bamboo para evitar ser acionado dentro da própria transação).



Se um changeset for invalidado por qualquer motivo, o e-mail nunca será enviado, como resultado da execução atômica de ambas as operações.



Conclusão



Na próxima vez que você estiver modelando algum comportamento com estado, pense sobre a abordagem usando um padrão de máquina de estado (máquina de estado); esse padrão pode ser um bom ajudante para você. É simples e eficaz. Este modelo permitirá que o diagrama de transição de estado modelado seja facilmente expresso em código, o que irá acelerar o desenvolvimento.



Vou fazer uma reserva, talvez o modelo de ator contribua para a simplicidade da implementação da máquina de estado no Elixir \ Erlang, cada ator tem seu próprio estado e uma fila de mensagens recebidas, que mudam de estado sequencialmente. No livro " Projetando sistemas escaláveis ​​em Erlang / OTP " sobre máquinas de estado finito está muito bem escrito, no contexto do modelo de ator.



Se você tem seus próprios exemplos de implementação de máquinas de estado finito em sua linguagem de programação, compartilhe um link, será interessante estudar.



All Articles