<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="fr"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://nseaseb.github.io/ElixirPhoenixRessources/feed.xml" rel="self" type="application/atom+xml" /><link href="https://nseaseb.github.io/ElixirPhoenixRessources/" rel="alternate" type="text/html" hreflang="fr" /><updated>2026-09-08T12:49:20+02:00</updated><id>https://nseaseb.github.io/ElixirPhoenixRessources/feed.xml</id><title type="html">Elixir &amp;amp; Phoenix en français</title><subtitle>Des articles en français sur Elixir, Phoenix, LiveView, Ecto et OTP. Le contenu francophone qui manque à l&apos;écosystème.</subtitle><author><name>nseaSeb</name></author><entry xml:lang="fr"><title type="html">Arrêter de déboguer avec des Logger.info : iex, IO.inspect, dbg, pry</title><link href="https://nseaseb.github.io/ElixirPhoenixRessources/articles/iex-dbg-debogage/" rel="alternate" type="text/html" title="Arrêter de déboguer avec des Logger.info : iex, IO.inspect, dbg, pry" /><published>2026-09-08T12:00:00+02:00</published><updated>2026-09-08T12:00:00+02:00</updated><id>https://nseaseb.github.io/ElixirPhoenixRessources/articles/iex-dbg-debogage</id><content type="html" xml:base="https://nseaseb.github.io/ElixirPhoenixRessources/articles/iex-dbg-debogage/"><![CDATA[<p>Il existe un réflexe universel, et il est mauvais :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="s2">"panier = </span><span class="si">#{</span><span class="n">inspect</span><span class="p">(</span><span class="n">panier</span><span class="p">)</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
</code></pre></div></div>

<p>Trois défauts, qu’on ne voit pas tant qu’on n’a pas essayé autre chose. Il <strong>casse le pipe</strong> — il faut sortir la valeur du flux pour l’observer. Il <strong>perd la provenance</strong> — au sixième log, plus personne ne sait lequel a produit quoi. Et il <strong>faut le retirer à la main</strong>, ce qu’on oublie une fois sur trois.</p>

<p>Elixir livre une petite échelle d’outils qui règlent ces défauts un par un. On monte d’un barreau quand le précédent ne suffit plus. Toutes les sorties ci-dessous ont été produites sur Elixir 1.19.5 / OTP 28.</p>

<!--more-->

<h2 id="barreau-0--le-shell-comme-terrain-dessai">Barreau 0 : le shell, comme terrain d’essai</h2>

<p>Avant de déboguer quoi que ce soit, il faut un endroit où essayer. C’est <code class="language-plaintext highlighter-rouge">iex</code>, et il ne demande aucun projet :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ iex
Interactive Elixir (1.19.5) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)&gt;
</code></pre></div></div>

<p>Quatre aides suffisent à s’y sentir chez soi.</p>

<p><strong><code class="language-plaintext highlighter-rouge">h</code></strong> ouvre la documentation sans quitter le shell — <code class="language-plaintext highlighter-rouge">h Enum.reduce</code> affiche la doc de la fonction, <code class="language-plaintext highlighter-rouge">h Enum</code> celle du module. <strong><code class="language-plaintext highlighter-rouge">i</code></strong> interroge une valeur :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>iex&gt; i :atome
Term
  :atome
Data type
  Atom
Reference modules
  Atom
Implemented protocols
  IEx.Info, Inspect, JSON.Encoder, List.Chars, String.Chars
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">v(n)</code></strong> rattrape un résultat qu’on a laissé filer. On a calculé quelque chose de long, on n’a pas pensé à l’affecter : <code class="language-plaintext highlighter-rouge">v(3)</code> renvoie le résultat de la troisième expression, et <code class="language-plaintext highlighter-rouge">v()</code> celui de la dernière.</p>

<p><strong>La touche tabulation</strong> complète les noms de modules et de fonctions. <code class="language-plaintext highlighter-rouge">Enum.</code> suivi de tabulation liste tout ce que le module expose ; c’est la façon la plus rapide de découvrir la bibliothèque standard.</p>

<p>Un cas qui bloque tous les débutants : on tape une expression, on oublie un <code class="language-plaintext highlighter-rouge">end</code>, et le shell reste coincé à attendre la suite. La sortie de secours est <code class="language-plaintext highlighter-rouge">#iex:break</code> :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>iex&gt; if true do
...&gt;   :oui
...&gt; #iex:break
** (TokenMissingError) token missing on iex:1:
error: incomplete expression
</code></pre></div></div>

<p>L’erreur n’est pas un échec : c’est le shell qui abandonne l’expression inachevée et rend la main.</p>

<h3 id="dans-un-projet--iex--s-mix">Dans un projet : <code class="language-plaintext highlighter-rouge">iex -S mix</code></h3>

<p>Le même shell, mais avec le projet chargé — toutes ses fonctions et toutes ses dépendances accessibles. C’est la façon normale de travailler sur du code Elixir, et pas seulement pour déboguer.</p>

<p>Le compagnon indispensable est <strong><code class="language-plaintext highlighter-rouge">recompile()</code></strong> : après avoir modifié un fichier dans l’éditeur, il recompile sans quitter la session, donc sans perdre l’état qu’on a monté à la main. Il répond <code class="language-plaintext highlighter-rouge">:ok</code> s’il a travaillé, <code class="language-plaintext highlighter-rouge">:noop</code> s’il n’y avait rien à faire.</p>

<p>Et <strong><code class="language-plaintext highlighter-rouge">open</code></strong> ouvre le source dans l’éditeur : <code class="language-plaintext highlighter-rouge">open Enum.map</code> saute directement à la définition, y compris dans la bibliothèque standard. Il s’appuie sur la variable d’environnement <code class="language-plaintext highlighter-rouge">ELIXIR_EDITOR</code>, et retombe sur <code class="language-plaintext highlighter-rouge">EDITOR</code> si elle n’est pas définie :</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">ELIXIR_EDITOR</span><span class="o">=</span><span class="s2">"zed"</span>
</code></pre></div></div>

<h2 id="barreau-1--ioinspect-et-son-label">Barreau 1 : <code class="language-plaintext highlighter-rouge">IO.inspect</code> et son <code class="language-plaintext highlighter-rouge">label:</code></h2>

<p>Premier vrai outil de débogage, et le seul que beaucoup connaissent. Son intérêt tient en une phrase : <strong>il renvoie la valeur qu’il reçoit</strong>, donc il se glisse dans un pipe sans le casser.</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">lignes</span>
<span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="o">&amp;</span><span class="p">(</span><span class="nv">&amp;1</span><span class="o">.</span><span class="n">quantite</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">))</span>
<span class="o">|&gt;</span> <span class="no">IO</span><span class="o">.</span><span class="n">inspect</span><span class="p">(</span><span class="ss">label:</span> <span class="s2">"après filtre"</span><span class="p">)</span>
<span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="o">&amp;</span><span class="p">(</span><span class="nv">&amp;1</span><span class="o">.</span><span class="n">prix</span> <span class="o">*</span> <span class="nv">&amp;1</span><span class="o">.</span><span class="n">quantite</span><span class="p">))</span>
<span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">sum</span><span class="p">()</span>
</code></pre></div></div>

<p>Le <code class="language-plaintext highlighter-rouge">label:</code> répond à la question qui vient toujours au sixième affichage : <em>lequel a produit ça ?</em></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>après filtre: [%{prix: 30, quantite: 2}, %{prix: 45, quantite: 1}]
</code></pre></div></div>

<h3 id="deux-options-qui-évitent-des-heures-perdues">Deux options qui évitent des heures perdues</h3>

<p><strong><code class="language-plaintext highlighter-rouge">limit:</code></strong> — au-delà d’une centaine d’éléments, l’affichage est tronqué par des points de suspension. Mesuré sur 1.19.5 : la limite par défaut est de 100, une liste de 101 éléments est coupée. On débogue alors une ellipse.</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">IO</span><span class="o">.</span><span class="n">inspect</span><span class="p">(</span><span class="n">grande_liste</span><span class="p">,</span> <span class="ss">label:</span> <span class="s2">"complet"</span><span class="p">,</span> <span class="ss">limit:</span> <span class="ss">:infinity</span><span class="p">)</span>
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">structs: false</code></strong> — pour voir ce qu’il y a <em>vraiment</em> dans une structure, sans la mise en forme du protocole <code class="language-plaintext highlighter-rouge">Inspect</code> :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>struct: %Panier{
  client: "Alice",
  lignes: [...]
}

brut: %{
  __struct__: Panier,
  client: "Alice",
  lignes: [...]
}
</code></pre></div></div>

<p>Sur un <code class="language-plaintext highlighter-rouge">%Ecto.Changeset{}</code>, dont l’affichage par défaut cache une bonne partie du contenu, la différence est décisive.</p>

<h2 id="barreau-2--dbg-qui-montre-le-chemin-et-pas-seulement-larrivée">Barreau 2 : <code class="language-plaintext highlighter-rouge">dbg</code>, qui montre le chemin et pas seulement l’arrivée</h2>

<p><code class="language-plaintext highlighter-rouge">IO.inspect</code> montre <em>une</em> valeur. <code class="language-plaintext highlighter-rouge">dbg</code> montre <strong>toutes les étapes qui y ont mené</strong>, avec le fichier, la ligne et la fonction. Il est disponible partout, sans <code class="language-plaintext highlighter-rouge">import</code> ni <code class="language-plaintext highlighter-rouge">require</code>.</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="n">total</span><span class="p">(%</span><span class="no">Panier</span><span class="p">{</span><span class="ss">lignes:</span> <span class="n">lignes</span><span class="p">})</span> <span class="k">do</span>
  <span class="n">lignes</span>
  <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="o">&amp;</span><span class="p">(</span><span class="nv">&amp;1</span><span class="o">.</span><span class="n">quantite</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">))</span>
  <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="o">&amp;</span><span class="p">(</span><span class="nv">&amp;1</span><span class="o">.</span><span class="n">prix</span> <span class="o">*</span> <span class="nv">&amp;1</span><span class="o">.</span><span class="n">quantite</span><span class="p">))</span>
  <span class="o">|&gt;</span> <span class="n">dbg</span><span class="p">()</span>
  <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">sum</span><span class="p">()</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[lib/panier.ex:8: Panier.total/1]
lignes #=&gt; [%{prix: 30, quantite: 2}, %{prix: 50, quantite: 0}, %{prix: 45, quantite: 1}]
|&gt; Enum.filter(&amp;(&amp;1.quantite &gt; 0)) #=&gt; [%{prix: 30, quantite: 2}, %{prix: 45, quantite: 1}]
|&gt; Enum.map(&amp;(&amp;1.prix * &amp;1.quantite)) #=&gt; ~c"&lt;-"
</code></pre></div></div>

<p>Deux choses à noter tout de suite.</p>

<p><strong><code class="language-plaintext highlighter-rouge">dbg</code> n’affiche que les étapes jusqu’à lui.</strong> Le <code class="language-plaintext highlighter-rouge">Enum.sum()</code> placé après n’apparaît pas — ce n’est pas un bug, c’est une macro qui ne voit que le pipe qu’on lui a passé. Pour voir la fin, on déplace le <code class="language-plaintext highlighter-rouge">dbg</code> à la fin.</p>

<p><strong>Et cette sortie contient un piège.</strong> Regardez la dernière ligne : <code class="language-plaintext highlighter-rouge">~c"&lt;-"</code>. C’est la liste <code class="language-plaintext highlighter-rouge">[60, 45]</code>. Les entiers 60 et 45 sont les codes des caractères <code class="language-plaintext highlighter-rouge">&lt;</code> et <code class="language-plaintext highlighter-rouge">-</code>, et comme <em>tous</em> les éléments sont imprimables, Elixir suppose une charlist. L’option <code class="language-plaintext highlighter-rouge">:charlists</code> vaut <code class="language-plaintext highlighter-rouge">:infer</code> par défaut. On n’est pas obligé de subir :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">|&gt;</span> <span class="n">dbg</span><span class="p">(</span><span class="ss">charlists:</span> <span class="ss">:as_lists</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>|&gt; Enum.map(&amp;(&amp;1.prix * &amp;1.quantite)) #=&gt; [60, 45]
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">dbg/2</code> accepte les mêmes options que <code class="language-plaintext highlighter-rouge">inspect/2</code>. C’est le genre de détail qui fait perdre une demi-heure à chercher d’où sort une chaîne bizarre, alors que la valeur était correcte depuis le début.</p>

<h3 id="dbg-comprend-if-et-case"><code class="language-plaintext highlighter-rouge">dbg</code> comprend <code class="language-plaintext highlighter-rouge">if</code> et <code class="language-plaintext highlighter-rouge">case</code></h3>

