• Contenu
  • Bas de page
logo ouidoulogo ouidoulogo ouidoulogo ouidou
  • Qui sommes-nous ?
  • Offres
    • 💻 Applications métier
    • 🤝 Collaboration des équipes
    • 🛡️ Sécurisation et optimisation du système d’information
    • 🔗 Transformation numérique
  • Expertises
    • 🖥️ Développement logiciel
    • ♾️ DevSecOps
    • ⚙️ Intégration de logiciels et négoce de licences
      • Atlassian : Jira, Confluence, Bitbucket…
      • Plateforme monday.com
      • GitLab
      • SonarQube
    • 📚​ Logiciel de CRM et de gestion
    • 🎨 UX/UI design
    • 🌐 Accessibilité Numérique
    • 🗂️​ Démarches simplifiées
    • 📝 Formations Atlassian
  • Références
  • Carrières
    • 🧐 Pourquoi rejoindre Ouidou ?
    • ✍🏻 Nous rejoindre
    • 👨‍💻 Rencontrer nos collaborateurs
    • 🚀 Grandir chez Ouidou
  • RSE
  • Ressources
    • 🗞️ Actualités
    • 🔍 Articles techniques
    • 📖 Livres blancs
    • 🎙️ Interviews Clients
Nous contacter
✕
Server-Sent Events vous dites ?Qui peut le plus peut le moinsLe cas d’usageQuelques inconvénients des WebSocketsServer-Sent Events : proof of conceptÉviter les fuitesDifférencier les streams du resteConclusion
Ressources > Articles techniques > Server-Sent Events, le mal-aimé ?

Server-Sent Events, le mal-aimé ?

Article écrit par Martin Catty

Server-Sent Events vous dites ?

Aussi surprenant que ça puisse paraitre avec près de 500 articles au compteur nous n’avons jamais évoqué les Server-Sent Events.

Les Server-Sent Events sont un mécanisme basé sur HTTP permettant comme son nom l’indique d’envoyer depuis le serveur des informations à un client. C’est donc un mécanisme unidirectionnel.

Ce manque d’engouement sur notre blog est un reflet assez fidèle de ce qu’on observe sur le net en général.

Cela tient au fait qu’on lui préfère quasi systématiquement les WebSockets devenues une technologie de premier rang dans nos frameworks préférés (ActionCable dans Rails et LiveView dans Phoenix pour ne citer qu’eux).

Pourtant les Server-Sent Events sont utilisables dans Rails depuis Rails 4 par le biais des streams.

Google trend Server-Sent Events vs WebSockets

Qui peut le plus peut le moins

Y a-t-il donc un intérêt à utiliser les Server-Sent Events plutôt que les WebSockets ?

En effet les WebSockets savent faire la même chose mais en mieux puisqu’elles sont bi-directionnelles ! Qui plus est les WebSockets peuvent transmettre les données plus efficacement notamment en binaire là où les Server-Sent Events se cantonnent au texte.

C’est vrai mais à chaque besoin son outil.

Le cas d’usage

Dans le cadre d’un nouveau projet nous avons essayé d’imaginer quelle serait la meilleure architecture pour ajouter une couche de temps réelle de manière progressive.

Il s’agit donc de mettre en place un fonctionnement classique API en Ruby on Rails + Single Page Application en Vue.js.

L’idée est de faire en sorte que lorsqu’une ressource est créée du côté de l’API les différents clients intéressés puissent être notifiés.

L’intérêt pour nous étant que la partie notification des évènements soit optionnelle si elle ne fonctionne pas notre application doit continuer à tourner. Par ailleurs notre API reste le single source of truth il n’est possible de créer des ressources par un autre biais.

Cela limite donc déjà l’intérêt des WebSockets dans cette situation (pas besoin de bi-directionnel) et on commence à regarder de plus près les Server-Sent Events.

Les SSE fonctionnent de façon assez simple : le client s’abonne à un stream duquel il va recevoir des évènements. Ceux-ci transitent pas le biais d’une connexion HTTP qui reste ouverte en permanence.

C’est à mon avis ce qui a rendu l’usage des Server-Sent Events si peu répandu. En HTTP/1.1 le nombre de connexions qu’un navigateur pouvait ouvrir pour un domaine donné se situait autour de 6 (d’où l’usage de CDN et le fait de servir des assets sur des domaines dédiés).

Le problème n’existait pas avec les WebSockets puisque la connexion ne se fait pas au travers d’HTTP (couche applicative) mais de TCP (couche de transport).

Toutefois en HTTP/2 la connexion est multiplexée ce qui permet de faire transiter des requêtes concurrentes dans un même tuyau rendant le nombre de connexions simultanées possibles bien supérieur.

Quelques inconvénients des WebSockets

