Chamagne Bastien
Développeur indépendant à Pau

Une machine à états

Modèle Acteur Erlang Elixir

Objectif

Nous allons créer notre première machine à états (aussi appelée Finite State Machine). L’exemple que nous allons développer est un perroquet qui répétera ce qu’on lui demande à condition de le nourrir.

  ---
    config:
        theme: dark
---
stateDiagram-v2
    [*] --> hungry
    hungry --> fed
    fed --> hungry

    hungry --> [*]
    fed --> [*]

Il s’agit d’une simple machine à états avec deux états indiquant son appétit. En fonction de son état il acceptera ou non de faire ce qu’on lui demande.

J’ai enlevé les événements du diagramme car ça part vite en cacahuète malgré la simplicité de l’exemple.

Il y aura 3 événements :

Le code de cet article se trouve sur bchamagne/parrot.

Les FSM en Elixir

Elixir n’a jamais converti le module Erlang de state machine : gen_statem. Nous avons donc plusieurs possibilités :

Pour une véritable application, je conseille de prendre GenStateMachine que j’ai déjà utilisé sur un gros projet, mais pour cet article utilisons le module Erlang directement.

Notre perroquet en gen_statem

defmodule Parrot do
  defstruct [:name, :energy]

  @food_acceptable [:seed, :nut, :fruit, :vegetable]

  # -- API --
  def start(name) when is_binary(name) do
    init_params = [name]
    opts = []
    :gen_statem.start(__MODULE__, init_params, opts)
  end

  def stop(pid) when is_pid(pid) do
    :gen_statem.stop(pid)
  end

  def repeat(pid, text)
      when is_pid(pid) and is_binary(text) do
    :gen_statem.call(pid, {:repeat, text})
  end

  @doc "millisec sert à accélérer les tests"
  def eat(pid, food, millisec \\ 2_000)
      when is_pid(pid) and is_atom(food) and is_integer(millisec) do
    :gen_statem.call(pid, {:eat, food, millisec})
  end

  # -- CALLBACKS --

  @doc "Initie le perroquet dans l'état hungry avec son nom et 0 d'énergie"
  def init(name) do
    {:ok, :hungry,
     %__MODULE__{
       name: name,
       energy: 0
     }}
  end

  # gen_statem a 2 modes de fonctionnement :
  #   state_functions : les événements sont traités par des fonctions qui ont le nom de l'état courant
  #   handle_event_function : tout est traité dans la fonction : handle_event/4
  #
  # j'ai choisi state_functions car je trouve ce mode plus lisible
  def callback_mode, do: :state_functions

  # ---------------
  # STATE: HUNGRY
  # ---------------
  def hungry({:call, from}, {:repeat, _text}, data) do
    {:keep_state, data, [reply(from, data, "Feed me first, human!")]}
  end

  def hungry({:call, from}, {:eat, food, millisec}, data) do
    {data, text} = do_eat_food(data, food, millisec)

    if data.energy > 0 do
      {:next_state, :fed, data, [reply(from, data, text)]}
    else
      {:keep_state, data, [reply(from, data, text)]}
    end
  end

  # ---------------
  # STATE: FED
  # ---------------
  def fed({:call, from}, {:repeat, text}, data) do
    case do_consume_energy(data) do
      data when data.energy == 0 ->
        {:next_state, :hungry, data, [reply(from, data, text)]}

      data ->
        {:keep_state, data, [reply(from, data, text)]}
    end
  end

  def fed({:call, from}, {:eat, food, millisec}, data) do
    {data, text} = do_eat_food(data, food, millisec)
    {:keep_state, data, [reply(from, data, text)]}
  end

  # -- INTERNALS --

  @doc "cette fonction fera planter le process plutôt que d'avoir une énergie négative"
  defp do_consume_energy(%__MODULE__{energy: energy} = data)
       when energy > 0 do
    %{data | energy: energy - 1}
  end

  defp do_eat_food(%__MODULE__{} = data, food, millisec)
       when food in @food_acceptable do
    Process.sleep(millisec)
    data = %{data | energy: data.energy + 1}
    {data, "Gochisousama deshita"}
  end

  defp do_eat_food(data, _food, _millisec), do: {data, "Not eating that!"}

  defp reply(from, %__MODULE__{} = data, text) do
    {:reply, from, "#{data.name} says: #{text}"}
  end
end
iex(1)> {:ok, pid} = Parrot.start("Alphonse")
{:ok, #PID<0.192.0>}

iex(2)> Parrot.repeat(pid, "Get Uppa!")
"Alphonse says: Feed me first, human!"

iex(3)> Parrot.eat(pid, :chocolate)
"Alphonse says: Not eating that!"

iex(4)> Parrot.eat(pid, :nut)
"Alphonse says: Gochisousama deshita"

iex(5)> Parrot.repeat(pid, "Get Uppa!")
"Alphonse says: Get Uppa!"

iex(6)> Parrot.repeat(pid, "Sex Machine")
"Alphonse says: Feed me first, human!"

Le code est un peu long mais je pense qu’il est très simple à comprendre grâce à l’utilisation massive de function clauses. Personnellement j’aurais préféré que chaque état soit un module et que chaque événement soit une fonction de ce module, mais je n’ai trouvé aucune bibliothèque de ce genre.

Conclusion

Cette behaviour n’est en pratique pas souvent utilisée. C’est dommage car je la trouve élégante et j’ai lu quelque part que n’importe quel système complexe n’est en fait qu’un assemblage de machines à états.

Dans la plupart des projets que j’ai vus, cela est directement géré dans un GenServer avec quelques conditions.