Curso Jakarta EE 9 (13). CDI (2) Ámbitos (scopes)

logo Jakarta EE

Hasta el momento, no he hablado del concepto ámbito (scope) aunque lo usamos cuando vimos las pruebas con Arquillian. Ahora que ya comprendemos los fundamentos de la inyección de dependencias realizada por el contenedor de CDI, exploremos a fondo este asunto. Su conocimiento resulta imprescindible.

>>>> ÍNDICE <<<<

Ámbitos (Scope)

El ámbito (scope) o contexto de un CDI bean indica el tipo de ciclo de vida de una instancia de la clase dentro del contenedor. Según este ciclo, el contenedor determina el momento de creación del objeto, pero también el de su destrucción: cuando ya no sea necesario, no volverá a inyectarse y quedará a la espera de ser eliminado por el recolector de basura de la jvm. Así pues, podemos considerar al ámbito como el tiempo durante el cual un objeto existe.

CDI proporciona un conjunto de ámbitos que abarca una gran cantidad de escenarios, poniendo el foco en las necesidades de las aplicaciones web. A continuación veremos los cuatro de uso más habitual, mostrados en la siguiente figura. Están organizados en función del tiempo de existencia de los objetos de cada ámbito con respecto a una aplicación web: de izquierda a derecha nos movemos de la mayor duración (el objeto existe mientras la aplicación esté desplegada) a la menor (solo una petición o request HTTP).

CDI ámbitos scopes
ApplicationScope

Es el ámbito que hemos usado hasta ahora en los ejemplos del curso. Con la anotación @ApplicationScoped estamos indicando que solo existe un objeto de la clase durante toda la ejecución de la aplicación. Dicho de otro modo, queremos que sea un singleton. Esta instancia será compartida entre todos los objetos que la requieran, así que este ámbito solo debe aplicarse a clases inmutables. Es el caso, por ejemplo, de aquellas que encapsulan la interacción con bases de datos (DAOs y repositorios), contienen la lógica de negocio o los servicios REST.

Vamos a crear un nuevo proyecto llamado cdi. Empecemos con este servlet.

package com.danielme.jakartaee.cdi.scope;

import jakarta.inject.Inject;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.ws.rs.core.MediaType;

import java.io.IOException;
import java.io.PrintWriter;

@WebServlet(ScopesServlet.URL)
public class ScopesServlet extends HttpServlet {

    public static final String URL = "scopesServlet";

    private final ApplicationScopedDependency applicationScoped;

    @Inject
    public ScopesServlet(ApplicationScopedDependency applicationScoped) {
        this.applicationScoped = applicationScoped;
    }

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        PrintWriter printWriter = response.getWriter();
        response.setContentType(MediaType.TEXT_PLAIN);
        printWriter.print(applicationScoped.getTimestamp());
    }

}

Esta es la clase inyectada en ScopesServlet.

package com.danielme.jakartaee.cdi.scope;

import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class ApplicationScopedDependency {

    private final Long timestamp;

    public ApplicationScopedDependency() {
        this.timestamp = System.currentTimeMillis();
    }

    Long getTimestamp() {
        return timestamp;
    }

}

En el constructor de ApplicationScopedDependency se establece el instante (timestamp) de creación del objeto. La idea es utilizarlo para verificar que siempre que llamamos al servlet (http://localhost:8080/cdi/scopesServlet) se utiliza la misma instancia de ApplicationScopedDependency. Por ello, el servlet devuelve el timestamp de la dependencia.

Automaticemos esta comprobación con una prueba, poniendo en práctica todo lo visto en los capítulos dedicados al testing automático.

package com.danielme.jakartaee.cdi.scope;

import org.jboss.arquillian.container.test.api.Deployment;
import org.jboss.arquillian.junit5.ArquillianExtension;
import org.jboss.arquillian.test.api.ArquillianResource;
import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.spec.WebArchive;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

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

@ExtendWith(ArquillianExtension.class)
class ScopesServletArquillianTest {

    @ArquillianResource
    private URL contextPath;

    @Deployment(testable = false)
    public static WebArchive createDeployment() {
        return ShrinkWrap.create(WebArchive.class, "cdi-test.war")
                .addPackage(ScopesServlet.class.getPackage());
    }

    @Test
    @DisplayName("ApplicationScoped es siempre el mismo")
    void testApplicationScopeServlet() throws IOException, InterruptedException {
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(contextPath + ScopesServlet.URL))
                .build();

        HttpResponse<String> response1 =
                HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
        HttpResponse<String> response2 =
                HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());

        assertThat(response1.body()).isEqualTo(response2.body());
    }

}

