Le design pattern Builder

Récemment, j’ai été confronté au fait de devoir manipuler les mêmes données pour ensuite les convertir dans trois formats différents (des formats de sous-titres en l’occurrence).

A des fins didactiques, je vais ici prendre un exemple très simple: manipuler les mêmes données pour les renvoyer en html ou en json (pour que ça soit plus parlant, ça pourrait être csv et json en imaginant que le traitement de construction des deux formats est complexe, dépend d’appels multiples en bdd ou d’API et nécessite un ordonnancement ou un algorithme complexe).

Le design pattern Builder est parfait pour ça. Si on ne l’applique pas, voici ce que cela pourrait donner:

    /**
     * @param Request $request
     *
     * @Route(
     *     "/monitoring/status.{_format}",
     *     methods={"GET"},
     *     defaults={"_format": "html"},
     *     requirements={
     *         "_format": "html|json"
     *     }
     * )
     *
     * @return JsonResponse|Response
     *
     * @throws \Exception
     */
    public function renderAction(Request $request)
    {
        // bien sûr, ici, c'est un service qui va aller chercher ces informations et les transformer en tableau
        $monitoring = [
            ['name' => "connection mysql", "status" => 1, "message" => "The mysql connection succeeded."],
            ['name' => "connection mongoDB", "status" => 0, "message" => "The mongodb connection failed."],
        ];

        if ('json' === $request->getRequestFormat()) {
            return new JsonResponse($monitoring);
        }

        return $this->render('index.html.twig', ['results' => $monitoring]);
    }

Ok, vous allez me dire: « oh, ce n’est pas dramatique! » Oui, c’est sûr. Mais imaginez que l’on doive finalement avoir un troisième rendu en csv… On revient sur le code (donc on ne respecte pas le O de SOLID) et on rajoute un if. Et en plus, comme le rendu est plus complexe, on appelle une classe qui va effectuer le rendu csv.

Et imaginez qu’il y a un quatrième rendu… puis un cinquième… et que chacun fait appel à sa propre classe de rendu (par exemple sous forme de camembert, que sais-je!). Ou qu’un jour, on décide de ne pas simplement renvoyer du json mais d’avoir un rendu plus élaboré (et donc d’appeler une classe qui va le rendre)… vous commencez à voir le souci.

Ce que je vous propose ici est un code simple mais qui va découpler les builders et rendre le contrôleur complètement dynamique sans que l’on ait un jour besoin d’y revenir.

Construire nos builders

<?php

namespace App\Builder;

use Symfony\Component\HttpFoundation\JsonResponse;

class JsonBuilder implements StatusBuilderInterface
{
    /**
     * @param array $results
     *
     * @return JsonResponse|\Symfony\Component\HttpFoundation\Response
     */
    public function getReport(array $results)
    {
        return new JsonResponse($results);
    }

    /**
     * @param string $format
     *
     * @return StatusBuilderInterface|string
     */
    public function isFormatMatch(string $format)
    {
        return 'json' === $format;
    }
}
<?php

namespace App\Builder;

use Symfony\Component\HttpFoundation\Response;

class HtmlBuilder implements StatusBuilderInterface
{
    /**
     * @var \Twig_Environment
     */
    private $templating;

    /**
     * @param \Twig_Environment $templating
     */
    public function __construct(\Twig_Environment $templating)
    {
        $this->templating = $templating;
    }
    /**
     * @param array $results
     *
     * @return Response
     *
     * @throws \Twig_Error_Loader
     * @throws \Twig_Error_Runtime
     * @throws \Twig_Error_Syntax
     */
    public function getReport(array $results)
    {
        return new Response($this->templating->render('index.html.twig', ['results' => $results]));
    }

    /**
     * @param string $format
     *
     * @return StatusBuilderInterface|string
     */
    public function isFormatMatch(string $format)
    {
        return 'html' === $format;
    }
}

Comme nous l’avons vu, ils sont très simples puisque le JsonBuilder renvoie juste les résultats en json alors que le HtmlBuilder fait appel à un template twig. Dans la vie réelle, ils seraient beaucoup plus complexes que ça. Et bien sûr, il pourrait y avoir un CsvBuilder, un XmlBuilder, etc. qui feraient également des traitements complexes et/ou ordonnés.

Ne vous étonnez pas de la fonction isFormatMatch, elle va nous servir plus tard pour déterminer à la volée le bon builder dans le contrôleur.

Notre BuilderResolver

Il est temps de déterminer la classe qui aura dans un tableau les différents builders, rajoutés à la volée par le CompilerPass, et qui retournera le builder voulu par rapport au format attendu:

<?php

namespace App\Builder;

class BuilderResolver
{
    /**
     * @var array
     */
    private $builders = [];

    /**
     * @param StatusBuilderInterface $builder
     */
    public function addBuilder(StatusBuilderInterface $builder)
    {
        array_push($this->builders, $builder);
    }

    /**
     * @param string $format
     *
     * @return StatusBuilderInterface
     *
     * @throws \Exception
     */
    public function getBuilder(string $format) : StatusBuilderInterface
    {
        /**
         * @var StatusBuilderInterface $builder
         */
        foreach ($this->builders as $builder)
        {
            if ($builder->isFormatMatch($format)) {
                return $builder;
            }
        }

        throw new \Exception(sprintf('No builder could be found with format %s.', $format));
    }
}

Vous allez peut-être me dire: « mais comment les builders vont-ils être ajoutés à la volée? ». Pour faire cela, il faut déjà tagguer nos builders qui implémente une interface:

<?php

