用dict存订阅者比set更稳妥,因闭包、lambda等在set中无法去重,而dict可通过(func, id(func))等唯一键稳定管理;需隔离异常、区分线程/异步场景,并用Enum或Protocol实现事件类型校验。

为什么直接用 dict 存订阅者比用 set 更稳妥
事件总线的核心是“一个事件名对应多个回调函数”,看起来用 set 去重很合理,但实际中回调常是闭包、lambda 或带状态的实例方法——它们在 set 中无法被正确识别为重复项,导致重复订阅或误删。用 dict 以 (func, id(func)) 或自定义唯一键(如 f"{func.__module__}.{func.__name__}_{id(func)}")为 key,能稳定管理生命周期。
实操建议:
- 不要依赖
func.__eq__判断是否已订阅,它对 lambda 和绑定方法基本失效 - 在
subscribe()中生成带时间戳和随机后缀的内部 ID,避免多线程下 key 冲突 - 若需支持取消订阅,保留原始
func引用的同时,额外存一份可哈希的标识符(如weakref.ref(func)+id(func)组合)
publish() 必须处理异常隔离,否则一个回调崩溃会中断整个事件流
典型错误是把所有监听器串行调用并裸抛异常:for handler in handlers: handler(event)。一旦某个 handler 抛出未捕获异常,后续监听器全部跳过,且调用方无法感知哪些执行成功了。
正确做法是单个 handler 执行独立 try/except,并提供失败回调或日志钩子:
立即学习“Python免费学习笔记(深入)”;
快速生成专业的 Python 脚本和应用代码。一键创建完整项目结构,支持CLI、API、爬虫、Bot、Django等多种项目类型,包含完整的项目结构、配置文件、依赖管理、测试、README和文档。
- 默认行为:捕获所有
Exception(不含KeyboardInterrupt和SystemExit),记录logger.warning("Handler %s failed: %s", handler, e) - 允许传入
raise_on_error=False参数控制是否传播异常 - 返回值建议是
NamedTuple,含success: int、failed: int、errors: List[Exception],方便测试断言
线程安全不能只靠 threading.Lock,得看使用场景
如果事件总线只在单线程中使用(如 CLI 工具、单元测试),加锁纯属冗余;但一旦涉及 asyncio、后台线程或信号处理(如 signal.signal),就必须区分同步/异步访问路径。
实操要点:
- 同步场景:用
threading.RLock(非Lock),因为同一线程可能嵌套调用publish()→ 触发新事件 → 再次publish() - 异步场景:不要在
async def publish()里用线程锁,改用asyncio.Lock,且所有subscribe/unsubscribe也需异步化(或明确文档注明“仅同步 API”) - 极端情况(如
atexit中发布事件):锁可能已销毁,应在__del__或weakref.finalize中确保锁对象不被提前回收
如何让事件类型真正可校验,而不是全靠字符串匹配
用字符串当事件名(如 "user.created")最简单,但拼写错误、重构 rename 后无人发现,测试难 mock。更健壮的做法是定义枚举或数据类作为事件标识。
推荐方案:
- 定义
class EventKind(enum.Enum),每个成员带.value(字符串名)和.payload_type: Type(标注期望参数结构) - 在
subscribe(kind: EventKind, handler)中做运行时检查:if not hasattr(handler, '__annotations__') or 'event' not in handler.__annotations__,提示类型不匹配 - 配合
typing.Protocol定义class EventHandler(Protocol): def __call__(self, event: Any) -> None: ...,让 IDE 和mypy能推导
真正的难点不在实现,而在于团队是否愿意为事件命名建立规范、是否把事件类型当成接口契约来维护——否则再好的类设计也会退化成字符串字典。

















