Mécanisme de publication et écoute d'événements dans le contexte Spring

Le framework Spring offre un puissant système de gestion d'événements basé sur le patron de conception observateur. Ce mécanisme permet une communication découplée entre différents composants d'une application grâce à l'interface ApplicationEventPublisher du conteneur IoC.

Scénario métier

Nous allons implémenter un système de gestion de livraison repas en ligne avec les étapes suivantes :

  1. Soumission d'une commande par le client
  2. Acceptation de la commande par le restaurant
  3. Notification du livreur pour récupération
  4. Le livreur confirme la prise en charge
  5. Le client réceptionne sa livraison

Structure du projet

L'application est organisée autour de trois couches principales : les événements, les modèles de données et les écouteurs réactifs.

Définition des événements

Classe de base pour les événements de commande

package com.delivery.event;

import org.springframework.context.ApplicationEvent;

public abstract class BaseDeliveryEvent<T> extends ApplicationEvent {

    protected BaseDeliveryEvent(T payload) {
        super(payload);
    }

    @SuppressWarnings("unchecked")
    @Override
    public T getSource() {
        return (T) super.getSource();
    }
}

Événement de nouvelle commande

package com.delivery.event;

import com.delivery.entity.PurchaseOrder;
import lombok.Getter;

@Getter
public class NewOrderEvent extends BaseDeliveryEvent<PurchaseOrder> {

    private final long timestamp;

    public NewOrderEvent(PurchaseOrder order) {
        super(order);
        this.timestamp = System.currentTimeMillis();
    }
}

Événement de demande de livraison

package com.delivery.event;

import com.delivery.entity.PurchaseOrder;
import lombok.Getter;

@Getter
public class DeliveryRequestEvent extends BaseDeliveryEvent<PurchaseOrder> {

    private final String priority;

    public DeliveryRequestEvent(PurchaseOrder order, String priority) {
        super(order);
        this.priority = priority;
    }
}

Événement de livraison terminée

package com.delivery.event;

import com.delivery.entity.Courier;
import lombok.Getter;

@Getter
public class DeliveryCompletedEvent extends BaseDeliveryEvent<Courier> {

    public DeliveryCompletedEvent(Courier courier) {
        super(courier);
    }
}

Entités du domaine

Commande d'achat

package com.delivery.entity;

import lombok.Data;
import java.math.BigDecimal;

@Data
public class PurchaseOrder {
    private String referenceCode;
    private String clientFullName;
    private String contactNumber;
    private String deliveryAddress;
    private BigDecimal totalAmount;
    private String specialInstructions;
}

Livreur

package com.delivery.entity;

import lombok.Data;

@Data
public class Courier {
    private PurchaseOrder associatedOrder;
    private String courierName;
    private int experienceYears;
    private String vehicleType;
}

Contrôleur REST pour les commandes

Le contrôleur déclenche la création de commande et publie l'événement correspondant :

package com.delivery.web;

import com.delivery.event.NewOrderEvent;
import com.delivery.entity.PurchaseOrder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;

@RestController
@Slf4j
@RequestMapping("/api/v1/purchases")
public class PurchaseOrderController {

    private final ApplicationEventPublisher publisher;

    public PurchaseOrderController(ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }

    @PostMapping
    public String submitOrder() {
        PurchaseOrder order = buildSampleOrder();
        publisher.publishEvent(new NewOrderEvent(order));
        log.info("Événement de création de commande diffusé avec succès");
        return "Commande enregistrée";
    }

    private PurchaseOrder buildSampleOrder() {
        PurchaseOrder order = new PurchaseOrder();
        order.setReferenceCode("CMD-20240315-001");
        order.setClientFullName("Marie Dupont");
        order.setContactNumber("+33612345678");
        order.setDeliveryAddress("15 Rue de la Paix, 75002 Paris");
        order.setTotalAmount(new BigDecimal("24.50"));
        order.setSpecialInstructions("Sonner à l'interphone B3");
        return order;
    }
}

Écouteurs d'événements

Écouteur du restaurant

Cet écouteur traite la commande et déclenche une demande de livraison via ApplicationEventPublisher :

package com.delivery.listener;

import com.delivery.event.DeliveryRequestEvent;
import com.delivery.event.NewOrderEvent;
import com.delivery.entity.PurchaseOrder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;

@Component
@Slf4j
public class RestaurantOrderHandler {

    @EventListener
    @Async
    public void processNewOrder(NewOrderEvent event) throws InterruptedException {
        PurchaseOrder order = event.getSource();
        log.info("Restaurant a reçu la commande: {}", order.getReferenceCode());
        log.info("Préparation en cours pour: {}", order.getClientFullName());
        
        // Simulation du temps de préparation
        TimeUnit.SECONDS.sleep(8);
        
        // Publication de la demande de livraison avec priorité haute
        log.info("Commande prête, envoi de la demande de livraison");
    }
}

