chaine-de-responsabilité

Réduire la complexité du code avec un patron de conception “chaîne de responsabilité”

Par Nicolas M le 18 juin 2021

Il n’est pas rare que, dans certains cas, les développeurs soient confrontés à du code utilisant de manière excessive les déclarations PHP (if, else, switch, etc…). Dans ce cas, ils cherchent forcément une solution pour refactoriser cette partie du code proprement. Ils peuvent opter pour un patron de chaîne de responsabilité.

L’exemple suivant illustre grossièrement la problématique à résoudre :

 

class DocumentsRetriever
{
    private const DOCUMENT_TYPES = [
        'TOS',
        'Invoice',
        'HowToUse',
        'Tracking',
    ];

    function getDocuments(OrderInterface $order): array
    {
        $documentsToPrint = [];

        foreach (self::DOCUMENT_TYPES as $documentType) {
            $countryCode = $order->getShippingAddress()->getCountryCode();
            $documents = $this->getDocumentToPrintForCountry($countryCode);

            switch ($documentType) {
                case 'TOS':
                    $documentsToPrint[] = [
                        'path' => $documents->getPath(),
                        'qty' => 1,
                    ];
                    break;

                case 'Invoice':
                    if ($countryCode === 'CH') {
                        $documentsToPrint[] = [
                            'path' => $documents->getPath(),
                            'qty' => 2,
                        ];
                    }
                    $documentsToPrint[] = [
                        'path' => $documents->getPath(),
                        'qty' => 1,
                    ];
                    break;

                case 'HowToUse':
                    if ($order->hasComplicatedItem()) {
                        $documentsToPrint[] = [
                            'path' => $documents->getPath(),
                            'qty' => 1,
                        ];
                    }
                    break;

                case 'Tracking':
                    if ($order->getShippingMethod() === 'firstCarrier') {
                        $downloadedDocument = $this->transporter1->getShippingLabel();
                        $documentsToPrint[] = [
                            'path' => $downloadedDocument->getPath(),
                            'qty' => 2,
                        ];
                    } else if ($order->getShippingMethod() === 'secondCarrier') {
                        $downloadedDocument = $this->transporter2->getShippingLabel();
                        $documentsToPrint[] = [
                            'path' => $downloadedDocument['label']->getPath(),
                            'qty' => 1,
                        ];
                        $documentsToPrint[] = [
                            'path' => $downloadedDocument['dangerous_notice']->getPath(),
                            'qty' => 1,
                        ];
                    }
                    break;
            }
        }

        return $documentsToPrint;
    }
}

C’est à ce moment-là que le patron de chaîne de responsabilité entre en jeu !

La théorie du patron chaîne de responsabilité et ses bienfaits

En quelques mots, il s’agit d’un patron de conception qui permet de déléguer les responsabilités propres à une tâche dans sa propre classe/service.

Appliquer le patron de chaîne de responsabilité de concert avec l’injection de dépendances existantes sur Symfony, Sylius, Adobe Commerce Magento et OroCommerce permet tout d’abord de découper le code dans des classes séparées pour gagner en visibilité, maintenabilité et testabilité. Cela permet aussi de spécifier des priorités sur les classes à responsabilité (utile, par exemple, pour prioriser des services peu gourmands en ressources pour lesquels il n’est possible de retourner qu’un seul résultat supporté).

En pratique

Il faut tout d’abord convertir la structure du premier exemple montré dans cet article pour le transformer en chaîne de responsabilité.

Le cas d’usage sera démontré sur un projet Symfony.

Voici un diagramme UML de ce à quoi devrait ressembler le code final :

 

 

Modélisation de l’objet Document et des interfaces

Il faut commencer par structurer les classes avec des interfaces.

La première classe sera le modèle de données qui va contenir des éléments pour récupérer des informations sur les documents plus tard.

interface DocumentInterface 
{
    public function getQuantity(): int;

    public function getPath(): string;

    public function getType(): string;
}

class Document implements DocumentInterface
{
    private string $type;
    private int $quantity;
    private ?string $path;

    public function __construct(string $type, int $quantity, ?string $path)
    {
        $this->type = $type;
        $this->quantity = $quantity;
        $this->path = $path;
    }
    
    public function getType(): string
    {
        return $this->type;
    }

    public function getQuantity(): int
    {
        return $this->quantity;
    }


    public function getPath(): ?string
    {
        return $this->path;
    }
}

Il est nécessaire d’interfacer les fournisseurs de documents. Le TAG_ID est l’identifiant unique qui va servir à mapper les fournisseurs de documents au bon data retriever (thématique abordée plus bas).

interface OrderDocumentProviderInterface 
{ 
        public const TAG_ID = 'order_document.provider'; 

        public function getDocument(OrderInterface $order): iterable; 

        public function support(OrderInterface $order): bool; 

} 

Création du récupérateur de données (data retriever)

Désormais, un service pour venir chercher les documents de manière transparente est nécessaire. Ce service va recevoir tous les fournisseurs de documents qui seront enregistrés dans les services. Il va simplement boucler sur chacun pour les récupérer s’ils sont supportés.

Notez que pour l’exemple de cet article, un tableau d’objets est retourné car un data retriever est présent pour renvoyer une liste de documents, mais il serait aussi possible de retourner l’objet du premier provider supporté (ex: des builders de data) ou bien d’exécuter une tâche qui ne retourne rien (ex: des processors).

 

interface ChainOrderDocumentProviderInterface 
{
    public function getDocuments(OrderInterface $order): iterable;
}
 

final class ChainOrderDocumentProvider implements ChainOrderDocumentProviderInterface
{
    /** @var array<DocumentInterface> */
    private $orderDocumentProviders;

