Day 26:事件总线模式(Event Bus / Publish-Subscribe)

C++/Qt 每日设计模式 · 架构模式阶段 · 2026-09-15

架构模式 发布-订阅 模块解耦 Qt 信号槽 跨线程

01

今日主题

一句话定义:事件总线(Event Bus)是一个全局的中介广播站——发布者(Publisher)把「发生了什么」打包成一个事件对象丢给总线,总线负责把它投递给所有对这类事件感兴趣的订阅者(Subscriber);发布者不认识任何订阅者,订阅者也不认识发布者,双方只认识总线(以及事件类型)。

它属于「发布-订阅(Publish-Subscribe)」家族,本质是把观察者模式(Day 18)里的「主题」抽出来变成一个与业务无关的通用基础设施:观察者模式里主题和观察者往往还同属一个领域(股票行情 → 行情面板),而在事件总线里,总线上跑的是跨模块、跨层、跨线程的消息(订单已创建 → 扣库存 / 发邮件 / 记审计日志 / 刷新界面)。

本篇定位:Day 24(MVC)解决层内职责切分,Day 25(MVVM)解决层内状态同步。今天我们往上走一层,解决模块之间怎么说话的问题——当应用长大到 30 个模块时,模块依赖图会从「树」退化成「蜘蛛网」,一个业务方法里能塞进六个模块的调用。这篇会先用 70 行纯 C++17 写一个 header-only 的事件总线(含 Token 退订、锁外回调);再用 Qt6 写出真正生产可用的类型安全信号总线(每事件一信号 + 自动断开 + 跨线程排队);最后给出事件总线最容易被滥用的三条红线。

Web 架构师的类比入口:EventBus ≈ 浏览器的 EventTarget / Node 的 EventEmitter / 前端的 mitt、Vue2 的 $emit 全局 Bus、Redux 的 dispatch。区别是:JS 里事件是「字符串 + 动态类型」,靠运行时约定;C++ 里我们用事件结构体 + 类型索引拿到编译期的类型安全,Qt 里更进一步——用信号(signal)把「事件类型」变成元对象系统里的一等公民,顺带白送跨线程排队与生命周期自动管理。

02

为什么需要这个模式

先看一段你几乎一定写过的代码:一个下单流程,成功之后需要「扣库存 → 存库 → 记日志 → 发确认邮件 → 刷新界面 → 判断是否要提示库存预警」。需求方是这么说的,代码也就这么写了。

无模式坏代码:业务方法直连六个模块

// ❌ 坏味道:OrderService 把六个模块的头文件全部 include 进来
#include <iostream>
#include <string>

struct Order { std::string id, sku, userId; int qty; };

// —— 以下类是各模块的接口,这里只保留最小签名 ——
struct InventoryService {
    bool lockStock(const std::string&, int) { return true; }
    int  left(const std::string&) { return 3; }
};
struct LogService {
    void warn(const std::string& s) { std::cout << "[WARN] " << s << "\n"; }
    void info(const std::string& s) { std::cout << "[INFO] " << s << "\n"; }
};
struct MailService {
    void sendConfirm(const std::string& u, const std::string& o) {
        std::cout << "[MAIL] " << u << " <- " << o << "\n";
    }
};
struct OrderRepo { void save(const Order& o) { std::cout << "[DB] save " << o.id << "\n"; } };
struct OrderListView {
    void refresh() { std::cout << "[UI] refresh\n"; }
    void showWarning(const std::string& s) { std::cout << "[UI] warning: " << s << "\n"; }
};

class OrderService {                 // ← 类膨胀 + 依赖爆炸的起点
public:
    OrderService(InventoryService& inv, LogService& log, MailService& mail,
                 OrderRepo& repo, OrderListView& view)
        : inv_(inv), log_(log), mail_(mail), repo_(repo), view_(view) {}

    void placeOrder(const Order& order) {
        if (!inv_.lockStock(order.sku, order.qty)) {   // ① 调库存模块
            log_.warn("库存不足 " + order.sku);        // ② 调日志模块
            return;
        }
        repo_.save(order);                             // ③ 调持久化模块
        log_.info("下单成功 " + order.id);
        mail_.sendConfirm(order.userId, order.id);     // ④ 调邮件模块
        view_.refresh();                               // ⑤ 调 UI 模块 —— 业务代码认识界面!
        if (inv_.left(order.sku) < 5)
            view_.showWarning("库存偏低");             // ⑥ 又调 UI
    }
private:
    InventoryService& inv_;  LogService& log_;  MailService& mail_;
    OrderRepo& repo_;        OrderListView& view_;   // 五个依赖全硬编码
};

int main() {
    InventoryService inv; LogService log; MailService mail; OrderRepo repo; OrderListView view;
    OrderService svc(inv, log, mail, repo, view);
    svc.placeOrder(Order{"A-1001", "SKU-9", "u-jianke", 2});
}

这段代码今天能跑,但它埋了四颗雷,每一颗都对应一条 SOLID 违背:

