lundi 10 décembre 2012

Les différentes façons de créer un service avec AngularJS

[Ajout du 02/01/2013] : tableau récapitulatif, et exemple d'utilisation des cinq méthodes (voir à la fin de l'article)

Les services dans une application AngularJS sont organisés en modules. Et chaque objet module dispose de cinq méthodes différentes permettant de créer un service : constantvalue, factory, service et provider. Nous allons voir quelles sont leurs particularités, et dans quels cas utiliser l'une plutôt que l'autre.

Mais d'abord, qu'est-ce qu'un service AngularJS ? C'est un objet JavaScript quelconque, qui peut même être un type primitif comme un nombre ou une chaîne de caractères. Par exemple le numéro de version de l'application, ou un objet contenant les informations sur l'utilisateur connecté, ou encore un objet qui met un ensemble de méthodes à disposition des contrôleurs. Ce qui en fait un service, c'est le fait de le publier sous un certain nom, via une des cinq méthodes citées, dans le module auquel il appartient. Attention, le module ne sert pas d'espace de nommage, donc il faut éviter de publier deux services sous le même nom dans deux modules différents, sous peine de collision si ces deux modules sont chargés ensemble dans la même application.

Donc un service n'est rien de plus qu'un objet publié sous un certain nom, pour qu'il puisse être injecté comme dépendance dans un contrôleur ou un autre service. Sauf qu'en réalité c'est un peu plus compliqué que ça. Lorsqu'on charge un module, les services ne sont pas immédiatement créés, et pour chaque service ce qui est référencé dans l'injecteur de dépendances, ce n'est pas le service lui-même puisqu'il n'existe pas encore, mais son provider.


Providers et services


Un provider de service, dans AngularJS, c'est un objet JavaScript qui sert à créer l'instance du service. Il comporte obligatoirement une méthode $get, c'est celle qui est appelée pour la création de l'instance du service. Et le provider est référencé dans l'injecteur de dépendance par le nom du service suffixé par 'Provider'.

Ainsi quand AngularJS doit injecter un service nommé ApiClient, dont l'instance n'a pas encore été créée, il va chercher dans le cache des providers le singleton référencé comme ApiClientProvider. Il appelle sa méthode $get pour créer l'instance du service ApiClient. Et il stocke l'instance créée dans le cache des services, ce qui fait qu'elle ne sera pas recréée lors des injections suivantes.

On a donc deux singletons, le provider et le service. Le chargement d'un module crée et référence le provider. Et la méthode $get du provider est appelée une seule fois, pour créer et référencer l'unique instance du service.

Comme le provider est responsable de l'instanciation du service, il peut aussi permettre de le configurer. Par exemple le service $http livré en standard avec le framework AngularJS peut être configuré via son provider $httpProvider, dans la méthode config() du module principal de l'application. Sa propriété $httpProvider.defaults est un objet contenant diverses options, comme les headers par défaut des requêtes HTTP. On peut les modifier, et lorsque ce provider instanciera le service, il sera créé avec une configuration différente de celle par défaut. Ainsi chaque provider peut comporter en plus de sa méthode $get diverses propriétés ou méthodes servant à configurer l'instanciation du service associé.

Voilà pour le fonctionnement interne. Mais alors, comment définir un service, ou plus précisément le provider d'un service, avec les différentes méthodes du module ?

provider()


La méthode la plus générale est module.provider(name, provider), les autres ne sont que des raccourcis plus pratiques pour les cas où l'on n'a pas besoin de toute la puissance du mécanisme.

Le premier paramètre de la méthode module.provider(name, provider), c'est le nom du service, donc sans le suffixe 'Provider' qui sera ajouté automatiquement par AngularJS en référençant le provider. Le second paramètre peut être soit un objet, soit une fonction. Si c'est un objet, rien de plus simple, c'est lui qui est stocké comme instance du provider, et il faut bien sûr qu'il comporte une méthode $get sinon il y aura un soucis quand AngularJS va essayer de l'appeler pour instancier le service.

Si le second paramètre est une fonction ApiClientProvider, AngularJS l'utilise immédiatement comme constructeur pour créer l'instance du provider, et la référencer. Je simplifie légèrement, en réalité le code exécuté n'est pas un simple new ApiClientProvider(), mais en pratique c'est à ça que ça revient, ce qui veut dire que l'instance du provider correspond à this dans la fonction ApiClientProvider, et qu'elle est chaînée au prototype ApiClientProvider.prototype. Donc pour déclarer la méthode $get du provider dans ApiClientProvider, il faut définir la propriété this.$get, comme ceci par exemple :

module.provider('ApiClient', ApiClientProvider);

function ApiClientProvider() {
    this.$get = function () {
        ...
    };
}


Et on peut ajouter à this, c'est-à-dire à l'instance du provider, toutes les méthodes ou propriétés qui serviront à configurer l'instanciation du service.

factory()


Mais il n'est pas toujours nécessaire de pouvoir configurer le service via son provider. Du coup pour tous les services dont le provider n'aurait que la seule méthode $get, on peut simplifier leur définition en utilisant l'une des deux méthodes factory() ou service() du module. Avec ces deux méthodes simplifiées, on n'a plus la main sur l'objet provider complet, car celui-ci est créé automatiquement par AngularJS.

La méthode module.factory(name, $getFn) qui est la plus utilisée des deux, prend encore comme premier paramètre le nom du service. Et le second paramètre doit être une fonction, qui est utilisée par AngularJS comme méthode $get de l'instance du provider qu'il génère automatiquement. Cette fonction $getFn doit créer l'instance du service. Le code du framework est explicite, la méthode factory délègue à la méthode provider, en lui passant un objet avec la fonction comme propriété $get :

