Как стать автором
Обновить
0

Архитектура в Django проектах — как выжить

Время на прочтение 13 мин
Количество просмотров 46K

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

Немного теории

Когда мы начинаем изучать Django без опыта из других языков и фреймворков, помимо документации мы читаем туториалы, статьи, книги, и почти во всех видим что-то подобное:

Django — это фреймворк, использующий шаблон проектирования Model-View-Controller (MVC).

И дальше куча неточных схем и объяснений о том, что такое MVC. Почему они неточные и что с ними не так, можно посмотреть здесь или здесь.

Обычно в таких схемах MVC описывают подобным образом:

  • Model — доступ к хранилищу данных

  • View — это интерфейс, с которым взаимодействует пользователь 

  • Controller — некий связывающий объект между model и view.

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

Стоит обратить внимание на две вещи.

Первое, часто под M в MVC подразумевают — модель данных, и говорят, что это некий класс, который отвечает за предоставление доступа к базе данных. Что неверно, и не соответствует классическому MVC и его потомкам MV*. В классическом MVC под M подразумевается domain model — объектная модель домена, объединяющая данные и поведение. Если говорить точнее, то M в MVC это интерфейс к доменной модели, так как domain model это некий слой объектов, описывающий различные стороны определенной области бизнеса. Где одни объекты призваны имитировать элементы данных, которыми оперируют в этой области, а другие должны формализовать те или иные бизнес-правила.

Второе, в  Django нет выделенного слоя controller, и когда вам говорят, что в Django слой views — это контроллер, не верьте этим людям. Обратитесь к официальной документации, а точнее к FAQ, тогда можно увидеть, что этот слой вписывается в принципы слоя View в MVC, особенно, если рассматривать DRF, а как такового слоя Controller в Django нет. Как говорится в FAQ, если вам очень хочется аббревиатур, то можно использовать в контексте Django аббревиатуру MTV (Model, Template, and View). Если очень хочется рассматривать Web MVC  и сравнивать Django с другими фреймворками, то для простоты можно считать view контроллером.

Несмотря на то, что Django не соответствует MVC аббревиатуре, в ней реализуется главный смысл MVC  — отделение бизнес-логики от логики представления данных. Но на практике это не всегда так по нескольким причинам, которые мы рассмотрим ниже.

Перейдем к практике

Выделим в Django приложениях несколько слоев, которые есть в каждом туториале и почти в каждом проекте:

  • front-end/templates

  • serializers/forms

  • views

  • models

Не будем рассматривать каждый слой подробно, это все можно найти в документации. В основном будем рассматривать Django  c использованием DRF.  Попробуем разобрать на двух простых кейсах, что стоит помещать в каждом из слоев и какая ответственность у каждого слоя. 

Первый кейс — создание заказа.  При создании заказа нам нужно:

  • проверить валидность заказа и доступность товаров

  • создать заказ

  • зарезервировать товар на складе

  • передать заявку менеджеру

  • оповестить пользователя о том, что его заказ принят в работу

Второй кейс — просмотр списка моих заказов. Здесь все просто, мы должны показать пользователю список его заказов:

  • получить список заказов пользователя 

Слой serializers/forms

У слоя serializers три основные функции (все выводы для serializers справедливы и для forms):

  • валидировать данные

  • преобразовывать данные запроса в типы данных Python

  • преобразовывать сложные Python объекты в простые типы данных Python (например, Django модели в dict)

Дополнительно сериалайзеры имеют два метода, create и update, которые вызываются в методе save() и почти всегда используются во view.

Пример использования из документации:

class CommentSerializer(serializers.Serializer):
	email = serializers.EmailField()
  content = serializers.CharField(max_length=200)
  created = serializers.DateTimeField()

  def create(self, validated_data):
  	return Comment.objects.create(**validated_data)

  def update(self, instance, validated_data):
    instance.email = validated_data.get('email', instance.email)
    instance.content = validated_data.get('content', instance.content)
    instance.created = validated_data.get('created', instance.created)
    instance.save()
    return instance

