В этой небольшой статье я хочу дать ответ на вопрос, который возник у меня, когда я познакомился с сессиями в 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 в базу раньше времени и оба объекта содержат свежие значение.
