Django обладает свойством, которое одновременно и хорошо, и мешает написать о нём интересную статью: фреймворк ровно настолько структурирован и «с мнением», что со временем становится незаметным. Части приложения, написанные «по-Django», настолько плотно сливаются с обычным Python-кодом и бизнес-логикой, что их трудно выделить отдельно. Глядя на кодовую базу почтового сервиса Buttondown, сложно указать пальцем и сказать «вот это — Django-штука». Видна просто хорошо структурированная система, во многом опирающаяся на решения более умных людей. Собственно, в этом и заключается главная ценность фреймворка. Однако такое наблюдение годится разве что для короткой заметки, поэтому ниже — попытка разобрать конкретно, какие части Django принесли проекту наибольшую долгосрочную пользу за последние годы.

1. Middleware

Абстракция middleware в Django невероятно простая, а потому невероятно мощная. Для тех, кто застал переход от middleware-функций к middleware-классам, привычно воспринимать middleware как обычную функцию, работающую с циклом запрос/ответ — независимо от того, каким примитивом Python она реализована. Единственное требование — соблюдать протокол, а дальше внутри него можно делать что угодно. На практике такой хук для запросов оказался полезен для множества задач: маршрутизации запроса к нужной рассылке по поддомену, захвата UTM-меток и referrer-атрибуции, установки заголовков Content-Security-Policy, записи просмотров страниц, привязки контекста запроса к структурированным логам и — как в примере ниже — проставления версии развёрнутой сборки.

Вот полностью middleware, которое помечает каждый ответ хэшем git-коммита развёрнутой сборки, чтобы устаревшая вкладка браузера могла заметить, что вышла новая версия:

# app/emails/middlewares/build_version.py
class Middleware:
    def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
        self.get_response = get_response

    def __call__(self, request: HttpRequest) -> HttpResponse:
        response = self.get_response(request)
        if settings.HEROKU_SLUG_COMMIT and not flag_is_active(CIRCUIT_BREAKER_FLAG):
            response[BUILD_VERSION_HEADER] = settings.HEROKU_SLUG_COMMIT
        return response

Если и есть один инструмент Django, который средний разработчик недооценивает — это middleware.

2. Модели (и лёгкое наследование)

В Buttondown избегают полиморфных моделей — отчасти потому что они считаются потенциальным источником проблем, но в основном потому что для них просто нет подходящих сценариев. Зато буквально каждая модель наследуется от базовой. Упрощённая версия выглядит так:

# app/utils/models.py
class BaseModel(models.Model):
    creation_date = models.DateTimeField(auto_now_add=True)
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    objects = TypeIDAwareManager()

    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)
        # For any tracked field that actually changed, fire its
        # handle_<field>_change hook and persist a transition row.
        ...

    class Meta:
        abstract = True
        ordering = ("-creation_date",)

Этот базовый класс незаметно выполняет много работы, и всё это — опционально и аддитивно:

  • UUID в качестве первичного ключа и поле creation_date для каждой таблицы — «из коробки».
  • Публичные ID с префиксом типа (sub_..., em_...), которые ORM прозрачно декодирует через кастомный менеджер и queryset.
  • Неявное отслеживание изменений: достаточно определить метод handle_<field>_change, и он будет вызываться при каждом изменении поля — без сигналов и регистрации.
  • Устойчивое протоколирование происхождения данных: поле связывается с таблицей переходов, и каждое изменение записывается отдельной строкой.
  • Хуки валидации для отдельных полей (validate_<field>).
  • Опциональный менеджер для мягкого удаления и интеграция с внутренней системой проверки целостности данных.

Может показаться странным заострять на этом внимание, но именно такая возможность собирать функциональность по частям делает подход Django к объектно-ориентированному программированию настолько удобным. Добавление нового общего поведения не требует значительных трудозатрат, миграций или рефакторинга. Например, добавить отслеживание происхождения для нового поля можно буквально одним методом — модель просто подключается к нему, а всю остальную работу берёт на себя базовый класс:

# app/emails/models/email/model.py
class Email(BaseModel):
    # Implicit change tracking: define handle_<field>_change and BaseModel
    # invokes it whenever that field actually changes. No signal, no wiring.
    def handle_body_change(self, **kwargs) -> None:
        AsynchronousAction.enqueue(sync_snippet_references, [str(self.id)])

    # Durable provenance: map a field to a transition table and every change
    # is persisted as a row. Adding one is a one-line dict entry.
    @classmethod
    def tracked_field_to_transition_class(cls) -> dict[str, type[BaseTransition]]:
        return {"status": EmailStatusTransition}

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

3. Actions

Вместо того чтобы позволять классам моделей обрастать десятками методов, каждое действие, которое может произойти с моделью, вынесено в отдельный файл в папке actions/ рядом с этой моделью — один глагол на модуль, каждый экспортирует функцию call(). Вот полный код действия «забанить подписчика», которое само вызывает другое действие:

# app/emails/models/subscriber/actions/ban.py
from emails.models.subscriber.actions import end_premium_subscription
from emails.models.subscriber.model import Subscriber


def call(subscriber: Subscriber) -> None:
    if subscriber.subscriber_type == Subscriber.Type.PREMIUM.value:
        end_premium_subscription.call(str(subscriber.id))

    subscriber.subscriber_type = Subscriber.Type.REMOVED.value
    subscriber.save(update_fields=["subscriber_type", "modification_date"])