namespace App\Builder;

use Symfony\Component\HttpFoundation\Response;

interface StatusBuilderInterface
{
    /**
     * @param array $results
     *
     * @return Response
     */
    public function getReport(array $results);

    /**
     * @param string $format
     *
     * @return StatusBuilderInterface
     */
    public function isFormatMatch(string $format);
}

Ensuite, dans services.yaml (Symfony 3.4/4.1):

services:
    _instanceof:
        App\Builder\StatusBuilderInterface:
            tags:
                - { name: app.status_builder }

 

Puis le compilerPass:

<?php

namespace App\DependencyInjection\Compiler;

use App\Builder\BuilderResolver;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
use Symfony\Component\DependencyInjection\Reference;

class StatusBuilderCompilerPass implements CompilerPassInterface
{
    /**
     * @param ContainerBuilder $container
     */
    public function process(ContainerBuilder $container)
    {
        if (!$container->has(BuilderResolver::class)) {
            throw new ServiceNotFoundException('BuilderResolver not found (BuilderResolver::class).');
        }

        $builderResolver = $container->getDefinition(BuilderResolver::class);

        foreach ($container->findTaggedServiceIds('app.status_builder') as $id => $tags) {
            $builderResolver->addMethodCall('addBuilder', [new Reference($id)]);
        }
    }
}

et sa déclaration dans Kernel.php:

    public function build(ContainerBuilder $container)
    {
        parent::build($container);

        $container->addCompilerPass(new StatusBuilderCompilerPass());
    }

Le contrôleur

Il va appeler le BuilderResolver et lui passer en argument le format demandé dans l’uri:

<?php

namespace App\Controller;

use App\Builder\BuilderResolver;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

class StatusReportController extends Controller
{
    /**
     * @param Request $request
     * @param BuilderResolver $builderResolver
     *
     * @Route(
     *     "/monitoring/status.{_format}",
     *     methods={"GET"},
     *     defaults={"_format": "html"},
     *     requirements={
     *         "_format": "html|json"
     *     }
     * )
     *
     * @return JsonResponse|Response
     *
     * @throws \Exception
     */
    public function renderAction(Request $request, BuilderResolver $builderResolver)
    {
        // bien sûr, ici, c'est un service qui va aller chercher ces informations et les transformer en tableau
        $monitoring = [
            ['name' => "connection mysql", "status" => 1, "message" => "The mysql connection succeeded."],
            ['name' => "connection mongoDB", "status" => 0, "message" => "The mongodb connection failed."],
        ];

        $builder = $builderResolver->getBuilder($request->getRequestFormat());

        return $builder->getReport($monitoring);
    }
}

Le mot de la fin

Déterminer la bonne classe à appeler à la volée relève du design pattern Strategy, pas builder. Mais ce dp est utilisé ici pour ensuite appeler le bon builder.

Je sais que ça a l’air plus lourd ainsi et que l’on se dit: « mais pourquoi se compliquer autant la vie? » Mais par expérience, dès qu’il commence à y avoir deux cas, il y en aura un jour un troisième, et un quatrième etc. Avec ce design pattern, vous vous assurez de ne pas modifier le code au niveau du contrôleur (à part au niveau de l’annotation pour rajouter de nouveaux formats).

Vous avez juste à créer de nouveaux builders, tous indépendants les uns des autres, et qui s’enregistreront automatiquement dans le BuilderResolver sans que vous n’ayez rien à faire.

Le code est visible sur github ici: https://github.com/jpsymfony/dp-builder

Cet article s’inspire de celui-ci: http://www.croes.org/gerald/blog/le-design-pattern-monteur-builder-en-php/687/

Rédigé par

4 comments

  1. Le design pattern que tu utilise est plutôt un pattern strategy. Un algo qui est sélectionné selon des conditions definis et c’est ce que tu voulais mettre en avant, il me semble plutôt que la création d’un JsonBuilder ou HtmlBuilder.
    Le pattern builder est plutôt pour la création d’objet complexe qui nécessite un ordonnancement. Du coup dans ton exemple j’aurais plutôt mis JsonEngine et HtmlEngine qui implemente EngineInterface
    et EngineResolver pour ton compiler Pass

    1. Alors tu as raison sur le fait que j’ai utilisé stratégy pour déterminer le bon builder à appeler. Cependant, les builders JsonBuilder et HtmlBuilder, bien qu’extrêmement simplistes ici à des fins didactiques, auraient pu prendre en compte un certain nombre de paramètres pour construire quelque chose de complexe, en effet, avec un ordonnancement ou un algorithme spécifique.

      Peut-être que l’exemple de l’html et du json était mal choisis ici, mais c’est parce que j’ai déjà eu à faire à des json complexes à construire, avec appels API, un ordre à respecter dans les appels (le résultat de l’un dépendant de l’appel du suivant), afin de construire le json en rapport avec les résultats obtenus lors des différents appels API. C’est effectivement moins vrai avec de l’html. J’aurais dû prendre comme exemple json et csv pour que ça soit plus parlant 😉

      L’exemple que j’ai eu dans la vie réelle était de convertir en trois formats différents (vtt, ttml et smi) un fichier binaire, et les trois formats étaient vraiment bien différents les uns des autres. Mais pas eu besoin de stratégie sur le moment car nous devions forcément générer les trois formats à l’aide de trois builders assez complexes.

      Mais je suis d’accord sur le fait que builder est destiné à une construction complexe, pas à une simple « conversion » d’un format qu’une librairie pourrait résoudre. Je vais le préciser dans l’article.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.