Где-то в нашей view:

# .save() will create a new instance.
serializer = CommentSerializer(data=data)
# .save() will update the existing `comment` instance.
serializer = CommentSerializer(comment, data=data)
comment = serializer.save()

В данном подходе за сохранение и обновление сущностей отвечает сериалайзер, точнее он оперирует методами модели.

Можно использовать ModelSerializer и ModelViewSet, что позволяет писать CRUD методы в пару-тройку строк.

# serializers.py
class OrderSerializer(serializers.ModelSerializer):
	class Meta:
  	model = Order
    fields = ‘__all__’

# views.py
class OrderViewsSet(viewsets.ModelViewSet):
	queryset = Order.objects.all()
  serializer_class = OrderSerializer

Если углубиться в реализацию ModelViewSet и ModelSerializer, то можно заметить, что за сохранение и обновление сущностей также отвечает сериалайзер. 

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

# serializers.py
class OrderSerializer(serializers.ModelSerializer):
	class Meta:
		model = Order
		fields = []

	def create(self, validated_data):
		# Проверяем, что все товары есть и заказ валиден
		...
		# Создаем запись о заказе в БД
		instance = super(OrderSerializer, self).create(validated_data)
    # Бронируем товары на складе
    ...
    # Передаем заявку менеджеру
    ...
    # Оповещаем пользователя
    ...
    return instance
  
# views.py
class OrderViewsSet(viewsets.ModelViewSet):
	queryset = Order.objects.all()
  serializer_class = OrderSerializer

Если с методом создания мы что-то придумали, то логику получения заказов пользователя придется помещать в view.

Получается такая схема:

Плюсы данного подхода:

Легко делать CRUD

Django и DRF предоставляют очень удобные инструменты с помощью которых можно легко создавать CRUD.

Минусы данного подхода: 

Нарушение идей MVC

Мы смешиваем бизнес-логику с задачей сериализации (получения/отображения) данных в одном слое. Ни о каком выделенном слое бизнес-логики у нас нет и речи.

Сериалайзеры стоит относить к слою View в MVC в контексте Django. Когда мы располагаем в сериалайзерах свою бизнес логику, мы нарушаем главный принцип  MVC — отделение логики представления данных от бизнес-логики.

Нельзя переиспользовать

Не получится переиспользовать логику одного сериалайзера в другом сериалайзере, или в каком-то другом компоненте.

Сложно тестировать

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

Сложно поддерживать

Остается открытым вопрос — куда помещать методы на чтение данных, можно перенести их в слой views.Не все правила бизнес-логики можно поместить в сериалайзеры, иногда это излишне, потому что мы не используем сами сериалайзеры.

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

Высокая зависимость от фреймворка

Высокая зависимость от DRF Serializers или Django Forms. Если мы захотим поменять способ сериализации и отказаться от serializers, то придется переносить или переписывать нашу логику. Также будет сложно переехать с Django Forms на DRF Serializers или наоборот.

Правильные обязанности слоя

Сериализация/десериализация данных

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

Валидация данных

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

Заключение

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

Стоит отказаться от ModelSerializer с его магическими методами create и update и заменить их на обычные сериалайзеры (можно использовать ModelSerializer как read only — для удобства). Если вы пишете какое-то простое приложение, где кроме CRUD ничего не нужно, то можно не отказываться от удобства DRF и использовать сериалайзеры как предлагается в документации.

Слой Views

Слой View в контексте Django отвечает за представление и обработку пользовательских данных, в нем мы описываем, какие данные нам нужны и как мы хотим их представить. Если вспомнить то, о чем мы говорили в начале, то можно сразу сделать вывод, что во views не нужно писать бизнес-логику, иначе мы смешиваем логику представления данных с бизнес-логикой. Даже если считать, что views в Django это контроллеры, то размещать в них бизнес-логику тоже не стоит, иначе у вас получатся ТТУКи («Толстые, тупые, уродливые контроллеры»; Fat Stupid Ugly Controllers).

