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 :
- feed : ajoute de l’énergie au perroquet
- repeat : demande au perroquet de répéter une phrase
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 :
- utiliser une bibliothèque comme GenStateMachine
- utiliser le module Erlang directement
- recoder le mécanisme par-dessus un GenServer
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.