Разбираемся с сессиями в SQLAlchemy

от автора

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

Какие проблемы мы здесь можем увидеть?:

  1. Мы отправляем множество мелких запросов, чем увеличиваем нагрузку на базу, т.к. при каждом вызове метода .save() ORM отправит в базе запрос INSERT/UPDATE. При вызове .delete() происходит то же самое, к слову.

  2. Нам необходимо самим поддерживать правильный порядок запросов, что увеличивает сложность и может приводить к ошибкам. Мы не сможем, к примеру, создать пользователя, если у него не заполнены все обязательные поля, как не сможем записать телефон, если не смогли сохранить пользователя. Для решения этой проблемы мы можем держать объекты в памяти и отправлять запросы уже в самом конце, но в таком случае нам нужно отправлять запросы в правильном порядке и порядок этот поддерживать вручную.

Теперь запишем все то же самое, но только с применением сессий:

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()

Чем же этот вариант лучше?

  1. Во-первых сессия откроет транзакцию прозрачным для программиста образом перед самой отправкой запросов в базу, т.е. мы не держим транзакцию долго открытой.

  2. Во-вторых, сессия сгруппирует операции обновления данных и мы избавимся от множества мелких запросов и снизим нагрузку на базу.

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

  1. Нам нужно следить за порядком операций

  2. Объекты могут содержать устаревшие данные

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 в базу раньше времени и оба объекта содержат свежие значение.


ссылка на оригинал статьи https://habr.com/ru/post/597999/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *