Maîtrisez votre app avec le pattern decorator
Publié le 03 novembre 2022
Note d'avant lecture : si des termes vous sont inconnus, mettez votre lecture en pause et allez vérifier leur signification avant de reprendre !
Dans API Platform, mais aussi dans Symfony — et dans énormément de logiciels —, il est souvent recommandé de "décorer" pour étendre du code tiers. Dans API Platform, on nous propose de décorer les providers, les processors, les listeners, les resolvers, les factories… Pour un·e dev expérimenté·e, c'est trivial. Mais si vous découvrez le terme, ce n'est pas si évident.
Avant d'aborder le pattern, il faut comprendre la raison de son existence. Pour cela, il faut définir la différence (en POO) entre la composition et l'héritage.
Héritage
L'héritage est l'un des quatre concepts majeurs associés à la programmation orientée objet. Cela comprend l'abstraction, l'encapsulation, l'héritage et le polymorphisme. L'héritage nous permet d'acquérir des propriétés et des méthodes de la classe de base dans la classe dérivée (ou enfant). Les classes dérivées ont généralement une relation (logique) avec la classe de base.
- L'héritage permet la réutilisation du code, d'hériter facilement des fonctionnalités de la classe mère sans copier le code.
- Elle fournit une structure hiérarchique claire qui nous permet de décomposer un modèle en une structure simple et facile à digérer.
- Les fonctions héritées sont plus lentes que les fonctions normales (sans parler de langage de programmation spécifiquement).
- Toutes les variables ainsi que les méthodes de la classe de base sont héritées même si elles ne sont pas utilisées, cela provoque un surplus inutile.
- De petits changements peuvent affecter toutes les classes dérivées de manière inattendue en raison du couplage étroit.
- L'héritage a une structure définie au moment de la compilation : nous ne pouvons pas modifier quoi que ce soit dans les classes supérieures au moment de la "distribution" des méthodes et propriétés publiques ou protégées aux classes dérivées. Les détails de la classe mère sont exposés à la classe fille et cela brise le concept fondamental d'encapsulation (à savoir "cacher", "garder privées" des propriétés et méthodes, pour maîtriser leurs contenus et leurs actions).
Pour faire une analogie, si je pose des Tucs sur la table pour chacun·e avec 1 Tuc par personne. Sans surveillance, après le passage de 2 ou 3 gourmand·e·s, il n'y en a plus pour les autres. Alors je dépose les Tucs dans un distributeur (ce serait une classe) qui se charge de vérifier qu'un seul soit distribué à chacun. Personne ne peut en prendre directement. J'ajoute un accesseur `obtenirUnTuc
`. Une fois vide cependant, je ne peux plus renouveler le stock. Alors je rajoute aussi un mutateur `insererDesTucs
`.
Est-ce suffisant ? Non ! Je viens de laisser la possibilité d'ajouter des Tucs barbecue ! Tragédie, ils ne sont pas bons ! Alors mon mutateur sera aussi responsable de contrôler la qualité des Tucs insérés.
<?php
class BoiteDeTucs
{
private array $authorizedFlavours = ['bacon', 'fromage', 'crème oignon', 'ail et fines herbes'];
protected array $tucs = [];
public function obtenirUnTuc()
{
if (empty($this->tucs)) {
throw new RuntimeException('boite vide !');
}
return array_shift($this->tucs);
}
public function insererDesTucs(array $tucs)
{
if (!empty($this->tucs)) {
throw new RuntimeException('On fini la boite avant !');
}
$this->tucs = array_filter($tucs, fn ($tuc) => in_array($tuc, $this->authorizedFlavours));
}
}
$boiteDeTucs = new BoiteDeTucs;
$boiteDeTucs->insererDesTucs(['bacon', 'barbecue', 'fromage', 'crème oignon']);
echo $boiteDeTucs->obtenirUnTuc().PHP_EOL; // bacon
echo $boiteDeTucs->obtenirUnTuc().PHP_EOL; // fromage, ouf pas de barbecue.
Nous avons correctement protégé l'accès et l'ajout des Tucs !
Si une implémentation d'une classe est parfaite d'un point de vue encapsulation, et si des changements sont appliqués sur cette classe, aucun changement ne sera nécessaire dans les classes qui en héritent. Seulement dans les faits, dès lors que je vais étendre la boite de Tucs, ma nouvelle classe aura connaissance de toutes les propriétés et méthodes publiques comme protégées.
Disons que je n'ai pas envie des Tucs au fromage aujourd'hui et que je souhaite faire le tri :
<?php
class BoiteDeTucs
{
protected array $authorizedFlavours = ['bacon', 'fromage', 'crème oignon', 'ail et fines herbes'];
protected array $tucs = [];
public function obtenirUnTuc()
{
if (empty($this->tucs)) {
throw new RuntimeException('boite vide !');
}
return array_shift($this->tucs);
}
public function insererdesTucs(array $tucs)
{
if (!empty($this->tucs)) {
throw new RuntimeException('On fini la boite avant !');
}
$this->tucs = array_filter($tucs, fn ($tuc) => in_array($tuc, $this->authorizedFlavours));
}
}
class BoiteDeTucsTriFromage extends BoiteDeTucs
{
public function obtenirUnTuc()
{
while ('fromage' === $tuc = parent::obtenirUnTuc()){
$this->tucs[] = $tuc;
}
return $tuc;
}
}
$boiteDeTucs = new BoiteDeTucsTriFromage;
$boiteDeTucs->insererdesTucs(['fromage', 'bacon', 'barbecue', 'crème oignon']);
echo $boiteDeTucs->obtenirUnTuc().PHP_EOL; // bacon
echo $boiteDeTucs->obtenirUnTuc().PHP_EOL; // crème oignon… Ouf, ni fromage ni barbecue
Imaginons que la marque distributrice de ces biscuits décide de remplacer la gamme fromage par 3 variations : beaufort, comté et chèvre. Lorsque je vais mettre à jour ma `BoiteDeTucs
`, je vais être obligé de modifier ma `BoiteDeTucsTriFromage
` : l'héritage a rompu le contrat d'encapsulation.
Composition
Dans la composition, l'une des classes possède une ou plusieurs instances d'autres classes souvent obtenues en les passant au constructeur.
- Les dépendances sont moindres par rapport à l'héritage, l'idée étant de surtout s'appuyer sur des arguments typés à l'aides d'interfaces pour être le plus versatile et résilient possible dans le futur.
- Les objets sont définis au moment de l'exécution, ils n'ont pas la possibilité d'accéder aux données protégées d'un autre objet : cela permet de maintenir l'encapsulation.
- Elle nécessite plus de temps et produit un code plus verbeux.
- Le système est dépendant de l'interrelation entre les objets.
- Un bon découpage pour conserver en souplesse tout en assurant l'évolutivité du code sans avoir à faire de profonde restructuration est difficile : il faut passer un peu de temps en amont à étudier les possibilités de structures, de design pattern, pour faire le choix optimal.
Pourquoi préférer la composition à l'héritage
La composition est un concept de programmation orientée objet qui stipule que les classes doivent avoir un comportement polymorphe (on parle surtout ici de surcharge de méthode, ce qui n'est pas possible en PHP par exemple). Cela rend le tout plus flexible, plus facile à maintenir malgré un code plus important au départ. C'est à cela que l'on doit le dicton “Favoriser la composition d'objets plutôt que l'héritage de classes”.
L'héritage est un outil puissant. S'il est bien utilisé, il peut rendre votre code compact, facile à lire et rapide à développer mais il existe de nombreux pièges dans lesquels une personne peut facilement tomber. Des changements minimes peuvent avoir des conséquences désastreuses comme vu plus haut. C'est pour cela qu'en règle générale, on opte plutôt pour la composition.
Quand hériter ou non ?
Voyons ensemble quelques règles de typage en POO et les limites qu'elles imposent. Avec celles-ci en tête, vous serez capable de déceler les situations dans lesquelles hériter n'est pas une bonne solution.
Connaissez-vous Barbara Liskov ? Elle a produit une publication nommée “A behavioral notion of subtyping” où elle formalise le Liskov Substitution Principle, un des principes SOLID. Je vous encourage à étudier ces principes (ainsi que la loi de Déméter).
Si S est un sous-type de T, alors les objets de type T dans un programme peuvent être remplacés par des objets de type S sans altérer aucune des propriétés de ce programme.
Le principe de substitution de Liskov impose certaines exigences sur les signatures de méthodes dans les langages de POO. Parmi elles, deux notions que nous allons détailler :
- Le respect de la contravariance des types de paramètres de méthode dans le sous-type.
- Le respect de la covariance des types de retour des méthodes dans le sous-type.
De nombreux langages de programmation prennent en charge le sous-typage. Par exemple, si le type Chat est un sous-type de Animal, un élément de type Chat devrait être accepté partout où un élément de type Animal est utilisé.
La variance fait référence à la manière dont le sous-typage entre des types plus complexes est lié au sous-typage entre leurs composants. Par exemple, comment une liste de chats doit-elle s'identifier comme une liste d'animaux ? Ou comment une fonction qui renvoie un chat doit-elle s'identifier à une fonction qui renvoie un animal ? Ces phrases sont complexes, et rien ne vaut un exemple par la pratique pour s'approprier la règle.
<?php
interface LivingThingInterface {}
interface AnimalInterface extends LivingThingInterface {}
interface DogInterface extends AnimalInterface {}
interface PlantInterface extends LivingThingInterface {}
class Dog implements DogInterface {}
class Plant implements PlantInterface {}
class Pet
{
public function __construct(AnimalInterface $animal) {}
}
class PetDog extends Pet
{
// Fonctionne
public function __construct(DogInterface $dog)
{
parent::__construct($dog);
}
}
class PlantPet extends Pet
{
// Ne fonctionne pas
public function __construct(PlantInterface $plant)
{
parent::__construct($plant);
}
}
DogInterface
hérite de AnimalInterface
, l'héritage tolère ce changement de type, c'est la contravariance. S sous type de T -> S étant DogInterface et T étant AnimalInterface.
PlantInterface
hérite de LivingThingInterface
, tout comme Animal
. Cependant PlantInterface
n'est pas un sous type de AnimalInterface
, c'est un sous type de LivingThingInterface
.
Considérons-le comme un cousin. L'héritage refusera ce changement de type.
<?php
class LivingThing{}
class Plant extends LivingThing {}
class Animal extends LivingThing {}
class Cat extends Animal {}
interface AnimalShelter
{
public function adopt(string $name): Animal;
}
class CatShelter implements AnimalShelter
{
// Fonctionne
public function adopt(string $name): Cat
{
return new Cat($name);
}
}
class PlantShelter implements AnimalShelter
{
// Ne fonctionne pas
public function adopt(string $name): Plant
{
return new Plant($name);
}
}
Cat
hérite de Animal
, l'héritage tolère ce changement de type, c'est la contravariance. S sous type de T -> S étant Cat et T étant Animal.
Plant
hérite de LivingThingInterface
, tout comme Animal
. Cependant Plant
n'est pas un sous type de Animal
. C'est un sous type de LivingThing
.
Considérons-le comme un cousin. L'héritage refusera ce changement de type.
On remarquera que par l'évidence des noms utilisés, il semble étrange d'avoir classé Plant
sous Animal
, mais ce n'est pas toujours aussi flagrant. Une meilleure structure aurait été de tout faire découler de LivingThing.
Aussi, de nouvelles exceptions ne devraient pas être lancées par les méthodes du sous-type, sauf si elles sont des sous-types d'exceptions lancées par les méthodes du super-type. Vous commencez à voir les limites de l'héritage !
Il y a un moment où dans nos structures, ces relations entrent en conflit avec ce que l'on souhaite accomplir. Alors, plutôt que d'hériter pour ajouter des comportements, cela devient plus clair en créant des classes qui font juste ce qui est désiré et de les injecter aux classes qui ont besoin de ce comportement. En prime, ces classes pourront plus facilement être utilisées.
La composition prend beaucoup plus de sens, d'accord. Et la décoration dans tout ça ?
Le pattern
Admettons que nous ayons utilisé de la composition dans notre code. Parmi les principes SOLID se trouve aussi le O pour Open/Closed Principle. Ce principe dit ceci : les entités logicielles (classes, modules, fonctions, etc.) devraient être ouvertes à l'extension, mais fermées à la modification. C'est-à-dire qu'une telle entité peut permettre d'étendre son comportement sans modifier son code source.
Comment ajouter un comportement à une classe, mais sans toucher à son interface et ses signatures, sans lui ajouter de nouvelles méthodes ? C'est envisageable d'hériter et de surcharger, mais nous ne voulons pas retomber dans les limites que nous avons vues plus haut. Alors on compose. On compose en décorant !
Un décorateur est une classe. C'est une classe qui expose la ou les mêmes interfaces que la classe qu'il décore, qu'il veut étendre. Ce n'est pas suffisant bien entendu, pour compléter la décoration, il va falloir passer une instance de la classe décorée à la classe qui la décore, souvent via son constructeur. L'image la plus répandue pour visualiser l'opération est celle des poupées russes s'imbriquant les unes dans les autres. On parle aussi d'onion architecture.
<?php
class LivingThing{}
class Animal extends LivingThing {}
class Cat extends Animal
{
public function __construct(
public string $name,
public bool $needToBeBrushed = true,
public bool $needClawsCutting = true,
public bool $needToBeFed = true
){}
}
interface AnimalShelter
{
public function adopt(string $name): Animal;
}
class CatShelter implements AnimalShelter
{
public function adopt(string $name): Cat
{
return new Cat($name);
}
}
class BetterCatShelter implements AnimalShelter
{
// On donne l'instance décorée
public function __construct(private AnimalShelter $decorated)
{}
public function adopt(string $name): Cat
{
// On utilise le comportement initial de l'instance décorée
$cat = $this->decorated->adopt($name);
// Ainsi qu'un comportement supplémentaire
$this->groom($cat);
return $cat;
}
private function groom(Cat $cat)
{
$cat->needToBeBrushed = false;
$cat->needClawsCutting = false;
$cat->needToBeFed = false;
}
}
class Me
{
private Cat $cat;
// Puis que le décorateur possède la même interface,
// je n'ai rien à changer dans cette classe pour utiliser l'un ou l'autre
public function getACatFromShelter(AnimalShelter $shelter, string $catName)
{
$this->cat = $shelter->adopt($catName);
}
public function checkCatStatus(): string
{
if (!$this->cat->needToBeBrushed && !$this->cat->needClawsCutting && !$this->cat->needToBeFed) {
return "Tout va bien pour {$this->cat->name}.";
}
return "Oula... {$this->cat->name} a besoin d'attention !";
}
}
$me = new Me;
$catShelter = new CatShelter;
$me->getACatFromShelter($catShelter, 'Cripper');
echo $me->checkCatStatus(). PHP_EOL;
$betterCatShelter = new BetterCatShelter($catShelter);
$me->getACatFromShelter($betterCatShelter, 'Jinjer');
echo $me->checkCatStatus(). PHP_EOL;
N'hésitez pas à jouer avec cet exemple, à le triturer pour bien appréhender la mécanique. Par exemple, amusez-vous à ajouter un CollarOptionCatShelter
qui met un collier à son nom au chat lorsque vous payez plus cher.
Et tentez de décorer CatShelter
, et/ou BetterCatShelter
.
Dans Symfony toutes les classes enregistrées comme service sont décorables. Dans la version 6.1 du framework voici comment s'y prendre (traduit de la documentation : https://symfony.com/blog/new-in-symfony-6-1-service-decoration-attributes). Vous pouvez aussi vous référer au talk de notre coopérateur Robin : https://slides.com/chalasr/symfonybc-promise-demystifed/.
Considérons le cas courant où vous voulez décorer un service (par exemple Mailer
) avec un nouveau service qui lui ajoute des capacités de log (par exemple LoggingMailer
). Voici comment vous pouvez configurer la décoration avec des attributs PHP :
Les deux classes devront implémenter une interface commune. Ici MailerInterface.
<?php
// src/Mailer/LoggingMailer.php
namespace App\Mailer;
// ...
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
#[AsDecorator(decorates: Mailer::class)]
class LoggingMailer<meta charset="utf-8"> implements MailerInterface
{
// ...
}
L'attribut `#[AsDecorator]
` prend en charge toutes les options supplémentaires dont vous pourriez avoir besoin :
<?php
// ...
#[AsDecorator(
decorates: Mailer::class,
priority: 10,
onInvalid: ContainerInterface::IGNORE_ON_INVALID_REFERENCE,
)]
class LoggingMailer implements MailerInterface
{
// ...
}
Si vous devez accéder au service décoré à l'intérieur du service décorateur, ajoutez l'attribut `#[MapDecorated]
` à l'un des arguments du constructeur du service :
<?php
// ...
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\MapDecorated;
#[AsDecorator(decorates: Mailer::class)]
class LoggingMailer implements MailerInterface
{
public function __construct(#[MapDecorated] <meta charset="utf-8">MailerInterface $originalMailer)
{
// ...
}
// ...
}
Attention à ne pas décorer un service servant d'Alias dans Symfony, pour éviter de chercher pourquoi le code ne fonctionne pas. L'injection de dépendance de Symfony ne supporte pas encore la décoration d'Alias.
De la part de Baptiste, Antoine, Vincent, Robin, Moi-même... (et tout ceux qui s'y sont frotté)
J'espère que ce concept est un peu plus clair pour vous maintenant. Pour votre veille personnelle, n'hésitez pas également à consulter ce billet de Symfony sur les différentes façons de décorer un service. Bon code à vous !