Greboca  

DLFP - Dépêches  -  Communiquer avec le serveur depuis un navigateur Web : XHR, SSE et WebSockets

 -  Avril 2021 - 

Dans cette dépêche, nous allons faire un tour d’horizon de différentes manières de communiquer avec un serveur depuis une application Web, avec un petit peu d’histoire, avant de rentrer plus profondément dans le fonctionnement des WebSockets, que nous allons démystifier. Nous digresserons ensuite à propos de la gestion (problématique) des requêtes longues et de HTTP 2 avec Apache, et nous discuterons d’une manière de limiter la casse. La dépêche contient quelques morceaux raisonnables mais l’absurdité est latente.

Supposons que nous ayons une application Web qui a besoin de recevoir des évènements du serveur pour voir si quelque chose s’est passé. À tout hasard, un jeu de société en ligne. Ce jeu a besoin d’envoyer les coups des joueurs et joueuses, et de recevoir les coups des autres.

Le serveur ne peut pas contacter le navigateur. Celui-ci est peut-être derrière un pare-feu, et de toute façon il n’y a pas de méthode pour cela. Le modèle du web, c’est une requête HTTP de la part du navigateur, et le serveur sert cette requête. Et puis, à la base, une requête = un chargement de page.

Mais des techniques sont apparues pour abuser de ce modèle, puis les standards se sont mis à intégrer des méthodes pour mener ces abus en toute sérénité.

Sommaire

AJAX avec XMLHttpRequest

Préambule

Au commencement, les utilisateurs saisissaient des adresses, cliquaient sur des liens ou validaient des formulaires, cela engendrait des requêtes HTTP, un (re)chargement de la page et tout le monde était content, les choses étaient claires et simples. Les pages étaient essentiellement statiques.

Puis des gens ont ressenti le besoin de rendre les pages un peu plus dynamiques. Chacun son truc ! Peut-être que ces personnes aimaient les journaux qui bougent dans Harry Potter et se sont pris pour des sorciers. Du coup, JavaScript est apparu.

Mais comme animer des formes, changer des couleurs et faire clignoter des trucs c’est rigolo mais ça va bien deux secondes, il leur a fallu plus et ils se sont mis à développer diverses bidouilles pour communiquer avec le serveur.

Tout d’abord, simple, vous créiez une balise script ou une image vers une adresse spécifique contenant des données à envoyer au serveur et c’est réglé : le navigateur va faire une requête.

Pour recevoir les évènements du serveur, vous pouviez créer des balises <script></code> à intervalle régulier, avec une adresse source spécifique. Cette adresse renvoie un code JavaScript qui appelle une fonction avec un nom prédéterminé et des données en paramètre de cette fonction. C’est la méthode JSONP. Avouez que ce n’est pas fou : on fait plein de requêtes inutiles, et paie la fluidité de ton jeu en ligne.</p>

<p>Certaines personnes, insatisfaites par la méthode précédente, se sont alors mises à détourner l’élément <code><iframe></code>, qui, à la base, servait à afficher une page dans une page (quelle drôle d’idée !). Le serveur pouvait envoyer des éléments <code><script></code> dans ces <code><iframe></code> pour exécuter du code au gré des évènements. Ça marche, mais bon, il n’y avait pas de manière simple et fiable de gérer les erreurs.</p>

<p>Des solutions s'appuyant sur les plugins Java ou Flash existaient également.</p>
<h3 id="toc-naissance-dxmlhttprequest-xhr">Naissance d’XMLHttpRequest (XHR)</h3>

<p>Et là, l’équipe en charge de faire le Webmail d’Outlook chez Microsoft a eu besoin de ce genre de fonctionnalité. Un weekend, Alex Hopmann, de cette équipe, lança Visual Studio, écrivit un composant qui permettait de communiquer avec le serveur. Et là, il fallait trouver un moyen de ne pas devoir installer ce composant pour pouvoir utiliser la version Web Outlook : tout l’intérêt était de pouvoir consulter ses mails sur n’importe quel ordinateur équipé d’un navigateur et d’une connexion internet sans rien installer. Il était proche de l’équipe développant MSXML, une bibliothèque de chez Microsoft pour traiter le XML. Rien à voir avec les communications avec le serveur, mais Alex Hopmann réalisa que MSXML allait être livré avec Internet Explorer, un navigateur de Microsoft un peu omniprésent à l’époque. Un peu comme Google Chrome aujourd’hui si vous voulez. Mais je digresse.</p>

<p>Et donc, il alla toquer à la porte de l’équipe en charge de MSXML et leur demanda s’ils pouvaient embarquer le composant, alors que la bêta 2 de la version d’Internet Explorer d’alors était sur le point d’être livrée (YOLO). « OK, c’est cool », lui répondit-on, « mais nomme-le XML-machin, ou un truc comme ça, sinon ça ne passera jamais les revues ». Ce qu’Alex fit. De toute façon, XML c’était vachement à la mode donc ça faisait bien d’avoir XML dans le nom même si ça n’avait rien à voir. Et XMLHttpRequest est né comme une partie d’un composant ActiveX dans Internet Explorer.</p>

<p>Par la suite, il a été adopté par les autres navigateurs qui ont décidé de garder le nom. Puis, c’est devenu un standard, tel quel. Et si en plus du fait que XML est hors sujet, le fait qu’il soit en majuscules alors qu’Http, le concept quand même central de cet objet, ne le soit pas, vous titille, eh bien c’est comme ça et vous feriez mieux de vous y faire, il y a quand même des problèmes plus importants dans la vie.</p>

