S'il y a une fonctionnalité que les
développeurs qui débutent sur AngularJS sous-estiment énormément,
c'est bien les services. Je me rend compte régulièrement en
formation ou lors d'audits que beaucoup de code pourrait être
largement simplifié en utilisant plus efficacement les services. Ça
n'a d'ailleurs rien d'étonnant, on commence à partir des exemples
qu'on trouve sur internet, qui sont des exemples courts et n'ayant
évidemment pas toute la structure d'une vraie application. Des
exemples qui montrent comment ça marche, pas comment il faut faire.
Dans une application non triviale, au
moins 90 % du code JavaScript devrait être dans les services. Il
peut y avoir quelques directives et filtres bien sûr, dont une
partie peut d'ailleurs être commune à plusieurs applications, mais
ce sont les contrôleurs qui doivent être aussi réduits que
possible. Un contrôleur AngularJS est juste une fonction
d'initialisation du scope correspondant, et il doit essentiellement
se limiter à cela : faire quelques affectations dans l'objet scope.
Il ne doit pas faire de requêtes HTTP, ni des traitements sur les
données, ni gérer le stockage des données.
Qu'est-ce qu'un service ?
Un service n'est rien d'autre qu'un singleton publié
sous un certain nom. On peut publier n'importe quelle valeur comme
service, un objet JavaScript (y compris un tableau ou une fonction),
ou même une valeur primitive : chaîne de caractères, nombre,
booléen.
AngularJS gère l'injection des
services là où ils sont indiqués comme dépendances, dans des
contrôleurs, dans d'autres services, dans des directives ou des
filtres. Il s'occupe aussi d'instancier le service, si ce n'est pas
un objet préexistant qui est publié. Une fois instancié et publié,
chaque injection du service fournira le même objet, par référence,
ou une copie de la même donnée primitive. C'est pourquoi un service
permet de stocker des données qui seront conservées tant que
l'application s'exécute, et qui peuvent bien sûr être modifiées
en cours de route.
Pour les différentes façons de créer
un service, voir cet article antérieur :
- Les différentes façons de créer un service avec AngularJS
- Les différentes façons de créer un service avec AngularJS
Que mettre en service ?
Pour faire simple, il faut écrire l'essentiel du code d'une application sous la forme de services. Si l'on excepte les directives et les filtres, et qu'on limite les contrôleurs au strict minimum qui est de publier dans le scope les données et fonctions utilisées dans les templates, tout le reste va dans les services.
La liste qui suit est donc loin d'être exhaustive.
Tout le code métier
En particulier, tout le code métier, sans exception, doit être organisé en services cohérents qui correspondent aux différentes fonctionnalités de l'application. C'est la seule façon de s'y retrouver quand l'application prend de l'ampleur.Si l'on peut concevoir qu'on prenne des raccourcis dans les exemples, et qu'on puisse avoir des règles métiers dans les contrôleurs et les vues, c'est totalement exclus dans une vraie application sur laquelle différents développeurs interviennent, et ce sur une durée plus ou moins longue. Comment s'y retrouver si chaque fonctionnalité de l'application est éparpillée dans de multiples contrôleurs et templates ? Même si AngularJS est un framework qui requiert peu de code, si on ne l'organise pas correctement il sera vite ingérable.
Ça veut dire que pour chaque fonctionnalité de l'application, il faut créer un service qui en regroupe tout les aspects, qui gère les données et fournit toutes les méthodes utiles. Attention en particulier aux règles métier qui peuvent facilement se dissimuler dans les conditions des templates.
Par exemple si l'on crée une application qui gère un compte bancaire, on peut vouloir ajouter une classe CSS 'warning' pour afficher en rouge le solde négatif d'un compte. Si on met des ng-class="{warning: compte.solde < 0}", ça n'a l'air de rien, mais c'est une règle métier qu'on a glissé dans les templates, sans doute à plusieurs endroits. Et le jour où la règle métier change, parce qu'il faut maintenant mettre la classe 'warning' lorsque le solde est en dessous du découvert autorisé, il va falloir revoir tous les templates, au risque d'en oublier et d'avoir des incohérences dans l'application. Alors que si l'on a fait ça proprement, en mettant la règle métier dans une fonction hasWarning() du service 'compte', on n'a qu'un seul endroit à changer pour la modifier.
A l'exception de certaines conditions purement techniques, comme le fait de tester qu'une propriété est définie, toutes les autres se rapportent plus ou moins à des règles métier, et doivent aller dans les services. Avantage supplémentaire, le fait de les mettre sous la forme de fonctions dans les services les rend testables, contrairement aux expressions en dur dans les templates.
Le code de présentation
Les services ne se limitent pas au code métier de l'application. Il y a aussi toute une partie du code, qui est plutôt du code de présentation général sans lien direct avec le métier, qui mérite aussi d'être organisé en services.J'indique plus loin deux exemples (dans des articles séparés) qui sont directement des fonctionnalités liées à la présentation : la conservation des valeurs des critères d'une page de recherche, et les notifications à l'utilisateur.
Ce sont juste deux exemples de fonctionnalités qui doivent être factorisées dans des services, même si on ne les utilise qu'à un seul endroit, car le fait de créer un service permet d'isoler la fonctionnalité, et de la tester plus facilement et indépendamment de tout contrôleur. Et ça améliore nettement la lisibilité du code de l'application. Ce n'est pas quelque chose de spécifique à AngularJS, c'est tout simplement la technique de refactoring "Extract Class", enfin plutôt "Extract Object" en JavaScript : le fait d'extraire un objet du code du contrôleur permet de lui donner un nom significatif (le nom du service), de nommer ses méthodes, et de l'isoler du reste du code. Les bonnes pratiques de la programmation objet ont toujours cours dans une application AngularJS.
Les requêtes
Les requêtes HTTP à des web services ne sont qu'un exemple de code métier qui n'a rien à faire dans les contrôleurs.Bien sûr dans les petits exemples on en voit partout, parce que c'est simple et que ça permet de montrer uniquement le fonctionnement des requêtes sans devoir expliquer tout le reste. Je fais aussi de tels exemples. Mais il faut les prendre pour ce qu'ils sont, des démonstrations techniques et surtout pas des exemples à suivre de la façon d'architecturer une vraie application.
Les requêtes à des serveurs posent un problème particulier quand on veut les mettre dans un service, c'est la gestion de l'asynchronisme. Comment écrire un service fournissant des données, mais qui doit faire des requêtes pour les obtenir, et donc qui n'a pas encore les données au moment où il devrait les renvoyer ?
Le service ne peut évidemment pas renvoyer des données qu'il n'a pas encore. Il n'est pas possible non plus d'attendre l'arrivée des données dans la fonction du service qui est appelée, car l'interpréteur JavaScript du navigateur est mono-thread, et la possibilité d'attendre n'existe pas. La fonction doit renvoyer quelque chose immédiatement. Trois solutions sont couramment utilisées :
- utiliser une fonction callback : la méthode du service ne
renvoie pas de valeur, mais prend en paramètre une fonction
callback qui sera appelée plus tard en lui passant les données reçues
- renvoyer un objet ou un tableau vide, qui sera alimenté plus
tard ; c'est ce que fait $resource
- renvoyer une promise, qui sera résolue avec les données
quand elles seront reçues du serveur
Petite remarque au passage sur les promises, depuis la toute récente version 1.2.0-rc3 les promises ne peuvent plus être utilisées directement dans les expressions AngularJS. Jusque là l'interpréteur d'expressions remplaçait chaque promise par sa valeur de résolution une fois qu'elle était résolue, ce qui permettait d'utiliser une promise de résultat différé comme le résultat lui-même. Cette possibilité est maintenant deprecated, et a même été désactivée par défaut.
Le modèle de données
Un contrôleur AngularJS publie dans son scope les données nécessaires à l'évaluation des expressions du template.Ca ne veut pas dire que le modèle d'une application AngularJS doit résider dans les scopes, bien au contraire. Il doit être géré, et conservé quand c'est nécessaire, dans des services.
Certains de ces services seront stateless, et d'autres statefull s'il s'agit d'une entité du modèle qui doit être conservée de façon globale, comme par exemple l'utilisateur connecté. Au contraire, si une vue de l'application affiche dans un formulaire une instance d'une collection d'entités fournies par un serveur REST par exemple, dans ce cas le service va être stateless, et simplement renvoyer pour qu'elle soit publiée dans le scope l'entité correspondant à la vue courante.
Ça dépend si l'on veut que l'instance soit conservée de façon globale et qu'on la retrouve dans le même état quand on revient sur la vue, ou s'il faut que cette vue charge une instance précise dont l'identifiant est passé dans l'URL de la route.
Mais dans tous les cas, qu'il conserve ou non l'instance courante, le service contient tout le code des traitements qui s'y appliquent, soit directement comme des méthodes du service, soit comme des méthodes ajoutées à l'instance.
Le fait que le modèle soit géré dans les services permet aussi d'injecter ces services dans d'autres services, et donc de bien organiser le découpage de son modèle de données (incluant les traitements) sous la forme d'un ensemble de services correctement isolés et qui coopèrent, chose totalement impossible si les données se trouvent uniquement dans les scopes.
Différents patterns sont utilisables pour créer les services qui travaillent sur des entités persistantes, comme par exemple le pattern Active Record popularisé par Ruby on Rails, le pattern Repository du DDD (Domain Driven Design), ou encore pour des services statefull le pattern Entity Home abondamment utilisé dans le framework Seam.
Découper les services
Après avoir dit de rassembler tous les constituants de chaque fonctionnalité de l'application dans un même service, me voici à recommander de les découper.
Comme de façon générale en programmation objet, il vaut mieux éviter d'avoir des services obèses. Si une fonctionnalité peut être découpée, parce qu'elle recouvre plusieurs aspects distincts, ou qu'elle concerne plusieurs couches différentes comme la communication avec le serveur, les traitements métier et la présentation, alors il vaut mieux faire plusieurs services qu'un seul. Le code n'en sera que plus compréhensible, et ce n'est pas contradictoire avec le fait de rassembler les éléments d'une même fonctionnalité si l'on ne mélange pas plusieurs fonctionnalités entre elles.
Il faut prendre exemple sur AngularJS lui-même, car le framework est conçu comme un assemblage de petits services, avec des services de haut niveau et d'autres de bas niveau.
Pour les requêtes HTTP, le service $http s'appuie sur un service $httpBackend qui réalise l'envoi de la requête et la réception de la réponse. Ce découpage permet de remplacer ce service de bas niveau $httpBackend par une version 'mock object' fournie avec AngularJS, qui évite de faire réellement les requêtes dans les tests unitaires. On a même le service de plus haut niveau $resource qui s'appuie sur $http, pour gérer des entités persistantes sur un serveur REST.
Pour la gestion du routage interne à l'application, le service $route s'appuie sur le service bas niveau $location qui gère l'URL.
En faisant de même, en découpant nos propres services lorsqu'ils s'y prêtent, on aura un code plus facile à comprendre, et aussi plus facile à tester. Si une fonctionnalité est découpée en plusieurs petits services, ce sera plus facile de tester chaque petit service séparément, en remplaçant les autres par des implémentations factices (mock objects). S'il y a un seul service et que certaines parties doivent être courcircuitées dans les tests unitaires, il va falloir bricoler l'unique objet au niveau de ses méthodes, en espérant que les parties en questions sont au moins dans des méthodes séparées, mais ça sera de toute façon beaucoup moins pratique qu'avec plusieurs services.
Pour les services classiques permettant l'accès à des entités persistantes, et qui font aussi quelques traitements sur ces données, notamment après leur réception du serveur ou avant leur envoi pour sauvegarde, il y a une séparation naturelle entre les traitements sur les entités et la communication avec le serveur, qui gagneront donc être séparés en deux services distincts.
Préférer les services aux événements du scope
Je n'ai jamais caché que je ne suis pas fan des événements qu'on peut propager entre les scopes. Ce n'est pas le concept en soi qui me gêne, mais plus le fait qu'il soit largement trop utilisé, et presque toujours dans des conditions où il n'est pas judicieux, et où un service serait bien plus adapté.
Bon évidemment, quand on a utilisé jQuery depuis des années, on doit trouver qu'AngularJS manque d'événements, et du coup les événements du scope doivent activer un réflexe pavlovien révélateur d'un conditionnement dont beaucoup de développeurs ont du mal à s'extraire. D'ailleurs si je me méfie beaucoup des événements du scope, j'ai une aversion bien plus grande pour l'attribut ng-change des champs de formulaire, qui ressemble davantage à une approche jQuery qu'à celle d'AngularJS. Mais j'en reparlerai une prochaine fois.
Les événements du scope, qui se propagent dans un sens ou dans l'autre de l'arborescence des scopes, n'ont de sens que pour une communication liée à la structure du HTML. Dans tous les autres cas, bien plus fréquents, il est plus logique et plus simple d'utiliser un service.
La façon naturelle avec AngularJS de partager des données entre deux contrôleurs... c'est de ne pas le faire. Car comme je l'ai expliqué plus haut, les données doivent être gérées dans les services, et si deux contrôleurs utilisent le même service, ils travaillent naturellement sur les mêmes données et les mêmes traitements, et il n'y a aucune raison pour qu'ils essaient de communiquer autrement.
De plus l'héritage par prototype entre les scopes fait qu'un objet publié dans son scope par un contrôleur associé à un élément HTML sera accessible aux scopes et contrôleurs correspondant à des éléménts enfants.
Du coup, s'il existe des cas où l'utilisation des événements est pertinente, je ne le nie pas, ils sont plutôt rares, et il vaut bien mieux avoir d'abord le réflexe service plutôt que le réflexe événements. Mais c'est l'inverse que je constate, avec des utilisations complètements illogiques des événements, allant jusqu'à récupérer le $rootScope pour propager un événement dans toute l'arborescence des scopes parce que le scope cible n'est pas dans la bonne branche, ce qui est véritable hérésie.
Exemples d'utilisation des services
Deux des exemples que j'ai présentés hier méritent un article séparé, car ils sont facilement réutilisables en dehors du cadre de cette présentation des usages des services. Vous pouvez donc les trouver sur les pages suivantes :
J'ai parlé aussi d'un service gérant l'utilisateur connecté, en donnant des pistes pour son implémentation. Il est important de créer un service pour la gestion de l'utilisateur, avec ses données et des fonctions par exemple pour vérifier ses droits, plutôt que de mettre ça plus ou moins en vrac dans le $rootScope.
Attention, je n'ai rien contre le fait de publier dans le $rootScope l'utilisateur connecté. C'est une donnée globale de l'application, et si on l'utilise à pas mal d'endroits pour conditionner l'affichage en fonction de ses droits, le plus simple est effectivement de l'avoir dans le $rootScope, pour y accéder depuis n'importe quel template. Mais il faut d'abord en faire un service, et publier ce service (ou une partie de ce service) dans le $rootScope. Comme ça le service lui-même peut être injecté dans d'autres services, ou dans une directive, sans avoir à aller chercher les données dans le scope - qui comme je l'ai dit n'est pas l'endroit où stocker les données du modèle.
Donc notre service pourra être un objet de ce style (et probablement moins simpliste) :
{
profile: {
firstname: "Thierry",
lastname: "Chatel"
},
hasRole: function (role) {
return ...
}
}
On crée un contrôleur sur l'élément <body>, qui publie le service complet dans le $rootScope. Si on a un service qui est un objet plus complexes, et donc seulement une partie concerne les templates, on peut faire un sous-objet avec cette partie, et publier seulement celle-ci dans le scope. N'hésitez pas à être imaginatifs.
Une remarque importante qui concerne la sécurité de l'application : il ne faut jamais cacher côté client des données auxquelles l'utilisateur ne doit pas avoir accès. C'est bien trop facile à contourner. Les données non autorisées ne doivent pas être envoyées par le serveur. Donc quand je parle de conditionner l'affichage en fonction des droits de l'utilisateur, c'est pour ne pas afficher une section ou un formulaire vide dans la page (les données n'ayant pas été envoyées par le serveur), c'est de l'esthétique et pas de la sécurité.
***
Voilà pour ce long article, bravo à ceux qui ont eu le courage de le lire jusqu'au bout, et amusez-vous bien avec les services !
J'ai écrit quelques réflexions complémentaires dans un nouvel article, qui peut compléter celui-ci :
- Services ou événements dans l'optique des tests
Merci pour l'article, de bons conseils (surtout avant de commencer sur ce framework).
RépondreSupprimerPetite questions sur des services:
- concernant les services REST. J'ai un peu fouillé avec google, et Rectangular est souvent revenu. L'avez vous déjà utilisé?
- concernant les services qui vont gérer la cache: le core Angular fournit une gestion du cache assez succinte. Angular-cache (https://github.com/jmdobry/angular-cache) semble proposer pas mal de choses. Du coup même question :)
Merci ;)
Je n'ai pas eu l'occasion d'utiliser Restangular, mais il a l'air bien fait, et moins rudimentaire que $resource.
RépondreSupprimerQuand à ce angular-cache, je ne connaissais même pas. Mais je suis plus sceptique sur l'utilité. Il ne s'agit pas d'un cache de requêtes, c'est juste pour mettre en cache quelques données (état) des vues ou des services, je ne pense vraiment pas qu'il y ait souvent besoin de quelque chose d'aussi sophistiqué.
Souvent un simple objet JS {} publié en service sera suffisant. Sinon $cacheFactory fera le boulot dans la plupart des cas. Inutile de s'embarquer sur une solution plus complexe quand rien ne la justifie.
Je trouve que le soucis avec $cacheFactory, c'est qu'il me semble qu'on ne peut pas spécifier de durée de vie.
RépondreSupprimerAvec angular-cache, on pourrait par exemple déterminer une durée de vie de l'objet, puis sur l'évènement onExpire, effectuer la requete AJAX correspondante pour récupérer des données récentes.
Oui effectivement $cacheFactory ne gère pas de durée de vie. S'il y en a besoin, alors il faut utiliser quelque chose de plus sophistiqué. Mais ce n'est pas si courant d'en avoir besoin dans un cache côté client. Tout dépend ce qu'on en fait.
RépondreSupprimer