Django-Signals

这篇讲什么

  • Signals 是 Django 内置的观察者模式:某个动作发生时把消息广播给所有注册过的接收器,两边不用互相 import。
  • 它是同步调用,所以定位是「解耦的钩子」而不是异步任务队列。这篇把内置信号清单(本机实测枚举)、connect 的四个参数、dispatch_uid 与 weak=True 的真实行为、注册位置、哪些写法不会发信号、多对多变化的六种 action、接收器抛异常的后果和自定义信号都跑了一遍。基线 Django 5.2.19。

一句话先说清楚

  • Signals 是 Django 内置的观察者模式:某个动作发生时(保存、删除、多对多变化、请求进来),发送方把消息广播给所有注册过的接收器,两边不用互相 import。
  • 它是同步调用。接收器就在当前进程、当前这一次 save() 里跑完才返回。实测一个 time.sleep(0.5) 的接收器让 save() 花了 0.51 秒。
  • 所以它的定位是「解耦的钩子」,不是「异步任务队列」。真要异步请交给 Celery(见《Django-Celery》),信号最常见的用法就是那根「触发 Celery 的胶水」。

什么时候该用,什么时候不该用

  • 适合:一处变化要多处响应,而且这几处互相没有依赖。
    • 记录操作日志、写审计表。
    • 数据变化后清缓存 / 预热缓存。
    • 关联业务通知:员工入职 → 发消息通知团队;离职 → 清理权限。
    • 把「主流程」和「附加动作」拆开,让 models.py 不必知道谁在监听。
  • 不适合:
    • 耗时动作(发钉钉、调第三方接口)直接写在接收器里 —— 它是同步的,用户就在屏幕前等这 0.5 秒。正确做法是在接收器里 transaction.on_commit() + 投 Celery 任务。
    • 强一致的业务规则 —— 接收器抛异常会把 save() 一起带崩(下面有实测),而且 bulk_create()、queryset.update() 根本不触发信号。约束请写进数据库约束或 Model.clean()。
    • 靠信号做「谁先谁后」的流程编排 —— 接收器的调用顺序只保证「后注册的先调用」,别把它当工作流引擎。

内置信号清单(本机 Django 5.2.19 实测枚举)

  • 把每个模块 dir() 一遍,凡是 django.dispatch.Signal 实例的都列出来:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import importlib

    from django.dispatch import Signal

    for name in ["django.core.signals", "django.db.models.signals",
    "django.contrib.auth.signals", "django.db.backends.signals",
    "django.test.signals", "django.utils.autoreload"]:
    mod = importlib.import_module(name)
    found = [n for n in dir(mod) if isinstance(getattr(mod, n), Signal)]
    print("%-32s %s" % (name, found))
    1
    2
    3
    4
    5
    6
    7
    django.core.signals            ['got_request_exception', 'request_finished', 'request_started', 'setting_changed']
    django.db.models.signals ['class_prepared', 'm2m_changed', 'post_delete', 'post_init', 'post_migrate',
    'post_save', 'pre_delete', 'pre_init', 'pre_migrate', 'pre_save']
    django.contrib.auth.signals ['user_logged_in', 'user_logged_out', 'user_login_failed']
    django.db.backends.signals ['connection_created']
    django.test.signals ['setting_changed', 'template_rendered']
    django.utils.autoreload ['autoreload_started', 'file_changed']
  • 常用的这几个记这几条就够(旧笔记只列了 9 条,这里把「什么时候发」和「接收器能拿到什么」补齐):

    信号 定义位置 什么时候发 接收器额外拿到的参数(实测)
    pre_init django.db.models.signals Model(...) 还没执行 __init__ 体 args、kwargs
    post_init 同上 Model(...) 初始化完;从库里读出来也会发 只有 instance(此时 pk 可能已有值)
    pre_save 同上 save() 里 SQL 执行前 update_fields(frozenset 或 None)、raw
    post_save 同上 save() 里 SQL 执行后 created(True 新建 / False 更新)、using
    pre_delete 同上 delete() 里 SQL 执行前 using、origin;此时对象还在库里
    post_delete 同上 delete() 里 SQL 执行后 同上;此时对象已经没了
    m2m_changed 同上 多对多的 add / remove / clear action、reverse、model、pk_set、using
    pre_migrate / post_migrate 同上 migrate 前后 建表后才能用的接收器挂这里
    request_started django.core.signals 请求进来、还没进中间件 WSGI 下是 environ,ASGI 下是 scope
    request_finished 同上 响应生成完 没有额外参数
    got_request_exception 同上 视图抛异常(500 前) request
    setting_changed 同上 override_settings 进入/退出时 setting、value、enter
    user_logged_in / user_logged_out django.contrib.auth.signals 登录、登出成功 user、request
    user_login_failed 同上 认证失败 credentials(密码已被替换成 **)、request
    connection_created django.db.backends.signals 新建一条数据库连接 connection
    template_rendered django.test.signals 模板渲染完(要挂 InstrumentedTemplateRenderer) template、context
  • 两个容易记错的点,都是实测:

    • request_started / request_finished / got_request_exception 在 django.core.signals,不在 django.db.models.signals。
    • 缓存没有「命中/未命中」信号:django.core.cache.signals 这个模块在本机 5.2.19 上直接 ModuleNotFoundError。想统计命中率要在自定义缓存后端或中间件里做。

