viernes, 29 de noviembre de 2013

JPA: Tips and tricks

Photo credit: imelenchon
“Good code is its own best documentation.”

(Steve McConnell)




Cualquiera que lleve varios años desarrollando aplicaciones empresariales Java habrá usado en mayor o menor medida algún ORM para la persistencia en bases de datos de nuestros objetos, dadas las ventajas que aporta al desarrollo y mantenimiento.

Las enormes ventajas de eficiencia, portabilidad, legibilidad y mantenimiento del código, además de la productividad que aporta evitando escribir fontanería manual de serialización/deserialización de datos a objetos lo hacen prácticamente imprescindible en cualquier proyecto que acceda a bases de datos. Sin embargo, me he encontrado con casos donde hay que usar elementos no portables de JPA o, directamente, no usar JPA en absoluto.  El rendimiento suele ser la razón fundamental y más frecuente, pero hay otras, como la legibilidad y mantenibilidad o simplemente, la sencillez. Son los siguientes casos:
  • Tratamiento o movimiento de datos masivo (grandes transacciones con operaciones de inserción, actualización o borrado). En estos casos, realizarlo a través de los objetos o vía JPQL no es eficiente. Es mejor realizar consultas nativas (NativeQuery) o funciones/procedimientos almacenados.
  • JPQL ineficiente. Hay casos donde, por ejemplo, un OUTER JOIN con JPQL no realiza lo que queremos. Además, no existe (al menos yo no conozco ninguno, se agradecen aportaciones ;-) ningún cliente JPQL que nos permita probar y testar consultas JPQL con lo que realizar consultas complejas en JPQL es una tarea tremendamente ardua y pesada. En estos casos, las consultas se crean y prueban en SQL nativo y se implementan como tales.
  • Presentación de datos tabulares estadísticos o calculados con sumatorios, agregados, agrupados, etc.... Hay veces que se necesita una tabla (normalmente de cálculo) producto de una consulta compleja que no coincide con nuestro modelo de datos. En estos casos la "fontanería" con objetos es ilegible o directamente intratable.
No hay que complicarse en exceso y usar el principio KISS. Si el problema se puede solucionar de forma sencilla, elegante y eficiente usando SQL nativo o funciones almacenadas, el "purismo" de la portabilidad no puede llevarse al extremo de realizar una aplicación ineficiente o inmantenible.

Dicho esto, mi recomendación es usar JPA por defecto en cualquier proyecto que trabaje con bases de datos y plantearse usar SQL nativo o funciones almacenadas exclusivamente en los casos estrictamente necesarios en los que el rendimiento, la mantenibilidad, legibilidad o simpleza así lo aconsejen.

En mi experiencia usando JPA 1.0 (Toplink Essentials) y JPA 2.0 (EclipseLink) en diversos proyectos he recopilado los siguientes consejos prácticos, trucos y advertencias que suelo tener en cuenta y que voy a compartir aquí:

1.- NamedQuerys en fichero orm.xml (y no como anotaciones en las entidades)

Aunque la especificación JPA enfatiza el uso de anotaciones, puedes usar el fichero de mapeo JPA orm.xml para almacenar los metadatos. Aunque el uso de anotaciones o descriptores XML es una cuestión de gustos, considero especialmente útil usar descriptores XML cuando nos apoyamos en wizards y herramientas de generación de código (como Dali), ya que éstas suelen sobreescribir nuestros ficheros de entidad perdiendo así todas nuestras NamedQuerys. Almacenar las NamedQuery en el fichero orm.xml nos permitirá no perderlas cuando Dali o cualquier otro generador de código sobreescriba nuestras entidades.

Es decir, en lugar de ésto en nuestras entidades:
@NamedQueries( {
    @NamedQuery(name = "Profile.findAll", 
              query = "SELECT o FROM Profile o ORDER BY o.name"),
    @NamedQuery(name = "Profile.findByName", 
               query = "SELECT o FROM Profile o WHERE UPPER(o.name) LIKE :name ORDER BY o.name")
})

Escribirlo en el fichero orm.xml:

  
    
      PROPERTY
    
  
  
      <![CDATA[SELECT o FROM Tag o INNER JOIN o.tagProfiles tp 
                         WHERE tp.profile <> :profile ORDER BY o.name]]>
  
  
      <![CDATA[SELECT o FROM Profile o ORDER BY o.name]]>
  
  
      <![CDATA[SELECT o FROM Profile o WHERE UPPER(o.name) LIKE :name ORDER BY o.name]]>
  


2.- Obtener objetos manejados y sincronizados

Es posible que te hayas encontrado en algún momento con errores del tipo:

java.lang.IllegalArgumentException: Entity must be managed to call remove: __MiObjeto__, try merging the detached and try the remove again.