He conservado los cuatro perfiles de Maven del proyecto arquillian. Elijamos cualquiera de ellos, comprobando que los valores del fichero arquillian.xml son los adecuados, para que Arquillian despliegue el artefacto de prueba en WildFly y el test puedan llamar al servlet simulando a un cliente del mismo (por eso el parámetro testable es false). El método testApplicationScopeServlet, empleando el nuevo cliente HTTP de Java 11, realiza dos llamadas a ScopesServlet para validar que ApplicationScopedDependency siempre es el mismo objeto.

IntelliJ run test arquillian WildFly

Podemos hacer pruebas similares de forma manual desplegando el proyecto en WildFly y realizando llamadas al servlet con varios navegadores o un cliente web (curl, Postman, etc).

@RequestScoped

Este ámbito asocia el objeto a una petición HTTP y su existencia se limita a la duración de ese «request». Son ideales, por tanto, para contener datos asociados en exclusiva a una petición, como, por ejemplo, los de un formulario. Cada vez que el usuario haga un envío (POST) con los datos del mismo, el objeto que lo modela será distinto y no hay peligro de datos «fantasmas» procedentes de una solicitud previa, pero tampoco conflicto alguno con los formularios enviados por otros usuarios.

Ampliemos el ejemplo con esta clase.

package com.danielme.jakartaee.cdi.scope;

import jakarta.enterprise.context.RequestScoped;

@RequestScoped
public class RequestScopedDependency {

    private final Long timestamp;

    public RequestScopedDependency() {
        this.timestamp = System.currentTimeMillis();
    }

    Long getTimestamp() {
        return timestamp;
    }

}

Vamos a generalizar el servlet para indicar con un parámetro en la petición, llamado «scope», la dependencia de la que queremos obtener su timestamp en la respuesta.

package com.danielme.jakartaee.cdi;

import jakarta.inject.Inject;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.ws.rs.core.MediaType;

import java.io.IOException;
import java.io.PrintWriter;

@WebServlet(ScopesServlet.URL)
public class ScopesServlet extends HttpServlet {

    public static final String URL = "scopesServlet";

    private static final String SCOPE_PARAM = "scope";

    public enum Scope {
        APPLICATION, REQUEST
    }

    private final ApplicationScopedDependency applicationScoped;
    private final RequestScopedDependency requestScoped;

    @Inject
    public ScopesServlet(ApplicationScopedDependency applicationScoped,
                         RequestScopedDependency requestScoped) {
        this.applicationScoped = applicationScoped;
        this.requestScoped = requestScoped;
    }

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        PrintWriter printWriter = response.getWriter();
        response.setContentType(MediaType.TEXT_PLAIN);
        printWriter.print(getTimestamp(request));
    }

    private Long getTimestamp(HttpServletRequest request) {
        String scopeParam = request.getParameter(SCOPE_PARAM);
        if (Scope.APPLICATION.name().equals(scopeParam)) {
            return applicationScoped.getTimestamp();
        }
        if (Scope.REQUEST.name().equals(scopeParam)) {
            return requestScoped.getTimestamp();
        }
        throw new IllegalArgumentException("invalid 'scope' param");
    }

}

El siguiente diagrama de clases permite visualizar cómo va a quedar ScopesServlet con todos los ejemplos del presente capítulo.

La llamada para la prueba de la sección anterior será http://localhost:8080/cdi/scopesServlet?scope=APPLICATION, mientras que para la actual es http://localhost:8080/cdi/scopesServlet?scope=REQUEST. Escribamos una nueva prueba que valide que el timestamp devuelto es distinto en cada llamada.

@Test
@DisplayName("RequestScoped es siempre distinto")
void testRequestScopeServlet() throws IOException, InterruptedException {
   HttpRequest request = setup(ScopesServlet.Scope.REQUEST);

   HttpResponse<String> response1 =
                HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
   HttpResponse<String> response2 =
                HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());

   assertThat(response1.body()).isNotEqualTo(response2.body());
}
@SessionScoped

Tal y como cabría esperar, si tenemos un ámbito para las peticiones HTTP también contamos con uno equivalente para cada sesión. Estas instancias serán las mismas para todas las peticiones realizadas bajo una misma sesión. Además, puesto que las sesiones HTTP pueden ser guardadas por el servidor, las clases de tipo @SessionScoped son serializables (*).

