Найкращі практики Python Django

💡 Усі статті, обговорення, новини про Python — в одному місці. Приєднуйтесь до Python спільноти!

Тепер уявіть цю ж саму ситуацію, застосовану до програмного забезпечення. Ми, як експерти-програмісти з Python, схожі на шахових майстрів з попереднього прикладу. Коли код не структурований, без дотримання будь-якої логіки чи стандартів, тоді нам буде так само важко помічати помилки, як і початківцю-розробнику. З іншого боку, якщо ми звикли читати код у структурованому вигляді, і ми навчилися швидко схоплювати ідеї з коду, слідуючи шаблонам, то ми маємо значну перевагу.
— «Clean Code in Python», Mariano Anaya

Привіт! Мене звати Андрій, я Python Developer у компанії Starnavi. У розробці ПЗ, попри принцип Zen of Python, не завжди є єдиний очевидний спосіб написання коду. Часто маємо кілька варіантів, і не завжди зрозуміло, який з них кращий. Тому хочу поділитися власним підходом до вирішення деяких архітектурних і стильових питань. Сподіваюся, це допоможе початківцям знайти вектор розвитку або прийняти технічне рішення. Адже навіть зараз у топ-результатах Google можна знайти статті із застарілими практиками.

Business logic

Code blocks should be like Lego. High cohesion — maximum independence.

Найкращим компромісом для реалізації бізнес-логіки в Django є сервісний шар. Це можуть бути як функції, так і класи. У статтях Where to Put Business Logic Django? та Business logic in a Django project описано варіанти розміщення логіки, а також їх плюси та мінуси. Також у Django-Styleguide є приклади сервісів та моделей.

Сервіси реалізують патерн Фасад (Facade Pattern): інкапсулюють складну логіку й надають простий інтерфейс. Це також дозволяє дотримуватись принципу low coupling, high cohesion. Якщо перевірка якоїсь умови відбувається у багатьох місцях — логіку можна винести у property самої моделі.

Якоюсь мірою сервіси можна назвати аналогом інтеракторів (UseCases) з Чистої Архітектури. Uncle Bob каже, що Interactor реалізує use case і має метод запуску execute() і виходить, що це патерн Команда.

Переваги сервісного шару:

  • Відокремлення логіки від представлення (views, Celery tasks).
  • Менше залежностей між компонентами.
  • Простіше тестування й розширення.
  • Краща читаність і повторне використання.

Тобто ключова роль сервісу програми полягає в оркестрації сценарію використання, тому високий рівень зчеплення між включеними в сценарій частинами зазвичай очікуваний. Іншими словами, архітектурна мета сервісу полягає в оркестрації логіки, щоб дозволити компонентам нижчого рівня менше зчеплюватися між собою.

Є також аргументи проти такого підходу — Against service layers in Django, More on service layers in Django. Та мені вони видались непереконливими.

Пакет ether-utils містить кілька прийомів, які допоможуть покращити написання сервісів.

Без ether:

DB = {}
class ServiceError(Exception):
    ...

@dataclass
class UserDTO:
    name: str
    age: int
    email: str

class UpdateUserService:
    user_dto: UserDTO
    client: Client

    def __init__(self, user_dto: UserDTO, client: Client):
        self.user_dto = user_dto
        self.client = client

    def run(self):
        log.info("Start creating user service")
        log.info("User DTO: %s", self.user_dto)

        if self.user_dto.email not in DB:
            log.info("User doesn't exists")
            raise ServiceError("User doesn't exists")

        user = DB[self.user_dto.email]

        if self.user_dto.name == user.name:
            log.info("User name is the same")
            return

        # update user logic

Після рефакторингу:

DB = {}
class ServiceError(Exception):
    ...

@dataclass
class UserDTO:
    name: str
    age: int
    email: str

@service
class UpdateUserSvc:
    user_dto: UserDTO
    client: Client

    @catch_a_break
    def run(self):
        user = self.get_user()
        self.check_name(user)
        self.update_user(user)

    def get_user(self) -> UserDTO:
        if self.user_dto.email not in DB:
            log.info("User doesn't exists")
            raise ServiceError("User doesn't exists")

        return DB[self.user_dto.email]

    def check_name(self, user: User) -> None:
        if self.user_dto.name == user.name:
            # log.info("User name is the same") <- no need - `@catch_a_break` will log
            raise Break("User name has the same")

    def update_user(self, user: User) -> User:
        ...