La caché de entidades de JPA es manejada por el framework internamente. Es posible que un entity de tu aplicación, que recuperaste vía em.find() o JPQL, ya no esté en dicha caché porque haya sido reciclado (aunque en tu código apenas "hayan pasado" un par de llamadas de métodos o un par de líneas de código ;-). En estos casos es cuando te encuentras con el primer error, por ejemplo, si llamas a EntityManager.remove() de un objeto "detached".

Una solución que se te puede ocurrir es usar EntityManager.refresh() para "refrescar" el objeto y sincronizarlo con la base de datos, es decir: para hacerlo "manejado" nuevamente... Pero te puedes encontrar con otro error:

refresh() cannot be called on a detached entity


Para asegurarte que tienes un objeto "manejado" y, en cualquier caso, para estar seguro que puedes modificar un objeto sincronizado con la base de datos, una la combinación de find() y refresh() siguiente:

Usuario e = em.find(Usuario.class, id); 
try {
  em.refresh(e); 
} catch( EntityNotFoundException ex ) { 
  e = null; 
}

3.- Uso de callbacks. El caso especial de la anotación @PreUpdate

Las anotaciones @Pre* y @Post* para callbacks methods tienen una utilidad ciertamente limitada. De hecho, no hay más que leer la sección 3.5 de la especificación JPA:
“In general, the lifecycle method of a portable application should not invoke EntityManager or Query operations, access other entity instances, or modify relationships within the same persistence context. A lifecycle callback method may modify the non-relationship state of the entity on which it is invoked.”

Existen razones técnicas de peso para lo anterior (por ejemplo, evitar bucles infinitos que pueden dar al traste con una aplicación), pero desde el punto de vista del desarrollo está muy lejos de constituir un equivalente a los disparadores en la base de datos, ya que sólo podemos trabajar con la propia entidad, e incluso, con restricciones, como ahora veremos.

La anotación @PrePersist nos permite establecer un estado en una entidad antes de que ésta sea persistida. ¿Funciona igual @PreUpdate? Vamos a verlo.

Imaginemos que tenemos una entidad con un campo campoDato que, cuando se modifica, debe tener su fecha correspondiente de modificación en su campo aparejado fechaCampoDato. Para olvidarnos de éste último, podríamos tener la tentación de hacer algo como esto:

@PrePersist
@PreUpdate
public void preUpdate() {
    if ( campoDato != null) {
        // Si tiene campoDato, hay que garantizar su fecha también
        if ( fechaCampoDato == null) {
            fechaCampoDato = new Date();
        }
    }
}

Sin embargo, no funciona (al menos en EclipseLink) ¿Por qué? Porque para la verificación de modificación ("dirty check"), los campos modificados son detectados antes de llamar al método @PreUpdate y, por tanto, los cambios realizados en el método @PreUpdate no son detectados. Nuevamente, esto está realizado así por razones de rendimiento debido a que las verificaciones de modificación son muy costosas.

En definitiva, los métodos callback no son la panacea en lo referente a la gestión del ciclo de vida de nuestras entidades.

4.- ¿JPQL demasiado lenta?

Cuando tenemos un modelo complejo con muchas relaciones, algunas ejecuciones de JPQL en el que muchas entidades se vean involucradas (muchos JOIN) pueden resultar inaceptablemente lentas. Especialmente cuando nos percatamos que la consulta nativa SQL equivalente apenas lleva unos pocos milisegundos. En estos casos, optamos por realizar una consulta nativa SQL ( createNativeQuery(java.lang.String sqlString,java.lang.Class resultClass) ) obteniendo también un rendimiento lamentable, cuando la misma consulta SQL se ejecuta centenas de veces más rápido en nuestro cliente SQL. ¿Qué está pasando? ¿Cómo solucionarlo?

En estos casos el problema no es la consulta: es el propio JPA, o más concretamente, la resolución de entidades de JPA. Cuando se usan NamedQuery o entity-mapped-nativequery (es decir, nativas usando mapping "automático" a la entidad de retorno), JPA realiza la consulta y mapeo de las entidades devueltas, resolviendo en éstas las relaciones involucradas en la consulta. Es decir, que si tengo una entidad con varias relaciones, resuelve las listas de estas relaciones, generando una cantidad de consultas enorme y consumiendo, por tanto, muchísimo tiempo.

La solución para estos casos es simple: realizar una consulta nativa que devuelva los identificadores de las entidades en lugar de las entidades, para luego resolver las entidades manualmente. Por ejemplo:

public List<User> getUserByProfile(Integer profileId) {

    List<Vector> rows = null;
    List<User> users = new ArrayList<User>();
    String query;
    try {
        query = "select distinct t1.id " +
            "  from t1,t2,t3,t4,t5" +
            " where t1.f1_id = t2.id" +
            "   and t2.f2_id = t3.id" +
            "   and t4.f4_id = t5.id" +
            "   and t5.id = " +
            "   and t2.end_date is null" +
            "   and exists (select * from a " + 
            "       where date is null and h = t5.id)" + 
            "   and t5.id = "+ profileId;

        Query q = em.createNativeQuery(query);
        log.debug("query {}", query);
        rows = q.getResultList();
        for ( Vector row : rows ) {
            users.add(em.find(User.class, (User)row.get(0)) );
        }
        return users;
    } catch (Exception e) {
        log.error("Excepcion ", e);
    }
}

Podrás comprobar que la solución anterior, aún siendo poco "bonita" y  más tediosa, es centenares de veces más rápida que dejar a JPA que resuelva las entidades. La razón es muy sencilla: con esta forma, no se resuelven innecesariamente las relaciones de las tablas en la consulta.

4.- @PrivateOwned o tratamiento de huérfanos

Es frecuente confundir el atributo cascade y pensar que éste se encarga de todo. Pero no es así. Si tenemos una relación @OneToMany(cascade=ALL) y borramos el padre, sus hijos también se eliminarán. Pero, ¿y si queremos borrar alguna entidad hija? Si tenemos un objeto A que tiene una lista de objetos B hijos y borramos el segundo, de la lista (usando remove()), una llamada a merge() no eliminará el objeto dereferenciado. ¿Por qué? Porque el objeto sigue siendo una entidad que no ha sido explícitamente eliminada. Hemos borrado una referencia a él, pero no la única y, en todo caso, el objeto sigue manteniendo la referencia a su padre.

Muchas veces nos encontramos la definición del atributo @PrivateOwned así: "Use @PrivateOwned to specify that a relationship is privately owned"... lo cual obviamente, no aclara mucho. Lo voy a explicar aquí un poco más claro: @PrivateOwned implica que los hijos pertenecientes a la lista no deben existir sin el padre, con lo que, al quedar dereferenciados, deberán ser eliminados. Podrás usar este atributo para buena parte de las relaciones @OneToMany cuyos hijos tengan una dependencia funcional absoluta del padre y en las que normalmente trabajarás con el objeto padre como una unidad única y no con los hijos de forma independiente. Es decir, en aquellas donde un objeto hijo "huérfano" no tiene sentido.

@OneToMany(mappedBy = "padre", cascade={CascadeType.ALL})
@PrivateOwned
private List<Hijo> hijos;

Con JPA 1.0, los objetos hijos "huérfanos" deben eliminarse explícitamente con em.remove() ya que este atributo sólo está disponible para JPA 2.0.

5.- Logging

Durante el ciclo de desarrollo, es una buena idea activar el logging de las sentencias SQL que nuestro proveedor de JPA realiza en tiempo de ejecución, para tener visibilidad de qué está ocurriendo en cada momento. En EclipseLink, se configura a través de una propiedad en el fichero persistence.xml.

<properties>
   <property name="eclipselink.logging.level" value="FINE"/>
</properties>


6.- JPA Caching

La caché de nivel 2, L2 o shared cache, es una caché más allá del EntityManager: es una caché global para toda la unidad de persistencia (PersistenceUnit). En general, la caché es un elemento importante y complejo cuya correcta configuración puede tener un significativo impacto en nuestra aplicación. Explicar estos detalles excede el alcance de este artículo y hay mucha información ya publicada al respecto. No obstante, expondré aquí mi experiencia con TopLink (JPA 1.0) y EclipseLink (JPA 2.0)

JPA 1.0

Mi experiencia con Toplink Essentials es que es conveniente desactivar la shared cache, salvo que la base de datos no tenga modificaciones externas simultáneamente a nuestra aplicación (otras aplicaciones, etc), lo cual no suele ocurrir (en mi caso, nunca). Así que, como norma general, la opción habitual es configurarlo en el persistence.xml:

<properties>
   <property name="toplink.cache.shared.default" value="false"/>
</properties>  

 JPA 2.0

EclipseLink ofrece mayores niveles de configuración y ajuste que permiten una configuración más fina y granulada, aunque aún me quedan por probar el comportamiento en producción de muchas opciones. De momento, una buena opción es desactivar la shared cache para todas las entidades y permitir sobreescribir esta configuración para entidades concretas vía orm.xml o anotación @Cacheable en función de nuestra aplicación.

<properties>
   <shared-cache-mode>ENABLE_SELECTIVE</shared-cache-mode>
</properties>



Referencias y más información:

1 comentario :

  1. Muy buena la aportación, mucha gracias
    lo pondre en practica por que ahorita si tengo muchos dolores de cabeza con JPA 2

    ResponderEliminar

Related Posts Plugin for WordPress, Blogger...
cookieassistant.com