Django: модели, инкапсуляция и целостность данных

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

Рассмотрим пример с пользовательскими аккаунтами для демонстрации данной проблемы.

STATUS_CHOICES = [
    ('trial', 'Trial'),
    ('signedup', 'Signed up'),
    ('expired', 'Expired')
]

class Account(models.Model):
    created = models.DateTimeField(auto_now_add=True)
    status = models.CharField(choices=STATUS_CHOICES, max_length=10,
                              db_index=True)
    signup_date = models.DateTimeField(null=True)

Правила, которые мы хотели бы применить к нашим аккаунтам:

  • Аккаунт изначально может быть создан в одном из состояний: «trial» или «signedup».
  • Аккаунт из состояния «trial» или «expired» может быть в любой момент переведен в состояние «signedup».
  • Спустя 30 дней аккаунт из состояния «trial» должен быть переведен в состояние «expired».

Давайте реализуем данные правила. Начнем с некоторой логики во view с именем create_account.

if 'signup' in request.POST:
    account = Account(status='signedup', signup_date=timezone.now())
else:
    account = Account(status='trial')
account.save()

У нас так же есть другая view с именем signup_account для регистрации уже существующего аккаунта со статусом trial или expired:

account.status = 'signedup'
account.signup_date = timezone.now()

Для отключения истекших аккаунтов у нас есть некоторая логика в команде управления expire_old_trials, которая периодически запускается по крону.

cutoff = timezone.now() - datetime.timedelta(days=30)
Account.objects.filter(status='trial', created__lte=cutoff)\
       .update(status='expired')

Так в чем же проблема?

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

Вот несколько возможных сценариев, которые могут произойти:

  • Добавляется еще одно представление, которое позволяет зарегистрировать аккаунт еще одним путем. Разработчик создает аккаунт со статусом signedup, но забывает выставить дату регистрации signup_date.
  • Разработчику потребовалось вручную перевести аккаунт пользователя в статус signedup и он забыл выставить дату регистрации signup_date.
  • Добавляется новое поле expire_date, которое корректно выставляется в команде expire_old_trials. Но разработчики забыли, что значение поле должно быть выставлено в None в представлении signup.

В каждом из этих случаев получаем противоречивые или неверные данные.

В нашем простейшем примере подобные ошибки находятся довольно просто. Но по мере роста объемов кодовой базы обнаружить их становится все труднее и риск возрастает.

Поход «Толстые модели, худые представления»

Стандартный совет тут будет: “Используйте толстые модели и тонкие представления”. Совершенно верно, но все же мало определенно. Что собой представляет “толстая” модель? Сколько логики достаточно для представления? Позволяет ли соглашение о “толстых” моделях по прежнему держать часть логики в хорошо определенных вспомогательных функциях?

Я переформирую данное утверждение более строго:

Никогда не устанавливайте поля модели и не вызывайте метод save() напрямую. Всегда используйте только методы модели или менеджеров для операций, изменяющих состояние модели.

Это простое и однозначное утверждение легко проверяется при обзоре кода.

Данный подход позволяет правильно инкапсулировать данные модели и позволяет ввести более строгие ограничения на уровне приложения для изменения состояний.

Данное правило так же подразумевает:

  • Никогда не вызывать конструктор модели напрямую.
  • Не использовать пакетные обновления или удаления нигде, кроме менеджеров модели.

Если ваша команда следует этому правилу, то вы можете более уверено утверждать о возможных состояниях данных и их изменениях.

Речь тут идет не о написании шаблонных сеттеров для каждого поля модели, а о написании методов, инкапсулирующих точки взаимодействия с уровнем базы данных. Представление по прежнему может проверять любое поле модели и реализовывать любую логику, основанную на этом, но не должно непосредственно менять состояние модели.

Мы обеспечены наличием уровня, в котором можем реализовывать правила целостности данных уровня приложения и который существует поверх уровня целостности данных, обеспечиваемых базой данных.

Давайте вернемся к нашему примеру, перенеся логику, изменяющую состояние, в модель и менеджер модели.

STATUS_CHOICES = [
    ('trial', 'Trial'),
    ('signedup', 'Signed up'),
    ('expired', 'Expired')
]

TRIAL_DURATION = datetime.timedelta(days=30)

class AccountManager(models.Manager):
    def create_trial(self):
        account = Account(status='trial')
        account.save()
        return account

    def create_signup(self):
        account = Account(status='signedup', signup_date=timezone.now())
        account.save()
        return account

    def expire_old_trials(self):
        cutoff = timezone.now() - TRIAL_DURATION
        self.filter(status='trial', created__lte=cutoff)\
            .update(status='expired')

class Account(models.Model):
    created = models.DateTimeField(auto_now_add=True)
    status = models.CharField(choices=STATUS_CHOICES, max_length=10,
                              db_index=True)
    signup_date = models.DateTimeField(null=True)

    objects = AccountManager()

    def signup(self):
        assert self.status in ('trial', 'expired')
        self.status = 'signedup'
        self.signup_date = timezone.now()
        self.save()

