HOAB

History of a bug

Commencer un projet avec Hibernate

Rédigé par gorki Aucun commentaire

Le problème :

Utiliser Hibernate, c'est bien, on gagne du temps, mais encore faut-il se poser les bonnes questions.

Solution :

Voici une liste de questions à se poser :

  • le modèle physique de données existe-t-il ? DDL ?

Si le modèle physique existe, le mapping JPA devra s'adapter, en DDL le modèle physique est généré par hibernate, il peut même être mis à jour par Hibernate.
Il faut cependant toujours vérifier le schéma généré et le faire valider par un DBA (junior, dilettante, senior, guru, ... au choix)
Si c'est à l'application de mettre à jour le modèle de données, des produits plus évolués comme Liquibase sont préférables au DDL hibernate pur.

Préconisation : Modèle physique non géré par Hibernate.

  • les liens dans les JPA doivent-il représenter tous les liens/contraintes de la base de données ?

Quel problème à mettre toutes les relations de la base physique dans les enttités JPA ? Aucun a priori, sauf avec de jeunes développeurs qui vont se restreindre aux relations JPA plutôt que d'inventer des requêtes SQL efficaces qui ne passent pas par les relations JPA.

Exemple : Si A -> B -> C et que C peut-être relier à A par une concaténation de chaine par exemple, le développeur "de base", va faire un A join B join C alors qu'il serait possible de faire un A, C where A.toto = concat(C.titi + truc).

