Curso Jakarta EE 9 (28). JPA con Hibernate (11): Relaciones: obtención eager\lazy, bytecode enhancement

logo Jakarta EE

En JPA, los atributos y relaciones que forman parte de una entidad se obtienen de la base de datos de dos maneras: en el momento en que la recuperemos (eager loading, carga inmediata o ansiosa) o bien cuando se accede a ellos por primera vez (lazy loading, carga perezosa), si es que esto llega a suceder.

>>>> ÍNDICE <<<<

Carga perezosa

La segunda técnica es un concepto de programación genérico. JPA lo propone -su funcionamiento no es el mismo en todas las implementaciones- para mejorar el rendimiento de la explotación de la base de datos siguiendo un principio que parece evidente (y que debemos seguir siempre): los datos que no necesitemos, mejor no obtenerlos. Tenemos que comprender el funcionamiento de este mecanismo en Hibernate para usarlo con responsabilidad. A ello está dedicado el presente capítulo.

Vamos a partir del ejemplo de las facturas del capítulo anterior.

@Entity
@Table(name = "invoices")
@Getter
@Setter
public class Invoice {
	
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToMany(mappedBy = "invoice", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<InvoiceItem> items;
@Entity
@Table(name = "invoices_items")
@Getter
@Setter
public class InvoiceItem {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@ManyToOne(fetch = FetchType.LAZY)
	private Invoice invoice;
}

Veamos un código de lo más inocente que oculta un pequeño secreto.

 @Test
 void testLazyOneToMany() {
    Invoice invoice = em.find(Invoice.class, Datasets.INVOICE_ID);
    log.info("loading...");
    assertThat(invoice.getItems()).hasSize(3);
 }

Echemos un vistazo a la bitácora.

13:16:14,296 INFO  [stdout] (default task-2) Hibernate: select invoice0_.id as id1_7_0_ from invoices invoice0_ where invoice0_.id=?
13:16:14,299 INFO  [com.danielme.jakartaee.jpa.EntityProxyTest] (default task-2) loading...
13:16:14,344 INFO  [stdout] (default task-2) Hibernate: select items0_.invoice_id as invoice_2_8_0_, items0_.id as id1_8_0_, items0_.id as id1_8_1_, items0_.invoice_id as invoice_2_8_1_ from invoices_items items0_ where items0_.invoice_id=?

La primera sentencia SELECT, correspondiente a la operación find, obtiene la factura pero no sus líneas. La petición de un dato de la lista items (getItems().size()) provoca la ejecución de una segunda SELECT que recupera la relación. Este comportamiento se debe a que, de forma predeterminada, las relaciones one-to-many (y many-to-many) son perezosas. Y todo gracias a que Hibernate utiliza implementaciones propias de las colecciones de Java que permiten esta funcionalidad mediante el uso de proxys. De este modo, nos ahorramos el tiempo y la memoria que consume la obtención de la relación desde la base de datos cuando no la necesitamos.

Es posible averiguar si una relación ha sido obtenida.

 @Test
 void testLazyOneToMany() {
    Invoice invoice = em.find(Invoice.class, Datasets.INVOICE_ID);
    PersistenceUnitUtil pUnitUtil = em.getEntityManagerFactory().getPersistenceUnitUtil();
    assertThat(pUnitUtil.isLoaded(invoice, "items")).isFalse();
    log.info("loading...");
    assertThat(invoice.getItems()).hasSize(3);
    assertThat(pUnitUtil.isLoaded(invoice, "items")).isTrue();
}

¿Y si en vez de una colección tenemos un solo objeto? Es el caso de InvoiceItem. Las relaciones many-to-one (y one-to-one) se cargan por omisión de forma inmediata, y hay que activar el modo lazy en su anotación con al atributo fetch.

@ManyToOne(fetch = FetchType.LAZY)
private Invoice invoice;
   @Test
    void testLazyManyToOne() {
        InvoiceItem invoiceItem = em.find(InvoiceItem.class, Datasets.INVOICE_ID_ITEM_1);
        log.info("loading...");
        assertThat(invoiceItem.getInvoice().getId()).isEqualTo(Datasets.INVOICE_ID);
        assertThat(invoiceItem.getInvoice()).isInstanceOf(HibernateProxy.class);
    }
13:37:42,368 INFO  [stdout] (default task-1) Hibernate: select invoiceite0_.id as id1_8_0_, invoiceite0_.invoice_id as invoice_2_8_0_ from invoices_items invoiceite0_ where invoiceite0_.id=?
13:37:42,397 INFO  [com.danielme.jakartaee.jpa.EntityProxyTest] (default task-1) loading...
13:37:42,398 INFO  [stdout] (default task-1) Hibernate: select invoice0_.id as id1_7_0_ from invoices invoice0_ where invoice0_.id=?

En esta ocasión, el atributo con la relación, no es una instancia «real» sino un objeto proxy o representante gestionado por Hibernate con la herramienta Byte Buddy. Sabremos en nuestro código si estamos en presencia de un proxy comprobando si implementa HibernateProxy.

  assertThat(invoiceItem.getInvoice()).isInstanceOf(HibernateProxy.class);

Ya vimos esta técnica cuando estudiamos CDI. El proxy, que siempre contiene al identificador de la entidad, mimetiza a un objeto de la clase real, pero incluye atributos y métodos inaccesibles desde el exterior para enriquecer a la clase con nuevas y asombrosas posibilidades. Podemos verificarlo con el depurador de cualquier IDE.

El objeto proxy intimida un poco, aunque no es algo de lo que tengamos preocupar porque su uso en el código es transparente. Si fuera necesario, podemos obtener el objeto «real» que contiene.

    @Test
    void testUnproxyHibernate() {
        InvoiceItem invoiceItem = em.find(InvoiceItem.class, Datasets.INVOICE_ID_ITEM_1);
        assertThat(invoiceItem.getInvoice()).isInstanceOf(HibernateProxy.class);
        Invoice invoiceUnproxied = (Invoice) Hibernate.unproxy(invoiceItem.getInvoice());
        assertThat(invoiceUnproxied).isNotInstanceOf(HibernateProxy.class);
    }

La carga perezosa solo está disponible en los objetos correspondientes a las entidades recuperadas de la base de datos por Hibernate, ya sea con los métodos del gestor de entidades o mediante consultas. No obstante, y esto es muy importante tenerlo claro, deben encontrarse en estado managed (pertenecientes a un contexto de persistencia). En cualquier otro caso (estado desligado o detached), los getters no devuelven null sino una de las excepciones más «célebres» de Hibernate. La podemos forzar de este modo pasando una factura de managed a detached antes de usar el listado de sus items.

 Invoice invoice = em.find(Invoice.class, Datasets.INVOICE_ID);
 em.detach(invoice);
 invoice.getItems().size();
org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.danielme.jakartaee.jpa.entities.cascade.Invoice.items, could not initialize proxy - no Session

El mensaje informa que la obtención de una relación perezosa no se ha realización dentro de una sesión de Hibernate, concepto que en la práctica equivale al de transacción.

    @Test
    void testLazyInitializationException() {
        Invoice invoice = em.find(Invoice.class, Datasets.INVOICE_ID);
        em.detach(invoice);

        assertThatExceptionOfType(LazyInitializationException.class)
                .isThrownBy(() -> invoice.getItems().size());
    }

Configuración de relaciones

Tal y como hemos visto, el tipo de obtención de las relaciones se establece con un valor del enumerado FetchType (EAGER o LAZY). Esta es la configuración predeterminada.

RelaciónModo
OneToManyLAZY
ManyToManyLAZY
OneToOneEAGER
ManyToOneEAGER

Ya lo comenté en la sección anterior: las relaciones múltiples (colecciones) se obtienen de forma perezosa, mientras que eager es el modo aplicado a las simples. Lo demuestro con más detalle en este tutorial, muy parecido al contenido del capítulo pero con otros ejemplos.

En teoría, todas las relaciones pueden configurarse a nuestro gusto. En la práctica, hay un par de casos en los que no es así.

La ilustración representa el ejemplo que usé para demostrar cómo crear relaciones one-to-one eficiente con @MapsId, compartiendo la clave primaria entre las dos entidades.

@Entity
@Table(name = "preferences")
@Getter
@Setter
public class Preferences {

	@Id
	private Long id;

	@OneToOne
	@MapsId
	private User user;

Al recuperarse la preferencia con find, también se obtiene su usuario porque one-to-one, de forma predeterminada, funciona en modo ansioso.

select preference0_.user_id as user_id3_9_0_, preference0_.enableNotifications as enableNo1_9_0_, preference0_.enableSharing as enableSh2_9_0_ 
from preferences preference0_ where preference0_.user_id=?

select user0_.id as id1_11_0_, user0_.name as name2_11_0_ from users user0_ where user0_.id=?

Configuremos la carga perezosa.

@OneToOne(fetch = FetchType.LAZY)
@MapsId
private User user;

Ahora, la siguiente prueba es válida y no se obtiene el usuario.

    @Test
    void testOneToOneUnidirectionalLazy() {
        Preferences preferences = em.find(Preferences.class, Datasets.USER_ID);

        assertThat(preferences.getUser()).isInstanceOf(HibernateProxy.class);
    }

De momento todo bien. Veamos una asociación bidireccional.

Marquemos los dos extremos con LAZY. No perdamos de vista que la clave ajena se ubica en Coupon.

public class Coupon { 
 @OneToOne(fetch = FetchType.LAZY)
 @JoinColumn(unique = true)
  private Expense expense;
public class Expense {
 @OneToOne(mappedBy = "expense", fetch = FetchType.LAZY)
  private Coupon coupon;

La obtención del cupón no recupera el gasto.

em.find(Coupon.class, 1L);
select
	coupon0_.id as id1_4_0_,
	coupon0_.amount as amount2_4_0_,
	coupon0_.expense_id as expense_5_4_0_,
	coupon0_.expiration as expirati3_4_0_,
	coupon0_.name as name4_4_0_
from
	coupons coupon0_
where
	coupon0_.id =?

Compárese con la SELECT generada si la carga fuera inmediata: se efectuaría la unión entre las tablas coupons e expenses. Esta unión es de tipo externa porque el gasto es opcional para el cupón.

 select
	coupon0_.id as id1_8_0_,
	coupon0_.amount as amount2_8_0_,
	coupon0_.expense_id as expense_5_8_0_,
	coupon0_.expiration as expirati3_8_0_,
	coupon0_.name as name4_8_0_,
	expense1_.id as id1_10_1_,
	expense1_.amount as amount2_10_1_,
	expense1_.category_id as category6_10_1_,
	expense1_.comments as comments3_10_1_,
	expense1_.concept as concept4_10_1_,
	expense1_.date as date5_10_1_
from
	coupons coupon0_
left outer join expenses expense1_ on
	coupon0_.expense_id = expense1_.id
where
	coupon0_.id =?

Todo correcto. Pero en el otro extremo el invento no funciona y se realizan dos consultas al ejecutarse find. La carga es, por tanto, inmediata.

em.find(Expense.class, 1L);
select expense0_.id as id1_6_0_, expense0_.amount as amount2_6_0_,...
select coupon0_.id as id1_4_0_, coupon0_.amount as amount2_4_0_, ...

Es razonable sentirse estafado, aunque hay un buen motivo para este comportamiento. Hibernate necesita saber si la relación existe para establecerla a null o bien crearle el proxy responsable de la carga perezosa. En el caso de la entidad Coupon, Hibernate sabe si tiene asociada un gasto simplemente comprobando la existencia de la clave ajena (columna expense_id), la cual conoce siempre porque recupera su valor con la SELECT que obtiene el cupón.

select 
    coupon0_.id as id1_4_0_,
	coupon0_.amount as amount2_4_0_,
	coupon0_.expense_id as expense_5_4_0_,

Dado que Expense no posee la clave ajena, la única forma que tiene Hibernate de saber qué gasto tiene un cupón es realizando una consulta a la base de datos. La consecuencia es la ejecución de una SELECT adicional a la que obtiene el gasto y que, como medida de optimización, se aprovecha para traer toda la entidad.

Una idea para evitar esto último es indicar que la relación no es opcional (*): si no puede ser nula, Hibernate debería iniciar el proxy sin tener que revisar la base de datos.

@OneToOne(mappedBy = "expense", fetch = FetchType.LAZY, optional = false)
private Coupon coupon;

(*) En nuestro ejemplo, no es válido dado que un gasto puede pagarse sin aplicar el descuento de un cupón.

Esto no funciona porque hemos pasado por alto un detalle fundamental: el proxy debe tener el identificador de la entidad que representa, así que la consulta adicional es inevitable y, con ella, la carga inmediata.

A pesar de sonar paradójico, es una pena que esta prueba funcione. El cupón del gasto no es un proxy.

    @Test
    void testOneToOneBidirectional() {
        Coupon coupon = em.find(Coupon.class, Datasets.COUPON_ID_SUPER);
        assertThat(coupon.getExpense()).isInstanceOf(HibernateProxy.class);

        Expense expense = em.find(Expense.class, Datasets.EXPENSE_ID_1);
        assertThat(expense.getCoupon()).isNotInstanceOf(HibernateProxy.class);
    }

En consecuencia, podemos afirmar que en Hibernate las relaciones one-to-one bidireccionales no pueden ser perezosas en el extremo en el que no tenemos la clave ajena (el que usa mappedBy). Pero no todo está perdido y podemos acudir a una de las siguientes alternativas.

  • Definir la relación como unidireccional. Es la solución más sencilla y recomendable, suponiendo que esta configuración nos sirva.
  • Aplicar la mejora de bytecode de Hibernate. Lo veremos al final del capítulo.
  • Reemplazar la relación por otra que sí admita carga perezosa. En nuestro ejemplo, convertiremos el one-to-one de Expense#coupon en one-to-many y pasaremos a tener una colección. Ocultaremos este “apaño” escribiendo el set y get apropiado para asegurar que la colección tenga un único elemento y desde fuera de la clase parezca que estemos ante una relación one-to-one.

En Expense hacemos estos cambios. Se ha desactivado la generación del getter y setter con lombok para la lista, no queremos que pueda manipularse.

public class Expense {
    
    @Getter(AccessLevel.NONE)
    @Setter(AccessLevel.NONE)
    @OneToMany(mappedBy = "expense")
    private List<Coupon> coupons = new ArrayList<>(1);

    public void setCoupon(Coupon coupon) {
        if (coupon == null) {
            coupons.clear();
        } else {
            coupons.set(0, coupon);
        }
     }

    public Coupon getCoupon() {
        return coupons.isEmpty() ? null : coupons.get(0);
    }

En Coupon, cambiamos el tipo de relación a many-to-one para que todo resulte coherente.

public class Coupon {    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(unique = true)
    private Expense expense;

Hemos conseguido crear una relación bidireccional perezosa en sus dos extremos que se comporta como un one-to-one cuando la usamos en el código.

Atributos simples

La anotación @Basic de JPA permite configurar el modo de carga de los atributos simples de una entidad, es decir, aquellos que no son relaciones. Parece extraño que no queramos disponer siempre de sus valores, pero pensemos en el siguiente caso: la entidad UserWithPicture tiene una foto del usuario

@Entity
@Table(name = "users_pic")
public class UserWithPicture {   
    @Lob
    private byte[] picture;

Dejando al margen el polémico debate de si se deben guardan ficheros en una tabla o bien almacenar su ubicación en el sistema de archivos, con la obtención inmediata tenemos un problema. Cada vez que recuperemos un usuario, el atributo picture contendrá su imagen y esta acción puede penalizar el rendimiento. Así pues, activamos siempre el modo lazy.

    @Basic(fetch=FetchType.LAZY)
    @Lob
    private byte[] picture;

Este es el otro caso en el que Hibernate no permite carga perezosa, a menos que usemos la mejora de bytecode. No obstante, podemos seguir la misma praxis de la sección anterior y diseñar una relación alternativa. Repetimos la jugada de User y Preference: una relación unidireccional one-to-one exclusiva en las que los objetos asociados en ambos extremos comparten la clave primaria. No necesitamos que sea bidireccional porque recuperaremos la foto de un usuario con el identificador del propio usuario.

@Entity
@Table(name = "pictures")
@Getter
@Setter
public class Picture {

    @Id
    private Long id;

    @Lob
    private byte[] picture;

    @OneToOne(fetch = FetchType.LAZY, optional = false)
    @MapsId
    private UserWithPicture user;

}
@Entity
@Table(name = "users_pic")
@Getter
@Setter
public class UserWithPicture {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

}

Ya no es un inconveniente que el binario se obtenga con carga inmediata porque el único propósito de la entidad Picture es recuperarlo.

UserWithPicture user = em.find(UserWithPicture.class, 1L);
byte[] pictureBinary = em.find(Picture.class, user.getId()).getPicture();

Mejora de bytecode (bytecode enhancement)

La mejora de bytecode (bytecode enhancement) es un mecanismo que incorpora nuevas capacidades a las entidades ya compiladas, esto es, modificando el bytecode de los .class en lugar de añadir código a la clase como hace lombok. Suena más complicado de lo que realmente es, pues todo se basa en aplicar un plugin a la fase de compilación en Maven.

Añadimos y configuramos el plugin del siguiente modo, habilitando el flag enableLazyInitialization. Necesitamos una versión de Hibernate compatible con el nombre de paquetes de Jakarta EE 9 (JPA 3.0), de lo contrario el plugin no hará nada. Son las dependencias con el sufijo -jakarta.

<plugin>
    <groupId>org.hibernate.orm.tooling</groupId>
    <artifactId>hibernate-enhance-maven-plugin</artifactId>
    <version>${hibernate.jakarta}</version>
    <executions>
        <execution>
            <goals>
                <goal>enhance</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <enableLazyInitialization>true</enableLazyInitialization>
    </configuration>
    <dependencies>
        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-core-jakarta</artifactId>
            <version>${hibernate.jakarta}</version>
        </dependency>
    </dependencies>
</plugin>

El plugin se ejecuta al compilarse el proyecto con Maven o con esta orden.

mvn hibernate-enhance:enhance

En IntelliJ, no se aplica cuando el IDE compila. Se puede lanzar desde la vista de Maven…

…o automáticamente cuando el proyecto se despliegue en el servidor, añadiendo la acción a Before launch en la configuración de ejecución.

Al «decompilar» el fichero Expense.class aparecerán unos métodos con nombres extraños que no estaban presentes en el código fuente.

   public Coupon getCoupon() {
      return this.$$_hibernate_read_coupon();
   }

   public void setCoupon(Coupon coupon) {
      this.$$_hibernate_write_coupon(coupon);
   } 

public Coupon $$_hibernate_read_coupon() {
      if (this.$$_hibernate_getInterceptor() != null) {
         this.coupon = (Coupon)this.$$_hibernate_getInterceptor().readObject(this, "coupon", this.coupon);
      }

      return this.coupon;
   }

   public void $$_hibernate_write_coupon(Coupon var1) {
      if (this.$$_hibernate_getInterceptor() != null) {
         this.coupon = (Coupon)this.$$_hibernate_getInterceptor().writeObject(this, "coupon", this.coupon, var1);
      } else {
         this.coupon = (Coupon)var1;
      }
   }

Este «hechizo» sobre los getters y setters habilita la carga perezosa en los dos casos en problemáticos: el lado sin clave ajena del one-to-one bidireccional y los atributos. Eso sí, hay que acompañar @OneToOne con @LazyToOne para indicar que no se use el mecanismo de proxy de Hibernate.

@OneToOne(mappedBy = "expense", fetch = FetchType.LAZY)
@LazyToOne(LazyToOneOption.NO_PROXY)
private Coupon coupon;

En versiones de Hibernate anteriores a la 5.5 (como la que incluye WildFly 22 Preview), publicada en junio de 2021, el empleo de bytecode enhancement tiene un impacto importante en todas las relaciones one-to-one y many-to-one de todas las entidades.

  • Las relaciones queramos obtener de forma perezosa, con independencia de que requieran la mejora de bytecode, deben anotarse con @LazyToOne, además de tener su fetch a LAZY. Si no se cumplen los dos requisitos, pasarán a ser de carga inmediata.
  • La primera vez que se solicite una relación de estos tipos en una entidad, se obtendrán a la vez todas sus relaciones simples y perezosas. Para evitarlo, tenemos que recurrir a la anotación LazyGroup y organizar en grupos aquellas de tipo lazy que deban obtenerse conjuntamente al mismo tiempo. Si queremos que se obtengan de una en una a medida que se soliciten con sus métodos get (lo más habitual), cada relación tiene que constituir un grupo propio.

En Expense, aplicaremos las dos soluciones así para que sus relaciones sean LAZY en este escenario.

 @ManyToOne(optional = false, fetch = FetchType.LAZY)
 @LazyToOne(LazyToOneOption.NO_PROXY)
 @LazyGroup("category")
 private Category category;

 @OneToOne(mappedBy = "expense", fetch = FetchType.LAZY)
 @LazyToOne(LazyToOneOption.NO_PROXY)
 @LazyGroup("coupon")
 private Coupon coupon;

¿Eager o Lazy?

Como regla general, usaremos carga perezosa para evitar traernos de las tablas datos innecesarios. También parece razonable y cómodo usar el modo ansioso cuando queramos que la entidad siempre contenga la relación. No obstante, esta última situación no será nada habitual, y aún así, tampoco es aconsejable el uso de relaciones EAGER por los efectos colaterales que pueden tener en la ejecución de consultas JPQL o con Criteria API, tal y como explicaré en un capítulo pendiente de publicar.

Con todo, la carga perezosa no es la panacea. Evitemos caer en el error de trabajar despreocupadamente con los getters, pues la consecuencia será un sistema con un desempeño pobre. Esto se debe a dos motivos.

  • La carga progresiva de datos en modo lazy invocando a getters va lanzando múltiples consultas que, probablemente, podrían ser reemplazadas por una sola que devuelva todo lo que vamos a necesitar.
  • Tal y como expliqué en su momento, si la relación es una colección grande cargarla entera con su get es una barbaridad. En este caso, recomendé no declarar la relación en su lado one-to-many.

El objetivo será obtener únicamente aquella información que necesitemos en cada proceso de negocio con el menor número de consultas SQL. Para conseguirlo, no podemos ser «perezosos»: hagamos el esfuerzo de escribir consultas que además sean eficientes. JPA provee los mecanismos adecuados, y a ellos estarán dedicados los próximos capítulos del curso.

Código de ejemplo

El código de ejemplo de los capítulos dedicados a las relaciones se encuentra en GitHub (todos los proyectos son independientes pero 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.