Accès JPA à une base de données via un patron DAO

Dans ce TP, nous allons reprendre le code du premier TP et le compléter pour gérer un autre moyen de persistance des objets via JPA au lieu de JDBC.

Génération automatique des POJO annotés

Les IDE comme Eclipse, IntelliJ ou Netbeans ont la capacité de générer automatiquement des POJO à partir de tables SQL. Le principe est que chaque table correspondra à une classe et que les colonnes d'une table auront un attribut de même nom et de type équivalent dans la classe associée. L'avantage majeur est que cette génération gère directement les jointures entre les tables en les transformant en attributs dans les classes.

Malheureusement la fonctionnalité "JPA Entities from Tables" d'Eclipse fonctionne mal, tout comme celle d'IntelliJ. Vous pouvez néanmoins suivre le tutoriel sur la génération des Entités JPA sous Netbeans (disponible sur Moodle). Sinon, si cela ne fonctionne pas, vous trouverez dans l'archive data.zip, le package "data" qui contient les 3 POJO Sport, Discipline et Sportif automatiquement générés par Netbeans à partir de la base SQL du TP1.

Rajoutez ce package avec ses classes dans le code de votre projet du TP1.

Configuration JPA du projet Java

Pour pouvoir utiliser les POJO JPA, il faut convertir votre projet Java du premier TP en un projet JPA. Pour cela, dans le navigateur du projet, sélectionnez le projet, faites un clic droit -> Configure -> Convert to JPA projet .... Vérifiez que la case JPA est cochée. Cliquez deux fois sur Next puis dans la fenêtre "JPA Facet", dans la liste Type de "JPA implementation", choisissez Disable Library Configuration puis cliquez sur Finish.

Le résultat principal de cette action est la création d'un fichier nommé "persistence.xml" dans un répertoire nouvellement créé nommé "META_INF" dans les sources du projet. C'est le fichier de configuration de JPA.

Editez le contenu de ce fichier pour y placer le contenu suivant :

<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.2" xmlns="http://xmlns.jcp.org/xml/ns/persistence" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence http://xmlns.jcp.org/xml/ns/persistence/persistence_2_2.xsd">
<persistence-unit name="SportsPU" transaction-type="RESOURCE_LOCAL">
<provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
<class>data.Sport</class>
<class>data.Discipline</class>
<class>data.Sportif</class>
<properties>
<property name="javax.persistence.jdbc.url" value="URL"/>
<property name="javax.persistence.jdbc.user" value="LOGIN"/>
<property name="javax.persistence.jdbc.password" value="MDP"/>
<property name="javax.persistence.jdbc.driver" value="org.mariadb.jdbc.Driver"/>
<property name="hibernate.show_sql" value="true" />
</properties>
</persistence-unit>
</persistence>

Le contenu de ce fichier de persistance définit, dans l'ordre :

Si vous voulez avoir un projet le plus possible portable comme nous le faisons depuis le début avec les DAO génériques, il faut éviter de mettre des configurations spécifiques au moteur JPA, ici Hibernate. Normalement, votre code doit fonctionner de la même façon avec un autre moteur, par exemple EclipseLink.

Néanmoins, cela peut être utile pour gérer des optimisations de performance précises. Hibernate propose beaucoup de configurations en ce sens (voir les documentations spécialisées à ce sujet).

Pour vérifier que tout fonctionne au niveau de JPA et des mappings avec la BDD, créez une classe "TestJPA" à la racine de votre projet Java qui contient le code suivant :

import data.Sport;
import data.Discipline;

import javax.persistence.*;

public class TestJPA {

   public static void main(String argv[]) throws Exception {

      // charge le gestionnaire d'entités lié à l'unité de persistance "SportsPU"
      EntityManagerFactory emf = Persistence.createEntityManagerFactory("SportsPU");
      EntityManager em = emf.createEntityManager();
      System.out.println("PU chargée");

      // récupère le sport d'identifiant 1, affiche son intitulé et ses disciplines
      int cle = 1;
      Sport sport = em.find(Sport.class, cle);
      System.out.println("Le sport " + cle + " est " + sport.getIntitule());
      for (Discipline disc : sport.getDisciplineSet())
         System.out.println(" -> " + disc.getIntitule());
   }
}

Exécutez ce code, vérifiez que les valeurs affichées sont bien celles qui sont dans votre BDD et étudiez les requêtes SQL réalisées par Hibernate. On constate qu'Hibernate fait une première requête pour récupérer le sport puis ensuite fait une deuxième requête pour récupérer les disciplines du sport (mais seulement une fois qu'on a appelé le getter pour récupérer les disciplines du sport) :