根因很清晰:业务动作(下单成功)和业务副作用(发邮件、刷界面、记日志)被写在了同一个地方,而且副作用是被「直接调用」的。事件总线的做法是把它们劈开——业务只管宣布事实,谁来听、听几遍、要不要跨线程听,由订阅方自己决定。

03

核心思想

生活类比:小区公告栏 + 邮局。你要卖一台二手显示器,过去的做法是挨个敲门(直接调用):先敲 301,再敲 502,敲的人越多越累,串门时还可能顺手把邻居家的活也干了(业务耦合)。事件总线的做法是在公告栏贴一张告示(publish):"周六下午,显示器一台,200 元"。你不需要知道谁会看到,也不需要知道有多少人看到了;关心的邻居自己定期来公告栏看一眼(subscribe)。楼主(发布者)与邻居(订阅者)之间唯一的共同知识是:告示贴在公告栏上,且告示有类型(交易/失物/停水通知)。

把它翻译成三句话,就是事件总线的全部内核:

  1. 事件是值对象:OrderPlaced{ id, sku, qty, amount }——只描述「发生了什么」的不可变事实,不带「你该做什么」的指令。事件名必须是过去式(OrderPlaced 而不是 CreateOrder),这是区分「事件」和「命令(Command,Day 14)」的关键习惯。
  2. 总线只做两件事:登记订阅(subscribe)+ 广播事件(publish)。它不认识业务、不写业务分支、不做编排——一旦总线里出现 if (topic == "order.placed") 这种语句,它就已经退化成上帝对象了。
  3. 依赖方向倒过来了:模块 A(订单)不再依赖模块 B(邮件),而是 A 和 B 都依赖「事件契约」这层抽象。这就是 Spring 里 ApplicationEventPublisher、Qt 里 QObject::connect、前端 mitt.emit 的本质——依赖倒置 + 时间解耦(发布之后,订阅者可以立刻处理,也可以排队到「以后」处理,比如主线程/下一个事件循环)。

关键区别一句话记住:观察者模式解决「一个主题通知它的多个观察者」,事件总线解决「互不相识的模块,通过一个公共广播站匿名通信」。前者是类之间的关系,后者是架构的基础设施。

04

UML / 角色关系

              ① 构造事件对象(值语义)
 ┌──────────────┐        ┌──────────────────┐        ┌───────────────┐
 │  Publisher   │──────► │    EventBus      │◄────── │  Subscriber A │
 │ (OrderSvc)   │  ② publish(E)  │(中介/广播站)│  订阅   │ (InventorySvc)│
 └──────────────┘        │  - subscribe()   │  ④ 回调 └───────────────┘
                         │  - publish()     │◄────── ┌───────────────┐
 ┌──────────────┐        │  - unsubscribe() │  订阅   │  Subscriber B │
 │  Event E     │───────►│  Token 退订凭证  │        │ (MailSvc)     │
 │(不可变值对象)│ 事件类型 │  (事件类型索引)  │        └───────────────┘
 └──────────────┘        └──────────────────┘
                         依赖方向:Publisher → Event 契约 ← Subscriber
                         Publisher 与 Subscriber 之间【零依赖】
角色职责要点
Event(事件)描述已发生事实的不可变值对象过去式命名、字段自足(别塞指针!)、可按事件类型分类
EventBus(总线)维护「事件类型 → 订阅者列表」的路由表,负责广播业务无关;线程安全;回调时先取快照(防迭代器失效)
Publisher(发布者)构造事件并交给总线,不关心谁收到只依赖事件类型与总线句柄,不依赖任何订阅者
Subscriber(订阅者)对感兴趣的事件类型注册回调,处理副作用处理要幂等、要可重入;理想情况下不抛异常打断广播
Token / Connection退订凭证(订阅 ID 或 Qt 的 QMetaObject::Connection)没有它就只能退化成「永不解绑」,是内存泄漏与悬空回调的源头

时序(一次 publish 发生了什么):Publisher → bus.publish(OrderPlaced{...}) → 总线按 typeid(E) 找到该类型的订阅者列表 → 拷贝一份快照 → 依次调用回调 → 每个回调在自己的上下文里做副作用(发邮件、写日志、发信号到 UI 线程)。注意最后一步里那句「拷贝快照」——它是总线能否在回调中安全退订的关键。

05

最小 C++ 示例(C++17,可编译)

下面是一个 header-only 的最小事件总线:类型安全(std::type_index + std::any 做类型擦除)、支持退订(Token)、线程安全(std::mutex)、并且在锁外调用回调(否则回调里再 publish 会死锁)。为了让你能直接 g++ 编译,我把 head 与 main 放在同一文件里;真实项目请拆成 event_bus.hpp + main.cpp。

// event_bus_demo.cpp —— g++ -std=c++17 -O2 -pthread event_bus_demo.cpp -o demo && ./demo
#include <any>
#include <atomic>
#include <cstddef>
#include <functional>
#include <iostream>
#include <mutex>
#include <string>
#include <typeindex>
#include <unordered_map>
#include <vector>

