Si alguna vez dedicó mucho tiempo a la gestión de transacciones de la base de datos de Django, sabe lo confuso que puede llegar a ser. En el pasado, la documentación brindaba bastante profundidad, pero la comprensión solo se lograba a través de la construcción y la experimentación.
Había una plétora de decoradores con los que trabajar, como commit_on_success
, commit_manually
, commit_unless_managed
, rollback_unless_managed
, enter_transaction_management
, leave_transaction_management
, Sólo para nombrar unos pocos. Afortunadamente, con Django 1.6 todo sale por la puerta. Realmente solo necesita saber acerca de un par de funciones ahora. Y llegaremos a ellos en solo un segundo. Primero, abordaremos estos temas:
- ¿Qué es la gestión de transacciones?
- ¿Qué tiene de malo la gestión de transacciones antes de Django 1.6?
Antes de saltar a:
- ¿Qué tiene de bueno la gestión de transacciones en Django 1.6?
Y luego tratar con un ejemplo detallado:
- Ejemplo de franja
- Transacciones
- La forma recomendada
- Uso de un decorador
- Transacción por solicitud HTTP
- Puntos de guardado
- Transacciones anidadas
¿Qué es una transacción?
De acuerdo con SQL-92, "Una transacción SQL (a veces llamada simplemente "transacción") es una secuencia de ejecuciones de sentencias SQL que es atómica con respecto a la recuperación". En otras palabras, todas las declaraciones SQL se ejecutan y confirman juntas. Del mismo modo, cuando se revierte, todas las declaraciones se revierten juntas.
Por ejemplo:
# START
note = Note(title="my first note", text="Yay!")
note = Note(title="my second note", text="Whee!")
address1.save()
address2.save()
# COMMIT
Entonces, una transacción es una sola unidad de trabajo en una base de datos. Y esa única unidad de trabajo está delimitada por una transacción de inicio y luego una confirmación o una reversión explícita.
¿Qué tiene de malo la gestión de transacciones antes de Django 1.6?
Para responder completamente a esta pregunta, debemos abordar cómo se manejan las transacciones en la base de datos, las bibliotecas de clientes y dentro de Django.
Bases de datos
Cada declaración en una base de datos debe ejecutarse en una transacción, incluso si la transacción incluye solo una declaración.
La mayoría de las bases de datos tienen un AUTOCOMMIT
configuración, que generalmente se establece en True de forma predeterminada. Este AUTOCOMMIT
envuelve cada declaración en una transacción que se compromete inmediatamente si la declaración tiene éxito. Por supuesto, puede llamar manualmente a algo como START_TRANSACTION
que suspenderá temporalmente el AUTOCOMMIT
hasta que llames a COMMIT_TRANSACTION
o ROLLBACK
.
Sin embargo, lo importante aquí es que el AUTOCOMMIT
la configuración aplica una confirmación implícita después de cada declaración .
Bibliotecas cliente
Luego están las bibliotecas cliente de Python. como sqlite3 y mysqldb, que permiten que los programas de Python interactúen con las propias bases de datos. Tales bibliotecas siguen un conjunto de estándares sobre cómo acceder y consultar las bases de datos. Ese estándar, DB API 2.0, se describe en PEP 249. Si bien puede ser una lectura un poco seca, una conclusión importante es que PEP 249 establece que la base de datos AUTOCOMMIT
debe estar APAGADO por defecto.
Esto claramente entra en conflicto con lo que sucede dentro de la base de datos:
- Las declaraciones SQL siempre tienen que ejecutarse en una transacción, que la base de datos generalmente abre para usted a través de
AUTOCOMMIT
. - Sin embargo, según PEP 249, esto no debería ocurrir.
- Las bibliotecas cliente deben reflejar lo que sucede dentro de la base de datos, pero dado que no se les permite activar
AUTOCOMMIT
activados de forma predeterminada, simplemente envuelven sus instrucciones SQL en una transacción, al igual que la base de datos.
Bueno. Quédate conmigo un poco más.
Django
Entra Django. Django también tiene algo que decir sobre la gestión de transacciones. En Django 1.5 y versiones anteriores, Django básicamente se ejecutaba con una transacción abierta y autocomprometía esa transacción cuando escribía datos en la base de datos. Así que cada vez que llamaste a algo como model.save()
o model.update()
, Django generó las sentencias SQL apropiadas y comprometió la transacción.
También en Django 1.5 y versiones anteriores, se recomendaba usar el TransactionMiddleware
para vincular transacciones a solicitudes HTTP. A cada solicitud se le dio una transacción. Si la respuesta regresara sin excepciones, Django confirmaría la transacción, pero si su función de visualización arrojara un error, ROLLBACK
sería llamado. Esto, en efecto, desactivó AUTOCOMMIT
. Si deseaba una gestión de transacciones de estilo de confirmación automática estándar a nivel de base de datos, tenía que gestionar las transacciones usted mismo, normalmente mediante el uso de un decorador de transacciones en su función de vista como @transaction.commit_manually
, o @transaction.commit_on_success
.
Tomar un respiro. O dos.
¿Qué significa esto?
Sí, están sucediendo muchas cosas allí, y resulta que la mayoría de los desarrolladores solo quieren las confirmaciones automáticas de nivel de base de datos estándar, lo que significa que las transacciones permanecen detrás de escena, haciendo lo suyo, hasta que necesite ajustarlas manualmente.
¿Qué tiene de bueno la gestión de transacciones en Django 1.6?
Ahora, bienvenido a Django 1.6. Haz tu mejor esfuerzo para olvidar todo lo que acabamos de hablar y simplemente recuerda que en Django 1.6, usas la base de datos AUTOCOMMIT
y gestione las transacciones manualmente cuando sea necesario. Esencialmente, tenemos un modelo mucho más simple que básicamente hace lo que la base de datos fue diseñada para hacer en primer lugar.
Basta de teoría. Codifiquemos.
Ejemplo de rayas
Aquí tenemos esta función de vista de ejemplo que maneja el registro de un usuario y llama a Stripe para el procesamiento de tarjetas de crédito.
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)
)
Esta vista primero llama a Customer.create
que en realidad llama a Stripe para manejar el procesamiento de la tarjeta de crédito. Luego creamos un nuevo usuario. Si recibimos una respuesta de Stripe, actualizamos el cliente recién creado con el stripe_id
. Si no recuperamos a un cliente (Stripe no funciona), agregaremos una entrada a UnpaidUsers
tabla con el correo electrónico de los clientes recién creados, para que podamos pedirles que vuelvan a intentar los detalles de su tarjeta de crédito más tarde.
La idea es que incluso si Stripe no funciona, el usuario aún puede registrarse y comenzar a usar nuestro sitio. Simplemente les pediremos nuevamente en una fecha posterior la información de la tarjeta de crédito.
Entiendo que este puede ser un ejemplo un poco artificial, y no es la forma en que implementaría dicha funcionalidad si tuviera que hacerlo, pero el propósito es demostrar transacciones.
Adelante. Pensando en transacciones, y teniendo en cuenta que por defecto Django 1.6 nos da AUTOCOMMIT
comportamiento para nuestra base de datos, veamos el código relacionado con la base de datos un poco más.
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:
# ...
¿Puedes detectar algún problema? Bueno, ¿qué sucede si los UnpaidUsers(email=cd['email']).save()
la línea falla?
Tendrá un usuario, registrado en el sistema, que el sistema cree que ha verificado su tarjeta de crédito, pero en realidad no ha verificado la tarjeta.
Solo queremos uno de dos resultados:
- El usuario se crea (en la base de datos) y tiene un
stripe_id
. - El usuario se crea (en la base de datos) y no tiene un
stripe_id
Y una fila asociada enUnpaidUsers
se genera una tabla con la misma dirección de correo electrónico.
Lo que significa que queremos que las dos declaraciones de base de datos separadas se confirmen o retrocedan. Un estuche perfecto para la humilde transacción.
Primero, escribamos algunas pruebas para verificar que las cosas se comporten como queremos.
@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)
El decorador en la parte superior de la prueba es un simulacro que arrojará un "Error de integridad" cuando intentemos guardar en los UnpaidUsers
mesa.
Esto es para responder a la pregunta, "¿Qué sucede si los UnpaidUsers(email=cd['email']).save()
falla la línea? El siguiente bit de código simplemente crea una sesión simulada, con la información adecuada que necesitamos para nuestra función de registro. Y luego el with mock.patch
obliga al sistema a creer que Stripe está caído... finalmente llegamos a la prueba.
resp = register(self.request)
La línea anterior simplemente llama a nuestra función de vista de registro pasando la solicitud simulada. Luego solo verificamos que las tablas no estén actualizadas:
#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)
Entonces debería fallar si ejecutamos la prueba:
======================================================================
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
----------------------------------------------------------------------
Lindo. Parece divertido decirlo, pero eso es exactamente lo que queríamos. Recuerde:estamos practicando TDD aquí. El mensaje de error nos dice que el usuario se está almacenando en la base de datos, ¡que es exactamente lo que no queremos porque no pagó!
Transacciones al rescate…
Transacciones
En realidad, hay varias formas de crear transacciones en Django 1.6.
Repasemos algunos.
La forma recomendada
Según la documentación de Django 1.6:
“Django proporciona una sola API para controlar las transacciones de la base de datos. […] La atomicidad es la propiedad definitoria de las transacciones de bases de datos. atomic nos permite crear un bloque de código dentro del cual se garantiza la atomicidad en la base de datos. Si el bloque de código se completa con éxito, los cambios se confirman en la base de datos. Si hay una excepción, los cambios se deshacen.”
Atomic se puede utilizar como decorador o como context_manager. Entonces, si lo usamos como administrador de contexto, el código en nuestra función de registro se vería así:
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')
Tenga en cuenta la línea with transaction.atomic()
. Todo el código dentro de ese bloque se ejecutará dentro de una transacción. Entonces, si volvemos a ejecutar nuestras pruebas, ¡todas deberían pasar! Recuerde que una transacción es una sola unidad de trabajo, por lo que todo dentro del administrador de contexto se revierte cuando los UnpaidUsers
falla la llamada.
Usando un decorador
También podemos intentar agregar atomic como decorador.
@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 volvemos a ejecutar nuestras pruebas, fallarán con el mismo error que tuvimos antes.
¿Porqué es eso? ¿Por qué la transacción no se revirtió correctamente? La razón es porque transaction.atomic
está buscando algún tipo de Excepción y bueno, detectamos ese error (es decir, el IntegrityError
en nuestro bloque try except), entonces transaction.atomic
nunca lo vi y, por lo tanto, el estándar AUTOCOMMIT
la funcionalidad se hizo cargo.
Pero, por supuesto, eliminar el intento de excepción hará que la excepción simplemente se arroje a la cadena de llamadas y lo más probable es que explote en otro lugar. Así que tampoco podemos hacer eso.
Entonces, el truco es colocar el administrador de contexto atómico dentro del bloque de prueba excepto, que es lo que hicimos en nuestra primera solución. Mirando el código correcto de nuevo:
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')
Cuando UnpaidUsers
dispara el IntegrityError
la transaction.atomic()
el administrador de contexto lo detectará y realizará la reversión. En el momento en que nuestro código se ejecuta en el controlador de excepciones (es decir, el form.addError
line) se realizará la reversión y podremos realizar llamadas a la base de datos de forma segura si es necesario. También tenga en cuenta cualquier llamada a la base de datos antes o después de transaction.atomic()
el administrador de contexto no se verá afectado independientemente del resultado final de context_manager.
Transacción por solicitud HTTP
Django 1.6 (como 1.5) también le permite operar en un modo de "Transacción por solicitud". En este modo, Django envolverá automáticamente su función de vista en una transacción. Si la función arroja una excepción, Django revertirá la transacción; de lo contrario, confirmará la transacción.
Para configurarlo, debe configurar ATOMIC_REQUEST
a True en la configuración de la base de datos para cada base de datos que desee que tenga este comportamiento. Así que en nuestro "settings.py" hacemos el cambio así:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(SITE_ROOT, 'test.db'),
'ATOMIC_REQUEST': True,
}
}
En la práctica, esto se comporta exactamente como si pusieras el decorador en nuestra función de vista. Así que no sirve para nuestros propósitos aquí.
Sin embargo, vale la pena señalar que tanto con ATOMIC_REQUESTS
y el @transaction.atomic
decorador, aún es posible capturar/manejar esos errores después de que se arrojen de la vista. Para detectar esos errores, tendría que implementar algún middleware personalizado, o podría anular urls.hadler500 o crear una plantilla 500.html.
Puntos de guardado
Aunque las transacciones son atómicas, se pueden dividir en puntos de guardado. Piense en los puntos de guardado como transacciones parciales.
Entonces, si tiene una transacción que requiere cuatro declaraciones SQL para completarse, puede crear un punto de guardado después de la segunda declaración. Una vez que se crea ese punto de guardado, incluso si la tercera o la cuarta declaración fallan, puede hacer una reversión parcial, deshaciéndose de la tercera y la cuarta declaración pero conservando las dos primeras.
Básicamente, es como dividir una transacción en transacciones más pequeñas y livianas, lo que le permite realizar reversiones parciales o confirmaciones.
Pero tenga en cuenta si la transacción principal se deshace (quizás debido a un IntegrityError
) que se generó y no se detectó, todos los puntos de guardado también se revertirán).
Veamos un ejemplo de cómo funcionan los puntos de guardado.
@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)
Aquí toda la función está en una transacción. Después de crear un nuevo usuario, creamos un punto de guardado y obtenemos una referencia al punto de guardado. Las siguientes tres declaraciones-
user.name = 'starting down the rabbit hole'
user.stripe_id = 4
user.save()
-no son parte del punto de guardado existente, por lo que tienen la posibilidad de ser parte del próximo savepoint_rollback
o savepoint_commit
. En el caso de un savepoint_rollback
, la línea user = User.create('jj','inception','jj','1234')
seguirá estando comprometido con la base de datos, aunque el resto de las actualizaciones no lo estarán.
Dicho de otra manera, estas dos pruebas siguientes describen cómo funcionan los puntos de guardado:
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')
Además, después de confirmar o revertir un punto de guardado, podemos continuar trabajando en la misma transacción. Y ese trabajo no se verá afectado por el resultado del punto de guardado anterior.
Por ejemplo, si actualizamos nuestros save_points
funcionar como tal:
@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')
Independientemente de si savepoint_commit
o savepoint_rollback
fue llamado el usuario 'limbo' aún se creará con éxito. A menos que otra cosa provoque que se revierta toda la transacción.
Transacciones anidadas
Además de especificar puntos de guardado manualmente, con savepoint()
, savepoint_commit
y savepoint_rollback
, crear una Transacción anidada creará automáticamente un punto de guardado para nosotros y lo revertirá si recibimos un error.
Extendiendo nuestro ejemplo un poco más, obtenemos:
@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
Aquí podemos ver que después de tratar con nuestros puntos de guardado, estamos usando transaction.atomic
administrador de contexto para encerrar nuestra creación del usuario 'limbo'. Cuando se llama a ese administrador de contexto, en realidad se está creando un punto de guardado (porque ya estamos en una transacción) y ese punto de guardado se confirmará o revertirá al salir del administrador de contexto.
Así, las siguientes dos pruebas describen su comportamiento:
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)
Entonces, en realidad, puede usar atomic
o savepoint
para crear puntos de guardado dentro de una transacción. Con atomic
no tiene que preocuparse explícitamente por la confirmación / reversión, mientras que con savepoint
tienes control total sobre cuándo sucede eso.
Conclusión
Si tenía alguna experiencia previa con versiones anteriores de las transacciones de Django, puede ver cuánto más simple es el modelo de transacción. También teniendo AUTOCOMMIT
on by default es un gran ejemplo de valores predeterminados "cuerdos" que tanto Django como Python se enorgullecen de ofrecer. Para muchos sistemas, no necesitará tratar directamente con las transacciones, simplemente deje que AUTOCOMMIT
hacer su trabajo. Pero si lo hace, esperamos que esta publicación le haya brindado la información que necesita para administrar transacciones en Django como un profesional.