PU chargée
Hibernate: select sport0_.code_sport as code_spo1_2_0_, sport0_.intitule as intitule2_2_0_ from sport sport0_ where sport0_.code_sport=?
Le sport 1 est athlétisme
Hibernate: select discipline0_.code_sport as code_spo3_0_0_, discipline0_.code_discipline as code_dis1_0_0_, discipline0_.code_discipline as code_dis1_0_1_, discipline0_.code_sport as code_spo3_0_1_, discipline0_.intitule as intitule2_0_1_ from discipline discipline0_ where discipline0_.code_sport=?
 -> 100 mètres
 -> 200 mètres
 -> saut en hauteur
 -> saut en longueur
 -> marathon

Travail à réaliser

Le code des POJO générés fonctionne parfaitement et évite de faire des requêtes SQL à la main. Le gain en termes de temps de développement et d'optimisation est donc important (n'oubliez pas que par défaut, JPA charge les références de type collection d'objets à la demande et de manière transparente comme on vient de le voir dans l'exemple ci-dessus).

Néanmoins, il y a deux problèmes :

Pour gérer ces problèmes :

  1. Classe par classe, recopiez à la main les annotations JPA des classes du package "data" vers celles du package "donnees". Prenez soin de vérifier que les noms des attributs référencés par les annotations d'une classe à l'autre correspondent bien aux noms dans les classes de "donnees" et non pas de "data".
  2. Modifiez le fichier de persistance pour que les classes des POJO soient maintenant celles du package "donnees".
  3. Modifiez votre classe TestJPA pour utiliser les POJO du package "donnees" (changez les imports et d'éventuels appels de getters). Vérifiez que cela fonctionne comme avant.
  4. Implémentez un DAO pour les POJO Sport, Discipline et Sportif : trois classes DAO_JPA_Sport, DAO_JPA_Discipline et DAO_JPA_Sportif héritant de la classe abstraite DAO.

    En constatant que le code sera quasiment le même pour chacune de ces trois classes à l'exception du type concret d'entité manipulée, factorisez votre code pour ne définir qu'un seul DAO concret qui utilisera un type générique pour l'entité : DAO_JPA<D>
  5. Créez une fabrique pour instancier les DAO JPA : une classe Sports_JPA_DAO_Factory héritant de la classe abstraite SportsDAOFactory. Modifiez la fabrique de fabriques AbstractDAOFactory pour gérer le cas de JPA.
  6. Reprenez votre programme de tests TestDAO et modifiez uniquement la première ligne en :
    SportsDAOFactory factory = AbstractDAOFactory.getDAOFactory(PersistenceKind.JPA);
    pour utiliser maintenant l'implémentation JPA de vos DAO. Le reste du code de la classe n'a pas à être modifié et doit fonctionner comme pour la version JDBC.
  7. En reprenant l'exemple du cours JPA (à partir du transparent 32), créez une classe Adresse qui contiendra les 3 champs d'adresse d'un sportif (rue, ville et code postal). Modifiez la classe Sportif pour utiliser un attribut de type Adresse et l'intégrer avec une annotation @Embedded.

Quelques règles de bonne pratique avec JPA

Hibernate et les connexions

Vous n'avez sans doute pas fait attention, mais dans les logs de lancement de vos projets Java sous Eclipse, vous voyez passer ceci :

WARN: HHH10001002: Using Hibernate built-in connection pool (not for production use!)

Hibernate vous informe qu'il ne faut pas l'utiliser en production (dans un "vrai" projet) ou du moins, pas avec son gestionnaire de connexions par défaut. Il est vrai que ce gestionnaire pose un gros problème : à chaque requête SQL, il ouvre une nouvelle connexion avec le serveur qu'il ne referme pas. Il est possible que ce mode de fonctionnement aboutisse à paralyser voire à planter le serveur SQL.

Il existe deux solutions à ce problème. La première est d'utiliser un autre moteur de persistance JPA qu'Hibernate. L'autre principal moteur qui existe est Eclipse Link dont le gestionnaire de connexions n'a pas les défauts de celui d'Hibernate. L'autre solution, comme nous avons déjà configuré notre projet pour fonctionner avec Hibernate, est d'utiliser avec Hibernate un autre gestionnaire de connexions comme par exemple c3p0.

Pour ajouter c3p0 à votre projet Java, rajoutez les dépendances Maven suivantes dans le fichier "pom.xml" (ou rajoutez à la main les fichiers .jar de l'archive jars-c3p0.zip si Maven ne fonctionne pas sur votre Eclipse) :

<dependency>
<groupId>com.mchange</groupId>
<artifactId>c3p0</artifactId>
<version>0.9.5.5</version>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-c3p0</artifactId>
<version>6.1.7.Final</version>
</dependency>

Ensuite, on peut configurer le gestionnaire de connexion de c3p0, par exemple, en ajoutant ces lignes dans le fichier "persistence.xml" :

<property name="hibernate.connection.provider_class" value="org.hibernate.connection.C3P0ConnectionProvider" />
<property name="hibernate.c3p0.min_size" value="5" />
<property name="hibernate.c3p0.max_size" value="10" />
<property name="hibernate.c3p0.timeout" value="500" />
<property name="hibernate.c3p0.max_statements" value="50" />
<property name="hibernate.c3p0.idle_test_period" value="300" />

En quelques mots, les valeurs de ces paramètres indiquent que :

Programmez proprement

Nous avons vu comment configurer le gestionnaire de connexions pour optimiser la relation avec le serveur SQL mais il faut tout de même ne pas faire n'importe quoi dans son code. Typiquement, ces 2 lignes de code ne doivent être exécutées qu'une seule fois dans un projet Java :

EntityManagerFactory emf = Persistence.createEntityManagerFactory("MaPU");
EntityManager em = emf.createEntityManager();

La fabrique de gestionnaire d'entités ne doit pas être instanciée plusieurs fois car chaque instance ouvre des connexions avec le serveur SQL. Comme personne ne se soucie en général de fermer les fabriques ouvertes, au bout d'un moment, le système peut saturer et on ne peut plus communiquer avec le serveur.

Le gestionnaire d'entités doit être lui aussi instancié en un seul exemplaire. Techniquement, on peut en avoir autant qu'on veut dans un programme Java mais 1) il n'y a pas de raisons d'en avoir plusieurs si un seul suffit et 2) les POJO sont rattachés à un gestionnaire et on peut se retrouver avec des incohérences si deux objets Java interdépendants sont gérés par des gestionnaires distincts. Donc sauf cas particuliers, un seul gestionnaire d'entités à la fois suffit.