// ==================== 1. 事件定义(值对象,过去式命名) ====================
struct OrderPlaced { std::string orderId; std::string sku; int qty;  double amount; };
struct StockLow    { std::string sku;     int left; };   // 库存偏低

// ==================== 2. 事件总线 ====================
class EventBus {
public:
    using Token = std::size_t;                                   // 退订凭证

    // 进程级单例:函数内 static(C++11 起保证初始化线程安全,见 Day 1)
    // 故意 new 而不放在静态存储上,避免「静态析构顺序」导致的退出期崩溃
    static EventBus& instance() {
        static EventBus* bus = new EventBus();
        return *bus;
    }

    // 订阅事件 E:返回 Token,handler 可以是 lambda / 函数对象 / 成员函数绑定
    template <typename E, typename F>
    Token subscribe(F&& handler) {
        const std::type_index key = typeid(E);                   // 事件类型即路由键
        const Token token = ++counter_;
        // 类型擦除:把「强类型回调」包成「吃 std::any 的统一回调」
        std::function<void(const E&)> typed(std::forward<F>(handler));
        auto erased = [fn = std::move(typed)](const std::any& payload) {
            fn(std::any_cast<const E&>(payload));               // 还原为具体类型
        };
        std::lock_guard<std::mutex> lock(mutex_);                 // 只在改表时加锁
        handlers_[key].push_back(Slot{token, std::move(erased)});
        return token;
    }

    // 退订:按 Token 精确移除(没有 Token 的订阅系统就是个漏勺)
    bool unsubscribe(Token token) {
        std::lock_guard<std::mutex> lock(mutex_);
        for (auto& [key, slots] : handlers_) {
            for (auto it = slots.begin(); it != slots.end(); ++it) {
                if (it->token == token) { slots.erase(it); return true; }
            }
        }
        return false;
    }

    // 发布:把事件广播给该类型的所有订阅者
    template <typename E>
    void publish(const E& event) {
        std::vector<std::function<void(const std::any&)>> snapshot;
        {
            std::lock_guard<std::mutex> lock(mutex_);
            auto it = handlers_.find(std::type_index(typeid(E)));
            if (it == handlers_.end()) return;                    // 没人关心:静默返回
            snapshot.reserve(it->second.size());
            for (const auto& slot : it->second) snapshot.push_back(slot.fn);  // ① 快照
        }                                                          // ② 先解锁
        for (const auto& fn : snapshot) fn(std::any{event});        // ③ 锁外回调
    }

private:
    struct Slot { Token token; std::function<void(const std::any&)> fn; };
    EventBus() = default;
    ~EventBus() = default;
    EventBus(const EventBus&) = delete;                 // 单例不可拷贝
    EventBus& operator=(const EventBus&) = delete;

    std::mutex mutex_;
    std::unordered_map<std::type_index, std::vector<Slot>> handlers_;
    std::atomic<Token> counter_{0};
};

// ==================== 3. 订阅者(互不相识的模块) ====================
struct InventoryService {
    void start() {
        bus_.subscribe<OrderPlaced>([this](const OrderPlaced& e) {   // 关心「已下单」
            stock_ -= e.qty;
            std::cout << "[库存模块] 扣减 " << e.sku << " x" << e.qty
                      << ",剩余 " << stock_ << "\n";
            if (stock_ < 5)                                              // 自己判断要不要发二次事件
                bus_.publish(StockLow{e.sku, stock_});
        });
    }
    int stock_ = 20;
    EventBus& bus_ = EventBus::instance();
};

struct MailService {
    void start() {
        bus_.subscribe<OrderPlaced>([this](const OrderPlaced& e) {
            std::cout << "[邮件模块] 向用户发送确认邮件:订单 " << e.orderId
                      << ",金额 " << e.amount << "\n";
        });
    }
    EventBus& bus_ = EventBus::instance();
};

struct AuditLog {                     // 审计日志:订单/库存两个事件都关心
    void start() {
        bus_.subscribe<OrderPlaced>([this](const OrderPlaced& e) {
            std::cout << "[审计模块] 记录下单事件 " << e.orderId << "\n";
        });
        bus_.subscribe<StockLow>([this](const StockLow& e) {
            std::cout << "[审计模块] 记录库存预警 " << e.sku << "\n";
        });
    }
    EventBus& bus_ = EventBus::instance();
};

// ==================== 4. main:发布者只认识事件 ====================
int main() {
    EventBus& bus = EventBus::instance();

    InventoryService inv;  inv.start();
    MailService      mail; mail.start();
    AuditLog         audit; audit.start();

    // ③ 一个会退订的订阅者:演示 Token 的作用(比如窗口关闭后不再收事件)
    const EventBus::Token tmp = bus.subscribe<OrderPlaced>([](const OrderPlaced&) {
        std::cout << "[临时面板] 打印一行日志\n";
    });

    std::cout << "---- 第 1 次下单:有临时面板 ----\n";
    bus.publish(OrderPlaced{"A-1001", "SKU-9", 16, 199.0});

    bus.unsubscribe(tmp);            // 面板关闭,退订
    std::cout << "---- 第 2 次下单:临时面板已退订 ----\n";
    bus.publish(OrderPlaced{"A-1002", "SKU-9", 1, 88.5});
    return 0;
}

