Spring Transaction

Spring Transaction est le module spécifique chargé de l’intégration des transactions. Il offre plusieurs avantages :

  • Il fournit une abstraction au dessus des différentes solutions disponibles dans le monde Java pour les gestion des transactions. Spring Transaction définit l’interface TransactionManager pour unifier ses différentes solutions et propose des classes concrètes pour chacun d’entre-elles.

  • Il se base sur la programmation orientée aspect (AOP) pour gérer la démarcation transactionnelles dans le code de notre application

  • Il permet une gestion déclarative des transactions.

La transaction

La notion de transaction est récurrente dans les systèmes d’information. Par exemple, la plupart des SGBDR (Oracle, MySQL, PostGreSQL…) intègrent un moteur de transaction. Une transaction est définie par le respect de quatre propriétés désignées par l’acronyme ACID :

Atomicité

La transaction garantit que l’ensemble des opérations qui la composent sont soit toutes réalisées avec succès soit aucune n’est conservée.

Cohérence

La transaction garantit qu’elle fait passer le système d’un état valide vers un autre état valide.

Isolation

Deux transactions sont isolées l’une de l’autre. C’est-à-dire que leur exécution simultanée produit le même résultat que si elles avaient été exécutées successivement.

Durabilité

La transaction garantit qu’après son exécution, les modifications qu’elle a apportées au système sont conservées durablement.

Une transaction est définie par un début et une fin qui peut être soit une validation des modifications (commit), soit une annulation des modifications effectuées (rollback). On parle de démarcation transactionnelle pour désigner la portion de code qui doit s’exécuter dans le cadre d’une transaction.

Spring Boot et la configuration automatique

La plupart des applications qui interagissent avec un SGBDR n’incorporent pas de moteur de gestion de transactions. Elles se contentent de déléguer cette gestion au moteur interne du SGBDR. Néanmoins, il existe des systèmes d’information qui supportent les transactions. La gestion des transactions n’est donc que partiellement liée aux systèmes de bases de données. C’est pour cette raison qu’il existe un standard Java dédié à la gestion des transactions : JTA (Java Transaction API). Il s’agit de l’API officielle pour interagir avec un moteur transactionnel. Cependant, cette API n’est pas systématiquement utilisée et il existe des solutions fournies par des technologies particulières. Par exemple, JDBC et JPA fournissent toutes deux leur propre solution et leur propre API pour gérer les transactions impliquant spécifiquement des bases de données. Il existe donc plusieurs solutions pour implémenter la gestion des transactions en Java.

Heureusement, Spring Boot joue pleinement son rôle en rendant, la plupart du temps, la configuration de la gestion des transactions complètement transparente. Spring Boot va se baser sur les dépendances déclarées dans le projet pour savoir quel gestionnaire de transaction (TransactionManager) doit être créé dans le contexte de votre application.

Si votre projet est géré par Maven et que vous avez ajouté une dépendance à :

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

Alors, c’est un bean de type JdbcTransactionManager qui sera ajouté dans votre contexte d’application et c’est l’API JDBC qui sera utilisée pour gérer les transactions.

Si vous avez ajouté une dépendance à :

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

Alors, c’est un bean de type JpaTransactionManager qui sera ajouté dans votre contexte d’application et c’est l’API JPA qui sera utilisée pour gérer les transactions.

Pour activer le support des transactions gérées géré par JTA, il faut ajouter un moteur transactionnel comme Atomikos. Cela se fait toujours par la déclaration d’une dépendance :

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-jta-atomikos</artifactId>
</dependency>

Transaction et couche de service

La démarcation transactionnelle désigne la portion de code au début de laquelle une transaction doit être commencée et à la fin de laquelle la transaction doit être validée (commit) ou annulée (rollback). Dans une approche objet, la méthode est une bonne unité pour déclarer la démarcation d’une transaction : la transaction est démarrée à l’appel de la méthode et elle est validée (ou annulée) au retour de la méthode.

Dans une architecture multi-couches (N-tiers), on identifie généralement une couche de service, appelée aussi couche métier (business tier). Comme une méthode d’une classe de service représente la réalisation d’une fonctionnalité de l’application, il est assez évident que les méthodes de service sont de bonnes candidates pour fournir une démarcation transactionnelle.

Prudence

Pour des raisons discutables, Spring Data JPA choisit d’activer par défaut les transactions sur les méthodes des repositories. Cela signifie que les transactions fonctionnent par défaut mais qu’elles sont validées par les méthodes des repositories, c’est-à-dire dans la couche d’accès aux données. Cela peut entraîner des incohérences de données. Par exemple, la méthode d’un service appelle plusieurs méthodes de repositories pour réaliser une fonctionnalité. Si un problème survient au cours de l’exécution de la méthode de service alors les appels déjà effectués aux repositories ne pourront pas être annulés.