    //$handlers is populated automatically with our tagged services
    public function __construct(\Traversable $handlers)
    {
        $this->orderDocumentProviders = iterator_to_array($handlers);
    }
    
    /** @return array<DocumentInterface> */
    public function getDocuments(OrderInterface $order): array
    {
        $documentsToReturn = [];

        foreach($this->orderDocumentProviders as $documentsProvider) {
            if ($documentsProvider->support($order)) {
                $documentsToReturn[] = $documentsProvider->getDocument($order);
            }
        }
        
        return $documentsToReturn;
    }
}

Il faut ensuite déclarer la classe en tant que service en injectant les fournisseurs de documents dans son constructeur. Cette magie s’opère grâce au TAG_ID. Cela permet d’injecter automatiquement toutes les classes qui portent ce tag.

services:
    App\OrderDocumentsRetriever:
        arguments:
            - !tagged_iterator { tag: !php/const App\OrderDocumentProviderInterface::TAG_ID }

Implémentation des services à responsabilité

La prochaine étape consiste à créer les fournisseurs de documents. Chacun possède sa méthode pour savoir s’il supporte la commande (order) et peut ainsi s’occuper seulement de sa responsabilité. S’il est supporté pour la commande, l’appel à la méthode getDocument() est effectué par le récupérateur de données (data retriever).

final class DefaultInvoiceDocumentProvider implements OrderDocumentProviderInterface
{
    private const TYPE = 'invoice';    

    public static function getDefaultPriority(): int
    {
        return -99;
    }    

    public function getDocument(OrderInterface $order): DocumentInterface
    {
        return
            (new Document())
            ->setType(self::TYPE)
            ->setQuantity(1)
        ;
    }    

    public function support(OrderInterface $order): bool
    {
        return $order->getShippingAddress()->getCountry()->isInvoiceRequired() && 'CH' !== $order->getShippingAddress()->getCountry()->getCode();
    }
}

<?php

final class TOSDocumentProvider implements OrderDocumentProviderInterface
{
    private const TYPE = 'tos';    public static function getDefaultPriority(): int
    {
        return -90;
    }    

    public function getDocument(OrderInterface $order): DocumentInterface
    {
        return 
            (new Document())
            ->setType(self::TYPE)
            ->setQuantity(1)
        ;
    }    

    public function support(OrderInterface $order): bool
    {
        return true;
    }
}

<?php

final class OtherDocumentProvider implements OrderDocumentProviderInterface
{
    private const TYPE = 'other';    public static function getDefaultPriority(): int
    {
        return -80;
    }    

    /**
     * @var YourService
     */
    private $yourService;    

    public function __construct(YourService $yourService) {
        $this->yourService = $yourService;
    }    

    public function getDocuments(OrderInterface $order): DocumentInterface
    {
        // TODO: implement your own logic
    }    

    public function support(OrderInterface $order): bool
    {
        // TODO: implement your own logic
        // $this->yourService->call()...
    }
}

Pour finir, il faut procéder à la déclaration des fournisseurs en tant que services. Grâce à l’interface du provider et à l’auto-configuration de Symfony, il est possible d’enregistrer tous les services avec une seule déclaration de configuration.

services:
    _instanceof:
        App\OrderDocumentProviderInterface:
            tags:
                - { name: !php/const App\OrderDocumentProviderInterface::TAG_ID }

Vous pouvez maintenant injecter le service ChainOrderDocumentProviderInterface dans votre classe pour récupérer la liste des documents.

class DocumentEmailer
{
    private ChainOrderDocumentProviderInterface $chainDocumentsRetriever;
    private MailerInterface $mailer;
    
    public function __construct(ChainOrderDocumentProviderInterface $chainDocumentsRetriever, MailerInterface $mailer)
    {
        $this->chainDocumentsRetriever = $chainDocumentsRetriever;
        $this->mailer = $mailer;
    }
    
    public function send(): void
    {
        $email = new Email();
        $documents = $this->chainDocumentsRetriever>getDocuments();
        array_walk($documents, fn(DocumentInterface $doc) use ($email): void =>                $email->attachFromPath($doc->getPath()));
        $this->mailer->send($email);
    }
}

Conclusion

Maintenant que les classes à responsabilité sont terminées, le couplage entre les objets a été réduit. Un faible couplage amène plusieurs bienfaits, notamment la réduction de l’impact lors de changement sur des classes à responsabilité. Cela n’affectera pas les autres ! L’écriture des classes sera plus simple, car elles seront centrées sur une seule responsabilité plutôt que de devoir en gérer plusieurs. La cerise sur le gâteau, c’est que chaque responsabilité peut être testée individuellement. Ce qui va réduire le temps d’écriture des tests et chaque responsabilité sera testée plus précisément. Ainsi, il s’agit de gagner en maintenabilité et de réduire le risque de régression.

Ce système est notamment utile lors du développement de plugins ou de bundles qui vont être installés sur des projets. Cela permet à chaque projet d’ajouter ses classes à responsabilité suivant les besoins spécifiques, sans avoir le besoin d’aller altérer le bundle. Si vous souhaitez voir des exemples réels d’utilisation de ce patron de conception, vous pouvez jeter un œil au plugin Sylius de Synolia SyliusAkeneoPlugin qui utilise ce procédé pour importer différents types d’attributs Akeneo dans Sylius. Vous trouverez des exemples complets dans le dossier Processor ainsi que dans le dossier Provider.

Si ce sujet vous a plu, vous serez peut-être intéressé par les cinq bonnes pratiques de l’orienté objet de l’acronyme SOLID qui sont :

  • Classe à responsabilité unique
  • Ouvert/fermé
  • Substitution de Liskov
  • Ségrégation des interfaces
  • Inversion des dépendances.
LinkedIn Google+ Email
GIF