Hibernate Survival Guide Partie 1
Un article de ODcWiki.
| |
Ceci est la première pièce d’une série d’articles qui vise à donner les clés cachées d’Hibernate. Hibernate est en effet un framework de mapping objet-relationnel très populaire mais en fait, mal connu. Pour beaucoup de développeurs, son fonctionnement reste un mystère dangereux, source d’exceptions difficilement maîtrisables, générateur d’une activité SQL débordante. Il semble erratique, peu performant.
Cette image est fausse. C’est en fait un outil puissant et sûr. Mais comme toutes les belles mécaniques, il faut en connaître les principes fondamentaux pour l’utiliser correctement. Derrière une API simple et une configuration aisément accessible, se cache un moteur complexe que nous allons explorer.
Commençons par l’aspect le plus fondamental : la gestion interne des entités.
Sommaire |
Entités attachées et détachées
Toute session Hibernate dispose d’un cache d’entités, appelé cache de premier niveau. Ce cache est peu connu des développeurs qui utilisent Hibernate et pourtant son rôle est fondamental car toute action Hibernate passe par ce cache. C’est le monde « connu » pour la session :
- Toute entité chargée par la session est conservée dans le cache
- Toute modification apportée à une entité contenue dans le cache sera reportée dans la base de données chaque fois que la session tentera de se « synchroniser » avec elle.
La session Hibernate propose des API comme « save », « saveOrUpdate », « update » ou « delete » et l’on pourrait penser - bien naïvement – que ces API invoquent le moteur de la base de données pour lui transmettre des requêtes de type « Insert », « Update » ou « Delete ». Il n’en est rien. Ces méthodes se contentent (à l’exception de « save » et « saveOrUpdate » qui ont un comportement un peu particulier) d’indiquer au cache que l’entité ciblée doit être créée, mise à jour ou supprimée lors de la prochaine synchronisation. Mais pas avant.
Hibernate, en effet, n’applique de mises à jour sur la base de données que lors des synchronisations (exception faite de « save » et parfois « saveOrUpdate », cf. ci dessus). Avant la synchronisation , Hibernate se contente de noter les modifications à apporter.
Les synchronisations sont déclenchées par un appel à la méthode « flush() » ou lorsqu’une transaction est validée. C’est seulement à ce moment là que la session Hibernate consulte les entités de son cache et applique en base, les mises à jour demandées, dans l’ordre qui lui convient. Cet ordre ne repose évidemment pas sur l’ordre des appels qu’elle a reçu.
Notons que seules les entités qui font partie du cache de premier niveau sont concernées par les synchronisations. Les entités qui existent en dehors de ce cache sont ignorées. Les entités du cache sont appelées entités attachées. Les autres sont dites détachées.
Les entités attachées disposent de nombreuses facultés ignorées par les entités détachées. Par exemple : les liens non-initialisés (lazy loading) que l’on tente de traverser seront automatiquement résolus si l’entité est attachée. Si elle ne l’est pas, une exception sera levée. Autre avantage : toute mise à jour sera synchronisée. Inutile donc, d’appeler la méthode « Session.update(E) » après modification d’un attribut de « E » si « E » est une entité attachée.
Mais alors, à quoi sert « Session.update() » ? Son rôle est d’attacher une entité qui ne l’était pas en indiquant à la session Hibernate qu’elle contient des modifications qui devront être reportées en base de données lors de la prochaine synchronisation. Son rôle premier est donc d’attacher l’entité, c'est-à-dire, de l’introduire dans le cache. Il n’est pas encore question, à ce moment là, d’invoquer la base de données. C’est la raison pour laquelle « Session.update() » n’invoque pas SQL update.
API et cache de premier niveau
Expliquons maintenant ce que font les API de la session Hibernate, en fonction du cache :
- « Session.get(clazz, id) » recherche dans le cache l’entité de classe « clazz » et de clé primaire « id ». Si elle la trouve, elle est retournée à la fonction appelante sans aucune interaction avec la base de données. Sinon, la session interroge la base de données, construit une entité contenant les informations reçues, insère cette entité dans son cache de premier niveau, avant de la retourner à la méthode appelante.
- « Session.save(ett) » attache l’entité « ett » et émet un ordre SQL insert afin de pouvoir récupérer une valeur à affecter à la clé primaire de cette entité. Il faut donc noter que là aussi, l’action de cette méthode est double.
- « Session.update(ett) » attache l’entité « ett » en indiquant à la session que le contenu de cette entité a été modifié et donc, doit être reporté en base de données lors de la prochaine synchronisation. J’insiste : le rôle de cette méthode est donc d’abord d’attacher une entité.
- « Session.saveOrUpdat(ett) » se comporte comme « Session.save(ett) » si l’entité est reconnue comme nouvelle (sa clé primaire possède une valeur – généralement zéro ou nulle – indiquant qu’elle n’existe pas encore en base de donnée) et comme « Session.update(ett) » dans le cas contraire.
- « Session.delete(ett) » indique que l’entité « ett » - qui est déjà attachée – doit être supprimée lors de la prochaine synchronisation. Notons que l’entité continue à exister dans le cache et dans la base de données, tant que cette synchronisation n’a pas eu lieu.
- « Session.flush() » demande à la session de se synchroniser avec la base de données. Cette synchronisation faite, le contenu du cache est nettoyée : les entités supprimées disparaissent. Les entités créées ou mises à jour sont réputées synchrones.
- « Query.list() » interroge la base de données (toujours), mais pour chaque ligne retournée, regarde si l’entité correspondante existe déjà dans le cache. Si ce n’est pas le cas, une nouvelle entité est créée et insérée dans le cache de premier niveau. Dans le cas contraire, l’entité existante est éventuellement complétée.
Nous constatons que le cache est sollicité chaque fois.
Contraintes et vicissitudes
Ceci étant dit, est-ce si important, pour développer une application robuste, de comprendre comment Hibernate utilise ce cache ? La réponse est oui. Car ce cache impose deux principes très forts :
- Une entité fonctionnelle (le client « Dupont » de clé primaire « 1 ») ne peut être présente qu’en un seul exemplaire dans le cache. Cela signifie que pour une classe donnée et une valeur de clé primaire donnée, au plus un seul objet Java est référencé par le cache.
- Une entité attachée ne peut être reliée qu’à d’autres entités attachées. On ne peut mélanger entités attachées et détachées. Sinon, le comportement d’Hibernate devient erratique : parfois ce sont des exceptions qui sont émises, d’autres fois, certaines mises à jour ne sont pas effectuées, il est même possible que tout se passe bien …
Le cache garantit qu’une entité fonctionnelle n’existe qu’en un seul exemplaire en son sein. Ce qui est en soi très intéressant. Si vous tentez d’attacher une entité équivalente à une autre entité déjà présente dans le cache, cela se terminera mal : Hibernate lève une exception. Conjuguée au fait qu’une entité attachée ne peut référencer que d’autres entités attachées, cette faculté peut poser de sérieux problèmes de robustesse …
Prenons un exemple qui illustre le danger :
Soit une session Hibernate « S » dont le cache est vide. Et soient une grappe d’entités constituée d’un client « C » qui références à partie de sa collection « C.produits », deux produits « P1 » et « P2 ». « P1 » et « P2 » désignent leur type « TP1 » (« P1 » et « P2 » sont de même type). Seul le contenu de « C » a été modifié.
Cette grappe est détachée. Nous allons l’attacher à notre session. Pour cela nous utilisons la méthode « S.update(C) ; ». Notre objet « C » devient attaché (en précisant au passage qu’une requête SQL Insert doit être invoquée pour C). Mais « P1 », « P2 » ainsi que « TP1 » restent détachés. La seconde règle est donc violée : le comportement d’Hibernate devient aléatoire (fig1) …
Okay, nous pouvons faire aussi « S.update() » pour les autres objets de la grappe et résoudre ainsi le problème. Mais cette solution n’est pas vraiment satisfaisante pour deux raisons :
- La première est que cela induit l’émission de requêtes SQL Insert pour « P1 », « P2 » et « TP1 », qui, nous le savons, n’ont pas été modifiés. Exécuter un peu trop de requête SQL Select n’est pas forcément gênant. Pour une requête SQL Update c’est probablement plus ennuyeux …
- Il est souvent très difficile de savoir quels sont les objets présents dans la grappe. Si nous ne connaissons explicitement que « C », comment faire pour savoir si « P1 », « P2 » et « TP1 » sont effectivement là ? Les liens qui unissent ces objets à « C » peuvent être marqués comme « lazy » et ne pas être chargés. Or le seul fait de les tester provoque une exception en mode détaché (« LazyLoadingException »).
Heureusement, nous avons le « cascading ». Nous pouvons indiquer pour tous nos liens que l’opération SAVE_OR_UPDATE doit être cascadée. Notons que pour que notre application soit robuste, il est nécessaire que TOUTES les relations présentes dans nos entités soient cascadées. A défaut de quoi, il est possible que dans le fin fond d’un graphe d’entités quelque unes d’entre-elles nous échappent … On comprend qu’un tel mode de fonctionnement risque d’entrainer par effet de bord un flot de requêtes SQL Update absolument incontrôlé !
Mais le pire est à venir …
Notre grappe d’objets a été récupérée de session Hibernate précédentes, maintenant défuntes (les entités sont donc détachées). En fait, d’une première session nous avons récupéré « C », « P1 » et « TP1 ». De la seconde session nous avons récupéré « P2 » et à nouveau « TP1 ». Puis nous avons ajouté « P2 » à « C.produits ». Notre graphe d’entités est très similaire à celui présenté plus haut, une seule différence notable : le type de produit est représenté par deux objets Java distincts (un désigné par « P1 », l’autre par « P2 »)(fig 2)..
Maintenant, nous faisons « S.saveOrUpdate(C) ; ». « C » est d’abord attaché. En cascade, « P1 » est attaché. Ce qui entraîne l’attachement du premier « TP1 ». Puis « P2 » est attaché, et enfin, la session tente d’attacher le second « TP1 ». Et là, c’est le drame car une session Hibernate ne peut contenir qu’un seul exemplaire pour un objet fonctionnel donné !
« Bon, okay, okay » allez vous me dire, « le cascading n’est pas suffisant ». Il faut aussi que le graphe d’objets soit correct. Ce qu’il est possible d’assurer à l’aide d’une programmation rigoureuse. On n’est pas pourtant à l’abri des problèmes. Imaginons que notre session Hibernate ne soit pas vide et qu’elle contienne déjà un autre client « C2 » qui dispose du produit « P3 » qui est aussi de type « TP1 » : il sera impossible d’y insérer notre graphe détaché. Notons que si « P3 » était d’un autre type, rien de fâcheux ne se serait passé. D’où l’impression négative sur la robustesse d’Hibernate que le développeur non averti peut avoir : des fois, çà marche, d’autres fois, non …
Mais ne pourrait-on pas couper les liens entre « P1 » et « P2 » d’une part et « TP1 » d’autre part, afin d’éviter la collision avec un éventuel type de produit présent dans le cache de la session ? Cela peut paraître légitime, vu que seules les mises à jour sur le client nous intéressent. Hélas non. Si les liens partant de « P1 », « P2 » vers « TP1 » sont mis à « null », Hibernate va modifier les lignes correspondantes de la base de données et nos deux produits vont se retrouver dans type … (fig 3)
Il est donc nécessaire de s’assurer en plus que la session Hibernate est vide. Cela commence à faire vraiment beaucoup de conditions. D’autant plus que tout cela, ne l’oublions pas génère des requêtes SQL Insert à profusion. Clairement, ce mode de fonctionnement n’est pas pertinent.
Session.merge() pour s’en sortir
Sommes-nous dans l’impasse ? Heureusement, non : il y a « merge ». « S.merge(E) » est une méthode à priori compliquée. Son utilité ne paraît pas évidente, car son comportement est le suivant :
- Si « E » est déjà attachée, « merge » se contente de retourner immédiatement « E ». Elle ne fait donc rien.
- Si « E » n’est pas attachée, « merge » recherche dans le cache, l’entité fonctionnelle équivalente à « E ». Appelons « E’ » cette version attachée de « E ».
- Si « E’ » n’est pas trouvée, elle est créée dans le cache (pour une nouvelle entité) ou chargée dans le cache (pour une entité déjà présente en base de donnés).
- Le contenu de « E » est copié dans « E’ ».
- « merge » retourne « E’ ».
Juste une petite précision. Si « E » pointe vers « F », « merge » fera pointer « E’ » vers « F’ », « F’ » étant la version attachée de « F ».
Ce fonctionnement à l’air très complexe, et l’on peut être tenté de se dire : « mon besoin est simple, je n’ai probablement pas besoin de tout çà. Il y « Save » et « update », faciles à comprendre ». J’espère vous avoir convaincu que les effets de bord de « save », « update » et « saveOrUpdate » sont tels que leur utilisation est vraiment problématique. C’est bien le comportement de « merge » qui est le plus adapté, car plus sûr. Et à l’usage, il se révèle très simple. Explications.
« merge » permet de faire migrer de l’information d’une grappe d’objets détachés vers une grappe d’objets attachés sans faire migrer les objets eux-mêmes d’un environnement à l’autre. Ce qui protège les développements contre tout mélange entre entités attachées et détachées. Comme pour « update », un « merge » peut être cascadé. On peut donc facilement mettre à jour une grappe entière d’objets, mais sans risque cette fois. Pourquoi ? Parce que la condition d’unicité de l’entité dans le cache ne risque pas d’être violée : seules le contenu des entités sont transférées.
Notons que « merge » sait aussi bien faire des créations en base que de simples mises à jour. Comme pour « saveOrUpdate », les créations sont immédiates (une requête SQL insert est émise) et les mises à jours simples attendent la prochaine synchronisation.
Contrairement à « update », « merge » ne provoque pas de requêtes « SQL update » intempestives. En effet, les entités attachées impliquées par « merge » sont créées de toute pièce (pour matérialiser des créations) ou chargées depuis la base de données. « merge » peut donc savoir immédiatement si les mises à jour provenant de la grappe des objets détachés modifient réellement les entités. Ce système en revanche génère davantage de requêtes de type « SQL select ». En fait le nombre de requêtes émises par « merge » reste à peu près identique au nombre de requêtes émises par « saveOrUpdate ». Mais il s’agit de requêtes moins dangereuses et plus efficaces.
Afin d’illustrer l’adaptabilité et la robustesse de « merge », reprenons l’exemple précédant en nous mettant dans le cas le moins favorable :
Hors du cache, nous disposons d’une grappe d’objets regroupant le client « C1 », les produits « P1 » et « P2 » et le type « TP1 » matérialisé par deux objets Java, un lié à « P1 », l’autre à « P2 ».
Nous invoquons « merge » sur « C1 ». Le contenu de « C1 » est copié vers le cache. Si la version attachée de « C1 » déjà présente ou chargée pour l’occasion est identique à la version détachée, aucune mise à jour ne sera ensuite demandée. Il en va de même pour « P1 » et « P2 ». « TP1 » sera copié deux fois. Ce n’est pas idéal, mais c’est déjà mieux qu’une exception systématique.
Mais il y a mieux : nous ne sommes plus obligés de généraliser l’opération sur l’ensemble de la grappe. Nous pouvons par exemple ne pas procéder au « cascading » entre produits « P1 » et « P2 » d’une part et « TP1 » d’autre part. Ce qui est particulièrement indiqué si « TP1 » est un objet de référence qui n’évolue presque jamais.