程序输出(g++ 13 / -std=c++17,Linux 实测):

---- 第 1 次下单:有临时面板 ----
[库存模块] 扣减 SKU-9 x16,剩余 4
[邮件模块] 向用户发送确认邮件:订单 A-1001,金额 199
[审计模块] 记录下单事件 A-1001
[临时面板] 打印一行日志
[审计模块] 记录库存预警 SKU-9
---- 第 2 次下单:临时面板已退订 ----
[库存模块] 扣减 SKU-9 x1,剩余 3
[邮件模块] 向用户发送确认邮件:订单 A-1002,金额 88.5
[审计模块] 记录下单事件 A-1002
[审计模块] 记录库存预警 SKU-9

注意输出顺序里藏着事件总线的一个特性:库存模块发起的 StockLow 事件是在它自己的回调里(也就是广播过程中)二次发布的,所以审计模块收到「下单」和「库存预警」两条的顺序是「先第一条的下单,再第二条的预警」。这也提醒你:事件风暴(一个事件触发 N 个事件,再触发 M 个)是事件总线最常见的事故源,生产代码里要限制级联层数并在日志里标注事件链。

06

Qt 实战:类型安全的信号总线

在 Qt 里做事件总线,有一个别人没有的作弊码:QObject 的元对象系统已经把「事件(信号)+ 订阅者(槽)」做成了语言级别的能力,还免费送了四件套——编译期类型检查、跨线程自动排队(QueuedConnection)、接收者析构自动断开、QMetaObject 反射式调用。所以 Qt 版事件总线的正确形态不是「注册 std::function」,而是「一个单例 QObject,每个事件类型一个 signal」。

① appeventbus.h:每事件一信号的总线

// appeventbus.h
#pragma once

#include <QObject>
#include <QString>

// ---- 事件载体:普通 struct,值语义,可以放进队列(跨线程) ----
struct OrderPlaced { QString orderId; QString sku; int qty; double amount; };
struct StockLow    { QString sku; int left; };
struct ThemeChanged{ QString themeName; };

Q_DECLARE_METATYPE(OrderPlaced)      // 让 QMetaType 认识它:
Q_DECLARE_METATYPE(StockLow)         // 跨线程的 QueuedConnection 需要把参数
Q_DECLARE_METATYPE(ThemeChanged)     // 拷贝进事件队列,必须有元类型支撑

// ---- 事件总线:单例 + 每事件一信号 ----
class AppEventBus : public QObject {
    Q_OBJECT                      // 有它,moc 才会为下面的 signal 生成元数据
public:
    static AppEventBus& instance() {
        static AppEventBus* bus = new AppEventBus();   // 函数内 static,线程安全初始化
        return *bus;                                   // 故意不析构:活到进程结束,避免退出期顺序问题
    }

    // 发布入口:显式方法 = 可以被 grep、被打断点、被日志包装
    void publish(const OrderPlaced& e) { emit orderPlaced(e); }
    void publish(const StockLow& e)    { emit stockLow(e); }
    void publish(const ThemeChanged& e){ emit themeChanged(e); }

signals:
    void orderPlaced(const OrderPlaced& e);    // 事件类型即信号,编译期类型安全
    void stockLow(const StockLow& e);
    void themeChanged(const ThemeChanged& e);

private:
    AppEventBus() = default;
    ~AppEventBus() override = default;
    AppEventBus(const AppEventBus&) = delete;
    AppEventBus& operator=(const AppEventBus&) = delete;
};

② main.cpp:订阅者 connect,发布者 emit

// main.cpp
#include "appeventbus.h"

#include <QCoreApplication>
#include <QDebug>
#include <QMetaObject>
#include <QThread>
#include <QTimer>

// ---- 订阅者 A:库存服务,住在工作线程 ----
class InventoryService : public QObject {
    Q_OBJECT
public:
    explicit InventoryService(QObject* parent = nullptr) : QObject(parent) {
        // 订阅:只写「我关心 OrderPlaced」,不写「谁发的」
        QObject::connect(&AppEventBus::instance(), &AppEventBus::orderPlaced,
                         this, &InventoryService::onOrderPlaced);   // 自动根据线程选 Direct/Queued
        QObject::connect(&AppEventBus::instance(), &AppEventBus::stockLow,
                         this, &InventoryService::onStockLow);
    }
public slots:
    void onOrderPlaced(const OrderPlaced& e) {
        stock_ -= e.qty;
        qInfo() << "[库存]" << QThread::currentThreadId()
                << "扣减" << e.sku << "x" << e.qty << "剩余" << stock_;
        if (stock_ < 5)
            AppEventBus::instance().publish(StockLow{e.sku, stock_});   // 二次事件
    }
    void onStockLow(const StockLow& e) {
        qInfo() << "[库存] 触发补货流程:" << e.sku;
    }
private:
    int stock_ = 20;
};

