Gestion des transactions

Préalables

Depuis le premier TP, nous utilisons un accès en base de données (via les Repositories Spring) presque sans nous préoccuper de la gestion des transactions.

L'annotation @Transactional indique à Spring d'utiliser la politique par défaut pour préparer une transaction utilisable. Cette politique revient à créer une transaction par thread et par méthode. La transaction est créée avant l'appel de la méthode et elle est validée et fermée après l'appel de la méthode.

Nous nous proposons d'étudier des variations autour de cette politique.

À faire :

Mise en oeuvre

Travail à faire : suivez les étapes ci-dessous.

>> Préparez l'entité suivante. C'est un compteur identifié par un nom.

package myboot.app2.model;

import javax.persistence.Basic;
import javax.persistence.Entity;
import javax.persistence.Id;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Counter {

    @Id
    private String name;

    @Basic
    private Integer value;

}

>> Ajoutez un nouveau service :

package myboot.app2.services;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import myboot.app2.model.Counter;

@Service
@Transactional
public class CounterManager {

    @PersistenceContext
    EntityManager em;

    public Counter getCounter(String name) {
        return em.find(Counter.class, name);
    }

    public void removeCounter(String name) {
        Counter c = em.find(Counter.class, name);
        if (c != null) {
            em.remove(c);
        }
    }

    public void createCounter(String name, Integer value) {
        removeCounter(name);
        Counter c = new Counter();
        c.setName(name);
        c.setValue(value);
        em.persist(c);
    }

}

Remarques :

>> Testez votre service :

package myboot.app2.test;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import myboot.app2.model.Counter;
import myboot.app2.services.CounterManager;

@SpringBootTest
public class TestCounterManager {

    @Autowired
    public CounterManager cm;

    @Test
    public void testCounterManager() {
        cm.createCounter("C1", 10);
        Counter c = cm.getCounter("C1");
        assertEquals(Integer.valueOf(10), c.getValue());
    }

}

>> Enrichissez votre test afin de vérifier la lecture et la suppression des compteurs.

Gestion des erreurs JPA

>> Ajoutez à votre service une méthode qui permet de sauvegarder deux compteurs :

public void doubleSave(Counter c1, Counter c2) {
    em.persist(c1);
    em.persist(c2);
}

>> Vérifiez dans un test unitaire que, si vous sauvegardez deux fois le même compteur, vous avez bien une erreur (violation de clé primaire) et qu'en plus, la première insertion est annulée (rollback).

Gestion des erreurs métier

>>  Imaginons que nous ajoutions à notre service un test sur la valeur du compteur et l'exception associée :

public void createValidCounter(String name, Integer value) throws BadCounter {
    removeCounter(name);
    Counter c = new Counter();
    c.setName(name);
    c.setValue(value);
    em.persist(c);
    if (value < 0) {
        throw new BadCounter();
    }
}
package myboot.app2.services;

public class BadCounter extends Exception {

    private static final long serialVersionUID = 1L;

    public BadCounter() {
        super("bad counter");
    }

}

Remarque : le test du compteur est volontairement placé à la fin pour générer une éventuelle exception et défaire les modifications déjà effectuées.

>>  Une première vérification : Prévoir un test unitaire afin de vérifier la génération de l'erreur et le rollback. Vous devriez avoir un problème avec le rollback. Par défaut, Spring ne réalise de retour-arrière que pour les exceptions qui héritent de RuntimeException. Ajoutez la clause suivante et testez à nouveau :

@Transactional(rollbackFor = BadCounter.class)
public void createValidCounter(String name, Integer value) throws BadCounter {
    ...
}

>>  Travail à faire : Prévoir un test afin de vérifier que la valeur précédente d'un compteur n'est pas supprimée en cas d'erreur.

Remarque : La gestion des erreurs (et les éventuels retours-arrière) sont des points très importants pour construire des services transactionnels fiables.

Partage de transaction

>>  Nous venons de voir que les méthodes d'un même service partagent la transaction courante. Mais quelle est la situation entre services différents. Imaginons que nous ayons un deuxième service spécialisé dans la suppression des compteurs :

package myboot.app2.services;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import myboot.app2.model.Counter;

@Service
@Transactional
public class CounterRemover {

    @PersistenceContext
    EntityManager em;

    public void removeCounter(String name) {
        Counter c = em.find(Counter.class, name);
        if (c != null) {
            em.remove(c);
        }
    }

}

Nous pouvons revoir la méthode createCounter du service CounterManager pour utiliser le service CounterRemover :

    @Autowired
    CounterRemover remover;

    @Transactional(rollbackFor = BadCounter.class)
    public void createCounter2(String name, Integer value) throws BadCounter {
        remover.removeCounter(name);
        Counter c = new Counter();
        c.setName(name);
        c.setValue(value);
        em.persist(c);
        if (value < 0) {
            throw new BadCounter();
        }
    }

>> Travail à faire : Vérifiez que cette deuxième version fonctionne comme la première. En clair, pour un client donné, le service appelant partage sa transaction avec le service appelé. Ainsi, l'ensemble des actions métier sont regroupées dans la même transaction.

Contrôler le partage

Le fonctionnement précédent convient dans la plupart des cas. Nous avons néanmoins besoin de contrôler ce partage. Cela passe par l'attribut propagation de l'annotation @Transactional.

Nouvelle transaction

Ajoutons une nouvelle méthode au service CounterRemover. L'annotation @Transactional indique que Spring doit créer une transaction pour cette méthode et la valider à la sortie de la méthode.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void removeCounterAndCommit(String name) {
    Counter c = em.find(Counter.class, name);
    if (c != null) {
        em.remove(c);
    }
}

Nous pouvons maintenant faire une troisième version de createCounter toujours dans le service CounterManager :

@Transactional(rollbackFor = BadCounter.class)
public void removeAndCreateCounter(String name, Integer value)
throws BadCounter {
    remover.removeCounterAndCommit(name);
    Counter c = new Counter();
    c.setName(name);
    c.setValue(value);
    em.persist(c);
    if (value < 0) {
        throw new BadCounter();
    }
}

Travaux à faire :

Transaction existante

Après avoir lu la documentation de l'énumération Propagation, créez une méthode qui renvoie la valeur d'un compteur et qui est annotée MANDATORY.

Travaux à faire :

Aucune transaction

Créez une méthode annotée par la propagation NEVER. Vérifiez par un test unitaire le fonctionnement de cette propagation.

À faire : Vous pouvez également tester les autres modes de propagation.

Transaction en lecture seule

Créez une méthode qui utilise l'attribut readOnly de @Transactional. Vérifiez par un test unitaire le fonctionnement de ce mode (lisez soigneusement la documentation).

Isolation

Avec l'attribut isolation de l'annotation @Transactional, nous pouvons piloter le mode d'isolation de la transaction.

Travaux à faire :

Documentation complémentaire.

Créer les transactions manuellement

Nous allons utiliser la classe TransactionTemplate pour programmer manuellement les actions à exécuter dans une transaction. Voila un premier exemple :

    @Autowired
    TransactionTemplate transactionTemplate;

    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    public void addManually(Counter c) {
        transactionTemplate.executeWithoutResult(status -> {
            em.persist(c);
        });
    }

Travaux à faire :