Écouteur du service de livraison

package com.delivery.listener;

import com.delivery.event.DeliveryRequestEvent;
import com.delivery.event.DeliveryCompletedEvent;
import com.delivery.entity.Courier;
import com.delivery.entity.PurchaseOrder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;

@Component
@Slf4j
public class DeliveryServiceHandler {

    private final ApplicationEventPublisher eventPublisher;

    public DeliveryServiceHandler(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }

    @EventListener
    @Async
    public void handleDeliveryRequest(DeliveryRequestEvent event) throws InterruptedException {
        PurchaseOrder order = event.getSource();
        log.info("Livreur assigné pour la commande: {}", order.getReferenceCode());
        log.info("Adresse de livraison: {}", order.getDeliveryAddress());
        
        TimeUnit.SECONDS.sleep(6);
        
        Courier courier = new Courier();
        courier.setAssociatedOrder(order);
        courier.setCourierName("Jean Martin");
        courier.setExperienceYears(3);
        courier.setVehicleType("Vélo électrique");
        
        eventPublisher.publishEvent(new DeliveryCompletedEvent(courier));
        log.info("Événement de livraison terminée publié");
    }
}

Écouteur côté client

package com.delivery.listener;

import com.delivery.event.DeliveryCompletedEvent;
import com.delivery.entity.Courier;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;

@Component
@Slf4j
public class ClientNotificationHandler {

    @EventListener
    @Async
    public void onDeliveryCompleted(DeliveryCompletedEvent event) throws InterruptedException {
        Courier courier = event.getSource();
        log.info("Livraison reçue! Commande: {}", courier.getAssociatedOrder().getReferenceCode());
        log.info("Livré par: {} ({} ans d'expérience)", 
            courier.getCourierName(), courier.getExperienceYears());
        log.info("Véhicule utilisé: {}", courier.getVehicleType());
        
        TimeUnit.SECONDS.sleep(2);
        log.info("Réception confirmée par le client");
    }
}

Comportement synchrone vs asynchrone

Par défaut, le mécanisme publishEvent fonctionne de manière synchrone. L'appelant reste bloqué jusqu'à ce que tous les écouteurs aient terminé leur traitement.

Pour activer le traitement asynchrone, deux conditions sont nécessaires :

  1. Ajouter l'annotation @EnableAsync sur la classe de configuration principale
  2. Combiner @EventListener avec @Async sur chaque méthode d'écoute
package com.delivery;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication
@EnableAsync
public class DeliveryApplication {
    public static void main(String[] args) {
        SpringApplication.run(DeliveryApplication.class, args);
    }
}

Exemple de traces d'exécution asynchrone

14:22:31.105 [http-nio-8080-exec-1] INFO  PurchaseOrderController - Événement de création de commande diffusé avec succès
14:22:31.107 [AsyncExecutor-1] INFO  RestaurantOrderHandler - Restaurant a reçu la commande: CMD-20240315-001
14:22:39.112 [AsyncExecutor-1] INFO  DeliveryServiceHandler - Livreur assigné pour la commande: CMD-20240315-001
14:22:45.118 [AsyncExecutor-1] INFO  DeliveryServiceHandler - Événement de livraison terminée publié
14:22:45.120 [AsyncExecutor-2] INFO  ClientNotificationHandler - Livraison reçue! Commande: CMD-20240315-001
14:22:47.125 [AsyncExecutor-2] INFO  ClientNotificationHandler - Réception confirmée par le client

Contrôle de l'ordre d'exécution

Lorsque plusieurs écouteurs taritent le même événement, il est possible d'ordonner leur exécution grâce à l'annotation @Order :

@Component
@Order(1)
public class AuditLogListener {
    
    @EventListener
    public void logOrderCreation(NewOrderEvent event) {
        // Enregistrement dans le journal d'audit en premier
    }
}

@Component
@Order(2)
public class NotificationListener {
    
    @EventListener
    public void sendConfirmation(NewOrderEvent event) {
        // Envoi de notification après l'audit
    }
}

Résumé des points essentiels

  • ApplicationContext.publishEvent() est synchrone par défaut
  • L'ajout de @Async sur les écouteurs permet un traitement non bloquant dans des threads séparés
  • L'annotation @EnableAsync doit être activée au niveau de l'application
  • L'annotation @Order permet de maîtriser la séquence d'exécution des écouteurs
  • Il est recommandé de maintenir un état de commande (OrderStatus) pour tracer chaque transition

Étiquettes: Spring Framework ApplicationEvent EventListener ApplicationContext asynchrone

Publié le 3 juin à 04h33