<p>C’est sa fonctionnalité la plus sous-estimée. Sur une condition, il montre l’argument, la branche prise, et le résultat :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="n">remise</span><span class="p">(</span><span class="n">total</span><span class="p">)</span> <span class="k">do</span>
  <span class="n">dbg</span><span class="p">(</span><span class="k">if</span> <span class="n">total</span> <span class="o">&gt;</span> <span class="mi">100</span><span class="p">,</span> <span class="k">do</span><span class="p">:</span> <span class="n">total</span> <span class="o">*</span> <span class="mf">0.9</span><span class="p">,</span> <span class="k">else</span><span class="p">:</span> <span class="n">total</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[lib/panier.ex:13: Panier.remise/1]
If condition:
total &gt; 100 #=&gt; true

If expression:
if total &gt; 100 do
  total * 0.9
else
  total
end #=&gt; 94.5
</code></pre></div></div>

<p>Sur un <code class="language-plaintext highlighter-rouge">case</code>, il indique <strong>quelle clause a matché</strong> :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Case argument:
statut #=&gt; :paye

Case expression (clause #1 matched):
case statut do
  :paye -&gt; "ok"
  :attente -&gt; "patiente"
end #=&gt; "ok"
</code></pre></div></div>

<p>« Quelle clause a matché » est précisément la question qu’on se pose devant un <code class="language-plaintext highlighter-rouge">case</code> qui tombe dans la mauvaise branche. Aucun <code class="language-plaintext highlighter-rouge">Logger.info</code> ne répond à ça sans qu’on l’écrive à la main dans chaque clause.</p>

<h2 id="barreau-3--le-même-dbg-en-point-darrêt">Barreau 3 : le même <code class="language-plaintext highlighter-rouge">dbg</code>, en point d’arrêt</h2>

<p>Voici l’idée qui change la façon de travailler : <strong>on ne modifie pas le code pour déboguer plus fort, on change le lanceur.</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>iex <span class="nt">--dbg</span> pry <span class="nt">-S</span> mix
</code></pre></div></div>

<p>Les <code class="language-plaintext highlighter-rouge">dbg()</code> déjà présents dans le code deviennent des points d’arrêt interactifs. Le code est identique ; seule la commande de démarrage a changé.</p>

<h3 id="le-mur-quon-rencontre-au-premier-essai">Le mur qu’on rencontre au premier essai</h3>

<p>Sur un projet déjà compilé, la commande échoue :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>** (Mix) the application :elixir has a different value set for key :dbg_callback
during runtime compared to compile time. Since this application environment entry
was marked as compile time, this difference can lead to different behavior than expected:

  * Compile time value was set to: {Macro, :dbg, []}
  * Runtime value was set to: {IEx.Pry, :dbg, []}
</code></pre></div></div>

<p>C’est le mécanisme de validation de <code class="language-plaintext highlighter-rouge">Application.compile_env</code> qui parle : le comportement de <code class="language-plaintext highlighter-rouge">dbg</code> est figé <strong>à la compilation</strong>, et il ne peut pas être changé au lancement d’un code déjà compilé. Le message propose lui-même un <code class="language-plaintext highlighter-rouge">--no-validate-compile-env</code> — sur 1.19.5, <code class="language-plaintext highlighter-rouge">mix run</code> répond <code class="language-plaintext highlighter-rouge">--no-validate-compile-env : Unknown option</code>. Ce n’est donc pas la solution.</p>

<p>Le remède est celui que le message donne en deuxième : <strong>recompiler</strong>.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mix clean <span class="o">&amp;&amp;</span> iex <span class="nt">--dbg</span> pry <span class="nt">-S</span> mix
</code></pre></div></div>

<h3 id="une-fois-arrêté">Une fois arrêté</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>iex(1)&gt; Panier.remise(200)
Break reached: Panier.remise/1 (lib/panier.ex:13)

   12:   def remise(total) do
   13:     dbg(if total &gt; 100, do: total * 0.9, else: total)
   14:   end

iex(2)&gt; binding()
[total: 200]
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">binding()</code> donne toutes les variables locales, <code class="language-plaintext highlighter-rouge">whereami</code> réaffiche le contexte autour de la ligne, et <code class="language-plaintext highlighter-rouge">continue</code> relance l’exécution. Entre les deux, on est dans un vrai shell : on peut appeler n’importe quelle fonction avec les valeurs réelles sous la main.</p>

<h3 id="quand-le-dbg-est-atteint-par-un-autre-processus">Quand le <code class="language-plaintext highlighter-rouge">dbg</code> est atteint par un autre processus</h3>

<p>Dans une requête Phoenix, le <code class="language-plaintext highlighter-rouge">dbg</code> n’est pas exécuté par le shell mais par le processus de la requête. IEx demande alors l’autorisation avant de vous confier la main :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Request to pry #PID&lt;0.153.0&gt; at Panier.remise/1 (lib/panier.ex:13)

   12:   def remise(total) do
   13:     dbg(if total &gt; 100, do: total * 0.9, else: total)
   14:   end

Allow? [Yn]
</code></pre></div></div>

<p>Répondre <code class="language-plaintext highlighter-rouge">n</code> laisse le processus continuer normalement — le <code class="language-plaintext highlighter-rouge">dbg</code> reprend son affichage habituel. C’est important : un point d’arrêt oublié dans un chemin très fréquenté ne fige pas l’application, il pose une question.</p>

<h2 id="barreau-4--break-sans-toucher-au-code">Barreau 4 : <code class="language-plaintext highlighter-rouge">break!</code>, sans toucher au code</h2>

<p>Les trois barreaux précédents supposent qu’on peut éditer le fichier. Parfois non — le code est dans une dépendance, ou dans la bibliothèque standard. <code class="language-plaintext highlighter-rouge">break!</code> pose un point d’arrêt sur une fonction <strong>déjà compilée</strong> :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>iex&gt; break! Panier.remise/1
1

iex&gt; breaks
 ID   Module.function/arity   Pending stops
---- ----------------------- ---------------
 1    Panier.remise/1         1
</code></pre></div></div>

<p>Et ça marche jusque dans <code class="language-plaintext highlighter-rouge">Enum</code> :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>iex&gt; break! Enum.map/2
1

iex&gt; Enum.map([1, 2], &amp; &amp;1)
Break reached: Enum.map/2 (/home/runner/work/elixir/elixir/lib/elixir/lib/enum.ex:1688)

iex&gt; binding()
[enumerable: [1, 2], fun: #Function&lt;42.113135111/1 in :erl_eval.expr/6&gt;]
</code></pre></div></div>

<p>Notez le chemin : <code class="language-plaintext highlighter-rouge">/home/runner/work/elixir/elixir/...</code>, celui de la machine qui a construit Elixir. Le fichier n’existe pas chez vous, donc aucun extrait de code n’est affiché — mais l’arrêt a bien lieu et <code class="language-plaintext highlighter-rouge">binding()</code> répond. <code class="language-plaintext highlighter-rouge">remove_breaks</code> nettoie tout.</p>

<h2 id="le-confort--iexexs">Le confort : <code class="language-plaintext highlighter-rouge">.iex.exs</code></h2>

<p>Un fichier <code class="language-plaintext highlighter-rouge">.iex.exs</code> à la racine du projet est exécuté à chaque ouverture du shell. C’est l’endroit où mettre les <code class="language-plaintext highlighter-rouge">alias</code> et les <code class="language-plaintext highlighter-rouge">import</code> qu’on retape dix fois par jour :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="no">Ecto</span><span class="o">.</span><span class="no">Query</span>
<span class="n">alias</span> <span class="no">MonApp</span><span class="o">.</span><span class="p">{</span><span class="no">Repo</span><span class="p">,</span> <span class="no">Comptes</span><span class="p">,</span> <span class="no">Facturation</span><span class="p">}</span>

<span class="no">IEx</span><span class="o">.</span><span class="n">configure</span><span class="p">(</span><span class="ss">inspect:</span> <span class="p">[</span><span class="ss">charlists:</span> <span class="ss">:as_lists</span><span class="p">,</span> <span class="ss">limit:</span> <span class="ss">:infinity</span><span class="p">])</span>
</code></pre></div></div>

<p>La dernière ligne referme les deux pièges vus plus haut, pour toute la session : plus de <code class="language-plaintext highlighter-rouge">~c"&lt;-"</code> surprise, plus de listes tronquées.</p>

<h2 id="la-frontière-avec-logger">La frontière avec <code class="language-plaintext highlighter-rouge">Logger</code></h2>

<p>Tout ce qui précède sert la boucle de développement. <code class="language-plaintext highlighter-rouge">Logger</code> sert autre chose : ce qu’on veut relire demain, en production, filtré par niveau et routé vers un backend.</p>

<p>La différence est vérifiable en trois lignes. Avec <code class="language-plaintext highlighter-rouge">Logger.configure(level: :error)</code>, un <code class="language-plaintext highlighter-rouge">Logger.info</code> disparaît — et le <code class="language-plaintext highlighter-rouge">dbg</code> de la ligne suivante s’affiche quand même :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[essai.exs:4: (file)]
:coucou #=&gt; :coucou
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">dbg</code> <strong>ne passe pas par <code class="language-plaintext highlighter-rouge">Logger</code></strong>. Il ne connaît ni les niveaux, ni les métadonnées, ni les backends, et il n’a donc rien à faire dans un release. C’est un outil de mise au point, pas de journalisation.</p>

<p>Le partage est simple :</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">dbg</code> et <code class="language-plaintext highlighter-rouge">IO.inspect</code></strong> : je cherche quelque chose maintenant, et je les retire avant de committer.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">Logger</code></strong> : je veux savoir demain ce qui s’est passé cette nuit.</li>
</ul>

<h2 id="à-retenir">À retenir</h2>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">iex</code> seul</strong> est un terrain d’essai sans projet : <code class="language-plaintext highlighter-rouge">h</code>, <code class="language-plaintext highlighter-rouge">i</code>, <code class="language-plaintext highlighter-rouge">v(n)</code>, tabulation, et <code class="language-plaintext highlighter-rouge">#iex:break</code> pour se dépêtrer d’une expression inachevée.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">iex -S mix</code></strong> charge le projet ; <code class="language-plaintext highlighter-rouge">recompile()</code> évite de relancer la session, <code class="language-plaintext highlighter-rouge">open</code> saute au source.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">IO.inspect</code></strong> renvoie sa valeur, donc ne casse pas un pipe. <code class="language-plaintext highlighter-rouge">label:</code> pour s’y retrouver, <code class="language-plaintext highlighter-rouge">limit: :infinity</code> et <code class="language-plaintext highlighter-rouge">structs: false</code> quand l’affichage ment par omission.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">dbg</code></strong> montre chaque étape d’un pipe, et pour un <code class="language-plaintext highlighter-rouge">if</code> ou un <code class="language-plaintext highlighter-rouge">case</code>, <strong>quelle branche a été prise</strong>. Il accepte les options d’<code class="language-plaintext highlighter-rouge">inspect</code> — dont <code class="language-plaintext highlighter-rouge">charlists: :as_lists</code>, sans quoi <code class="language-plaintext highlighter-rouge">[60, 45]</code> s’affiche <code class="language-plaintext highlighter-rouge">~c"&lt;-"</code>.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">iex --dbg pry -S mix</code></strong> transforme ces mêmes <code class="language-plaintext highlighter-rouge">dbg</code> en points d’arrêt, sans toucher au code. Il faut recompiler (<code class="language-plaintext highlighter-rouge">mix clean</code>) : la valeur est figée à la compilation.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">break!</code></strong> s’arrête dans du code qu’on ne peut pas éditer, dépendances et bibliothèque standard comprises.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">Logger</code> n’est pas un outil de débogage.</strong> Un <code class="language-plaintext highlighter-rouge">dbg</code> traverse un filtre de niveau qui bloquerait un <code class="language-plaintext highlighter-rouge">Logger.info</code> — ce sont deux mécanismes séparés, pour deux besoins séparés.</li>
</ul>

<p>Quand malgré tout ça une erreur reste illisible, la suite se trouve dans <a href="/ElixirPhoenixRessources/articles/lire-une-stacktrace-elixir/">Lire une stacktrace Elixir sans paniquer</a>. Et sur la raison pour laquelle <code class="language-plaintext highlighter-rouge">dbg</code> ne peut pas changer de comportement au lancement, <a href="/ElixirPhoenixRessources/articles/structurer-un-projet-elixir/">Structurer un projet Elixir</a> détaille <code class="language-plaintext highlighter-rouge">Application.compile_env</code>.</p>]]></content><author><name>nseaSeb</name></author><category term="elixir" /><category term="elixir" /><category term="iex" /><category term="debogage" /><category term="outillage" /><summary type="html"><![CDATA[Le réflexe Logger.info(inspect(truc)) a trois défauts qu'on ne voit qu'après avoir essayé autre chose. Petite échelle d'outils, du shell interactif au point d'arrêt, mesurée sur Elixir 1.19.5.]]></summary></entry><entry xml:lang="fr"><title type="html">La liste chaînée n’est pas le mauvais outil, c’est la matière première</title><link href="https://nseaseb.github.io/ElixirPhoenixRessources/articles/files-et-zippers/" rel="alternate" type="text/html" title="La liste chaînée n’est pas le mauvais outil, c’est la matière première" /><published>2026-09-08T10:38:00+02:00</published><updated>2026-09-08T10:38:00+02:00</updated><id>https://nseaseb.github.io/ElixirPhoenixRessources/articles/files-et-zippers</id><content type="html" xml:base="https://nseaseb.github.io/ElixirPhoenixRessources/articles/files-et-zippers/"><![CDATA[<p><a href="/ElixirPhoenixRessources/articles/aja-vecteurs-ordmap/">Un précédent article</a> posait le problème : une liste chaînée est lente en accès par index, et un vecteur y répond mieux. En le partageant sur l’<a href="https://elixirforum.com/t/whats-the-approach-for-handling-lists-that-allows-for-optimized-traversal/76584">Elixir Forum</a>, j’ai reçu deux réponses qui déplacent la question — <code class="language-plaintext highlighter-rouge">:queue</code> et les zippers — et elles racontent quelque chose de plus intéressant qu’une liste d’alternatives.</p>

<p>Ces deux structures ne remplacent pas la liste chaînée. <strong>Elles sont faites avec.</strong></p>

<!--more-->

<h2 id="le-problème--une-file-dattente">Le problème : une file d’attente</h2>

<p>Vous voulez une file d’attente : on ajoute d’un côté, on retire de l’autre. Avec une liste, un des deux gestes est forcément coûteux.</p>

<p>Retirer en tête est gratuit. Ajouter en queue recopie toute la liste :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="n">enfiler</span><span class="p">(</span><span class="n">file</span><span class="p">,</span> <span class="n">x</span><span class="p">),</span> <span class="k">do</span><span class="p">:</span> <span class="n">file</span> <span class="o">++</span> <span class="p">[</span><span class="n">x</span><span class="p">]</span>     <span class="c1"># O(n)</span>
<span class="k">def</span> <span class="n">defiler</span><span class="p">([</span><span class="n">h</span> <span class="o">|</span> <span class="n">t</span><span class="p">]),</span> <span class="k">do</span><span class="p">:</span> <span class="p">{</span><span class="n">h</span><span class="p">,</span> <span class="n">t</span><span class="p">}</span>          <span class="c1"># O(1)</span>
</code></pre></div></div>

<p>Sur cinquante mille allers-retours, ça se voit :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>50 000 enfilements puis 50 000 défilements
  liste native (++)   5 194 ms
</code></pre></div></div>

<h2 id="lastuce--deux-listes-au-lieu-dune">L’astuce : deux listes au lieu d’une</h2>

<p>La solution est ancienne et tient en une idée. Gardez <strong>deux</strong> listes : celle de l’avant dans le bon sens, celle de l’arrière <strong>à l’envers</strong>. Enfiler, c’est empiler en tête de l’arrière. Défiler, c’est prendre la tête de l’avant. Et quand l’avant est vide, on retourne l’arrière d’un coup.</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Surtout pas `defmodule File` : ça remplacerait le module de la</span>
<span class="c1"># bibliothèque standard, et File.read/1 cesserait d'exister pour le reste</span>
<span class="c1"># de la session — avec un simple avertissement qu'on survole.</span>
<span class="k">defmodule</span> <span class="no">FileAttente</span> <span class="k">do</span>
  <span class="k">def</span> <span class="n">new</span><span class="p">,</span> <span class="k">do</span><span class="p">:</span> <span class="p">{[],</span> <span class="p">[]}</span>

  <span class="c1"># Empiler en tête : gratuit.</span>
  <span class="k">def</span> <span class="n">enfiler</span><span class="p">({</span><span class="n">avant</span><span class="p">,</span> <span class="n">arriere</span><span class="p">},</span> <span class="n">x</span><span class="p">),</span> <span class="k">do</span><span class="p">:</span> <span class="p">{</span><span class="n">avant</span><span class="p">,</span> <span class="p">[</span><span class="n">x</span> <span class="o">|</span> <span class="n">arriere</span><span class="p">]}</span>

  <span class="k">def</span> <span class="n">defiler</span><span class="p">({[],</span> <span class="p">[]}),</span> <span class="k">do</span><span class="p">:</span> <span class="ss">:vide</span>
  <span class="c1"># L'avant est vide : on retourne l'arrière, une fois.</span>
  <span class="k">def</span> <span class="n">defiler</span><span class="p">({[],</span> <span class="n">arriere</span><span class="p">}),</span> <span class="k">do</span><span class="p">:</span> <span class="n">defiler</span><span class="p">({</span><span class="no">Enum</span><span class="o">.</span><span class="n">reverse</span><span class="p">(</span><span class="n">arriere</span><span class="p">),</span> <span class="p">[]})</span>
  <span class="k">def</span> <span class="n">defiler</span><span class="p">({[</span><span class="n">h</span> <span class="o">|</span> <span class="n">t</span><span class="p">],</span> <span class="n">arriere</span><span class="p">}),</span> <span class="k">do</span><span class="p">:</span> <span class="p">{</span><span class="n">h</span><span class="p">,</span> <span class="p">{</span><span class="n">t</span><span class="p">,</span> <span class="n">arriere</span><span class="p">}}</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Le <code class="language-plaintext highlighter-rouge">Enum.reverse</code> est en O(n), mais il n’arrive qu’une fois par élément inséré : chaque élément est retourné exactement une fois dans sa vie. Réparti sur toutes les opérations, le coût par opération est constant — c’est ce qu’on appelle une complexité <strong>amortie</strong>.</p>

<p>Le résultat :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  liste native (++)      5 194 ms
  deux listes (maison)       2 ms
</code></pre></div></div>

<p>Dix lignes, et un facteur <strong>2 600</strong>.</p>

<h2 id="queue-la-même-chose-en-mieux-testé"><code class="language-plaintext highlighter-rouge">:queue</code>, la même chose en mieux testé</h2>

<p>Inutile d’écrire ce module : Erlang le fournit depuis toujours, et il fait exactement ça.</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">file</span> <span class="o">=</span> <span class="ss">:queue</span><span class="o">.</span><span class="n">new</span><span class="p">()</span>
<span class="n">file</span> <span class="o">=</span> <span class="ss">:queue</span><span class="o">.</span><span class="ow">in</span><span class="p">(</span><span class="ss">:a</span><span class="p">,</span> <span class="n">file</span><span class="p">)</span>
<span class="n">file</span> <span class="o">=</span> <span class="ss">:queue</span><span class="o">.</span><span class="ow">in</span><span class="p">(</span><span class="ss">:b</span><span class="p">,</span> <span class="n">file</span><span class="p">)</span>

<span class="p">{{</span><span class="ss">:value</span><span class="p">,</span> <span class="n">premier</span><span class="p">},</span> <span class="n">file</span><span class="p">}</span> <span class="o">=</span> <span class="ss">:queue</span><span class="o">.</span><span class="n">out</span><span class="p">(</span><span class="n">file</span><span class="p">)</span>
<span class="n">premier</span>   <span class="c1">#=&gt; :a</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  deux listes (maison)   2 ms
  :queue (OTP)           3 ms
</code></pre></div></div>

<p>Ma version maison n’est pas plus rapide — elle est juste moins éprouvée. <code class="language-plaintext highlighter-rouge">:queue</code> gère aussi les deux bouts : <code class="language-plaintext highlighter-rouge">in_r/2</code> et <code class="language-plaintext highlighter-rouge">out_r/1</code> insèrent et retirent à l’envers, ce qui en fait une file <strong>doublement terminée</strong>.</p>

<h3 id="un-piège-à-connaître">Un piège à connaître</h3>

<p><code class="language-plaintext highlighter-rouge">:queue.len/1</code> est en <strong>O(n)</strong>. La documentation l’annonce, et c’est contre-intuitif pour une structure aussi soignée :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>100 appels à :queue.len sur une file de 100 000 : 15 ms
100 appels à :queue.is_empty                    :  0 ms
</code></pre></div></div>

<p>La raison est un choix délibéré : ne pas maintenir de compteur évite de reconstruire la structure à chaque insertion. Conséquence pratique — <strong><code class="language-plaintext highlighter-rouge">:queue.len(q) == 0</code> est une faute</strong>, il faut <code class="language-plaintext highlighter-rouge">:queue.is_empty(q)</code>.</p>

<p>Le module propose par ailleurs trois API, dont une dite « Okasaki » aux noms inversés (<code class="language-plaintext highlighter-rouge">cons</code>, <code class="language-plaintext highlighter-rouge">snoc</code>, <code class="language-plaintext highlighter-rouge">head</code>, <code class="language-plaintext highlighter-rouge">tail</code>) que la documentation elle-même qualifie de « by many regarded as strange and avoidable ». Restez sur <code class="language-plaintext highlighter-rouge">in</code>, <code class="language-plaintext highlighter-rouge">out</code>, <code class="language-plaintext highlighter-rouge">peek</code>.</p>

<h2 id="le-zipper--se-déplacer-dans-une-structure-immuable">Le zipper : se déplacer dans une structure immuable</h2>

<p>Second retour du forum, et il répond à un besoin différent : parcourir une structure <strong>en la modifiant au passage</strong>, avec la possibilité de revenir en arrière.</p>

<p>Avec une liste, modifier l’élément courant coûte cher, et il n’y a aucun moyen de reculer :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>parcourir 50 000 éléments en modifiant chacun
  List.replace_at à chaque pas   20 879 ms
</code></pre></div></div>

<p>Un zipper applique la même idée que la file : <strong>retourner la liste</strong>, cette fois autour d’un point de focus. À gauche, ce qu’on a déjà parcouru, stocké à l’envers. À droite, ce qui reste.</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">defmodule</span> <span class="no">Zipper</span> <span class="k">do</span>
  <span class="k">def</span> <span class="n">depuis</span><span class="p">(</span><span class="n">liste</span><span class="p">),</span> <span class="k">do</span><span class="p">:</span> <span class="p">{[],</span> <span class="n">liste</span><span class="p">}</span>

  <span class="k">def</span> <span class="n">droite</span><span class="p">({</span><span class="n">gauche</span><span class="p">,</span> <span class="p">[</span><span class="n">x</span> <span class="o">|</span> <span class="n">droite</span><span class="p">]}),</span> <span class="k">do</span><span class="p">:</span> <span class="p">{[</span><span class="n">x</span> <span class="o">|</span> <span class="n">gauche</span><span class="p">],</span> <span class="n">droite</span><span class="p">}</span>
  <span class="k">def</span> <span class="n">gauche</span><span class="p">({[</span><span class="n">x</span> <span class="o">|</span> <span class="n">gauche</span><span class="p">],</span> <span class="n">droite</span><span class="p">}),</span> <span class="k">do</span><span class="p">:</span> <span class="p">{</span><span class="n">gauche</span><span class="p">,</span> <span class="p">[</span><span class="n">x</span> <span class="o">|</span> <span class="n">droite</span><span class="p">]}</span>

  <span class="k">def</span> <span class="n">lire</span><span class="p">({</span><span class="n">_</span><span class="p">,</span> <span class="p">[</span><span class="n">x</span> <span class="o">|</span> <span class="n">_</span><span class="p">]}),</span> <span class="k">do</span><span class="p">:</span> <span class="n">x</span>
  <span class="k">def</span> <span class="n">remplacer</span><span class="p">({</span><span class="n">gauche</span><span class="p">,</span> <span class="p">[</span><span class="n">_</span> <span class="o">|</span> <span class="n">droite</span><span class="p">]},</span> <span class="n">v</span><span class="p">),</span> <span class="k">do</span><span class="p">:</span> <span class="p">{</span><span class="n">gauche</span><span class="p">,</span> <span class="p">[</span><span class="n">v</span> <span class="o">|</span> <span class="n">droite</span><span class="p">]}</span>

  <span class="k">def</span> <span class="n">vers_liste</span><span class="p">({</span><span class="n">gauche</span><span class="p">,</span> <span class="n">droite</span><span class="p">}),</span> <span class="k">do</span><span class="p">:</span> <span class="no">Enum</span><span class="o">.</span><span class="n">reverse</span><span class="p">(</span><span class="n">gauche</span><span class="p">)</span> <span class="o">++</span> <span class="n">droite</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Six lignes utiles. Chaque déplacement fait passer un élément d’une pile à l’autre : temps constant. La modification locale est un simple remplacement de tête : temps constant aussi.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  List.replace_at à chaque pas   20 879 ms
  zipper                              2 ms
</code></pre></div></div>

<p>Facteur <strong>10 000</strong>. Et surtout, une capacité que la liste n’a pas du tout :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">z</span> <span class="o">=</span> <span class="no">Zipper</span><span class="o">.</span><span class="n">depuis</span><span class="p">([</span><span class="ss">:a</span><span class="p">,</span> <span class="ss">:b</span><span class="p">,</span> <span class="ss">:c</span><span class="p">,</span> <span class="ss">:d</span><span class="p">])</span>
<span class="n">z</span> <span class="o">=</span> <span class="n">z</span> <span class="o">|&gt;</span> <span class="no">Zipper</span><span class="o">.</span><span class="n">droite</span><span class="p">()</span> <span class="o">|&gt;</span> <span class="no">Zipper</span><span class="o">.</span><span class="n">droite</span><span class="p">()</span>
<span class="no">Zipper</span><span class="o">.</span><span class="n">lire</span><span class="p">(</span><span class="n">z</span><span class="p">)</span>                        <span class="c1">#=&gt; :c</span>
<span class="n">z</span> <span class="o">=</span> <span class="no">Zipper</span><span class="o">.</span><span class="n">gauche</span><span class="p">(</span><span class="n">z</span><span class="p">)</span>
<span class="no">Zipper</span><span class="o">.</span><span class="n">lire</span><span class="p">(</span><span class="n">z</span><span class="p">)</span>                        <span class="c1">#=&gt; :b</span>
<span class="no">Zipper</span><span class="o">.</span><span class="n">vers_liste</span><span class="p">(</span><span class="no">Zipper</span><span class="o">.</span><span class="n">remplacer</span><span class="p">(</span><span class="n">z</span><span class="p">,</span> <span class="ss">:B</span><span class="p">))</span>
<span class="c1">#=&gt; [:a, :B, :c, :d]</span>
</code></pre></div></div>

<p>On recule. Dans une liste chaînée, c’est impossible : les cellules ne pointent que vers l’avant.</p>

<h3 id="là-où-ça-devient-vraiment-utile">Là où ça devient vraiment utile</h3>

<p>Le zipper prend tout son sens sur un <strong>arbre</strong>, où l’on garde en plus le chemin parcouru depuis la racine. C’est ce qui permet de descendre dans une structure, de modifier une feuille, et de remonter — sans reconstruire l’arbre entier à chaque étape.</p>

<p>En Elixir, l’implémentation de référence est <a href="https://hexdocs.pm/sourceror/Sourceror.Zipper.html"><code class="language-plaintext highlighter-rouge">Sourceror.Zipper</code></a>, qui travaille sur l’arbre syntaxique. C’est ce qui fait tourner les outils de réécriture automatique de code — et sa documentation est un bon endroit pour comprendre le concept sur un cas réel.</p>

<p>Fred Hebert a écrit <a href="https://ferd.ca/yet-another-article-on-zippers.html">l’article de référence sur le sujet</a> en 2010, en Erlang. Il reste la meilleure explication du mécanisme.</p>

<h2 id="ce-que-ces-deux-structures-ont-en-commun">Ce que ces deux structures ont en commun</h2>

<p>Ni l’une ni l’autre n’abandonne la liste chaînée. Elles en utilisent deux.</p>

<p>C’est le même geste dans les deux cas : <strong>placer le coût du bon côté</strong>. Une liste est gratuite en tête et chère en queue ; alors on en met deux dos à dos, et les deux extrémités deviennent gratuites. On paie un retournement, mais une seule fois par élément.</p>

<p>Cette idée éclaire aussi la limite. Une liste ne devient pas magiquement indexable : <code class="language-plaintext highlighter-rouge">:queue</code> ne sait pas plus accéder au 5 000ᵉ élément qu’une liste, et un zipper doit s’y rendre pas à pas. Pour l’accès par index, il faut une structure d’une autre forme — <a href="/ElixirPhoenixRessources/articles/aja-vecteurs-ordmap/">un vecteur</a>, c’est-à-dire un arbre.</p>

<h2 id="comment-choisir">Comment choisir</h2>

<table>
  <thead>
    <tr>
      <th>Le besoin</th>
      <th>La réponse</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Empiler et dépiler du même côté</td>
      <td>une liste, <code class="language-plaintext highlighter-rouge">[x \| reste]</code></td>
    </tr>
    <tr>
      <td>Ajouter d’un côté, retirer de l’autre</td>
      <td><code class="language-plaintext highlighter-rouge">:queue</code></td>
    </tr>
    <tr>
      <td>Parcourir en modifiant, avec retour arrière</td>
      <td>un zipper</td>
    </tr>
    <tr>
      <td>Accéder ou modifier par index</td>
      <td>un vecteur</td>
    </tr>
    <tr>
      <td>Ordre d’insertion avec des clés quelconques</td>
      <td>une map ordonnée</td>
    </tr>
  </tbody>
</table>

<p>La question n’est jamais « quelle est la meilleure structure ». C’est « de quel côté vais-je payer ».</p>

<hr />

<p><em>Ces deux pistes viennent d’un <a href="https://elixirforum.com/t/whats-the-approach-for-handling-lists-that-allows-for-optimized-traversal/76584">fil de l’Elixir Forum</a>, en réponse à l’article sur les vecteurs. Merci à <strong>dimitarvp</strong>, qui a signalé <code class="language-plaintext highlighter-rouge">:queue</code> et l’article de Fred Hebert sur les zippers, et à <strong>sodapopcan</strong>, qui a pointé l’implémentation de <code class="language-plaintext highlighter-rouge">Sourceror</code>. Publier sert exactement à ça : je suis reparti avec deux structures que je ne connaissais pas.</em></p>

<p><em>Toutes les mesures ont été prises sur Elixir 1.19.5 et Erlang/OTP 28. Les nombres varient d’une machine à l’autre ; les ordres de grandeur, non.</em></p>]]></content><author><name>nseaSeb</name></author><category term="elixir" /><category term="elixir" /><category term="structures-de-donnees" /><category term="performance" /><category term="erlang" /><summary type="html"><![CDATA[Une file d'attente construite avec deux listes bat la liste native d'un facteur 2 600. Un zipper, d'un facteur 10 000. Aucune de ces structures ne remplace la liste : elles la réarrangent.]]></summary></entry><entry xml:lang="fr"><title type="html">Des tests concurrents avec une base de données : comment, et jusqu’où</title><link href="https://nseaseb.github.io/ElixirPhoenixRessources/articles/tests-elixir-async-sandbox/" rel="alternate" type="text/html" title="Des tests concurrents avec une base de données : comment, et jusqu’où" /><published>2026-09-08T08:10:00+02:00</published><updated>2026-09-08T08:10:00+02:00</updated><id>https://nseaseb.github.io/ElixirPhoenixRessources/articles/tests-elixir-async-sandbox</id><content type="html" xml:base="https://nseaseb.github.io/ElixirPhoenixRessources/articles/tests-elixir-async-sandbox/"><![CDATA[<p>Dans la plupart des langages, faire tourner une suite de tests en parallèle contre une base de données relève de l’acrobatie : bases séparées, schémas dédiés, verrous, nettoyage entre chaque test. Beaucoup d’équipes y renoncent.</p>

<p>En Elixir, on écrit <code class="language-plaintext highlighter-rouge">async: true</code> et ça marche. Il vaut la peine de comprendre pourquoi — parce que les raisons qui le rendent possible dessinent aussi précisément l’endroit où il cesse de vous protéger.</p>

<!--more-->

<h2 id="pourquoi-la-concurrence-est-envisageable">Pourquoi la concurrence est envisageable</h2>

<p>Deux tests qui tournent ensemble ne se marchent dessus que s’ils partagent quelque chose de modifiable. En Elixir, il n’y a presque rien à partager : pas de variable globale, pas d’objet mutable, pas de singleton. Chaque test vit dans son processus, avec son propre tas.</p>

<p>Reste exactement une ressource commune, et elle est de taille : <strong>la base de données.</strong></p>

<p>C’est là qu’intervient la sandbox.</p>

<h2 id="ce-que-fait-vraiment-la-sandbox">Ce que fait vraiment la sandbox</h2>

<p>Le principe est plus simple qu’il n’en a l’air. Chaque test <strong>emprunte</strong> une connexion, et la sandbox l’enveloppe dans une transaction. À la fin du test, la transaction est annulée.</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/test.exs</span>
<span class="n">config</span> <span class="ss">:mon_app</span><span class="p">,</span> <span class="no">MonApp</span><span class="o">.</span><span class="no">Repo</span><span class="p">,</span> <span class="ss">pool:</span> <span class="no">Ecto</span><span class="o">.</span><span class="no">Adapters</span><span class="o">.</span><span class="no">SQL</span><span class="o">.</span><span class="no">Sandbox</span>

<span class="c1"># test/support/data_case.ex</span>
<span class="n">setup</span> <span class="n">tags</span> <span class="k">do</span>
  <span class="n">pid</span> <span class="o">=</span> <span class="no">Ecto</span><span class="o">.</span><span class="no">Adapters</span><span class="o">.</span><span class="no">SQL</span><span class="o">.</span><span class="no">Sandbox</span><span class="o">.</span><span class="n">start_owner!</span><span class="p">(</span><span class="no">MonApp</span><span class="o">.</span><span class="no">Repo</span><span class="p">,</span> <span class="ss">shared:</span> <span class="ow">not</span> <span class="n">tags</span><span class="p">[</span><span class="ss">:async</span><span class="p">])</span>
  <span class="n">on_exit</span><span class="p">(</span><span class="k">fn</span> <span class="o">-&gt;</span> <span class="no">Ecto</span><span class="o">.</span><span class="no">Adapters</span><span class="o">.</span><span class="no">SQL</span><span class="o">.</span><span class="no">Sandbox</span><span class="o">.</span><span class="n">stop_owner</span><span class="p">(</span><span class="n">pid</span><span class="p">)</span> <span class="k">end</span><span class="p">)</span>
  <span class="ss">:ok</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Deux tests concurrents travaillent donc sur deux connexions différentes, chacune dans sa transaction non validée. Aucun ne voit ce que l’autre écrit, et personne ne nettoie quoi que ce soit : le <code class="language-plaintext highlighter-rouge">ROLLBACK</code> s’en charge.</p>

<p>C’est élégant, et c’est aussi la source des trois pièges qui suivent.</p>

<h2 id="piège-1--les-autres-processus-nont-pas-la-connexion">Piège 1 — les autres processus n’ont pas la connexion</h2>

<p>En mode <code class="language-plaintext highlighter-rouge">:manual</code>, un processus qui n’a pas emprunté de connexion n’a pas accès à la base. Or votre code en démarre : une tâche, un <code class="language-plaintext highlighter-rouge">GenServer</code>, un travailleur Oban.</p>

<p>Il faut leur en donner explicitement le droit :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">parent</span> <span class="o">=</span> <span class="n">self</span><span class="p">()</span>

<span class="n">tache</span> <span class="o">=</span>
  <span class="no">Task</span><span class="o">.</span><span class="n">async</span><span class="p">(</span><span class="k">fn</span> <span class="o">-&gt;</span>
    <span class="c1"># En PREMIÈRE ligne de la tâche : autoriser depuis l'extérieur créerait</span>
    <span class="c1"># une course, Task.async démarrant l'exécution immédiatement.</span>
    <span class="no">Ecto</span><span class="o">.</span><span class="no">Adapters</span><span class="o">.</span><span class="no">SQL</span><span class="o">.</span><span class="no">Sandbox</span><span class="o">.</span><span class="n">allow</span><span class="p">(</span><span class="no">MonApp</span><span class="o">.</span><span class="no">Repo</span><span class="p">,</span> <span class="n">parent</span><span class="p">,</span> <span class="n">self</span><span class="p">())</span>
    <span class="no">MonApp</span><span class="o">.</span><span class="no">Comptes</span><span class="o">.</span><span class="n">compter</span><span class="p">()</span>
  <span class="k">end</span><span class="p">)</span>

<span class="no">Task</span><span class="o">.</span><span class="n">await</span><span class="p">(</span><span class="n">tache</span><span class="p">)</span>
</code></pre></div></div>

<p>L’ordre compte. Écrire <code class="language-plaintext highlighter-rouge">Sandbox.allow(...)</code> <strong>après</strong> le <code class="language-plaintext highlighter-rouge">Task.async</code> semble naturel et fonctionne la plupart du temps — jusqu’au jour où la tâche atteint la requête avant que l’autorisation soit posée. On obtient alors un test rouge par intermittence, le pire genre.</p>

<p>Sans ce <code class="language-plaintext highlighter-rouge">allow/3</code>, la tâche échoue sur une erreur de propriété — un message déroutant la première fois, qui n’a rien à voir avec votre requête.</p>

<p>L’autre solution est le mode <code class="language-plaintext highlighter-rouge">:shared</code>, où tous les processus se partagent la connexion du test. Il est plus commode, et la documentation en donne le prix sans détour :</p>

<blockquote>
  <p>The downside is that tests can no longer run concurrently in shared mode.</p>
</blockquote>

<p>D’où le <code class="language-plaintext highlighter-rouge">shared: not tags[:async]</code> du <code class="language-plaintext highlighter-rouge">setup</code> ci-dessus : les tests synchrones prennent le confort, les tests concurrents gardent l’isolation.</p>

<h2 id="piège-2--tout-se-passe-dans-une-transaction-jamais-validée">Piège 2 — tout se passe dans une transaction jamais validée</h2>

<p>Celui-là est le plus coûteux, parce qu’il produit un test <strong>vert</strong> sur du code <strong>faux</strong>.</p>

<p>Dans la sandbox, votre test et le code testé vivent dans la même transaction ouverte. Rien n’est jamais validé. Or plusieurs comportements de PostgreSQL ne se manifestent qu’<strong>au COMMIT</strong> — et vos tests ne l’atteignent jamais.</p>

<p>Le cas typique : du code qui écrit en base puis diffuse une notification, laquelle déclenche ailleurs une relecture de la donnée.</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Repo</span><span class="o">.</span><span class="n">transaction</span><span class="p">(</span><span class="k">fn</span> <span class="o">-&gt;</span>
  <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">commande</span><span class="p">}</span> <span class="o">=</span> <span class="no">Repo</span><span class="o">.</span><span class="n">insert</span><span class="p">(</span><span class="n">changeset</span><span class="p">)</span>
  <span class="c1"># Diffusé AVANT le COMMIT : c'est le bug.</span>
  <span class="no">Phoenix</span><span class="o">.</span><span class="no">PubSub</span><span class="o">.</span><span class="n">broadcast</span><span class="p">(</span><span class="no">MonApp</span><span class="o">.</span><span class="no">PubSub</span><span class="p">,</span> <span class="s2">"commandes"</span><span class="p">,</span> <span class="p">{</span><span class="ss">:creee</span><span class="p">,</span> <span class="n">commande</span><span class="o">.</span><span class="n">id</span><span class="p">})</span>
<span class="k">end</span><span class="p">)</span>
</code></pre></div></div>

<p>En production, l’abonné reçoit le message, relit la commande <strong>depuis une autre connexion</strong>, et ne la trouve pas : elle n’est pas encore validée.</p>

<p>En test, il n’y a plus de frontière à franchir. Si la relecture a lieu dans le processus de test — le cas le plus courant — elle se fait dans la transaction ouverte, et trouve une commande que personne n’a encore validée. La relecture réussit, et le test passe.</p>

<p>Une précision qui découle du piège précédent : en mode <code class="language-plaintext highlighter-rouge">:shared</code>, tous les processus partagent cette connexion, donc le problème est masqué même quand l’abonné est ailleurs. En <code class="language-plaintext highlighter-rouge">async: true</code>, un abonné dans un autre processus obtiendrait plutôt une erreur de propriété — le test échouerait bruyamment, ce qui est préférable mais pour la mauvaise raison.</p>

<p><strong>Dans le cas courant, le test ne peut pas voir ce bug</strong>, parce que la sandbox supprime précisément la frontière qui le révèle. Un test vert n’est pas une preuve que le code est bon ; c’est une preuve qu’il se comporte bien dans les conditions du test.</p>

<p>La parade est de diffuser <strong>après</strong> le commit — et pas dans une étape de <code class="language-plaintext highlighter-rouge">Multi</code>, car il n’y en a aucune qui s’exécute hors transaction : <code class="language-plaintext highlighter-rouge">Multi.run/3</code> compris, tout se déroule à l’intérieur de celle qu’ouvre <code class="language-plaintext highlighter-rouge">Repo.transaction/2</code>.</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">resultat</span> <span class="o">=</span>
  <span class="no">Multi</span><span class="o">.</span><span class="n">new</span><span class="p">()</span>
  <span class="o">|&gt;</span> <span class="no">Multi</span><span class="o">.</span><span class="n">insert</span><span class="p">(</span><span class="ss">:commande</span><span class="p">,</span> <span class="n">changeset</span><span class="p">)</span>
  <span class="o">|&gt;</span> <span class="no">Repo</span><span class="o">.</span><span class="n">transaction</span><span class="p">()</span>

<span class="c1"># Ici seulement : la transaction est validée, la donnée est visible de tous.</span>
<span class="n">with</span> <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="p">%{</span><span class="ss">commande:</span> <span class="n">commande</span><span class="p">}}</span> <span class="o">&lt;-</span> <span class="n">resultat</span> <span class="k">do</span>
  <span class="no">Phoenix</span><span class="o">.</span><span class="no">PubSub</span><span class="o">.</span><span class="n">broadcast</span><span class="p">(</span><span class="no">MonApp</span><span class="o">.</span><span class="no">PubSub</span><span class="p">,</span> <span class="s2">"commandes"</span><span class="p">,</span> <span class="p">{</span><span class="ss">:creee</span><span class="p">,</span> <span class="n">commande</span><span class="o">.</span><span class="n">id</span><span class="p">})</span>
  <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">commande</span><span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Et il faut savoir que la vérification, elle, devra être faite autrement que par un test unitaire.</p>

<h2 id="piège-3--mox-en-mode-global-interdit-la-concurrence">Piège 3 — Mox en mode global interdit la concurrence</h2>

<p>Mox fonctionne par défaut en mode <strong>privé</strong> : les attentes posées dans un test ne valent que pour son processus, et il faut un <code class="language-plaintext highlighter-rouge">allow/3</code> pour les partager. C’est ce qui le rend compatible avec <code class="language-plaintext highlighter-rouge">async: true</code>.</p>

<p>Le mode <strong>global</strong> rend les attentes visibles de tous les processus. Plus commode quand le code sous test démarre des processus qu’on ne contrôle pas — et la documentation est catégorique :</p>

<blockquote>
  <p>An ExUnit case where tests use Mox in global mode cannot be <code class="language-plaintext highlighter-rouge">async: true</code>.</p>
</blockquote>

<p>C’est la même mécanique que pour la sandbox : dès qu’on partage un état entre processus, on renonce à la concurrence. Le confort se paie en temps de suite.</p>

<h2 id="un-outil-sous-utilisé--start_supervised1">Un outil sous-utilisé : <code class="language-plaintext highlighter-rouge">start_supervised/1</code></h2>

<p>Quand un test démarre un processus, la question est toujours : qui l’arrête ? Un <code class="language-plaintext highlighter-rouge">start_link</code> dans un test laisse un processus derrière lui, qui polluera le test suivant.</p>

<p><code class="language-plaintext highlighter-rouge">start_supervised/1</code> le place sous le superviseur du test, avec une garantie que la documentation énonce ainsi :</p>

<blockquote>
  <p>The advantage of starting a process under the test supervisor is that it is guaranteed to exit before the next test starts.</p>
</blockquote>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">test</span> <span class="s2">"le cache répond"</span> <span class="k">do</span>
  <span class="n">pid</span> <span class="o">=</span> <span class="n">start_supervised!</span><span class="p">({</span><span class="no">MonApp</span><span class="o">.</span><span class="no">Cache</span><span class="p">,</span> <span class="ss">nom:</span> <span class="ss">:test</span><span class="p">})</span>
  <span class="n">assert</span> <span class="no">MonApp</span><span class="o">.</span><span class="no">Cache</span><span class="o">.</span><span class="n">recuperer</span><span class="p">(</span><span class="n">pid</span><span class="p">,</span> <span class="ss">:cle</span><span class="p">)</span> <span class="o">==</span> <span class="no">nil</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Aucun nettoyage à écrire. Et les processus sont arrêtés dans l’ordre inverse de leur démarrage, ce qui évite qu’un enfant survive à ce dont il dépend.</p>

<h2 id="ce-que-vos-tests-ne-prouvent-pas">Ce que vos tests ne prouvent pas</h2>

<p>Deux limites à garder en tête, en plus des trois pièges.</p>

<p><strong>Un test qui passe dans la sandbox ne dit rien du comportement transactionnel réel.</strong> C’est le piège 2, et il mérite d’être répété : ce que vous validez, c’est le code dans des conditions où la frontière entre connexions n’existe pas.</p>

<p><strong>Un test LiveView ne passe pas par un navigateur.</strong> Dans leur forme fondée sur la vue, <code class="language-plaintext highlighter-rouge">render_click</code> et <code class="language-plaintext highlighter-rouge">render_change</code> envoient l’événement directement au processus. Ils prouvent que votre <code class="language-plaintext highlighter-rouge">handle_event</code> fonctionne, pas qu’un utilisateur peut le déclencher — c’est le sujet d’<a href="/ElixirPhoenixRessources/articles/pannes-silencieuses-liveview/">un précédent article</a>.</p>

<h2 id="en-résumé">En résumé</h2>

<p><code class="language-plaintext highlighter-rouge">async: true</code> fonctionne en Elixir parce qu’il n’y a presque rien à partager entre processus, et parce que la sandbox transforme la seule ressource commune — la base — en une ressource privée par test.</p>

<p>Les trois limites découlent toutes du même mécanisme. Un processus tiers n’a pas la connexion, sauf autorisation explicite. Le mode partagé et Mox global rendent la commodité contre la concurrence. Et surtout, la transaction jamais validée efface une frontière qui existe en production.</p>

<p>Cette dernière est la seule qui produise un test vert sur du code faux. C’est celle qu’il faut connaître par cœur.</p>]]></content><author><name>nseaSeb</name></author><category term="elixir" /><category term="tests" /><category term="exunit" /><category term="ecto" /><category term="phoenix" /><summary type="html"><![CDATA[async: true est possible en Elixir là où d'autres langages y renoncent. La sandbox Ecto explique pourquoi — et connaître ses limites évite le test vert qui masque un bug de production.]]></summary></entry><entry xml:lang="fr"><title type="html">« Let it crash » ne veut pas dire ce que vous croyez</title><link href="https://nseaseb.github.io/ElixirPhoenixRessources/articles/let-it-crash/" rel="alternate" type="text/html" title="« Let it crash » ne veut pas dire ce que vous croyez" /><published>2026-09-08T08:00:00+02:00</published><updated>2026-09-08T08:00:00+02:00</updated><id>https://nseaseb.github.io/ElixirPhoenixRessources/articles/let-it-crash</id><content type="html" xml:base="https://nseaseb.github.io/ElixirPhoenixRessources/articles/let-it-crash/"><![CDATA[<p>Vous avez lu la formule dix fois. On la sort à chaque discussion sur Elixir, souvent en haussant les épaules, comme une excentricité de la BEAM : <strong>« let it crash »</strong>, laissez planter.</p>

<p>Et presque toujours, elle est comprise de travers — comme une permission de ne pas gérer les erreurs. C’est l’inverse.</p>

<!--more-->

<h2 id="ce-que-la-formule-dit-vraiment">Ce que la formule dit vraiment</h2>

<p>Elle ne dit pas « ne gérez pas les erreurs ». Elle dit : <strong>séparez le code qui travaille du code qui décide quoi faire quand ça rate.</strong></p>

<p>Dans la plupart des langages, les deux sont entrelacés. Chaque fonction attrape ce qu’elle peut, remet une valeur par défaut, journalise, continue tant bien que mal — et vous vous retrouvez avec un objet à moitié initialisé qui traverse trois couches avant de causer un problème ailleurs, quinze minutes plus tard.</p>

<p>Elixir propose autre chose : quand un processus rencontre une situation qu’il ne sait pas décrire, <strong>il meurt</strong>. Un autre processus, dont c’est le seul métier, le remplace par un neuf dans un état connu.</p>

<p>Le pari est là : il est plus simple de raisonner sur un état <strong>frais</strong> que sur un état <strong>abîmé</strong>.</p>

<h2 id="la-démonstration">La démonstration</h2>

<p>Un compteur supervisé, qu’on incrémente puis qu’on tue :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">sup</span><span class="p">}</span> <span class="o">=</span> <span class="no">Supervisor</span><span class="o">.</span><span class="n">start_link</span><span class="p">([{</span><span class="no">Compteur</span><span class="p">,</span> <span class="ss">name:</span> <span class="ss">:c</span><span class="p">}],</span> <span class="ss">strategy:</span> <span class="ss">:one_for_one</span><span class="p">)</span>

<span class="no">GenServer</span><span class="o">.</span><span class="n">cast</span><span class="p">(</span><span class="ss">:c</span><span class="p">,</span> <span class="ss">:incr</span><span class="p">)</span>
<span class="no">GenServer</span><span class="o">.</span><span class="n">cast</span><span class="p">(</span><span class="ss">:c</span><span class="p">,</span> <span class="ss">:incr</span><span class="p">)</span>
<span class="no">GenServer</span><span class="o">.</span><span class="n">call</span><span class="p">(</span><span class="ss">:c</span><span class="p">,</span> <span class="ss">:valeur</span><span class="p">)</span>
<span class="c1">#=&gt; 2</span>

<span class="n">pid_avant</span> <span class="o">=</span> <span class="no">Process</span><span class="o">.</span><span class="n">whereis</span><span class="p">(</span><span class="ss">:c</span><span class="p">)</span>
<span class="no">Process</span><span class="o">.</span><span class="k">exit</span><span class="p">(</span><span class="n">pid_avant</span><span class="p">,</span> <span class="ss">:kill</span><span class="p">)</span>
<span class="no">Process</span><span class="o">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">50</span><span class="p">)</span>

<span class="no">Process</span><span class="o">.</span><span class="n">whereis</span><span class="p">(</span><span class="ss">:c</span><span class="p">)</span> <span class="o">!=</span> <span class="n">pid_avant</span>   <span class="c1">#=&gt; true  — c'est un autre processus</span>
<span class="no">GenServer</span><span class="o">.</span><span class="n">call</span><span class="p">(</span><span class="ss">:c</span><span class="p">,</span> <span class="ss">:valeur</span><span class="p">)</span>        <span class="c1">#=&gt; 0     — l'état est reparti de zéro</span>
</code></pre></div></div>

<p>Le superviseur a fait exactement son travail : il a remplacé un processus mort par un processus vivant, dans l’état que <code class="language-plaintext highlighter-rouge">init/1</code> définit.</p>

<p><strong>Et les deux incréments sont perdus.</strong> C’est le point que les explications enthousiastes passent sous silence, et c’est pourtant le cœur du sujet.</p>

<h2 id="ce-que-ça-coûte">Ce que ça coûte</h2>

<p>Trois choses, toutes vérifiables.</p>

<h3 id="létat-disparaît">L’état disparaît</h3>

<p>C’est ce que la démonstration ci-dessus montre. « Let it crash » ne fonctionne que si l’état perdu <strong>peut être reconstruit</strong> — relu en base, redemandé à un service, recalculé.</p>

<p>Un <code class="language-plaintext highlighter-rouge">GenServer</code> qui détient la seule copie d’une donnée importante n’est pas un candidat au redémarrage joyeux : c’est un problème de conception qu’on découvrira le jour du crash. La règle pratique : <strong>ce qui doit survivre à un crash ne vit pas dans l’état d’un processus.</strong></p>

<h3 id="le-crash-emporte-celui-qui-attendait">Le crash emporte celui qui attendait</h3>

<p>Un <code class="language-plaintext highlighter-rouge">GenServer.call/2</code> est synchrone. Si le serveur meurt en traitant la demande, l’appelant ne reçoit pas une erreur qu’il pourrait examiner tranquillement : il reçoit un <code class="language-plaintext highlighter-rouge">exit</code>, et meurt à son tour s’il ne l’attrape pas.</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">defmodule</span> <span class="no">Compteur</span> <span class="k">do</span>
  <span class="kn">use</span> <span class="no">GenServer</span>
  <span class="k">def</span> <span class="n">start_link</span><span class="p">(</span><span class="n">opts</span><span class="p">),</span> <span class="k">do</span><span class="p">:</span> <span class="no">GenServer</span><span class="o">.</span><span class="n">start_link</span><span class="p">(</span><span class="bp">__MODULE__</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="n">opts</span><span class="p">)</span>
  <span class="k">def</span> <span class="n">init</span><span class="p">(</span><span class="n">n</span><span class="p">),</span> <span class="k">do</span><span class="p">:</span> <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">n</span><span class="p">}</span>
  <span class="k">def</span> <span class="n">handle_call</span><span class="p">(</span><span class="ss">:boum</span><span class="p">,</span> <span class="n">_from</span><span class="p">,</span> <span class="n">_n</span><span class="p">),</span> <span class="k">do</span><span class="p">:</span> <span class="k">raise</span><span class="p">(</span><span class="s2">"panne métier"</span><span class="p">)</span>
<span class="k">end</span>

<span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">pid</span><span class="p">}</span> <span class="o">=</span> <span class="no">GenServer</span><span class="o">.</span><span class="n">start</span><span class="p">(</span><span class="no">Compteur</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>

<span class="k">try</span> <span class="k">do</span>
  <span class="no">GenServer</span><span class="o">.</span><span class="n">call</span><span class="p">(</span><span class="n">pid</span><span class="p">,</span> <span class="ss">:boum</span><span class="p">)</span>
<span class="k">catch</span>
  <span class="c1"># La raison d'un exit provoqué par une exception est un tuple imbriqué :</span>
  <span class="c1"># {{exception, pile}, {GenServer, :call, arguments}}. Filtrer sur</span>
  <span class="c1"># {raison, _} attraperait la paire {exception, pile}, pas l'exception.</span>
  <span class="ss">:exit</span><span class="p">,</span> <span class="p">{{</span><span class="n">raison</span><span class="p">,</span> <span class="n">_pile</span><span class="p">},</span> <span class="n">_</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="no">IO</span><span class="o">.</span><span class="n">inspect</span><span class="p">(</span><span class="n">raison</span><span class="p">)</span>
  <span class="c1">#=&gt; %RuntimeError{message: "panne métier"}</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Le serveur, lui, est redémarré par son superviseur. Mais la requête en cours est perdue, et le processus appelant — souvent un LiveView ou un contrôleur — tombe avec elle. Un crash n’est jamais confiné au seul processus fautif : il se propage à qui l’attendait.</p>

<h3 id="trop-de-crashs-tuent-larbre">Trop de crashs tuent l’arbre</h3>

<p>Un superviseur n’est pas un ressusciteur infatigable. Il compte les redémarrages, et abandonne :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">sup</span><span class="p">}</span> <span class="o">=</span> <span class="no">Supervisor</span><span class="o">.</span><span class="n">start_link</span><span class="p">([{</span><span class="no">Compteur</span><span class="p">,</span> <span class="ss">name:</span> <span class="ss">:c3</span><span class="p">}],</span>
  <span class="ss">strategy:</span> <span class="ss">:one_for_one</span><span class="p">,</span> <span class="ss">max_restarts:</span> <span class="mi">2</span><span class="p">,</span> <span class="ss">max_seconds:</span> <span class="mi">5</span><span class="p">)</span>

<span class="c1"># Indispensable pour rejouer ceci dans IEx : start_link lie le superviseur à</span>
<span class="c1"># vous. Quand il renonce, il sort en :shutdown et emporte la session avec lui.</span>
<span class="no">Process</span><span class="o">.</span><span class="n">unlink</span><span class="p">(</span><span class="n">sup</span><span class="p">)</span>

<span class="n">for</span> <span class="n">i</span> <span class="o">&lt;-</span> <span class="mi">1</span><span class="o">..</span><span class="mi">3</span> <span class="k">do</span>
  <span class="k">if</span> <span class="no">Process</span><span class="o">.</span><span class="n">alive?</span><span class="p">(</span><span class="n">sup</span><span class="p">)</span> <span class="k">do</span>
    <span class="no">Process</span><span class="o">.</span><span class="k">exit</span><span class="p">(</span><span class="no">Process</span><span class="o">.</span><span class="n">whereis</span><span class="p">(</span><span class="ss">:c3</span><span class="p">),</span> <span class="ss">:kill</span><span class="p">)</span>
    <span class="no">Process</span><span class="o">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">60</span><span class="p">)</span>
  <span class="k">end</span>
  <span class="no">IO</span><span class="o">.</span><span class="n">puts</span><span class="p">(</span><span class="s2">"crash </span><span class="si">#{</span><span class="n">i</span><span class="si">}</span><span class="s2"> : superviseur vivant ? </span><span class="si">#{</span><span class="no">Process</span><span class="o">.</span><span class="n">alive?</span><span class="p">(</span><span class="n">sup</span><span class="p">)</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>crash 1 : superviseur vivant ? true
crash 2 : superviseur vivant ? true
crash 3 : superviseur vivant ? false
</code></pre></div></div>

<p>Au-delà de <code class="language-plaintext highlighter-rouge">max_restarts</code> redémarrages en <code class="language-plaintext highlighter-rouge">max_seconds</code> secondes, <strong>le superviseur se termine lui-même</strong> — et son propre superviseur applique alors la même logique, un cran plus haut. Une erreur qui se reproduit systématiquement ne tourne donc pas en boucle : elle remonte l’arbre et finit par arrêter l’application.</p>

<p>C’est délibéré et c’est sain. Un service qui redémarre en boucle sans jamais fonctionner est pire qu’un service arrêté : il consomme, il ment aux sondes de santé, et personne ne le remarque. Les valeurs par défaut sont de 3 redémarrages en 5 secondes.</p>

<h2 id="ce-qui-nest-pas-un-crash">Ce qui n’est PAS un crash</h2>

<p>Voilà la distinction qui manque à toutes les explications rapides, et elle est décisive.</p>

<p><strong>Une erreur attendue n’est pas un crash.</strong> Un utilisateur introuvable, un formulaire invalide, une adresse mal formée, un service externe qui répond 503 : ce sont des issues <strong>prévues</strong> de votre code. Elles se représentent par des valeurs.</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Attendu : ça fait partie du fonctionnement normal.</span>
<span class="k">case</span> <span class="no">Comptes</span><span class="o">.</span><span class="n">recuperer</span><span class="p">(</span><span class="n">id</span><span class="p">)</span> <span class="k">do</span>
  <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">utilisateur</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="n">afficher</span><span class="p">(</span><span class="n">utilisateur</span><span class="p">)</span>
  <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="ss">:introuvable</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="n">rediriger_vers_inscription</span><span class="p">()</span>
<span class="k">end</span>

<span class="c1"># Inattendu : cet identifiant DOIT exister à ce stade, sinon c'est un bug.</span>
<span class="n">utilisateur</span> <span class="o">=</span> <span class="no">Comptes</span><span class="o">.</span><span class="n">recuperer!</span><span class="p">(</span><span class="n">id</span><span class="p">)</span>
</code></pre></div></div>

<p>La convention Elixir rend la frontière visible : <code class="language-plaintext highlighter-rouge">recuperer/1</code> renvoie un tuple parce que l’échec est un cas normal ; <code class="language-plaintext highlighter-rouge">recuperer!/1</code> lève parce que l’échec signifierait que le programme se trompe sur lui-même.</p>

<p><strong>« Let it crash » ne concerne que la seconde catégorie.</strong> Écrire un <code class="language-plaintext highlighter-rouge">try/rescue</code> autour d’un formulaire invalide n’est pas du Elixir idiomatique — c’est simplement du code qui confond une erreur et un bug.</p>

<h2 id="et-terminate2-">Et <code class="language-plaintext highlighter-rouge">terminate/2</code> ?</h2>

<p>On y pense vite : puisqu’un processus meurt, autant nettoyer derrière lui. Mais <code class="language-plaintext highlighter-rouge">terminate/2</code> n’est pas la porte de sortie qu’on imagine. Mesuré :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>exception dans un callback   → terminate/2 appelé
GenServer.stop/1             → terminate/2 appelé
Process.exit(pid, :kill)     → terminate/2 PAS appelé
</code></pre></div></div>

<p>Un <code class="language-plaintext highlighter-rouge">:kill</code> est brutal par définition, et c’est exactement ce qui arrive quand la machine s’arrête sèchement, quand la mémoire manque, ou quand un superviseur perd patience. <strong>Ce qui doit absolument être fait ne peut pas dépendre de <code class="language-plaintext highlighter-rouge">terminate/2</code>.</strong></p>

<p>Si vous avez besoin d’une garantie, elle doit vivre ailleurs : une transaction en base, un travail persistant dans une file, un <code class="language-plaintext highlighter-rouge">Process.monitor</code> chez quelqu’un qui survivra.</p>

<h2 id="ce-que-ça-change-à-lécriture">Ce que ça change à l’écriture</h2>

<p>En pratique, cette philosophie se traduit par trois habitudes.</p>

<p><strong>On écrit le chemin heureux.</strong> Pas de vérification défensive à chaque étape pour des états qui ne devraient pas exister. Si l’invariant est faux, le pattern matching échoue et le processus meurt — ce qui est le comportement correct.</p>

<p><strong>On dessine l’arbre de supervision comme on dessine un schéma de base.</strong> Qui redémarre qui, dans quel ordre, avec quelle stratégie. C’est une décision d’architecture, pas une formalité de démarrage.</p>

<p><strong>On range l’état durable hors des processus.</strong> Base de données, ETS, service externe. L’état d’un <code class="language-plaintext highlighter-rouge">GenServer</code> est un cache de travail, pas une mémoire.</p>

<h2 id="en-résumé">En résumé</h2>

<p>« Let it crash » n’autorise pas à ignorer les erreurs. Elle sépare deux responsabilités qui étaient mélangées : faire le travail, et décider quoi faire quand il échoue.</p>

<p>Le prix est réel — l’état est perdu, l’appelant tombe avec, et une panne répétée arrête l’application. Ces trois coûts sont voulus : ils vous forcent à concevoir un système où un processus peut mourir sans que ce soit un drame.</p>

<p>Et le jour où ça arrive en production à trois heures du matin, un processus mort et remplacé vaut infiniment mieux qu’un processus vivant dans un état que personne ne sait décrire.</p>

<hr />

<p><em>Les trois comportements décrits ici — perte d’état, propagation à l’appelant, arrêt du superviseur — ont été exécutés sur Elixir 1.19.5 et Erlang/OTP 28 avant publication. Le <a href="https://livebook.dev/run/?url=https://raw.githubusercontent.com/nseaSeb/ElixirPhoenixRessources/main/OTP/genserver_supervisor.livemd">notebook du parcours</a> permet de les rejouer.</em></p>]]></content><author><name>nseaSeb</name></author><category term="elixir" /><category term="otp" /><category term="genserver" /><category term="supervision" /><category term="elixir" /><summary type="html"><![CDATA[La formule la plus citée d'Elixir est aussi la plus mal comprise. Elle ne dit pas d'ignorer les erreurs : elle dit de séparer le travail de la reprise. Avec ce que ça coûte, mesuré.]]></summary></entry><entry xml:lang="fr"><title type="html">Les hooks JavaScript de LiveView : la frontière et ses règles</title><link href="https://nseaseb.github.io/ElixirPhoenixRessources/articles/hooks-js-liveview/" rel="alternate" type="text/html" title="Les hooks JavaScript de LiveView : la frontière et ses règles" /><published>2026-09-07T10:24:00+02:00</published><updated>2026-09-07T10:24:00+02:00</updated><id>https://nseaseb.github.io/ElixirPhoenixRessources/articles/hooks-js-liveview</id><content type="html" xml:base="https://nseaseb.github.io/ElixirPhoenixRessources/articles/hooks-js-liveview/"><![CDATA[<p>LiveView tient une promesse rare : construire une interface réactive sans écrire de JavaScript. Elle tient presque toujours — et puis vient le jour où il faut du glisser-déposer, une carte, un éditeur de texte riche, un menu contextuel. Des choses que le serveur ne peut pas faire, parce qu’elles vivent dans le navigateur.</p>

<p>C’est là qu’interviennent les <strong>hooks</strong>. Ils sont simples à écrire et faciles à mal écrire, parce qu’ils posent une question qu’on n’a pas l’habitude de se poser : <strong>qui possède ce morceau de DOM ?</strong></p>

<!--more-->

<h2 id="dabord--avez-vous-vraiment-besoin-dun-hook-">D’abord : avez-vous vraiment besoin d’un hook ?</h2>

<p>Beaucoup de choses pour lesquelles on écrit un hook n’en demandent pas. Le module <code class="language-plaintext highlighter-rouge">Phoenix.LiveView.JS</code> déclenche des commandes côté client sans aller au serveur ni écrire une ligne de JavaScript :</p>

<div class="language-heex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;button</span> <span class="na">phx-click=</span><span class="s">{JS.toggle(to:</span> <span class="err">"</span><span class="na">#menu</span><span class="err">")}</span><span class="nt">&gt;</span>Menu<span class="nt">&lt;/button&gt;</span>
<span class="nt">&lt;div</span> <span class="na">phx-click=</span><span class="s">{JS.hide(to:</span> <span class="err">"</span><span class="na">#alerte</span><span class="err">")</span> <span class="err">|</span><span class="nt">&gt;</span> JS.push("marquer_lu")}&gt;…<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>Afficher, masquer, basculer une classe ou un attribut, poser le focus, animer une transition, naviguer : tout cela existe déjà. En revanche <code class="language-plaintext highlighter-rouge">JS</code> ne sait pas tout — il n’a par exemple aucune commande de défilement, qu’il faut obtenir par <code class="language-plaintext highlighter-rouge">JS.dispatch</code> et un écouteur.</p>

<p>Un hook se justifie quand il faut <strong>intégrer du code qui n’est pas le vôtre</strong> — une librairie JavaScript — ou réagir à un événement du navigateur que LiveView n’expose pas.</p>

<h2 id="le-hook-minimal">Le hook minimal</h2>

<p>Trois pièces. Un objet JavaScript :</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">RightClickHook</span> <span class="o">=</span> <span class="p">{</span>
  <span class="nx">mounted</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">this</span><span class="p">.</span><span class="nx">el</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">contextmenu</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="nx">event</span><span class="p">.</span><span class="nx">preventDefault</span><span class="p">()</span>
      <span class="k">this</span><span class="p">.</span><span class="nx">pushEvent</span><span class="p">(</span><span class="dl">"</span><span class="s2">right-click</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span>
        <span class="na">x</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">clientX</span><span class="p">,</span>
        <span class="na">y</span><span class="p">:</span> <span class="nx">event</span><span class="p">.</span><span class="nx">clientY</span><span class="p">,</span>
        <span class="na">element_id</span><span class="p">:</span> <span class="k">this</span><span class="p">.</span><span class="nx">el</span><span class="p">.</span><span class="nx">id</span>
      <span class="p">})</span>
    <span class="p">})</span>
  <span class="p">}</span>
