Mettre en place une factory
Dans une application, on a souvent besoin d'utiliser des services. Pour cela, l'injection de dépendances est souvent une solution. Mais parfois, on aimerait s'injecter un type un peu plus général que le type même de notre service, par exemple en s'injectant une interface. Et dans ce cas, on aimerait que selon le contexte, on puisse obtenir une instance d'un service adapté au contexte en question.
Pour cela, on va pouvoir mettre en place une factory qui se chargera de nous fournir le service dont nous avons besoin.
Imaginons le contexte suivant :
Nous avons besoin de services qui transforment une entité en tableau. Pour se faire, nous allons créer des Transformers qui prennent un objet en entrée et renvoie un tableau en sortie.
Par exemple, on crée deux transformers : UserTransformer et EntityTransformer. Si l'objet à transformer est un User, on utilisera un UserTransformer, mais si c'est un autre objet, on utilisera EntityTransformer. Hyper simple. En fonction du contexte on s'injectera un UserTransformer ou un EntityTransformer.
Mais si dans une méthode d'un controlleur on a besoin des deux, on va devoir s'injecter 2 dépendances et être vigilant à chaque nouveau besoin de savoir de quel service on a besoin. Bref, si cette nécessité de transformers s'étend, cela peut devenir complexe et difficile à maintenir. C'est là que la notion de Factory devient utile.
C'est parti...
Dans une méthode d'un controlleur, nous avons besoin de pouvoir transformer des entités. On peut alors imaginer un service :
<?php namespace App\Service\Transformers; class TransformerService { public function __construct() { } /** * @param object $entity * * @return array */ public function transform(object $entity): array { return [ $entity->getId(), $entity->getCreatedAt(), ]; } }
Notre difficulté à ce stade est que selon que l'entité est un User, un Template, ou tout autre type, nous avons besoin de retourner un tableau différent. Nous aurons donc besoin d'autant de Transformers que d'entités.
Nous souhaitons donc injecter un seul type qui sera assez polyvalent pour savoir quel transformer utiliser en fonction de l'entité à transformer.
Une interface
Nous allons en premier lieu créer une interface que devront implémenter tous nos transformers.
<?php namespace App\Service\Transformers; use App\Entity\Subscription; use Doctrine\ORM\Mapping\Entity; interface TransformerInterface { public function supports(object $entity): bool; public function transform(object $entity): array; }
Créer les transformers
Ici l'exemple du UserTransformer mais vous pouvez en créer autant que vous le souhaitez.
<?php namespace App\Service\Transformers; use App\Entity\User; use Doctrine\ORM\Mapping\Entity; use Symfony\Component\Security\Core\User\UserInterface; class UserTransformer implements TransformerInterface { /** * @param object $entity * * @return array */ public function transform(object $entity): array { return [ 'id' => $entity->getId(), 'name' => $entity->getLastname() ]; } /** * @param object $entity * * @return bool */ public function supports(object $entity): bool { return $entity instanceof User; } }
La méthode supports() sert à définir quel type d'objet est concerné par ce transformer.
Tagger les transformers
Pour que Symfony reconnaisse tous nos transformers en tant que tel, il va nous falloir le lui dire.
Pour cela, dans config/services.yaml, on va déclarer que tous nos transformers qui implémentent TransformerInterface sont de type transformer en utilisant un tag.
services: _instanceof: App\Transformers\Interface\TransformerInterface: tags: [ 'app.transformer' ]
De la sorte, toutes les classes qui implémentent notre TransformerInterface sont taggées 'app.transformer'
Créer la Factory
Pour notre factory, nous avons besoin de récupérer toutes les classes qui implémentent TransformerInterface, de tester si l'entité transformée supporte notre entité et sinon utiliser la classe générique EntityTransformer
class TransformerFactory { private ?TransformerInterface $transformer; private iterable $transformers; /** * @param iterable $transformers * * see services.yaml for configuration */ public function __construct(#[TaggedIterator('app.transformer')] iterable $transformers) { $this->transformers = $transformers; } /** * @param object $entity * @return TransformerInterface */ public function getTransformer(object $entity): TransformerInterface { foreach ($this->transformers as $transformer) { if(!$transformer instanceof EntityTransformer) { if ($transformer->supports($entity)) { return $transformer; } } } return new EntityTransformer(); // le transformer par défaut } }
Le service
Il nous reste à créer le service final qui sera injecté là ou nous en aurons besoin.
<?php namespace App\Service\Transformers; class TransformerService { private TransformerFactory $factory; public function __construct(TransformerFactory $factory) { $this->factory = $factory; } /** * @param object $entity * * @return array */ public function transform(object $entity): array { $transformer = $this->factory->getTransformer($entity); return $transformer->transform($entity); } }
Déclaration
Pour que notre factory fonctionne, il nous faut la déclarer dans services.yaml et faire en sorte qu'elle reçoive en argument de son constructeur toutes les classes qui implémentent TransformerInterface
services: _instanceof: App\Service\Transformers\TransformerInterface: tags: [ 'app.transformer' ] App\Service\Transformers\TransformerFactory: arguments: - !tagged_iterator 'app.transformer'
Injection
Désormais, quel que soit l'endroit de l'application, il est possible de s'injecter notre service et d'en appeler la méthode transform(). De facto, quelque soit l'objet que l'on cherche à transformer, on récupèrera le bon transformer grâce à la factory instanciée dans le service. Exemple ci-dessous dans un controlleur :
#[Route('/', name: 'app_home')] public function index( TransformerService $transformer ): Response { $user = $this->getUser(); $data = $transformer->transform($user); // ...
En résumé
Nous avons donc désormais un système opérationnel
- Une interface TransformerInterface
- Des classes qui implémentent TransformerInterface
- Ces classes sont taggées dans service.yaml
- Une Factory qui reçoit en argument la liste de celles-ci
- La Factory est configurée dans services.yaml
- Et enfin un service que l'on peut s'injecter partout où nous en avons besoin.
- Ce service utilise la Factory pour récupérer le transformer adéquat.
Mais ....
Tel que notre service est conçu, il reste fortement couplé à notre interface en nous obligeant à ajouter à notre service toutes les méthodes que l'on serait amené à ajouter à notre interface.
Pour éviter cela, nous allons apporter une modification à notre service en lui supprimant sa méthode transform() et en utilisant ma méthode magique __call().
final class TransformerService { private TransformerFactory $factory; public function __construct(TransformerFactory $factory) { $this->factory = $factory; } /** * @param string $method * @param array $arguments * * @return mixed * * Delegate this service to its related Transformer */ public function __call(string $method, array $arguments): mixed { if (count($arguments) === 0 || is_object(!$arguments[0])) { throw new \InvalidArgumentException('The first argument must be an object.'); } $entity = $arguments[0]; $transformer = $this->factory->getTransformer($entity); if (!method_exists($transformer, $method)) { throw new \BadMethodCallException(sprintf( 'Method "%s" does not exist on transformer "%s".', $method, get_class($transformer) )); } return $transformer->$method(...$arguments); } }
Désormais, quelle que soit la méthode que l'on ajouterait à notre interface, il faudrait bien sur l'ajouter aux classes qui implémentent l'interface, mais notre service continuera à fonctionner sans avoir besoin de le modifier.
Il reste un détail
Notre interface impose une méthode supports() pour que notre Factory puisse savoir quel transformer elle doit nous fournir. C'est un peu dommage à double titre :
- Cela nous oblige à ajouter cette méthode à chacun de nos transformers
- Celle-ci n'a pas de lien direct avec le rôle de nos transformers
Pour remédier à cela, nous allons créer un Attribut qui, lui, saura définir quelle entité est concernée par notre transformer.
#[Attribute] readonly class TransformerSupports { /** * @param string $supports * * FQCN of supported type */ public function __construct(private string $supports) { } }
Il va donc nous falloir un peu modifier la factory pour tenir compte de cet attribut plutôt que de la méthode supports()
/** * @param object $entity * * @return TransformerInterface */ public function getTransformer(object $entity): TransformerInterface { foreach ($this->transformers as $transformer) { if( !$transformer instanceof EntityTransformer && $this->transformerSupports($transformer, $entity::class) ) { return $transformer; } } return new EntityTransformer(); } private function transformerSupports(TransformerInterface $transformer, string $fqcn) { $class = new \ReflectionClass($transformer); $attributes = $class->getAttributes(); foreach ($attributes as $attribute) { if($attribute->getName() === TransformerSupports::class && $attribute->getArguments()['supports'] === $fqcn) { return true; } } return false; }
Il nous reste à supprimer la méthode supports de notre interface et de nos transformers, puis ajouter notre attribut à notre transformer et à tous les prochains :
#[TransformerSupports(supports: User::class)] class UserTransformer implements TransformerInterface { /** * @param object $entity * * @return array */ public function transform(object $entity): array { return [ 'id' => $entity->getId(), 'name' => $entity->getLastname() ]; } }
Retrouver tout le code sur github