Se hai mai dedicato molto tempo alla gestione delle transazioni del database di Django, sai quanto può creare confusione. In passato, la documentazione forniva un po' di approfondimento, ma la comprensione veniva solo costruendo e sperimentando.
C'era una pletora di decoratori con cui lavorare, come commit_on_success
, commit_manually
, commit_unless_managed
, rollback_unless_managed
, enter_transaction_management
, leave_transaction_management
, solo per citarne alcuni. Fortunatamente, con Django 1.6 tutto esce dalla porta. Hai davvero bisogno di conoscere solo un paio di funzioni ora. E arriveremo a quelli in appena un secondo. In primo luogo, affronteremo questi argomenti:
- Cos'è la gestione delle transazioni?
- Cosa c'è che non va nella gestione delle transazioni prima di Django 1.6?
Prima di entrare in:
- Cosa c'è di giusto nella gestione delle transazioni in Django 1.6?
E poi fare un esempio dettagliato:
- Esempio di strisce
- Transazioni
- Il modo consigliato
- Utilizzo di un decoratore
- Transazione per richiesta HTTP
- Punti di risparmio
- Transazioni nidificate
Cos'è una transazione?
Secondo SQL-92, "Una transazione SQL (a volte chiamata semplicemente "transazione") è una sequenza di esecuzioni di istruzioni SQL atomica rispetto al ripristino". In altre parole, tutte le istruzioni SQL vengono eseguite e salvate insieme. Allo stesso modo, quando viene eseguito il rollback, tutte le istruzioni vengono ripristinate insieme.
Ad esempio:
# START
note = Note(title="my first note", text="Yay!")
note = Note(title="my second note", text="Whee!")
address1.save()
address2.save()
# COMMIT
Quindi una transazione è una singola unità di lavoro in un database. E quella singola unità di lavoro è delimitata da una transazione iniziale e quindi da un commit o da un rollback esplicito.
Cosa c'è che non va nella gestione delle transazioni prima di Django 1.6?
Per rispondere pienamente a questa domanda, dobbiamo affrontare il modo in cui le transazioni vengono gestite nel database, nelle librerie client e all'interno di Django.
Banche dati
Ogni istruzione in un database deve essere eseguita in una transazione, anche se la transazione include una sola istruzione.
La maggior parte dei database ha un AUTOCOMMIT
impostazione, che di solito è impostata su True come impostazione predefinita. Questo AUTOCOMMIT
avvolge ogni istruzione in una transazione che viene immediatamente salvata se l'istruzione ha esito positivo. Ovviamente puoi chiamare manualmente qualcosa come START_TRANSACTION
che sospenderà temporaneamente l'AUTOCOMMIT
finché non chiami COMMIT_TRANSACTION
o ROLLBACK
.
Tuttavia, il take away qui è che l'AUTOCOMMIT
l'impostazione applica un commit implicito dopo ogni istruzione .
Librerie client
Poi ci sono le librerie client di Python come sqlite3 e mysqldb, che consentono ai programmi Python di interfacciarsi con i database stessi. Tali librerie seguono una serie di standard su come accedere e interrogare i database. Quello standard, DB API 2.0, è descritto in PEP 249. Sebbene possa fornire una lettura leggermente secca, un aspetto importante è che PEP 249 afferma che il database AUTOCOMMIT
dovrebbe essere OFF per impostazione predefinita.
Questo è chiaramente in conflitto con ciò che sta accadendo all'interno del database:
- Le istruzioni SQL devono sempre essere eseguite in una transazione, che generalmente il database apre per te tramite
AUTOCOMMIT
. - Tuttavia, secondo PEP 249, questo non dovrebbe accadere.
- Le librerie client devono rispecchiare ciò che accade all'interno del database, ma poiché non sono autorizzate a girare
AUTOCOMMIT
per impostazione predefinita, avvolgono semplicemente le tue istruzioni SQL in una transazione, proprio come il database.
Bene. Resta con me ancora un po'.
Django
Entra Django. Django ha anche qualcosa da dire sulla gestione delle transazioni. In Django 1.5 e versioni precedenti, Django funzionava sostanzialmente con una transazione aperta e si impegnava automaticamente per quella transazione quando scrivevi i dati nel database. Quindi ogni volta che hai chiamato qualcosa come model.save()
o model.update()
, Django ha generato le istruzioni SQL appropriate e ha eseguito il commit della transazione.
Anche in Django 1.5 e precedenti, si consigliava di utilizzare TransactionMiddleware
per associare le transazioni alle richieste HTTP. Ad ogni richiesta è stata assegnata una transazione. Se la risposta viene restituita senza eccezioni, Django commetterà la transazione ma se la tua funzione di visualizzazione genera un errore, ROLLBACK
verrebbe chiamato. Questo in effetti ha disattivato AUTOCOMMIT
. Se volevi una gestione delle transazioni in stile autocommit standard a livello di database, dovevi gestire le transazioni tu stesso, di solito utilizzando un decoratore di transazioni sulla tua funzione di visualizzazione come @transaction.commit_manually
o @transaction.commit_on_success
.
Prendi un respiro. O due.
Cosa significa?
Sì, c'è molto da fare e si scopre che la maggior parte degli sviluppatori vuole solo il commit automatico a livello di database standard, il che significa che le transazioni rimangono dietro le quinte, facendo le loro cose, finché non è necessario regolarle manualmente.
Cosa c'è di giusto nella gestione delle transazioni in Django 1.6?
Ora, benvenuto in Django 1.6. Fai del tuo meglio per dimenticare tutto ciò di cui abbiamo appena parlato e ricorda semplicemente che in Django 1.6 usi il database AUTOCOMMIT
e gestire le transazioni manualmente quando necessario. In sostanza, abbiamo un modello molto più semplice che fondamentalmente fa ciò per cui il database è stato progettato in primo luogo.
Basta teoria. Codifichiamo.
Esempio di strisce
Qui abbiamo questa funzione di visualizzazione di esempio che gestisce la registrazione di un utente e la chiamata a Stripe per l'elaborazione della carta di credito.
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)
)
Questa vista chiama prima Customer.create
che in realtà chiama Stripe per gestire l'elaborazione della carta di credito. Quindi creiamo un nuovo utente. Se riceviamo una risposta da Stripe, aggiorniamo il cliente appena creato con stripe_id
. Se non riavremo un cliente (Stripe è inattivo), aggiungeremo una voce a UnpaidUsers
tabella con l'e-mail dei clienti appena creati, in modo da poter chiedere loro di riprovare i dati della carta di credito in un secondo momento.
L'idea è che anche se Stripe non funziona, l'utente può comunque registrarsi e iniziare a utilizzare il nostro sito. Chiederemo loro di nuovo in un secondo momento le informazioni sulla carta di credito.
Capisco che questo potrebbe essere un esempio un po' forzato e non è il modo in cui implementerei tale funzionalità se dovessi, ma lo scopo è dimostrare le transazioni.
Avanti. Pensando alle transazioni e tenendo presente che per impostazione predefinita Django 1.6 ci fornisce AUTOCOMMIT
comportamento per il nostro database, diamo un'occhiata al codice relativo al database un po' più a lungo.
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:
# ...
Riesci a individuare eventuali problemi? Bene, cosa succede se UnpaidUsers(email=cd['email']).save()
la linea non riesce?
Avrai un utente, registrato nel sistema, che il sistema ritiene abbia verificato la sua carta di credito, ma in realtà non ha verificato la carta.
Vogliamo solo uno dei due risultati:
- L'utente è stato creato (nel database) e ha un
stripe_id
. - L'utente è stato creato (nel database) e non ha un
stripe_id
E una riga associata nelUnpaidUsers
viene generata una tabella con lo stesso indirizzo email.
Il che significa che vogliamo che le due istruzioni di database separate siano entrambe commit o entrambi rollback. Un caso perfetto per l'umile transazione.
Per prima cosa, scriviamo alcuni test per verificare che le cose si comportino nel modo in cui vogliamo.
@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)
Il decoratore in cima al test è un mock che genererà un "IntegrityError" quando tenteremo di salvare in UnpaidUsers
tabella.
Questo per rispondere alla domanda "Cosa succede se UnpaidUsers(email=cd['email']).save()
la linea non funziona?" Il prossimo bit di codice crea semplicemente una sessione simulata, con le informazioni appropriate di cui abbiamo bisogno per la nostra funzione di registrazione. E poi il with mock.patch
costringe il sistema a credere che Stripe sia inattivo... finalmente arriviamo alla prova.
resp = register(self.request)
La riga precedente chiama semplicemente la nostra funzione di visualizzazione del registro che passa la richiesta simulata. Quindi controlliamo solo per assicurarci che le tabelle non siano aggiornate:
#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)
Quindi dovrebbe fallire se eseguiamo il 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
----------------------------------------------------------------------
Bello. Sembra divertente da dire, ma è esattamente quello che volevamo. Ricorda:qui stiamo praticando il TDD. Il messaggio di errore ci dice che l'Utente è effettivamente memorizzato nel database, che è esattamente ciò che non vogliamo perché non ha pagato!
Operazioni in soccorso...
Transazioni
Esistono in realtà diversi modi per creare transazioni in Django 1.6.
Esaminiamone alcuni.
Il modo consigliato
Secondo la documentazione di Django 1.6:
“Django fornisce un'unica API per controllare le transazioni del database. […] L'atomicità è la proprietà che definisce le transazioni del database. atomic ci permette di creare un blocco di codice all'interno del quale è garantita l'atomicità sul database. Se il blocco di codice viene completato correttamente, le modifiche vengono salvate nel database. Se c'è un'eccezione, le modifiche vengono annullate."
Atomic può essere utilizzato sia come decoratore che come context_manager. Quindi, se lo usiamo come gestore del contesto, il codice nella nostra funzione di registro sarebbe simile a questo:
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')
Nota la riga with transaction.atomic()
. Tutto il codice all'interno di quel blocco verrà eseguito all'interno di una transazione. Quindi, se rieseguiamo i nostri test, dovrebbero passare tutti! Ricorda che una transazione è una singola unità di lavoro, quindi tutto all'interno del gestore del contesto viene ripristinato quando UnpaidUsers
chiamata non riuscita.
Utilizzo di un decoratore
Possiamo anche provare ad aggiungere atomic come decoratore.
@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')
Se eseguiamo nuovamente i nostri test, falliranno con lo stesso errore che avevamo prima.
Perché? Perché la transazione non è stata ripristinata correttamente? Il motivo è perché transaction.atomic
sta cercando una sorta di eccezione e, beh, abbiamo rilevato quell'errore (ad esempio IntegrityError
nel nostro tentativo eccetto il blocco), quindi transaction.atomic
non l'ho mai visto e quindi lo standard AUTOCOMMIT
la funzionalità ha preso il sopravvento.
Ma ovviamente la rimozione di try eccetto farà sì che l'eccezione venga semplicemente generata nella catena di chiamate e molto probabilmente esploderà da qualche altra parte. Quindi non possiamo farlo neanche noi.
Quindi il trucco è inserire il gestore del contesto atomico all'interno del blocco try eccetto che è quello che abbiamo fatto nella nostra prima soluzione. Guardando di nuovo il codice corretto:
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')
Quando UnpaidUsers
attiva il IntegrityError
il transaction.atomic()
il gestore del contesto lo catturerà ed eseguirà il rollback. Quando il nostro codice viene eseguito nel gestore delle eccezioni, (cioè il form.addError
linea) verrà eseguito il rollback e potremmo effettuare chiamate al database in sicurezza, se necessario. Nota anche tutte le chiamate al database prima o dopo transaction.atomic()
il gestore del contesto non sarà interessato indipendentemente dal risultato finale del gestore del contesto.
Transazione per richiesta HTTP
Django 1.6 (come 1.5) consente anche di operare in modalità "Transazione per richiesta". In questa modalità Django avvolgerà automaticamente la tua funzione di visualizzazione in una transazione. Se la funzione genera un'eccezione, Django eseguirà il rollback della transazione, altrimenti eseguirà il commit della transazione.
Per configurarlo devi impostare ATOMIC_REQUEST
su True nella configurazione del database per ogni database per il quale si desidera avere questo comportamento. Quindi nel nostro "settings.py" apportiamo la modifica in questo modo:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(SITE_ROOT, 'test.db'),
'ATOMIC_REQUEST': True,
}
}
In pratica questo si comporta esattamente come se mettessi il decoratore nella nostra funzione di visualizzazione. Quindi non serve ai nostri scopi qui.
Vale comunque la pena notare che con entrambi ATOMIC_REQUESTS
e il @transaction.atomic
decoratore è ancora possibile catturare/gestire quegli errori dopo che sono stati lanciati dalla vista. Per intercettare quegli errori dovresti implementare del middleware personalizzato, oppure potresti sovrascrivere urls.hadler500 o creare un modello 500.html.
Punti di risparmio
Anche se le transazioni sono atomiche, possono essere ulteriormente suddivise in punti di salvataggio. Pensa ai punti di salvataggio come a transazioni parziali.
Quindi, se hai una transazione che richiede quattro istruzioni SQL per essere completata, puoi creare un punto di salvataggio dopo la seconda istruzione. Una volta creato quel punto di salvataggio, anche se la 3a o la 4a istruzione falliscono, puoi eseguire un rollback parziale, eliminando la 3a e la 4a istruzione ma mantenendo le prime due.
Quindi è fondamentalmente come dividere una transazione in transazioni leggere più piccole che ti consentono di eseguire rollback o commit parziali.
Ma tieni presente se la transazione principale in cui eseguire il rollback (forse a causa di un IntegrityError
che è stato sollevato e non catturato, anche tutti i punti di salvataggio verranno ripristinati).
Diamo un'occhiata a un esempio di come funzionano i punti di salvataggio.
@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)
Qui l'intera funzione è in una transazione. Dopo aver creato un nuovo utente, creiamo un punto di salvataggio e otteniamo un riferimento al punto di salvataggio. Le prossime tre affermazioni-
user.name = 'starting down the rabbit hole'
user.stripe_id = 4
user.save()
-non fanno parte del punto di salvataggio esistente, quindi hanno la possibilità di far parte del prossimo savepoint_rollback
o savepoint_commit
. Nel caso di un savepoint_rollback
, la riga user = User.create('jj','inception','jj','1234')
verrà comunque eseguito il commit nel database anche se il resto degli aggiornamenti non lo sarà.
In altre parole, questi due test seguenti descrivono come funzionano i punti di salvataggio:
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')
Inoltre, dopo aver eseguito il commit o il rollback di un punto di salvataggio, possiamo continuare a lavorare nella stessa transazione. E quel lavoro non sarà influenzato dal risultato del punto di salvataggio precedente.
Ad esempio, se aggiorniamo i nostri save_points
funzione come tale:
@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')
Indipendentemente dal fatto che savepoint_commit
o savepoint_rollback
è stato chiamato l'utente "limbo" verrà comunque creato correttamente. A meno che qualcos'altro non provochi il rollback dell'intera transazione.
Transazioni nidificate
Oltre a specificare manualmente i punti di salvataggio, con savepoint()
, savepoint_commit
e savepoint_rollback
, la creazione di una transazione nidificata creerà automaticamente un punto di salvataggio per noi e lo annullerà se riceviamo un errore.
Estendendo ulteriormente il nostro esempio otteniamo:
@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
Qui possiamo vedere che dopo aver gestito i nostri punti di salvataggio, stiamo usando transaction.atomic
gestore del contesto per racchiudere la nostra creazione dell'utente "limbo". Quando viene chiamato quel gestore di contesto, in effetti sta creando un punto di salvataggio (perché siamo già in una transazione) e quel punto di salvataggio verrà salvato o ripristinato all'uscita dal gestore di contesto.
Pertanto i due test seguenti descrivono il loro comportamento:
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)
Quindi in realtà puoi usare sia atomic
o savepoint
per creare punti di salvataggio all'interno di una transazione. Con atomic
non devi preoccuparti esplicitamente del commit / rollback, dove come con savepoint
hai il pieno controllo su quando ciò accade.
Conclusione
Se hai avuto esperienze precedenti con le versioni precedenti delle transazioni Django, puoi vedere quanto è più semplice il modello di transazione. Avere anche AUTOCOMMIT
on per impostazione predefinita è un ottimo esempio di impostazioni predefinite "sane" che Django e Python sono entrambi orgogliosi di offrire. Per molti sistemi non avrai bisogno di gestire direttamente le transazioni, lascia che AUTOCOMMIT
fare il suo lavoro. Ma se lo fai, si spera che questo post ti abbia fornito le informazioni di cui hai bisogno per gestire le transazioni in Django come un professionista.