Curso Jakarta EE 9 (16). CDI (5): Interceptores y decoradores

logo Jakarta EE

La inyección de dependencias con Jakarta CDI ya no guarda secretos. Tenemos los conocimientos suficientes para afrontar casi cualquier necesidad que surja al trabajar con inyecciones y CDI beans en nuestros proyectos Jakarta EE. No obstante, vamos a estudiar funcionalidades adicionales derivadas de la naturaleza del contenedor de CDI y sus «proxy». Su uso no es muy habitual, pero no están de más en nuestra «caja de herramientas».

>>>> ÍNDICE <<<<

Interceptores

Una característica interesante y quizás un tanto desconocida de CDI es la posibilidad de implementar métodos «interceptores» que sean ejecutados automáticamente siempre que se invoquen ciertos métodos que indiquemos. Estos interceptores permiten modelar y encapsular comportamientos comunes «transversales» a múltiples métodos de la aplicación siempre y cuando pertenezcan a objetos gestionados por el contenedor de CDI, esto es, a CDI beans. Además, están desacoplados del código sobre el que se aplican: los métodos que son el «objetivo» (target) de un interceptor lo desconocen en su implementación.

Por tanto, esta técnica amplía las posibilidades de la programación orientada a objetos y nos ayudará a la hora de escribir código fácil de mantener y evolucionar gracias a su alta modularidad y nivel de abstracción. Esto último cobra especial relevancia si tenemos en cuenta que, en general, las operaciones que realizan los interceptores suelen estar relacionadas con la infraestructura técnica de la aplicación (sistemas de logging y auditoría, gestión de seguridad, cálculo de métricas de rendimiento…) y no con la lógica de negocio propiamente dicha, así que la separación clara entre ambos mundos resulta más que apropiada.

Esta técnica no es una creación propia de la especificación CDI y consiste en la aplicación sencilla y directa de los principios de la programación orientada aspectos (AOP) en el mundo Jakarta EE. Y sin ser conscientes de ello ya lo hemos utilizado, pues con las anotaciones @PostConstruct y @PreDestroy no hicimos sino aplicar métodos interceptores a eventos pertenecientes al ciclo de vida de los CDI beans.

No examinaremos los fundamentos de AOP. Tampoco necesitamos saber más para utilizarla en nuestras aplicaciones basadas en CDI, y lo voy a demostrar implementando el caso más típico: la creación de interceptores para realizar la escritura en la bitácora del servidor de los detalles de las llamadas a los métodos que queramos.

El primer paso es la creación del método interceptor en una clase marcada con @Interceptor. Escribimos un método, ni estático ni final, anotado con @AroundInvoke y con la visibilidad que necesitemos. Solo puede haber uno en una misma clase. Recibimos un objeto de tipo InvocationContext que contiene los datos de la llamada al método que estamos interceptando. Se pueden lanzar excepciones.

package com.danielme.jakartaee.cdi.interceptors;

import jakarta.inject.Inject;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.InvocationContext;
import jakarta.interceptor.Interceptor;

import java.util.List;

@Interceptor
public class LoggingInterceptor {

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

    @AroundInvoke
    private Object log(InvocationContext context) throws Exception {
        logger.add(context.getMethod().getName());
        return context.proceed();
    }

}

La clase anterior será nuestro interceptor de ejemplo. El log se ha simulado con una lista, creada en un productor, que podemos inyectar y recuperar en una prueba para comprobar su contenido.

package com.danielme.jakartaee.cdi.interceptors;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;

import java.util.ArrayList;
import java.util.List;

public class LoggerProducer {

    @Produces
    @Named("logger")
    @ApplicationScoped
    List<String> logger() {
        return new ArrayList<>();
    }
}

A la hora de devolver la respuesta, tenemos dos opciones.

  • Invocaremos a context.proceed() y devolvemos su resultado para continuar con la ejecución de toda la cola de interceptores, hasta concluir con la ejecución del método interceptado. Es lo habitual.
  • Devolvemos null si queremos abortar la ejecución. Esto permite escribir interceptores que decidan si un método debe ejecutarse.

Aplicamos el interceptor a todos los métodos de una clase o bien a algunos de ellos con la anotación @Interceptors. En ella indicaremos las clases con interceptores que deben aplicarse. Sus métodos @AroundInvoke se ejecutarán siguiendo el orden en el que se declaren dentro de la anotación.

package com.danielme.jakartaee.cdi.interceptors;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.interceptor.Interceptors;

@Interceptors(LoggingInterceptor.class)
@ApplicationScoped
public class TargetClass {

    public TargetClass() {
    }

    public void methodToIntercept1() {
        methodToIntercept2();
    }

