Dozer + Lazy loading = performances non accrues

JPA et Dozer sont deux frameworks JEE répandus, mais leur utilisation
conjointe peut s’avérer risquée. En effet, dans une architecture par
couches, une utilisation inappropriée de Dozer sur les entités JPA
peut être la source d’un nombre exponentiel de requêtes inutiles. Nous
allons expliquer plus en détail le mécanisme, avant de voir comment
contourner le problème.

Dozer

Dozer est un framework de mapping d’objets se basant sur un fichier de configuration XML. Il est bien pratique pour éviter de mapper « à la main » des beans par le biais d’un long code fastidieux à écrire. Son mécanisme est assez simple. Il copie dans l’objet cible le contenu des attributs de l’objet source s’ils ont le même nom. Exemple de configuration XML :

    <mapping>
        <class-a>com.cwse.erp.data.domain.Entreprise</class-a>
        <class-b>com.cwse.erp.business.beans.EntrepriseBean</class-b>
    </mapping>

Exemples d’appels de l’API Dozer :

        EntrepriseBean entreprise =
                dozerMapper.map( entrepriseDAO.getEntityById(1), EntrepriseBean.class);

Je vous invite à suivre ce petit tutoriel si vous souhaitez approfondir sur Dozer, car ce n’est pas l’objet central de ce billet :
https://www.sodifrance.fr/blog/index.php/post/2011/12/13/Dozer-tutorial-%3A-int%C3%A9gration-Spring-et-premiers-mappings.

Notons simplement pour la suite de cet article que l’API Dozer fait des appels automatiques et sous-jacents aux getters et setters des classes mappées.

Le Lazy Loading (exemple avec JPA)

Le LAZY Loading permet de faire instancier les objets persistants JPA au besoin seulement. Les attributs mappant des relations paramétrées en LAZY ne font l’objet de requêtes SQL qu'après un appel de getter. Le but étant de maitriser le nombre de requêtes et améliorer les performances.

Prenons le cas d’un objet Entreprise, encapsulant un Set d’objets Equipe.

@Entity
@Table(name = "entreprise")
public class Entreprise {
    @Id
    @GeneratedValue
    @Column(name = "id_entreprise")
    private int id;

    @OneToMany(mappedBy = "entreprise")
    private Set<Equipe> equipes; // LAZY

    public int getId() {
        return id;
    }
    public void setId(int id) {
        this.id = id;
    }
    public Set<Equipe> getEquipes() {
        return equipes;
    }
    public void setEquipes(Set<Equipe> equipes) {
        this.equipes = equipes;
    }
}

Au chargement de l’entité entreprise, la collection Entreprise.equipes ne sera pas alimentée ;  cela ne sera fait qu'à  l'appel de entreprise.getEquipes(). JPA va alors générer les requêtes nécessaires pour remplir cette collection.

Par défaut, les relations OneToMany et ManyToMany (qui sont mappées par un attribut d’un type Set ou List) sont LAZY. Les relations MAnyToOne (non mappées par un Set ou une List, mais par un objet) sont EAGER.

Interactions entre JPA et Dozer

L’utilisation de Dozer sur un objet JPA va provoquer la génération de requêtes supplémentaires si des attributs mappant une relation LAZY sont utilisés durant le mapping. D’une part l’effet du LAZY loading est donc annulé, d’autre part le nombre de requêtes effectuées pendant le mapping peut vite .... exploser.

Reprenons l’exemple ci-dessus : Entreprise, Equipe, et ajoutons dans le modèle un objet Projet. Une entreprise encapsule un Set d’équipe, et une équipe encapsule un set de projets.

@Entity
@Table(name = "equipe")
public class Equipe {

    @Id
    @GeneratedValue
    @Column(name = "id_equipe")
    private int id;

    @ManyToOne
    @JoinColumn(name = "id_entreprise")
    private Entreprise entreprise;

    @OneToMany(mappedBy = "equipe")    // LAZY
    private Set<Projet> projets;

