Le design pattern strategy pour gérer les CRUD

Plus le temps passe, et plus je me rends compte que découper son code en plusieurs classes permet de le maintenir plus facilement.

Aussi, dans cette optique, j’ai décidé de gérer dorénavant mes CRUD avec le design pattern Strategy en n’ayant qu’une seule action pour la création ou l’édition d’une entité, et en n’ayant qu’un seul template également pour ces deux actions possibles.

Le design pattern Strategy

C’est un patron de conception (design pattern) de type comportemental grâce auquel des algorithmes peuvent être sélectionnés à la volée au cours du temps d’exécution selon certaines conditions.

Le patron de conception stratégie est utile pour des situations où il est nécessaire de permuter dynamiquement les algorithmes utilisés dans une application. Le patron stratégie est prévu pour fournir le moyen de définir une famille d’algorithmes, encapsuler chacun d’eux en tant qu’objet, et les rendre interchangeables. Ce patron laisse les algorithmes changer indépendamment des clients qui les emploient.

strategy_example

L’application sur un CRUD

Ok, super, c’est cool de savoir ça mais ça vous fait une belle jambe sans démonstration.

Voici le diagramme UML de mon implémentation de ce design pattern en Symfony 2.8:

strategy

Ca parait compliqué comme ça mais il n’en est rien.

Voici le cheminement que suit le code:

  1. Le contrôleur appelle le service MovieFormHandler et lui sette un service: le NewMovieFormHandlerStrategy ou le UpdateMovieFormHandlerStrategy.
  2. Le MovieFormHandler appelle ensuite la fonction createForm(qui sera celle de l’une des deux classes précédemment citées)
  3. Le MovieFormHandler appelle ensuite la fonction handleForm qui va appeler la fonction handleForm de l’une des deux classes Strategy.
  4. En cas de succès, le MovieFormHandler récupère le message de succès retourné par la fonction handleForm de l’une des deux classes Strategy.
  5. Puis le MovieFormHandler appelle la fonction createView (puisqu’elle était exactement identique dans les deux classe Strategy, je l’ai placée dans une classe abstraite)

En pratique, cela donne cela:

Voici le code épuré du MovieFormHandler (sans les setters):

<?php
namespace App\PortalBundle\Form\Handler\Movie;

class MovieFormHandler
{
    private $message = "";

    /**
     * @var MovieFormHandlerStrategy
     */
    protected $movieFormHandlerStrategy;

    public function handleForm(FormInterface $form, Movie $movie, Request $request)
    {
        if (
            (null === $movie->getId() && $request->isMethod('POST'))
            || (null !== $movie->getId() && $request->isMethod('PUT'))
        ) {

            $originalHashTags = new ArrayCollection();

            // Create an ArrayCollection of the current Tag objects in the database
            foreach ($movie->getHashTags() as $tag) {
                $originalHashTags->add($tag);
            }

            $form->handleRequest($request);

            if (!$form->isValid()) {
                return false;
            }

            $this->message = $this->movieFormHandlerStrategy->handleForm($request, $movie, $originalHashTags);

            return true;
        }
    }

    public function createForm(Movie $movie)
    {
        return $this->movieFormHandlerStrategy->createForm($movie);
    }

    public function createView()
    {
        return $this->movieFormHandlerStrategy->createView();
    }
}

Que fais-je ici?

  • Je vérifie que l’on est en POST (et donc que c’est une création avec un id null) ou en PUT (donc une mise à jour avec un id non null)
  • Dans le cas où c’est une mise à jour, je récupère les hashtags (collection), de la fiche du film, présents en base de données
  • J’appelle la classique méthode handleRequest et je vérifie si le formulaire est valide
  • J’appelle la méthode handleForm de l’une des deux classes Strategy, NewMovieFormHandlerStrategy ou UpdateMovieFormHandlerStrategy, selon ce que le contrôleur a setté, et je retourne un message
  • La fonction createForm appelle la fonction createForm de NewMovieFormHandlerStrategy ou UpdateMovieFormHandlerStrategy
  • La fonction createView appelle la fonction createView de NewMovieFormHandlerStrategy ou UpdateMovieFormHandlerStrategy.

 

Etape par étape, voici ce que cela va donner:

  1. Le contrôleur appelle le service MovieFormHandler et lui sette un service: le NewMovieFormHandlerStrategy ou le UpdateMovieFormHandlerStrategy.
    /**
     * @Route("/admin/movies/new", name="movie_new")
     * @Route("/admin/movies/{id}/edit", name="movie_edit")
     * @Template("@AppPortal/Movie/edit.html.twig")
     * @ParamConverter("movie", class="AppPortalBundle:Movie")
     */
    public function newEditAction(Request $request, Movie $movie = null)
    {
        // we create entity if not exists in database
        if (is_null($movie)) {
            $movie = new Movie();
            $this->getMovieFormHandler()->setMovieFormHandlerStrategy(
                  $this->get('app_portal.new_movie.form.handler.strategy')
            );
        } else { // we get entity from database
            $this->getMovieFormHandler()->setMovieFormHandlerStrategy(
                  $this->get('app_portal.update_movie.form.handler.strategy')
            );
        }

        $form = $this->getMovieFormHandler()->createForm($movie);

        if ($this->getMovieFormHandler()->handleForm($form, $movie, $request)) {
            // we add flash messages to stick with context (new or edited object)
            $this->addFlash('success', $this->getMovieFormHandler()->getMessage());

            return $this->redirectToRoute('movie_edit', array('id' => $movie->getId()));
        }

        return array(
            'form' => $form->createView(),
            'movie' => $movie,
        );
    }

 