Préconisation : seulement les liens métiers forts. (assez indéfinissable en soit, c'est tout l'apport d'un architecte à ce niveau - et de se remettre en cause pour faire évoluer ces choix :))

  • quel Flushmode par défaut utiliser ?

Le flushmode.AUTO, s'il est pratique, fait qu'Hibernate réalise beaucoup d'opération

Préconisation : Ici c'est Monsieur performance qui parle : Flushmode.COMMIT par défaut. Les autres Flushmode à la demande en fonction des cas.

  • quel pool de connexion ?

Surtout le principe est de connaitre son pool de connexion et de le configurer (nb maximum de connexions, timeout, idle, requête de validation, etc...)

Préconisation : n'importe du moment qu'on puisse configurer les paramètres précédents

  • quel cache de niveau 1 ?

De la même façon que le pool de connexion, il faut savoir quel est celui utilisé et comment le configurer.

Préconisation : idem, n'importe mais le maitriser

  • utiliser ou non le cache de niveau 2 ?

Le cache de niveau 2 est partagé par toutes les sessions, en général seulement au sein de la JVM. Il est possible de partager un cache de niveau 2 au travers de plusieurs JVM via des caches distribués.

Préconisation : si 1 seule JVM, vous pouvez l'activer. Idem que les autres, maitriser le contenu du cache, sa taille, etc... Si plusieurs JVM, soit pas de cache niveau 2, soit cache distribué ou cache de niveau 2 pour des données en lecture seule.

  • spring data ou non ?

Permet de s'abstraire de pas mal de code technique pour la couche DAO.

Préconisation : Oui si vous avez Spring. Si vous n'avez pas Spring pensez-y...

  • écrire des requêtes avec les critérias, en HQL

Comment écrire les requêtes pour accéder à la base de données.

  1. en criteria : ça permet des refactors assez facile
  2. en HQL écrit à la main : très particulier pour des requêtes très flexibles
  3. en namedQuery : ça permet à la JVM de valider les requêtes au lancement

Préconisation : NamedQuery sur SpringData, Criteria pour les plus compliquées/flexibles

Quelque soit le mode d'écriture, on n'utilise QUE des paramètres bindés.

  • comment tester ? Derby / H2 ? base mémoire / base physique ?

L'avantage des bases mémoire c'est leur rapidité (de mise en oeuvre et d'exécution). Leur inconvénient est qu'il peut y avoir des écarts avec la base physique vraiment utilisé.

Par contre dans les deux cas, le problème est la création des données pour les tests, d'où l'utilisation préconisée des mocks qui simulent les accès base de données :)

Préconisation : des mocks pour les services métiers, une base réelle pour la validation des requêtes écrites par le développeur.

  • Transaction

Les transactions sont utilisées pour deux choses :

  1. garantir que plusieurs requêtes SQL sont validées en même temps
  2. conserver une connexion pour charger les objets et leurs enfants (et éviter les lazy-loading)

Les transactions en lecture seule existe et se termine quand même par un COMMIT et non pas un rollback... 

Préconisation : Déterminer les services qui ont besoin d'une transaction, leur cadre (commit globale ou optimisation de connexion). Utiliser un framework de transaction (Spring, EJB 3.1) et les tags @Transactional.

  • Monitoring

Comme pour les caches et les pools, il convient de savoir ce qui se passe dans la JVM. Des outils existent, mais l'important est d'avoir des informations sur :

- les caches (taille, nombre d'éléments, nombre de hits)
- les pools (nombre de connexions en cours /  nombre max de connexions)
- c'est pratique d'avoir un accès en lecture seule à la base de production pour consulter les informations indexs, query_plan, etc...

 

Un projet Web avec Hibernate tout simple (avec Maven, déjà ce n'est plus simple...)

Rédigé par gorki Aucun commentaire

Le problème :

Pour faire des tests, il peut être utile d'avoir un projet Hibernate minimaliste, sans fioriture pour tester simplement une fonctionnalité particulière du framework.

Et aussi si ça pouvait être une application WEB, ça serait cool.

Maven c'est pour éviter d'avoir à télécharger les librairies quand on a déjà Eclipse/Maven sur son poste.

On a bien quelques liens sur google, mais je ne trouvais pas ça super simple pour quelqu'un qui connaissait un peu hibernate : ici, google.

Solution :

Librement inspirée de ce site.

Prérequis :

  • savoir ce qu'est Hibernate, Maven, Eclipse, Java, JPA. Je n'explique rien ici, mais le web est là pour ça.
  • avoir un Eclipse récent qui supporte Maven en natif (à partir de la version Kepler je crois)
  • savoir créer un projet sous Eclipse et savoir où se placent les fichiers dans une arbo Maven (Sinon télécharger l'archive en fin de page, je sais GitHub existe...)
  • savoir piocher les bouts qui vont bien pour votre propre besoin, c'est un peu le mode bloc note ici.

.

  1. Créer le pom qui va bien :
    • Servlet pour la partie annotation Web
    • HSQLDB pour la base
    • SLF4J et Logback pour les logs
    • Hibernate pour Hibernate
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">

	<modelVersion>4.0.0</modelVersion>
	<groupId>com.mygroup</groupId>
	<artifactId>testApp</artifactId>
	<packaging>war</packaging>
	<version>1.0-SNAPSHOT</version>

	<name>Webapp</name>

	<dependencies>
		<!-- Servlet -->

		<dependency>
			<groupId>javax.servlet</groupId>
			<artifactId>javax.servlet-api</artifactId>
			<version>3.1.0</version>
			<scope>compile</scope>
		</dependency>

		<!-- BDD -->

		<dependency>
			<groupId>org.hsqldb</groupId>
			<artifactId>hsqldb</artifactId>
			<version>2.3.2</version>
		</dependency>

		<!-- Logger -->

		<dependency>
			<groupId>org.slf4j</groupId>
			<artifactId>slf4j-api</artifactId>
			<version>1.7.6</version>
		</dependency>

		<dependency>
			<groupId>ch.qos.logback</groupId>
			<artifactId>logback-classic</artifactId>
			<version>1.1.1</version>
		</dependency>

		<!-- ORM -->

		<dependency>
			<groupId>org.hibernate</groupId>
			<artifactId>hibernate-core</artifactId>
			<version>4.3.4.Final</version>
		</dependency>
		


	</dependencies>

	<build>

		<plugins>
			<plugin>
				<artifactId>maven-compiler-plugin</artifactId>
				<configuration>
					<source>1.7</source>
					<target>1.7</target>
				</configuration>
			</plugin>
		</plugins>
	</build>
</project>
  1. Un fichier web.xml tout petit :
    • Attention ! à la version des servlets : 3.0 - pour pouvoir les déployer avec des annotations @WebServlet
    • Un servlet de startup pour initialiser la base mémoire
<web-app 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_3_0.xsd"
	version="3.0">


	<display-name>Test Application</display-name>


	<servlet>
		<servlet-name>StartupGenerique</servlet-name>
		<servlet-class>com.mygroup.servlet.StartupServlet</servlet-class>
		<load-on-startup>0</load-on-startup>
	</servlet>

</web-app>
  1. Une servlet qui répond à toutes les requêtes (on fait dans le super simple)
    • La servlet répond au motif HTTP : "/". Donc toutes les requêtes....
    • Algorithme de haut-niveau :
      • Créer un objet DOG
      • Le sauvegarde
      • Recherche de l'objet DOG sauvé
      • Retourne une réponse
package com.mygroup.servlet;

// Import required java libraries
import java.io.IOException;
import java.io.PrintWriter;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.mygroup.dao.DogDAO;
import com.mygroup.model.Dog;

/**
 * The Class TestServlet1.
 */
@WebServlet("/")
public class TestServlet extends HttpServlet {

	/** The Constant serialVersionUID. */
	private static final long serialVersionUID = 1L;

	/** The Constant LOGGER. */
	private static final Logger LOGGER = LoggerFactory.getLogger(TestServlet.class);

	/*
	 * (non-Javadoc)
	 * 
	 * @see javax.servlet.http.HttpServlet#doGet(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse)
	 */
	@Override
	public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		DogDAO dogDAO = new DogDAO();

		dogDAO.startConnection();

		String responseString = null;

		try {
			/*
			 * Save Dog
			 */
			Dog dog = new Dog();
			dog.setName("Beethoven " + System.currentTimeMillis());
			dog.setWeight(45);
			dogDAO.save(dog);

			/*
			 * Find dog
			 */
			dog = dogDAO.find(dog.getId());
			LOGGER.debug("Dog saved : {}", dog.getName());

			/*
			 * Response
			 */
			responseString = "OK, Dog saved : " + dog.getName();
		} catch (Exception e) {
			LOGGER.error("Erreur générale", e);
			responseString = "Erreur, voir log";
		} finally {
			dogDAO.commitConnection();
			dogDAO.closeConnection();
		}

		// Set response content type
		response.setContentType("text/html");
		// Actual logic goes here.
		PrintWriter out = response.getWriter();
		out.println(responseString);
	}
}
  1. Le DAO qui permet de manipuler les objets DOG et d'ouvrir les connexions à la BDD (via DatabaseUtils ci-dessous)
package com.mygroup.dao;

import java.util.List;

import org.hibernate.Session;
import org.hibernate.Transaction;
import org.hibernate.criterion.Restrictions;

import com.mygroup.model.Dog;

public class DogDAO {

	private Transaction newTransaction;
	private Session newSession;

	public void startConnection() {
		newSession = DatabaseUtils.getSessionFactory().openSession();
		newTransaction = newSession.beginTransaction();

	}

	public void closeConnection() {
		newSession.close();
	}

	public void commitConnection() {
		newTransaction.commit();
	}

	public void save(Dog dog) {
		newSession.persist(dog);
	}

	public void edit(Dog dog) {
		newSession.merge(dog);
	}

	public Dog find(int dogId) {
		return (Dog) newSession.createCriteria(Dog.class).add(Restrictions.idEq(dogId)).uniqueResult();
	}

	public void remove(Dog dog) {
		newSession.delete(dog);
	}

	public List listALL() {
		return newSession.createQuery("from " + Dog.class).list();
	}
}
  1. Le modèle DOG qui est persisté en base de données (et quand est-ce qu'on pourra se passer de ces getters/setters....)
package com.mygroup.model;

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

@Entity
@Table(name = "dog")
public class Dog {

	public static final String LIST_ALL = "listALL";

	@Id
	@GeneratedValue(strategy = GenerationType.AUTO)
	private int id;
	private String name;
	private double weight;

	// Getters and Setters
	public int getId() {
		return id;
	}

	public void setId(int id) {
		this.id = id;
	}

	public String getName() {
		return name;
	}

	public void setName(String name) {
		this.name = name;
	}

	public double getWeight() {
		return weight;
	}

	public void setWeight(double weight) {
		this.weight = weight;
	}

}
  1. La création de connexion via Hibernate
package com.mygroup.dao;

import org.hibernate.SessionFactory;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
import org.hibernate.cfg.Configuration;
import org.hibernate.service.ServiceRegistry;

import com.mygroup.model.Dog;

public class DatabaseUtils {

	private static SessionFactory sessionFactory;

	static {
		Configuration cfg = new Configuration().addAnnotatedClass(Dog.class);
		ServiceRegistry serviceRegistry = new StandardServiceRegistryBuilder().applySettings(cfg.getProperties()).build();
		sessionFactory = cfg.buildSessionFactory(serviceRegistry);
	}

	public static SessionFactory getSessionFactory() {
		return sessionFactory;
	}

}
  1. Le fichier hibernate.properties :
hibernate.connection.driver_class = org.hsqldb.jdbcDriver
hibernate.connection.url = jdbc:hsqldb:mem:.
hibernate.connection.username = sa
hibernate.connection.password = 
hibernate.hbm2ddl.auto = update
hibernate.c3p0.min_size=1
hibernate.c3p0.max_size=2
hibernate.c3p0.timeout=5
hibernate.c3p0.max_statements=50
hibernate.dialect = org.hibernate.dialect.HSQLDialect
  1. Le fichier logback pour les logs :
<configuration>

	<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
		<!-- encoders are assigned the type ch.qos.logback.classic.encoder.PatternLayoutEncoder 
			by default -->
		<encoder>
			<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
			</pattern>
		</encoder>
	</appender>


	<logger name="com.mygroup" level="DEBUG" />


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

Et voilà.

Alors attention, ça ne part pas en production comme ça, on est d'accord ! :)

Le zip avec le tout ici : SimpleWebappHibernate.7z

 

 

Transaction is not active

Rédigé par gorki Aucun commentaire

Le problème :

Lors de l'exécution d'un service, l'erreur suivante apparait :

javax.resource.ResourceException: IJ000459: Transaction is not active

Plusieurs liens internet en parle, c'est systématiquement lié à JBoss puisque c'est lui qui gère ce niveau transactionnel en mode container et que le code IJ... c'est du JBoss.

Solution :

En court : Bien tracer toutes les erreurs, une erreur précédent celle-ci est survenue et a mis la transaction en rollback-only (on pourrait rapprocher ce problème de ceci).

En long :

Contexte d'exécution : JBoss 7.1.1, Hibernate 4.1.6

  • Si une exception est remontée par hibernate, elle met par défaut la transaction au statut ABORT
  • L’erreur de haut niveau lorsqu’on essaye d’utiliser une transaction avec le statut ABORT (en lecture ou écriture) est : « Could not open connection »

Ce genre de cas arrive lorsque la gestion d’erreur est mal codée (le service de haut niveau ne se termine par immédiatement et on essaye de mettre à jour quelque chose en base…)

Scénario d'exemple :

  1. 1ere erreur hibernate :
    • EJB Invocation failed on component
    • javax.persistence.OptimisticLockException
    • Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect):
  2. 2eme erreur :
    • EJB Invocation failed on component
    • javax.persistence.PersistenceException: org.hibernate.exception.GenericJDBCException: Could not open connection
    • org.hibernate.exception.GenericJDBCException: Could not open connection
    • java.sql.SQLException: javax.resource.ResourceException: IJ000460: Error checking for a transaction
    • javax.resource.ResourceException: IJ000460: Error checking for a transaction
    • javax.resource.ResourceException: IJ000459: Transaction is not active: tx=TransactionImple  <.... ActionStatus.ABORT_ONLY >

Lorsque l’exception numéro 1 est arrivée, votre transaction devient inutilisable, pas la peine d'essayer de faire autre chose avec.

Exemple de code incorrect :

Pour tous les objets

     Try {

           Mise à jour objet

     } catch() {

           Logger l’erreur. // Pour rappel, on loggue bien sur les informations fonctionnelles ET techniques (pile d’exception)

     }

Fpour

Mise à jour d’un statut OK ou KO <= impossible si la ligne « mise à jour objet » à mis la transaction en rollback.

Solution pour cet exemple :

  1. sortir de la boucle en remontant l'erreur, ne pas mettre à jour de statut (rien n’est mis à jour)
  2. sortir de la boucle en remontant l'erreur, les statuts sont stockés au fur et à mesure et mis à jour dans nouvelle transaction
  3. tracer l’erreur, continuer la boucle, les statuts sont mis à jour en même temps que l'objet
  4. et bien d'autres cas possibles suivant votre fonctionnel...

Fuite mémoire sur QueryCachePlan

Rédigé par gorki Aucun commentaire

Le problème :

L'objet QueryPlanCache d'Hibernate fait 400Mo. (Récupéré via un dump mémoire de la JVM, analysé via MAT). Normal ou pas ?

Oui et non :)

C'est normal sans aucun doute, mais alors vous avez un problème.

Le QueryPlanCache conserve l'analyse par Hibernate des requêtes HQL qui sont parsées, explosées et stockées dans un objet pour faciliter la vie à Hibernate la prochaine fois qu'il rencontre votre requête.

Si le cache utilise beaucoup de mémoire, c'est qu'il y a beaucoup de requête différentes et des grosses requêtes. (Par grosse requête j'entends : beaucoup de tables, beaucoup de conditions, etc...)

1ere raison évidente :

Vous n'utilisez pas de paramètres bindés dans vos requêtes, exemple : 

"SELECT * FROM mytable where id = " + myId;

Ce n'est pas bien, il faut, des explications ici par exemple.

2eme raison moins triviale :

Vous utilisez des grosses collections avec des clauses IN.

En HQL vous écrivez

SELECT * FROM mytable WHERE id in (:collection);

C'est bien, paramètre bindé, pas de problème. Sauf qu'en SQL les collections ça n'existe pas, donc Hibernate traduit votre requête en :

SELECT * FROM mytable WHERE id in (:item0, :item1, :item2, ...);

C'est bien, toujours des paramètres bindés, sauf que si la collection à 3 éléments ou 4 éléments, ça fait 2 requêtes

SELECT * FROM mytable WHERE id in (:item0, :item1, :item2);
SELECT * FROM mytable WHERE id in (:item0, :item1, :item2, item3);

Et donc deux entrées dans le cache.

Avec 1000 éléments (taille max de la clause IN chez Oracle), ça fait 1,5Mo par entrée du cache...

Solution :

1) Réduire la taille du cache : la taille du cache est de 2048, et peut être configuré via :

hibernate.query.plan_cache_max_size

2) Modifier la classe QueryPlanCache pour ne pas stocker les requêtes qui ont plus de X paramètres.

La 2eme solution est plus sûre au niveau mémoire et performance, mais nécessite 5 lignes de codes dans Hibernate, l'autre c'est un paramètre....

3) Si ce n'est pas trop tard, utiliser des tables temporaires et faire un join sur ces tables.

