Curso Jakarta EE 9 (17). CDI (6): Eventos

logo Jakarta EE

El bloque dedicado a Jakarta CDI llega a su fin con el estudio de una funcionalidad que no parece tener mucho que ver con la inyección de dependencias, pero que aprovecha la existencia del contenedor de CDI para ayudarnos en la creación de sistemas más desacoplados y abstractos si cabe.

>>>> ÍNDICE <<<<

Eventos

Al igual que Spring (ver tutorial), el contenedor de CDI provee un sencillo sistema de eventos para permitir que los objetos que contiene puedan comunicarse entre sí de forma transparente manteniéndolos desacoplados, esto es, sin que se conozcan mutuamente. Este sistema se inspira en el popular patrón de diseño Observador. Su objetivo es conseguir que un objeto sea informado de ciertos sucesos producidos en la aplicación para que realice alguna acción si fuera necesario.

En el proyecto de ejemplo cdi, vamos a crear una interfaz que guarde documentos tal y como sigue.

package com.danielme.jakartaee.cdi.events;

public interface DocumentStorage {

void add(Document document);

}
package com.danielme.jakartaee.cdi.events;

public class Document {

    private final String name;
    private final byte[] content;

    public Document(String name, byte[] content) {
        this.name = name;
        this.content = content;
    }

    public String getName() {
        return name;
    }

    public byte[] getContent() {
        return content;
    }

}

De momento escribimos una implementación que no hará nada.

package com.danielme.jakartaee.cdi.events;

import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class DocumentStorageImpl implements  DocumentStorage{

    @Override
    public void add(Document document) {
    }
}

Supongamos que hay otras clases en la aplicación que deberían ser informadas de que se ha producido el guardado de un documento para, por ejemplo, enviar una notificación por correo electrónico o bien con la tecnología WebSockets a clientes web que tengan conexión en ese momento. El cometido de DocumentStorageImpl es guardar el documento, pero puede informar al contenedor de CDI de que ha realizado esa operación enviando un evento utilizando el contrato Event.

package com.danielme.jakartaee.cdi.events;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Event;
import jakarta.inject.Inject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@ApplicationScoped
public class DocumentStorageImpl implements DocumentStorage {

    private static final Logger log =
            LoggerFactory.getLogger(DocumentStorageImpl.class);

    @Inject
    Event<DocumentSavedEvent> event;

    @Override
    public void add(Document document) {
        event.fire(new DocumentSavedEvent(document));
        log.info("document saved");
    }

}

CDI nos lo pone fácil. Tenemos que pedir una implementación de la interfaz Event para invocar al método fire con el objeto que queramos que represente al evento, en nuestro caso perteneciente a la clase DocumentSavedEvent. Así pues, en la práctica un evento se modela como una clase cualquiera y sin tener que implementar ninguna interfaz o usar anotación alguna. Más limpio, imposible.

package com.danielme.jakartaee.cdi.events;

public class DocumentSavedEvent {

    private final Document document;

    public DocumentSavedEvent(Document document) {
        this.document = document;
    }

    public Document getDocument() {
        return document;
    }

}

Para atender al evento, creamos un método «observador», con la única restricción de que no sea abstracto, en clases de tipo CDI beans. Usamos la anotación @Observes para marcar el parámetro en el que queremos recibir el objeto que lanzamos con fire. El tratamiento del evento va a consistir en registrar su recepción en una lista llamada notifications que simula una cola de notificaciones pendiente de enviar, por ejemplo, mediante correo electrónico.

package com.danielme.jakartaee.cdi.events;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.List;

@ApplicationScoped
public class NotificationService {

    private static final Logger log =
            LoggerFactory.getLogger(NotificationService.class);

    @Inject
    @Named("notifications")
    private List<String> notifications;

    void documentCreationObserver(@Observes DocumentSavedEvent documentSavedEvent) {
        notifications.add(Observes.class.getSimpleName() + documentSavedEvent.getDocument().getName());
        log.info("notification sended");
    }

}

