Spring Framework: sistema de eventos

Última actualización: 03/13/2022
logo spring

Los sistemas basados en eventos permiten que múltiples componentes se comuniquen entre sí compartiendo cualquier tipo de información y, lo más importante, sin relacionarse directamente. La idea general es que ciertos componentes generen eventos o mensajes y otros los reciban y procesen, ya sea de forma síncrona o asíncrona.

>> Read this post in English here <<

El core de Spring Framework cuenta con un sencillo sistema de eventos poco conocido; un pequeño tesoro oculto a plena vista. Incluso en la bibliografía se suele citar de pasada, y a veces ni eso. Utiliza el ApplicationContext (el contenedor de beans) como bróker para el envío y la recepción de eventos entre los beans que contiene.

Nada que ver con las posibilidades que ofrecen potentes ( y complejas) plataformas como Rabbit MQ o Apache Kafka: comunicación entre distintas aplicaciones o microservicios, gestión de colas, soporte de altas cargas de trabajo… Con todo, en algunos escenarios, el sistema de eventos de Spring puede ser suficiente. Nos evitará recurrir a soluciones sobredimensionadas para nuestras necesidades.

En el presente tutorial echaremos un vistazo a este sistema de eventos.

  1. Proyecto de ejemplo
  2. Envío y recepción de eventos
    1. Primeros pasos
    2. Mejor con la anotación @EventListener
    3. Testing con JUnit 5
    4. Orden
    5. Transacciones
  3. Ejecución asíncrona del receptor con @Async
  4. Código de ejemplo

Nota. En el mundo Jakarta EE \ JEE existe un sistema de eventos parecido. Lo explico aquí.

Proyecto de ejemplo

Voy a utilizar un proyecto de ejemplo Maven basado en Spring Boot 3.0 (requiere Java 17). La versión es irrelevante: examinaremos funciones del core de Spring disponibles desde hace muchos años, así que no te preocupes si usas versiones antiguas de Spring.

Este es el pom. Además del starter básico de Spring Boot, he añadido spring-boot-starter-test para hacer una pequeña prueba.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>3.0.0</version>
		<relativePath /> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.danielme.spring</groupId>
	<artifactId>spring-events</artifactId>
	<version>0.0.1-SNAPSHOT</version>

	<dependencies>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>

	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>

La clase con el método main requerido por Spring Boot.

package com.danielme.spring.events;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class EventsApplication {

	public static void main(String[] args) {
		SpringApplication.run(EventsApplication.class, args);
	}

}

El proyecto se ejecuta con EventsApplication. Solo arranca y detiene Spring. El código que escribamos lo usaremos en una prueba que explicaré a su debido tiempo.

Envío y recepción de eventos

Primeros pasos

La gestión de un evento en Spring precisa la creación de tres elementos: la representación del evento con sus datos asociados, sus emisores (publishers) y los receptores (listeners) que lo atienden.

El evento es una clase Serializable que especializa a ApplicationEvent (más adelante veremos que no siempre es obligatorio). La consecuencia es que hereda un timestamp que contiene el instante de la creación del evento, así como source, objeto que representa al emisor. Este source se proporciona al constructor de ApplicationEvent y no puede ser null. Naturalmente, nuestro evento puede contener todos los atributos que necesitemos.

package com.danielme.spring.events.model;

import org.springframework.context.ApplicationEvent;

public class MessageEvent extends ApplicationEvent {

	private static final long serialVersionUID = -3762610544324295353L;

	private final String message;

	public MessageEvent(Object source, String message) {
		super(source);
		this.message = message;
	}

	public String getMessage() {
		return message;
	}

}

El emisor es un bean de Spring que recibe vía inyección una instancia de ApplicationEventPublisher (en realidad, el ApplicationContext). Su método publish envía un evento de cualquier tipo.

package com.danielme.spring.events.services;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

import com.danielme.spring.events.model.MessageEvent;

@Service
public class DemoPublisherService {

	private static final Logger logger = LoggerFactory.getLogger(DemoPublisherService.class);

	private final ApplicationEventPublisher publisher;

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

	public void doSomething() {
		logger.info("init doSomething method");
		MessageEvent messageEvent = new MessageEvent(this, "Hello!!");
		publisher.publishEvent(messageEvent);
		logger.info("event was published");
	}

}

Todos los beans que implementen la interfaz ApplicationListener tipada para MessageEvent recibirán el evento representado por messageEvent.

package com.danielme.spring.events.listeners;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;

