Les services, théorie et création

Vous avez souvent eu besoin d'exécuter une certaine fonction à plusieurs endroits différents dans votre code ? Ou de vérifier une condition sur toutes les pages ? Alors ce chapitre est fait pour vous ! Nous allons découvrir ici une fonctionnalité importante de Symfony : le système de services. Vous le verrez, les services sont utilisés partout dans Symfony2, et sont une fonctionnalité incontournable pour commencer à développer sérieusement un site internet sous Symfony2.

Ce chapitre ne présente que des notions sur les services, juste ce qu'il vous faut savoir pour les manipuler simplement. Nous verrons dans un prochain chapitre leur utilisation plus poussée.

C'est parti !

Pourquoi utiliser des services ?

Genèse

Vous l'avez vu jusqu'ici, une application PHP, qu'elle soit faite avec Symfony2 ou non, utilise beaucoup d'objets PHP. Un objet remplit une fonction comme envoyer un e-mail, enregistrer des informations dans une base de données, etc. Vous pouvez créer vos propres objets qui auront les fonctions que vous leur donnez. Bref, une application est en réalité un moyen de faire travailler tous ces objets ensemble, et de profiter du meilleur de chacun d'entre eux.

Dans bien des cas, un objet a besoin d'un ou plusieurs autres objets pour réaliser sa fonction. Se pose alors la question de savoir comment organiser l'instanciation de tous ces objets. Si chaque objet a besoin d'autres objets, par lequel commencer ?

L'objectif de ce chapitre est de vous présenter le conteneur de services. Chaque objet est défini en tant que service, et le conteneur de services permet d'instancier, d'organiser et de récupérer les nombreux services de votre application. Étant donné que tous les objets fondamentaux de Symfony2 utilisent le conteneur de services, nous allons apprendre à nous en servir. C'est une des fonctionnalités incontournables de Symfony2, et c'est ce qui fait sa très grande flexibilité.

Qu'est-ce qu'un service ?

Un service est simplement un objet PHP qui remplit une fonction, associé à une configuration.

Cette fonction peut être simple : envoyer des e-mails, vérifier qu'un texte n'est pas un spam, etc. Mais elle peut aussi être bien plus complexe : gérer une base de données (le service Doctrine !), etc.

Un service est donc un objet PHP qui a pour vocation d'être accessible depuis n'importe où dans votre code. Pour chaque fonctionnalité dont vous aurez besoin dans toute votre application, vous pourrez créer un ou plusieurs services (et donc une ou plusieurs classes et leur configuration). Il faut vraiment bien comprendre cela : un service est avant tout une simple classe.

Quant à la configuration d'un service, c'est juste un moyen de l'enregistrer dans le conteneur de services. On lui donne un nom, on précise quelle est sa classe, et ainsi le conteneur a la carte d'identité du service.

Prenons pour exemple l'envoi d'e-mails. On pourrait créer une classe avec comme nom MonMailer et la définir comme un service grâce à un peu de configuration. Elle deviendrait alors utilisable n'importe où grâce au conteneur de services. En réalité, Symfony2 intègre déjà une classe nommée Swift_Mailer, qui est enregistrée en tant que service Mailer via sa configuration.

Pour ceux qui connaissent, le concept de service est un bon moyen d'éviter d'utiliser trop souvent à mauvais escient le pattern singleton (utiliser une méthode statique pour récupérer l'objet depuis n'importe où).

L'avantage de la programmation orientée services

L'avantage de réfléchir sur les services est que cela force à bien séparer chaque fonctionnalité de l'application. Comme chaque service ne remplit qu'une seule et unique fonction, ils sont facilement réutilisables. Et vous pouvez surtout facilement les développer, les tester et les configurer puisqu'ils sont assez indépendants. Cette façon de programmer est connue sous le nom d'architecture orientée services, et n'est pas unique à Symfony2 ni au PHP.

Le conteneur de services

Mais alors, si un service est juste une classe, pourquoi appeler celle-ci un service ? Et pourquoi utiliser les services ?

L'intérêt réel des services réside dans leur association avec le conteneur de services. Ce conteneur de services (services container en anglais) est une sorte de super-objet qui gère tous les services. Ainsi, pour accéder à un service, il faut passer par le conteneur.

L'intérêt principal du conteneur est d'organiser et d'instancier (créer) vos services très facilement. L'objectif est de simplifier au maximum la récupération des services depuis votre code à vous (depuis le contrôleur ou autre). Vous demandez au conteneur un certain service en l'appelant par son nom, et le conteneur s'occupe de tout pour vous retourner le service demandé.

