JEE Back-end

Spring Data

Big Picture

Spring Data

L'intégration de JPA en Spring se fait grâce au module Spring Data.

Par rapport à travailler avec Hibernate en JPA, on va obtenir plusieurs simplifications dans notre code:

  • gestion automatique des transactions,
  • écriture automatique des CRUD Repository,
  • ...

Pas vraiment de nouveaux concepts par rapport à ce qu'on a vu précédemment.

Configuration avec Spring Boot

Il faut inclure la dépendance Maven:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>    

Il faut un bean DataSource dans une classe de configuration qui décrit les paramètres d'accès à la BD pour JDBC.

@Bean
DataSource getDataSource(){
    DataSourceBuilder dataSourceBuilder = DataSourceBuilder.create();
    dataSourceBuilder.driverClassName("com.p6spy.engine.spy.P6SpyDriver");
    dataSourceBuilder.url("jdbc:p6spy:h2:tcp://localhost/~/h2DB");
    dataSourceBuilder.username("sa");
    dataSourceBuilder.password("");
    return dataSourceBuilder.build();
}

Les paramètres additionnels de configuration de Hibernate iront dans le fichier application.properties.

Repository de Spring (1)

Les Repository sont codés automatiquement. Il suffit de déclarer leur interface.

@Entity
public class Pokemon {

    @Id
    @GeneratedValue
    Long id;
    String name;
    int score;
}

@Repository
public interface PokemonRepository extends CrudRepository<Pokemon,Long> {

}    

Spring va créer un bean du type PokemonRepository en codant toutes les méthodes de l'interface CrudRepository<Pokemon,Long> pour vous.

Repository de Spring (2)

Spring construit les méthodes à partir de leur nom!

@Repository
public interface PokemonRepository extends CrudRepository<Pokemon,Long> {

    Pokemon findByName(String name);
    Set<Pokemon> findAllByNameOrderByScoreAsc(String name);
}

On peut aussi definir une méthode d'une requête JPQL.

	@Query("SELECT p FROM Pokemon p WHERE p.score > :score")
	Set<Pokemon> findPokemonsWithHighScores(int score);

PokemonService

La pratique consiste à écrire les méthodes complexes qui manipule des Pokemon dans un classe PokemonService qui va utiliser PokemonRepository.

@Service
public class PokemonService {


    private final PokemonRepository pokemonRepository;

    public PokemonService(PokemonRepository pokemonRepository){
    	this.pokemonRepository = pokemonRepository;
    }

	public void incrementWrong(Long id){
        var pokemon = pokemonRepository.findById(id).orElseThrow();
        pokemon.setScore(pokemon.getScore()+1);
        pokemonRepository.update(pokemon);
    }
}

Attention, cela crée deux transactions !

@Transactional

@Service
public class PokemonService {

    private final PokemonRepository pokemonRepository;

    ...

    @Transactional
    public void incrementWrong(Long id){
        var pokemon = pokemonRepository.findById(id).orElseThrow();
        pokemon.setScore(pokemon.getScore()+1);
        pokemonRepository.update(pokemon);
    }
}

L'annotation @Transactional créer un transaction avant l'appel à incrementWrong et faire un commit après l'appel.

Il y a bien une seule transaction!

@Transactional (2)

  • On ne peut annoter par @Transactional que des méthodes publiques.
  • Si une méthode appelle une méthode de la même classe annotée @Transactional, l'annotation de la seconde méthode est ignorée.
    En effet, l'ajout des transactions se fait par un proxy.

@PersistenceContext et @PersitenceUnit

@Service
public class PokemonService {

    private final PokemonRepository pokemonRepository;

    @PersistenceUnit
    private final EntityManagerFactory emf;

    @PersistenceContext
    private final EntityManager em;


    public PokemonService(PokemonRepository pokemonRepository,
    					  @PersistenceUnit EntityManagerFactory emf,
    					  @PersistenceContext EntityManager em){
    	this.pokemonRepository = pokemonRepository;
    	this.emf = emf;
    	this em = emf;
    }
    ...

}
  • EntityManager est en fait un SharedEntityManager qui est récupérer à partir de la transaction courante.
  • On ne peut pas s'en servir pour créer de nouvelles transactions.
  • On pourrait géner les transactions à la main comme on l'a fait dans les cours précédents.

Problème de concurrence

Si deux threads (i.e. deux visiteurs sur votre site) appellent au même moment la méthode incrementWrong, on peut obtenir un seul incrément en BD.

Il y a 3 leviers pour résoudre les problèmes de concurrence.

  • Le niveau d'isolation des requêtes,
  • Les locks pessimistes,
  • Les locks optimistes.

Dans notre cas particulier, on peut faire un UPDATE atomique.

Niveau d'isolation

Il existe plusieurs niveaux d'isolation dans le standard SQL.

  • READ UNCOMMITTED
  • READ COMMITTED (interdit les dirty reads)
  • REPEATABLE READ (interdit les dirty et les non-repeatable reads)
  • SERIALIZABLE (interdit les dirty, non-repeatable reads et les phantom reads)
    @Transactional(isolation = Isolation.SERIALIZABLE)
    public void incrementScoreWithSerialization(String name){
        var pokemon = pokemonRepository.findByName(name);
        pokemon.incrementScore();
        pokemonRepository.save(pokemon);
    }

Si la transaction n'a pas pu s'exécutée avec la sémantique demandée, une exception:

org.springframework.dao.CannotAcquireLockException

est levée.

Malheureusement H2 que nous utilisons pour les TPs n'implémente pas l'isolation au niveau des transactions. Pour tester, il vous faudra installer une autre BD.

Locks pessimistes

@Transactional
public void incrementScoreWithPessimisticLock(String name) {
    var pokemonToUpdate=em.find(Pokemon.class,name, LockModeType.PESSIMISTIC_WRITE);
    pokemonToUpdate.setScore(pokemonToUpdate.getScore()+1);
}

Un lock est pris sur la BD qui empèche les modifications, suppressions, et lectures sur cette ligne.

Il existe d'autre locks dans la JPA.

Locks optimistes (1)

On stocke en BD un numéro de version pour chaque enregistrement.

Quand l'ORM récupère un enregistrement, il récupère son numéro de version. Au moment de mettre à jour l'enregistrement en DB, il vérifie que le numéro de version est le même qu'au moment de la lecture.

Si ce n'est pas le cas, une exception:

org.springframework.orm.ObjectOptimisticLockingFailureException

est levée et la transaction et la transaction est rollback.

Si c'est le cas, il suffit de retenter la transaction.

Locks optimistes (2)

@Entity
public class Pokemon {

    @Id
    @GeneratedValue
    Long id;
    String name;
    int score;
    @Version
    Long version;
}	

Il n'y a rien d'autre à faire à part gérer l'exception.

Locks optimistes (2)

public void incrementScoreWithOptimisticLock(String name){
  var retry=true;
  while(retry) {
      retry=false;
      try {
          incrementScore(name);
      } catch (org.springframework.orm.ObjectOptimisticLockingFailureException e){
          retry=true;
      }
  }
}

@Transactional
public void incrementScore(String name){
    var pokemon = pokemonRepository.findByName(name);
    pokemon.incrementScore();
    pokemonRepository.save(pokemon);
}

Ce code ne marche pas, pourquoi ?

Locks optimistes (3)

@Transactional
public void incrementScoreWithOptimisticLock(String name){
  var retry=true;
  while(retry) {
      retry=false;
      try {
          incrementScore(name);
      } catch (org.springframework.orm.ObjectOptimisticLockingFailureException e){
          retry=true;
      }
  }
}

@Transactional
public void incrementScore(String name){
    var pokemon = pokemonRepository.findByName(name);
    pokemon.incrementScore();
    pokemonRepository.save(pokemon);
}

Ce code ne marche pas, non plus, pourquoi ?

Locks optimistes (4)

@Service 
public class PokemonService{
	@Autowired
	PokemonServiceWithFailure pokemonServiceWithFailure;
	...

	public void incrementScoreWithOptimisticLock(String name){
 	 	var retry=true;
  		while(retry) {
      		retry=false;
      		try {
          		pokemonServiceWithFailure.incrementScoreWrong(name);
      		} catch (org.springframework.orm.ObjectOptimisticLockingFailureException e){
          		retry=true;
      		}
 		 }
	}
}

@Service
public class PokemonServiceWithFailure{
	...
	@Transactional
	public void incrementScoreWrong(String name){
    	var pokemon = pokemonRepository.findByName(name);
    	pokemon.incrementScore();
    	pokemonRepository.save(pokemon);	
	}
}

Solution : mettre les deux méthodes dans des classes différentes.
On peut utiliser aussi Spring Retry mais c'est moins drôle!