注册接收器:connect 的四个参数

  • 所有信号都是 django.dispatch.Signal 的实例,注册就是调它的 connect:

    1
    Signal.connect(receiver, sender=None, weak=True, dispatch_uid=None)
    参数 类型 默认 说明
    receiver 可调用对象 必填 处理信号的函数,签名必须能接住 **kwargs,否则 DEBUG=True 时 connect() 当场报 ValueError: Signal receivers must accept keyword arguments (**kwargs).
    sender 任意对象 None 只接收这个发送方的信号;None 表示谁都收(这时你必须在接收器里自己判断 instance 类型)
    weak bool True 弱引用接收器。默认 True 的意义见下一节
    dispatch_uid 可哈希(一般字符串) None 给接收器一个唯一身份,用来防止重复注册
  • sender 还能写字符串(模型路径),这是 @receiver 才用得上的写法,好处是接收器模块不必 import 模型:

    1
    @receiver(post_save, sender="jobs.Resume")   # 等价于 sender=Resume

    实测这样挂上去照样生效,接收器里的 sender 拿到的还是真正的类对象(打印出来是 sender=Resume created=True)。

dispatch_uid 到底解决什么问题

  • 旧笔记写的是「用来避免接收器被重复注册」,方向对但不够准。实测三种情形:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    hits = {"a": 0, "b": 0, "c": 0}


    def make_receiver(bucket):
    def handler(sender, **kwargs):
    hits[bucket] += 1

    return handler


    # 情形 1:每次新建一个闭包,不带 uid
    for _ in range(3):
    post_save.connect(make_receiver("a"), sender=AuditLog)
    AuditLog.objects.create(action="C-无uid", detail="")
    print(hits["a"]) # 3

    # 情形 2:同样三次,但都带同一个 dispatch_uid
    for _ in range(3):
    post_save.connect(make_receiver("b"), sender=AuditLog, dispatch_uid="same_uid")
    AuditLog.objects.create(action="C-有uid", detail="")
    print(hits["b"]) # 1

    # 情形 3:同一个函数对象 connect 两次
    same = make_receiver("c")
    post_save.connect(same, sender=AuditLog)
    post_save.connect(same, sender=AuditLog)
    AuditLog.objects.create(action="C-同一函数", detail="")
    print(hits["c"]) # 1
    1
    2
    3
    3
    1
    1
  • 结论(对着 django/dispatch/dispatcher.py 的实现读):

    • 不带 dispatch_uid 时,去重键是 (id(receiver), id(sender))。同一个函数对象重复 connect 本来就不会重复注册(情形 3)。
    • 但每次新造的闭包 / lambda / 绑定方法都是不同的对象,id 不同,于是注册三遍就收三次(情形 1)。
    • dispatch_uid 把去重键换成你给的字符串,于是「每次新建的接收器」也能只留一个(情形 2)。
  • 所以真正需要它的是这两种场景:接收器是闭包/lambda,或者同一个模块可能被重复 import(下一节的 reload)。给接收器起个全局唯一的 dispatch_uid,是几乎零成本的保险。

