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.
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 :
- Zones in Angular 2
- Understanding Zones
- Angular 2 Change Detection explained
- Change Detection in Angular 2
- [StackOverflow] Creating an RxJS Observable from a SSE
- [StackOverflow] Angular 2 View Not Changing After Data Is Updated
- [StackOverflow] How to update view after change in Angular 2 after Google event listener fired