Découverte du langage Elixir

Voir la vidéo

Elixir est un langage de programmation fonctionnel, concurrent qui se repose sur la machine virtuelle Erlang (BEAM). Elixir est construit par dessus Erlang et partage les mêmes niveaux d'abstractions pour construire une application distribuée et résistante aux erreurs. Même si le langage est relativement récent il est déjà utilisé par des entreprises comme Pinterest, Discord, etc...

Erlang et OTP

Avant de parler du langage Elixir il est important de faire un point sur la technologie qui lui sert de base : Erlang. Je vous invite d'ailleurs à lire la définition Wikipedia qui décrit bien mieux que moi cette technologie et ses spécificités.

En lisant cette définition on peut se demander comment une telle technologie, pensée pour la téléphonie, peut s'adapter au cadre d'une application web mais au final les problématiques sont "relativement" similaires :

  • Lors d'une montée en charge il faut être capable de distribuer une partie de notre application pour répondre aux besoins sans affecter le reste de l'application.
  • Une requête qui crash ou un process qui échoue (échec d'un traitement d'image, d'un envoie d'email...) ne doit pas affecter toute l'application.
  • Lors d'une mise à jour, on souhaite mettre à jour l'application sans couper la connexion de tous nos utilisateurs (encore plus critique dans le cas des websockets).
  • Dans le cadre d'une application de petite taille, on souhaite partager le même code pour le serveur HTTP et le serveur de WebSocket (et ne pas avoir 2 codes / 2 langages à maintenir)

Erlang apporte une solution à toutes ces problématiques gràce à la structure OTP (Open Telecom Platform) qui consiste à découper notre application sous forme de processus ultra légers qui vont être capable de communiquer ensemble à travers un système de message interne à la machine virtuelle. Les processus seront accompagnés d'un superviseur capable d'agir en cas d'erreur (en redémarrant le process ou en faisant remonter le crash au superviseur parent). Cette structure donne d'ailleurs lieu à une philosophie propre à Erlang :

Let it crash

Si notre process rencontre une erreur qui n'est pas gérée, le process crash et un nouveau processus est démarré pour le remplacer. Ce processus redémarre dans un état "stable" et l'application ne se trouve pas affecté.
Attention cependant, ce système ne rendra pas votre application "fault tolerant" par magie. Si un processus crash en boucle le problème sera alors remonté et pourra affecter l'ensemble de votre application. Si la base de données est inaccessible par exemple, le processus n'arrivera pas à se reconnecter lors de son démarrage et au bout d'un certain nombre de redémarrage, fera planter le superviseur parent.

Enfin, Erlang permet de gérer nativement la distribution de son application en offrant la possibilité à l'application de communiquer avec des processus se trouvant sur un autre noeud sur le réseau.

Elixir, le langage

Erlang est un langage un peu trop "simple" qui entraine beaucoup de boilerplate et de répétition ce qui peut le rendre frustrant par moment. Elixir permet une meilleur organisation du code et offre une série de module afin de travailler plus simplement avec les outils fournis par erlang.

Les types

Le langage présente un certain nombre de types de variables.

nil        # Null
1          # Entier
1.0        # Float
true       # Booleen
"Salut"    # Chaine (<<83, 97, 108, 117, 116>>)
'Salut'    # Liste de caractères [83, 97, 108, 117, 116]
:atom      # Atom

# Fonction anonyme
fn a -> a * 2 end
fn a -> 
  a * 2 
end

[1, 2, "a"]  # List
{1, 2, "a"}  # Tuple

# Keyword lists
[{:a, 1}, {:b, 2}]
[a: 1, b: 2]
[
  where: "...",
  where: "..."
]

# Map
%{:a => 1, :b => 2, "clef" => 3}
%{a: 1, b: 2, "clef" => 3}

On remarque surtout la présence de structure similaire comme par exemple les List et les Tuple. Même si au premier abord, ces 2 types permettent de représenter les mêmes données, le stockage en mémoire est complètement différent et un choix peut s'avérer plus performant qu'un autre suivant les cas. Contrairement à certains langages on sera beaucoup plus attentifs aux performances lors de la sélection d'un type de variable.

Les chaînes de caractères peuvent aussi être représentées de 2 façons, sous forme de chaine binaire ou sous forme de liste de caractère. Les listes de caractères sont surtout là afin d'assurer la compatibilité avec d'anciennes librairies Erlang mais sont au final assez peu utilisé dans le code Elixir.

Le pattern matching

Le système de pattern matching permet de définir une fonction plusieurs fois avec des signatures différentes. Ce système est indispensable pour la récursivité, mais permet aussi de simplifier l'organisation du code.

  @spec is_prime?(integer()) :: boolean()
  def is_prime?(number) do
    is_prime?(number, number - 1)
  end
  def is_prime?(number, 2), do: rem(number, 2) != 0
  def is_prime?(number, divider) do
    if rem(number, divider) == 0 do
      false
    else
      is_prime?(number, divider - 1)
    end
  end

L'opérateur pipe

Combiner des fonctions peut gêner la lisibilité à cause du sens de lecture.

fonction4(fonction3(fonction2(fonction1(variable)), [3, 4]) + 4, "demo")

La première fonction qui est éxécutée se trouve au milieu de l'expression et le sens de lecture se fait de la droite vers la gauche ce qui est peu naturel. L'opérateur pipe permet d'écrir le même code de la manière suivante.

variable
  |> fonction1()
  |> fonction2()
  |> fonction3([3, 4])
  |> Kernel.+(4)
  |> fonction4("demo")