weak=True 的真实行为

  • weak=True(默认)时,Django 存的是接收器的弱引用:接收器一旦被垃圾回收,注册自动失效。

  • 危险的不是普通函数(模块级函数被模块持有,永远活着),而是绑定方法和局部函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class Notifier:
    def __init__(self):
    self.n = 0

    def handle(self, sender, **kwargs):
    self.n += 1
    print("绑定方法收到一次(累计 %s)" % self.n)


    nt = Notifier()
    post_save.connect(nt.handle, sender=AuditLog)
    AuditLog.objects.create(action="D-方法还在", detail="") # 打印 累计 1

    del nt
    gc.collect()
    AuditLog.objects.create(action="D-对象没了", detail="") # 什么都不打印
    1
    绑定方法收到一次(累计 1)
    • 实测 del nt 之后 weakref.ref(nt)() 已经是 None,第二条 create() 再也不会触发那个接收器 —— 这就是「明明注册过却不执行」的经典原因。
    • 对策二选一:把接收器写成模块级函数(推荐),或者 connect(..., weak=False)。
    • weak=False 实测确实一直收得到(实例删了照样收到),代价是这个对象永远不会被回收,热重载也换不掉它。

在哪注册:AppConfig.ready()

  • 官方推荐把接收器单独放一个模块,然后在应用的 ready() 里 import。原因是 ready() 保证在所有应用加载完、数据库表可用之后执行且只执行一次。

  • 按官方推荐这么放就行:

    jobs/apps.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    import logging

    from django.apps import AppConfig

    logger = logging.getLogger(__name__)


    class JobConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "jobs"
    verbose_name = "招聘"

    def ready(self):
    logger.info("JobConfig.ready() 开始加载信号接收器")
    import jobs.signals # noqa: F401
    settings.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    # settings.py:注册 AppConfig 本身,不要只写应用名
    INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "jobs.apps.JobConfig",
    ]
    • 一个应用只能出现一次:"jobs" 和 "jobs.apps.JobConfig" 同时写会报 ImproperlyConfigured: Application labels aren't unique, duplicates: jobs。
    • 只写 "jobs" 时,Django 会自动找 jobs/apps.py 里 default = True 的那个 AppConfig(Django 3.2 起的行为),所以两种写法都能跑,写全名更直白。
  • 为什么强调「只 import 一次」:@receiver 是模块顶层语句,模块被重复执行就会重复注册。实测把 jobs.signals 重新加载一次:

    1
    2
    3
    4
    5
    import importlib

    pre_save._live_receivers(Resume) # 加载前:1 个
    importlib.reload(jobs.signals)
    pre_save._live_receivers(Resume) # 加载后:2 个
    1
    2
    3
    对照:pre_save(Resume) 的接收器 reload 前=1(on_pre_save 没写 dispatch_uid)
    reload 之后=2 —— 没有 dispatch_uid 的接收器会翻倍
    post_save(Resume) 因为写了 dispatch_uid,reload 前后都是 1

    同一条 post_save 因为带了 dispatch_uid="resume_post_save",数量纹丝不动。这就是那一行 dispatch_uid 的价值。