4. Views

Подход к views предельно строгий и простой. Каждый view обязан:

  1. жить в собственном файле;
  2. быть функцией, а не class-based view;
  3. экспортировать эту функцию под именем view.
# app/emails/views/record_lifecycle_email_open.py
def view(request: HttpRequest, compressed_id: str) -> HttpResponse:
    try:
        account_id, email_type = _decode_open_payload(compressed_id)
    except (UnicodeDecodeError, Base64Error, ValueError):
        return HttpResponse(TRANSPARENT_GIF, content_type="image/gif")

    with transaction.atomic():
        LifecycleEmailEvent.objects.create(
            account_id=account_id,
            email_type=email_type,
            event_type=LifecycleEmailEvent.EventType.OPENED,
            timestamp=timezone.now(),
            metadata=_build_metadata(request),
        )
    return HttpResponse(TRANSPARENT_GIF, content_type="image/gif")

Зачем такая жёсткость и однообразие? В основном из-за издержек переключения контекста. Поддерживаемый код view — это скорее вопрос избегания ошибок, чем поиска элегантных решений, а ошибки обычно принимают форму лишних уровней косвенности и отсутствия переиспользования кода. Оба этих риска снижаются, если делать views максимально «чистыми» в функциональном смысле.

5. Тестирование

О тестах уже написано немало в личном блоге разработчика — последний год значительная часть работы была посвящена тому, чтобы сделать CI-конвейер, где backend-тесты долго были узким местом, максимально быстрым. Используются pytest, pytest-django и множество плагинов для pytest. Примечательно, что генератор фикстур вроде Factory Boy не применяется — фикстуры пишутся вручную, чтобы выжать больше производительности. Тест — это обычная функция, которая принимает нужные фикстуры и проверяет реальные строки в базе:

# app/emails/views/record_lifecycle_email_open--test.py
# `account` is a hand-rolled fixture, colocated in account/model--mock.py and
# registered via pytest_plugins — no factory_boy, no mock.patch.
def test_records_open_event(account):
    encoded = encode_open_payload(str(account.pk), "unconfirmed")
    request = RequestFactory().get(f"/lo/{encoded}/")

    response = view(request, encoded)

    assert response.status_code == 200
    event = LifecycleEmailEvent.objects.get(account=account)
    assert event.event_type == LifecycleEmailEvent.EventType.OPENED

То, что осталось за бортом

Если Rails славится подходом «omakase» — то есть готовым набором решений на всё, — то одна из главных ценностей Django как раз в обратном: во всём том, что не используется, потому что по тем или иным причинам не подошло проекту.

Несколько примеров.

Сигналы. Сложно даже сказать, что сигналы «не используются» — скорее, ими просто не злоупотребляют. В проекте есть ровно один сигнал — лёгкая связка с django-allauth. Внутреннее использование сигналов, когда связываются два куска кода, которые сами же и написаны, признано антипаттерном: это затрудняет понимание происходящего и усложняет оптимизацию производительности в будущем.

Class-based views. У CBV есть свои плюсы в определённых сценариях, но одна из самых болезненных вещей при перемещении по кодовой базе — переключение контекста между функциональным view и классовым. Какие бы небольшие преимущества ни давал CBV в отдельных случаях, они не перевешивают выгоду от строгой унификации того, как устроен каждый view.

Apps. Django-приложения (apps) не используются в привычном модульном смысле, по двум причинам. Во-первых, крайне сложно работать с миграциями между приложениями, особенно при их «сжатии» (squash). Во-вторых, apps не дают явного преимущества перед другими способами организации кода — Django в этом плане достаточно гибок, — например, перед простой группировкой связанных моделей по папкам. Есть два исключения из этого правила: базовая API-инфраструктура вынесена в отдельное приложение просто потому, что так была построена ещё до того, как сформировался более зрелый взгляд на архитектуру. Второе исключение — код, который потенциально может быть вынесен в отдельный пакет или в open source: здесь app помогает заранее очертить границы между этим кодом и остальной частью проекта.

Checks. Механизм checks в Django действительно неплохой, и есть определённое сожаление, что он используется не так активно. На практике оказалось проще применять для этого обычные тесты — с некоторой потерей производительности (можно сказать, что технически REPL становится медленнее), зато на одну движущуюся часть системы меньше.

Формы и фронтенд. Django-формы не используются вообще. Более того, подход к фронтенду довольно нестандартный: применяется паттерн гидратации, при котором задача Django для большинства авторизованных страниц — отрендерить тонкую «оболочку» и наполнить её данными, а не генерировать HTML. Почти каждую страницу приложения обслуживает один и тот же view: он резолвит сессию, сериализует данные аккаунта (а для части маршрутов — ещё и первую страницу нужного ресурса) в теги json_script и передаёт управление Vue, который загружается и «гидратируется» этими данными вместо того, чтобы делать отдельный запрос к API при загрузке. Django рендерит каркас, всё остальное делает SPA.

Почему вообще был выбран Django

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

Одна из давних принципиальных позиций — ограничивать количество «токенов инноваций». Не хотелось тратить силы на переключение контекста между разными фреймворками, перемещаясь между основной работой и сторонним проектом. За прошедшие с тех пор восемь лет выбор Vue в качестве фронтенд-фреймворка не раз вызывал сожаление — но выбор Django, пусть и не был результатом тщательного взвешенного анализа, ни разу не вызвал сомнений.