Objectif
Nous allons créer notre premier acteur qui va rester en “vie” en permanence. Celui-ci sera un compteur qui s’incrémentera sur demande. Ensuite nous allons le refactoriser pour le rendre moins verbeux.
L’acteur
defmodule Counter do
def start(initial_count \\ 0) do
spawn(__MODULE__, :loop, [initial_count])
end
def stop(pid) do
send(pid, :stop)
:ok
end
def count(pid) do
send(pid, {:count, self()})
receive do
reply -> {:ok, reply}
after 1_000 ->
{:error, :timeout}
end
end
def incr(pid) do
send(pid, {:incr, self()})
receive do
reply -> {:ok, reply}
after 1_000 ->
{:error, :timeout}
end
end
defp loop(count) do
receive do
{:incr, from} ->
new_count = count + 1
send(from, new_count)
loop(new_count)
{:count, from} ->
send(from, count)
loop(count)
:stop ->
:ok
end
end
end
iex(4)> pid = Counter.start
#PID<0.119.0>
iex(5)> Counter.count pid
{:ok, 0}
iex(6)> Counter.incr pid
{:ok, 1}
iex(7)> Counter.count pid
{:ok, 1}
iex(8)> Counter.stop pid
:ok
iex(9)> Counter.count pid
{:error, :timeout}
Syntaxe : Les parenthèses sont optionnelles en Elixir !
Comme vous pouvez le voir, l’acteur permanent est en fait une fonction récursive ! Elle lit un message, réagit à celui-ci et enfin se rappelle elle-même. L’acteur reste donc en vie et maintient le count en mémoire. Tant que personne n’appelle la fonction stop/1 celui-ci vivra.
Il serait bien d’avoir un {:error, :not_alive} plutôt qu’un {:error, :timeout} et on pourrait le faire en utilisant Process.alive?/1 dans count/1 et incr/1 mais plutôt que de faire ça, commençons à utiliser les outils à notre disposition : OTP.
Refactor en GenServer
En fait, cet acteur pourrait être comparé à un serveur dans une architecture client-serveur. Ce genre d’acteur représente probablement la grande partie des acteurs d’un système et c’est pourquoi le framework OTP a inventé la behaviour du gen_server/GenServer.
Le GenServer est un serveur générique qui maintient un état et fournit des abstractions pour recevoir des messages synchrones ou asynchrones. Transformons notre module Counter :
defmodule Counter do
use GenServer
####################
# API
####################
def start() do
GenServer.start(__MODULE__, 0)
end
def stop(pid) do
GenServer.stop(pid)
end
def count(pid) do
GenServer.call(pid, :count, 1_000)
end
def incr(pid) do
GenServer.call(pid, :incr, 1_000)
end
####################
# CALLBACKS
####################
def init(count) do
{:ok, count}
end
def handle_call(:count, _from, state) do
{:reply, {:ok, state}, state}
end
def handle_call(:incr, _from, state) do
new_state = state + 1
{:reply, {:ok, new_state}, new_state}
end
end
iex(4)> {:ok, pid} = Counter.start
{:ok, #PID<0.139.0>}
iex(5)> Counter.count pid
{:ok, 0}
iex(6)> Counter.incr pid
{:ok, 1}
iex(7)> Counter.count pid
{:ok, 1}
iex(8)> Counter.stop pid
:ok
iex(9)> Counter.count pid
** (exit) exited in: GenServer.call(#PID<0.139.0>, :count, 1000)
** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
(elixir 1.19.4) lib/gen_server.ex:1142: GenServer.call/3
iex:9: (file)
Le code est bien plus concis. Voyez comme je sépare le code en 2 parties : API et Callbacks. L’API contient les fonctions qu’appelleront les callers de cet acteur. Tandis que les callbacks sont en fait des fonctions appelées par le module GenServer lui même.
Une importante distinction est donc qu’au niveau de l’API, le code est exécuté par le caller. La fonction self() retourne donc le pid du caller. Alors que dans les callbacks, le code est exécuté par l’acteur lui même. La fonction self() retourne le pid de l’acteur.
Vous avez probablement grincé des dents en voyant le message d’erreur (exit) exited in: GenServer.call(#PID<0.139.0>, :count, 1000). Il s’agit en fait du comportement du GenServer quand on essaie d’appeler un acteur qui n’existe pas/plus. C’est encore la philosophie du Let it crash. Pour lui (GenServer) si l’acteur appelé n’existe pas, c’est qu’il y a un problème et qu’il vaut mieux s’arrêter immédiatement.
L’utilisation du GenServer a permis d’abstraire ces choses :
- envoi de message
- réception de message
- gestion du timeout
- crash si appel d’un acteur non existant (au lieu d’attendre le timeout)
Le GenServer permet bien d’autres choses, notamment le traitement asynchrone de messages via handle_cast/2 ou le traitement de messages standards (ex : tick d’un timer) avec handle_info/2. Le module permet d’avoir une fonction terminate/2 qui sera appelée avant l’arrêt de l’acteur (même en cas de crash). Il permet aussi d’utiliser des fonctions d’introspection du genre :sys.get_state/1.
Bref, à partir du moment où un acteur est permanent, je pense qu’il est bienvenu d’utiliser une behaviour. Il en existe d’autres :
-
supervisor (erl, ex) pour relancer les acteurs en cas d’arrêt non prévu
-
gen_statem (erl) pour les machines à états
Refactor en Agent
En Elixir il existe une behaviour pour les serveurs simples comme celui-ci : Agent. Voici ce que donne le refactor en gardant la même API. Les callbacks du GenServer sont devenus des fonctions anonymes.
defmodule Counter do
use Agent
def start() do
Agent.start(fn -> 0 end)
end
def stop(pid) do
Agent.stop(pid)
end
def count(pid) do
{:ok, Agent.get(pid, fn count -> count end)}
end
def incr(pid) do
Agent.update(pid, fn count -> count + 1 end)
count(pid)
end
end
iex(23)> {:ok, pid} = Counter.start
{:ok, #PID<0.168.0>}
iex(24)> Counter.count pid
{:ok, 0}
iex(25)> Counter.incr pid
{:ok, 1}
iex(26)> Counter.count pid
{:ok, 1}
iex(27)> Counter.stop pid
:ok
iex(28)> Counter.count pid
** (exit) exited in: GenServer.call(#PID<0.168.0>, {:get, #Function<0.86489201/1 in Counter.count/1>}, 5000)
** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
(elixir 1.19.4) lib/gen_server.ex:1142: GenServer.call/3
iex:40: Counter.count/1
iex:31: (file)
Conclusion
Rien de sorcier donc, à partir du moment où on a compris la récursion, on a compris les acteurs permanents. On a aussi vu le côté verbeux des articles précédents disparaître grâce à des behaviours.
La chose la plus importante à retenir ici, c’est de savoir quel acteur exécute le code. Comme vu plus haut, au sein d’un même module certaines parties sont exécutées par le caller et d’autres par l’acteur. L’utilisation de conventions et de behaviours facilite grandement la lecture du code.
Dans les prochains articles j’utiliserai donc les behaviours et la librairie standard.