两种注册写法

  • 装饰器写法(推荐,声明和注册贴在一起):

    jobs/signals.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    from django.db.models.signals import post_save
    from django.dispatch import receiver

    from jobs.models import Resume


    @receiver(post_save, sender=Resume, dispatch_uid="resume_post_save")
    def on_resume_saved(sender, instance, created, **kwargs):
    message = "简历已保存:%s(%s)" % (
    instance.username, "新建" if created else "更新")
    print(message)
  • 手工写法(适合「同一个函数要挂到多个信号/多个 sender」):

    1
    2
    post_delete.connect(on_resume_deleted, sender=Resume,
    dispatch_uid="resume_post_delete")
  • @receiver 还支持一次挂多个信号,实测这样写没问题:

    1
    2
    3
    @receiver(signal=[post_save, post_delete], sender=Resume)
    def on_resume_changed(sender, instance, **kwargs):
    ...
  • 接收器的调用顺序:同一个 sender 上,后注册的先被调用(send() 遍历前会把列表反转)。别依赖这个顺序写业务,需要顺序就合并成一个接收器。

保存与删除:信号里到底给了什么

  • 示例模型(jobs/models.py):

    models.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    class Job(models.Model):
    job_name = models.CharField("岗位名", max_length=50)
    tags = models.ManyToManyField("jobs.Tag", verbose_name="标签", blank=True)


    class Resume(models.Model):
    username = models.CharField("候选人", max_length=30)
    apply_position = models.CharField("投递岗位", max_length=50)
    job = models.ForeignKey(Job, on_delete=models.PROTECT, related_name="resumes")
  • 新建一条简历,四个信号依次收到(实测输出,pre_save 里我顺手 strip() 了字段):

    1
    2
    3
    4
    5
    pre_init     {'args': (), 'kwargs': {}}
    post_init {'pk': None}
    pre_save {'pk': None, 'update_fields': None, 'raw': False}
    post_save {'created': True, 'sender': 'Resume', 'using': 'default', 'pk': 4}
    库里 apply_position='后端开发'(pre_save 里 strip 过了)
    • pre_init 的 args / kwargs 就是你调用 Resume(...) 时传的那份参数,此时对象还没初始化。
    • post_init 在 Resume(...) 和从数据库读出来时都会发,实测 Resume.objects.get(pk=...) 和 list(Resume.objects.all()) 各触发一次 pre_init + post_init。所以别在 post_init 里做「每次新建都要干一次」的副作用,它比你想象的更常跑。
    • pre_save 里改 instance 的字段会真的落库(上面那行 strip() 就是证据)。想统一清洗数据,这里是钩子。
    • raw 是 loaddata 之类的原始加载标志,正常业务代码里恒为 False。
  • 更新时带了 update_fields:

    1
    2
    resume.apply_position = "前端开发"
    resume.save(update_fields=["apply_position"])
    1
    2
    pre_save   {'update_fields': frozenset({'apply_position'}), 'raw': False}
    post_save {'created': False, ...}
    • 注意 post_save 的 created 是 False,接收器必须两种情况都处理。
    • 看到 update_fields 不是 None 时,别在接收器里读其它字段的「数据库真值」——你手上这个 instance 只有被更新的那几列是新值。
  • 删除时两个信号的区别,实测就是「对象还在不在库里」:

    1
    2
    pre_delete   {'sender': 'Resume', 'pk': 9, 'using': 'default', '还在库里': True}
    post_delete {'sender': 'Resume', 'pk': 9, 'using': 'default', '还在库里': False}
    • 想留快照(比如把删掉的记录写进回收站表),必须在 pre_delete 里做。
    • 两个信号还额外收到 signal(就是信号对象本身)和 origin(谁发起的删除,用于区分级联来源)。