function factory(name, factoryFn) {
    return provider(name, { $get: factoryFn });
}


service()


La méthode module.service(name, constructor) est très similaire à la méthode factory, son deuxième paramètre est aussi une fonction JavaScript, qui cette fois sera utilisée comme constructeur par AngularJS, c'est-à-dire qu'il fait (en simplifiant) un new constructor(). Donc la fonction dans ce cas n'est pas chargée d'instancier le service, car l'instance lui est passée en temps que this, et il n'y a plus qu'à alimenter ses propriétés. Le code de la méthode service fait une délégation à la méthode factory, avec une fonction qui instanciera le service avec le constructeur fourni à la méthode service, ce qui revient comme je le disais à faire un new avec ce constructeur :

function service(name, constructor) {
    return factory(name, ['$injector', function($injector) {
        return $injector.instantiate(constructor);
    }]);
}


value()


Quant à la méthode module.value(name, value), elle est encore plus simplifiée que les deux précédentes, car son second paramètre est directement l'instance du service à publier. Ca veut dire que contrairement aux méthodes précédemment décrites, avec lesquelles l'instance du service sera créée lors de la première injection de dépendances où il intervient, cette fois-ci l'instance du service est un objet existant avant l'appel de cette méthode. AngularJS créé là encore automatiquement un provider, mais qui ne fera que renvoyer l'instance préexistante du service. Comme l'objet est préexistant, créé par le code du chargement du module, il ne peut pas bénéficier d'injection d'autres services, ça ne peut être qu'un objet plus simple, avec des propriétés et méthodes qui ne dépendent pas du reste de l'application ni de services standards du framework. Le code d'AngularJS fait là encore une délégation à la méthode factory, en lui passant cette fois une fonction qui renvoie simplement l'objet préexistant :

function value(name, value) {
    return factory(name, valueFn(value));
}


constant()


La méthode module.constant(name, value) utilise elle-aussi un objet préexistant comme instance du service, mais elle enregistre aussi cet objet comme son propre provider, sous le même nom que le service donc sans le suffixe 'Provider', ce qu'on voit dans le code du framework :

function constant(name, value) {
    providerCache[name] = value;
    instanceCache[name] = value;
}

Du coup le fonctionnement est très différent. C'est le seul cas où le service est directement inscrit dans le cache des instances de services. Dans tous les autres cas, seul le provider est inscrit dans le cache des providers, et sa méthode $get est appelée lors du premier accès au service. Ici le provider et le service sont en fait le même objet, celui passé comme second paramètre de la méthode constant(), et comme le service existe déjà, il n'y aura jamais d'appel de la méthode $get du provider, ce qui tombe bien puisqu'elle n'existe pas. Mais par contre il est possible d'injecter directement le service - ou plus exactement le provider qui est le même objet que le service et qui est publié sous le même nom sans suffixe 'Provider' - pour configurer cet objet dans la méthode config() de l'application.


Cas d'utilisation


Alors dans quels cas utiliser plutôt l'une ou l'autre de ces différentes méthodes ?

Si le service est un objet plutôt simple et ne dépend d'aucun autre service, même pas d'un service intégré au framework, alors il suffit de créer directement l'instance et de la passer à la méthode value(). C'est ce qu'on fera généralement pour un objet avec des valeurs en dur, ou une bibliothèque de méthodes utilitaires.

Dans le cas le plus fréquent, où le service peut être dépendant d'autres services, on va écrire une fonction qui crée l'instance du service, et passer cette fonction à la méthode factory().

La variante service() permet de faire de la fonction passée en paramètre un constructeur, si on veut typer l'objet JavaScript. Ca plaira sans doute à ceux qui trouvent que pour un langage objet le JavaScript manque vraiment de "classes", mais comme il n'existe qu'une seule instance du service, ce n'est pas forcément d'une grande utilité. C'est surtout une histoire de style, à chacun de choisir celui qu'il préfère.

Mais si le service doit pouvoir être configuré, alors il faut utiliser la méthode provider(), et passer l'instance ou le constructeur du provider, avec toutes les méthodes et propriétés nécessaires à la configuration du service.

Et la méthode constant() correspond au cas très particulier d'un objet simple sans dépendances, mais pouvant être configuré au niveau de l'application - en utilisant le nom du service lui-même, sans suffixe 'Provider'.

[Ajout du 02/01/2013]
Voici un tableau récapitulatif des possibilités des services créés par les différentes méthodes :


Méthode Configurable Injection de dépendances Création du service Nom du provider
module.provider() Oui Oui Au premier accès, par appel de la méthode $get du provider nom du service + 'Provider'
module.factory() Non Oui Au premier accès, par appel de la fonction fournie nom du service + 'Provider'
module.service() Non Oui Au premier accès, en utilisant la fonction fournie comme un constructeur nom du service + 'Provider'
module.value() Non Non Le service est un objet préexistant nom du service + 'Provider'
module.constant() Oui Non Le service est un objet préexistant nom du service

Et vous trouverez ici un exemple sur jsFiddle où ces cinq méthodes sont utilisées, ce qui permet de voir la syntaxe de chacune, ainsi que la façon de configurer un service créé par les méthodes provider() et constant().

8 commentaires:

  1. Merci pour cet excellent article.
    C'est exactement les informations dont j'avais besoin pour choisir la bonne approche!

    RépondreSupprimer
  2. PS: j'adorerais trouver un article du même type concernant $injector et les différentes façons d'injecter un service dans un test-u coté Jasmine ;)

    RépondreSupprimer
  3. Merci, pour cette explication bien clair. :)

    RépondreSupprimer