Сигналы: когда использовать и когда нет
Учимся подключать сигналы 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 — сам сохранённый объект, created — True, если строка только что создана, и 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()/сервис почти всегда яснее сигнала.