哪些写法「不会」发信号

  • 这是新手最容易踩的一类:代码看起来执行了,接收器却一次都没跑。实测:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    Resume.objects.bulk_create([
    Resume(username="李四", apply_position="算法", job=job),
    Resume(username="王五", apply_position="算法", job=job),
    ])
    # post_save 收到 0 次

    Resume.objects.filter(pk=r.pk).update(apply_position="数据开发")
    # 事件列表是空的:update() 直接发 UPDATE SQL,不实例化对象,也不发信号

    Resume.objects.filter(pk=r.pk).delete()
    # ['pre_init', 'post_init', 'pre_delete', 'post_delete']
    写法 发不发信号 说明
    Model.objects.create() / instance.save() 发 pre_save + post_save 正常路径
    Model.objects.bulk_create([...]) 都不发 官方文档明确写了不发送 save() 信号
    queryset.update(...) 都不发 只发一条 UPDATE SQL
    instance.delete() 发 pre_delete + post_delete 正常路径
    queryset.delete() 会发(每个对象一对) 它先把对象查出来(所以连 pre_init/post_init 都发了),再逐个删
    ForeignKey 赋值 resume.job = job 不发 只有真正 save() 才发;多对多才有 m2m_changed
  • 于是有两条实用规则:

    1. 需要「每条数据都被处理」的逻辑,别指望 bulk_create;要么循环 save()(慢),要么在批量写入后手工补一次处理。
    2. 反过来,做数据迁移/初始化时如果接收器会捣乱(比如写审计表把数据库撑爆),就在迁移里用 raw=True 判断,或者临时 disconnect。

m2m_changed:六个 action 与正反两个方向

  • 接收器一般挂在中间表上,这样才拿得到 pk_set:

    1
    2
    3
    @receiver(m2m_changed, sender=Job.tags.through)
    def on_tags_changed(sender, action, reverse, model, pk_set, instance, **kwargs):
    print(action, reverse, model.__name__, pk_set, type(instance).__name__)
  • 实测三个动作各发一对(前置 + 后置):

    1
    2
    3
    job.tags.add(t1, t2)  -> [('pre_add',  [6, 7], False), ('post_add',  [6, 7], False)]
    job.tags.remove(t1) -> [('pre_remove', [6]), ('post_remove', [6])]
    job.tags.clear() -> [('pre_clear', None), ('post_clear', None)]
    参数 含义
    action pre_add / post_add / pre_remove / post_remove / pre_clear / post_clear
    reverse 从哪一侧发起。False = 从定义 ManyToManyField 的一侧(job.tags);True = 反方向(tag.job_set)
    model 对侧的模型类。正向时是 Tag,反向时是 Job
    pk_set 对侧被改的那些主键集合;clear 时是 None
    instance 本侧的对象。正向是那个 Job,反向是那个 Tag
  • 反方向实测(t2.job_set.add(job)):

    1
    {'action': 'pre_add', 'reverse': True, 'model': 'Job', 'pk_set': [16], 'instance': 'Tag(9)'}
    • 所以接收器里不能直接写 instance.job_name——反向时 instance 是 Tag,会当场 AttributeError: 'Tag' object has no attribute 'job_name'(我踩过了)。稳妥写法是先看 reverse 或用 isinstance(instance, Job) 分流。
    • pre_* 里改 pk_set 可以影响最终写入的关系(pre_clear 时抛异常还能阻止清空)。

接收器抛异常会怎样

  • 把会炸的函数挂到 post_save 上,实测:

    1
    2
    3
    4
    5
    6
    def broken_receiver(sender, **kwargs):
    raise RuntimeError("接收器炸了")


    post_save.connect(broken_receiver, sender=Tag, dispatch_uid="broken")
    Tag.objects.create(name="炸一下")
    1
    2
    create() 抛出:RuntimeError: 接收器炸了
    但行数从 6 变成 7 —— post_save 是在 INSERT 之后发的,数据已经进去了
  • 两个结论:

    1. 异常会原样冒出来,不会被吞(send() 的行为;send_robust() 才会把异常收集进返回值)。
    2. post_save 时数据已经写库了,于是出现「接口 500,但数据其实存成功了」这种半完成状态。要避免就:
      • 把副作用放到 transaction.on_commit() 里,保证提交成功才执行;
      • 或者把副作用交给 Celery,接收器只负责投任务(投任务本身很快)。
  • 顺带一个反直觉的点:信号不感知事务。实测在 atomic() 里保存:

    1
    2
    3
    4
    5
    6
    order = []
    with transaction.atomic():
    Job.objects.create(job_name="事务里的岗位") # 这里 post_save 立刻进了 order
    transaction.on_commit(lambda: order.append("on_commit"))
    print(order) # ['post_save']
    print(order) # ['post_save', 'on_commit']

    也就是说 post_save 触发时事务还没提交,接收器里如果去查这条数据(另开连接)反而可能查不到。跨连接/跨进程的动作一律放 on_commit。