À part pour des applications très simples, la démarcation transactionnelle au niveau de la couche d’accès aux données est systématiquement une mauvaise idée. Si vous utilisez Spring Data JPA, vous devriez désactiver la prise en charge automatique des transactions par les repositories avec l’annotation @EnableJpaRepositories

Désactivation de la transaction au niveau des répositories
package dev.gayerie;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

@SpringBootApplication
@EnableJpaRepositories(enableDefaultTransactions = false)
public class MyApplication {

  public static void main(String[] args) {
    SpringApplication.run(MyApplication.class, args);
  }

}

Une fois cette option désactivée, cela signifie qu’un appel à une méthode de repository qui effectue une modification sur la base de données devra être appelée dans le cadre d’une transaction ou sinon l’appel échouera.

L’annotation @Transactional

Avec Spring Transaction, la démarcation transactionnelle est marquée par l’annotation @Transactional que l’on ajoute sur des méthodes.

Note

Il existe deux annotations @Transactional : celle fournie par le Spring Framework (org.springframework.transaction.annotation.Transactional) et celle fournie par JTA (javax.transaction.Transactional). Le Spring Framework est capable d’utiliser les deux. Elles permettent de configurer les transactions de la même manière mais leurs attributs diffèrent légèrement. Dans cette section, nous présenterons l’annotation @Transactional fournie par le Spring Framework.

package dev.gayerie.service;

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

@Service
public class UserService {

  @Transactional(readOnly = true)
  public User getUser() {
    // ...
  }

  @Transactional
  public void saveUser(User user) {
    // ...
  }
}

L’annotation @Transactional supporte des propriétés afin de pouvoir configurer le support de transaction. Ainsi, l’attribut readOnly permet d’indiquer si la transaction est en lecture seule (false par défaut).

Note

Pour les interactions avec les bases de données, les transactions en lecture seule signifient que l’on n’effectue que des requêtes pour lire des données.

Une transaction en lecture seule est sensée être plus facile à gérer pour un moteur transactionnel et lui permettre d’effectuer des optimisations. Dans la pratique, si votre application se base sur le moteur transactionnel de votre SGBDR, il y a des chances pour que ce moteur ne fasse aucune différence entre une transaction et une transaction en lecture seule.

Pour les méthodes annotées avec @Transactional, une transaction est démarrée à l’appel de cette méthode et est validée au retour de la méthode. Nous n’avons pas de code particulier à écrire pour cela. Spring Transaction utilise la programmation orientée aspect pour instrumenter notre code afin d’obtenir le comportement souhaité.

Gestion déclarative du rollback

Par défaut, une transaction est invalidée (rollback) uniquement si la méthode transactionnelle échoue à cause d’une unchecked exception. Une unchecked exception est une exception qui n’est pas vérifiée par le compilateur (d’où son nom). Il s’agit des classes d’exception qui héritent de RuntimeException ou de Error. Dans tous les autres cas, la transaction est validée (un commit est effectué).

Donc si une méthode se termine par une checked exception, Spring Transaction considère la transaction comme valide et réalise un commit. Cela peut paraître étonnant comme comportement par défaut mais c’est malheureusement celui qui a été choisi.

Si le comportement par défaut ne convient pas, il est possible d’utiliser l’attribut rollbackFor de l’annotation @Transactional pour ajouter une ou plusieurs classes d’exception qui devront produire un rollback.

Utilisation de rollbackFor
package dev.gayerie.service;

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

@Service
public class UserService {

  @Transactional(rollbackFor = UserExistsException.class)
  public void saveUser(User user) throws UserExistsException, NoEmailException {
    // ...
  }
}

Dans l’exemple ci-dessus, la méthode du service peut lever deux exceptions. UserExistsException entraînera un rollback comme spécifié dans l’annotation. Par contre, NoEmailException ne produira pas de rollback s’il s’agit d’une checked exception.

Note

Il existe également l’attribut noRollbackFor pour spécifier un ou des types d’exception pour lesquels on ne souhaite pas faire de rollback s’ils sont levés.

La classe de l’exception donnée dans les attributs rollbackFor et noRollbackFor implique également toutes les classes qui héritent de cette exception. Ainsi si vous voulez être sûr qu’un rollback aura lieu pour n’importe quelle exception, vous pouvez écrire :

@Transactional(rollbackFor = Exception.class)
public void executerService() throws ServiceException {
  // ...
}

Comme toutes les exceptions héritent directement ou indirectement de la classe Exception, la levée de n’importe quelle exception produira un rollback dans la méthode executerService.

Configuration avancée pour les transactions

La propagation

