Parcours
/
Elixir
Elixir
/
Exercices
/
Prends un numéro deluxe
Prends un numéro deluxe

Prends un numéro deluxe

Exercice d'apprentissage

Introduction

GenServer

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.

Note

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.

Exemple

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

Callbacks

init/1

Un 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 :

  • Acceptent en premier argument un module qui implémente le comportement GenServer.
  • Acceptent n'importe quoi en deuxième argument, appelé init_arg. Comme son nom le suggère, cet argument est passé au callback init/1.
  • Acceptent un troisième argument facultatif contenant des options avancées pour l'exécution du processus, que l'on ne verra pas maintenant.

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/3

On 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 :

  1. message : la valeur passée en deuxième argument à GenServer.call/2.
  2. from : le pid du processus qui appelle GenServer.call/2. La plupart du temps, cet argument peut être ignoré.
  3. 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.

Note

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/2

On 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.

Note

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.

Faut-il utiliser 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/2

Des 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.

Délais d'attente

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.

Instructions

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 :

  • Garder la trace des numéros actuellement dans la file d'attente.
  • Définir le numéro minimum et maximum. Cela permettra d'utiliser plusieurs machines Take-A-Number deluxe pour mettre en file d'attente les clients de différents services d'un même établissement, et de distinguer les services par la plage de numéros.
  • Permettre à certains numéros de passer devant la file d'attente afin d'offrir un service prioritaire aux femmes enceintes et aux personnes âgées.
  • L'arrêt automatique pour éviter de laisser accidentellement la machine allumée tout le week-end et de gaspiller de l'énergie.

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.

1. Démarre la machine

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.

2. Rapporte l'état de la machine

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,
#    }

3. Mets de nouveaux numéros en file d'attente

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}

4. Sers le prochain numéro de la file d'attente

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}

5. Réinitialise l'état

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

6. Implémente l'arrêt automatique

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
Modifie via GitHub Le lien s'ouvre dans une nouvelle fenêtre ou un nouvel onglet
Elixir Exercism

Prêt à commencer Prends un numéro deluxe ?

Inscris-toi sur Exercism pour apprendre et maîtriser Elixir avec 58 concepts168 exercices, et un vrai mentorat humain, le tout gratuitement.