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.
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.
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.
É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.
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.
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 :
_
)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.
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
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}]]]}
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.
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.
L’équipe Synbioz,
Libres d’être ensemble.
Nos conseils et ressources pour vos développements produit.