jeudi 7 juillet 2011

Hibernate: flush et dirty checking

Avec Hibernate, lorsqu'une entité (attachée) est manipulée, toute modification qui lui est apportée est supposée être reportée dans la base de données.

Néanmoins, afin d'éviter des "update" permanents, Hibernate retarde le plus possible cette mise à jour en utilisant la session comme cache. A certains moments, la session sera synchronisée avec la base de données (ce qu'on appelle le "flush"). Hibernate vérifiera si les entités attachées ont subi des modifications avant de lancer les updates (ce qu'on appelle le "dirty checking").

L'article qui suit aborde le fonctionnement du dirty checking et montre à quelles occasions un flush est effectué.

Pour illustrer tout cela, je vais définir un jeu d'entités. La version d'Hibernate utilisée est 3.3.0.SP1. Le fichier hibernate.cfg.xml est configuré de manière à afficher le SQL généré.

Les entités


Voici les classes utilisées pour les tests. Le schéma DB a été généré sur base du mapping décrit dans les classes.

Dog


import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class Dog {
   @Id @GeneratedValue(strategy=GenerationType.AUTO)
   private Long id;
   private String name;
   public Long getId() {
       return id;
   }
   public void setId(Long id) {
       this.id = id;
   }
   public String getName() {
       return name;
   }
   public void setName(String name) {
       this.name = name;
   }
   
   @Override
   public int hashCode() {
       final int prime = 31;
       int result = 1;
       result = prime * result + ((name == null) ? 0 : name.hashCode());
       return result;
   }
   @Override
   public boolean equals(Object obj) {
       if (this == obj)
           return true;
       if (obj == null)
           return false;
       if (getClass() != obj.getClass())
           return false;
       Dog other = (Dog) obj;
       if (name == null) {
           if (other.name != null)
               return false;
       } else if (!name.equals(other.name))
           return false;
       return true;
   }
}

Address


import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class Address {
   @Id @GeneratedValue(strategy=GenerationType.AUTO)
   private Long id;
   private String town;
   public Long getId() {
       return id;
   }
   public void setId(Long id) {
       this.id = id;
   }
   public String getTown() {
       return town;
   }
   public void setTown(String town) {
       this.town = town;
   }
}

Master


import java.util.Set;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.OneToMany;
import javax.persistence.OneToOne;

@Entity
public class Master {
   @Id @GeneratedValue(strategy=GenerationType.AUTO)
   private Long id;
   private String name;
   private String couleurCheveux;
   @Column(updatable=false)
   private int age;
   @OneToOne
   private Address address;
   @OneToMany
   @JoinColumn(name="master_id")
   private Set<Dog> dogs;
   
   public Long getId() {
       return id;
   }
   public void setId(Long id) {
       this.id = id;
   }
   public String getName() {
       return name;
   }
   public void setName(String name) {
       this.name = name;
   }
   public String getCouleurCheveux() {
       return couleurCheveux;
   }
   public void setCouleurCheveux(String couleurCheveux) {
       this.couleurCheveux = couleurCheveux;
   }
   public int getAge() {
       return age;
   }
   public void setAge(int age) {
       this.age = age;
   }
   public Address getAddress() {
       return address;
   }
   public void setAddress(Address address) {
       this.address = address;
   }
   public Set<Dog> getDogs() {
       return dogs;
   }
   public void setDogs(Set<Dog> dogs) {
       this.dogs = dogs;
   }
}

Les données


Voici un script qui permet d'insérer les données utilisées pour ces tests:

INSERT INTO address (id, town) VALUES (2, 'Bruxelles');
INSERT INTO address (id, town) VALUES (3, 'Anvers');
INSERT INTO dog (id, name, master_id) VALUES (4, 'Bill', 7);
INSERT INTO dog (id, name, master_id) VALUES (5, 'Médor', 7);
INSERT INTO dog (id, name, master_id) VALUES (6, 'Brutus', NULL);
INSERT INTO master (id, age, name, address_id, couleurcheveux) VALUES (7, 12, 'Boule', 2, 'Roux');

Le dirty checking


C'est le mécanisme utilisé par Hibernate pour déterminer quelles entités attachées à la session ont été modifiées et doivent déclencher un update de la base de données.

D'une manière générale, cette opération est effectuée lors d'un flush de la session. Nous verrons plus loin à quelles occasions cela arrive.

En pratique, dans ce qui suit, je provoquerai le flush manuellement, en appelant la méthode "flush" de l'objet session.

Tests effectués


Dans les exemples qui suivent, je ne souhaite pas modifier réellement mes données. Aussi, les modifications seront à l'intérieur d'une transaction sur laquelle j'appellerai rollback au final.

De cette manière, je verrai le SQL émis par Hibernate vers la base de données, mais les modifications ne seront pas commitées.

C'est d'ailleurs un point qu'il est important de noter car certains développeurs s'inquiètent des flush qu'Hibernate est susceptible de faire à tout moment (voir plus loin) et ne réalisent pas toujours que les données ne sont pas forcément commitées.

Modification d'une propriété


Voici le premier test (pour les puristes, ce n'est pas un test unitaire, mais juste une méthode "main"). Il servira de base aux tests suivants et sera modifié au besoin:

import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.cfg.AnnotationConfiguration;

public class PropertyUpdate {
   public static void main(String[] args) {
       SessionFactory sf = new AnnotationConfiguration().configure().buildSessionFactory();
       Session session = sf.openSession();
       Transaction tx = session.beginTransaction();

       Master master = (Master) session.get(Master.class,7L);
       master.setName("Marcel");
       
       session.flush();
       
       tx.rollback();
       session.close();
       sf.close();
   }
}

Le résultat est l'écriture dans la console de:

Hibernate: update Master set address_id=?, couleurCheveux=?, name=? where id=?

Ce qui montre qu'Hibernate met à jour la row correspondant à l'objet Master modifié. Il est facile de vérifier que cette mise à jour arrive au moment du flush. C'est lui qui entraîne un dirty checking et c'est le résultat de ce dirty checking qui entraîne l'update.

Au passage, remarquons que la colonne "age" n'est pas mise à jour. Heureusement, puisqu'elle est updatable=false.

Fonctionnement

Lorsqu'il charge une entité, Hibernate conserve l'état initial des données d'un côté et crée une entité de l'autre. Il renvoie ensuite la référence de cette dernière. Lors du flush, Hibernate compare la valeur des propriétés de l'entité avec son état initial, conservé au niveau de la session. S'il constate une différence, il déclare l'entitié "dirty" ce qui provoquera l'update.

Modification d'une propriété non updatable


Cette fois, j'essaye

master.setAge(50);

en remplacement de

master.setName("Marcel");

Ce code ne provoque aucun update. Hibernate ne vérifie pas les propriétés non updatables lors du dirty checking. (On discutera plus loin de ce setAge...)

Modification d'une collection


Je vais ajouter un nouveau chien à la collection "dogs". A noter que ce chien est persistant. Bien sûr, je ne modifie aucune autre propriété. Donc en remplacement de la ligne ci-dessus, j'ai:

Dog dog = (Dog) session.get(Dog.class,6L);
master.getDogs().add(dog);

Le flush provoque bien un update.

Hibernate: update Dog set master_id=? where id=?

Pour les collections, Hibernate utilise un système particulier. Dans une entité provenant de la session, les références collections sont des implémentations propres à Hibernate. Par exemple, dans le cas présents, "dogs" est un PersistentSet. Une particularité de ces implémentations est de posséder une propriété "dirty", un boolean qui est à false au début mais qui sera mis à true lors d'une modification du contenu.

La ligne master.getDogs().add(dog) provoque plusieurs choses:

  1. l'initialisation de la collection (pour faire le "add"), car la collection étant lazy-loadée (c'est la valeur par défaut), le set doit être initialisé. Dans la console, la ligne
    Hibernate: select dogs0_.master_id as master3_1_, dogs0_.id as id1_, dogs0_.id as id2_0_, dogs0_.name as name2_0_ from Dog dogs0_ where dogs0_.master_id=?
    apparaît.
  2. l'ajout du chien provoque la levée du flag "diry" qui indique que la collection a été modifiée et qu'Hibernate doit faire un update des relations.
Lors du dirty checking, Hibernate vérifie également le statut des collections et provoque un update si elles sont "dirty".

Supposons à présent qu'on ajoute un chien déjà présent dans la collection:
Dog dog = (Dog) session.get(Dog.class,4L); //Bill
master.getDogs().add(dog);

Cette fois, le flag dirty n'est pas levé, puisque le contenu de la collection n'est pas modifié, et aucun update n'est effectué.

Que se passe-t-il par contre si j'ajoute un autre chien nommé "Bill"?

Dog dog = (Dog) session.get(Dog.class,12L); //Un autre Bill
master.getDogs().add(dog);

Parce que j'ai défini l'égalité sur la propriété "name" de Dog, la collection n'est pas modifiée et il n'y a pas d'update.

En fait, la logique globale est incorrecte. Si l'égalité porte effectivement sur le "name" alors il ne devrait pas y avoir deux chiens différents qui s'appellent de la même manière (et nous sommes bien d'accord: sur un plan purement fonctionnel, ça n'a aucun sens). Je considère comme une bonne pratique de combiner une contrainte d'unicité (qui peut porter sur plusieurs colonnes) à la définition de la méthode equals.

Et pour ceux qui pourraient être tentés, je rappelle que ce n'est pas une bonne pratique - c'est même une importante source d'erreur - que de définir la méthode equals sur base d'un id auto-généré.

Modification d'une référence si la table possède la FK


Address a = (Address) session.get(Address.class,3L);
master.setAddress(a);

Hibernate fait l'update. En fait, il compare les id des références. Si on explore la session en mode debug, on constate qu'Hibernate a bien conservé comme valeur de référence, pour la propriété "address", l'id de l'objet Address référencé.

Flush? Oui mais quand?


Dans les exemples précédents, nous avons provoqué un flush en appelant la méthode flush. Un flush aura également lieu à la fin d'une transaction, lors du commit. A noter que ce comportement est "par défaut" et qu'il peut être modifié en changeant de le FlushMode de la session.

Ainsi, si on ajoute dans le code:

session.setFlushMode(FlushMode.MANUAL);

le commit de la transaction ne provoquera pas de flush.

Il existe d'autres circonstances, moins bien cernées par les développeurs. Par exemple, un flush sera nécessaire avant certaines requêtes HQL (ou SQL).

Imaginons le cas suivant: je récupère une entité dont je change une propriété. Puis je fais une requête portant sur la propriété modifiée.

Voici le code:

Master master = (Master) session.get(Master.class,7L);
master.setName("toto");

session.createQuery("from Master m where m.name=:name").setParameter("name", "Alfred").list();

Pour les raisons déjà citées, cela se passe dans le cadre d'une transaction et il y a un rollback à la fin. Attention aussi, si vous avez suivi les exemples, de ne pas modifier le FlushMode par défaut.

En pratique, j'exécute ce code en mode debug et je place un breakpoint sur le "createQuery". De cette manière, je vois quand les flush sont fait.

En effet, c'est sur cette ligne que le SQL suivant apparaît:

Hibernate: update Master set address_id=?, couleurCheveux=?, name=? where id=?

suivi de:

Hibernate: select master0_.id as id0_, master0_.address_id as address4_0_, master0_.age as age0_, master0_.name as name0_ from Master master0_ where master0_.name=?

Hibernate comprend que la requête porte sur des entités attachées qui peuvent avoir été modifiées et synchronise toutes ces entités de la session en effectuant un flush, et donc un dirty checking qui provoque l'update.

Dans le cas présent, les modifications effectuées n'ont pas d'impact sur le résultat de la requête, mais ça, Hibernate ne peut pas le savoir.

Hibernate détecte néanmoins certaines situations. Par exemple:

Dog dog = (Dog) session.get(Dog.class, 4L); //Bill, lié à Boule
dog.setName("Patch");

session.createQuery("from Master m where m.name=:name").setParameter("name", "Boule").list();

On peut tester ce code avec la collection de Dogs en lazy ou en eager, ça ne change rien. Hibernate ne fait pas d'update de Dog. En effet, la requête ne porte pas sur une entitié attachée à la session.

Le eager n'y change rien car dans ce cas, Hibernate fait un select sur Dog pour initialiser la collection, y retrouve un Dog d'id 4 qu'il a déjà dans sa session et renvoie l'entité de sa session, celle qui s'appelle désormais Patch...

Voici d'autres cas de figure.

Recherche par id


Master master = (Master) session.get(Master.class,7L);
master.setName("toto");

Master m = (Master) session.get(Master.class,7L);

Dans ce cas, Hibernate ne fait même pas de deuxième requête. Il se contente d'aller vérifier que la session ne contient pas déjà un objet Master d'id 7. Ce qui est le cas, et la référence de l'entité modifiée est renvoyée.

Recherche sur une propriété non modifiée


Que se passe-t-il si on fait une recherche sur une propriété qui n'a pas été modifiée?

Master master = (Master) session.get(Master.class,7L);
master.setCouleurCheveux("Blond");

session.createQuery("from Master m where m.name=:name").setParameter("name", "Boule").uniqueResult();

Hibernate fait l'update (le moyen qu'il devrait mettre en oeuvre pour ne pas le faire serait de confronter les critères de recherche aux paramètres modifiés... Un peu trop complexe.)