(*) En realidad, todos los ámbitos cuyas anotaciones estén a su vez marcadas con @NormalScope (passivating = true) requieren clases serializables.

package com.danielme.jakartaee.cdi.scope;

import jakarta.enterprise.context.SessionScoped;

import java.io.Serializable;

@SessionScoped
public class SessionScopedDependency implements Serializable {

    private final Long timestamp;

    public SessionScopedDependency() {
        this.timestamp = System.currentTimeMillis();
    }

    Long getTimestamp() {
        return timestamp;
    }

}

Para probar bien el funcionamiento de este contexto tendremos que implementar dos pruebas que llamen a http://localhost:8080/cdi/scopesServlet?scope=SESSION para comprobar que la dependencia es la misma cuando mantenemos la sesión y distinta si cada petición pertenece a una diferente. Lo primero se consigue configurando la clase CookieHandler para conservar y reutilizar la cookie que el servidor devuelve y que contiene el identificador de la sesión.

@Test
@DisplayName("SessionScoped es el mismo dentro de una sesión")
void testSessionScopeEqualServlet() throws IOException, InterruptedException {
    HttpRequest request = setup(ScopesServlet.Scope.SESSION);
    //guarda la cookie en la memoria y la envía en cada petición
    CookieHandler.setDefault(new CookieManager());
    HttpClient client = HttpClient.newBuilder()
                .cookieHandler(CookieHandler.getDefault())
                .build();

    HttpResponse<String> response1 =
                client.send(request, HttpResponse.BodyHandlers.ofString());
    HttpResponse<String> response2 =
                client.send(request, HttpResponse.BodyHandlers.ofString());

    assertThat(response1.body()).isEqualTo(response2.body());
}

@Test
@DisplayName("SessionScoped es distinto para sesiones distintas")
void testSessionScopeNotEqualServlet() throws IOException, InterruptedException {
    HttpRequest request = setup(ScopesServlet.Scope.SESSION);

    HttpResponse<String> response1 =
                HttpClient.newBuilder().build().send(request, HttpResponse.BodyHandlers.ofString());
    HttpResponse<String> response2 =
                HttpClient.newBuilder().build().send(request, HttpResponse.BodyHandlers.ofString());

    assertThat(response1.body()).isNotEqualTo(response2.body());
}
@ConversationScoped

Este ámbito resulta especial -y poco habitual-, pues no encaja con ninguno de los dos típicos, por decirlo de algún modo, que esperamos en una aplicación web (request y sesión). De hecho, es una combinación de ambos: abarca a un conjunto de peticiones realizadas dentro de una misma sesión.

Su funcionamiento se observa con claridad tomando como ejemplo un formulario dividido en varios pasos. El usuario lo va rellenando de forma secuencial, o incluso volviendo hacia atrás si lo desea, y de este modo va generando sucesivas peticiones al servidor. Todas ellas conforman una misma conversación que finalizará tras el último paso del formulario. La sesión puede que siga abierta, pero ya no necesitamos el objeto en el que se han ido guardando los datos de la conversación, por lo que el contenedor de CDI debe descartarlo.

El uso de @ConversationScoped no es transparente porque somos los responsables de iniciar y terminar la conversación entre el cliente y nuestra aplicación. Esto lo hacemos con los métodos de la interfaz Conversation que inyectaremos en la clase que modela la conversación.

package com.danielme.jakartaee.cdi.scope;

import jakarta.enterprise.context.Conversation;
import jakarta.enterprise.context.ConversationScoped;
import jakarta.inject.Inject;

import java.io.Serializable;

@ConversationScoped
public class ConversationScopedDependency implements Serializable {

    private Conversation conversation;
    private int steps;

    public ConversationScopedDependency() {
    }

    @Inject
    public ConversationScopedDependency(Conversation conversation) {
        this.conversation = conversation;
    }

    String init() {
        conversation.begin();
        return conversation.getId();
    }

    int doStep() {
        return ++steps;
    }

    int end() {
        conversation.end();
        return steps;
    }

}

La clase ConversationScopedDependency representa de forma simplificada cómo se modela una conversación. Proporciona métodos para iniciarla y finalizarla, y uno que simula las acciones. Lo único que hará es contar el número de llamadas realizadas con el atributo steps.