<p>Et puisqu’il faut un terme branché pour désigner tout ce mécanisme de mettre à jour la page avec des nouvelles données sans recharger la page en entier (truc de fou à l’époque !), le concept d’AJAX est né (<em>Asynchronous JavaScript And XML</em> - hé hé oui, on ne se débarrasse pas de XML comme ça, même dans le terme répandu, même quand XML n’est pas du tout impliqué. Parce que si des gens font bien transiter du XML de temps en temps, bien souvent, on utilise du JSON. Bien joué, Alex !).</p>

<p>De toute façon, maintenant il ne faut plus parler d’AJAX, il vaut mieux parler d’API REST et de PWA ou de SPA (non, ça n’a rien avoir avec les bains à remous ou les obstacles de saut équestre) ; ça sonne quand même mieux ces derniers temps. XML c’est moyen, tout le monde trouve ça verbeux et pénible un peu comme cette dépêche, ce n’est bon que pour faire du XMPP et pour les développeurs Java qui utilisent encore Maven. Mettez-vous au goût du jour et utilisez JSON et YAML. Utilisez les bons mots-clés de votre époque, vous n’êtes plus au début des années 2000 par Toutatis. (Note : il serait intéressant de présenter REST mais la notion mériterait un article dédié.)</p>

<p>Enfin bon, revenons à notre pote XMLHttpRequest avec son nom doucement rétro (bon, OK, j’en fais des tonnes).</p>
<h3 id="toc-xmlhttprequest-comment-ça-marche">XMLHttpRequest, comment ça marche ?</h3>

<p>On crée un objet <code>XMLHttpRequest</code>, on lui indique l’URL de la page à télécharger, la méthode HTTP à utiliser pour la requête (<code>GET</code> ou <code>POST</code>), on paramètre les en-têtes HTTP qu’on veut, puis on envoie la requête avec éventuellement les données <code>POST</code> qu’on veut.</p>

<p>On peut alors suivre l’état de la requête en suivant l’évènement <code>readystatechange</code> : la connexion a été ouverte, réception en cours et réponse complètement reçue. On peut récupérer les données pendant la réception et/ou une fois la réponse reçue, vérifier le statut HTTP. On a un contrôle très fin.</p>

<p>On peut lancer des requêtes pour envoyer des données au serveur, mais on peut aussi envoyer une requête de longue durée (<em>long polling</em>) et lire les données qui arrivent au fur et à mesure. C’est tricky mais possible (vous pouvez passer sans problème le code, il est là pour les gens qui aiment les détails techniques) :</p>

<pre><code class="javascript"><span class="kr">const</span> <span class="nx">xhr</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">XMLHttpRequest</span><span class="p">();</span>

