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.
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
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 :
É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.