Elixir ꞉ focus sur OTP Server

Publié le 1 février 2018 par Nicolas Cavigneaux | elixir

Cet article est publié sous licence CC BY-NC-SA

Dans l’article d’introduction sur Elixir j’ai mentionné le fait qu’OTP est un ensemble d’outils incroyables fourni par Erlang et qu’il mérite à lui seul tout un ensemble d’article.

Commençons donc par le premier, l’écriture de processus serveur.

OTP, c’est quoi déjà ?

Dès qu’on commence à s’intéresser à Elixir ou Erlang, on est rapidement confronté à l’acronyme OTP, un concept qui semble être central.

OTP est l’acronyme pour « Open Telecom Platform ». Naturellement en voyant ça, on se demande à quoi ça va bien pouvoir nous servir dans notre développement quotidien. Il s’avère que le nom est trompeur, c’est en fait un ensemble d’outils ayant des applications bien plus généralistes que la téléphonie.

Sous ce nom se cache un vaste ensemble de bibliothèques facilitant le développement de systèmes distribués, concurrents et tolérant les pannes.

Lorsque vous utilisez Elixir, vous utilisez OTP sans même vous en rendre compte. Les outils qui constituent Elixir en font tous un usage intensif.

GenServer

Dans cet article, nous allons voir comment créer nos propres outils tirants partie d’OTP, particulièrement de la partie GenServer qui simplifie la mise en place de la partie serveur dans une relation client / serveur.

GenServer a pour vocation de simplifier la mise en place de processus qui pourront gérer un état, exécuter du code de manière asynchrone, etc. Nous pourrions très bien écrire ça à la main, et c’est d’ailleurs un bon exercice pour comprendre le fonctionnement interne, mais l’utilisation de GenServer nous met à disposition une interface standard incluant des comportements par défaut que nous n’aurons qu’à écraser pour obtenir le fonctionnement désiré.

L’utilisation de GenServer simplifie aussi la gestion de nos processus par un superviseur mais nous verrons ça en détail dans un prochain article.

Écriture d’un serveur OTP

Écrire un serveur OTP consiste finalement à écrire un module qui contiendra les callbacks dont nous avons besoin. La grande majorité des serveurs ont les mêmes besoins, c’est pourquoi GenServer norme les callbacks disponibles via une interface (behaviour).

Par exemple, lorsqu’une requête est envoyée au serveur, la fonction handle_call va être appelée dans notre module. Cet appel de fonction se fait en passant en paramètres le message, l’origine et l’état courant du serveur. Cette fonction devra répondre avec un tuple décrivant le type de réponse, la valeur de la réponse et l’état mis à jour.

Créons un serveur OTP simpliste et voyons comment tout cela fonctionne en pratique. Dans cet exemple, on se contentera d’avoir un serveur qui nous renvoie un nombre qui sera incrémenté à chaque appel.

Création de l’application

Pour créer ce serveur, nous allons créer un projet dédié à l’aide de Mix.

$ mix new incrementer

* creating README.md
* creating .gitignore
* creating mix.exs
* creating config
* creating config/config.exs
* creating lib
* creating lib/incrementer.ex
* creating test
* creating test/test_helper.exs
* creating test/incrementer_test.exs

Your Mix project was created successfully.
You can use "mix" to compile it, test it, and more:

    cd incrementer
    mix test

Run "mix help" for more commands.

Si vous avez lu l’article précédent, vous vous souvenez sûrement que Mix est un outil central dans la gestion de projets. Ici la commande new a créé une structure prête à l’emploi. Un README est disponible ainsi qu’un .gitignore adapté à un projet Elixir.

Nous avons également un répertoire dédié à la configuration de l’application, un répertoire prêt à recevoir les tests et le plus important dans le cadre de cet article, le répertoire lib qui va contenir notre code applicatif.

Mix a généré un fichier lib/incrementer.ex pour nous donner une base de travail :

## lib/incrementer.ex

defmodule Incrementer do
  @moduledoc """
  Documentation for Incrementer.
  """

  @doc """
  Hello world.

  ## Examples

      iex> Incrementer.hello
      :world

  """
  def hello do
    :world
  end
end

Nous n’allons pas en garder grand-chose, ce fichier auto-généré fait surtout office d’exemple de structuration et de documentation du code.

Première version minimaliste

Voici à quoi ressemble notre version modifiée :

defmodule Incrementer do
  use GenServer

  def init(number) do
    {:ok, number}
  end

  def handle_call(:next, _from, current) do
    {:reply, current, current + 1}
  end
end

La toute première chose qu’on note c’est l’utilisation de GenServer. Pour faire simple, le mot-clé use permet de requérir le module spécifié puis d’appeler un callback dessus pour qu’il puisse injecter du code dans notre module. C’est de cette façon que GenServer nous fournit une implémentation par défaut.

On a ensuite déclaré une fonction init qui a pour vocation à être appelée lorsque notre serveur sera démarré via GenServer.start_link/2. Son but est simple, initialiser l’état du serveur avec le nombre qui nous sera passé en paramètre au lancement.

La seconde fonction handle_call est appelée lorsque le serveur reçoit une requête. Il est possible, et c’est souvent le cas, d’avoir plusieurs fonctions handle_call qui répondent à différents messages. C’est une fois encore un exemple de pattern matching.

Comme dit plus haut, les paramètres de cette fonction sont dans l’ordre :

  • le nom du message
  • l’émetteur (que nous ignorons ici grâce au _)
  • l’état courant du serveur

Cette fonction va répondre (atome :reply) avec l’état courant (le nombre courant) et retourner le nouvel état (le nombre incrémenté) pour l’appel suivant.