Но часто можно увидеть что-то подобное:

# views.py
class OrderViewsSet(viewsets.ModelViewSet):
	queryset = Order.objects.all()
  serializer_class = OrderSerializer
                                
  def perform_create(self, serializer):
  	# Проверяем, что все товары есть и заказ валиден
    ...
    # Создаем запись о заказе в БД
    super(OrderViewsSet, self).perform_create(serializer)
    # Бронируем товары на складе
    ...
    # Передаем заявку менеджеру
    ...
    # Оповещаем пользователя
    ...

По дефолту, в ModelViewSet для сохранения и обновления данных используется сериалайзер, что не входит в его обязанности. Это можно исправить, полностью переопределить метод perform_create  (не вызывать super, но тогда встает вопрос об объективности наследования от ModelViewSet). Можно написать кастомные методы в ModelViewSet или написать кастомные APIView:

# views.py
class OrderCreateApi(views.APIView):
	class InputSerializer(serializers.ModelSerializer):
  	number = serializers.IntegerField()
    ...                   
  def post(self, request):
  	serializer = self.InputSerializer(data=request.data)
    serializer.is_valid(raise_exception=True)
    # Проверяем, что все товары есть и заказ валиден
    ...
    # Создаем запись о заказе в БД                                         
    order = Order.objects.create(**serializer.validated_data)
    # Бронируем товары на складе
    ...
    # Передаем заявку менеджеру
    ...
    # Оповещаем пользователя
    ...                   
    return Response(status=status.HTTP_201_CREATED)

В отличии от слоя serializers, мы теперь легко можем поместить нашу логику получения заказов в слой views.

# views.py
class OrderViewsSet(viewsets.ModelViewSet):
	queryset = Order.objects.all()
  serializer_class = OrderSerializer
                          
  def get_queryset(self):
  	queryset = super(OrderViewsSet, self).get_queryset()
    queryset = queryset.filter(user=self.request.user)
    return queryset

Тем самым, ограничив не только получение списка, но и другие методы CRUD, что, иногда, очень удобно и быстро. Также, можно переопределить каждый метод по отдельности.

Получается такая схема:

Стоит помнить, что мы отказались от использования save у serializers. В таком случае слой serializers остается “чистым” и выполняет только “правильные” обязанности.

Плюсы данного подхода

Легко делать CRUD

Django и DRF предоставляют очень удобные инструменты, с помощью которых можно легко создавать CRUD и views не исключение.

Минусы данного подхода

Нарушение идей MVC

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

Нельзя переиспользовать

Не получится переиспользовать логику одной view в другой view, или в каком-то другом компоненте. Так же будут проблемы, если мы захотим вызывать нашу бизнес-логику из других интерфейсов, например, из Celery задач.

Высокая зависимость от фреймворка

Высокая зависимость от DRF View или Django View. Если мы захотим поменять способ обработки запроса и отказаться от views, то придется переносить или переписывать нашу логику. Будет сложно переехать с Django View на DRF View или наоборот.

Сложно тестировать

Достаточно сложно протестировать код во views независимо от serializers и остальной инфраструктуры Django + придется использовать http client для тестирования.

Сложно поддерживать

Со временем views разрастаются, часть логики переносится в Celery задачи, часть в модели, код во views дублируется, так как их нельзя переиспользовать — все это приводит к тому, что проект сложно поддерживать.

Правильные обязанности слоя 

Обработка запроса

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

Делегирование сериализации данных сериалайзерам

Всю логику сериализации данных должны выполнять сериалайзеры.

Вызов методов бизнес-логики

После подготовки и сериализации данных вызывается интерфейс бизнес-логики.

Логика представления данных

Мы должны обработать ответ от методов бизнес-логики и предоставить нужные данные клиенту.

Заключение

