Tags et CompilerPass de Symfony2 expliqués par l'exemple

Le container de dépendance est le composant central de Symfony2, tout le monde l'utilise. Moins sont les développeurs qui utilisent les tags dans la définition d'un service et malheureusement peu savent comment ça marche. C'est l'object de cet article, montrer par l'exemple l'utilité des tags et leur utilisation via le CompilerPass. Cela permet de réduire les dépendances entre les classes et facilite la maintenabilité du code.

Il y a quelques temps, j'ai du travailler sur une application qui devait stocker en base des rendez-vous avec comme format d'entrée le format iCal. Ce format ressemble à ça:

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//hacksw/handcal//NONSGML v1.0//EN
BEGIN:VEVENT
DTSTART:19970714T170000Z
DTEND:19970715T035959Z
SUMMARY:Fête à la Bastille
END:VEVENT
END:VCALENDAR

L'idée est d'extraire les règles entre les lignes BEGIN:VEVENT et END:VEVENT. J'ai utilisé la librairie sabre-dav pour ça. Elle me permet d'itérer sur un objet et me retourne une ligne (une règle) à chaque itération. A l'origne du projet peu de règles devaient être prises en compte, mais cela aller évoluer et il fallait donc trouver un moyen de rajouter le support de nouvelles règles facilement.
Je suis partie sur le principe d'une classe par règles avec un interface assez simple, permettant de déterminer si la classe supporte la règle et de la traiter.

interface ICalRule
{
    public function isSupported($atomicRule);
 
    public function process($atomicRule);
}

Ma classe qui traite la conversion de l'iCal reçoit les classes de règle et itère dessus, ce qui donne en version simplifié cette classe:

class TransformIcal
{
    private $rules = [];
 
    public function addRule(ICalRule $rule)
    {
        $this->rules[] = $rule;
    }
 
    public function transform($iCal)
    {
        foreach ($iCal as $line) {
            foreach ($this->rules as $rule) {
                if ($rule->isSupported($line)) {
                    $rule->process($line);
                }
            }
        }
    }
}

La méthode addRule() me permet d'ajouter les classes implémentant mon interface et ainsi je peux facilement ajouter le support de nouvelle règle. Il ne me reste plus qu'a injecter mes classes de règle dans la classe TransformIcal. C'est la qu'entre en jeu l'injection de dépendance de Symfony2 avec les tags et le CompilerPass.

Admettons que j'ai pour le moment que 2 règles pour la date de début et l'intitulé du rendez-vous. J'ai créé 2 services que j'ai taggé avec le nom "rule.ical". Le nom des tags est totalement libre dans Symfony2.

services:
    acme.transformer.ical:
        class: Acme\Transformer\TransformIcal
 
    acme.rule.start_date:
        class: Acme\Rule\StartDateRule
        tags:
            - { name: rule.ical }
 
    acme.rule.summary:
        class: Acme\Rule\SummaryRule
        tags:
            - { name: rule.ical }

 

[EDIT]: avec symfony 3.4 le code du compiler passe ci dessous peut être remplacé par une simple configuration de service: http://symfony.com/blog/new-in-symfony-3-4-simpler-injection-of-tagged-services

Le CompilerPass est une classe qui doit implémenter l'interface Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface fourni par sf2. Elle doit définir la méthode process() qui reçoit une instance du ContainerBuidler. Le ContainerBuilder n'est pas le container tel qu'on l'utilise dans le reste du code, c'est le container en cours de construction, ce qui veut dire que l'on peut encore le modifier, le container étant lui immutable. Les CompilerPass sont éxecutés en dernier (après le chargement de tous les bundles) ce qui implique que le ContainerBuilder contient les définitions de tous les services de l'application. 
L'idée est de chercher tous les services ayant mon tag "rule.ical" et de les injecter dans ma classe TransformIcal.

class AddRuleCompilerPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container)
    {
        if (!$container->hasDefinition('acme.transform.ical')) {
            return;
        }
 
        $definition = $container->getDefinition('acme.transform.ical');
 
        $taggedServices = $container->findTaggedServiceIds('rule.ical');
 
        foreach ($taggedServices as $serviceName => $service) {
            $definition->addMethodCall('addRule', array(new Reference($serviceName)));
        }
    }
}

Maintenant à chaque construction du container, tous les services avec mon tag "rule.ical" seront récupérés par ce CompilerPass et ajoutés à ma classe TransformIcal. On peut le vérifier en lisant le container depuis le cache de Symfony2:

protected function getAcme_Transformer_Ical()
{
    $this->services['acme.transformer.ical'] = $instance = new \Acme\Transformer\TransformeIcal();
 
    $instance->addRule($this->get('acme.rule.start_date'));
    $instance->addRule($this->get('acme.rule.summary'));
 
    return $instance;
}

Il manque encore une étape. Il faut dire à sf2 d'utiliser le CompilerPass, pour cela il faut l'ajouter  au ContainerBuilder dans la classe *Bundle du bundle.

class AcmeBundle extends Bundle
{
    public function build(ContainerBuilder $container)
    {
        $container->addCompilerPass(new AddRuleCompilerPass());
    }
}

Si je résume pour prendre en charge une nouvelle règle de mon format iCal, j'ai juste à créer une nouvelle classe implémentant l'interface ICalRule et ajouter le tag "rule.ical" à la definition de son service.

Le mécanisme des tags et du CompilerPass peuvent également servir à laisser l'implémentation de fonctionnalité à d'autre. Imaginons que l'on travaille sur un client HTTP dans un bundle autonome. On souhaite avoir la possibilité de signer les requêtes, selon le protocole utilisé Oauth, Hawk..... la génération de la signature est complexe et il n'est pas toujours possible de proposer une implémentation de tous les protocoles. En fournissant une interface et un tag, il devient possible à n'importe qui d'implémenter le protocole qui l'intérèsse et d'étendre ainsi le support des protocoles pris en charge.

 

Ajouter un commentaire