<span class="nx">xhr</span><span class="p">.</span><span class="nx">open</span><span class="p">(</span><span class="s2">"POST"</span><span class="p">,</span> <span class="nx">Conf</span><span class="p">.</span><span class="nx">API_ENTRY_POINT</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
<span class="nx">xhr</span><span class="p">.</span><span class="nx">setRequestHeader</span><span class="p">(</span><span class="s2">"Content-Type"</span><span class="p">,</span> <span class="s2">"text/plain"</span><span class="p">);</span>
<span class="nx">xhr</span><span class="p">.</span><span class="nx">send</span><span class="p">(</span><span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">(</span><span class="nx">data</span><span class="p">));</span>

<span class="kd">let</span> <span class="nx">currentIndex</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
<span class="kd">let</span> <span class="nx">expectedLength</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>

<span class="nx">xhr</span><span class="p">.</span><span class="nx">onreadystatechange</span> <span class="o">=</span> <span class="kd">function</span> <span class="p">()</span> <span class="p">{</span>
<span class="k">if</span> <span class="p">(</span><span class="nx">xhr</span><span class="p">.</span><span class="nx">readyState</span> <span class="o">===</span> <span class="mi">3</span><span class="p">)</span> <span class="p">{</span>
<span class="c1">// des données arrivent</span>

<span class="k">while</span> <span class="p">(</span><span class="kc">true</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">expectedLength</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">let</span> <span class="nx">i</span> <span class="o">=</span> <span class="nx">currentIndex</span><span class="p">;</span>
<span class="c1">// On récupère la taille du message qui arrive</span>
<span class="k">while</span> <span class="p">(</span><span class="nx">i</span> <span class="o"><</span> <span class="nx">xhr</span><span class="p">.</span><span class="nx">responseText</span><span class="p">.</span><span class="nx">length</span><span class="p">)</span> <span class="p">{</span>
<span class="k">if</span> <span class="p">(</span><span class="s2">"0123456789"</span><span class="p">.</span><span class="nx">indexOf</span><span class="p">(</span><span class="nx">xhr</span><span class="p">.</span><span class="nx">responseText</span><span class="p">.</span><span class="nx">charAt</span><span class="p">(</span><span class="nx">i</span><span class="p">))</span> <span class="o">===</span> <span class="o">-</span><span class="mi">1</span><span class="p">)</span> <span class="p">{</span>
<span class="nx">expectedLength</span> <span class="o">=</span> <span class="nb">parseInt</span><span class="p">(</span><span class="nx">xhr</span><span class="p">.</span><span class="nx">responseText</span><span class="p">.</span><span class="nx">substring</span><span class="p">(</span><span class="nx">currentIndex</span><span class="p">,</span> <span class="nx">i</span><span class="p">));</span>
<span class="nx">currentIndex</span> <span class="o">=</span> <span class="nx">i</span><span class="p">;</span>
<span class="k">break</span><span class="p">;</span>
<span class="p">}</span>
<span class="o">++</span><span class="nx">i</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">}</span>

<span class="k">if</span> <span class="p">(</span><span class="nx">expectedLength</span> <span class="o">&amp;&amp;</span> <span class="p">(</span><span class="nx">xhr</span><span class="p">.</span><span class="nx">responseText</span><span class="p">.</span><span class="nx">length</span> <span class="o">>=</span> <span class="nx">currentIndex</span> <span class="o">+</span> <span class="nx">expectedLength</span><span class="p">))</span> <span class="p">{</span>
<span class="kr">const</span> <span class="nx">end</span> <span class="o">=</span> <span class="nx">currentIndex</span> <span class="o">+</span> <span class="nx">expectedLength</span><span class="p">;</span>
<span class="kd">let</span> <span class="nx">msgs</span><span class="p">;</span>

<span class="k">try</span> <span class="p">{</span>
<span class="nx">msgs</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span>
<span class="nx">xhr</span><span class="p">.</span><span class="nx">responseText</span><span class="p">.</span><span class="nx">substring</span><span class="p">(</span>
<span class="nx">currentIndex</span><span class="p">,</span>
<span class="nx">end</span>
<span class="p">)</span>
<span class="p">);</span>
<span class="nx">currentIndex</span> <span class="o">=</span> <span class="nx">end</span><span class="p">;</span>
<span class="nx">expectedLength</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
<span class="c1">// Gérer l'erreur json</span>
<span class="c1">// on ne doit pas continuer à chercher à lire des messages sur</span>
<span class="c1">// cette connexion</span>
<span class="nx">xhr</span><span class="p">.</span><span class="nx">abort</span><span class="p">();</span>
<span class="c1">// se reconnecter ou demander à l'utilisateur de rafraichir</span>
<span class="c1">// sa page</span>
<span class="k">return</span><span class="p">;</span>
<span class="p">}</span>

<span class="nx">handleReceivedMessages</span><span class="p">(</span><span class="nx">msgs</span><span class="p">);</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
<span class="k">break</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="nx">xhr</span><span class="p">.</span><span class="nx">readyState</span> <span class="o">===</span> <span class="mi">4</span><span class="p">)</span> <span class="p">{</span>
<span class="c1">// La connexion est fermée, il faut gérer la reconnexion</span>
<span class="p">}</span>
<span class="p">};</span></code></pre>

<p>Un peu tricky oui, et peut-être difficile à suivre, mais globalement ça marche (c’est <a href="https://gitlab.com/raphj/trivabble/-/blob/dddee16e/public/trivabble.js#L1347">un extrait un peu adapté du code de Trivabble</a>, ça tournait comme ça jusqu’au début de l’année dernière et c’est toujours dispo en solution de repli si tout échoue).</p>

<p>Aujourd’hui, pour les requêtes courtes, on pourrait utiliser la nouvelle API <code>fetch</code>, qui permet de faire des requêtes de manière simple, moins verbeuse, avec des belles promesses JavaScript à la mode. Mais le principe reste le même et tout ce que peut faire <code>fetch</code> peut être fait avec XMLHttpRequest et quand on utilise déjà <code>XMLHttpRequest</code> dans un code pour les requêtes longues, l’intérêt d’utiliser <code>fetch</code> pour les requêtes courtes n’est pas toujours si clair selon l’organisation du code, d’autant que cela casse la compatibilité avec les navigateurs anciens<sup id="fnref1"><a href="#fn1">1</a></sup>.</p>

<p>Revenons à notre jeu de société : <code>XMLHttpRequest</code> permet d’arriver à nos fins : pour envoyer des évènements aux serveurs, on fait des requêtes classiques avec <code>XMLHttpRequest</code>, et pour en recevoir, on lance une requête de longue durée, et on reçoit les messages du serveur au fur et à mesure, plus ou moins en temps réel parce que l’évènement <code>readystatechange</code> est généré à chaque fois qu’on reçoit des données<sup id="fnref2"><a href="#fn2">2</a></sup>.</p>

<p>Par contre, il faut se payer la gestion de la séparation des messages. Rien ne garantit qu’on va recevoir chaque message en un coup. Il « suffit » donc d’utiliser un séparateur (par exemple un ou deux retours à la ligne) ou de préfixer chaque message par sa taille, et de garder trace de l’indice du prochain message à lire en mémoire. Un peu pénible, sujet à erreur, mais faisable.</p>
<h2 id="toc-les-server-sent-events-à-la-rescousse">Les Server-Sent Events à la rescousse</h2>

<p>Maintenant que le monde tourne dans le navigateur et que tout le monde écrit son application avec les technologies du Web, les requêtes longue durée sont bien évidemment devenues très répandues. Alors les développeurs de navigateurs et les organismes de standardisation du monde du Web, qui sont en fait essentiellement les mêmes personnes, se sont dit que ça serait bien de proposer une solution propre qui permettrait que les gens ne réinventent pas la roue à chaque fois.</p>

<p>Naissent donc les Server-Sent Events (SSE). Et comme le monde passe par les ports 80 et 443 et emballe tout dans de la requête HTTP pour faire plaisir aux différents NAT, pare-feux et autres serveurs mandataires d’entreprise retors, les SSE prennent la forme d’une requête HTTP longue durée avec un type de contenu (<code>Content-Type</code>) spécifique : <code>text/event-stream</code>. Ce format de données consiste en une suite d’évènements séparés par deux retours à la ligne. Grosso modo, chaque évènement est composé de plusieurs lignes <code>clé:valeur</code>, dont deux qui nous intéressent : la clé <code>event</code>, qui donne un nom à l’évènement (optionnel), et la clé <code>data</code> dans laquelle vous placez les données de l’évènement.</p>

<p>Dans les navigateurs, un bel objet <code>EventSource</code> permet de se connecter à un tel flux d’évènements, de s’y reconnecter de façon transparente en cas de perte de connexion et de récupérer chaque message facilement en surveillant l’évènement <code>message</code> de cet objet. Plus besoin de gérer soi-même la séparation des messages et de garder en mémoire tout le contenu de la requête. Ce sont ces deux points qui font tout l’intérêt de cette nouvelle méthode de communication par rapport à une requête longue classique. Un petit exemple d’utilisation :</p>

<pre><code class="javascript"><span class="kr">const</span> <span class="nx">connection</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">EventSource</span><span class="p">(</span><span class="s2">"/api/sse/"</span><span class="p">);</span>

<span class="nx">connection</span><span class="p">.</span><span class="nx">onmessage</span> <span class="o">=</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">let</span> <span class="nx">msg</span><span class="p">;</span>

<span class="k">try</span> <span class="p">{</span>
<span class="nx">msg</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">data</span><span class="p">);</span>
<span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
<span class="c1">// penser à gérer les échecs de parsing JSON et couper la connexion</span>
<span class="c1">// si ça arrive</span>
<span class="nx">connection</span><span class="p">.</span><span class="nx">close</span><span class="p">();</span>
<span class="k">return</span><span class="p">;</span>
<span class="p">}</span>

<span class="nx">handleReceivedMessage</span><span class="p">(</span><span class="nx">msg</span><span class="p">);</span>
<span class="p">};</span>

<span class="nx">connection</span><span class="p">.</span><span class="nx">onerror</span> <span class="o">=</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
<span class="c1">// gérer l'erreur, demander à l'utilisateur de rafraîchir la page par exemple</span>
<span class="p">};</span></code></pre>

<p>Un peu plus clair que le code qui fait du long polling avec XmlHttpRequest, n’est-ce pas ?</p>

<p>Pour les quelques navigateurs perdus encore en circulation qui n’implémenteraient pas <code>EventSource</code> (<em>ahem</em> IE et l’ancien Edge <em>ahem</em>), il est tout à fait possible de l'implémenter soi-même à base de <code>XMLHttpRequest</code>. Il faudra juste garder à l’esprit que la requête entière sera gardée en mémoire.</p>
<h2 id="toc-les-websockets">Les WebSockets</h2>
<h3 id="toc-introduction-parce-quil-en-faut-bien-une">Introduction parce qu’il en faut bien une</h3>

<p>En même temps que les SSE, les WebSockets font leur apparition <a href="https://en.wikipedia.org/wiki/Websocket#Browser_implementation">dans les navigateurs</a>. Petite frise chronologique : </p>

<ul>
<li>mars 1999 : XMLHttpRequest apparait accidentellement dans Internet Explorer 5. Début de la pente glissante que nous dégringolons encore. Ne blâmons pas Microsoft, s’ils n’avaient pas commencé, d’autres l’auraient fait. Ils nous ont peut-être épargné des solutions à base d’iframe, ou pire, de Java et/ou Flash pendant la première décennie de ce millénaire ;</li>
<li>2010-2011 : apparition des WebSockets et des SSE dans les versions stables des différents navigateurs. En tout cas, dans Firefox, Safari et Chrome. Internet Explorer n’implémente jamais les SSE, et une implémentation des WebSockets arrive doucement en 2012 dans une version qui sera adoptée bien plus tard de toute façon ;</li>
<li>2006 : apparition des SSE dans Opera de manière expérimentale. Pourquoi l’avoir mis après 2011 ? Parce que les développeurs d’Opera ont utilisé une machine à voyager dans le temps pour l’implémentation de cette fonctionnalité. Si vous vouliez lire de l’histoire correcte, vous auriez dû ouvrir un livre d’histoire. Si quoi que ce soit d’écrit dans cette dépêche ressemblait plus ou moins à des évènements qui se sont vraiment passés, ce serait purement fortuit. Vous êtes prévenu·e.</li>
</ul>

<p>WebSocket est un acronyme récursif pour <em>a WebSocket is not a socket, at all</em><sup id="fnref3"><a href="#fn3">3</a></sup>. Alors, quel est le point commun avec un socket classique, s’il y en a un ? C’est la communication dans les deux sens, ce que ne permettent pas les autres techniques.</p>
<h3 id="toc-intérêt">Intérêt</h3>

<p>Les quatre points intéressants des WebSockets que je vois sont les suivants :</p>

<ol>
<li>on ne se paie plus le coût d’une connexion HTTP (établissement de la connexion TCP, puis TLS (de nos jours), envoi et réceptions des en-têtes HTTP) à chaque fois que l’on veut envoyer une donnée. Cela réduit les délais et les quantités de données transmises ;</li>
<li>ordonnancement des messages : on peut avoir un dialogue entre le navigateur et le serveur et les messages arrivent, partent et sont traités dans un ordre prévisible, dans un même canal de communication ;</li>
<li>transferts de message binaires faciles (même si finalement, avec XHR, ça se fait aussi) ;</li>
<li>Comme avec les SSE et contrairement à XHR, le découpage des communications se fait par messages de taille arbitraire.</li>
</ol>

<p>Pour le deuxième point, considérez le cas suivant : dans notre jeu de société, un joueur pose une pièce sur un plateau. Sans les WebSockets :</p>

<ol>
<li>Une requête HTTP est envoyée pour avertir le serveur.</li>
<li>Le serveur répond OK à la requête, puis, </li>
<li>transmet le déplacement à tous les joueurs, y compris celui qui a joué, par simplicité. Ce déplacement est reçu par le joueur via la requête de longue durée.</li>
</ol>

<p>Sauf que dans la vraie vie, le déplacement peut être reçu avant la réponse OK par le joueur. Si le jeu est codé avec les pieds (ça arrive, en cas de tendinites aux deux bras par exemple), cela peut entraîner des bugs intéressants et difficiles à reproduire surtout en local pendant le développement, comme une disparition inopinée de la pièce. Je dis ça, c’est totalement théorique, hein, je n'aurais jamais codé moi-même un truc pareil ! (Ah, bah si. Oups)</p>

<p>Avec les WebSockets :</p>

<ol>
<li>Le joueur envoie le coup par la WebSocket</li>
<li>Le serveur répond ok dans la WebSocket</li>
<li>Le serveur envoie le déplacement à tous les joueurs, dont à ce joueur à travers cette même WebSocket.</li>
</ol>

<p>Dans ces conditions, il faut vraiment une gestion horrible des évènements côté client pour recevoir le OK après le déplacement.</p>

<p>Donc pour résumer : pourquoi les WebSocket c’est intéressant ? Communication à deux sens plus efficace et plus prévisible. Mais alors, y a-t-il des inconvénients ? Oui. Déjà, certains proxys peuvent ne pas être compatibles (on verra pourquoi dans la partie suivante), et les WebSockets peuvent demander des configurations spécifiques côté serveur et c’est facile de se planter.</p>

<p>Du coup, si vous n’avez pas besoin d’une communication à deux sens, utilisez plutôt AJAX (SSE, XHR, fetch, comme vous voulez), votre vie sera probablement plus simple.</p>
<h3 id="toc-comment-ça-marche">Comment ça marche ?</h3>

<p>L’idée des WebSockets a certainement démarré avec une conversation de ce style.</p>

<ul>
<li>Ce serait quand même bien d’avoir un mécanisme de communication dans les deux sens sur le web, sans se payer le coup des requêtes HTTP, et de pouvoir aussi balancer du binaire.</li>
<li>Un peu comme des sockets, mais pour le Web ?</li>
<li>Oui, exactement !</li>
<li>T’es fou, avec tous ces proxys, ces NAT et ces pare-feux dans tous les sens ça ne va jamais marcher, non ?</li>
<li>Bah, tu connais déjà la solution à ce problème ! On fait comme d’habitude…</li>
<li>On emballe dans une requête HTTP ? Mais c’est horrible, non ?</li>
<li>Pas le choix ! Hé hé.</li>
<li>Mais et les ports ? Et la communication dans les deux sens ? HTTP, c’est un aller, puis un retour, je te rappelle !</li>
<li>Les ports, on s’en fiche ! On peut utiliser des URLs, c’est bien plus pratique que des nombres qui n’ont pas de sens. Pour la communication dans les deux sens, j’ai ma petite idée…</li>
<li>J’ai peur d’avance…</li>
</ul>

<p>Vous l’aurez deviné, une connexion par WebSocket commence comme une connexion HTTP. Ensuite, un mécanisme de négociation permet de se débarrasser d’HTTP et de commencer une communication à deux sens.</p>

<p>Note : ce qui suit est technique. Vous pouvez passer la section sans problème. Vous n’avez pas besoin de connaître ces mécanismes en détails pour utiliser les WebSockets : il existe des bibliothèques pour cela, et l’objet WebSocket des navigateurs fait tout le travail pour vous.</p>
<h3 id="toc-début-des-négociations">Début des négociations</h3>

<p>La connexion WebSocket est toujours démarrée par le client / navigateur. Le serveur peut accepter les connexions. Le client se connecte au serveur et envoie des en-têtes HTTP classiques, et ceux-ci :</p>

<pre><code class="http"><span class="err">GET /api/websocket</span>
<span class="err">Upgrade: websocket</span>
<span class="err">Connection: Upgrade</span>
<span class="err">Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==</span>
<span class="err">Sec-WebSocket-Version: 13</span></code></pre>

<p>Le serveur, reconnaissant les entêtes <code>Connection: Upgrade</code> et <code>Upgrade: websocket</code>, répond par le statut 101 indiquant qu’il veut bien changer de protocole pour gérer la WebSocket, en passant également les entêtes <code>Connection</code> et <code>Upgrade</code>. Il construit aussi une chaîne à partir clé de l’entête <code>Sec-WebSocket-Key</code> en appliquant des opérations définies par le protocole et met cette chaîne dans l’entête <code>Sec-WebSocket-Accept</code> :</p>

<pre><code class="http"><span class="kr">HTTP</span><span class="o">/</span><span class="m">1.1</span> <span class="m">101</span> <span class="ne">Switching Protocols</span>
<span class="na">Upgrade</span><span class="o">:</span> <span class="l">websocket</span>
<span class="na">Connection</span><span class="o">:</span> <span class="l">Upgrade</span>
<span class="na">Sec-WebSocket-Accept</span><span class="o">:</span> <span class="l">s3pPLMBiTxaQ9kYGzzhZRbK+xOo=</span></code></pre>

<p>À quoi sert cet échange de clé dans les entêtes <code>Sec-WebSocket-Key</code> et <code>Sec-WebSocket-Accept</code> ? Ça a l’air un peu superflu, mais c’est une mesure de sécurité qui permet de montrer au client que le serveur comprend bien qu’il a affaire à une WebSocket.</p>

<p>Note : on remarque qu’ici, la requête se fait en HTTP 1.1. Il existe également une norme pour faire des WebSocket à l’aide d’une requête HTTP 2, la <a href="https://tools.ietf.org/html/rfc8441">RFC 8441</a>. Il n’existe pas (encore), à ma connaissance, la possibilité de faire des WebSocket sur HTTP/3, qui ne s’appuie d’ailleurs pas sur TCP.</p>

<p>Une fois cet échange initial effectué, le serveur et le client vont pouvoir s’échanger des messages dans des <em>frames</em> WebSocket, le canal est bidirectionnel. À charge du client et du serveur de n’envoyer que des messages que l’autre attend, comme pour n’importe quelle connexion par socket finalement.</p>

<p>Il est important de réaliser que les WebSocket sont bien implémentées au-dessus de TCP (cela pourrait changer à l’avenir). L’implication la plus évidente pour moi c’est que ça permet de faire des trames (<em>frames</em>) de taille variable. Ces trames sont composées d’un entête indiquant leur type et leur taille, et du message lui-même.</p>

<p>On ne rentrera pas dans le détail ici (la doc est là pour ça ☺), mais notons qu’il existe différent types de frames :</p>

<ul>
<li>les trames <em>pong</em>, qui ne contiennent pas de message mais qui permettent d’éviter que la connexion soit coupée par les intermédiaires pour cause d’inactivité (une sorte de <em>keep-alive</em>, quoi - et non, je ne sais pas pourquoi le <em>keep-alive</em> de TCP ne suffisait pas) ;</li>
<li>les trames <em>ping</em>, qui permettent de demander à l’autre d’envoyer un <em>pong</em> (d’ailleurs, un <em>pong</em> peut-être envoyé sans que l’autre ait envoyé de <em>ping</em>) </li>
<li>les trames contenant la fin d’un message (et ça peut être le message entier s’il n’y avait pas de bout de message avant) ;</li>
<li>les trames contenant le bout d’un message, dans ce cas le serveur doit s’attendre à une suite ;</li>
<li>les trames de fin de connexion, demandant à celui qui reçoit le message de fermer la connexion et d’ignorer quoi que ce soit qui pourrait suivre.</li>
</ul>

<p>Les messages pas trop longs pourront tenir en une seule trame. Pour les trames contenant un (bout de) message, un code indique si c’est du texte (en UTF-8) ou du binaire quelconque (et donc oui, il faut implémenter l’UTF-8 si on implémente soi-même les WebSockets. Mais ça va, <a href="http://doc.cat-v.org/bell_labs/utf-8_history">UTF-8 a été conçu en un soir à une table de resto en 1992</a>). </p>

<p>Donc en résumé, ça se passe comme ça :</p>

<ul>
<li>Navigateur : Salut, une petite WebSocket, ça te dit ?</li>
<li>Serveur : Ouais ouais, carrément !</li>
<li>… et c’est parti pour un échange bidirectionnel endiablé</li>
</ul>

<p>Je vous passe le code côté serveur, vous utiliserez probablement une bibliothèque répandue pour votre langage de programmation préféré, mais si la curiosité vous pique, il y a la page <a href="https://en.wikipedia.org/wiki/WebSocket">Wikipedia anglaise</a> qui est intéressante, la <a href="https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers">doc MDN assez complète</a> et une <a href="https://gitlab.com/raphj/trivabble/-/blob/dddee16e/server/trivabble-server.js#L784">implémentation simplissime en JavaScript dans Trivabble</a> largement inspirée de <a href="https://medium.com/hackernoon/implementing-a-websocket-server-with-node-js-d9b78ec5ffa8">cet article</a>.</p>

<p>Côté navigateur, ce n’est pas si différent de <code>EventSource</code>, on sent une certaine cohérence dans la conception des API (attention cependant, pas de reconnexion automatique contrairement à <code>EventSource</code> !) :</p>

<pre><code class="javascript"><span class="kr">const</span> <span class="nx">connection</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">WebSocket</span><span class="p">(</span><span class="s2">"/api/ws/"</span><span class="p">);</span>

<span class="nx">connection</span><span class="p">.</span><span class="nx">onmessage</span> <span class="o">=</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">let</span> <span class="nx">msg</span><span class="p">;</span>

<span class="k">try</span> <span class="p">{</span>
<span class="nx">msg</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">data</span><span class="p">);</span>
<span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
<span class="c1">// penser à gérer les échecs de parsing JSON et couper la connexion</span>
<span class="c1">// si ça arrive</span>
<span class="nx">connection</span><span class="p">.</span><span class="nx">close</span><span class="p">();</span>
<span class="k">return</span><span class="p">;</span>
<span class="p">}</span>

<span class="nx">handleReceivedMessage</span><span class="p">(</span><span class="nx">msg</span><span class="p">);</span>
<span class="p">};</span>

<span class="nx">connection</span><span class="p">.</span><span class="nx">onerror</span> <span class="o">=</span> <span class="kd">function</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">{</span>
<span class="c1">// gérer l’erreur, demander à l’utilisateur de rafraîchir la page par exemple</span>
<span class="p">};</span>