<span class="p">}</span>

<span class="k">export</span> <span class="k">default</span> <span class="nx">RightClickHook</span>
</code></pre></div></div>

<p>Son enregistrement au démarrage du socket :</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="nx">RightClickHook</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">./hooks/right-hook</span><span class="dl">"</span>

<span class="kd">const</span> <span class="nx">liveSocket</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">LiveSocket</span><span class="p">(</span><span class="dl">"</span><span class="s2">/live</span><span class="dl">"</span><span class="p">,</span> <span class="nx">Socket</span><span class="p">,</span> <span class="p">{</span>
  <span class="na">params</span><span class="p">:</span> <span class="p">{</span><span class="na">_csrf_token</span><span class="p">:</span> <span class="nx">csrfToken</span><span class="p">},</span>
  <span class="na">hooks</span><span class="p">:</span> <span class="p">{</span><span class="nx">RightClickHook</span><span class="p">}</span>
<span class="p">})</span>
</code></pre></div></div>

<p>Et l’attribut côté gabarit :</p>

<div class="language-heex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"zone"</span> <span class="na">phx-hook=</span><span class="s">"RightClickHook"</span><span class="nt">&gt;</span>Clic droit ici<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p><strong>L’<code class="language-plaintext highlighter-rouge">id</code> n’est pas facultatif</strong>, et LiveView le vérifie à deux endroits.</p>