2. Via le contrôleur, le MovieFormHandler appelle ensuite la fonction createForm (qui sera celle de l’une des deux classes précédemment citées)

a. S’il appelle celui du NewMovieFormHandlerStrategy, on va rentrer dans ce code:

namespace App\PortalBundle\Form\Handler\Movie;

class NewMovieFormHandlerStrategy extends AbstractMovieFormHandlerStrategy
{
    public function createForm(Movie $movie)
    {
        $this->form = $this->formFactory->create(new MovieType(), $movie, array(
            'action' => $this->router->generate('movie_new'),
            'method' => 'POST',
            'hashtags_hidden' => false,
        ));

        return $this->form;
    }
}

Ici, je crée un formulaire avec la méthode POST.

b. S’il appelle celui du UpdateMovieFormHandlerStrategy, on va rentrer dans ce code:

namespace App\PortalBundle\Form\Handler\Movie;

class UpdateMovieFormHandlerStrategy extends AbstractMovieFormHandlerStrategy
{
    public function createForm(Movie $movie)
    {
        // we put image in the constructor of MovieType to fill value when the form is loaded
        $this->form = $this->formFactory->create(new MovieType($movie->getImage()), $movie, array(
            'action' => $this->router->generate('movie_edit', array('id' => $movie->getId())),
            'method' => 'PUT',
            'hashtags_hidden' => false,
        ));

        return $this->form;
    }
}

Vous voyez qu’ici, j’effectue un traitement supplémentaire:

  • Je passe la valeur de l’image dans le constructeur du MovieType (pour qu’à l’édition, elle soit bien chargée dans le formulaire)
  • Je définis la méthode à PUT

 

3. Le MovieFormHandler appelle ensuite la fonction handleForm qui va appeler la fonction handleForm de l’une des deux classes Strategy.

a. S’il appelle celui du NewMovieFormHandlerStrategy, on va rentrer dans ce code:

    public function handleForm(Request $request, Movie $movie, ArrayCollection $originalHashTags = null)
    {
        $movie->setAuthor($this->securityTokenStorage->getToken()->getUser());
        $this->movieManager->save($movie, true, true);

        return $this->translator
            ->trans('film.ajouter.succes', array(
                '%titre%' => $movie->getTitle()
            ));
    }

Ici, je sette l’auteur de la fiche du film (ce sera utile pour les voters, pour connaître l’auteur d’une fiche d’un film et ainsi empêcher qu’un éditeur ne modifie une fiche qu’un autre éditeur a créé).

J’appelle aussi une méthode générique save du movieManager (le second paramètre persist, le troisième flush) et je retourne un message traduit.

b. S’il appelle celui du UpdateMovieFormHandlerStrategy, on va rentrer dans ce code:

    public function handleForm(Request $request, Movie $movie, ArrayCollection $originalHashTags = null)
    {
        if (!$this->authorizationChecker->isGranted(MovieVoter::EDIT, $movie)) {
            $errorMessage = $this->translator->trans('film.modifier.erreur', ['%movie%' => $movie->getTitle()]);

            throw new AccessDeniedException($errorMessage);
        }

        foreach ($originalHashTags as $hashTag) {
            if (false === $movie->getHashTags()->contains($hashTag)) {
                $movie->removeHashTag($hashTag);
                $this->hashTagManager->remove($hashTag);
            }
        }

        $this->movieManager->save($movie, false, true);

        return $this->translator
            ->trans('film.modifier.succes', array(
                '%titre%' => $movie->getTitle()
            ));
    }

Ici, je fais un traitement plus lourd que lors de la création d’une fiche:

  • J’appelle un Voter pour empêcher qu’un éditeur n’essaie pas de modifier la fiche d’un film qu’un autre éditeur a créé
  • Je fais un traitement sur une collection de hashTag pour supprimer ceux que l’on a retiré dans le formulaire
  • J’appelle ma méthode générique save mais en mettant le second paramètre à false car je n’ai pas besoin de persister mon objet ici
  • Je retourne un message de succès de mise à jour

Vous imaginez s’il avait fallu faire tout cela dans le contrôleur ?  🙄

4. En cas de succès, le MovieFormHandler récupère le message de succès retourné par la fonction handleForm de l’une des deux classes Strategy.