Nota. En el observador, además del evento, podemos recibir cualquier objeto inyectable.

¡Voilà! Ya tenemos nuestro sistema de comunicación basado en eventos y no ha supuesto la más mínima dificultad. Probémoslo como corresponde con una nueva clase de pruebas. Las aserciones se basan en la inspección de la lista notifications, en ella debe quedar constancia de la ejecución de documentCreationObserver.

package com.danielme.jakartaee.cdi.events;

import com.danielme.jakartaee.cdi.Deployments;
import jakarta.inject.Inject;
import org.jboss.arquillian.container.test.api.Deployment;
import org.jboss.arquillian.junit5.ArquillianExtension;
import org.jboss.shrinkwrap.api.spec.WebArchive;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@ExtendWith(ArquillianExtension.class)
public class DocumentEventsArquillianTest {

    @Inject
    @Named("notifications")
    private List<String> notifications;

    @Inject
    private DocumentStorage documentStorage;

    @Deployment
    public static WebArchive createDeployment() {
        return Deployments.filesAndEvents();
    }

    @Test
    void testDocumentObserver() {
        Document document = new Document("test", new byte[]{});

       documentStorage.add(document);

       assertThat(notifications).containsExactly(Observes.class.getSimpleName() + document.getName());
    }

}

Si echamos un vistazo al diagrama de clases, vemos que no existe relación entre NotificationService y DocumentStorageImpl (receptor y emisor del evento) aunque ambas utilizan la clase DocumentSavedEvent.

Eventos asíncronos

Prestemos atención a la salida de la prueba anterior. Si usamos IntelliJ, en la consola veremos lo siguiente.

cdi event test

La ejecución de los observadores es síncrona con respecto al método que lanza el evento. Es decir, DocumentStorageImpl#add no prosigue después de invocar a fire hasta que hayan finalizado todos los observadores. Sin embargo, en muchos casos necesitaremos que el procesado de los eventos sea asíncrono para evitar que la ejecución del método emisor quede suspendida y\o se vea afectada por el lanzamiento de una excepción en uno de los observadores, pues el error se elevará hasta llegar al punto en el que se hace la llamada a fire.

En el ejemplo, hemos supuesto que el método que hace el guardado del documento realiza las operaciones para esa tarea, mientras que las acciones complementarias, aunque pasos imprescindibles en el proceso de negocio o caso de uso, serán realizadas por otras clases. Ahora queremos que esas operaciones adicionales no tengan influencia alguna en el proceso de guardado. Consideramos el hecho de que se envíe o no la notificación como irrelevante para DocumentStorage, porque el documento siempre debe quedar guardado y todo lo demás es accesorio.

Esta asincronía se consigue declarando el envío y la observación del evento del siguiente modo, usando el método fireSync y la anotación @ObservesAsync.

    @Override
    public void add(Document document) {
        event.fire(new DocumentSavedEvent(document));
        event.fireAsync(new DocumentSavedEvent(document));
        log.info("document saved");
    }
    void documentCreationObserverAsync(@ObservesAsync DocumentSavedEvent documentSavedEvent) {        
        notifications.add(ObservesAsync.class.getSimpleName() + documentSavedEvent.getDocument().getName());
        log.info("notification sended async");
    }

De nuevo resulta sencillo. En esta ocasión, se emiten dos eventos de tipo DocumentSavedEvent, uno síncrono y otro asíncrono, y cada cual cuenta con su propio observador. Pero hay un problema: el observador asíncrono es tan rápido que si ejecutamos la prueba testDocumentObserver fallará porque cuando evaluamos la lista de notificaciones ya tiene los elementos correspondientes al observador síncrono y al asíncrono. Lo vemos en los resultados.

Por este motivo, voy a demorar la ejecución del observador asíncrono un segundo.

void documentCreationObserverAsync(@ObservesAsync DocumentSavedEvent documentSavedEvent) {
    try {
        Thread.sleep(1000);
    } catch (InterruptedException e) {
        log.error(ex.getMessage(), ex););
    }
    notifications.add(ObservesAsync.class.getSimpleName() + documentSavedEvent.getDocument().getName());
    log.info("notification sended async");
 }