<span class="c1">// Pour envoyer un message :</span>
<span class="nx">connection</span><span class="p">.</span><span class="nx">send</span><span class="p">(</span><span class="s1">'{"command": "hello"}'</span><span class="p">);</span></code></pre>

<p>Et donc, oui, il est assez simple de partager le code si vous voulez gérer les deux types de connexion, en utilisant l’un comme une solution de repli quand l’autre n’est pas gérée ou échoue pour une raison x ou y. D’ailleurs, il est facile de casser le fonctionnement des WebSockets en configurant mal son serveur mandataire (<em>proxy</em>), qui doit lui-même prendre en charge les WebSockets. En effet, il faut généralement une configuration explicite sur les chemins qui impliquent des WebSockets, par exemple pour Nginx, même s’il y a probablement moyen d’avoir une configuration qui gère ça automatiquement :</p>

<pre><code>location /api/ws/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1; # Bien demander du HTTP 1.1
proxy_set_header Upgrade $http_upgrade; # Bien passer l’entête Upgrade
proxy_set_header Connection "Upgrade"; # Bien configurer l’entête Connection
proxy_set_header Host $host; # on garde l’hôte aussi, au cas où l’applicatif derrière s’en serve
}
</code></pre>
<h2 id="toc-digression-sur-les-connexions-longues-http2-et-apache">Digression sur les connexions longues, HTTP2 et Apache</h2>

