lundi 1 novembre 2021

Les transactions en JPA avec Springs

Comme bien souvent avec le framework spring, une annotation permet de lui confier une tâche. Dans cet article, nous détaillerons quelques points qu'il faut faire attention afin d'obtenir les résultats escompté.

Transaction

Les transactions sont disponibles dans la majorité des bases de données. Mais qu'est-ce qu'une transaction. C'est une opération avec un début et une fin. Elle est définie par 4 propriétés qui sont désignés par l'acronyme ACID.

Atomicité

Tous les opérations qui composent la transaction sont soit réussite soit en échec. C'est tout ou rien.

Cohérence

La transaction doit emmener le système valide dans un état valide

Isolation

La transaction doit s'exécuter comme si seul elle était seul à fonctionner.

Durabilité

Les modifications apportés lors d'une transaction doivent être conservés.

Spring permet de respecté ces propriétés avec sa gestion des transactions.

Nous utiliserons spring boot avec Hibernate comme implémentation de JPA.

Dans le fichier application.properties ajouter ces lignes

logging.level.ROOT=INFO
logging.level.org.springframework.orm.jpa=DEBUG
logging.level.org.springframework.transaction=DEBUG
 
# for Hibernate only
logging.level.org.hibernate.engine.transaction.internal.
TransactionImpl=DEBUG

 
afin d'avoir plus de détails sur les opérations effectués. 

Particularité de spring

Spring ignore les méthodes private


@Service
public class ProductService{
 
    private final ClientRepository clientRepository;
    
    public ProductService(ClientRepository clientRepository){
         this.clientRepository=clientRepository;
    }
 
    public void mainClient(){
         Client client = new Client();
         saveClient(client);
    }

   @Transactional(propagation = Propagation.REQUIRES_NEW)
    private long saveClient(Client client){
        clientRepository.save(client);
        return clientRepository.count();
    }
    
}

Dans les logs générés, il y a pas de transactions qui exécute saveClient comme un tout.
L'opération save et count sont exécuté dans des transactions séparés.

L'annotation @Transactional fonctionne uniquement sur les méthodes public.
De plus, cela ne fonctionnera pas si l'annotation est sur une méthode dans une classe où elle est aussi appelé.

Afin de palier à cette particularité de Spring, une nouvelle classe peut être créé.

@Service
public class ProductHelperService{
 
    private final ClientRepository clientRepository;
    
    public ProductHelperService(ClientRepository clientRepository){
         this.clientRepository=clientRepository;
    }

   @Transactional(propagation = Propagation.REQUIRES_NEW)
    private long saveClient(Client client){
        clientRepository.save(client);
        return clientRepository.count();
    }
    
}
 
On doit aussi modifié notre classe ProductService
 
@Service
public class ProductService{
 
    private final ProductHelperService productHelperService;
    
    public ProductService(ProductHelperService productHelperService){
         this.productHelperService=productHelperService;
    }
 
    public void mainClient(){
         Client client = new Client();
         productHelperService.saveClient(client);
    }
    
}
 
Les logs afficheront maintenant les deux opérations dans la même transaction. 

Méthode manuellement créer dans une interface

Ces méthodes ne sont pas transactionnels par défaut.
 
@Repository
public interface ClientRepository extends JpaRepository<Client, Long>{
    @Query("select c from Client c where c.firstname = ?1")
    public Client getByFirstname(String firstname) ;

Aucune transaction n'est créé, idem si vous utilisez les requêtes générés par le Spring Data Query Builder

@Repository
public interface ClientRepository extends JpaRepository<Client, Long>{
    @Query("select c from Client c where c.firstname = ?1")
    public Client getByFirstname(String firstname) ;

    public Client findByFirstName(String firstname);
}

L'exécution de la méthode findByFirstName ne sera pas faite dans une transaction

Voici un autre cas

@Modifying
@Query("DELETE FROM Client c WHERE a.genre <> ?1")
public int deleteByNeGenre(String genre);

Dans une classe 
 
public void deleteClientByGenre(String genre){
     clientRepository.deleteByNeGenre(genre);
}

En exécutant deleteClientByGenre, vous allerz obtenir une erreur qu'une transaction est requis.
Vous pouvez ajouter l'annotation @Transactional à la méthode
deleteClientByGenre.
 
Si vous désirez exécuter différentes opérations dans une méthode, ajouter l'annotation @Transactional
 
@Transactional
public void findClientAndUpdate(Long id){
     Client client = clientRepository.findById(id). orElseThrow();
     client.modifiedDate(LocalDate.now());
}

Puisque qu'un champs de l'entité est modifié, elle sera automatiquement sauvegardé. Rien ne vous empêche d'ajouter


clientRepository.save(client);
 
si vous trouvez cela plus compréhensible. 

Les deux opérations seront exécuter dans la même transaction.

Bonne pratique

Toute opération au niveau du repository devrait être dans une transaction. Cette pratique permet d'éviter d'avoir de grosse transaction bloquante.


@Repository
@Transactional(readOnly=true)
public interface ClientRepository extends JpaRepository<Client, Long>{
    @Query("select c from Client c where c.firstname = ?1")
    public Client getByFirstname(String firstname) ;

    public Client findByFirstName(String firstname);

    @Transactional
    @Modifying
    @Query("DELETE FROM Client c WHERE a.genre <> ?1")
    public int deleteByNeGenre(String genre);
}

Dans une classe de service on va appeler ce repository et simuler un long appel

@Transactional
public void longCallTransaction(){
    Thread.sleep(50000); //50 secondes
    Client client = clientRepository.getByFirstname("Paul");
     clientRepository.deleteByNeGenre("Entreprise");
}

Dans les logs, vous pouvez remarquer qu'une transaction est démarré au niveau de la classe de service. Spring invite celle relatif au repository de participer à celle déjà existante. Spring attend l'appel au repository avant d'aquérir la connection à la bd. Cette particularité permet d'éviter une longue transaction.

En ajoutant l'annotation Transaction au niveau du repository, vous évitez de potentiel erreur.


@Repository
@Transactional(readOnly=true)
public interface ClientRepository extends JpaRepository<Client, Long>{
    @Query("select c from Client c where c.firstname = ?1")
    public Client getByFirstname(String firstname) ;
}

@Service
public class ProductService{
 
    private final ClientRepository clientRepository;
    
    public ProductService(ClientRepository clientRepository){
         this.clientRepository=clientRepository;
    }
 
    public void mainClient(){
         Client client = fetchClient
         System.out.println(client);
    }

   @Transactional(readOnly = true)
    private Client fetchClient(){
        return clientRepository.getByFirstname("Paul");
    }
    
}
 
Si on appelle la méthode mainClient, cette dernière appelle fetchClient qui a l'annotation Transactional. Cependant c'est un appel dans la même classe, Spring va ignorer la transaction. Par chance au niveau du repository, l'annotation est là et une transaction sera bien créé.
 

Cascading et les transactions 

Imaginons une classe Auteur et Livre qui ont une relation bidirectionnel de type lazy. En persistant un auteur, les livres seront aussi sauvegardé.
 
public saveAuteur(){
 
    Auteur auteur = new Auteur
    auteur.prenom="Martin";
 
    Livre livre1 = new Livre();

    Livre livre2 = new Livre();
 
    auteur.addLivre(livre1);
    auteur.addLivre(livre2);
 
    auteurRepository.save(auteur);
 
L'appel à saveAuteur va générer 3 insert. Spring va mettre tous les mettres dans la même transaction. Si un insert échoue, la transaction sera annulée.