// ---- 订阅者 B:界面桥,住在主线程 ----
class UiBridge : public QObject {
    Q_OBJECT
public:
    explicit UiBridge(QObject* parent = nullptr) : QObject(parent) {
        QObject::connect(&AppEventBus::instance(), &AppEventBus::orderPlaced,
                         this, &UiBridge::onOrderPlaced);          // 跨线程 → 自动 QueuedConnection
    }
public slots:
    void onOrderPlaced(const OrderPlaced& e) {
        qInfo() << "[UI]" << QThread::currentThreadId()
                << "刷新订单列表,插入" << e.orderId;              // 这里可以安全摸控件
    }
};

// ---- 订阅者 C:审计日志,用 lambda + 上下文对象(生命周期自动绑定) ----
class Auditor : public QObject {
    Q_OBJECT
public:
    using QObject::QObject;
    void start() {
        QObject::connect(&AppEventBus::instance(), &AppEventBus::orderPlaced, this,
                         [this](const OrderPlaced& e) {
                             qInfo() << "[审计] 记录" << e.orderId << "金额" << e.amount;
                         });      // 传 this 作上下文:this 析构时连接自动断开,不会悬空
    }
};

int main(int argc, char** argv) {
    QCoreApplication app(argc, argv);

    qRegisterMetaType<OrderPlaced>();     // 跨线程队列连接前注册一次(Qt6 多数情况下自动,但显式更稳)
    qRegisterMetaType<StockLow>();

    auto* auditor = new Auditor(&app);
    auditor->start();

    auto* ui = new UiBridge(&app);                     // 主线程订阅者

    auto* worker = new QThread(&app);
    auto* inv = new InventoryService();                // 无 parent,转移到工作线程
    inv->moveToThread(worker);                         // 事件由工作线程处理
    QObject::connect(worker, &QThread::started, inv, [] {   // 线程起来后再订阅也一样
        qInfo() << "[库存] 工作线程就绪";
    });
    worker->start();

    // 发布者:一个只认识事件类型的模块
    QTimer::singleShot(0, [&] {
        qInfo() << "[订单]" << QThread::currentThreadId() << "下单 A-1001";
        AppEventBus::instance().publish(OrderPlaced{"A-1001", "SKU-9", 16, 199.0});
    });
    QTimer::singleShot(300, [&] { app.quit(); });
    return app.exec();
}

#include "main.moc"   // 单文件 demo 偷懒写法;正式项目把类拆到 .h,交给 AUTOMOC

典型输出(Qt 6.5 / Linux):

[库存] 工作线程就绪
[订单] 0x7f... 下单 A-1001
[UI] 0x7f... 刷新订单列表,插入 A-1001        ← 主线程收到(排队投递)
[库存] 0x7f... 扣减 SKU-9 x16 剩余 4          ← 工作线程处理
[审计] 记录 A-1001 金额 199
[库存] 触发补货流程:SKU-9

两处值得盯住:(1) UI 与库存的线程 id 不同,但发布者只写了一次 publish——跨线程投递是 Qt 用事件队列自动完成的;(2) 没有一行退订代码——connect 记录了 receiver,receiver 一析构连接即失效,这正好补上手写总线最脆弱的环节。

③ 插件/脚本场景:动态 topic + QVariant

当事件名要在运行时决定(插件通信、脚本桥、通用消息面板),用字符串 topic 换掉编译期类型,代价是失去类型安全:

class DynamicBus : public QObject {
    Q_OBJECT
public:
    static DynamicBus& instance() { static DynamicBus b; return b; }
    // 订阅:topic -> 处理器;receiver 作为上下文对象,用于自动断开
    void subscribe(const QString& topic, QObject* receiver, std::function<void(const QVariant&)> h) {
        QObject::connect(this, &DynamicBus::published, receiver,
                         [topic, h = std::move(h)](const QString& t, const QVariant& payload) {
                             if (t == topic) h(payload);
                         });
    }
    void publish(const QString& topic, const QVariant& payload = {}) {
        emit published(topic, payload);
    }
signals:
    void published(const QString& topic, const QVariant& payload);
};

④ CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
project(eventbus_qt LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)                    # 关键:Q_OBJECT 需要 moc

find_package(Qt6 REQUIRED COMPONENTS Core)
# Qt 6.3+ 可用一行替代上面三行:
# qt_standard_project_setup()

add_executable(eventbus_qt
    main.cpp
    appeventbus.h                        # 头文件也要列进来,AUTOMOC 才会处理它
)
target_link_libraries(eventbus_qt PRIVATE Qt6::Core)

一句话讲清两个方案怎么选:模块内部/同一进程内的业务事件 → AppEventBus(每事件一信号);插件、脚本、需要动态 topic 或对接外部消息(MQTT/WebSocket)→ DynamicBus(topic + QVariant),并在网关层做一次「字符串 topic → 强类型事件」的转换,别让 QVariant 泄漏到业务层。

