Spring: Sistema de eventos

Última actualización: 02/10/2022
logo spring

Los sistemas basados en eventos permiten que múltiples componentes se comuniquen entre sí, compartiendo cualquier tipo de información y sin relacionarse directamente. La idea general es que ciertos componentes emitan eventos, y otros los reciban y procesan, ya sea de forma síncrona o asíncrona. El resultado es un sistema con un elevado grado de abstracción gracias a su bajo nivel de acoplamiento.

El core de Spring cuenta con un sistema de eventos que quizás no sea demasiado conocido, de hecho en la bibliografía se suele citar de pasada -a veces ni eso-. Utiliza el propio ApplicationContext (el contenedor de beans) de Spring como bróker para el envío y la recepción de eventos exclusivamente entre los beans que contiene.

Nada que ver, por tanto, con las posibilidades que ofrecen potentes, pero también 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…). No obstante, en algunos escenarios puede sernos útil y, debido a su facilidad de uso, nos evitará implementar soluciones más complejas y sobredimensionadas para nuestras necesidades.

En el presente artículo echaremos un vistazo a este sistema eventos con un enfoque práctico.

Proyecto de ejemplo

Para hacer nuestras pruebas, voy a utilizar un proyecto de ejemplo basado en Spring Boot 2.7. En todo caso, vamos a examinar funcionalidades genéricas del core de Spring disponibles desde hace años.

Este es el pom. Además del starter básico de Spring Boot, he añadido el de 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>2.7.2</version>
		<relativePath /> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.danielme.spring</groupId>
	<artifactId>spring-evens</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 Main requerida 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 la clase Main, pero no hace nada, aparte de iniciar y detener Spring. El código que escribamos lo usaremos en una prueba automatizada que explicaré a su debido tiempo.

Envío y recepción de eventos

La gestión de un evento en Spring requiere la configuración de tres elementos: la representación del evento propiamente dicho, su emisor o publisher y los receptores o listeners que lo atienden. Entre todos ellos, el broker ejerce de intermediario.

El evento es una clase Serializable que especializa a ApplicationEvent. Contendrá, si fuera necesario, los datos que queramos asociar al evento. Hereda un timestamp que contiene el momento en el que se produce el evento, así como source, el objeto que representa al emisor. Este último estamos obligados a proporcionarlo al constructor de ApplicationEvent y no puede ser null.

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 una bean de Spring que recibe vía inyección una instancia de ApplicationEventPublisher (en realidad, se trata del ApplicationContext). Su método publish le permitirá enviar 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");
	}

}

El evento será atendido por todos los beans que implementen la interfaz ApplicationListener tipada para la clase que lo modela.

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());
    }

Esta técnica basada en una interfaz es limitante ¿Qué sucede si queremos atender en una clase más de un tipo de evento? No es un requisito raro y, sin embargo, no resulta posible, a menos que busquemos algún «apaño». Por fortuna, desde Spring 4.2 se puede anotar cualquier método público con @EventListener para que reciba un evento. Esto permite que en una misma clase puedan definirse todos los @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());
	}

}

El argumento del método indica el tipo de evento. Si no lo queremos recibir, tenemos que especificar su clase en la anotación.

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

Un mismo método es capaz de procesar eventos distintos. Es el caso de este ejemplo, que además muestra tres eventos emitidos automáticamente 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 proporciona 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 según la documentación es que el método puede emitir un evento con tan solo devolver su objeto y sin usar ApplicationEventListener. De hecho, puede devolver varios en una Collection o array y cada uno se enviará como un evento independiente.

El siguiente diagrama muestra cómo se relacionan las clases que se han creado. No hay ningún vínculo físico entre emisor y receptores, más allá de que todos usan MessageEvent.

Comprobemos que todo está en orden con una prueba de JUnit 5.

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
public 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. Se trata de una clase que contiene pruebas que precisan del arranque de Spring, por ello se ha marcado con @SpringBootTest. De forma predeterminada, esas pruebas se escriben con Jupiter (JUnit 5) sin ninguna integración adicional.

En EventsTest se pueden hacer inyecciones. Las de MessageAnnotatedListener y MessageListener son un poco especiales porque no usan @Autowired. Lo que quiero son versiones de esas dependencias convertidas en objetos de tipo Spy de Mockito (*) para «verificar» que se han ejecutado exactamente una vez sus métodos que escuchan a los eventos (líneas 33 y 34).

(*) Librería ya incluida en spring-boot-starter-test.

En la bitácora o log también se puede verificar «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. DemoPublisherService#doSomething lanza el evento y queda a la espera de que todos los receptores lo procesen antes de continuar con la siguiente línea de código. Esto implica que una excepción en cualquiera de ellos se propagará hasta el emisor.

El orden de ejecución de los receptores se puede fijar 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. Con la siguiente configuración, aseguramos que siempre se ejecuta primero MessageAnnotatedListener, lo contrario de lo que muestra el log anterior.

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

Con respecto a la transaccionalidad en eventos, los receptores participan en una hipotética transacción en curso aunque no estén anotados con @Transactional. 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, no olvides que el receptor solo será llamado cuando el evento se lance desde un emisor transaccional.

Tratamiento asíncrono

En la práctica, es muy habitual que los receptores deban ejecutarse de forma asíncrona -cada uno en su propio hilo- y no «bloqueen» la ejecución del método emisor. Este comportamiento se consigue aplicando el mecanismo estándar de ejecución asíncrona de métodos de Spring presentado en el mini-tutorial Tips Spring : [CORE, BOOT] Ejecución asíncrona de métodos con @Async. 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());
	}

}

Asimismo, esta anotación solo se aplica si se activa su uso anotando con @EnableAsync una clase de configuración (esto es, marcada con @Configuration o @SpringBootApplication).

@SpringBootApplication
@EnableAsync
public class EventsApplication {

Ahora en el log veremos que cuando el @EventListener asíncrono se ejecuta, 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 se ejecutan en hilos distintos. Lo que sí es igual que en los receptores síncronos es el empleo de @Order y @TransactionalEventListener.

Las pruebas automáticas se complican en el momento en que atañen a procesos asíncronos. Esta «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));
}

Con Mockito, podemos ayudarnos de la clase VerificationWithTimeout para que se compruebe repetidamente una condición hasta que sea cierta dentro de un límite de tiempo.

@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));
}

testEventsWereListened será exitoso siempre y cuando listenAsyncMessageEvent tarde menos de un segundo en ser invocado desde el instante en el que se comience a ejecutar la línea 7.

Código de ejemplo

El proyecto completo se encuentra disponible en GitHub. Para más información sobre cómo utilizar 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: 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.