Si une méthode marquée comme transactionnelle est exécutée, comment doit-elle se comporter si aucune transaction n’a encore été créée ? Et au contraire, comment doit-elle se comporter si le code appelant a déjà initié une transaction ? La réponse a ces questions est donnée par la stratégie de propagation de la transaction. On utilise l’attribut propagation de l’annotation @Transactional pour définir la stratégie :

Configuration de la propagation
package dev.gayerie;

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

@Service
public class BusinessService {

  @Transactional(propagation = Propagation.REQUIRED)
  public void doSomething() {
    // ...
  }

}

La stratégie de propagation peut avoir les valeurs suivantes :

REQUIRED (propagation par défaut)

Une transaction doit exister pour l’exécution de la méthode. Si une transaction existe déjà alors l’exécution de la méthode s’inscrit dans cette transaction. Si aucune transaction ne préexiste, une nouvelle est créée automatiquement.

REQUIRES_NEW

Quel que soit le contexte d’exécution, une nouvelle transaction est créée pour l’exécution de la méthode. Si une transaction préexiste, elle est suspendue le temps de l’appel à la méthode. Cela signifie que si la nouvelle transaction est annulée (rollback), cela n’aura aucun impact sur la transaction suspendue qui sera réactivée après l’appel de la méthode.

SUPPORTS

Si une transaction préexiste alors l’appel à la méthode est inclus dans la transaction. Si aucune transaction ne préexiste, alors aucune transaction n’est créée. Ce type de propagation est utile pour une méthode qui n’a pas besoin de transaction pour s’exécuter mais qui peut invalider (rollback) une transaction existante dans certains cas.

NESTED

Si une transaction préexiste, alors une transaction encapsulée (nested) est créée. Cela signifie que si la transaction encapsulée échoue (rollback), toutes les modifications réalisées par la transaction encapsulée seront abandonnées mais la transaction englobante pourra être validée. Si aucune transaction ne préexiste alors une nouvelle transaction est créée. Ce type avancé de propagation est utilisé notamment avec les SGBDR grâce à la notion de point de sauvegarde (savepoint) en JDBC.

MANDATORY

L’appel à la méthode a besoin de s’exécuter dans une transaction. Si aucune transaction ne préexiste, l’appel à cette méthode échoue.

NEVER

L’appel à la méthode ne peut pas se faire dans le cadre d’une transaction. Si une transaction préexiste, l’appel à cette méthode échoue.

NOT_SUPPORTED

L’appel à la méthode ne peut pas se faire dans le cadre d’une transaction. Si une transaction préexiste, cette dernière est suspendue le temps d’exécution de la méthode.

Note

Cette liste des types de propagation est théorique. Le moteur de transaction que vous utilisez dans votre application ne les supporte pas obligatoirement tous.

L’isolation

L’isolation est une des propriétés fondamentales d’une transaction (le I de ACID). Cela signifie que plusieurs transactions s’exécutant simultanément ne devraient pas s’impacter mutuellement (elles doivent être isolées les unes des autres). En pratique, il existe plusieurs niveaux d’isolation. Il est possible de spécifier un niveau d’isolation avec l’attribut isolation de l’annotation @Transactional :

Configuration de l’isolation
package dev.gayerie;

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

@Service
public class BusinessService {

  @Transactional(isolation = Isolation.READ_COMMITTED)
  public void doSomething() {
    // ...
  }

}

Pour comprendre les problèmes que cherchent à adresser chaque niveau d’isolation, il faut comprendre les anomalies qui peuvent survenir lorsque plusieurs transactions s’exécutent simultanément.

Lecture sale (dirty read)

Ce cas survient lorsqu’une transaction peut consulter les données modifiées par une autre transaction qui n’a pas encore été validée (commit). Cette situation s’apparente au fait qu’il n’existe pas d’isolation.

Lectures non répétables (non repeatable reads)

Une transaction lit des données. Une autre transaction modifie ces données et est validée (commit). Si la première transaction relit les données alors ces dernières ont changé. Dans cette situation, il y a un risque lorsqu’on relit des données d’obtenir un résultat différent.

Lectures fantomatiques (phantom reads)

Une transaction lit une série d’enregistrements. Une autre transaction ajoute des enregistrements à cette série et est validée (commit). Si la première transaction relit les enregistrements alors elle voit les nouveaux enregistrements (que l’on qualifie d’enregistrements fantômes).

Les niveaux d’isolation possibles sont les suivants :

DEFAULT (isolation par défaut)

Il ne s’agit pas vraiment d’un niveau d’isolation. Cette valeur indique simplement qu’il faut utiliser le niveau d’isolation du système transactionnel. Dans le cas d’une base de données, il faut utiliser le niveau d’isolation configuré dans la base de données.

READ_UNCOMMITED

Ce niveau autorise la lecture sale, les lectures non répétables et les lectures fantomatiques. Ce niveau est en fait une désactivation de l’isolation.

READ_COMMITED

Ce niveau protège des lectures sales mais il autorise les lectures non répétables et les lectures fantomatiques.

REPEATABLE_READ

Ce niveau protège des lectures sales et des lectures non répétables mais il autorise les lectures fantomatiques.

SERIALIZABLE

Ce niveau protège des lectures sales, des lectures non répétables et des lectures fantomatiques. Il s’agit d’un niveau d’isolation complet.

Le choix d’un niveau d’isolation peut parfois être conditionné par les besoins d’une fonctionnalité. Mais le plus souvent, il s’agit d’un compromis entre les performances et un niveau acceptable pour le fonctionnement de l’application. En effet, plus le niveau d’isolation est élevé et plus un système transactionnel doit utiliser de ressources pour le garantir. Par exemple, le niveau SERIALIZABLE implique que les données doivent être mises en cache pour pouvoir être relues sans risque.

Note

Cette liste des niveaux d’isolation est théorique. Le moteur de transaction que vous utilisez dans votre application ne les supporte pas obligatoirement tous.

Configuration des transactions sans Spring Boot

Si vous ne souhaitez pas utiliser Spring Boot, vous devrez réaliser la configuration d’un gestionnaire de transaction dans votre application.

Pour signaler que vous voulez activer le support des transactions, vous devez utiliser l’annotation @EnableTransactionManagement et vous assurer qu’il existe dans votre contexte d’application un bean implémentant l’interface TransactionManager.

Configuration d’un gestionnaire de transaction
package dev.gayerie;

import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.TransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@Configuration
@ComponentScan
@EnableTransactionManagement
public class MyApplication {

  @Bean
  public TransactionManager transactionManager() {
    // ...
  }

  public static void main(String[] args) {
    try(AnnotationConfigApplicationContext appCtx =
                 new AnnotationConfigApplicationContext(MyApplication.class)) {
    // ...
    }
  }
}

Vous n’aurez certainement pas à créer vous-même une classe implémentant cette interface car différents modules du Spring Framework fournissent déjà des classes implémentant l’interface TransactionManager pour gérer les principales configurations.

Quelques exemples de classes implémentant TransactionManager

Classe

Gestionnaire de transactions

Module Spring

DataSourceTransactionManager

JDBC

Spring JDBC

JtaTransactionManager

JTA

Spring Transaction

JpaTransactionManager

JPA

Spring ORM

Si nous voulons utiliser un gestionnaire de transaction compatible avec JPA, nous devons utiliser la classe JpaTransactionManager. Nous avons besoin d’une instance d’un EntityManagerFactory pour pouvoir créer un objet de cette classe. Spring ORM fournit par ailleurs la classe LocalEntityManagerFactoryBean qui encapsule pour nous la création d’un EntityManagerFactory à partir du nom de l’unité de persistance (persistence unit). Nous pouvons donc ajouter un bean de ce type dans notre contexte d’application. Même si cela n’est pas obligatoire, il est conseillé de nommer ce bean entityManagerFactory.

Notre exemple de création d’un contexte d’application Spring devient :

Configuration d’un gestionnaire de transaction JPA
package dev.gayerie;

import javax.persistence.EntityManagerFactory;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalEntityManagerFactoryBean;
import org.springframework.transaction.TransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@Configuration
@ComponentScan
@EnableTransactionManagement
public class MyApplication {

  @Bean
  public LocalEntityManagerFactoryBean entityManagerFactory() {
    LocalEntityManagerFactoryBean factoryBean = new LocalEntityManagerFactoryBean();
    factoryBean.setPersistenceUnitName("database");
    return factoryBean;
  }

  @Bean
  public TransactionManager transactionManager(EntityManagerFactory emf) {
    return new JpaTransactionManager(emf);
  }

  public static void main(String[] args) {
    try(AnnotationConfigApplicationContext appCtx =
                 new AnnotationConfigApplicationContext(MyApplication.class)) {
      // ...
    }
  }
}

Note

Pour être compilable, la configuration ci-dessus requière une dépendance au module Spring ORM. Vous aurez également besoin d’une implémentation de JPA (par exemple Hibernate) ainsi que le pilote de base de données.

Dépendances dans le pom.xml pour une projet Maven
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-orm</artifactId>
  <version>5.3.1</version>
</dependency>
<dependency>
  <groupId>org.hibernate</groupId>
  <artifactId>hibernate-core</artifactId>
  <version>5.4.9.Final</version>
</dependency>
<dependency>
  <groupId>mysql</groupId>
  <artifactId>mysql-connector-java</artifactId>
  <version>8.0.21</version>
</dependency>