Évaluons votre projet

Server-Sent Events, le mal-aimé ?

Publié le 2 juillet 2021 par Martin Catty | dev - javascript

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

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.


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