Évaluons votre projet

Introduction à Phoenix - Épisode 2

Publié le 24 juillet 2021 par Nicolas Cavigneaux | elixir - framework

Cet article est publié sous licence CC BY-NC-SA

Dans ce deuxième épisode, nous allons continuer sur notre lancée et présenter plus en détail le cycle de vie d’une requête. On mettra ensuite en place nos premières pages de bout en bout.

Request Pipeline

Dans le cadre d’une application Phoenix, ce qui nous intéresse c’est de gérer des requêtes entrantes sur notre serveur, pouvoir les identifier et les gérer comme il se doit, puis de répondre au client de manière appropriée (code de retour, réponse HTML ou JSON, etc).

On a donc, pour schématiser, une fonction qui prend une URL en entrée et sera capable de déterminer ce qu’elle doit faire pour répondre au client.

Une chose que je trouve appréciable avec Phoenix, c’est que rien n’est masqué. Très peu de magie opère et dans la majorité des cas il suffit de parcourir les fichiers de son application pour comprendre comment tout s’emboîte.

Cette fameuse grosse fonction qui gère la requête n’est en fait qu’un empilement de petites fonctions ayant une tâche simple à remplir et qui passent ensuite la main à une autre petite fonction. C’est ce qu’on appelle le request pipeline.

Il est très courant en Elixir, et en programmation fonctionnelle de manière générale, d’écrire de petites fonctions simples ayant un périmètre très restreint. On enchaînera ensuite ces appels de petites fonctions pour monter un mécanisme plus complexe.

Notre request pipeline est constitué d’un tas de petites fonctions qu’on appelle des plugs. Chaque plug reçoit en entrée une structure spéciale Plug.Conn. Cette structure représente toute la vie d’une requête de son arrivée sur le serveur jusqu’à sa sortie.

Pour les rubyistes, on pourrait rapprocher un plug d’un middleware rack.

Chaque plug va consommer et modifier légèrement les informations de cette structure pour créer une réponse HTTP.

Toute requête arrive d’abord sur ce qui dans le monde Phoenix s’appelle un endpoint. Chaque application Phoenix est générée avec un endpoint par défaut.

On peut trouver le nom de ce endpoint dans le fichier config/config.ex où on peut lire la ligne suivante :

config :first, FirstWeb.Endpoint

Cette ligne est suivie de configurations qui permettent de gérer comment ce endpoint doit se comporter. Elle permet donc de définir la configuration de FirstWeb.Enpoint dans notre application :first.

On peut maintenant essayer de voir comment ce endpoint se comporte en allant jeter un œil au fichier lib/first_web/endpoint.ex :

defmodule FirstWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :first

  # snip
  plug Plug.Static,
    at: "/",
    from: :first,
    gzip: false,
    only: ~w(css fonts images js favicon.ico robots.txt)

  # Code reloading can be explicitly enabled under the
  # :code_reloader configuration of your endpoint.
  if code_reloading? do
    socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
    plug Phoenix.LiveReloader
    plug Phoenix.CodeReloader
    plug Phoenix.Ecto.CheckRepoStatus, otp_app: :first
  end

  plug Phoenix.LiveDashboard.RequestLogger,
    param_key: "request_logger",
    cookie_key: "request_logger"

  plug Plug.RequestId
  plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]

  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Phoenix.json_library()

  plug Plug.MethodOverride
  plug Plug.Head
  plug Plug.Session, @session_options
  plug FirstWeb.Router
end

On y trouve principalement une liste de plugs qui viennent les uns après les autres pour définir comment notre endpoint doit se comporter.

On voit par exemple qu’on va pouvoir charger du contenu statique grâce au plug Plug.Static qui va se brancher sur / en toute première place et permettra de servir les fichiers CSS, les images, le JavaScript, etc. sans les gzipper.

Il y a ensuite quelques plugs pour faciliter le développement, un logger, de quoi gérer la session puis en dernier vient FirstWeb.Router qui est notre propre routeur, celui dans lequel nous avons déjà ajouté une route.

En parcourant ce fichier, on sait exactement comment une requête est traitée de bout en bout.

Notre routeur qu’on trouve dans lib/first_web/router.ex est composé de pipelines :

pipeline :browser do
  plug(:accepts, ["html"])
  plug(:fetch_session)
  plug(:fetch_flash)
  plug(:protect_from_forgery)
  plug(:put_secure_browser_headers)
