Chargement...
Accueil /  Blog /  Symfony /  Le pattern Décorateur avec Symfony

Le pattern Décorateur avec Symfony

Publié le mercredi 16 mars 2022

Apprenez à découper votre code devenu trop complexe avec le pattern décorateur, en vous aidant de Symfony.

Le pattern Décorateur

Ce design pattern propose une façon de découper et d'agencer un ensemble de classe répondant à un même contrat, et œuvrant ensemble dans le même objectif.
L'architecture propose d'imbriquer les objets les uns dans les autres (à la manière de poupées russes), chaque couche supplémentaire ajoutant sa spécificité.

L'intérêt de ce découpage est de proposer un ensemble de classe avec des spécificités, facilement composables, et respectant le principe Ouvert/Fermé : le comportement du code est modifié, mais pas le code en lui-même.

Exemple concret

Disons que votre application doit chercher des informations à propos de ses utilisateurs sur un service tiers (peu importe la technologie).
Vous avez créé un service répondant à ce besoin.

final class UserFetcher
{
    public function fetch(string $username): array
    {
        // ...
    }
}

Mais voilà, on vous demande d'ajouter quelques capacités annexes au code que vous avez produit :

  • logger les appels qui sont faits
  • ajouter des informations par défaut lorsque la récupération échoue
  • mettre en cache les informations reçues
  • chronométrer les appels qui sont faits