Hibernate Envers - Audit de table - Relation 1-N

Rédigé par gorki Aucun commentaire

Le problème :

Envers est un module core d'Hibernate qui permet d'auditer des entités, i.e, historiser toutes les modifications de l'objet.

Le principe est de stocker dans une table quasiment identique les différentes versions de l'objet.

Exemple :

Table source : UTILISATEUR(id, version, nom, adresse)

Table audit : UTILISATEUR_AUD(id, rev, nom, adresse)

Le champ version de la table source est utilisé pour l'optimist locking, on n'a donc pas besoin de ce champ dans la tableau auditée.

Par contre la table auditée a un champ un peu équivalent : "révision" qui permet de stocker les différentes versions de l'objet.

Attention cette révision est différente de la version de l'objet, en effet cela correspond plus à un numéro de commit global : tous les objets modifiés et audités dans une même transaction auront la même révision (un peu comme un commit SVN).

Coté code source :

@Audited
@Entity
public class UTILISATEUR {

    @Id
    private Long id;

    @Version
    private Long version;

    private String nom;

    private String adresse;
}

Facile comme tout (il manque des trucs bien sur : création de la table d'audit, des séquences qui vont bien, des informations supplémentaires trackées avec l'audit, etc...)

A chaque sauvegarde d'une instance UTILISATEUR, on aura une ligne dans UTILISATEUR_AUD.

