Сигналы: когда использовать и когда нет

Учимся подключать сигналы Django и трезво оцениваем, когда они помогают, а когда вредят.

Сигнал — это механизм «издатель — подписчик» внутри Django: одно место кода объявляет «случилось событие», а другие места выполняют свой код в ответ, не зная друг о друге напрямую.

Зачем это нужно на практике

Иногда нужно отреагировать на событие, источник которого вам не принадлежит. Классика: при сохранении встроенного User создать связанный Profile. Код модели User лежит внутри Django, дописать в её save() свою строку вы не можете, а вот подписаться на её сигнал «после сохранения» — да. То же со сторонними приложениями, установленными через pip: их модели менять нельзя, а реагировать на их события — пожалуйста. Сигналы дают слабую связанность: код реакции живёт отдельно от кода действия, источник о подписчиках вообще ничего не знает. Это плюс для расширяемости (особенно в переиспользуемых приложениях, где автор не может предусмотреть всех будущих сценариев) и минус для прозрачности — об этом ниже. Важно с самого начала держать в голове обе стороны: сигнал — мощный, но «тихий» инструмент, и злоупотребление им превращает простой save() в чёрный ящик.

Основные сигналы моделей

Чаще всего работают с сигналами жизненного цикла модели. Главные из них:

СигналКогда срабатывает
pre_saveперед сохранением объекта в БД
post_saveпосле сохранения (флаг created говорит, новый ли это объект)
pre_deleteперед удалением — объект ещё в базе, доступны его поля
post_deleteпосле удаления из базы
m2m_changedпри изменении many-to-many связи (добавили/убрали)

Есть и сигналы запроса (request_started, request_finished), и сигнал миграций. Но 90% практики — это post_save и pre_delete.

receiver: как подписаться

Подписчик — обычная функция с фиксированной сигнатурой. Подключают её декоратором @receiver. Обязателен **kwargs: Django передаёт в обработчик много именованных аргументов, и без «звёздочек» код сломается при следующем обновлении.

from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import User
from .models import Profile

@receiver(post_save, sender=User)
def create_profile(sender, instance, created, **kwargs):
    if created:                       # только при первом сохранении
        Profile.objects.create(user=instance)

Разберём аргументы: sender — класс-источник (User), instance — сам сохранённый объект, createdTrue, если строка только что создана, и False при последующих сохранениях того же объекта. Проверка if created здесь критична: без неё профиль пытался бы создаться при каждом редактировании пользователя, и вы получили бы ошибку уникальности. Фильтр sender=User тоже важен: без него обработчик звался бы на любую сохранённую модель в проекте — на статьи, заказы, комментарии — и пытался бы лепить профиль ко всему подряд.

Где это регистрировать? В методе ready() конфигурации приложения, иначе при импорте модуль с сигналами может не загрузиться:

class AccountsConfig(AppConfig):
    name = "accounts"

    def ready(self):
        from . import signals    # noqa: F401 — импорт ради побочного эффекта

Типичные применения

  • Создание связанных объектов: профиль к пользователю, корзина к новому клиенту.
  • Денормализация и счётчики: при post_save комментария увеличить comments_count у статьи.
  • Очистка ресурсов: при post_delete модели с файлом — удалить файл с диска.
  • Аудит: записать в журнал, кто и что изменил.

Объединяет их одно: реакция логически отделена от действия и часто относится к другому приложению. Например, приложение blog ничего не знает про приложение search, но search может подписаться на post_save статьи и переиндексировать её. Так модули остаются независимыми: вы можете выкинуть search целиком, и blog продолжит работать. Именно ради такой развязки между приложениями сигналы и придуманы — это их «родная» ниша.

Как это работает под капотом

Сигнал — это объект Signal с реестром подписчиков. @receiver просто вызывает signal.connect(handler, sender=...) и кладёт вашу функцию в этот реестр (слабые ссылки по умолчанию). Когда модель сохраняется, в конце Model.save() вызывается post_save.send(sender=..., instance=..., created=...). Метод send проходит по подписчикам и синхронно, в том же потоке и в той же транзакции вызывает каждого по очереди.

