Atelier Elixir

JDLL, Lyon, 2 avril 2022. Par Stéphane Bortzmeyer. Atelier. (Source du support en Markdown. Article sur cet atelier.)

Pourquoi Elixir

Exemples de programmes écrits en Elixir :

Ressources à garder sous la main

Elixir en dix minutes

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

Solution

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

Variables immuables (ou presque)

v = 1
v = 2 # Nouvelle variable !
IO.puts(v)

« warning: variable “v” is unused »

Structures de contrôle

Plutôt fonctionnelles (peu de if, encore moins de for)

Correspondance de motif

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

Solution

Fonctions

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

Les boucles, c’est mal

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

Tuyaux

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

Solution

Structures de données

Parallélisme

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

Exercices

Prouver qu’on bosse

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

Solution

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 ?

Solution

Avec dépendances

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

Le contenu de get-try.exs

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.

Solution

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.

Solution

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.

Solution

Un serveur Internet simple

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

Un serveur DNS en Elixir

Drink utilise la bibliothèque dns.

Pour aller plus loin

Un exemple d’utilisation du module Task pour faire la preuve de travail : solution.

Les cadriciels

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.