    public void methodToIntercept2() {
    }

}

Nota. Si queremos un interceptor que solo se ejecute para los métodos de una única clase, es suficiente con crear en ella un método @AroundInvoke.

La siguiente prueba nos permite comprobar cómo se comporta el interceptor. La llamada a methodToIntercept1 causa que el interceptor añada a la lista logger el nombre del método interceptado.

package com.danielme.jakartaee.cdi.interceptors;

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 LoggingInterceptorArquillianTest {

    @Inject
    private TargetClass targetClass;

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

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

    @Test
    void testInterceptorMethod1() {
        targetClass.methodToIntercept1();

        assertThat(logger).containsExactly("methodToIntercept1");
    }

}

Su ejecución correcta demuestra que el interceptor se aplica a todos los métodos «de entrada», por así decirlo, en la clase: la llamada a methodToIntercept2 no se intercepta porque se realiza desde methodToIntercept1. Pero si se invoca de forma directa, el interceptor entra en acción.

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

@Test
void testInterceptorMethod2() {
    targetClass.methodToIntercept2();

    assertThat(logger).containsExactly("methodToIntercept2");
}

Recordemos que un principio fundamental del diseño de pruebas es la independencia entre ellas, de tal modo que el orden de ejecución nunca debe afectar a los resultados. Por este motivo, en el código anterior se limpia la lista loggers después de cada prueba.

Nada cambia si aplicamos el interceptor a métodos concretos. La prueba se seguirá ejecutando con éxito si TargetClass se anota del siguiente modo.

@ApplicationScoped
public class TargetClass {

    public TargetClass() {
    }

    @Interceptors(LoggingInterceptor.class)
    public void methodToIntercept1() {
        methodToIntercept2();
    }

    @Interceptors(LoggingInterceptor.class)
    public void methodToIntercept2() {
    }

}

Ambas estrategias se pueden utilizar al mismo tiempo, aunque debemos tener en cuenta que los interceptores declarados para la clase se ejecutan antes que los definidos en sus métodos.

También es posible interceptar constructores, pero el método interceptor se anota con @AroundConstruct.

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

@AroundConstruct
private Object construct(InvocationContext context) throws Exception {
    constructors.add(context.getConstructor().getDeclaringClass().getSimpleName());
    return context.proceed();
}

En el fragmento de código anterior he usado una nueva lista llamada constructors en lugar de logger para facilitar las pruebas. Contemplando este nuevo interceptor, LoggingInterceptorArquillianTest queda tal y como sigue.

@ExtendWith(ArquillianExtension.class)
public class LoggingInterceptorArquillianTest {

    @Inject
    private TargetClass targetClass;

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

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

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

    @AfterEach
    void cleanUp() {
        logger.clear();
    }

    @Test
    void testInterceptorInvokedOnce() {
        targetClass.methodToIntercept1();

        assertThat(logger).containsExactly("methodToIntercept1");
    }

    @Test
    void testInterceptorMethod2() {
        targetClass.methodToIntercept2();

        assertThat(logger).containsExactly("methodToIntercept2");
    }

    @Test
    void testConstructor() {
        assertThat(constructors).containsExactly(TargetClass.class.getSimpleName());
    }

}
Calificadores

Comprobemos el diagrama de clases del ejemplo para tener una perspectiva general.

Desde TargetClass no se invocan a los métodos de LogginInterceptor, y ese es el quid del asunto. Pero hay algo feo en el diagrama: ambas clases están relacionadas porque en @Interceptors se indica la clase del interceptor. Nada grave, aunque si somos puristas es un detalle molesto.

La asignación de interceptores se puede hacer de forma más elegante y desacoplada creando anotaciones personalizadas de tipo @InterceptorBinding.

package com.danielme.jakartaee.cdi.interceptors;

import jakarta.interceptor.InterceptorBinding;

import java.lang.annotation.*;

@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
@InterceptorBinding
public @interface Logging {
}

La anotación Logging, aplicable tanto a clases como a métodos, será el calificador del interceptor LoggingInterceptor que ahora se define así.

@Interceptor
@Logging
public class LoggingInterceptor {

En lugar de @Interceptors usaremos @Logging.

//@Interceptors(LoggingInterceptor.class)
@Logging
@ApplicationScoped
public class TargetClass {

Hemos conseguido que en TargetClass no haya referencias a la clase LoggingInterceptor. La relación entre ellas se establece de forma «limpia» gracias a @Logging.

Cuando los interceptores se aplican de esta forma, es necesario «activar» las clases interceptoras. Hay dos opciones.