    public int getId() {
        return id;
    }
    public void setId(int id) {
        this.id = id;
    }
    public Entreprise getEntreprise() {
        return entreprise;
    }
    public void setEntreprise(Entreprise entreprise) {
        this.entreprise = entreprise;
    }
    public Set<Projet> getProjets() {
        return projets;
    }
    public void setProjets(Set<Projet> projets) {
        this.projets = projets;
    }
}

@Entity
@Table(name = "projet")
public class Projet {

    @Id
    @GeneratedValue
    @Column(name = "id_projet")
    private int id;

    @ManyToOne
    @JoinColumn(name = "id_equipe")
    private Equipe equipe;
   
    public int getId() {
        return id;
    }
    public void setId(int id) {
        this.id = id;
    }
    public Equipe getEquipe() {
        return equipe;
    }
    public void setEquipe(Equipe equipe) {
        this.equipe = equipe;
    }
}

Supposons que Entreprise, Equipe et Projet soient mappées par Dozer sur trois classes EntrepriseBean, EquipeBean et ProjetBean.

    <mapping>
        <class-a>com.cwse.erp.data.domain.Entreprise</class-a>
        <class-b>com.cwse.erp.business.beans.EntrepriseBean</class-b>
    </mapping>
   
    <mapping>
        <class-a>com.cwse.erp.data.domain.Equipe</class-a>
        <class-b>com.cwse.erp.business.beans.EquipeBean</class-b>
    </mapping>
   
    <mapping>
        <class-a>com.cwse.erp.data.domain.Projet</class-a>
        <class-b>com.cwse.erp.business.beans.ProjetBean</class-b>
    </mapping>

Essayons de récupérer une Entité de type Entreprise et de le mapper par Dozer dans un objet EntrepriseBean.; nous ne voulons que l’entreprise, sans ses équipes et ses projets.

        EntrepriseBean entreprise =
                dozerMapper.map( entrepriseDAO.getEntityById(1), EntrepriseBean.class);

Voici le résultat de la ligne de code ci-dessus :
Nous avons tout d’abord la requête pour obtenir l’entreprise d’id 1, générée suite à l’appel de entrepriseDAO.getEntityById(1). Jusqu’ici tout est normal.  Mais la requête suivante va être générée par l’appel de entreprise.getEquipes() par Dozer :

select equipes0_.id_entreprise as id2_19_1_, equipes0_.id_equipe as id1_1_, equipes0_.id_equipe as id1_36_0_, equipes0_.id_entreprise as id2_36_0_ from equipe equipes0_ where equipes0_.id_entreprise=?

Ensuite d’autres requêtes seront générées pour obtenir les projets. En effet, Dozer mappe le contenu de la collection equipe.projets de chaque équipe.

select projets0_.id_equipe as id2_36_1_, projets0_.id_projet as id1_1_, projets0_.id_projet as id1_33_0_, projets0_.id_equipe as id2_33_0_ from projet projets0_ where projets0_.id_equipe=?

select projets0_.id_equipe as id2_36_1_, projets0_.id_projet as
id1_1_, projets0_.id_projet as id1_33_0_, projets0_.id_equipe as
id2_33_0_ from projet projets0_ where projets0_.id_equipe=?

...

Résultat :  Nous voulions simplement obtenir un entrepriseBean simple.Une seule requête sur la table entreprise était nécessaire, mais le mapping de Dozer a activé le mécanisme du Lazy Loading , ce qui a provoqué des requêtes supplémentaires pour obtenir les équipes et les projets.

Il ne s’agit ici que d’un petit exemple, mais sur une application avec beaucoup de données, le nombre de requêtes supplémentaires peut vite devenir énorme. De plus, au niveau de la JVM, des objets JPA peuvent donc être instanciés en nombre, que le garbage collector va tenter de supprimer lorsqu’ils ne seront plus référencés. Il se peut alors que cette exception se produise, si le nombre de requêtes est très important et que le Garbage Collector n’arrive pas à suivre.

java.lang.OutOfMemoryError: GC overhead limit exceeded