Два следствия, которые часто упускают. Первое: сигнал — не фоновая задача. Тяжёлый обработчик замедлит сам запрос, который сохранял объект. Второе: сигнал шлётся в рамках транзакции — если обработчик упадёт с исключением, откатится и исходное сохранение. А если нужно выполнить действие только после успешного коммита (например, отправить письмо), используйте transaction.on_commit(), иначе письмо уйдёт даже при откате.

Почему сигналы усложняют отладку

Главная претензия к сигналам — невидимое действие на расстоянии. Вы читаете User.objects.create(...) и не видите, что заодно создаётся профиль, шлётся письмо и пишется лог: всё это спрятано в обработчиках в других файлах. Отсюда боль:

  • Трудно проследить, что вообще происходит при сохранении — нет явного списка эффектов.
  • Порядок вызова обработчиков на одно событие не гарантирован — нельзя полагаться, что один отработает раньше другого.
  • Тесты «молча» запускают побочную логику; легко получить лишние объекты или письма в тестах.
  • Рекурсия: обработчик post_save, который снова вызывает save() того же объекта, запускает сигнал повторно и может зациклиться.

Альтернативы

Прежде чем тянуться к сигналу, спросите: а владею ли я кодом действия? Если да — почти всегда есть способ яснее:

  • Переопределить save() модели или метод сервиса — эффект виден прямо там, где происходит действие.
  • Сервисный слой: функция register_user(), которая явно создаёт пользователя и профиль. Весь сценарий читается в одном месте.
  • Очередь задач (Celery и т.п.) для тяжёлых или внешних действий — их сигналом делать опасно из-за синхронности и транзакции.

Эмпирика: сигналы оправданы, когда вы не контролируете источник события (чужая/встроенная модель) или пишете переиспользуемое приложение, которому нужны точки расширения. Для логики внутри своего домена явный вызов почти всегда лучше.

Частые ошибки

  • Нет **kwargs в сигнатуре — код ломается при добавлении новых аргументов в сигнал.
  • Регистрация не в ready() — модуль с обработчиками не импортируется, сигнал «не работает» без ошибок.
  • Тяжёлая работа прямо в обработчике — запрос тормозит; письма/HTTP выносите в on_commit или очередь.
  • Бизнес-логику своего домена прячут в сигналы — потом никто не может понять, что делает save().

Итоги

  • Сигнал — синхронный «издатель—подписчик»: обработчик исполняется в том же потоке и транзакции, что и действие.
  • Главные сигналы — post_save (с флагом created) и pre_delete; подписка через @receiver с обязательным **kwargs.
  • Регистрируйте обработчики в AppConfig.ready(), а отложенные эффекты — через transaction.on_commit().
  • Сильная сторона — слабая связанность; слабая — невидимое действие на расстоянии, мешающее отладке.
  • Если вы владеете кодом действия, явный save()/сервис почти всегда яснее сигнала.
Проверьте себя
1. Почему обработчик сигнала обязан принимать **kwargs?
AИначе Django вообще не вызовет обработчик
BЧтобы можно было фильтровать по sender
CDjango передаёт в обработчик набор именованных аргументов, и без **kwargs код сломается при добавлении новых
DЭто нужно только для сигнала m2m_changed
2. В каком потоке и контексте выполняется обработчик post_save?
AВ фоновом потоке, не влияя на скорость запроса
BСинхронно, в том же потоке и той же транзакции, что и сохранение объекта
CВ отдельном процессе через очередь задач
DПосле завершения HTTP-ответа
3. Когда сигнал — оправданный выбор, а не лишнее усложнение?
AВсегда, когда нужно что-то сделать при сохранении своей модели
BКогда вы не контролируете источник события (чужая/встроенная модель) или пишете переиспользуемое приложение
CКогда нужно отправить письмо после регистрации
DКогда логика короткая и помещается в одну строку