07

代码执行流程

以 C++ 最小实现为例,一次 bus.publish(OrderPlaced{...}) 的完整路径:

  1. ① 构造事件:发布者在栈上构造 OrderPlaced{...}(值语义、不可变),它不含指针、不含 UI 引用——这是事件能安全排队、跨线程、可序列化的前提。若字段带裸指针或 QObject*,跨线程时你就在共享可变状态,事故只是时间问题。
  2. ② 查路由表:publish 用 std::type_index(typeid(E)) 在 handlers_ 里查出该事件类型对应的订阅者列表;查不到就静默返回(无人关心不是错误)。这一步是总线的「路由」,时间复杂度 O(1)。
  3. ③ 取快照并解锁:在持锁区间内把订阅者回调拷一份到局部 snapshot,然后立刻释放锁。目的有两个:避免回调执行期间长时间占锁(吞吐),以及——最关键的——允许回调里再次 subscribe/unsubscribe/publish。如果直接遍历原列表,回调里退订会使迭代器失效,程序可能在事件风暴里崩溃;锁内回调则直接死锁(std::mutex 不可重入)。Qt 也做了同样的事:信号激活时先把连接列表拷出来(QMetaObject::activate 里操作 connections 的副本)。
  4. ④ 依次回调:在锁外按注册顺序调用每个订阅者;每个订阅者自行决定副作用(扣库存、发邮件、写日志)。若某个订阅者自己又 publish(StockLow),会递归进入②③④——这就是「级联事件」,面试常问的「事件风暴」由此而来。生产实践:给事件带上 correlationId 与 depth,超过 N 层就记为异常并告警。

反向流程(订阅与退订):subscribe<E>(handler) → 把强类型 lambda 包成 std::function<void(const std::any&)> → 分配自增 Token → 插入路由表,返回 Token;unsubscribe(token) → 持锁遍历找到 Token 删除。有了 Token,界面关闭、对象销毁时才能精确解绑,避免「对象已死、回调还在跑」的悬空调用。

Qt 版的流程多一步——投递决策:emit orderPlaced(e) → QMetaObject::activate 遍历该信号的连接 → 对每个连接判断线程:同一个线程走 DirectConnection(立即函数调用,栈帧在原线程上),不同线程走 QueuedConnection(把事件对象 QMetaCallEvent 放进接收者线程的事件队列,参数按值拷贝/Move)→ 接收者线程的事件循环取出后执行槽。这也解释了两个 Qt 必知事实:跨线程事件必须是可拷贝或可移动的类型(所以要 Q_DECLARE_METATYPE),以及订阅者在忙循环里不跑事件循环时,永远等不到 Queued 事件。

08

为什么这样设计

把第 02 节那段坏代码换成事件总线后,收益不是「代码变少了」,而是六个可独立变化的方向被切开了。逐条对照 SOLID 与工程现实:

还有两条不在 SOLID 里、但同样重要的设计取舍:广播语义让「不知道未来有多少订阅者」成为常态(插件、动态面板、脚本扩展全靠它),代价是执行顺序不再由你控制;总线只做路由不做业务——一旦发现自己在总线里写 if (topic == ...),说明你真正需要的是一台显式的编排器(Orchestrator)或状态机,而不是总线。

09

不使用会怎样(反例)

不用事件总线,你最终一定会写出下面两种代码之一——它们的共同点是「每加一个消费者,都要动核心业务代码」:

反例 1:事件枚举 + switch 分派(分发器膨胀)

// ❌ 集中式分派:所有模块的联动都写在一个 switch 里
void ModuleManager::onAppEvent(AppEventType type, const void* payload) {
    switch (type) {
        case AppEventType::OrderPlaced: {
            auto* o = static_cast<const Order*>(payload);   // void*:类型安全全靠人脑
            mailService_->sendConfirm(o->userId, o->id);
            auditService_->write("order", o->id);
            listView_->refresh();
            break;
        }
        case AppEventType::StockLow:
            stockPanel_->showWarning(static_cast<const StockLow*>(payload));
            break;
        case AppEventType::NetDown:
            statusBar_->show("网络异常");
            break;
        default: break;   // ← 每加一个事件/消费者,这个方法长一截,且是 Git 冲突高发区
    }
}

反例 2:依赖参数不断膨胀的「上帝服务」

// ❌ 构造函数签名记录了架构的腐败史
OrderService(InventoryService&, LogService&, MailService&, OrderRepo&,
             OrderListView&, SmsService&, AnalyticsService&, AuditService&,
             PushService&, CacheInvalidator&, /* ... */);   // 谁改谁怕

