Angular 2, SSE et la détection de changements

Dans cet article, nous allons vous faire un retour d’expérience sur la résolution d’un bug surprenant dû à la détection de changements auto-magique d’Angular 2.

Le contexte

Nous travaillons actuellement sur une application type Dashboard : un écran sur lequel sont affichées différentes briques qui affichent des informations variées, provenant d’une API.
Les briques doivent se rafraîchir afin d’afficher les dernières valeurs lorsque c’est nécessaire. Inutile cependant de sortir les websockets : les Server-Side Events (SSE) permettent au serveur de pousser les nouvelles informations au client lorsqu’elles arrivent.
Cependant, le nombre de connexions SSE est limité pour un client. Il n’est donc pas envisageable d’avoir une connexion SSE pour chaque brique du Dashboard.

Sachant qu’Angular 2 a adopté RxJS, nous avons choisi d’utiliser les Observables afin de propager les événements de façon réactive entre les briques composantes du Dashboard.

Le flux de Server-Side Events est converti en Observable dans le Dashboard. Cet Observable est filtré pour que chaque composant récupère les événements qui l’intéresse.

Mise à jour en temps réel avec les Server-Side Events et les Observables

Dans le composant Dashboard, nous créons un Observable mainStream qui émet un nouvel événement à chaque fois que le serveur pousse un événement. En gros, nous convertissons notre flux SSE en flux d’événements via RxJS :