Alors évidemment, vous pourriez tout simplement modifier le code du service que vous avez déjà écrit, et lui ajouter en une seule fois toutes ces choses.
Seulement voilà, ça fait beaucoup de responsabilités pour une seule et même classe (c'est même une violation du principe de responsabilité unique).

Mise en oeuvre avec Symfony

Ce n'est pas une obligation du pattern, mais vous gagnerez vraiment à exposer un contrat pour ce composant, et ici une interface sera le candidat idéal.
Il suffit d'extraire depuis le service que vous avez déjà écrit la signature de la méthode principale :

namespace App\User;

interface UserFetcherInterface
{
    public function fetch(string $username): array;
}

Commençons déjà par poser l'interface sur votre service :

namespace App\User;

final class UserFetcher implements UserFetcherInterface
{
    public function fetch(string $username): array
    {
        // ...
    }
}

Il s'agit maintenant d'écrire chacun des comportements décrits plus haut, en respectant le contrat.
De façon générale, un décorateur a toujours plus ou moins la même structure :

  • Il implémente l'interface du composant qu'il décore
  • Il reçoit à la construction un autre élément de la même interface
  • Il "entoure" l'appel à l'élément décoré de sa logique
namespace App\User;

final class WhateverFetcher implements UserFetcherInterface
{
    public function __construct(
        private UserFetcherInterface $decorated,
    ) {
    }

    public function fetch(string $username): array
    {
        // quelque chose à faire avant ?
        $result = $this->decorated->fetch($username);
        // quelque chose à faire après ?

        return $result;
    }
}

Mettre en cache les informations

Ce décorateur nécessite symfony/cache et autorise la mise en cache du résultat de l'appel à l'instance qu'il décore.

namespace App\User;

use Symfony\Contracts\Cache\CacheInterface;

final class CacheUserFetcher implements UserFetcherInterface
{
    public function __construct(
        private UserFetcherInterface $decorated,
        private CacheInterface $cache,
    ) {
    }

    public function fetch(string $username): array
    {
        return $this->cache->get(
            'fetch_user_' . $username,
            fn () => $this->decorated->fetch($username)
        );
    }
}

Résultat par défaut en cas d'échec

Cette implémentation permet seulement d'éviter qu'une erreur déclenchée par l'instance décorée ne vienne bloquer le process, et propose une valeur de remplacement lorsque cela arrive.

namespace App\User;

final class DefaultsUserFetcher implements UserFetcherInterface
{
    public function __construct(
        private UserFetcherInterface $decorated,
        private array $defaults,
    ) {
    }

    public function fetch(string $username): array
    {
        try {
            return $this->decorated->fetch($username);
        } catch (\Throwable) {
            return $this->defaults;
        }
    }
}

Logger les appels et les erreurs

Cette implémentation nécessite un logger (généralement grâce à symfony/monolog-bundle) qui sera utilisé pour tracer chaque appel à l'implémentation décorée ainsi que les erreurs qu'elle pourrait émettre.

namespace App\User;

use Psr\Log\LoggerInterface;

final class LogUserFetcher implements UserFetcherInterface
{
    public function __construct(
        private UserFetcherInterface $decorated,
        private LoggerInterface $logger,
    ) {
    }

    public function fetch(string $username): array
    {
        $this->logger->info(
            'Fetching user info from API client.',
            ['username' => $username, 'client' => \get_class($this->decorated)]
        );

        try {
            return $this->decorated->fetch($username);
        } catch (\Throwable $error) {
            $this->logger->error(
                'An error occurred while fetching info from API client.',
                ['error' => $error, 'client' => \get_class($this->decorated)]
            );

            throw $error;
        }
    }
}

Chronométrer les appels

Cette implémentation utilise symfony/stopwatch qui sera appelé pour profiler le code de l'instance décorée.

namespace App\User;

use Symfony\Component\Stopwatch\Stopwatch;

final class TraceUserFetcher implements UserFetcherInterface
{
    public function __construct(
        private UserFetcherInterface $decorated,
        private Stopwatch $stopwatch,
    ) {
    }

    public function fetch(string $username): array
    {
        $this->stopwatch->start('fetch_user_' . $username);

        try {
            return $this->decorated->fetch($username);
        } finally {
            $this->stopwatch->stop('fetch_user_' . $username);
        }
    }
}

Déclarer les services

Maintenant que nos classes sont créées, nous allons pouvoir nous attaquer aux services associés.

Notre objectif reste de pouvoir continuer à injecter le user fetcher par autowiring, sans avoir à se poser la question l'instance réelle que l'on reçoit.
Pour ça, on va utiliser un alias Interface => Classe qui permettra à Symfony de savoir que dès lors qu'un service demande l'interface de notre user fetcher, il doit injecter tel service à la place.

services:
    App\User\UserFetcherInterface: '@App\User\UserFetcher'

Maintenant, nous allons pouvoir nous attaquer à la décoration à proprement parler.
De ce côté là, c'est parfait car Symfony dispose d'une mécanique intégrée au composant DependencyInjection.

Nous allons donc utiliser les options decorates & decoration_priority pour contrôler la fabrication de l'ensemble de nos services.

services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\User\CacheUserFetcher:
        decoration_priority: 2
        decorates: App\User\UserFetcher

    App\User\DefaultsUserFetcher:
        decoration_priority: 1
        decorates: App\User\UserFetcher
        arguments:
            $defaults:
                firstName: John
                lastName: Doe

    App\User\LogUserFetcher:
        decoration_priority: 4
        decorates: App\User\UserFetcher

    App\User\TraceUserFetcher:
        decoration_priority: 3
        decorates: App\User\UserFetcher

Avec cette configuration, on obtient la construction suivante :

new LogUserFetcher(
    new TraceUserFetcher(
        new CacheUserFetcher(
            new DefaultsUserFetcher(
                new UserFetcher(),
                ...
            ),
            ...
        ),
        ...
    ),
    ...
);

Mais comme vous l'avez compris, en jouant avec decoration_priority, vous pouvez fabriquer n'importe quelle combinaison de classes.
C'est d'ailleurs pour cette raison que le contrat est important, car sans lui, la structure serait figée.

Conclusion

Ce pattern peut être mis en place dès que vous repérez une classe ayant trop de dépendances, notamment techniques.
Mais de façon générale, c'est un très bon pattern de structure qui convient à bien des moments.

Grâce à la mise en place de ce pattern, vous obtiendrez des structures de classes flexibles où l'on peut changer le comportement du code sans changer le code lui-même.
Cette flexibilité, apportée notamment par la mise en place d'une interface, rend notre code plus robuste et plus facile à tester.
Et comme la mise en place dans Symfony est très simple, c'est un pattern dont nous pouvons abuser.

Suivez notre actualité en avant première. Pas plus d’une newsletter par mois.