Cet opérateur permet de refléter le principe de la programmation fonctionnel où les fonctions sont considérées comme des transformations qui sont ensuite combinées ensemble pour créer un système plus complet. Le résultat de l'opération précédente est passé à l'opération suivante.

Le meta programming

Elixir permet de modifier le langage en introduisant de nouveaux mots clefs qui seront transformés à la compilation. C'est un système qui est utilisé en interne pour les conditions par exemples :

variable = if condition do
    "Condition a marché"
else
    "Condition n'a pas marché :("
end

Mais qui peut aussi être utilisé pour rajouter de nouveaux verbes. Par exemple le router Plug utilise des macro pour définir des routes plus facilement.

defmodule MonSuperRouter do
  use Plug.Router

  plug :match
  plug :dispatch

  get "/hello" do
    send_resp(conn, 200, "world")
  end

  forward "/users", to: UsersRouter

  match _ do
    send_resp(conn, 404, "oops")
  end
end

En revanche, il ne faudra pas trop en abuser au risque de rendre le code difficile à comprendre car il n'est pas forcément évident de comprendre ce qui se cache derrière une macro.

Module

Nos fonctions seront organisées dans des modules qui servent de "namespace".

defmodule Number do
  @moduledoc """
  Permet de faire des tests sur les nombres
  """

  @doc """
  Vérifie si un nombre est pair

  ## Examples

      iex> Number.is_pair?(14)
      true

      iex> Number.is_pair?(3)
      false

  """
  @spec is_pair?(integer()) :: boolean()
  def is_pair?(number) do
    rem(number, 2) == 0
  end

  @doc """
  Vérifie si un nombre est premier

  ## Examples

      iex> Number.is_prime?(29)
      true

      iex> Number.is_prime?(14)
      false

  """
  @spec is_prime?(integer()) :: boolean()
  def is_prime?(number) do
    is_prime?(number, number - 1)
  end
  defp is_prime?(number, 2), do: rem(number, 2) != 0
  defp is_prime?(number, divider) do
    if rem(number, divider) == 0 do
      false
    else
      is_prime?(number, divider - 1)
    end
  end

  @doc """
  Trouve le premier nombre premier d'une liste

  ## Examples

      iex> Number.first_prime([2, 14, 29, 12, 3])
      29

      iex> Number.first_prime([10, 6, 9])
      nil
  """
  @spec first_prime(list(integer())) :: integer() 
  def first_prime(numbers) do
    numbers
      |> Enum.filter(fn (number) -> is_prime?(number) end)
      |> List.first()
  end

end

Superviseur & Process

Créer un superviseur est extrèmement simple gràce au module Application

defmodule Counter.Application do

  use Application

  def start(_type, _args) do
    import Supervisor.Spec, warn: false

    # Définit les enfants à superviser
    children = [
      worker(Counter.Worker, []),
    ]

    # Définit les options de notre superviseur et lui donne un nom
    opts = [strategy: :one_for_one, name: Counter.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

Les processus enfants peuvent être de différents types et se basent sur GenServer

defmodule Counter.Worker do

  use GenServer

  def start_link() do
    state = %{
      number: 0
    }
    GenServer.start_link(__MODULE__, state, [name: :counter])
  end

  def get_number() do
    GenServer.call(:counter, :get)
  end

  def increment() do
    GenServer.cast(:counter, :increment)
  end

  def handle_call(:get, _from, state) do
    {:reply, state.number, state}
  end

  def handle_cast(:increment, state) do
    new_state = Map.put(state, :number, state.number + 1)
    {:noreply, new_state}
  end

end

La fonction start_link permet de démarrer le processus et sera automatiquement lancée par le superviseur. Elle permet de définr l'état de départ du processus qui sera ensuite gardé en mémoire.

Les fonctions handle_call et handle_cast permettent au processus de répondre aux messages qui lui seront envoyés. En plus de la réponse, ces fonctions peuvent renvoyer un nouvel état afin de garder en mémoire l'état du processus. Ce système d'état permet de contrebalancer la nature immutable de la programmation fonctionnelle et de faire évoluer le système.

Mix

mix est un outil qui permet de gérer un projet simplement. Par exemple, créer un projet peut se faire très simplement.

# Créer une nouvelle application
mix new app 

# Créer une nouvelle application avec un superviseur
mix new app --sup

Mais cette commande nous servira aussi à compiler notre application ou lancer les tests

mix compile
mix test

Tests unitaires

Les projets générés intègrent les tests unitaires de base et utilisent le système de macro pour une écriture simplifiée.

defmodule PairTest do
  use ExUnit.Case
  doctest Number

  test "the truth" do
    assert 1 + 1 == 2
  end
end

Les tests peuvent être aussi écrit lorsque l'on documente une fonction.

@doc """
Vérifie si un nombre est pair

## Examples

  iex> Number.is_pair?(14)
  true

  iex> Number.is_pair?(3)
  false

"""
@spec is_pair?(integer()) :: boolean()
def is_pair?(number) do
    rem(number, 2) == 0
end

Hex, un gestionnaire de paquet

mix permet aussi d'accéder au gestionnaire de paquet hex.pm afin d'intégrer plus facilement des librairies tiers au niveau de son projet.

mix deps.get

On appréciera notamment la génération automatique de la documentation qui permet de centraliser toutes les informations et présenter les choses de manière uniforme.

Phoenix, un framework web

Enfin, si vous souhaitez utiliser elixir pour créer une application web vous pouvez jeter un oeil au framework Phoenix qui vous offre tous les outils dont vous avez besoins pour vous lancer.

Publié
Technologies utilisées
Auteur :
Grafikart
Partager