<p>D’abord à la compilation. HEEx refuse de compiler un gabarit où <code class="language-plaintext highlighter-rouge">phx-hook</code> — ou <code class="language-plaintext highlighter-rouge">phx-update</code> — apparaît sans <code class="language-plaintext highlighter-rouge">id</code> littéral :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>attribute "phx-hook" requires the "id" attribute to be set
</code></pre></div></div>

<p>C’est le cas le plus fréquent, et le plus confortable : <code class="language-plaintext highlighter-rouge">mix compile</code> échoue, vous corrigez, vous n’avez jamais lancé le navigateur.</p>

<p>Le second garde-fou est côté client, pour ce que le compilateur ne peut pas voir — un <code class="language-plaintext highlighter-rouge">id={@quelque_chose}</code> qui vaut <code class="language-plaintext highlighter-rouge">nil</code> à l’exécution, ou un identifiant arrivant par des attributs dynamiques :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>no DOM ID for hook "RightClickHook". Hooks require a unique ID on each element.
</code></pre></div></div>

<p>Celui-là ne sort que dans la console du navigateur. Si votre hook ne démarre pas sans raison apparente, c’est le premier endroit à regarder.</p>

<p>La raison de fond est la même dans les deux cas : LiveView doit pouvoir retrouver l’élément d’un rendu à l’autre pour savoir s’il s’agit du même. Sans identifiant, il ne le peut pas.</p>

<h2 id="les-six-moments-de-la-vie-dun-hook">Les six moments de la vie d’un hook</h2>

<p>La documentation en définit six, et chacun répond à un besoin précis.</p>

<p><strong><code class="language-plaintext highlighter-rouge">mounted()</code></strong> — l’élément est dans le DOM et le LiveView a fini de monter. C’est là qu’on instancie sa librairie, qu’on pose ses écouteurs, qu’on démarre ce qu’on a à démarrer. Neuf hooks sur dix n’utilisent que celui-là.</p>

<p><strong><code class="language-plaintext highlighter-rouge">beforeUpdate()</code></strong> — l’élément est sur le point d’être mis à jour. Vous recevez <code class="language-plaintext highlighter-rouge">toEl</code>, une copie détachée portant la mise à jour à venir, tandis que <code class="language-plaintext highlighter-rouge">this.el</code> est encore l’élément inchangé. C’est le moment de sauvegarder ce que la mise à jour va détruire : une position de défilement, une sélection de texte. <strong>Tout ce que vous faites ici doit être synchrone</strong> — l’opération ne peut être ni différée ni annulée.</p>

<p><strong><code class="language-plaintext highlighter-rouge">updated()</code></strong> — le serveur vient de mettre l’élément à jour. On y resynchronise sa librairie avec le nouveau contenu, et on y restaure ce que <code class="language-plaintext highlighter-rouge">beforeUpdate</code> avait mis de côté.</p>

<p><strong><code class="language-plaintext highlighter-rouge">destroyed()</code></strong> — l’élément a quitté la page, retiré par une mise à jour du parent ou par la disparition du parent. <strong>C’est votre seule occasion de faire le ménage</strong>, et on y reviendra.</p>

<p><strong><code class="language-plaintext highlighter-rouge">disconnected()</code></strong> et <strong><code class="language-plaintext highlighter-rouge">reconnected()</code></strong> — le LiveView parent a perdu, puis retrouvé, sa connexion au serveur. De quoi griser une interface pendant une coupure et la réactiver au retour.</p>

<p>Un hook n’a pas besoin de les déclarer tous. Mais les lister vides, avec un commentaire expliquant à quoi chacun sert, est un excellent réflexe quand on débute — on se souvient qu’ils existent le jour où on en a besoin.</p>

<h2 id="les-deux-sens-de-la-conversation">Les deux sens de la conversation</h2>

<h3 id="du-navigateur-vers-le-serveur">Du navigateur vers le serveur</h3>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">this</span><span class="p">.</span><span class="nx">pushEvent</span><span class="p">(</span><span class="dl">"</span><span class="s2">right-click</span><span class="dl">"</span><span class="p">,</span> <span class="nx">payload</span><span class="p">)</span>          <span class="c1">// vers le LiveView</span>
<span class="k">this</span><span class="p">.</span><span class="nx">pushEventTo</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">el</span><span class="p">,</span> <span class="dl">"</span><span class="s2">reposition</span><span class="dl">"</span><span class="p">,</span> <span class="nx">params</span><span class="p">)</span> <span class="c1">// vers le propriétaire de l'élément</span>
</code></pre></div></div>

<p>La différence est décisive et se paie cher quand on se trompe. <code class="language-plaintext highlighter-rouge">pushEvent</code> vise <strong>le LiveView</strong>. <code class="language-plaintext highlighter-rouge">pushEventTo</code> vise, selon la documentation, « the LiveComponent or LiveView owning the targeted element(s) ».</p>

<p><strong>Dans un LiveComponent, il faut <code class="language-plaintext highlighter-rouge">pushEventTo</code>.</strong> Sinon l’événement arrive chez le parent, qui n’a évidemment pas de handler pour lui, et vous cherchez longtemps un bug dans le composant alors que l’erreur nomme le parent. Le conteneur doit également porter <code class="language-plaintext highlighter-rouge">phx-target={@myself}</code> pour que les événements déclaratifs suivent le même chemin.</p>

<p>En cas de doute, <code class="language-plaintext highlighter-rouge">pushEventTo(this.el, …)</code> fonctionne dans les deux situations : il vise le propriétaire réel de l’élément, LiveView ou composant.</p>