Вывод примерно такой же как и с serializers — в views не стоит размещать бизнес-логику.

Стоит отказаться от ModelViewSet и миксинов, так как они используют сериалайзеры для сохранения данных, вместо этого использовать обычные APIView или GenericAPIView.

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

Слой models

Если опираться на более продвинутые туториалы или вспомнить слой Model в MVC, то кажется, что models отличное место для размещения бизнес-логики.

Это может выглядеть примерно так:

# models.py
class Order(models.Model):
	number = serializers.IntegerField()
  created = models.DateTimeField(auto_now_add=True)
  status = models.CharField(max_length=16)
                                             
  def update_status(self, status: str) -> None:
  	self.status = status
    self.save(update_fields=('status',))
  ...
  
  @classmethod
  def create(cls, data...):
  	instance = cls(...)
    # Проверяем, что все товары есть и заказ валиден
    ...
    # Создаем запись о заказе в БД
    instance = instance.save()
    # Бронируем товары на складе
    ...
    # Передаем заявку менеджеру (например на почту или создаем какую то запись в БД)
    ...
    # Оповещаем пользователя
    ...

# views.py
class OrderCreateApi(views.APIView):
	class InputSerializer(serializers.ModelSerializer):
  	number = serializers.IntegerField()
    ... 
   
  def post(self, request):
  	serializer = self.InputSerializer(data=request.data)
    serializer.is_valid(raise_exception=True)
    
    Order.create(**serializer.validated_data)    
             
   	return Response(status=status.HTTP_201_CREATED)

В view мы сериализуем данные с помощью serializer и вызываем метод создания заказа у класса модели. В данном случае мы реализовали classmethod, что бы не было необходимости создавать экземпляр модели. Иначе нам придется понимать какие данные относятся к полям модели, а какие мы должны передать в метод создания, а это уже некие бизнес- правила.

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

Для методов получения данных в таком случае стоит использовать Managers.

# views.py
class OrderListApi(views.APIView):
	class OutputSerializer(serializers.ModelSerializer):
  	class Meta:
    	model = Order
      fields = ‘__all__’ 
                          
  def get(self, request):
  	orders = Order.objects.filter(user=request.user)
    # если у вас сложные условия фильтрации
    # например, Order.objects.filter(user=request.user, is_deleted=False, is_archived=False...)
    # то стоит написать кастомные методы в Manager
    
    data = self.OutputSerializer(orders, many=True).data
    
    return Response(data)

Получается такая схема:

В данном случае слои serializers и views становятся “чистыми” и правила бизнес-логики концентрируются в одном слое.

Плюсы данного подхода

Следование идеям MVC

Мы отделили логику представления данных от логики предметной области. View только подготавливает данные и вызывает методы модели, все бизнес правила и процессы описаны в методах модели. Данный подход соответствует главной идее MVC.

Легко тестировать

Вся бизнес-логика собрана в одном слое, который не зависит от других слоев, например, от views или serializers. Каждый метод модели можно протестировать по отдельности как обычный python код. Остается только замокать метод save и базовые методы managers или использовать базу данных, если требуется.

Можно переиспользовать

Методы модели можно вызывать из любого компонента, DRF Views, Django Views, Celery задачи и т.д.

Минусы данного подхода

Зависимость от фреймворка

У нас все еще есть зависимость от фреймворка, но это не так критично. Так как отказ от Django models и ORM или их замена — очень редкий кейс.

Сложно поддерживать большие проекты

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

Усложнение CRUD проектов

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

Заключение

Мы не нарушили главных идей MVC и наш код соответствует им. Такой подход можно использовать в малых проектах, когда бизнес-логики не много и она умещается в классах моделей.

Слой Services

Мы перебрали все дефолтные слои в Django приложении, теперь можем вспомнить о том, что под слоем Model в MVC подразумевается не один объект, а набор объектов. 

