GenServer (serveur générique) est un comportement qui fournit une abstraction pour les interactions client-serveur courantes entre processus Elixir.
Tu te souviens de la boucle de réception, quand on a découvert les processus ? Le comportement GenServer fournit des abstractions pour implémenter de telles boucles, et pour échanger des messages avec un processus qui exécute une telle boucle. Il facilite la conservation d'un état et l'exécution de code asynchrone.
Attention, le nom GenServer est polysémique. Il sert aussi à désigner un module qui utilise le comportement GenServer, ainsi qu'un processus qui a été démarré depuis un module qui utilise le comportement GenServer.
Le comportement GenServer définit un callback obligatoire, init/1, et quelques callbacks facultatifs intéressants : handle_call/3, handle_cast/2 et handle_info/3. Les clients qui utilisent un GenServer ne sont pas censés appeler ces callbacks directement. À la place, le module GenServer fournit des fonctions que les clients peuvent utiliser pour communiquer avec un processus GenServer.
Souvent, un seul module définit à la fois une API client, un ensemble de fonctions que les autres parties de ton application Elixir peuvent appeler pour communiquer avec ce processus GenServer, et des implémentations de callbacks serveur, qui contiennent la logique de ce GenServer.
Regardons d'abord un exemple simple de GenServer, puis on verra ce que chaque callback signifie.
Voici un exemple de serveur capable de répondre aux questions répétitives de passagers pénibles pendant un long trajet en voiture, plus précisément à la question : « on est bientôt arrivés ? ». Il garde la trace du nombre de fois où cette question a été posée, et renvoie des réponses de plus en plus agacées.
defmodule AnnoyingPassengerAutoresponder do
use GenServer
# Client API
def start_link(init_arg) do
GenServer.start_link(__MODULE__, init_arg)
end
def are_we_there_yet?(pid) do
GenServer.call(pid, :are_we_there_yet?)
end
# Server callbacks
@impl GenServer
def init(_init_arg) do
# the initial count of questions asked is always 0
state = 0
{:ok, state}
end
@impl GenServer
def handle_call(:are_we_there_yet?, _from, state) do
reply =
cond do
state <= 3 -> "No."
state <= 10 -> "I told you #{state} times already. No."
true -> "..."
end
# increase the count of questions asked
new_state = state + 1
# reply to the caller
{:reply, reply, new_state}
end
end
init/1Un serveur peut être démarré en appelant GenServer.start/3 ou GenServer.start_link/3. On a vu la différence entre ces deux fonctions dans le concept des liens.
Ces deux fonctions :
GenServer.init_arg. Comme son nom le suggère, cet argument est passé au callback init/1.Démarrer un serveur en appelant GenServer.start/3 ou GenServer.start_link/3 invoque le callback init/1 de manière bloquante. La valeur de retour de init/1 détermine si le serveur peut démarrer correctement.
Le callback init/1 renvoie généralement l'une de ces valeurs :
{:ok, state}. Le serveur démarre sa boucle de réception en utilisant state comme état initial. state peut être de n'importe quel type.{:stop, reason}. reason peut être de n'importe quel type. Le serveur ne démarre pas sa boucle de réception. Le processus se termine avec la raison donnée.Il existe aussi des possibilités plus avancées que l'on ne verra pas maintenant.
Si la boucle de réception du serveur démarre, les fonctions GenServer.start/3 et GenServer.start_link/3 renvoient un tuple {:ok, pid}. Sinon, elles renvoient {:error, reason}
handle_call/3On peut envoyer un message qui nécessite une réponse à un processus serveur avec GenServer.call/2. Cette fonction attend en premier argument le pid d'un processus serveur en cours d'exécution, et le message en deuxième argument. Le message peut être de n'importe quel type.
Le callback handle_call/3 est chargé de traiter les messages synchrones et d'y répondre. Il reçoit trois arguments :
message : la valeur passée en deuxième argument à GenServer.call/2.from : le pid du processus qui appelle GenServer.call/2. La plupart du temps, cet argument peut être ignoré.state : l'état actuel du serveur. Rappelle-toi que sa valeur initiale a été définie dans le callback init/1.Le callback handle_call/3 renvoie généralement un tuple de 3 éléments {:reply, reply, state}. Cela signifie que le deuxième élément du tuple, un reply qui peut être de n'importe quel type, est renvoyé à l'appelant. Le troisième élément du tuple, state, est le nouvel état du serveur après le traitement de ce message.
Il existe aussi des possibilités plus avancées que l'on ne verra pas maintenant.
Pour retenir ce que fait ce callback à partir de son nom, pense à « appeler » quelqu'un au téléphone.
Si cette personne est disponible, tu reçois une réponse immédiatement (de façon synchrone).
handle_cast/2On peut envoyer un message qui n'attend pas de réponse à un processus serveur avec GenServer.cast/2. Ses arguments sont identiques à ceux de GenServer.call/2.
Le callback handle_cast/2 est chargé de traiter ces messages. Il reçoit deux arguments, message et state, qui sont les mêmes que dans le callback handle_call/3 (à l'exception de from).
Le callback handle_cast/2 renvoie généralement un tuple de 2 éléments {:noreply, state}.
Il existe aussi des possibilités plus avancées que l'on ne verra pas maintenant.
Pour retenir ce que fait ce callback à partir de son nom, souviens-toi que « to cast » signifie aussi « lancer ».
Si tu lances un message dans une bouteille à la mer, tu ne t'attends pas à recevoir une réponse immédiatement, ni peut-être jamais.
call ou cast ?Utilise presque toujours call, même si ton code client n'a pas besoin de la réponse du serveur.
Avec call, on attend la réponse, ce qui sert de mécanisme de contre-pression (pour éviter que les clients envoient trop de messages d'un coup). Recevoir une réponse du serveur est aussi le seul moyen d'être sûr que le serveur a bien reçu et traité le message du client.
handle_info/2Des messages peuvent aussi arriver dans la boîte de réception du serveur par d'autres moyens que l'appel à GenServer.call/2 ou GenServer.cast/2, par exemple en appelant la simple fonction send/2.
Pour traiter de tels messages, utilise le callback handle_info/2. Ce callback fonctionne exactement de la même manière que handle_cast/2.
Le comportement GenServer fournit une implémentation fourre-tout de handle_info/2 qui journalise les erreurs liées aux messages inattendus. Si tu redéfinis cette implémentation par défaut, veille à toujours inclure ta propre implémentation fourre-tout. Si tu l'oublies, le serveur plantera s'il reçoit un message inattendu.
La valeur de retour de chacun des quatre callbacks décrits ci-dessus peut être complétée par un élément de tuple supplémentaire, un délai d'attente. Par exemple, au lieu de renvoyer {:ok, state} depuis init/1, renvoie {:ok, state, timeout}.
Le délai d'attente permet de détecter l'absence de messages dans la boîte aux lettres pendant une période donnée. Si le serveur renvoie un délai d'attente depuis l'un de ses callbacks, et que le nombre de millisecondes spécifié s'est écoulé sans qu'aucun message n'arrive, handle_info/2 est appelé avec :timeout comme premier argument.
La machine Take-A-Number de base se vendait très bien, mais certains utilisateurs se plaignaient de son manque de fonctionnalités avancées par rapport aux autres modèles disponibles sur le marché.
Le fabricant a écouté les retours des utilisateurs et a décidé de sortir un modèle deluxe avec plus de fonctionnalités, et une fois de plus, on t'a confié l'écriture du logiciel de cette machine.
Les nouvelles fonctionnalités ajoutées au modèle deluxe incluent :
La logique métier de la machine a déjà été implémentée par ton collègue et se trouve dans le module TakeANumberDeluxe.State. Maintenant, ta tâche est de l'encapsuler dans un GenServer.
Utilise le comportement GenServer dans le module TakeANumberDeluxe.
Implémente la fonction start_link/1 et le callback GenServer nécessaire.
L'argument passé à start_link/1 est une liste de mots-clés. Elle contient les clés :min_number et :max_number. Les valeurs associées à ces clés doivent être passées à la fonction TakeANumberDeluxe.State.new/2.
Si TakeANumberDeluxe.State.new/2 renvoie un tuple {:ok, state}, la machine doit démarrer, en utilisant l'état renvoyé comme état. Si elle renvoie plutôt un tuple {:error, error}, la machine doit s'arrêter, en donnant l'erreur renvoyée comme raison d'arrêt.
TakeANumberDeluxe.start_link(min_number: 1, max_number: 9)
# => {:ok, #PID<0.174.0>}
TakeANumberDeluxe.start_link(min_number: 9, max_number: 1)
# => {:error, :invalid_configuration}
Tu as peut-être remarqué que la fonction TakeANumberDeluxe.State.new/2 prend aussi un troisième argument optionnel, auto_shutdown_timeout. On l'utilisera dans la dernière étape de cet exercice.
Implémente la fonction report_state/1 et le callback GenServer nécessaire. La machine doit répondre à l'appelant avec son état actuel.
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.report_state(machine)
# => %TakeANumberDeluxe.State{
# max_number: 10,
# min_number: 1,
# queue: %TakeANumberDeluxe.Queue{in: [], out: []},
# auto_shutdown_timeout: :infinity,
# }
Implémente la fonction queue_new_number/1 et le callback GenServer nécessaire.
Cette fonction doit appeler la fonction TakeANumberDeluxe.State.queue_new_number/1 avec l'état actuel de la machine.
Si TakeANumberDeluxe.State.queue_new_number/1 renvoie un tuple {:ok, new_number, new_state}, la machine doit répondre à l'appelant avec le nouveau numéro et définir le nouvel état comme son état. Si elle renvoie plutôt un tuple {:error, error}, la machine doit répondre à l'appelant avec l'erreur et ne pas changer d'état.
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 2)
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 1}
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 2}
TakeANumberDeluxe.queue_new_number(machine)
# => {:error, :all_possible_numbers_are_in_use}
Implémente la fonction serve_next_queued_number/2 et le callback GenServer nécessaire.
Cette fonction doit appeler la fonction TakeANumberDeluxe.State.serve_next_queued_number/2 avec l'état actuel de la machine et son deuxième argument optionnel, priority_number.
Si TakeANumberDeluxe.State.serve_next_queued_number/2 renvoie un tuple {:ok, next_number, new_state}, la machine doit répondre à l'appelant avec le prochain numéro et définir le nouvel état comme son état. Si elle renvoie plutôt un tuple {:error, error}, la machine doit répondre à l'appelant avec l'erreur et ne pas changer d'état.
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 1}
TakeANumberDeluxe.serve_next_queued_number(machine)
# => {:ok, 1}
TakeANumberDeluxe.serve_next_queued_number(machine)
# => {:error, :empty_queue}
Implémente la fonction reset_state/1 et le callback GenServer nécessaire.
Cette fonction doit appeler la fonction TakeANumberDeluxe.State.new/2 pour créer un nouvel état à partir des min_number et max_number de l'état actuel. La machine doit définir ce nouvel état comme son état. Elle ne doit pas répondre à l'appelant.
{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.reset_state(machine)
# => :ok
Modifie le démarrage de la machine. Elle doit lire la valeur associée à la clé :auto_shutdown_timeout dans la liste de mots-clés passée comme init_arg et la passer comme troisième argument à TakeANumberDeluxe.State.new/3. Utilise la valeur par défaut :infinity si :auto_shutdown_timeout n'a pas été fourni.
Modifie la réinitialisation de l'état de la machine pour passer aussi auto_shutdown_timeout à TakeANumberDeluxe.State.new/3.
Modifie les valeurs de retour de tous les callbacks implémentés (init/1 et tous les callbacks handle_*) pour définir un délai d'expiration. Utilise la valeur associée à la clé :auto_shutdown_timeout dans l'état actuel de la machine. N'ajoute pas le délai à la valeur de retour {:stop, reason} de init/1 : les délais ne s'appliquent qu'après que le serveur a démarré sa boucle de réception.
Implémente un callback GenServer pour gérer le message :timeout qui sera envoyé à la machine si elle ne reçoit aucun autre message dans le délai imparti. Il doit arrêter le processus avec la raison :normal.
Assure-toi aussi de gérer les messages inattendus en les ignorant.
{:ok, machine} =
TakeANumberDeluxe.start_link(
min_number: 1,
max_number: 10,
auto_shutdown_timeout: :timer.hours(2)
)
# after 3 hours...
TakeANumberDeluxe.queue_new_number(machine)
# => ** (exit) exited in: GenServer.call(#PID<0.171.0>, :queue_new_number, 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.13.0) lib/gen_server.ex:1030: GenServer.call/3
Inscris-toi sur Exercism pour apprendre et maîtriser Elixir avec 58 concepts168 exercices, et un vrai mentorat humain, le tout gratuitement.