Перевод статьи: Haki Benita — Bullet Proofing Django Models
Недавно мы добавили банковские реквизиты в один из наших продуктов. Во время разработки мы столкнулись с некоторыми проблемами, и я подумал, что это может быть хорошей возможностью для изучения некоторых шаблонов, которые мы используем в наших моделях Django.
Эта статья была написана в том порядке, в котором мы обычно решаем новые проблемы:
Теперь, когда у нас есть бизнес-требования, мы можем начать с определения модели.
# models.py import uuid from django.conf import settings from django.db import models class Account(models.Model): class Meta: verbose_name = 'Account' verbose_name_plural = 'Accounts' MAX_TOTAL_BALANCES = 10000000 MAX_BALANCE = 10000 MIN_BALANCE = 0 MAX_DEPOSIT = 1000 MIN_DEPOSIT = 1 MAX_WITHDRAW = 1000 MIN_WITHDRAW = 1 id = models.AutoField( primary_key=True, ) uid = models.UUIDField( unique=True, editable=False, default=uuid.uuid4, verbose_name='Public identifier', ) user = models.OneToOneField( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, ) created = models.DateTimeField( blank=True, ) modified = models.DateTimeField( blank=True, ) balance = models.PositiveIntegerField( verbose_name='Current balance', )
Давайте разберемся с этим:
Теперь, когда у нас есть модель учетной записи, мы можем создать модель для регистрации действий, выполненных с учетной записью:
# models.py class Action(models.Model): class Meta: verbose_name = 'Account Action' verbose_name_plural = 'Account Actions' ACTION_TYPE_CREATED = 'CREATED' ACTION_TYPE_DEPOSITED = 'DEPOSITED' ACTION_TYPE_WITHDRAWN = 'WITHDRAWN' ACTION_TYPE_CHOICES = ( (ACTION_TYPE_CREATED, 'Created'), (ACTION_TYPE_DEPOSITED, 'Deposited'), (ACTION_TYPE_WITHDRAWN, 'Withdrawn'), ) REFERENCE_TYPE_BANK_TRANSFER = 'BANK_TRANSFER' REFERENCE_TYPE_CHECK = 'CHECK' REFERENCE_TYPE_CASH = 'CASH' REFERENCE_TYPE_NONE = 'NONE' REFERENCE_TYPE_CHOICES = ( (REFERENCE_TYPE_BANK_TRANSFER, 'Bank Transfer'), (REFERENCE_TYPE_CHECK, 'Check'), (REFERENCE_TYPE_CASH, 'Cash'), (REFERENCE_TYPE_NONE, 'None'), ) id = models.AutoField( primary_key=True, ) user_friendly_id = models.CharField( unique=True, editable=False, max_length=30, ) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, help_text='User who performed the action.', ) created = models.DateTimeField( blank=True, ) account = models.ForeignKey( Account, ) type = models.CharField( max_length=30, choices=ACTION_TYPE_CHOICES, ) delta = models.IntegerField( help_text='Balance delta.', ) reference = models.TextField( blank=True, ) reference_type = models.CharField( max_length=30, choices=REFERENCE_TYPE_CHOICES, default=REFERENCE_TYPE_NONE, ) comment = models.TextField( blank=True, ) # Fields used solely for debugging purposes. debug_balance = models.IntegerField( help_text='Balance after the action.', )
Что же мы имеем здесь?
Примечание о дизайне: сохранение рассчитанных полей в модели обычно является плохим дизайном. По возможности следует избегать хранения вычисляемых полей, таких как баланс счета.
Однако в нашей «реальной» реализации есть дополнительные типы действий и тысячи действий для каждой учетной записи — мы рассматриваем хранение рассчитанного атрибута как оптимизацию.
У нас есть три клиентских приложения, которые мы должны поддерживать:
Наша задача так же состоит в том, чтобы следовать принципу DRY, насколько это возможно.
У нас есть два типа проверок, скрывающихся в бизнес-требованиях:
Проверка ввода, такая как «сумма должна быть между X и Y», «баланс не может превышать Z» и т. д. — эти типы проверки хорошо поддерживаются Django и обычно могут быть выражены как ограничения базы данных (database constraints) или проверки django (django validations).
Вторая проверка немного сложнее. Мы должны обеспечить, чтобы общая сумма всех остатков во всей системе не превышала определенной суммы. Это заставляет нас проверять экземпляр на соответствие всем остальным экземплярам модели.
Условия гонки — это очень распространенная проблема в распределенных системах, и особенно в моделях, которые поддерживают состояние, таких как банковский счет (вы можете прочитать больше о условиях гонки в Википедии (race conditions in Wikipedia)).
Для иллюстрации проблемы рассмотрим пример со счетом с балансом 100 $. Пользователь подключается с двух разных устройств одновременно и запускает снятие 100$. Поскольку оба действия были выполнены в одно и то же время, возможно, что оба они получили текущий баланс в 100$. Учитывая, что оба сеанса видят достаточный баланс, они оба будут одобрены и обновят новый баланс до 0$. Пользователь снял в общей сложности 200$, и текущий баланс теперь равен 0$ — у нас есть условие гонки, и мы потеряли 100$.
Журнал логирования служит двум целям:
Записи истории должны быть на 100% неизменными.
Давайте начнем с наивного внедрения депозита (сразу предупредим это не очень хорошая реализация):
class Account(models.Model): # ... def deposit(self, amount, deposited_by, asof): assert amount > 0 if not self.MIN_DEPOSIT <= amount <= self.MAX_DEPOSIT: raise InvalidAmount(amount) if self.balance + amount > self.MAX_BALANCE: raise ExceedsLimit() total = Account.objects.aggregate( total=Sum('balance') )['total'] if total + amount > self.MAX_TOTAL_BALANCES: raise ExceedsLimit() action = self.actions.create( user=deposited_by, type=Action.ACTION_TYPE_DEPOSITED, delta=amount, asof=asof, ) self.balance += amount self.modified = asof self.save()
И давайте добавим для этого простую конечную точку (URL), используя DRF @api_view:
# api.py # ... from django.db.models import transaction # ... @api_view('POST') def deposit(request): try: amount = int(request.data\['amount'\]) except (KeyError, ValueError): return Response(status=status.HTTP_400_BAD_REQUEST) with transaction.atomic(): try: account = ( Account.objects .select_for_update() .get(user=request.user) ) except Account.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) try: account.deposit( amount=amount, deposited_by=request.user, asof=timezone.now(), ) except (ExceedsLimit, InvalidAmount): return Response(status=status.HTTP_400_BAD_REQUEST) return Response(status=status.HTTP_200_OK)
Так в чем проблема?
Блокировка учетной записи — экземпляр не может заблокировать себя, поскольку он уже был получен. Мы отказались от контроля над блокировкой и извлечением, поэтому мы должны доверять вызывающей стороне для правильного получения блокировки — это очень плохой дизайн. Не верьте мне на слово, посмотрите на философию дизайна Django (Django’s design philosophy):
Слабая связь Основная цель стека Джанго — слабая связь и тесная сплоченность. Различные слои фреймворка не должны «знать» друг о друге без крайней необходимости.
Так действительно ли дело в нашем API, формах и администраторе django, чтобы получить учетную запись для нас и получить надлежащую блокировку? Думаю, нет.
Валидация — учетная запись должна проверять себя в отношении всех других учетных записей — что просто неудобно.
Нам нужно подключиться к процессу до получения учетной записи (чтобы создать блокировку) и в месте, где имеет смысл проверять и обрабатывать более одной учетной записи.
Давайте начнем с функции, чтобы создать экземпляр Action и записать его как classmethod:
# models.py from django.core.exceptions import ValidationError class Action(models.Model): # ... @classmethod def create( cls, user, account, type, delta, asof, reference=None, reference_type=None, comment=None, ): """Create Action. user (User): User who executed the action. account (Account): Account the action executed on. type (str, one of Action.ACTION_TYPE_\*): Type of action. delta (int): Change in balance. asof (datetime.datetime): When was the action executed. reference (str or None): Reference number when appropriate. reference_type(str or None): Type of reference. Defaults to "NONE". comment (str or None): Optional comment on the action. Raises: ValidationError Returns (Action) """ assert asof is not None if (type == cls.ACTION_TYPE_DEPOSITED and reference_type is None): raise errors.ValidationError({ 'reference_type': 'required for deposit.', }) if reference_type is None: reference_type = cls.REFERENCE_TYPE_NONE # Don't store null in text field. if reference is None: reference = '' if comment is None: comment = '' user_friendly_id = generate_user_friendly_id() return cls.objects.create( user_friendly_id=user_friendly_id, created=asof, user=user, account=account, type=type, delta=delta, reference=reference, reference_type=reference_type, comment=comment, debug_balance=account.balance, )
Что же мы имеем здесь:
Прежде чем перейти к реализации методов учетной записи, давайте определим пользовательские исключения для нашего модуля учетной записи:
# errors.py class Error(Exception): pass class ExceedsLimit(Error): pass class InvalidAmount(Error): def __init__(self, amount): self.amount = amount def __str__(str): return 'Invalid Amount: {}'.format(amount) class InsufficientFunds(Error): def __init__(self, balance, amount): self.balance = balance self.amount = amount def __str__(self): return 'amount: {}, current balance: {}'.format( self.amount, self.balance)
Мы определяем базовый класс Error, который наследуется от Exception. Это то, что мы сочли очень полезным и используем его очень часто. Базовый класс ошибок позволяет нам отлавливать все ошибки, поступающие от определенного модуля:
from account.errors import Error as AccountError try: # action on account except AccountError: # Handle all errors from account
Аналогичный шаблон можно найти в популярном пакете requests.
Давайте реализуем метод для создания новой учетной записи Account:
class Account(models.Model): # ... @classmethod def create(cls, user, created_by, asof): """Create account. user (User): Owner of the account. created_by (User): User that created the account. asof (datetime.datetime): Time of creation. Returns (tuple): [0] Account [1] Action """ with transaction.atomic(): account = cls.objects.create( user=user, created=asof, modified=asof, balance=0, ) action = Action.create( user=created_by, account=account, type=Action.ACTION_TYPE_CREATED, delta=0, asof=asof, ) return account, action
Довольно просто — создайте экземпляр, создайте действие и верните их обоих.
Обратите внимание, что и здесь мы принимаем asof — изменено, создано и время создания действия одинаково — вы не можете сделать это с помощью auto_add и auto_add_now.
Теперь к бизнес-логике:
# models.py @classmethod def deposit( cls, uid, deposited_by, amount, asof, comment=None, ): """Deposit to account. uid (uuid.UUID): Account public identifier. deposited_by (User): Deposited by. amount (positive int): Amount to deposit. asof (datetime.datetime): Time of deposit. comment(str or None): Optional comment. Raises Account.DoesNotExist InvalidAmount ExceedsLimit Returns (tuple): [0] (Account) Updated account instance. [1] (Action) Deposit action. """ assert amount > 0 with transaction.atomic(): account = cls.objects.select_for_update().get(uid=uid) if not (cls.MIN_DEPOSIT <= amount <= cls.MAX_DEPOSIT): raise errors.InvalidAmount(amount) if account.balance + amount > cls.MAX_BALANCE: raise errors.ExceedsLimit() total = cls.objects.aggregate(total=Sum('balance'))\['total'\] if total + amount > cls.MAX_TOTAL_BALANCES: raise errors.ExceedsLimit() account.balance += amount account.modified = asof account.save(update_fields=[ 'balance', 'modified', ]) action = Action.create( user=deposited_by, account=account, type=Action.ACTION_TYPE_DEPOSITED, delta=amount, asof=asof, ) return account, action @classmethod def withdraw( cls, uid, withdrawn_by, amount, asof comment=None, ): """Withdraw from account. uid (uuid.UUID): Account public identifier. withdrawn_by (User): The withdrawing user. amount (positive int): Amount to withdraw. asof (datetime.datetime): Time of withdraw. comment (str or None): Optional comment. Raises: Account.DoesNotExist InvalidAmount InsufficientFunds Returns (tuple): [0] (Account) Updated account instance. [1] (Action) Withdraw action. """ assert amount > 0 with transaction.atomic(): account = cls.objects.select_for_update().get(uid=uid) if not (cls.MIN_WITHDRAW <= amount <= cls.MAX_WITHDRAW): raise InvalidAmount(amount) if account.balance - amount < cls.MIN_BALANCE: raise InsufficientFunds(amount, account.balance) account.balance -= amount account.modified = asof account.save(update_fields=[ 'balance', 'modified', ]) action = Action.create( user=withdrawn_by, account=account, type=Action.ACTION_TYPE_WITHDRAWN, delta=-amount, asof=asof, ) return account, action
Что мы здесь сделали:
Итак, как модель справляется с нашими проблемами?
Наше приложение будет неполным без надлежащих тестов. Ранее я писал о тестировании на основе классов — здесь мы собираемся использовать немного другой подход, но у нас все еще будет базовый класс с функциями утилит:
# tests/common.py class TestAccountBase: DEFAULT = object() @classmethod def default(value, default_value): return default_value if value is cls.DEFAULT else value @classmethod def setUpTestData(cls): super().setUpTestData() # Set up some default values cls.admin = User.objects.create_superuser( 'Admin', 'admin', 'admin@testing.test', ) cls.user_A = User.objects.create_user( 'user_A', 'user_A', 'A@testing.test', ) @classmethod def create( cls, user=DEFAULT, created_by=DEFAULT, asof=DEFAULT ): user = cls.default(user, cls.user_A) created_by = cls.default(created_by, cls.admin) asof = cls.default(asof, timezone.now()) account, action = Account.create(user, created_by, asof) return cls.account, action def deposit( self, amount, account=DEFAULT, deposited_by=DEFAULT, asof=DEFAULT, comment=DEFAULT, ): account = self.default(account, self.account) deposited_by = self.default(deposited_by, self.admin) asof = self.default(asof, timezone.now()) comment = self.default(comment, 'deposit comment') self.account, action = Account.deposit( uid=account.uid, deposited_by=deposited_by, amount=amount, asof=asof, ) self.assertEqual(action.type, Action.ACTION_TYPE_DEPOSITED) self.assertIsNotNone(action.user_friendly_id) self.assertEqual(action.created, asof) self.assertEqual(action.delta, amount) self.assertEqual(action.user, deposited_by) return action def withdraw( self, amount, account=DEFAULT, withdrawn_by=DEFAULT, asof=DEFAULT, comment=DEFAULT, ): account = self.default(account, self.account) withdrawn_by = self.default(withdrawn_by, self.admin) asof = self.default(asof, timezone.now()) comment = self.default(comment, 'withdraw comment') self.account, action = Account.withdraw( uid=account.uid, withdrawn_by=withdrawn_by, amount=amount, asof=asof, ) self.assertEqual(action.type, Action.ACTION_TYPE_WITHDRAWN) self.assertIsNotNone(action.user_friendly_id) self.assertEqual(action.created, asof) self.assertEqual(action.delta, amount) self.assertEqual(action.user, withdrawn_by) return action
Чтобы упростить написание теста, мы используем служебные функции, чтобы каждый раз указывать пользователя, учетную запись и т. д. путем предоставления значений по умолчанию и работы с self.account.
Давайте использовать наш базовый класс, чтобы написать несколько тестов:
# tests/test_account.py from unittest import mock from django.test import TestCase from .common import TestAccoutBase from ..models import Account, Action from ..errors import ( InvalidAmount, ExceedsLimit, InsuficientFunds, ) class TestAccount(TestAccountBase, TestCase): def setUp(self): **self.account, _ = cls.create()** def test_should_start_with_zero_balance(self): self.assertEqual(self.account.balance, 0) def test_should_deposit(self): self.deposit(100) self.assertEqual(self.account.balance, 100) self.deposit(150) self.assertEqual(self.account.balance, 250) def test_should_fail_to_deposit_less_than_minimum(self): with self.assertRaises(InvalidAmount): self.deposit(Account.MIN_DEPOSIT - 1) self.assertEqual(self.account.balance, 0) def test_should_fail_to_deposit_more_than_maximum(self): with self.assertRaises(InvalidAmount): self.deposit(Account.MAX_DEPOSIT + 1) self.assertEqual(self.account.balance, 0) @mock.patch('account.models.Account.MAX_BALANCE', 500) @mock.patch('account.models.Account.MAX_DEPOSIT', 502) def test_should_fail_to_deposit_more_than_max_balance(self): with self.assertRaises(ExceedsLimit): self.deposit(501) self.assertEqual(self.account.balance, 0) @mock.patch('account.models.Account.MAX_BALANCE', 500) @mock.patch('account.models.Account.MAX_DEPOSIT', 500) @mock.patch('account.models.Account.MAX_TOTAL_BALANCES', 600) def test_should_fail_when_exceed_max_total_balances(self): # Exceed max total balances for the same account self.deposit(500) with self.assertRaises(ExceedsLimit): self.deposit(500) self.assertEqual(self.account.balance, 500) # Exceed max total balances in other account other_user = User.objects.create_user('foo', 'bar', 'baz') other_account = self.create(user=other_user) with self.assertRaises(ExceedsLimit): self.deposit(200, account=other_account) self.assertEqual(other_account.balance, 0) def test_should_withdraw(self): self.deposit(100) self.withdraw(50) self.assertEqual(self.account.balance, 50) self.withdraw(30) self.assertEqual(self.account.balance, 20) def test_should_fail_when_insufficient_funds(self): self.deposit(100) with self.assertRaises(InsufficientFunds): self.withdraw(101) self.assertEqual(self.account.balance, 100)
Классовый подход зарекомендовал себя в нашей разработке уже довольно давно. Мы обнаружили, что он обеспечивает необходимую гибкость, удобочитаемость и тестируемость при минимальных затратах.
В этой статье мы представили две общие проблемы, с которыми мы часто сталкиваемся — проверка и параллелизм. Этот метод может быть расширен для обработки контроля доступа (разрешений) и кэширования (у нас есть полный контроль над выборкой, помните?), Оптимизации производительности (используйте select_related и update_fields …), аудита и мониторинга и дополнительной бизнес-логики.
Мы обычно поддерживаем несколько интерфейсов для каждой модели — интерфейс администратора для поддержки, API для мобильных клиентов и клиентов SPA и панель инструментов. Инкапсуляция бизнес-логики внутри модели сократила количество дублирования кода и требует проведения тестов, что приводит к общему качеству кода, который легко поддерживать.
В последующем посте я (возможно) представлю интерфейс администратора для этой модели с некоторыми полезными трюками (такими как настраиваемые действия, промежуточные страницы и т. д.) и, возможно, реализацией RPC, использующей DRF для взаимодействия с учетной записью в качестве API.
Краткий перевод: https://vuejs.org/guide/components/v-model.html Основное использование v-model используется для реализации двусторонней привязки в компоненте. Начиная с Vue…
Сегодня мы рады объявить о выпуске Vue 3.4 «🏀 Slam Dunk»! Этот выпуск включает в…
Vue.js — это универсальный и адаптируемый фреймворк. Благодаря своей отличительной архитектуре и системе реактивности Vue…
Недавно, у меня истек сертификат и пришлось заказывать новый и затем устанавливать на хостинг с…
Каким бы ни было ваше мнение о JavaScript, но всем известно, что работа с датами…
Все, кто следит за последними событиями в мире адаптивного дизайна, согласятся, что введение контейнерных запросов…
View Comments
Спасибо, благодаря этой статье я много понял как работать с балансами в реальном времени