getIndicatorsStream(): Observable {
  return Observable.create((observer) => {
    let eventSource = this.sseService
                        .createEventSource('http://localhost:8080/api');
    eventSource.onmessage = (event) => observer.next(JSON.parse(event.data));
    eventSource.onerror = (error) => observer.error(error);
});

Dans le composant OctoNumber (une brique qui a pour rôle d’afficher une valeur numérique), nous créons un nouvel Observable à partir de mainStream en filtrant sur les valeurs qui nous intéressent.
Il suffit ensuite de passer cet Observable dans le pipe async afin qu’Angular 2 souscrive à celui-ci et mette à jour la valeur affichée.

Le problème, c’est que ça ne marche pas.

La phase de debug

Ça ne marche pas, pourtant :

  • Si on remplace mainStream par un Observable.interval( 1000 ) qui incrémente un compteur chaque seconde, alors ça fonctionne !
  • Si on log les valeurs qui passent dans mainStream, on voit bien qu’elles sont émises. Simplement, elles ne s’affichent pas.
  • Si une autre brique (disons OctoQuote) provoque un rafraîchissement de la vue, alors la valeur de la brique qui nous intéresse (OctoNumber) est bien mise à jour en même temps.

À ce moment là, le debug est parsemé de “WTF ?!?” à mesure que les hypothèses fusent et semblent se contredire :

  • Est-ce-que le pipe async fonctionne ? Il semblerait que oui quand on lui donne un Observable classique…
  • Est-ce-que les événements du serveur sont poussés dans notre mainStream ? Ça à l’air d’être le cas. D’ailleurs, si on voit les logs passer, c’est bien que nous faisons un subscribe sur l’Observable avec le pipe async, sinon il ne se passerait rien.
  • Est-ce-que c’est une histoire d’instance ? Mais le résultat est le même avec une seule brique…

L’illumination

En fait, c’est un problème de détection de changements automatique d’Angular !

En effet, si le pipe async de notre template fait bien un subscribe de l’Observable qu’on lui passe, ce n’est pas ça qui déclenche directement le rafraîchissement du DOM. Pour cela, il faut qu’Angular ait détecté un changement.
C’est la raison pour laquelle la valeur se rafraîchit très bien lorsque d’autres briques font des mises à jour : le rafraîchissement du DOM fait apparaître la dernière valeur de notre brique à ce moment-là.

Pourquoi l’émission d’un nouvel événement dans l’Observable n’est pas détectée par Angular ?
Parce qu’il est exécuté dans l’EventSource : c’est le callback « eventSource.onmessage » qui fait le job. Et Angular n’est pas notifié de ça !

Pour résoudre ce problème, il faut donc qu’Angular soit au courant lorsque le callback est exécuté. Et c’est à ça que sert zone.js, dont dépend Angular 2.
Donc il « suffit » d’instancier une NgZone et d’appeler « zone.run( callback ) » à la place de notre callback. Ainsi, Angular est notifié qu’un changement s’est produit.

getIndicatorsStream(): Observable {
  return Observable.create((observer) => {
    let eventSource = this.sseService
                        .createEventSource('http://localhost:8080/api');
    eventSource.onmessage = (event) => {
      this.zone.run(() => observer.next(JSON.parse(event.data)));
    };
    eventSource.onerror = (error) => observer.error(error);
});

Et ça marche !

On peut alors consommer mainStream comme un Observable classique dans nos composants : le pipe async va « comprendre » qu’il y a eu un changement lorsqu’il y aura un nouvel événement.

La leçon

De ce point de vue, Angular n’a pas changé : c’est toujours une boîte noire qui fait plein de magie pour que ça marche facilement tout seul, pour tout le monde. Quand on arrive dans un cas de figure qui n’est pas standard en revanche, il vaut mieux connaître tous les concepts du framework pour arriver à résoudre ça proprement… et y’a pleiiins de concepts.

Les adeptes de la programmation réactive pourront être désappointés également. Si Angular 2 utilise les Observables de RxJS, ça reste du code passif sous le tapis.

Et cela peut surprendre, car un code réactif n’aurait jamais posé de problème : le rendu de la vue aurait été une simple fonction, appelée en subscribe à l’Observable. Pas de concept de zone, de détection hors-application, etc. Juste une fonction… Qui marche tout le temps.

Edit du 03/11/16 : une issue pour supporter EventSource est actuellement ouverte sur zone.js.

Voici les liens qui nous ont aidé à comprendre tout ça, si vous souhaitez creuser le sujet :

3 commentaires sur “Angular 2, SSE et la détection de changements”

  • Hey, tout d'abord merci pour l'article :) "Si Angular 2 utilise les Observables de RxJS, ça reste du code passif sous le tapis" tu peux développer stp ? AMHA les observables n'ont rien de passif dans la machinerie Angular, notamment grâce à leur gestion asynchrone et aussi en se proposant comme solution pour remplacer la gestion du cycle de vie de l'application par Event, chaotique dans la plupart des projets sous Angular v1.
  • Bonjour Jeff ! Quand je dis qu’Angular 2 est "passif sous le tapis", c’est pour contraster avec l’approche réactive qui voudrait que le rendu de la vue soit une fonction exécutée en souscription d’un Observable. Dans un tel cas de figure, la vue est rendue seulement lorsqu’un nouvel événement est émis également… mais peu importe la source de l’événement : ça réagit dès qu’il y en a un. À ce sujet on peut comparer Angular 2 et un framework tel que Cycle.js, ou un langage tel que Elm. Je dirais que ces derniers sont réactifs, mais "pas tout à fait" pour Angular 2. Nul doute que la détection de changements d’Angular 2 est bien mieux fichue et performante que le dirty checking d’Angular 1. Mais le framework n’est pas allé jusqu’à faire du réactif. Ils ont plutôt adopté la solution de la zone temporelle où les mises à jour sont faites en même temps si c’est nécessaire, afin de limiter le redraw (concept que l’on retrouve chez Ember ou React). Si on prend l’implémentation du pipe async (? angular/common/src/pipes/async_pipe.ts#L137), on remarque qu’il souscrit à l’Observable et met à jour la valeur de manière réactive… puis laisse un marqueur pour indiquer au Change Detector qu’il faudra rafraîchir la vue. C’est logique, vu que la manière dont Angular met à jour la vue. Mais du coup, ça ne marche pas si les événements de l’Observable sont émis en dehors de la zone temporelle d’Angular. D’où le bug étrange qui nous a surpris car nous ne connaissions pas le fonctionnement d’Angular "sous le capot". Ce que je trouve dommage, personnellement, c’est d’avoir quelque chose de qui marche tout seul, mais qui est assez complexe sous le capot pour faire cette magie. Du coup ça brille, mais quand ça tombe en panne, ça demande d’aller décortiquer la mécanique.
  • Merci pour le partage! J’avais imaginé utiliser SSE avec Angular maintenant je sais que ça fonctionne ????
    1. Laisser un commentaire

      Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *


      Ce formulaire est protégé par Google Recaptcha