De l’asynchrone avec Symfony 5 et RabbitMQ

Introduction

Je vous propose de mettre en place un système de gestion Asynchrone dans un projet Symfony 5 en utilisant RabbitMQ, et vous allez le voir c’est super simple ! Pour nous simplifier la tâche Symfony dispose d’un super composant : Messenger.

Pour que le concept soit simple à comprendre nous allons simuler le cas d’usage suivant :

  • Création d’une application (le strict minimum pour l’exemple) de déclaration d’incidents avec enregistrement et page de login pour des utilisateurs
  • Pour chaque déclaration une tâche d’envoi de mail s’exécute
  • Pour la démonstration, nous ferons en sorte que cet envoi de mail prenne du temps (10 secondes)

Nous ferons la constations que l’expérience utilisateur n’est pas top (attendre 10 secondes à chaque fois, c’est juste pas possible) et qu’il nous faut donc différer dans le temps et passer en arrière-plan l’envoie du mail. Pour cela nous utiliserons RabbitMQ.

RabbitMQ est un logiciel d’agent de messages open source qui implémente le protocole Advanced Message Queuing (AMQP), mais aussi avec des plugins Streaming Text Oriented Messaging Protocol (STOMP) et Message Queuing Telemetry Transport (MQTT). Le serveur RabbitMQ est écrit dans le langage de programmation Erlang.

Contenu soumis à la licence CC-BY-SA 3.0 (http://creativecommons.org/licenses/by-sa/3.0/deed.fr) Source : Article RabbitMQ de Wikipédia en français (http://fr.wikipedia.org/wiki/RabbitMQ).

Prérequis

Afin de reproduire l’exemple chez vous assurez-vous de disposer des éléments suivant :

  • Docker
  • Docker-compose
  • Extension PHP AMQP

Initialisation du projet Symfony : utilisateurs, enregistrement et authentification

Créons un nouveau projet Symfony .

symfony new mq --full
cd mq

Pour la démonstration nous utiliserons une base de données SQLite, modifions donc le fichier .env en conséquence.

###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7"
# DATABASE_URL="postgresql://db_user:db_password@127.0.0.1:5432/db_name?serverVersion=13&charset=utf8"
###< doctrine/doctrine-bundle ###

Et créons sans plus attendre la base de données.

symfony console doctrine:database:create

Comme précisé dans le “cas d’usage” en introduction, nous voulons des utilisateurs, créons donc des utilisateurs.

symfony console make:user User

 Do you want to store user data in the database (via Doctrine)? (yes/no) [yes]:
 > yes

 Enter a property name that will be the unique "display" name for the user (e.g. email, username, uuid) [email]:
 > email

 Will this app need to hash/check user passwords? Choose No if passwords are not needed or will be checked/hashed by some other system (e.g. a single sign-on server).

 Does this app need to hash/check user passwords? (yes/no) [yes]:
 > yes

Créons et exécutons-la migration.

symfony console make:migration
symfony console doctrine:migrations:migrate

Maintenant que nous disposons d’une entité “User”, mettons en place une authentification.

symfony console make:auth

 What style of authentication do you want? [Empty authenticator]:
  [0] Empty authenticator
  [1] Login form authenticator
 > 1

 The class name of the authenticator to create (e.g. AppCustomAuthenticator):
 > UserAuthenticator

 Choose a name for the controller class (e.g. SecurityController) [SecurityController]:
 > SecurityController

 Do you want to generate a '/logout' URL? (yes/no) [yes]:
 > yes

Créons également un processus d’enregistrement de nouveaux utilisateurs.

symfony console make:registration-form

 Creating a registration form for App\Entity\User

 Do you want to add a @UniqueEntity validation annotation on your User class to make sure duplicate accounts aren't created? (yes/no) [yes]:
 > yes

 Do you want to send an email to verify the user's email address after registration? (yes/no) [yes]:
 > no

 Do you want to automatically authenticate the user after registration? (yes/no) [yes]:
 > yes

Créons-nous également un contrôleur et une route histoire d’avoir quelque chose à afficher à nos utilisateurs 😉

symfony console make:controller Home

Protégeons notre route en modifiant le fichier /config/packages/security.yaml.

security:
    encoders:
        App\Entity\User:
            algorithm: auto

    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    providers:
        # used to reload user from session & other features (e.g. switch_user)
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        main:
            anonymous: true
            lazy: true
            provider: app_user_provider
            guard:
                authenticators:
                    - App\Security\UserAuthenticator
            logout:
                path: app_logout
                # where to redirect after logout
                # target: app_any_route

            # activate different ways to authenticate
            # https://symfony.com/doc/current/security.html#firewalls-authentication

            # https://symfony.com/doc/current/security/impersonating_user.html
            # switch_user: true

    # Easy way to control access for large sections of your site
    # Note: Only the *first* access control that matches will be used
    access_control:
        - { path: ^/home, roles: ROLE_USER }
        # - { path: ^/admin, roles: ROLE_ADMIN }
        # - { path: ^/profile, roles: ROLE_USER }

Dernière chose à cette étape, modifiions le fichier /src/Security/UserAuthentificator.php pour rediriger nos utilisateurs sur la route “home” après l’ouverture de session.

public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $providerKey)
    {
        if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
            return new RedirectResponse($targetPath);
        }

        return new RedirectResponse($this->urlGenerator->generate('home'));
    
    }

