Depuis jOOQ 3.4, nous avons une API qui simplifie la logique transactionnelle au-dessus de JDBC dans jOOQ, et à partir de jOOQ 3.17 et #13502, une API équivalente sera également disponible au-dessus de R2DBC, pour les applications réactives.
Comme pour tout jOOQ, les transactions sont implémentées à l’aide d’une logique explicite basée sur l’API. La logique implicite implémentée dans Jakarta EE et Spring fonctionne très bien pour ces plates-formes, qui utilisent des annotations et des aspects partout, mais le paradigme basé sur les annotations ne correspond pas bien à jOOQ.
Cet article montre comment jOOQ a conçu l’API de transaction et pourquoi le Spring Propagation.NESTED
la sémantique est la valeur par défaut dans jOOQ.
Suivre les valeurs par défaut de JDBC
Dans JDBC (autant que dans R2DBC), une instruction autonome est toujours non transactionnelle ou auto-validante. Il en va de même pour jOOQ. Si vous transmettez une connexion JDBC non transactionnelle à jOOQ, une requête comme celle-ci sera également validée automatiquement :
ctx.insertInto(BOOK)
.columns(BOOK.ID, BOOK.TITLE)
.values(1, "Beginning jOOQ")
.values(2, "jOOQ Masterclass")
.execute();
Jusqu’ici tout va bien, cela a été une valeur par défaut raisonnable dans la plupart des API. Mais généralement, vous ne vous engagez pas automatiquement. Vous écrivez la logique transactionnelle.
Lambda transactionnels
Si vous souhaitez exécuter plusieurs instructions dans une seule transaction, vous pouvez écrire ceci dans jOOQ :
// The transaction() call wraps a transaction
ctx.transaction(trx -> {
// The whole lambda expression is the transaction's content
trx.dsl()
.insertInto(AUTHOR)
.columns(AUTHOR.ID, AUTHOR.FIRST_NAME, AUTHOR.LAST_NAME)
.values(1, "Tayo", "Koleoso")
.values(2, "Anghel", "Leonard")
.execute();
trx.dsl()
.insertInto(BOOK)
.columns(BOOK.ID, BOOK.AUTHOR_ID, BOOK.TITLE)
.values(1, 1, "Beginning jOOQ")
.values(2, 2, "jOOQ Masterclass")
.execute();
// If the lambda is completed normally, we commit
// If there's an exception, we rollback
});
Le modèle mental est exactement le même qu’avec Jakarta EE et Spring @Transactional
aspects. L’achèvement normal valide implicitement, l’achèvement exceptionnel annule implicitement. Le lambda entier est une “unité de travail” atomique, ce qui est assez intuitif.
Vous êtes propriétaire de votre flux de contrôle
S’il y a une exception récupérable dans votre code, vous êtes autorisé à le gérer avec élégance, et la gestion des transactions de jOOQ ne le remarquera pas. Par exemple:
ctx.transaction(trx -> {
try {
trx.dsl()
.insertInto(AUTHOR)
.columns(AUTHOR.ID, AUTHOR.FIRST_NAME, AUTHOR.LAST_NAME)
.values(1, "Tayo", "Koleoso")
.values(2, "Anghel", "Leonard")
.execute();
}
catch (DataAccessException e) {
// Re-throw all non-constraint violation exceptions
if (e.sqlStateClass() != C23_INTEGRITY_CONSTRAINT_VIOLATION)
throw e;
// Ignore if we already have the authors
}
// If we had a constraint violation above, we can continue our
// work here. The transaction isn't rolled back
trx.dsl()
.insertInto(BOOK)
.columns(BOOK.ID, BOOK.AUTHOR_ID, BOOK.TITLE)
.values(1, 1, "Beginning jOOQ")
.values(2, 2, "jOOQ Masterclass")
.execute();
});
Il en va de même pour la plupart des autres API, y compris Spring. Si Spring n’est pas au courant de vos exceptions, il n’interprétera pas ces exceptions pour la logique transactionnelle, ce qui est parfaitement logique. Après tout, n’importe quelle bibliothèque tierce peut lancer et intercepter des exceptions internes sans toi remarquant, alors pourquoi Spring devrait-il le remarquer.
Propagation des transactions
Jakarta EE et Spring offrent une variété de modes de propagation des transactions (TxType
à Jakarta EE, Propagation
au printemps). La valeur par défaut dans les deux est REQUIRED
. J’ai essayé de rechercher pourquoi REQUIRED
est la valeur par défaut, et non NESTED
, ce que je trouve beaucoup plus logique et correct, comme je l’expliquerai plus tard. Si vous le savez, faites-le moi savoir sur Twitter ou dans les commentaires :
Mon hypothèse pour ces API est
NESTED
a besoinSAVEPOINT
prise en charge, qui n’est pas disponible dans tous les RDBMS prenant en charge les transactionsREQUIRED
éviteSAVEPOINT
surcharge, ce qui peut être un problème si vous n’avez pas réellement besoin d’imbriquer les transactions (bien que nous puissions affirmer que l’API est alors annotée à tort avec trop d’accessoires@Transactional
annotations. Tout comme vous ne devriez pas courir sans réfléchirSELECT *
vous ne devriez pas tout annoter sans y réfléchir suffisamment.)- Il n’est pas improbable que dans le code utilisateur Spring, chaque la méthode de service est simplement annotée aveuglément avec
@Transactional
sans trop réfléchir à ce sujet (comme pour la gestion des erreurs), puis en effectuant des transactionsREQUIRED
au lieu deNESTED
serait juste une valeur par défaut plus pratique “pour le faire fonctionner”. Ce serait en faveur deREQUIRED
étant plus un défaut accidentel qu’un défaut bien choisi. - JPA ne peut pas vraiment bien fonctionner avec
NESTED
transactions, car les entités deviennent corrompues (voir le commentaire de Vlad à ce sujet). À mon avis, c’est juste un bogue ou une fonctionnalité manquante, bien que je puisse voir que l’implémentation de la fonctionnalité est très complexe et n’en vaut peut-être pas la peine dans JPA.
Ainsi, pour toutes ces raisons purement techniques, il semble compréhensible que des API comme Jakarta EE ou Spring ne fassent pas NESTED
la valeur par défaut (Jakarta EE ne le supporte même pas du tout).
Mais c’est jOOQ et jOOQ a toujours pris du recul pour réfléchir à la façon dont les choses devrait êtreplutôt que d’être impressionné par la façon dont les choses sont.
Quand tu penses au code suivant :
@Transactional
void tx() {
tx1();
try {
tx2();
}
catch (Exception e) {
log.info(e);
}
continueWorkOnTx1();
}
@Transactional
void tx1() { ... }
@Transactional
void tx2() { ... }
L’intention du programmeur qui a écrit ce code ne peut être qu’une chose :
- Démarrez une transaction globale dans
tx()
- Effectuez un travail transactionnel imbriqué dans
tx1()
- Essayez d’effectuer d’autres tâches transactionnelles imbriquées dans
tx2()
- Si
tx2()
réussit, très bien, passez à autre chose - Si
tx2()
échoue, enregistrez simplement l’erreur,ROLLBACK
à avanttx2()
et avance
- Si
- Indépendamment de
tx2()
continuez à travailler avectx1()
‘s (et peut-être aussitx2()
‘s) résultat
Mais ce n’est pas ce REQUIRED
, qui est la valeur par défaut dans Jakarta EE et Spring, fera l’affaire. Il ne fera que revenir en arrière tx2()
et tx1()
laissant la transaction externe dans un état très étrange, ce qui signifie que continueWorkOnTx1()
échouera. Mais doit-il vraiment échouer ? tx2()
était censé être une unité atomique de travail, indépendamment de qui l’appelait. Ce n’est pas le cas par défaut, donc le Exception e
devoir être propagé. La seule chose que l’on puisse faire dans catch
bloquer, avant de relancer obligatoirement, est de nettoyer certaines ressources ou de faire de la journalisation. (Bonne chance pour vous assurer que chaque développeur respecte ces règles !)
Et, une fois qu’on relance obligatoirement, REQUIRED
devient effectivement le même que NESTED
, sauf qu’il n’y a plus de points de sauvegarde. Ainsi, la valeur par défaut est :
- Le même que
NESTED
dans le chemin heureux - Bizarre dans le chemin pas si heureux
Ce qui est un argument de poids en faveur de faire NESTED
la valeur par défaut, au moins dans jOOQ. Maintenant le discussion twitter liée digressé un peu dans les préoccupations architecturales de pourquoi:
NESTED
est une mauvaise idée ou ne fonctionne pas partout- Le verrouillage pessimiste est une mauvaise idée
- etc.
Je ne suis pas en désaccord avec bon nombre de ces arguments. Pourtant, en se concentrant seul sur le code listé, et en me mettant dans la peau d’un développeur de bibliothèque, qu’est-ce que le programmeur aurait pu vouloir par ce code ? Je ne vois rien d’autre que le printemps NESTED
sémantique des transactions. Je ne peux tout simplement pas.
jOOQ implémente la sémantique NESTED
Pour les raisons ci-dessus, les transactions de jOOQ n’implémentent que Spring’s NESTED
sémantique si les points de sauvegarde sont pris en charge, ou échouent complètement à l’imbrication s’ils ne sont pas pris en charge (étrangement, ce n’est pas une option dans Jakarta EE et Spring, car ce serait une autre valeur par défaut raisonnable). La différence avec Spring étant, encore une fois, que tout est fait par programme et explicitement, plutôt que d’utiliser implicitement des aspects.
Par exemple:
ctx.transaction(trx -> {
trx.dsl().transaction(trx1 -> {
// ..
});
try {
trx.dsl().transaction(trx2 -> {
// ..
});
}
catch (Exception e) {
log.info(e);
}
continueWorkOnTrx1(trx);
});
Si trx2
échoue avec une exception, seulement trx2
est annulé. Pas trx1
. Bien sûr, vous pouvez toujours relancer l’exception pour tout annuler. Mais la position ici est que si vous, le programmeur, dites à jOOQ d’exécuter une transaction imbriquée, eh bien, jOOQ obéira, car c’est ce que vous voulez.
Vous ne pourriez pas vouloir autre chose, car alors, vous n’imbriquerez pas la transaction en premier lieu, non ?
Opérations R2DBC
Comme mentionné précédemment, jOOQ 3.17 prendra (enfin) en charge les transactions également dans R2DBC. La sémantique est exactement la même qu’avec les API de blocage de JDBC, sauf que tout est maintenant un Publisher
. Ainsi, vous pouvez maintenant écrire :
Flux<?> flux = Flux.from(ctx.transactionPublisher(trx -> Flux
.from(trx.dsl()
.insertInto(AUTHOR)
.columns(AUTHOR.ID, AUTHOR.FIRST_NAME, AUTHOR.LAST_NAME)
.values(1, "Tayo", "Koleoso")
.values(2, "Anghel", "Leonard"))
.thenMany(trx.dsl()
.insertInto(BOOK)
.columns(BOOK.ID, BOOK.AUTHOR_ID, BOOK.TITLE)
.values(1, 1, "Beginning jOOQ")
.values(2, 2, "jOOQ Masterclass"))
}));
L’exemple utilise le réacteur comme implémentation d’API de flux réactifs, mais vous pouvez également utiliser RxJava, Mutiny ou autre. L’exemple fonctionne exactement de la même manière que celui de JDBC, initialement.
L’imbrication fonctionne également de la même manière, de la manière habituelle, réactive (c’est-à-dire plus laborieuse):
Flux<?> flux = Flux.from(ctx.transactionPublisher(trx -> Flux
.from(trx.dsl().transactionPublisher(trx1 -> { ... }))
.thenMany(Flux
.from(trx.dsl().transactionPublisher(trx2 -> { ... }))
.onErrorContinue((e, t) -> log.info(e)))
.thenMany(continueWorkOnTrx1(trx))
));
Le séquençage utilisant thenMany()
n’est qu’un exemple. Vous pouvez avoir besoin de primitives de création de flux entièrement différentes, qui ne sont pas strictement liées à la gestion des transactions.
Conclusion
L’imbrication des transactions est parfois utile. Avec jOOQ, la propagation des transactions est beaucoup moins un sujet qu’avec Jakarta EE ou Spring car tout ce que vous faites est généralement explicite, et en tant que tel, vous n’imbriquez pas accidentellement des transactions, quand vous le faites, vous le faites intentionnellement. C’est pourquoi jOOQ a opté pour une valeur par défaut différente de celle de Spring, et celle que Jakarta EE ne prend pas du tout en charge. Le Propagation.NESTED
la sémantique, qui est un moyen puissant de garder la logique laborieuse liée aux points de sauvegarde hors de votre code.