end

pipeline :api do
  plug(:accepts, ["json"])
end

On voit que le premier se destine à gérer les requêtes qui ont pour but d’être rendues dans un navigateur.

Ce pipeline se charge de faire en sorte, grâce à d’autres plugs, d’accepter uniquement les requêtes HTML, de récupérer la session, d’afficher les flashes (des messages temporaires), d’assurer la protection contre des requêtes forgées, puis d’ajouter des en-têtes de sécurité dans la réponse.

L’autre pipeline, plus court, est lui destiné aux appels API. Tout ce qu’il fait par défaut, c’est s’assurer que la requête est bien de type JSON.

Le pipeline :browser est ensuite mis à contribution dans un scope :

scope "/", FirstWeb do
  pipe_through(:browser)

  get("/", PageController, :index)
  get("/hello/:name", HelloController, :world)
end

Ce scope concerne toutes les requêtes qui seront faites sur une URL qui débute par un /, passeront dans le pipeline :browser, puis arriveront dans notre contrôleur d’actions. On aura donc accès à la session, on sera sûr que la requête n’a pas été forgée, etc.

Si on résume, une requête entre donc par un endpoint, passe ensuite par le routeur potentiellement à travers un pipeline puis finalement dans un contrôleur.

Dans le contrôleur, c’est notre code qui prend le pas. Notre action a pour but d’orchestrer ce qu’il va se passer en faisant par exemple appel à du code métier qui lui-même interroge la base de données, l’action va ensuite exposer les informations à la vue qui, enfin, se chargera de rendre le template.

Découverte des contextes / contrôleurs / vues / helpers

Nous allons maintenant essayer de développer une application plus riche en termes de fonctionnalités et qui nous permettra de voir chaque brique de Phoenix plus en détail.

Nous allons créer une nouvelle application qui nous servira de support jusqu’à la fin. Cette application aura pour but de pouvoir créer un canal de discussion centré autour d’une vidéo

Chaque utilisateur pourra regarder cette vidéo et la commenter en temps réel. Les autres utilisateurs présents verront ces commentaires s’afficher. Si un autre utilisateur vient par la suite et regarde la vidéo depuis le début, on lui affichera les commentaires déjà publiés aux moments opportuns de la vidéo.

Les utilisateurs pourront se créer un compte et s’identifier.

Nous allons commencer par créer une page qui permet de lister les utilisateurs et une autre qui permet d’accéder aux détails d’un utilisateur donné.

Création de la nouvelle application

mix phx.new commentator
cd commentator

On peut maintenant éditer le fichier config/dev.exs pour y mettre les bonnes informations pour notre base de données. En ce qui me concerne j’aurais quelque chose comme :

config :commentator, Commentator.Repo,
  username: "nico",
  password: "",
  database: "commentator_dev",
  hostname: "localhost",
  show_sensitive_data_on_connection_error: true,
  pool_size: 10

On peut maintenant demander la création de cette base :

mix ecto.create

Notre base de développement a été créée, on va pouvoir lancer le serveur. On peut le faire avec le très classique

mix phx.server

Mais pour ma part j’aime lancer le serveur à travers iex ce qui me permet d’avoir une console interactive qui a connaissance de mon projet :

iex -S mix phx.server

On peut se rendre sur http://localhost:4000/ pour voir notre page par défaut s’afficher !

Page d'accueil par défaut

Commençons à apporter quelques changements. On va utiliser cette page par défaut, et son framework CSS Milligram inclus par défaut pour présenter notre application.

Éditons le fichier lib/commentator_web/templates/page/index.html.eex :

<section class="phx-hero">
  <h1><%= gettext "Welcome to %{name}!", name: "Commentator" %></h1>
  <p>Let's comment everything!</p>
</section>

Dès que vous sauvez ce fichier, vous voyez que votre contenu se met à jour dans le navigateur. Le système de hot reloading a détecté vos changements et vous présente automatiquement la nouvelle version. Plutôt pratique !

Notre page d'accueil personnalisée

Nous allons maintenant mettre en place notre page de listing des utilisateurs.

Contextes

Nous pourrions créer un contrôleur avec une action et aller y mettre directement toute la logique de gestion de nos utilisateurs dedans. Techniquement ça fonctionnerait mais, sur le long terme, cette pratique rendrait l’application difficilement maintenable et évolutive.