La figure suivante montre le rôle du conteneur de services et son utilisation. L'exemple est constitué de deux services, sachant que le Service1 nécessite le Service2 pour fonctionner, il faut donc qu'il soit instancié après celui-ci.

Fonctionnement du conteneur de services

Vous voyez que le conteneur de services fait un grand travail, mais que son utilisation (depuis le contrôleur) est vraiment simple.

Comment définir les dépendances entre services ?

Maintenant que vous concevez le fonctionnement du conteneur, il faut passer à la configuration des services. Comment dire au conteneur que le Service2 doit être instancié avant le Service1 ? Cela se fait grâce à la configuration dans Symfony2.

L'idée est juste de définir pour chaque service :

  • Son nom, qui permettra de l'identifier au sein du conteneur ;
  • Sa classe, qui permettra au conteneur d'instancier le service ;
  • Les arguments dont il a besoin. Un argument peut être un autre service, mais aussi un paramètre (défini dans le fichier parameters.yml par exemple).

Nous allons voir la syntaxe de la configuration d'ici peu.

La persistance des services

Il reste un dernier point à savoir avant d'attaquer la pratique. Dans Symfony2, chaque service est « persistant ». Cela signifie simplement que la classe du service est instanciée une seule fois (à la première récupération du service). Si un nouvel appel est fait du même service, c'est cette instance qui est retournée et donc utilisée par la suite. Cette persistance permet de manipuler très facilement les services tout au long du processus. Concrètement, c'est le même objet $service1 qui sera utilisé dans toute votre application.

Utiliser un service en pratique

Récupérer un service

Continuons sur notre exemple d'e-mail. Comme je vous l'ai mentionné, il existe dans Symfony un composant appelé Swiftmailer, qui permet d'envoyer des e-mails simplement. Il est présent par défaut dans Symfony, sous forme du service Mailer. Ce service est déjà créé, et sa configuration est déjà faite, il ne reste plus qu'à l'utiliser !

Pour accéder à un service déjà enregistré, il suffit d'utiliser la méthode get($nomDuService) du conteneur. Par exemple :

1
2
<?php
$container->get('mailer');

Pour avoir la liste des services disponibles, utilisez la commande php app/console container:debug.

Et comment j'accède à $container, moi ?!

En effet, la question est importante. Dans Symfony, il existe une classe nommée ContainerAware qui possède un attribut $container. Le cœur de Symfony alimente ainsi les classes du framework en utilisant la méthode setContainer(). Donc pour toute classe de Symfony héritant de ContainerAware, on peut faire ceci :

1
2
<?php
$this->container->get('mailer');

Heureusement pour nous, la classe de base des contrôleurs nommée Controller hérite de cette classe ContainerAware, on peut donc appliquer ceci aux contrôleurs :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<?php
// src/Sdz/BlogBundle/Controller/BlogController.php

namespace Sdz\BlogBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller; // Cette classe étend ContainerAware

class BlogController extends Controller
{
  public function indexAction()
  {
    $mailer = $this->container->get('mailer'); // On a donc accès au conteneur

    // On peut envoyer des e-mails, etc.
  }
}

Il se peut que vous ayez déjà rencontré, depuis un contrôleur, l'utilisation de $this->get() sans passer par l'attribut $container. C'est parce que la classe Controller fournit un raccourci, la méthode $this->get() faisant simplement appel à la méthode $this->container->get(). Donc dans un contrôleur, $this->get('…') est strictement équivalent à $this->container->get('…').

Et voilà ! Vous savez utiliser le conteneur de services depuis un contrôleur, vraiment très simple comme on l'a vu précédemment.

Créer un service simple

Création de la classe du service

Maintenant que nous savons utiliser un service, apprenons à le créer. Comme un service n'est qu'une classe, il suffit de créer un fichier n'importe où et de créer une classe dedans.

La seule convention à respecter, de façon générale dans Symfony, c'est de mettre notre classe dans un namespace correspondant au dossier où est le fichier. C'est la norme PSR-0 pour l'autoload. Par exemple, la classe Sdz\BlogBundle\Antispam\SdzAntispam doit se trouver dans le répertoire src/Sdz/BlogBundle/Antispam/SdzAntispam.php. C'est ce que nous faisons depuis le début du cours. :)

