JDLL, Lyon, 2 avril 2022. Par Stéphane Bortzmeyer. Atelier. (Source du support en Markdown. Article sur cet atelier.)
Exemples de programmes écrits en Elixir :
EXERCICE Mettez IO.puts("Hello")
dans un fichier hello.exs
. Exécutez-le avec elixir hello.exs
.
(Sur Arch Linux, sudo pacman -S elixir
, sur Debian, sudo apt install elixir
, etc.)
Si votre programme boucle et que vous l’interrompez avec Control-C, vous aurez :
BREAK: (a)bort (A)bort with dump (c)ontinue (p)roc info (i)nfo
(l)oaded (v)ersion (k)ill (D)b-tables (d)istribution
C’est normal, et répondez a
(pour abort).
v = 1
v = 2 # Nouvelle variable !
IO.puts(v)
« warning: variable “v” is unused »
Plutôt fonctionnelles (peu de if, encore moins de for)
# Ce qui commence par deux points est un atome.
light = :green
case light do
:green -> IO.puts("Go")
:red -> IO.puts("No go")
:orange -> IO.puts("Check")
end
Beaucoup de fonctions en Elixir renvoient un tuple sur lequel on peut faire de la correspondance de motif. Par exemple, on renverra {:ok}
quand tout s’est bien passé et {:error, "Le petit chat est mort"}
quand il y a eu un problème. Un « no match of right hand side value » ou « no case clause matching » indique typiquement un cas non traité.
Un exemple dans le code de Certstream :
case HTTPoison.get(full_url, [user_agent], options) do
{:ok, %HTTPoison.Response{status_code: 200} = response} ->
...
{:ok, response} ->
Logger.error("Unexpected status code #{response.status_code} fetching url #{full_url}! ...
...
{:error, %HTTPoison.Error{reason: reason}} ->
Logger.error("Error: #{inspect reason} while GETing #{full_url}! ...
EXERCICE Si vous mettez light
à :white
, que se passe-t-il ? Modifier le programme pour gérer ce cas. Indication : _
correspond à tout, mais vous pouvez aussi utiliser un nom de variable dans le membre de gauche d’une clause, et cette variable sera liée à la valeur.
# On ne peut définir des fonctions que dans des modules
defmodule Hello do
def hello do
:coucou # Pas de 'return'
end
end
IO.puts(Hello.hello) # Pas besoin de parenthèses si pas de paramètres
La signature d’une fonction aide à choisir la bonne fonction :
defmodule Hello do
def hello (text) do
"Hello, #{text}"
end
# Version sans paramètres
def hello do
"Hello, world"
end
end
# Elixir en interne identifie les fonctions par nom/arité (hello/0 et
# hello/1)
IO.puts(Hello.hello)
IO.puts(Hello.hello("utilisateurice"))
La correspondance de motif marche aussi avec les fonctions :
defmodule Actions do
def action ({:ok}) do
"Tout va bien"
end
def action({:error, reason}) do
"Aïe, aïe, tout à raté en raison de \"#{reason}\""
end
end
IO.puts(Actions.action({:ok}))
IO.puts(Actions.action({:error, "Tempête de sable"}))
#IO.puts(Actions.action({:foobar, "no function clause matching in Actions.action/1"}))
Exemple dans le code de Certstream :
def handle_info({:ssl_closed, _}, state) do
Logger.info("Worker #{inspect self()} got :ssl_closed message. Ignoring.")
{:noreply, state}
end
def handle_info(:init, state) do
...
def handle_info(:update, state) do
...
Le module Enum nous fournit plein d’opérations sur les structures de données.
numbers = [1, 5, 2, 18, 3]
# Notez la fonction anonyme (fn…)
evens = Enum.filter(numbers, fn n -> rem(n, 2) == 0 end) # rem = remainder (reste de la division)
IO.inspect(evens)
triples = Enum.map(numbers, fn n -> n*3 end)
IO.inspect(triples)
Lire la documentation des modules :
random(enumerable)
random(t()) :: element()
Returns a random element of an enumerable.
Un enumerable
est n’importe quoi qui peut être énuméré (une liste, par exemple). C’est ce qu’on nomme en Elixir un protocole : un comportement défini par les fonctions possibles (ici, énumérer les valeurs du n’importe quoi).
Technique courante en Elixir : chainer les opérations (un peu comme le tube du shell Unix) avec |>
:
:crypto.hash(:sha256, "toto")
|> Base.encode16()
|> String.downcase()
|> IO.puts
# Équivaut à
# IO.puts(String.downcase(Base.encode16(:crypto.hash(:sha256, "toto"))))
Pris dans le code de Mobilizon :
headers =
[{:Accept, "application/activity+json"}]
|> maybe_date_fetch(date)
|> sign_fetch(on_behalf_of, url, date, options)
EXERCICE En utilisant tuyaux, Enum.map
et Enum.take
, écrire un programme qui renvoie les trois premiers carrés de la liste [1, 3, 4, 10, 15]
. (IO.puts
ne donnera pas le résultat attendu, utilisez IO.inspect
.)
{:foo, 3, "bar"}
[1, 2, 3, 0]
(Ce n’est pas un tableau)%{"Pécresse" => "LR", "Poutou" => "NPA", "Jadot" => "Verts"}
Il y a plusieurs raisons pour faire du parallélisme :
Elixir permet de créer des processus et de les synchroniser entre eux, par envoi de messages (pas de mémoire partagée).
pid = spawn(fn -> dosomething end)
send(pid, :message)
receive do
m -> IO.puts("J'ai reçu #{m}")
end
Voici un exemple où deux processus ne sont pas synchronisés : le programme.
EXERCICE Faites-le tourner et notez que chaque processus affiche sa séquence de nombres indépendamment. Puis mettez sync
à true
. Que se passe-t-il ?
Vous avez des modules de plus haut niveau pour les cas courants : Process, Task, Agent, GenServer (qui définit un comportement, pas du code).
L’exercice est inspiré de la preuve de travail, utilisée notamment par Bitcoin. Pour éviter que la chaîne de blocs ne soit noyée sous des blocs futiles, on exige de ceux qui ajoutent des blocs (les mineurs) qu’ils démontrent qu’ils ont effectué un certain travail. Le travail en question est de compléter un bloc avec du remplissage de tel façon à ce que le condensat SHA-256 du bloc commence par N zéros (ici, nous prendrons N = 2).
On va effectuer ce travail en séquentiel, puis en parallèle ce qui, sur une machine multi-processeurs / multi-cœurs, devrait aller bien plus vite.
EXERCICE Écrire d’abord une version séquentielle. Pour générer le remplissage du bloc, on peut utiliser le module Erlang :rand
. Pour transformer une liste en une valeur scalaire, on peut utiliser le module Erlang :binary
ou le module Binary
(qui ajouterait une dépendance). Le module String
peut également servir.
Si le remplissage est fait avec des données aléatoires, pour mesurer sérieusement le temps de calcul, il faut itérer plusieurs fois (sinon, un coup de chance ou de malchance peut fausser la mesure).
EXERCICE Développez maintenant une version parallèle, en créant autant de processus qu’il y a de cœurs dans votre machine. Vous pouvez (mais ce n’est pas obligatoire) utiliser le module Task
. Si vous utilisez directement spawn
, vous devrez probablement passer au processus créé le PID de son créateur, pour qu’il puisse envoyer le résultat.
Mesurez le temps nécessaire pour produire 1 000 condensats commençant par deux zéros, avec la version séquentielle et la parallèle. Voyez-vous une différence ?
Elixir est assez pénible ici. Si un programme nécessite une bibliothèque extérieure, il n’y a pas de solution pratique pour installer cette bibliothèque pour tous vos programmes. La méthode la plus courante est que chaque projet soit une fermeture, incluant toutes ses dépendances. L’outil mix
sert à gérer cela.
Attention, sur des systèmes d’exploitation comme Debian et dérivés, il sera souvent nécessaire d’avoir le paquetage erlang-dev
pour compiler certaines bibliothèques.
Autre avertissement : la première fois, mix vous demandera s’il peut installer hex (le dépôt de programmes). Dites oui.
Prenons comme exemple un programme qui va chercher à récupérer le fichier security.txt
d’un site Web. Pour faire du HTTP, il nous faut une bibliothèque extérieure, ici HTTPoison. On va créer un projet :
% mix new sec_txt_search
% cd sec_txt_search
% ${EDITOR} mix.exs
# Ajouter {:httpoison, "~> 1.7"} à la liste deps
% mix deps.get
% ${EDITOR} get-try.exs
% mix run get-try.exs bortzmeyer.org
EXERCICE Permettre de tester N domaines en parallèle. On peut utiliser spawn_monitor
au lieu de spawn
car il génère automatiquement un message au créateur lorsque le processus créé se termine.
EXERCICE Afficher les titres des questions (issues) d’un projet sur Framagit. Framagit est un gitlab donc on peut utiliser l’API de gitlab (documentée en ligne). Par exemple, la requête HTTP https://framagit.org/api/v4/projects/20125/issues
(20125 est Mobilizon) va renvoyer un tableau JSON où chaque élément a un membre title
. Vous allez donc devoir utiliser HTTPoison (ou équivalent) pour faire la requête HTTP et Jason (ou équivalent) pour décoder le JSON.
EXERCICE (Avertissement : plus difficile.) Afficher les dates de création de noms de domaine sous .bzh
(comme quimper.bzh
). On va utiliser le protocole RDAP (RFC 9082 et RFC 9083). L’URL du service est https://rdap.nic.bzh/domain/
et on ajoute le nom de domaine à la fin pour avoir une réponse en JSON qui inclut un tableau events
nous donnant la réponse :
[
{
"eventAction": "registration",
"eventDate": "2014-12-03T13:51:17Z"
},
{
"eventAction": "expiration",
"eventDate": "2022-12-03T13:51:17Z"
},
{
"eventAction": "last changed",
"eventDate": "2021-10-19T14:08:19.756973Z"
}
]
Vous allez donc devoir utiliser HTTPoison (ou équivalent) pour faire la requête HTTP et Jason (ou équivalent) pour décoder le JSON.
Il met en œuvre le protocole echo (RFC 862) : le code.
% elixir echo-server.exs
[Autre fenêtre]
% telnet localhost 7777
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
toto
toto
tata
tata
Drink utilise la bibliothèque dns.
Un exemple d’utilisation du module Task pour faire la preuve de travail : solution.
Beaucoup de programmes écrits en Elixir ne font pas tout eux-même mais développent à partir d’un cadriciel, un ensemble de bibliothèques et de conventions qui synthétisent une certaine façon de développer, par exemple, une application Web. Le plus connu, pour ces applications Web, est Phoenix, utilisé notamment par Mobilizon et Pleroma. Mais il y a aussi Plug.
Un autre cadriciel (mais c’est plus conceptuel) connu est OTP, qui permet entre autres de faire des serveurs robustes, en assurant leur démarrage, leur redémarrage s’ils plantent, etc. Pleroma l’utilise.