Recherche sur une propriété non modifiable


Quid d'une recherche sur "age"? Propriété updatable=false?

Master master = (Master) session.get(Master.class,7L);
master.setCouleurCheveux("Blond");

session.createQuery("from Master m where m.age=:age").setParameter("age", 12).uniqueResult();

Il y a update... Ce qui est peut-être un peu frustrant car Hibernate pourrait détecter que la propriété ne peut être modifiée...

Recherche sur une propriété après modification d'une propriété non modifiable


Tout est dans le titre: modification d'une propriété non modifiable (autrement dit, non updatable)? Attention, frustration possible...

Je modifie l'âge, propriété non updatable, et je fais une recherche sur le nom.

Master master = (Master) session.get(Master.class,7L);
master.setAge(15);

Master m = (Master) session.createQuery("from Master m where m.name=:name").setParameter("name", "Boule").uniqueResult();

Hibernate ne fait pas l'update ! Pourquoi? En fait, le flush est fait, mais le dirty checking ne signale aucune modification de l'entité puisqu'il ne porte pas sur les propriétés non updatables.

Cela semble correct, non? Certainement, mais vous pourriez vous sentir frustrés car, pour la première fois, vous manipulez avec m une entité qui ne correspond pas à la valeur de la base de données. Son âge est de 15. En DB l'âge reste (et restera) 12.

En fait, m et master pointe sur la même instance car Hibernate a détecté que le résultat était l'entité d'id 7 et a donc renvoyé la référence contenue dans la session.

Quel est le problème? Ce n'est pas Hibernate, ni la DB. Non, le problème est entre la chaise et le clavier. C'est le développeur qui a construit un modèle peu solide en permettant la modification d'une propriété qui ne peut pas être modifiée. En fait, "age" ne devrait avoir aucun setter...

Conclusion


On pourrait continuer et faire d'autres tests. Le principe de base, c'est qu'Hibernate essaye de maintenir une cohérence entre les entités attachées et la base de données. Ainsi, toute modification apportée à une entité devrait être répercutée sur la base de données.

Mais pour améliorer ses performances, Hibernate retarde cette mise à jour jusqu'au dernier moment. Quand Hibernate sent que le résultat d'une requête pourrait être impacté par les modifications apportées aux entités, il provoque un flush de la session, ce qui déclenche un dirty checking et, le cas échéant, une mise à jour de la base de données.

Pas sorcier, juste logique.