Vai al contenuto

Le Code in Symfony

Le code di messaggi sono uno strumento fondamentale nello sviluppo di applicazioni moderne. Permettono ai diversi componenti di un sistema di comunicare in modo asincrono e affidabile: un servizio pubblica un messaggio, un altro lo legge e lo elabora quando è pronto. Questo approccio aumenta la scalabilità e l’affidabilità dell’architettura.

In questo articolo  vedremo i problemi più comuni incontrati con Symfony Messenger e RabbitMQ, e le soluzioni pratiche adottate per gestire code complesse in produzione.

Cos’è una coda (se non lo sai già)

Creare una coda semplice è facile. Ma quando le code diventano decine e gestiscono processi critici, sorgono subito molte domande:

  • Come nominare correttamente le code per non confonderti?
  • Come organizzare logging e monitoring per capire cosa succede ai messaggi?
  • Come gestire correttamente gli errori?

In parole semplici, una coda è un meccanismo per far comunicare parti differenti di un sistema attraverso messaggi. Un servizio invia un messaggio alla coda riguardo a un evento, e il destinatario lo preleva e lo elabora quando è pronto. Questo evita che i componenti debbano aspettare una risposta immediata.

Tipicamente la situazione è questa: ci sono più servizi che devono reagire a un evento. Ad esempio, un amministratore crea o modifica un prodotto nell’admin panel; i dati devono poi essere arricchiti, la cache aggiornata e la vetrina clienti aggiornata. Per far comunicare queste parti senza bloccare le richieste HTTP, si usa Symfony Messenger e RabbitMQ per gestire le code. Funziona, ma quando i servizi crescono, cresce anche la complessità.

Errore #1: Naming delle code caotico

La prima difficoltà, che probabilmente capita a tutti, è il naming delle code quando il loro numero aumenta.

Quando hai poche code, nomi come queue_1, prodotti o failed sono sufficienti. Ma presto ti ritrovi con un “zoo” di exchange, routing key e code che non capisci più nulla. È importante quindi scegliere nomi che diano subito queste informazioni:

  • Quale servizio pubblica il messaggio;
  • Cosa fa il messaggio;
  • Quale servizio lo consuma;
  • Che tipo di risorsa è (coda, exchange o routing key);
  • Se è una coda standard o una coda di errore (“failed”).

Ecco qui un template per nominare le risorse in RabbitMQ:

 {nomeServizio}.{eventoOComando}[.{nomeServizioConsumatore}].{tipoRisorsa}[.{failed}]

Dove:

  • nomeServizio – il servizio che pubblica il messaggio (es. crediti, apiProdotti);
  • eventoOComando – l’evento o comando che causa l’invio del messaggio (es. prodottoAggiornato, utenteCreato);
  • nomeServizioConsumatore – opzionale, il servizio che consuma il messaggio;
  • tipoRisorsa – indica se è una coda, un exchange o una routing key;
  • failed – suffisso per indicare code o exchange di errore.

Esempi concreti di nomi seguendo lo schema:

  • crediti.prodottoAggiornato.queue – coda per l’evento prodottoAggiornato del servizio crediti;
  • crediti.prodottoAggiornato.exchange – exchange per lo stesso evento;
  • crediti.prodottoAggiornato.routingKey – routing key associata;
  • adminProdotti.prodottoCreato.consumerApiProdottiEnrich.queue.failed – coda di errore per il consumer specifico;

Tipi di Exchange e uso

Direct Exchange – quando il messaggio deve andare in una specifica coda:

framework:
  messenger:
    transports:
      prodottoAggiornato:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        options:
          exchange:
            name: prodotti.prodottoAggiornato.exchange
            type: direct
          queues:
            prodotti.prodottoAggiornato.queue:
              binding_keys:
                - prodotti.prodottoAggiornato.routingKey

Fanout Exchange – quando devi inviare lo stesso messaggio a tutte le code sottoscritte:

framework:
  messenger:
    transports:
      cacheAggiornata:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        options:
          exchange:
            name: prodotti.cacheAggiornata.exchange
            type: fanout
          queues:
            prodotti.cacheAggiornata.consumerApi.queue: ~
            prodotti.cacheAggiornata.consumerBff.queue: ~

