Si vous avez déjà consacré beaucoup de temps à la gestion des transactions de la base de données Django, vous savez à quel point cela peut être déroutant. Dans le passé, la documentation fournissait un peu de profondeur, mais la compréhension ne venait que par la construction et l'expérimentation.
Il y avait une pléthore de décorateurs avec lesquels travailler, comme commit_on_success
, commit_manually
, commit_unless_managed
, rollback_unless_managed
, enter_transaction_management
, leave_transaction_management
, Juste pour en nommer quelques-uns. Heureusement, avec Django 1.6, tout s'en va. Vous n'avez vraiment besoin de connaître que quelques fonctions maintenant. Et nous y arriverons dans une seconde. Tout d'abord, nous aborderons ces sujets :
- Qu'est-ce que la gestion des transactions ?
- Quel est le problème avec la gestion des transactions avant Django 1.6 ?
Avant de vous lancer :
- Qu'y a-t-il de bien dans la gestion des transactions dans Django 1.6 ?
Et ensuite traitant d'un exemple détaillé :
- Exemple de bande
- Transactions
- La méthode recommandée
- Utiliser un décorateur
- Transaction par requête HTTP
- Points de sauvegarde
- Transactions imbriquées
Qu'est-ce qu'une transaction ?
Selon SQL-92, "Une transaction SQL (parfois simplement appelée "transaction") est une séquence d'exécutions d'instructions SQL qui est atomique en ce qui concerne la récupération". En d'autres termes, toutes les instructions SQL sont exécutées et validées ensemble. De même, lorsqu'elles sont annulées, toutes les instructions sont annulées ensemble.
Par exemple :
# START
note = Note(title="my first note", text="Yay!")
note = Note(title="my second note", text="Whee!")
address1.save()
address2.save()
# COMMIT
Ainsi, une transaction est une seule unité de travail dans une base de données. Et cette unité de travail unique est délimitée par une transaction de démarrage, puis un commit ou une annulation explicite.
Quel est le problème avec la gestion des transactions avant Django 1.6 ?
Afin de répondre pleinement à cette question, nous devons aborder la manière dont les transactions sont traitées dans la base de données, les bibliothèques clientes et dans Django.
Bases de données
Chaque instruction d'une base de données doit s'exécuter dans une transaction, même si la transaction ne comprend qu'une seule instruction.
La plupart des bases de données ont un AUTOCOMMIT
paramètre, qui est généralement défini sur True par défaut. Ce AUTOCOMMIT
encapsule chaque instruction dans une transaction qui est immédiatement validée si l'instruction réussit. Bien sûr, vous pouvez appeler manuellement quelque chose comme START_TRANSACTION
qui suspendra temporairement le AUTOCOMMIT
jusqu'à ce que vous appeliez COMMIT_TRANSACTION
ou ROLLBACK
.
Cependant, ce qu'il faut retenir ici, c'est que le AUTOCOMMIT
paramètre applique un commit implicite après chaque instruction .
Bibliothèques clientes
Ensuite, il y a les bibliothèques clientes Python comme sqlite3 et mysqldb, qui permettent aux programmes Python de s'interfacer avec les bases de données elles-mêmes. Ces bibliothèques suivent un ensemble de normes sur la façon d'accéder et d'interroger les bases de données. Cette norme, DB API 2.0, est décrite dans la PEP 249. Bien qu'elle puisse donner lieu à une lecture légèrement sèche, un point important à retenir est que la PEP 249 stipule que la base de données AUTOCOMMIT
doit être OFF par défaut.
Cela est clairement en conflit avec ce qui se passe dans la base de données :
- Les instructions SQL doivent toujours s'exécuter dans une transaction, que la base de données ouvre généralement pour vous via
AUTOCOMMIT
. - Cependant, selon la PEP 249, cela ne devrait pas arriver.
- Les bibliothèques clientes doivent refléter ce qui se passe dans la base de données, mais comme elles ne sont pas autorisées à activer
AUTOCOMMIT
activées par défaut, elles encapsulent simplement vos instructions SQL dans une transaction, tout comme la base de données.
D'accord. Reste avec moi un peu plus longtemps.
Django
Entrez Django. Django a aussi son mot à dire sur la gestion des transactions. Dans Django 1.5 et versions antérieures, Django fonctionnait essentiellement avec une transaction ouverte et auto-commit cette transaction lorsque vous écriviez des données dans la base de données. Donc, chaque fois que vous appelez quelque chose comme model.save()
ou model.update()
, Django a généré les instructions SQL appropriées et validé la transaction.
Toujours dans Django 1.5 et versions antérieures, il était recommandé d'utiliser le TransactionMiddleware
pour lier les transactions aux requêtes HTTP. A chaque demande correspondait une transaction. Si la réponse est renvoyée sans exception, Django validera la transaction mais si votre fonction de vue génère une erreur, ROLLBACK
serait appelé. Cela a en effet désactivé AUTOCOMMIT
. Si vous vouliez une gestion standard des transactions de type autocommit au niveau de la base de données, vous deviez gérer les transactions vous-même - généralement en utilisant un décorateur de transaction sur votre fonction d'affichage, comme @transaction.commit_manually
, ou @transaction.commit_on_success
.
Respirez. Ou deux.
Qu'est-ce que cela signifie ?
Oui, il se passe beaucoup de choses là-bas, et il s'avère que la plupart des développeurs veulent juste les autocommits standard au niveau de la base de données - ce qui signifie que les transactions restent dans les coulisses, faisant leur travail, jusqu'à ce que vous ayez besoin de les ajuster manuellement.
Qu'y a-t-il de bien dans la gestion des transactions dans Django 1.6 ?
Maintenant, bienvenue dans Django 1.6. Faites de votre mieux pour oublier tout ce dont nous venons de parler et rappelez-vous simplement que dans Django 1.6, vous utilisez la base de données AUTOCOMMIT
et gérez les transactions manuellement si nécessaire. Essentiellement, nous avons un modèle beaucoup plus simple qui fait essentiellement ce pour quoi la base de données a été conçue en premier lieu.
Assez de théorie. Codez.
Exemple de bande
Ici, nous avons cet exemple de fonction d'affichage qui gère l'enregistrement d'un utilisateur et l'appel à Stripe pour le traitement des cartes de crédit.
def register(request):
user = None
if request.method == 'POST':
form = UserForm(request.POST)
if form.is_valid():
customer = Customer.create("subscription",
email = form.cleaned_data['email'],
description = form.cleaned_data['name'],
card = form.cleaned_data['stripe_token'],
plan="gold",
)
cd = form.cleaned_data
try:
user = User.create(cd['name'], cd['email'], cd['password'],
cd['last_4_digits'])
if customer:
user.stripe_id = customer.id
user.save()
else:
UnpaidUsers(email=cd['email']).save()
except IntegrityError:
form.addError(cd['email'] + ' is already a member')
else:
request.session['user'] = user.pk
return HttpResponseRedirect('/')
else:
form = UserForm()
return render_to_response(
'register.html',
{
'form': form,
'months': range(1, 12),
'publishable': settings.STRIPE_PUBLISHABLE,
'soon': soon(),
'user': user,
'years': range(2011, 2036),
},
context_instance=RequestContext(request)
)
Cette vue appelle d'abord Customer.create
qui appelle en fait Stripe pour gérer le traitement des cartes de crédit. Ensuite, nous créons un nouvel utilisateur. Si nous recevons une réponse de Stripe, nous mettons à jour le client nouvellement créé avec le stripe_id
. Si nous ne récupérons pas un client (Stripe est en panne), nous ajouterons une entrée au UnpaidUsers
table avec l'e-mail des clients nouvellement créés, afin que nous puissions leur demander de réessayer leurs détails de carte de crédit plus tard.
L'idée est que même si Stripe est en panne, l'utilisateur peut toujours s'inscrire et commencer à utiliser notre site. Nous leur redemanderons ultérieurement les informations de carte de crédit.
Je comprends que cela peut être un exemple un peu artificiel, et ce n'est pas la façon dont j'implémenterais une telle fonctionnalité si je devais le faire, mais le but est de démontrer les transactions.
En avant. Penser aux transactions, et garder à l'esprit que par défaut Django 1.6 nous donne AUTOCOMMIT
comportement pour notre base de données, examinons un peu plus le code lié à la base de données.
cd = form.cleaned_data
try:
user = User.create(
cd['name'], cd['email'],
cd['password'], cd['last_4_digits'])
if customer:
user.stripe_id = customer.id
user.save()
else:
UnpaidUsers(email=cd['email']).save()
except IntegrityError:
# ...
Pouvez-vous repérer des problèmes? Eh bien, que se passe-t-il si UnpaidUsers(email=cd['email']).save()
la ligne échoue ?
Vous aurez un utilisateur, enregistré dans le système, que le système pense avoir vérifié sa carte de crédit, mais en réalité, il n'a pas vérifié la carte.
Nous ne voulons qu'un des deux résultats :
- L'utilisateur est créé (dans la base de données) et a un
stripe_id
. - L'utilisateur est créé (dans la base de données) et n'a pas de
stripe_id
ET une ligne associée dans leUnpaidUsers
table avec la même adresse e-mail est générée.
Ce qui signifie que nous voulons que les deux instructions de base de données distinctes soient toutes les deux validées ou les deux annulées. Un cas parfait pour l'humble transaction.
Tout d'abord, écrivons quelques tests pour vérifier que les choses se comportent comme nous le souhaitons.
@mock.patch('payments.models.UnpaidUsers.save', side_effect = IntegrityError)
def test_registering_user_when_strip_is_down_all_or_nothing(self, save_mock):
#create the request used to test the view
self.request.session = {}
self.request.method='POST'
self.request.POST = {'email' : '[email protected]',
'name' : 'pyRock',
'stripe_token' : '...',
'last_4_digits' : '4242',
'password' : 'bad_password',
'ver_password' : 'bad_password',
}
#mock out stripe and ask it to throw a connection error
with mock.patch('stripe.Customer.create', side_effect =
socket.error("can't connect to stripe")) as stripe_mock:
#run the test
resp = register(self.request)
#assert there is no record in the database without stripe id.
users = User.objects.filter(email="[email protected]")
self.assertEquals(len(users), 0)
#check the associated table also didn't get updated
unpaid = UnpaidUsers.objects.filter(email="[email protected]")
self.assertEquals(len(unpaid), 0)
Le décorateur en haut du test est une simulation qui lancera une "IntegrityError" lorsque nous essaierons d'enregistrer dans UnpaidUsers
tableau.
C'est pour répondre à la question, "Que se passe-t-il si le UnpaidUsers(email=cd['email']).save()
la ligne échoue ? » Le morceau de code suivant crée simplement une session simulée, avec les informations appropriées dont nous avons besoin pour notre fonction d'enregistrement. Et puis le with mock.patch
force le système à croire que Stripe est en panne… enfin nous arrivons au test.
resp = register(self.request)
La ligne ci-dessus appelle simplement notre fonction d'affichage de registre en transmettant la requête simulée. Ensuite, nous vérifions simplement que les tables ne sont pas mises à jour :
#assert there is no record in the database without stripe_id.
users = User.objects.filter(email="[email protected]")
self.assertEquals(len(users), 0)
#check the associated table also didn't get updated
unpaid = UnpaidUsers.objects.filter(email="[email protected]")
self.assertEquals(len(unpaid), 0)
Il devrait donc échouer si nous lançons le test :
======================================================================
FAIL: test_registering_user_when_strip_is_down_all_or_nothing (tests.payments.testViews.RegisterPageTests)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/j1z0/.virtualenvs/django_1.6/lib/python2.7/site-packages/mock.py", line 1201, in patched
return func(*args, **keywargs)
File "/Users/j1z0/Code/RealPython/mvp_for_Adv_Python_Web_Book/tests/payments/testViews.py", line 266, in test_registering_user_when_strip_is_down_all_or_nothing
self.assertEquals(len(users), 0)
AssertionError: 1 != 0
----------------------------------------------------------------------
Joli. C'est drôle à dire mais c'est exactement ce que nous voulions. N'oubliez pas :nous pratiquons le TDD ici. Le message d'erreur nous indique que l'utilisateur est bien enregistré dans la base de données - ce qui est exactement ce que nous ne voulons pas car il n'a pas payé !
Les transactions à la rescousse…
Transactions
Il existe en fait plusieurs façons de créer des transactions dans Django 1.6.
Passons en revue quelques-unes.
La méthode recommandée
Selon la documentation de Django 1.6 :
« Django fournit une API unique pour contrôler les transactions de la base de données. […] L'atomicité est la propriété déterminante des transactions de base de données. atomic nous permet de créer un bloc de code au sein duquel l'atomicité sur la base de données est garantie. Si le bloc de code est terminé avec succès, les modifications sont validées dans la base de données. S'il y a une exception, les modifications sont annulées."
Atomic peut être utilisé à la fois comme décorateur ou comme context_manager. Donc, si nous l'utilisons comme gestionnaire de contexte, le code de notre fonction de registre ressemblerait à ceci :
from django.db import transaction
try:
with transaction.atomic():
user = User.create(
cd['name'], cd['email'],
cd['password'], cd['last_4_digits'])
if customer:
user.stripe_id = customer.id
user.save()
else:
UnpaidUsers(email=cd['email']).save()
except IntegrityError:
form.addError(cd['email'] + ' is already a member')
Notez la ligne with transaction.atomic()
. Tout le code à l'intérieur de ce bloc sera exécuté dans une transaction. Donc, si nous réexécutons nos tests, ils devraient tous réussir ! N'oubliez pas qu'une transaction est une unité de travail unique, donc tout ce qui se trouve dans le gestionnaire de contexte est restauré lorsque le UnpaidUsers
l'appel échoue.
Utiliser un décorateur
Nous pouvons également essayer d'ajouter atomic comme décorateur.
@transaction.atomic():
def register(request):
# ...snip....
try:
user = User.create(
cd['name'], cd['email'],
cd['password'], cd['last_4_digits'])
if customer:
user.stripe_id = customer.id
user.save()
else:
UnpaidUsers(email=cd['email']).save()
except IntegrityError:
form.addError(cd['email'] + ' is already a member')
Si nous réexécutons nos tests, ils échoueront avec la même erreur que nous avions auparavant.
Pourquoi donc? Pourquoi la transaction n'a-t-elle pas été annulée correctement ? La raison est que transaction.atomic
recherche une sorte d'exception et bien, nous avons détecté cette erreur (c'est-à-dire IntegrityError
dans notre try except block), donc transaction.atomic
jamais vu et donc le standard AUTOCOMMIT
la fonctionnalité a pris le relais.
Mais bien sûr, la suppression de try except fera que l'exception sera lancée dans la chaîne d'appel et explosera très probablement ailleurs. Nous ne pouvons donc pas faire cela non plus.
L'astuce consiste donc à placer le gestionnaire de contexte atomique à l'intérieur du bloc try except, ce que nous avons fait dans notre première solution. En regardant à nouveau le bon code :
from django.db import transaction
try:
with transaction.atomic():
user = User.create(
cd['name'], cd['email'],
cd['password'], cd['last_4_digits'])
if customer:
user.stripe_id = customer.id
user.save()
else:
UnpaidUsers(email=cd['email']).save()
except IntegrityError:
form.addError(cd['email'] + ' is already a member')
Lorsque UnpaidUsers
déclenche l'IntegrityError
le transaction.atomic()
le gestionnaire de contexte l'attrapera et effectuera la restauration. Au moment où notre code s'exécute dans le gestionnaire d'exceptions, (c'est-à-dire le form.addError
ligne) la restauration sera effectuée et nous pourrons en toute sécurité effectuer des appels de base de données si nécessaire. Notez également tous les appels de base de données avant ou après le transaction.atomic()
Le gestionnaire de contexte ne sera pas affecté quel que soit le résultat final de context_manager.
Transaction par requête HTTP
Django 1.6 (comme 1.5) permet également de fonctionner en mode « Transaction par requête ». Dans ce mode, Django enveloppera automatiquement votre fonction de vue dans une transaction. Si la fonction lève une exception, Django annulera la transaction, sinon il validera la transaction.
Pour le configurer, vous devez définir ATOMIC_REQUEST
sur True dans la configuration de la base de données pour chaque base de données pour laquelle vous souhaitez avoir ce comportement. Donc, dans notre "settings.py", nous effectuons le changement comme ceci :
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(SITE_ROOT, 'test.db'),
'ATOMIC_REQUEST': True,
}
}
En pratique, cela se comporte exactement comme si vous placiez le décorateur sur notre fonction d'affichage. Cela ne sert donc pas nos objectifs ici.
Il est cependant intéressant de noter qu'avec les deux ATOMIC_REQUESTS
et le @transaction.atomic
décorateur, il est toujours possible d'attraper/gérer ces erreurs après qu'elles aient été supprimées de la vue. Pour détecter ces erreurs, vous devez implémenter un middleware personnalisé, ou vous pouvez remplacer urls.hadler500 ou créer un modèle 500.html.
Points de sauvegarde
Même si les transactions sont atomiques, elles peuvent être décomposées en points de sauvegarde. Considérez les points de sauvegarde comme des transactions partielles.
Ainsi, si vous avez une transaction qui nécessite quatre instructions SQL, vous pouvez créer un point de sauvegarde après la deuxième instruction. Une fois ce point de sauvegarde créé, même si la 3e ou la 4e instruction échoue, vous pouvez effectuer une restauration partielle, en vous débarrassant de la 3e et de la 4e instruction mais en conservant les deux premières.
Cela revient donc à diviser une transaction en transactions légères plus petites, ce qui vous permet d'effectuer des restaurations ou des validations partielles.
Mais gardez à l'esprit si la transaction principale doit être annulée (peut-être à cause d'une
IntegrityError
qui a été déclenché et non intercepté, tous les points de sauvegarde seront également annulés).
Regardons un exemple du fonctionnement des points de sauvegarde.
@transaction.atomic()
def save_points(self,save=True):
user = User.create('jj','inception','jj','1234')
sp1 = transaction.savepoint()
user.name = 'starting down the rabbit hole'
user.stripe_id = 4
user.save()
if save:
transaction.savepoint_commit(sp1)
else:
transaction.savepoint_rollback(sp1)
Ici, toute la fonction est dans une transaction. Après avoir créé un nouvel utilisateur, nous créons un point de sauvegarde et obtenons une référence au point de sauvegarde. Les trois déclarations suivantes-
user.name = 'starting down the rabbit hole'
user.stripe_id = 4
user.save()
-ne font pas partie du point de sauvegarde existant, ils ont donc une chance de faire partie du prochain savepoint_rollback
, ou savepoint_commit
. Dans le cas d'un savepoint_rollback
, la ligne user = User.create('jj','inception','jj','1234')
sera toujours engagé dans la base de données même si le reste des mises à jour ne le sera pas.
En d'autres termes, les deux tests suivants décrivent le fonctionnement des points de sauvegarde :
def test_savepoint_rollbacks(self):
self.save_points(False)
#verify that everything was stored
users = User.objects.filter(email="inception")
self.assertEquals(len(users), 1)
#note the values here are from the original create call
self.assertEquals(users[0].stripe_id, '')
self.assertEquals(users[0].name, 'jj')
def test_savepoint_commit(self):
self.save_points(True)
#verify that everything was stored
users = User.objects.filter(email="inception")
self.assertEquals(len(users), 1)
#note the values here are from the update calls
self.assertEquals(users[0].stripe_id, '4')
self.assertEquals(users[0].name, 'starting down the rabbit hole')
De plus, après avoir validé ou annulé un point de sauvegarde, nous pouvons continuer à travailler dans la même transaction. Et ce travail ne sera pas affecté par le résultat du point de sauvegarde précédent.
Par exemple, si nous mettons à jour nos save_points
fonctionner comme tel :
@transaction.atomic()
def save_points(self,save=True):
user = User.create('jj','inception','jj','1234')
sp1 = transaction.savepoint()
user.name = 'starting down the rabbit hole'
user.save()
user.stripe_id = 4
user.save()
if save:
transaction.savepoint_commit(sp1)
else:
transaction.savepoint_rollback(sp1)
user.create('limbo','illbehere@forever','mind blown',
'1111')
Que savepoint_commit
ou savepoint_rollback
a été appelé l'utilisateur "limbo" sera toujours créé avec succès. À moins que quelque chose d'autre ne provoque l'annulation de toute la transaction.
Transactions imbriquées
En plus de spécifier manuellement les points de sauvegarde, avec savepoint()
, savepoint_commit
, et savepoint_rollback
, la création d'une transaction imbriquée créera automatiquement un point de sauvegarde pour nous et l'annulera si nous obtenons une erreur.
En étendant notre exemple un peu plus loin, nous obtenons :
@transaction.atomic()
def save_points(self,save=True):
user = User.create('jj','inception','jj','1234')
sp1 = transaction.savepoint()
user.name = 'starting down the rabbit hole'
user.save()
user.stripe_id = 4
user.save()
if save:
transaction.savepoint_commit(sp1)
else:
transaction.savepoint_rollback(sp1)
try:
with transaction.atomic():
user.create('limbo','illbehere@forever','mind blown',
'1111')
if not save: raise DatabaseError
except DatabaseError:
pass
Ici, nous pouvons voir qu'après avoir traité nos points de sauvegarde, nous utilisons le transaction.atomic
gestionnaire de contexte pour enfermer notre création de l'utilisateur "limbo". Lorsque ce gestionnaire de contexte est appelé, il crée en fait un point de sauvegarde (car nous sommes déjà dans une transaction) et ce point de sauvegarde sera validé ou annulé à la sortie du gestionnaire de contexte.
Ainsi les deux tests suivants décrivent leur comportement :
def test_savepoint_rollbacks(self):
self.save_points(False)
#verify that everything was stored
users = User.objects.filter(email="inception")
self.assertEquals(len(users), 1)
#savepoint was rolled back so we should have original values
self.assertEquals(users[0].stripe_id, '')
self.assertEquals(users[0].name, 'jj')
#this save point was rolled back because of DatabaseError
limbo = User.objects.filter(email="illbehere@forever")
self.assertEquals(len(limbo),0)
def test_savepoint_commit(self):
self.save_points(True)
#verify that everything was stored
users = User.objects.filter(email="inception")
self.assertEquals(len(users), 1)
#savepoint was committed
self.assertEquals(users[0].stripe_id, '4')
self.assertEquals(users[0].name, 'starting down the rabbit hole')
#save point was committed by exiting the context_manager without an exception
limbo = User.objects.filter(email="illbehere@forever")
self.assertEquals(len(limbo),1)
Donc, en réalité, vous pouvez utiliser soit atomic
ou savepoint
pour créer des points de sauvegarde dans une transaction. Avec atomic
vous n'avez pas à vous soucier explicitement du commit / rollback, alors qu'avec savepoint
vous avez un contrôle total sur le moment où cela se produit.
Conclusion
Si vous avez déjà utilisé des versions antérieures des transactions Django, vous pouvez voir à quel point le modèle de transaction est plus simple. Ayant également AUTOCOMMIT
on by default est un excellent exemple de valeurs par défaut "saines" que Django et Python sont fiers de proposer. Pour de nombreux systèmes, vous n'aurez pas besoin de traiter directement les transactions, laissez simplement AUTOCOMMIT
faire son travail. Mais si vous le faites, j'espère que cet article vous aura donné les informations dont vous avez besoin pour gérer les transactions dans Django comme un pro.