<h3 id="du-serveur-vers-le-navigateur">Du serveur vers le navigateur</h3>

<p>C’est la moitié qu’on oublie souvent. Le serveur pousse avec <code class="language-plaintext highlighter-rouge">push_event/3</code> :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="n">handle_event</span><span class="p">(</span><span class="s2">"exporter"</span><span class="p">,</span> <span class="n">_params</span><span class="p">,</span> <span class="n">socket</span><span class="p">)</span> <span class="k">do</span>
  <span class="p">{</span><span class="ss">:noreply</span><span class="p">,</span> <span class="n">push_event</span><span class="p">(</span><span class="n">socket</span><span class="p">,</span> <span class="s2">"telecharger"</span><span class="p">,</span> <span class="p">%{</span><span class="ss">url:</span> <span class="n">url</span><span class="p">})}</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Et le hook écoute :</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">mounted</span><span class="p">()</span> <span class="p">{</span>
  <span class="k">this</span><span class="p">.</span><span class="nx">handleEvent</span><span class="p">(</span><span class="dl">"</span><span class="s2">telecharger</span><span class="dl">"</span><span class="p">,</span> <span class="p">({</span><span class="nx">url</span><span class="p">})</span> <span class="o">=&gt;</span> <span class="nb">window</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="nx">url</span><span class="p">))</span>
<span class="p">}</span>
</code></pre></div></div>

<p>À défaut de hook, ces événements sont aussi diffusés sur <code class="language-plaintext highlighter-rouge">window</code>, préfixés de <code class="language-plaintext highlighter-rouge">phx:</code> — <code class="language-plaintext highlighter-rouge">window.addEventListener("phx:telecharger", …)</code>. Pratique pour du code global, mais un hook reste préférable dès que l’événement concerne un élément précis.</p>

<h2 id="la-vraie-question--qui-possède-ce-dom-">La vraie question : qui possède ce DOM ?</h2>

<p>Tout le reste découle de là. Deux cas concrets, opposés.</p>

<h3 id="cas-1--le-serveur-reste-maître--le-tri-par-glisser-déposer">Cas 1 — le serveur reste maître : le tri par glisser-déposer</h3>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">mounted</span><span class="p">()</span> <span class="p">{</span>
  <span class="k">new</span> <span class="nx">Sortable</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">el</span><span class="p">,</span> <span class="p">{</span>
    <span class="na">animation</span><span class="p">:</span> <span class="mi">150</span><span class="p">,</span>
    <span class="na">onEnd</span><span class="p">:</span> <span class="nx">e</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">params</span> <span class="o">=</span> <span class="p">{</span> <span class="na">old</span><span class="p">:</span> <span class="nx">e</span><span class="p">.</span><span class="nx">oldIndex</span><span class="p">,</span> <span class="na">new</span><span class="p">:</span> <span class="nx">e</span><span class="p">.</span><span class="nx">newIndex</span><span class="p">,</span> <span class="p">...</span><span class="nx">e</span><span class="p">.</span><span class="nx">item</span><span class="p">.</span><span class="nx">dataset</span> <span class="p">}</span>
      <span class="k">this</span><span class="p">.</span><span class="nx">pushEventTo</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">el</span><span class="p">,</span> <span class="dl">"</span><span class="s2">reposition</span><span class="dl">"</span><span class="p">,</span> <span class="nx">params</span><span class="p">)</span>
    <span class="p">}</span>
  <span class="p">})</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-heex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"sort-items"</span> <span class="na">phx-hook=</span><span class="s">"SortableHook"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">:for=</span><span class="s">{item</span> <span class="err">&lt;</span><span class="na">-</span> <span class="err">@</span><span class="na">columns</span><span class="err">}</span> <span class="na">data-id=</span><span class="s">{item.id}</span><span class="nt">&gt;</span>…<span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>Le <code class="language-plaintext highlighter-rouge">data-id</code> sur chaque enfant mérite qu’on s’y arrête : c’est lui que <code class="language-plaintext highlighter-rouge">...e.item.dataset</code> fait remonter dans la charge utile. Sans lui, le serveur ne reçoit que deux positions, <code class="language-plaintext highlighter-rouge">old</code> et <code class="language-plaintext highlighter-rouge">new</code> — ce qui suffit tant que la liste est complète et non filtrée, et casse dès qu’elle est paginée, filtrée ou servie par un stream, où l’indice affiché ne correspond plus à l’indice réel. <strong>Faites toujours voyager une identité, pas seulement une position.</strong></p>

<p>Remarquez ce qui <strong>n’est pas</strong> là : aucun <code class="language-plaintext highlighter-rouge">phx-update="ignore"</code>. C’est délibéré. Sortable réordonne le DOM tout de suite, pour que le geste paraisse instantané ; puis le serveur reçoit <code class="language-plaintext highlighter-rouge">reposition</code>, met à jour ses données, et re-rend la liste dans le bon ordre. Le DOM affiché redevient celui que le serveur décide.</p>

<p>Le hook ne fait ici que <strong>traduire un geste en événement</strong>. L’ordre des éléments appartient au serveur — et c’est ce qui rend le montage robuste : le jour où vous enregistrez cet ordre en base, un rechargement de page le restitue sans toucher au hook.</p>

<p>Précisons pour être honnête : le dépôt d’exemple garde ses colonnes dans l’état du socket, pas en base. Rechargez sa page de démonstration et l’ordre initial revient. La propriété décrite ici tient à l’architecture — le serveur décide — pas à cette démonstration en particulier.</p>

<h3 id="cas-2--la-librairie-reste-maîtresse--un-éditeur-de-texte-riche">Cas 2 — la librairie reste maîtresse : un éditeur de texte riche</h3>

<p>Un éditeur comme ProseMirror ou TipTap construit et gère son propre arbre DOM, avec la position du curseur, la sélection, l’historique d’annulation. Si LiveView le re-rend, tout est perdu au milieu d’une phrase.</p>

<p>Il faut donc lui céder le terrain :</p>

<div class="language-heex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"editeur"</span> <span class="na">phx-hook=</span><span class="s">"EditorHook"</span> <span class="na">phx-update=</span><span class="s">"ignore"</span><span class="nt">&gt;&lt;/div&gt;</span>
</code></pre></div></div>

<p>Et là, un piège que la documentation énonce en une incise facile à manquer :</p>

<blockquote>
  <p>Updates from the server to the element’s content and attributes are ignored, <strong>except for data attributes</strong>.</p>
</blockquote>

<p>Les attributs <code class="language-plaintext highlighter-rouge">data-*</code> continuent donc d’être mis à jour — et pire, ceux que le serveur n’envoie pas sont <strong>retirés</strong>. Un hook qui range son état dans un <code class="language-plaintext highlighter-rouge">data-*</code> sur son élément racine le verra disparaître au rendu suivant.</p>

<p><strong>La règle : l’état du hook vit à l’intérieur du sous-arbre ignoré, jamais sur sa racine.</strong> Dans un éditeur, il vit naturellement dans le nœud DOM de l’éditeur lui-même.</p>

<h3 id="comment-trancher">Comment trancher</h3>

<p>Posez-vous une seule question : <strong>après un rechargement complet de la page, qu’est-ce qui doit survivre ?</strong></p>

<p>Ce qui doit survivre appartient au serveur — il le stocke, il le re-rend, et le hook se contente de lui signaler les gestes. Ce qui est éphémère appartient à la librairie — on lui donne <code class="language-plaintext highlighter-rouge">phx-update="ignore"</code> et on ne s’en mêle plus.</p>

<h2 id="faire-le-ménage">Faire le ménage</h2>

<p>Un hook qui pose un écouteur sur <code class="language-plaintext highlighter-rouge">this.el</code> n’a rien à nettoyer : l’élément disparaît, l’écouteur avec lui. Mais dès qu’il touche à autre chose, la fuite est réelle :</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">mounted</span><span class="p">()</span> <span class="p">{</span>
  <span class="k">this</span><span class="p">.</span><span class="nx">onResize</span> <span class="o">=</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nx">recalculer</span><span class="p">()</span>
  <span class="nb">window</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">resize</span><span class="dl">"</span><span class="p">,</span> <span class="k">this</span><span class="p">.</span><span class="nx">onResize</span><span class="p">)</span>
  <span class="k">this</span><span class="p">.</span><span class="nx">timer</span> <span class="o">=</span> <span class="nx">setInterval</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="k">this</span><span class="p">.</span><span class="nx">rafraichir</span><span class="p">(),</span> <span class="mi">5000</span><span class="p">)</span>
<span class="p">},</span>

<span class="nx">destroyed</span><span class="p">()</span> <span class="p">{</span>
  <span class="nb">window</span><span class="p">.</span><span class="nx">removeEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">resize</span><span class="dl">"</span><span class="p">,</span> <span class="k">this</span><span class="p">.</span><span class="nx">onResize</span><span class="p">)</span>
  <span class="nx">clearInterval</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">timer</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Sans ce <code class="language-plaintext highlighter-rouge">destroyed()</code>, chaque navigation ajoute un écouteur et un minuteur de plus. La page ralentit progressivement, sans qu’aucune erreur n’apparaisse jamais. Écouteurs sur <code class="language-plaintext highlighter-rouge">window</code> ou <code class="language-plaintext highlighter-rouge">document</code>, minuteurs, <code class="language-plaintext highlighter-rouge">IntersectionObserver</code>, <code class="language-plaintext highlighter-rouge">MutationObserver</code>, connexions WebSocket ouvertes à la main : tout cela se libère dans <code class="language-plaintext highlighter-rouge">destroyed()</code>.</p>

<h2 id="ce-que-les-tests-ne-diront-pas">Ce que les tests ne diront pas</h2>

<p>Un point à garder en tête, développé dans <a href="/ElixirPhoenixRessources/articles/pannes-silencieuses-liveview/">l’article précédent</a> : <code class="language-plaintext highlighter-rouge">render_click</code>, <code class="language-plaintext highlighter-rouge">render_change</code> et leurs voisins, dans leur forme fondée sur la vue, envoient l’événement directement au LiveView <strong>sans navigateur</strong>.</p>

<p>Ils prouvent que votre <code class="language-plaintext highlighter-rouge">handle_event</code> fait ce qu’il faut. Ils ne prouvent rien de votre hook — qui n’a même pas été chargé. Pour tester ce qu’un hook fait réellement, il faut un vrai navigateur : <a href="https://github.com/elixir-wallaby/wallaby">Wallaby</a> pilote un Chrome.</p>

<h2 id="en-résumé">En résumé</h2>

<p>Un hook est un traducteur posté à la frontière. Il convertit des gestes du navigateur en événements pour le serveur, et des décisions du serveur en actions dans le navigateur.</p>

<p>Écrivez-en le moins possible : <code class="language-plaintext highlighter-rouge">Phoenix.LiveView.JS</code> couvre déjà beaucoup. Quand il en faut un, décidez d’abord qui possède le DOM concerné, nettoyez dans <code class="language-plaintext highlighter-rouge">destroyed()</code> tout ce que vous avez posé ailleurs que sur votre élément, et souvenez-vous que dans un LiveComponent, <code class="language-plaintext highlighter-rouge">pushEventTo</code> est la seule forme correcte.</p>

<p>Deux exemples complets et exécutables : <a href="https://github.com/nseaSeb/SortableBoilerPlate">le tri par glisser-déposer</a> et <a href="https://github.com/nseaSeb/right-click-elixir">le menu au clic droit</a>.</p>]]></content><author><name>nseaSeb</name></author><category term="liveview" /><category term="liveview" /><category term="javascript" /><category term="hooks" /><category term="phoenix" /><summary type="html"><![CDATA[Écrire son premier hook, comprendre ses six moments de vie, faire circuler l'information dans les deux sens — et surtout répondre à la seule question qui compte : qui possède ce morceau de DOM ?]]></summary></entry><entry xml:lang="fr"><title type="html">Les pannes silencieuses de LiveView</title><link href="https://nseaseb.github.io/ElixirPhoenixRessources/articles/pannes-silencieuses-liveview/" rel="alternate" type="text/html" title="Les pannes silencieuses de LiveView" /><published>2026-09-07T09:18:00+02:00</published><updated>2026-09-07T09:18:00+02:00</updated><id>https://nseaseb.github.io/ElixirPhoenixRessources/articles/pannes-silencieuses-liveview</id><content type="html" xml:base="https://nseaseb.github.io/ElixirPhoenixRessources/articles/pannes-silencieuses-liveview/"><![CDATA[<p>Un bug qui plante est un bug facile. Il y a une erreur, une trace, un fichier, une ligne. On ouvre, on corrige.</p>

<p>Les pires sont ceux dont l’information se perd en route. Tantôt le serveur n’a rien vu passer et les journaux sont vides ; tantôt il lève bien une erreur, mais elle désigne le mauvais coupable. Et dans un cas, les tests restent au vert.</p>

<p>Voici trois de ces pannes, avec pour chacune l’endroit exact où l’information se perd.</p>

<!--more-->

<h2 id="1-un-phx-change-hors-dun-formulaire">1. Un <code class="language-plaintext highlighter-rouge">phx-change</code> hors d’un formulaire</h2>

<p>Vous ajoutez un champ de recherche qui filtre une liste au fil de la frappe :</p>

<div class="language-heex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"text"</span> <span class="na">name=</span><span class="s">"q"</span> <span class="na">phx-change=</span><span class="s">"filtrer"</span> <span class="nt">/&gt;</span>
</code></pre></div></div>

<p>Vous tapez. Rien. Aucune erreur côté serveur, aucune ligne dans les journaux, <code class="language-plaintext highlighter-rouge">handle_event("filtrer", …)</code> n’est jamais appelé.</p>

<p><strong>La cause est côté navigateur</strong>, dans une exception que personne ne regarde. Le code de LiveView est explicite — <code class="language-plaintext highlighter-rouge">assets/js/phoenix_live_view/view.ts</code> :</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">pushInput</span><span class="p">(</span><span class="nx">inputEl</span><span class="p">,</span> <span class="nx">targetCtx</span><span class="p">,</span> <span class="nx">forceCid</span><span class="p">,</span> <span class="nx">phxEvent</span><span class="p">,</span> <span class="nx">opts</span><span class="p">,</span> <span class="nx">callback</span><span class="p">?)</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">inputEl</span><span class="p">.</span><span class="nx">form</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">throw</span> <span class="k">new</span> <span class="nb">Error</span><span class="p">(</span><span class="dl">"</span><span class="s2">form events require the input to be inside a form</span><span class="dl">"</span><span class="p">);</span>
  <span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">phx-change</code> est un <strong>événement de formulaire</strong>. Sans <code class="language-plaintext highlighter-rouge">&lt;form&gt;</code> parent, LiveView refuse de pousser quoi que ce soit et lève dans la console. Le serveur, lui, n’apprendra jamais qu’on a essayé.</p>

<p>Le réflexe est d’entourer d’un <code class="language-plaintext highlighter-rouge">&lt;form&gt;</code>. Il est juste, mais incomplet — et l’incomplétude est elle-même une panne silencieuse.</p>

<div class="language-heex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- Corrige le premier problème, en crée un second. --&gt;</span>
<span class="nt">&lt;form</span> <span class="na">phx-change=</span><span class="s">"filtrer"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"text"</span> <span class="na">name=</span><span class="s">"q"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;/form&gt;</span>
</code></pre></div></div>

<p>Un formulaire qui porte <code class="language-plaintext highlighter-rouge">phx-change</code> <strong>sans</strong> <code class="language-plaintext highlighter-rouge">phx-submit</code> est traité par LiveView comme un formulaire externe. Toujours dans <code class="language-plaintext highlighter-rouge">live_socket.ts</code> :</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">externalFormSubmitted</span> <span class="o">&amp;&amp;</span> <span class="nx">phxChange</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="nx">phxSubmit</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">externalFormSubmitted</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
  <span class="nx">e</span><span class="p">.</span><span class="nx">preventDefault</span><span class="p">();</span>
  <span class="k">this</span><span class="p">.</span><span class="nx">withinOwners</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">target</span><span class="p">,</span> <span class="p">(</span><span class="nx">view</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
    <span class="nx">view</span><span class="p">.</span><span class="nx">disableForm</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">target</span> <span class="k">as</span> <span class="nx">HTMLFormElement</span><span class="p">,</span> <span class="nx">phxChange</span><span class="p">);</span>
    <span class="c1">// safari needs next tick</span>
    <span class="nb">window</span><span class="p">.</span><span class="nx">requestAnimationFrame</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="k">if</span> <span class="p">(</span><span class="nx">DOM</span><span class="p">.</span><span class="nx">isUnloadableFormSubmit</span><span class="p">(</span><span class="nx">e</span><span class="p">))</span> <span class="p">{</span>
        <span class="k">this</span><span class="p">.</span><span class="nx">unload</span><span class="p">();</span>
      <span class="p">}</span>
      <span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">target</span> <span class="k">as</span> <span class="nx">HTMLFormElement</span><span class="p">).</span><span class="nx">submit</span><span class="p">();</span>
    <span class="p">});</span>
  <span class="p">});</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">form.submit()</code> : un envoi de formulaire pour de vrai, avec rechargement complet de la page et perte de l’état du LiveView. Or un formulaire à un seul champ déclenche sa soumission implicite dès qu’on appuie sur <strong>Entrée</strong> — un geste que fait naturellement quelqu’un qui vient de taper une recherche.</p>

<p>La version qui tient debout déclare les deux événements :</p>

<div class="language-heex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;form</span> <span class="na">phx-change=</span><span class="s">"filtrer"</span> <span class="na">phx-submit=</span><span class="s">"filtrer"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"text"</span> <span class="na">name=</span><span class="s">"q"</span> <span class="nt">/&gt;</span>
<span class="nt">&lt;/form&gt;</span>
</code></pre></div></div>

<p>Entrée déclenche alors <code class="language-plaintext highlighter-rouge">handle_event("filtrer", …)</code> comme la frappe, et la page reste en place.</p>

<h3 id="et-le-test-reste-vert">Et le test reste vert</h3>

<p>Voilà le vrai piège. Vous écrivez le test qui va bien :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">assert</span> <span class="n">render_change</span><span class="p">(</span><span class="n">view</span><span class="p">,</span> <span class="ss">:filtrer</span><span class="p">,</span> <span class="p">%{</span><span class="s2">"q"</span> <span class="o">=&gt;</span> <span class="s2">"elixir"</span><span class="p">})</span> <span class="o">=~</span> <span class="s2">"résultat"</span>
</code></pre></div></div>

<p>Il passe. Il passera toujours, formulaire ou pas — parce qu’il ne regarde pas le HTML. Dans <code class="language-plaintext highlighter-rouge">live_view_test.ex</code>, cette forme de <code class="language-plaintext highlighter-rouge">render_change/3</code> se réduit à :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="n">render_change</span><span class="p">(</span><span class="n">view</span><span class="p">,</span> <span class="n">event</span><span class="p">,</span> <span class="n">value</span><span class="p">)</span> <span class="k">do</span>
  <span class="n">render_event</span><span class="p">(</span><span class="n">view</span><span class="p">,</span> <span class="ss">:change</span><span class="p">,</span> <span class="n">event</span><span class="p">,</span> <span class="n">value</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>L’événement part <strong>directement au LiveView</strong>. Le balisage rendu n’est jamais consulté, donc l’absence de formulaire lui est invisible. Votre test valide un comportement que l’utilisateur n’obtiendra jamais.</p>

<p>La forme fondée sur un élément — <code class="language-plaintext highlighter-rouge">element(view, "#recherche") |&gt; render_change(%{…})</code> — est plus honnête, puisqu’elle exige au moins que l’élément existe dans le rendu. Mais rien ne remplace un test navigateur pour ce qui dépend du navigateur.</p>

<h2 id="2-un-hook-dans-un-livecomponent">2. Un hook dans un LiveComponent</h2>

<p>Votre hook JavaScript pousse un événement, et le LiveView plante avec un <code class="language-plaintext highlighter-rouge">no function clause matching in MonAppWeb.ParentLive.handle_event/3</code> — alors que le handler existe, sous vos yeux, dans le composant.</p>

<p>Il existe deux fonctions, et elles ne visent pas la même chose. La documentation les distingue nettement : <code class="language-plaintext highlighter-rouge">pushEvent</code> pousse vers <strong>le LiveView</strong>, tandis que <code class="language-plaintext highlighter-rouge">pushEventTo</code> pousse « to the LiveComponent or LiveView owning the targeted element(s) ».</p>

<p>Un hook posé dans un LiveComponent qui appelle <code class="language-plaintext highlighter-rouge">pushEvent</code> envoie donc son événement au <strong>parent</strong>, qui n’a effectivement aucun handler pour lui.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Dans un LiveComponent : l'événement part chez le parent.</span>
<span class="k">this</span><span class="p">.</span><span class="nx">pushEvent</span><span class="p">(</span><span class="dl">"</span><span class="s2">ouvrir</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span><span class="na">id</span><span class="p">:</span> <span class="mi">1</span><span class="p">})</span>

<span class="c1">// Ce qu'il faut : viser le composant propriétaire de l'élément.</span>
<span class="k">this</span><span class="p">.</span><span class="nx">pushEventTo</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">el</span><span class="p">,</span> <span class="dl">"</span><span class="s2">ouvrir</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span><span class="na">id</span><span class="p">:</span> <span class="mi">1</span><span class="p">})</span>
</code></pre></div></div>

<p>Et côté gabarit, le conteneur doit porter <code class="language-plaintext highlighter-rouge">phx-target={@myself}</code> pour que les événements déclaratifs suivent le même chemin.</p>

<p>Le message d’erreur ne ment pas, et il est même précis : il nomme le module où il a cherché. Ce qu’il ne peut pas dire, c’est <strong>qui aurait dû recevoir l’événement</strong>. Vous lisez le nom du parent et vous allez inspecter le parent, alors que le problème est dans l’enfant.</p>

<h2 id="3-phx-updateignore-ne-protège-pas-ce-quon-croit">3. <code class="language-plaintext highlighter-rouge">phx-update="ignore"</code> ne protège pas ce qu’on croit</h2>

