Lors du développement d’un projet, on voit souvent une collection de if les uns à la suite des autres, ou encore un énorme switch / case permettant d’exécuter du code en fonction d’un état. Ce code est bien souvent illisible et difficilement maintenable.

Mais savez-vous qu’il existe un patron de conception permettant d’éviter ça ? Et ça tombe bien, Symfony nous permet de l’intégrer facilement.

Pourquoi nous utilisons un Design Pattern Strategy ?

Lors du développement d’une application dans le domaine de l’immobilier, nous avions à implémenter une fonctionnalité permettant d’obtenir et d’afficher les consommations de son logement.

Au départ, il n’était possible que de saisir manuellement le relevé de son compteur (électrique, gaz ou autre). Les consommations étaient ensuite calculées selon la saisie de ces relevés.

Nous avons voulu ensuite exploiter les capacités d’interopérabilité des fournisseurs d’énergie, en récupérant automatiquement les consommations depuis les compteurs Linky et Gazpar. Mais comment le faire en minimisant l’impact sur le code déjà fonctionnel ?

La solution : Le Design Pattern Strategy.

Définition du Design Pattern Strategy

Le Design Pattern Strategy est un patron de conception permettant la sélection et l’exécution d’algorithmes selon certaines conditions, le tout en respectant l’un des principes SOLID, le Single Responsability (la responsabilité unique).

Nous aurons d’abord un service qui sera chargé d’exécuter ces différents algorithmes. Nous nommerons ce service ExternalConsommationProvider. Chaque algorithme sera représenté par un service que nous appellerons provider. Un provider aura pour objectif de nous fournir les consommations d’une certaine catégorie.

Dans notre exemple, nous aurons 2 providers :

  • EnedisDataConnectProvider : Qui nous retournera les consommations de notre compteur Linky.
  • GrdfGazparProvider : Qui nous retournera les consommations de notre compteur Gazpar.

Voici un schéma pour mieux comprendre :

Sur ce diagramme, nous retrouvons notre service ExternalConsommationProvider (Le bloc jaune tout en haut). Celui-ci contiendra une liste de providers (la propriété visible via le petit carré rouge). Chaque provider sera une instance de ConsommationProviderInterface (le bloc jaune au milieu). Ce qui obligera chaque provider à développer 2 méthodes (support() et findByRange()).

Enfin, nous retrouvons nos 2 providers EnedisDataConnectProvider et GrdfGazparProvider (chacun implémentant l’interface en question, symbolisée par la flèche en pointillé).

L’objectif de notre service ExternalConsommationProvider est, via sa méthode findByRange, de nous retourner les consommations. Sans même avoir développer nos 2 providers, nous pouvons déjà développer cette méthode :

public function findByRange(Logement $logement, DateTime $start, DateTime $end, User $user): array
{
    $consommations = [];

    foreach ($this->getProviders() as $provider) {
        if ($provider->support($logement, $user)) {
            $consommations = array_merge(
                $consommations,
                $provider->findByRange($logement, $start, $end, $user)
            );
        }
    }

    return $consommations;
}

Voyons ce code : on boucle sur les providers de notre service. Si chaque provider est supporté (pour résumer, si l’utilisateur souhaite obtenir les consommations depuis ce provider), on ajoute dans notre tableau $consommations les consommations récupérées grâce au provider.

En ce qui concerne nos providers, il suffit de développer les méthodes de notre interface :

class EnedisDataConnectProvider implements ConsommationProviderInterface
{
    public function support(Logement $logement, User $user): bool
    {
        // On vérifie si notre logement est bien connecté au compteur Linky
    }

    public function findByRange(Logement $logement, DateTime $start, DateTime $end, User $user): array
    {
        // On retourne les consommations de notre compteur Linky grâce à l'API Data Connect d'Enedis
    }
}

Maintenant que nous avons implémenté notre service ExternalConsommationProvider ainsi que nos 2 providers, comment intégrer ces providers dans notre service ?

C’est là qu’intervient le Compiler Pass de Symfony.

Définition du Compiler Pass

Pour ajouter un provider à notre service, nous disposons de la méthode addProvider(), qui attends en paramètre une instance de ConsommationProviderInterface.

Avec Symfony, nous avons la possibilité de tagger nos services, afin de les identifier plus facilement dans notre application.

Nous allons donc ajouter un tag sur tous nos services implémentant l’interface ConsommationProviderInterface. Pour cela, il suffit d’ajouter les lignes suivantes dans notre fichier config/services.yaml :

_instanceof:
  App\Service\ConsommationProviderInterface:
    tags: [ app.rt2012.external_consommation_provider ] 

Ainsi, nos 2 providers auront le tags app.rt2012.external_consommation_provider.

Maintenant que nous pouvons identifier nos providers, nous pouvons créer un CompilerPass. Pour cela, Créons la class ExternalConsommationProviderPass comme ceci :

class ExternalConsommationProviderPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container)
    {
        if (!$container->has(ExternalConsommationProvider::class)) {
            return;
        }

        $definition = $container->findDefinition(ExternalConsommationProvider::class);

        $taggedServices = $container->findTaggedServiceIds('app.rt2012.external_consommation_provider');

        foreach ($taggedServices as $id => $tags) {
            $definition->addMethodCall('addProvider', [new Reference($id)]);
        }
    }
}

Dans notre méthode process(), nous vérifions si le service ExternalConsommationProvider existe. Ensuite, nous récupérons tous les services tagués sous le nom app.rt2012.external_consommation_provider, soit nos 2 providers. Enfin, nous bouclons sur ces services pour les ajouter dans notre service ExternalConsommationProvider grâce à la méthode addProvider().

Pour terminer, il faut que notre application prenne en compte ce CompilerPass. Pour cela, il suffit de l’ajouter dans le container, via la méthode build de la class App\Kernel :

class Kernel extends BaseKernel
{
    public function build(ContainerBuilder $container)
    {
        $container->addCompilerPass(new ExternalConsommationProviderPass());
    }
}

L’intérêt d’un Design Pattern Strategy

Ce n’est pas la seule et unique fois où nous avons utilisé cette technique. Nous avons pu en exploiter ses avantages dans le contexte de simples importations de fichiers. Nous avons développé un service nommé FileImporter permettant d’importer deux types de fichier différents, comme un fichier .xml et un fichier .yaml. Ce qui nous donne deux services : XmlFileImporter et YamlFileImporter. Chacun contient une méthode support permettant de vérifier si le fichier est conforme et une méthode execute pour importer le fichier. Notre service principal saura déterminer lequel choisir en fonction du fichier à importer.

L’intérêt principal du Design Pattern Strategy est de nous permettre d’exécuter différents algorithme selon un état.

Imaginons que, demain, nous avions aussi à afficher les consommations d’un équipement d’autoconsommation solaire, nous n’aurons qu’à créer un provider implémentant l’interface ConsommationProviderInterface.

De même, si nous devons traiter l’import d’un autre type de fichier (au format .json par exemple), nous n’aurons qu’a créer un autre service (par exemple JsonFileImporter).

Nous serons donc prêts pour de futures améliorations 😉