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

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

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.

Tugdual Saunier

March 28, 2025
Tweet

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)