<p>On pose <code class="language-plaintext highlighter-rouge">phx-update="ignore"</code> sur un conteneur pour que LiveView n’écrase pas ce qu’une librairie JavaScript y a construit — un éditeur de texte riche, une carte, un composant de tri.</p>

<p>Puis l’état que le hook conservait disparaît au rendu suivant. La documentation explique pourquoi, à condition de lire la fin de la phrase :</p>

<blockquote>
  <p>Updates from the server to the element’s content and attributes are ignored, <strong>except for data attributes</strong>.</p>
</blockquote>

<p>Cette exception est tout le problème. Un hook range volontiers son état dans un <code class="language-plaintext highlighter-rouge">data-*</code> sur son élément racine — et les attributs <code class="language-plaintext highlighter-rouge">data-*</code> sont précisément ceux que LiveView continue de mettre à jour. La fonction <code class="language-plaintext highlighter-rouge">DOM.mergeAttrs</code> va même plus loin : elle <strong>retire</strong> de l’élément les <code class="language-plaintext highlighter-rouge">data-*</code> que le serveur n’envoie pas. L’état posé par le hook n’est pas seulement écrasé, il est effacé.</p>

<p>La règle qui en découle : <strong>l’état maintenu par le hook doit vivre à l’intérieur du sous-arbre ignoré, pas sur sa racine.</strong> Dans un éditeur ProseMirror, par exemple, il vit naturellement dans le nœud DOM de l’éditeur lui-même, à l’abri.</p>

<h2 id="ce-que-ces-trois-pannes-ont-en-commun">Ce que ces trois pannes ont en commun</h2>

<p>Elles se ressemblent, et pas par hasard : <strong>l’information existe, mais elle reste dans la couche qui l’a produite.</strong></p>

<p>Le navigateur sait que le <code class="language-plaintext highlighter-rouge">phx-change</code> est mal placé — il a levé une exception. Le serveur ne le saura jamais, puisque rien ne lui a été envoyé. Le parent sait qu’il n’a pas de handler — mais pas que le vrai destinataire était son enfant. Le rendu sait qu’il a mis à jour un <code class="language-plaintext highlighter-rouge">data-*</code> — il ignore que le hook y rangeait quelque chose.</p>

<p>LiveView efface presque entièrement la frontière client/serveur, et c’est sa qualité principale. Ces trois cas sont le prix de cette réussite : quand quelque chose se perd à la frontière, il n’y a plus personne pour le dire.</p>

<h2 id="trois-réflexes">Trois réflexes</h2>

<p><strong>Ouvrez la console du navigateur.</strong> C’est la première chose à faire devant une interaction qui ne produit rien côté serveur. Le message y est peut-être depuis le début.</p>

<p><strong>Méfiez-vous d’un test vert sur un comportement qui dépend du DOM.</strong> Dans leur forme <code class="language-plaintext highlighter-rouge">render_change(view, :evenement, params)</code>, ces fonctions pilotent le LiveView directement, sans jamais regarder le rendu : elles prouvent que votre <code class="language-plaintext highlighter-rouge">handle_event</code> fonctionne, pas qu’un utilisateur peut le déclencher.</p>

<p>Préférez systématiquement la forme fondée sur un élément — <code class="language-plaintext highlighter-rouge">element(view, "#recherche") |&gt; render_change(%{…})</code> — qui résout le sélecteur dans le HTML produit et exige donc que l’élément et sa liaison existent vraiment. Et pour ce qui dépend du navigateur lui-même, il faut un vrai navigateur : <a href="https://github.com/elixir-wallaby/wallaby">Wallaby</a> pilote un Chrome.</p>

<p><strong>Quand un événement se perd, demandez-vous qui l’écoute.</strong> Dans un LiveComponent, la réponse par défaut est « le parent », et ce n’est presque jamais ce que vous vouliez.</p>]]></content><author><name>nseaSeb</name></author><category term="liveview" /><category term="liveview" /><category term="phoenix" /><category term="debogage" /><category term="tests" /><summary type="html"><![CDATA[Trois défaillances dont l'information se perd entre le navigateur et le serveur — l'une ne laisse aucune trace, une autre accuse le mauvais module, et la première laisse même les tests au vert.]]></summary></entry><entry xml:lang="fr"><title type="html">L’immutabilité ne coûte pas ce que vous croyez</title><link href="https://nseaseb.github.io/ElixirPhoenixRessources/articles/immutabilite-partage-structurel/" rel="alternate" type="text/html" title="L’immutabilité ne coûte pas ce que vous croyez" /><published>2026-09-07T07:48:00+02:00</published><updated>2026-09-07T07:48:00+02:00</updated><id>https://nseaseb.github.io/ElixirPhoenixRessources/articles/immutabilite-partage-structurel</id><content type="html" xml:base="https://nseaseb.github.io/ElixirPhoenixRessources/articles/immutabilite-partage-structurel/"><![CDATA[<p>C’est l’objection réflexe de quiconque arrive d’un langage impératif. On explique qu’en Elixir les données sont immuables, qu’ajouter un élément à une liste de dix mille entrées produit une <strong>nouvelle</strong> liste — et l’interlocuteur fronce les sourcils. <em>Si vous recopiez dix mille éléments à chaque modification, comment ça peut être utilisable ?</em></p>

<p>La question est excellente. La réponse est qu’on ne recopie rien.</p>

<!--more-->

<h2 id="ce-que--nouvelle-liste--veut-vraiment-dire">Ce que « nouvelle liste » veut vraiment dire</h2>

<p>Prenons une liste de dix mille éléments et ajoutons-lui une tête. La machine virtuelle sait mesurer ce que ça coûte : <code class="language-plaintext highlighter-rouge">:erts_debug.flat_size/1</code> compte une valeur comme si rien n’était partagé, <code class="language-plaintext highlighter-rouge">:erts_debug.size/1</code> en tenant compte du partage. Mesurons les deux listes <strong>ensemble</strong> :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">grande</span> <span class="o">=</span> <span class="no">Enum</span><span class="o">.</span><span class="n">to_list</span><span class="p">(</span><span class="mi">1</span><span class="o">..</span><span class="mi">10_000</span><span class="p">)</span>
<span class="n">avec_tete</span> <span class="o">=</span> <span class="p">[</span><span class="mi">0</span> <span class="o">|</span> <span class="n">grande</span><span class="p">]</span>
<span class="n">paire</span> <span class="o">=</span> <span class="p">{</span><span class="n">grande</span><span class="p">,</span> <span class="n">avec_tete</span><span class="p">}</span>

<span class="ss">:erts_debug</span><span class="o">.</span><span class="n">flat_size</span><span class="p">(</span><span class="n">paire</span><span class="p">)</span>   <span class="c1">#=&gt; 40_005 mots</span>
<span class="ss">:erts_debug</span><span class="o">.</span><span class="n">size</span><span class="p">(</span><span class="n">paire</span><span class="p">)</span>        <span class="c1">#=&gt; 20_005 mots</span>
</code></pre></div></div>

<p>Deux listes de dix mille éléments occupent l’espace d’une seule. L’écart de cinq mots mérite d’être décomposé, parce qu’il est instructif : trois viennent du tuple que j’ai construit pour la mesure, et <strong>deux seulement sont la cellule nouvelle</strong>. Ajouter une tête à une liste de dix mille éléments coûte deux mots — une valeur et un pointeur.</p>

<p>La seconde liste n’a pas recopié la première : elle <strong>pointe dessus</strong>.</p>

<p>C’est le partage structurel. Une liste chaînée est une suite de cellules, chacune contenant une valeur et un pointeur vers la suivante. <code class="language-plaintext highlighter-rouge">[0 | grande]</code> fabrique une unique cellule nouvelle, dont le pointeur vise la première cellule de <code class="language-plaintext highlighter-rouge">grande</code>. Rien d’autre n’est touché.</p>

<p><strong>Et c’est l’immutabilité qui rend ce partage possible.</strong> Dans un langage où <code class="language-plaintext highlighter-rouge">grande</code> pourrait changer, partager sa structure serait dangereux : modifier l’une modifierait l’autre. Puisque rien ne peut changer, tout peut être partagé sans risque. L’immutabilité n’est pas le prix à payer pour la sûreté — c’est ce qui autorise l’optimisation.</p>

<h2 id="ce-que-ça-donne-à-léchelle">Ce que ça donne à l’échelle</h2>

<p>Cent mille ajouts en tête, en conservant chaque résultat pour empêcher le ramasse-miettes de tricher :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="n">temps</span><span class="p">,</span> <span class="n">listes</span><span class="p">}</span> <span class="o">=</span> <span class="ss">:timer</span><span class="o">.</span><span class="n">tc</span><span class="p">(</span><span class="k">fn</span> <span class="o">-&gt;</span>
  <span class="no">Enum</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="mi">1</span><span class="o">..</span><span class="mi">100_000</span><span class="p">,</span> <span class="k">fn</span> <span class="n">i</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="n">i</span> <span class="o">|</span> <span class="n">grande</span><span class="p">]</span> <span class="k">end</span><span class="p">)</span>
<span class="k">end</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>temps                              : 8 ms
taille réelle des 100 000 listes   :           420 000 mots
si chacune était une copie complète : 2 000 400 000 mots
</code></pre></div></div>

<p>Un facteur <strong>4 762</strong>. Sur une machine 64 bits où un mot fait 8 octets, c’est 3,4 Mo au lieu de 16 Go. Voilà pourquoi <code class="language-plaintext highlighter-rouge">[element | accumulateur]</code> est le geste idiomatique en Elixir : il est réellement gratuit.</p>

<h2 id="le-revers---en-boucle">Le revers : <code class="language-plaintext highlighter-rouge">++</code> en boucle</h2>

<p>L’ajout en <strong>fin</strong> ne peut rien partager. Pour que la dernière cellule pointe vers un nouvel élément, il faut la reconstruire — donc reconstruire toutes celles qui y mènent. Et comme l’accumulateur grandit à chaque tour, le coût de chaque tour grandit avec lui.</p>

<p>C’est ce qui rend l’effet visible dès qu’on change d’échelle :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Enum</span><span class="o">.</span><span class="n">reduce</span><span class="p">(</span><span class="mi">1</span><span class="o">..</span><span class="mi">1_000</span><span class="p">,</span> <span class="p">[],</span> <span class="k">fn</span> <span class="n">i</span><span class="p">,</span> <span class="n">acc</span> <span class="o">-&gt;</span> <span class="n">acc</span> <span class="o">++</span> <span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="k">end</span><span class="p">)</span>   <span class="c1"># 1 ms</span>
<span class="no">Enum</span><span class="o">.</span><span class="n">reduce</span><span class="p">(</span><span class="mi">1</span><span class="o">..</span><span class="mi">10_000</span><span class="p">,</span> <span class="p">[],</span> <span class="k">fn</span> <span class="n">i</span><span class="p">,</span> <span class="n">acc</span> <span class="o">-&gt;</span> <span class="n">acc</span> <span class="o">++</span> <span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="k">end</span><span class="p">)</span>  <span class="c1"># 273 ms</span>
</code></pre></div></div>

<p><strong>Dix fois plus d’éléments, deux cent soixante-treize fois plus de temps.</strong> C’est la signature d’un algorithme quadratique, et c’est le piège classique de l’accumulateur construit avec <code class="language-plaintext highlighter-rouge">++</code>.</p>

<p>Le remède tient en deux gestes : empiler en tête, puis <code class="language-plaintext highlighter-rouge">Enum.reverse/1</code> une seule fois à la fin. L’inversion est linéaire, l’ensemble reste linéaire.</p>

<h2 id="le-partage-nest-pas-magique">Le partage n’est pas magique</h2>

<p>Modifier un élément <strong>au milieu</strong> oblige à reconstruire tout ce qui précède — la partie qui suit, elle, reste partagée. Le coût est donc proportionnel à la distance depuis la tête, et ça se mesure :</p>

<table>
  <thead>
    <tr>
      <th>Position modifiée</th>
      <th>10 000 appels</th>
      <th>Les deux listes ensemble</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>10</td>
      <td>~0 ms</td>
      <td>20 025 mots</td>
    </tr>
    <tr>
      <td>5 000</td>
      <td>462 ms</td>
      <td>30 005 mots</td>
    </tr>
    <tr>
      <td>9 999</td>
      <td>881 ms</td>
      <td>40 003 mots</td>
    </tr>
  </tbody>
</table>

<p>À la position 9 999, on est à 40 003 mots : plus rien n’est partagé, la liste entière a été reconstruite. La progression est parfaitement régulière — le partage porte sur la queue, jamais sur la tête.</p>

<h2 id="le-partage-ne-rend-pas-le-parcours-rapide">Le partage ne rend pas le parcours rapide</h2>

<p>Voilà la distinction qu’il ne faut pas manquer, parce qu’elle sépare deux questions qu’on confond volontiers.</p>

<p>Le partage porte sur la <strong>mémoire</strong>. Il n’a aucun effet sur le <strong>temps d’accès</strong>. <code class="language-plaintext highlighter-rouge">Enum.at(liste, 9_999)</code> doit suivre neuf mille neuf cent quatre-vingt-dix-neuf pointeurs, que la liste soit partagée avec dix autres ou avec aucune. Aucune structure partagée ne raccourcit une chaîne.</p>

<p>On pourrait croire qu’un vecteur — comme ceux d’<a href="https://hexdocs.pm/aja">Aja</a>, dont <a href="/ElixirPhoenixRessources/articles/aja-vecteurs-ordmap/">j’ai parlé ici</a> — s’en sort mieux parce qu’il partagerait davantage. Ce n’est pas ça du tout. Mesuré, il partage exactement de la même façon :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">vec</span> <span class="o">=</span> <span class="no">Aja</span><span class="o">.</span><span class="no">Vector</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="mi">1</span><span class="o">..</span><span class="mi">10_000</span><span class="p">)</span>
<span class="n">vec2</span> <span class="o">=</span> <span class="no">Aja</span><span class="o">.</span><span class="no">Vector</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">vec</span><span class="p">,</span> <span class="ss">:nouveau</span><span class="p">)</span>

<span class="ss">:erts_debug</span><span class="o">.</span><span class="n">flat_size</span><span class="p">({</span><span class="n">vec</span><span class="p">,</span> <span class="n">vec2</span><span class="p">})</span>  <span class="c1">#=&gt; 22_779 mots</span>
<span class="ss">:erts_debug</span><span class="o">.</span><span class="n">size</span><span class="p">({</span><span class="n">vec</span><span class="p">,</span> <span class="n">vec2</span><span class="p">})</span>       <span class="c1">#=&gt; 11_454 mots</span>
</code></pre></div></div>

<p>Même mécanisme, même économie de moitié. Ce qui sépare les deux structures n’est donc pas le partage : c’est leur <strong>forme</strong>. Une liste est une chaîne qu’il faut parcourir ; un vecteur est un arbre large et plat qu’on indexe. Et ça se voit :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>100 000 accès à l'index 9 999
  liste   : 1 619 ms
  vecteur :     6 ms
</code></pre></div></div>

<p>Un facteur <strong>256</strong>, sur des structures qui partagent aussi bien l’une que l’autre.</p>

<p>D’où la conclusion, qui n’a rien de contradictoire avec tout ce qui précède : l’immutabilité ne vous coûte presque rien, mais <strong>elle ne vous dispense pas de choisir la bonne structure de données</strong>. Le partage rend une modification bon marché ; il ne rend pas une chaîne indexable.</p>

<h2 id="-mais-la-beam-sait-optimiser-non--">« Mais la BEAM sait optimiser, non ? »</h2>

<p>On lit parfois que le parcours n’est pas toujours linéaire, et c’est vrai — à condition de voir d’où vient l’optimisation. Elle ne vient jamais d’une ruse sur les listes chaînées : elle vient de ce que <strong>la chose parcourue n’est pas une liste</strong>.</p>

<p>Comparons la même fonction <code class="language-plaintext highlighter-rouge">Enum</code> appliquée à une liste et à un intervalle, sur cent mille itérations :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Enum.at(liste, 9_999)                 1 619 ms
Enum.at(1..10_000, 9_999)                10 ms      160x

Enum.slice(liste, 9_990..9_999)       2 159 ms
Enum.slice(1..10_000, 9_990..9_999)       9 ms      235x

Enum.count(liste)                     1 050 ms
Enum.count(1..10_000)                     3 ms      269x
</code></pre></div></div>

<p>Deux ordres de grandeur d’écart, sur les trois paires. Pourtant c’est le même appel, et <code class="language-plaintext highlighter-rouge">1..10_000</code> a bien dix mille éléments.</p>