请求、配置、登录这三类信号

  • 用 Django 的测试客户端跑一次正常请求和一次 500,实测收到的顺序与参数:

    1
    2
    3
    4
    5
    6
    GET /jobs/          -> ['request_started', 'request_finished']
    GET /jobs/boom/ -> ['request_started', 'got_request_exception', 'request_finished']

    request_started sender=<class 'django.test.client.ClientHandler'> keys=['environ']
    got_request_exception sender=None keys=['request']
    request_finished sender=None keys=[]
    • request_started 的 sender 是处理器类(WSGI/ASGI),参数在 WSGI 下是 environ,ASGI 下换成 scope。
    • request_finished 什么额外参数都不给,sender 是 None。
    • got_request_exception 给的是 request 对象,异常本身不在参数里;要拿异常得从 request 上或日志里取。
    • 用测试客户端看 500 时记得 Client(raise_request_exception=False),否则异常会先抛回你的脚本。
  • setting_changed 在 override_settings 进入和退出时各发一次,实测:

    1
    2
    {'setting': 'TIME_ZONE', 'value': 'UTC',  'enter': True}    # 进入:给新值
    {'setting': 'TIME_ZONE', 'value': 'Asia/Shanghai', 'enter': False} # 退出:给旧值

    缓存了配置的组件(比如自己写的 settings 解析器)就靠它做失效。

  • 登录相关三个:

    1
    2
    3
    user_logged_in   sender=User 类   keys=['request', 'user']
    user_logged_out sender=User 类 keys=['request', 'user']
    user_login_failed sender='django.contrib.auth' keys=['credentials', 'request']
    • user_login_failed 的 sender 是字符串 'django.contrib.auth'(后端模块路径),不是类;credentials 里的密码已经被替换成 **,可以放心写日志。
    • 登录失败时 request 可能是 None(用 authenticate() 直接调用时就是 None)。

实战:改状态时发通知到钉钉

  • 这是旧笔记里那个例子的可跑版本。三个文件,全部实测过。

    jobs/signal_processor.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    import json
    import logging

    from django.db.models.signals import post_delete, post_save
    from django.forms.models import model_to_dict
    from django.dispatch import receiver

    from interview.dingtalk import send
    from jobs.models import Job, Resume

    logger = logging.getLogger(__name__)


    @receiver(post_save, sender=Resume, dispatch_uid="resume_post_save")
    @receiver(post_save, sender=Job, dispatch_uid="job_post_save")
    def post_save_callback(sender, instance=None, created=False, **kwargs):
    if isinstance(instance, Job):
    message = "Job for %s has been saved" % instance.job_name
    else:
    message = "Resume for %s %s has been saved" % (
    instance.username, instance.apply_position)
    logger.info(message)
    send(message)


    def post_delete_callback(sender, instance=None, using=None, **kwargs):
    obj = model_to_dict(instance, exclude=("picture", "attachment",
    "created_date", "modified_date"))
    message = "Instance of %s has been deleted: %s" % (
    type(instance), json.dumps(obj, ensure_ascii=False))
    logger.info(message)
    send(message)


    post_delete.connect(post_delete_callback, sender=Resume,
    dispatch_uid="resume_post_delete")
    jobs/apps.py
    1
    2
    3
    4
    5
    class JobConfig(AppConfig):
    name = "jobs"

    def ready(self):
    import jobs.signal_processor # noqa: F401
    settings.py
    1
    2
    3
    4
    5
    INSTALLED_APPS = [
    "django.contrib.admin",
    # ...
    "jobs.apps.JobConfig", # 不要再单独写 "jobs"
    ]
  • 三个细节:

    1. 两个 @receiver 叠在同一个函数上是可以的(实测两个 sender 各收到一次),但函数里必须用 isinstance 分流,否则 instance.job_name 会在 Resume 上炸。
    2. 旧笔记这里把 **kwarg 写成了单数,接收器必须能接住 **kwargs。
    3. send() 是同步阻塞的。生产上把这一行换成「投一个 Celery 任务」,钉钉的 200ms 就不会压在用户身上。

