Symfony Messenger : vole petit petit pigeon voyageur
Évitez la chaufferie CPU avec Symfony Messenger. Apprenez à gérer les messages comme un pro, avec une touche d'humour cynique.
Si t'as déjà eu l'impression que ton serveur s'écroule sous les requêtes comme un député sous une question gênante, bienvenue à toi. Pour éviter de surcharger ton application Symfony de requêtes synchrones et bloquantes en transformant ton CPU en radiateur, alors utilise Symfony Messenger.
L'idée de départ : faites passer le message, je suis occupé
Bon, voilà le topo : j'avais besoin de récupérer les stats de visite depuis Umami, les transformer, calculer des scores pour chaque page et tout stocker en base. Le genre de traitement qui, une fois mis bout à bout, te fait un truc aussi long qu'un discours présidentiel
Ce qu'il ne faut pas faire au risque de finir enduit de miel à la merci de frelons affamés : "Ok, je vais tout mettre dans une seule fonction, avec 600 lignes de code et 15 niveaux d'imbrication. De toute façon, c'est juste une commande console, qui va remarquer ?"
Messenger, c'est quoi ce bordel ?
Pour faire simple, Messenger c'est comme un gars qui fait passer des petits papiers en classe, mais pour ton code. Tu lui files un message, il se débrouille pour le donner à celui qui sait le traiter.
Le principe c'est :
- Tu crées un Message (un petit objet qui contient des données)
- Tu l'envoies dans le MessageBus (le facteur)
- Un Handler le récupère et fait le taf
Et le meilleur dans tout ça ? Tu peux décider si ça se fait tout de suite (synchrone) ou plus tard (asynchrone). Comme quand t'annonces à tes potes que tu as un billet en trop pour aller voir Lady Gaga : soit ils hurlent tout de suite, soit tu reportes la crise pour plus tard.
Installation : aussi simple qu'un débat politique
composer require symfony/messenger
Et boom, le bundle est installé. Symfony te génère même un fichier de config config/packages/messenger.yaml
où tu peux définir tes transports et routes. Tout simple, tout propre.
Les messages : hé toi, j'ai un truc pour toi
Première étape, j'ai créé un message pour stocker mes données d'analytics. C'est juste une classe PHP avec quelques propriétés et des getters :
// src/Message/Analytic/ProcessPageMetricsMessage.php
namespace App\Message\Analytic;
class ProcessPageMetricsMessage
{
private readonly string $url;
private readonly int $views;
public function __construct(string $url, int $views)
{
$this->url = $url;
$this->views = $views;
}
public function getUrl(): string
{
return $this->url;
}
public function getViews(): int
{
return $this->views;
}
}
C'est plus minimaliste que la garde-robe d'un naturiste, mais ça fait le job.
Le Handler : le mec qui fait vraiment le daron qui bosse
Ensuite, j'ai créé le Handler qui traite ce message. C'est là que la magie opère :
// src/MessageHandler/Analytic/ProcessPageMetricsHandler.php
namespace App\MessageHandler\Analytic;
use App\Entity\Analytic\PageMetric;
use App\Message\Analytic\ProcessPageMetricsMessage;
use App\Service\Analytic\PageViewTransformer;
use App\Service\Analytic\ScoreCalculator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class ProcessPageMetricsHandler
{
public function __construct(
private EntityManagerInterface $entityManager,
private PageViewTransformer $pageViewTransformer,
private ScoreCalculator $scoreCalculator,
) {
}
public function __invoke(ProcessPageMetricsMessage $message): void
{
// Créer une nouvelle métrique
$metric = new PageMetric();
$metric->setUrl($message->getUrl());
$metric->setViews($message->getViews());
// Trouver le post associé
$post = $this->pageViewTransformer->findPostFromUrl($message->getUrl());
if ($post instanceof \App\Entity\Content\Post) {
$metric->setPost($post);
}
// Calculer le score pondéré
$weightedScore = $this->scoreCalculator->calculateWeightedScore($metric);
$metric->setWeightedScore($weightedScore);
// Persister en base de données
$this->entityManager->persist($metric);
$this->entityManager->flush();
}
}
Remarque l'attribut #[AsMessageHandler]
en haut, qui dit à Symfony "Hé, ce gars-là, il traite les messages".
Comment j'utilise ça ?
La partie fun, c'est comment j'envoie mes messages. J'ai créé une commande console qui récupère les stats depuis Umami puis dispatch un message pour chaque URL :
// src/Command/Analytic/FetchMetricsStatsCommand.php
namespace App\Command\Analytic;
use App\Message\Analytic\ProcessPageMetricsMessage;
use App\Service\Analytic\PageViewTransformer;
use App\Service\Analytic\UmamiClient;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsCommand(
name: 'app:analytics:fetch-stats',
description: 'Fetch Umami stats and dispatch messages to process them',
)]
class FetchMetricsStatsCommand extends Command
{
public function __construct(
private readonly UmamiClient $umamiClient,
private readonly PageViewTransformer $pageViewTransformer,
private readonly MessageBusInterface $messageBus,
#[Autowire('%umami_website_id%')] private readonly string $websiteId,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln('Récupération des statistiques depuis Umami...');
// Récupérer les métriques d'Umami
$umamiData = $this->umamiClient->getMetrics(
websiteId: $this->websiteId,
);
if ($umamiData === []) {
$output->writeln('<e>Aucune donnée récupérée depuis Umami</e>');
return Command::FAILURE;
}
// Transformer les données brutes en DTOs
$metrics = $this->pageViewTransformer->transformUmamiData($umamiData);
$output->writeln(\sprintf('Transformation de %d métriques...', \count($metrics)));
// Dispatcher un message pour chaque métrique
$dispatchedCount = 0;
foreach ($metrics as $metric) {
$message = new ProcessPageMetricsMessage(
$metric->getUrl(),
$metric->getViews(),
);
$this->messageBus->dispatch($message);
++$dispatchedCount;
}
$output->writeln(\sprintf('<info>%d messages envoyés au bus pour traitement</info>', $dispatchedCount));
return Command::SUCCESS;
}
}
Synchrone ou asynchrone ? "Comme pour les impôts, y a un délai de traitement"
L'avantage de Messenger, c'est que tu peux démarrer en mode synchrone (les messages sont traités immédiatement) puis passer en asynchrone quand tu veux. Dans ma config actuelle, c'est du synchrone, mais le passage à l'asynchrone c'est littéralement 2 lignes à décommenter :
# config/packages/messenger.yaml
framework:
messenger:
# Décommente ça pour envoyer les messages en échec dans ce transport pour traitement ultérieur
# failure_transport: failed
transports:
# https://symfony.com/doc/current/messenger.html#transport-configuration
# Pour passer en asynchrone, suffit de décommenter :
# async: '%env(MESSENGER_TRANSPORT_DSN)%'
# failed: 'doctrine://default?queue_name=failed'
# sync: 'sync://'
routing:
# Route tes messages vers les transports
# 'App\Message\YourMessage': async
Pour quoi c'est génial ? "Les avantages qui déchirent"
-
Découplage : Mon code est organisé en petits morceaux indépendants, comme un menu McDo mais en version propre.
-
Scalabilité : Si demain mes pages explosent en popularité (haha, la bonne blague), je peux passer en asynchrone et distribuer le traitement.
-
Tolérance aux pannes : Si un traitement échoue, ça ne bloque pas tout le reste. C'est comme quand t'as un chat possédé par le démon sur trois : les deux autres peuvent continuer à vivre.
-
Lisibilité : Mon code est plus court et plus focalisé. Chaque classe fait une seule chose, bien.
Comment j'ai architécture tout ça ?
Dans mon architecture, tout part du client Umami qui récupère les données brutes :
UmamiClient
se connecte à l'API Umami et récupère les métriquesPageViewTransformer
convertit ces données en objets DTO (PageMetricDto
)- La commande
FetchMetricsStatsCommand
boucle sur ces DTOs et envoie un message pour chacun ProcessPageMetricsHandler
reçoit chaque message et fait le traitement de fond :- Créer une entité
PageMetric
- Associer l'URL au post correspondant
- Calculer un score pondéré avec
ScoreCalculator
- Persister en base de données
- Créer une entité
Et voilà, la boucle est bouclée ! Après ça, mes TopPosts sont automatiquement mis à jour avec les articles les plus pertinents.
Le mot de la fin : avec Messenger, c'est comme les promesses politiques mais ça marche vraiment
Avec Symfony Messenger, j'ai transformé ce qui aurait pu être un gros bloc de code indigeste en un système élégant, découplé et prêt à scaler. Et tout ça sans même avoir besoin de me lever de mon canapé (ouais presque hein), ce qui est quand même le but ultime de tout développeur qui se respecte.
Si tu te demandes comment suivre ce chemin de sagesse, voici un conseil : commence petit, en synchrone, puis évolue quand tu en as besoin. C'est comme les implants capillaires : t'y vas progressivement, sinon les gens remarquent.