<p>L’explication tient au protocole <code class="language-plaintext highlighter-rouge">Enumerable</code>. Les deux structures implémentent la fonction <code class="language-plaintext highlighter-rouge">slice/1</code> — une implémentation de protocole doit définir tous ses callbacks — mais elles n’y répondent pas la même chose :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Enumerable</span><span class="o">.</span><span class="no">Range</span><span class="o">.</span><span class="n">slice</span><span class="p">(</span><span class="mi">1</span><span class="o">..</span><span class="mi">10_000</span><span class="p">)</span>  <span class="c1">#=&gt; {:ok, 10000, #Function&lt;...&gt;}</span>
<span class="no">Enumerable</span><span class="o">.</span><span class="no">List</span><span class="o">.</span><span class="n">slice</span><span class="p">([</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">3</span><span class="p">])</span>   <span class="c1">#=&gt; {:error, Enumerable.List}</span>
</code></pre></div></div>

<p>L’intervalle répond « oui, je sais me découper, voici comment » ; la liste répond « non, débrouille-toi en me parcourant ». C’est cette réponse, et non l’existence de la fonction, qui décide du coût.</p>

<p>Un intervalle n’est pas une collection en mémoire : c’est un début, une fin et un pas. <code class="language-plaintext highlighter-rouge">Enum.at(1..10_000, 9_999)</code> <strong>calcule</strong> le résultat, il ne va pas le chercher. La liste, elle, n’a pas ce luxe — il faut suivre la chaîne.</p>

<p>Deux autres points valent d’être posés clairement :</p>

<p><strong>Le JIT ne change pas la complexité.</strong> Depuis OTP 24, la BEAM compile à la volée vers du code machine, et tout va plus vite — mais un parcours linéaire reste linéaire. Le JIT améliore le facteur constant, pas l’exposant.</p>

<p><strong>Certaines opérations sur les listes sont bel et bien en temps constant</strong> — <code class="language-plaintext highlighter-rouge">hd/1</code> par exemple, mesuré à 0 ms sur cent mille appels. Mais ce sont celles qui touchent à la <strong>tête</strong>. Dès qu’on vise la fin, on repaie le parcours : <code class="language-plaintext highlighter-rouge">List.last/1</code> coûte 1 927 ms sur les mêmes cent mille itérations.</p>

<p>La règle qui résume tout : sur une liste chaînée, ce qui est près de la tête est gratuit, ce qui est loin se paie — et aucune optimisation de la machine virtuelle ne rend une chaîne indexable.</p>

<h2 id="quand-rien-ne-peut-être-partagé">Quand rien ne peut être partagé</h2>

<p><code class="language-plaintext highlighter-rouge">Enum.map/2</code> produit une liste dont <strong>chaque</strong> élément diffère. Il n’y a rien à partager, et la mesure le confirme :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">doubles</span> <span class="o">=</span> <span class="no">Enum</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">grande</span><span class="p">,</span> <span class="o">&amp;</span><span class="p">(</span><span class="nv">&amp;1</span> <span class="o">*</span> <span class="mi">2</span><span class="p">))</span>
<span class="ss">:erts_debug</span><span class="o">.</span><span class="n">size</span><span class="p">({</span><span class="n">grande</span><span class="p">,</span> <span class="n">doubles</span><span class="p">})</span>       <span class="c1">#=&gt; 40_003 mots</span>
<span class="ss">:erts_debug</span><span class="o">.</span><span class="n">flat_size</span><span class="p">({</span><span class="n">grande</span><span class="p">,</span> <span class="n">doubles</span><span class="p">})</span>  <span class="c1">#=&gt; 40_003 mots</span>
</code></pre></div></div>

<p>Les deux chiffres sont identiques : aucun partage. C’est normal et ce n’est pas un défaut — simplement, le partage récompense les transformations qui laissent la majorité des données intactes, pas celles qui touchent à tout.</p>

<p>Les maps, elles, sont des arbres : ajouter une clé à une map de dix mille entrées ne recopie que le chemin menant à la feuille. Encore faut-il le mesurer correctement — comparer deux <code class="language-plaintext highlighter-rouge">flat_size</code> séparés ne prouverait rien, puisqu’une copie intégrale donnerait exactement le même chiffre. Il faut mesurer les deux maps <strong>ensemble</strong> :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">m</span> <span class="o">=</span> <span class="no">Map</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="mi">1</span><span class="o">..</span><span class="mi">10_000</span><span class="p">,</span> <span class="o">&amp;</span><span class="p">{</span><span class="nv">&amp;1</span><span class="p">,</span> <span class="nv">&amp;1</span><span class="p">})</span>
<span class="n">m2</span> <span class="o">=</span> <span class="no">Map</span><span class="o">.</span><span class="n">put</span><span class="p">(</span><span class="n">m</span><span class="p">,</span> <span class="ss">:nouveau</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>

<span class="ss">:erts_debug</span><span class="o">.</span><span class="n">flat_size</span><span class="p">({</span><span class="n">m</span><span class="p">,</span> <span class="n">m2</span><span class="p">})</span>  <span class="c1">#=&gt; 75_638 mots</span>
<span class="ss">:erts_debug</span><span class="o">.</span><span class="n">size</span><span class="p">({</span><span class="n">m</span><span class="p">,</span> <span class="n">m2</span><span class="p">})</span>       <span class="c1">#=&gt; 37_875 mots</span>
</code></pre></div></div>

<p>En retranchant la map d’origine et le tuple de mesure, le <code class="language-plaintext highlighter-rouge">Map.put</code> coûte <strong>58 mots</strong> — soit 0,15 % de la map. Pas dix mille.</p>

<h2 id="là-où-limmutabilité-coûte-vraiment">Là où l’immutabilité coûte vraiment</h2>

<p>Il y a un endroit où la copie a bien lieu, et il vaut mieux le connaître : <strong>entre processus</strong>.</p>

<p>Chaque processus de la BEAM possède son propre tas. Un message envoyé d’un processus à un autre est donc <strong>copié</strong>, précisément parce qu’il ne peut rien partager avec l’extérieur.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>envoi d'une liste de 10 000 éléments à un autre processus : 76 µs
</code></pre></div></div>

<p>C’est peu, mais ce n’est plus zéro — et ça devient significatif si vous faites transiter de grosses structures entre processus dans une boucle chaude. C’est le vrai coût de l’isolation, et c’est ce qui rend possible le ramasse-miettes par processus, sans pause globale.</p>

<p>Une exception notable : les binaires de plus de 64 octets vivent <strong>hors</strong> du tas et sont comptés par référence. Ils ne sont donc pas recopiés à l’envoi.</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="ss">:erts_debug</span><span class="o">.</span><span class="n">flat_size</span><span class="p">(</span><span class="ss">:crypto</span><span class="o">.</span><span class="n">strong_rand_bytes</span><span class="p">(</span><span class="mi">60</span><span class="p">))</span>        <span class="c1">#=&gt; 10 mots</span>
<span class="ss">:erts_debug</span><span class="o">.</span><span class="n">flat_size</span><span class="p">(</span><span class="ss">:crypto</span><span class="o">.</span><span class="n">strong_rand_bytes</span><span class="p">(</span><span class="mi">1_000_000</span><span class="p">))</span> <span class="c1">#=&gt; 8 mots</span>
</code></pre></div></div>

<p>Un mégaoctet occupe <strong>moins</strong> de place sur le tas que soixante octets. Le petit binaire y est stocké en entier ; le grand n’y laisse qu’une référence. C’est pour ça qu’on peut passer de gros contenus entre processus sans y penser — et pourquoi découper une grosse binaire en petits morceaux peut, paradoxalement, consommer davantage.</p>

<h2 id="ce-quil-faut-en-retenir">Ce qu’il faut en retenir</h2>

<p><strong>Empilez en tête, inversez à la fin.</strong> C’est gratuit dans un sens, quadratique dans l’autre.</p>

<p><strong>Ne craignez pas de « copier » une structure pour la modifier.</strong> <code class="language-plaintext highlighter-rouge">Map.put</code>, <code class="language-plaintext highlighter-rouge">%{struct | champ: valeur}</code>, <code class="language-plaintext highlighter-rouge">List.replace_at</code> près de la tête : rien de tout cela ne duplique vos données.</p>

<p><strong>Méfiez-vous de la distance à la tête</strong>, pas de la modification elle-même. Si vous accédez par index sur une grande collection, le problème n’est pas l’immutabilité — c’est la liste chaînée.</p>

<p><strong>Surveillez ce qui traverse les frontières de processus.</strong> C’est le seul endroit où le runtime copie <strong>même quand rien n’a changé</strong> — ailleurs, comme avec <code class="language-plaintext highlighter-rouge">Enum.map/2</code>, on paie une copie parce qu’on a effectivement produit des données nouvelles. C’est aussi le cas d’ETS, qui copie à l’écriture comme à la lecture.</p>

<p>L’immutabilité en Elixir n’est pas un compromis qu’on accepte en échange de la sûreté. C’est ce qui permet à la machine virtuelle de ne presque jamais copier — et de vous laisser raisonner sur vos données en sachant que personne ne les changera sous vos pieds.</p>

<hr />

<p><strong>Refaites ces mesures vous-même.</strong> Toutes celles de cet article vivent dans un notebook exécutable : <a href="https://livebook.dev/run/?url=https://raw.githubusercontent.com/nseaSeb/ElixirPhoenixRessources/main/Tips/partage_structurel.livemd">ouvrez-le dans votre Livebook</a> et relancez-les. C’est la seule réponse honnête au fait que ces nombres dépendent de la machine.</p>

<p><em>Les mesures publiées ici ont été prises sur Elixir 1.19.5 avec Erlang/OTP 28, sur une machine 64 bits où un mot fait 8 octets. Les nombres varient d’une machine à l’autre ; les ordres de grandeur, non.</em></p>]]></content><author><name>nseaSeb</name></author><category term="elixir" /><category term="elixir" /><category term="immutabilite" /><category term="performance" /><category term="beam" /><summary type="html"><![CDATA[Si toute modification crée une nouvelle valeur, comment un langage immuable peut-il être rapide ? Réponse mesurée : parce que rien n'est copié. Le partage structurel, ses limites, et là où l'immutabilité coûte vraiment.]]></summary></entry><entry xml:lang="fr"><title type="html">Structurer un projet Elixir : ce que le compilateur ne vérifie pas</title><link href="https://nseaseb.github.io/ElixirPhoenixRessources/articles/structurer-un-projet-elixir/" rel="alternate" type="text/html" title="Structurer un projet Elixir : ce que le compilateur ne vérifie pas" /><published>2026-09-07T06:56:00+02:00</published><updated>2026-09-07T06:56:00+02:00</updated><id>https://nseaseb.github.io/ElixirPhoenixRessources/articles/structurer-un-projet-elixir</id><content type="html" xml:base="https://nseaseb.github.io/ElixirPhoenixRessources/articles/structurer-un-projet-elixir/"><![CDATA[<p>Créez un projet, mettez le module <code class="language-plaintext highlighter-rouge">MonApp.Comptes</code> dans <code class="language-plaintext highlighter-rouge">lib/nimportequoi.ex</code>, compilez. Ça marche. Renommez le fichier en <code class="language-plaintext highlighter-rouge">zzz.ex</code>, recompilez. Ça marche encore.</p>

<p>C’est déroutant quand on vient de Ruby ou de Python, où le chemin d’un fichier détermine ce qu’on peut importer. En Elixir, <strong>le compilateur ne fait aucun lien entre un nom de module et un nom de fichier.</strong> Il compile tout ce qu’il trouve dans <code class="language-plaintext highlighter-rouge">lib/</code> et enregistre les modules par leur nom.</p>

<p>Alors pourquoi tout le monde suit-il la convention ? Et y a-t-il un endroit où Elixir vérifie vraiment quelque chose ? Oui — un seul, et il ne se contente pas d’avertir : il refuse de démarrer.</p>

<!--more-->

<h2 id="ce-que-mix-new-crée-réellement">Ce que <code class="language-plaintext highlighter-rouge">mix new</code> crée réellement</h2>

<p>Sept fichiers — huit avec <code class="language-plaintext highlighter-rouge">--sup</code>, qui ajoute l’arbre de supervision :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>mix.exs                       le projet, ses dépendances, son application
lib/mon_app.ex                le module racine
lib/mon_app/application.ex    l'arbre de supervision (avec --sup)
test/mon_app_test.exs
test/test_helper.exs
.formatter.exs
.gitignore
README.md
</code></pre></div></div>

<p>Pas de <code class="language-plaintext highlighter-rouge">config/</code>. Pas de <code class="language-plaintext highlighter-rouge">priv/</code>. Ils apparaissent quand on en a besoin — et c’est déjà une indication : ce ne sont pas des dossiers obligatoires, ce sont des conventions que l’outillage sait exploiter.</p>

<h2 id="le-nom-du-fichier-ne-sert-pas-au-compilateur">Le nom du fichier ne sert pas au compilateur</h2>

<p>Il sert à tout le reste.</p>

<p><strong>À vous, dans six mois.</strong> La règle « <code class="language-plaintext highlighter-rouge">MonApp.Comptes.Utilisateur</code> vit dans <code class="language-plaintext highlighter-rouge">lib/mon_app/comptes/utilisateur.ex</code> » transforme un nom de module aperçu dans une stacktrace en un chemin de fichier, sans réfléchir ni chercher.</p>

<p><strong>À votre éditeur — en partie seulement.</strong> Soyons précis, parce que c’est là qu’on raconte facilement n’importe quoi : « aller à la définition » ne dépend pas de la convention. Elixir enregistre le vrai chemin du fichier source dans le module compilé, et les serveurs de langage le lisent :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Le module vit dans lib/zzz.ex, contre toute convention.</span>
<span class="no">MonApp</span><span class="o">.</span><span class="no">Comptes</span><span class="o">.</span><span class="n">__info__</span><span class="p">(</span><span class="ss">:compile</span><span class="p">)[</span><span class="ss">:source</span><span class="p">]</span>
<span class="c1">#=&gt; ~c"/chemin/du/projet/lib/zzz.ex"</span>
</code></pre></div></div>

<p>Ce qui dépend bel et bien de la convention, c’est tout ce qui part du <strong>nom</strong> : ouvrir un fichier en tapant son nom de module dans le sélecteur, basculer entre un module et son test, deviner un chemin depuis une stacktrace sans rien ouvrir.</p>

<p><strong>Aux tests.</strong> <code class="language-plaintext highlighter-rouge">mix test</code> cherche <code class="language-plaintext highlighter-rouge">test/**/*_test.exs</code>. Là, c’est une vraie contrainte : un fichier de test mal nommé n’est simplement jamais exécuté, sans le moindre avertissement. C’est le premier endroit où une convention ignorée devient un bug silencieux.</p>

<p><strong>Aux relectures.</strong> Un fichier de mille lignes contenant quatre modules passe la compilation. Il ne passe pas une revue.</p>

<p>La convention n’est donc pas arbitraire : elle est ce qui permet à tout le monde — humains et outils — de deviner où sont les choses.</p>

<h2 id="une-porte-dentrée-par-domaine">Une porte d’entrée par domaine</h2>

<p>La seconde convention, moins visible, est celle qui rend un projet lisible à mesure qu’il grossit.</p>

<p>Un domaine fonctionnel expose <strong>un module public</strong>, et cache le reste :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>lib/mon_app/comptes.ex              l'API publique : inscrire, authentifier…
lib/mon_app/comptes/utilisateur.ex  un détail d'implémentation
lib/mon_app/comptes/jeton.ex        un autre
</code></pre></div></div>

<p>Rien n’empêche techniquement d’appeler <code class="language-plaintext highlighter-rouge">MonApp.Comptes.Jeton.creer/1</code> depuis un contrôleur. C’est bien le problème : rien ne l’empêche, donc quelqu’un le fera, et la frontière disparaîtra sans qu’aucun outil ne s’en aperçoive.</p>

<p>Le marqueur conventionnel est <code class="language-plaintext highlighter-rouge">@moduledoc false</code> sur les modules internes. Il ne verrouille rien — il ne fait que les retirer de la documentation générée. C’est une convention entre humains, comme le souligne cette phrase qu’on lit souvent : en Elixir, la frontière publique est un accord, pas une barrière.</p>

<p>Si vous voulez une vraie barrière, il existe des outils d’analyse qui la font respecter. Mais l’accord suffit dans la plupart des projets, à condition d’être explicite.</p>

<h2 id="priv-le-dossier-qui-suit-le-code"><code class="language-plaintext highlighter-rouge">priv/</code>, le dossier qui suit le code</h2>

<p><code class="language-plaintext highlighter-rouge">priv/</code> contient ce qui doit voyager avec l’application sans être du code : migrations, fichiers de traduction, certificats, gabarits, ressources statiques.</p>

<p>Sa particularité est d’exister aussi dans la release, à un chemin différent. On ne le devine donc pas :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Faux : ce chemin n'existe plus une fois la release assemblée.</span>
<span class="no">File</span><span class="o">.</span><span class="n">read!</span><span class="p">(</span><span class="s2">"priv/donnees.csv"</span><span class="p">)</span>

<span class="c1"># Juste : Elixir résout le chemin réel, en développement comme en production.</span>
<span class="ss">:mon_app</span>
<span class="o">|&gt;</span> <span class="no">Application</span><span class="o">.</span><span class="n">app_dir</span><span class="p">(</span><span class="s2">"priv/donnees.csv"</span><span class="p">)</span>
<span class="o">|&gt;</span> <span class="no">File</span><span class="o">.</span><span class="n">read!</span><span class="p">()</span>
</code></pre></div></div>

<p>C’est une erreur classique, et elle a le mauvais goût de ne se manifester qu’au déploiement.</p>

<h2 id="la-configuration-seul-endroit-où-elixir-vous-rattrape">La configuration, seul endroit où Elixir vous rattrape</h2>

<p>Voilà la partie qui justifie cet article, et la seule où une convention ignorée provoque une erreur explicite.</p>

<p>Un projet Elixir a deux familles de fichiers de configuration, qui ne sont <strong>pas évaluées au même moment</strong> :</p>

<p><strong><code class="language-plaintext highlighter-rouge">config/config.exs</code></strong> — et les <code class="language-plaintext highlighter-rouge">dev.exs</code>, <code class="language-plaintext highlighter-rouge">test.exs</code>, <code class="language-plaintext highlighter-rouge">prod.exs</code> qu’il importe — est lu <strong>à la compilation</strong>, sur la machine qui construit. Ses valeurs sont figées dans l’artefact produit.</p>

<p><strong><code class="language-plaintext highlighter-rouge">config/runtime.exs</code></strong> est lu <strong>à chaque démarrage</strong>, sur la machine qui exécute.</p>

<p>La documentation officielle est explicite sur le piège :</p>

<blockquote>
  <p>The <code class="language-plaintext highlighter-rouge">:secret_key</code> key under <code class="language-plaintext highlighter-rouge">:my_app</code> will be computed on the host machine, whenever the release is built.</p>
</blockquote>

<p>…à propos de cette configuration :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="no">Config</span>
<span class="n">config</span> <span class="ss">:my_app</span><span class="p">,</span> <span class="ss">:secret_key</span><span class="p">,</span> <span class="no">System</span><span class="o">.</span><span class="n">fetch_env!</span><span class="p">(</span><span class="s2">"MY_APP_SECRET_KEY"</span><span class="p">)</span>
</code></pre></div></div>

<p>Autrement dit : votre secret de production est celui qui existait sur la machine de compilation. Au mieux la construction échoue parce que la variable n’y est pas ; au pire elle réussit avec la mauvaise valeur, et vous déployez.</p>

<p><strong>Toute valeur qui dépend de l’environnement va dans <code class="language-plaintext highlighter-rouge">runtime.exs</code>.</strong> Ce fichier obéit à trois règles que la documentation formule en majuscules : il <em>doit</em> commencer par <code class="language-plaintext highlighter-rouge">import Config</code>, il ne <em>doit pas</em> utiliser <code class="language-plaintext highlighter-rouge">import_config</code>, et il ne <em>doit pas</em> toucher à <code class="language-plaintext highlighter-rouge">Mix</code> — lequel n’existe pas dans une release.</p>

<h3 id="le-contrôle-qui-refuse-de-démarrer">Le contrôle qui refuse de démarrer</h3>

<p>Certaines valeurs doivent légitimement être lues à la compilation, quand elles déterminent le code produit. Elixir fournit pour ça <code class="language-plaintext highlighter-rouge">Application.compile_env/2</code> — une <strong>macro</strong>, et non une fonction, ce qui n’est pas un détail : c’est ce qui lui permet d’enregistrer la valeur vue à la compilation.</p>

<p>Ce que vous y gagnez, c’est un garde-fou. J’ai monté le cas — projet compilé avec une valeur, release assemblée, puis <code class="language-plaintext highlighter-rouge">runtime.exs</code> en imposant une autre — et voici ce qui se passe au démarrage (message abrégé, il propose ensuite trois façons de corriger) :</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ERROR! the application :demo_cfg has a different value set for key :mode
during runtime compared to compile time. Since this application environment
entry was marked as compile time, this difference can lead to different
behavior than expected:

  * Compile time value was set to: :dev
  * Runtime value was set to: :prod

[…]

Runtime terminating during boot ({&lt;&lt;"aborting boot"&gt;&gt;,...})
</code></pre></div></div>

<p>La release <strong>ne démarre pas</strong>. C’est exactement ce qu’on veut : mieux vaut un déploiement qui échoue franchement qu’une application qui tourne avec la configuration de développement.</p>

<p>Notez au passage un comportement rassurant en développement : si vous modifiez <code class="language-plaintext highlighter-rouge">config.exs</code>, <code class="language-plaintext highlighter-rouge">mix</code> recompile les modules concernés tout seul. Le problème ne se pose donc qu’entre une compilation et une exécution séparées — c’est-à-dire dans une release, précisément là où on ne le verrait pas.</p>

<h2 id="en-résumé">En résumé</h2>

<p>Presque toutes les conventions d’un projet Elixir sont des accords entre humains : le compilateur ne relie pas les fichiers aux modules, ne protège pas les frontières de vos domaines, ne vous empêche pas de tout mettre dans un seul fichier. On les suit parce que l’outillage et les relecteurs s’appuient dessus, et parce qu’un projet qui ressemble aux autres se reprend sans effort.</p>

<p>Deux endroits font exception, et ce sont ceux à connaître par cœur : <code class="language-plaintext highlighter-rouge">mix test</code> ignore silencieusement un fichier mal nommé, et une valeur marquée comme lue à la compilation fait échouer le démarrage si elle change. La seconde est brutale — et c’est une bonne nouvelle.</p>

<p><em>Cet article doit son sujet au billet de João Paulo Abreu cité ci-dessous, qui couvre le même terrain en anglais, avec une progression pédagogique différente. Le texte, les exemples et l’angle sont les miens.</em></p>]]></content><author><name>nseaSeb</name></author><category term="elixir" /><category term="mix" /><category term="configuration" /><category term="conventions" /><category term="releases" /><summary type="html"><![CDATA[Presque toutes les conventions d'un projet Elixir sont facultatives — rien ne casse si vous les ignorez. Sauf à un endroit, où Elixir refuse de démarrer. Voici lequel, et pourquoi les autres méritent quand même d'être suivies.]]></summary></entry><entry xml:lang="fr"><title type="html">Brancher un notebook sur une application qui tourne</title><link href="https://nseaseb.github.io/ElixirPhoenixRessources/articles/livebook-application-qui-tourne/" rel="alternate" type="text/html" title="Brancher un notebook sur une application qui tourne" /><published>2026-09-07T06:26:00+02:00</published><updated>2026-09-07T06:26:00+02:00</updated><id>https://nseaseb.github.io/ElixirPhoenixRessources/articles/livebook-application-qui-tourne</id><content type="html" xml:base="https://nseaseb.github.io/ElixirPhoenixRessources/articles/livebook-application-qui-tourne/"><![CDATA[<p>Quelque chose ne va pas en production. Les journaux montrent le symptôme sans la cause. Vous ouvrez un <code class="language-plaintext highlighter-rouge">iex</code> distant, vous tapez une ligne, puis une autre, vous remontez dans l’historique, vous perdez le fil. Une heure plus tard vous avez compris — et il ne reste rien de ce que vous avez fait.</p>

<p><a href="/ElixirPhoenixRessources/articles/outil-interne-livebook/">L’article précédent</a> montrait comment transformer un script jetable en outil. Celui-ci s’attaque à la suite : brancher cet outil non plus sur une API publique, mais sur <strong>votre application, en train de tourner</strong>.</p>

<!--more-->

<h2 id="pourquoi-cest-possible">Pourquoi c’est possible</h2>

<p>Rien de magique : la BEAM est distribuée depuis toujours. Un nœud Erlang peut en joindre un autre et exécuter du code dedans, à condition de connaître son nom et de partager son secret — le <em>cookie</em>.</p>

<p>C’est le mécanisme derrière <code class="language-plaintext highlighter-rouge">iex --remsh</code>, derrière les commandes d’une release, derrière Observer. Livebook s’en sert, avec une interface au-dessus.</p>

<p>Deux mécanismes distincts, qu’on confond souvent alors qu’ils n’ont ni la même puissance ni les mêmes risques.</p>

<h2 id="préparer-lapplication">Préparer l’application</h2>

<p>Dans les deux cas, l’application doit avoir un nom de nœud et un cookie. En développement, c’est la ligne que donne la documentation de Livebook :</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>iex <span class="nt">--name</span> phoenix-app@127.0.0.1 <span class="nt">--cookie</span> secret <span class="nt">-S</span> mix phx.server
</code></pre></div></div>

<p>Pour une release, ces valeurs viennent des variables <code class="language-plaintext highlighter-rouge">RELEASE_NODE</code> et <code class="language-plaintext highlighter-rouge">RELEASE_COOKIE</code>. Sur un hébergeur, la marche à suivre dépend de la plateforme — <a href="https://fly.io/docs/elixir/advanced-guides/connect-livebook-to-your-app/">Fly.io documente la sienne</a>.</p>

<h2 id="mécanisme-1--la-cellule--remote-execution-">Mécanisme 1 — la cellule « Remote execution »</h2>

<p>Le plus prudent, et curieusement le moins connu.</p>

<p>Vous ajoutez une cellule intelligente <strong>« Remote execution »</strong> via le bouton « + Smart cell ». Elle vous demande le nom du nœud, le cookie, et le code à exécuter là-bas.</p>

<p>Ce qui compte : <strong>votre runtime reste le vôtre.</strong> Le notebook tourne dans son propre nœud, avec ses propres dépendances installées par <code class="language-plaintext highlighter-rouge">Mix.install</code>. Seul le code de cette cellule part sur le nœud distant, et son résultat revient.</p>

<p>Vous pouvez donc récupérer des données de production, puis les analyser localement avec des librairies que l’application n’a pas — les tracer avec VegaLite, les charger dans un DataFrame Explorer — sans rien installer sur le système en fonctionnement.</p>

<h2 id="mécanisme-2--le-runtime--attached-node-">Mécanisme 2 — le runtime « Attached node »</h2>

<p>L’autre approche déplace le curseur entièrement.</p>

<p>Dans la barre latérale, l’icône « Runtime » — ou le raccourci <code class="language-plaintext highlighter-rouge">s</code> puis <code class="language-plaintext highlighter-rouge">r</code> — permet de choisir « Attached node » et d’y saisir le nom et le cookie. À partir de là, <strong>le notebook entier s’exécute dans le nœud de l’application</strong>. Vos cellules appellent directement les fonctions de votre code, comme si vous étiez dedans. Parce que vous y êtes.</p>