Démarrons le serveur Symfony et vérifions que tous fonctionne.

symfony serve -d

Créons-nous un utilisateur en nous rendant sur la page https://127.0.0.1:8000/register.

Nous devrions être redirigés immédiatement sur la route “home” si tout ce passe bien !

Notre formulaire de déclaration d’incidents

Rappelez-vous le cas d’usage, nous voulons mettre en place un formulaire de déclaration d’incidents ! Il est donc temps de s’occuper de ce fameux formulaire non !?

Commençons par créer l’entité “Incident”.

symfony console make:entity Incident

 New property name (press <return> to stop adding fields):
 > user

 Field type (enter ? to see all types) [string]:
 > relation

 What class should this entity be related to?:
 > User

 Relation type? [ManyToOne, OneToMany, ManyToMany, OneToOne]:
 > ManyToOne

 Is the Incident.user property allowed to be null (nullable)? (yes/no) [yes]:
 > yes

 Do you want to add a new property to User so that you can access/update Incident objects from it - e.g. $user->getIncidents()? (yes/no) [yes]:
 > yes

 A new property will also be added to the User class so that you can access the related Incident objects from it.

 New field name inside User [incidents]:
 > incidents

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > createdAt

 Field type (enter ? to see all types) [datetime]:
 > datetime

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

 Add another property? Enter the property name (or press <return> to stop adding fields):
 > description

 Field type (enter ? to see all types) [string]:
 > text

 Can this field be null in the database (nullable) (yes/no) [no]:
 > no

N’oublions pas de générer la migration et de l’appliquer.

symfony console make:migration
symfony console doctrine:migrations:migrate

Puis créons le formulaire associé à cette entité.

symfony console make:form IncidentType

 The name of Entity or fully qualified model class name that the new form will be bound to (empty for none):
 > Incident

Éditons le fichier qui vient d’être générer par Symfony /src/Form/IncidentType.php pour n’y laisser que le champ “Description” et y ajouter un bouton de “submit“.

<?php

namespace App\Form;

use App\Entity\Incident;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class IncidentType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('description')
            ->add('submit', SubmitType::class)
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => Incident::class,
        ]);
    }
}

Ajoutons ce formulaire à notre route “home” dans le fichier /src/Controller/HomeController.php et mettons en place un dd() (die et dump) pour vérifier que nous récupérions bien les valeurs du formulaire dans notre contrôleur. Au passage on en profite pour faire persister le formulaire dans la base de donnée à l’aide du EntityManagerInterface.

<?php

namespace App\Controller;

use App\Entity\Incident;
use App\Form\IncidentType;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class HomeController extends AbstractController
{
    /**
     * @Route("/home", name="home")
     */
    public function index(Request $request, EntityManagerInterface $em): Response
    {
        $task = new Incident();
        $task->setUser($this->getUser())
             ->setCreatedAt(new DateTime('now'));

        $form = $this->createForm(IncidentType::class, $task);

        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $task = $form->getData();

            $em->persist($task);
            $em->flush();

            dd($task);

            return $this->redirectToRoute('home');
        }
        return $this->render('home/index.html.twig', [
            'form' => $form->createView(),
            'controller_name' => 'HomeController',
        ]);
    }
}

Pour que le formulaire s’affiche effectivement sur notre page nous modifions le fichier twig /templates/home/index.html.twig.

{% extends 'base.html.twig' %}

{% block title %}Hello HomeController!{% endblock %}

{% block body %}
<style>
    .example-wrapper { margin: 1em auto; max-width: 800px; width: 95%; font: 18px/1.5 sans-serif; }
    .example-wrapper code { background: #F5F5F5; padding: 2px 6px; }
</style>

<div class="example-wrapper">
    {{form(form) }}
</div>
{% endblock %}

Vérifions cela en chargeant la page https://127.0.0.1:8000/home dans notre navigateur !

Génial, maintenant essayons de soumettre le formulaire, nous devrions voir notre dd() !

Bingo, notre formulaire fonctionne à merveille !

Mettons en place notre envoi de mail (volontairement lent !)

Pour le développement de notre superbe (c’est faux) application de déclaration d’incidents, nous devons mettre en place une notification par mail afin de prévenir qu’un nouveau incident vient d’être soumettre.

Dans le cadre de cette démonstration nous allons volontairement rendre cette opération longue et lente (10 secondes !) pour que l’utilisation de l’asynchrone prenne du sens dans la suite de la démo ;-).