return $this->translator
            ->trans('film.ajouter.succes', array(
                '%titre%' => $movie->getTitle()
            ));

ou

return $this->translator
            ->trans('film.modifier.succes', array(
                '%titre%' => $movie->getTitle()
            ));

5. Puis le MovieFormHandler appelle la fonction createView.

Cette fonction est celle-ci:

public function createView()
    {
        return $this->form->createView();
    }

L’intérêt de la dupliquer dans les deux classes Strategy étant nulle, j’ai choisi de la placer dans une classe abstraite, AbstractMovieFormHandlerStrategy, qui a également un certain nombre d’attributs dont se servent les deux classes Strategy:

namespace App\PortalBundle\Form\Handler\Movie;

abstract class AbstractMovieFormHandlerStrategy implements MovieFormHandlerStrategy
{
    /**
     * @var Form
     */
    protected $form;

    /**
     * @var MovieManagerInterface
     */
    protected $movieManager;

    /**
     * @var FormFactoryInterface
     */
    protected $formFactory;

    /**
     * @var RouterInterface $router
     */
    protected $router;

    /**
     * @var TranslatorInterface
     */
    protected $translator;

    public function createView()
    {
        return $this->form->createView();
    }

    abstract public function handleForm(Request $request, Movie $movie, ArrayCollection $originalTags = null);

    abstract public function createForm(Movie $movie);

}

Je vous ai retiré les setters pour plus de lisibilité. Comme vous le voyez, les fonctions handleForm et createForm sont abstraites, puisque ce sont les deux classes Strategy qui les redéfinissent, mais la fonction createView est commune aux deux.

J’en profite pour vous parler d’une technique très sympathique en Symfony2, l’héritage de classe sous forme de services avec le mot clef parent:

parameters:
    app_portal.abstract_movie.form.handler.strategy.class: App\PortalBundle\Form\Handler\Movie\AbstractMovieFormHandlerStrategy
    app_portal.movie.form.handler.class: App\PortalBundle\Form\Handler\Movie\MovieFormHandler
    app_portal.new_movie.form.handler.strategy.class: App\PortalBundle\Form\Handler\Movie\NewMovieFormHandlerStrategy
    app_portal.update_movie.form.handler.strategy.class: App\PortalBundle\Form\Handler\Movie\UpdateMovieFormHandlerStrategy

services:
    app_portal.abstract_movie.form.handler.strategy:
        abstract:  true
        class: %app_portal.abstract_movie.form.handler.strategy.class%
        calls:
           - [setMovieManager, ['@app_portal.movie.manager']]
           - [setFormFactory, ['@form.factory']]
           - [setRouter, ['@router']]
           - [setTranslator, ['@translator']]

    app_portal.movie.form.handler:
        class: %app_portal.movie.form.handler.class%
        calls:
            - [setActorManager, ['@app_portal.actor.manager']]
            - [setCategoryManager, ['@app_portal.category.manager']]
            - [setHashTagManager, ['@app_portal.hashtag.manager']]

    app_portal.new_movie.form.handler.strategy:
        class: %app_portal.new_movie.form.handler.strategy.class%
        parent: app_portal.abstract_movie.form.handler.strategy
        arguments:
            - "@security.token_storage"

    app_portal.update_movie.form.handler.strategy:
        class: %app_portal.update_movie.form.handler.strategy.class%
        parent: app_portal.abstract_movie.form.handler.strategy
        arguments:
            - "@app_portal.hashtag.manager"
            - "@security.authorization_checker"

Ici, vous voyez que les deux services Strategy, app_portal.new_movie.form.handler.strategy et app_portal.update_movie.form.handler.strategy ont un parent, qui est app_portal.abstract_movie.form.handler.strategy.

Ainsi, elles héritent de ses attributs MovieManager, FormFactory, Router et Translator, plutôt que d’avoir chacune dans leur injection de dépendance ces quatre services.

En découpant à ce point mon code, je le rends très souple et j’évite au maximum de faire du traitement dans mon contrôleur. Ainsi, je me retrouve avec une seule action pour la création et l’édition, et je peux facilement faire les traitements qui s’imposent dans mes deux classes Strategy (je pourrais même injecter le dispatcher dans l’AbstractMovieFormHandlerStrategy pour dispatcher un événement dans l’une des deux classes Strategy!)

Comme d’habitude, le code est disponible https://github.com/jpsymfony/symfony2-generic-project

Rédigé par

5 comments

    1. Hello,

      J’ai cinq articles dans les cartons mais je ne parviens pas à trouver le temps pour les écrire. Ce seront des articles sur la pagination des API REST, gatling, les tests unitaires poussés, la qualité de code avec php-cs-fixer et mess detector, l’utilisation du compilerPass et la surcharge de templates twig (Symfony3 pour les deux derniers articles).

      Je pense aussi à faire des vidéos, il faut que je dégage un peu de temps. Mais c’est prévu 😉 Merci pour ton appréciation 😀

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.