C’est le moment de tester notre petit serveur dans une console interactive :

iex -S mix

iex> {:ok, pid} = GenServer.start_link(Incrementer, 10)
{:ok, #PID<0.142.0>}
iex> GenServer.call(pid, :next)
10
iex> GenServer.call(pid, :next)
11
iex> GenServer.call(pid, :next)
12
iex> GenServer.call(pid, :next)
13

Simple et efficace.

Il est à noter qu’un appel à call signifie que le client attend une réponse en retour et que c’est un appel synchrone.

Si vous souhaitez gérer des appels clients pour lesquels aucune réponse n’est attendue il faudra passer par cast. Dans ce cas le serveur devra implémenter une fonction handle_cast adéquate.

Gestion des appels asynchrones

On pourrait par exemple écrire une fonction qui permet de redéfinir le compteur courant :

def handle_cast({:set, number}, _state) do
  {:noreply, number}
end

Cette fois, notre fonction n’attend que deux paramètres, la requête et l’état.

Vous aurez noté qu’ici on a choisit de passer un tuple plutôt qu’un simple atome. Nous avons fait ça pour pouvoir, lors de l’appel au serveur, passer à la fois un nom de message mais également une valeur associée. C’est en passant par un tuple que vous pouvez passer plus d’un argument lors de votre requête au serveur.

Notre fonction n’ayant à retourner quoi que ce soit au client, nous répondons avec un tuple de type :noreply en s’assurant de passer le nouvel état du serveur.

Essayons dans IEx :

iex> {:ok, pid} = GenServer.start_link(Incrementer, 10)
{:ok, #PID<0.174.0>}

iex> GenServer.call(pid, :next)
10
iex> GenServer.call(pid, :next)
11
iex> GenServer.call(pid, :next)
12
iex> GenServer.cast(pid, {:set, 100})
:ok
iex> GenServer.call(pid, :next)
100

Nommer un processus

Notre exemple est très simple, utilisé uniquement localement et à travers une seule application, avec une seule instance. Ça ne pose donc aucun problème à l’utilisation.

Qu’advient-il quand on lance une multitude de processus ? La gestion via les PIDs devient fastidieuse et cryptique. Heureusement, il est possible de nommer les processus de manière unique sur un nœud donné.

On pourra ensuite référencer un processus via son nom plutôt que par son PID.

Pour nommer un processus, il suffit de le lancer en passant l’argument name à start_link :

iex> GenServer.start_link(Incrementer, 10, name: :inc)
{:ok, #PID<0.182.0>}
iex> GenServer.call(:inc, :next)
10
iex> :sys.get_status(:inc)
{:status, #PID<0.182.0>, {:module, :gen_server},
 [["$ancestors": [#PID<0.140.0>, #PID<0.57.0>],
   "$initial_call": {Incrementer, :init, 1}], :running, #PID<0.140.0>, [],
  [header: 'Status for generic server inc',
   data: [{'Status', :running}, {'Parent', #PID<0.140.0>},
    {'Logged events', []}], data: [{'State', 11}]]]}

Encapsuler la logique de gestion du serveur

Notre code est tout à fait fonctionnel. Pourtant, quand on écrit un serveur de ce type, on préfère généralement fournir une interface publique à l’utilisateur pour gérer le lancement du serveur ainsi que les appels. On souhaite éviter les appels directs au module GenServer ce qui simplifie largement sa compréhension et rend notre module plus naturel pour l’utilisateur final.

Améliorons donc notre module serveur pour proposer une interface plus sexy et complète qui évitera les appels à d’autre module à nos utilisateurs.

Nous allons ajouter trois fonctions start_link, next et set qui vont encapsuler les appels à GenServer.

Voici à quoi ressemble notre fichier modifié :

defmodule Incrementer do
  use GenServer

  def init(number) do
    {:ok, number}
  end

  def handle_call(:next, _from, current) do
    {:reply, current, current + 1}
  end

  def handle_cast({:set, number}, _state) do
    {:noreply, number}
  end

  def start_link(number) do
    GenServer.start_link(__MODULE__, number, name: __MODULE__)
  end

  def next do
    GenServer.call(__MODULE__, :next)
  end

  def set(number) do
    GenServer.cast(__MODULE__, {:set, number})
  end
end

Essayons cette nouvelle version dans IEx :

iex> Incrementer.start_link(10)
{:ok, #PID<0.199.0>}
iex> Incrementer.next()
10
iex> Incrementer.next()
11
iex> Incrementer.set(50)
:ok
iex> Incrementer.next()
50

On a maintenant une version qui semble plus aboutie et plus naturelle à utiliser. Notre module inclut tout le nécessaire à sa manipulation et ne nécessite plus de connaître son fonctionnement interne pour pouvoir l’utiliser.

Évidemment on aurait pu aller encore plus loin en séparant le code de l’interface, du métier et de l’implémentation serveur dans différents modules pour éviter le couplage, faciliter l’écriture des tests et éviter de se retrouver avec un module géant qui fait tout si les fonctionnalités venaient à se multiplier.

Et pour la suite ?

Seriez-vous intéressés par un article expliquant le fonctionnement interne d’un GenServer dans lequel nous écririons le nôtre from scratch ? Si oui, faites-le-moi savoir dans les commentaires.

Il reste encore beaucoup de chose à voir concernant OTP, les superviseurs, la gestion des bases de données, la gestion des releases, le scaling automatique, …

J’espère que cet article vous aura éclairé si vous ne connaissiez pas OTP et qu’il vous aura donné envie de creuser le sujet.

Ressources


L’équipe Synbioz,
Libres d’être ensemble.