Le fait que les WebSockets ne transitent pas par HTTP fait qu’elles sont parfois rejetées par un certain nombre de composants intermédiaires du réseau qu’on ne maitrise pas.

Cela peut être le cas d’un proxy load balancer firewall open office etc.

D’autre part les WebSockets ne portent aucune information liée à l’authentification.

Or dans notre projet nous aimerions mettre nos API derrière une API gateway qui se chargera de l’authentification via JWT en cookie. Le fait que les Server-Sent Events transitent par HTTP fait que les streams seront gérés comme n’importe quelle requête classique à notre API.

Server-Sent Events : proof of concept

Sur le papier le choix semble se tenir maintenant reste à expérimenter pour s’assurer que notre solution tient la route.

On va donc mettre en place un POC mettant en œuvre une SPA en Vue.js qui attaquera notre API en Rails.

Fonctionnellement parlant on veut pouvoir créer des posts récupérer la liste des posts et ouvrir un stream de posts.

Les clients (SPA) iront récupérer la liste des posts sur l’API lors de leur premier chargement.

On aura donc le fonctionnement suivant :

  • client 1 se connecte et récupère la liste des posts. Il ouvre un stream.
  • client 2 se connecte et récupère la liste des posts. Il ouvre un stream.
  • client 1 crée un post via l’API
  • l’API notifie client 1 et 2 de la création d’un post
  • client 1 n’en tient pas compte. C’est lui qui vient de créer le post il est au courant
  • client 2 intègre l’information et rafraichit le composant lié

Côté JavaScript notre composant Posts ressemble à ça :

<script> import Post from "./Post.vue"; import CreatePost from "./CreatePost.vue";  export default {   name: "Posts"    data() {     return {       posts: {}     };   }    beforeMount() {     this.getPosts();   }    mounted() {     const sse = new EventSource(`${this.$root.config.streamHost}/posts/stream`);     const vm = this;     sse.addEventListener("message"  function (e) {       if (e.data !== "ping") {         const json = JSON.parse(e.data);         vm.addPost(json);       }     });   }    methods: {     async getPosts() {       const response = await fetch(`${this.$root.config.apiHost}/posts`);       const json = await response.json();       this.posts = json;     }      addPost(post) {       if (         post !== null &&         !this.posts.map((post) => post.id).includes(post.id)       ) {         this.posts.unshift(post);       }     }   }    components: {     Post      CreatePost   } }; </script> 

Le CreatePost.vue :

<template>   <p>     <textarea v-model="body" id="" cols="30" rows="10"></textarea>   </p>   <p>     <input @click="createPost" type="submit" value="Enregistrer" />   </p> </template>  <script> export default {   name: "CreatePost"    props: ["body"]    emits: ["addPost"]    methods: {     createPost() {       const vm = this;       const url = `${this.$root.config.apiHost}/posts`;       const headers = new Headers({         "Content-Type": "application/json"          Accept: "application/json"       });       const payload = {         post: {           body: `${this.body}`         }       };        fetch(url  {         method: "POST"          headers: headers          body: JSON.stringify(payload)       }).then(function (response) {         if (response.ok) {           response.json().then(function (json) {             vm.$emit("addPost"  json);           });         }       });     }   } }; </script> 

Avant de monter le composant on récupère la liste des Post en vigueur auprès de l’API pour hydrater nos données.

Puis dans le mounted() on initialise notre EventSource. À chaque fois qu’on recevra un payload JSON on viendra enrichir notre collection ce qui entrainera un rafraichissement de la vue associée.

Du côté du addPost on fait une vérification préalable pour éviter d’ajouter deux fois le Post à la collection (ce qui arrive pour le client qui crée le Post qui l’ajoute après que l’appel API ait fonctionné et qui le reçoit également via le stream).

Côté serveur on monte une API rails. Voilà à quoi ressemble une action de stream toute simple :

class PostsController < ApplicationController   include ActionController::Live    def simple_stream     response.headers["Content-Type"] = "text/event-stream"      sse = SSE.new(response.stream)      1.upto(10).each do |index|       sse.write({ count: index })       sleep(3)     end   ensure     sse.close   end end 

Pour tester notre action :

curl -N -H "Accept: text/event-stream" http://stream.social-network.syn/posts/simple_stream 

Simple stream counter

Attention à l’heure où j’écris ces lignes un bug connu lié aux ETags empêche le streaming. Le middleware bufferise la réponse ce qui fait qu’en reproduisant ce code vous risquez de recevoir les 10 payloads d’un seul coup.

Dans mon cas j’ai simplement désactivé le middleware concerné dans le fichier config/application.rb :

config.middleware.delete Rack::ETag 