Modifions notre fichier /src/Controller/HomeController.php pour y ajouter l’envoie d’un mail avec la MailerInterface.

<?php

namespace App\Controller;

use App\Entity\Incident;
use App\Form\IncidentType;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Annotation\Route;

class HomeController extends AbstractController
{
    /**
     * @Route("/home", name="home")
     */
    public function index(Request $request, EntityManagerInterface $em, MailerInterface $mailer): Response
    {
        $task = new Incident();
        $task->setUser($this->getUser())
             ->setCreatedAt(new DateTime('now'));

        $form = $this->createForm(IncidentType::class, $task);

        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $task = $form->getData();

            $em->persist($task);
            $em->flush();

            $email = (new Email())
            ->from($task->getUser()->getEmail())
            ->to('you@example.com')
            ->subject('New Incident #' . $task->getId() . ' - ' . $task->getUser()->getEmail())
            ->html('<p>' . $task->getDescription() . '</p>');

            sleep(10);

            $mailer->send($email);

            return $this->redirectToRoute('home');
        }
        return $this->render('home/index.html.twig', [
            'form' => $form->createView(),
            'controller_name' => 'HomeController',
        ]);
    }
}

Si nous rechargeons notre page https://127.0.0.1:8000/home nous obtenons un message d’erreur : C’est normal nous n’avons pas spécifié de serveur pour l’envoie de mails !

Pour éviter de spammer et pour nous simplifier la tâche mettons en place un petit MailCatcher grâce à Docker et Docker-compose !

Créons un fichier docker-compose.yml à la racine du projet et éditons-le.

version: '3'

services:
    mailer:
      image: schickling/mailcatcher
      ports: [1025, 1080]

Puis démarrons le conteneur et redémarrons le serveur Symfony. Symfony nous fournit une intégration de docker-compose et nous n’aurons pas besoin de déclarer nous-même la variable d’environnement pour le serveur de mail, cool n’est-ce pas !

docker-compose up -d
symfony server:stop
symfony serve -d

Rechargeons la page et vérifions ! Dans le profiler de Symfony on remarque qu’il détecte bien notre conteneur pour les mails, impeccable !

En cliquant sur “Open” dans le profiler ou via la commande suivante ouvrons le webmail qui va recevoir nos mails.

symfony open:local:webmail

Essayons donc de soumettre un formulaire et vérifier que nous recevions bien le mail dans le webmail (opération qui va être longue, vous vous souvenez ?).

Super, nous recevons bien le mail mais 10 secondes plus tard et durant ce temps votre utilisateur doit patienter devant une page qui semble ne pas charger… pas terrible n’est-ce pas ?!

Mise en place d’un bus de message

L’idée maintenant c’est de mettre en place un bus de message. Il faut comprendre par “message” n’importe qu’elles données simple (pas un objet php par exemple). Dans un premier temps notre application Symfony, avec le composant Messenger, enverra un message sur un bus et le traitera directement, nous verrons ensuite comment envoyer le message via un “transport” (RabbitMQ dans notre exemple).

Commençons pas installer le composant messenger.

symfony composer req messenger

Maintenant nous devons créer deux fichiers :

  • L’un sera “l’enveloppe” du message, autrement dis, la description du message que l’on va envoyer et de ses données
  • L’autre sera le gestionnaire (handler), c’est-à-dire le fichier qui décrit le traitement à opérer sur le message quand votre application va le recevoir

Commençons par l’enveloppe. Pour cela, créons un répertoire Message dans src, et ajoutons-y un fichier MailNotification.php.

<?php

namespace App\Message;

class MailNotification
{
    private $description;
    private $id;
    private $from;

    public function __construct(string $description, int $id, string $from)
    {
        $this->description = $description;
        $this->id = $id;
        $this->from = $from;
    }

    public function getDescription(): string
    {
        return $this->description;
    }

    public function getId(): int
    {
        return $this->id;
    }

    public function getFrom(): string
    {
        return $this->from;
    }
}

Passons maintenant au handler. Créons un répertoire MessageHandler dans src et ajoutons-y un fichier MailNotificationHandler.php. Dans ce fichier nous reprenons le logique d’envoi de mail (et le sleep(10) qui rend l’opération trop longue ;-)).

(Profitons-en pour changer l’adresse mail de destination par admin@my-incident.io, nous pourrons facilement vérifié que notre mail est le bon juste après !)