问题是连锁的:(1) 编译期耦合——任何下游模块改接口,核心业务全量重编;(2) 循环依赖——下游想回调业务只能互相 include 头文件,最后靠前置声明与回调函数打补丁;(3) 单测瘫痪——测一个下单要拉起十个真实对象,UI 还要有窗口,CI 上直接挂;(4) 条件逻辑扩散——「只在移动端发短信」「会员才发邮件」这类策略判断渗透进业务方法,最终长成 if (channel == kApp && !user.isVip() && order.amount > 100) 这种没人敢删的屎山;(5) 责任人失焦——「为什么这封邮件没发出去」在订单模块回答不了,因为触发点硬编码在业务里,而下游还有 5 处隐藏分支。

10

何时使用

适合的场景

不适合的场景

过度设计提醒(三条红线):① 全局可变状态——事件处理顺序不可控,谁都能在任何地方 publish,别让总线承载「必须按顺序发生的状态迁移」;② 隐式调用链——「点了这个按钮会导致什么」从代码里读不出来,排查只能靠日志,因此事件必须可追踪(事件名 + 关键 ID + 级联深度);③ 不可静态追踪——重构时 IDE 的「查找引用」找不到订阅者,所以把事件总线限制在跨模块边界使用,模块内部继续用直接调用与信号槽,是最划算的纪律。

11

与其他模式的区别

维度观察者(Day 18)中介者(Day 16)事件总线(本篇)
谁通知谁主题持有观察者列表,主题 = 广播者中介者认识所有同事,双向路由总线不懂业务,只按事件类型路由
依赖方向观察者依赖主题接口同事只依赖中介者(星形)发布者/订阅者都只依赖事件契约
通信形态多为单向、无返回值可双向、可请求响应单向广播,天然匿名
典型落点一个领域对象 + 它的多个视图一个对话框里多个控件的协调跨模块/跨层/跨线程基础设施
常见事故观察者泄漏(遗忘退订)中介者膨胀成上帝对象事件风暴、隐式调用链

事件(Event)vs 命令(Command,Day 14):事件是过去式的事实(OrderPlaced),可有 0 个或 N 个接收者,发布者不期待结果;命令是祈使句的意图(PlaceOrderCommand),通常有唯一执行者,且支持撤销(undo())与排队。把两者混在一起,就会出现「发了个事件却要求别人必须做什么」的畸形设计。

事件总线 vs 消息队列(Kafka/RabbitMQ)vs Qt 信号槽:三者是同一思想的三个量级。Qt 信号槽是编译期绑定、点对点(1 信号 → N 槽)、同进程;事件总线是运行期路由、进程内匿名广播;消息队列跨进程/跨机器,多了持久化、重放、消费者组,代价是最终一致性与运维复杂度。桌面客户端 99% 的场景,Qt 信号总线已经在正确的层级上。

12

Qt 源码中的体现

Qt 本身就是一个把「事件 + 订阅」做进内核的框架,事件总线的每个零件都能在 Qt 里找到官方实现:

13

面试常见问题

Q1:事件总线和观察者模式的区别是什么?

A:观察者模式里「主题」是业务实体,它知道自己有哪些观察者(持有列表),关系是「一对多依赖」;事件总线的主题被抽成一个与业务无关的路由器,发布者与订阅者互不相识,是「多对多匿名广播」。判据是事件是否成为一等公民:事件总线里事件是独立值对象(有类型、可排队、可序列化、可审计);观察者模式里事件往往就是一次 update() 调用。另外事件总线天然支持跨线程投递,经典观察者模式则假设同步调用。

Q2:如何避免悬空回调与内存泄漏?

A:四招:① Token + RAII——把 Token 装进一个 Subscription 小对象(析构时自动 unsubscribe),别让退订散落在各处;② 绑定生命周期——Qt 里 connect(..., receiver, ...) 的 receiver 析构即自动断开,lambda 也要把上下文对象传进去,别用不捕获 this 的裸 lambda 订阅全局总线;③ 弱引用——跨生命周期的订阅者用 std::weak_ptr(C++)或 QPointer(Qt)先判断对象是否还活着;④ 禁止在回调中销毁自己——广播过程中对象死亡会让后续回调踩到已释放内存,真要销毁就延迟到事件循环下一轮(deleteLater())。

Q3:事件总线如何做到线程安全?

A:分两层。结构安全——注册表用 std::mutex(读多写少可换 std::shared_mutex)保护,且绝不在持锁时执行回调(否则回调里再 publish 直接死锁),永远先取快照再解锁调用。投递语义——订阅者要在「自己的线程」执行回调就必须走队列:Qt 的 QueuedConnection 用事件队列把参数按值投递到接收者线程,代价是接收者要跑事件循环、参数必须可拷贝/可移动(要 Q_DECLARE_METATYPE + qRegisterMetaType)。同理,std::any 里放的事件对象也必须是值语义——别把裸指针或引用塞进事件。

Q4:事件总线最大的风险是什么,怎么治理?