Переваги такого підходу:

  • Відсутність методу __init__ — хоча в цьому випадку це просто, у складніших сценаріях з багатьма аргументами такий підхід забезпечує більшу гнучкість.
  • Чітка семантика через декоратор @service — він явно позначає клас як сервіс, уникаючи плутанини з @dataclass, який слід використовувати для DTO.
  • Автоматичне логування — під час ініціалізації сервісу автоматично логуються передані аргументи (args і kwargs).
  • Чистіша структура коду — метод run містить лише послідовність дій, а бізнес-логіка винесена в окремі методи.
  • Акуратна обробка помилок зі структурованими логами — операції можуть перериватися коректно з інформативними повідомленнями в логах.

Також при роботі з моделями для choices використовуйте models.TextChoices та models.IntegerChoices. Однак краще їх зберігати в окремому файлі constants.py, а не в моделі, як радить офіційна документація Джанго.

Project structure

Django з коробки пропонує модульну архітектуру, добре придатну для малих і середніх проєктів. Applications в Джанго це свого роду домени в термінах DDD. Вони забезпечують high cohesion. Тобто все, що відноситься до конкретного домену, знаходиться в одному місці — моделі, сервіси, константи. Це допомагає уникати антипаттерну big ball of mud, де все залежить від усього.

Хорошим покращенням стандартної структури ще може бути створення директорії core/base на верхньому рівні, де зберігаються спільні для всього проєкту речі: винятки, поля, абстрактні класи, константи тощо.

DRF

Більшість проєктів на Джанго — це АПІ, де найбільшу популярність здобув Django REST framework. ViewSet-и та Serializer-и чудово підходять для CRUD-операцій. Але при складній логіці ModelSerializer часто перетворюється на антипаттерн: логіка розмазується по всіх шарах, стає важкою для супроводу.

У складних кейсах серіалізатори варто використовувати лише для валідації вхідних даних, тобто клієнтського вводу. Бізнес-правила слід винести у сервісний шар. Так, ми втрачаємо велику частину drf серіалайзера, але отримуємо зрозуміліший код. Також, для того, щоб після валідації серіалайзера отримати об’єкт DTO, можна використати пакет github.com/...​restframework-dataclasses. Це схоже на те, як Pydantic використовується в FastAPI.

Для роботи з вкладеними словниками іноді буде зручно використовувати nget та destruct з пакету ether-utils:

>>> data = {'result': {'users': [{'address': {'street': 'Main St'}}]}}
>>> nget(data, 'result', 'users', 0, 'address', 'street')
'Main St'

Avoid mixins

Загальне правило при написанні коду — prefer composition over inheritance. В неті є багато матеріалів на цю тему. Частково я написав про причини тут: composition-vs-inheritance.

Тобто можна сказати, що використання успадкування (через клас mixin) є досить фіктивним. Ми не використовуємо успадкування для створення більш спеціалізованої версії класу, а лише для того, щоб скористатися методом міксіну. Це погано з двох (доповнюючих) причин: по-перше, успадкування — не найкращий спосіб повторного використання коду. Гарний код повторно використовується за допомогою невеликих, цілісних абстракцій, а не створення ієрархій. По-друге, створення підкласу має слідувати з ідеєю спеціалізації, відношення типу «є».

Іноді міксіни справді корисні, і дозволяють реалізувати принцип ISP — Принцип розділення інтерфейсу. Прикладом можуть слугувати mixins.CreateModelMixin, mixins.ListModelMixin, mixins.RetrieveModelMixin в DRF. Але в більшості інших випадків це просто клас з однією-двома функціями або полями.

Явне краще неявного, тому навіть якщо це службові поля в моделі — буде краще вказати їх явно і продублювати в усіх місцях, ніж виносити в окрему модель. Може здатись, що це суперечить DRY, та при зловживанні ми можемо створити складне дерево наслідування, де, щоб дізнатись усі поля моделі, треба обійти кілька рівнів вкладеності. Також це може призводити до конфліктів імен та атрибутів. Тобто заради економії кількох рядків коду ми отримуємо багато потенційних проблем з кодом у майбутньому.

Avoid versioning

Може здатись, що версіонування АПІ — це чудовий вихід. Джанго дозволяє доволі легко додавати нові версії АПІ, але такий код стає дуже складно підтримувати вже в дуже короткій перспективі. Адже щоб впевнитись, що у нас все працює так, як треба, потрібно продублювати усі тести для кожної версії АПІ. В бізнес-логіці в нас починаються складні розгалуження логіки залежно від версій, які можуть навіть суперечити одна одній. 3-5 версій можуть швидко перетворити навіть недавній код на легасі.