Topic Exchange – per routing flessibile basato su pattern:

framework:
  messenger:
    transports:
      eventiUtente:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        options:
          exchange:
            name: utenti.eventi.exchange
            type: topic
          queues:
            utenti.eventi.consumerAnalytics.queue:
              binding_keys:
                - utenti.eventi.*.routingKey
            utenti.eventi.consumerBilling.queue:
              binding_keys:
                - utenti.eventi.creato.routingKey

Errore #2: Messaggi JSON “grezzi” invece di DTO espliciti

Non trattare il messaggio in coda come una semplice stringa JSON con header. Linguaggi e librerie diversi reagiscono in modi differenti se il formato cambia leggermente: un campo mancante può essere ignorato in un linguaggio e far fallire tutto in un altro.

Symfony Messenger di default usa l’header type per capire in che classe trasformare il corpo del messaggio. Ma cosa succede se un servizio esterno invia JSON senza questo header?

La nostra soluzione è stata:

  • Creare un DTO (Data Transfer Object) per ogni tipo di messaggio;
  • Forzare Symfony Messenger a deserializzare sempre in quel DTO, anche se manca type;

Questo lo otteniamo con un serializer personalizzato:

<?php
declare(strict_types=1);

namespace App\Messenger\Serializer;

use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Transport\Serialization\Serializer;
use Symfony\Component\Serializer\SerializerInterface;

class SerializerJsonForzato extends Serializer
{
    private string $classeMessaggio;

    public function __construct(
        string $classeMessaggio,
        SerializerInterface $serializer
    ) {
        parent::__construct($serializer);
        $this->classeMessaggio = $classeMessaggio;
    }

    public function decode(array $encodedEnvelope): Envelope
    {
        $encodedEnvelope['headers']['type'] = $this->classeMessaggio;

        return parent::decode($encodedEnvelope);
    }

    public function encode(Envelope $envelope): array
    {
        $encoded = parent::encode($envelope);
        unset($encoded['headers']['type']);

        return $encoded;
    }
}

In pratica:

  • configuri questo serializer per un certo trasporto;
  • in decode() imposti sempre la classe DTO;
  • in encode() rimuovi il tipo per non esporre nomi interni.

Errore #3: Non usare code di errore (“failed”)

Quando tutto sembra funzionare, è facile ignorare gli errori. Ma senza una coda di errori dedicata, parte dei messaggi falliti può andare persa o non essere tracciata.

Le nostre regole:

  • Ogni trasporto importante ha la sua coda failed;
  • Ogni coda failed ha un exchange dedicato;
  • I messaggi nella coda failed hanno un tempo di vita (TTL) per evitare accumuli infiniti.

Esempio di configurazione:

framework:
  messenger:
    transports:
      prescoringFailed:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        options:
          exchange:
            name: prescoring.exchange.failed
            type: fanout
          queues:
            prescoring.queue.failed:
              arguments:
                x-message-ttl: 604800000

Errore #4: “Le code funzionano quindi va tutto bene”

Anche se sembrano funzionare, senza monitoraggio rischi di scoprire problemi solo quando è troppo tardi. È fondamentale raccogliere metriche e log:

  • Tempo di elaborazione dei messaggi (P95, P99);
  • Dimensione delle code;
  • Numero di messaggi failed;

Un esempio di handler che logga metriche e dettagli è il seguente:

declare(strict_types=1);

namespace App\Messenger\Handler;

use App\DTO\RisultatoPrescoring;
use App\Service\PrescoringService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class GestorePrescoring
{
    public function __construct(
        private PrescoringService $service,
        private LoggerInterface $logger
    ) {}

    public function __invoke(RisultatoPrescoring $messaggio): void
    {
        try {
            $this->service->processa($messaggio);
        } catch (\Throwable $e) {
            $this->logger->error(
                'Errore durante elaborazione messaggio',
                [
                    'eccezione' => $e,
                    'id' => $messaggio->id ?? null
                ]
            );
            throw $e;
        }
    }
}

Questa esperienza ci ha insegnato che le code non sono solo infrastruttura, ma parte integrante del design dell’applicazione. Pensarle bene all’inizio evita problemi enormi in produzione.