A:三个——隐式调用链(读代码看不出因果)、事件风暴(级联放大甚至递归)、顺序不可控(订阅者之间的依赖被隐藏)。治理:事件过去式命名并集中定义(events/ 目录,禁止随手定义匿名事件);给事件带 correlationId/traceId 与级联深度,超阈值告警;规定「产生事件」与「消费事件」在同一模块不可兼得,避免 A→B→A 环;关键流程用编排器/状态机显式化而不是靠事件隐式串联;为总线加统一日志与采样开关,生产环境能一键打开看完整事件流。

Q5:什么时候你不该用事件总线?

A:① 需要同步返回结果(用接口调用或 request-response);② 业务流程有强顺序与事务要求(用编排器/Saga/状态机);③ 依赖固定且只有一两个(直接组合更清楚);④ 热路径性能敏感(类型擦除与队列有开销);⑤ 团队规模小、模块少于 5 个时引入全局总线,等于提前支付「全局可变状态」的复杂度利息,收益接近零——事件总线的价值随模块数量超线性增长,模块太少时它是负收益。

14

今日练习(15-30 分钟)

任务:给最小事件总线加一个「请求-响应(RPC 风格)」能力

需求:在今天 EventBus 的基础上新增 request<E, R>(const E& req, std::chrono::milliseconds timeout),同步取得某个处理者返回的结果 R;若超时或无人处理则返回 std::optional<R>{}。要求:

  • 请求与响应用 correlationId(请求 ID) 关联,保证 A 的请求不会被 B 的响应唤醒;
  • 总线上至少 3 个模块订阅了 OrderPlaced,但只有 InventoryService 会响应 LockStockRequest;
  • 写 2 个断言:正常返回、超时返回空值。

提示 1:在「事件」之外引入「命令」——LockStockRequest{ std::string correlationId; std::string sku; int qty; },响应同理。实现时用 std::promise<R>/std::future<R> 可以省掉手写条件变量:先注册一个挂在 correlationId 上的待唤醒槽,publish 请求后 wait_for(timeout)。做完你会发现——这已经不是事件总线,而是消息总线/请求-响应总线了,这正是「事件 vs 命令」边界的实战体会。

提示 2:若用 Qt 做这个练习,别自己搓 future:直接 QMetaObject::invokeMethod(target, ..., Qt::BlockingQueuedConnection, Q_RETURN_ARG(R, out), Q_ARG(E, req)),并亲自验证「同线程 BlockingQueued 会立刻死锁」——这就是为什么绝大多数 Qt 事件总线只提供异步语义。

15

今日总结

事件总线 = 把「我要通知谁」换成「发生了什么事实」:发布者与订阅者互不相识,只共享事件契约。

代码特征信号(看到这些,脑子里就该冒出「事件总线」)

  1. bus.subscribe<E>(handler) / emit someEvent(e)——注册与广播分离,发布处看不到任何接收者信息。
  2. 事件结构体是过去式命名、纯值语义(OrderPlaced、ConnectionLost),集中定义在 events/ 目录,字段里没有裸指针与 UI 对象。
  3. 订阅返回凭证:Token / QMetaObject::Connection / Subscription RAII 包装——有凭证,才有生命周期解绑。
  4. 总线实现里必然出现「拷贝订阅者快照 → 解锁 → 回调」这三步,以及单例、互斥量、类型索引(顺带复习 Day 1 单例、Day 18 观察者)。
  5. 跨线程处出现 Qt::QueuedConnection / moveToThread / Q_DECLARE_METATYPE——「事件参数必须可拷贝」是它留下的指纹。

今天要带走的三句话

  1. 事件是「事实」,不是「命令」:命名用过去式,发布者不期待结果;要结果、要顺序、要事务,请用命令 + 编排器/状态机。
  2. 回调必须在锁外、且基于快照:手写总线里这是最容易写错、最难复现的一处崩溃;Qt 在 QMetaObject::activate 里替你做了。
  3. 总线的价值 ≈ 模块数 × 变化频率:它买的是「扩展点」,代价是「依赖关系从编译期搬到运行期」。模块太少就上总线,是给未来的自己挖坑。

明日预告:Day 27 —— 插件架构模式(Plugin Architecture / QPluginLoader)

今天我们解决了「模块之间怎么说话」,但还有一个更激进的问题没答:模块能不能在编译期之后才存在?主程序发布时完全不知道未来会有哪些功能,用户装上一个 .so 功能就出现,删掉文件功能干净消失,而主程序一行代码没改——这就是桌面客户端的插件架构:用「接口 + 元数据 + 加载器」把编译期依赖换成运行期发现。明天我们用 Qt 官方三件套落地它:Q_DECLARE_INTERFACE(把纯虚接口变成可查询元数据)、Q_PLUGIN_METADATA(插件的身份证:IID、版本、JSON 元信息)、QPluginLoader(扫描目录、加载、instance()、卸载),并写出完整最小工程:IPlugin 接口 + 两个插件实现 + 插件管理器(热加载、版本校验、加载失败隔离)。届时你会发现:插件就是「事件总线的订阅者」从编译期长到了运行期——今天讲的 subscribe,明天会变成插件加载后自己向总线注册的第一步。