Notre exemple est intéressant mais on est encore loin de ce qu’on veut faire. Pour notifier nos clients lors de la création d’un Post on va utiliser le mécanisme de pub/sub disponible dans Redis depuis la version 5.

class Post < ApplicationRecord   after_create :notify_creation    private    def notify_creation     Rails.configuration.redis_client.publish("post:creation"  self.to_json)   end end 

Après création de notre Post on notifie un évènement sur le canal post:creation avec notre objet sérialisé.

Rails.configuration.redis_client correspond à un client Redis initialisé au lancement de mon app.

Maintenant nous pouvons réagir à ces évènements dans notre contrôleur.

def stream   redis = Redis.new(host: ENV.fetch("REDIS_HOST"))   response.headers["Content-Type"] = "text/event-stream"    sse = SSE.new(response.stream)    redis.subscribe("post:creation"  "heartbeat") do |on|     on.message do |channel  data|       begin         sse.write(data)       rescue ActionController::Live::ClientDisconnected         redis.unsubscribe("post:creation"  "heartbeat")       end     end   end ensure   sse&.close   redis&.quit end 

La première chose à prendre compte c’est qu’il faut impérativement faire le ménage pour éviter les connexions fantômes d’où le ensure.

Dans notre action nous allons initialiser un nouveau client Redis afin de souscrire au topic qui nous intéresse post:creation. Ici on ne peut pas ré-utiliser notre client Rails.configuration.redis_client car on veut que chaque connexion ouverte à notre stream soit notifiée de l’évènement. En utilisant une même connexion Redis l’évènement serait dépilé et 1 seul des clients connectés serait notifié.

Le redis.subscribe est une boucle infinie en attente d’évènement. Lorsqu’on va recevoir un message on va donc rentrer dans le on.message et l’écrire dans la connexion ouverte avec le client.

Si l’écriture échoue notamment si le client s’est déconnecté une exception ActionController::Live::ClientDisconnected sera levée. C’est à cette occasion qu’on se désabonnera via redis.unsubscribe ce qui aura pour effet de sortir de la boucle.

Ce faisant on passera dans notre ensure qui va nettoyer la connexion SSE et celle à Redis.

Un exemple en images j’ouvre la connexion au stream puis je crée un nouveau post via mon API :

Stream à l'écoute

Éviter les fuites

Si on s’en tient au code que j’ai expliqué il ne faudra pas attendre un âge avancé pour avoir des fuites.

En effet chaque nouveau stream va monopoliser un thread de notre serveur applicatif. Mettons que mon serveur (Puma) lance 8 threads applicatifs dès que je vais avoir 8 clients connectés mon serveur ne pourra plus répondre à aucune requête aussi bien stream que création de ressources sur mon API.

C’est d’autant plus gênant que si mes clients se sont déconnectés le ménage ne sera fait que lors de la prochaine création d’un Post (le seule moment où on passera dans le ActionController::Live::ClientDisconnected).

Pour éviter cela on va mettre en place un mécanisme de type heartbeat. À intervalle régulier on va envoyer un message sur un canal dédié. En faisant suivre ce message au client on pourra se rendre compte s’il est fermé ou non. Du côté du client lorsqu’on recevra un message de ce type on n’en fera simplement rien.

Nos applications backend tournant intégralement sous Docker j’utilise le mécanisme de healthcheck intégré. Ici toutes les 5 secondes j’envoie un message sur /heartbeat.

services:   app:     << : *app_common     environment:       VIRTUAL_HOST: app.social-network.syn       VIRTUAL_PORT: 3000     healthcheck:       test: ["CMD"  "curl"  "-f"  "http://localhost:3000/heartbeat"]       interval: 5s       timeout: 5s       retries: 5       start_period: 30s 

Et dans mon fichier de Rackup associé je définis une route dédiée qui sera en charge d’envoyer un évènement sur le canal heartbeat :

map "/heartbeat" do   run -> (_env) {     Rails.configuration.redis_client.publish("heartbeat"  "ping")     [200  { "Content-Type" => "text/plain" }  ["alive"]]   } end 

Dans mon action j’ai soucrit aux deux topics je vais donc également recevoir ces évènements.

Différencier les streams du reste

À ce stade on a une solution qui tient la route mais pas vraiment résistante à toute épreuve.

En effet si j’ai N clients qui se connectent et monopolisent tous mes threads mon API ne va plus répondre mon healthcheck va échouer et mon orchestrateur va probablement tuer le conteneur qui fait tourner l’application. Pas glop.

Pour palier ce problème je mets en place deux services un propre à mon API et l’autre pour les streams. Les deux chargent la même base de code.