Hagamos las pruebas pertinentes con un nuevo servlet que puede recibir los siguientes parámetros en la url para simular una conversación completa.

  • init. Inicia la conversación y devuelve su identificador.
  • (ningún parámetro). Invoca al método doStep de la conversación abierta. Se retorna el valor de steps.
  • close. Cierra la conversación y devuelve steps.
package com.danielme.jakartaee.cdi.scope;

import jakarta.inject.Inject;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.ws.rs.core.MediaType;

import java.io.IOException;
import java.io.PrintWriter;

@WebServlet(ConversationServlet.URL)
public class ConversationServlet extends HttpServlet {

    public static final String URL = "conversationServlet";

    private final ConversationScopedDependency conversationScoped;

    @Inject
    public ConversationServlet(ConversationScopedDependency conversationScoped) {
        this.conversationScoped = conversationScoped;
    }

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        PrintWriter printWriter = response.getWriter();
        response.setContentType(MediaType.TEXT_PLAIN);
        if (request.getParameter("init") != null) {
            printWriter.print(conversationScoped.init());
        } else if (request.getParameter("close") != null) {
            printWriter.print(conversationScoped.end());
        } else {
            printWriter.print(conversationScoped.doStep());
        }
    }

}

Así queda el ejemplo.

Para que todo funcione de la forma esperada, el contenedor de CDI necesita conocer el identificador único de la conversación, la cual, recordemos, está asociada a la sesión del usuario\cliente. En una aplicación web desarrollada con Jakarta Server Faces (JSF) esto se realiza de forma automática, pero con nuestro servlet debemos proveer el identificador de la conversación en un parámetro llamado cid. Se procesa automáticamente y no es necesario hacer nada con él.

Una simulación de conversación usando un navegador (las peticiones deben efectuarse dentro de la misma sesión) es la siguiente.

http://localhost:8080/cdi/conversationServlet?init -> 1
http://localhost:8080/cdi/conversationServlet?cid=1 -> 1
http://localhost:8080/cdi/conversationServlet?cid=1 -> 2
http://localhost:8080/cdi/conversationServlet?cid=1 -> 3
http://localhost:8080/cdi/conversationServlet?cid=1&end -> 3
http://localhost:8080/cdi/conversationServlet?cid=1->ERROR org.jboss.weld.contexts.NonexistentConversationException: WELD-000321: No conversation found to restore for id 1

Una vez cerrada la conversación, si se intenta hacer una comunicación con la misma se devuelve un error 500 indicando que no se ha podido encontrar. Idéntico fallo tendremos si solicitamos interactuar con una conversación cuyo identificador no existe.

@ConversationScope not found WELD-000321

La siguiente prueba -admito que resulta poco elegante- realiza una secuencia de llamadas similar.

package com.danielme.jakartaee.cdi;

import org.jboss.arquillian.container.test.api.Deployment;
import org.jboss.arquillian.junit5.ArquillianExtension;
import org.jboss.arquillian.test.api.ArquillianResource;
import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.spec.WebArchive;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import java.io.IOException;
import java.net.*;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

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

@ExtendWith(ArquillianExtension.class)
class ConversationServletArquillianTest {

    @ArquillianResource
    private URL contextPath;

    @Deployment(testable = false)
    public static WebArchive createDeployment() {
        return ShrinkWrap.create(WebArchive.class, "cdi-test.war")
                .addPackage(ConversationServlet.class.getPackage());
    }

    @Test
    @DisplayName("Simula una conversación")
    void testSessionScopeEqualServlet() throws IOException, InterruptedException {
        CookieHandler.setDefault(new CookieManager());
        HttpClient client = HttpClient.newBuilder()
                .cookieHandler(CookieHandler.getDefault())
                .build();

        HttpRequest requestInit = HttpRequest.newBuilder()
                .uri(URI.create(contextPath + ConversationServlet.URL + "?init="))
                .build();
        HttpResponse<String> response1 =
                client.send(requestInit, HttpResponse.BodyHandlers.ofString());
        String cid = response1.body();

        HttpRequest requestStep = HttpRequest.newBuilder()
                .uri(URI.create(contextPath + ConversationServlet.URL + "?cid=" + cid))
                .build();
        HttpResponse<String> responseStep =
                client.send(requestStep, HttpResponse.BodyHandlers.ofString());

        HttpRequest requestStop = HttpRequest.newBuilder()
                .uri(URI.create(contextPath + ConversationServlet.URL + "?cid=" + cid + "&amp;end"))
                .build();
        HttpResponse<String> responseStop =
                client.send(requestStop, HttpResponse.BodyHandlers.ofString());

        HttpRequest requestClosed = HttpRequest.newBuilder()
                .uri(URI.create(contextPath + ConversationServlet.URL + "?cid=" + cid))
                .build();
        HttpResponse<String> responseClosed =
                client.send(requestClosed, HttpResponse.BodyHandlers.ofString());

        assertThat(responseStep.body()).isEqualTo("1");
        assertThat(responseStop.body()).isEqualTo("1");
        assertThat(responseClosed.statusCode()).isEqualTo(HttpURLConnection.HTTP_INTERNAL_ERROR);
    }

}