import com.danielme.spring.events.model.MessageEvent;

@Component
public class MessageListener implements ApplicationListener<MessageEvent> {

    private static final Logger logger = LoggerFactory.getLogger(MessageListener.class);

    public void onApplicationEvent(MessageEvent event) {
        logger.info("Event received, content = " + event.getMessage() + ", timestamp " + event.getTimestamp());
    }

Mejor con la anotación @EventListener

Eso es todo. Resultó fácil, pero veo algo que no me gusta: esta técnica basada en una interfaz es limitante. ¿Qué hacemos si queremos atender en una clase más de un tipo de evento? No es un requisito exótico y, sin embargo, resulta imposible, a menos que busquemos algún apaño.

¡No lo hagas! Por fortuna, desde Spring 4.2 (julio de 2015) es posible marcar cualquier método público de un bean con @EventListener para que reciba un evento. Gracias a esta anotación, en una misma clase definiremos todos los métodos @EventListener que necesitemos.

package com.danielme.spring.events.listeners;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

import com.danielme.spring.events.model.MessageEvent;

@Component
public class MessageAnnotatedListener {

	private static final Logger logger = LoggerFactory.getLogger(MessageAnnotatedListener.class);

	@EventListener
	public void listenMessageEvent(MessageEvent event) {
		logger.info("Event received, content = {}, timestamp {}", event.getMessage(), event.getTimestamp());
	}

}

Un beneficio adicional de librarnos de la interfaz ApplicationListener es que ya no estamos obligados a que el evento herede de ApplicationEvent. Este requisito solo deben cumplirlo los eventos empleados para tipar ApplicationListener.

Ahora, el argumento del método @EventListener indica el tipo del evento a procesar. Si no queremos el objeto con el evento, tendremos que especificar su clase en la anotación.

@EventListener(MessageEvent.class)
public void listenMessageEvent() {
   ...
}

Un método puede recibir varios eventos. Es el caso del próximo ejemplo, que además muestra tres eventos emitidos por Spring:

  • ContextStartedEvent. Evento que se produce cuando se inicia un ApplicationContext (ApplicationContext#start).
  • ContextRefreshedEvent. Evento que se produce cuando se inicia o actualiza un ApplicationContext (ApplicationContext#refresh).
  • ContextStoppedEvent. Evento que se produce cuando se detiene un ApplicationContext (ApplicationContext#stop).

Spring Boot aporta estos eventos adicionales.

package com.danielme.spring.events.listeners;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.ApplicationContextEvent;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.ContextStartedEvent;
import org.springframework.context.event.ContextStoppedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class ApplicationContextEventsListener {

	private static final Logger logger = LoggerFactory.getLogger(ApplicationContextEventsListener.class);

	@EventListener({ ContextStartedEvent.class, ContextRefreshedEvent.class, ContextStoppedEvent.class, })
	public void listenContextEvents(ApplicationContextEvent event) {
		logger.info("context event received: {}", event.getClass().getSimpleName());
	}

}

Una capacidad curiosa de @EventListener es que el método es capaz de emitir un evento con tan solo devolver su objeto. De hecho, puede retornar varios en una Collection o array, y cada uno se enviará como un evento independiente.

El siguiente diagrama UML muestra las relaciones de las clases que hemos escrito. Fíjate en la ausencia de vínculo alguno entre el emisor y los receptores, más allá de que utilicen MessageEvent.

Testing con JUnit 5

Comprobemos que todo está en orden con una prueba de JUnit 5. Invocará al método DemoPublisherService#doSomething con el objetivo de chequear que los receptores son ejecutados.

package com.danielme.spring.events;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;

import com.danielme.spring.events.listeners.MessageAnnotatedListener;
import com.danielme.spring.events.listeners.MessageListener;
import com.danielme.spring.events.model.MessageEvent;
import com.danielme.spring.events.services.DemoPublisherService;

@SpringBootTest
class EventsTest {

	@Autowired
	DemoPublisherService publisher;

	@SpyBean
	MessageAnnotatedListener annotatedListener;

	@SpyBean
	MessageListener messageListener;

	@Test
	void testEventsWereListened() {
		publisher.doSomething();

		verify(annotatedListener, times(1)).listenMessageEvent(any(MessageEvent.class));
		verify(messageListener, times(1)).onApplicationEvent(any(MessageEvent.class));
	}

}

Hay varias cosas que aclarar sobre EventsTest. En primer lugar, es una clase con pruebas que requieren el contexto de Spring, de ahí la anotación @SpringBootTest. De forma predeterminada, esas pruebas se escriben con Jupiter (JUnit 5).

En EventsTest se puede inyectar cualquier bean de Spring. Las inyecciones de MessageAnnotatedListener y MessageListener son especiales porque usan @SpyBean en lugar de @Autowired. Lo que quiero son versiones de esos beans convertidas en objetos de tipo Spy de Mockito (librería incluida en spring-boot-starter-test). Esto me permite verificar si sus métodos que escuchan a los eventos se ejecutaron una sola vez (líneas 33 y 34).

En la bitácora o log también podemos comprobar a ojo el correcto funcionamiento de los eventos.

2022-10-01 20:44:19.620  INFO 46136 --- [           main] com.danielme.spring.events.EventsTest    : Started EventsTest in 2.03 seconds (JVM running for 3.527)
2022-10-01 20:44:19.670  INFO 46136 --- [           main] c.d.s.e.services.DemoPublisherService    : init doSomething method
2022-10-01 20:44:19.685  INFO 46136 --- [           main] c.d.s.events.listeners.MessageListener   : Event received, content = Hello!!, timestamp 1664649859670
2022-10-01 20:44:19.690  INFO 46136 --- [           main] c.d.s.e.l.MessageAnnotatedListener       : Event received, content = Hello!!, timestamp 1664649859670
2022-10-01 20:44:19.691  INFO 46136 --- [           main] c.d.s.e.services.DemoPublisherService    : event was published

Queda patente que los receptores de MessageEvent se ejecutan de manera síncrona en el mismo hilo o hebra (thread) que el método emisor. DemoPublisherService#doSomething lanza el evento y permanece a la espera de que todos los receptores lo procesen. Luego continuará con la próxima línea de código. Esto implica que una excepción en cualquiera de los receptores se propagará hacia el emisor.

Orden

Si fuera necesario, puedes establecer el orden de ejecución de los receptores acompañando @EventListener con @Order. Se indica con un número, de tal modo que la secuencia de ejecución va desde los valores menores (más prioritarios) a los mayores.

La siguiente configuración asegura que MessageAnnotatedListener siempre se ejecutará primero. Es justo lo contrario de lo que mostró el log anterior.

@EventListener
@Order(1)
public void listenMessageEvent(MessageEvent event) {
Transacciones

Con respecto a la transaccionalidad, los métodos receptores participarán en la (hipotética) transacción en curso de la que forme parte el emisor aunque carezcan de la anotación @Transactional. Ninguna sorpresa: es el comportamiento habitual.

Te recomiendo la lectura de este artículo (en inglés) acerca de la anotación @TransactionalEventListener. Es un @EventListener que permite configurar en qué momento de la transacción en curso se ejecutará el receptor. Si lo usas, ten en cuenta que el receptor solo se invocará cuando el evento se lance desde un emisor transaccional.

Ejecución asíncrona del receptor con @Async

En la práctica, lo común será que los receptores deban ejecutarse de forma asíncrona cada uno en su propio hilo o thread. No bloquearán la ejecución del método que emite el evento: este lo lanza y continúa con sus asuntos.

Si consultas otros tutoriales, verás que algunos autores explican que esto se puede conseguir creando un bean de tipo ApplicationEventMulticaster que Spring usará para llamar a los receptores.

@Bean
ApplicationEventMulticaster applicationEventMulticaster() {
    SimpleApplicationEventMulticaster eventMulticaster = new SimpleApplicationEventMulticaster();
	eventMulticaster.setTaskExecutor(new SimpleAsyncTaskExecutor());
	return eventMulticaster;
}

SimpleApplicationEventMulticaster es una implementación de ApplicationEventMulticaster que invoca a los receptores en el mismo hilo en el que corre el método emisor. Es decir, lo que hemos hecho hasta el momento: todo el proceso es síncrono. Pero si te fijas en la línea 4, es posible especificar un Executor con el que se llamará a los receptores. Se trata de una interfaz que se limita a ejecutar un método dado en la forma de un Runnable. La implementación elegida (SimpleAsyncTaskExecutor) ejecutará cada llamada a TaskExecutor#execute que realice ApplicationEventMulticaster en un nuevo hilo.

ApplicationEventMulticaster distribuirá todos los eventos que lancemos, por lo que todos los receptores serán asíncronos. ¿Y si queremos que algunos lo sean y otros no? Tendremos que buscarnos la vida y crear, por ejemplo, nuestro propio multicaster.

Nos hemos metido innecesariamente en un aprieto porque podemos olvidarnos de lo anterior y convertir un @EventListener en una tarea asíncrona. No entraré en detalles; para eso está el siguiente tutorial.

En resumen, basta con acompañar @EventListener con @Async.

package com.danielme.spring.events.listeners;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

import com.danielme.spring.events.model.MessageEvent;

@Component
public class MessageAsyncListener {

	private static final Logger logger = LoggerFactory.getLogger(MessageAsyncListener.class);

	@EventListener
	@Async
	public void processMessageEvent(MessageEvent event) {
		logger.info("Procesing event asynchronous, content = {} , timestamp {}", event.getMessage(),
				event.getTimestamp());
	}

}

Vemos en acción uno de los puntos fuertes de los sistemas basados en eventos: la inclusión de este nuevo receptor para MessageEvent no requiere tocar el código ya existente. Se respeta el célebre principio open-closed.

Para que @Async funcione, hay que anotar con @EnableAsync una clase de configuración (esto es, marcada con @Configuration o @SpringBootApplication).

@SpringBootApplication
@EnableAsync
public class EventsApplication {

Mision cumplida 💪. En el log vemos que cuando el @EventListener asíncrono escribe su mensaje doSomething ya ha finalizado.

2022-10-01 22:09:31.050  INFO 49458 --- [           main] c.d.s.e.services.DemoPublisherService    : init doSomething method
2022-10-01 22:09:31.063  INFO 49458 --- [           main] c.d.s.events.listeners.MessageListener   : Event received, content = Hello!!, timestamp 1664654971050
2022-10-01 22:09:31.067  INFO 49458 --- [           main] c.d.s.e.l.MessageAnnotatedListener       : Event received, content = Hello!!, timestamp 1664654971050
2022-10-01 22:09:31.130  INFO 49458 --- [           main] c.d.s.e.services.DemoPublisherService    : event was published
2022-10-01 22:09:31.159  INFO 49458 --- [         task-1] c.d.s.e.listeners.MessageAsyncListener   : Procesing event asynchronous, content = Hello!! , timestamp 1664654971050

Los receptores asíncronos no pueden lanzar un evento retornando su objeto: deben recurrir a ApplicationEventPublisher. Asimismo, sus excepciones no afectan al emisor, pues ambos corren en hilos distintos. Lo que sí es igual que en los receptores síncronos es el empleo de @Order y @TransactionalEventListener.

Las pruebas se complican en el momento en que atañen a procesos asíncronos. La siguiente «mejora» del test fallará debido al último verify porque todavía no se ha ejecutado listenAsyncMessageEvent.

@Test
void testEventsWereListened() {
  publisher.doSomething();

  verify(annotatedListener, times(1)).listenMessageEvent(any(MessageEvent.class));
  verify(messageListener, times(1)).onApplicationEvent(any(MessageEvent.class));
  verify(asyncListener, times(1)).listenAsyncMessageEvent(any(MessageEvent.class));
}

La solución está en el método Mockito#timeout. Permite definir un timeout en milisegundos para las condiciones admitidas por verify.

@Test
void testEventsWereListened() {
  publisher.doSomething();

  verify(annotatedListener, times(1)).listenMessageEvent(any(MessageEvent.class));
  verify(messageListener, times(1)).onApplicationEvent(any(MessageEvent.class));
  verify(asyncListener, timeout(1000).times(1)).listenAsyncMessageEvent(any(MessageEvent.class));
}

Ahora la tercera aserción será exitosa cuando listenAsyncMessageEvent sea ejecutado antes de que transcurra un segundo a partir del instante en el que la verificación comience a comprobarse.

Código de ejemplo

El proyecto se encuentra en GitHub. Para más información sobre GitHub, consultar este artículo.

Otros tutoriales relacionados con Spring

Testing en Spring Boot con JUnit 4-5. Mockito, MockMvc, REST Assured, bases de datos embebidas

Introducción a Spring Boot: Aplicación Web con servicios REST y Spring Data JPA

Spring JDBC Template: simplificando el uso de SQL

Persistencia en BD con Spring Data JPA

Spring REST: Securización BASIC y JDBC con Spring Security

2 comentarios sobre “Spring Framework: sistema de eventos

  1. Hola Daniel me parece un articulo fabuloso me gusto, pero sabes como podría hacer esto pero con Spring5 y no usar spring boot (esto ya lo hice y si funciona 🙂 )

Deja una respuesta

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Salir /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Salir /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Salir /  Cambiar )

Conectando a %s

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.