Phoenix propose d’utiliser le concept de contextes. Les contextes ne sont que de simples modules Elixir mais leur but est d’exposer une API métier publique qui pourra être utilisée par le reste de l’application.

Exposer une API va permettre de complètement masquer la machinerie derrière la logique métier. Les contrôleurs et autre morceaux de l’application pourront donc utiliser l’API publique pour mettre à disposition les fonctionnalités sans avoir à se soucier de comment ça fonctionne sous le capot. En appelant cette API publique qu’offre notre contexte, on s’assure d’avoir toujours les mêmes fonctions, paramètres et retours, peu importe que cette API aille chercher ses informations dans une base de données, un CSV ou une API externe. D’ailleurs si cette source de données venait à changer entre temps, notre code applicatif qui utilise l’API n’en aurait pas conscience et ne serait pas du tout impacté.

Pour illustrer ce propos et faciliter la mise en place de notre première page, nous allons mettre en place un contexte qui va nous permettre de récupérer une liste d’utilisateurs ou un utilisateur donné.

Cette première version du contexte utilisera des données en dur, elle n’ira pas interroger une base de données. Dans un second temps, on modifiera notre contexte pour qu’il utilise une base de données et vous pourrez voir à quel point ça aide à abstraire cette logique du reste du code applicatif.

Nos utilisateurs, en plus d’avoir le mérite d’exister, pourront par la suite s’authentifier. La gestion des utilisateurs et de leur authentification se fera probablement à travers le même contexte. On va donc pouvoir créer un contexte Accounts qui viendra accueillir la définition de la structure de données d’un utilisateur et par la suite du système d’authentification.

Commençons par créer un module pour la structure d’un utilisateur. On crée le fichier lib/commentator/accounts/user.ex :

defmodule Commentator.Accounts.User do
  defstruct [:id, :name, :username]
end

On a simplement créé un module Elixir tout ce qu’il y a de plus classique qui appartient à notre application Commentator, qui se trouve dans le contexte Accounts et qui s’appelle User.

Dans ce module, on a un appel à defstruct qui est un facilitateur permettant de structurer des données. Pour résumer, defstruct dit ici à notre module qu’il va accueillir une map (un équivalent de hash au sens Ruby). Cette structure aura trois clés, id, name et username.

Dès lors qu’on créera une struct de type User, ces trois clés seront présentes.

Voyons un exemple :

iex> alias Commentator.Accounts.User
iex> %User{}
%Commentator.Accounts.User{id: nil, name: nil, username: nil}

Un struct a été créé avec l’ensemble des clés présentes et ayant une valeur à nil, on pourrait également passer des valeurs à la création :

iex> nico = %User{name: "Nico"}
%Commentator.Accounts.User{id: nil, name: "Nico", username: nil}
iex> nico.name
"Nico"

Un struct va également prendre soin de vérifier que les clés utilisées sont valides :

iex(5)> %User{something: "Nico"}
** (KeyError) key :something not found

Quand on maîtrise sa structure de données, il est donc préférable d’utiliser un struct plutôt qu’un simple map qui ne contraint aucunement ce qui est utilisé et ne propose pas de gestion de valeurs par défaut.

Forts de ce nouveau module qui structure nos utilisateurs, nous pouvons passer à l’écriture de fonctions de gestion de ces derniers dans notre contexte Accounts. On édite donc le fichier lib/commentator/accounts.ex :

defmodule Commentator.Accounts do
  @moduledoc """
  Accounts context dedicated to handle users and authentication
  """

  alias Commentator.Accounts.User

  def list_users do
    [
      %User{id: "1", name: "Nico C", username: "Bounga"},
      %User{id: "2", name: "Jon F", username: "Jon"},
      %User{id: "3", name: "Martin C", username: "Fuse"}
    ]
  end

  def get_user(id) do
    Enum.find(list_users(), fn user -> user.id == id end)
  end

  def get_user_by(params) do
    Enum.find(list_users(), fn user ->
      Enum.all?(params, fn {key, value} -> Map.get(user, key) == value end)
    end)
  end
end

Nous avons donc un module (un contexte) Accounts qui a pour but d’être notre interface publique pour tout ce qui concerne la gestion des utilisateurs et de l’authentification.