El retraso no afecta ni al método add ni al evento tratado de forma síncrona, ya que documentCreationObserverAsync se ejecuta en otro hilo. El resultado del test será correcto y en la salida de WildFly seguimos viendo los mismos mensajes que se mostraban antes de añadir el evento asíncrono.

notification sended
document saved

Esto es debido a que la prueba finaliza antes de que documentCreationObserverAsync haya escrito su mensaje en notifications. Vamos a mejorarla con el objetivo de validar que, en efecto, se realiza el tratamiento asíncrono del evento, teniendo en cuenta que tendrá lugar después de que DocumentStorage#add finalice por completo.

Aprovecho esta circunstancia para introducir una pequeña herramienta de código abierto llamada Awaitility. Su propósito es facilitar el testeo de sistemas asíncronos.

 <dependency>
    <groupId>org.awaitility</groupId>
    <artifactId>awaitility</artifactId>
    <version>${awaitility.version}</version>
    <scope>test</scope>
 </dependency>

Recordemos que nuestras pruebas se ejecutan dentro de WildFly. No podemos olvidar incluir esta librería, algo que ya hacemos con AssertJ, en el artefacto que despliega Arquillian.

    private static File[] getAwaitibilityFiles() {
        return Maven.resolver()
                .loadPomFromFile("pom.xml")
                .resolve("org.awaitility:awaitility")
                .withTransitivity()
                .asFile();
    }

Ahora la prueba verifica la ejecución correcta de los dos observadores.

    @Test
    void testDocumentObserver() {
        Document document = new Document("test", new byte[]{});

        documentStorage.add(document);

        await()
                .atMost(Duration.ofSeconds(3))
                .until(() -> {
                    SoftAssertions softly = new SoftAssertions();
                    softly.assertThat(notifications)
                            .containsExactly(Observes.class.getSimpleName() + document.getName(),
                                             ObservesAsync.class.getSimpleName() + document.getName());
                    return softly.wasSuccess();
                });
    }

Este código requiere de ciertas aclaraciones. El método estático await detiene la ejecución de la prueba hasta que la lambda del método until retorne true, o bien finalice el periodo de tiempo indicado con atMost (esto último en realidad no es necesario porque hay un valor predeterminado). Si el plazo expira antes de que la expresión de until se haya cumplido (true), se lanza una excepción de tipo ConditionTimeoutException y la prueba será errónea.

Necesitamos que la evaluación del resultado realizada dentro de until devuelva un boolean. Por comodidad y claridad, usemos una aserción de AssertJ. Su clase SoftAssertions nos permite recoger en un objeto el resultado de la aserción y obtenerlo posteriormente como un boolean.

Con la nueva versión del test, veremos los mensajes en el orden esperado.

Eventos calificados

Nada impide que un tipo de evento sea lanzado desde métodos distintos. Puesto que la clase que lo modela siempre es la misma, parece razonable que sea atendido por los mismos observadores, con independencia de quién haya sido el responsable de su envío.

Esta situación se refleja en la siguiente imagen en la que tenemos tres emisores distintos de un «evento Z», y todos los envíos terminan siendo atendidos por el mismo observador sin importar su origen. Aquí estamos obviando la discriminación entre eventos síncronos y asíncronos que acabamos de ver, y en esta sección del capítulo supondremos que son síncronos.

¿Y si queremos tener varios observadores distintos para un mismo tipo de evento dependiendo del emisor? CDI contempla este escenario al permitir la aplicación de calificadores tanto a la declaración de la inyección de objetos de tipo Event como a la definición de parámetros con @Observes. Vamos a demostrarlo ampliando un ejemplo del capítulo dedicado a los calificadores. Añadimos un método a la interfaz FileStorage.

public interface FileStorage {

    List<String> availableFiles();

    void add(FileContent document);
}

