Apprenez à découper votre code devenu trop complexe avec le pattern décorateur, en vous aidant de Symfony.
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.
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 :
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).
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 :
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;
}
}
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)
);
}
}
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;
}
}
}
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;
}
}
}
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);
}
}
}
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.
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.