La première chose qu’on ajoute c’est une annotation @moduledoc qui documente le rôle du module. Je vous conseille de prendre l’habitude de l’ajouter, Elixir est un langage où la documentation tient une place très importante.

Ensuite on met en place un alias. Ce n’est absolument pas obligatoire mais en le faisant, à chaque fois qu’on voudra faire référence au module Commentator.Accounts.User, on pourra le faire en appelant simplement User. Ça économise les doigts et je pense aussi que ça améliore la lisibilité.

On définit ensuite la première fonction de notre contexte, list_users qui comme son nom l’indique va nous permettre de récupérer la liste des utilisateurs.

Pour le moment c’est une liste fixe, qui crée trois struct de type User et nous les retourne dans une liste (un tableau au sens Ruby).

Notre deuxième fonction permet de récupérer un utilisateur sur la base de son id. On utilise pour ce faire Enum.find/3 qui parcourt la liste jusqu’à trouver la première occurrence d’un élément qui remplit la condition décrite dans la fonction anonyme.

Pour finir notre dernière fonction permet de rechercher le premier utilisateur de la liste dont les paramètres passés correspondent à ceux du struct. Cette fonction est légèrement plus compliquée. Elle parcourt un par un les éléments de list_users/0 et pour chaque élément elle vérifie si chacun des paramètres passés correspond. Si oui, elle retourne cet élément.

Voyons un peu tout ça en action :

iex> Commentator.Accounts.list_users()
[
  %Commentator.Accounts.User{id: "1", name: "Nico C", username: "Bounga"},
  %Commentator.Accounts.User{id: "2", name: "Jon F", username: "Jon"},
  %Commentator.Accounts.User{id: "3", name: "Martin C", username: "Fuse"}
]

iex> Commentator.Accounts.get_user("2")
%Commentator.Accounts.User{id: "2", name: "Jon F", username: "Jon"}

iex> Commentator.Accounts.get_user_by(username: "Bounga")
%Commentator.Accounts.User{id: "1", name: "Nico C", username: "Bounga"}

Avec ce peu de code, on a un système exploitable de gestion des comptes. On va pouvoir l’utiliser dans nos contrôleurs et même écrire des tests qui s’assurent de son bon fonctionnement.

Quand on souhaitera changer son fonctionnement interne, ça ne devrait avoir aucun impact sur nos contrôleurs, ni nos tests.

On a cloisonné la logique dans notre contexte qui ne laissera pas transpirer les détails d’implémentation et permettra aux autres briques de l’application de travailler sereinement.

Vous vous étonnez peut-être d’avoir utilisé une chaîne pour l’ID plutôt qu’un entier. Ce n’est pas une décision d’architecture mais un choix qui facilite les fonctions que nous écrirons par la suite. Dans un souci de clarté, je préfère garder les fonctions les plus simples possible dans un premier temps.

Créer notre contrôleur

Nous allons avoir besoin de deux routes, une qui va nous présenter la liste complète des utilisateurs de la plate-forme et une autre qui permettra d’avoir le détail à propos d’un utilisateur donné.

On pourrait prendre un raccourci et utiliser les outils mis à notre disposition pour mettre ça en place, mais dans un premier temps, faisons-le par nous-même. Par la suite, on utilisera les générateurs et les macros qui permettent de générer un ensemble de routes pour nous.

Pour commencer, on va ajouter les routes qui vont pointer vers des contrôleurs / actions qu’on mettra en place ensuite.

On édite donc lib/commentator_web/router.ex :

scope "/", CommentatorWeb do
  pipe_through :browser

  get "/", PageController, :index
  get "/users", UserController, :index
  get "/users/:id", UserController, :show
end

La sous-partie qui nous intéresse accueille donc deux nouvelles routes. La première nous permettra de lister l’ensemble des utilisateurs. La deuxième, qui prend un paramètre :id, va elle nous servir à afficher les informations détaillées d’un utilisateur sur la base de son identifiant.

Comme vous pouvez le voir, la liste pointe sur l’action index de notre contrôleur à créer, le détail d’un utilisateur pointe lui vers l’action show.

Notre prochaine étape va donc être de créer ce contrôleur, créer les deux actions qui correspondent et mettre en place le code nécessaire pour ces actions.

Comme nous l’avions anticipé, elles n’auront quasiment qu’à utiliser les fonctions de notre contexte Accounts.

On crée le fichier lib/commentator_web/controllers/user_controller.ex :