Je vous propose, pour suivre notre fil rouge du blog, de créer un système anti-spam. Notre besoin : détecter les spams à partir d'un simple texte. Comme c'est une fonction à part entière, et qu'on aura besoin d'elle à plusieurs endroits (pour les articles et pour les commentaires), faisons-en un service. Ce service devra être réutilisable simplement dans d'autres projets Symfony : il ne devra pas être dépendant d'un élément de notre blog. Je nommerai ce service « SdzAntispam », mais vous pouvez le nommer comme vous le souhaitez. Il n'y a pas de règle précise à ce niveau, mis à part que l'utilisation des underscores (« _ ») est fortement déconseillée.

Je mets cette classe dans le répertoire /Antispam de notre bundle, mais vous pouvez à vrai dire faire comme vous le souhaitez.

Créons donc le fichier src/Sdz/BlogBundle/Antispam/SdzAntispam.php, avec ce code pour l'instant :

1
2
3
4
5
6
7
8
<?php
// src/Sdz/BlogBundle/Antispam/SdzAntispam.php

namespace Sdz\BlogBundle\Antispam;

class SdzAntispam
{
}

C'est tout ce qu'il faut pour avoir un service. Il n'y a vraiment rien d'obligatoire, vous y mettez ce que vous voulez. Pour l'exemple, faisons un rapide anti-spam : considérons qu'un message est un spam s'il contient au moins trois liens ou adresses e-mail. Voici ce que j'obtiens :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<?php
// src/Sdz/BlogBundle/Antispam/SdzAntispam.php

namespace Sdz\BlogBundle\Antispam;

class SdzAntispam
{
  /**
   * Vérifie si le texte est un spam ou non
   * Un texte est considéré comme spam à partir de 3 liens
   * ou adresses e-mail dans son contenu
   *
   * @param string $text
   */
  public function isSpam($text)
  {
    return ($this->countLinks($text) + $this->countMails($text)) >= 3;
  }

  /**
   * Compte les URL de $text
   *
   * @param string $text
   */
  private function countLinks($text)
  {
    preg_match_all(
      '#(http|https|ftp)://([A-Z0-9][A-Z0-9_-]*(?:.[A-Z0-9][A-Z0-9_-]*)+):?(d+)?/?#i',
      $text,
      $matches);

    return count($matches[0]);
  }

  /**
   * Compte les e-mails de $text
   *
   * @param string $text
   */
  private function countMails($text)
  {
    preg_match_all(
      '#[a-z0-9._-]+@[a-z0-9._-]{2,}\.[a-z]{2,4}#i',
      $text,
      $matches);

    return count($matches[0]);
  }
}

La seule méthode publique de cette classe est isSpam(), c'est celle que nous utiliserons par la suite. Elle retourne true si le message donné en argument (variable $text) est identifié en tant que spam, false sinon.

Création de la configuration du service

Maintenant que nous avons créé notre classe, il faut la signaler au conteneur de services, c'est ce qui va en faire un service en tant que tel. Un service se définit par sa classe ainsi que sa configuration. Pour cela, nous pouvons utiliser le fichier src/Sdz/BlogBundle/Ressources/config/services.yml.

Si vous avez généré votre bundle avec le generator en répondant « oui » pour créer toute la structure du bundle, alors ce fichier services.yml est chargé automatiquement. Vérifiez-le en confirmant que le répertoire DependencyInjection de votre bundle existe, il devrait contenir le fichier SdzBlogExtension.php.

Si ce n'est pas le cas, vous devez créer le fichier DependencyInjection/SdzBlogExtension.php (adaptez à votre bundle évidemment). Mettez-y le contenu suivant, qui permet de charger automatiquement le fichier services.yml que nous allons modifier :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<?php
// src/Sdz/BlogBundle/DependencyInjection/SdzBlogExtension.php

namespace Sdz\BlogBundle\DependencyInjection;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\DependencyInjection\Loader;

class SdzBlogExtension extends Extension
{
  public function load(array $configs, ContainerBuilder $container)
  {
    $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
    $loader->load('services.yml');
  }
}

La méthode load() de cet objet est automatiquement exécutée par Symfony2 lorsque le bundle est chargé. Et dans cette méthode, on charge le fichier de configuration services.yml, ce qui permet d'enregistrer la définition des services qu'il contient dans le conteneur de services. Fin de la parenthèse.

