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 обязан:
- жить в собственном файле;
- быть функцией, а не class-based view;
- экспортировать эту функцию под именем
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, пусть и не был результатом тщательного взвешенного анализа, ни разу не вызвал сомнений.