defmodule CommentatorWeb.UserController do
  use CommentatorWeb, :controller

  alias Commentator.Accounts

  def index(conn, _params) do
    users = Accounts.list_users()

    render(conn, "index.html", users: users)
  end
end

Notre action ne fait pas beaucoup plus qu’utiliser notre contexte Accounts pour récupérer la liste des utilisateurs, ensuite elle demande un rendu à la vue en lui passant la liste d’utilisateurs.

Vue

Il va maintenant nous falloir écrire la vue qui est appelée par le contrôleur pour préparer le rendu.

Comme on l’a déjà vu auparavant, les vues dans Phoenix sont une brique à part entière. Il y a une réelle distinction entre vue et template.

La vue est un module Elixir, un intermédiaire entre l’action contrôleur et le template, qui va définir un certain nombre de fonctions qui seront mises à disposition du template et qui serviront à préparer et formater des données.

Les vues peuvent servir à définir des fonctions qui aideront à préparer de l’HTML, du JSON, du XML, peu importe. C’est une couche intermédiaire qui va éviter d’avoir à faire du traitement de formatage directement dans le template.

Créons notre module de vue pour y écrire une fonction simple qui va extraire la première partie du nom complet de l’utilisateur, donc à priori son prénom.

On crée donc le fichier lib/commentator_web/views/user.ex :

defmodule CommentatorWeb.UserView do
  use CommentatorWeb, :view

  alias Commentator.Accounts.User

  def first_name(%User{name: name}) do
    name
    |> String.split(" ")
    |> Enum.at(0)
  end
end

On a simplement ajouté une fonction qui va nous permettre d’afficher le prénom sur la base de nom complet dans le template. Cette solution n’est pas parfaite mais nous sera bien suffisante pour l’exemple.

On a donc une fonction first_name/1 qui prend un struct de type User en paramètre et essaie de nous en retourner son prénom.

Template

Avec tout ces outils dans notre poche, on est en mesure d’écrire la vue qui va afficher la liste des utilisateurs :

lib/commentator_web/templates/user/index.html.eex

<h1>Liste des utilisateurs</h1>

<table>
  <%= for user <- @users do %>
  <tr>
    <td><b><%= first_name(user) %></b> (<%= user.id %>)</td>
    <td><%= link "Détails", to: Routes.user_path(@conn, :show, user.id) %></td>
  </tr>
  <% end %>
</table>

On fait ici une boucle sur notre liste d’utilisateurs mise à disposition par notre contrôleur à travers la variable @users. Pour chaque utilisateur, on affiche le prénom grâce à la fonction définie dans notre vue. On affiche également son identifiant. Pour finir, on crée un lien vers la page de détail de l’utilisateur en utilisant les fonctions mises à disposition par le routeur.

Ces fonctions, livrées par Phoenix, nous sont disponibles parce que chaque vue fait appel à use CommentatorWeb, view qui vient mettre à disposition un certain nombre d’helpers (liens, I18n, messages flash, éléments de formulaire, …) dans nos templates.

Si vous allez jeter un œil au fichier lib/commentator_web.ex vous y trouverez la fonction view qui importe ces différents outils.

Liste des utilisateurs

Détails d’un utilisateur

On peut maintenant passer à l’écriture de la page de détail d’un utilisateur. Nous avons déjà mis en place la route qui s’attend à recevoir l’id de l’utilisateur à afficher. Cette route est liée à l’action show de notre contrôleur UserController.

On va donc créer la fonction show :

def show(conn, %{"id" => id}) do
 user = Accounts.get_user(id)

 render(conn, "show.html", user: user)
end

Une fois encore, on fait usage du pattern matching pour extraire l’id des paramètres reçus par l’action. On utilise ensuite la fonction get_user définie plus tôt dans notre contexte Accounts pour récupérer un utilisateur sur la base de son id, puis on demande le rendu du template show en lui passant la variable user.

On peut maintenant créer ce template :

<h1><%= @user.name %></h1>
<p>id: <%= @user.id %></p>
<p>username: <%= @user.username %></p>

Si on se rend sur http://localhost :4000/users/1 on verra le détail de notre utilisateur ayant pour id 1, qui pour le moment correspond à «Nico C» puisque notre contexte définit les utilisateurs en dur dans le code.

Détails d'un utilisateur


L’équipe Synbioz.
Libres d’être ensemble.