  • Con la anotación @Priority, definiendo además un número que indica su prioridad. Cuanto menor sea este número, mayor será la prioridad del interceptor cuando se apliquen varios a un mismo método.
@Priority(value = Interceptor.Priority.APPLICATION)
  • En el fichero beans.xml.
<beans xmlns="https://jakarta.ee/xml/ns/jakartaee"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/beans_3_0.xsd"
       version="3.0"
       bean-discovery-mode="all">

    <interceptors>
        <class>com.danielme.jakartaee.cdi.interceptors.LoggingInterceptor</class>
    </interceptors>

</beans>

En el ejemplo se va a utilizar la segunda estrategia. Con estos cambios todo debe seguir funcionando igual, y las pruebas de la clase LoggingInterceptorArquillianTest permiten validar que la refactorización ha sido correcta, una de los grandes beneficios de escribir pruebas automáticas.

Puede darse el caso de que tengamos que aplicar más de un interceptor a una clase o método mediante calificadores. En esta situación, los interceptores se ejecutan en función del orden en el que aparecen en el beans.xml con una excepción importante: los anotados con @Priority tienen preferencia sobre los registrados en el fichero, y entre ellos se aplican en el orden dictado por el entero de value.

Decoradores

Un decorador es una clase que intercepta los objetos del contenedor de CDI pertenecientes a cierta interfaz para sobrescribir algunos de sus métodos. De este modo, es posible cambiar operaciones de los objetos sin modificar el código de su clase o tener que especializarla, algo que no siempre resulta posible. Asimismo, podemos crear un decorador como alternativa al uso, valga la redundancia, de implementaciones alternativas (@Alternative) cuando solo queremos alterar una parte de las operaciones de una implementación ya existente.

Vamos a implementar un decorador para una nueva interfaz en nuestro proyecto cdi.

package com.danielme.jakartaee.cdi.decorators;

public interface MessageService {

    String message1();

    String message2();
}
package com.danielme.jakartaee.cdi.decorators;

import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class MessageServiceImpl implements MessageService {

    @Override
    public String message1() {
        return "message 1";
    }

    @Override
    public String message2() {
        return "message 2";
    }

}

Veamos qué aspecto tiene un decorador para todos los objetos de tipo MessageService. Tenemos que crear una clase anotada con @Decorator que implemente dicho contrato.

package com.danielme.jakartaee.cdi.decorators;

import jakarta.decorator.Decorator;
import jakarta.decorator.Delegate;
import jakarta.enterprise.inject.Any;
import jakarta.inject.Inject;
import jakarta.inject.Named;

import java.util.List;

@Decorator
public abstract class MessageDecorator implements MessageService {

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

    @Inject
    @Delegate
    @Any
    private MessageService messageService;

    @Override
    public String message1() {
        String msg = messageService.message1();
        logger.add(msg);
        return msg;
    }

}

Es obligatorio inyectar el objeto que estamos decorando y que, por tanto, pertenece a la interfaz MessageService. Además de @Inject, acompañamos su punto de inyección con @Delegate. Si hubiera más de una implementación de MessageService y quisiéramos decorar los objetos de todas ellas, añadimos el calificador @Any. Pero si el decorador solo debe aplicarse a una en concreto, usaremos el calificador apropiado como en cualquier inyección (@Named o un calificador personalizado).

De la interfaz, implementaremos los métodos que queremos sobrescribir para el objeto decorado, por lo que si no se implementa todo el contrato tendremos que declarar la clase como abstracta. En el ejemplo, cuando se llame al método message1 del objeto decorado se ejecutará en su lugar el message1 del decorador. He añadido una cadena a la lista logger para comprobar en una prueba que el método se ha invocado.

El siguiente diagrama muestra las clases anteriores.

Antes de implementar las pruebas, falta un último paso: activar MessageDecorator en el fichero beans.xml.

<beans xmlns="https://jakarta.ee/xml/ns/jakartaee"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/beans_3_0.xsd"
       version="3.0"
       bean-discovery-mode="all">

    <decorators>
        <class>com.danielme.jakartaee.cdi.decorators.MessageDecorator</class>
    </decorators>

</beans>

Ahora sí podemos probar el correcto funcionamiento del decorador. La siguiente clase comprueba que la llamada a MessageService#message1 ejecuta el método del decorador.

package com.danielme.jakartaee.cdi.decorators;

import com.danielme.jakartaee.cdi.Deployments;
import jakarta.inject.Inject;
import jakarta.inject.Named;
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 DecoratorArquillianTest {

    @Inject
    private MessageService messageService;

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

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

    @Test
    void testDecorateMethod1() {
        String msg = messageService.message1();

        assertThat(logger).containsExactly(msg);
    }

}

La prueba será fallida si el decorador no está activado.

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 )

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.