Bon, mais que se passe-t-il si au lieu d'avoir 1 adresse, UTILISATEUR a N adresses ?

@Audited
@Entity
public class UTILISATEUR {

    @Id
    private Long id;

    @Version
    private Long version;

    private String nom;

    @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "UTILISATEUR_ID")
    private List<String> adresses;
}

Solution :

Déjà lire la documentation.

Ensuite choisir : est-ce que les adresses doivent être auditée ? Si oui, on continue.

Evidemment il va y a voir une deuxième table ADRESSE_AUD.

Mais un des points forts d'envers est d'auditer seulement les deltas : une ADRESSE modifiée ne veut pas dire que UTILISATEUR est aussi modifié.
La conséquence importante est que là où on a une relation 1..N, on passe à une relation N..N. Pourquoi ?

Voici ce qui se passe :

// Sauvegarde d'un utilisateur avec une adresse, dans les tables d'audit on obtient

U1 (rev1) -> A1 (rev1)

// on modifie l'adresse (toujours du 1..N)

U1 (rev1) -> A1 (rev1)

U1 (rev1) -> A1 (rev2)

// on modifie l'utilisateur (la relation devient N..N ! deux utilisateurs pointent vers la même adresse)

U1 (rev1) -> A1 (rev1)

U1 (rev1) -> A1 (rev2)

