Article écrit par Nicolas Cavigneaux
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.