Cette Exception montre une saturation du GC. Elle se produit lorsque que 98% du temps est passé par la JVM à supprimer des instances non utilisées, tout en ne supprimant que 2% de celles-ci.

Paramétrage de Dozer

Ce problème peut être pallié une configuration Dozer adéquate, en ajoutant un field-exclude sur les champs à ne pas mapper.

    <mapping map-id="entreprise_basic">
        <class-a>com.cwse.erp.data.domain.Entreprise</class-a>
        <class-b>com.cwse.erp.business.beans.EntrepriseBean</class-b>
        <field-exclude>
            <a>equipes</a>
            <b>equipes</b>
        </field-exclude>
    </mapping>

Dans cet exemple, le mapping réalisé entre Equipe et EquipeBean ne tiendra pas compte de l’attribut projets. Aucun appel Equipe.getProjets() n’est donc fait par Dozer , et JPA ne génère pas de requêtes supplémentaires pour obtenir les projets.

Bien sûr, il peut y avoir des cas où l’on a besoin d’un bean entrepriseBean avec le set equipes, et des cas où l’on ne souhaite qu’un bean entrepriseBean sans le set Equipe. Pour répondre à ce besoin, il est possible de définir plusieurs stratégies de mapping Dozer sur une même classe. Par exemple :

    <mapping map-id="entreprise_basic">
        <class-a>com.cwse.erp.data.domain.Entreprise</class-a>
        <class-b>com.cwse.erp.business.beans.EntrepriseBean</class-b>
        <field-exclude>
            <a>equipes</a>
            <b>equipes</b>
        </field-exclude>
    </mapping>
   
    <mapping map-id="entreprise_equipe">
        <class-a>com.cwse.erp.data.domain.Entreprise</class-a>
        <class-b>com.cwse.erp.business.beans.EntrepriseBean</class-b>
        <field map-id="equipe_basic">
            <a>equipes</a>
            <b>equipes</b>
        </field>
    </mapping>
   
    <mapping map-id="equipe_basic">
        <class-a>com.cwse.erp.data.domain.Equipe</class-a>
        <class-b>com.cwse.erp.business.beans.EquipeBean</class-b>
        <field-exclude>
            <a>projets</a>
            <b>projets</b>
        </field-exclude>
    </mapping>

L’utilisation du mapping Dozer entreprise_basic ne fournira qu’un EntrepriseBean, sans le Set d’équipes :

        dozerMapper.map( entrepriseDAO.getEntityById(2),
                EntrepriseBean.class, "entreprise_basic");

L’utilisation du mapping Dozer entreprise_equipe fournira un EntrepriseBean avec son Set d’équipes.

        dozerMapper.map( entrepriseDAO.getEntityById(3),
                EntrepriseBean.class, "entreprise_equipe");

Conclusion

Nous venons de voir que l’utilisation de Dozer conjointement à celle de JPA/Hibernate, pouvait devenir vite problématique et nuire grandement aux performances d’une application si elle n’est pas maitrisée. Toutefois, une bonne configuration de Dozer permet de remédier au problème, au prix d’un fichier de configuration Dozer verbeux. Est-ce plus long et fastidieux que mapper ses objets un par un à la main ? A vous de voir.

2 commentaires

  1. Merci pour ce billet,
    Ceci rejoint notre conclusion sur l’un de nos projets.
    Ceci dit, ce n’est ni Dozer ni l’association des deux framework qui produisait cette dégradation de perfs, jusqu’au fameux OOM, mais une mauvaise conception. Et pour remédier à cela, il y a la méthode mapperDozerBean.map(object, clazz, « lazyMap ») dont le dernier argument est un mapId qui permet de spécifier exactement quelles sont les propriétés à mapper. On peut donc imaginer une config avec un mapId= »lazy » et un autre « full » ..etc

  2. Merci pour cet article intéressant. On peut constater qu’une mauvaise conception de l’appli et une association Dozer-Hibernate non maitrisée nuit gravement aux performances!

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Captcha *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.