Proxys y pseudo-scope

El contenedor de CDI no entrega una instancia directa de los objetos (es decir, la que creamos nosotros mismos con un new), sino que utiliza objetos de tipo Proxy. Esta técnica, definida como un patrón de diseño, permite enriquecer las clases con nuevas funcionalidades de manera transparente y es utilizada por marcos de trabajo y librerías tan populares como Spring o Hibernate. En CDI, el contenedor crear las clases con proxies para gestionar la serialización de los objetos según su ámbito y, sobre todo, conseguir integrar de forma poco menos que mágica clases de contextos distintos.

Tal vez el lector se sorprendiera cuando probamos la inyección de SessionScopedDependency en ScopesServlet. El servlet siempre es el mismo, y esto implica que su objeto sessionScoped también lo es. Pero si se llama a sessionScoped.getTimestamp(), en realidad estamos interactuando con el objeto vinculado a la sesión HTTP asociada a la petición (request) para la que el contenedor de servlets ha ejecutado ScopesServlet.

Lo anterior resulta desconcertante, aunque tiene una explicación sencilla: sessionScoped es un intermediario (proxy) capaz de recuperar el objeto de tipo SessionScopedDependency de la sesión con la que estemos tratando en cada momento. El mismo funcionamiento es aplicable a @RequestScoped y @ConversationScoped.

La siguiente imagen refleja este comportamiento.

Los objetos proxy son generados por Weld en tiempo de ejecución. Cuando programamos son tan transparentes que ni siquiera existen, y trabajamos asumiendo que serán los objetos «reales». Al depurar, veremos lo que el mago esconde en la chistera.

Weld Proxy object

La captura de pantalla, realizada en IntelliJ, muestra la inspección de un objeto de tipo SessionScopeDependency. Podemos apreciar que el proxy ha «enriquecido» a la clase con nuevos atributos que son utilizados por el contenedor de CDI.

Todo lo anterior es válido para los ámbitos que hemos estudiado porque su anotación está, valga la redundancia, anotada con @NormalScope. Pero hay dos, conocidos como seudoámbitos o pseudo-scope, que no están definidos de este modo. CDI, cuando tiene que inyectar objetos correspondientes a clases de esos ámbitos, utiliza una instancia «real» de los mismos, esto es, sin crear un proxy como intermediario.

@Dependent es uno de ellos. Cada vez que se solicita la inyección de una clase de tipo @Dependent, CDI devuelve una instancia nueva y directa. Este ámbito es importante conocerlo no solo por su posible utilidad, sino porque es el predeterminado para los CDI beans no anotados con su ámbito si en nuestra aplicación se ha configurado la opción bean-discovery-mode=»all» -y la clase no ha sido excluida en el fichero beans.xml o con @Vetoed-.

La característica anterior es confusa si también trabajamos con Spring. En este ecosistema de desarrollo se ha optado por lo contrario, y de forma predeterminada todas las clases son de tipo singleton. Lo más parecido a @Dependent es prototype.

El otro pseudo-scope es, precisamente, @Singleton (no confundir con la anotación jakarta.ejb.singleton de la especificación EJB). En este caso, también se inyecta una instancia «real» de la clase, pero, a diferencia de @Dependent, siempre es el mismo objeto. Por tanto, equivale a @ApplicationScoped sin el uso de un proxy. Esto provoca la pérdida de la gestión automática de la serialización, lo que a su vez puede causar que tengamos más de una instancia para una clase @Singleton como consecuencia de la serialización de los objetos que la contienen. Si queremos asegurar la unicidad de un @Singleton, debemos implementar su serialización, pero es mejor no complicarse la vida y usar @ApplicationScoped.

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.