Als je ooit veel tijd hebt besteed aan het beheer van Django-databasetransacties, weet je hoe verwarrend het kan zijn. In het verleden bood de documentatie nogal wat diepgang, maar begrip kwam alleen door te bouwen en te experimenteren.
Er was een overvloed aan decorateurs om mee te werken, zoals commit_on_success
, commit_manually
, commit_unless_managed
, rollback_unless_managed
, enter_transaction_management
, leave_transaction_management
, om er een paar te noemen. Gelukkig gaat dat bij Django 1.6 allemaal de deur uit. Je hoeft nu echt maar een paar functies te kennen. En daar komen we zo op terug. Eerst zullen we deze onderwerpen behandelen:
- Wat is transactiebeheer?
- Wat is er mis met transactiebeheer voorafgaand aan Django 1.6?
Voordat je instapt:
- Wat klopt er aan transactiebeheer in Django 1.6?
En dan een gedetailleerd voorbeeld behandelen:
- Stripvoorbeeld
- Transacties
- De aanbevolen manier
- Een decorateur gebruiken
- Transactie per HTTP-verzoek
- Punten sparen
- Geneste transacties
Wat is een transactie?
Volgens SQL-92 is "Een SQL-transactie (soms eenvoudig een "transactie" genoemd) een reeks uitvoeringen van SQL-instructies die atomair is met betrekking tot herstel". Met andere woorden, alle SQL-instructies worden samen uitgevoerd en vastgelegd. Evenzo, wanneer teruggedraaid, worden alle uitspraken teruggedraaid.
Bijvoorbeeld:
# START
note = Note(title="my first note", text="Yay!")
note = Note(title="my second note", text="Whee!")
address1.save()
address2.save()
# COMMIT
Een transactie is dus een enkele werkeenheid in een database. En die ene werkeenheid wordt afgebakend door een starttransactie en vervolgens een vastlegging of een expliciete terugdraaiing.
Wat is er mis met transactiebeheer voorafgaand aan Django 1.6?
Om deze vraag volledig te beantwoorden, moeten we nagaan hoe transacties worden afgehandeld in de database, clientbibliotheken en binnen Django.
Databases
Elke verklaring in een database moet in een transactie worden uitgevoerd, zelfs als de transactie slechts één verklaring bevat.
De meeste databases hebben een AUTOCOMMIT
instelling, die meestal standaard is ingesteld op True. Deze AUTOCOMMIT
verpakt elke instructie in een transactie die onmiddellijk wordt uitgevoerd als de instructie slaagt. Natuurlijk kun je handmatig zoiets als START_TRANSACTION
. aanroepen waardoor de AUTOCOMMIT
temporarily tijdelijk wordt opgeschort totdat je COMMIT_TRANSACTION
. aanroept of ROLLBACK
.
Het voordeel hier is echter dat de AUTOCOMMIT
instelling past een impliciete commit toe na elke instructie .
Cliëntbibliotheken
Dan zijn er nog de Python clientbibliotheken zoals sqlite3 en mysqldb, waarmee Python-programma's kunnen communiceren met de databases zelf. Dergelijke bibliotheken volgen een reeks standaarden voor toegang tot en opvragen van de databases. Die standaard, DB API 2.0, wordt beschreven in PEP 249. Hoewel het voor wat droge lezing kan zorgen, is een belangrijk voordeel dat PEP 249 stelt dat de database AUTOCOMMIT
moet UIT zijn standaard.
Dit is duidelijk in strijd met wat er in de database gebeurt:
- SQL-statements moeten altijd worden uitgevoerd in een transactie, die de database doorgaans voor u opent via
AUTOCOMMIT
. - Volgens PEP 249 zou dit echter niet mogen gebeuren.
- Clientbibliotheken moeten spiegelen wat er in de database gebeurt, maar aangezien ze
AUTOCOMMIT
niet mogen veranderen standaard ingeschakeld, verpakken ze uw SQL-statements gewoon in een transactie, net als de database.
Oké. Blijf nog wat langer bij me.
Django
Voer Django in. Django heeft ook iets te zeggen over transactiebeheer. In Django 1.5 en eerder liep Django in feite met een open transactie en voerde die transactie automatisch door toen u gegevens naar de database schreef. Dus elke keer dat je iets aanroept als model.save()
of model.update()
, heeft Django de juiste SQL-instructies gegenereerd en de transactie uitgevoerd.
Ook in Django 1.5 en eerder werd het aanbevolen om de TransactionMiddleware
. te gebruiken om transacties te binden aan HTTP-verzoeken. Elk verzoek kreeg een transactie. Als het antwoord zonder uitzonderingen terugkwam, zou Django de transactie uitvoeren, maar als uw weergavefunctie een fout opleverde, ROLLBACK
zou worden genoemd. Dit is in feite uitgeschakeld AUTOCOMMIT
. Als u standaard transactiebeheer op databaseniveau met autocommit-stijl wilde, moest u de transacties zelf beheren - meestal door een transactiedecorateur in uw weergavefunctie te gebruiken, zoals @transaction.commit_manually
, of @transaction.commit_on_success
.
Haal adem. Of twee.
Wat betekent dit?
Ja, er gebeurt daar veel, en het blijkt dat de meeste ontwikkelaars gewoon de standaard autocommits op databaseniveau willen - wat betekent dat transacties achter de schermen blijven en hun ding doen, totdat je ze handmatig moet aanpassen.
Wat klopt er aan transactiebeheer in Django 1.6?
Welkom bij Django 1.6. Doe je best om alles te vergeten waar we het net over hadden en onthoud gewoon dat je in Django 1.6 de database AUTOCOMMIT
gebruikt en beheer transacties handmatig wanneer dat nodig is. In wezen hebben we een veel eenvoudiger model dat in feite doet waarvoor de database in de eerste plaats is ontworpen.
Genoeg theorie. Laten we coderen.
Streepvoorbeeld
Hier hebben we deze voorbeeldweergavefunctie die het registreren van een gebruiker en het aanroepen van Stripe voor creditcardverwerking afhandelt.
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)
)
Deze weergave roept eerst Customer.create
. op die eigenlijk Stripe aanroept om de creditcardverwerking af te handelen. Dan maken we een nieuwe gebruiker aan. Als we een reactie van Stripe hebben gekregen, werken we de nieuw aangemaakte klant bij met de stripe_id
. Als we een klant niet terugkrijgen (Stripe is uitgeschakeld), voegen we een vermelding toe aan de UnpaidUsers
tabel met het nieuwe e-mailadres van de klant, zodat we ze kunnen vragen om hun creditcardgegevens later opnieuw te proberen.
Het idee is dat zelfs als Stripe niet beschikbaar is, de gebruiker zich nog steeds kan registreren en onze site kan gaan gebruiken. We zullen ze op een later tijdstip gewoon opnieuw om de creditcardgegevens vragen.
Ik begrijp dat dit misschien een beetje een gekunsteld voorbeeld is, en het is niet de manier waarop ik dergelijke functionaliteit zou implementeren als het moest, maar het doel is om transacties te demonstreren.
Voorwaarts. Nadenken over transacties en in gedachten houden dat Django 1.6 ons standaard AUTOCOMMIT
geeft gedrag voor onze database, laten we de database-gerelateerde code wat langer bekijken.
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:
# ...
Kun je problemen ontdekken? Welnu, wat gebeurt er als de UnpaidUsers(email=cd['email']).save()
lijn mislukt?
Er is een gebruiker geregistreerd in het systeem waarvan het systeem denkt dat hij zijn creditcard heeft geverifieerd, maar in werkelijkheid heeft hij de kaart niet geverifieerd.
We willen slechts een van de twee uitkomsten:
- De gebruiker is aangemaakt (in de database) en heeft een
stripe_id
. - De gebruiker is aangemaakt (in de database) en heeft geen
stripe_id
EN een bijbehorende rij in deUnpaidUsers
tabel met hetzelfde e-mailadres wordt gegenereerd.
Wat betekent dat we willen dat de twee afzonderlijke database-statements ofwel beide committen of beide terugdraaien. Een perfecte case voor de bescheiden transactie.
Laten we eerst wat tests schrijven om te controleren of dingen zich gedragen zoals we willen.
@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)
De decorateur bovenaan de test is een schijnvertoning die een 'IntegrityError' zal veroorzaken wanneer we proberen op te slaan naar de UnpaidUsers
tafel.
Dit is om de vraag te beantwoorden:"Wat gebeurt er als de UnpaidUsers(email=cd['email']).save()
lijn mislukt?” Het volgende stukje code creëert gewoon een bespotte sessie, met de juiste informatie die we nodig hebben voor onze registratiefunctie. En dan de with mock.patch
dwingt het systeem te geloven dat Stripe niet werkt ... eindelijk komen we op de proef.
resp = register(self.request)
De bovenstaande regel roept gewoon onze registerweergavefunctie op en geeft het bespotte verzoek door. Daarna controleren we of de tabellen niet zijn bijgewerkt:
#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)
Het zou dus moeten mislukken als we de test uitvoeren:
======================================================================
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
----------------------------------------------------------------------
Leuk. Het lijkt grappig om te zeggen, maar dat is precies wat we wilden. Onthoud:we zijn hier TDD aan het oefenen. De foutmelding vertelt ons dat de gebruiker inderdaad wordt opgeslagen in de database - en dat is precies wat we niet willen omdat ze niet hebben betaald!
Transacties om te redden ...
Transacties
Er zijn eigenlijk verschillende manieren om transacties aan te maken in Django 1.6.
Laten we er een paar doornemen.
De aanbevolen manier
Volgens de documentatie van Django 1.6:
“Django biedt één enkele API om databasetransacties te beheren. […] Atomiciteit is de bepalende eigenschap van databasetransacties. atomic stelt ons in staat om een codeblok te maken waarbinnen de atomiciteit op de database is gegarandeerd. Als het codeblok met succes is voltooid, worden de wijzigingen doorgevoerd in de database. Als er een uitzondering is, worden de wijzigingen teruggedraaid.”
Atomic kan zowel als decorateur of als context_manager worden gebruikt. Dus als we het als contextmanager gebruiken, ziet de code in onze registerfunctie er als volgt uit:
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')
Let op de regel with transaction.atomic()
. Alle code in dat blok wordt binnen een transactie uitgevoerd. Dus als we onze tests opnieuw uitvoeren, zouden ze allemaal moeten slagen! Onthoud dat een transactie een enkele werkeenheid is, dus alles binnen de contextmanager wordt weer samengevoegd wanneer de UnpaidUsers
oproep mislukt.
Een decorateur gebruiken
We kunnen ook proberen om atomic toe te voegen als decorateur.
@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')
Als we onze tests opnieuw uitvoeren, zullen ze mislukken met dezelfde fout die we eerder hadden.
Waarom is dat? Waarom is de transactie niet correct teruggedraaid? De reden is dat transaction.atomic
is op zoek naar een soort uitzondering en goed, we hebben die fout ontdekt (d.w.z. de IntegrityError
in onze poging behalve blok), dus transaction.atomic
nooit gezien en dus de standaard AUTOCOMMIT
functionaliteit nam het over.
Maar natuurlijk zal het verwijderen van de try behalve ervoor zorgen dat de uitzondering gewoon in de oproepketen wordt gegooid en hoogstwaarschijnlijk ergens anders wordt opgeblazen. Dat kunnen wij dus ook niet.
Dus de truc is om de atomaire contextmanager in het try-behalve-blok te plaatsen, wat we in onze eerste oplossing hebben gedaan. Nog eens naar de juiste code kijken:
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')
Wanneer UnpaidUsers
activeert de IntegrityError
de transaction.atomic()
contextmanager zal het opvangen en het terugdraaien uitvoeren. Tegen de tijd dat onze code wordt uitgevoerd in de uitzonderingshandler (d.w.z. de form.addError
regel) wordt de rollback uitgevoerd en kunnen we indien nodig veilig database-aanroepen doen. Let ook op alle database-aanroepen voor of na de transaction.atomic()
contextmanager wordt niet beïnvloed, ongeacht het uiteindelijke resultaat van de contextmanager.
Transactie per HTTP-verzoek
Met Django 1.6 (zoals 1.5) kunt u ook werken in de modus "Transactie per verzoek". In deze modus zal Django uw weergavefunctie automatisch in een transactie verpakken. Als de functie een uitzondering genereert, zal Django de transactie terugdraaien, anders zal het de transactie vastleggen.
Om het in te stellen, moet je ATOMIC_REQUEST
. instellen op True in de databaseconfiguratie voor elke database waarvoor u dit gedrag wilt hebben. Dus in onze "settings.py" maken we de wijziging als volgt:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(SITE_ROOT, 'test.db'),
'ATOMIC_REQUEST': True,
}
}
In de praktijk gedraagt dit zich gewoon precies alsof je de decorateur op onze kijkfunctie zet. Het voldoet hier dus niet aan ons doel.
Het is echter de moeite waard om op te merken dat met zowel ATOMIC_REQUESTS
en de @transaction.atomic
decorateur is het mogelijk om die fouten nog steeds op te vangen / af te handelen nadat ze uit het zicht zijn gegooid. Om die fouten op te vangen, zou je wat aangepaste middleware moeten implementeren, of je zou urls.hadler500 kunnen overschrijven of door een 500.html-sjabloon te maken.
Punten sparen
Hoewel transacties atomair zijn, kunnen ze verder worden onderverdeeld in savepoints. Beschouw spaarpunten als deeltransacties.
Dus als u een transactie heeft waarvoor vier SQL-instructies nodig zijn, kunt u een opslagpunt maken na de tweede instructie. Als dat opslagpunt eenmaal is gemaakt, kunt u, zelfs als de 3e of 4e instructie faalt, een gedeeltelijke rollback doen, waarbij u de 3e en 4e instructie verwijdert, maar de eerste twee behoudt.
Het is dus eigenlijk alsof je een transactie opsplitst in kleinere lichtgewicht transacties, zodat je gedeeltelijke rollbacks of commits kunt doen.
Houd er echter rekening mee dat de hoofdtransactie moet worden teruggedraaid (misschien vanwege een IntegrityError
dat is verhoogd en niet is gepakt, dan worden alle savepoints ook teruggedraaid).
Laten we eens kijken naar een voorbeeld van hoe spaarpunten werken.
@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)
Hier zit de hele functie in een transactie. Na het aanmaken van een nieuwe gebruiker maken we een savepoint aan en krijgen we een verwijzing naar het savepoint. De volgende drie uitspraken-
user.name = 'starting down the rabbit hole'
user.stripe_id = 4
user.save()
-maken geen deel uit van het bestaande opslagpunt, dus ze maken kans om deel uit te maken van het volgende savepoint_rollback
, of savepoint_commit
. In het geval van een savepoint_rollback
, de regel user = User.create('jj','inception','jj','1234')
zal nog steeds worden vastgelegd in de database, ook al doen de rest van de updates dat niet.
Anders gezegd, deze volgende twee tests beschrijven hoe de spaarpunten werken:
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')
Ook nadat we een savepoint hebben vastgelegd of teruggedraaid, kunnen we doorgaan met werken in dezelfde transactie. En dat werk wordt niet beïnvloed door de uitkomst van het vorige savepoint.
Als we bijvoorbeeld onze save_points
. bijwerken functioneren als zodanig:
@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')
Ongeacht of savepoint_commit
of savepoint_rollback
de 'limbo'-gebruiker werd genoemd, wordt nog steeds met succes aangemaakt. Tenzij iets anders ervoor zorgt dat de hele transactie wordt teruggedraaid.
Geneste transacties
Naast het handmatig specificeren van savepoints, met savepoint()
, savepoint_commit
, en savepoint_rollback
, zal het creëren van een geneste Transactie automatisch een opslagpunt voor ons creëren en dit terugdraaien als we een foutmelding krijgen.
Als we ons voorbeeld iets verder uitbreiden, krijgen we:
@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
Hier kunnen we zien dat nadat we onze savepoints hebben afgehandeld, we de transaction.atomic
gebruiken contextmanager om onze creatie van de 'limbo'-gebruiker te omsluiten. Wanneer die contextmanager wordt aangeroepen, wordt in feite een opslagpunt gemaakt (omdat we al in een transactie zijn) en dat opslagpunt wordt vastgelegd of teruggedraaid bij het verlaten van de contextmanager.
Zo beschrijven de volgende twee tests hun gedrag:
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)
Dus in werkelijkheid kun je ofwel atomic
of savepoint
om savepoints te creëren binnen een transactie. Met atomic
je hoeft je niet expliciet zorgen te maken over de commit / rollback, zoals bij savepoint
je hebt volledige controle over wanneer dat gebeurt.
Conclusie
Als u eerdere ervaring had met eerdere versies van Django-transacties, kunt u zien hoeveel eenvoudiger het transactiemodel is. Ook met AUTOCOMMIT
on by default is een goed voorbeeld van "gezonde" standaardwaarden die Django en Python beide trots zijn op het leveren. Voor veel systemen hoeft u transacties niet rechtstreeks af te handelen, laat AUTOCOMMIT
zijn werk doen. Maar als je dat doet, heeft dit bericht je hopelijk de informatie gegeven die je nodig hebt om transacties in Django als een professional te beheren.