Теперь изменения состояния аккуратно заключены в одном модуле, а не разбросаны по нескольких файлам.

Наше представление create_account теперь использует вызов метода менеджера модели:

if 'signup' in request.POST:
    account = Account.objects.create_signup()
else:
    account = Account.objects.create_trial()

В представлении signup_account вызывается метод модели:

account.signup()

В команде управления вызывается метод .expire_old_trials() менеджера модели:

Account.objects.expire_old_trials()

Связанные модели и отношения

Соблюдение правила “Никогда не присваивайте значений полям модели и не вызывайте метод save() напрямую” особенно ценно, когда дело касается обеспечения целостности данных между несколькими связанными экземплярами моделей.

Хорошим примером может служить ситуация, когда экземпляры двух моделей должны создаваться одновременно. Вместо того, чтоб в коде вызывать два отдельных метода .create(), мы определим один метод в менеджере и обеспечим, что дочерний экземпляр всегда будет создаваться в том же месте, где и родительский.

Например, мы создали новую модель BillingInfo, которая будет использоваться для хранения информации о зарегистрированных аккаунтах.

class BillingInfo(models.Model):
    account = models.OneToOneField('accounts.Account',
                                   related_name='billing_info')
    address = models.TextField()
    card_type = models.CharField(max_length=20, choices=CARD_TYPE_CHOICES)

Мы расчитываем, что как только экземпляр аккаунта становится “signedup”, всегда будет создаваться связанный с ним экземпляр BillingInfo.

class AccountManager(models.Manager):
    ...

    def create_signup(self, address, card_type):
        account = Account(status='signedup', signup_date=timezone.now())
        account.save()
        BillingInfo.objects.create(address=address, card_type=card_type,
                                   account=self)
        return account

class Account(models.Model):
    ...

    def signup(self, address, card_type):
        assert self.status in ('trial', 'expired')
        self.status = 'signedup'
        self.signup_date = timezone.now()
        self.save()
        BillingInfo.objects.create(address=address, card_type=card_type,
                                   account=self)

Поскольку мы всегда используем методы модели и менеджера для смены состояния, то не сложно обеспечить создание объекта BillingInfo для любого зарегистрированного аккаунта.

Методы signup и create_signup теперь принимаются обязательные параметры address и card_type, и мы можем быть уверены в обеспечении целостности наших данных.

Тут также можно заметить, что наши методы дают нам прекрасные возможности для управления транзакциями. Мы легко можем обеспечить атомарность вносимых изменений, добавив декоратор @transaction.atomic к методу модели или менеджера.

Множественное обновление полей

Часто мы имеем модели с большим числом полей, чем мы хотели бы позволить менять.

Я бы советовал оборачивать такие обновления в явные методы. Это позволяет строго следить какие поля могут быть изменены. Вот пример, в котором все обновляемые поля обязательны:

def update(self, name, address, sort_code, phone_name, email):
    self.name = name
    self.address = address
    self.sort_code = sort_code
    self.phone_number = phone_number 
    self.email = email
    self.save()

Далее представлен альтернативный метод, который разрешает необязательные поля, но при этом ограничивает список доступных для изменения полей.

def update(self, **kwargs):
    allowed_attributes = {'name', 'address', 'sort_code', 'phone_number', 'email'}
    for name, value in kwargs.items():
        assert name in allowed_attributes
        setattr(self, name, value)
    self.save()

Нарушение правила

Есть одна специфичная часть Django, нарушающая правило инкапсуляции, которое должны обеспечивать классы моделей. Это ModelForm.

При использовании ModelForm мы не можем использовать методы менеджера .create() для инкапсуляции создания экземпляра, поскольку процесс вализации требует, чтоб инициализация объекта и его сохранения были на разных этапах. Вызов .is_valid() напрямую инициирует объект и делает его доступным как form.object. Этот объект сохраняется затем при вызове form.save().

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

Можно много говорить по этой теме, но я бы советовал использовать ModelForm осторожно и по возможности пользоваться явными классами Form.

Тесты

Единственное место в вашем коде, где вы можете смело нарушать данное правило - это тесты. Конечно более правильно строго придерживаться инкапсуляции моделей и мененджеров и тут, но нету ничего плохого в использовании и других подходов для инициализации тестовых экземпляров.

Итог

Главная мысль данного поста - простое соглашение, которое я повторю:

Никогда не устанавливайте поля модели и не вызывайте метод save() напрямую. Всегда используйте только методы модели или менеджеров для операций, изменяющих состояние модели.

Это соглашение более четкое и ему легче следовать, чем “Толстые модели, тонкие представления”, и оно не ограничевает вашу команду в дополнительной бизнес-логики над вашими моделями, если необходимо.

Принятие этого как части ваших правил программирования на Django поможет вашей команде выработать хороший стиль кодирования и придаст вам уверенности в целостности данных на уровне приложения.

Источник: https://www.dabapps.com/blog/django-models-and-encapsulation/

 
comments powered by Disqus