Gestion programmatique des transactions au printemps

1. Vue d'ensemble

L' annotation @Transactional de Spring fournit une belle API déclarative pour marquer les limites transactionnelles.

En coulisse, un aspect s'occupe de créer et de maintenir les transactions telles qu'elles sont définies dans chaque occurrence de l' annotation @Transactional . Cette approche permet de dissocier facilement notre logique métier de base des préoccupations transversales telles que la gestion des transactions.

Dans ce tutoriel, nous verrons que ce n'est pas toujours la meilleure approche. Nous explorerons les alternatives programmatiques fournies par Spring, comme TransactionTemplate , et nos raisons de les utiliser.

2. Problèmes au paradis

Supposons que nous mélangions deux types d'E / S différents dans un service simple:

@Transactional public void initialPayment(PaymentRequest request) { savePaymentRequest(request); // DB callThePaymentProviderApi(request); // API updatePaymentState(request); // DB saveHistoryForAuditing(request); // DB }

Ici, nous avons quelques appels de base de données à côté d'un appel d'API REST éventuellement coûteux. À première vue, il peut être judicieux de rendre toute la méthode transactionnelle, car nous souhaitons peut-être utiliser un EntityManager pour effectuer toute l'opération de manière atomique.

Cependant, si cette API externe prend plus de temps que d'habitude à répondre, pour une raison quelconque, nous pourrions bientôt manquer de connexions à la base de données!

2.1. La nature dure de la réalité

Voici ce qui se passe lorsque nous appelons la méthode initialPayment :

  1. L'aspect transactionnel crée un nouvel EntityManager et démarre une nouvelle transaction - donc, il emprunte une connexion du pool de connexions
  2. Après le premier appel à la base de données, il appelle l'API externe tout en conservant la connexion empruntée
  3. Enfin, il utilise cette connexion pour effectuer les appels de base de données restants

Si l'appel d'API répond très lentement pendant un certain temps, cette méthode monopoliserait la connexion empruntée en attendant la réponse .

Imaginez que pendant cette période, nous recevions une rafale d'appels à la méthode initialPayment . Ensuite, toutes les connexions peuvent attendre une réponse de l'appel d'API. C'est pourquoi nous pouvons manquer de connexions de base de données - à cause d'un service back-end lent!

Le mélange des E / S de base de données avec d'autres types d'E / S dans un contexte transactionnel est une mauvaise odeur. Ainsi, la première solution pour ce genre de problèmes est de séparer ces types d'E / S tout à fait . Si, pour une raison quelconque, nous ne pouvons pas les séparer, nous pouvons toujours utiliser les API Spring pour gérer les transactions manuellement.

3. Utilisation de TransactionTemplate

TransactionTemplate fournit un ensemble d'API basées sur le rappel pour gérer les transactions manuellement. Pour l'utiliser, nous devons d'abord l'initialiser avec un PlatformTransactionManager.

Par exemple, nous pouvons configurer ce modèle en utilisant l'injection de dépendances:

// test annotations class ManualTransactionIntegrationTest { @Autowired private PlatformTransactionManager transactionManager; private TransactionTemplate transactionTemplate; @BeforeEach void setUp() { transactionTemplate = new TransactionTemplate(transactionManager); } // omitted }

Le PlatformTransactionManager aide le modèle pour créer, engager ou transactions rollback.

Lors de l'utilisation de Spring Boot, un bean approprié de type PlatformTransactionManager sera automatiquement enregistré, nous devons donc simplement l'injecter. Sinon, nous devons enregistrer manuellement un bean PlatformTransactionManager .

3.1. Exemple de modèle de domaine

Désormais, à titre de démonstration, nous allons utiliser un modèle de domaine de paiement simplifié. Dans ce domaine simple, nous avons une entité de paiement pour encapsuler les détails de chaque paiement:

@Entity public class Payment { @Id @GeneratedValue private Long id; private Long amount; @Column(unique = true) private String referenceNumber; @Enumerated(EnumType.STRING) private State state; // getters and setters public enum State { STARTED, FAILED, SUCCESSFUL } }

De plus, nous exécuterons tous les tests dans une classe de test, en utilisant la bibliothèque Testcontainers pour exécuter une instance PostgreSQL avant chaque cas de test:

@DataJpaTest @Testcontainers @ActiveProfiles("test") @AutoConfigureTestDatabase(replace = NONE) @Transactional(propagation = NOT_SUPPORTED) // we're going to handle transactions manually public class ManualTransactionIntegrationTest { @Autowired private PlatformTransactionManager transactionManager; @Autowired private EntityManager entityManager; @Container private static PostgreSQLContainer pg = initPostgres(); private TransactionTemplate transactionTemplate; @BeforeEach public void setUp() { transactionTemplate = new TransactionTemplate(transactionManager); } // tests private static PostgreSQLContainer initPostgres() { PostgreSQLContainer pg = new PostgreSQLContainer("postgres:11.1") .withDatabaseName("baeldung") .withUsername("test") .withPassword("test"); pg.setPortBindings(singletonList("54320:5432")); return pg; } }

3.2. Transactions avec résultats

Le TransactionTemplate propose une méthode appelée execute, qui peut exécuter n'importe quel bloc de code donné à l'intérieur d'une transaction, puis retourner un résultat:

@Test void givenAPayment_WhenNotDuplicate_ThenShouldCommit() { Long id = transactionTemplate.execute(status -> { Payment payment = new Payment(); payment.setAmount(1000L); payment.setReferenceNumber("Ref-1"); payment.setState(Payment.State.SUCCESSFUL); entityManager.persist(payment); return payment.getId(); }); Payment payment = entityManager.find(Payment.class, id); assertThat(payment).isNotNull(); }

Ici, nous conservons une nouvelle instance de paiement dans la base de données, puis nous renvoyons son identifiant généré automatiquement.

Semblable à l'approche déclarative, le modèle peut nous garantir l'atomicité . Autrement dit, si l'une des opérations à l'intérieur d'une transaction échoue, elleles annule tous:

@Test void givenTwoPayments_WhenRefIsDuplicate_ThenShouldRollback() { try { transactionTemplate.execute(status -> { Payment first = new Payment(); first.setAmount(1000L); first.setReferenceNumber("Ref-1"); first.setState(Payment.State.SUCCESSFUL); Payment second = new Payment(); second.setAmount(2000L); second.setReferenceNumber("Ref-1"); // same reference number second.setState(Payment.State.SUCCESSFUL); entityManager.persist(first); // ok entityManager.persist(second); // fails return "Ref-1"; }); } catch (Exception ignored) {} assertThat(entityManager.createQuery("select p from Payment p").getResultList()).isEmpty(); }

Étant donné que le deuxième referenceNumber est un doublon, la base de données rejette la deuxième opération persistante, provoquant l'annulation de l'ensemble de la transaction. Par conséquent, la base de données ne contient aucun paiement après la transaction. Il est également possible de déclencher manuellement une annulation en appelant setRollbackOnly () sur TransactionStatus:

@Test void givenAPayment_WhenMarkAsRollback_ThenShouldRollback() { transactionTemplate.execute(status -> { Payment payment = new Payment(); payment.setAmount(1000L); payment.setReferenceNumber("Ref-1"); payment.setState(Payment.State.SUCCESSFUL); entityManager.persist(payment); status.setRollbackOnly(); return payment.getId(); }); assertThat(entityManager.createQuery("select p from Payment p").getResultList()).isEmpty(); }

3.3. Transactions sans résultats

Si nous n'avons pas l'intention de retourner quoi que ce soit de la transaction, nous pouvons utiliser la classe de rappel TransactionCallbackWithoutResult :

@Test void givenAPayment_WhenNotExpectingAnyResult_ThenShouldCommit() { transactionTemplate.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus status) { Payment payment = new Payment(); payment.setReferenceNumber("Ref-1"); payment.setState(Payment.State.SUCCESSFUL); entityManager.persist(payment); } }); assertThat(entityManager.createQuery("select p from Payment p").getResultList()).hasSize(1); }

3.4. Configurations de transaction personnalisées

Jusqu'à présent, nous avons utilisé le TransactionTemplate avec sa configuration par défaut. Bien que cette valeur par défaut soit largement suffisante la plupart du temps, il est toujours possible de modifier les paramètres de configuration.

Par exemple, nous pouvons définir le niveau d'isolation des transactions:

transactionTemplate = new TransactionTemplate(transactionManager); transactionTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ);

De même, nous pouvons modifier le comportement de propagation des transactions:

transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);

Ou nous pouvons définir un délai d'expiration, en secondes, pour la transaction:

transactionTemplate.setTimeout(1000);

Il est même possible de bénéficier d'optimisations pour les transactions en lecture seule:

transactionTemplate.setReadOnly(true);

Quoi qu'il en soit, une fois que nous avons créé un TransactionTemplate avec une configuration, toutes les transactions utiliseront cette configuration pour s'exécuter. Donc, si nous avons besoin de plusieurs configurations, nous devons créer plusieurs instances de modèle .

4. Utilisation de PlatformTransactionManager

In addition to the TransactionTemplate, we can use an even lower-level API like PlatformTransactionManager to manage transactions manually. Quite interestingly, both @Transactional and TransactionTemplate use this API to manage their transactions internally.

4.1. Configuring Transactions

Before using this API, we should define how our transaction is going to look. For example, we can set a three-second timeout with the repeatable read transaction isolation level:

DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); definition.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ); definition.setTimeout(3); 

Transaction definitions are similar to TransactionTemplate configurations. However, we can use multiple definitions with just one PlatformTransactionManager.

4.2. Maintaining Transactions

Après avoir configuré notre transaction, nous pouvons gérer les transactions par programme:

@Test void givenAPayment_WhenUsingTxManager_ThenShouldCommit() { // transaction definition TransactionStatus status = transactionManager.getTransaction(definition); try { Payment payment = new Payment(); payment.setReferenceNumber("Ref-1"); payment.setState(Payment.State.SUCCESSFUL); entityManager.persist(payment); transactionManager.commit(status); } catch (Exception ex) { transactionManager.rollback(status); } assertThat(entityManager.createQuery("select p from Payment p").getResultList()).hasSize(1); }

5. Conclusion

Dans ce didacticiel, nous avons d'abord vu quand il fallait choisir la gestion des transactions par programme plutôt que l'approche déclarative. Ensuite, en introduisant deux API différentes, nous avons appris à créer, valider ou annuler manuellement une transaction donnée.

Comme d'habitude, l'exemple de code est disponible over sur GitHub.