Натомість намагайтесь зберігати зворотну сумісність API. Якщо все ж потрібне versioning — мінімізуйте його використання.

Avoid signals and events

Використання сигналів та подій може бути спокусливим. Так ми ніби зменшуємо coupling, та насправді це тільки збільшує проблеми в розробці.

Сигнали в Джанго є своєрідним антипаттерном. Мало того що вони синхронні, так ще розмазують бізнес-логіку по багатьох шарам та роблять її неочевидною. Деякі пояснення можна прочитати в статтях Django Anti-Patterns: Signals та signals.

Але навіть якби сигнали були асинхронними, Event Driven Architecture все одно слід уникати до останнього. При використанні events інший розробник може не знати про їх існування, і таким чином це робить код менш передбачуваним. Тобто в коді більше немає єдиного місця, де можна подивитись, як проходить увесь flow. Не вийде просто відкрити код і пробігтись очима по потоку виконання.

Це вимагає широкого тестового покриття, яке буде містити усі події та можливі обробники. При змінах потрібно кожен раз розкручувати весь клубок подій, документувати його, підтримувати цю документацію в актуальному стані. І навіть разом з тим — помилки неминучі, адже людський фактор ніхто не відміняв. Це дорого і ненадійно.

Та сама проблема виникає при використанні хореографічної SAGA — немає централізованого місця, де можна переглянути логіку викликів. Тому в SAGA також краще використовувати оркестрацію, а не хореографію — явне краще неявного.

Settings

Краще відразу розділяти налаштування по компонентах — drf, celery, sentry. Є два підходи, як це зробити — або з допомогою пакета django-split-settings, або простими імпортами — from celery import *. Налаштування відповідно потрібно зберігати у змінних середовищах.

Читати можна з допомогою django-environ, який вміє також читати змінні з .env-файла (добрим тоном буде мати цей файл з фейковими необхідними змінним в репозиторії).

Також, якщо потрібно задавати змінні з джанго адмінки, в пригоді стане django-constance.. Більше можна прочитати в статті Configuring Django Settings: Best Practices.

Tests

pytest вже давно став стандартом. Додаткові інструменти для тестування можна знайти у цьому розділі testing, а приклади самих тестів — тут tests, testing.

Хорошою практикою є написати 1–2 інтеграційні тести, а всі edge cases покривати юніт-тестами. Тут у пригоді стануть сервіси з першого розділу. Не забувайте про фікстури — вони спрощують код тестів і прискорюють написання нових.

У CI/CD рекомендую використовувати xdist для паралельного запуску тестів та coverage для вимірювання покриття. Спробуйте досягати 100% покриття — не шляхом тестування кожного рядка, а за потреби, позначаючи непридатний до тестування код директивою # pragma: no cover. Додавши перевірку покриття у CI/CD, ви стимулюєте написання тестів одразу, що зменшує технічний борг і прискорює виявлення помилок.

Linting, formatting, type checking, package management

Black та flake8 вірою і правдою служили нам багато років, як і poetry. На зміну їм приходять ruff і uv — сучасні, швидкі й надійні інструменти. Замість mypy чекаємо ty.

Хоча pre-commit все ще не втрачає своєї актуальності. GitHub Actions дозволяє легко інтегрувати всі ці перевірки у CI/CD-процеси.

Avoid UUID for id

Як реалізувати пагінацію за uuid? Ніяк, оскільки UUID не мають природного порядку, як автоінкрементні цілочисельні ідентифікатори (за винятком uuid7). Також є ряд інших проблем. Детальніше можна ознайомитись за посиланням uuid. UUID все ще має свої застосування, але не слід вважати його універсальним рішенням або використовувати як ідентифікатор за замовчуванням.

Dependency Injection (DI)

Суть DI полягає в тому, що клас не створює свої залежності самостійно, а отримує їх ззовні — через конструктор або метод (наприклад, Logger() всередині конструктора). Це підвищує тестованість, гнучкість і полегшує зміну реалізацій.

class Logger:
    def log(self, message):
        print(f"[LOG]: {message}")

class UserService:
    def __init__(self, logger):
        self.logger = logger

    def register_user(self, username):
        self.logger.log(f"Користувача '{username}' зареєстровано.")

# usage
logger = Logger()
service = UserService(logger)
service.register_user("andriy_92")

# in tests
class MockLogger:
    def log(self, message):
        self.last_message = message  # can check in tests

Більше прикладів можна знайти в документації до одного з популяних DI-фреймворків: python-dependency-injector.

DDD, Clean Architecture