Las implementaciones de FileStorage lanzarán un evento, pero a diferencia de lo que hicimos en la clase DocumentStorageImpl, cuando se inyecte en ellas Event usaremos un calificador que además será distinto para cada implementación. Aunque podríamos recurrir a @Named, en este caso concreto ya disponemos de calificadores personalizados que resultan más que adecuados.

@ApplicationScoped
@FileStorageLocalQualifier
public class FileStorageLocal implements FileStorage {

    @Inject
    @FileStorageLocalQualifier
    private Event<FileContent> event;

    @Override
    public void add(FileContent fileContent) {
        event.fire(fileContent);
    }

    @Override
    public List<String> availableFiles() {
        return List.of("JakartaEE.pdf");
    }

}
@ApplicationScoped
@FileStorageRemoteQualifier
public class FileStorageRemote implements FileStorage {

    @Inject
    @FileStorageRemoteQualifier
    private Event<FileContent> event;

    @Override
    public void add(FileContent fileContent) {
        event.fire(fileContent);
    }

    @Override
    public List<String> availableFiles() {
        return null;
    }

}

Los eventos quedan asociados al calificador de la instancia de Event que los lanza. Haremos uso de esa asociación en los observadores para recibir solo los eventos correspondientes al calificador que indiquemos.

public class FileStorageEventListener {

    @Inject
    @Named("notifications")
    private List<String> notifications;

    void observerLocal(@Observes @FileStorageLocalQualifier FileContent fileContent) {
        notifications.add(FileStorageLocalQualifier.class.getSimpleName() + fileContent.getName());
    }

    void observerRemote(@Observes @FileStorageRemoteQualifier FileContent fileContent) {
        notifications.add(FileStorageRemoteQualifier.class.getSimpleName() + fileContent.getName());
    }

}

El siguiente esquema muestra las relaciones entre los elementos. Si bien observador y emisor están vinculados a través del calificador, el sistema de eventos continúa siendo abstracto porque ambos siguen sin conocerse mutuamente.

Con la nueva clase FileStorageEventsArquillianTest comprobamos el funcionamiento de FileStorageEventListener.

@ExtendWith(ArquillianExtension.class)
public class FileStorageEventsArquillianTest {

    @Inject
    @Named("notifications")
    private List<String> notifications;

    @Inject
    @FileStorageLocalQualifier
    private FileStorage fileStorageLocal;

    @Inject
    @FileStorageRemoteQualifier
    private FileStorage fileStorageRemote;

    @Deployment
    public static WebArchive createDeployment() {
        return Deployments.filesAndEvents();
    }

    @AfterEach
    void cleanUp() {
        if (notifications != null)
            notifications.clear();
    }

    @Test
    void testLocalObserver() {
        FileContent fileContent = new FileContent("test-local", new byte[]{});

        fileStorageLocal.add(fileContent);

        assertThat(notifications).containsExactly(FileStorageLocalQualifier.class.getSimpleName() + fileContent.getName());
    }

    @Test
    void testRemoteObserver() {
        FileContent fileContent = new FileContent("test-remote", new byte[]{});

        fileStorageRemote.add(fileContent);

        assertThat(notifications).containsExactly(FileStorageRemoteQualifier.class.getSimpleName() + fileContent.getName());
    }

}

En cada prueba solo se estará invocando a uno de los dos observadores y, por tanto, la lista notifications solo debe contener un elemento. Podemos asegurar que el observador ejecutado es el correcto porque el nombre de la cadena contenida en la lista señala el calificador indicado en el observador que procesó el evento.

Por último, merece la pena destacar que es posible recibir todos los eventos de tipo Document en un único observador, ignorando los calificadores (si los hubiera).

void observerAll(@Observes Document document) {
    notifications.add(documenft.getName());
}

Código de ejemplo

El código de ejemplo del capítulo se encuentra en GitHub (todos los proyectos están en un único repositorio). Para más información sobre cómo utilizar GitHub, consultar este artículo.

>>>> ÍNDICE <<<<

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 )

Google photo

Estás comentando usando tu cuenta de Google. 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.