Revenons à notre fichier de configuration. Ouvrez ou créez le fichier Ressources/config/services.yml de votre bundle, et ajoutez-y la configuration pour notre service :

1
2
3
4
5
# src/Sdz/BlogBundle/Resources/config/services.yml

services:
    sdz_blog.antispam:
        class: Sdz\BlogBundle\Antispam\SdzAntispam

Dans cette configuration :

  • sdz_blog.antispam est le nom de notre service fraîchement créé. De cette manière, le service sera accessible via <?php $container->get('sdz_blog.antispam');. Essayez de respecter la convention en préfixant le nom de vos services par « nomApplication_nomBundle ». Pour notre bundle Sdz\BlogBundle, on a donc préfixé notre service de « sdz_blog. ».
  • class est un attribut obligatoire de notre service, il définit simplement le namespace complet de la classe du service. Cela permet au conteneur de services d'instancier la classe lorsqu'on le lui demandera.

Il existe bien sûr d'autres attributs pour affiner la définition de notre service, nous les verrons dans le prochain chapitre sur les services.

Sachez également que le conteneur de Symfony2 permet de stocker aussi bien des services (des classes) que des paramètres (des variables). Pour définir un paramètre, la technique est la même que pour un service, dans le fichier services.yml :

1
2
3
4
5
parameters:
    mon_parametre: ma_valeur

services:
    # ...

Et pour accéder à ce paramètre, la technique est la même également, sauf qu'il faut utiliser la méthode <?php $container->getParameter('nomParametre'); au lieu de get().

Utilisation du service

Maintenant que notre classe est définie, et notre configuration déclarée, nous avons affaire à un vrai service. Voici un exemple simple de l'utilisation que l'on pourrait en faire :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
// src/Sdz/BlogBundle/Controller/BlogController.php

namespace Sdz\BlogBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class BlogController extends Controller
{
  public function indexAction()
  {
    // On récupère le service
    $antispam = $this->container->get('sdz_blog.antispam');

    // Je pars du principe que $text contient le texte d'un message quelconque
    if ($antispam->isSpam($text)) {
      throw new \Exception('Votre message a été détecté comme spam !');
    }

    // Le message n'est pas un spam, on continue l'action…
  }
}

Et voilà, vous avez créé et utilisé votre premier service !

Si vous définissez la variable $text avec 3 adresses e-mail, vous aurez droit au message d'erreur de la figure suivante.

Mon message était du spam

Créer un service avec des arguments

Passer à la vitesse supérieure

Nous avons un service flambant neuf et opérationnel. Parfait. Mais on n'a pas utilisé toute la puissance du conteneur de services que je vous ai promise : l'utilisation interconnectée des services.

Injecter des arguments dans nos services

En effet, la plupart du temps vos services ne fonctionneront pas seuls, et vont nécessiter l'utilisation d'autres services, de paramètres ou de variables. Il a donc fallu trouver un moyen propre et efficace pour pallier ce problème, et c'est le conteneur de services qui propose la solution ! Pour passer des arguments à votre service, il faut utiliser la configuration du service :

1
2
3
4
5
6
# src/Sdz/BlogBundle/Resources/config/services.yml

services:
    sdz_blog.antispam:
        class: Sdz\BlogBundle\Antispam\SdzAntispam
        arguments: [] # Tableau d'arguments

Les arguments peuvent être :

  • Des valeurs normales en YAML (des booléens, des chaînes de caractères, des nombres, etc.) ;
  • Des paramètres (définis dans le parameters.yml par exemple) : l'identifiant du paramètre est encadré de signes « % » : %nomDuParametre% ;
  • Des services : l'identifiant du service est précédé d'une arobase : @nomDuService.

Pour faire l'utilisation de ces trois types d'arguments, je vous propose d'injecter différentes valeurs dans notre service d'antispam, comme ceci par exemple :

1
2
3
4
5
6
# src/Sdz/BlogBundle/Resources/config/services.yml

services:
    sdz_blog.antispam:
        class: Sdz\BlogBundle\Antispam\SdzAntispam
        arguments: [@mailer, %locale%, 3]

