架构模式 发布-订阅 模块解耦 Qt 信号槽 跨线程
一句话定义:事件总线(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)把「事件类型」变成元对象系统里的一等公民,顺带白送跨线程排队与生命周期自动管理。
先看一段你几乎一定写过的代码:一个下单流程,成功之后需要「扣库存 → 存库 → 记日志 → 发确认邮件 → 刷新界面 → 判断是否要提示库存预警」。需求方是这么说的,代码也就这么写了。
// ❌ 坏味道: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 违背:
OrderService 直接依赖 5 个具体模块的接口,任何模块改签名都要重编这里;如果库存模块反过来想调用订单模块,立刻形成循环依赖——C++ 里表现为头文件互相 include,最轻也要前置声明,重则无法编译。OrderService::placeOrder,一次两次还行,十次以后这个方法变成 300 行意大利面。根因很清晰:业务动作(下单成功)和业务副作用(发邮件、刷界面、记日志)被写在了同一个地方,而且副作用是被「直接调用」的。事件总线的做法是把它们劈开——业务只管宣布事实,谁来听、听几遍、要不要跨线程听,由订阅方自己决定。
生活类比:小区公告栏 + 邮局。你要卖一台二手显示器,过去的做法是挨个敲门(直接调用):先敲 301,再敲 502,敲的人越多越累,串门时还可能顺手把邻居家的活也干了(业务耦合)。事件总线的做法是在公告栏贴一张告示(publish):"周六下午,显示器一台,200 元"。你不需要知道谁会看到,也不需要知道有多少人看到了;关心的邻居自己定期来公告栏看一眼(subscribe)。楼主(发布者)与邻居(订阅者)之间唯一的共同知识是:告示贴在公告栏上,且告示有类型(交易/失物/停水通知)。
把它翻译成三句话,就是事件总线的全部内核:
OrderPlaced{ id, sku, qty, amount }——只描述「发生了什么」的不可变事实,不带「你该做什么」的指令。事件名必须是过去式(OrderPlaced 而不是 CreateOrder),这是区分「事件」和「命令(Command,Day 14)」的关键习惯。if (topic == "order.placed") 这种语句,它就已经退化成上帝对象了。ApplicationEventPublisher、Qt 里 QObject::connect、前端 mitt.emit 的本质——依赖倒置 + 时间解耦(发布之后,订阅者可以立刻处理,也可以排队到「以后」处理,比如主线程/下一个事件循环)。关键区别一句话记住:观察者模式解决「一个主题通知它的多个观察者」,事件总线解决「互不相识的模块,通过一个公共广播站匿名通信」。前者是类之间的关系,后者是架构的基础设施。
① 构造事件对象(值语义)
┌──────────────┐ ┌──────────────────┐ ┌───────────────┐
│ 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 线程)。注意最后一步里那句「拷贝快照」——它是总线能否在回调中安全退订的关键。
下面是一个 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 个)是事件总线最常见的事故源,生产代码里要限制级联层数并在日志里标注事件链。
在 Qt 里做事件总线,有一个别人没有的作弊码:QObject 的元对象系统已经把「事件(信号)+ 订阅者(槽)」做成了语言级别的能力,还免费送了四件套——编译期类型检查、跨线程自动排队(QueuedConnection)、接收者析构自动断开、QMetaObject 反射式调用。所以 Qt 版事件总线的正确形态不是「注册 std::function」,而是「一个单例 QObject,每个事件类型一个 signal」。
// 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
#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 换掉编译期类型,代价是失去类型安全:
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);
};
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 泄漏到业务层。
以 C++ 最小实现为例,一次 bus.publish(OrderPlaced{...}) 的完整路径:
OrderPlaced{...}(值语义、不可变),它不含指针、不含 UI 引用——这是事件能安全排队、跨线程、可序列化的前提。若字段带裸指针或 QObject*,跨线程时你就在共享可变状态,事故只是时间问题。publish 用 std::type_index(typeid(E)) 在 handlers_ 里查出该事件类型对应的订阅者列表;查不到就静默返回(无人关心不是错误)。这一步是总线的「路由」,时间复杂度 O(1)。snapshot,然后立刻释放锁。目的有两个:避免回调执行期间长时间占锁(吞吐),以及——最关键的——允许回调里再次 subscribe/unsubscribe/publish。如果直接遍历原列表,回调里退订会使迭代器失效,程序可能在事件风暴里崩溃;锁内回调则直接死锁(std::mutex 不可重入)。Qt 也做了同样的事:信号激活时先把连接列表拷出来(QMetaObject::activate 里操作 connections 的副本)。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 事件。
把第 02 节那段坏代码换成事件总线后,收益不是「代码变少了」,而是六个可独立变化的方向被切开了。逐条对照 SOLID 与工程现实:
OrderService 不再依赖 MailService/InventoryService/OrderListView 这些具体模块,只依赖「事件类型 + 总线句柄」这一层稳定抽象。依赖箭头从「业务 → 具体模块」反转为「业务 → 事件契约 ← 具体模块」,模块图重新变回一棵树甚至一片森林。placeOrder 一个字都不用改。扩展点从「改老代码」变成「加新代码」——这是架构能长期演进的根因。publish 一次,UI 刷新自动回到主线程,业务代码里再也不用写 invokeMethod(...Qt::QueuedConnection) 这种护身符。AuditLog 可以同时订阅 5 种事件、在测试里只订阅 1 种;换成继承,你得为每种组合派生一个子类。subscribe<OrderPlaced> 一个把事件塞进 std::vector 的 lambda,就能断言「下单成功后事件被发布了、字段正确」——测试的边界跟着架构的边界走。还有两条不在 SOLID 里、但同样重要的设计取舍:广播语义让「不知道未来有多少订阅者」成为常态(插件、动态面板、脚本扩展全靠它),代价是执行顺序不再由你控制;总线只做路由不做业务——一旦发现自己在总线里写 if (topic == ...),说明你真正需要的是一台显式的编排器(Orchestrator)或状态机,而不是总线。
不用事件总线,你最终一定会写出下面两种代码之一——它们的共同点是「每加一个消费者,都要动核心业务代码」:
// ❌ 集中式分派:所有模块的联动都写在一个 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 冲突高发区
}
}
// ❌ 构造函数签名记录了架构的腐败史
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 处隐藏分支。
OrderPlaced,UI 自己决定是刷新列表、弹 Toast 还是播动画。subscribe 就行——广播语义天然适配「不知道有多少订阅者」的插件世界(明天 Day 27 正是它的续集)。bool canPlaceOrder() 要结果,别用事件(除非实现 request-response + 关联 ID 版本,见今日练习)。Parser → Tokenizer 这种「我调用你、我要你的返回值」的关系,直接组合或 std::function 回调更清楚——套总线是两地之间坐飞机。std::any + std::function 有类型擦除与堆分配开销(Qt 信号也有拷贝与元调用成本);每秒十万次的传感器采样该用无锁队列或直接调用。过度设计提醒(三条红线):① 全局可变状态——事件处理顺序不可控,谁都能在任何地方 publish,别让总线承载「必须按顺序发生的状态迁移」;② 隐式调用链——「点了这个按钮会导致什么」从代码里读不出来,排查只能靠日志,因此事件必须可追踪(事件名 + 关键 ID + 级联深度);③ 不可静态追踪——重构时 IDE 的「查找引用」找不到订阅者,所以把事件总线限制在跨模块边界使用,模块内部继续用直接调用与信号槽,是最划算的纪律。
| 维度 | 观察者(Day 18) | 中介者(Day 16) | 事件总线(本篇) |
|---|---|---|---|
| 谁通知谁 | 主题持有观察者列表,主题 = 广播者 | 中介者认识所有同事,双向路由 | 总线不懂业务,只按事件类型路由 |
| 依赖方向 | 观察者依赖主题接口 | 同事只依赖中介者(星形) | 发布者/订阅者都只依赖事件契约 |
| 通信形态 | 多为单向、无返回值 | 可双向、可请求响应 | 单向广播,天然匿名 |
| 典型落点 | 一个领域对象 + 它的多个视图 | 一个对话框里多个控件的协调 | 跨模块/跨层/跨线程基础设施 |
| 常见事故 | 观察者泄漏(遗忘退订) | 中介者膨胀成上帝对象 | 事件风暴、隐式调用链 |
事件(Event)vs 命令(Command,Day 14):事件是过去式的事实(OrderPlaced),可有 0 个或 N 个接收者,发布者不期待结果;命令是祈使句的意图(PlaceOrderCommand),通常有唯一执行者,且支持撤销(undo())与排队。把两者混在一起,就会出现「发了个事件却要求别人必须做什么」的畸形设计。
事件总线 vs 消息队列(Kafka/RabbitMQ)vs Qt 信号槽:三者是同一思想的三个量级。Qt 信号槽是编译期绑定、点对点(1 信号 → N 槽)、同进程;事件总线是运行期路由、进程内匿名广播;消息队列跨进程/跨机器,多了持久化、重放、消费者组,代价是最终一致性与运维复杂度。桌面客户端 99% 的场景,Qt 信号总线已经在正确的层级上。
Qt 本身就是一个把「事件 + 订阅」做进内核的框架,事件总线的每个零件都能在 Qt 里找到官方实现:
QMetaObject::activate()(qobject.cpp)——emit 展开后调用的就是它。源码里它先拷贝当前连接列表再逐个调用,正是本篇第 07 节讲的「快照后回调」,因为槽里可以断开连接甚至 delete 发送者。QObject::connect() 返回的 QMetaObject::Connection 就是官方 Token——可 disconnect(conn) 精确解绑,等价于我们手写的 EventBus::Token。内部结构 QObjectPrivate::ConnectionData 维护「信号索引 → 连接链表」的路由表,与 unordered_map<type_index, vector<Slot>> 思路完全一致。connect 的第 5 个参数 Qt::ConnectionType(Auto/Direct/Queued/BlockingQueued/Unique)就是「立即回调 vs 排队稍后回调」的官方开关;跨线程时 Qt 构造 QMetaCallEvent 塞进接收者线程的事件队列。QCoreApplication::postEvent() / sendEvent() + QEvent 家族。事件「类型」是 QEvent::Type 枚举(等价于我们的事件类型键),事件循环(QAbstractEventDispatcher)负责取出并投递给目标 QObject::event()。它与本篇总线的分工:QEvent 是投递给指定接收者的点对点消息(另有 eventFilter 做全局监听),事件总线是匿名广播——两者互补。QObject::destroyed(QObject*)、QCoreApplication::aboutToQuit() 是官方提供的「对象死亡事件」,订阅它们即可做资源清理;而 QObject::connect 在 receiver 析构时自动断开,正是「手写总线必须自己管理订阅者生命周期」这一痛点的官方解法。Q_GLOBAL_STATIC 是 Qt 为「全局单例 + 静态析构顺序」给出的标准答案,可直接用来实现总线实例;QSignalSpy(QtTest)是测试事件总线的现成工具——把信号当事件录下来做断言;QSignalBlocker 用于临时屏蔽订阅者,避免批量操作时的事件风暴。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 个时引入全局总线,等于提前支付「全局可变状态」的复杂度利息,收益接近零——事件总线的价值随模块数量超线性增长,模块太少时它是负收益。
需求:在今天 EventBus 的基础上新增 request<E, R>(const E& req, std::chrono::milliseconds timeout),同步取得某个处理者返回的结果 R;若超时或无人处理则返回 std::optional<R>{}。要求:
OrderPlaced,但只有 InventoryService 会响应 LockStockRequest;提示 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 事件总线只提供异步语义。
bus.subscribe<E>(handler) / emit someEvent(e)——注册与广播分离,发布处看不到任何接收者信息。OrderPlaced、ConnectionLost),集中定义在 events/ 目录,字段里没有裸指针与 UI 对象。QMetaObject::Connection / Subscription RAII 包装——有凭证,才有生命周期解绑。Qt::QueuedConnection / moveToThread / Q_DECLARE_METATYPE——「事件参数必须可拷贝」是它留下的指纹。QMetaObject::activate 里替你做了。今天我们解决了「模块之间怎么说话」,但还有一个更激进的问题没答:模块能不能在编译期之后才存在?主程序发布时完全不知道未来会有哪些功能,用户装上一个 .so 功能就出现,删掉文件功能干净消失,而主程序一行代码没改——这就是桌面客户端的插件架构:用「接口 + 元数据 + 加载器」把编译期依赖换成运行期发现。明天我们用 Qt 官方三件套落地它:Q_DECLARE_INTERFACE(把纯虚接口变成可查询元数据)、Q_PLUGIN_METADATA(插件的身份证:IID、版本、JSON 元信息)、QPluginLoader(扫描目录、加载、instance()、卸载),并写出完整最小工程:IPlugin 接口 + 两个插件实现 + 插件管理器(热加载、版本校验、加载失败隔离)。届时你会发现:插件就是「事件总线的订阅者」从编译期长到了运行期——今天讲的 subscribe,明天会变成插件加载后自己向总线注册的第一步。