Utilisez un design pattern singleton pour instancier en un seul exemplaire un gestionnaire d'entités. Inspirez vous de celui de la classe SQLConnection du code fourni qui réalise cela pour avoir une connexion SQL unique dans le contexte de JDBC.

JPA n'est pas magique !

Dans ce cours, nous voyons une introduction à JPA, principalement par l'exemple et par l'usage : les classes des POJO sont générées automatiquement sans avoir besoin de modifier beaucoup de choses à part un peu de nommage et ensuite JPA gère lui-même les requêtes SQL et les jointures permettant d'associer les objets entre eux. En cas d'accès un peu complexe aux données, on utilisera des requêtes en JPQL sur les structures des classes Java à la place de faire du SQL sur les tables.

En termes de performances, JPA gère déjà un certain nombre de choses comme la configuration en lazy loading sur les jointures renvoyant des collections d'objets. Ainsi, comme nous l'avons vu, quand on charge un objet de type sport, on ne charge pas tout de suite toutes ses disciplines, cela sera seulement fait quand on appellera le getter sur la collection des disciplines. A ce moment là, JPA exécutera de manière transparente une requête SQL pour charger les objets Java de la collection soit au moment où on en aura besoin.

Si cette transparence est confortable d'un point de vue programmation (on n'a pas besoin de savoir comment ni quand les objets sont récupérés à partir de la base SQL), dans le cadre de développements avec de gros accès en base de données, il faudra tout de même comprendre précisément comment fonctionne JPA en arrière-plan pour éviter de gros problèmes de performance, comme le "N + 1 query problem". Sans rentrer dans les détails, l'idée est que quand on réalise une requête qui par exemple renvoie une collection de 50 objets Java que l'on parcourra ensuite par un itérateur, au lieu d'avoir une seule requête SQL qui charge le contenu de ces 50 objets en une seule fois, on pourra avoir une nouvelle requête SQL pour accèder au prochain objet de la collection (d'où le "N + 1") et donc 50 requêtes au total. La détection de ces problèmes de performance peut être complexe car d'un point de vue Java tout fonctionne correctement. Il faut regarder précisément les logs des requêtes SQL exécutées voire utiliser des outils d'analyse du code JPA pour repérer les problèmes. Mais tout cela sort du cadre de l'introduction à JPA de ce cours.


Eric Cariou, dernière modification : 08/01/24