JPA: les mappings @OneToOne et @ManyToOne, ou comment transformer une modèle relationnel en marasme de performance?

Introduction

JPA 2.1 permet de représenter les dépendances entre les entités et impose de charger les dites dépendances lorsqu’on lit une entité en base. Bien sûr, il est possible d’éviter ce comportement, sauf pour les relations @OneToOne et @ManyToOne. Et c’est là que les problèmes commencent.

Exemple de base

On va prendre un cas classique: la facturation. On a donc une entité “Facture” lié à l’entité “Client”. Là, c’est l’exemple bateau qu’on trouve partout. Donc ce petit exemple, on va ajouter que le client possède deux adresses (réception et facturation) et que chaque adresse est liée à une ville. Là, déjà, c’est moins ressemblant aux tutoriels qu’on trouve partout et on s’approche un peu plus de ce qu’on va trouver dans un projet d’entreprise.

MCD

On part donc avec ce MCD: MCD des enfers

Côté Java

Du côté Java, on doit écrire 4 entités (représentées uniquement avec leurs champs et mappings, j’occulte volontairement le reste).

Ville

    
1class EnferVille {
2    @Id private String nom;
3    @Id private String codePostal;
4    private String paysIso;
5}

Adresse

    
 1class EnferAdresse {
 2    @Id private Integer numAdr;
 3    private Integer numClient;
 4    
 5    private Boolean facturation; 
 6    private String nom;
 7    private String rue;
 8    private String complement;
 9    private String ville;
10    private String villeCP;
11    
12    @JoinColumns({
13        @JoinColumn(name="ville", referencedColumnName="nom"),
14        @JoinColumn(name="villeCP", referencedColumnName="codePostal")
15    })
16    @OneToOne(fetch = FetchType.LAZY)
17    private EnferVille enVille;
18}

Client

    
 1class EnferClient {
 2    @Id private Integer numClient;
 3        
 4    private String nom;
 5    private String prenom;
 6    private Boolean persMorale;
 7    private Integer numAdrLivraison;
 8    private Integer numAdrFacturation;
 9    
10    @JoinColumns({
11        @JoinColumn(name="numAdrLivraison", referencedColumnName="numAdr")
12    })
13    @OneToOne(fetch = FetchType.LAZY)
14    private EnferAdresse enAdrLivraison;
15    
16    @JoinColumns({
17        @JoinColumn(name="numAdrFacturation", referencedColumnName="numAdr")
18    })
19    @OneToOne(fetch = FetchType.LAZY)
20    private EnferAdresse enAdrFacturation;
21}

Facture

    
 1class EnferFacture {
 2    @Id private Integer numFacture;
 3        
 4    private Integer numClient;    
 5    @Temporal(type=Date) private Date dateFacture;
 6    private Double montantTotal;
 7    private Double montantTVA;
 8    
 9    @JoinColumns({
10        @JoinColumn(name="numClient", referencedColumnName="numClient")
11    })
12    @OneToOne(fetch = FetchType.LAZY)
13    private EnferClient enClient;
14}

Requête JPQL

De base, disons qu’on cherche toutes les factures du mois. On écrit donc cette requête JPQL: SELECT fa FROM EnferFacture fa WHERE fa.dateFacture >= ? AND fa.dateFacture <= ? ORDER BY fa.dateFacture ASC

Généricité dans la création des requêtes

Afin de ne pas écrire TOUTES les requêtes nécessaires, j’ai écrit un composant donc l’objectif est de produire la bonne requête JPQL en fonction de l’entité et des critéres de tri et filtres des écrans. Cela permet de standardiser les écrans puisque toutes les listes ont la même structure technique et côté backend, on trouve un seul contrôleur REST pour traiter toutes les listes. Mais si les avantages sont évidents, les problèmes posés sont plus difficiles à percevoir.

Le problème “N+1”

Ici, lorsqu’on lance la recherche, il se passe deux choses:

  1. La requête est transformé dans le dialecte de la base puis exécutée en base. JPA obtient la liste des résultats.
  2. JPA va appliquer un contrôle de cohérence aux données, son objectif est de vérifier si les clés étrangères (FK) sont satisfaites. C’est ici que le bât blesse: contrôler la cohérence d’un mapping “…ToOne” revient à trouver ce mapping en base! Du coup, les développeurs JPA conservent le mapping trouvé, pour éviter d’aller chercher à nouveau.

Dans notre cas, chaque facture trouvée provoque la recherche du client (d’où le nom N+1). Et les tutos que vous avez déjà lu ne vont pas plus loin. Mais dans notre exemple, JPA ne s’arrête pas au client! Le client étant lui-même lié deux adresses et chaque adresse à une ville, on se retrouve à voir passer 4 requêtes de plus, soit au total 5 pour chaque facture. Si la requête de base a retourné 100 factures, JPA provoque 500 “SELECT” supplémentaires!