U1 (rev3) -> A1 (rev2)

P.S : Notez bien que les objets modifiés dans une même transaction ont la même révision

Les conséquences directes sont :

- une table d'association est nécessaire entre UTILISATEUR_AUD et ADRESSE_AUD (alors que ce n'est pas le cas dans le modèle normal).

- en 1..N, le lien est porté par la table fille, on a donc dans ADRESSE une colonne qui référence l'utilisateur (UTILISATEUR_ID) ; dans la table d'audit, c'est la table d'association qui porte ce lien.

@Audited
@Entity
public class UTILISATEUR {

    @Id
    private Long id;

    @Version
    private Long version;

    private String nom;

    private List<String> adresses;
}

 

@Audited
@Entity
public class ADRESSE {

    @Id
    private Long id;

    @Version
    private Long version;

    @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinColumn(name = "UTILISATEUR_ID")
    @AuditJoinTable(name = "UTILISATEUR_ADRESSE_AUD", inverseJoinColumns = {
        @JoinColumn(name = "ADRESSE_ID")
    })
    private List<String> adresses
}

Je mettrais bien le DDL, mais j'ai la flemme :)

Trucs à savoir

  • évidemment Envers utilise beaucoup de ressource, attention aux performances
  • en plus des tables d'audit, il y a une table pour référencer toutes les révisions, cette table est customisable pour y ajouter des informations (utilisateur, objets, etc...)
  • il est possible d'utiliser des EJB dans les EntityTrackingRevisionListener, en faisant un lookup JNDI.(entre autre pour retrouver un EJB qui possède une variable @RequestScoped). Si j'ai le temps, je ferai une description rapide du truc.
  • Best practice : il y a des fonctions pour recharger les révisions, mais ça ne marche bien que si l'objet "chapeau" est modifié à chaque révision. Sinon c'est compliqué de retrouver l'historique à partir de l'objet chapeau. (Il suffit par exemple de positionner une date de modification) => ça n'aide pas les performances.

 

Fil RSS des articles de ce mot clé