This is an important point: remember that code is for us, people, to understand, so only we can determine what is good or bad code. We should invest time in code reviews, thinking about what is good code, and how readable and understandable it is. When looking at the code written by a peer, you should ask such questions as:
— Is this code easy to understand and follow to a fellow programmer?
— Does it speak in terms of the domain of the problem?
— Would a new person joining the team be able to understand it, and work with it effectively?
— «Clean Code in Python», Mariano Anaya

DDD та Clean Architecture можуть бути корисними для будівництва справді великих монолітів, де рано чи пізно виникають проблеми з high coupling, коли зміни в одному модулі призводять до багів в іншому, адже бізнес-логіки надто багато, і вона розкидана по різним місцям. Це стає меншою проблемою в епоху компактних мікросервісів.

Також іноді розробники настільки захоплюються створенням розширюваної архітектури, що система стає надмірно складною й важкою для розуміння навіть для простих проєктів. Якщо абстракція створює більше проблем, ніж вирішує, варто переглянути підхід і не використовувати її. Як казав Альберт Ейнштейн: все має бути зроблено настільки просто, наскільки це можливо, але не простіше. Корисна стаття на цю тему — Overengineering in Onion/Hexagonal Architectures.

Інша проблема — rich domain model з DDD. Це підхід, коли вся бізнес-логіка зосереджена безпосередньо в об’єктах доменної моделі. Тобто об’єкти не лише зберігають дані, а й самі знають, як з цими даними працювати. Проте без достатнього досвіду це призводить до появи так званих Fat Models або God Objects, які ми вже згадували раніше.

Альтернативою може бути поєднання ідей Clean Architecture, DDD та Vertical Slice Architecture. Наприклад, у статті Maybe it’s time to rethink our project structure with .NET 6 код пропонується групувати за модулями як в стандартному підході Джанго. Все нове — добре забуте старе.

Resources

Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

👍ПодобаєтьсяСподобалось14
До обраногоВ обраному7
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

На мою думку, в архітектурі DRF немає як такого service layer. Візьмемо, наприклад, дженерики і ModelSerializer: за clean architecture, ендпоінти або вьюхи не повинні взаємодіяти з моделлю напряму, а лише через сервіс. У FastAPI, наприклад, service layer використовується постійно. Endpoint -> InputDTO, Service logic, OutputDTO, а в DRF generics за замовчуванням працюють з кверісетами. Щоб нормально додати сервіс, потрібно постійно оверрайтити дефолтні методи, і в результаті пропадає сама суть ModelViewSet, generics та інших фіч DRF. aбо ж виходить франкштейн з неоднорідним стилем коду.

Все так. Розробка програмного забезпечення — це завжди компроміс. Срібної пулі не існує. DRF дозволяє дуже швидко створити CRUD додаток. Якщо ж з’являється складна бізнес-логіка — виносимо у сервіси — аналогічно як ви написали про FastAPI. Численні гайди радять починати з моноліту, і тільки критичні частини виносити у мікросервіси. Так і тут. Ідеальні рішення — на те ідеальні, що існують тільки у вакуумі.

Як реалізувати пагінацію за uuid? Ніяк

А для чого пагінація за uuid взагалі? Чому не взяти якийсь created_at, updated_at, title, чи ще щось що потрібне користувачу.

Крім пагінації — там ще є проблеми. Ну і пагінація по ід — це default в більшості систем. Щоб пагінація працювала на created_at — треба на нього додавати індекс. updated_at — може змінюватись, тому не дуже хороше рішення. Тобто — будь-яку проблему можна вирішити. Питання — ціна і навіщо? В статті я намагався вказати варіанти за замовчуванням. Але срібної пулі не існує. Тому так, в деяких випадках доведеться від них відступати.

Так, updated_at може змінюватись, як і нові об’єкти можуть додаватись з ID. Але тут питання як це потрібно видавати користувачу.
Бо якщо лише розбити на частини (сторінки), то навіть UUID підійде і видасть у якомусь сталому (хоч і рандомному) порядку.

Ну так. В першу чергу треба відштовхуватись від того, що від нас хоче замовник. Але якщо він залишає це на розгляд розробника, то завжди краще використовувати варіант за замовчуванням. Ну тобто — так, проблеми uuid можна вирішити. Але — навіщо створювати собі ці проблеми, щоб потім їх вирішувати?

Ось тут можете ще подивись та прочитати про проблеми пагінації:
dou.ua/forums/topic/44535

Чисто для розуміння, як ви робите пагінацію по «id»? Можете приклад SQL надати? І заодно, скільки максимум строк в системі було, по яким доводилось робити пагінацію.