Dans la vraie vie, ce comportement provoque des vrais problèmes de performance. Dans mon projet pro, l’entité principale repose sur un arbre de 80 dépendances, autant dire que même en limitant à 20 éléments par page, il ne faut pas être trop pressé!

Mitigation avec JOIN FETCH

Une première solution consiste à transformer la requête avec “JOIN FETCH”: SELECT fa FROM EnferFacture fa JOIN FETCH fa.enClient WHERE fa.dateFacture >= ? AND fa.dateFacture <= ? ORDER BY fa.dateFacture ASC

Lors de la préparation pour la base de données, JPQL va ajouter une clause “LEFT JOIN” vers la table des clients. Youpi! On s’évite le premier lot de “SELECT” pour trouver les clients. Sauf que, sauf que… Cela ne résoud pour le problème pour les adresses et les villes! Pour s’en sortir, il faudrait écrire:

    
1SELECT fa FROM EnferFacture fa 
2JOIN FETCH fa.enClient 
3JOIN FETCH fa.enClient.enAdrLivraison
4JOIN FETCH fa.enClient.enAdrLivraisonenVille
5JOIN FETCH fa.enClient.enAdrFacturation
6JOIN FETCH fa.enClient.enAdrFacturationenVille
7WHERE fa.dateFacture >= ? AND fa.dateFacture <= ? ORDER BY fa.dateFacture ASC 

A ce stade, on pourrait encore produire la requête de manière générique en inspectant toutes les propriétés et classes annotées @OneToOne et @ManyToOne, en partant de l’entité de base. Mais est-ce qu’on vraiment récupérer toutes les entités de l’arbre de dépendance? Je ne pense pas.

Solution avec “Select new”

Une autre solution consiste à écrire une @NamedQuery dont le “SELECT” est particulier: il contient le mot clé new et permet alors de créer entité partielle.

    
1SELECT new (fa.numFacture, fa.dateFacture, fa.montantTotal) FROM EnferFacture fa 
2WHERE fa.dateFacture >= ? AND fa.dateFacture <= ? ORDER BY fa.dateFacture ASC 

On doit alors ajouter le constructeur suivant dans la classe Facture:

    
1public EnferFacture(Integer numFacture, Date dateFacture, Double montantTotal) {
2    this.numFacture = numFacture;
3    this.dateFacture = dateFacture;
4    this.montantTotal = montantTotal;
5}

Ainsi on obtient des entités Facture sans aucun mapping. C’est top. On bien sûr aller plus loin, par exemple en chargeant le nom du client:

    
1SELECT new (fa.numFacture, fa.dateFacture, fa.montantTotal, cl.nom) FROM EnferFacture fa
2LEFT JOIN EnferClient cl ON cl.numClient = fa.numClient 
3WHERE fa.dateFacture >= ? AND fa.dateFacture <= ? ORDER BY fa.dateFacture ASC 

On modifie le constructeur suivant dans la classe Facture:

    
1public EnferFacture(Integer numFacture, Date dateFacture, Double montantTotal, String nomClient) {
2    this.numFacture = numFacture;
3    this.dateFacture = dateFacture;
4    this.montantTotal = montantTotal;
5    this.enClient = new EnferClient();
6    this.enClient.setNomClient(nomClient);
7}

Limites de cette solution

La première limite est qu’il faut écrire une requête par liste, y compris celles basées sur la même entité si on a besoin de mappings différents.

L’autre gros problème de cette solution est que la clause “WHERE” ne peut pas être modifiée! On doit exécuter la @NamedQuery en l’état. Dès lors, il est impossible d’appliquer des filtres aux colonnes ou de changer l’ordre de tri. Cette technique est bien pratique pour des calculs en backend puisqu’on connait les critères de recherche lors de l’écriture de la fonctionnalité. Pour alimenter un front-end, c’est totalement inexploitable. Dommage, le gain en performance est juste incroyable.

Pour l’expérience, j’ai essayé de prendre une @NamedQuery et de modifier sa clause “WHERE”. Techniquement ça fonctionne MAIS JPA exécute alors une requête JPQL classique et on se reprend le problème des mappings dans la figure.

Conclusion

JPA est pratique au quotidien, il offre des possibilités poussées mais ces fichues grappes de dépendances sont un enfer sans fin. Et j’ai l’impression, pour le cas qui m’occupe, qu’à part casser le modèle de données existant pour réduire le nombre de dépendances, il n’y a plus grand chose à faire, si ce n’est utiliser un cache évolué ou un base de données “In memory” (je suis en train de lorgner sur Apache Ignite…).