Dans cet exemple, notre service utilise :

  • @mailer : le service Mailer (pour envoyer des e-mails) ;
  • %locale% : le paramètre locale (pour récupérer la langue) :
  • 3 : et le nombre 3 (qu'importe son utilité !).

Une fois vos arguments définis dans la configuration, il vous suffit de les récupérer avec le constructeur du service. Les arguments de la configuration et ceux du constructeur vont donc de paire. Si vous modifiez l'un, n'oubliez pas d'adapter l'autre. Voici donc le constructeur adapté à notre nouvelle configuration :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?php
// src/Sdz/BlogBundle/Antispam/SdzAntispam.php

namespace Sdz\BlogBundle\Antispam;

class SdzAntispam
{
  protected $mailer;
  protected $locale;
  protected $nbForSpam;

  public function __construct(\Swift_Mailer $mailer, $locale, $nbForSpam)
  {
    $this->mailer    = $mailer;
    $this->locale    = $locale;
    $this->nbForSpam = (int) $nbForSpam;
  }

  /**
   * Vérifie si le texte est un spam ou non
   * Un texte est considéré comme spam à partir de 3 liens
   * ou adresses e-mails dans son contenu
   *
   * @param string $text
   */
  public function isSpam($text)
  {
    // On utilise maintenant l'argument $this->nbForSpam et non plus le « 3 » en dur :
    return ($this->countLinks($text) + $this->countMails($text)) >= $this->nbForSpam;
  }

  // … on pourrait également utiliser $this->mailer pour prévenir d'un spam l'administrateur par exemple
}

Le constructeur est très simple, l'idée est juste de récupérer les arguments pour les stocker dans les attributs de la classe. L'ordre des arguments du constructeur est le même que l'ordre des arguments définis dans la configuration du service.

Vous pouvez voir que j'ai également modifié la méthode isSpam() pour vous montrer comment utiliser un argument. Ici, j'ai remplacé le « 3 » que j'avais mis en dur précédemment par la valeur de l'argument nbForSpam. Ainsi, si vous décidez de passer cette valeur à 10 au lieu de 3, vous ne modifiez que la configuration du service, sans toucher à son code !

L'injection de dépendances

Vous ne vous en êtes pas forcément aperçus, mais on vient de réaliser quelque chose d'assez exceptionnel ! En une seule ligne de configuration, on vient d'injecter un service dans un autre. Ce mécanisme s'appelle l'injection de dépendances (dependency injection en anglais).

L'idée, comme on l'a vu précédemment, c'est que le conteneur de services s'occupe de tout. Votre service a besoin du service Mailer ? Pas de soucis, précisez-le dans sa configuration, et le conteneur de services va prendre soin d'instancier Mailer, puis de vous le transmettre à l'instanciation de votre service.

Cela permet à un service d'utiliser d'autres services. En fait, on pourrait également injecter tout le conteneur dans un service, qui aurait ainsi accès à tous les autres services, au même titre que les contrôleurs. Mais ce n'est pas très propre, et il faut essayer de bien découpler vos services et surtout leurs dépendances.

Vous pouvez bien entendu utiliser votre nouveau service dans un prochain service. Au même titre que vous avez mis @mailer en argument, vous pourrez mettre @sdz_blog.antispam ! ;)

Ainsi retenez bien : lorsque vous développez un service dans lequel vous auriez besoin d'un autre, injectez-le dans les arguments de la configuration, et libérez la puissance de Symfony2 !

Pour conclure

Je me permets d'insister sur un point : les services et leur conteneur sont l'élément crucial et inévitable de Symfony2. Les services sont utilisés intensément par le cœur même du framework, et nous serons amenés à en créer assez souvent dans la suite de ce cours.

Gardez en tête que leur intérêt principal est de bien découpler les fonctions de votre application. Tout ce que vous comptez utiliser à plusieurs endroits dans votre code mérite un service. Gardez vos contrôleurs les plus simples possible, et n'hésitez pas à créer des services qui contiennent la logique de votre application. ;)

Ce chapitre vous a donc apporté les connaissances nécessaires pour définir et utiliser simplement les services. Bien sûr, il y a bien d'autres notions à voir, mais nous les verrons un peu plus loin dans un prochain chapitre.

Si vous souhaitez aborder plus en profondeur les notions théoriques abordées dans ce chapitre, je vous propose les lectures suivantes :

En attendant, la prochaine partie abordera la gestion de la base de données !


En résumé

  • Un service est une simple classe associée à une certaine configuration.
  • Le conteneur de services organise et instancie tous vos services, grâce à leur configuration.
  • Les services sont la base de Symfony2, et sont très utilisés par le cœur même du framework.
  • L'injection de dépendances est assurée par le conteneur, qui connaît les arguments dont a besoin un service pour fonctionner, et les lui donne donc à sa création.