Le design pattern Strategy dans Symfony

Découvrez comment mettre en place le design pattern Strategy dans Symfony.

Jérémy 🤘
Jérémy 🤘

Je suis actuellement en train de développer un système de release management avec Gitlab et afin de faire évoluer les issues et les merge request j'utilise leurs webhooks.

Le souci c'est qu'ils envoient tous les webhooks sur la même URL avec juste certains paramètres qui permettent de les identifier. Je dois donc être en mesure de distinguer dans mon code quelle méthode exécuter.

C'est pourquoi je vais vous parler aujourd'hui du design pattern Strategy. Ce pattern répond exactement à ce que je souhaite faire. Avoir des processus différents orchestrés autour d'un même système.

La théorie

Prenons par exemple le cas d'un système qui a besoin de comparer des choses. Peu importe comment il doit comparer ce n'est pas à lui d'avoir l'algorithme de comparaison. Il doit juste savoir si une valeur est plus grande, égale ou plus petite que l'autre.

Comme il est plus facile de visualiser cela avec un schéma, voici un diagramme UML :

Diagramme UML design pattern Strategy
Diagramme UML design pattern Strategy

Dans notre système nous avons la possibilité de comparer des dates et des entiers.

Code

  • ComparatorInterface.php
Copier
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Strategy;

interface ComparatorInterface
{
    /**
     * @param mixed $a
     * @param mixed $b
     */
    public function compare($a, $b): int;
    
    public function getType(): string;
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Strategy;

interface ComparatorInterface
{
    /**
     * @param mixed $a
     * @param mixed $b
     */
    public function compare($a, $b): int;
    
    public function getType(): string;
}
  • DateComparator.php
Copier
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Strategy;

class DateComparator implements ComparatorInterface
{
    public function compare(string $a, string $b): int
    {
        $aDate = new \DateTime($a);
        $bDate = new \DateTime($b);

        return $aDate <=> $bDate;
    }
    
    public function getType(): string
    {
        return 'date';
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Strategy;

class DateComparator implements ComparatorInterface
{
    public function compare(string $a, string $b): int
    {
        $aDate = new \DateTime($a);
        $bDate = new \DateTime($b);

        return $aDate <=> $bDate;
    }
    
    public function getType(): string
    {
        return 'date';
    }
}
  • IntComparator.php
Copier
<?php 

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Strategy;

class IntComparator implements ComparatorInterface
{
    public function compare(int $a, int $b): int
    {
        return $a <=> $b;
    }
    
    public function getType(): string
    {
        return 'int';
    }
}
<?php 

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Strategy;

class IntComparator implements ComparatorInterface
{
    public function compare(int $a, int $b): int
    {
        return $a <=> $b;
    }
    
    public function getType(): string
    {
        return 'int';
    }
}
  • Context.php
Copier
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Strategy;

class Context
{
    protected ComparatorInterface $comparator;

    public function __construct(ComparatorInterface $comparator)
    {
        $this->comparator = $comparator;
    }

    /**
     * @param mixed $a
     * @param mixed $b
     */
    public function executeStrategy($a, $b): array
    {
        return $this->compare($a, $b);
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Strategy;

class Context
{
    protected ComparatorInterface $comparator;

    public function __construct(ComparatorInterface $comparator)
    {
        $this->comparator = $comparator;
    }

    /**
     * @param mixed $a
     * @param mixed $b
     */
    public function executeStrategy($a, $b): array
    {
        return $this->compare($a, $b);
    }
}

Utilisation

Copier
<?php

declare(strict_types=1);

use \DesignPatterns\Behavioral\Strategy\Context;

$context = new Context(
    new DateComparator()
);
$sortedDates = $context->executeStrategy(
    '2020-05-20',
    '2020-12-20',
]);

$context = new Context(
    new IntComparator()
);
$sortedDates = $context->executeStrategy(
    52,
    25,
]);
<?php

declare(strict_types=1);

use \DesignPatterns\Behavioral\Strategy\Context;

$context = new Context(
    new DateComparator()
);
$sortedDates = $context->executeStrategy(
    '2020-05-20',
    '2020-12-20',
]);

$context = new Context(
    new IntComparator()
);
$sortedDates = $context->executeStrategy(
    52,
    25,
]);

Grâce à ce pattern, j'ai pu comparer des valeurs totalement différentes sans me soucier de l'algorithme qui est derrière. Et si je dois placer cela dans un controller ou autre, mon système est facilement maintenable et sans duplication de code.

La pratique

Maintenant que nous avons vu comment cela fonctionne, voici comment on pourrait l'appliquer dans un cas un peu plus concret et sous Symfony.

Dans Symfony il est possible d'utiliser des tags pour différencier nos services. Ce qui va nous être bien pratique par la suite.

  • config/services.yml
Copier
services:
    _instanceof:
        DesignPatterns\Behavioral\Strategy\ComparatorInterface:
            tags: ['app.comparator']
    