services:   app:     << : *app_common     environment:       VIRTUAL_HOST: app.social-network.syn       VIRTUAL_PORT: 3000     healthcheck:       test: ["CMD"  "curl"  "-f"  "http://localhost:3000/heartbeat"]       interval: 5s       timeout: 5s       retries: 5       start_period: 30s    stream:     << : *app_common     environment:       VIRTUAL_HOST: stream.social-network.syn       VIRTUAL_PORT: 3000     command: /bin/sh -c "rm -f /app/tmp/pids/server.stream.pid && puma -C config/puma.stream.rb" 

Mon app front s’abonne donc aux events sur le service dédié (stream.social-network.syn). S’il échoue pour une raison quelconque cela ne bloque pas l’API dans la logique d’amélioration progressive qu’on voulait.

J’utilise même un fichier de configuration différent pour Puma permettant de faire varier le nombre de threads utilisés d’un cas à l’autre.

Petit bonus vous avez dans Puma un module activable permettant de contrôler à un instant T le nombre de threads utilisés. Il dispose d’un mécanisme d’authentification built-in permettant de le brancher sur des systèmes tels que Prometheus par exemple.

activate_control_app "tcp://0.0.0.0:9000"  { no_token: true } 

On peut ensuite requêter son service de la sorte :

→ curl --silent http://172.18.0.20:9000/stats | jq {   "started_at": "2021-04-13T14:40:38Z"   "backlog": 0   "running": 64   "pool_capacity": 64   "max_threads": 64   "requests_count": 1 } 

Ici mon Puma à traité une requête et la capacité du pool est de 64 ce qui correspond au nombre de threads max que j’ai défini dans ma configuration.

Si j’ouvre un stream :

curl -N -H "Accept: text/event-stream" http://stream.social-network.syn/posts/stream 
→ curl --silent http://172.18.0.20:9000/stats | jq {   "started_at": "2021-04-13T14:40:38Z"   "backlog": 0   "running": 64   "pool_capacity": 63   "max_threads": 64   "requests_count": 2 } 

La capacité de mon pool est maintenant de 63 et le nombre de requêtes traitées augmente en conséquence.

Conclusion

Les Server-Sent Events sont une bonne solution à ne pas rejeter d’emblée. Leur support est excellent et dès lors qu’on n’a pas besoin d’une connexion bi-directionnelle il est légitime de se poser la question plutôt que de partir tête baissée vers les WebSockets.

À lire aussi

Fresque numérique miniature image
16 avril 2025

Fresque du Numérique

Lire la suite

intelligence artificielle Ouicommit miniature image
17 mars 2025

Ouicommit – L’intelligence artificielle en entreprise, on y est ! 

Lire la suite

Image miniature Hackathon Women in Tech
13 mars 2025

Hackathon Women in Tech :  un engagement pour une tech plus inclusive 

Lire la suite

image miniature les nouveautés Atlassian
26 février 2025

Les nouveautés Atlassian en 2025

Lire la suite

Articles associés

Fresque numérique miniature image
16 avril 2025

Fresque du Numérique


Lire la suite
intelligence artificielle Ouicommit miniature image
17 mars 2025

Ouicommit – L’intelligence artificielle en entreprise, on y est ! 


Lire la suite
Image miniature Hackathon Women in Tech
13 mars 2025

Hackathon Women in Tech :  un engagement pour une tech plus inclusive 


Lire la suite

À propos

  • Qui sommes-nous ?
  • Références
  • RSE
  • Ressources

Offres

  • Applications métier
  • Collaboration des équipes
  • Sécurisation et optimisation du système d’information
  • Transformation numérique

Expertises

  • Développement logiciel
  • DevSecOps
  • Intégration de logiciels et négoce de licences
  • Logiciel de CRM et de gestion
  • UX/UI design
  • Accessibilité Numérique
  • Démarches simplifiées
  • Formations Atlassian

Carrières

  • Pourquoi rejoindre Ouidou ?
  • Nous rejoindre
  • Rencontrer nos collaborateurs
  • Grandir chez Ouidou

SIEGE SOCIAL
70-74 boulevard Garibaldi, 75015 Paris

Ouidou Nord
165 Avenue de Bretagne, 59000 Lille

Ouidou Rhône-Alpes
4 place Amédée Bonnet, 69002 Lyon

Ouidou Grand-Ouest
2 rue Crucy, 44000 Nantes

Ouidou Grand-Est
7 cour des Cigarières, 67000 Strasbourg

  • Linkedin Ouidou
  • GitHub Ouidou
  • Youtube Ouidou
© 2024 Ouidou | Tous droits réservés | Plan du site | Mentions légales | Déclaration d'accessibilité
    Nous contacter
      preload imagepreload image
      Quatres astuces pour maitriser ses rebases
      Quatres astuces pour maitriser ses rebases
      23 juin 2021
      Vivatech 2021
      Vivatech 2021
      2 juillet 2021