Cette question me turlupinait dans le trajet retour, et j'ai essayé de pousser plus loin la réflexion en explorant cette piste et diverses alternatives.
Trop de services dans un contrôleur
En faisant un service pour chaque fonctionnalité, voire parfois plusieurs services pour une même fonctionnalité, ne risque-t-on pas de devoir injecter beaucoup trop de services à un même contrôleur ? Partons du principe qu'on a correctement isolé tout le modèle de données et les traitements métier dans des services, et que le contrôleur se contente de publier dans le scope ce qui est utilisé dans les expressions des templates. Il ne doit y avoir aucun traitement complexe dans les contrôleurs, qui devraient donc être faciles à tester.
Si l'on a un contrôleur auquel on injecte de nombreux services, c'est clairement le signe d'un problème, et pas seulement pour tester ce contrôleur. On est typiquement dans le cas de l'anti-pattern “God object”, l'objet qui en fait beaucoup trop. Evidemment il va être complexe à tester, mais il est surtout trop complexe tout court. Le fait qu'il soit difficile à tester n'est qu'un révélateur, pas le cœur du problème.
Que faire avec un tel contrôleur ? On peut déjà sans doute le découper en plusieurs contrôleurs. S'il s'agit d'un contrôleur associé à une des vues de l'application (le template associé à une route), une vue certainement assez complexe, il est quand même probable que certains des services injectés ne sont utilisés que dans une portion de la vue. On peut alors associé un contrôleur séparé à cette portion de la vue, avec une directive ngController sur l'élément HTML, ce qui crée un scope enfant. Ce nouveau contrôleur n'aura besoin que d'un seul service, ou d'un tout petit nombre de services, pour l'initialisation de ce scope enfant, qui hérite aussi de tout ce qui est publié dans son scope parent.
Il ne faut pas hésiter à créer plusieurs contrôleurs à l'intérieur d'une vue, le code sera bien plus lisible avec plusieurs petits contrôleurs qu'avec un seul contrôleur obèse, et chacun sera plus facile à tester, parce qu'on aura réduit le nombre de dépendances.
On peut aussi sans doute limiter le nombre de services à injecter en faisant des services de plus haut niveau. Les fonctionnalités un peu complexe de l'application méritent d'être découpées en plusieurs services, mais il s'agit de découper l'implémentation, pas l'interface. Si ça revient à devoir injecter plein de petits services, ça ne va pas. Il faut faire un service de haut niveau en frontal, à injecter dans les contrôleurs et autre services qui l'utilisent, et dont l'implémentation est découpée en plusieurs services plus élémentaires. Mais les services élémentaires ne doivent pas être manipulés ni même connus de l'extérieur, ils doivent être encapsulés dans le service de plus haut niveau. Donc le fait d'avoir des services du niveau d'abstraction que manipule le contrôleur doit permettre d'éviter cette multiplicité des injections.
C'est par ces deux méthodes, en séparant des parties du code dans des petits contrôleurs liés à des portions de la vue et en créant des services de plus haut niveau, qu'on va pouvoir régler le problème à la source, plutôt que d'essayer de trouver comment tester plus facilement un contrôleur mal écrit.
Communication entre services ou par événements ?
Vaut-il mieux faire communiquer plutôt les services entre eux, ou passer par les événements d'AngularJS pour découpler d'avantage.
Le fait de découpler est généralement plutôt une bonne chose, mais même des bonnes choses il ne faut pas abuser. Les événements d'AngularJS ont trois caractéristiques très importantes :
- ils s'appliquent sur les scopes dans la vue, ce qui implique qu'il ne peut pas y avoir d'événement entre les services, en l'absence de scope
- la propagation se base sur l'arborescence des scopes, vers la racine ou dans l'autre sens
- il peut y avoir plusieurs listeners d'un même événement, ou aucun
Ça veut dire qu'utiliser un événement n'a du sens que s'il faut prévenir de quelque chose un nombre inconnu de listeners attachés aux scope parents ou enfants suivant le sens de propagation choisi, donc en quelque sorte aux éléments HTML parents ou enfants. Ce n'est pas du tout le même paradigme qu'un appel de méthode sur un objet (un service injecté).
Utiliser un événement pour éviter d'appeler directement une méthode d'un service, et surtout éviter d'avoir à l'injecter, c'est détourner le concept pour en faire une utilisation illogique. Ce n'est certainement pas la meilleure façon de rendre le code lisible et facile à comprendre.
Ça revient à compliquer le code, en détournant le concept d'événement, en devant enregistrer un listener et émettre l'événement, pour simplement espérer simplifier un peu les tests. Combattre le mal par le mal, ce n'est pas une bonne idée, au moins en informatique, donc inutile d'essayer de corriger un problème par l'ajout d'un autre problème.
Clairement, il faut limiter l'usage des événements du scope aux rares cas où les trois caractéristiques citées plus haut s'appliquent pleinement, et ne surtout pas essayer d'en faire l'outil générique qu'ils ne sont pas.
Les événements simplifient-ils vraiment les tests ?
Imaginons quand même qu'on veuille utiliser des événements du scope dans l'objectif des tests unitaires. Les rendent-ils vraiment plus simple ?
Avec un couplage par événement, il n'y a aucun paramètre à injecter. Pour les tests qui ne sont pas liés à l'événement, il n'y a donc rien de particulier à faire, pas d'objet à substituer. Mais bien sûr, il faut aussi tester que l'événement est bien déclenché lorsqu'il doit l'être et avec les bonnes données. Deux solutions sont possibles :
- utiliser un faux scope, pour vérifier l'appel de $emit() ou $broadcast()
- utiliser une mini hiérarchie de scopes avec un listener, pour vérifier qu'il reçoit bien le bon événement
La première est la plus simple : un même mock object du scope pourra faire les vérifications concernant les émissions d'événements pour tous les tests des contrôleurs.
Et avec un service injecté comme dépendance du contrôleur ? Il faut lui substituer un faux service, pour tester seulement le code du contrôleur et non celui du service. Pour les tests qui ne concernent pas ce service, on peut carrément le remplacer par null. Mais pour ceux qui entraînent l'utilisation du service, il faut le remplacer par un objet factice spécifique à ce service, et vérifier les appels de méthodes qui lui sont faits.
On peut créer cet objet factice du service en utilisant un framework de doublure. Il en existe plusieurs en JavaScript, donc un qui est inclus dans Jasmine. Celui-ci peut créer des objets factices, mais aussi remplacer une méthode réelle d'un objet réel par une implémentation factice. Il suffit d'une seule ligne pour obtenir un mock objet avec les méthodes souhaitées.
Donc en fin de compte, si l'on utilise un framework de doublure, le test d'un contrôleur avec injection de service sera aussi simple à écrire que celui d'un contrôleur avec événement.
Evénements entre les services
En isolant chaque fonctionnalité de l'application dans un service, éventuellement subdivisé en plusieurs services plus petits, on va créer pas mal de dépendances entre des services. Bien sûr là aussi il faut s'inquiéter d'un même service qui en prendrait beaucoup d'autres en dépendance, un tel service mériterait sans doute un bon refactoring. Mais on va couramment avoir un service qui prend comme dépendances un ou deux autres services.
Dans une application plutôt conséquente, on peut vouloir limiter le couplage entre certains services, et créer un système d'événements. Attention, il ne s'agit évidemment pas d'utiliser les événements des scopes pour faire communiquer deux services. Les services ne sont pas liés aux scopes. Mais on peut introduire de toutes pièces, sous la forme d'un service bien sûr, un pattern Observer (ou Listener) utilisable entre les services de l'application.
Dans ce cas il n'y a évidemment aucune propagation, simplement un événement qui est envoyé par un unique service de gestion des événements aux listeners enregistrés. L'intérêt est évidemment le découplage entre le déclencheur de l'événement et un nombre quelconque d'observateurs.
Il n'existe pas de système d'événements de ce type en standard dans AngularJS, mais il est très facile d'en faire une implémentation basique bien suffisante, et d'un usage plus large que les événements des scopes.
On peut éventuellement lier ce système d'événements à celui des scopes, en le mettant dans le $rootScope pour propager aux services inscrits, et qui ne sont pas nécessairement publiés dans le scope, des événements tels que les $routeChange*.
Je me souviens d'avoir vu passer un retour d'expérience qui affirmait que sur une grosse application AngularJS, il était indispensable d'avoir un véritable Event Bus. Ou un autre pattern équivalent, mais je ne sais plus où j'ai lu ça. Je ne serais peut-être pas aussi catégorique au point d'en faire une obligation. Mais sur une grosse application, on a vraiment intérêt à faire du découpage, et à la diviser en plusieurs parties bien isolées. Et un Event Bus sert justement à découpler les différentes parties, en remplaçant une dépendance entre deux parties par une dépendance de chacune sur l'Event Bus. On pourra signaler un changement de contexte important dans l'application sans se préoccuper directement des impacts, car dans n'importe quelle autre partie de l'application les services concernés par ce changement se seront inscrits auprès de l'Event Bus et en seront notifiés.
En fait sur une grosse application, il est très probable qu'un Event Bus entre les services s'avère très utile, et si je ne le présente pas comme une obligation, c'est juste parce que je préfère qu'un pattern soit introduit quand on en a constaté le besoin, plutôt que sur des recommandations trop générales, ou parce que ça fait chic.
Et il ne faut surtout pas essayer de remplacer ainsi toutes les dépendances entre les services, et tous les appels de méthodes par des événements, ça n'aurait aucun sens. Il s'agit juste d'introduire un petit nombre d'événements, pour retirer quelques liens trop directs entre des parties autrement séparées de l'application. L'essentiel des appels de méthodes entre services doivent rester inchangés, sur des services injectés comme dépendances.
***
Voilà pour ces quelques réflexions et digressions, et juste en résumé s'il faut retenir 4 conseils :
- découpez vos contrôleurs, en extrayant des services et des sous-contrôleurs
- n'utilisez les événements des scopes que dans le cadre très restreint où ils peuvent être pertinents
- dans les tests unitaires, utilisez un framework pour créer des mocks objets des services à injecter
- créez un service Event Bus pour gérer des événements entre les services, si la taille ou la structure de votre application le justifie
C'est le principe numéro 1 du S.O.L.I.D. qui est en défaut dans une classe qui demande trop de paramètres à injecter : Single Responsibility. C'est symptomatique d'une classe qui a plus d'une responsabilité lorsqu'elle est trop difficile à tester.
RépondreSupprimerUn seul remède : le Refactoring pour découpler. Et c'est valable quelque soit le langage orienté objet.