Django - отличный фреймворк, но он, на самом деле, толком не дает, да и не должен давать, ответ на вопрос, каким образом лучше всего хранить вашу бизнес-логику. Хранение бизнес-логики в моделях или views имеет множество недостатков, которые обычно начинают проявляться при росте кодовой базы проекта. Чтобы решить эти проблемы, разработчики часто начинают искать способы выделения бизнес-логики в своем приложении.
В этой статье я хотел бы попробовать дать стартовую точку на пути выделения слоя бизнес-логики у себя в приложениях и навести на новые мысли тех разработчиков, которые считают выделение этого слоя в своих приложениях чем-то излишним.
Так же хочу обратить внимание, что цель данной статьи не в том, чтобы дать правила, которым требуется слепо следовать, но в том, чтобы указать направление. Сервисный слой и в принципе его наличие, это такая вещь, которую нужно адаптировать под нужды вашей команды, компании и бизнеса.
На самом деле, изложенный далее текст относится не только к Django-проектам. Разрабатывая веб-приложения, используя другие инструменты, вроде Flask, люди используют те же концепции веб-разработки, причём часто именно в таком же виде, как они реализованы, в Django - views, request-response объекты, middlewares, модели, формы.
Со временем данный подход стал дефолтным в Django-сообществе. И этот подход, несомненно, работает. Какое-то время.
В реальности, большинство крупных проектов представляют из себя не просто CRUD-приложения, а нечто большее. Более менее серьезный бизнес-процесс подразумевает за собой манипулирование несколькими сущностями, какие-то промежуточные операции и прочее.
Следуя данному принципу, в какой-то момент роста проекта вырисовывается весьма интересная картина.
В худшем случае разработчик оставит весь код в модели, импортируя модели друг в друга или вынося некоторые куски кода в некие другие модули, затем снова импортируя их эти же в модели. Добро пожаловать в кольцевые импорты! В вырожденных случаях разработчик может просто начать складировать код всех моделей и их логики в один файл, получая на выходе один models.py на 1000+ строк кода.
Но чаще всего бизнес-логика начинает плавно перетекать во views.
Однако, реальность довольна сурова. Бизнес-логика перетекает во views бесконтрольно, потому что никто точно не может сказать, когда наша thin view стала не такой уж и thin. А писать код во views быстро и просто. Там сразу и request-объект со всеми данными, и формы, которые эти данные отвалидировали.
Впоследствии размер view начинает постепенно расти и в коде начинают появляться интересные ситуации, например, использование элемента глобального состояния view, такого как request-объект, прямо в бизнес логике, что намертво прибивает эту бизнес-логику к этой view.
Разработчикам удобно использовать глобальное состояние view прямо в бизнес-логике. Некоторые даже не брезгуют доклеивать к нему свои промежуточные вычисления. Это всегда затрудняет чтение кода. Это делает невозможным переиспользование данной бизнес-логики в других местах проекта, таких как celery-задачи, или management-команды. В конце концов это мешает вам адекватно покрыть ваш код unit-тестами. Единственной точкой тестирования в этом случае остается ваш view.
На этом этапе бизнес-логика становится сильно привязанной к инфраструктурному слою (фреймворк/библиотека) вашего проекта.
Часто я наблюдаю, что люди даже не пытаются как-то формализовать правила к этому слою в своих приложениях. Все называют его по-разному. Кто-то создаёт в корне проекта файлик utils.py, кто-то создаёт модули helpers или processors. А кто-то - всё это одновременно.
В этих модулях часто могут лежать как и элементы бизнес-логики, так и какие-то универсальные утилиты. Одним словом, каша.
Бизнес-логика - это код реализации бизнес-правил. Например, логика списаний с одного баланса и начисление на другой. Слой бизнес-логики является отображением процессов реального мира на программный код.
Код инфраструктуры - это код реализации инфраструктурных процессов, манипулирующий в основном искусственными понятиями, сформулированными сугубо для разработки ПО, например: вью, модели, фреймворки, субд, валидация входных параметров, реквесты, респонсы, API и тд.
Что такое сервисный слой? Давайте тоже попробуем дать определение. Сервисный слой - это такой набор компонентов программного кода, которые инкапсулируют в себе логику приложения. Такие компоненты мы и будем называть сервисами бизнес-логики или доменными сервисами.
Бизнес-логика в сервисном слое стремится быть максимально изолированной от нашей инфраструктуры, которая представляет из себя детали реализации фреймворка и других внешних зависимостей, и быть максимально близкой к предметной области. Я специально использую слово стремится вместо должна, потому что разработчики должны самостоятельно решать, какой уровень изоляции им требуется.
Если говорить в терминах Django, то целью сервисного слоя будет вынести весь код, относящийся к бизнес-логике приложения, из моделей, view и всех остальных мест, где её быть не должно, вроде middlewares или management-команд, в отдельные модули, оформленные по определенным правилам, оставляя в инфраструктурном слое лишь вызовы из этих модулей.
В рамках сервисного слоя можно выделить также инфраструктурные сервисы. Дело в том, что полностью вынести какие-то инфраструктурные операции за пределы бизнес-логики далеко не всегда возможно. Например, такие операции, как общение с СУБД, запись/чтение диска, хождение по сети. Их-то и можно изолировать в инфраструктурных сервисах, чтобы затем использовать в своей бизнес-логике c помощью более высокоуровневого интерфейса.
Инфраструктурные сервисы здорово помогают читать бизнес-логику, так как скрывают детали реализации работы с вашей инфраструктурой, а также упрощают написание тестов бизнес-логики, так как один сервисный эндпоинт гораздо проще подменить на mock, чем набор низкоуровневых операций.
Таким образом, сервисом в самом общем понимании является базовый блок кода, слоя бизнес-логики, либо инфраструктурного слоя, но не одновременно.
project_name
├── app_name
│ ├── services
│ │ └── ...
│ ├── tests
│ ├── views
│ ├── models
...
Вы также можете сделать один глобальный модуль сервисов, если у вас, например, мало django-приложений, словом, отталкивайтесь от своего проекта.
Внутри services будут храниться наши модули сервисов. Каждый отдельный модуль сервисов хранит в себе какой-то один бизнес-процесс, объединённый тесным контекстом. Модуль сервиса на уровне структуры проекта может состоять как из одного py-файла, так и из сложного набора подмодулей, организованного исходя из требований вашей бизнес-логики. Соблюдайте баланс, старайтесь не класть несколько сервисов используемых в рамках одного бизнес-процесса в один файл:
services
├── __init__.py
├── complicated_service_example
│ ├── __init__.py
│ └── ...
└── simple_service_example.py
При оформлении сервиса в виде модуля старайтесь явно отражать его публичный API в __init__.py , туда должны быть импортированы только сущности, предназначенные для публичного использования.
Также структуру сервисного слоя можно оформить исходя из разделения на инфраструктурные сервисы и сервисы бизнес-логики.
services
├── __init__.py
├── example_with_separated_service_layers
│ ├── __init__.py
│ ├── infrastructure
│ │ └── ...
│ ├── domain
│ │ └── ...
...
В сервисном слое следует изолировать бизнес-логику от инфраструктуры.
Проектируя сервисный слой, вы должны заставить работать свой фреймворк на свою бизнес-логику, а не наоборот, как это часто происходит в Django-приложениях. Это значит, что вы не должны использовать в коде сервисов бизнес-логики такие вещи, как request-объекты, которые принимают views, формы, celery-задачи, различные сторонние библиотеки, отвечающие за инфраструктурные задачи и т. д. Это правило очень важно. И весь подход к написанию кода отталкивается именно от него. В местах, когда вынести из пайплайна бизнес-логики инфраструктуру невозможно, требуется должным образом её изолировать. Способы изоляции будут описаны далее.
На уровне кода типичный сервис бизнес-логики представляет из себя разновидность паттерна Command, который является производной от паттерна Method Object.
Для примера придумаем простой сервис с минимальным количеством бизнес-логики. Допустим, требуется периодически готовить отчёт о пользователях в системе. Нужно взять все username пользователей, затем сохранить их в csv-файл, имя которого должно быть сгенерировано определенным образом.
Создадим в директории services файл make_users_report_service.py со следующим содержимым:
import csv
from datetime import datetime
from typing import List
from django.contrib.auth.models import User
class MakeUsersReportService:
def _extract_username_list(self) -> List[str]:
return list(User.objects.values_list('username', flat=True))
def _generate_file_name(self) -> str:
return '{}-{}'.format('users_report', datetime.now().isoformat())
def _load_username_list_to_csv(self, file_name: str, username_list: List[str]) -> str:
full_path = f'reports/{file_name}.csv'
with open(full_path, 'w', newline='') as csv_file:
writer = csv.writer(csv_file)
writer.writerow(['username'])
writer.writerows(
([username] for username in username_list)
)
return full_path
def execute(self) -> str:
username_list = self._extract_username_list()
file_name = self._generate_file_name()
return self._load_username_list_to_csv(file_name, username_list)
Такой сервис в виде класса является базовым строительным блоком сервисного слоя. Давайте внимательно рассмотрим его особенности:
1) Используются аннотации типов
Аннотации типов и их чекеры вроде mypy - одно из самых значимых нововведений Python за последние годы. Аннотации типов в сервисном слое важны, в первую очередь из-за того, что помогают следовать правилам инфраструктурного слоя.
Если методы вашего сервиса возвращают или принимают инфраструктурные объекты, к примеру, из Django, то это является явным маркером того, что вы что-то делаете не так.
Аннотации типов также помогают отразить неявные зависимости внутри кода, так как вам придется импортировать типы, объекты которых не создаются в вашем коде напрямую, если вы хотите использовать их в качестве аргументов или возвращаемых значений методов.
2) В коде единственная публичная точка входа
Обратите внимание на метод execute - он является единственной публичной точкой входа в данном сервисе. Всё, что он делает - вызывает под капотом приватные методы в определенном порядке. Такая схема позволяет проще читать код - открыв сервис, сразу видно, в какой последовательности вызывается логика и как её вызывать.
Данный подход помогает в проектировании сервисов. Помните, если вам хочется сделать несколько публичных методов подобных execute в одном сервисе, то это явный признак того, что скорее всего логику нужно разделить и вынести в отдельный сервис.
3) Изолированы обращения к СУБД
Такое инфраструктурное действие, как обращение к СУБД, изолировано в отдельном методе - _extract_username_list. В приватном методe вы можете заменить, к примеру, вызов values_list на raw-sql запрос, или вообще, обращение к некому внешнему API, но при этом ваша бизнес-логика, которой просто нужен список из юзернеймов, всё равно не изменится. Мы зафиксировали интерфейс в примитивах языка.
В данном примере нужны только username-ы пользователей, и поэтому мы можем ограничиться встроенными типами Python - List[str] и не возвращать список объектов модели, ведь Django ORM является такой же частью инфраструктуры как и всё остальное. Более сложные кейсы мы рассмотрим чуть позже.
Выбор, конечно, за вами, но помните, используя queryset-ы напрямую в коде вашей бизнес-логики, вы усложняете себе написание unit-тестов, ведь замокать queryset-ы гораздо сложнее чем просто метод, возвращающий примитив языка. Использование интеграционных тестов Django, которые позволяют вам поднять под капотом тестовую СУБД, важно, но отрабатывают такие тесты гораздо дольше, чем обычные unit-тесты, которые её не используют. А в случае если источником данных выступает некий внешний API стороннего сервиса, то и выбора по сути не остаётся - надо мокать его.
Для тестирования бизнес-логики не нужно каждый раз использовать тестовую базу данных. Ведь для бизнес-логики неважно откуда пришли данные - из СУБД ли, из файла на диске или внешнего API.
Для себя я вывел такую формулу: если возможно, покрывайте ваш сервис на 70% юнит-тестами с замоканным источником данных и на 30% интеграционными тестами, которые умеют поднимать вашу инфраструктуру, частью которой и является ваш источник данных в виде СУБД. Такая практика сделает приятнее разработку по TDD, а также ускорит прохождение тестов в вашем CI/CD пайплайне, что тоже не может не радовать.
4) Изолированы обращения к коду формирования csv-файла
Запись информации в файл на диске - такая же инфраструктурная задача. В принципе, все тезисы, что я перечислил в 3 пункте про СУБД, подходят и сюда - проще тестировать, не надо менять остальной код в случае, если понадобится писать, к примеру, exel файл вместо csv.
5) Название сервиса отражает конкретное действие
Название сервиса должно отражать некое конкретное действие. Размытое название сигнализирует о том, что код выполняет более чем одно конкретное действие, что является нежелательным для сервисов бизнес-логики.
Только что мы рассмотрели пример очень простого сервиса, состоящего из трёх простых этапов. Бизнес-логики в этом сервисе было весьма мало. В реальности сервисы, конечно же, гораздо сложнее. Давайте рассмотрим, какие проблемы у вас могут возникнуть с кодом выше и как их можно решить в рамках сервисного слоя.
Тогда, часть кода выше, отвечающая за запрос к СУБД можно модифицировать следующим образом (реализацию остальных методов пока опустим):
...
from dataclasses import dataclass
from typing import Generator
from django.contrib.auth.models import User
@dataclass
class UsersReportRow:
pk: int
username: str
email: str
is_active: bool
class UsersReportService:
def _extract_users_data(self) -> Generator[UsersReportRow]:
for user in User.objects.all():
yield UsersReportRow(
pk=user.pk,
username=user.username,
email=user.email,
is_active=user.is_active,
)
...
Метод _extract_username_list мы заменили на _extract_users_data, который теперь возвращает генератор с Value Object-ами, реализованный в виде дата-класса. Дата-классы - весьма полезный инструмент в разработке на Python.
Часто питонисты пренебрегают создавать классы просто для передачи данных и могут использовать в подобных ситуациях, к примеру, список из dict-ов, что является худшей альтернативой. Достоинство классов перед dict-ами состоит в фиксированной схеме данных, которая находится у вас в коде. Такую фиксированную схему данных удобнее использовать в аннотациях типов. При вызове метода, который возвращает пользовательский объект вместо dict, отпадает потребность идти и каждый раз вспоминать его содержимое - ваша IDE сможет помочь вам, когда вам потребуется обратиться к атрибутам.
Иногда под Value Object имеют в виду другой паттерн - DTO - Data Transfer Object. С моей точки зрения, DTO - сущность общения между сервисами, в то время как Value Object является сущностью общения внутри сервиса.
Если представить, что наш сервис ,помимо пути к сгенерированному отчёту, должен будет вернуть ещё какую-либо информацию, например, уникальный id отчёта, это могло бы выглядеть так:
...
from dataclasses import dataclass
@dataclass
class UsersReportDTO:
full_path: str
report_id: str
class UsersReportService:
...
def execute(self) -> UsersReportDTO:
...
return UsersReportDTO(
full_path=full_path,
report_id=report_id,
)
Чаще всего, помимо чтения данных, может понадобится и много других операций с хранилищем: создание, обновление данных, агрегация. Как оставить сервис простым и не засорять бизнес-логику кодом работы с хранилищем?
Ответ - изолировать работу с вашим хранилищем через DAO - Data access object. Вот так может выглядеть DAO для взаимодействия с таблицей пользователей в Django:
from dataclasses import dataclass
from typing import Iterable
from django.contrib.auth.models import User
@dataclass
class UserEntity:
pk: int
username: str
email: str
is_active: bool
class UsersDAO:
def _orm_to_entity(self, user_orm: User) -> UserEntity:
return UserEntity(
pk=user_orm.pk,
username=user_orm.username,
email=user_orm.email,
is_active=user_orm.is_active,
)
def fetch_all(self) -> Iterable[UserEntity]:
return map(self._orm_to_entity, User.objects.all())
def count_all(self) -> int:
...
def update_is_active(self, users_ids: Iterable[int], is_active: bool) -> None:
...
Таким образом, в коде сервиса вашей бизнес-логики остаётся только использование DAO без построения запросов к СУБД напрямую.
Хочу ещё раз обратить внимание - мы специально не используем объекты моделей Django. Заставляя двигать данные внутри нашего сервисного слоя через примитивы языка и пользовательские объекты, мы изолируем нашу бизнес-логику от инфраструктуры.
Изолирование работы с ORM в коде обычно вызывает больше всего негативных эмоций у Django-разработчиков. Думаю, это происходит в первую очередь из-за того, что очень часто доменная модель бизнес-сущностей накладывается на модели Django в начале разработки новой фичи.
Может быть, такая изоляция и выглядит избыточной, когда в примере можно сделать всего один простой запрос в ORM, но поверьте, если для бизнес-логики вам в какой-то момент потребуется не просто достать набор строк из таблицы, а собрать некий агрегат, делая несколько запросов в разные источники данных, или сделать raw-sql запрос через драйвер к СУБД, который возвращает вместо адекватного объекта какой-то tuple из tuple-ов, то помимо всех достоинств, описанных ещё в предыдущем более простом примере, вы получите ещё и улучшенную в разы читаемость кода.
Так же весьма важным эффектом является то, что изолируя ORM, если в какой-то момет вам придётся заменить источник данных, к примеру, на MongoDB, или вообще, веб-API вашего другого сервиса, вам не придётся переписывать кучу unit-тестов и всю бизнес-логику. Нужно будет только переписать тесты на ваш DAO или метод сервиса, который возвращает DTO/Value Object. Главное лишь то, чтобы ваш код изолирующий работу с данными сохранял старый интерфейс. Сделайте вашу инфраструктуру зависимой от бизнес-логики, а не наоборот!
Теперь давайте снова усложним наш пример. Представим, что заказчик захотел, чтобы помимо csv-файла была так же возможность сгенерировать и exel-файл.
Создадим два новых инфраструктурных сервиса, затем встроим их в наш сервис бизнес-логики. Один из них будет отвечать за запись xlsx, а второй - за csv.
Модифицируем исходный файл make_users_report_service.py (снова опустим подробности реализации):
...
import abc
from typing import Iterable
class IReportFileAdapter(abc.ABC):
@abc.abstractmethod
def create_report_file(self, file_name: str, username_list: Iterable[UsersReportRow]) -> str:
pass
...
class UsersReportService:
def __init__(self, report_file_adapter: IReportFileAdapter):
self.report_file_adapter = report_file_adapter
def _extract_users_data(self) -> Generator[UsersReportRow]:
...
def _generate_file_name(self) -> str:
...
def execute(self) -> str:
username_list = self._extract_users_data()
file_name = self._generate_file_name()
return self.report_file_adapter.create_report_file(file_name, users_data_list)
И далее, создадим новый файл с инфраструктурными сервисами, реализованными в виде адаптеров report_file_adapters.py:
import abc
from typing import Iterable
from .make_users_report_service import IReportFileAdapter
class CSVReportFileAdapter(IReportFileAdapter):
def create_report_file(self, file_name: str, username_list: List[str]) -> str:
...
class XLSXReportFileAdapter(IReportFileAdapter):
def create_report_file(self, file_name: str, username_list: List[str]) -> str:
...
Таким образом, вызывать сервис бизнес-логики вы сможете с разными адаптерами, в зависимости от того, какой файл вам нужно сгенерировать.
from your_django_app.services.reports import (
UsersReportService,
CSVReportFileAdapter,
)
users_report_service = UsersReportService(report_file_adapter=CSVReportFileAdapter())
path_to_report = users_report_service.execute()
Давайте рассмотрим написанный код подробнее:
1) Inversion of Control
С помощью абстрактного базового класса мы эмулировали интерфейс в нашем коде и положили его именно рядом с нашей бизнес-логикой, а не адаптерами. Таким образом, мы смогли достичь Инверсии управления в нашем коде. Благодаря этому в файл с бизнес-логикой не нужно делать импорты инфраструктурных сервисов, которые умеют писать csv/xlsx. Мы добились зависимости инфраструктурного слоя от бизнес-логики, а не наоборот.
Вы так же можете достичь IoC с помощью типа Protocol, появившегося в typing начиная с python 3.8.
Многие считают IoC в python излишним. Применяйте по ситуации: если вам не нужна высокая степень изоляции компонентов, можно обойтись и без IoC, либо использовать абстрактные базовые классы с реализацией, которые уже следовало бы держать рядом с адаптерами в моём примере. Сервисный слой гибок.
2) Удобный mock
Одному мне не нравится каждый раз писать в тестах длинный @mock.patch('...')? Длинный путь к объекту, который вы хотите замокать, банально неудобно читать и прописывать. К тому же, при перемещении или переименовании объекта, который вы мокаете, эту строку нужно будет не забыть поправить. Теперь всё можно сделать гораздо приятнее. Встраивая сервисы друг в друга, при написании тестов, вы получаете возможность пробрасывать Mock при инициализации вашего сервиса:
from mock import Mock
from unittest import TestCase
from your_django_app.services.reports import (
UsersReportService,
IReportFileAdapter,
)
class UsersReportServiceTestCase(TestCase):
def test_example(self):
report_file_adapter_mock = Mock(spec=IReportFileAdapter)
users_report_service = UsersReportService(
report_file_adapter=report_file_adapter_mock
)
...
...
Используя внедрение зависимостей, вы можете строить сложные пайплайны бизнес-логики из большого количества ваших рабочих блоков с кодом - сервисов.
и теперь у нас две проблемы). Идея сервисного слоя не нова, и библиотеки на эту тему уже есть.
https://github.com/mixxorz/django-service-objects - автор предлагает писать сервисный слой на основе Django-форм. С моей точки зрения, валидация входных данных должна происходить в инфраструктурном слое, а не перегружать собой слой бизнес-логики. На слое сервисов можно валидировать только бизнес-правила. А всякие проверки, что число - это число, а не строка, лучше оставить обычным Django-формам.
https://github.com/dry-python/stories - ещё одна библиотека для построения сервисного слоя. Давно слежу за ребятами. Помимо stories, у них много других интересных библиотек на разные темы.
Я против использования подобных библиотек. И вот почему:
В принципе, если возвести все приемы, которые я показал в статье, в абсолют, и везде использовать DI и IoC, а также ещё пару приёмов, то у вас получится своя реализация чистой архитектуры.
Вы могли видеть в данной статье элементы Domain-driven design. Для ознакомления рекомендую обратить внимание на книгу "Domain-Driven Design: Tackling Complexity in the Heart of Software" от Эрика Эванса. Сервисный слой можно также развить в полноценное DDD приложение.
В этой статье я хотел бы попробовать дать стартовую точку на пути выделения слоя бизнес-логики у себя в приложениях и навести на новые мысли тех разработчиков, которые считают выделение этого слоя в своих приложениях чем-то излишним.
Так же хочу обратить внимание, что цель данной статьи не в том, чтобы дать правила, которым требуется слепо следовать, но в том, чтобы указать направление. Сервисный слой и в принципе его наличие, это такая вещь, которую нужно адаптировать под нужды вашей команды, компании и бизнеса.
На самом деле, изложенный далее текст относится не только к Django-проектам. Разрабатывая веб-приложения, используя другие инструменты, вроде Flask, люди используют те же концепции веб-разработки, причём часто именно в таком же виде, как они реализованы, в Django - views, request-response объекты, middlewares, модели, формы.
Как обычно обстоят дела в реальных Django-проектах?
Большинство начинающих разработчиков на Django, после прохождения официального туториала, зачастую начинают интересоваться, собственно, а где и как им хранить код? Вариантов много, но типичный Django-разработчик, скорее всего, найдет для себя ответ в знаменитой книге "Two Scopes of Django", заключающийся в следующем принципе: "Fat Models, Utility Modules, Thin Views, Stupid Templates".Со временем данный подход стал дефолтным в Django-сообществе. И этот подход, несомненно, работает. Какое-то время.
В реальности, большинство крупных проектов представляют из себя не просто CRUD-приложения, а нечто большее. Более менее серьезный бизнес-процесс подразумевает за собой манипулирование несколькими сущностями, какие-то промежуточные операции и прочее.
Следуя данному принципу, в какой-то момент роста проекта вырисовывается весьма интересная картина.
Эти модели слишком толстые
На самом деле, считаю правило "толстых моделей" корнем всех проблем. Самые простые бизнес-процессы, как правило, взаимодействуют только с одной сущностью. Но с ростом этих сущностей в одном бизнес-процессе у разработчика возникает следующая дилемма: "Ок, я написал огромную простыню кода, которая использует несколько моделей. В какую из моделей мне положить всё это добро?".В худшем случае разработчик оставит весь код в модели, импортируя модели друг в друга или вынося некоторые куски кода в некие другие модули, затем снова импортируя их эти же в модели. Добро пожаловать в кольцевые импорты! В вырожденных случаях разработчик может просто начать складировать код всех моделей и их логики в один файл, получая на выходе один models.py на 1000+ строк кода.
Но чаще всего бизнес-логика начинает плавно перетекать во views.
Кажется, наши тонкие views становятся не такими уж и тонкими
Авторы "Two Scopes of Django" сами же пишут, что хранение логики во views - плохая идея, и вводят в своей книге главу "Trimming Models", в рамках которой поясняют, что при росте модели логику стоит выносить в слой, который они называют "Utility Modules".Однако, реальность довольна сурова. Бизнес-логика перетекает во views бесконтрольно, потому что никто точно не может сказать, когда наша thin view стала не такой уж и thin. А писать код во views быстро и просто. Там сразу и request-объект со всеми данными, и формы, которые эти данные отвалидировали.
Впоследствии размер view начинает постепенно расти и в коде начинают появляться интересные ситуации, например, использование элемента глобального состояния view, такого как request-объект, прямо в бизнес логике, что намертво прибивает эту бизнес-логику к этой view.
Разработчикам удобно использовать глобальное состояние view прямо в бизнес-логике. Некоторые даже не брезгуют доклеивать к нему свои промежуточные вычисления. Это всегда затрудняет чтение кода. Это делает невозможным переиспользование данной бизнес-логики в других местах проекта, таких как celery-задачи, или management-команды. В конце концов это мешает вам адекватно покрыть ваш код unit-тестами. Единственной точкой тестирования в этом случае остается ваш view.
На этом этапе бизнес-логика становится сильно привязанной к инфраструктурному слою (фреймворк/библиотека) вашего проекта.
Каша из Utility Modules
Utility Modules - одна из самых недопонятых концепций в мире Django. Что именно мы должны там хранить? Многие из нас начинают складировать там мусор.Часто я наблюдаю, что люди даже не пытаются как-то формализовать правила к этому слою в своих приложениях. Все называют его по-разному. Кто-то создаёт в корне проекта файлик utils.py, кто-то создаёт модули helpers или processors. А кто-то - всё это одновременно.
В этих модулях часто могут лежать как и элементы бизнес-логики, так и какие-то универсальные утилиты. Одним словом, каша.
Сервисный слой
Основа концепции сервисного слоя закладывается в понимании разницы между бизнес-логикой и инфраструктурой. Часто разработчики склонны путать эти понятия, поэтому давайте дадим им определения.Бизнес-логика - это код реализации бизнес-правил. Например, логика списаний с одного баланса и начисление на другой. Слой бизнес-логики является отображением процессов реального мира на программный код.
Код инфраструктуры - это код реализации инфраструктурных процессов, манипулирующий в основном искусственными понятиями, сформулированными сугубо для разработки ПО, например: вью, модели, фреймворки, субд, валидация входных параметров, реквесты, респонсы, API и тд.
Что такое сервисный слой? Давайте тоже попробуем дать определение. Сервисный слой - это такой набор компонентов программного кода, которые инкапсулируют в себе логику приложения. Такие компоненты мы и будем называть сервисами бизнес-логики или доменными сервисами.
Бизнес-логика в сервисном слое стремится быть максимально изолированной от нашей инфраструктуры, которая представляет из себя детали реализации фреймворка и других внешних зависимостей, и быть максимально близкой к предметной области. Я специально использую слово стремится вместо должна, потому что разработчики должны самостоятельно решать, какой уровень изоляции им требуется.
Если говорить в терминах Django, то целью сервисного слоя будет вынести весь код, относящийся к бизнес-логике приложения, из моделей, view и всех остальных мест, где её быть не должно, вроде middlewares или management-команд, в отдельные модули, оформленные по определенным правилам, оставляя в инфраструктурном слое лишь вызовы из этих модулей.
В рамках сервисного слоя можно выделить также инфраструктурные сервисы. Дело в том, что полностью вынести какие-то инфраструктурные операции за пределы бизнес-логики далеко не всегда возможно. Например, такие операции, как общение с СУБД, запись/чтение диска, хождение по сети. Их-то и можно изолировать в инфраструктурных сервисах, чтобы затем использовать в своей бизнес-логике c помощью более высокоуровневого интерфейса.
Инфраструктурные сервисы здорово помогают читать бизнес-логику, так как скрывают детали реализации работы с вашей инфраструктурой, а также упрощают написание тестов бизнес-логики, так как один сервисный эндпоинт гораздо проще подменить на mock, чем набор низкоуровневых операций.
Таким образом, сервисом в самом общем понимании является базовый блок кода, слоя бизнес-логики, либо инфраструктурного слоя, но не одновременно.
Сервисный слой на уровне структуры проекта
С чего же можно начать формирование сервисного слоя? Начнём с самого простого - структуры модулей. Следует договориться о создании отдельных модулей на уровне каждого Django-приложения. В данной статье в качестве примера будем называть такие модули services:project_name
├── app_name
│ ├── services
│ │ └── ...
│ ├── tests
│ ├── views
│ ├── models
...
Вы также можете сделать один глобальный модуль сервисов, если у вас, например, мало django-приложений, словом, отталкивайтесь от своего проекта.
Внутри services будут храниться наши модули сервисов. Каждый отдельный модуль сервисов хранит в себе какой-то один бизнес-процесс, объединённый тесным контекстом. Модуль сервиса на уровне структуры проекта может состоять как из одного py-файла, так и из сложного набора подмодулей, организованного исходя из требований вашей бизнес-логики. Соблюдайте баланс, старайтесь не класть несколько сервисов используемых в рамках одного бизнес-процесса в один файл:
services
├── __init__.py
├── complicated_service_example
│ ├── __init__.py
│ └── ...
└── simple_service_example.py
При оформлении сервиса в виде модуля старайтесь явно отражать его публичный API в __init__.py , туда должны быть импортированы только сущности, предназначенные для публичного использования.
Также структуру сервисного слоя можно оформить исходя из разделения на инфраструктурные сервисы и сервисы бизнес-логики.
services
├── __init__.py
├── example_with_separated_service_layers
│ ├── __init__.py
│ ├── infrastructure
│ │ └── ...
│ ├── domain
│ │ └── ...
...
Сервисный слой на уровне кода
Проектируя код сервисного слоя, в голове следует держать главное правило:В сервисном слое следует изолировать бизнес-логику от инфраструктуры.
Проектируя сервисный слой, вы должны заставить работать свой фреймворк на свою бизнес-логику, а не наоборот, как это часто происходит в Django-приложениях. Это значит, что вы не должны использовать в коде сервисов бизнес-логики такие вещи, как request-объекты, которые принимают views, формы, celery-задачи, различные сторонние библиотеки, отвечающие за инфраструктурные задачи и т. д. Это правило очень важно. И весь подход к написанию кода отталкивается именно от него. В местах, когда вынести из пайплайна бизнес-логики инфраструктуру невозможно, требуется должным образом её изолировать. Способы изоляции будут описаны далее.
На уровне кода типичный сервис бизнес-логики представляет из себя разновидность паттерна Command, который является производной от паттерна Method Object.
Для примера придумаем простой сервис с минимальным количеством бизнес-логики. Допустим, требуется периодически готовить отчёт о пользователях в системе. Нужно взять все username пользователей, затем сохранить их в csv-файл, имя которого должно быть сгенерировано определенным образом.
Создадим в директории services файл make_users_report_service.py со следующим содержимым:
import csv
from datetime import datetime
from typing import List
from django.contrib.auth.models import User
class MakeUsersReportService:
def _extract_username_list(self) -> List[str]:
return list(User.objects.values_list('username', flat=True))
def _generate_file_name(self) -> str:
return '{}-{}'.format('users_report', datetime.now().isoformat())
def _load_username_list_to_csv(self, file_name: str, username_list: List[str]) -> str:
full_path = f'reports/{file_name}.csv'
with open(full_path, 'w', newline='') as csv_file:
writer = csv.writer(csv_file)
writer.writerow(['username'])
writer.writerows(
([username] for username in username_list)
)
return full_path
def execute(self) -> str:
username_list = self._extract_username_list()
file_name = self._generate_file_name()
return self._load_username_list_to_csv(file_name, username_list)
Такой сервис в виде класса является базовым строительным блоком сервисного слоя. Давайте внимательно рассмотрим его особенности:
1) Используются аннотации типов
Аннотации типов и их чекеры вроде mypy - одно из самых значимых нововведений Python за последние годы. Аннотации типов в сервисном слое важны, в первую очередь из-за того, что помогают следовать правилам инфраструктурного слоя.
Если методы вашего сервиса возвращают или принимают инфраструктурные объекты, к примеру, из Django, то это является явным маркером того, что вы что-то делаете не так.
Аннотации типов также помогают отразить неявные зависимости внутри кода, так как вам придется импортировать типы, объекты которых не создаются в вашем коде напрямую, если вы хотите использовать их в качестве аргументов или возвращаемых значений методов.
2) В коде единственная публичная точка входа
Обратите внимание на метод execute - он является единственной публичной точкой входа в данном сервисе. Всё, что он делает - вызывает под капотом приватные методы в определенном порядке. Такая схема позволяет проще читать код - открыв сервис, сразу видно, в какой последовательности вызывается логика и как её вызывать.
Данный подход помогает в проектировании сервисов. Помните, если вам хочется сделать несколько публичных методов подобных execute в одном сервисе, то это явный признак того, что скорее всего логику нужно разделить и вынести в отдельный сервис.
3) Изолированы обращения к СУБД
Такое инфраструктурное действие, как обращение к СУБД, изолировано в отдельном методе - _extract_username_list. В приватном методe вы можете заменить, к примеру, вызов values_list на raw-sql запрос, или вообще, обращение к некому внешнему API, но при этом ваша бизнес-логика, которой просто нужен список из юзернеймов, всё равно не изменится. Мы зафиксировали интерфейс в примитивах языка.
В данном примере нужны только username-ы пользователей, и поэтому мы можем ограничиться встроенными типами Python - List[str] и не возвращать список объектов модели, ведь Django ORM является такой же частью инфраструктуры как и всё остальное. Более сложные кейсы мы рассмотрим чуть позже.
Выбор, конечно, за вами, но помните, используя queryset-ы напрямую в коде вашей бизнес-логики, вы усложняете себе написание unit-тестов, ведь замокать queryset-ы гораздо сложнее чем просто метод, возвращающий примитив языка. Использование интеграционных тестов Django, которые позволяют вам поднять под капотом тестовую СУБД, важно, но отрабатывают такие тесты гораздо дольше, чем обычные unit-тесты, которые её не используют. А в случае если источником данных выступает некий внешний API стороннего сервиса, то и выбора по сути не остаётся - надо мокать его.
Для тестирования бизнес-логики не нужно каждый раз использовать тестовую базу данных. Ведь для бизнес-логики неважно откуда пришли данные - из СУБД ли, из файла на диске или внешнего API.
Для себя я вывел такую формулу: если возможно, покрывайте ваш сервис на 70% юнит-тестами с замоканным источником данных и на 30% интеграционными тестами, которые умеют поднимать вашу инфраструктуру, частью которой и является ваш источник данных в виде СУБД. Такая практика сделает приятнее разработку по TDD, а также ускорит прохождение тестов в вашем CI/CD пайплайне, что тоже не может не радовать.
4) Изолированы обращения к коду формирования csv-файла
Запись информации в файл на диске - такая же инфраструктурная задача. В принципе, все тезисы, что я перечислил в 3 пункте про СУБД, подходят и сюда - проще тестировать, не надо менять остальной код в случае, если понадобится писать, к примеру, exel файл вместо csv.
5) Название сервиса отражает конкретное действие
Название сервиса должно отражать некое конкретное действие. Размытое название сигнализирует о том, что код выполняет более чем одно конкретное действие, что является нежелательным для сервисов бизнес-логики.
Только что мы рассмотрели пример очень простого сервиса, состоящего из трёх простых этапов. Бизнес-логики в этом сервисе было весьма мало. В реальности сервисы, конечно же, гораздо сложнее. Давайте рассмотрим, какие проблемы у вас могут возникнуть с кодом выше и как их можно решить в рамках сервисного слоя.
Value Object, DTO, DAO
Давайте представим, что теперь в наш отчёт нужно писать не только username-ы пользователей, но так же и их emal-ы, имена, id и прочее. В рамках сервисного слоя мы стремимся отгородить бизнес-логику от инфраструктуры. А это значит, что вернув список из объектов Django-модели, мы отступим от наших принципов.Тогда, часть кода выше, отвечающая за запрос к СУБД можно модифицировать следующим образом (реализацию остальных методов пока опустим):
...
from dataclasses import dataclass
from typing import Generator
from django.contrib.auth.models import User
@dataclass
class UsersReportRow:
pk: int
username: str
email: str
is_active: bool
class UsersReportService:
def _extract_users_data(self) -> Generator[UsersReportRow]:
for user in User.objects.all():
yield UsersReportRow(
pk=user.pk,
username=user.username,
email=user.email,
is_active=user.is_active,
)
...
Метод _extract_username_list мы заменили на _extract_users_data, который теперь возвращает генератор с Value Object-ами, реализованный в виде дата-класса. Дата-классы - весьма полезный инструмент в разработке на Python.
Часто питонисты пренебрегают создавать классы просто для передачи данных и могут использовать в подобных ситуациях, к примеру, список из dict-ов, что является худшей альтернативой. Достоинство классов перед dict-ами состоит в фиксированной схеме данных, которая находится у вас в коде. Такую фиксированную схему данных удобнее использовать в аннотациях типов. При вызове метода, который возвращает пользовательский объект вместо dict, отпадает потребность идти и каждый раз вспоминать его содержимое - ваша IDE сможет помочь вам, когда вам потребуется обратиться к атрибутам.
Иногда под Value Object имеют в виду другой паттерн - DTO - Data Transfer Object. С моей точки зрения, DTO - сущность общения между сервисами, в то время как Value Object является сущностью общения внутри сервиса.
Если представить, что наш сервис ,помимо пути к сгенерированному отчёту, должен будет вернуть ещё какую-либо информацию, например, уникальный id отчёта, это могло бы выглядеть так:
...
from dataclasses import dataclass
@dataclass
class UsersReportDTO:
full_path: str
report_id: str
class UsersReportService:
...
def execute(self) -> UsersReportDTO:
...
return UsersReportDTO(
full_path=full_path,
report_id=report_id,
)
Чаще всего, помимо чтения данных, может понадобится и много других операций с хранилищем: создание, обновление данных, агрегация. Как оставить сервис простым и не засорять бизнес-логику кодом работы с хранилищем?
Ответ - изолировать работу с вашим хранилищем через DAO - Data access object. Вот так может выглядеть DAO для взаимодействия с таблицей пользователей в Django:
from dataclasses import dataclass
from typing import Iterable
from django.contrib.auth.models import User
@dataclass
class UserEntity:
pk: int
username: str
email: str
is_active: bool
class UsersDAO:
def _orm_to_entity(self, user_orm: User) -> UserEntity:
return UserEntity(
pk=user_orm.pk,
username=user_orm.username,
email=user_orm.email,
is_active=user_orm.is_active,
)
def fetch_all(self) -> Iterable[UserEntity]:
return map(self._orm_to_entity, User.objects.all())
def count_all(self) -> int:
...
def update_is_active(self, users_ids: Iterable[int], is_active: bool) -> None:
...
Таким образом, в коде сервиса вашей бизнес-логики остаётся только использование DAO без построения запросов к СУБД напрямую.
Хочу ещё раз обратить внимание - мы специально не используем объекты моделей Django. Заставляя двигать данные внутри нашего сервисного слоя через примитивы языка и пользовательские объекты, мы изолируем нашу бизнес-логику от инфраструктуры.
Изолирование работы с ORM в коде обычно вызывает больше всего негативных эмоций у Django-разработчиков. Думаю, это происходит в первую очередь из-за того, что очень часто доменная модель бизнес-сущностей накладывается на модели Django в начале разработки новой фичи.
Может быть, такая изоляция и выглядит избыточной, когда в примере можно сделать всего один простой запрос в ORM, но поверьте, если для бизнес-логики вам в какой-то момент потребуется не просто достать набор строк из таблицы, а собрать некий агрегат, делая несколько запросов в разные источники данных, или сделать raw-sql запрос через драйвер к СУБД, который возвращает вместо адекватного объекта какой-то tuple из tuple-ов, то помимо всех достоинств, описанных ещё в предыдущем более простом примере, вы получите ещё и улучшенную в разы читаемость кода.
Так же весьма важным эффектом является то, что изолируя ORM, если в какой-то момет вам придётся заменить источник данных, к примеру, на MongoDB, или вообще, веб-API вашего другого сервиса, вам не придётся переписывать кучу unit-тестов и всю бизнес-логику. Нужно будет только переписать тесты на ваш DAO или метод сервиса, который возвращает DTO/Value Object. Главное лишь то, чтобы ваш код изолирующий работу с данными сохранял старый интерфейс. Сделайте вашу инфраструктуру зависимой от бизнес-логики, а не наоборот!
Dependency injection - построение комплексных сервисов
Чаще всего, нам требуется писать сложные пайплайны бизнес-логики, и в таких случаях, при помещении всего кода в один сервис, он становится плохо читаемым. Тогда требуется отделять сложные части пайплайна в отдельные сервисы. Таким образом, сервисы могут быть комплексными и использовать под капотом другие сервисы, как инфраструктурные, так и доменные.Теперь давайте снова усложним наш пример. Представим, что заказчик захотел, чтобы помимо csv-файла была так же возможность сгенерировать и exel-файл.
Создадим два новых инфраструктурных сервиса, затем встроим их в наш сервис бизнес-логики. Один из них будет отвечать за запись xlsx, а второй - за csv.
Модифицируем исходный файл make_users_report_service.py (снова опустим подробности реализации):
...
import abc
from typing import Iterable
class IReportFileAdapter(abc.ABC):
@abc.abstractmethod
def create_report_file(self, file_name: str, username_list: Iterable[UsersReportRow]) -> str:
pass
...
class UsersReportService:
def __init__(self, report_file_adapter: IReportFileAdapter):
self.report_file_adapter = report_file_adapter
def _extract_users_data(self) -> Generator[UsersReportRow]:
...
def _generate_file_name(self) -> str:
...
def execute(self) -> str:
username_list = self._extract_users_data()
file_name = self._generate_file_name()
return self.report_file_adapter.create_report_file(file_name, users_data_list)
И далее, создадим новый файл с инфраструктурными сервисами, реализованными в виде адаптеров report_file_adapters.py:
import abc
from typing import Iterable
from .make_users_report_service import IReportFileAdapter
class CSVReportFileAdapter(IReportFileAdapter):
def create_report_file(self, file_name: str, username_list: List[str]) -> str:
...
class XLSXReportFileAdapter(IReportFileAdapter):
def create_report_file(self, file_name: str, username_list: List[str]) -> str:
...
Таким образом, вызывать сервис бизнес-логики вы сможете с разными адаптерами, в зависимости от того, какой файл вам нужно сгенерировать.
from your_django_app.services.reports import (
UsersReportService,
CSVReportFileAdapter,
)
users_report_service = UsersReportService(report_file_adapter=CSVReportFileAdapter())
path_to_report = users_report_service.execute()
Давайте рассмотрим написанный код подробнее:
1) Inversion of Control
С помощью абстрактного базового класса мы эмулировали интерфейс в нашем коде и положили его именно рядом с нашей бизнес-логикой, а не адаптерами. Таким образом, мы смогли достичь Инверсии управления в нашем коде. Благодаря этому в файл с бизнес-логикой не нужно делать импорты инфраструктурных сервисов, которые умеют писать csv/xlsx. Мы добились зависимости инфраструктурного слоя от бизнес-логики, а не наоборот.
Вы так же можете достичь IoC с помощью типа Protocol, появившегося в typing начиная с python 3.8.
Многие считают IoC в python излишним. Применяйте по ситуации: если вам не нужна высокая степень изоляции компонентов, можно обойтись и без IoC, либо использовать абстрактные базовые классы с реализацией, которые уже следовало бы держать рядом с адаптерами в моём примере. Сервисный слой гибок.
2) Удобный mock
Одному мне не нравится каждый раз писать в тестах длинный @mock.patch('...')? Длинный путь к объекту, который вы хотите замокать, банально неудобно читать и прописывать. К тому же, при перемещении или переименовании объекта, который вы мокаете, эту строку нужно будет не забыть поправить. Теперь всё можно сделать гораздо приятнее. Встраивая сервисы друг в друга, при написании тестов, вы получаете возможность пробрасывать Mock при инициализации вашего сервиса:
from mock import Mock
from unittest import TestCase
from your_django_app.services.reports import (
UsersReportService,
IReportFileAdapter,
)
class UsersReportServiceTestCase(TestCase):
def test_example(self):
report_file_adapter_mock = Mock(spec=IReportFileAdapter)
users_report_service = UsersReportService(
report_file_adapter=report_file_adapter_mock
)
...
...
Используя внедрение зависимостей, вы можете строить сложные пайплайны бизнес-логики из большого количества ваших рабочих блоков с кодом - сервисов.
О любви Python-сообщества к библиотекам и зависимости от инфрастурктуры
Что делают в python-сообществе когда возникает проблема? Правильно - пишут новую awesome-библиотеку (https://github.com/mixxorz/django-service-objects - автор предлагает писать сервисный слой на основе Django-форм. С моей точки зрения, валидация входных данных должна происходить в инфраструктурном слое, а не перегружать собой слой бизнес-логики. На слое сервисов можно валидировать только бизнес-правила. А всякие проверки, что число - это число, а не строка, лучше оставить обычным Django-формам.
https://github.com/dry-python/stories - ещё одна библиотека для построения сервисного слоя. Давно слежу за ребятами. Помимо stories, у них много других интересных библиотек на разные темы.
Я против использования подобных библиотек. И вот почему:
- Сторонняя библиотека - это уже часть инфраструктуры, сама по себе. Завязывая вашу бизнес-логику на такую библиотеку, вы заранее завязываете свою бизнес-логику на инфраструктуру, что уже противоречит идеи разделения бизнес-логики и инфраструктуры на разные слои. Однажды вам может понадобится выйти за рамки вашей библиотеки для написания бизнес-логики, и, скорее всего, это будет больно. Сильно задумайтесь, нужно ли вам это.
- У многих в голове стереотип, что Python - язык для data science и каких-то DevOps-скриптов, и без дополнительных приседаний в виде специальных библиотек, нормально оформить в нём сложную бизнес-логику нельзя. Естественно, это не правда. Python давно готов для написания серьезных приложений с нагруженной бизнес-логикой. В этой статье я привёл примеры разных приёмов, которые не требовали сторонних зависимостей для описания бизнес-логики. Если вам захочется выйти за рамки описанного в статье, вы будете ограничены лишь языком программирования, а не какой-то библиотекой.
Что дальше?
Лучше всех по теме негативных последствий от зависимости бизнес-логики от инфраструктуры приложения уже высказывался Роберт Мартин в своих статьях "Screaming Architecture" и "The Clean Architecture", а также прекрасной книге "Clean Architecture: A Craftsman's Guide to Software Structure and Design".В принципе, если возвести все приемы, которые я показал в статье, в абсолют, и везде использовать DI и IoC, а также ещё пару приёмов, то у вас получится своя реализация чистой архитектуры.
Вы могли видеть в данной статье элементы Domain-driven design. Для ознакомления рекомендую обратить внимание на книгу "Domain-Driven Design: Tackling Complexity in the Heart of Software" от Эрика Эванса. Сервисный слой можно также развить в полноценное DDD приложение.
Подводя итог
Итак, подведём итог нашему путешествию в сервисный слой:- Начните со структуры - заведите отдельный модуль для вашего сервисного слоя и хорошо структурируйте его.
- Сервисы бизнес-логики оформляются в виде классов-команд (классов с одним публичным методом). Инфраструктурные - по ситуации.
- Трафик данных в сервисах, а также между ними, лучше всего осуществлять в примитивах языках и пользовательских объектах с помощью Value Object, DTO, DAO.
- Больше тестов! Покрывайте сервисы тестами, активно мокайте инфраструктуру и другие сервисы от которых вы зависите.
- Сервисы могут быть комплексными и вызывать под капотом другие сервисы. Такое взаимодействие будет удобно организовать через Dependency Injection.
- Сервисный слой должен подходить потребностям вашей команды. Опасайтесь завязки на чужие решения в виде библиотек или фреймворков. Используйте только те приёмы, что вы считаете нужными. Возможно, просто вынесение кода из ваших views, без серьезной изоляции инфраструктуры, может решить большинство ваших проблем.
Python service layer: основы оформления бизнес-логики на примере Django-приложений
Django - отличный фреймворк, но он, на самом деле, толком не дает, да и не должен давать, ответ на вопрос, каким образом лучше всего хранить вашу бизнес-логику. Хранение бизнес-логики в моделях или...
habr.com