<?php

namespace App\MessageHandler;

use App\Message\MailNotification;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
use Symfony\Component\Mime\Email;

class MailNotificationHandler implements MessageHandlerInterface
{
    private $mailer;

    public function __construct(MailerInterface $mailer)
    {
        $this->mailer = $mailer;
    }

    public function __invoke(MailNotification $message)
    {
        $email = (new Email())
        ->from($message->getFrom())
        ->to('admin@my-incident.io')
        ->subject('New Incident #' . $message->getId() . ' - ' . $message->getFrom())
        ->html('<p>' . $message->getDescription() . '</p>');

        sleep(10);

        $this->mailer->send($email);
    }
}

Il ne nous reste plus qu’a modifier notre fichier /src/Controller/HomeController.php pour lui indiquer d’envoyer un message dans un bus à chaque soumission du formulaire ! Pour cela nous utilisons la méthode (merci Symfony !) $this->dispatchMessage().

<?php

namespace App\Controller;

use App\Entity\Incident;
use App\Form\IncidentType;
use App\Message\MailNotification;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class HomeController extends AbstractController
{
    /**
     * @Route("/home", name="home")
     */
    public function index(Request $request, EntityManagerInterface $em): Response
    {
        $task = new Incident();
        $task->setUser($this->getUser())
             ->setCreatedAt(new DateTime('now'));

        $form = $this->createForm(IncidentType::class, $task);

        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $task = $form->getData();

            $em->persist($task);
            $em->flush();

            $this->dispatchMessage(new MailNotification($task->getDescription(), $task->getId(), $task->getUser()->getEmail()));

            return $this->redirectToRoute('home');
        }
        return $this->render('home/index.html.twig', [
            'form' => $form->createView(),
            'controller_name' => 'HomeController',
        ]);
    }
}

Soumettons un nouveau formulaire et checkons le webmail.

Trop cool, notre mail est bien envoyé à notre serveur de mail, en passant au travers d’un bus de message !

Déléguons le transport des messages à RabbitMQ

Nous y sommes, mettons en place RabbitMQ afin d’offrir à notre application un mode de traitement asynchrone des messages ! Vous allez voir, on n’a déjà fait le plus dur 😉 .

Première opération, mettre en place un conteneur Docker RabbitMQ. Pour cela nous ajoutons ce service dans notre fichier docker-compose.yml.

version: '3'

services:
    mailer:
      image: schickling/mailcatcher
      ports: [1025, 1080]

    rabbitmq:
      image: rabbitmq:3.7-management
      ports: [5672, 15672]

Redémarrons tout ce beau monde !

docker-compose up -d
symfony server:stop
symfony serve -d

Tout comme avec l’utilisation de mailcatcher tout à l’heure, les variables d’environnement de RabbitMQ vont être injectées pour nous par le serveur Symfony (bien que cela fonctionne bizarrement sur mon poste de temps à autre…).

Configurons le composant Messenger pour lui indiquer d’utiliser RabbitMQ en éditant le fichier /config/packages/messenger.yaml.

framework:
    messenger:
        # Uncomment this (and the failed transport below) to send failed messages to this transport for later handling.
        # failure_transport: failed

        transports:
            # https://symfony.com/doc/current/messenger.html#transport-configuration
            async: '%env(RABBITMQ_DSN)%'
            # failed: 'doctrine://default?queue_name=failed'
            # sync: 'sync://'

        routing:
            # Route your messages to the transports
            'App\Message\MailNotification': async

Et testons à nouveau de soumettre un formulaire. Il ne devrait plus y avoir de temps d’attente pour l’utilisateur, le message étant parti dans le bus dont le transport est confié à RabbitMQ !

Cependant, en l’état, nos messages ne serons jamais confiés à notre handler, nous devons indiquer à Symfony qu’il doit “consommer” des messages avec cette commande :

symfony console messenger:consume async -vv

La console devrait petit à petit consommer les messages en “attente” dans RabbitMQ et vous devriez voir les mails arriver dans le webmail !

Vous pouvez accéder à la console de gestion web de RabbitMQ (guest/guest) avec la commande suivante.

symfony open:local:rabbitmq

Conclusions et dépôt GitLab

Un long article juste pour vous démontrer tout l’intérêt d’utiliser de l’asynchrone dans un certains de situations. Le composant Messenger de Symfony nous simplifie grandement la tâche et l’intégration avec un outil comme RabbitMQ rend la solution très élégante !

Nous avons aussi pu découvrir que le serveur de Symfony fournit une prise en charge des fichiers docker-compose.yml et injecte pour nous un certain nombre de variables d’environnement, pratique pour le développement.

Vous trouverez toutes les sources du projet sur ce dépôt GitLab.

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.