<p>Le mélange de ces trois ingrédients peut mal se passer, de manière assez spectaculaire (la raison 3 va vous étonner et… allez, j’arrête, ça suffit). Apache est un serveur conçu dans les années 90, quand les navigateurs faisaient rarement beaucoup de connexions à la fois, et ne faisaient pas de connexions longues, et quand il y avait également moins souvent beaucoup de connexions simultanées à un même serveur. Les usages ont un peu changé depuis, comme vous avez certainement pu le constater en observant le monde trois secondes. De plus, il s’agit surtout d’un serveur HTTP auquel on a ajouté la possibilité de faire serveur mandataire. À contraster avec NGINX, plus récent (et donc architecturé avec les usages de maintenant en tête), qui est, pour ne presque pas caricaturer, un serveur mandataire qui peut également faire serveur HTTP (à tel point qu’il est fréquent de mettre Apache derrière NGINX pour avoir un serveur HTTP avec des fonctions avancées <em>et</em> un truc qui dépote pour gérer les connexions – le combo gagnant !).</p>

<p>Et du coup, si NGINX gère sans problème vos requêtes longues, avec Apache, ce n’est pas forcément la même histoire. Pour caricaturer, quand NGINX est capable, avec un processus, de surveiller un paquet de connexions et de se réveiller quand il se passe un truc sur l’une d’elles, Apache, historiquement, dédie un processus par connexion. Il est maintenant possible d’approcher le fonctionnement de NGINX en configurant la manière dont les connexions sont gérées, et notamment adopter <a href="https://httpd.apache.org/docs/2.4/mod/event.html">le module multi-processus event</a> et être très attentif à ses différents paramètres de configuration, gérant notamment le nombre maximal de processus et de requêtes traitées par processus.</p>

