В этой небольшой статье я хочу дать ответ на вопрос, который возник у меня, когда я познакомился с сессиями в SQLAlchemy. Если сформулировать его кратко, то звучит он примерно так: “А зачем оно надо вообще”? Меня, как человека пришедшего из мира джанги, сессии приводили в уныние и я считал их откровенной фигней, которая усложняет жизнь. Но я ошибался. Как оказалось, сессии в алхимии - штука очень даже полезная. И вот почему.
Cессии являются неотъемлемой частью SQLAlchemy ORM и реализуют шаблоны Unit Of Work и Identity Map. Что это за шаблоны и зачем они нужны мы сейчас и разберем.
Unit Of Work (UoW, Единица работы)
Сессии в рамках этого паттерна отслеживают изменения, сделанные в рамках одной бизнес-транзакции, а затем “сбрасывают” их пачкой в базу, предварительно выполнив топологическую сортировку по зависимостям и сгруппировав повторяющиеся операции.
Чтобы понять зачем это надо, возьмем пару сниппетов с ActiveRecord ORM-ом и посмотрим какие проблемы там возникают и как сессии их решают.
class User(models.Model):
username = models.CharField(max_length=255)
name = models.CharField(max_length=255)
last_name = models.CharField(max_length=255)
class PhoneNumber(models.Model):
number = models.CharField(max_length=255)
user = models.ForeignKey(User, on_delete=models.CASCADE)
def process_users(users_records):
for user_record in users_records:
u = User(**user_record['user'])
# Мы не сможем сохранить пользователя, если отсутствуют обязательные поля
u.save()
for entry in user_record['entries']:
if entry['type'] == 'phone':
p = PhoneNumber(user=u, number=entry['phone'])
# Если мы не сохранили пользователя выше, то мы не сможем добавить телефон
p.save()
elif entry['type'] == 'fields':
u.name = entry['name']
u.last_name = entry['last_name']
u.save()
Какие проблемы мы здесь можем увидеть?:
Мы отправляем множество мелких запросов, чем увеличиваем нагрузку на базу, т.к. при каждом вызове метода .save() ORM отправит в базе запрос INSERT/UPDATE. При вызове .delete() происходит то же самое, к слову.
Нам необходимо самим поддерживать правильный порядок запросов, что увеличивает сложность и может приводить к ошибкам. Мы не сможем, к примеру, создать пользователя, если у него не заполнены все обязательные поля, как не сможем записать телефон, если не смогли сохранить пользователя. Для решения этой проблемы мы можем держать объекты в памяти и отправлять запросы уже в самом конце, но в таком случае нам нужно отправлять запросы в правильном порядке и порядок этот поддерживать вручную.
Теперь запишем все то же самое, но только с применением сессий:
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
username = Column(String(255))
name = Column(String(255))
last_name = Column(String(255))
phones = relationship(
"PhoneNumber", back_populates="user"
)
class PhoneNumber(Base):
__tablename__ = 'phone_numbers'
id = Column(Integer, primary_key=True)
number = Column(String(255))
user_id = Column(Integer, ForeignKey(User.id))
user = relationship(User, back_populates="phones")
def process_users(users_records):
with Session() as sess:
for user_record in users_records:
user = User(**user_record['user'])
# Никаких запросов в базу не пойдет
sess.add(user)
for entry in user_record['entries']:
if entry['type'] == 'phone':
phone = PhoneNumber(user=user, number=entry['phone'])
# Здесь также никаких запросов в базу не отправляется
sess.add(phone)
elif entry['type'] == 'fields':
user.name = entry['name']
user.last_name = entry['last_name']
# Здесь сессия откроет транзакцию, отправит запросы и выполнит commit
sess.commit()
Чем же этот вариант лучше?
Во-первых сессия откроет транзакцию прозрачным для программиста образом перед самой отправкой запросов в базу, т.е. мы не держим транзакцию долго открытой.
Во-вторых, сессия сгруппирует операции обновления данных и мы избавимся от множества мелких запросов и снизим нагрузку на базу.
В-третьих, сессия за наc аггрегирует в памяти изменения и отправит запросы в правильном порядке, выполнив сортировку по зависимостям.
Чтобы было нагляднее, приведу пример входных данных и sql, который прийдет для них в базу.
USERS_RECORDS = [
{
'user': {
'username': 'donald',
'name': 'Donald',
'last_name': 'Duck'
},
'entries': [
{
'type': 'phone',
'phone': '+7 941 234 43 45'
}
]
},
{
'user': {
'username': 'bullwi',
'name': 'Bullwinkle',
'last_name': 'Moose'
},
'entries': [
{
'type': 'fields',
'name': 'Rocky',
'last_name': 'Squirrel'
}
]
}
]
process_users(USERS_RECORDS)
BEGIN
INSERT INTO users (username, name, last_name) VALUES ('donald', 'Donald', 'Duck'),('bullwi', 'Rocky', 'Squirrel') RETURNING users.id
INSERT INTO phone_numbers (number, user_id) VALUES ('+7 941 234 43 45', 3) RETURNING phone_numbers.id
COMMIT
Обратите внимание на порядок запросов и их число. В частности сначала создаются пользователи вне зависимости от того, в каком порядке объекты создавались в приложении, а для создания пользователей нам потребовался всего один запрос.
Identity Map (IM, Карта идентичности)
Этот паттерн гарантирует, что объекты, загруженные из базы присутствуют в приложении только в одном экземпляре. Помимо гарантии уникальности сессия в рамках этого шаблона может сокращать число запросов к базе. Но не во всех случаях.
Рассмотрим пример.
u1 = User.objects.filter(username='donald').first()
u2 = User.objects.filter(username='donald').first()
u3 = User.objects.get(3) # Donald
u4 = User.objects.filter(id=3).first() # Donald
assert u1 is u2 is u3 is u4 # Fails
В случае ActiveRecord-а в базу уйдет 4 запроса на выборку и мы получим 4 разных объекта на уровне приложения. Это приводит опять же к тому, что:
Нам нужно следить за порядком операций
Объекты могут содержать устаревшие данные
def process_user_one():
u = User.objects.get(3) # Donald
u.name = 'Don'
return u
def process_user_two():
u = User.objects.get(3) # Donald
if u.name == 'Don':
p = PhoneNumber(user=u, number='+1 234 443 23 42')
p.save()
u.name = 'Donald'
return u
user1 = process_user_one()
user1.save()
user2 = process_user_two()
user2.save()
assert user1.name == 'Donald' # Fails, user1.name == ‘Don’
assert user2.name == 'Donald'
Как видим, нам нужно позаботиться, чтобы user1
был сохранен в базу раньше вызова process_user_two()
, в противном случае результат будет другим. Вторая же проблема - это устаревшие данные: user1
все еще зовут Don. В большом приложении это может стать источником неприятных ошибок.
В случае использования сессий обе эти проблемы будут решены, плюс число запросов к базе может сократиться.
u1 = session.query(User).filter_by(username='donald').one()
u2 = session.query(User).filter_by(username='donald').one()
u3 = session.query(User).get(3) # Donald
u4 = session.query(User).filter_by(id=3).one() # Donald
assert u1 is u2 is u3 is u4 # Success
В данном примере в базу уйдет только 3 запроса. Когда мы вызовем метод .get()
, сессия возьмет нашего Дональда из своей карты объектов без доп. запроса.
def process_user_one(session):
u = session.query(User).get(3)
u.name = 'Don'
return u
def process_user_two(session):
u = session.query(User).get(3)
if u.name == 'Don':
p = PhoneNumber(user=u, number='+1 234 443 23 42')
session.add(p)
u.name = 'Donald'
return u
with Session() as sess:
user1 = process_user_one(sess)
user2 = process_user_two(sess)
assert user1.name == 'Donald' # Success
assert user2.name == 'Donald' # Success
Здесь же нам не обязательно сохранять user1
в базу раньше времени и оба объекта содержат свежие значение.
danilovmy
Спасибо @romblin, а что насчет builk_create и atomic transaction, разве это не аналогичный функционал в Джанго?
Соответственно если во втором примере - создание телефона для Don в Джанго - сделать через один запрос Phone.filter(user_Name=Don).create(**kwargs) - то в чем преимущество сессии?
Спрашиваю,т.к. смотрел на алхимию из Джанго-орм но больших преимуществ не нашел.