Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Tirez profit de Messenger pour améliorer votre...

Sponsored · Ship Features Fearlessly Turn features on and off without deploys. Used by thousands of Ruby developers. →

Tirez profit de Messenger pour améliorer votre architecture

Symfony Messenger est principalement vu comme un composant pour déléguer des traitements en tâche de fond.
Mais est-il réellement limité à ce cas d'utilisation?

Dans cette présentation nous verrons à travers une session de refactoring comment l'utilisation de Messenger peut avant tout être bénéfique à l'architecture et au découplage de nos applications.

Avatar for Tugdual Saunier

Tugdual Saunier

March 28, 2025

More Decks by Tugdual Saunier

Other Decks in Technology

Transcript

  1. Tugdual Saunier / @tucksaun / [email protected] Quick recap “Messenger provides

    a message bus with the ability to send messages and then handle them immediately in your application or send them through transports (e.g. queues) to be handled later.”
  2. Tugdual Saunier / @tucksaun / [email protected] // src/Message/SmsNotification.php namespace App\Message;

    class SmsNotification { public function __construct( private string $content, ) { } public function getContent(): string { return $this->content; } }
  3. Tugdual Saunier / @tucksaun / [email protected] // src/Controller/DefaultController.php namespace App\Controller;

    use App\Message\SmsNotification; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Messenger\MessageBusInterface; class DefaultController extends AbstractController { public function index(MessageBusInterface $bus): Response { // will cause the SmsNotificationHandler to be called $bus->dispatch(new SmsNotification('Look! I created a message!')); // ... } }
  4. Tugdual Saunier / @tucksaun / [email protected] // src/MessageHandler/SmsNotificationHandler.php namespace App\MessageHandler;

    use App\Message\SmsNotification; use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler] class SmsNotificationHandler { public function __invoke(SmsNotification $message) { // ... do some work - like sending an SMS message! } }
  5. Tugdual Saunier / @tucksaun / [email protected] Are we required to

    use Messenger asynchronously? https://flic.kr/p/J1oew2
  6. Tugdual Saunier / @tucksaun / [email protected] Is Messenger only async?

    “Messenger provides a message bus with the ability to send messages and then handle them immediately in your application or send them through transports (e.g. queues) to be handled later.”
  7. Tugdual Saunier / @tucksaun / [email protected] Is Messenger only async?

    “Messenger provides a message bus with the ability to send messages and then handle them immediately in your application or send them through transports (e.g. queues) to be handled later.”
  8. Tugdual Saunier / @tucksaun / [email protected] Or for big-guns architectures?

    “The Messenger component can be used in CQRS architectures where command & query buses are central pieces of the application.”
  9. Tugdual Saunier / @tucksaun / [email protected] Or for big-guns architectures?

    “The Messenger component can be used in CQRS architectures where command & query buses are central pieces of the application.”
  10. Tugdual Saunier / @tucksaun / [email protected] NO Let’s see what

    we can do with synchronous Messenger https://www.flickr.com/photos/holger_schramm/50803465153/
  11. Tugdual Saunier / @tucksaun / [email protected] Illustrative cases Two examples

    • Invoice creation • Statistics export with some heavy processing
  12. Tugdual Saunier / @tucksaun / [email protected] namespace App\Controller; final class

    InvoiceController extends AbstractController { #[Route('/invoicing', name: 'app_invoicing')] public function index( Request $request, EntityManagerInterface $entityManager ): Response { $form = $this->createForm(NewInvoiceType::class); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { $entityManager->persist($form->getData()); $entityManager->flush(); $this->invoiceGenerationService->generateInvoice($form->getData()) $this->redirect(/* ... */); } return $this->render('invoicing/index.html.twig', [ 'form' => $form, ]); } }
  13. Tugdual Saunier / @tucksaun / [email protected] namespace App\Entity; #[ORM\Entity(repositoryClass: InvoiceRepository::class)]

    class Invoice { #[ORM\ManyToOne(inversedBy: 'invoices')] #[ORM\JoinColumn(nullable: false)] private ?Client $client = null; #[ORM\Column(enumType: ServiceType::class)] private ?ServiceType $serviceType = null; #[ORM\Column(enumType: VatRate::class)] private ?VatRate $vatRate = null; #[ORM\Column] private ?int $days = null; #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $notes = null; /* ... */ }
  14. Tugdual Saunier / @tucksaun / [email protected] namespace App\Entity; #[ORM\Entity(repositoryClass: InvoiceRepository::class)]

    class Invoice { /* ... */ public function getClient(): ?Client { return $this->client; } public function setClient(?Client $client): static { $this->client = $client; return $this; } public function getNumber(): ?int { return $this->number; } public function setNumber(int $number): static { $this->number = $number; return $this; } /* ... */ }
  15. Tugdual Saunier / @tucksaun / [email protected] The old way What

    if we want ? • Prevent entire edition of the invoice • But allow partial edition • Reuse some logic once persisting became more complex (locking, sending email, etc) in another context (CLI, API)
  16. Tugdual Saunier / @tucksaun / [email protected] The old way 😞Form

    data is directly mapped to the Entity 😞The entity needs setters, even for properties that should be immutable 😞Your entity is mutated THEN validated which allows invalid states 😞Less testable 😞Loss of the “context” (eg. user intent) when processing the data
  17. Tugdual Saunier / @tucksaun / [email protected] namespace App\UseCase; final class

    CreateInvoice { public Client $client; public ServiceType $serviceType; public VatRate $vatRate; public int $days; public ?string $notes = ''; }
  18. Tugdual Saunier / @tucksaun / [email protected] namespace App\Form; final class

    NewInvoiceType extends AbstractType { /* ... */ public function configureOptions( OptionsResolver $resolver ): void { $resolver->setDefaults([ 'data_class' => CreateInvoice::class, ]); } }
  19. Tugdual Saunier / @tucksaun / [email protected] namespace App\Controller; final class

    InvoiceController extends AbstractController { #[Route('/invoicing', name: 'app_invoicing')] public function index( Request $request, MessageBusInterface $messageBus ): Response { /* ... */ if ($form->isSubmitted() && $form->isValid()) { $messageBus->dispatch($form->getData()); $this->redirect(/* ... */); } /* ... */ } }
  20. Tugdual Saunier / @tucksaun / [email protected] namespace App\UseCase; #[AsMessageHandler] final

    class CreateInvoiceHandler { /* ... */ public function __invoke(CreateInvoice $useCase): Invoice { $invoice = new Invoice(); $invoice->setClient($useCase->client); $invoice->setServiceType($useCase->serviceType); $invoice->setVatRate($useCase->vatRate); $invoice->setDays($useCase->days); if (!is_null($useCase->notes)) { $invoice->setNotes($useCase->notes); } $this->entityManager->persist($invoice); $this->entityManager->flush(); $this->invoiceGenerationService->generateInvoice($invoice); return $invoice; } }
  21. Tugdual Saunier / @tucksaun / [email protected] Using Messenger 👍Forces you

    to think about the user intent, the business action 👍You recovers the context or business use-case (eg. CreateInvoice object) 👍Naturally improves discoverability for new developers 👍Forces you to decouple from HTTP
  22. Tugdual Saunier / @tucksaun / [email protected] #[CoversClass(NewInvoiceHandler::class)] class CreateInvoiceHandlerTest extends

    TestCase { #[CoversFunction('App\DTO\CreateInvoiceHandler::__invoke')] public function testNewInvoiceShouldBeCreatedWithoutNotes(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $invoiceGenerationService = $this->createMock(InvoiceGenerationInterface::class); $entityManager->expects($this->once())->method('persist'); $invoiceGenerationService->expects($this->once())->method('generateInvoice'); $handler = new CreateInvoiceHandler($entityManager, $invoiceGenerationService); $useCase = new CreateInvoice(); $useCase->client = new Client('Symfony'); $useCase->serviceType = ServiceType::Training; $useCase->vatRate = VatRate::TwentyPercent; $useCase->days = 10; $this->assertInstanceOf(Invoice::class, $handler->__invoke($useCase)); } }
  23. Tugdual Saunier / @tucksaun / [email protected] # config/packages/messenger.yaml when@test: framework:

    # prevent messages to be handled immediately messenger: transports: testing: 'in-memory://' routing: 'App\*': testing
  24. Tugdual Saunier / @tucksaun / [email protected] #[CoversClass(InvoiceController::class)] class CreateInvoiceControllerTest extends

    WebTestCase { #[CoverFunction('InvoiceController::index')] public function testSomething(): void { $client = static::createClient(); $crawler = $client->request('GET', '/invoicing'); $this->assertResponseIsSuccessful(); $crawler = $client->submitForm('Create Invoice', [ 'form[client]' => '1', 'form[serviceType]' => 'training', 'form[vatRate]' => 20, 'form[days]' => 10, ]); $this->assertResponseIsSuccessful(); $this->assertCount(1, $this->getContainer()->get('messenger.bus.default')- >getDispatchedMessages()); } }
  25. Tugdual Saunier / @tucksaun / [email protected] Using Messenger 👍You naturally

    end-up with a more testable code (because you decouple from HTTP) 👍It is easier to test different possible user scenarios
  26. Tugdual Saunier / @tucksaun / [email protected] namespace App\Entity; #[ORM\Entity(repositoryClass: InvoiceRepository::class)]

    class Invoice { public function __construct( #[ORM\ManyToOne(inversedBy: 'invoices')] #[ORM\JoinColumn(nullable: false)] private readonly Client $client, #[ORM\Column(enumType: ServiceType::class)] private readonly ServiceType $serviceType, #[ORM\Column(enumType: VatRate::class)] private readonly VatRate $vatRate, #[ORM\Column] private readonly int $days, ) { } // No more setters for those properties /* ... */ }
  27. Tugdual Saunier / @tucksaun / [email protected] namespace App\UseCase; #[AsMessageHandler] final

    class CreateInvoiceHandler { /* ... */ public function __invoke(CreateInvoice $useCase): Invoice { $invoice = new Invoice( $this->clientRepository->find($useCase->clientId), $useCase->serviceType, $useCase->vatRate, $useCase->days ); if (!is_null($useCase->notes)) { $invoice->setNotes($useCase->notes); } $this->entityManager->persist($invoice); $this->entityManager->flush(); $this->invoiceGenerationService->generateInvoice($invoice); return $invoice; } }
  28. Tugdual Saunier / @tucksaun / [email protected] namespace App\UseCase; final class

    CreateInvoice { public int $clientId; public ServiceType $serviceType; public VatRate $vatRate; public int $days; public ?string $notes = ''; }
  29. Tugdual Saunier / @tucksaun / [email protected] Using Messenger 👍Allows the

    Entities to be clean without boilerplate for the forms 👍Allows the Entities to actually represent you business domain 👍You can even use the new ObjectMapper component
  30. Tugdual Saunier / @tucksaun / [email protected] namespace App\UseCase; #[AsMessageHandler] final

    class CreateInvoiceHandler { /* ... */ public function __invoke(CreateInvoice $useCase): Invoice { /* ... */ $message = new GenerateInvoice($invoice->getId()); $this->messageBus->dispatch($message); /* ... */ } }
  31. Tugdual Saunier / @tucksaun / [email protected] namespace App\UseCase; final class

    GenerateStatsReport { public function __construct( public \DateTimeInterface $moment = new \DateTimeImmutable(), ) { } }
  32. Tugdual Saunier / @tucksaun / [email protected] namespace App\UseCase; #[AsMessageHandler] class

    GenerateStatsReportHandler { public function __construct( #[Autowire('%kernel.project_dir%/public/reports')] private string $targetDir ) { } public function __invoke(GenerateStatsReport $statsReport) { $path = \sprintf( '%s/%s.pdf', $this->targetDir, $statsReport->moment->format('Y-m') ); // do something with heavy computation ... return $path; } }
  33. Tugdual Saunier / @tucksaun / [email protected] final class StatsReportController extends

    AbstractController { use HandleTrait; /* ... */ #[Route('/stats/report', name: 'app_stats_report')] public function index(Request $request): Response { /* ... */ if ($form->isSubmitted() && $form->isValid()) { $filepath = $this->handle(new GenerateStatsReport()); return new BinaryFileResponse( file: $filepath, headers: [ 'Content-Type' => 'application/pdf', ] ); } return $this->render('stats_report/index.html.twig', [ 'form' => $form, ]); } }
  34. Tugdual Saunier / @tucksaun / [email protected] #[Route('/stats/report', name: 'app_stats_report')] public

    function index(Request $request): Response { $report = new GenerateStatsReport(); $form = $this->createForm(StatsReportType::class, $report); /* ... */ if (!$this->isGranted('generate', $report)) { $form->addError(new FormError("You are not allowed to generate this report.")); } /* ... */ $filepath = $this->handle($report); /* ... */ }
  35. Tugdual Saunier / @tucksaun / [email protected] Using Messenger 👍You use

    cases becomes first-class citizens in your application 👍You can decide to implement Voters for them
  36. Tugdual Saunier / @tucksaun / [email protected] # config/packages/messenger.yaml framework: messenger:

    buses: messenger.bus.default: middleware: # wraps all handlers in a single Doctrine transaction # handlers do not need to call flush() and an error in # any handler will cause a rollback - doctrine_transaction # validates the message object using the Validator # component before handling it. If validation fails, # a ValidationFailedException will be thrown. The # ValidationStamp can be used to configure the # validation groups. - validation - App\Messenger\AuditMiddleware - App\Messenger\ChainMiddleware - App\Messenger\UsageStatsMiddleware - App\Messenger\TenantPriorityMiddleware
  37. Tugdual Saunier / @tucksaun / [email protected] namespace App\Messenger; class AuditMiddleware

    implements MiddlewareInterface { public function __ construct( private readonly AuditLoggerInterface $logger ) { } public function handle( Envelope $envelope, StackInterface $stack ): Envelope { $this -> logger - > log($envelope); return $stack - > next() -> handle($envelope, $stack); } }
  38. Tugdual Saunier / @tucksaun / [email protected] Using Messenger 👍You can

    mutualize your logics (audit, stats, security, etc) using Middlewares 👍Implement more advanced patterns (see Kris Wallsmith ’ s ChainMiddleware for example)
  39. Tugdual Saunier / @tucksaun / [email protected] namespace App\Entity; #[ORM\Entity(repositoryClass: InvoiceRepository::class)]

    #[ApiResource( operations: [ new GetCollection(), new Get(), new Post( input: CreateInvoice::class, processor: MessengerProcessor::class ), ] )] class Invoice { /* ... */ }
  40. Tugdual Saunier / @tucksaun / [email protected] namespace App\Command; #[AsCommand( name:

    'app:invoice:create', description: 'Create a new invoice from the command line', )] class CreateInvoiceCommand extends Command { /* ... */ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $message = new CreateInvoice(); // Get the values from the user and map to $message ... $this->bus->dispatch($message); $io->success('Invoice has been created.'); return Command::SUCCESS; } }
  41. Tugdual Saunier / @tucksaun / [email protected] namespace GenerateStatsCommand; #[AsCommand( name:

    'app:generate:stats', )] class GenerateStatsCommand extends Command { use HandleTrait; /* ... */ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $date = new \DateTime($input->getArgument('date')); $io->writeln('Generating stats report for '.$date->format('Y/m')); $path = $this->handle(new GenerateStatsReport($date)); $io->success(sprintf('Report has ben generated in %s.', $path)); return Command::SUCCESS; } }
  42. Tugdual Saunier / @tucksaun / [email protected] Using Messenger 👍You can

    easily trigger some processing from another entry-point
  43. Tugdual Saunier / @tucksaun / [email protected] namespace App\Scheduler; #[AsSchedule("sales")] class

    SaleTaskProvider implements ScheduleProviderInterface { public function getSchedule(): Schedule { return $this->schedule ??= (new Schedule()) ->with( RecurringMessage::cron( '15 7 * * *', new GenerateStatsReport() )); } }
  44. Tugdual Saunier / @tucksaun / [email protected] Using Messenger 👍You can

    trigger some job on a regular basis but easily trigger it manually on- demand
  45. Tugdual Saunier / @tucksaun / [email protected] #[Route('/stats/report', name: 'app_stats_report')] public

    function index(Request $request): Response { /* ... */ $message = new GenerateStatsReport(); if ($form->get('generateLater')->isClicked()) { $filepath = $this->handle(Envelope::wrap( $message, new TransportNamesStamp(['async']), )); return $this->redirect(/* ... */); } /* ... */ }
  46. Tugdual Saunier / @tucksaun / [email protected] namespace App\Messenger; class TenantPriorityMiddleware

    implements MiddlewareInterface { public function __ construct( private Security $security, ) { } public function handle( Envelope $envelope, StackInterface $stack ): Envelope { // if the user didn't subscribe to the priority plan, we move the // message to an async transport if ( $envelope->getMessage() instanceof PriorizedMessageInterface && !$this->security->isGranted('ROLE_TENANT_PRIORITY') ) { $envelope = $envelope->with(new TransportNamesStamp(['async'])); } return $stack->next()->handle($envelope, $stack); } }
  47. Tugdual Saunier / @tucksaun / [email protected] Using Messenger 👍You can

    decide dynamically to do some processing immediately or delay it
  48. Tugdual Saunier / @tucksaun / [email protected] Limitations 😞Decoupling can be

    hard for existing projects 😞Does not always works well with CRUD logics (Admin generator for examples)