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 😉