<p>Mais voilà, avec HTTP 2, qui permet de multiplexer complètement plusieurs requêtes (au lieu d’avoir le truc complètement séquentiel qu’on a avec HTTP 1.1), l’histoire ne s’arrête pas là. <a href="https://httpd.apache.org/docs/2.4/mod/mod_http2.html">La documentation d’Apache</a> appelle à la prudence :</p>

<blockquote>
<p>Activer HTTP/2 sur votre serveur Apache a un impact sur la consommation de ressources, et si votre site est très actif, il est conseillé d’en prendre sérieusement en compte les implications.</p>

<p>HTTP/2 attribue à chaque requête qu’il reçoit son propre thread de travail pour son traitement, la collecte des résultats et l’envoi de ces derniers au client. Pour y parvenir, il lui faut lancer des threads supplémentaires, et ceci constituera le premier effet notable de l’activation de HTTP/2.</p>

<p>Dans l’implémentation actuelle, ces threads de travail font partie d’un jeu de threads distinct de celui des threads de travail du MPM avec lequel vous êtes familier.</p>
</blockquote>

<p>Donc on a les paramètres du module multi-processus, et en fait il y a encore d’autres paramètres qui règlent le fonctionnement d’HTTP/2 ! Bref, difficile de configurer tout ça correctement.</p>