自定义信号

  • 三步:定义 → 注册接收器 → 触发。旧笔记这里有个拼写错误(from self_signalimport my_signal,少空格),下面按能跑的版本写。

    1
    2
    3
    4
    # jobs/signals.py 里定义(放在应用里,不要放项目根目录,免得 import 路径飘)
    import django.dispatch

    resume_deleted = django.dispatch.Signal()
    1
    2
    3
    4
    5
    6
    7
    8
    9
    # 注册
    from jobs.signals import resume_deleted


    def on_resume_deleted(sender, *, resume_id=None, **kwargs):
    print("收到通知:sender=%s resume_id=%s" % (sender, resume_id))


    resume_deleted.connect(on_resume_deleted, dispatch_uid="resume_deleted_notify")
    1
    2
    3
    4
    # 触发:业务代码里
    from jobs.signals import resume_deleted

    resume_deleted.send(sender="Recruitment", resume_id=7)
    1
    2
    收到通知:sender=Recruitment resume_id=7
    send() 返回 [(接收器, 返回值)]:[('on_resume_deleted', None)]
  • 关于 Signal() 的两个事实(实测):

    • Signal(providing_args=[...]) 在 Django 4.0 就被删了,5.2 上直接报错:

      1
      TypeError: Signal.__init__() got an unexpected keyword argument 'providing_args'

      老教程里凡是带 providing_args 的自定义信号,照抄必崩。参数约定改成写在文档/类型注解里。

    • Signal(use_caching=True) 是构造参数:内置的模型信号都开了缓存,自定义信号默认没开。接收器很多时可以考虑开,但要注意它缓存的是弱引用版本。

  • send() 与 send_robust() 的区别,实测:

    1
    2
    send() 不吞异常:RuntimeError: 自定义信号的接收器炸了
    send_robust() 把异常放进返回值:[('on_resume_deleted', None), ('bad', 'RuntimeError: 自定义信号的接收器炸了')]
    • send() 只要有一个接收器抛异常,后面的接收器不会执行,异常直接冒到调用方。
    • send_robust() 会把每个接收器的返回值(或异常)都收进列表,全部执行完再返回。业务通知类场景更合适。
  • 触发信号时 sender 是必填位置参数,其它都走关键字。接收器签名里 sender 一定要接住(def handler(sender, **kwargs))。

新手卡点速查

现象 原因 处理
接收器死活不执行 应用没注册 / 没在 ready() 里 import 接收器模块 INSTALLED_APPS 写 "jobs.apps.JobConfig",ready() 里 import jobs.signals
接收器执行了两遍 应用被注册了两次,或接收器模块被重复 import 检查 INSTALLED_APPS;给接收器加 dispatch_uid
跑一段时间后才不执行 / 单元测试里不执行 weak=True + 接收器是绑定方法或局部函数,对象被回收 用模块级函数,或 weak=False
bulk_create 后没反应 它不发 save() 信号 改成循环 save(),或批量后手工补处理
接口 500 但数据已经存进去了 post_save 在 INSERT 之后触发,接收器抛异常 接收器里别放会炸的逻辑;副作用挪到 on_commit 或 Celery
接收器里查不到刚保存的数据 事务还没提交 用 transaction.on_commit() 包起来
TypeError: ... unexpected keyword argument 'providing_args' 该参数在 Django 4.0 已移除 去掉 providing_args
反向 add 报 AttributeError m2m_changed 的 instance 会随 reverse 换类型 先判 reverse / isinstance
想在 pre_delete 存快照却读不到数据 用错信号,post_delete 时对象已不在库里 快照写进 pre_delete

参考

本文结束 感谢您的阅读
正在加载今日诗词....