Выделим отдельный сервисный слой services внутри слоя Model, который будет отвечать за бизнес-правила предметной области и приложения. В models оставить только простые property, в которых нет сложных бизнес-правил, и методы для работы с собственными данными модели, например обновление полей. Тогда наши кейсы можно реализовать так:

# models.py
class Order(models.Model):
	number = serializers.IntegerField()
  created = models.DateTimeField(auto_now_add=True)
  status = models.CharField(max_length=16)
                                             
  def update_status(self, status: str) -> None:
  	self.status = status
    self.save(update_fields=('status',))
	...

# services.py
# вместо пречесления всех аргументов можно реализовать DTO
def order_create(name: str, number: int ...) -> bool:
	# Проверяем, что все товары есть и заказ валиден
  ...
  # Создаем запись о заказе в БД
  order = Order.objects.create(...)
  # Бронируем товары на складе
  ...
  # Передаем заявку менеджеру (например на почту или создаем какую то запись в БД)
  ...
  # Оповещаем пользователя
  ...

# views.py
class OrderCreateApi(views.APIView):
	class InputSerializer(serializers.ModelSerializer):
  	number = serializers.IntegerField()
    ...
                           
  def post(self, request):
  	serializer = self.InputSerializer(data=request.data)
    serializer.is_valid(raise_exception=True)
    
  	services.order_create(**serializer.validated_data)
             
    return Response(status=status.HTTP_201_CREATED)

Стоит придерживаться следующему подходу:

  • views — подготовка данных запроса, вызов бизнес логики, подготовка ответа

  • serializers — сериализация данных, простая валидация

  • services — простые функции с бизнес правилами или классы (Service Objects)

  • managers — содержит в себе правила работы с данными (доступ к данным)

  • models — единственный окончательный источник правды о данных

Получение заказов пользователя:

# services.py
def order_get_by_user(user: User) -> Iterable[Order]:
	return Order.objects.filter(user=user)

# views.py
class OrderListApi(views.APIView):
	class OutputSerializer(serializers.ModelSerializer):
  	class Meta:
    	model = Order
      fields = ('id', 'number', ...)
                          
  def get(self, request):
  	orders = services.order_get_by_user(user=request.user)
                                         
    data = self.OutputSerializer(orders, many=True).data
                            
    return Response(data)

Получается такая схема:

Плюсы данного подхода

Следование идеям MVC

Как и в предыдущем подходе, мы полностью отделили бизнес-логику от логики представления.

Легко тестировать

Сервисы представляют собой простые Python функции, которые легко тестировать.

Можно переиспользовать

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

Легко поддерживать и расширять

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

Гибкость

Существует множество подходов написания и расширения сервисного слоя.

Минусы данного подхода

Зависимость от фреймворка

Доменный слой не отделен от слоя приложения и инфраструктуры. Мы все еще используем Django модели в качестве сущностей. У нас могут возникнуть проблемы, когда мы захотим отказаться от Django ORM, но это очень редкий кейс и для многих проектов неактуален.

Усложнение CRUD проектов

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

Заключение

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

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

Что касается Django, советую обратить внимание на стайл гайд от HackSoftware у них схожие взгляды, но они разделяют сервисный слой на два компонента (services и selectors) и не используют кастомные методы в managers. Подход написания serializers и включения их во views я взял у них. Также стоит посмотреть на идеи ребят из dry-python.

Общий итог

Получается, что поддерживаемость и “чистота” Django проектов страдает от удобства и плюшек фреймворка. Django и DRF очень классные инструменты, но не все их возможности стоит использовать. Можно сделать вывод, что, чем больше ваш проект и чем сложнее в нем бизнес-правила и сущности, тем более абстрактным и независимым от фреймворка должен быть ваш код. И выделение сервисного слоя — это далеко не предел и не идеал архитектуры приложения.

Теги:
Хабы:
+16
Комментарии 12
Комментарии Комментарии 12

Публикации

Информация

Сайт
vivid.money
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Германия

Истории