<p>Alors, que se passe-t-il si on n’a pas une configuration adaptée ? HTTP 2 ou pas, et requêtes longues ou pas si on atteint un maximum quelque part, Apache va simplement mettre les requêtes en attente. Pour les utilisateurs et les utilisatrices, c’est une interface qui ne répond plus, ou une page blanche qui n’en finit pas de se charger. Avec aucune possibilité pour l’application de l’avertir, puisque… le serveur ne veut plus rien savoir.</p>

<p>Avec des requêtes longues, c’est facile d’atteindre les maxima, bien sûr, puisqu’un fil d’exécution leur sont dédiées, et avec HTTP 2, c’est encore pire à cause de ces problèmes de paramétrage compliqués et de ces fils d’exécutions dédiés pour le multiplexage. De plus, les navigateurs, en HTTP 1.1, se limitent avec leur configuration par défaut à 6 requêtes simultanées vers chaque hôte. Ce n’est pas le cas en HTTP 2, et donc peuvent surcharger plus facilement un serveur si celui-ci ne gère pas bien les requêtes simultanées. Du coup, si votre application a beaucoup de ressources à charger au démarrage et qu’elle fait des requêtes vers l’API à la pelle, c’est vite la mort.</p>

<p>En plus, c’est pernicieux, parce que pendant le développement, quand vous êtes seul·e sur votre machine, il est probable que tout semble fonctionner correctement et les problèmes n’arrivent même pas forcément immédiatement lors de la mise en production, mais un peu après quand les visiteurs arrivent en masse et — incroyable mais vrai — utilisent votre application.</p>
<h3 id="toc-damage-control-le-broadcastchannel">
<em>Damage control</em>: le <code>BroadcastChannel</code>
</h3>

<p>Pour moi, la première mesure à appliquer si on peut le faire, c’est de passer à NGINX si on doit gérer des requêtes longues : l’outil est fait pour gérer ça. Si on doit rester sur Apache, bien gérer ses paramètres et éventuellement éviter HTTP 2 (ce qui est dommage, parce que ça permet des communications plus efficaces sinon, mais on préfère vite pas idéal à échec spectaculaire).</p>

<p>Par ailleurs, si votre application est susceptible d’être ouverte dans plusieurs onglets en même temps (parce qu’elle permet d’afficher des documents, par exemple), maintenir une connexion par onglet n’est pas idéal : vous pouvez facilement réduire la charge que vous imposez à votre serveur et à vos utilisateurs en vous limitant à une connexion pour tous les onglets. Ça utilise moins de bande passante et ça monopolise moins de fils d’exécution sur le serveur, surtout si Apache est utilisé.</p>

<p>Les onglets peuvent communiquer entre eux en utilisant <a href="https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel">BroadcastChannel</a> (et, oui, les onglets peuvent communiquer entre eux, j’ai été surpris de découvrir ça l’année dernière). Un message envoyé dans un canal sera reçu par tous les onglets écoutant ce même canal. Un onglet est choisi (par élection de leader) pour se connecter au serveur et vous pouvez alors dispatcher les messages reçus du serveur à tous les onglets, ou aux onglets concernés.</p>

<p>C’est ce qu’on utilise dans <a href="https://www.tracim.fr">Tracim</a>, à travers la bibliothèque <a href="https://github.com/pubkey/broadcast-channel">broadcast-channel</a>, qui fournit une rustine (<em>polyfill</em>) pour les navigateurs ne fournissant pas une implémentation native et qui fournit également un mécanisme d’élection de leader super simple à utiliser.</p>
<h2 id="toc-conclusion">Conclusion</h2>

<p>Vous êtes toujours là ? Bon, d’accord, concluons. Si vous lisez encore, c’est que vous pouvez lire <em>n’importe quoi</em> à ce stade alors allons-y gaiement.</p>

<p>Pour résumer, des gens ont conçu un super système pour partager des documents inter-liés. Ensuite, des gens ont trouvé que faire des trucs qui clignotent dans des documents c’est rigolo. De fil en aiguille, on est arrivé avec des problèmes compliqués de pages blanches qui n’en finissent plus de charger, tout ça parce que quelqu’un qui voulait, dans un monde de plus en plus instantané, lire ses mails sans rafraîchir sa page, a soudoyé l’équipe voisine pour ajouter des trucs qui n’avaient rien à faire dans une visionneuse de documents, dans celle qui était à l’époque tristement en situation de monopole. Du coup, pour rester « pertinent », les potes concurrents ont adopté ces trucs. Bien plus tard, des gens ont cherché (et trouvé, c’est ça le pire !) des solutions sur-sophistiquées pour faire communiquer des documents magiques entre eux sans tout faire péter. Aujourd’hui, on fait un tas de trucs avec ces visionneuses de documents devenues des systèmes d’exploitation entiers, et de temps en temps on lit des documents avec. Désactiver certaines fonctionnalités de ces visionneuses et bloquer les trois quarts de ces documents permettent de retrouver la fluidité et le zen d’antan. Ou des pages blanches, quand ces documents sont devenus, par inadvertance, des applications.</p>

<p>Et le pire dans tout ça, c’est qu’il y a des gens pour raconter tout ça dans des dépêches impossiblement longues.</p>

<div class="footnotes">
<hr>
<ol>

<li id="fn1">
<p>oui, si vous voulez faire fonctionner votre jeu sur Safari 9, vous pouvez dire au revoir à fetch à moins d’utiliser un de ces fameux polyfills. Mais est-ce vraiment bien la peine d’alourdir votre projet avec encore une dépendance tout ça pour éviter trois pauvres lignes de code ? <a href="#fnref1">↩</a></p>
</li>

<li id="fn2">
<p>Le fait que la propriété <code>readyState</code> de l’objet <code>XMLHttpRequest</code> indiquant l’état de la réponse ne change pas de valeur malgré le nom de l’évènement est purement accidentel. Vous ne voudriez pas que les API standardisées et présentes dans chaque navigateur de la planète soient complètement logiques non plus ? La vie des développeurs Web serait un peu morose si tout fonctionnait tout le temps logiquement. <a href="#fnref2">↩</a></p>
</li>

<li id="fn3">
<p>Bon, d’accord, WebSocket veut dire « Socket pour le Web », mais ce n’est pas comme un socket quand même. Je tiens également à préciser que <em>socket</em> ne veut pas du tout dire socquette, <a href="https://en.wiktionary.org/wiki/socquette">qui se dit ankle sock</a>. Parenthèse fermée. <a href="#fnref3">↩</a></p>
</li>

</ol>
</div>
</div><div><a href="https://linuxfr.org/news/communiquer-avec-le-serveur-depuis-un-navigateur-web-xhr-sse-et-websockets.epub">Télécharger ce contenu au format EPUB</a></div> <p>
<strong>Commentaires :</strong>
<a href="//linuxfr.org/nodes/120264/comments.atom">voir le flux Atom</a>
<a href="https://linuxfr.org/news/communiquer-avec-le-serveur-depuis-un-navigateur-web-xhr-sse-et-websockets#comments">ouvrir dans le navigateur</a>
</p>

par raphj, volts, Benoît Sibaud, theojouedubanjo, ted, Ysabeau, jona, olivierweb, palm123, Xavier Claude, Pierre Tramal

DLFP - Dépêches

LinuxFr.org

Retour d’expérience sur l’utilisation de GrapheneOS (ROM Android libre)

 -  18 mars - 

Suite à la dépêche Comparatif : GrapheneOS vs LineageOS, je souhaitais faire part d’un retour d’expérience sur l’utilisation de GrapheneOS sur un (...)


Ubix Linux, le datalab de poche

 -  16 mars - 

Ubix Linux est une distribution Linux libre et open-source dérivée de Debian.Le nom « Ubix » est la forme contractée de « Ubics », acronyme issu de (...)


Open Food Facts : récit d’un contributeur

 -  15 mars - 

Récit de mon aventure en tant que contributeur pour le projet Open Food Facts, la base de donnée alimentaire ouverte et collaborative, où je suis (...)


Sortie de GIMP 2.99.18 (version de développement)

 -  13 mars - 

Note : cette dépêche est une traduction de l'annonce officielle de la sortie de GIMP 2.99.18 du 21 février 2024 (en anglais).Voici enfin la (...)


Des cycles, des applis et des données

 -  11 mars - 

Avec son plus d’un quart de siècle, il serait temps que LinuxFr se penche sur un sujet qui concerne la population en situation de procréer, soit (...)