Affichage des articles dont le libellé est hibernate. Afficher tous les articles
Affichage des articles dont le libellé est hibernate. Afficher tous les articles

jeudi 6 février 2014

Deux projets prototypes et didactiques

Parmi mes tâches en tant qu'architecte, je dois vérifier le fonctionnement des frameworks utilisés, plus particulièrement de certaines de leurs propriétés, afin de bien les comprendre et d'assister les développeurs. Une autre tâche, parallèle à celle-là, est de coacher les développeurs et leur montrer comment fonctionnent les frameworks.

Pour cela, j'utilise des projets "prototypes", correctement configurés, qui me permettent d'effectuer mes divers tests. Après un peu de nettoyage, j'ai décidé de les pousser sur GitHub, où ils sont accessibles à tous.

L'objectif est surtout didactique: démontrer le fonctionnement des frameworks aux travers de petits tests, complètement indépendants les uns des autres. Et quand je dis "tests", je parle réellement de tests unitaires.

Deux projets sont disponibles:

TestsSpringHibernate (https://github.com/hittepit/TestsSpringHibernate

Ce projet teste le fonctionnement d'Hibernate, en particulier lorsqu'il est intégré avec Spring. Cependant, de nombreux tests ne profitent de Spring que pour créer une SessionFactory.

LazyLoading, mappings d'héritage, cache de second niveau et plusieurs autres fonctionnalités ont été ainsi testées. Il y aura d'autres développements en fonction des besoins rencontrés.

Voici les principales librairies utilisées et leurs versions (le laisse de côté les modules spécifiques de chaque framework, ils sont dans le pom.xml):
  • Hibernate 3.5.6
  • Spring 3.2.3
  • TestNg 6.8.5
  • Mockito 1.9.0
  • H2 1.3

TestSpring (https://github.com/hittepit/TestSpring)

Ce projet teste le fonctionnement de Spring. Il est plus récent que le précédent, donc moins complet. Il contient cependant quelques tests intéressants: l'utilisation de l'annotation @Async, de l'AOP ou des PostProcessors.

Les librairies et leurs versions sont:
  • Spring 3.2.3
  • TestNg 6.8.5

Structure des projets

Les deux projets sont similaires. Tous deux sont "maven". Le code de base se trouve donc dans src/main/java.

Les packages utilisent une base commune (be.fabrice.testspring pour TestSpring, be.fabrice pour TestsSpringHibernate), puis continuent avec un identifiant en rapport avec la fonctionnalité testée. Un sous-division existe parfois.

Par exemple, dans TestsSpringHibernate, le sous-package "inheritance" est sous-divisé en "join", "single" et "table" selon la manière dont l'héritage est fait. Dans TestSpring, le sous-package "postProcessor" est divisé en "bean", "definitionRegistry" et "factory" selon le type de PostProcesor qui est testé.

La division va plus loin dans le projet TestsSpringHibernate, où il y a systématiquement un dernier sous-package "entity" (pour les entités, avec le mapping Hibernate en annotations) ou "dao" (pour les Data Access Objects utilisés pour gérer ces entités).

Les tests, qui démontrent les propriétés, sont évidemment dans src/test/java. Le packaging est exactement pareil à celui de la fonctionnalité testée, à l'exception de TestsSpringHibernate où seule la sous-division "dao" est utilisée.

Les ressources ne sont configurées qu'au niveau des tests, c'est-à-dire dans src/test/resources, avec un découpage en répertoires basé directement sur celui des sous-packages du packaging de base (donc les ressources pour les tests sur l'héritage Hibernate sont en inheritance/join, inheritance/single et inheritance/table).

Chaque test utilise ses propres fichiers ressources, c'est-à-dire un fichier spring (xml) mais aussi un fichier SQL (uniquement pour TestsSpringHibernate) lorsque la base de données de test doit être initialisée.

Ce qu'il manque

Plusieurs fonctionnalités, clairement. Je tiens à jour le fichier README qui contient une liste de TODOs. Avec d'ailleurs un TODO qui manque:  "compléter la liste des TODOs"...

Ca manque aussi de documentation. J'essaye d'expliquer les fonctionnements dans la JavaDoc, mais je manque de discipline :-/ Le nom des méthodes de test est normalement descriptif de ce qu'elles vérifient.
J'aimerais ajouter des pages Wiki sur GitHub, mais ça prend du temps.

Patience...

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.

mercredi 1 juin 2011

Configuration d'une application Struts2, Spring et Hibernate

Depuis quelques temps, je vois des articles qui expliquent comment créer le squelette d'une application Struts2. Malheureusement, ils s'en tiennent à la partie purement Web et négligent l'intégration avec Spring,  Hibernate (en supposant qu'on en aie besoin), et avec Maven...

Le but de cet article est de décrire, étape par étape, la création d'un squelette d'application mêlant Struts2, Spring et Hibernate, développé sur Eclipse avec le plugin d'intégration Maven et immédiatement déployable sur Tomcat (parce qu'il est bien intégré à Eclipse, mais l'application peut - sous certaines conditionss - se déployer sur n'importe quel serveur).

Allez, au boulot !

Environnement

L'environnement de développement visé est composé de :
  • jdk1.6 (mais ça fonctionne avec un jdk1.5)
  • Eclipse 3.6 (mais la procédure fonctionne globalement avec les versions 3.5 et 3.4, les plugins doivent éventuellement être adaptés). J'utilise la perspective Java EE.
  • le plugin maven-integration for Eclipse 0.12 (http://m2eclipse.sonatype.org/sites/m2e/)
  • le plugin maven-integration for WTP 0.12 ( à partir de http://m2eclipse.sonatype.org/sites/m2e-extras/)
  • un Tomcat 6 et donc jee5.

Suppositions et partis pris

Je pars du principe que vous savez utiliser Eclipse et Maven, que vous savez comment développer une application Web en Java et que vous savez déployer la dite application sur un Tomcat depuis Eclipse.

Les plugins d'intégration Maven ont été correctement installés.

Enfin, ce n'est pas non plus un tutorial pour utiliser Struts2, Spring ou Hibernate...

Création du projet de base

C'est un projet Maven. Donc, menu File>New>Other, choisir Maven Project (filtrer éventuellement sur Maven). Ne surtout pas cocher "create a simple project" parce qu'on va utiliser un archetype.

Next >

Dans la fenêtre suivante, sélectionner l'archetype. Pour cela, filtrer sur "webapp" pour plus de facilité et sélectionner org.codehaus.mojo.archetypes:webapp-jee5. (Voir note ci-dessous en cas de problème.)

Next >

Remplir les groupId, artifactId et version du projet (la valeur par défaut est généralement acceptable: 0.0.1-SNAPSHOT). Dans mon cas, j'ai choisi "be.fabrice" pour le groupId et "zeDemo" pour l'artifactId.

Le package se rempli automatiquement à partir de ces deux noms. Si ce n'est pas le cas, vous pouvez le remplir manuellement, mais l'archetype n'en fait a priori aucun cas.

Note

Il est commun dans certaines entreprises d'avoir un proxy. Dans certains cas, le plugin d'intégration Maven ne fonctionne pas correctement, en particulier en ce qui concerne les archetypes. C'est le cas pour la version 0.12. J'espère que la version suivante réglera ça.

Eventuellement, il est possible d'utiliser la ligne de commande et de créer le projet avec, en ligne de commande:

mvn archetype:generate -DgroupId=be.fabrice -DartifactId=zeDemo -DarchetypeGroupId=org.codehaus.mojo.archetypes -DarchetypeArtifactId=webapp-jee5 -DarchetypeVersion=1.2

Remplir les données demandées. Pour terminer, il faudra faire un "import existing maven projects" dans Eclipse.

Néanmoins, certaines facilités offertes par le plugin Maven ne seront pas disponibles.

Configuration de base

J'ai déjà remarqué qu'en fonction de la version plugin maven et de la version de l'archetype utilisé, certains éléments pouvaient ne pas exister. Notamment certains répertoires sources.

Dans mon cas, j'ai les répertoires src/main/java et src/test/java, mais pas les "resources". Il suffit de faire un clic droit sur le répertoire src/main pour y ajouter le répertoire "resources" et la même chose pour src/test.

Ces répertoires ne sont pas encore reconnus par Eclipse comme des répertoires sources. Il suffit d'un clic doit sur le projet et, dans le menu contextuel de choisir Maven > Upade Project Configuration.



Le projet est maintenant configuré.

Ajout des dépendances Maven

J'ai un principe quand j'ajoute les dépendances: réduire leur nombre au minimum et profiter des dépendances transitives.

Par exemple, pour utiliser Struts2, dans la mesure où l'intégration avec Spring doit se faire, la dépendance struts2-spring-plugin est suffisante. Elle importe de manière transitive struts2-core et la plupart des modules de spring 2.5.6.

Pour cela, ouvrir le fichier pom.xml dans l'éditeur par défaut (celui qui vient du plugin). Choisir l'onglet "Dependencies", cliquer sur le bouton Add et dans le champ de saisie du dialogue qui s'affiche, sélectionner struts2-spring. Rapidement, le plugin filtre les dépendances disponibles. Développez l'entrée org.apache.struts:struts2-spring-plugin et sélectionnez la version 2.2.3.



Note: de nouveau, il peut y avoir des problèmes avec un proxy... Il faudra cliquer sur "create" plutôt  que "add" et remplir les informations "à la main"...

Confirmez et sauvez le projet.

Vérifiez toujours les dépendances transitives. Les versions évoluant, elles changent parfois.

Pour cela, cliquez sur l'onglet "Dependency hierarchy". Vous y verrez la liste des dépendances importées transitivement par ce premier ajout.


Il reste à présent à ajouter les dépendances Hibernate. Souvent les annotations propres à Hibernate seront nécessaires en plus de celles venant de JPA pour pouvoir faire la jonction entre les entités et un schéma DB imposé.

Pour cela, la dépendance à importer est (même opération que pour Struts2) org.hibernate:hibernate-annotations:3.5.6-Final.

De nombreuses dépendances Spring ont déjà été importées, mais pas toutes celles dont nous avons besoin.

Pour que ce projet fonctionne, il faut encore ajouter org.springframework:spring-orm:2.5.6, org.springframework:spring-aop:2.5.6 et org.springframework:spring-jdbc:2.5.6 (apparemment nécessaire pour logback).

Car slf4j étant une dépendance transitive de nos framework, il lui faut un logger. Pour ma part, j'utilise logback et j'ajoute donc ch.qos.logback:logback-classic:0.9.17.

Je laisse de côté les dépendances de test.

Configuration xml

La configuration du projet se fait dans trois fichiers xml différents:

web.xml

Deux éléments à configurer obligatoirement: Struts2 et Spring.

Spring se charge à l'aide d'un ServletContextListener implémenté par une classe de Spring: ContextLoaderListener.

<listener>
     <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>

Par défaut, cette configuration ira chercher le fichier applicationContext.xml à la racine du WEB-INF. La pratique veut toutefois que les fichiers de configuration (à l'exception du web.xml) soient placés dans le répertoire src/main/resources, ce qui aura pour effet de les placer dans le WEB-INF/classes. Il arrive aussi parfois que l'on souhaite changer le nom du fichier de configuration de spring.

Il importe donc d'ajouter les lignes suivantes:

<context-param>
     <param-name>contextConfigLocation</param-name>
     <param-value>classpath:applicationContext.xml</param-value>
</context-param>

Le <param-value> est à adapter en fonction de la configuration. Celui-ci cherchera le fichier applicationContext.xml situé à la racine du classpath (soit WEB-INF/classes).

Il faut enfin configurer l'application pour qu'elle utilise Struts2. Cela se fait en configurant un filtre:

<filter>
       <filter-name>struts2</filter-name>
    <filter-class>org.apache.struts2.dispatcher.ng.filter.StrutsPrepareAndExecuteFilter</filter-class>
</filter>

<filter-mapping>
       <filter-name>struts2</filter-name>
       <url-pattern>/*</url-pattern>
</filter-mapping>

OpenSessionInViewFilter?

Une pratique assez courante dans les applications Web "simples" avec Hibernate, est d'ajouter l'OpenSessionInViewFilter qui permet de garder "en vie" la session Hibernate dans le thread d'exécution de la requête. Cela permet notamment d'accéder aux objets lazy-loadés d'Hibernate de manière transparente partout dans l'application.

La configuration se fait à l'aide d'un filtre, placé AVANT le filtre de Struts2. Il est ici configuré pour n'intercepter que les requêtes se terminant par .demo, requêtes qui seront gérées par Struts2 (comme nous le verrons dans un instant).

<filter>
       <filter-name>hibernate</filter-name>
       <filter-class>org.springframework.orm.hibernate3.support.OpenSessionInViewFilter</filter-class>
</filter>

<filter-mapping>
       <filter-name>hibernate</filter-name>
       <url-pattern>*.demo</url-pattern>
</filter-mapping>

Fichier final

Pour récapituler voici le fichier web.xml final.
<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
  <display-name>ZeDemo demo application</display-name>
  <context-param>
      <param-name>contextConfigLocation</param-name>
      <param-value>classpath:applicationContext.xml</param-value>
  </context-param>

  <filter>
      <filter-name>hibernate</filter-name>
      <filter-class>org.springframework.orm.hibernate3.support.OpenSessionInViewFilter</filter-class>
  </filter>

  <filter>
      <filter-name>struts2</filter-name>
      <filter-class>org.apache.struts2.dispatcher.ng.filter.StrutsPrepareAndExecuteFilter</filter-class>
  </filter>

  <filter-mapping>
      <filter-name>hibernate</filter-name>
      <url-pattern>*.demo</url-pattern>
  </filter-mapping>

  <filter-mapping>
      <filter-name>struts2</filter-name>
      <url-pattern>/*</url-pattern>
  </filter-mapping>

  <listener>
      <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
  </listener>
</web-app>

applicationContext.xml

Voici le fichier de configuration de Spring. Il sera placé dans src/main/resources.

Il est configuré pour fonctionner de la manière suivante:
  • les beans sont configurés à l'aide d'annotations (@Component et dérivées) à l'exception de certains beans provenant de frameworks. Spring scannera les packages be.fabrice.zeDemo pour les trouver.
  • un pool de connexions doit exister dans le serveur et être exposé comme une ressource JNDI du nom de jdbc/zeDemoDatasource.
  • les transactions seront définies à l'aide de l'annotation @Transactionnal.
  • le gestionnaire de base de données que j'utilise est PostgreSQL et je souhaite mettre le schéma à jour en fonction des entités (nous sommes en phase de développement).
  • la configuration des entités Hibernate se fait par annotations et la SessionFactory scannera les packages be.fabrice.zeDemo.entities pour les trouver.

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
   xmlns:tx="http://www.springframework.org/schema/tx"
   xmlns:context="http://www.springframework.org/schema/context"
   xmlns:jee="http://www.springframework.org/schema/jee"
   xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-2.5.xsd
        http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-2.5.xsd
        http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-2.5.xsd
        http://www.springframework.org/schema/jee http://www.springframework.org/schema/jee/spring-jee-2.5.xsd"
        default-autowire="autodetect">

   <context:component-scan base-package="be.fabrice.zeDemo.*" annotation-config="true" />
   
   <jee:jndi-lookup id="datasource" jndi-name="jdbc/zeDemoDatasource" />
   
   <bean id="sessionFactory" class="org.springframework.orm.hibernate3.annotation.AnnotationSessionFactoryBean">
       <property name="dataSource" ref="datasource" />
       <property name="hibernateProperties">
           <value>
               hibernate.show_sql=true
               hibernate.hbm2ddl.auto=update
               hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
           </value>
       </property>
       <property name="packagesToScan" value="be.fabrice.zeDemo.entities" />
   </bean>

   <tx:annotation-driven transaction-manager="transactionManager" />
   
   <bean id="transactionManager"
       class="org.springframework.orm.hibernate3.HibernateTransactionManager">
       <property name="sessionFactory" ref="sessionFactory" />
   </bean>
</beans>

Config sans ressource JNDI

Une petite note pour indiquer les modifications à effectuer si on ne souhaite pas utiliser une ressource JNDI pour la configuration.

Il faut modifier le pom.xml pour y ajouter, par exemple, les apache-commons DBCP et le driver du gestionnaire de la base de données.

Dans le fichier applicationContext.xml, il faut remplacer:

<jee:jndi-lookup id="datasource" jndi-name="jdbc/zeDemoDatasource" />

par

<bean id="datasource" class="org.apache.commons.dbcp.BasicDataSource">
       <property name="driverClassName" value="driverClass" />
       <property name="username" value="username" />
       <property name="password" value="password" />
       <property name="url" value="urlDb" />
</bean>

Les valeurs "driverClass", "username", "password" et "urlDB" sont à remplacer par les valeurs adéquates...

struts.xml

Le dernier fichier à configurer est le fichier struts.xml, lui aussi dans les src/main/resources. Le seul élément que nous devons configurer pour le moment, c'est que les requêtes gérées par Struts2 doivent se terminer par "demo":

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE struts PUBLIC "-//Apache Software Foundation//DTD Struts Configuration 2.0//EN" "http://struts.apache.org/dtds/struts-2.1.7.dtd">

<struts>
   <constant name="struts.action.extension" value="demo" />

   <include file="struts-default.xml" />

   <package name="default" extends="struts-default" >
   </package>
</struts>


logback.xml

Pas absolument nécessaire mais ça vaut mieux si vous utilisez logback. Toujours dans src/main/resources, le fichier logback.xml:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
 <appender name="STDOUT"  class="ch.qos.logback.core.ConsoleAppender">
    <layout class="ch.qos.logback.classic.PatternLayout">
     <Pattern>ZeDemo - %d{HH:mm:ss.SSS} %-5level %logger{55} - %msg%n</Pattern>
    </layout>
 </appender>

 <root>
    <level value="INFO" />
    <appender-ref ref="STDOUT" />
 </root>
</configuration>

Test

Testons que l'application est correctement configurée. Pour cela, nous devons configurer le serveur et créer une action de test. On pourrait aller plus loin en créant un bean Spring et vérifier qu'il est bien injecté dans l'action, mais ce n'est pas nécessaire.

Configuration du serveur

Généralement, je n'ai pas grand chose à faire de ce côté car le serveur est prêt. Néanmoins, voici les éléments indispensables.

Driver de la base de données
Lorsqu'on utilise une ressource JNDI pour définir la DataSource, il faut que le driver du gestionnaire de base de données soit dans le classpath du serveur et non de celui de l'application.

Le jar du driver doit être copié dans le TOMCAT_HOME/lib, tout simplement...

Ressource JNDI
Lorsque vous créez un runtime de serveur dans Eclipse, un dossier "Servers" est créé dans le "Project Explorer". Il y a un sous-répertoire pour le runtime du Tomcat et dans ce répertoire plusieurs fichiers de configuration, dont context.xml.

La configuration de la ressource JNDI consiste à ajouter dans ce fichier les lignes suivantes:

<Resource name="jdbc/zeDemoDatasource" auth="Container" type="javax.sql.DataSource"
              maxActive="30" maxIdle="10" maxWait="10000"
              username="username" password="password" 
driverClassName="org.postgresql.Driver"
              url="urlDb"/>

Le nom de la ressource doit être le même que celui configuré dans l'applicationContext.xml. Les propriétés "username", "password" et "urlDB" sont à modifier en fonction de vos propres valeurs.

Pour que le déploiement fonctionne, la base de données doit exister.

Action de test

Il ne s'agit pas de faire compliqué.

Pour l'action, une classe Java:

package be.fabrice.zeDemo;

public class TestAction {
   public String execute(){
       return "success";
   }
}

et une JSP test.jsp (que je mets dans le WEB-INF pour le moment):

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>OK</title>
</head>
<body>
Ca marche...
</body>
</html>

Bref, ça ne fait rien, mais permet de valider la configuration.

Pour terminer, il faut ajouter l'action dans l'élément <package> du fichier struts.xml :

<action name="test" class="be.fabrice.zeDemo.TestAction">
       <result name="success">/WEB-INF/test.jsp</result>
</action>

Les valeurs sont à modifier en fonction de vos propres packages...

Déploiement

Dans la view "Servers", clic droit sur le runtime du serveur, "Add and Remove". Sélectionner le nouveau projet (zeDemo) dans la liste de gauche et cliquer sur Add puis finish.

Il suffit de lancer le serveur. Souvent, pour éviter le timeout du serveur, je force le "publish" avant.

Si tout s'est bien passé, il suffit d'entrer l'adresse suivante dans le navigateur: http://localhost:8080/zeDemo/test.demo

Normalement, "Ca marche" devrait s'afficher...

Ouf...

mardi 22 février 2011

Relations sans foreign keys en Hibernate


En Hibernate, une relation entre deux entités s'exprime en générale à l'aide d'une foreign key. Néanmoins, de nombreux développeurs s'imaginent à tort qu'Hibernate a besoin d'une contrainte de foreign key entre les tables pour mapper une relation.

Certes, c'est une bonne idée. Prenons par exemple le cas de deux tables (MASTER et DOG, tables utilisées dans un précédent article). DOG ne contient que deux colonnes: ID et NAME. MASTER en contient trois: ID, NAME et DOG_ID, cette dernière contenant un ID de la table DOG.

Un schéma classique qui se mappe comme suit (cf. toujours le même article):
@Entity
public class Dog {
     @Id @GeneratedValue(strategy=GenerationType.AUTO)
     private Integer id;

     private String name;

     //Setters, getters, equals, hashcode...   
}
et
@Entity
public class Master {

     @Id @GeneratedValue(strategy=GenerationType.AUTO)
     private Integer id;

     private String name;

     @OneToOne
     private Dog dog;

     //setters getters, equals, hashcode    
}
Si on laisse à Hibernate le soin de générer le schéma, il ajoutera une contrainte FK sur la colonne DOG_ID pour que les valeurs qu'elle contient soient toujours des clés primaires de DOG.

Sans contrainte

Cependant, cette contrainte n'est absolument pas nécessaire et cela n'empêchera pas Hibernate de fonctionner (de la même manière, une propriété annotée @Id ne doit pas forcément correspondre à une clé primaire, mais c'est une autre histoire).

Curieusement, ce qui ressemble à une mauvaise pratique est plus courant qu'on ne le croit, même si elle ne se justifie souvent que par le poids de l'héritage (legacy).

Cela pose quand même un problème. Imaginons que notre DB contiennent les informations suivantes:
  • dans DOG, une ligne ID=1, NAME=Brutus
  • dans MASTER, une ligne ID=2, NAME=Toto, DOG_ID=1 et une ligne ID=3, NAME=Totor, DOG_ID=7

Nous avons donc une ligne master dont le DOG_ID ne référence aucun DOG.

Que va-t-il se passer avec le code suivant?
public class TestContrainte {
    public static void main(String[] args) {
        Configuration cfg = newAnnotationConfiguration().configure(); 
        SessionFactory sf = cfg.buildSessionFactory();
        Session session = sf.openSession();

        Master maitre = (Master)session.get(Maitre.class, 3);

        System.out.println(maitre.getNom());
        if(maitre.getChien() == null){ 
                System.out.println("Chien introuvable");
        } else {
                System.out.println("Chien: "+maitre.getChien().getNom());
        }

        session.close();
    }
} 

En fait, le code lance une exception:
Exception in thread "main" org.hibernate.ObjectNotFoundException: No row with the given identifier exists: [entities.Chien#7]

Evidemment, si on avait demandé l'id 2, on aurait obtenu la réponse:
Toto
Chien: Brutus

La solution

Le fait est qu'Hibernate considère que la référence vers Dog DOIT exister si une “FK” existe. Une exception est donc lancée. Ce comportement par défaut est généralement correct, mais dans les cas où la contrainte de foreign key n'existe pas, le code risque de planter.

Heusement, Hibernate propose un moyen de s'en sortir par le biais de ses annotations propres (hibernate-annotations).

Voici comment modifier la référence dans Master vers Dog:
@OneToOne
@NotFound(action=NotFoundAction.IGNORE)
private Dog dog;
L'annotation @org.hibernate.annotations.NotFound prend l'enum org.hibernate.annotations.NotFoundAction en paramètre. La valeur prise par défaut est NotFoundAction.EXCEPTION, ce qui correspond au comportement généralement observé. Mais quand "action" est en "IGNORE", comme dans ces quelques lignes, si Hibernate rencontre une "foreign key" ne pointant vers aucune ligne, il ne lancera pas d'exception, mais se contentera mettre la référence à null.

Ainsi donc, le test donné ci-dessus donnera comme résultat:
Totor
Chien introuvable

Ce qui n'est pas faux...

samedi 19 février 2011

NonUniqueObjectException, cascade et evict

Notre équipe d'architectes (Yannick et moi-même) a volé à la rescousse par un développeur qui ne savait plus à quel Saint se vouer.

Son code, à base d'Hibernate et de Spring, lançait une org.hibernate.NonUniqueObjectException et il n'en comprenait pas l'origine et pouvait encore moins s'en débarrasser.

Je l’avoue, dans les standards de développement mis en place (et que certains appellent à tort "architecture"), c’est la première fois que je rencontre ce cas.

L’API d’Hibernate décrit l'exception comme suit:
This exception is thrown when an operation would break session-scoped identity. This occurs if the user tries to associate two different instances of the same Java class with a particular identifier, in the scope of a single Session.

Reproduction simple

L'erreur est facile à reproduire. Utilisons pour l'exemple une entité Dog, simpliste:
@Entity
public class Dog {
     @Id @GeneratedValue(strategy=GenerationType.AUTO)
     private Integer id;

     private String name;

     //Setters, getters, equals, hashcode...   
}

Dans la base de données, il y au moins un entrée d’id 1 et de name "Bill". Voici comment reproduire l’erreur:
public class NonUniqueChien {
     public static void main(String[] args) {
          SessionFactory sf = new AnnotationConfiguration().configure().buildSessionFactory();
          Session session = sf.openSession();
          Transaction tx = session.beginTransaction();

          Dog oldDog = (Dog) session.get(Dog.class, 1);

          Dog dog = new Dog();
          dog.setName("Totor");
          dog.setId(1);

          session.saveOrUpdate(dog);

          tx.commit();
          session.close();
          sf.close();
     }
}

L’exécution de ce script provoque la org.hibernate.NonUniqueObjectException.

Explications:
  1. A la ligne 7, le code va rechercher l’entité Dog correspondant à l’id 1 (ce brave Bill). La session contient donc une entité Dog d’id 1.
  2. De 9 à 11, le code construit ensuite un objet Dog transient, avec un autre nom, mais le même id.
  3. A la ligne 13, il appelle saveOrUpdate. Dans la mesure où il y a un id, déclaré comme étant auto-généré, Hibernate part du principe que c’est un update. Par sécurité, le framework vérifie si l’objet existe déjà en session, ce qui est le cas (il s'agit de Bill). Ce n’est donc pas la même référence. Du point de vue d’Hibernate, il y a un risque: deux objets, représentant la même entité, peuvent potentiellement contenir des champs différents (ce qui est le cas ici). Quelle entité persister? Le chien qui s’appelle “Bill” ou celui qui s’appelle “Totor”? Ne pouvant décider, Hibernate lance la NonUniqueObjectException.

A noter que le problème se pose aussi avec "update" (bien sûr), mais pas avec "save" (Hibernate considère qu’il s’agit d’une nouvelle entité et génère un index à la place de celui fourni), ni avec merge évidemment.

Le problème est apparemment simple, mais détecter où il se produit est plus difficile. Le développeur, débutant en Spring et en Hibernate, a perdu le contrôle sur son application. Le modèle est complexe, avec des références vers de nombreuses autres entités dans des relations ToMany ou ToOne, toujours bidirectionnelles et du cascading CascadeType.ALL sur toutes les relations. C’est un cauchemar. Les méthodes s’enchaînent, passent d’un service ou d’un DAO à l’autre, sautant du transactionnel au non transactionnel...

Reproduction du problème complexe


Après un long moment de debugging, le noeud du problème est enfin trouvé.

Le développeur, parce qu'il ne maîtrise pas les transactions, le dirty checking et le lazy-loading, a fait beaucoup d'erreurs. Pour tenter de résoudre une des difficultés rencontrées, il a utilisé à plusieurs reprises des "evict" sur la session.

Une petite parenthèse s'impose ici: que ne nous a-t-il pas appelé à l'aide plus tôt, dés qu'il a eu des soucis! Au lieu de ça, il s'est enfoncé dans du bricolage fait du bricolage, pour obtenir au final un code spaghetti, non maintenable, d'une grande fragilité... et qui ne fonctionne pas. Fin de la parenthèse.

Voici comment on peut reproduire le problème tel qu'il l'a, mais d'une manière hautement simplifiée...

Une deuxième entité est nécessaire: le maître du chien. Présentation donc de Master:

@Entity
public class Master {

     @Id @GeneratedValue(strategy=GenerationType.AUTO)
     private Integer id;

     private String name;

     @OneToOne(cascade=javax.persistence.CascadeType.ALL)
     private Dog dog;

     //setters getters, equals, hashcode    
}


En DB, une ligne est créée dans chacune des tables. Nous avons maintenant notre Dog d'id=1 (Bill) et un Master d'id=2 (Boule), fier maître de Bill grâce à sa foreignkey vers Dog valant 1.

Le code suivant va provoquer l’erreur:

public class NonUniqueObjectTest {
    public static void main(String[] args) {
        SessionFactory sf = new AnnotationConfiguration().configure().buildSessionFactory();
        Session session = sf.openSession();
        Transaction tx = session.beginTransaction();
       
        Master boule = (Master) session.get(Master.class,2);
        session.evict(boule.getDog());
       
        Dog bill = (Dog) session.get(Dog.class, 1);
       
        tx.commit();
        session.close();
        sf.close();
    }
}


Explications
La cause du problème est due à une combinaison du "evict" et du cascading. Des deux, seul le "evict" est réellement erroné. Si le cascading était supprimé, il n'y aurait plus d'erreur, mais la logique du code resterait douteuse.
  1. A la ligne 7, l'entité Master (Boule) est récupérée. Elle contient une référence vers Dog (Bill).
  2. A la ligne 8, l'entité Dog (Bill) est détachée de la session avec "evict".
  3. A la ligne 10, l'entité Dog (Bill) est récupérée à nouveau. Il y a alors deux objets Dog (Bill): un est référencé par la propriété dog de Master (Bill), l'autre par la variable bill.
  4. A la ligne 12, la transaction est commitée. La session est flushée. La cascading amène Hibernate à vérifier le statut de l'objet référencé par dog. Voyant qu'il n'est pas attaché, il détecte qu'il est détaché (et non transient) et vérifie que la session ne contient pas déjà cette entité. Or, comme nous l'avons rechargée à la ligne 10, elle la contient. Hibernate, ne pouvant décider quel objet est le bon, lance une exception. L'ironie dans le cas présent est que les deux objets sont rigoureusement identiques...

Solution

Malheureusement pour le développeur, il n'y a pas de solution miracle. Même si dans le code qui précède, on pourrait résoudre le problème de différentes manières (retirer le "evict", retirer le cascading, limiter le cascading pour ne pas considérer les updates...), ce n'est pas aussi simple dans la réalité.

Le code développé est fragile, les effets de bord sont nombreux. S'il y a un evict, c'est parce qu'il en a eu besoin à ce moment-là. Même chose pour le cascading.

Pour lui, une seule solution, tout recommencer et mieux maîtriser la succession des opérations.

Pour les autres, une règle de base: éviter le evict. C'est rarement utile.