Оффсет і курсор пагінації в принципі добре розглянуто в цих статтях:
dou.ua/forums/topic/44416
dou.ua/forums/topic/44535
Тут більше деталей саме по дрф
www.django-rest-framework.org/api-guide/pagination
Суть пагінації по int id в тому, що вона більш ефективна ніж по таймстампу. ChatGPT дає достатньо валідну відповідь, чому.

А як без

Event Driven

боротися з високим навантаженням?

Як і мікросервіси, так і Event Driven — це last resort, коли вже всі інші засоби перепробували та нічого не допомагає. Відповідно треба розуміти, які проблеми з’являться. Ну тобто це рішення вже рівня Senior/Architector, і тягне за собою багато супутньої роботи — це і документація, і діаграми, трейси, логування, тести, високий рівень розробки і т.д. Більше не можна пройтись по стеку виклику, тому що будь-який код може викликатись будь-коли, циклічні виклики івентів, вибух івентів — коли один породжує декілька, а ті декілька ще — і т.д. Це важко розробляти, і ще важче підтримувати.

> Як зробити пагінацію на uuid?

🤦🏼‍♂️🤦🏻‍♂️🤦🏽‍♂️🤦🏾‍♂️🤦🏾‍♂️

І про івенти той самий жест. Джангівські рідні івенти кепські, нема пріоритезаціі і так дальше, але будь яку систему більше 100к слок не получиться без івентів розділити.

Джуни, якщо ви це читаєте, то це не набагато краще за айай слоп.

По-перше — дякую за коментар. По-друге, так, погоджуюсь, більшість топіків холіварні і категоричні 😉. Тому якщо знаєте як краще — звісно робіть як знаєте. Стаття орієнтована на тих, хто не знає. Ну і так, на те, що в коментарях нададуть альтернативи. Тож, по-третє — буду вдячний за доповнення. А от по івентах не згіден. Є досить популярна стаття/сайт www.cosmicpython.com на івентах. Так їх (івенти) там критикували всі кому не лінь. Неможливо побудувати хорошу систему на івентах, тому їх застосовують тільки як last resort.

Дякую за прекрасну статтю, відразу після того як прочитав витягнув значну частину логіки з серіалайзерів та розбив монструозний settings.py на купу невеличких з використанням django-split-settings. Стало значно краще

Дякую за високу оцінку!

django-split-settings

має один великий мінус — перестає працювати автодоповнення сеттінгів. Можна поєднати обидва підходи — робити імпорт основних компонентів, а уже дев, прод, тестінг підключати за допомогою `include()`

Не з усім згоден з автором, але в цілому мені є що написати. Я не дуже багато працював з Django, так вийшло...Тільки останні декілька років активно з ним працюю. Так ось, на проектах з Django я побачив все... Це просто жах як люди розуміють цей фреймворк і які антипатерни можна знайти на реальних проектах:
— жирні моделі, логіку з яких нормально складно кудись перетягнути
— бізнес логіка, яку розмазали по всіх можливих і неможливих місцях
— фактична відсутність сервісного лейеру, юзкейсів
— файли на 10000 рядків з моделями та вюшками, тю, а навіщо придумувати якусь структуру?)
— DRF серіалізатори які явно і неявно виконують не їхню задачу (серіалізація даних), а взагалі все що можна (хелоу неявні N+1), включно з операціями на зміну даних та запитами до бази
— міксіни з get object або get queryset і купою логіки в них
— неявні сигнали десь в сраці проджекту без документації
— поліморфні зв’язки в яких зламаєш голову читаючи код
— ендпоінти на кшталт /list /create /delete, але хоча б http методи правильні...
— банальні N+1, особливо в тих хто працював з іншими фреймворками. Django трохи відключає мізки, люди менше думають про джойни.

Я не кажу що це все проблема Django. Звісно, це лише інструмент. Можна ним забити цвях, а можна розбити собі ним голову. Але з нормальним гайдом по Django є дійсно проблеми, особливо по DRF. Люди не знають що вони роблять, не задумуються навіщо існує один чи інший клас/метод/протокол/патерн. Просто ліплять шо вийде, воно якось задачу закриває, поки рано чи пізно не перетвориться в одне велике легасі.
Сам фреймворк чудовий, але існує багато «але».

На Python/Django/DRF дуже легко почати щось писати. І дуже швидко накидати mvp. Ае так — це і благословення, і прокляття одночасною.
Ну і те що офіційна документація і популярні гайди та відео спонукають словживати можливостями — це також ложка дьогтю.

Підписатись на коментарі