    DesignPatterns\Behavioral\Strategy\Registry:
    	autoconfigure: true
    	arguments:
        	- !tagged app.comparator
services:
    _instanceof:
        DesignPatterns\Behavioral\Strategy\ComparatorInterface:
            tags: ['app.comparator']
    
    DesignPatterns\Behavioral\Strategy\Registry:
    	autoconfigure: true
    	arguments:
        	- !tagged app.comparator

Ainsi lors de l'autoconfiguration des services par Symfony, il va pouvoir tagger automatiquement les services qui sont une instance de ComparatorInterface.

L'autre avantage, c'est que je peux lui dire de passer en paramètre à mon registry tous les services qui ont ce tag là.

  • Registry.php
Copier
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Strategy;

class Registry
{
    /** @var ComparatorInterface[] */
    protected array $comparators;

    public function __construct(iterable $comparators)
    {
        foreach($comparators as $comparator) {
            $this->comparators[$comparator->getType()] = $comparator;
        }
    }
    
    public function getComparator(string $type): ?ComparatorInterface
    {
        return $this->comparators[$type] ?? null;
    }
}
<?php

declare(strict_types=1);

namespace DesignPatterns\Behavioral\Strategy;

class Registry
{
    /** @var ComparatorInterface[] */
    protected array $comparators;

    public function __construct(iterable $comparators)
    {
        foreach($comparators as $comparator) {
            $this->comparators[$comparator->getType()] = $comparator;
        }
    }
    
    public function getComparator(string $type): ?ComparatorInterface
    {
        return $this->comparators[$type] ?? null;
    }
}

Une fois que j'aurai enregistré tous mes services dans la configuration de Symfony, le container va passer au constructeur de mon registry tous les services qui sont taggués. Et afin de les retrouver plus facilement je les trie juste par le type qu'ils sont capables de traiter.

Par la suite, imaginons que je dispose d'une API avec un endpoint qui permet de comparer des valeurs.

Copier
POST /api/compare
{
	"values": [a, b],
	"type": string,
}
POST /api/compare
{
	"values": [a, b],
	"type": string,
}

Je vais avoir un controller qui ressemblera à ça :

  • CompareController.php
Copier
<?php

declare(strict_types=1);

namespace App\Controller\Api;

use DesignPatterns\Behavioral\Strategy\Registry;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;

class CompareController
{
    protected Registry $registry;

    public function __construct(Registry $registry)
    {
        $this->registry = $registry;
    }

    public function __invoke(Request $request): JsonResponse
    {
        $payload = $request->request->all();
        
        if (false === \array_key_exists('type', $payload)
            || false === \array_key_exists('values', $payload)
            || false === \is_array($payload['values'])
            || 2 !== \count($payload['values'])
        ) {
            return new JsonResponse(
                [
                    'error' => 'Bad request.',
                ],
                Response::HTTP_BAD_REQUEST,
            );
        }
        
        $comparator = $this
            ->registry
            ->getComparator($payload['type'])
        ;
        
        if (false === $comparator instanceof ComparatorInterface) {
            return new JsonResponse(
                [
                    'error' => 'Comparator "' . $payload['type'] . '" not found.',
                ],
                Response::HTTP_NOT_FOUND,
            );
        }
        
        $comparatorResult = $comparator->compare(
            $payload['values'][0],
            $payload['values'][1],
        );
        
        return new JsonResponse(
            [
                'result' => $comparatorResult,
            ],
            Response::HTTP_OK,
        );
    }
}
<?php

declare(strict_types=1);

namespace App\Controller\Api;

use DesignPatterns\Behavioral\Strategy\Registry;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;

class CompareController
{
    protected Registry $registry;

    public function __construct(Registry $registry)
    {
        $this->registry = $registry;
    }

    public function __invoke(Request $request): JsonResponse
    {
        $payload = $request->request->all();
        
        if (false === \array_key_exists('type', $payload)
            || false === \array_key_exists('values', $payload)
            || false === \is_array($payload['values'])
            || 2 !== \count($payload['values'])
        ) {
            return new JsonResponse(
                [
                    'error' => 'Bad request.',
                ],
                Response::HTTP_BAD_REQUEST,
            );
        }
        
        $comparator = $this
            ->registry
            ->getComparator($payload['type'])
        ;
        
        if (false === $comparator instanceof ComparatorInterface) {
            return new JsonResponse(
                [
                    'error' => 'Comparator "' . $payload['type'] . '" not found.',
                ],
                Response::HTTP_NOT_FOUND,
            );
        }
        
        $comparatorResult = $comparator->compare(
            $payload['values'][0],
            $payload['values'][1],
        );
        
        return new JsonResponse(
            [
                'result' => $comparatorResult,
            ],
            Response::HTTP_OK,
        );
    }
}

Comme vous pouvez le voir, mon controller permet de gérer facilement les différents types de comparaison sur un seul endpoint et si je dois créer un nouveau type de comparaison, je n'ai qu'a créer une nouvelle classe qui implémtente l'interface et l'inscrire dans les services de Symfony.

Source