<p>La documentation officielle assortit ce mode d’un avertissement qui mérite d’être cité tel quel :</p>

<blockquote>
  <p>Once connected, be careful: any code that you execute in the notebook now runs within the connected application.</p>
</blockquote>

<p>Et d’une limite technique nette :</p>

<blockquote>
  <p>your notebook cannot invoke <code class="language-plaintext highlighter-rouge">Mix.install</code>, it only has access to what’s already loaded in the external node</p>
</blockquote>

<p>Ce n’est pas une lacune, c’est une conséquence : on ne recompile pas des dépendances dans un système en production. La documentation le dit d’ailleurs sans détour — <em>« nor would that be a good idea on a running system »</em>.</p>

<p><strong>Comment choisir.</strong> Le mode attaché quand vous avez besoin d’appeler le code de l’application directement, en développement ou sur un environnement de recette. La cellule « Remote execution » quand vous touchez à la production, parce qu’elle vous force à décider explicitement ce qui part là-bas.</p>

<h2 id="ce-quon-va-vraiment-y-chercher">Ce qu’on va vraiment y chercher</h2>

<p>L’intérêt n’est pas de remplacer <code class="language-plaintext highlighter-rouge">iex</code>, c’est de garder une trace.</p>

<p><strong>L’état d’un processus.</strong> <code class="language-plaintext highlighter-rouge">:sys.get_state(MonApp.Cache)</code> vous rend l’état interne d’un <code class="language-plaintext highlighter-rouge">GenServer</code>. Dans un notebook, la commande reste écrite, commentée, réexécutable — et le collègue à qui vous envoyez le fichier voit ce que vous avez regardé.</p>

<p><strong>Une requête sur la vraie base.</strong> En mode attaché, <code class="language-plaintext highlighter-rouge">Repo</code> est déjà chargé : <code class="language-plaintext highlighter-rouge">Repo.aggregate(Commande, :count)</code> fonctionne. Combiné à un <code class="language-plaintext highlighter-rouge">Kino.DataTable</code>, on obtient une exploration lisible plutôt qu’un <code class="language-plaintext highlighter-rouge">IO.inspect</code> déroulant.</p>

<p><strong>La forme du système.</strong> <code class="language-plaintext highlighter-rouge">Supervisor.count_children/1</code>, <code class="language-plaintext highlighter-rouge">Registry.count/1</code>, <code class="language-plaintext highlighter-rouge">Process.list() |&gt; length()</code>, <code class="language-plaintext highlighter-rouge">:erlang.memory()</code>. Ces chiffres, pris à intervalle régulier dans un <code class="language-plaintext highlighter-rouge">Kino.Frame</code>, donnent un tableau de bord improvisé en quelques lignes — sans déployer quoi que ce soit.</p>

<p><strong>Un correctif de données ponctuel.</strong> Le cas où l’on migre trois lignes à la main. Écrit dans un notebook, il est relu avant d’être exécuté, et il reste ensuite comme trace de ce qui a été fait.</p>

<h2 id="les-précautions-qui-ne-sont-pas-facultatives">Les précautions, qui ne sont pas facultatives</h2>

<p>Ce mécanisme donne un accès complet à un système en fonctionnement. Il mérite le même sérieux qu’un accès SSH root.</p>

<p><strong>Le cookie est un secret.</strong> Qui connaît le nom du nœud et le cookie exécute ce qu’il veut sur votre machine. Un cookie par environnement, jamais dans un dépôt, jamais dans une cellule de notebook — Livebook a des secrets prévus pour ça.</p>

<p><strong>La distribution Erlang n’est pas chiffrée par défaut.</strong> En clair sur le réseau, cookie compris. Ne l’exposez jamais sur l’internet public : passez par un tunnel SSH, un réseau privé, ou <a href="https://www.erlang.org/doc/apps/ssl/ssl_distribution.html">activez TLS sur la distribution</a>.</p>

<p><strong>Tout ce que vous exécutez tourne pour de bon.</strong> Une boucle infinie occupe un ordonnanceur de production. Un <code class="language-plaintext highlighter-rouge">Enum.map</code> sur une table entière la charge en mémoire. Un <code class="language-plaintext highlighter-rouge">System.halt()</code> arrête l’application. Il n’y a ni bac à sable ni confirmation.</p>

<p><strong>Lire avant d’écrire.</strong> Les commandes qui observent — <code class="language-plaintext highlighter-rouge">:sys.get_state</code>, un <code class="language-plaintext highlighter-rouge">Repo.all</code> borné, <code class="language-plaintext highlighter-rouge">:erlang.memory</code> — sont sans danger. Celles qui modifient méritent d’être relues à froid, et de préférence par quelqu’un d’autre.</p>

<p><strong>Le notebook garde ce que vous avez tapé.</strong> C’est sa qualité, et c’est aussi un risque : un fichier contenant des données de production ne se laisse pas traîner. Nettoyez les sorties avant de le partager.</p>

<h2 id="ce-que-ça-change">Ce que ça change</h2>

<p>Le vrai gain n’est pas technique — <code class="language-plaintext highlighter-rouge">iex</code> distant faisait déjà l’essentiel. Il est dans la <strong>trace</strong>.</p>

<p>Une session de débogage devient un document : les commandes, leurs résultats, et vos commentaires entre les deux. Il se relit le lendemain, se transmet à un collègue, se range à côté du code. La prochaine fois que le même symptôme apparaît, vous rouvrez le fichier au lieu de tout refaire.</p>

<p>C’est le même déplacement que dans l’article précédent — du script jetable vers quelque chose qui se garde — appliqué cette fois à ce qui tourne pour de vrai.</p>

<hr />

<p><em>Cet article n’est pas livré en notebook exécutable, contrairement au précédent : il n’aurait de sens que branché à votre propre application. Les commandes ci-dessus se recopient dans un notebook vierge une fois la connexion établie.</em></p>]]></content><author><name>nseaSeb</name></author><category term="livebook" /><category term="livebook" /><category term="phoenix" /><category term="production" /><category term="debogage" /><summary type="html"><![CDATA[Les journaux ne disent pas tout, et un iex distant s'oublie à mesure qu'on tape. Livebook sait se connecter à un nœud Elixir vivant — voici les deux mécanismes, ce qu'ils permettent, et les précautions qu'ils exigent.]]></summary></entry><entry xml:lang="fr"><title type="html">Un outil interne en trente lignes, dans un notebook</title><link href="https://nseaseb.github.io/ElixirPhoenixRessources/articles/outil-interne-livebook/" rel="alternate" type="text/html" title="Un outil interne en trente lignes, dans un notebook" /><published>2026-09-06T14:46:00+02:00</published><updated>2026-09-06T14:46:00+02:00</updated><id>https://nseaseb.github.io/ElixirPhoenixRessources/articles/outil-interne-livebook</id><content type="html" xml:base="https://nseaseb.github.io/ElixirPhoenixRessources/articles/outil-interne-livebook/"><![CDATA[<p>Vous ouvrez <code class="language-plaintext highlighter-rouge">iex</code>, vous tapez dix lignes pour interroger une API ou compter des lignes en base, vous lisez la réponse dans un <code class="language-plaintext highlighter-rouge">IO.inspect</code>, vous fermez le terminal. Le lendemain, il faut tout retaper. Et le collègue à qui vous voudriez passer ce petit outil n’a rien à quoi se raccrocher.</p>

<p>Un notebook Livebook change exactement ça : le même script devient quelque chose qui se garde, s’envoie, et s’utilise <strong>sans lire une ligne de code</strong>.</p>

<!--more-->

<h2 id="ce-que-mixinstall-change-vraiment">Ce que <code class="language-plaintext highlighter-rouge">Mix.install</code> change vraiment</h2>

<p>Un notebook commence par ses dépendances :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Mix</span><span class="o">.</span><span class="n">install</span><span class="p">([</span>
  <span class="p">{</span><span class="ss">:kino</span><span class="p">,</span> <span class="s2">"~&gt; 0.19"</span><span class="p">},</span>
  <span class="p">{</span><span class="ss">:req</span><span class="p">,</span> <span class="s2">"~&gt; 0.5"</span><span class="p">}</span>
<span class="p">])</span>
</code></pre></div></div>

<p>Ce n’est pas un détail de confort. C’est ce qui rend le fichier <strong>autonome</strong> : pas de <code class="language-plaintext highlighter-rouge">mix.exs</code>, pas de projet, pas de « il faut d’abord cloner le dépôt ». Vous envoyez un fichier, la personne l’ouvre, ça tourne.</p>

<p>Une nuance sur laquelle il vaut mieux ne pas se tromper : <code class="language-plaintext highlighter-rouge">~&gt; 0.5</code> <strong>ne fige pas</strong> la version 0.5. La contrainte signifie « au moins 0.5.0 et moins de 1.0.0 », donc une installation faite aujourd’hui ramènera la dernière 0.x publiée — 0.7.4 pour <code class="language-plaintext highlighter-rouge">req</code> au moment où j’écris. Si la reproductibilité compte vraiment, écrivez la version exacte : <code class="language-plaintext highlighter-rouge">{:req, "== 0.7.4"}</code>. Le notebook est autonome, pas figé dans le temps.</p>

<p>Un notebook <code class="language-plaintext highlighter-rouge">.livemd</code> est par ailleurs du Markdown ordinaire. Il se lit sur GitHub, se versionne, se relit dans une revue de code. C’est une différence de fond avec les notebooks au format JSON, dont le diff est illisible.</p>

<h2 id="étape-1--rendre-la-sortie-lisible">Étape 1 — Rendre la sortie lisible</h2>

<p>Interrogeons l’API publique de Hex, qui a le bon goût de ne demander aucune authentification.</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">paquets</span> <span class="o">=</span> <span class="no">Req</span><span class="o">.</span><span class="n">get!</span><span class="p">(</span><span class="s2">"https://hex.pm/api/packages"</span><span class="p">,</span> <span class="ss">params:</span> <span class="p">[</span><span class="ss">search:</span> <span class="s2">"liveview"</span><span class="p">])</span><span class="o">.</span><span class="n">body</span>
<span class="n">length</span><span class="p">(</span><span class="n">paquets</span><span class="p">)</span>
<span class="c1">#=&gt; 100</span>
</code></pre></div></div>

<p>Cent paquets. Un <code class="language-plaintext highlighter-rouge">IO.inspect</code> produirait un mur illisible. <code class="language-plaintext highlighter-rouge">Kino.DataTable</code> en fait un tableau trié et triable :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">paquets</span>
<span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="k">fn</span> <span class="n">p</span> <span class="o">-&gt;</span>
  <span class="p">%{</span>
    <span class="ss">nom:</span> <span class="n">p</span><span class="p">[</span><span class="s2">"name"</span><span class="p">],</span>
    <span class="ss">version:</span> <span class="n">p</span><span class="p">[</span><span class="s2">"latest_version"</span><span class="p">],</span>
    <span class="ss">telechargements:</span> <span class="n">get_in</span><span class="p">(</span><span class="n">p</span><span class="p">,</span> <span class="p">[</span><span class="s2">"downloads"</span><span class="p">,</span> <span class="s2">"all"</span><span class="p">])</span> <span class="o">||</span> <span class="mi">0</span><span class="p">,</span>
    <span class="ss">description:</span> <span class="n">p</span><span class="p">[</span><span class="s2">"meta"</span><span class="p">][</span><span class="s2">"description"</span><span class="p">]</span>
  <span class="p">}</span>
<span class="k">end</span><span class="p">)</span>
<span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">sort_by</span><span class="p">(</span><span class="o">&amp;</span> <span class="nv">&amp;1</span><span class="o">.</span><span class="n">telechargements</span><span class="p">,</span> <span class="ss">:desc</span><span class="p">)</span>
<span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">take</span><span class="p">(</span><span class="mi">20</span><span class="p">)</span>
<span class="o">|&gt;</span> <span class="no">Kino</span><span class="o">.</span><span class="no">DataTable</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">name:</span> <span class="s2">"Paquets Hex"</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Kino.DataTable.new/2</code> accepte n’importe quelle donnée tabulaire — une liste de maps convient. L’option <code class="language-plaintext highlighter-rouge">:keys</code> fixe les colonnes et leur ordre si l’ordre par défaut ne vous va pas.</p>

<p>Déjà à ce stade, ce n’est plus le même objet : quelqu’un peut trier par téléchargements sans savoir ce qu’est une map.</p>

<h2 id="étape-2--rendre-loutil-interactif">Étape 2 — Rendre l’outil interactif</h2>

<p>Un tableau figé sur <code class="language-plaintext highlighter-rouge">"liveview"</code> reste un script. Ce qui en fait un outil, c’est le formulaire.</p>

<p>D’abord, isolons la recherche dans un module — le notebook exécutera cette cellule une fois pour toutes :</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">defmodule</span> <span class="no">OutilHex</span> <span class="k">do</span>
  <span class="k">def</span> <span class="n">chercher</span><span class="p">(</span><span class="n">terme</span><span class="p">)</span> <span class="k">do</span>
    <span class="s2">"https://hex.pm/api/packages"</span>
    <span class="o">|&gt;</span> <span class="no">Req</span><span class="o">.</span><span class="n">get!</span><span class="p">(</span><span class="ss">params:</span> <span class="p">[</span><span class="ss">search:</span> <span class="n">terme</span><span class="p">])</span>
    <span class="o">|&gt;</span> <span class="no">Map</span><span class="o">.</span><span class="n">fetch!</span><span class="p">(</span><span class="ss">:body</span><span class="p">)</span>
    <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="k">fn</span> <span class="n">p</span> <span class="o">-&gt;</span>
      <span class="p">%{</span>
        <span class="ss">nom:</span> <span class="n">p</span><span class="p">[</span><span class="s2">"name"</span><span class="p">],</span>
        <span class="ss">version:</span> <span class="n">p</span><span class="p">[</span><span class="s2">"latest_version"</span><span class="p">],</span>
        <span class="ss">telechargements:</span> <span class="n">get_in</span><span class="p">(</span><span class="n">p</span><span class="p">,</span> <span class="p">[</span><span class="s2">"downloads"</span><span class="p">,</span> <span class="s2">"all"</span><span class="p">])</span> <span class="o">||</span> <span class="mi">0</span><span class="p">,</span>
        <span class="ss">description:</span> <span class="n">p</span><span class="p">[</span><span class="s2">"meta"</span><span class="p">][</span><span class="s2">"description"</span><span class="p">]</span>
      <span class="p">}</span>
    <span class="k">end</span><span class="p">)</span>
    <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">sort_by</span><span class="p">(</span><span class="o">&amp;</span> <span class="nv">&amp;1</span><span class="o">.</span><span class="n">telechargements</span><span class="p">,</span> <span class="ss">:desc</span><span class="p">)</span>
    <span class="o">|&gt;</span> <span class="no">Enum</span><span class="o">.</span><span class="n">take</span><span class="p">(</span><span class="mi">20</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Trois pièces suffisent ensuite. <code class="language-plaintext highlighter-rouge">Kino.Control.form/2</code> construit le formulaire ; <code class="language-plaintext highlighter-rouge">Kino.Frame</code> réserve une zone d’affichage ; <code class="language-plaintext highlighter-rouge">Kino.listen/2</code> réagit aux soumissions.</p>

<div class="language-elixir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">formulaire</span> <span class="o">=</span> <span class="no">Kino</span><span class="o">.</span><span class="no">Control</span><span class="o">.</span><span class="n">form</span><span class="p">([</span><span class="ss">terme:</span> <span class="no">Kino</span><span class="o">.</span><span class="no">Input</span><span class="o">.</span><span class="n">text</span><span class="p">(</span><span class="s2">"Rechercher"</span><span class="p">)],</span> <span class="ss">submit:</span> <span class="s2">"Chercher"</span><span class="p">)</span>
<span class="n">zone</span> <span class="o">=</span> <span class="no">Kino</span><span class="o">.</span><span class="no">Frame</span><span class="o">.</span><span class="n">new</span><span class="p">()</span>

<span class="no">Kino</span><span class="o">.</span><span class="n">listen</span><span class="p">(</span><span class="n">formulaire</span><span class="p">,</span> <span class="k">fn</span> <span class="p">%{</span><span class="ss">data:</span> <span class="p">%{</span><span class="ss">terme:</span> <span class="n">terme</span><span class="p">}}</span> <span class="o">-&gt;</span>
  <span class="k">case</span> <span class="no">OutilHex</span><span class="o">.</span><span class="n">chercher</span><span class="p">(</span><span class="n">terme</span><span class="p">)</span> <span class="k">do</span>
    <span class="p">[]</span> <span class="o">-&gt;</span> <span class="no">Kino</span><span class="o">.</span><span class="no">Frame</span><span class="o">.</span><span class="n">render</span><span class="p">(</span><span class="n">zone</span><span class="p">,</span> <span class="no">Kino</span><span class="o">.</span><span class="no">Markdown</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="s2">"Aucun paquet pour **</span><span class="si">#{</span><span class="n">terme</span><span class="si">}</span><span class="s2">**."</span><span class="p">))</span>
    <span class="n">lignes</span> <span class="o">-&gt;</span> <span class="no">Kino</span><span class="o">.</span><span class="no">Frame</span><span class="o">.</span><span class="n">render</span><span class="p">(</span><span class="n">zone</span><span class="p">,</span> <span class="no">Kino</span><span class="o">.</span><span class="no">DataTable</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">lignes</span><span class="p">,</span> <span class="ss">name:</span> <span class="s2">"Résultats"</span><span class="p">))</span>
  <span class="k">end</span>
<span class="k">end</span><span class="p">)</span>

<span class="no">Kino</span><span class="o">.</span><span class="no">Layout</span><span class="o">.</span><span class="n">grid</span><span class="p">([</span><span class="n">formulaire</span><span class="p">,</span> <span class="n">zone</span><span class="p">],</span> <span class="ss">boxed:</span> <span class="no">true</span><span class="p">)</span>
</code></pre></div></div>

<p>Deux choses méritent d’être soulignées.</p>

<p><strong>Une option d’émission est obligatoire.</strong> La documentation est explicite : « Either <code class="language-plaintext highlighter-rouge">:submit</code> or <code class="language-plaintext highlighter-rouge">:report_changes</code> must be specified ». Avec <code class="language-plaintext highlighter-rouge">:submit</code>, le formulaire n’émet qu’à la validation, et l’option donne son libellé au bouton. Avec <code class="language-plaintext highlighter-rouge">:report_changes</code>, il émet à chaque frappe, sous la forme <code class="language-plaintext highlighter-rouge">%{type: :change}</code> — pratique pour un filtre qui se met à jour en direct, coûteux si chaque événement déclenche un appel réseau comme ici.</p>

<p><strong>L’événement reçu est une map</strong> de la forme <code class="language-plaintext highlighter-rouge">%{data: %{…}, origin: …, type: :submit}</code>. Le <code class="language-plaintext highlighter-rouge">origin</code> identifie le client : si deux personnes ouvrent le même notebook, vous savez laquelle a soumis. Le pattern matching sur <code class="language-plaintext highlighter-rouge">%{data: %{terme: terme}}</code> suffit tant qu’on n’en a pas besoin.</p>

<p>Et surtout : <strong>aucune cellule n’est réexécutée</strong>. <code class="language-plaintext highlighter-rouge">Kino.listen</code> démarre un processus qui redessine la zone. C’est du BEAM ordinaire, et c’est pour ça que ça semble instantané.</p>

<h2 id="ce-que-ça-ne-remplace-pas">Ce que ça ne remplace pas</h2>

<p>Il faut être clair sur les limites, sinon la déception arrive au mauvais moment.</p>

<p><strong>Ce n’est pas une application.</strong> L’état vit dans un processus attaché au notebook. Vous le fermez, tout disparaît. Il n’y a ni persistance ni redémarrage automatique.</p>

<p><strong>Ce n’est pas un tableau de bord partagé.</strong> Chaque personne qui ouvre le notebook exécute le sien, avec ses propres processus. Livebook sait déployer un notebook comme une application multi-utilisateur, mais c’est un autre mécanisme — et un autre article.</p>

<p><strong>Ce n’est pas un endroit pour des secrets en clair.</strong> Un notebook se partage justement trop facilement. Livebook a des secrets prévus pour ça ; une clé d’API collée dans une cellule finira dans un dépôt.</p>

<p>Ce que c’est, en revanche : un outil personnel qu’on envoie par courriel, qu’on range à côté du code qu’il interroge, et qu’on relance dans six mois sans se demander quelles versions étaient installées.</p>

<h2 id="le-seuil-intéressant">Le seuil intéressant</h2>

<p>Tout ce qui précède interroge une API publique, ce qui est commode pour un exemple mais reste anecdotique.</p>

<p>Le vrai basculement, c’est quand le même formulaire ne parle plus à une API distante mais à <strong>une application Elixir en cours d’exécution</strong> — inspecter l’état d’un <code class="language-plaintext highlighter-rouge">GenServer</code>, lancer une requête Ecto sur la base de production, lire des métriques en direct. Livebook sait s’attacher à un nœud existant, et c’est là qu’il cesse d’être un outil de démonstration.</p>

<p>C’est le sujet du prochain article.</p>

<hr />

<p><strong>Ce notebook est exécutable.</strong> Il vit dans le dépôt à côté de cet article : <a href="https://livebook.dev/run/?url=https://raw.githubusercontent.com/nseaSeb/ElixirPhoenixRessources/main/Tips/outil_interne_livebook.livemd">ouvrez-le dans votre Livebook</a> et modifiez-le. Tout le code ci-dessus y a été exécuté avant publication.</p>]]></content><author><name>nseaSeb</name></author><category term="livebook" /><category term="livebook" /><category term="kino" /><category term="outillage" /><summary type="html"><![CDATA[Le petit script qu'on écrit dans iex, qu'on lit une fois et qu'on reperd. Livebook en fait un outil qui se garde, se partage et s'utilise sans lire le code — voici comment, et ce que ça ne remplace pas.]]></summary></entry></feed>